Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion cli/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
plugins {
alias(libs.plugins.kotlin.jvm)
alias(libs.plugins.shadow)
application
}

Expand All @@ -17,9 +18,13 @@ kotlin {
}

application {
mainClass.set("team.dedinside.MainKt")
mainClass = "team.dedinside.yapi.application.MainKt"
}

tasks.test {
useJUnitPlatform()
}

tasks.named<JavaExec>("run") {
standardInput = System.`in`
}
4 changes: 2 additions & 2 deletions cli/src/main/kotlin/team/dedinside/yapi/application/Main.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ package team.dedinside.yapi.application

import com.github.ajalt.clikt.core.main
import com.github.ajalt.clikt.core.subcommands
import team.dedinside.yapi.command.Test
import team.dedinside.yapi.command.Yapi
import team.dedinside.yapi.cli.Test
import team.dedinside.yapi.cli.Yapi

fun main(args: Array<String>) = Yapi()
.subcommands(Test())
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
package team.dedinside.yapi.command
package team.dedinside.yapi.cli

import com.github.ajalt.clikt.core.CliktCommand
import com.github.ajalt.clikt.core.Context
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,17 @@
package team.dedinside.yapi.command
package team.dedinside.yapi.cli

import com.github.ajalt.clikt.core.CliktCommand
import com.github.ajalt.clikt.core.Context
import com.github.ajalt.mordant.terminal.Terminal
import team.dedinside.yapi.tui.Tui

class Yapi : CliktCommand(name = "yapi") {
override val invokeWithoutSubcommand: Boolean get() = true

override fun help(context: Context) = "TUI/CLI HTTP API client"

override fun run() {
echo("Hello world")
if (currentContext.invokedSubcommand != null) return
Tui(Terminal()).run()
}
}
61 changes: 61 additions & 0 deletions cli/src/main/kotlin/team/dedinside/yapi/tui/Tui.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package team.dedinside.yapi.tui

import com.github.ajalt.mordant.animation.Animation
import com.github.ajalt.mordant.animation.animation
import com.github.ajalt.mordant.input.InputReceiver
import com.github.ajalt.mordant.input.isCtrlC
import com.github.ajalt.mordant.input.receiveKeyEvents
import com.github.ajalt.mordant.terminal.Terminal
import team.dedinside.yapi.tui.core.Screen
import team.dedinside.yapi.tui.model.ScreenAction
import team.dedinside.yapi.tui.screen.main.mainScreen

/** Drives a stack of [Screen]s, repainting on every key event. */
class Tui(private val terminal: Terminal) {
fun run() {
val stack = ArrayDeque<Screen>().apply { addLast(mainScreen()) }
val animation: Animation<Screen> = terminal.animation { it.render() }
animation.update(stack.last())

try {
terminal.receiveKeyEvents { event ->
if (event.isCtrlC) return@receiveKeyEvents InputReceiver.Status.Finished(Unit)

val action = stack.last().handle(event)
when (action) {
ScreenAction.Stay -> {
animation.update(stack.last())
InputReceiver.Status.Continue
}

is ScreenAction.Push -> {
stack.addLast(action.screen)
animation.update(stack.last())
InputReceiver.Status.Continue
}

is ScreenAction.Replace -> {
if (stack.isNotEmpty()) stack.removeLast()
stack.addLast(action.screen)
animation.update(stack.last())
InputReceiver.Status.Continue
}

ScreenAction.Pop -> {
if (stack.size > 1) {
stack.removeLast()
animation.update(stack.last())
InputReceiver.Status.Continue
} else {
InputReceiver.Status.Finished(Unit)
}
}

ScreenAction.Exit -> InputReceiver.Status.Finished(Unit)
}
}
} finally {
animation.clear()
}
}
}
18 changes: 18 additions & 0 deletions cli/src/main/kotlin/team/dedinside/yapi/tui/core/Hotkeys.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package team.dedinside.yapi.tui.core

import com.github.ajalt.mordant.input.KeyboardEvent
import team.dedinside.yapi.tui.model.ScreenAction

/**
* Hotkey handler that returns [ScreenAction.Pop] when [KeyboardEvent.key] matches
* any of [keys], and [ScreenAction.Stay] otherwise. Convenient for simple
* dismissable overlays:
*
* ```kotlin
* onHotkey = popOn("Escape", "q", "Q", "Backspace")
* ```
*/
fun popOn(vararg keys: String): (KeyboardEvent) -> ScreenAction {
val set = keys.toSet()
return { event -> if (event.key in set) ScreenAction.Pop else ScreenAction.Stay }
}
14 changes: 14 additions & 0 deletions cli/src/main/kotlin/team/dedinside/yapi/tui/core/Screen.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package team.dedinside.yapi.tui.core

import com.github.ajalt.mordant.input.KeyboardEvent
import com.github.ajalt.mordant.rendering.Widget
import team.dedinside.yapi.tui.model.ScreenAction

/** A single screen in the TUI screen stack. */
interface Screen {
/** Render the screen as a mordant [Widget]. */
fun render(): Widget

/** React to a [KeyboardEvent]; the result tells the controller what to do next. */
fun handle(event: KeyboardEvent): ScreenAction
}
52 changes: 52 additions & 0 deletions cli/src/main/kotlin/team/dedinside/yapi/tui/core/ScreenBuilder.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package team.dedinside.yapi.tui.core

import com.github.ajalt.mordant.input.KeyboardEvent
import com.github.ajalt.mordant.rendering.Widget
import team.dedinside.yapi.tui.model.ScreenAction

@DslMarker
annotation class TuiDsl

@TuiDsl
class ScreenScope {
/**
* Hotkey handler invoked for every keyboard event the screen receives.
* Defaults to a no-op that returns [ScreenAction.Stay].
*/
var onHotkey: (KeyboardEvent) -> ScreenAction = { ScreenAction.Stay }
}

/**
* Build a [Screen] from a builder block. The block re-runs on every render, so
* mutable state captured in the surrounding scope can be read freely:
*
* ```kotlin
* fun home(): Screen {
* var url = ""
* return screen {
* onHotkey = { e ->
* when (e.key) {
* "Backspace" -> { url = url.dropLast(1); ScreenAction.Stay }
* else -> ScreenAction.Stay
* }
* }
* verticalLayout {
* cell(Text("URL: $url"))
* }
* }
* }
* ```
*
* The block's return value (last expression) is the widget tree to render. Set
* `onHotkey` somewhere inside the block to wire keyboard input.
*/
fun screen(block: ScreenScope.() -> Widget): Screen {
val scope = ScreenScope()
// Prime so that onHotkey is set even if handle() is invoked before the
// first render — defensive against future runtime changes.
scope.block()
return object : Screen {
override fun render(): Widget = scope.block()
override fun handle(event: KeyboardEvent): ScreenAction = scope.onHotkey(event)
}
}
20 changes: 20 additions & 0 deletions cli/src/main/kotlin/team/dedinside/yapi/tui/model/ScreenAction.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
package team.dedinside.yapi.tui.model

import team.dedinside.yapi.tui.core.Screen

sealed class ScreenAction {
/** Stay on the current screen and re-render. */
data object Stay : ScreenAction()

/** Push a new screen on top of this one. */
data class Push(val screen: Screen) : ScreenAction()

/** Replace the current screen with a new one (pop + push). */
data class Replace(val screen: Screen) : ScreenAction()

/** Pop the current screen; the controller exits the app if the stack becomes empty. */
data object Pop : ScreenAction()

/** Exit the TUI loop entirely. */
data object Exit : ScreenAction()
}
72 changes: 72 additions & 0 deletions cli/src/main/kotlin/team/dedinside/yapi/tui/screen/HelpScreen.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package team.dedinside.yapi.tui.screen

import com.github.ajalt.mordant.rendering.TextAlign
import com.github.ajalt.mordant.rendering.TextStyles.bold
import com.github.ajalt.mordant.rendering.TextStyles.dim
import com.github.ajalt.mordant.rendering.Whitespace
import com.github.ajalt.mordant.table.verticalLayout
import com.github.ajalt.mordant.widgets.Padding
import com.github.ajalt.mordant.widgets.Panel
import com.github.ajalt.mordant.widgets.Text
import team.dedinside.yapi.tui.core.Screen
import team.dedinside.yapi.tui.core.popOn
import team.dedinside.yapi.tui.core.screen

private fun helpBody(): String = buildString {
appendLine(bold("Pane focus"))
appendLine()
appendLine(" Tab Editor: cycle URL → Tabs → Response")
appendLine(" ${dim(" ")}From Project: drill into editor (lands on URL)")
appendLine(" Shift+Tab Cycle in reverse")
appendLine(" Esc / q Drill out: editor → Project, Project → quit")
appendLine(" Ctrl+B Toggle Project sidebar")
appendLine()
appendLine(bold("Project pane"))
appendLine()
appendLine(" ↑ / k, ↓ / j Move tree selection")
appendLine(" ← / h Collapse folder, or jump to parent")
appendLine(" → / l Expand folder, or step into it")
appendLine(" Enter Open request in editor, or toggle folder")
appendLine(" n New request (clears editor, focuses URL)")
appendLine(" D Toggle demo data / empty state")
appendLine()
appendLine(bold("URL focus"))
appendLine()
appendLine(" type Edit URL")
appendLine(" Backspace Delete last character")
appendLine(" Ctrl+U Clear URL")
appendLine(" Enter Send ${dim("(stub — real flow in #4)")}")
appendLine(" Esc Drill out to Project (URL preserved)")
appendLine()
appendLine(bold("Tabs focus"))
appendLine()
appendLine(" ← / h, → / l Cycle request tabs (Headers / Query / Body / Auth)")
appendLine()
appendLine(bold("Response focus"))
appendLine()
appendLine(" Esc Drill out to Project (notice preserved)")
appendLine()
appendLine(bold("Global"))
appendLine()
appendLine(" Ctrl+P Command palette")
appendLine(" ? Open this help")
appendLine(" Ctrl+C Quit from anywhere")
appendLine()
appendLine(dim("Press Esc, q, or ? to return."))
}

fun helpScreen(): Screen = screen {
onHotkey = popOn("Escape", "q", "Q", "?", "Backspace")
verticalLayout {
spacing = 0
cell(
Panel(
Text(helpBody(), whitespace = Whitespace.PRE_WRAP),
title = Text("Help"),
expand = true,
padding = Padding(1, 2, 1, 2),
)
)
cell(Text(dim("Esc / q / ? back · Ctrl+C quit"), align = TextAlign.CENTER))
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package team.dedinside.yapi.tui.screen

import com.github.ajalt.mordant.rendering.TextAlign
import com.github.ajalt.mordant.rendering.TextStyles.dim
import com.github.ajalt.mordant.rendering.Whitespace
import com.github.ajalt.mordant.table.verticalLayout
import com.github.ajalt.mordant.widgets.Padding
import com.github.ajalt.mordant.widgets.Panel
import com.github.ajalt.mordant.widgets.Text
import team.dedinside.yapi.tui.core.Screen
import team.dedinside.yapi.tui.core.popOn
import team.dedinside.yapi.tui.core.screen

/** Generic stub used by command-palette commands whose flow lives in another issue. */
fun placeholderScreen(title: String, body: String): Screen = screen {
onHotkey = popOn("Escape", "q", "Q", "Backspace")
verticalLayout {
spacing = 0
cell(
Panel(
Text(body, whitespace = Whitespace.PRE_WRAP),
title = Text(title),
expand = true,
padding = Padding(1, 2, 1, 2),
)
)
cell(Text(dim("Esc / q back · Ctrl+C quit"), align = TextAlign.CENTER))
}
}

/**
* Backwards-compatible class wrapper, so existing call sites that say
* `PlaceholderScreen("Foo", "Bar")` keep working.
*/
class PlaceholderScreen(title: String, body: String) :
Screen by placeholderScreen(title, body)
Loading
Loading