Skip to content

nassendelft/ftxui-kt-framework

Repository files navigation

ftxui-kt-framework

An opinionated TUI application framework for Kotlin Multiplatform Native, built on top of ftxui-kt. It provides a component builder architecture, a navigation stack, built-in views, and framework-level overlays (help, toasts, dialogs, logging, preferences) so you can focus on writing application logic rather than plumbing.

Targets: macOS ARM64, Linux x64
Kotlin: 2.3.21


Architecture

The framework follows a component builder pattern driven by AppContext:

runApp(name, builder)
   ↓  creates
AppContext  (preferences, navigator, requestRedraw, post, terminalSize)
   ↓  passed to
Component builders (plain classes with a build() method)
   ↓  register shortcuts via
navigator.registerShortcutsForComponent(comp, shortcuts)
   ↑  trigger redraw on state change
AppContext.requestRedraw()

Component Builders

Screens are plain classes that take a Navigator and AppContext as constructor parameters. The build() method calls context.run { ... } to access the AppContext view builder extensions in scope:

class MyScreen(private val navigator: Navigator, private val context: AppContext) {
    private var count by context.mutableStateOf(0)

    fun build(): Component = context.run {
        val comp = list(
            getEntries = { getItems(count) },
            renderItem = { item, focused ->
                if (focused) text(item.name).inverted() else text(item.name)
            },
            renderHeader = { name -> hbox(text("── $name ──").bold(), filler()) }
        )
        navigator.registerShortcutsForComponent(comp, listOf(
            Shortcut(Key.CtrlS, "^S  Save", description = "Save changes") { saveData() },
        ))
        comp
    }
}

State & Reactivity

Reactivity can be managed in two ways:

  1. Local State (mutableStateOf): A Compose-like delegate available on AppContext. Mutating a variable defined with mutableStateOf automatically schedules a redraw.

    var selectedIndex by context.mutableStateOf(0)
  2. Flow / StateFlow: Launch a coroutine to collect state and call requestRedraw(). Use navigator.scopeFor(comp) to get a scope that is automatically cancelled when the component is popped from the stack.

    fun build(): Component = context.run {
        val comp = list(getEntries = { viewModel.state.value.items }, ...)
        navigator.scopeFor(comp).launch {
            viewModel.state.collect { context.requestRedraw() }
        }
        comp
    }

Views & Components

Views are declarative Component functions built as extensions on AppContext (e.g. list, table, split). They wrap native FTXUI focus and event handling, and accept lambda retrievers (e.g., getEntries = { ... }) to pull state dynamically on render.

Navigator

Navigator is available inside AppContext via context.navigator and can be stored as a constructor parameter. Use it to push/pop screens or show global UI overlays:

navigator.push(DetailScreen(navigator, context, item).build())
navigator.pop()
navigator.showDialog(Dialog.Alert(title = "Done", message = "Saved."))
navigator.notify("Item saved", Toast.SHORT, Toast.Type.Success)

Additional Navigator APIs:

// Coroutine scope tied to this component's lifetime on the stack
val scope = navigator.scopeFor(comp)

// Callback invoked when the component becomes/stops being the top screen
navigator.onTopChanged(comp) { isVisible -> /* pause/resume work */ }

// Register shortcuts shown in the status bar and ? help overlay
navigator.registerShortcutsForComponent(comp, listOf(...))

// Register shortcuts lazily (re-evaluated each time they're needed)
navigator.registerShortcutsForComponent(comp) { buildDynamicShortcuts() }

Entry points

Single-screen app

fun main() {
    runApp(
        name = "my-app",
        initialComponentBuilder = { MyScreen(navigator, this).build() },
        confirmOnQuit = true,
        enableCtrlZ = true,
    )
}

runApp creates the AppContext and Preferences automatically. The builder lambda receives AppContext as this; navigator is available on it.


Built-in Views

All views are instantiated via AppContext extension methods and return a standard FTXUI Component. Call them inside a context.run { ... } block.

list

Scrollable list with headers, fuzzy search, and vim-style navigation.

val comp = list(
    getEntries = {
        buildList {
            add(ListEntry.Header("Fruits"))
            fruits.forEach { add(ListEntry.Item(it) { navigator.push(DetailScreen(...).build()) }) }
        }
    },
    renderItem = { fruit, focused ->
        if (focused) text(fruit.name).inverted() else text(fruit.name)
    },
    renderHeader = { name -> hbox(text("── $name ──").bold(), filler()) },
    toSearchString = { it.name },
    style = ListStyle(focusedItemBackground = Color.Blue),
    keybindings = ListKeybindings(), // optional, fully customisable
)

Keys: j/k navigate, g/G top/bottom, Ctrl+U/D half-page, / fuzzy search.

table

Sortable table with customisable column renderers.

val comp = table(
    getRows = { FRUIT_ROWS },
    columns = listOf(
        TableColumn("Name", extract = { it.name }),
        TableColumn(
            header = "Category",
            extract = { it.category },
            renderCell = { item, width, focused ->
                val color = if (item.category == "Fruit") Color.Green else Color.Yellow
                val el = text(item.category.padEnd(width + 3)).color(color)
                if (focused) el.inverted() else el
            }
        ),
    ),
    onEnter = { row -> navigator.push(DetailScreen(...).build()) }
)

Keys: j/k navigate, s cycles column sort (▲ / ▼ / off), Enter triggers onEnter.

pager

Read-only scrollable text panel with incremental search.

val comp = pager(
    getState = { PagerState(lines = myLines, showLineNumbers = true) }
)

Keys: j/k/g/G scroll, / search, n/N next/prev match.

tree

Hierarchical tree with expand/collapse nodes.

val comp = tree(
    getState = { TreeState(roots) },
    renderNode = { label, depth, focused, hasChildren, isExpanded ->
        if (focused) text(label).inverted() else text(label)
    }
)

Create a tree of TreeNode<T> which defines children, isExpanded, and optional onToggle/onEnter callbacks.

Keys: j/k navigate, /l expand, /h collapse, Enter triggers onEnter.

split

Two components side-by-side; Tab/Shift+Tab switches focus, and the inactive panel is dimmed automatically.

val comp = split(
    left = leftComponent,
    right = rightComponent,
    leftTitle = "Left Pane",
    rightTitle = "Right Pane"
)

textEditor

Multiline text editor with built-in undo/redo stack. Requires enableCtrlZ = true in runApp().

var content = "Initial Text"

val comp = textEditor(
    content = ::content,
    showLineNumbers = true,
    onContentChange = { newText -> Logger.debug("Text length: ${newText.length}") },
    onStateChange = { newState ->
        editorState = newState
        context.requestRedraw()
    }
)

Keys: Arrow keys, Home/End, Page Up/Down, Backspace/Delete/Enter, Ctrl+Z/Y undo/redo.

filePicker

POSIX filesystem browser.

val comp = filePicker(
    initialPath = ".",
    onFileSelected = { path -> navigator.pop(); handleFile(path) },
    showHiddenInitially = false,
    filter = { entry -> entry.name.endsWith(".kt") }
)

Keys: j/k navigate, Enter enter directory or select file, Backspace/h go up, / filter, . toggle hidden files.

dashboard

Grid of independent cells, each rendering a child Component.

val comp = dashboard(
    columns = 2,
    cells = listOf(
        DashboardCell("CPU Gauge", render = { cpuGaugeComponent }),
        DashboardCell("Logs", render = { logListComponent })
    )
)

Keys: Tab/Shift+Tab cycle active cell focus.

paginatedList

Lazily loads pages of data reactively via a suspend loader.

val comp = paginatedList(
    pageSize = 50,
    loadThreshold = 10,
    loadPage = { offset, limit -> fetchItems(offset, limit) },
    renderItem = { item, focused -> if (focused) text(item.name).inverted() else text(item.name) },
    renderHeader = { name -> hbox(text("  $name").bold(), filler()) }
)

A loading row appears at the bottom while the next page fetches. Fuzzy search / filters over all currently loaded items.

stepProgress

Renders a pipeline of steps with status icons, spinners, and expandable output.

val comp = stepProgress(
    getState = {
        StepProgressState(
            steps = listOf(
                ProgressStep("Build", StepStatus.Done, output = buildLogs),
                ProgressStep("Test", StepStatus.Running)
            ),
            spinnerTick = currentTick
        )
    }
)

Keys: j/k navigate, /l expand output, /h collapse, Space toggle.


Component Styling

Each built-in view can be styled individually by passing a specific *Style config class. By default, styles fall back to the active theme's colors defined in Theme.current.

val comp = list(
    getEntries = { entries },
    renderItem = { item, focused -> text(item) },
    renderHeader = { text(it) },
    style = ListStyle(
        focusedItemForeground = Color.Black,
        focusedItemBackground = Color.Yellow,
        headerForeground = Color.Cyan
    )
)

Supported Styles

  • ListStyle: focusedItemForeground, focusedItemBackground, headerForeground, scrollThumb, searchHighlight
  • TableStyle: headerForeground, focusedRowForeground, focusedRowBackground, sortIndicatorColor, scrollThumb, borderStyle
  • TreeStyle: focusedNodeForeground, focusedNodeBackground, expandedIcon, collapsedIcon, leafIndent, scrollThumb
  • SplitStyle: activeTitleForeground, inactiveTitleForeground, borderStyle, activeBorderStyle
  • DashboardStyle: focusedTitleForeground, unfocusedTitleForeground, borderStyle, focusedBorderStyle
  • PagerStyle: searchHighlight, lineNumberColor, scrollThumb
  • StepProgressStyle: pendingColor, runningColor, doneColor, failedColor, skippedColor
  • FilePickerStyle: directoryColor, fileColor, pathColor, scrollThumb
  • TextEditorStyle: lineNumbersColor, cursorForeground, cursorBackground, scrollThumb

To configure global colors, subclass or instantiate ThemeColors and assign it:

Theme.current = ThemeColors(
    accent = Color.Green,
    border = Color.GrayDark
)

Keybindings

Every built-in view accepts an optional *Keybindings parameter that lets you remap any key without subclassing:

val comp = list(
    getEntries = { entries },
    renderItem = { item, focused -> text(item) },
    renderHeader = { text(it) },
    keybindings = ListKeybindings(
        moveUpChars = listOf("k", "p"),
        moveDownChars = listOf("j", "n"),
    )
)

Available keybinding types: ListKeybindings, TableKeybindings, TreeKeybindings, PagerKeybindings, StepProgressKeybindings, FilePickerKeybindings, DashboardKeybindings, SplitKeybindings, TextEditorKeybindings.


Framework overlays (built-in, no setup needed)

Shortcut Overlay
? Help — all registered shortcuts + framework defaults
Ctrl+P Command palette — fuzzy search over registered shortcuts
Ctrl+N Notification history — scroll with j/k, Esc to close
Ctrl+L Log viewer — scroll with j/k/g/G, Esc to close
Ctrl+Alt+P Performance overlay — FPS, frame time, stack depth, terminal size

Dialogs

navigator.showDialog(Dialog.Alert(title = "Info", message = "Done."))

navigator.showDialog(Dialog.Confirm(
    title = "Delete?",
    message = "This cannot be undone.",
    onConfirm = { doDelete() },
    onCancel  = { /* nothing */ },
))

navigator.showDialog(Dialog.Prompt(
    title = "Enter name",
    placeholder = "Name",
    onSubmit = { name -> save(name) },
))

Toasts

navigator.notify("Saved",          Toast.SHORT, Toast.Type.Success)
navigator.notify("Low disk space", Toast.SHORT, Toast.Type.Warning)
navigator.notify("Load failed",    Toast.LONG,  Toast.Type.Error)
navigator.notify("FYI",            Toast.SHORT, Toast.Type.Info)   // default

Up to 3 toasts are stacked simultaneously. Each shows an animated countdown border. All toasts are accessible via Ctrl+N notification history.


Singletons

Logger

Logger.debug("Verbose detail")
Logger.info("User opened settings")
Logger.warn("Config missing, using defaults")
Logger.error("Connection refused")

Logs are kept in a 1000-entry in-memory ring buffer, rendered inside a log panel overlay (Ctrl+L).

Preferences

Persistent key-value store backed by ~/.config/<appName>/prefs.properties. The Preferences instance is created automatically by runApp and is accessible via AppContext.preferences.

val count = preferences.getInt("launch.count", default = 0)
preferences.setInt("launch.count", count + 1)

preferences.setString("last.file", "/path/to/file")
val path = preferences.getString("last.file", default = "")

preferences.setBoolean("dark.mode", true)

Preferences are saved automatically on app exit.


Utilities

UndoRedoStack<T>

Generic undo/redo stack (used internally by textEditor).

val history = UndoRedoStack(initial = emptyList<String>(), maxSize = 100)
history.push(newState)
history.undo()
history.redo()
history.reset(newInitial)

Responsive layout

Switches between two layouts based on terminal width or height. Both variants are called as extensions on AppContext.

// Switch on terminal width
val comp = responsiveHorizontal(
    breakpoint = 120,
    narrow = { buildNarrowLayout() },
    wide   = { buildWideLayout() },
)

// Switch on terminal height
val comp = responsiveVertical(
    breakpoint = 30,
    short = { buildShortLayout() },
    tall  = { buildTallLayout() },
)

Viewport

Viewport measures the actual pixel-dimensions of the slot assigned to a component after layout. It is created via AppContext.viewport() and used internally by scrollable views; it can also be used in custom renderers when you need the true available height or width.

val vp = viewport()
// vp.width / vp.height — exact slot size after the first render frame

Build & run

# Build (macOS ARM64)
./gradlew linkDebugExecutableMacosArm64

# Run the demo app
./build/bin/macosArm64/debugExecutable/ftxui-kt-framework.kexe

# Build (Linux x64)
./gradlew linkDebugExecutableLinuxX64

Conventions

  • Ctrl+N, Ctrl+L, Ctrl+P, and ? are reserved by the framework; do not register them on your components.
  • enableCtrlZ = true is required in runApp() to use undo/redo in textEditor.
  • Use navigator.scopeFor(comp) rather than GlobalScope for coroutines that collect ViewModel state — the scope is cancelled automatically when the component is popped.
  • Use navigator.onTopChanged(comp) to pause background work (polling, refresh loops) while a screen is obscured by another.
  • Component focus is managed via native FTXUI focus APIs; the framework transfers focus to whichever component is currently on top of the navigation stack.

About

No description or website provided.

Topics

Resources

License

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages