TUI builder widgets for Gerbil Scheme
  • Scheme 98.2%
  • Makefile 1.8%
Find a file
2026-02-10 16:48:40 -07:00
examples TUI 2026-02-10 16:35:29 -07:00
widgets TUI 2026-02-10 16:35:29 -07:00
.gitignore TUI 2026-02-10 16:35:29 -07:00
app.ss TUI 2026-02-10 16:35:29 -07:00
buffer-test.ss TUI 2026-02-10 16:35:29 -07:00
buffer.ss TUI 2026-02-10 16:35:29 -07:00
build.ss TUI 2026-02-10 16:35:29 -07:00
cell-test.ss TUI 2026-02-10 16:35:29 -07:00
cell.ss TUI 2026-02-10 16:35:29 -07:00
event.ss TUI 2026-02-10 16:35:29 -07:00
gerbil.pkg TUI 2026-02-10 16:35:29 -07:00
layout-test.ss TUI 2026-02-10 16:35:29 -07:00
layout.ss TUI 2026-02-10 16:35:29 -07:00
Makefile TUI 2026-02-10 16:35:29 -07:00
manifest.ss TUI 2026-02-10 16:35:29 -07:00
README.md add readme 2026-02-10 16:48:40 -07:00
rect-test.ss TUI 2026-02-10 16:35:29 -07:00
rect.ss TUI 2026-02-10 16:35:29 -07:00
style-test.ss TUI 2026-02-10 16:35:29 -07:00
style.ss TUI 2026-02-10 16:35:29 -07:00
tui.ss TUI 2026-02-10 16:35:29 -07:00
widget.ss TUI 2026-02-10 16:35:29 -07:00

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

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

  1. Immutable widgetshandle-event returns a new widget value and a list of commands. Widgets never mutate in place.
  2. Interface-based polymorphism — All widgets implement the Widget interface. Method dispatch uses Gerbil's {widget.method args} syntax.
  3. Command pattern — Widgets communicate upward by returning command objects (cmd-quit, cmd-redraw, cmd-custom).
  4. Constraint-based layout — The layout engine allocates space declaratively, making UIs responsive to terminal resizing.
  5. 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 application
  • Tab / 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'horizontal or 'vertical
  • entries — 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:

  1. Fixed and min — allocate their requested size
  2. Percent — allocate a percentage of the total
  3. Max — allocate up to their maximum from remaining space
  4. 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 from handle-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 children and indicate which one is focused via focused-child. If your widget is a leaf that handles input directly, return #t from focusable? and [] from children.
  • 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, d to 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.