diff --git a/PartnerCtl/src/main/java/work/slhaf/partner/ctl/ui/Prompt.kt b/PartnerCtl/src/main/java/work/slhaf/partner/ctl/ui/Prompt.kt new file mode 100644 index 00000000..9783cc72 --- /dev/null +++ b/PartnerCtl/src/main/java/work/slhaf/partner/ctl/ui/Prompt.kt @@ -0,0 +1,329 @@ +package work.slhaf.partner.ctl.ui + +import org.jline.reader.EndOfFileException +import org.jline.reader.LineReader +import org.jline.reader.LineReaderBuilder +import org.jline.reader.UserInterruptException +import org.jline.terminal.Terminal +import org.jline.terminal.TerminalBuilder + +class Prompt private constructor( + private val terminal: Terminal, + private val reader: LineReader, +) { + + companion object { + fun create(): Prompt { + val terminal = TerminalBuilder.builder() + .system(true) + .dumb(true) + .build() + val reader = LineReaderBuilder.builder() + .terminal(terminal) + .build() + return Prompt(terminal, reader) + } + } + + fun print(message: String) { + terminal.writer().print(message) + terminal.writer().flush() + } + + fun println(message: String = "") { + terminal.writer().println(message) + terminal.writer().flush() + } + + fun blank() = println() + + fun section(title: String) { + blank() + println("❯ $title") + } + + fun info(message: String) = println("[info] $message") + + fun success(message: String) = println("[ok] $message") + + fun warn(message: String) = println("[warn] $message") + + fun error(message: String) = println("[error] $message") + + fun ask( + label: String, + defaultValue: String? = null, + required: Boolean = true, + validator: ((String) -> String?)? = null, + ): String { + val suffix = if (defaultValue != null) " [$defaultValue]" else "" + + while (true) { + val input = readLine("$label$suffix: ").trim() + val value = when { + input.isNotEmpty() -> input + defaultValue != null -> defaultValue + !required -> "" + else -> { + error("This value is required.") + continue + } + } + + val validationError = validator?.invoke(value) ?: return value + error(validationError) + } + } + + fun confirm(label: String, defaultValue: Boolean = true): Boolean { + val suffix = if (defaultValue) " [Y/n]" else " [y/N]" + + while (true) { + when (readLine("$label$suffix ").trim().lowercase()) { + "" -> return defaultValue + "y", "yes" -> return true + "n", "no" -> return false + else -> error("Please answer yes or no.") + } + } + } + + fun select( + label: String, + choices: List>, + defaultIndex: Int = 0, + ): T { + require(choices.isNotEmpty()) { "choices must not be empty" } + require(defaultIndex in choices.indices) { "defaultIndex must be within choices indices" } + + println(label) + choices.forEachIndexed { index, choice -> + val disabled = if (choice.enabled) "" else " (unavailable)" + val description = choice.description?.let { " - $it" } ?: "" + println(" ${index + 1}. ${choice.label}$disabled$description") + } + + while (true) { + val input = readLine("Choose [${defaultIndex + 1}]: ").trim() + val selectedIndex = if (input.isEmpty()) defaultIndex else input.toIntOrNull()?.minus(1) + + if (selectedIndex != null && selectedIndex in choices.indices) { + val choice = choices[selectedIndex] + if (choice.enabled) return choice.value + error("${choice.label} is not available.") + continue + } + + error("Please enter a number between 1 and ${choices.size}.") + } + } + + fun multiSelect( + label: String, + choices: List>, + defaultSelected: Set = emptySet(), + ): List { + require(choices.isNotEmpty()) { "choices must not be empty" } + require(defaultSelected.all { it in choices.indices }) { "defaultSelected must only contain valid choice indices" } + + return if (terminal.type == "dumb") { + fallbackMultiSelect(label, choices, defaultSelected) + } else { + try { + interactiveMultiSelect(label, choices, defaultSelected) + } catch (e: PromptCancelledException) { + throw e + } catch (_: Throwable) { + fallbackMultiSelect(label, choices, defaultSelected) + } + } + } + + private fun interactiveMultiSelect( + label: String, + choices: List>, + defaultSelected: Set, + ): List { + val selected = defaultSelected + .filter { choices[it].enabled } + .toMutableSet() + var cursor = choices.indexOfFirst { it.enabled }.takeIf { it >= 0 } ?: 0 + var renderedLines = 0 + val oldAttributes = terminal.enterRawMode() + + try { + while (true) { + renderedLines = renderMultiSelect(label, choices, selected, cursor, renderedLines) + + when (readKey()) { + PromptKey.UP -> cursor = moveCursor(choices, cursor, -1) + PromptKey.DOWN -> cursor = moveCursor(choices, cursor, 1) + PromptKey.SPACE -> { + if (choices[cursor].enabled) { + if (!selected.add(cursor)) selected.remove(cursor) + } else { + beep() + } + } + + PromptKey.ENTER -> { + terminal.attributes = oldAttributes + println() + return selected.sorted().map { choices[it].value } + } + + PromptKey.CANCEL -> throw PromptCancelledException() + PromptKey.OTHER -> Unit + } + } + } finally { + terminal.attributes = oldAttributes + } + } + + private fun fallbackMultiSelect( + label: String, + choices: List>, + defaultSelected: Set, + ): List { + println(label) + choices.forEachIndexed { index, choice -> + val selected = if (index in defaultSelected) "x" else " " + val disabled = if (choice.enabled) "" else " (unavailable)" + val description = choice.description?.let { " - $it" } ?: "" + println(" ${index + 1}. [$selected] ${choice.label}$disabled$description") + } + println("Enter numbers separated by comma. Leave empty to use defaults.") + + while (true) { + val input = readLine("Select: ").trim() + val selectedIndices = if (input.isEmpty()) { + defaultSelected + } else { + parseIndexList(input, choices.size) + } + + if (selectedIndices == null) { + error("Please enter numbers between 1 and ${choices.size}, separated by comma.") + continue + } + + val disabled = selectedIndices.map { choices[it] }.filterNot { it.enabled } + if (disabled.isNotEmpty()) { + error("Unavailable choice selected: ${disabled.joinToString { it.label }}") + continue + } + + return selectedIndices.sorted().map { choices[it].value } + } + } + + private fun renderMultiSelect( + label: String, + choices: List>, + selected: Set, + cursor: Int, + previousLines: Int, + ): Int { + if (previousLines > 0) { + terminal.writer().print("\u001B[${previousLines}A") + terminal.writer().print("\u001B[J") + } + + val lines = buildList { + add(label) + add(" ↑/↓ move, Space toggle, Enter confirm") + choices.forEachIndexed { index, choice -> + val pointer = if (index == cursor) "❯" else " " + val checked = if (index in selected) "x" else " " + val disabled = if (choice.enabled) "" else " (unavailable)" + val description = choice.description?.let { " - $it" } ?: "" + add("$pointer [$checked] ${choice.label}$disabled$description") + } + } + + lines.forEach { terminal.writer().println(it) } + terminal.writer().flush() + return lines.size + } + + private fun moveCursor(choices: List>, current: Int, delta: Int): Int { + var next = current + repeat(choices.size) { + next = (next + delta + choices.size) % choices.size + if (choices[next].enabled) return next + } + return current + } + + private fun readKey(): PromptKey { + return when (val first = terminal.reader().read()) { + -1, 3, 4 -> PromptKey.CANCEL + 10, 13 -> PromptKey.ENTER + 32 -> PromptKey.SPACE + 27 -> readEscapeKey() + else -> when (first.toChar()) { + 'k', 'K' -> PromptKey.UP + 'j', 'J' -> PromptKey.DOWN + 'q', 'Q' -> PromptKey.CANCEL + else -> PromptKey.OTHER + } + } + } + + private fun readEscapeKey(): PromptKey { + val second = terminal.reader().read(50L) + if (second == -1) return PromptKey.CANCEL + if (second != '['.code) return PromptKey.OTHER + + return when (terminal.reader().read(50L)) { + 'A'.code -> PromptKey.UP + 'B'.code -> PromptKey.DOWN + else -> PromptKey.OTHER + } + } + + private fun beep() { + terminal.writer().print('\u0007') + terminal.writer().flush() + } + + private fun readLine(prompt: String): String { + return try { + reader.readLine(prompt) + } catch (_: UserInterruptException) { + throw PromptCancelledException() + } catch (_: EndOfFileException) { + throw PromptCancelledException() + } + } + + private fun parseIndexList(input: String, size: Int): Set? { + if (input.isBlank()) return emptySet() + + return input + .split(',', ' ') + .filter { it.isNotBlank() } + .map { it.toIntOrNull()?.minus(1) ?: return null } + .takeIf { indices -> indices.all { it in 0 until size } } + ?.toSet() + } +} + +data class Choice( + val label: String, + val value: T, + val description: String? = null, + val enabled: Boolean = true, +) + +private enum class PromptKey { + UP, + DOWN, + SPACE, + ENTER, + CANCEL, + OTHER, +} + +class PromptCancelledException : RuntimeException() diff --git a/PartnerCtl/src/test/java/experimental/PromptDemo.kt b/PartnerCtl/src/test/java/experimental/PromptDemo.kt new file mode 100644 index 00000000..75979bb8 --- /dev/null +++ b/PartnerCtl/src/test/java/experimental/PromptDemo.kt @@ -0,0 +1,120 @@ +package experimental + +private enum class DemoAction { + HOME, + RUNTIME, + CONFIGURATION, + FINISH, +} + +private enum class DemoModule { + WEBSOCKET_GATEWAY, + ONEBOT_ADAPTER, + TELEGRAM_ADAPTER, +} + +fun main() { + val prompt = _root_ide_package_.work.slhaf.partner.ctl.ui.Prompt.Companion.create() + + try { + prompt.section("Output") + prompt.println("Plain println") + prompt.print("Plain print") + prompt.println(" + println") + prompt.info("This is an info message.") + prompt.success("This is a success message.") + prompt.warn("This is a warning message.") + prompt.error("This is an error message.") + prompt.blank() + + prompt.section("Ask") + val required = prompt.ask( + label = "Required value", + required = true, + ) + prompt.info("Required value = $required") + + val withDefault = prompt.ask( + label = "Value with default", + defaultValue = "default-value", + ) + prompt.info("Value with default = $withDefault") + + val optional = prompt.ask( + label = "Optional value", + required = false, + ) + prompt.info("Optional value = ${optional.ifBlank { "" }}") + + val port = prompt.ask( + label = "Port", + defaultValue = "8765", + ) { value -> + val number = value.toIntOrNull() + when (number) { + null -> "Port must be a number." + !in 1..65535 -> "Port must be between 1 and 65535." + else -> null + } + } + prompt.info("Port = $port") + + prompt.section("Confirm") + val confirmed = prompt.confirm("Continue?", defaultValue = true) + prompt.info("Continue = $confirmed") + + prompt.section("Select") + val action = prompt.select( + label = "Choose next action:", + choices = listOf( + _root_ide_package_.work.slhaf.partner.ctl.ui.Choice("Home", DemoAction.HOME, "Configure Partner home"), + _root_ide_package_.work.slhaf.partner.ctl.ui.Choice( + "Runtime", + DemoAction.RUNTIME, + "Install Partner runtime" + ), + _root_ide_package_.work.slhaf.partner.ctl.ui.Choice( + "Configuration", + DemoAction.CONFIGURATION, + "Configure model and gateway" + ), + _root_ide_package_.work.slhaf.partner.ctl.ui.Choice( + "Download release", + DemoAction.FINISH, + "Not available yet", + enabled = false + ), + _root_ide_package_.work.slhaf.partner.ctl.ui.Choice("Finish", DemoAction.FINISH), + ), + defaultIndex = 0, + ) + prompt.info("Selected action = $action") + + prompt.section("Multi select") + val modules = prompt.multiSelect( + label = "Select modules:", + choices = listOf( + _root_ide_package_.work.slhaf.partner.ctl.ui.Choice( + "WebSocket gateway", + DemoModule.WEBSOCKET_GATEWAY, + "Local WebSocket gateway" + ), + _root_ide_package_.work.slhaf.partner.ctl.ui.Choice("OneBot adapter", DemoModule.ONEBOT_ADAPTER), + _root_ide_package_.work.slhaf.partner.ctl.ui.Choice( + "Telegram adapter", + DemoModule.TELEGRAM_ADAPTER, + "Not available yet", + enabled = false + ), + ), + defaultSelected = setOf(0), + ) + prompt.info("Selected modules = ${modules.joinToString()}") + + prompt.section("Done") + prompt.success("Prompt demo completed.") + } catch (_: work.slhaf.partner.ctl.ui.PromptCancelledException) { + prompt.blank() + prompt.warn("Prompt demo cancelled.") + } +}