- Scheme 98.2%
- Makefile 1.8%
| examples | ||
| widgets | ||
| .gitignore | ||
| app.ss | ||
| buffer-test.ss | ||
| buffer.ss | ||
| build.ss | ||
| cell-test.ss | ||
| cell.ss | ||
| event.ss | ||
| gerbil.pkg | ||
| layout-test.ss | ||
| layout.ss | ||
| Makefile | ||
| manifest.ss | ||
| README.md | ||
| rect-test.ss | ||
| rect.ss | ||
| style-test.ss | ||
| style.ss | ||
| tui.ss | ||
| widget.ss | ||
gerbil-tui
A composable TUI (Text User Interface) framework for Gerbil Scheme. Build terminal applications with an immutable widget system, constraint-based layouts, double-buffered rendering, and automatic focus management.
┌─────────────────────────────────────────────────────┐
│ SYSTEM DASHBOARD ─ Gerbil TUI Demo │
├──────────┬──────────┬──────────┬────────────────────┤
│ ▓▓▓▓▓░░░ │ ▓▓▓░░░░░ │ ▓▓▓▓▓▓▓░ │ ▓▓░░░░░░░░░░░░░░ │
│ CPU 73% │ MEM 45% │ DISK 91% │ NET 22% │
├───────────┴──────────┴──────────┴────────────────────┤
│ PID USER CPU% MEM% COMMAND │
│ 1042 root 32.1 4.2 gerbil-server │
│ 1187 www 18.7 12.3 nginx: worker │
└──────────────────────────────────────────────────────┘
Features
- 8 built-in widgets: text, input, list, table, gauge, block (border), panel (layout container)
- Constraint-based layout: fixed, percentage, fill, min, max — compose responsive UIs declaratively
- Immutable architecture: widgets are pure values; state changes produce new widgets
- Double-buffered rendering: only changed cells are written to the terminal
- Focus management: automatic Tab/Shift-Tab focus cycling across the widget tree
- Composable styling: foreground color, background color, bold, italic, underline, reverse
- Custom widgets: implement the Widget interface to create your own components
- Mouse support: enabled by default alongside keyboard input
Prerequisites
- Gerbil Scheme (v0.18+)
- gerbil-termbox — FFI wrapper around the termbox C library
Installation
1. Install gerbil-termbox
Build and install gerbil-termbox first. By default, the Makefile expects it at ~/mine/gerbil-termbox:
cd ~/mine/gerbil-termbox
make build && make install
If your gerbil-termbox is elsewhere, set the GERBIL_TERMBOX_LIB environment variable:
export GERBIL_TERMBOX_LIB=/path/to/gerbil-termbox/.gerbil/lib
2. Build gerbil-tui
cd gerbil-tui
make build
3. Install (optional)
Installs to ~/.gerbil/lib/gerbil-tui:
make install
4. Run the examples
make example-dashboard # System monitoring dashboard
make example-todo # Interactive todo list
make example-explorer # File browser with split panes
Press Ctrl-C to quit any example.
5. Run the tests
make test
Quick Start
A minimal "Hello World" application:
#!/usr/bin/env gxi
(import :gerbil-tui/tui
:gerbil-termbox/termbox)
(run-app
(new-block
(new-text "Hello, gerbil-tui!" align: 'center)
title: " Greeting "
style: (style-with-fg default-style TB_CYAN)))
A more complete example — a counter app:
#!/usr/bin/env gxi
(import :gerbil-tui/tui
:gerbil-termbox/termbox
:gerbil-termbox/draw)
;; Custom widget: a simple counter
(defstruct counter-widget (count)
transparent: #t)
(defmethod {render counter-widget}
(lambda (self area buf)
(let ((text (new-text
(string-append "Count: "
(number->string (counter-widget-count self))
" [UP/DOWN to change, Ctrl-C to quit]")
align: 'center)))
{text.render area buf})))
(defmethod {handle-event counter-widget}
(lambda (self ev)
(if (tb-event-key? ev)
(let ((key (tb-event-key ev)))
(cond
((= key TB_KEY_ARROW_UP)
(values (make-counter-widget (+ (counter-widget-count self) 1))
[(make-cmd-redraw)]))
((= key TB_KEY_ARROW_DOWN)
(values (make-counter-widget (- (counter-widget-count self) 1))
[(make-cmd-redraw)]))
(else (values self []))))
(values self []))))
(defmethod {preferred-size counter-widget}
(lambda (self max-w max-h) (values max-w max-h)))
(defmethod {focusable? counter-widget}
(lambda (self) #t))
(defmethod {children counter-widget}
(lambda (self) []))
(defmethod {focused-child counter-widget}
(lambda (self) #f))
;; Run it
(run-app
(new-block (make-counter-widget 0)
title: " Counter "
border-style: box-style-rounded
style: (style-with-fg default-style TB_GREEN)))
Architecture
┌─────────────────────────────────────────────────────┐
│ Your Application (custom widgets) │
├─────────────────────────────────────────────────────┤
│ Event Loop (app) & Focus Management (event) │
├─────────────────────────────────────────────────────┤
│ Widget Implementations (8 built-in widgets) │
├─────────────────────────────────────────────────────┤
│ Widget Interface & Command System (widget) │
├─────────────────────────────────────────────────────┤
│ Layout Engine (layout) & Panel Container │
├─────────────────────────────────────────────────────┤
│ Styling (style) & Buffer Management (buffer) │
├─────────────────────────────────────────────────────┤
│ Cell (cell) & Rectangle Geometry (rect) │
├─────────────────────────────────────────────────────┤
│ gerbil-termbox (terminal I/O via FFI) │
└─────────────────────────────────────────────────────┘
Key Design Principles
- Immutable widgets —
handle-eventreturns a new widget value and a list of commands. Widgets never mutate in place. - Interface-based polymorphism — All widgets implement the
Widgetinterface. Method dispatch uses Gerbil's{widget.method args}syntax. - Command pattern — Widgets communicate upward by returning command objects (
cmd-quit,cmd-redraw,cmd-custom). - Constraint-based layout — The layout engine allocates space declaratively, making UIs responsive to terminal resizing.
- Double-buffered rendering — The app loop diffs the previous and current frame buffers, writing only changed cells to the terminal.
Module Reference
Importing
Use the convenience re-export module to import everything:
(import :gerbil-tui/tui)
Or import individual modules:
(import :gerbil-tui/rect
:gerbil-tui/style
:gerbil-tui/layout
:gerbil-tui/widget
:gerbil-tui/widgets/text
:gerbil-tui/widgets/block
:gerbil-tui/app)
run-app — Application Entry Point
(run-app root-widget
output-mode: TB_OUTPUT_NORMAL ; optional
input-mode: (bitwise-ior TB_INPUT_ESC TB_INPUT_MOUSE)) ; optional
Starts the TUI event loop. Initializes the terminal, renders the widget tree, polls for events, and dispatches them through the focus system. The loop runs until Ctrl-C, Ctrl-Q, or a cmd-quit command is returned by a widget.
Output modes (from termbox):
| Mode | Description |
|---|---|
TB_OUTPUT_NORMAL |
8 basic colors |
TB_OUTPUT_256 |
256-color mode |
TB_OUTPUT_216 |
216-color mode |
TB_OUTPUT_GRAYSCALE |
24 grayscale shades |
Built-in key handling:
Ctrl-C/Ctrl-Q— quit the applicationTab/Shift-Tab— cycle focus between focusable widgets- Terminal resize — automatic full redraw
Widget Interface
Every widget must implement these 6 methods:
(interface Widget
(render area buf) ; Draw into buffer within the given rect
(handle-event ev) ; Handle input -> (values new-widget commands)
(preferred-size max-w max-h) ; Layout hint -> (values width height)
(focusable?) ; Can this widget receive focus? -> boolean
(children) ; Child widgets -> list
(focused-child)) ; Which child has focus? -> widget or #f
Commands returned by handle-event:
| Command | Constructor | Purpose |
|---|---|---|
| Quit | (make-cmd-quit) |
Exit the application |
| Redraw | (make-cmd-redraw) |
Request a screen refresh |
| Custom | (make-cmd-custom type data) |
Application-defined command |
Widgets
new-text — Static Text Display
(new-text content
align: 'left ; 'left, 'right, or 'center
wrap?: #f ; enable word wrapping
style: default-style)
Displays text content. Supports alignment and optional word-wrapping for multi-line display. Not focusable.
;; Simple left-aligned text
(new-text "Hello, world!")
;; Centered header
(new-text "Dashboard" align: 'center
style: (style-with-bold (style-with-fg default-style TB_CYAN)))
;; Multi-line wrapped paragraph
(new-text "This is a long paragraph that will wrap at word boundaries..."
wrap?: #t)
new-input — Single-Line Text Input
(new-input placeholder: ""
style: default-style)
Editable single-line text field with cursor. Focusable. Scrolls horizontally when text exceeds the available width. Shows the placeholder text (dimmed) when the value is empty.
Keyboard:
| Key | Action |
|---|---|
| Printable characters | Insert at cursor |
| Backspace / Delete | Remove character |
| Left / Right arrows | Move cursor |
| Home / End | Jump to start / end |
Accessing the value:
(tui-input-value my-input) ; -> string
(tui-input-cursor-pos my-input) ; -> integer
new-list — Scrollable Selectable List
(new-list items
selected: 0
style: default-style
selected-style: #f) ; defaults to reverse of style
Displays a scrollable list of items with a highlighted selection. Focusable. Automatically scrolls to keep the selected item visible.
Keyboard:
| Key | Action |
|---|---|
| Arrow Up / Down | Move selection |
| Home / End | Jump to first / last |
| Page Up / Down | Move by 10 items |
Accessing the selection:
(tui-list-selected my-list) ; -> integer (index)
(tui-list-selected-item my-list) ; -> item value or #f
(tui-list-items my-list) ; -> list
new-table — Columnar Data Table
(new-table headers rows
column-widths: #f ; list of (integer or #f); #f = auto
style: default-style
header-style: #f ; defaults to bold
selected-style: #f) ; defaults to reverse
Displays tabular data with a header row and selectable data rows. Focusable. Column widths can be fixed integers or #f for automatic distribution of remaining space.
Keyboard: Same as list (Arrow Up/Down, Home/End, Page Up/Down).
;; Table with mixed fixed and auto column widths
(new-table '("PID" "USER" "CPU%" "COMMAND")
'(("1042" "root" "32.1" "gerbil-server")
("1187" "www" "18.7" "nginx"))
column-widths: (list 7 10 7 #f)) ; last column fills remaining space
Accessing the selection:
(tui-table-selected-row my-table) ; -> row (list of strings) or #f
new-gauge — Progress Bar
(new-gauge ratio
label: #f
style: default-style
filled-style: #f) ; defaults to reverse of style
Displays a horizontal progress bar. The ratio is clamped to [0.0, 1.0]. The optional label is overlaid centered on the bar. Not focusable. Always 1 row tall.
(new-gauge 0.73
label: " CPU 73% "
style: (style-with-fg default-style TB_GREEN)
filled-style: (style-with-reverse
(style-with-fg default-style TB_GREEN)))
new-block — Bordered Container
(new-block child
title: #f
border-style: box-style-single
padding: 0
style: default-style)
Wraps a child widget with a border and an optional title. Not focusable itself, but delegates events and focus to its child.
Border styles (from gerbil-termbox/draw):
| Style | Description |
|---|---|
box-style-single |
Single-line box (+-+, |) |
box-style-rounded |
Rounded corners |
box-style-double |
Double-line box |
box-style-heavy |
Heavy/thick lines |
;; Rounded border with title and cyan color
(new-block (new-text "Content here")
title: " My Panel "
border-style: box-style-rounded
padding: 1
style: (style-with-fg default-style TB_CYAN))
new-panel — Layout Container
(new-panel direction entries)
Arranges child widgets in a row or column using layout constraints. Not focusable itself, but its children participate in the focus chain.
direction—'horizontalor'verticalentries— list of(constraint . widget)pairs
;; Three-column layout: sidebar | main content | info panel
(new-panel 'horizontal
(list (cons (constraint-fixed 20) sidebar-widget)
(cons (constraint-fill) main-widget)
(cons (constraint-fixed 30) info-widget)))
;; Vertical layout: header + body
(new-panel 'vertical
(list (cons (constraint-fixed 1) header-widget)
(cons (constraint-fill) body-widget)))
Layout Constraints
The layout engine splits a rectangle into sub-regions according to a list of constraints. Constraints are resolved in 4 passes:
- Fixed and min — allocate their requested size
- Percent — allocate a percentage of the total
- Max — allocate up to their maximum from remaining space
- Fill — distribute remaining space proportionally by weight
(layout-split area direction constraints) ; -> list of rects
| Constructor | Description |
|---|---|
(constraint-fixed n) |
Exactly n units |
(constraint-percent pct) |
pct% of total space |
(constraint-fill) |
Expand to fill remaining space (weight 1) |
(constraint-fill weight) |
Fill with custom weight for proportional distribution |
(constraint-min n) |
At least n units |
(constraint-max n) |
At most n units |
Examples:
;; 60/40 split
(list (constraint-fill 3) (constraint-fill 2))
;; Fixed sidebar + flexible main area
(list (constraint-fixed 25) (constraint-fill))
;; Three equal columns
(list (constraint-fill) (constraint-fill) (constraint-fill))
;; Header (1 row) + gauges (5 rows) + expanding body
(list (constraint-fixed 1) (constraint-fixed 5) (constraint-fill))
Styling
Styles compose foreground color, background color, and text attributes into a single value.
;; Start from defaults
default-style ; TB_DEFAULT fg, TB_DEFAULT bg, no attributes
;; Set colors
(style-with-fg default-style TB_RED)
(style-with-bg default-style TB_BLUE)
;; Add attributes (these compose — each returns a new style)
(style-with-bold s)
(style-with-italic s)
(style-with-underline s)
(style-with-reverse s)
;; Compose multiple attributes
(def my-style
(style-with-bold
(style-with-fg
(style-with-bg default-style TB_BLUE)
TB_WHITE)))
Available colors (from termbox):
TB_DEFAULT, TB_BLACK, TB_RED, TB_GREEN, TB_YELLOW, TB_BLUE, TB_MAGENTA, TB_CYAN, TB_WHITE
When using TB_OUTPUT_256, pass integer color codes (0-255) directly.
Merging for rendering:
(let-values (((fg bg) (style-merge my-style)))
(buffer-set-char! buf x y ch fg bg))
style-merge combines fg and attrs via bitwise-ior and returns the bg separately, producing the two values termbox expects.
Rectangles
Axis-aligned rectangles used throughout for positioning.
(make-rect x y w h) ; constructor
(rect-x r) (rect-y r) ; position
(rect-w r) (rect-h r) ; dimensions
(rect-right r) ; x + w
(rect-bottom r) ; y + h
(rect-empty? r) ; w <= 0 or h <= 0?
(rect-contains? r px py) ; point-in-rect test
(rect-inner r pl pt pr pb) ; shrink by padding (clamped to >= 0)
(rect-clamp r px py) ; clamp point to rect bounds
Buffer
Off-screen 2D cell buffer for rendering, with differential update support.
(new-buffer w h) ; allocate w*h buffer of empty cells
(buffer-ref buf x y) ; get cell at (x,y), bounds-checked
(buffer-set! buf x y cell) ; set cell at (x,y), no-op if out of bounds
(buffer-set-char! buf x y ch fg bg) ; set by code point + colors
(buffer-print! buf x y str fg bg) ; print string, clips at right edge
(buffer-fill! buf area ch fg bg) ; fill a rect with a character
(buffer-clear! buf) ; reset all cells to empty
(buffer-diff prev cur) ; -> list of (x y cell) changes
(buffer-flush! diff) ; write changes to termbox
Focus & Events
Focus is managed automatically by the framework. The focus chain is collected by walking the widget tree depth-first, gathering all widgets where (focusable?) returns #t.
(collect-focusable widget) ; -> list of focusable widgets
(focus-next chain current) ; -> next widget (wraps around)
(focus-prev chain current) ; -> previous widget (wraps around)
(dispatch-event focused chain ev) ; -> (values new-focused commands)
- Tab cycles focus forward through the chain
- Shift-Tab cycles backward
- All other events are dispatched to the currently focused widget
Creating Custom Widgets
To create a custom widget, define a struct and implement all 6 methods of the Widget interface.
Minimal Template
(defstruct my-widget (some-state)
transparent: #t)
(defmethod {render my-widget}
(lambda (self area buf)
;; Draw into buf within area
...))
(defmethod {handle-event my-widget}
(lambda (self ev)
;; Return (values new-self commands)
(values self [])))
(defmethod {preferred-size my-widget}
(lambda (self max-w max-h)
(values max-w max-h)))
(defmethod {focusable? my-widget}
(lambda (self) #t)) ; or #f for non-interactive widgets
(defmethod {children my-widget}
(lambda (self) [])) ; list of child widgets, or []
(defmethod {focused-child my-widget}
(lambda (self) #f)) ; which child has focus, or #f
Guidelines
- Immutability: Never mutate
self. Return a new struct fromhandle-event. - Commands: Return
[(make-cmd-redraw)]when the widget's visual state changes. Return[(make-cmd-quit)]to exit the application. - Focus: If your widget contains focusable children, return them from
childrenand indicate which one is focused viafocused-child. If your widget is a leaf that handles input directly, return#tfromfocusable?and[]fromchildren. - Composition: Use existing widgets inside your render method. Create temporary widgets and call
{widget.render area buf}to draw them.
Example: Custom Widget with Internal Sub-Widgets
See examples/todo.ss for a complete example of a custom widget that manages an input field and a list, handles Tab-to-switch-focus internally, and processes Enter-to-add and d-to-delete actions.
Examples
Dashboard (examples/dashboard.ss)
A system monitoring dashboard demonstrating:
- Nested panel layouts (horizontal and vertical)
- Progress gauges with labels and colored fill
- Data table with headers and fixed/auto column widths
- Scrollable event log list
- Multi-line wrapped text
- 256-color output mode
- Multiple border styles (single, rounded, double)
Todo App (examples/todo.ss)
An interactive todo list demonstrating:
- Custom widget implementation
- Text input with placeholder
- Dynamic list manipulation (add/delete items)
- Internal focus management (Tab switches between input and list)
- Enter to add,
dto delete
File Explorer (examples/explorer.ss)
A split-pane file browser demonstrating:
- Custom widget with directory traversal
- Split-pane layout (file list + info preview)
- Dynamic content updates on navigation
- Gambit built-ins for filesystem operations
- Enter to open directories, Backspace to go up
Project Structure
gerbil-tui/
├── gerbil.pkg # Package declaration
├── build.ss # Build script (module ordering)
├── Makefile # Build, test, install, examples
│
├── rect.ss # Rectangle geometry
├── cell.ss # Terminal cell (char + colors)
├── buffer.ss # 2D cell buffer with diff
├── style.ss # Visual styling (fg, bg, attributes)
├── layout.ss # Constraint-based layout solver
├── widget.ss # Widget interface definition
├── event.ss # Focus chain & event dispatch
├── app.ss # Main event loop
├── tui.ss # Re-export convenience module
│
├── widgets/
│ ├── text.ss # Text display with alignment/wrapping
│ ├── input.ss # Single-line text input
│ ├── list.ss # Scrollable selectable list
│ ├── gauge.ss # Progress bar
│ ├── table.ss # Columnar data table
│ ├── block.ss # Bordered container with title
│ └── panel.ss # Layout container
│
├── examples/
│ ├── dashboard.ss # System monitoring dashboard
│ ├── todo.ss # Interactive todo list
│ └── explorer.ss # File browser
│
├── rect-test.ss # Tests for rect
├── cell-test.ss # Tests for cell
├── buffer-test.ss # Tests for buffer
├── style-test.ss # Tests for style
├── layout-test.ss # Tests for layout
└── widgets/
└── widgets-test.ss # Tests for all widgets + focus
License
See LICENSE file for details.