-
Notifications
You must be signed in to change notification settings - Fork 0
Troubleshooting
Start here when an app does not render, input is missing, or native behavior differs from the JVM. Work from the first relevant symptom; most problems come from TTY availability, state lifetime, tracked reads, or event consumption.
glyphora needs a controlling TTY. An IDE output panel, redirected pipe, or headless
CI process intentionally returns UnsupportedTerminal instead of entering raw mode.
Run the app in a terminal:
./mill examples.showcase.runFor CI, inject HeadlessBackend; see Testing.
Check these in order:
- The view must read reactive state with
.get, not.peek. - A signal must live outside
view; recreating it resets the value every pass. - A signal only notifies when the new value is not equal (
!=) to the old one. - Replace immutable collections instead of mutating one in place.
- Third-party callbacks must write on the render thread.
private val rows = Signal(Vector.empty[Row])
def view(using ReactiveScope) = renderRows(rows.get)
def append(row: Row) = rows.update(_ :+ row)TextInputState, TextAreaState, ListState, and other interactive state objects
must be created once on the app or owning screen:
private val inputState = TextInputState()
def view(using ReactiveScope) = input(inputState)Creating TextInputState() in view replaces it with an empty editor on the next
redraw.
- Confirm the element is focusable and press
Tabto move focus. - Return
falsefrom a custom handler when the event should bubble. - A user handler that always returns
truecan block built-in input/list behavior. - Keep global behavior in
KeyBindings; focused handlers run before them. - Do not parse escape sequences in the app;
JLine3Backendowns decoding. -
TuiAppenables backend interaction modes. A custom runner owns that lifecycle.
See Mouse & focus for the exact routing order.
Focus is positional unless an element has a stable key. Add .key("unique-name") to
interactive controls that can move when branches appear or disappear. Keys must be
unique among focusable elements in the current tree.
Both advance on ticks. Configure a cadence:
import io.worxbend.tui.runtime.RunnerConfig
import scala.concurrent.duration.*
override def config = RunnerConfig(tickRate = Some(100.millis))A splash supplies ticks automatically when the app has none; normal toasts and
runEffect calls do not.
Use CharWidth for custom width calculations. Java/Scala string length counts
UTF-16 code units, not terminal cells. Built-in widgets already use CharWidth for
clipping, wrapping, and cursor placement.
If built-in widgets agree but the emulator still looks wrong, check the emulator's ambiguous/emoji width policy and font fallback. An application cannot force an emulator to assign a particular width to a new emoji.
Key, mouse, binding, and tick handlers are already safe. Futures, HTTP clients, and other callbacks must hop back before writing.
Prefer the structured helper:
Async.runCatching(fetch()) {
case Right(value) => data.set(value)
case Left(error) => failure.set(Some(error.getMessage))
}Its completion already runs on the render thread. For an externally owned callback,
use RenderThread.runOnRenderThread.
Borders consume space. A panel needs at least three rows to leave one inner row. In
a row, child constraints allocate width; in a column, they allocate height.
Temporarily replace nested content with labeled text elements and inspect each
container's .length, .percent, and .fill constraints from the outside in.
DataTableState.selected indexes the filtered and sorted view, not the original row
sequence. Resolve the selection with:
state.selected.flatMap(table.visibleRows(state).lift)First confirm the JVM build and tests:
./mill app.compile
./mill app.test
./mill app.nativeImageKeep --no-fallback enabled. Inspect your own dependencies for runtime reflection,
dynamic class loading, resources, JNI, and proxies. glyphora's derivation APIs use
compile-time Scala 3 macros and require no reflection config. See
Native binaries.
It should not be. glyphora owns the terminal's signal handling instead of leaving it
to JLine's defaults, so every ordinary way of ending a TUI restores the terminal.
Measured on a real PTY with examples/hello-world:
| How the app ended | termios | alternate screen | cursor | paste / focus / kitty modes |
|---|---|---|---|---|
normal return (q) |
✅ | ✅ | ✅ | ✅ |
uncaught exception from view
|
✅ | ✅ | ✅ | ✅ |
Ctrl+C (SIGINT) |
✅ | ✅ | ✅ | ✅ |
SIGQUIT |
✅ | ✅ | ✅ | ✅ |
SIGTERM |
✅ | ✅ | ✅ | ✅ |
SIGHUP (window closed) |
✅ | ✅ | ✅ | ✅ |
System.exit from a handler |
✅ | ✅ | ✅ | ✅ |
SIGKILL |
❌ | ❌ | ❌ | ❌ |
Ctrl+C arrives as Event.Interrupt and quits through the same teardown as any other
exit; override TuiApp.onInterrupt() and return true to intercept it (to confirm, or
to cancel in-flight work) instead. Signal-terminated exits additionally go through a
JVM shutdown hook that writes the mode-reset sequences straight to the stdout
descriptor, so restoration does not depend on the backend still being intact.
SIGKILL cannot be caught by anything; after kill -9, run reset.
Set GLYPHORA_DEBUG=1 to have failed teardown steps report themselves on stderr rather
than being swallowed.
When launching an external interactive program, use TuiApp.suspend { ... } so the
terminal is deliberately handed over and restored.
Ordinary println writes into the terminal glyphora is repainting. Use a Log
widget, write to a file, or call printAbove(...) from a handler to add durable lines
to scrollback without corrupting the frame.
Search existing issues, then open a minimal reproduction with:
- glyphora, Scala, JVM, and OS versions;
- terminal emulator and shell;
- smallest terminal size that reproduces it;
- whether
HeadlessBackendreproduces it; - relevant stack trace and key/mouse sequence;
- JVM/native-image difference, if any.
Documentation is maintained in website/docs. Read the styled guide · API reference · MIT license