term2

A terminal library for Crystal

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

  1. Add the dependency to your shard.yml:

    dependencies:
      term2:
        github: dsisnero/term2
      cml:
        github: dsisnero/cml
    
  2. 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 types
  • TC - Alias for Term2::Components (e.g., TC::TextInput)
  • KeyMsg, WindowSizeMsg, FocusMsg, BlurMsg - Common messages
  • MouseEvent - 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 buffer
  • WithMouseAllMotion - Track all mouse motion (hover)
  • WithMouseCellMotion - Track mouse drag only
  • WithReportFocus - Report focus in/out events
  • WithInput(io) - Custom input source
  • WithOutput(io) - Custom output destination
  • WithFPS(fps) - Set frame rate
  • WithoutRenderer - Disable rendering (headless mode)
  • WithoutCatchPanics - Disable panic recovery
  • WithoutBracketedPaste - 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() (accepts Color, hex strings, or AdaptiveColor)
  • 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:

  1. Zone Registration: Components define a zone_id and wrap their output with Zone.mark(id, content)
  2. Automatic Scanning: After each render, Term2 scans the output to extract zone positions using invisible Unicode markers
  3. Focus Management: Zones can be focused via Tab/Shift+Tab or mouse clicks
  4. Click Handling: Mouse clicks automatically dispatch to the correct zone with relative coordinates
  5. 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, ZoneBlurMsg for 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) and Zone.blur(id)
  • Auto-focus: Components can call focus method 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_id and use Zone.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

  1. Fork it (https://github.com/dsisnero/term2/fork)
  2. Create your feature branch (git checkout -b my-new-feature)
  3. Commit your changes (git commit -am 'Add some feature')
  4. Push to the branch (git push origin my-new-feature)
  5. Create a new Pull Request

License

MIT License - see LICENSE

Repository

term2

Owner
Statistic
  • 0
  • 0
  • 0
  • 0
  • 5
  • about 19 hours ago
  • November 26, 2025
License

MIT License

Links
Synced at

Sat, 10 Jan 2026 20:04:47 GMT

Languages