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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +21 to +25

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

The back-button hitbox is still a fixed one-row, 16-cell box.

render() goes through wrapInBox(...), but onEvent assumes the label always occupies one row and exactly 16 cells. That can miss clicks on narrow layouts and on terminals where renders wider than one cell. Reuse the label's measured bounds instead of hard-coding them.

Also applies to: 33-37

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@bobatea/src/commonMain/kotlin/com/github/dylanwatsonsoftware/bobatea/BackButton.kt`
around lines 21 - 25, The hitbox is hard-coded to one row and 16 cells; change
onEvent to use the measured bounds produced by render/wrapInBox instead of fixed
values: use the computed widthPx and heightPx (and any visibleLength logic)
returned after calling wrapInBox(styled, availableWidth, availableHeight) to
determine the button rectangle and test the incoming click/touch coordinates
against that rectangle; update both occurrences (the onEvent handling around the
first render and the second occurrence at lines 33-37) so they check x in [x0,
x0+widthPx) and y in [y0, y0+heightPx) rather than assuming height=1 and
width=16. Ensure you reference render(), wrapInBox(...), onEvent, widthPx and
heightPx when making the change.

}

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
}
}
Original file line number Diff line number Diff line change
@@ -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()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
fd 'Boba\.(jvm|wasm)\.kt$' bobatea/src | while read -r f; do
  echo "== $f =="
  sed -n '1,120p' "$f"
done
rg -n 'currentTimeMillis\(' bobatea/src

Repository: dylanwatsonsoftware/bobatea

Length of output: 1544


Use monotonic time for frame-delta calculations instead of wall-clock time.

Both actual implementations use wall-clock APIs—JVM uses System.currentTimeMillis() and WASM uses Date.now()—which can jump backward after system clock adjustments. This causes frame deltas to become negative or unexpectedly large. Use System.nanoTime() on JVM and performance.now() on WASM instead.

Affected: lines 10, 28-30, 42 in Boba.kt

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@bobatea/src/commonMain/kotlin/com/github/dylanwatsonsoftware/bobatea/Boba.kt`
at line 10, The frame-timing uses wall-clock time (lastTick =
currentTimeMillis()) which can jump; update both platform-specific
implementations in Boba.kt to use monotonic clocks: on JVM replace
System.currentTimeMillis()/currentTimeMillis() with System.nanoTime() and on
WASM replace Date.now() with performance.now(); keep lastTick and delta
calculations consistent by storing the same unit on both sides (e.g., use
nanoseconds everywhere or convert nano→ms when computing delta) and update any
delta math that uses lastTick so it no longer produces negative/large values
after clock changes.


// 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)
Comment on lines +23 to +33

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

The idle loop is capped at roughly 16 FPS.

readEvent(50) and then delay(10) make no-input frames take at least ~60 ms, so tick-driven animations will only update around 16 times per second. Fold both waits into a single frame budget, or make the extra delay conditional on how long event polling already blocked.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@bobatea/src/commonMain/kotlin/com/github/dylanwatsonsoftware/bobatea/Boba.kt`
around lines 23 - 33, The idle loop currently uses terminal.readEvent(50) plus
delay(10) which forces no-input frames to ~60ms; instead consolidate to a single
per-frame budget: introduce a targetFrameMs (e.g. 16L) and measure elapsed time
at frame start, call terminal.readEvent(remainingTimeout) where remainingTimeout
= max(0, targetFrameMs - elapsedSoFar), then call root.onEvent(event) and
root.tick(now - lastTick) and only call delay(remainingDelay) if you still have
positive remaining time after event polling and ticking; update uses of
terminal.readEvent, root.tick, lastTick and delay to implement this single-frame
budget behavior.

}
} finally {
terminal.disableMouseTracking()
}
}
}
}

internal expect fun currentTimeMillis(): Long
Original file line number Diff line number Diff line change
Expand Up @@ -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")

Expand Down Expand Up @@ -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
}
Expand All @@ -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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Comment on lines +55 to +59

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Gate SPACE/ENTER behind focus or parent routing.

Line 56 toggles on every SPACE/ENTER event. In bobatea/src/commonMain/kotlin/com/github/dylanwatsonsoftware/bobatea/Stack.kt:59-63, children are tried in order and propagation stops on the first true, so the first ExpandableComponent in a container will steal those keys from later interactive siblings.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@bobatea/src/commonMain/kotlin/com/github/dylanwatsonsoftware/bobatea/ExpandableComponent.kt`
around lines 54 - 58, The handler for BobaEvent.Key currently toggles expanded
on any SPACE/ENTER and so the first ExpandableComponent steals those keys;
change the condition in the BobaEvent.Key branch so you only toggle expanded
when this component is the intended target (e.g., the component has focus or the
event is routed to it) instead of unconditionally. Concretely, wrap the
SPACE.key/ENTER.key check with a focus/route guard (check your component's focus
state method or event target/routing flag on the incoming BobaEvent.Key) before
flipping expanded so later siblings aren't preempted (symbols to modify:
BobaEvent.Key handling, SPACE.key, ENTER.key, expanded; behavior referenced in
Stack.kt children propagation).

}
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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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()) {
Expand All @@ -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))
Comment on lines 42 to +52

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Set the child's position before calling render().

Nested containers use their own x/y during render() to position descendants. Rendering first and assigning child.x / child.y afterwards leaves grandchildren with stale coordinates, so nested mouse hit detection drifts.

Suggested fix
                 row {
                     children.forEach { child ->
-                        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)
+                        val renderedChild = child.render(childAvailableWidth, resolvedHeight)
+                        val lines = renderedChild.lines()
                         child.widthPx = lines.maxOfOrNull { visibleLength(it) } ?: 0
                         child.heightPx = lines.size
 
                         currentX += child.widthPx
                         cell(Text(renderedChild))
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@bobatea/src/commonMain/kotlin/com/github/dylanwatsonsoftware/bobatea/Inline.kt`
around lines 42 - 52, In the Inline rendering loop (the children.forEach block
in Inline.kt) set the child's position (child.x and child.y) before invoking
child.render(...) so nested containers compute correct coordinates for their
descendants; move the assignment of child.x = currentX and child.y =
this@Inline.y + padding + border offset to precede the call to
child.render(childAvailableWidth, resolvedHeight), then compute
child.widthPx/heightPx from the rendered output and advance currentX and emit
the cell(Text(renderedChild)) as before.

}
}
}
}

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) }
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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 <R> runLoading(
message: String = "",
Expand All @@ -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 <R> 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
}
}
}
Expand Down
Loading
Loading