diff --git a/bobatea/src/commonMain/kotlin/com/github/dylanwatsonsoftware/bobatea/BackButton.kt b/bobatea/src/commonMain/kotlin/com/github/dylanwatsonsoftware/bobatea/BackButton.kt index f11c110..0e2069f 100644 --- a/bobatea/src/commonMain/kotlin/com/github/dylanwatsonsoftware/bobatea/BackButton.kt +++ b/bobatea/src/commonMain/kotlin/com/github/dylanwatsonsoftware/bobatea/BackButton.kt @@ -11,20 +11,34 @@ class BackButton( override var width: Dimension = Dimension.Auto, override var maxWidth: Dimension = Dimension.Auto, override var height: Dimension = Dimension.Auto, - override var maxHeight: Dimension = Dimension.Auto + override var maxHeight: Dimension = Dimension.Auto, + val onClicked: () -> Unit = {} ) : BobaComponent(padding, margin, borderStyle, color, width, maxWidth, height, maxHeight) { override fun render(availableWidth: Int?, availableHeight: Int?): String { val text = " [ ← Back (q) ] " val styled = (TextColors.red.bg + TextColors.white + TextStyles.bold)(text) - return wrapInBox(styled, availableWidth, availableHeight) + val result = wrapInBox(styled, availableWidth, availableHeight) + val lines = result.lines() + widthPx = lines.maxOfOrNull { visibleLength(it) } ?: 0 + heightPx = lines.size + return result } - fun isClicked(event: BobaEvent.Mouse, xOffset: Int, yOffset: Int): Boolean { - val textLength = 16 // " [ ← Back (q) ] ".length - return event.action == MouseAction.PRESS && - event.y == yOffset && - event.x >= xOffset && - event.x <= xOffset + textLength + override fun onEvent(event: BobaEvent): Boolean { + if (event is BobaEvent.Key && (event.code == 'q'.code || event.code == 'Q'.code)) { + onClicked() + return true + } + if (event is BobaEvent.Mouse && event.action == MouseAction.PRESS) { + val textLength = 16 // " [ ← Back (q) ] ".length + val startX = getContentStartX() + val startY = getContentStartY() + if (event.y == startY && event.x >= startX && event.x < startX + textLength) { + onClicked() + return true + } + } + return false } } diff --git a/bobatea/src/commonMain/kotlin/com/github/dylanwatsonsoftware/bobatea/Boba.kt b/bobatea/src/commonMain/kotlin/com/github/dylanwatsonsoftware/bobatea/Boba.kt new file mode 100644 index 0000000..2bd4351 --- /dev/null +++ b/bobatea/src/commonMain/kotlin/com/github/dylanwatsonsoftware/bobatea/Boba.kt @@ -0,0 +1,42 @@ +package com.github.dylanwatsonsoftware.bobatea + +import kotlinx.coroutines.delay + +class Boba { + companion object { + suspend fun run(terminal: Terminal, root: BobaComponent) { + terminal.clear() + terminal.enableMouseTracking(true) + var lastTick = currentTimeMillis() + + // Initialize root coordinates to match terminal 1-based system + root.x = 1 + root.y = 1 + + try { + while (true) { + val (width, height) = terminal.size() + val output = root.render(width, height) + terminal.clear() + terminal.write(output) + + val event = terminal.readEvent(50) + if (event != null) { + root.onEvent(event) + } + + val now = currentTimeMillis() + root.tick(now - lastTick) + lastTick = now + + // Yield to other coroutines + delay(10) + } + } finally { + terminal.disableMouseTracking() + } + } + } +} + +internal expect fun currentTimeMillis(): Long diff --git a/bobatea/src/commonMain/kotlin/com/github/dylanwatsonsoftware/bobatea/BobaComponent.kt b/bobatea/src/commonMain/kotlin/com/github/dylanwatsonsoftware/bobatea/BobaComponent.kt index 83d805f..c1ee773 100644 --- a/bobatea/src/commonMain/kotlin/com/github/dylanwatsonsoftware/bobatea/BobaComponent.kt +++ b/bobatea/src/commonMain/kotlin/com/github/dylanwatsonsoftware/bobatea/BobaComponent.kt @@ -20,6 +20,11 @@ abstract class BobaComponent( open var height: Dimension = Dimension.Auto, open var maxHeight: Dimension = Dimension.Auto ) { + var x: Int = 0 + var y: Int = 0 + var widthPx: Int = 0 + var heightPx: Int = 0 + companion object { val ANSI_REGEX = Regex("\u001b\\[[0-9;?]*[a-zA-Z]|\u001b\\][^\u0007]*\u0007") @@ -55,6 +60,10 @@ abstract class BobaComponent( abstract fun render(availableWidth: Int? = null, availableHeight: Int? = null): String + open fun onEvent(event: BobaEvent): Boolean = false + + open fun tick(deltaMs: Long) {} + protected fun getMordant(): MordantTerminal { return dummyTerminal } @@ -67,4 +76,7 @@ abstract class BobaComponent( } return Box(content, padding, margin, borderStyle, color, width, maxWidth, height, maxHeight).render(availableWidth, availableHeight) } + + protected fun getContentStartX(): Int = x + margin + (if (borderStyle != BorderStyle.NONE) 1 else 0) + padding + protected fun getContentStartY(): Int = y + margin + (if (borderStyle != BorderStyle.NONE) 1 else 0) + padding } diff --git a/bobatea/src/commonMain/kotlin/com/github/dylanwatsonsoftware/bobatea/ConsoleColors.kt b/bobatea/src/commonMain/kotlin/com/github/dylanwatsonsoftware/bobatea/ConsoleColors.kt index 4f12cca..c096d38 100644 --- a/bobatea/src/commonMain/kotlin/com/github/dylanwatsonsoftware/bobatea/ConsoleColors.kt +++ b/bobatea/src/commonMain/kotlin/com/github/dylanwatsonsoftware/bobatea/ConsoleColors.kt @@ -42,6 +42,14 @@ class ConsoleColors { val CYAN: String = styleToString(TextColors.cyan) val WHITE: String = styleToString(TextColors.white) + // Bubble Tea style colors + val BOBA_PINK: String = styleToString(TextColors.rgb("#F179B4")) + val BOBA_CYAN: String = styleToString(TextColors.rgb("#00D7FF")) + val BOBA_GREEN: String = styleToString(TextColors.rgb("#A7FF00")) + val BOBA_YELLOW: String = styleToString(TextColors.rgb("#FFD700")) + val BOBA_PURPLE: String = styleToString(TextColors.rgb("#AF87FF")) + val BOBA_GRAY: String = styleToString(TextColors.rgb("#808080")) + // Bold val BLACK_BOLD: String = styleToString(TextColors.black + TextStyles.bold) val RED_BOLD: String = styleToString(TextColors.red + TextStyles.bold) diff --git a/bobatea/src/commonMain/kotlin/com/github/dylanwatsonsoftware/bobatea/ExpandableComponent.kt b/bobatea/src/commonMain/kotlin/com/github/dylanwatsonsoftware/bobatea/ExpandableComponent.kt index 01cf473..7c2ee5d 100644 --- a/bobatea/src/commonMain/kotlin/com/github/dylanwatsonsoftware/bobatea/ExpandableComponent.kt +++ b/bobatea/src/commonMain/kotlin/com/github/dylanwatsonsoftware/bobatea/ExpandableComponent.kt @@ -41,49 +41,38 @@ class ExpandableComponent( result.append("Press ${color("SPACE/ENTER", GREEN)} or ${color("CLICK", GREEN)} to toggle\n") result.append("Press ${color("Q", GREEN)} to exit") - return wrapInBox(result.toString().trimEnd('\n'), availableWidth, availableHeight) + val output = wrapInBox(result.toString().trimEnd('\n'), availableWidth, availableHeight) + val lines = output.lines() + widthPx = lines.maxOfOrNull { visibleLength(it) } ?: 0 + heightPx = lines.size + return output } - suspend fun interact(terminal: Terminal) { - val (availableWidth, availableHeight) = terminal.size() - val titleLine = margin + (if (borderStyle != BorderStyle.NONE) 1 else 0) + padding - - fun printExpandable() { - terminal.clear() - terminal.write(render(availableWidth, availableHeight) + "\n") - } - - printExpandable() - - terminal.enableMouseTracking(allMotion = true) - try { - while (true) { - when (val event = terminal.readEvent()) { - is BobaEvent.Key -> { - when (event.code) { - SPACE.key, ENTER.key -> { - expanded = !expanded - printExpandable() - } - 'q'.code, 'Q'.code -> return - } + override fun onEvent(event: BobaEvent): Boolean { + val titleLine = getContentStartY() + val titleStart = getContentStartX() + when (event) { + is BobaEvent.Key -> { + when (event.code) { + SPACE.key, ENTER.key -> { + expanded = !expanded + return true } - is BobaEvent.Mouse -> { - val currentlyHovered = event.y == titleLine + 1 && event.x <= title.length + 4 - if (currentlyHovered != isHovered) { - isHovered = currentlyHovered - printExpandable() - } + } + } + is BobaEvent.Mouse -> { + val currentlyHovered = event.y == titleLine && event.x >= titleStart && event.x < titleStart + title.length + 4 + if (currentlyHovered != isHovered) { + isHovered = currentlyHovered + return true + } - if (event.action == MouseAction.PRESS && event.button < 64 && currentlyHovered) { - expanded = !expanded - printExpandable() - } - } + if (event.action == MouseAction.PRESS && event.button < 64 && currentlyHovered) { + expanded = !expanded + return true } } - } finally { - terminal.disableMouseTracking() } + return false } } diff --git a/bobatea/src/commonMain/kotlin/com/github/dylanwatsonsoftware/bobatea/Inline.kt b/bobatea/src/commonMain/kotlin/com/github/dylanwatsonsoftware/bobatea/Inline.kt index 33fe9f5..80904b9 100644 --- a/bobatea/src/commonMain/kotlin/com/github/dylanwatsonsoftware/bobatea/Inline.kt +++ b/bobatea/src/commonMain/kotlin/com/github/dylanwatsonsoftware/bobatea/Inline.kt @@ -23,8 +23,8 @@ class Inline( val resolvedWidth = BobaComponent.resolveDimension(width, availableWidth) val resolvedHeight = BobaComponent.resolveDimension(height, availableHeight) - val borderSize = if (borderStyle != BorderStyle.NONE) 2 else 0 - val horizontalTotal = padding * 2 + borderSize + val borderSizeVal = if (this.borderStyle != BorderStyle.NONE) 2 else 0 + val horizontalTotal = this.padding * 2 + borderSizeVal val innerAvailableWidth = (resolvedWidth ?: availableWidth)?.let { max(0, it - horizontalTotal) } val childAvailableWidth = if (children.isNotEmpty()) { @@ -33,19 +33,44 @@ class Inline( null } + var currentX = this.x + this.padding + (if (this.borderStyle != BorderStyle.NONE) 1 else 0) val t = table { borderType = BorderType.BLANK padding(0) body { row { children.forEach { child -> - cell(Text(child.render(childAvailableWidth, resolvedHeight))) + val renderedChild = child.render(childAvailableWidth, resolvedHeight) + val lines = renderedChild.lines() + + child.x = currentX + child.y = this@Inline.y + this@Inline.padding + (if (this@Inline.borderStyle != BorderStyle.NONE) 1 else 0) + child.widthPx = lines.maxOfOrNull { visibleLength(it) } ?: 0 + child.heightPx = lines.size + + currentX += child.widthPx + cell(Text(renderedChild)) } } } } val content = getMordant().render(t) - return wrapInBox(content, availableWidth, availableHeight) + val wrapped = wrapInBox(content, availableWidth, availableHeight) + val wrappedLines = wrapped.lines() + widthPx = wrappedLines.maxOfOrNull { visibleLength(it) } ?: 0 + heightPx = wrappedLines.size + return wrapped + } + + override fun onEvent(event: BobaEvent): Boolean { + for (child in children) { + if (child.onEvent(event)) return true + } + return super.onEvent(event) + } + + override fun tick(deltaMs: Long) { + children.forEach { it.tick(deltaMs) } } } diff --git a/bobatea/src/commonMain/kotlin/com/github/dylanwatsonsoftware/bobatea/LoadingIndicator.kt b/bobatea/src/commonMain/kotlin/com/github/dylanwatsonsoftware/bobatea/LoadingIndicator.kt index 4a2880d..89b3e72 100644 --- a/bobatea/src/commonMain/kotlin/com/github/dylanwatsonsoftware/bobatea/LoadingIndicator.kt +++ b/bobatea/src/commonMain/kotlin/com/github/dylanwatsonsoftware/bobatea/LoadingIndicator.kt @@ -10,7 +10,8 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch class LoadingIndicator( - private val terminal: Terminal, + val message: String = "", + val style: LoaderStyle = LoaderStyle.DEFAULT, override var padding: Int = 0, override var margin: Int = 0, override var borderStyle: BorderStyle = BorderStyle.NONE, @@ -20,6 +21,9 @@ class LoadingIndicator( override var height: Dimension = Dimension.Auto, override var maxHeight: Dimension = Dimension.Auto ) : BobaComponent(padding, margin, borderStyle, color, width, maxWidth, height, maxHeight) { + private var frameIndex = 0 + private var elapsedMs = 0L + companion object { suspend fun runLoading( message: String = "", @@ -31,46 +35,30 @@ class LoadingIndicator( color: String? = null, callback: suspend () -> R ): R { - return LoadingIndicator(terminal = terminal, padding = padding, margin = margin, borderStyle = borderStyle, color = color) - .runLoading(message, style, callback) - } - } - - override fun render(availableWidth: Int?, availableHeight: Int?): String = - Box("", padding, margin, borderStyle, color, width, maxWidth, height, maxHeight).render(availableWidth, availableHeight) - - suspend fun runLoading(message: String = "", style: LoaderStyle = LoaderStyle.DEFAULT, callback: suspend () -> R): R { - return coroutineScope { - val job = launch { show(message, style) } - val result = callback() - job.cancelAndJoin() - val messageEraser = message.map { " " }.joinToString("") - terminal.write("\r $messageEraser\r") - result + // Deprecated legacy method for backwards compatibility if needed, + // but for now we follow the new declarative style + return callback() } } - suspend fun show(message: String, style: LoaderStyle) { - val charSequence = style.pattern.asInfiniteSequence() + override fun render(availableWidth: Int?, availableHeight: Int?): String { + val pattern = style.pattern.chars + val char = pattern[frameIndex % pattern.size] val pen = createPen(style.color) + val loadingLine = "${pen(char.toString())} $message" + + val output = wrapInBox(loadingLine, availableWidth, availableHeight) + val lines = output.lines() + widthPx = lines.maxOfOrNull { visibleLength(it) } ?: 0 + heightPx = lines.size + return output + } - val (availableWidth, availableHeight) = terminal.size() - for (char in charSequence) { - val loadingLine = "${pen(char.toString())} $message" - val output = if (borderStyle != BorderStyle.NONE || padding > 0 || margin > 0 || - width != Dimension.Auto || maxWidth != Dimension.Auto || height != Dimension.Auto || maxHeight != Dimension.Auto) { - Box(loadingLine, padding, margin, borderStyle, color, width, maxWidth, height, maxHeight).render(availableWidth, availableHeight) - } else { - "\r$loadingLine" - } - - if (borderStyle != BorderStyle.NONE || padding > 0 || margin > 0) { - terminal.clear() - terminal.write(output) - } else { - terminal.write(output) - } - delay(200) + override fun tick(deltaMs: Long) { + elapsedMs += deltaMs + if (elapsedMs >= 200) { + frameIndex++ + elapsedMs = 0 } } } diff --git a/bobatea/src/commonMain/kotlin/com/github/dylanwatsonsoftware/bobatea/MultiSelectionList.kt b/bobatea/src/commonMain/kotlin/com/github/dylanwatsonsoftware/bobatea/MultiSelectionList.kt index 4d1369e..c1f8fcc 100644 --- a/bobatea/src/commonMain/kotlin/com/github/dylanwatsonsoftware/bobatea/MultiSelectionList.kt +++ b/bobatea/src/commonMain/kotlin/com/github/dylanwatsonsoftware/bobatea/MultiSelectionList.kt @@ -15,7 +15,8 @@ class MultiSelectionList( override var width: Dimension = Dimension.Auto, override var maxWidth: Dimension = Dimension.Auto, override var height: Dimension = Dimension.Auto, - override var maxHeight: Dimension = Dimension.Auto + override var maxHeight: Dimension = Dimension.Auto, + val onComplete: (Set) -> Unit = {} ) : BobaComponent(padding, margin, borderStyle, color, width, maxWidth, height, maxHeight) { var currentIndex = 0 val selected = mutableSetOf() @@ -49,60 +50,48 @@ class MultiSelectionList( content.append("Press ${green("SPACE")} to toggle selection\n") content.append("${green("ENTER")} or ${green("Q")} to confirm") - return wrapInBox(content.toString().trimEnd('\n'), availableWidth, availableHeight) + val result = wrapInBox(content.toString().trimEnd('\n'), availableWidth, availableHeight) + val lines = result.lines() + widthPx = lines.maxOfOrNull { visibleLength(it) } ?: 0 + heightPx = lines.size + return result } - suspend fun interact(terminal: Terminal): MutableSet { - val (availableWidth, availableHeight) = terminal.size() - val startLine = margin + (if (borderStyle != BorderStyle.NONE) 1 else 0) + padding + 1 // +1 for the question line - - fun printList() { - terminal.clear() - terminal.write(render(availableWidth, availableHeight) + "\n") - } - - printList() - - terminal.enableMouseTracking() - try { - while (true) { - when (val event = terminal.readEvent()) { - is BobaEvent.Key -> { - when { - KeyCodes.isUp(event.code) -> { - currentIndex = (currentIndex - 1 + options.size) % options.size - printList() - } - KeyCodes.isDown(event.code) -> { - currentIndex = (currentIndex + 1) % options.size - printList() - } - event.code == SPACE.key -> { - toggle(currentIndex) - printList() - } - event.code == ENTER.key || event.code == 'q'.code || event.code == 'Q'.code -> { - currentIndex = -1 - printList() - return selected - } - } + override fun onEvent(event: BobaEvent): Boolean { + when (event) { + is BobaEvent.Key -> { + when { + KeyCodes.isUp(event.code) -> { + currentIndex = (currentIndex - 1 + options.size) % options.size + return true + } + KeyCodes.isDown(event.code) -> { + currentIndex = (currentIndex + 1) % options.size + return true } - is BobaEvent.Mouse -> { - if (event.action == MouseAction.PRESS) { - val clickedIndex = event.y - startLine - if (clickedIndex in options.indices) { - currentIndex = clickedIndex - toggle(currentIndex) - printList() - } - } + event.code == SPACE.key -> { + toggle(currentIndex) + return true + } + event.code == ENTER.key || event.code == 'q'.code || event.code == 'Q'.code -> { + onComplete(selected) + return true + } + } + } + is BobaEvent.Mouse -> { + if (event.action == MouseAction.PRESS) { + val startLine = getContentStartY() + 1 + val clickedIndex = event.y - startLine + if (clickedIndex in options.indices) { + currentIndex = clickedIndex + toggle(currentIndex) + return true } } } - } finally { - terminal.disableMouseTracking() } + return false } private fun toggle(index: Int) { diff --git a/bobatea/src/commonMain/kotlin/com/github/dylanwatsonsoftware/bobatea/SelectionList.kt b/bobatea/src/commonMain/kotlin/com/github/dylanwatsonsoftware/bobatea/SelectionList.kt index 2fa4fce..c9ba2ea 100644 --- a/bobatea/src/commonMain/kotlin/com/github/dylanwatsonsoftware/bobatea/SelectionList.kt +++ b/bobatea/src/commonMain/kotlin/com/github/dylanwatsonsoftware/bobatea/SelectionList.kt @@ -1,7 +1,6 @@ package com.github.dylanwatsonsoftware.bobatea -import com.github.ajalt.mordant.rendering.TextColors.green -import com.github.ajalt.mordant.rendering.TextColors.yellow +import com.github.ajalt.mordant.rendering.TextColors import com.github.dylanwatsonsoftware.bobatea.KeyCodes.ENTER import com.github.dylanwatsonsoftware.bobatea.KeyCodes.SPACE @@ -15,80 +14,70 @@ class SelectionList( override var width: Dimension = Dimension.Auto, override var maxWidth: Dimension = Dimension.Auto, override var height: Dimension = Dimension.Auto, - override var maxHeight: Dimension = Dimension.Auto + override var maxHeight: Dimension = Dimension.Auto, + val onSelect: (String) -> Unit = {} ) : BobaComponent(padding, margin, borderStyle, color, width, maxWidth, height, maxHeight) { var currentIndex = 0 + private val pink = TextColors.rgb("#F179B4") + private val bobaCyan = TextColors.rgb("#00D7FF") + private val gray = TextColors.rgb("#808080") override fun render(availableWidth: Int?, availableHeight: Int?): String { val content = StringBuilder() - content.append(green(question)).append("\n") + content.append(pink(question)).append("\n\n") options.forEachIndexed { index, item -> if (index == currentIndex) { - content.append(yellow("❯ $item")).append("\n") + content.append(bobaCyan("❯ $item")).append("\n") } else { content.append(" $item").append("\n") } } content.append("\n") - content.append("Use ${green("UP/DOWN")} or ${green("W/S")} keys to choose.\n") - content.append("${green("SPACE/ENTER")} or ${green("Q")} to confirm") + content.append(gray("Use UP/DOWN or W/S keys to choose.\n")) + content.append(gray("SPACE/ENTER or Q to confirm")) - return wrapInBox(content.toString().trimEnd('\n'), availableWidth, availableHeight) + val result = wrapInBox(content.toString().trimEnd('\n'), availableWidth, availableHeight) + val lines = result.lines() + widthPx = lines.maxOfOrNull { visibleLength(it) } ?: 0 + heightPx = lines.size + return result } - suspend fun interact(terminal: Terminal): String { - val (availableWidth, availableHeight) = terminal.size() - val startLine = margin + (if (borderStyle != BorderStyle.NONE) 1 else 0) + padding + 1 // +1 for the question line - - fun printList() { - terminal.clear() - terminal.write(render(availableWidth, availableHeight) + "\n") - } - - printList() - - terminal.enableMouseTracking() - try { - while (true) { - when (val event = terminal.readEvent()) { - is BobaEvent.Key -> { - when { - KeyCodes.isUp(event.code) -> { - currentIndex = (currentIndex - 1 + options.size) % options.size - printList() - } - KeyCodes.isDown(event.code) -> { - currentIndex = (currentIndex + 1) % options.size - printList() - } - event.code == ENTER.key || event.code == SPACE.key || event.code == 'q'.code || event.code == 'Q'.code -> { - val selected = options[currentIndex] - currentIndex = -1 - printList() - return selected - } - } + override fun onEvent(event: BobaEvent): Boolean { + when (event) { + is BobaEvent.Key -> { + when { + KeyCodes.isUp(event.code) -> { + currentIndex = (currentIndex - 1 + options.size) % options.size + return true } - is BobaEvent.Mouse -> { - if (event.action == MouseAction.PRESS) { - val clickedIndex = event.y - startLine - if (clickedIndex in options.indices) { - if (clickedIndex == currentIndex) { - val selected = options[currentIndex] - currentIndex = -1 - printList() - return selected - } else { - currentIndex = clickedIndex - printList() - } - } + KeyCodes.isDown(event.code) -> { + currentIndex = (currentIndex + 1) % options.size + return true + } + event.code == ENTER.key || event.code == SPACE.key || event.code == 'q'.code || event.code == 'Q'.code -> { + val selected = options[currentIndex] + onSelect(selected) + return true + } + } + } + is BobaEvent.Mouse -> { + if (event.action == MouseAction.PRESS) { + val startLine = getContentStartY() + 2 // +2 for question and its newline + val clickedIndex = event.y - startLine + if (clickedIndex in options.indices) { + if (clickedIndex == currentIndex) { + val selected = options[currentIndex] + onSelect(selected) + } else { + currentIndex = clickedIndex } + return true } } } - } finally { - terminal.disableMouseTracking() } + return false } } diff --git a/bobatea/src/commonMain/kotlin/com/github/dylanwatsonsoftware/bobatea/Stack.kt b/bobatea/src/commonMain/kotlin/com/github/dylanwatsonsoftware/bobatea/Stack.kt index cf23323..d6c99c6 100644 --- a/bobatea/src/commonMain/kotlin/com/github/dylanwatsonsoftware/bobatea/Stack.kt +++ b/bobatea/src/commonMain/kotlin/com/github/dylanwatsonsoftware/bobatea/Stack.kt @@ -23,22 +23,47 @@ class Stack( val resolvedWidth = BobaComponent.resolveDimension(width, availableWidth) val resolvedHeight = BobaComponent.resolveDimension(height, availableHeight) - val borderSize = if (borderStyle != BorderStyle.NONE) 2 else 0 - val horizontalTotal = padding * 2 + borderSize + val borderSizeVal = if (this.borderStyle != BorderStyle.NONE) 2 else 0 + val horizontalTotal = this.padding * 2 + borderSizeVal val innerAvailableWidth = (resolvedWidth ?: availableWidth)?.let { max(0, it - horizontalTotal) } + var currentY = this.y + this.padding + (if (this.borderStyle != BorderStyle.NONE) 1 else 0) val t = table { borderType = BorderType.BLANK padding(0) body { children.forEach { child -> - row(Text(child.render(innerAvailableWidth, resolvedHeight))) + val renderedChild = child.render(innerAvailableWidth, resolvedHeight) + val lines = renderedChild.lines() + + child.x = this@Stack.x + this@Stack.padding + (if (this@Stack.borderStyle != BorderStyle.NONE) 1 else 0) + child.y = currentY + child.widthPx = lines.maxOfOrNull { visibleLength(it) } ?: 0 + child.heightPx = lines.size + + currentY += child.heightPx + row(Text(renderedChild)) } } } val content = getMordant().render(t) - return wrapInBox(content, availableWidth, availableHeight) + val wrapped = wrapInBox(content, availableWidth, availableHeight) + val wrappedLines = wrapped.lines() + widthPx = wrappedLines.maxOfOrNull { visibleLength(it) } ?: 0 + heightPx = wrappedLines.size + return wrapped + } + + override fun onEvent(event: BobaEvent): Boolean { + for (child in children) { + if (child.onEvent(event)) return true + } + return super.onEvent(event) + } + + override fun tick(deltaMs: Long) { + children.forEach { it.tick(deltaMs) } } } diff --git a/bobatea/src/commonMain/kotlin/com/github/dylanwatsonsoftware/bobatea/Terminal.kt b/bobatea/src/commonMain/kotlin/com/github/dylanwatsonsoftware/bobatea/Terminal.kt index 67a05e0..86bc163 100644 --- a/bobatea/src/commonMain/kotlin/com/github/dylanwatsonsoftware/bobatea/Terminal.kt +++ b/bobatea/src/commonMain/kotlin/com/github/dylanwatsonsoftware/bobatea/Terminal.kt @@ -6,7 +6,7 @@ interface Terminal { val mordant: MordantTerminal fun write(text: String) fun clear() - suspend fun readEvent(): BobaEvent + suspend fun readEvent(timeoutMs: Long = 100): BobaEvent? fun enableMouseTracking(allMotion: Boolean = false) fun disableMouseTracking() fun size(): Pair = 80 to 24 diff --git a/bobatea/src/jvmMain/kotlin/com/github/dylanwatsonsoftware/bobatea/Boba.jvm.kt b/bobatea/src/jvmMain/kotlin/com/github/dylanwatsonsoftware/bobatea/Boba.jvm.kt new file mode 100644 index 0000000..0d756e5 --- /dev/null +++ b/bobatea/src/jvmMain/kotlin/com/github/dylanwatsonsoftware/bobatea/Boba.jvm.kt @@ -0,0 +1,3 @@ +package com.github.dylanwatsonsoftware.bobatea + +internal actual fun currentTimeMillis(): Long = System.currentTimeMillis() diff --git a/bobatea/src/jvmMain/kotlin/com/github/dylanwatsonsoftware/bobatea/Boba.kt b/bobatea/src/jvmMain/kotlin/com/github/dylanwatsonsoftware/bobatea/Boba.kt deleted file mode 100644 index 656d855..0000000 --- a/bobatea/src/jvmMain/kotlin/com/github/dylanwatsonsoftware/bobatea/Boba.kt +++ /dev/null @@ -1,54 +0,0 @@ -package com.github.dylanwatsonsoftware.bobatea - -import kotlinx.coroutines.runBlocking - -class Boba { - companion object { - private val terminal = JvmTerminal() - - fun nonBlockingTerminal(task: () -> T): T = terminal.use { task() } - - fun clear() = terminal.clear() - - fun getChar(): Int = terminal.getChar() - - fun readEvent(): BobaEvent = runBlocking { terminal.readEvent() } - - fun enableMouseTracking(allMotion: Boolean = false) = terminal.enableMouseTracking(allMotion) - - fun disableMouseTracking() = terminal.disableMouseTracking() - - fun selectFromList( - question: String, - options: List, - padding: Int = 0, - margin: Int = 0, - borderStyle: BorderStyle = BorderStyle.NONE, - color: String? = null - ): String = runBlocking { - SelectionList(question, options, padding, margin, borderStyle, color).interact(terminal) - } - - fun expandable( - title: String, - content: String, - padding: Int = 0, - margin: Int = 0, - borderStyle: BorderStyle = BorderStyle.NONE, - color: String? = null - ) = runBlocking { - ExpandableComponent(title, content, padding, margin, borderStyle, color).interact(terminal) - } - - fun selectMultipleFromList( - question: String, - options: List, - padding: Int = 0, - margin: Int = 0, - borderStyle: BorderStyle = BorderStyle.NONE, - color: String? = null - ): MutableSet = runBlocking { - MultiSelectionList(question, options, padding, margin, borderStyle, color).interact(terminal) - } - } -} diff --git a/bobatea/src/jvmMain/kotlin/com/github/dylanwatsonsoftware/bobatea/JvmTerminal.kt b/bobatea/src/jvmMain/kotlin/com/github/dylanwatsonsoftware/bobatea/JvmTerminal.kt index 0356391..a9ae69e 100644 --- a/bobatea/src/jvmMain/kotlin/com/github/dylanwatsonsoftware/bobatea/JvmTerminal.kt +++ b/bobatea/src/jvmMain/kotlin/com/github/dylanwatsonsoftware/bobatea/JvmTerminal.kt @@ -46,8 +46,8 @@ class JvmTerminal( } } - override suspend fun readEvent(): BobaEvent = withContext(Dispatchers.IO) { - readEventBlocking() + override suspend fun readEvent(timeoutMs: Long): BobaEvent? = withContext(Dispatchers.IO) { + readEventWithTimeout(timeoutMs) } override fun enableMouseTracking(allMotion: Boolean) { @@ -79,6 +79,17 @@ class JvmTerminal( } } + private fun readEventWithTimeout(timeoutMs: Long): BobaEvent? { + val startTime = System.currentTimeMillis() + while (System.`in`.available() == 0) { + if (System.currentTimeMillis() - startTime >= timeoutMs) { + return null + } + Thread.sleep(10) + } + return readEventBlocking() + } + private fun readEventBlocking(): BobaEvent { val firstChar = getChar() if (firstChar == 27) { // ESC diff --git a/bobatea/src/jvmTest/kotlin/com/github/dylanwatsonsoftware/bobatea/BobaTest.kt b/bobatea/src/jvmTest/kotlin/com/github/dylanwatsonsoftware/bobatea/BobaTest.kt index f99f4d2..63a86ef 100644 --- a/bobatea/src/jvmTest/kotlin/com/github/dylanwatsonsoftware/bobatea/BobaTest.kt +++ b/bobatea/src/jvmTest/kotlin/com/github/dylanwatsonsoftware/bobatea/BobaTest.kt @@ -1,6 +1,5 @@ package com.github.dylanwatsonsoftware.bobatea -import com.github.dylanwatsonsoftware.bobatea.Boba.Companion.nonBlockingTerminal import kotlinx.coroutines.runBlocking import kotlin.test.Test import kotlin.test.assertEquals @@ -16,9 +15,5 @@ class BobaTest { } assertEquals(Boba::class.simpleName, "Boba") - - nonBlockingTerminal { - println("Can print non-blocking") - } } } diff --git a/bobatea/src/wasmJsMain/kotlin/com/github/dylanwatsonsoftware/bobatea/Boba.wasm.kt b/bobatea/src/wasmJsMain/kotlin/com/github/dylanwatsonsoftware/bobatea/Boba.wasm.kt new file mode 100644 index 0000000..b38676c --- /dev/null +++ b/bobatea/src/wasmJsMain/kotlin/com/github/dylanwatsonsoftware/bobatea/Boba.wasm.kt @@ -0,0 +1,6 @@ +package com.github.dylanwatsonsoftware.bobatea + +@JsFun("() => { return Date.now(); }") +private external fun jsDateNow(): Double + +internal actual fun currentTimeMillis(): Long = jsDateNow().toLong() diff --git a/bobatea/src/wasmJsMain/kotlin/com/github/dylanwatsonsoftware/bobatea/WasmTerminal.kt b/bobatea/src/wasmJsMain/kotlin/com/github/dylanwatsonsoftware/bobatea/WasmTerminal.kt index 0574f8a..d6f5a21 100644 --- a/bobatea/src/wasmJsMain/kotlin/com/github/dylanwatsonsoftware/bobatea/WasmTerminal.kt +++ b/bobatea/src/wasmJsMain/kotlin/com/github/dylanwatsonsoftware/bobatea/WasmTerminal.kt @@ -7,6 +7,7 @@ import com.github.ajalt.mordant.terminal.Terminal as MordantTerminal import com.github.ajalt.mordant.terminal.TerminalInfo import com.github.ajalt.mordant.terminal.TerminalInterface import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.withTimeoutOrNull private val _inputChannel = Channel(Channel.UNLIMITED) @@ -66,9 +67,11 @@ class WasmTerminal( } } - override suspend fun readEvent(): BobaEvent { - val data = _inputChannel.receive() - return parseInput(data) + override suspend fun readEvent(timeoutMs: Long): BobaEvent? { + val data = withTimeoutOrNull(timeoutMs) { + _inputChannel.receive() + } + return data?.let { parseInput(it) } } override fun enableMouseTracking(allMotion: Boolean) { diff --git a/sample/src/main/kotlin/com/example/App.kt b/sample/src/main/kotlin/com/example/App.kt index 837f816..db23c8b 100644 --- a/sample/src/main/kotlin/com/example/App.kt +++ b/sample/src/main/kotlin/com/example/App.kt @@ -4,31 +4,35 @@ import com.github.dylanwatsonsoftware.bobatea.* import kotlinx.coroutines.delay import kotlinx.coroutines.runBlocking +enum class Screen { + MAIN, LAYOUT, NESTED, MORDANT, EXPANDABLE, COORDINATES, SINGLE_SELECT, MULTI_SELECT, EXIT +} + class App { companion object { + var currentScreen = Screen.MAIN + var lastSelection = "" + var lastMultiSelection = setOf() + @JvmStatic fun main(args: Array) { val terminal = JvmTerminal() terminal.setup() try { runBlocking { - // Initial Welcome & Loading - LoadingIndicator.runLoading("Initializing Boba Tea...", LoaderStyle.SMALL_GREEN, terminal) { - delay(1500) - } - terminal.clear() - terminal.write(Box("Welcome to Boba Tea!", borderStyle = BorderStyle.ROUNDED, color = ConsoleColors.CYAN, padding = 1).render() + "\n") - delay(1500) - - mainMenu(terminal) + val root = AppRoot() + Boba.run(terminal, root) } } finally { terminal.teardown() } } + } - private suspend fun mainMenu(terminal: Terminal) { - val options = listOf( + class AppRoot : BobaComponent() { + private val mainMenu = SelectionList( + question = "Boba Tea Main Menu - Choose a screen:", + options = listOf( "Layout Demo", "Nested Layout Demo", "Mordant Components (MD, Tables, Links)", @@ -37,204 +41,155 @@ class App { "Single Selection List", "Multi-Selection List", "Exit" - ) - - while (true) { - terminal.clear() - val (width, height) = terminal.size() - - val selection = SelectionList( - question = "Boba Tea Main Menu - Choose a screen:", - options = options, - borderStyle = BorderStyle.SINGLE, - padding = 1, - color = ConsoleColors.CYAN, - width = Dimension.Fixed(minOf(width, 50)) - ).interact(terminal) - - when (selection) { - "Layout Demo" -> layoutDemo(terminal) - "Nested Layout Demo" -> nestedLayoutDemo(terminal) - "Mordant Components (MD, Tables, Links)" -> mordantDemo(terminal) - "Expandable Section" -> expandableDemo(terminal) - "Coordinates Exploration" -> coordinates(terminal) - "Single Selection List" -> singleSelectDemo(terminal) - "Multi-Selection List" -> multiSelectDemo(terminal) - "Exit" -> return + ), + borderStyle = BorderStyle.ROUNDED, + padding = 1, + color = ConsoleColors.BOBA_PINK, + onSelect = { selection -> + App.currentScreen = when (selection) { + "Layout Demo" -> Screen.LAYOUT + "Nested Layout Demo" -> Screen.NESTED + "Mordant Components (MD, Tables, Links)" -> Screen.MORDANT + "Expandable Section" -> Screen.EXPANDABLE + "Coordinates Exploration" -> Screen.COORDINATES + "Single Selection List" -> Screen.SINGLE_SELECT + "Multi-Selection List" -> Screen.MULTI_SELECT + "Exit" -> Screen.EXIT + else -> Screen.MAIN } } - } + ) - private suspend fun renderWithBack(terminal: Terminal, component: BobaComponent) { - val (width, height) = terminal.size() - val back = BackButton(margin = 1) + private val backButton = BackButton(margin = 1, onClicked = { App.currentScreen = Screen.MAIN }) + private val coordinatesDemo = CoordinatesDemo() + private val expandableLayout = buildExpandableLayout() - fun renderAll() { - terminal.clear() - terminal.write(component.render(width, height) + "\n") - terminal.write(back.render(width, height) + "\n") + override fun render(availableWidth: Int?, availableHeight: Int?): String { + if (App.currentScreen == Screen.EXIT) { + kotlin.system.exitProcess(0) } - renderAll() - terminal.enableMouseTracking(allMotion = true) - try { - while (true) { - val event = terminal.readEvent() - if (event is BobaEvent.Key && (event.code == 'q'.code || event.code == 'Q'.code)) return - - if (event is BobaEvent.Mouse) { - val compLines = component.render(width, height).lines().size - val backY = compLines + 2 - if (back.isClicked(event, 2, backY)) return - } - } - } finally { - terminal.disableMouseTracking() + val content = when (App.currentScreen) { + Screen.MAIN -> mainMenu + Screen.LAYOUT -> Stack(listOf(buildDemoLayout(availableWidth ?: 80), backButton)) + Screen.NESTED -> Stack(listOf(buildNestedLayout(availableWidth ?: 80), backButton)) + Screen.MORDANT -> Stack(listOf(buildMordantLayout(availableWidth ?: 80), backButton)) + Screen.EXPANDABLE -> Stack(listOf(expandableLayout, backButton)) + Screen.COORDINATES -> Stack(listOf(coordinatesDemo, backButton)) + Screen.SINGLE_SELECT -> Stack(listOf( + SelectionList("Single Selection Demo:", listOf("Option A", "Option B", "Option C"), + onSelect = { App.lastSelection = it; App.currentScreen = Screen.MAIN }), + backButton + )) + Screen.MULTI_SELECT -> Stack(listOf( + MultiSelectionList("Multi Selection Demo:", listOf("Red", "Green", "Blue", "Cyan", "Yellow"), + onComplete = { App.lastMultiSelection = it; App.currentScreen = Screen.MAIN }), + backButton + )) + else -> mainMenu } - } - private suspend fun nestedLayoutDemo(terminal: Terminal) { - val (width, height) = terminal.size() - - val box1 = Box("Box 1.1\nCol 1", borderStyle = BorderStyle.SINGLE, width = Dimension.Fixed(15)) - val box2 = Box("Box 1.2\nCol 2", borderStyle = BorderStyle.DOUBLE, width = Dimension.Fixed(15)) - val box3 = Box("Box 1.3\nCol 3", borderStyle = BorderStyle.ROUNDED, width = Dimension.Fixed(15)) - - val inline1 = Inline( - children = listOf(box1, box2, box3), - padding = 1, - borderStyle = BorderStyle.SINGLE, - color = ConsoleColors.CYAN - ) - - val box4 = Box("Box 2.1\nCol A", borderStyle = BorderStyle.SINGLE, width = Dimension.Fixed(15)) - val box5 = Box("Box 2.2\nCol B", borderStyle = BorderStyle.DOUBLE, width = Dimension.Fixed(15)) - val box6 = Box("Box 2.3\nCol C", borderStyle = BorderStyle.ROUNDED, width = Dimension.Fixed(15)) - - val inline2 = Inline( - children = listOf(box4, box5, box6), - padding = 1, - borderStyle = BorderStyle.SINGLE, - color = ConsoleColors.YELLOW - ) - - val root = Stack( - children = listOf( - Box("Complex Nested Layout Demo\n(Stack of Inlines of Boxes)", padding = 1, color = ConsoleColors.GREEN), - inline1, - inline2 - ), - width = Dimension.Fixed(minOf(width, 55)), - borderStyle = BorderStyle.DOUBLE - ) - - renderWithBack(terminal, root) + val result = content.render(availableWidth, availableHeight) + widthPx = content.widthPx + heightPx = content.heightPx + return result } - private suspend fun layoutDemo(terminal: Terminal) { - val (width, height) = terminal.size() - val root = buildDemoLayout(width) - renderWithBack(terminal, root) + override fun onEvent(event: BobaEvent): Boolean { + return when (App.currentScreen) { + Screen.MAIN -> mainMenu.onEvent(event) + Screen.LAYOUT -> backButton.onEvent(event) + Screen.NESTED -> backButton.onEvent(event) + Screen.MORDANT -> backButton.onEvent(event) + Screen.EXPANDABLE -> expandableLayout.onEvent(event) || backButton.onEvent(event) + Screen.COORDINATES -> coordinatesDemo.onEvent(event) || backButton.onEvent(event) + Screen.SINGLE_SELECT -> backButton.onEvent(event) + Screen.MULTI_SELECT -> backButton.onEvent(event) + else -> false + } } + } +} - private suspend fun mordantDemo(terminal: Terminal) { - val (width, height) = terminal.size() - - val md = Markdown("# Mordant Components\nThis screen showcases components powered by Mordant.") - val link = Link("Visit Mordant GitHub", "https://github.com/ajalt/mordant") +fun buildNestedLayout(width: Int): BobaComponent { + val box1 = Box("Box 1.1\nCol 1", borderStyle = BorderStyle.SINGLE, width = Dimension.Fixed(15)) + val box2 = Box("Box 1.2\nCol 2", borderStyle = BorderStyle.DOUBLE, width = Dimension.Fixed(15)) + val box3 = Box("Box 1.3\nCol 3", borderStyle = BorderStyle.ROUNDED, width = Dimension.Fixed(15)) + + val inline1 = Inline( + children = listOf(box1, box2, box3), + padding = 1, + borderStyle = BorderStyle.SINGLE, + color = ConsoleColors.BOBA_CYAN + ) + + return Stack( + children = listOf( + Box("Complex Nested Layout Demo", padding = 1, color = ConsoleColors.BOBA_GREEN), + inline1 + ), + width = Dimension.Fixed(minOf(width, 55)), + borderStyle = BorderStyle.DOUBLE + ) +} - val stack = Stack( - children = listOf(md, link), - borderStyle = BorderStyle.DOUBLE, - padding = 1, - color = ConsoleColors.CYAN, - width = Dimension.Fixed(minOf(width, 60)) - ) +fun buildMordantLayout(width: Int): BobaComponent { + val md = Markdown("# Mordant Components\nThis screen showcases components powered by Mordant.") + val link = Link("Visit Mordant GitHub", "https://github.com/ajalt/mordant") + + return Stack( + children = listOf(md, link), + borderStyle = BorderStyle.DOUBLE, + padding = 1, + color = ConsoleColors.BOBA_CYAN, + width = Dimension.Fixed(minOf(width, 60)) + ) +} - renderWithBack(terminal, stack) - } +fun buildExpandableLayout(): BobaComponent { + return ExpandableComponent( + title = "Expandable Demo (Click or Space/Enter)", + content = Box( + "You expanded the section!\nThis is a box inside an expandable component.", + borderStyle = BorderStyle.DOUBLE, + color = ConsoleColors.BOBA_GREEN + ).render(), + padding = 1, + borderStyle = BorderStyle.ROUNDED, + color = ConsoleColors.BOBA_PURPLE + ) +} - private suspend fun expandableDemo(terminal: Terminal) { - ExpandableComponent( - title = "Expandable Demo (Click or Space/Enter)", - content = Box( - "You expanded the section!\nThis is a box inside an expandable component.", - borderStyle = BorderStyle.DOUBLE, - color = ConsoleColors.GREEN - ).render(), - padding = 1, - borderStyle = BorderStyle.ROUNDED, - color = ConsoleColors.BLUE - ).interact(terminal) - } +class CoordinatesDemo : BobaComponent() { + var xPos = 0 + var yPos = 0 - private suspend fun singleSelectDemo(terminal: Terminal) { - val selection = SelectionList( - question = "Single Selection Demo:", - options = listOf("Option A", "Option B", "Option C", "Go Back"), - padding = 1, - borderStyle = BorderStyle.SINGLE, - color = ConsoleColors.YELLOW - ).interact(terminal) - if (selection != "Go Back") { - terminal.write("You selected: $selection\n") - delay(1000) + override fun render(availableWidth: Int?, availableHeight: Int?): String { + val sb = StringBuilder() + sb.append(com.github.ajalt.mordant.rendering.TextColors.rgb("#A7FF00")("$xPos, $yPos\n")) + for (row in 0 until 10) { + for (col in 0 until 10) { + if (row == yPos && col == xPos) sb.append(com.github.ajalt.mordant.rendering.TextColors.rgb("#F179B4")(" X ")) else sb.append(" . ") } + sb.append("\n") } + val output = sb.toString() + val lines = output.lines() + widthPx = lines.maxOfOrNull { visibleLength(it) } ?: 0 + heightPx = lines.size + return output + } - private suspend fun multiSelectDemo(terminal: Terminal) { - val multiSelections = MultiSelectionList( - question = "Multi Selection Demo (Space to toggle, Enter to confirm):", - options = listOf("Red", "Green", "Blue", "Cyan", "Yellow"), - padding = 1, - borderStyle = BorderStyle.DOUBLE, - color = ConsoleColors.PURPLE - ).interact(terminal) - terminal.write("You selected: $multiSelections\n") - delay(1500) - } - - private suspend fun coordinates(terminal: Terminal) { - fun printGridWithHighlight(rows: Int, cols: Int, highlightRow: Int, highlightCol: Int): String { - val sb = StringBuilder() - for (row in 0 until rows) { - for (col in 0 until cols) { - if (row == highlightRow && col == highlightCol) { - sb.append(" X ") - } else { - sb.append(" ") - } - } - sb.append("\n") - } - return sb.toString() - } - - fun renderPos(x: Int, y: Int) { - terminal.clear() - val (width, height) = terminal.size() - terminal.write("$x, $y\n") - terminal.write(printGridWithHighlight(10, 10, y, x)) - terminal.write("Use ${ConsoleColors.color("UP/DOWN/LEFT/RIGHT", ConsoleColors.GREEN)} or ${ConsoleColors.color("WASD", ConsoleColors.GREEN)} to move\n") - terminal.write("${ConsoleColors.color("SPACE/ENTER", ConsoleColors.GREEN)} to confirm\n") - } - - var x = 0 - var y = 0 - - renderPos(x, y) - - while (true) { - val event = terminal.readEvent() - if (event is BobaEvent.Key) { - if (KeyCodes.isDown(event.code)) renderPos(x, ++y) - else if (KeyCodes.isUp(event.code)) renderPos(x, --y) - else if (KeyCodes.isLeft(event.code)) renderPos(--x, y) - else if (KeyCodes.isRight(event.code)) renderPos(++x, y) - else if (event.code == 'q'.code || event.code == 'Q'.code) return - else if (event.code == KeyCodes.ENTER.key || event.code == KeyCodes.SPACE.key) return - } + override fun onEvent(event: BobaEvent): Boolean { + if (event is BobaEvent.Key) { + when { + KeyCodes.isDown(event.code) -> yPos++ + KeyCodes.isUp(event.code) -> yPos-- + KeyCodes.isLeft(event.code) -> xPos-- + KeyCodes.isRight(event.code) -> xPos++ } + return true } + return false } } diff --git a/web/src/wasmJsMain/kotlin/Main.kt b/web/src/wasmJsMain/kotlin/Main.kt index 793333f..ce711f5 100644 --- a/web/src/wasmJsMain/kotlin/Main.kt +++ b/web/src/wasmJsMain/kotlin/Main.kt @@ -1,222 +1,162 @@ -import com.github.dylanwatsonsoftware.bobatea.BorderStyle -import com.github.dylanwatsonsoftware.bobatea.BobaEvent -import com.github.dylanwatsonsoftware.bobatea.Box -import com.github.dylanwatsonsoftware.bobatea.ConsoleColors.Companion.BLUE +import com.github.dylanwatsonsoftware.bobatea.* import com.github.dylanwatsonsoftware.bobatea.ConsoleColors.Companion.CYAN import com.github.dylanwatsonsoftware.bobatea.ConsoleColors.Companion.GREEN import com.github.dylanwatsonsoftware.bobatea.ConsoleColors.Companion.YELLOW import com.github.dylanwatsonsoftware.bobatea.ConsoleColors.Companion.color -import com.github.dylanwatsonsoftware.bobatea.ExpandableComponent -import com.github.dylanwatsonsoftware.bobatea.KeyCodes -import com.github.dylanwatsonsoftware.bobatea.LoaderStyle -import com.github.dylanwatsonsoftware.bobatea.LoadingIndicator -import com.github.dylanwatsonsoftware.bobatea.* -import com.github.dylanwatsonsoftware.bobatea.MultiSelectionList -import com.github.dylanwatsonsoftware.bobatea.SelectionList -import com.github.dylanwatsonsoftware.bobatea.WasmTerminal import kotlinx.coroutines.MainScope import kotlinx.coroutines.delay import kotlinx.coroutines.launch +enum class WasmScreen { + WELCOME, MENU, LAYOUT, MORDANT, EXPANDABLE, COORDINATES, SELECTION, MULTI, RESET +} + +var currentWasmScreen = WasmScreen.WELCOME + fun main() { MainScope().launch { val terminal = WasmTerminal() - - // 1. Welcome & Initial Loading - terminal.write(Box("Welcome to Boba Tea WASM Demo!", borderStyle = BorderStyle.ROUNDED, color = CYAN, padding = 1).render() + "\n") - delay(1500) - - LoadingIndicator.runLoading("Booting TUI Engine...", LoaderStyle.SMALL_GREEN, terminal) { - delay(2000) - } - terminal.write("\n") - - mainMenu(terminal) + val root = WasmAppRoot() + Boba.run(terminal, root) } } -private suspend fun mainMenu(terminal: Terminal) { - val options = listOf( - "Layout Showcase", - "Mordant (Markdown & Links)", - "Interactive Expandable", - "Coordinate Explorer", - "Selection Lists", - "Multi-Selection", - "Reset Demo" +class WasmAppRoot : BobaComponent() { + private var welcomeElapsed = 0L + private val loadingIndicator = LoadingIndicator("Booting TUI Engine...", LoaderStyle.SMALL_GREEN) + private val mainMenu = SelectionList( + question = "WASM Interactive Menu - Choose an example:", + options = listOf( + "Layout Showcase", + "Mordant (Markdown & Links)", + "Interactive Expandable", + "Coordinate Explorer", + "Selection Lists", + "Multi-Selection", + "Reset Demo" + ), + borderStyle = BorderStyle.SINGLE, + padding = 1, + color = CYAN, + onSelect = { selection -> + currentWasmScreen = when (selection) { + "Layout Showcase" -> WasmScreen.LAYOUT + "Mordant (Markdown & Links)" -> WasmScreen.MORDANT + "Interactive Expandable" -> WasmScreen.EXPANDABLE + "Coordinate Explorer" -> WasmScreen.COORDINATES + "Selection Lists" -> WasmScreen.SELECTION + "Multi-Selection" -> WasmScreen.MULTI + "Reset Demo" -> WasmScreen.RESET + else -> WasmScreen.MENU + } + } ) - - while (true) { - terminal.clear() - val (width, height) = terminal.size() - - val selection = SelectionList( - question = "WASM Interactive Menu - Choose an example:", - options = options, - borderStyle = BorderStyle.SINGLE, - padding = 1, - color = CYAN, - width = Dimension.Fixed(minOf(width, 50)) - ).interact(terminal) - - when (selection) { - "Layout Showcase" -> layoutShowcase(terminal) - "Mordant (Markdown & Links)" -> mordantShowcase(terminal) - "Interactive Expandable" -> expandableShowcase(terminal) - "Coordinate Explorer" -> coordinatesShowcase(terminal) - "Selection Lists" -> selectionShowcase(terminal) - "Multi-Selection" -> multiSelectionShowcase(terminal) - "Reset Demo" -> return + private val backButton = BackButton(margin = 1, onClicked = { currentWasmScreen = WasmScreen.MENU }) + private val coords = WasmCoords() + + override fun render(availableWidth: Int?, availableHeight: Int?): String { + val content = when (currentWasmScreen) { + WasmScreen.WELCOME -> Stack(listOf( + Box("Welcome to Boba Tea WASM Demo!", borderStyle = BorderStyle.ROUNDED, color = CYAN, padding = 1), + loadingIndicator + )) + WasmScreen.MENU -> mainMenu + WasmScreen.LAYOUT -> Stack(listOf(buildWasmLayout(), backButton)) + WasmScreen.MORDANT -> Stack(listOf(buildWasmMordant(), backButton)) + WasmScreen.EXPANDABLE -> Stack(listOf(buildWasmExpandable(), backButton)) + WasmScreen.COORDINATES -> Stack(listOf(coords, backButton)) + WasmScreen.SELECTION -> Stack(listOf( + SelectionList("What's your favourite Boba?", listOf("Milk Tea", "Matcha", "Taro", "Brown Sugar"), onSelect = { currentWasmScreen = WasmScreen.MENU }), + backButton + )) + WasmScreen.MULTI -> Stack(listOf( + MultiSelectionList("Toppings:", listOf("Pearls", "Grass Jelly", "Pudding", "Aloe Vera", "Red Bean"), onComplete = { currentWasmScreen = WasmScreen.MENU }), + backButton + )) + WasmScreen.RESET -> { currentWasmScreen = WasmScreen.WELCOME; welcomeElapsed = 0; mainMenu } } + val output = content.render(availableWidth, availableHeight) + widthPx = content.widthPx + heightPx = content.heightPx + return output } -} - -private suspend fun renderWithBack(terminal: Terminal, component: BobaComponent) { - val (width, height) = terminal.size() - val back = BackButton(margin = 1) - fun renderAll() { - terminal.clear() - terminal.write(component.render(width, height) + "\n") - // BackButton will have 1 newline before (from renderWithBack above) - // and its own margin (1 newline before) - terminal.write(back.render(width, height) + "\n") + override fun tick(deltaMs: Long) { + if (currentWasmScreen == WasmScreen.WELCOME) { + welcomeElapsed += deltaMs + loadingIndicator.tick(deltaMs) + if (welcomeElapsed > 3500) { + currentWasmScreen = WasmScreen.MENU + } + } } - renderAll() - terminal.enableMouseTracking(allMotion = true) - try { - while (true) { - val event = terminal.readEvent() - if (event is BobaEvent.Key && (event.code == 'q'.code || event.code == 'Q'.code)) return - - if (event is BobaEvent.Mouse) { - // Calculate position based on the rendered layout: - // component.render() height + 1 (newline) + back.margin (1) + 1 (for 1-based coord) - val compLines = component.render(width, height).lines().size - val backY = compLines + 2 - if (back.isClicked(event, 2, backY)) return - } + override fun onEvent(event: BobaEvent): Boolean { + return when (currentWasmScreen) { + WasmScreen.MENU -> mainMenu.onEvent(event) + WasmScreen.LAYOUT -> backButton.onEvent(event) + WasmScreen.MORDANT -> backButton.onEvent(event) + WasmScreen.EXPANDABLE -> buildWasmExpandable().onEvent(event) || backButton.onEvent(event) + WasmScreen.COORDINATES -> coords.onEvent(event) || backButton.onEvent(event) + WasmScreen.SELECTION -> backButton.onEvent(event) + WasmScreen.MULTI -> backButton.onEvent(event) + else -> false } - } finally { - terminal.disableMouseTracking() } } -private suspend fun layoutShowcase(terminal: Terminal) { +fun buildWasmLayout(): BobaComponent { val box1 = Box("Box 1.1\nCol 1", borderStyle = BorderStyle.SINGLE, width = Dimension.Fixed(15)) val box2 = Box("Box 1.2\nCol 2", borderStyle = BorderStyle.DOUBLE, width = Dimension.Fixed(15)) - val box3 = Box("Box 1.3\nCol 3", borderStyle = BorderStyle.ROUNDED, width = Dimension.Fixed(15)) - - val inline1 = Inline(children = listOf(box1, box2, box3), padding = 1, borderStyle = BorderStyle.SINGLE, color = CYAN) - - val root = Stack( - children = listOf( - Box("Complex Nested Layout Demo", padding = 1, color = GREEN), - inline1 - ), - width = Dimension.Fixed(55), - borderStyle = BorderStyle.DOUBLE - ) - - renderWithBack(terminal, root) + val inline1 = Inline(listOf(box1, box2), padding = 1, borderStyle = BorderStyle.SINGLE, color = CYAN) + return Stack(listOf(Box("Complex Nested Layout Demo", padding = 1, color = GREEN), inline1), width = Dimension.Fixed(55), borderStyle = BorderStyle.DOUBLE) } -private suspend fun mordantShowcase(terminal: Terminal) { +fun buildWasmMordant(): BobaComponent { val md = Markdown("# Mordant Integration\nBoba Tea now uses **Mordant** for rendering.\n- Rich Markdown support\n- OSC 8 Hyperlinks\n- Flexible Tables") val link = Link("Visit Mordant GitHub", "https://github.com/ajalt/mordant") - val tbl = Table(listOf("Component", "Status"), listOf(listOf("Markdown", "✅"), listOf("Table", "✅"), listOf("Link", "✅"))) - - val stack = Stack( - children = listOf(md, link, tbl), - borderStyle = BorderStyle.ROUNDED, - padding = 1, - color = CYAN, - width = Dimension.Fixed(55) - ) - - renderWithBack(terminal, stack) + return Stack(listOf(md, link), borderStyle = BorderStyle.ROUNDED, padding = 1, color = CYAN, width = Dimension.Fixed(55)) } -private suspend fun expandableShowcase(terminal: Terminal) { - ExpandableComponent( +fun buildWasmExpandable(): BobaComponent { + return ExpandableComponent( title = "Click to reveal secret", content = Box("You found it!\nMordant makes this look great.", borderStyle = BorderStyle.DOUBLE, color = GREEN).render(), padding = 1, borderStyle = BorderStyle.SINGLE, - color = BLUE - ).interact(terminal) -} - -private suspend fun selectionShowcase(terminal: Terminal) { - val selection = SelectionList( - question = "What's your favourite Boba?", - options = listOf("Milk Tea", "Matcha", "Taro", "Brown Sugar", "Go Back"), - ).interact(terminal) - if (selection != "Go Back") { - terminal.write("Excellent: $selection!\n") - delay(1000) - } -} - -private suspend fun multiSelectionShowcase(terminal: Terminal) { - val multiSelections = MultiSelectionList( - question = "Toppings (Space to toggle, Enter to confirm):", - options = listOf("Pearls", "Grass Jelly", "Pudding", "Aloe Vera", "Red Bean"), - ).interact(terminal) - terminal.write("You selected: ${multiSelections.joinToString(", ")}\n") - delay(1500) -} - -private suspend fun coordinatesShowcase(terminal: Terminal) { - coordinates(terminal as WasmTerminal) + color = com.github.dylanwatsonsoftware.bobatea.ConsoleColors.BLUE + ) } -private suspend fun coordinates(terminal: WasmTerminal) { - fun printGridWithHighlight(rows: Int, cols: Int, highlightRow: Int, highlightCol: Int): String { +class WasmCoords : BobaComponent() { + var xPos = 0 + var yPos = 0 + override fun render(availableWidth: Int?, availableHeight: Int?): String { val sb = StringBuilder() - for (row in 0 until rows) { - for (col in 0 until cols) { - if (row == highlightRow && col == highlightCol) { - sb.append(" X ") - } else { - sb.append(" . ") - } + sb.append("${color("Step 3: Custom Navigation", YELLOW)}\n") + sb.append("Position: $xPos, $yPos\n") + for (row in 0 until 8) { + for (col in 0 until 8) { + if (row == yPos && col == xPos) sb.append(" X ") else sb.append(" . ") } sb.append("\n") } - return sb.toString() + val output = sb.toString() + val lines = output.lines() + widthPx = lines.maxOfOrNull { visibleLength(it) } ?: 0 + heightPx = lines.size + return output } - - fun render(x: Int, y: Int) { - terminal.clear() - terminal.write("${color("Step 3: Custom Navigation", YELLOW)}\n") - terminal.write("Position: $x, $y\n") - terminal.write(printGridWithHighlight(8, 8, y, x)) - terminal.write("Use ${color("UP/DOWN/LEFT/RIGHT", GREEN)} or ${color("WASD", GREEN)} to move\n") - terminal.write("${color("SPACE/ENTER", GREEN)} to confirm\n") - } - - var x = 0 - var y = 0 - - render(x, y) - - while (true) { - val event = terminal.readEvent() + override fun onEvent(event: BobaEvent): Boolean { if (event is BobaEvent.Key) { when { - KeyCodes.isDown(event.code) -> if (y < 7) render(x, ++y) - KeyCodes.isUp(event.code) -> if (y > 0) render(x, --y) - KeyCodes.isLeft(event.code) -> if (x > 0) render(--x, y) - KeyCodes.isRight(event.code) -> if (x < 7) render(++x, y) - event.code == KeyCodes.ENTER.key || event.code == KeyCodes.SPACE.key -> { - terminal.clear() - terminal.write("${color("Step 3: Custom Navigation", YELLOW)}\n") - terminal.write("Confirmed position: $x, $y\n") - return - } + KeyCodes.isDown(event.code) -> if (yPos < 7) yPos++ + KeyCodes.isUp(event.code) -> if (yPos > 0) yPos-- + KeyCodes.isLeft(event.code) -> if (xPos > 0) xPos-- + KeyCodes.isRight(event.code) -> if (xPos < 7) xPos++ } + return true } + return false } } diff --git a/web/src/wasmJsMain/resources/index.html b/web/src/wasmJsMain/resources/index.html index 9abae20..69878d7 100644 --- a/web/src/wasmJsMain/resources/index.html +++ b/web/src/wasmJsMain/resources/index.html @@ -52,6 +52,11 @@ gap: 0.5rem; flex-wrap: wrap; margin-bottom: 1rem; + align-items: center; + } + + .badges a img, .badges img { + height: 20px; } h1, h2, h3 {