term2
Term2
A Crystal port of the Bubble Tea terminal UI library, built with concurrent ML (cml) for concurrency.
Features
- Elm Architecture: Model-Update-View pattern for building terminal applications
- Concurrent ML: Built on cml for efficient concurrency
- 200+ Key Sequences: Support for xterm, urxvt, linux console, VT100/VT220
- Mouse Support: SGR and legacy X10 mouse protocols
- Focus Reporting: FocusIn/FocusOut events
- Alternate Screen: Clean terminal restoration
- Components: TextInput, Spinner, ProgressBar, CountdownTimer
- Zone System: Built-in focus and click tracking with automatic tab cycling
- Tree/List/Table: Static rendering components for hierarchical data
- Rich Command System: Batch, sequence, timeout, and async commands
- Styling API: Full color and style support with fluent API
- Cross-platform: Works on Linux, macOS, and Windows
Installation
-
Add the dependency to your
shard.yml:dependencies: term2: github: dsisnero/term2 cml: github: dsisnero/cml -
Run
shards install
Quick Start
require "term2"
include Term2::Prelude
class CounterModel
include Model
getter count : Int32
def initialize(@count = 0); end
def init : Cmd
Cmds.none
end
def update(msg : Message) : {Model, Cmd}
case msg
when KeyMsg
case msg.key.to_s
when "q", "ctrl+c"
{self, Term2.quit}
when "+", "="
{CounterModel.new(@count + 1), Cmds.none}
when "-", "_"
{CounterModel.new(@count - 1), Cmds.none}
else
{self, Cmds.none}
end
else
{self, Cmds.none}
end
end
def view : String
"Count: #{@count}\n\nPress +/- to change, q to quit"
end
end
Term2.run(CounterModel.new)
Prelude & Aliases
Including Term2::Prelude provides convenient aliases for common types:
Model,Cmd,Cmds,Message- Core typesTC- Alias forTerm2::Components(e.g.,TC::TextInput)KeyMsg,WindowSizeMsg,FocusMsg,BlurMsg- Common messagesMouseEvent- Mouse input events
Program Options
Configure your application by passing options to Term2.run:
Term2.run(model, options: Term2::ProgramOptions.new(
WithAltScreen.new, # Use alternate screen buffer
WithMouseAllMotion.new, # Enable mouse tracking
WithReportFocus.new # Enable focus reporting
))
Available options:
WithAltScreen- Use alternate screen bufferWithMouseAllMotion- Track all mouse motion (hover)WithMouseCellMotion- Track mouse drag onlyWithReportFocus- Report focus in/out eventsWithInput(io)- Custom input sourceWithOutput(io)- Custom output destinationWithFPS(fps)- Set frame rateWithoutRenderer- Disable rendering (headless mode)WithoutCatchPanics- Disable panic recoveryWithoutBracketedPaste- Disable bracketed paste
Text Styling
Term2 provides multiple APIs for styled text output without raw escape codes:
Method 1: String Extensions (Simple Single Styles)
For quick, simple styling:
puts "Hello".bold
puts "Error!".red
puts "Warning".yellow.bold # Note: chaining returns nested codes
Method 2: S Builder (Chained/Composed Styles)
For basic color and attribute combinations:
# Method chaining with .apply()
puts S.bold.cyan.apply("Styled text")
puts S.red.on_white.underline.apply("Alert!")
# Pipe operator shorthand
puts S.green.bold | "Success!"
puts S.bright_magenta.italic | "Fancy"
# 256-color palette
puts S.fg(208).bold | "Orange"
# True color RGB
puts S.fg(100, 149, 237).bold | "Cornflower Blue"
puts S.bg(30, 30, 30).white | "Dark background"
Method 3: Fluent Style API (Recommended for Complex Styling)
For advanced styling with borders, padding, margins, alignment, and complex layouts, use the Term2::Style class:
# Create a styled box with border and padding
style = Term2::Style.new
.bold(true)
.foreground(Term2::Color::CYAN)
.padding(1, 2)
.border(Term2::Border.rounded)
.width(30)
.align(Term2::Position::Center)
puts style.render("Styled Box")
# Complex layout with multiple styles
title_style = Term2::Style.new
.bold(true)
.foreground(Term2::Color::WHITE)
.background(Term2::Color.from_hex("#3366CC"))
.padding(0, 2)
content_style = Term2::Style.new
.padding(1)
.border(Term2::Border.normal)
.width(40)
puts title_style.render("Title")
puts content_style.render("Content goes here")
Available Styles
Text Attributes:
.bold,.faint/.dim,.italic,.underline.blink,.reverse,.hidden,.strike
Foreground Colors:
- Standard:
.black,.red,.green,.yellow,.blue,.magenta,.cyan,.white,.gray - Bright:
.bright_red,.bright_green,.bright_yellow,.bright_blue,.bright_magenta,.bright_cyan,.bright_white - 256-color:
.fg(0-255) - RGB:
.fg(r, g, b)
Background Colors:
- Standard:
.on_black,.on_red,.on_green,.on_yellow,.on_blue,.on_magenta,.on_cyan,.on_white - 256-color:
.bg(0-255) - RGB:
.bg(r, g, b)
Style API Features:
The Term2::Style class provides a comprehensive fluent API:
- Text Formatting:
.bold(),.italic(),.underline(),.strikethrough(),.reverse(),.blink(),.faint() - Colors:
.foreground(),.background()(acceptsColor, hex strings, orAdaptiveColor) - Dimensions:
.width(),.height(),.max_width(),.max_height() - Alignment:
.align(),.align_horizontal(),.align_vertical() - Padding:
.padding()(CSS-style shorthand),.padding_top(),.padding_right(), etc. - Margins:
.margin()(CSS-style shorthand),.margin_top(),.margin_right(), etc. - Borders:
.border(),.border_style(),.border_top(),.border_foreground(),.border_background() - Layout:
.inline(),.tab_width(),.transform() - Rendering:
.render()to apply style to text,.to_s()for string representation
Zone System: Focus and Click Tracking
Term2 includes a built-in Zone system that incorporates BubbleZone functionality for focus and click management. This system allows components to register interactive zones and automatically receive focus/click events without requiring a separate BubbleZone dependency.
How Zones Work
The Zone system works by embedding invisible markers in the rendered output that are scanned after each frame to determine zone positions:
- Zone Registration: Components define a
zone_idand wrap their output withZone.mark(id, content) - Automatic Scanning: After each render, Term2 scans the output to extract zone positions using invisible Unicode markers
- Focus Management: Zones can be focused via Tab/Shift+Tab or mouse clicks
- Click Handling: Mouse clicks automatically dispatch to the correct zone with relative coordinates
- Keyboard Navigation: Tab and Shift+Tab cycle through registered zones
Basic Zone Usage
# Mark clickable areas
Zone.mark("button1", "Click me!")
Zone.mark("button2", "Or click me!")
# Handle clicks in update
def update(msg : Message) : {Model, Cmd}
case msg
when ZoneClickMsg
case msg.zone_id
when "button1"
# Handle button1 click at (msg.x, msg.y)
{self, Cmds.none}
when "button2"
# Handle button2 click
{self, Cmds.none}
end
when ZoneFocusMsg
# Zone gained focus
if msg.zone_id == "button1"
# Button 1 is now focused
end
{self, Cmds.none}
else
{self, Cmds.none}
end
end
# Tab through focusable zones
Zone.focus_next # or Zone.focus_prev
Component Integration with Zones
Components can easily integrate with the Zone system by implementing zone_id and using Zone.mark:
class Button
include Model
getter label : String
getter id : String
def initialize(@label, @id); end
def zone_id : String?
@id
end
def view : String
style = focused? ? Style.reverse : Style.new
Zone.mark(@id, style.apply("[#{@label}]"))
end
def focused? : Bool
Zone.focused?(@id)
end
def update(msg : Message) : {Button, Cmd}
case msg
when ZoneClickMsg
if msg.zone_id == @id && msg.action == MouseEvent::Action::Press
# Handle button click
puts "Button #{@id} clicked at (#{msg.x}, #{msg.y})"
{self, Cmds.none}
else
{self, Cmds.none}
end
when ZoneFocusMsg
if msg.zone_id == @id
# Button gained focus
{self, Cmds.none}
else
{self, Cmds.none}
end
else
{self, Cmds.none}
end
end
end
Zone Architecture
The Zone system is fully integrated into Term2's core:
- Zone Module: Provides global zone management (
Term2::Zone) - ZoneInfo: Tracks zone boundaries and coordinates
- Zone Messages:
ZoneClickMsg,ZoneFocusMsg,ZoneBlurMsgfor event handling - Automatic Integration: Built into the main event loop and render pipeline
Focus Navigation
- Tab: Move focus to next zone (cycles through all registered zones)
- Shift+Tab: Move focus to previous zone
- Mouse Click: Focus clicked zone automatically on press
- Programmatic: Use
Zone.focus(id)andZone.blur(id) - Auto-focus: Components can call
focusmethod to request focus
Advanced Zone Features
- Z-index Support: Zones can have different z-index values for overlapping areas
- Relative Coordinates: Click events include coordinates relative to zone bounds
- Efficient Scanning: Zone scanning skips ANSI escape sequences
- Spatial Indexing: Optimized zone lookup by coordinates
- Tab Cycle: Automatic focus cycling through all interactive elements
Integration with Event System
The Zone system is deeply integrated into Term2's event loop:
# In the main event loop:
if zone_click = Zone.handle_mouse(mouse_event)
# Auto-focus clicked zone on press
if mouse_event.action == MouseEvent::Action::Press
Zone.focus(zone_click.zone_id)
end
dispatch(zone_click)
end
# Tab key handling:
if key.type == KeyType::Tab
if next_id = Zone.focus_next
dispatch(ZoneFocusMsg.new(next_id))
end
end
Built-in Components with Zone Support
- TextInput: Full-featured text input with cursor navigation and automatic zone registration
- Checkbox: Toggleable checkbox with focus support
- Radio: Radio button groups with keyboard navigation
- Custom Components: Any component can implement
zone_idand useZone.mark
Layout System
Term2 provides multiple layout options:
Join Utilities
Use layout join utilities to combine styled content:
# Join content horizontally
Term2.join_horizontal(Term2::Position::Top, left_panel, right_panel)
# Join content vertically
Term2.join_vertical(Term2::Position::Left, header, content, footer)
# Place content at specific position
Term2.place(80, 24, Term2::Position::Center, Term2::Position::Center, content)
Fluent Style API
Term2 provides a complete Lipgloss-style fluent API for advanced styling and layout in Term2::Style:
style = Term2::Style.new
.bold(true)
.foreground(Term2::Color::RED)
.padding(1, 2)
.border(Term2::Border.rounded)
.width(20)
.align(Term2::Position::Center)
puts style.render("Hello Styled Text!")
The Style API provides comprehensive styling capabilities including:
- Layout utilities:
Term2.join_horizontal,Term2.join_vertical,Term2.place - Borders: Multiple border styles (normal, rounded, thick, double, hidden, block, half-block)
- Spacing: Full padding and margin control with CSS-style shorthand syntax
- Alignment: Horizontal and vertical alignment options
- Colors: Named colors, 256-color palette, true color RGB, adaptive colors, hex support
- Text formatting: Bold, italic, underline, strikethrough, reverse, blink, faint
- Dimensions: Fixed and maximum width/height constraints
- Transformations: Custom text transformation functions
Handling Input
Keyboard
def update(msg : Message) : {Model, Cmd}
case msg
when KeyMsg
case msg.key.to_s
when "q"
{self, Term2.quit}
when "up", "k"
# ...
end
end
end
Handling Input
Keyboard
def update(msg : Message, model : Model)
case msg
when KeyPress
case msg.key
when "up", "k" then move_up(model)
when "down", "j" then move_down(model)
when "enter" then select(model)
when "ctrl+c" then {model, Term2.quit}
else {model, Cmds.none}
end
else
{model, Cmds.none}
end
end
Mouse
def update(msg : Message, model : Model)
case msg
when MouseEvent
case msg.action
when MouseEvent::Action::Press
handle_click(model, msg.x, msg.y, msg.button)
when MouseEvent::Action::Move
handle_hover(model, msg.x, msg.y)
end
else
{model, nil}
end
end
Focus
def update(msg : Message, model : Model)
case msg
when FocusMsg
# Window gained focus
{model.with_focused(true), nil}
when BlurMsg
# Window lost focus
{model.with_focused(false), nil}
else
{model, nil}
end
end
Commands
# No-op
Cmds.none
# Quit the application
Term2.quit
# Batch multiple commands
Cmds.batch(cmd1, cmd2, cmd3)
# Run commands in sequence
Cmds.sequence(cmd1, cmd2, cmd3)
# Tick command for timers
Cmds.tick(1.second) { TickMsg.new }
# Send a message
Cmds.message(MyCustomMsg.new(result))
Components
Spinner
spinner = Components::Spinner.new(
frames: ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"],
interval: 100.milliseconds
)
model, cmd = spinner.init("Loading...")
ProgressBar
progress = Components::ProgressBar.new(
width: 30,
complete_char: '█',
incomplete_char: '░',
show_percentage: true
)
model, cmd = progress.init
TextInput
# Create a text input with full features
input = Components::TextInput.new(
placeholder: "Type here...",
width: 40,
prompt: "> ",
echo_mode: Components::TextInput::EchoMode::Normal,
char_limit: 100,
id: "search_input" # Zone ID for focus management
)
# Use in your model
class SearchModel
include Model
getter input : Components::TextInput
def initialize
@input = Components::TextInput.new(
placeholder: "Search...",
width: 50
)
end
def update(msg : Message) : {Model, Cmd}
case msg
when KeyMsg
# Handle input focus
if msg.key.to_s == "tab"
{self, Zone.focus_next}
else
# Delegate to text input
new_input, cmd = @input.update(msg, self)
{SearchModel.new(new_input), cmd}
end
when Zone::ClickMsg
if msg.id == "search_input"
{self, Zone.focus("search_input")}
end
else
{self, Cmds.none}
end
end
def view : String
@input.view(self)
end
end
Documentation
Development
Running Tests
# Run all tests
crystal spec
# Run interactive tests (requires TTY)
TERM2_TEST_TTY=1 crystal spec --tag interactive
# Run benchmarks
crystal run --release benchmarks/benchmark.cr
Project Structure
src/
term2.cr # Main module, Program, Cmds, KeyReader
base_types.cr # Model, Message, Key, KeyType
key_sequences.cr # Escape sequence mappings
mouse.cr # Mouse event handling
renderer.cr # StandardRenderer, NilRenderer
terminal.cr # Terminal control utilities, Cursor escapes
style.cr # Lipgloss-style fluent styling API
zone.cr # Zone system for focus/click tracking
program_options.cr # Program configuration
components/ # Built-in UI components
text_input.cr # Full-featured text input component
table.cr # Table component with StyleFunc
list.cr # List with Enumerators
tree.cr # Static tree renderer
spinner.cr # Loading spinner
progress.cr # Progress bar
viewport.cr # Scrollable viewport
cursor.cr # Cursor management
key.cr # Key binding system
Contributing
- Fork it (https://github.com/dsisnero/term2/fork)
- Create your feature branch (
git checkout -b my-new-feature) - Commit your changes (
git commit -am 'Add some feature') - Push to the branch (
git push origin my-new-feature) - Create a new Pull Request
License
MIT License - see LICENSE
term2
- 0
- 0
- 0
- 0
- 5
- about 19 hours ago
- November 26, 2025
MIT License
Sat, 10 Jan 2026 20:04:47 GMT