From 4abba15838e4f439479fbacf973b57b8b4b6f5c9 Mon Sep 17 00:00:00 2001 From: syncended Date: Tue, 28 Apr 2026 02:54:28 +0300 Subject: [PATCH] Add TUI workspace screen, command palette, and screen DSL Implements the main TUI screen for #5 as a single IDE-like workspace: - Persistent Project sidebar (collections tree + recent activity) - Editor pane with URL/method/Send, request tabs, response section - Command palette as an in-place dialog (Ctrl+P), grouped by category - Linear Tab focus cycle inside the editor; Esc drills out to Project, Esc never destroys typed input (use Ctrl+U to clear the URL) - Help, placeholder screens; screen { } DSL builder + popOn helper - Tree split across screen/main/{MainState,Tree,Render,Sidebar,Editor} - Reusable widgets: headerBar, focusMarker, methodBadge, statusBadge, statusFooter - Distribution: shadow fat jar, tui.sh launcher (Gradle daemon cannot host a raw-mode TUI, so the script execs the install-dist binary) - Renamed command package to cli; renamed TuiApp to Tui Refs #5 Co-Authored-By: Claude Opus 4.7 (1M context) --- cli/build.gradle.kts | 7 +- .../team/dedinside/yapi/application/Main.kt | 4 +- .../dedinside/yapi/{command => cli}/Test.kt | 2 +- .../dedinside/yapi/{command => cli}/Yapi.kt | 7 +- .../kotlin/team/dedinside/yapi/tui/Tui.kt | 61 ++++ .../team/dedinside/yapi/tui/core/Hotkeys.kt | 18 ++ .../team/dedinside/yapi/tui/core/Screen.kt | 14 + .../dedinside/yapi/tui/core/ScreenBuilder.kt | 52 ++++ .../dedinside/yapi/tui/model/ScreenAction.kt | 20 ++ .../dedinside/yapi/tui/screen/HelpScreen.kt | 72 +++++ .../yapi/tui/screen/PlaceholderScreen.kt | 36 +++ .../dedinside/yapi/tui/screen/main/Editor.kt | 148 ++++++++++ .../yapi/tui/screen/main/MainScreen.kt | 30 ++ .../yapi/tui/screen/main/MainState.kt | 279 ++++++++++++++++++ .../dedinside/yapi/tui/screen/main/Render.kt | 45 +++ .../dedinside/yapi/tui/screen/main/Sidebar.kt | 115 ++++++++ .../dedinside/yapi/tui/screen/main/Tree.kt | 25 ++ .../yapi/tui/widget/CommandPalette.kt | 142 +++++++++ .../team/dedinside/yapi/tui/widget/Widgets.kt | 55 ++++ gradle/libs.versions.toml | 2 + tui.sh | 13 + 21 files changed, 1141 insertions(+), 6 deletions(-) rename cli/src/main/kotlin/team/dedinside/yapi/{command => cli}/Test.kt (92%) rename cli/src/main/kotlin/team/dedinside/yapi/{command => cli}/Yapi.kt (59%) create mode 100644 cli/src/main/kotlin/team/dedinside/yapi/tui/Tui.kt create mode 100644 cli/src/main/kotlin/team/dedinside/yapi/tui/core/Hotkeys.kt create mode 100644 cli/src/main/kotlin/team/dedinside/yapi/tui/core/Screen.kt create mode 100644 cli/src/main/kotlin/team/dedinside/yapi/tui/core/ScreenBuilder.kt create mode 100644 cli/src/main/kotlin/team/dedinside/yapi/tui/model/ScreenAction.kt create mode 100644 cli/src/main/kotlin/team/dedinside/yapi/tui/screen/HelpScreen.kt create mode 100644 cli/src/main/kotlin/team/dedinside/yapi/tui/screen/PlaceholderScreen.kt create mode 100644 cli/src/main/kotlin/team/dedinside/yapi/tui/screen/main/Editor.kt create mode 100644 cli/src/main/kotlin/team/dedinside/yapi/tui/screen/main/MainScreen.kt create mode 100644 cli/src/main/kotlin/team/dedinside/yapi/tui/screen/main/MainState.kt create mode 100644 cli/src/main/kotlin/team/dedinside/yapi/tui/screen/main/Render.kt create mode 100644 cli/src/main/kotlin/team/dedinside/yapi/tui/screen/main/Sidebar.kt create mode 100644 cli/src/main/kotlin/team/dedinside/yapi/tui/screen/main/Tree.kt create mode 100644 cli/src/main/kotlin/team/dedinside/yapi/tui/widget/CommandPalette.kt create mode 100644 cli/src/main/kotlin/team/dedinside/yapi/tui/widget/Widgets.kt create mode 100755 tui.sh diff --git a/cli/build.gradle.kts b/cli/build.gradle.kts index 6f07d37..e3d0caa 100644 --- a/cli/build.gradle.kts +++ b/cli/build.gradle.kts @@ -1,5 +1,6 @@ plugins { alias(libs.plugins.kotlin.jvm) + alias(libs.plugins.shadow) application } @@ -17,9 +18,13 @@ kotlin { } application { - mainClass.set("team.dedinside.MainKt") + mainClass = "team.dedinside.yapi.application.MainKt" } tasks.test { useJUnitPlatform() } + +tasks.named("run") { + standardInput = System.`in` +} diff --git a/cli/src/main/kotlin/team/dedinside/yapi/application/Main.kt b/cli/src/main/kotlin/team/dedinside/yapi/application/Main.kt index e3c2640..831966e 100644 --- a/cli/src/main/kotlin/team/dedinside/yapi/application/Main.kt +++ b/cli/src/main/kotlin/team/dedinside/yapi/application/Main.kt @@ -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) = Yapi() .subcommands(Test()) diff --git a/cli/src/main/kotlin/team/dedinside/yapi/command/Test.kt b/cli/src/main/kotlin/team/dedinside/yapi/cli/Test.kt similarity index 92% rename from cli/src/main/kotlin/team/dedinside/yapi/command/Test.kt rename to cli/src/main/kotlin/team/dedinside/yapi/cli/Test.kt index f6c2c32..cec1023 100644 --- a/cli/src/main/kotlin/team/dedinside/yapi/command/Test.kt +++ b/cli/src/main/kotlin/team/dedinside/yapi/cli/Test.kt @@ -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 diff --git a/cli/src/main/kotlin/team/dedinside/yapi/command/Yapi.kt b/cli/src/main/kotlin/team/dedinside/yapi/cli/Yapi.kt similarity index 59% rename from cli/src/main/kotlin/team/dedinside/yapi/command/Yapi.kt rename to cli/src/main/kotlin/team/dedinside/yapi/cli/Yapi.kt index a2f99b8..a2aa183 100644 --- a/cli/src/main/kotlin/team/dedinside/yapi/command/Yapi.kt +++ b/cli/src/main/kotlin/team/dedinside/yapi/cli/Yapi.kt @@ -1,7 +1,9 @@ -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 @@ -9,6 +11,7 @@ class Yapi : CliktCommand(name = "yapi") { override fun help(context: Context) = "TUI/CLI HTTP API client" override fun run() { - echo("Hello world") + if (currentContext.invokedSubcommand != null) return + Tui(Terminal()).run() } } \ No newline at end of file diff --git a/cli/src/main/kotlin/team/dedinside/yapi/tui/Tui.kt b/cli/src/main/kotlin/team/dedinside/yapi/tui/Tui.kt new file mode 100644 index 0000000..d36fac8 --- /dev/null +++ b/cli/src/main/kotlin/team/dedinside/yapi/tui/Tui.kt @@ -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().apply { addLast(mainScreen()) } + val animation: Animation = 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() + } + } +} diff --git a/cli/src/main/kotlin/team/dedinside/yapi/tui/core/Hotkeys.kt b/cli/src/main/kotlin/team/dedinside/yapi/tui/core/Hotkeys.kt new file mode 100644 index 0000000..61249fe --- /dev/null +++ b/cli/src/main/kotlin/team/dedinside/yapi/tui/core/Hotkeys.kt @@ -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 } +} diff --git a/cli/src/main/kotlin/team/dedinside/yapi/tui/core/Screen.kt b/cli/src/main/kotlin/team/dedinside/yapi/tui/core/Screen.kt new file mode 100644 index 0000000..0c5f22b --- /dev/null +++ b/cli/src/main/kotlin/team/dedinside/yapi/tui/core/Screen.kt @@ -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 +} diff --git a/cli/src/main/kotlin/team/dedinside/yapi/tui/core/ScreenBuilder.kt b/cli/src/main/kotlin/team/dedinside/yapi/tui/core/ScreenBuilder.kt new file mode 100644 index 0000000..82bd0ba --- /dev/null +++ b/cli/src/main/kotlin/team/dedinside/yapi/tui/core/ScreenBuilder.kt @@ -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) + } +} diff --git a/cli/src/main/kotlin/team/dedinside/yapi/tui/model/ScreenAction.kt b/cli/src/main/kotlin/team/dedinside/yapi/tui/model/ScreenAction.kt new file mode 100644 index 0000000..c9458d8 --- /dev/null +++ b/cli/src/main/kotlin/team/dedinside/yapi/tui/model/ScreenAction.kt @@ -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() +} diff --git a/cli/src/main/kotlin/team/dedinside/yapi/tui/screen/HelpScreen.kt b/cli/src/main/kotlin/team/dedinside/yapi/tui/screen/HelpScreen.kt new file mode 100644 index 0000000..36e7b18 --- /dev/null +++ b/cli/src/main/kotlin/team/dedinside/yapi/tui/screen/HelpScreen.kt @@ -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)) + } +} diff --git a/cli/src/main/kotlin/team/dedinside/yapi/tui/screen/PlaceholderScreen.kt b/cli/src/main/kotlin/team/dedinside/yapi/tui/screen/PlaceholderScreen.kt new file mode 100644 index 0000000..5a9bc26 --- /dev/null +++ b/cli/src/main/kotlin/team/dedinside/yapi/tui/screen/PlaceholderScreen.kt @@ -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) diff --git a/cli/src/main/kotlin/team/dedinside/yapi/tui/screen/main/Editor.kt b/cli/src/main/kotlin/team/dedinside/yapi/tui/screen/main/Editor.kt new file mode 100644 index 0000000..acb21e1 --- /dev/null +++ b/cli/src/main/kotlin/team/dedinside/yapi/tui/screen/main/Editor.kt @@ -0,0 +1,148 @@ +package team.dedinside.yapi.tui.screen.main + +import com.github.ajalt.mordant.rendering.TextColors.brightBlue +import com.github.ajalt.mordant.rendering.TextColors.brightGreen +import com.github.ajalt.mordant.rendering.TextColors.brightWhite +import com.github.ajalt.mordant.rendering.TextStyles.bold +import com.github.ajalt.mordant.rendering.TextStyles.dim +import com.github.ajalt.mordant.rendering.TextStyles.inverse +import com.github.ajalt.mordant.rendering.TextStyles.italic +import com.github.ajalt.mordant.rendering.TextStyles.underline +import com.github.ajalt.mordant.rendering.Whitespace +import com.github.ajalt.mordant.rendering.Widget +import com.github.ajalt.mordant.table.ColumnWidth +import com.github.ajalt.mordant.table.horizontalLayout +import com.github.ajalt.mordant.table.verticalLayout +import com.github.ajalt.mordant.widgets.HorizontalRule +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.widget.focusMarker + +internal fun editorPanel(s: MainState): Widget { + val focused = s.focus in setOf(Focus.URL, Focus.TABS, Focus.RESPONSE) + val titleText = when (s.focus) { + Focus.URL -> brightBlue(bold("◆ Editor · URL")) + Focus.TABS -> brightBlue(bold("◆ Editor · ${s.tab.label}")) + Focus.RESPONSE -> brightBlue(bold("◆ Editor · Response")) + Focus.PROJECT -> dim("◇ Editor") + } + + val body = verticalLayout { + spacing = 0 + cell(urlRow(s)) + cell(HorizontalRule(ruleCharacter = "─")) + cell(tabsRow(s)) + cell(Text("")) + cell(tabBody(s)) + cell(HorizontalRule(ruleCharacter = "─")) + cell(responseHeader(s)) + cell(Text("")) + cell(responseBody(s)) + } + + return Panel( + content = body, + title = Text(titleText), + expand = true, + padding = Padding(0, 1, 0, 1), + borderStyle = if (focused) brightBlue + bold else null, + ) +} + +private fun urlRow(s: MainState): Widget { + val urlFocused = s.focus == Focus.URL + val urlField = when { + s.url.isEmpty() && urlFocused -> italic(dim("type a URL or paste a curl command…")) + brightWhite("▌") + s.url.isEmpty() -> italic(dim("type a URL or paste a curl command…")) + urlFocused -> s.url + brightWhite("▌") + else -> s.url + } + return horizontalLayout { + spacing = 1 + column(0) { width = ColumnWidth.Fixed(2) } + column(1) { width = ColumnWidth.Auto } + column(2) { width = ColumnWidth.Expand() } + column(3) { width = ColumnWidth.Auto } + cell(Text(focusMarker(urlFocused))) + cell(Text(brightGreen(bold(" ${s.method} ")) + dim(" ▾"))) + cell(Text(urlField)) + cell(Text(brightWhite(inverse(" ⏎ Send ")))) + } +} + +private fun tabsRow(s: MainState): Widget { + val tabsFocused = s.focus == Focus.TABS + val tabsLine = WorkbenchTab.entries.joinToString(" · ") { tabLabel(it, it == s.tab, tabsFocused) } + return horizontalLayout { + spacing = 1 + column(0) { width = ColumnWidth.Fixed(2) } + column(1) { width = ColumnWidth.Expand() } + cell(Text(focusMarker(tabsFocused))) + cell(Text(tabsLine)) + } +} + +private fun tabLabel(t: WorkbenchTab, active: Boolean, regionFocused: Boolean): String = when { + active && regionFocused -> brightWhite(bold(underline(t.label))) + active -> bold(underline(t.label)) + else -> dim(t.label) +} + +private fun tabBody(s: MainState): Widget { + val text = when (s.tab) { + WorkbenchTab.HEADERS -> "No headers yet. ${dim("(Adding headers lands in #4.)")}" + WorkbenchTab.QUERY -> "No query parameters yet. ${dim("(Editing lands in #4.)")}" + WorkbenchTab.BODY -> "No body. ${dim("(Body editing lands in #4.)")}" + WorkbenchTab.AUTH -> "No auth configured. ${dim("(Auth helpers land in v0.2.)")}" + } + return horizontalLayout { + spacing = 1 + column(0) { width = ColumnWidth.Fixed(2) } + column(1) { width = ColumnWidth.Expand() } + cell(Text("")) + cell(Text(text, whitespace = Whitespace.PRE_WRAP)) + } +} + +private fun responseHeader(s: MainState): Widget { + val responseFocused = s.focus == Focus.RESPONSE + val status = s.lastSent?.let { dim("stub send · ") + brightGreen("queued") } ?: dim("no request sent yet") + return horizontalLayout { + spacing = 2 + column(0) { width = ColumnWidth.Fixed(2) } + column(1) { width = ColumnWidth.Expand() } + column(2) { width = ColumnWidth.Auto } + cell(Text(focusMarker(responseFocused))) + cell(Text(bold("Response"))) + cell(Text(status)) + } +} + +private fun responseBody(s: MainState): Widget { + val text = s.lastSent?.let { sent -> + buildString { + appendLine(dim("──> would send (real executor lands in #4):")) + appendLine() + appendLine(" ${s.method} $sent") + appendLine() + appendLine(dim("Press Esc to drill out to Project.")) + } + } ?: buildString { + appendLine(bold("Welcome to yapi.")) + appendLine() + appendLine("To get started:") + appendLine(" · " + bold("Tab") + " into the URL field, type a URL, press " + bold("Enter")) + appendLine(" · Pick a saved request from the " + bold("Project") + " sidebar") + appendLine(" · Press " + bold("Ctrl+P") + " for the command palette") + appendLine() + appendLine(dim("Tip: ") + bold("Ctrl+B") + dim(" hides the sidebar to give the editor full width.")) + } + return horizontalLayout { + spacing = 1 + column(0) { width = ColumnWidth.Fixed(2) } + column(1) { width = ColumnWidth.Expand() } + cell(Text("")) + cell(Text(text, whitespace = Whitespace.PRE_WRAP)) + } +} diff --git a/cli/src/main/kotlin/team/dedinside/yapi/tui/screen/main/MainScreen.kt b/cli/src/main/kotlin/team/dedinside/yapi/tui/screen/main/MainScreen.kt new file mode 100644 index 0000000..06b3451 --- /dev/null +++ b/cli/src/main/kotlin/team/dedinside/yapi/tui/screen/main/MainScreen.kt @@ -0,0 +1,30 @@ +package team.dedinside.yapi.tui.screen.main + +import team.dedinside.yapi.tui.core.Screen +import team.dedinside.yapi.tui.core.screen + +/** + * IDE-like workspace screen. + * + * Layout: + * - Header bar. + * - Workspace row: Project sidebar (left, toggle Ctrl+B) + Editor (right). + * When the command palette is open it occupies the editor cell as a dialog. + * - Footer with context-sensitive hints. + * + * Focus: + * - `Tab` / `Shift+Tab` cycle inside the editor: URL → TABS → RESPONSE → URL. + * `Tab` from PROJECT drills into the editor. + * - `Esc` / `q` drill out: editor → Project, Project → quit. Esc never destroys + * typed input — use `Ctrl+U` to clear the URL. + * + * State and key handling live on [MainState]; rendering is split across + * [Render], [Sidebar], and [Editor] siblings. + */ +fun mainScreen(): Screen { + val state = MainState() + return screen { + onHotkey = state::handleKey + renderHome(state) + } +} diff --git a/cli/src/main/kotlin/team/dedinside/yapi/tui/screen/main/MainState.kt b/cli/src/main/kotlin/team/dedinside/yapi/tui/screen/main/MainState.kt new file mode 100644 index 0000000..8bef6c8 --- /dev/null +++ b/cli/src/main/kotlin/team/dedinside/yapi/tui/screen/main/MainState.kt @@ -0,0 +1,279 @@ +package team.dedinside.yapi.tui.screen.main + +import com.github.ajalt.mordant.input.KeyboardEvent +import team.dedinside.yapi.tui.model.ScreenAction +import team.dedinside.yapi.tui.screen.helpScreen +import team.dedinside.yapi.tui.screen.placeholderScreen +import team.dedinside.yapi.tui.widget.CommandPalette +import team.dedinside.yapi.tui.widget.PaletteCommand + +internal enum class WorkbenchTab(val label: String) { + HEADERS("Headers"), + QUERY("Query"), + BODY("Body"), + AUTH("Auth"), +} + +internal enum class Focus { PROJECT, URL, TABS, RESPONSE } + +internal class MainState { + var demo: Boolean = false + var sidebarVisible: Boolean = true + var focus: Focus = Focus.PROJECT + var treeSelected: Int = 0 + + var url: String = "" + val method: String = "GET" + var tab: WorkbenchTab = WorkbenchTab.HEADERS + var lastSent: String? = null + + /** Non-null while the command palette dialog is open. */ + var palette: CommandPalette? = null + + val tree: List = listOf( + Folder( + "users", listOf( + RequestFile("list.http", "GET", "/users"), + RequestFile("get-by-id.http", "GET", "/users/{id}"), + RequestFile("create.http", "POST", "/users"), + RequestFile("delete.http", "DELETE", "/users/{id}"), + ) + ), + Folder( + "orders", listOf( + RequestFile("list.http", "GET", "/orders"), + RequestFile("submit.http", "POST", "/orders"), + ), + expanded = false, + ), + Folder( + "auth", listOf( + RequestFile("login.http", "POST", "/auth/login"), + RequestFile("logout.http", "POST", "/auth/logout"), + RequestFile("refresh.http", "POST", "/auth/refresh"), + ) + ), + ) + + val recent: List = listOf( + RecentEntry("GET", "/users/42", 200, 142, "12:43"), + RecentEntry("POST", "/users", 201, 88, "12:39"), + RecentEntry("GET", "/users/9999", 404, 31, "12:31"), + ) + + fun visibleRows(): List { + if (!demo) return emptyList() + val out = mutableListOf() + for (f in tree) walk(f, depth = 0, out) + return out + } + + private fun walk(node: TreeNode, depth: Int, out: MutableList) { + out += TreeRow(depth, node) + if (node is Folder && node.expanded) for (child in node.children) walk(child, depth + 1, out) + } + + fun startNewRequest() { + url = "" + lastSent = null + tab = WorkbenchTab.HEADERS + focus = Focus.URL + } + + fun drillOutToProject(): ScreenAction { + if (!sidebarVisible) sidebarVisible = true + focus = Focus.PROJECT + return ScreenAction.Stay + } + + private fun cycleFocus(forward: Boolean) { + focus = when (focus) { + Focus.PROJECT -> Focus.URL + Focus.URL -> if (forward) Focus.TABS else Focus.RESPONSE + Focus.TABS -> if (forward) Focus.RESPONSE else Focus.URL + Focus.RESPONSE -> if (forward) Focus.URL else Focus.TABS + } + } + + private fun isPrintable(event: KeyboardEvent): Boolean = + !event.ctrl && !event.alt && event.key.length == 1 && event.key[0] >= ' ' + + fun handleKey(event: KeyboardEvent): ScreenAction { + // Palette dialog absorbs all input while open. + palette?.let { p -> + return when (val outcome = p.handle(event)) { + CommandPalette.Outcome.Stay -> ScreenAction.Stay + CommandPalette.Outcome.Cancel -> { palette = null; ScreenAction.Stay } + is CommandPalette.Outcome.Run -> { + palette = null + outcome.action() + } + } + } + + if (event.ctrl && event.key == "p") { + palette = CommandPalette(buildPaletteCommands()) + return ScreenAction.Stay + } + if (event.ctrl && event.key == "b") { + sidebarVisible = !sidebarVisible + if (!sidebarVisible && focus == Focus.PROJECT) focus = Focus.URL + return ScreenAction.Stay + } + if (event.key == "Tab" && !event.shift) { cycleFocus(forward = true); return ScreenAction.Stay } + if (event.key == "Tab" && event.shift) { cycleFocus(forward = false); return ScreenAction.Stay } + + return when (focus) { + Focus.PROJECT -> handleProjectFocus(event) + Focus.URL -> handleUrlFocus(event) + Focus.TABS -> handleTabsFocus(event) + Focus.RESPONSE -> handleResponseFocus(event) + } + } + + private fun handleProjectFocus(event: KeyboardEvent): ScreenAction { + val rows = visibleRows() + return when (event.key) { + "ArrowUp", "k" -> { if (rows.isNotEmpty()) treeSelected = (treeSelected - 1 + rows.size) % rows.size; ScreenAction.Stay } + "ArrowDown", "j" -> { if (rows.isNotEmpty()) treeSelected = (treeSelected + 1) % rows.size; ScreenAction.Stay } + "Home", "g" -> { treeSelected = 0; ScreenAction.Stay } + "End", "G" -> { if (rows.isNotEmpty()) treeSelected = rows.lastIndex; ScreenAction.Stay } + "ArrowLeft", "h" -> { collapseOrAscend(rows); ScreenAction.Stay } + "ArrowRight", "l" -> { expandOrDescend(rows); ScreenAction.Stay } + "Enter", " " -> activateTreeSelection(rows) + "n", "N" -> { startNewRequest(); ScreenAction.Stay } + "d", "D" -> { demo = !demo; treeSelected = 0; ScreenAction.Stay } + "?" -> ScreenAction.Push(helpScreen()) + "q", "Q", "Escape" -> ScreenAction.Exit + else -> ScreenAction.Stay + } + } + + private fun collapseOrAscend(rows: List) { + if (rows.isEmpty()) return + val current = rows[treeSelected] + when (val node = current.node) { + is Folder -> if (node.expanded) node.expanded = false + is RequestFile -> { + val parentIdx = (treeSelected - 1 downTo 0).firstOrNull { rows[it].depth == current.depth - 1 } + if (parentIdx != null) treeSelected = parentIdx + } + } + } + + private fun expandOrDescend(rows: List) { + if (rows.isEmpty()) return + val current = rows[treeSelected] + if (current.node is Folder && !current.node.expanded) { + current.node.expanded = true + } else if (current.node is Folder && current.node.expanded && current.node.children.isNotEmpty()) { + treeSelected = (treeSelected + 1).coerceAtMost(rows.size - 1) + } + } + + private fun activateTreeSelection(rows: List): ScreenAction { + if (rows.isEmpty()) { startNewRequest(); return ScreenAction.Stay } + val current = rows[treeSelected] + when (val node = current.node) { + is Folder -> node.expanded = !node.expanded + is RequestFile -> { + url = "https://api.example.com" + node.path + lastSent = null + tab = WorkbenchTab.HEADERS + focus = Focus.URL + } + } + return ScreenAction.Stay + } + + private fun handleUrlFocus(event: KeyboardEvent): ScreenAction = when { + event.ctrl && event.key == "u" -> { url = ""; ScreenAction.Stay } + event.key == "Backspace" -> { if (url.isNotEmpty()) url = url.dropLast(1); ScreenAction.Stay } + event.key == "Enter" -> { if (url.isNotEmpty()) lastSent = url; ScreenAction.Stay } + event.key == "Escape" -> drillOutToProject() + event.key == "?" && url.isEmpty() -> ScreenAction.Push(helpScreen()) + isPrintable(event) -> { url += event.key; ScreenAction.Stay } + else -> ScreenAction.Stay + } + + private fun handleTabsFocus(event: KeyboardEvent): ScreenAction { + val tabs = WorkbenchTab.entries + return when (event.key) { + "ArrowLeft", "h" -> { tab = tabs[(tab.ordinal - 1 + tabs.size) % tabs.size]; ScreenAction.Stay } + "ArrowRight", "l" -> { tab = tabs[(tab.ordinal + 1) % tabs.size]; ScreenAction.Stay } + "?" -> ScreenAction.Push(helpScreen()) + "Escape", "q", "Q" -> drillOutToProject() + "n", "N" -> { startNewRequest(); ScreenAction.Stay } + else -> ScreenAction.Stay + } + } + + private fun handleResponseFocus(event: KeyboardEvent): ScreenAction = when (event.key) { + "Escape" -> drillOutToProject() + "?" -> ScreenAction.Push(helpScreen()) + "q", "Q" -> drillOutToProject() + "n", "N" -> { startNewRequest(); ScreenAction.Stay } + else -> ScreenAction.Stay + } + + private fun buildPaletteCommands(): List = listOf( + PaletteCommand( + label = "New request", + hint = "clear editor & focus URL", + shortcut = "n", + category = "Workspace", + ) { startNewRequest(); ScreenAction.Stay }, + PaletteCommand( + label = "Toggle sidebar", + hint = if (sidebarVisible) "hide project pane" else "show project pane", + shortcut = "Ctrl+B", + category = "Workspace", + ) { + sidebarVisible = !sidebarVisible + if (!sidebarVisible && focus == Focus.PROJECT) focus = Focus.URL + ScreenAction.Stay + }, + PaletteCommand( + label = "Toggle demo data", + hint = if (demo) "show empty state" else "show demo tree", + shortcut = "D", + category = "Workspace", + ) { demo = !demo; treeSelected = 0; ScreenAction.Stay }, + PaletteCommand( + label = "History", + hint = "recent requests · #12", + category = "Navigation", + ) { + ScreenAction.Push( + placeholderScreen( + title = "History", + body = "Recent request log will appear here.\n\nTracked in issue #12.", + ) + ) + }, + PaletteCommand( + label = "Settings", + hint = "edit configuration · #7", + category = "Navigation", + ) { + ScreenAction.Push( + placeholderScreen( + title = "Settings", + body = "Configuration editor will appear here.\n\nTracked in issue #7.", + ) + ) + }, + PaletteCommand( + label = "Help", + hint = "key bindings reference", + shortcut = "?", + category = "General", + ) { ScreenAction.Push(helpScreen()) }, + PaletteCommand( + label = "Quit", + hint = "exit yapi", + shortcut = "q", + category = "General", + ) { ScreenAction.Exit }, + ) +} diff --git a/cli/src/main/kotlin/team/dedinside/yapi/tui/screen/main/Render.kt b/cli/src/main/kotlin/team/dedinside/yapi/tui/screen/main/Render.kt new file mode 100644 index 0000000..fe062b7 --- /dev/null +++ b/cli/src/main/kotlin/team/dedinside/yapi/tui/screen/main/Render.kt @@ -0,0 +1,45 @@ +package team.dedinside.yapi.tui.screen.main + +import com.github.ajalt.mordant.rendering.Widget +import com.github.ajalt.mordant.table.ColumnWidth +import com.github.ajalt.mordant.table.horizontalLayout +import com.github.ajalt.mordant.table.verticalLayout +import team.dedinside.yapi.tui.widget.headerBar +import team.dedinside.yapi.tui.widget.statusFooter + +internal fun renderHome(s: MainState): Widget { + val mainCell: Widget = s.palette?.render() ?: editorPanel(s) + val workspace = if (s.sidebarVisible) { + horizontalLayout { + spacing = 1 + column(0) { width = ColumnWidth.Fixed(34) } + column(1) { width = ColumnWidth.Expand() } + cell(sidebarPanel(s)) + cell(mainCell) + } + } else mainCell + + return verticalLayout { + spacing = 0 + cell(headerBar("yapi", "0.0.1", "default", "~/.config/yapi ? help Ctrl+P palette Ctrl+B sidebar")) + cell(workspace) + cell(footer(s)) + } +} + +private fun footer(s: MainState): Widget { + if (s.palette != null) { + return statusFooter("Palette", "↑↓ move · ⏎ run · Esc cancel") + } + val (label, keys) = when (s.focus) { + Focus.PROJECT -> "Project" to ( + "↑↓ move · ←→ collapse/expand · ⏎ open · Tab → editor · n new · D " + + (if (s.demo) "empty" else "demo") + + " · Ctrl+P palette · ? help · q quit" + ) + Focus.URL -> "URL" to "type to edit · ⏎ send · Ctrl+U clear · Tab next · Esc back · Ctrl+B sidebar · Ctrl+P palette · ? help" + Focus.TABS -> "Tabs: ${s.tab.label}" to "← → cycle tabs · Tab next · Esc back · Ctrl+B sidebar · Ctrl+P palette · ? help" + Focus.RESPONSE -> "Response" to "Esc back · Tab next · Ctrl+B sidebar · Ctrl+P palette · ? help" + } + return statusFooter(label, keys) +} diff --git a/cli/src/main/kotlin/team/dedinside/yapi/tui/screen/main/Sidebar.kt b/cli/src/main/kotlin/team/dedinside/yapi/tui/screen/main/Sidebar.kt new file mode 100644 index 0000000..5c1e01a --- /dev/null +++ b/cli/src/main/kotlin/team/dedinside/yapi/tui/screen/main/Sidebar.kt @@ -0,0 +1,115 @@ +package team.dedinside.yapi.tui.screen.main + +import com.github.ajalt.mordant.rendering.TextColors.brightBlue +import com.github.ajalt.mordant.rendering.TextColors.brightWhite +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.rendering.Widget +import com.github.ajalt.mordant.table.ColumnWidth +import com.github.ajalt.mordant.table.horizontalLayout +import com.github.ajalt.mordant.table.verticalLayout +import com.github.ajalt.mordant.widgets.HorizontalRule +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.widget.methodBadge +import team.dedinside.yapi.tui.widget.statusBadge + +internal fun sidebarPanel(s: MainState): Widget { + val focused = s.focus == Focus.PROJECT + val title = if (focused) brightWhite(bold("Project")) else dim("Project") + val rightHint = dim(if (s.demo) "[D] empty" else "[D] demo") + + val titleRow = horizontalLayout { + spacing = 1 + column(0) { width = ColumnWidth.Expand() } + column(1) { width = ColumnWidth.Auto } + cell(Text(title)) + cell(Text(rightHint)) + } + + val body = if (!s.demo) { + verticalLayout { + spacing = 0 + cell(titleRow) + cell(Text("")) + cell(emptyTreeOnboarding()) + } + } else { + verticalLayout { + spacing = 0 + cell(titleRow) + cell(Text("")) + cell(Text(dim(" collections"))) + cell(treeWidget(s)) + cell(Text("")) + cell(HorizontalRule(ruleCharacter = "─")) + cell(Text(dim(" recent"))) + cell(verticalLayout { for (r in s.recent) cell(Text(formatRecent(r))) }) + } + } + + return Panel( + content = body, + title = Text(if (focused) brightBlue(bold("◆ Project")) else dim("◇ Project")), + expand = true, + padding = Padding(0, 1, 0, 1), + ) +} + +private fun treeWidget(s: MainState): Widget { + val rows = s.visibleRows() + if (rows.isEmpty()) return emptyTreeOnboarding() + return verticalLayout { + for ((i, row) in rows.withIndex()) { + val isCurrent = i == s.treeSelected && s.focus == Focus.PROJECT + val isDimSelect = i == s.treeSelected && s.focus != Focus.PROJECT + cell(Text(formatTreeRow(row, isCurrent, isDimSelect))) + } + } +} + +private fun emptyTreeOnboarding(): Widget = Text( + buildString { + appendLine(" " + dim("Empty project")) + appendLine() + appendLine(" No saved requests yet.") + appendLine() + appendLine(" " + brightBlue("›") + " " + bold("n") + " compose a new request") + appendLine(" " + brightBlue("›") + " " + bold("Ctrl+P") + " browse commands") + appendLine(" " + brightBlue("›") + " " + bold("D") + " load demo tree") + appendLine() + appendLine(dim(" ─")) + appendLine() + appendLine(dim(" Saved requests live as .http files")) + appendLine(dim(" under ~/.config/yapi/requests/.")) + appendLine(dim(" Disk loading lands in issue #11.")) + }, + whitespace = Whitespace.PRE_WRAP, +) + +private fun formatTreeRow(row: TreeRow, isCurrent: Boolean, isDimSelect: Boolean): String { + val indent = " ".repeat(row.depth) + val marker = when { + isCurrent -> brightBlue("▶ ") + isDimSelect -> dim("▶ ") + else -> " " + } + return when (val node = row.node) { + is Folder -> { + val chevron = if (node.expanded) "▾" else "▸" + val name = if (isCurrent) brightWhite(bold(node.name + "/")) else node.name + "/" + val count = dim(" (${node.children.size})") + "$marker$indent$chevron $name$count" + } + + is RequestFile -> { + val name = if (isCurrent) brightWhite(bold(node.name)) else node.name + "$marker$indent ${methodBadge(node.method)} $name" + } + } +} + +private fun formatRecent(r: RecentEntry): String = " ${methodBadge(r.method)} ${statusBadge(r.status)} " + + "${r.path}${dim(" ${r.elapsedMs}ms ${r.time}")}" diff --git a/cli/src/main/kotlin/team/dedinside/yapi/tui/screen/main/Tree.kt b/cli/src/main/kotlin/team/dedinside/yapi/tui/screen/main/Tree.kt new file mode 100644 index 0000000..71b54c9 --- /dev/null +++ b/cli/src/main/kotlin/team/dedinside/yapi/tui/screen/main/Tree.kt @@ -0,0 +1,25 @@ +package team.dedinside.yapi.tui.screen.main + +internal sealed interface TreeNode { val name: String } + +internal class Folder( + override val name: String, + val children: List, + var expanded: Boolean = true, +) : TreeNode + +internal class RequestFile( + override val name: String, + val method: String, + val path: String, +) : TreeNode + +internal data class RecentEntry( + val method: String, + val path: String, + val status: Int, + val elapsedMs: Int, + val time: String, +) + +internal data class TreeRow(val depth: Int, val node: TreeNode) diff --git a/cli/src/main/kotlin/team/dedinside/yapi/tui/widget/CommandPalette.kt b/cli/src/main/kotlin/team/dedinside/yapi/tui/widget/CommandPalette.kt new file mode 100644 index 0000000..e31fa9e --- /dev/null +++ b/cli/src/main/kotlin/team/dedinside/yapi/tui/widget/CommandPalette.kt @@ -0,0 +1,142 @@ +package team.dedinside.yapi.tui.widget + +import com.github.ajalt.mordant.input.KeyboardEvent +import team.dedinside.yapi.tui.model.ScreenAction +import com.github.ajalt.mordant.rendering.TextColors.brightBlue +import com.github.ajalt.mordant.rendering.TextColors.brightWhite +import com.github.ajalt.mordant.rendering.TextStyles.bold +import com.github.ajalt.mordant.rendering.TextStyles.dim +import com.github.ajalt.mordant.rendering.TextStyles.italic +import com.github.ajalt.mordant.rendering.Widget +import com.github.ajalt.mordant.table.ColumnWidth +import com.github.ajalt.mordant.table.horizontalLayout +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 + +/** + * A single entry in the command palette. + * + * - [shortcut] is displayed right-aligned next to the label, e.g. `"Ctrl+B"`. + * - [category] groups commands under a section header (preserves first-seen order). + * - [action] runs after the palette closes; the returned [ScreenAction] is applied + * by the host screen (e.g. push a help screen, exit, or stay). + */ +data class PaletteCommand( + val label: String, + val hint: String? = null, + val shortcut: String? = null, + val category: String = "General", + val action: () -> ScreenAction, +) + +/** + * Modeless command palette **dialog**. Not a [Screen] — a host screen (e.g. + * [MainScreen]) keeps an instance of this in its state, calls [render] inside its + * own layout, and routes key events through [handle] until the palette is + * cancelled or runs a command. + * + * Keeping the palette inside the host avoids the "full-screen popup" feel and + * lets the surrounding workspace (header, project sidebar) stay visible behind + * the dialog. + */ +class CommandPalette(val commands: List) { + + sealed interface Outcome { + /** Palette stays open; re-render. */ + data object Stay : Outcome + /** User dismissed the palette; close it. */ + data object Cancel : Outcome + /** User picked a command; close the palette and apply [action]. */ + data class Run(val action: () -> ScreenAction) : Outcome + } + + private var selected = 0 + + fun render(): Widget { + val titleRow = horizontalLayout { + spacing = 2 + column(0) { width = ColumnWidth.Expand() } + column(1) { width = ColumnWidth.Auto } + cell(Text(brightWhite(bold("Command palette")))) + cell(Text(dim("${commands.size} items"))) + } + + val searchRow = horizontalLayout { + spacing = 1 + column(0) { width = ColumnWidth.Fixed(2) } + column(1) { width = ColumnWidth.Expand() } + cell(Text(brightBlue("›"))) + cell(Text(italic(dim("type to filter… ")) + dim("(filtering lands later)"))) + } + + val rowList = verticalLayout { + spacing = 0 + var lastCategory: String? = null + for ((i, cmd) in commands.withIndex()) { + if (cmd.category != lastCategory) { + if (lastCategory != null) cell(Text("")) + cell(Text(" " + dim(cmd.category.uppercase()))) + lastCategory = cmd.category + } + cell(commandRow(cmd, isCurrent = i == selected)) + } + } + + val footer = Text(dim(" ↑↓ move ⏎ run Esc cancel")) + + val body = verticalLayout { + spacing = 0 + cell(titleRow) + cell(Text("")) + cell(searchRow) + cell(Text("")) + cell(rowList) + cell(Text("")) + cell(footer) + } + + return Panel( + content = body, + title = Text(brightBlue(bold(" ◆ Palette "))), + expand = true, + padding = Padding(1, 2, 1, 2), + ) + } + + private fun commandRow(cmd: PaletteCommand, isCurrent: Boolean): Widget { + val marker = if (isCurrent) brightBlue(bold("›")) else " " + val label = if (isCurrent) brightWhite(bold(cmd.label)) else cmd.label + val hint = cmd.hint?.let { dim(it) } ?: "" + val shortcut = cmd.shortcut?.let { dim(it) } ?: "" + + return horizontalLayout { + spacing = 1 + column(0) { width = ColumnWidth.Fixed(2) } + column(1) { width = ColumnWidth.Fixed(22) } + column(2) { width = ColumnWidth.Expand() } + column(3) { width = ColumnWidth.Fixed(10) } + cell(Text(marker)) + cell(Text(label)) + cell(Text(hint)) + cell(Text(shortcut)) + } + } + + fun handle(event: KeyboardEvent): Outcome = when (event.key) { + "ArrowUp", "k" -> { + selected = (selected - 1 + commands.size) % commands.size + Outcome.Stay + } + "ArrowDown", "j" -> { + selected = (selected + 1) % commands.size + Outcome.Stay + } + "Home", "g" -> { selected = 0; Outcome.Stay } + "End", "G" -> { selected = commands.lastIndex; Outcome.Stay } + "Enter", " " -> Outcome.Run(commands[selected].action) + "Escape" -> Outcome.Cancel + else -> Outcome.Stay + } +} diff --git a/cli/src/main/kotlin/team/dedinside/yapi/tui/widget/Widgets.kt b/cli/src/main/kotlin/team/dedinside/yapi/tui/widget/Widgets.kt new file mode 100644 index 0000000..e9beb0b --- /dev/null +++ b/cli/src/main/kotlin/team/dedinside/yapi/tui/widget/Widgets.kt @@ -0,0 +1,55 @@ +package team.dedinside.yapi.tui.widget + +import com.github.ajalt.mordant.rendering.TextAlign +import com.github.ajalt.mordant.rendering.TextColors.brightBlue +import com.github.ajalt.mordant.rendering.TextColors.brightCyan +import com.github.ajalt.mordant.rendering.TextColors.brightGreen +import com.github.ajalt.mordant.rendering.TextColors.brightRed +import com.github.ajalt.mordant.rendering.TextColors.brightWhite +import com.github.ajalt.mordant.rendering.TextColors.brightYellow +import com.github.ajalt.mordant.rendering.TextStyles.bold +import com.github.ajalt.mordant.rendering.TextStyles.dim +import com.github.ajalt.mordant.rendering.Widget +import com.github.ajalt.mordant.table.ColumnWidth +import com.github.ajalt.mordant.table.horizontalLayout +import com.github.ajalt.mordant.widgets.Text + +/** Two-column app header bar: brand + profile on the left, hint on the right. */ +fun headerBar( + brand: String, + version: String, + profile: String, + rightHint: String, +): Widget = horizontalLayout { + spacing = 2 + column(0) { width = ColumnWidth.Expand() } + column(1) { width = ColumnWidth.Auto } + cell(Text(brightWhite(bold(brand)) + dim(" $version ") + brightBlue("profile:") + " $profile")) + cell(Text(dim(rightHint))) +} + +/** Left-edge focus marker — bright when focused, blank otherwise. */ +fun focusMarker(focused: Boolean): String = if (focused) brightBlue("▎") else " " + +/** Coloured HTTP method badge. */ +fun methodBadge(method: String): String = when (method.uppercase()) { + "GET" -> brightGreen(bold(method.padEnd(6))) + "POST" -> brightYellow(bold(method.padEnd(6))) + "PUT" -> brightBlue(bold(method.padEnd(6))) + "PATCH" -> brightCyan(bold(method.padEnd(6))) + "DELETE" -> brightRed(bold(method.padEnd(6))) + else -> dim(method.padEnd(6)) +} + +/** Coloured HTTP status badge by class (2xx green, 3xx cyan, 4xx yellow, 5xx red). */ +fun statusBadge(status: Int): String = when (status) { + in 200..299 -> brightGreen(bold(status.toString())) + in 300..399 -> brightCyan(bold(status.toString())) + in 400..499 -> brightYellow(bold(status.toString())) + in 500..599 -> brightRed(bold(status.toString())) + else -> dim(status.toString()) +} + +/** Footer line: blue `[Label]` + dim context-keys string. */ +fun statusFooter(label: String, keys: String): Widget = + Text(brightBlue("[$label] ") + dim(keys), align = TextAlign.LEFT) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e86220d..cfa142b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,6 +3,7 @@ kotlin = "2.3.0" mordant = "3.0.2" coroutines = "1.10.2" clikt = "5.1.0" +shadow = "9.4.1" [libraries] mordant-core = { module = "com.github.ajalt.mordant:mordant-core", version.ref = "mordant" } @@ -13,3 +14,4 @@ clikt = { module = "com.github.ajalt.clikt:clikt", version.ref = "clikt" } [plugins] kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } +shadow = { id = "com.gradleup.shadow", version.ref = "shadow" } diff --git a/tui.sh b/tui.sh new file mode 100755 index 0000000..99ba00e --- /dev/null +++ b/tui.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env sh +# Build and launch yapi attached to the current TTY. +# +# `./gradlew :cli:run` cannot host the raw-mode TUI because the Gradle daemon +# pipes the child's stdio. This script delegates compilation to Gradle but +# execs the install-dist start script directly so the JVM inherits the shell's +# real terminal. +set -e + +cd "$(dirname "$0")" + +./gradlew --quiet :cli:installDist +exec ./cli/build/install/cli/bin/cli "$@"