diff --git a/.dialyzer_ignore.exs b/.dialyzer_ignore.exs
new file mode 100644
index 00000000..1e4163db
--- /dev/null
+++ b/.dialyzer_ignore.exs
@@ -0,0 +1,4 @@
+[
+ # MDEx 0.13.5 refers to the type of its optional Lumis dependency.
+ {"lib/mdex/document.ex", "Unknown type: Lumis.options/0."}
+]
diff --git a/.formatter.exs b/.formatter.exs
index d2cda26e..34e93ebf 100644
--- a/.formatter.exs
+++ b/.formatter.exs
@@ -1,4 +1,11 @@
# Used by "mix format"
[
- inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
+ inputs: [
+ "{mix,.formatter}.exs",
+ "{config,lib,test}/**/*.{ex,exs}",
+ "examples/iex_counter/{mix,run}.exs",
+ "examples/iex_counter/{lib,test}/**/*.{ex,exs}",
+ "examples/showcase/{mix,run,.formatter}.exs",
+ "examples/showcase/{lib,test}/**/*.{ex,exs}"
+ ]
]
diff --git a/.github/dependabot.yml b/.github/dependabot.yml
new file mode 100644
index 00000000..c1aac932
--- /dev/null
+++ b/.github/dependabot.yml
@@ -0,0 +1,25 @@
+version: 2
+updates:
+ - package-ecosystem: "mix"
+ directory: "/"
+ schedule:
+ interval: "weekly"
+ open-pull-requests-limit: 10
+ labels:
+ - "dependencies"
+ - "elixir"
+ commit-message:
+ prefix: "deps"
+ include: "scope"
+
+ - package-ecosystem: "github-actions"
+ directory: "/"
+ schedule:
+ interval: "weekly"
+ open-pull-requests-limit: 10
+ labels:
+ - "dependencies"
+ - "github-actions"
+ commit-message:
+ prefix: "deps"
+ include: "scope"
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index bb05360c..3164e170 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1,53 +1,88 @@
name: CI
on:
- push:
- branches: [main]
pull_request:
- branches: [main]
+ merge_group:
+ push:
+ branches:
+ - develop
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.ref }}
+ cancel-in-progress: true
-env:
- MIX_ENV: test
+permissions:
+ actions: read
+ contents: read
jobs:
- test:
- name: Test (Elixir ${{ matrix.elixir }} / OTP ${{ matrix.otp }})
- runs-on: ubuntu-latest
+ ci:
+ name: CI
+ uses: agentjido/github-actions/.github/workflows/jido-ci.yml@v5
+ secrets: inherit
+ with:
+ test_matrix: '[{"otp":"28","elixir":"1.18.4"},{"otp":"28","elixir":"1.19"},{"otp":"28","elixir":"1.20"},{"otp":"29","elixir":"1.20"}]'
+ format_command: mix do format --check-formatted, xref graph --format cycles --fail-above 0
+ docs_command: mix docs --warnings-as-errors -f html && mix doctor --raise
+ test_command: mix coveralls --warnings-as-errors
+ tty-nif-policy:
+ name: TTY NIF (${{ matrix.mode }}, OTP ${{ matrix.otp }}, Elixir ${{ matrix.elixir }})
+ runs-on: ubuntu-latest
strategy:
+ fail-fast: false
matrix:
include:
- - elixir: "1.19"
+ - mode: source
otp: "28"
-
+ elixir: "1.18.4"
+ - mode: source
+ otp: "29"
+ elixir: "1.20"
+ - mode: disabled
+ otp: "29"
+ elixir: "1.20"
+ env:
+ TERM_UI_TTY_NIF: ${{ matrix.mode }}
steps:
- - name: Checkout code
- uses: actions/checkout@v4
-
- - name: Setup Elixir
- uses: erlef/setup-beam@v1
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ - uses: erlef/setup-beam@fc68ffb90438ef2936bbb3251622353b3dcb2f93 # v1.24.0
with:
- elixir-version: ${{ matrix.elixir }}
otp-version: ${{ matrix.otp }}
+ elixir-version: ${{ matrix.elixir }}
+ - name: Compile selected native policy
+ run: |
+ mix deps.get
+ mix compile --warnings-as-errors
+ - name: Verify selected native policy
+ run: |
+ if [ "$TERM_UI_TTY_NIF" = "source" ]; then
+ mix run --no-start --no-compile -e \
+ 'unless TermUI.Terminal.TtyNif.ensure_loaded() == :ok and TermUI.Terminal.TtyNif.loaded?(), do: raise("TTY NIF did not load")'
+ else
+ test ! -e _build/dev/lib/term_ui/priv/term_ui_tty_nif.so
+ mix run --no-start --no-compile test/support/no_nif_backend_probe.exs
+ fi
+ - name: Test selected native policy
+ run: mix test --warnings-as-errors
- - name: Cache dependencies
- uses: actions/cache@v4
+ examples:
+ name: Example (${{ matrix.directory }})
+ runs-on: ubuntu-latest
+ strategy:
+ fail-fast: false
+ matrix:
+ directory: [iex_counter, showcase]
+ steps:
+ - uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ - uses: erlef/setup-beam@fc68ffb90438ef2936bbb3251622353b3dcb2f93 # v1.24.0
with:
- path: |
- deps
- _build
- key: ${{ runner.os }}-mix-${{ matrix.otp }}-${{ matrix.elixir }}-${{ hashFiles('**/mix.lock') }}
- restore-keys: |
- ${{ runner.os }}-mix-${{ matrix.otp }}-${{ matrix.elixir }}-
-
- - name: Install dependencies
- run: mix deps.get
-
- - name: Check formatting
- run: mix format --check-formatted
-
- - name: Compile with warnings as errors
- run: mix compile --warnings-as-errors
-
- - name: Run tests
- run: mix test
+ otp-version: "29"
+ elixir-version: "1.20"
+ - name: Compile and test example
+ working-directory: examples/${{ matrix.directory }}
+ run: |
+ mix deps.get
+ mix deps.unlock --check-unused
+ git diff --exit-code -- mix.lock
+ mix test --warnings-as-errors
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 00000000..07ccc640
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,59 @@
+name: Release
+
+on:
+ push:
+ tags:
+ - "v*"
+ workflow_dispatch:
+ inputs:
+ operation:
+ description: "Release operation: auto, prepare, or publish"
+ required: false
+ type: choice
+ default: auto
+ options:
+ - auto
+ - prepare
+ - publish
+ tag_name:
+ description: "Optional v-prefixed tag for publish simulation"
+ required: false
+ type: string
+ default: ""
+ dry_run:
+ description: "Dry run (no git push, no tag, no GitHub release, no Hex publish)"
+ required: false
+ type: boolean
+ default: false
+ hex_dry_run:
+ description: "Hex dry run only (run all git/release steps, but skip actual Hex publish)"
+ required: false
+ type: boolean
+ default: false
+ skip_tests:
+ description: "Skip tests before release"
+ required: false
+ type: boolean
+ default: false
+ version_override:
+ description: "Optional bare SemVer override (for example 1.2.3, not v1.2.3)"
+ required: false
+ type: string
+ default: ""
+
+permissions:
+ actions: write
+ contents: write
+
+jobs:
+ release:
+ name: Release
+ uses: agentjido/github-actions/.github/workflows/jido-release.yml@v5
+ with:
+ operation: ${{ inputs.operation || 'auto' }}
+ tag_name: ${{ inputs.tag_name || '' }}
+ dry_run: ${{ inputs.dry_run || false }}
+ hex_dry_run: ${{ inputs.hex_dry_run || false }}
+ skip_tests: ${{ inputs.skip_tests || false }}
+ version_override: ${{ inputs.version_override || '' }}
+ secrets: inherit
diff --git a/.github/workflows/review.yml b/.github/workflows/review.yml
new file mode 100644
index 00000000..afdd8301
--- /dev/null
+++ b/.github/workflows/review.yml
@@ -0,0 +1,21 @@
+name: Jido Review
+
+on:
+ pull_request:
+ branches:
+ - develop
+
+concurrency:
+ group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: true
+
+permissions:
+ actions: read
+ contents: read
+ issues: write
+ pull-requests: write
+
+jobs:
+ review:
+ name: Jido Review
+ uses: agentjido/github-actions/.github/workflows/jido-review.yml@v5
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 00000000..2f9d8ad2
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,17 @@
+# TermUI Agent Instructions
+
+TermUI is a small Elm-style terminal runtime for Elixir and the BEAM.
+
+Keep these runtime boundaries:
+
+- One runtime process owns application state and update order.
+- Application views return one complete `TermUI.Frame`.
+- One backend owner controls terminal input, output, size, capabilities, and cleanup.
+- Widgets are pure. The parent application owns widget state and effects.
+- Commands are data values. Do not run effects in widget code.
+
+Use `develop` as the pull request target. Preserve the public `TermUI` namespace
+and the Jido Console runtime contract. Run `mix quality` and `mix coveralls`
+before a commit. Terminal lifecycle changes also need a real terminal check.
+
+Use Conventional Commits. Never mention an AI assistant in a commit message.
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b7e59041..5493f7a8 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,72 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
+### Added
+
+- Added a tested interactive showcase for live widgets, input, rich content,
+ BEAM snapshots, and the TermUI architecture.
+- Added pure checkbox, toggle, radio group, select, spinner, and breadcrumb
+ widgets, with a Controls page in the showcase.
+- Added pure row, column, fixed-grid, inset, placement, and mouse-region layout
+ helpers.
+- Added complete raw control-byte input on OTP 28 and OTP 29. A small native
+ terminal layer now passes Ctrl+O, Ctrl+C, Ctrl+S, and Ctrl+Q to applications
+ and restores the original terminal flags during shutdown.
+- Restored the general widget feature set as parent-owned pure widgets under
+ `TermUI.Widget`.
+- Added an MDEx Markdown renderer and scrollable Markdown viewer.
+- Added unified and side-by-side terminal diff views.
+- Added frame overlay composition for widget frames.
+- Added Zoi schemas for public boundary data, including cells, styles, frames,
+ events, commands, clipboard operations, mouse regions, selections, and table
+ columns.
+- Added bounded clipboard commands that run through the serialized backend owner.
+- Added pure Unicode grapheme selection for single-line and multiline text input.
+- Added pure mouse regions, local-coordinate routing, hover state, and drag tracking.
+- Added local mouse behavior for interactive widgets, scrollbars, and split panes.
+
+### Changed
+
+- Private backend, parser, stream, and widget state now uses plain structs.
+ Zoi remains at public data boundaries instead of defining every struct.
+- Boundary schemas now validate colors, cell invariants, frame bounds, nested
+ cells, and command payloads.
+- Process, stream, supervision, and cluster widgets now render data snapshots
+ supplied by the parent application. They do not own effect processes.
+- Markdown rendering and incremental documents now share one internal parser
+ dependency, which removes their cross-reference cycle.
+- Buttons, lists, menus, tabs, trees, blocks, and dialogs now support richer
+ decoration, disabled-item navigation, and child-frame composition.
+
+### Fixed
+
+- Unsupported terminal control sequences no longer emit their parameter bytes
+ as application text.
+- Frame mutations preserve wide-grapheme pairs and cannot place a wide
+ grapheme past the frame boundary.
+- Timed-out terminal command tasks are now stopped.
+- SGR mouse releases retain their button, and X10 releases are no longer
+ reported as presses.
+
+## [1.0.0-rc] - 2026-08-19
+
+### Changed
+
+- Replaced the component process system with one Elm application runtime.
+- Made `TermUI.Frame` the only application render value.
+- Moved terminal lifecycle, input, output, size, cursor, and capabilities into backends.
+- Split printable text from named and modified key events.
+- Replaced effect tuples with `TermUI.Command` data.
+- Replaced process widgets with parent-owned pure widgets.
+
+### Removed
+
+- Removed component servers, registries, supervisors, event routers, and focus managers.
+- Removed legacy input handlers, render nodes, renderer buffers, and duplicate widget namespaces.
+- Removed the SSH backend until it can own a complete terminal session lifecycle.
+
+See `guides/migration-1.0.md` for the replacement map.
+
## [0.2.0] - 2024-12-01
### Added
@@ -95,6 +161,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- Developer guides (architecture, runtime, rendering, events, buffers, terminal, creating widgets)
- Widget examples with READMEs
-[Unreleased]: https://github.com/pcharbon70/term_ui/compare/v0.2.0...HEAD
-[0.2.0]: https://github.com/pcharbon70/term_ui/compare/v0.1.0...v0.2.0
-[0.1.0]: https://github.com/pcharbon70/term_ui/releases/tag/v0.1.0
+[Unreleased]: https://github.com/mikehostetler/term_ui/compare/v1.0.0-rc...HEAD
+[1.0.0-rc]: https://github.com/mikehostetler/term_ui/compare/v0.2.0...v1.0.0-rc
+[0.2.0]: https://github.com/mikehostetler/term_ui/compare/v0.1.0...v0.2.0
+[0.1.0]: https://github.com/mikehostetler/term_ui/releases/tag/v0.1.0
diff --git a/CLAUDE.md b/CLAUDE.md
index 63da7178..c24a63f0 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -4,23 +4,23 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Overview
-TermUI is a direct-mode Terminal UI framework for Elixir/BEAM, currently in the research and design phase. The goal is to build a world-class TUI framework that leverages BEAM's unique strengths (fault tolerance, actor model, hot code reloading, distribution) while adopting proven patterns from modern TUI frameworks like BubbleTea (Go) and Ratatui (Rust).
+TermUI is a small Elm-style terminal runtime for Elixir and the BEAM.
## Target Architecture
-The framework uses The Elm Architecture adapted for OTP with three abstraction layers:
+The runtime has three clear boundaries:
-1. **Port layer** - Low-level terminal interface (raw mode, escape sequences, capability detection)
-2. **Renderer layer** - Virtual screen buffer with differential updates (ETS-based double buffering)
-3. **Widget layer** - OTP-based component system with supervision
+1. **Application** - One runtime process owns application state and serializes updates.
+2. **Frame** - Pure views return one canonical `TermUI.Frame`.
+3. **Backend** - One backend owner serializes input, output, size, capabilities, and cleanup.
### Key Design Decisions
- **OTP 28+ only** - Uses native raw mode via `shell.start_interactive({:noshell, :raw})`
-- **Process-per-component** for interactive widgets, shared state for static display elements
-- **Framerate-limited rendering** (60 FPS default) with intelligent diffing
-- **Cassowary constraint solver** for layouts with LRU caching
-- **Commands pattern** for side effects (async operations return messages to update loop)
+- **Pure widgets** with state owned by the parent application
+- **Coalesced frame scheduling** with one final meaningful render
+- **One normalized event model** for keys, text, paste, mouse, resize, and focus
+- **Data commands** for messages, timers, asynchronous work, and shutdown
### Platform Targets
@@ -30,21 +30,16 @@ The framework uses The Elm Architecture adapted for OTP with three abstraction l
## Project Status
-Currently in research phase. The `notes/research/state_of_tui.md` contains comprehensive analysis of:
-- Historical terminal architecture (terminfo, curses, VT100)
-- Modern TUI frameworks (BubbleTea, Ratatui, Textual, FTXUI, etc.)
-- BEAM-specific patterns (GenServer, Supervisors, GenStage, Ports vs NIFs)
-- Direct mode programming requirements
-- Proposed architecture and implementation roadmap
+The `1.0.0-rc` design is implemented. Files under `notes/` are historical research and are not current architecture guidance.
## Development Notes
-When implementation begins, follow these patterns:
+Follow these patterns:
-- Use **GenServer** for stateful widgets with clear message-based APIs
-- Use **Supervisors** to mirror UI component hierarchies for fault isolation
-- Prefer **Ports over NIFs** for terminal I/O (crash isolation)
-- Use **ETS tables** for render buffers (`:screen_current`, `:screen_previous`)
-- Implement **cursor optimization** (compare cost of absolute vs relative positioning)
-- Support graceful degradation for terminal features (true color → 256 → 16 → mono)
-- IMPORTANT you must NEVER mention Claude or any AI assistant in your commit messages!
\ No newline at end of file
+- Keep application and widget transitions pure.
+- Keep terminal implementation logic in backends.
+- Return `TermUI.Frame` directly from application views.
+- Keep backend callback state under one serialized owner.
+- Use `TermUI.Command` values for effects.
+- Preserve graceful degradation for terminal features.
+- IMPORTANT you must NEVER mention Claude or any AI assistant in your commit messages!
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 00000000..78a281b7
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,32 @@
+# Contributing to TermUI
+
+Use the `develop` branch as the pull request target. Keep changes focused and
+include tests for behavior changes.
+
+Before you submit a pull request, run:
+
+```bash
+mix deps.get
+mix quality
+mix coveralls
+mix deps.unlock --check-unused
+mix hex.audit
+mix docs --warnings-as-errors -f html
+HEX_API_KEY=dry-run mix hex.publish --dry-run --yes
+```
+
+TermUI uses the shared v5 Jido CI, review, and release workflows. Dependabot
+checks Mix and GitHub Actions dependencies each week. Use Conventional Commits.
+Do not edit `CHANGELOG.md` in a normal pull request. `git_ops` creates release
+notes from commit history during release preparation.
+
+Terminal lifecycle changes also need a manual check in a real terminal. Verify
+normal exit, application failure, backend failure, and forced process exit.
+After each case, confirm that cooked input, the cursor, style, paste mode,
+focus events, mouse tracking, and the active screen are restored.
+
+The supported runtime matrix is Elixir 1.18.4 or later on OTP 28 or later. CI
+tests Elixir 1.18.4, 1.19, and 1.20 on OTP 28, and Elixir 1.20 on OTP 29.
+
+See [Package quality](guides/package-quality.md) for the Jido standard and the
+documented compatibility exceptions.
diff --git a/Makefile b/Makefile
new file mode 100644
index 00000000..aecadfc1
--- /dev/null
+++ b/Makefile
@@ -0,0 +1,32 @@
+PRIV_DIR = $(MIX_APP_PATH)/priv
+
+ifeq ($(OS),Windows_NT)
+NIF_EXT = .dll
+LDFLAGS = -shared
+else
+NIF_EXT = .so
+ifeq ($(shell uname -s),Darwin)
+LDFLAGS = -dynamiclib -undefined dynamic_lookup
+else
+LDFLAGS = -shared
+endif
+endif
+
+NIF = $(PRIV_DIR)/term_ui_tty_nif$(NIF_EXT)
+SRC = c_src/term_ui_tty_nif.c
+
+CFLAGS += -O2 -Wall -Wextra -fPIC -I"$(ERTS_INCLUDE_DIR)"
+
+.PHONY: all clean
+
+all: $(NIF)
+
+$(PRIV_DIR):
+ test ! -L $@ || test -e $@ || rm -f $@
+ mkdir -p $@
+
+$(NIF): $(SRC) | $(PRIV_DIR)
+ $(CC) $(CFLAGS) $(LDFLAGS) -o $@ $<
+
+clean:
+ rm -f $(NIF)
diff --git a/Makefile.win b/Makefile.win
new file mode 100644
index 00000000..9fe2475e
--- /dev/null
+++ b/Makefile.win
@@ -0,0 +1,17 @@
+PRIV_DIR = $(MIX_APP_PATH)/priv
+NIF = $(PRIV_DIR)/term_ui_tty_nif.dll
+OBJ = $(PRIV_DIR)/term_ui_tty_nif.obj
+LIB = $(PRIV_DIR)/term_ui_tty_nif.lib
+SRC = c_src/term_ui_tty_nif.c
+
+all: $(NIF)
+
+$(NIF): $(SRC)
+ @if not exist "$(PRIV_DIR)" mkdir "$(PRIV_DIR)"
+ cl /nologo /O2 /W4 /LD /I"$(ERTS_INCLUDE_DIR)" /Fo:"$(OBJ)" /Fe:"$(NIF)" "$(SRC)" /link /IMPLIB:"$(LIB)"
+
+clean:
+ @if exist "$(NIF)" del /Q "$(NIF)"
+ @if exist "$(OBJ)" del /Q "$(OBJ)"
+ @if exist "$(LIB)" del /Q "$(LIB)"
+ @if exist "$(PRIV_DIR)/term_ui_tty_nif.exp" del /Q "$(PRIV_DIR)/term_ui_tty_nif.exp"
diff --git a/README.md b/README.md
index 27c170f9..7dbab036 100644
--- a/README.md
+++ b/README.md
@@ -1,233 +1,223 @@
# TermUI
-[](https://hex.pm/packages/term_ui)
-[](https://hexdocs.pm/term_ui)
-[](https://github.com/pcharbon70/term_ui/blob/main/LICENSE)
+TermUI is a small terminal runtime for Elixir and the BEAM. It uses the Elm
+architecture and has one render value: `TermUI.Frame`.
-A direct-mode Terminal UI framework for Elixir/BEAM, inspired by [BubbleTea](https://github.com/charmbracelet/bubbletea) (Go) and [Ratatui](https://github.com/ratatui-org/ratatui) (Rust).
+The runtime owns application state, command execution, frame timing, and
+shutdown. A backend owns terminal setup, input, output, size, cursor state,
+capabilities, and cleanup.
-TermUI leverages BEAM's unique strengths—fault tolerance, actor model, hot code reloading—to build robust terminal applications using The Elm Architecture.
+Jido Console uses TermUI as its terminal runtime. TermUI does not depend on
+Jido, so it also remains useful as a general Elixir terminal package.
-
-
-
-
-
+## Install
-## Features
+TermUI requires Elixir 1.18.4 or later and Erlang/OTP 28 or later. CI tests
+Elixir 1.18.4, 1.19, and 1.20 on OTP 28, and Elixir 1.20 on OTP 29.
-- **Elm Architecture** - Predictable state management with `init/update/view`
-- **Rich Widget Library** - Gauges, tables, menus, charts, dialogs, and more
-- **Efficient Rendering** - Double-buffered differential updates at 60 FPS
-- **Themable** - True color RGB support (16 million colors)
-- **Cross-Platform** - Linux, macOS, Windows 10+ terminal support
-- **OTP Integration** - Supervision trees, fault tolerance, hot code reload
-- **IEx Compatible** - Run TUI applications directly in IEx for interactive development
+Add the release candidate to `mix.exs`:
-## IEx Compatibility
+```elixir
+def deps do
+ [
+ {:term_ui, "~> 1.0.0-rc.1"}
+ ]
+end
+```
-TermUI applications work directly in IEx with no code changes. This is perfect for:
-- Interactive debugging and development
-- Admin tools and dashboards in production IEx sessions
-- Prototyping and testing TUI interfaces
+TermUI uses MDEx to parse Markdown for terminal display. It uses Zoi schemas
+for public data that crosses application, runtime, backend, or configuration
+boundaries. Private runtime and widget state uses plain structs. TermUI has an
+optional small C NIF for complete control-key input in the local raw backend
+on OTP 28 and OTP 29. The TTY, SSH, and deterministic test backends do not
+need this NIF or a source compiler.
-### Running in IEx
+## Application contract
```elixir
-# In your IEx session
-iex> TermUI.Runtime.run(root: MyApp.Counter)
-# Use arrow keys, press Q to quit, returns to IEx prompt
-```
+defmodule Counter do
+ use TermUI.Elm
-### How It Works
+ alias TermUI.{Command, Event, Frame, Style}
-TermUI uses Erlang's `:io.get_chars/2` for input instead of Elixir's `IO` module wrapper. This bypasses IEx's input interception, allowing TUI applications to receive keyboard input directly.
+ def init(opts) do
+ %{count: 0, dimensions: Keyword.fetch!(opts, :dimensions)}
+ end
-### Detection and Configuration
+ def event_to_msg(%Event.Key{key: :up}, _state), do: {:msg, :increment}
+ def event_to_msg(%Event.Key{key: :down}, _state), do: {:msg, :decrement}
+ def event_to_msg(%Event.Text{text: text}, _state) when text in ["q", "Q"],
+ do: {:msg, :quit}
-You can detect if your application is running in IEx:
+ def event_to_msg(%Event.Resize{width: width, height: height}, _state),
+ do: {:msg, {:resize, width, height}}
-```elixir
-iex> TermUI.iex_mode?()
-true
+ def event_to_msg(_event, _state), do: :ignore
-iex> TermUI.running_mode()
-:iex
-```
+ def update(:increment, state), do: %{state | count: state.count + 1}
+ def update(:decrement, state), do: %{state | count: state.count - 1}
+ def update(:quit, state), do: {state, [Command.shutdown()]}
+ def update({:resize, width, height}, state), do: %{state | dimensions: {width, height}}
-Force IEx-compatible mode via configuration:
+ def view(%{count: count, dimensions: {width, height}}) do
+ heading = Style.new(fg: :cyan, attrs: [:bold])
-```elixir
-# config/config.exs
-config :term_ui,
- iex_compatible: true
+ Frame.from_rows(
+ [[{"Counter", heading}], "", "Count: #{count}", "", "Up/Down: change Q: quit"],
+ width,
+ height
+ )
+ end
+end
+
+TermUI.run(Counter)
```
-Or via environment variable:
+`init/1` receives `:dimensions` as `{columns, rows}`. Printable input arrives
+as `Event.Text`. Named and modified keys arrive as `Event.Key`. Paste, mouse,
+resize, and focus input have separate event types.
-```bash
-export TERM_UI_IEX_MODE=true
-```
+External input adapters can use `TermUI.Input` as one normalization boundary.
+Its helpers keep printable text and committed composition in `Event.Text`,
+paste in `Event.Paste`, and named or modified keys in `Event.Key`. It rejects
+an unmodified printable value passed as a special key.
+
+`view/1` must return one complete `TermUI.Frame`. A frame contains its size,
+cells, and optional cursor. The cursor is `{column, row}` and is one-based.
+
+## Effects
+
+`update/2` returns new state or `{new_state, commands}`. Commands are data:
+
+- `Command.message/1` queues an application message.
+- `Command.send/2` sends data to another process.
+- `Command.timer/2` queues a later application message.
+- `Command.async/2` runs work outside the runtime process. The function can
+ return any term. The runtime wraps a normal return as `{:ok, value}` and a
+ raised, thrown, or exited function as `{:error, reason}`. Its mapper always
+ receives this one runtime-produced result. For example, a function return of
+ `{:ok, value}` reaches the mapper as `{:ok, {:ok, value}}`.
+- `TermUI.Clipboard.copy/2` and `TermUI.Clipboard.clear/1` request bounded,
+ serialized OSC 52 clipboard output.
+- `Command.shutdown/1` requests one final render and cleanup.
-### Important Notes
-
-- **Arrow keys work immediately** - No need to press Enter for navigation
-- **All keyboard shortcuts work** - Including Tab, Enter, Escape, function keys
-- **Clean shutdown** - Terminal state is restored when the app exits
-- **IEx remains responsive** - The TUI app can be exited to return to IEx prompt
-
-## Widgets
-
-| Widget | Description |
-|--------|-------------|
-| **Gauge** | Progress bar with color zones |
-| **Sparkline** | Compact inline trend graph |
-| **Table** | Scrollable data table with selection and sorting |
-| **Menu** | Hierarchical menu with submenus |
-| **TextInput** | Single-line and multi-line text input |
-| **Dialog** | Modal dialog with buttons |
-| **PickList** | Modal selection with type-ahead filtering |
-| **Tabs** | Tabbed interface for switchable panels |
-| **AlertDialog** | Modal dialog for confirmations with standard button configurations |
-| **ContextMenu** | Right-click context menu with keyboard and mouse support |
-| **Toast** | Auto-dismissing notifications with stacking |
-| **Viewport** | Scrollable view with keyboard and mouse support |
-| **SplitPane** | Resizable multi-pane layouts for IDE-style interfaces |
-| **TreeView** | Hierarchical data display with expand/collapse |
-| **FormBuilder** | Structured forms with validation and multiple field types |
-| **CommandPalette** | VS Code-style command discovery with fuzzy search |
-| **BarChart** | Horizontal/vertical bar charts for categorical data |
-| **LineChart** | Line charts using Braille characters for sub-character resolution |
-| **Canvas** | Direct drawing surface for custom visualizations |
-| **LogViewer** | High-performance log viewer with virtual scrolling and filtering |
-| **StreamWidget** | GenStage-integrated widget with backpressure support |
-| **ProcessMonitor** | Live BEAM process inspection with sorting and filtering |
-| **SupervisionTreeViewer** | OTP supervision hierarchy visualization |
-| **ClusterDashboard** | Distributed Erlang cluster monitoring |
-
-## Installation
-
-Add `term_ui` to your dependencies in `mix.exs`:
+## Pure widgets
+
+A widget is not a process. The parent application owns widget state. It sends
+events to the widget and composes the returned frame into its application frame.
```elixir
-def deps do
- [
- {:term_ui, "~> 0.2.0"}
- ]
-end
+list = TermUI.Widget.List.init(items: ["one", "two"])
+{list, messages} = TermUI.Widget.List.update(event, list)
+list_frame = TermUI.Widget.List.view(list, {30, 10})
+frame = TermUI.Frame.overlay(frame, list_frame, 2, 3)
```
-## Quick Start
+Use `TermUI.Mouse` to route global events to local widget coordinates. Use
+`TermUI.Widget.mouse/4` to apply the local event. `TextInput` and `TextArea`
+support keyboard and mouse selection. Copy and cut return `{:copy, text}` to
+the parent, which can return a `TermUI.Clipboard.copy/2` command.
-```elixir
-defmodule Counter do
- use TermUI.Elm
+The supplied pure widgets include:
- alias TermUI.Event
- alias TermUI.Renderer.Style
+- Text: label, single-line input, validated line input, multiline text area,
+ Markdown viewer, log viewer, stream view, and diff viewer.
+- Selection: button, checkbox, toggle, radio group, select, list, pick list,
+ menu, context menu, command palette, tabs, table, tree view, and forms.
+- Layout and feedback: block, breadcrumb, dialog, alert dialog, split pane,
+ viewport, scrollbar, spinner, and toast.
+- Data views: progress, gauge, sparkline, bar chart, line chart, canvas,
+ process snapshots, supervision trees, and cluster snapshots.
- def init(_opts), do: %{count: 0}
+System views accept data snapshots from the parent. They do not start polling
+processes or perform RPC.
- def event_to_msg(%Event.Key{key: :up}, _state), do: {:msg, :increment}
- def event_to_msg(%Event.Key{key: :down}, _state), do: {:msg, :decrement}
- def event_to_msg(%Event.Key{key: "q"}, _state), do: {:msg, :quit}
- def event_to_msg(_, _), do: :ignore
-
- def update(:increment, state), do: {%{state | count: state.count + 1}, []}
- def update(:decrement, state), do: {%{state | count: state.count - 1}, []}
- def update(:quit, state), do: {state, [:quit]}
-
- def view(state) do
- stack(:vertical, [
- text("Counter Example", Style.new(fg: :cyan, attrs: [:bold])),
- text("", nil),
- text("Count: #{state.count}", nil),
- text("", nil),
- text("↑/↓ to change, Q to quit", Style.new(fg: :bright_black))
- ])
- end
-end
+`TermUI.Layout` supplies pure row, column, and fixed-grid rectangle allocation.
+The parent renders child frames and places them in these zero-based rectangles.
-# Run the application
-TermUI.Runtime.run(root: Counter)
-```
+## Streaming and application UI state
-## Documentation
-
-### User Guides
-
-| Guide | Description |
-|-------|-------------|
-| [Overview](https://github.com/pcharbon70/term_ui/blob/main/guides/user/01-overview.md) | Introduction to TermUI concepts |
-| [Getting Started](https://github.com/pcharbon70/term_ui/blob/main/guides/user/02-getting-started.md) | First steps and setup |
-| [Elm Architecture](https://github.com/pcharbon70/term_ui/blob/main/guides/user/03-elm-architecture.md) | Understanding init/update/view |
-| [Events](https://github.com/pcharbon70/term_ui/blob/main/guides/user/04-events.md) | Handling keyboard and mouse input |
-| [Styling](https://github.com/pcharbon70/term_ui/blob/main/guides/user/05-styling.md) | Colors, attributes, and themes |
-| [Layout](https://github.com/pcharbon70/term_ui/blob/main/guides/user/06-layout.md) | Arranging components on screen |
-| [Widgets](https://github.com/pcharbon70/term_ui/blob/main/guides/user/07-widgets.md) | Using built-in widgets |
-| [Terminal](https://github.com/pcharbon70/term_ui/blob/main/guides/user/08-terminal.md) | Terminal capabilities and modes |
-| [Commands](https://github.com/pcharbon70/term_ui/blob/main/guides/user/09-commands.md) | Side effects and async operations |
-| [Advanced Widgets](https://github.com/pcharbon70/term_ui/blob/main/guides/user/10-advanced-widgets.md) | Navigation, visualization, streaming, and BEAM introspection widgets |
-
-### Developer Guides
-
-| Guide | Description |
-|-------|-------------|
-| [Architecture Overview](https://github.com/pcharbon70/term_ui/blob/main/guides/developer/01-architecture-overview.md) | System layers and design |
-| [Runtime Internals](https://github.com/pcharbon70/term_ui/blob/main/guides/developer/02-runtime-internals.md) | GenServer event loop and state |
-| [Rendering Pipeline](https://github.com/pcharbon70/term_ui/blob/main/guides/developer/03-rendering-pipeline.md) | View to terminal output stages |
-| [Event System](https://github.com/pcharbon70/term_ui/blob/main/guides/developer/04-event-system.md) | Input parsing and dispatch |
-| [Buffer Management](https://github.com/pcharbon70/term_ui/blob/main/guides/developer/05-buffer-management.md) | ETS double buffering |
-| [Terminal Layer](https://github.com/pcharbon70/term_ui/blob/main/guides/developer/06-terminal-layer.md) | Raw mode and ANSI sequences |
-| [Elm Implementation](https://github.com/pcharbon70/term_ui/blob/main/guides/developer/07-elm-implementation.md) | Elm Architecture for OTP |
-| [Creating Widgets](https://github.com/pcharbon70/term_ui/blob/main/guides/developer/08-creating-widgets.md) | How to build and contribute widgets |
-| [Testing Framework](https://github.com/pcharbon70/term_ui/blob/main/guides/developer/09-testing-framework.md) | Component and widget testing |
-
-## Examples
-
-The `examples/` directory contains standalone applications demonstrating each widget:
-
-| Example | Description |
-|---------|-------------|
-| [alert_dialog](https://github.com/pcharbon70/term_ui/tree/main/examples/alert_dialog) | Confirmation dialogs with standard buttons |
-| [bar_chart](https://github.com/pcharbon70/term_ui/tree/main/examples/bar_chart) | Horizontal and vertical bar charts |
-| [canvas](https://github.com/pcharbon70/term_ui/tree/main/examples/canvas) | Free-form drawing with box/braille characters |
-| [cluster_dashboard](https://github.com/pcharbon70/term_ui/tree/main/examples/cluster_dashboard) | Distributed Erlang cluster monitoring |
-| [command_palette](https://github.com/pcharbon70/term_ui/tree/main/examples/command_palette) | VS Code-style command discovery |
-| [context_menu](https://github.com/pcharbon70/term_ui/tree/main/examples/context_menu) | Right-click context menus |
-| [dashboard](https://github.com/pcharbon70/term_ui/tree/main/examples/dashboard) | System monitoring dashboard with multiple widgets |
-| [dialog](https://github.com/pcharbon70/term_ui/tree/main/examples/dialog) | Modal dialogs with buttons |
-| [form_builder](https://github.com/pcharbon70/term_ui/tree/main/examples/form_builder) | Structured forms with validation |
-| [gauge](https://github.com/pcharbon70/term_ui/tree/main/examples/gauge) | Progress bars and percentage indicators |
-| [line_chart](https://github.com/pcharbon70/term_ui/tree/main/examples/line_chart) | Braille-based line charts |
-| [log_viewer](https://github.com/pcharbon70/term_ui/tree/main/examples/log_viewer) | Real-time log display with filtering |
-| [menu](https://github.com/pcharbon70/term_ui/tree/main/examples/menu) | Nested menus with keyboard navigation |
-| [pick_list](https://github.com/pcharbon70/term_ui/tree/main/examples/pick_list) | Modal selection with type-ahead |
-| [process_monitor](https://github.com/pcharbon70/term_ui/tree/main/examples/process_monitor) | Live BEAM process inspection |
-| [sparkline](https://github.com/pcharbon70/term_ui/tree/main/examples/sparkline) | Inline data visualization |
-| [split_pane](https://github.com/pcharbon70/term_ui/tree/main/examples/split_pane) | Resizable multi-pane layouts |
-| [stream_widget](https://github.com/pcharbon70/term_ui/tree/main/examples/stream_widget) | Backpressure-aware data streaming |
-| [supervision_tree_viewer](https://github.com/pcharbon70/term_ui/tree/main/examples/supervision_tree_viewer) | OTP supervision hierarchy |
-| [table](https://github.com/pcharbon70/term_ui/tree/main/examples/table) | Scrollable data tables with selection |
-| [tabs](https://github.com/pcharbon70/term_ui/tree/main/examples/tabs) | Tab-based navigation |
-| [text_input](https://github.com/pcharbon70/term_ui/tree/main/examples/text_input) | Single and multi-line text input |
-| [toast](https://github.com/pcharbon70/term_ui/tree/main/examples/toast) | Auto-dismissing notifications |
-| [tree_view](https://github.com/pcharbon70/term_ui/tree/main/examples/tree_view) | Hierarchical data with expand/collapse |
-| [viewport](https://github.com/pcharbon70/term_ui/tree/main/examples/viewport) | Scrollable content areas |
-
-```bash
-# Run any example
-cd examples/dashboard
-mix deps.get
-mix termui.run
-```
+`TermUI.Stream.ProducerAdapter` is an optional bounded bridge for external
+token producers. It sends one batch at a time and waits for an explicit
+acknowledgement. The application applies each batch with
+`TermUI.Widget.Stream.push_many/2`. Stream state provides drop-oldest,
+drop-newest, and whole-batch reject policies with visible counters.
+
+`TermUI.Theme`, `TermUI.Focus`, and `TermUI.Shortcut` are pure values for
+themes, focus traversal, and timestamp-bounded key sequences. They have no
+registry or service process.
+
+`TermUI.Widget.Viewport` can expose geometry and render draggable local
+scrollbars. `TermUI.Widget.SplitPane` supports named multi-pane layouts,
+collapse state, keyboard resize, and local separator drag.
+
+## Markdown and diffs
+
+`TermUI.Widget.MarkdownViewer` uses MDEx and supports CommonMark headings,
+emphasis, links, quotes, lists, tasks, code blocks, rules, and tables.
+Completed top-level blocks are parsed once. Only the unfinished streaming tail is
+reparsed as new fragments arrive.
-## Requirements
+`TermUI.Widget.DiffViewer` accepts `:before` and `:after` text or a
+`:unified_diff`. It supports unified and side-by-side terminal views.
+
+## Backends
+
+Use `:auto`, `:raw`, or `:tty` with the `:backend` option. Tests can use the
+public deterministic backend or inject another module that implements
+`TermUI.Backend`.
+
+```elixir
+TermUI.start_link(Counter,
+ backend: {TermUI.Test.DeterministicBackend, owner: self(), size: {8, 40}}
+)
+```
-- Elixir 1.15+
-- OTP 28+ (required for native raw terminal mode)
-- Terminal with Unicode support
+`TermUI.Test.DeterministicBackend` captures complete frames and final shutdown
+state. It supports normalized event and resize injection without a terminal or
+TTY NIF. See the [backend guide](guides/backend.md).
+
+Raw mode needs OTP 28 or later. TTY mode is the fallback when raw mode is not
+available. `TermUI.Backend.SSH` runs one isolated v2 runtime for each remote
+session. Applications that own an SSH server can use its direct session API.
+OTP SSH daemons can use `TermUI.Backend.SSH.Channel` as their `:ssh_cli`
+callback. The host keeps control of authentication and session limits.
+
+### Optional local TTY NIF
+
+`TERM_UI_TTY_NIF` controls the native build:
+
+- `auto` is the default. It builds the NIF from source when `make` and a C
+ compiler are available. If a tool is absent, it uses the pure BEAM TTY
+ fallback.
+- `source` requires a source build. A build error names each missing tool and
+ identifies the non-native backend paths.
+- `disabled` does not build the NIF. Use this mode for SSH servers, tests, and
+ installations that use `backend: :tty`.
+
+The NIF loads only when the local raw backend needs the native control-flag
+fallback. It does not load when the runtime uses the TTY, SSH, deterministic,
+or another custom backend. TermUI does not ship precompiled NIF artifacts.
+This policy keeps platform binaries out of the package and uses the existing
+pure BEAM TTY backend when a source build is not available.
+
+## Documents
+
+- [Architecture](guides/architecture.md)
+- [UI context decision](guides/ui-context.md)
+- [Backend contract](guides/backend.md)
+- [Pure widgets](guides/widgets.md)
+- [Widget migration parity](guides/widget-parity.md)
+- [Clipboard, selection, and mouse](guides/interaction.md)
+- [Markdown and diff viewers](guides/markdown-and-diffs.md)
+- [Advanced feature parity](guides/feature-parity.md)
+- [Package quality](guides/package-quality.md)
+- [Removed and deferred features](guides/removed-and-deferred.md)
+- [Migration to 1.0](guides/migration-1.0.md)
+- [Interactive showcase](guides/showcase.md)
+- [Runnable showcase application](examples/showcase/README.md)
+- [Counter example](https://github.com/pcharbon70/term_ui/tree/develop/examples/iex_counter)
## License
-MIT License - see [LICENSE](https://github.com/pcharbon70/term_ui/blob/main/LICENSE) for details.
+TermUI uses the MIT License. The repository includes the license text.
diff --git a/c_src/term_ui_tty_nif.c b/c_src/term_ui_tty_nif.c
new file mode 100644
index 00000000..6ec82582
--- /dev/null
+++ b/c_src/term_ui_tty_nif.c
@@ -0,0 +1,224 @@
+/*
+ * Disable terminal-driver handling of control bytes while a TermUI raw
+ * session is active. OTP 28 and OTP 29 leave these controls enabled.
+ *
+ * The returned token contains only the flags that this NIF owns. Restore
+ * merges those saved values into the current terminal mode so it does not
+ * overwrite settings that another terminal layer owns.
+ */
+
+#include
+
+#ifdef _WIN32
+#include
+#else
+#include
+#include
+#include
+#endif
+
+static ERL_NIF_TERM atom_error;
+static ERL_NIF_TERM atom_ok;
+static ERL_NIF_TERM atom_posix;
+static ERL_NIF_TERM atom_true;
+static ERL_NIF_TERM atom_win32;
+
+static int load(ErlNifEnv *env, void **priv_data, ERL_NIF_TERM load_info) {
+ (void)priv_data;
+ (void)load_info;
+
+ atom_error = enif_make_atom(env, "error");
+ atom_ok = enif_make_atom(env, "ok");
+ atom_posix = enif_make_atom(env, "posix");
+ atom_true = enif_make_atom(env, "true");
+ atom_win32 = enif_make_atom(env, "win32");
+ return 0;
+}
+
+static ERL_NIF_TERM make_error(ErlNifEnv *env, ERL_NIF_TERM platform,
+ unsigned long code) {
+ ERL_NIF_TERM reason =
+ enif_make_tuple2(env, platform, enif_make_uint64(env, code));
+ return enif_make_tuple2(env, atom_error, reason);
+}
+
+static ERL_NIF_TERM make_token(ErlNifEnv *env, ErlNifUInt64 local_flags,
+ ErlNifUInt64 input_flags) {
+ ERL_NIF_TERM token =
+ enif_make_tuple2(env, enif_make_uint64(env, local_flags),
+ enif_make_uint64(env, input_flags));
+ return enif_make_tuple2(env, atom_ok, token);
+}
+
+static int get_token(ErlNifEnv *env, ERL_NIF_TERM term,
+ ErlNifUInt64 *local_flags,
+ ErlNifUInt64 *input_flags) {
+ int arity;
+ const ERL_NIF_TERM *elements;
+
+ return enif_get_tuple(env, term, &arity, &elements) && arity == 2 &&
+ enif_get_uint64(env, elements[0], local_flags) &&
+ enif_get_uint64(env, elements[1], input_flags);
+}
+
+static ERL_NIF_TERM loaded(ErlNifEnv *env, int argc,
+ const ERL_NIF_TERM argv[]) {
+ (void)env;
+ (void)argc;
+ (void)argv;
+ return atom_true;
+}
+
+#ifdef _WIN32
+
+static ERL_NIF_TERM disable_control_flags(ErlNifEnv *env, int argc,
+ const ERL_NIF_TERM argv[]) {
+ HANDLE input;
+ DWORD mode;
+ DWORD saved;
+
+ (void)argc;
+ (void)argv;
+
+ input = GetStdHandle(STD_INPUT_HANDLE);
+ if (input == NULL || input == INVALID_HANDLE_VALUE) {
+ return make_error(env, atom_win32, GetLastError());
+ }
+ if (!GetConsoleMode(input, &mode)) {
+ return make_error(env, atom_win32, GetLastError());
+ }
+
+ saved = mode & ENABLE_PROCESSED_INPUT;
+ if (!SetConsoleMode(input, mode & ~ENABLE_PROCESSED_INPUT)) {
+ return make_error(env, atom_win32, GetLastError());
+ }
+
+ return make_token(env, saved, 0);
+}
+
+static ERL_NIF_TERM restore_control_flags(ErlNifEnv *env, int argc,
+ const ERL_NIF_TERM argv[]) {
+ ErlNifUInt64 saved;
+ ErlNifUInt64 unused;
+ HANDLE input;
+ DWORD mode;
+
+ if (argc != 1 || !get_token(env, argv[0], &saved, &unused)) {
+ return enif_make_badarg(env);
+ }
+
+ input = GetStdHandle(STD_INPUT_HANDLE);
+ if (input == NULL || input == INVALID_HANDLE_VALUE) {
+ return make_error(env, atom_win32, GetLastError());
+ }
+ if (!GetConsoleMode(input, &mode)) {
+ return make_error(env, atom_win32, GetLastError());
+ }
+
+ mode = (mode & ~ENABLE_PROCESSED_INPUT) |
+ ((DWORD)saved & ENABLE_PROCESSED_INPUT);
+ if (!SetConsoleMode(input, mode)) {
+ return make_error(env, atom_win32, GetLastError());
+ }
+
+ return atom_ok;
+}
+
+#else
+
+static tcflag_t controlled_local_flags(void) {
+ tcflag_t flags = 0;
+
+#ifdef ISIG
+ flags |= ISIG;
+#endif
+#ifdef IEXTEN
+ flags |= IEXTEN;
+#endif
+
+ return flags;
+}
+
+static tcflag_t controlled_input_flags(void) {
+ tcflag_t flags = 0;
+
+#ifdef IXON
+ flags |= IXON;
+#endif
+
+ return flags;
+}
+
+static ERL_NIF_TERM disable_control_flags(ErlNifEnv *env, int argc,
+ const ERL_NIF_TERM argv[]) {
+ struct termios terminal;
+ tcflag_t local_mask = controlled_local_flags();
+ tcflag_t input_mask = controlled_input_flags();
+ int error_number;
+
+ (void)argc;
+ (void)argv;
+
+ if (tcgetattr(STDIN_FILENO, &terminal) != 0) {
+ error_number = errno;
+ return make_error(env, atom_posix, (unsigned long)error_number);
+ }
+
+ tcflag_t saved_local = terminal.c_lflag & local_mask;
+ tcflag_t saved_input = terminal.c_iflag & input_mask;
+ terminal.c_lflag &= ~local_mask;
+ terminal.c_iflag &= ~input_mask;
+
+ if (tcsetattr(STDIN_FILENO, TCSANOW, &terminal) != 0) {
+ error_number = errno;
+ return make_error(env, atom_posix, (unsigned long)error_number);
+ }
+
+ return make_token(env, (ErlNifUInt64)saved_local,
+ (ErlNifUInt64)saved_input);
+}
+
+static ERL_NIF_TERM restore_control_flags(ErlNifEnv *env, int argc,
+ const ERL_NIF_TERM argv[]) {
+ ErlNifUInt64 saved_local;
+ ErlNifUInt64 saved_input;
+ struct termios terminal;
+ tcflag_t local_mask = controlled_local_flags();
+ tcflag_t input_mask = controlled_input_flags();
+ int error_number;
+
+ if (argc != 1 ||
+ !get_token(env, argv[0], &saved_local, &saved_input)) {
+ return enif_make_badarg(env);
+ }
+
+ if (tcgetattr(STDIN_FILENO, &terminal) != 0) {
+ error_number = errno;
+ return make_error(env, atom_posix, (unsigned long)error_number);
+ }
+
+ terminal.c_lflag =
+ (terminal.c_lflag & ~local_mask) |
+ ((tcflag_t)saved_local & local_mask);
+ terminal.c_iflag =
+ (terminal.c_iflag & ~input_mask) |
+ ((tcflag_t)saved_input & input_mask);
+
+ if (tcsetattr(STDIN_FILENO, TCSANOW, &terminal) != 0) {
+ error_number = errno;
+ return make_error(env, atom_posix, (unsigned long)error_number);
+ }
+
+ return atom_ok;
+}
+
+#endif
+
+static ErlNifFunc nif_functions[] = {
+ {"loaded?", 0, loaded, 0},
+ {"disable_control_flags", 0, disable_control_flags, 0},
+ {"restore_control_flags", 1, restore_control_flags, 0}
+};
+
+ERL_NIF_INIT(Elixir.TermUI.Terminal.TtyNif, nif_functions, load, NULL, NULL,
+ NULL)
diff --git a/config/config.exs b/config/config.exs
new file mode 100644
index 00000000..81fb6e36
--- /dev/null
+++ b/config/config.exs
@@ -0,0 +1,12 @@
+import Config
+
+if config_env() == :dev do
+ config :git_ops,
+ mix_project: Mix.Project.get!(),
+ changelog_file: "CHANGELOG.md",
+ repository_url: "https://github.com/pcharbon70/term_ui",
+ manage_mix_version?: true,
+ version_tag_prefix: "v"
+end
+
+import_config "#{config_env()}.exs"
diff --git a/config/dev.exs b/config/dev.exs
new file mode 100644
index 00000000..becde769
--- /dev/null
+++ b/config/dev.exs
@@ -0,0 +1 @@
+import Config
diff --git a/config/prod.exs b/config/prod.exs
new file mode 100644
index 00000000..becde769
--- /dev/null
+++ b/config/prod.exs
@@ -0,0 +1 @@
+import Config
diff --git a/config/test.exs b/config/test.exs
new file mode 100644
index 00000000..becde769
--- /dev/null
+++ b/config/test.exs
@@ -0,0 +1 @@
+import Config
diff --git a/coveralls.json b/coveralls.json
new file mode 100644
index 00000000..d13cfc03
--- /dev/null
+++ b/coveralls.json
@@ -0,0 +1,6 @@
+{
+ "skip_files": ["test/support/"],
+ "coverage_options": {
+ "minimum_coverage": 90
+ }
+}
diff --git a/docs/phase-05/task-5.5.2-summary.md b/docs/phase-05/task-5.5.2-summary.md
deleted file mode 100644
index b01ebf72..00000000
--- a/docs/phase-05/task-5.5.2-summary.md
+++ /dev/null
@@ -1,217 +0,0 @@
-# Task 5.5.2 Summary: CharacterSet Integration for ASCII Fallback
-
-## Task Overview
-
-Integrate the existing CharacterSet module into all TermUI widgets to enable graceful ASCII fallback for terminals that don't support Unicode characters.
-
-## Completion Status
-
-**✅ COMPLETE** - All 20 widgets successfully integrated with CharacterSet
-
-## Implementation Details
-
-### Widgets Integrated
-
-#### P0 Widgets (Critical - Box Drawing)
-1. **Dialog** (66 tests) - Box-drawing characters for borders
-2. **AlertDialog** (37 tests) - Box-drawing characters for borders
-3. **Table** (28 tests) - Box-drawing for grid lines and borders
-4. **TreeView** (22 tests) - Tree branch characters and expand/collapse indicators
-
-#### P1 Widgets (High Priority)
-5. **Menu** (31 tests) - Submenu arrows and separators
-6. **FormBuilder** (50 tests) - Group expand/collapse arrows
-7. **SupervisionTreeViewer** (43 tests) - Status/type icons and tree indicators
-
-#### P2 Widgets (Medium Priority - Visualization & Interaction)
-8. **Gauge** (24 tests) - Bar characters for progress visualization
-9. **Sparkline** (24 tests) - 8-level bar characters for mini charts
-10. **BarChart** (24 tests) - Bar characters for chart rendering
-11. **ScrollBar** (31 tests) - Track and thumb characters
-12. **Canvas** (30 tests) - Line and box-drawing primitives
-13. **ContextMenu** (28 tests) - Separator lines
-14. **TextInput** (58 tests) - Scroll indicator arrows
-15. **ProcessMonitor** (44 tests) - Sort arrows and help text arrows
-16. **SplitPane** (67 tests) - Divider characters
-17. **Toast** (30 tests) - Box-drawing for notification borders
-18. **Viewport** (29 tests) - Scrollbar characters
-
-#### P3 Widgets (Special Cases)
-19. **ClusterDashboard** (41 tests) - Help text navigation arrows
-20. **LineChart** (22 tests) - Axis box-drawing characters
-
-### Total Test Coverage
-
-- **19 widgets tested**: 688 tests passing
-- **1 widget** (ClusterDashboard): Pre-existing test setup issues unrelated to changes
-- **Overall impact**: All widgets now support ASCII fallback
-
-## Implementation Pattern
-
-Consistent 4-step pattern applied across all widgets:
-
-```elixir
-# 1. Add CharacterSet alias
-alias TermUI.CharacterSet
-
-# 2. Get charset in render function
-chars = CharacterSet.current_charset()
-
-# 3. Replace hardcoded Unicode with charset lookups
-# Before: "─"
-# After: chars.h_line
-
-# 4. Update function signatures to pass charset through
-defp render_border(state, width, chars) do
- # Use chars.tl, chars.tr, chars.bl, chars.br, etc.
-end
-```
-
-## Character Mappings Used
-
-| Category | Unicode | ASCII | CharacterSet Field |
-|----------|---------|-------|-------------------|
-| **Box Drawing** | | | |
-| Horizontal line | `─` | `-` | `h_line` |
-| Vertical line | `│` | `\|` | `v_line` |
-| Top-left corner | `┌` | `+` | `tl` |
-| Top-right corner | `┐` | `+` | `tr` |
-| Bottom-left corner | `└` | `+` | `bl` |
-| Bottom-right corner | `┘` | `+` | `br` |
-| **Arrows** | | | |
-| Up arrow | `↑` | `^` | `arrow_up` |
-| Down arrow | `↓` | `v` | `arrow_down` |
-| Left arrow | `←` | `<` | `arrow_left` |
-| Right arrow | `→` | `>` | `arrow_right` |
-| **Bar Characters** | | | |
-| Full block | `█` | `#` | `bar_full` |
-| Empty block | `░` | `.` | `bar_empty` |
-| Bar levels (8) | `▁▂▃▄▅▆▇█` | `▏▎▍▌▋▊▉█` (5) | `bar_levels` |
-
-## Notable Implementations
-
-### Module Attribute Conversion
-
-Some widgets had module attributes converted to runtime functions:
-
-**SupervisionTreeViewer:**
-```elixir
-# Before:
-@status_icons %{running: "○", restarting: "↻", ...}
-
-# After:
-defp get_status_icons do
- %{running: "o", restarting: "~", ...}
-end
-```
-
-### Variable Shadowing Prevention
-
-**LineChart:**
-```elixir
-# Renamed inner loop variable to avoid shadowing charset
-chars_row = # Was: chars
- for x <- 0..(width - 1) do
- pattern = get_cell_pattern(canvas, x, y)
- <<@braille_base + pattern::utf8>>
- end
-```
-
-### Bi-directional Arrows
-
-**TextInput scroll indicators:**
-```elixir
-# Unicode: "↕" (single bi-directional character)
-# ASCII: "^v" (concatenated up + down arrows)
-indicator = "#{chars.arrow_up}#{chars.arrow_down}"
-```
-
-### Test Updates
-
-**Sparkline tests** made charset-agnostic:
-```elixir
-# Before: assert result == "▁"
-# After:
-bars = Sparkline.bar_characters()
-assert result == List.first(bars)
-```
-
-**Toast test** fixed to match implementation:
-```elixir
-# ToastManager.render returns list of overlays, not stack node
-assert is_list(result)
-assert length(result) == 2
-assert Enum.all?(result, fn overlay -> overlay.type == :overlay end)
-```
-
-## Git Commit History
-
-1. **P0 widgets** (4 widgets, 153 tests) - `1b103a8`
-2. **P1 widgets** (3 widgets, 124 tests) - `fb5c0e1`
-3. **P2 batch 1** (3 widgets, 72 tests) - `aa62b8c`
-4. **P2 batch 2** (3 widgets, 89 tests) - `bf6a49d`
-5. **P2 batch 3** (3 widgets, 169 tests) - `edcfe7e`
-6. **P2 batch 4** (2 widgets, 59 tests) - `b1a8a0f`
-7. **P3 widgets** (2 widgets, 63 tests) - `74ede29`
-
-## Benefits
-
-### 1. Terminal Compatibility
-- Widgets now work correctly in ASCII-only terminals
-- Graceful degradation for limited character sets
-- No visual corruption from unsupported Unicode
-
-### 2. Consistent Implementation
-- Single source of truth for character mappings
-- Easy to add new character sets (e.g., different box-drawing styles)
-- Centralized configuration through CharacterSet module
-
-### 3. Future Extensibility
-- Foundation for theme-based character set selection
-- Support for custom character sets per user preference
-- Easy to add locale-specific characters
-
-## Testing
-
-All modified widgets maintain 100% test pass rate:
-- No test regressions introduced
-- Existing functionality preserved
-- Character rendering logic validated through existing tests
-
-## Next Steps
-
-Task 5.5.2 is complete. Ready to proceed with:
-- Task 5.5.3: Implement ASCII renderer backend (if applicable)
-- Or continue with next phase of multi-renderer architecture
-
-## Files Modified
-
-### Widget Files (20)
-- `lib/term_ui/widgets/alert_dialog.ex`
-- `lib/term_ui/widgets/bar_chart.ex`
-- `lib/term_ui/widgets/canvas.ex`
-- `lib/term_ui/widgets/cluster_dashboard.ex`
-- `lib/term_ui/widgets/context_menu.ex`
-- `lib/term_ui/widgets/dialog.ex`
-- `lib/term_ui/widgets/form_builder.ex`
-- `lib/term_ui/widgets/gauge.ex`
-- `lib/term_ui/widgets/line_chart.ex`
-- `lib/term_ui/widgets/menu.ex`
-- `lib/term_ui/widgets/process_monitor.ex`
-- `lib/term_ui/widgets/scroll_bar.ex`
-- `lib/term_ui/widgets/sparkline.ex`
-- `lib/term_ui/widgets/split_pane.ex`
-- `lib/term_ui/widgets/supervision_tree_viewer.ex`
-- `lib/term_ui/widgets/table.ex`
-- `lib/term_ui/widgets/text_input.ex`
-- `lib/term_ui/widgets/toast.ex`
-- `lib/term_ui/widgets/tree_view.ex`
-- `lib/term_ui/widgets/viewport.ex`
-
-### Test Files (2)
-- `test/term_ui/widgets/sparkline_test.exs` - Made charset-agnostic
-- `test/term_ui/widgets/toast_test.exs` - Fixed to match implementation
-
-## Conclusion
-
-Task 5.5.2 successfully integrated CharacterSet into all TermUI widgets, enabling graceful ASCII fallback for terminals without Unicode support. The implementation was systematic, well-tested, and maintains backward compatibility while adding new functionality.
diff --git a/docs/widget-compatibility.md b/docs/widget-compatibility.md
deleted file mode 100644
index 66e4dbe8..00000000
--- a/docs/widget-compatibility.md
+++ /dev/null
@@ -1,359 +0,0 @@
-# Widget Compatibility Guide
-
-This document describes widget behavior across different terminal backends (Raw Mode and TTY Mode) and provides best practices for building compatible widgets.
-
-## Overview
-
-TermUI supports two terminal backends:
-
-- **Raw Mode**: Full terminal control with mouse support, 60 FPS rendering, and immediate key handling. Requires OTP 28+ with native raw mode support.
-- **TTY Mode**: Compatible mode using standard I/O operations. Works on all systems but with limited features.
-
-Most widgets work identically in both modes because keyboard navigation (arrows, Tab, Enter) uses `IO.getn/2` which provides character-by-character input regardless of terminal mode.
-
-## Widget Compatibility Matrix
-
-| Widget | Raw Mode | TTY Mode | Notes |
-|--------|----------|----------|-------|
-| **Navigation & Selection** |
-| Menu | Full | Full | Keyboard navigation works identically |
-| Tabs | Full | Full | Tab switching via keyboard |
-| Table | Full | Full | Arrow keys for navigation, sorting |
-| TreeView | Full | Full | Expand/collapse via arrow keys |
-| CommandPalette | Full | Full | Fuzzy search and selection |
-| **Input** |
-| TextInput | Full | Full | Character-by-character input |
-| TextInput.Line | Full | Full | Shell line editing via `IO.gets/1` |
-| FormBuilder | Full | Full | Tab navigation between fields |
-| **Feedback** |
-| Dialog | Full | Full | Modal with button navigation |
-| AlertDialog | Full | Full | Type-based icons and styling |
-| Toast | Full | Full | Auto-dismissing notifications |
-| **Layout** |
-| SplitPane | Full | Keyboard | Mouse drag unavailable; use Ctrl+arrows |
-| Viewport | Full | Full | Keyboard scrolling |
-| ScrollBar | Full | Keyboard | Click unavailable; use arrow keys |
-| **Data Visualization** |
-| Gauge | Full | Full | Progress bars |
-| BarChart | Full | Full | Vertical bar charts |
-| LineChart | Full | Full | Line graphs |
-| Sparkline | Full | Full | Inline mini charts |
-| Canvas | Full | Full | Pixel/character drawing |
-| **Context Menus** |
-| ContextMenu | Full | Position N/A | Use ContextMenu.Inline for TTY |
-| ContextMenu.Inline | Full | Full | Numbered selection, no positioning |
-| **Monitoring** |
-| ProcessMonitor | Full | Full | Process list display |
-| SupervisionTreeViewer | Full | Full | Tree visualization |
-| ClusterDashboard | Full | Full | Cluster status |
-| LogViewer | Full | Full | Log streaming |
-
-### Legend
-
-- **Full**: All features work as expected
-- **Keyboard**: Mouse features unavailable; keyboard alternatives provided
-- **Position N/A**: Requires mouse positioning; use inline variant instead
-
-## Widget Variants
-
-Some widgets have variants optimized for different backends:
-
-### TextInput vs TextInput.Line
-
-| Feature | TextInput | TextInput.Line |
-|---------|-----------|----------------|
-| Input Method | Character-by-character | Line-based (`IO.gets/1`) |
-| Shell Editing | No | Yes (history, readline) |
-| Real-time Validation | Yes | On submit only |
-| Cursor Control | Full | Shell-controlled |
-| Blocking | No (event-driven) | Yes (blocks during read) |
-| Best For | Real-time input, search | Free-form text entry |
-
-> **Note:** `TextInput.Line` uses blocking I/O. When `read/1` is called, the
-> process blocks until the user presses Enter. This is intentional to enable
-> shell line editing features. For non-blocking input, use `TextInput`.
-
-**Usage:**
-```elixir
-# Real-time input (e.g., search)
-TextInput.new(placeholder: "Search...")
-
-# Line-based input with shell editing
-alias TermUI.Widgets.TextInput.Line
-Line.new(prompt: "> ", label: "Enter command")
-```
-
-### ContextMenu vs ContextMenu.Inline
-
-| Feature | ContextMenu | ContextMenu.Inline |
-|---------|-------------|-------------------|
-| Positioning | Mouse cursor | Below current focus |
-| Selection | Click or arrows | Numbers or arrows |
-| Best For | Right-click menus | Keyboard-only environments |
-
-**Usage:**
-```elixir
-alias TermUI.Widgets.ContextMenu
-alias TermUI.Widgets.ContextMenu.Inline, as: InlineMenu
-
-# Create menu items (same for both variants)
-items = [
- ContextMenu.action(:copy, "Copy"),
- ContextMenu.action(:paste, "Paste"),
- ContextMenu.separator(),
- ContextMenu.action(:delete, "Delete")
-]
-
-# Positioned context menu (requires mouse)
-ContextMenu.new(items: items, position: {x, y})
-
-# Inline menu with number keys
-InlineMenu.new(items: items)
-# Renders: [1] Copy [2] Paste [3] Delete
-```
-
-## Features with Keyboard Alternatives
-
-### SplitPane Resize
-
-Mouse dragging is unavailable in TTY mode. Use keyboard shortcuts:
-
-| Action | Shortcut |
-|--------|----------|
-| Decrease left/top pane | Ctrl+Left / Ctrl+Up |
-| Increase left/top pane | Ctrl+Right / Ctrl+Down |
-
-```elixir
-alias TermUI.Widgets.SplitPane
-
-# SplitPane uses :panes list for pane definitions
-SplitPane.new(
- orientation: :horizontal,
- panes: [
- %{id: :left, content: left_panel, size: 0.5},
- %{id: :right, content: right_panel, size: 0.5}
- ],
- ctrl_resize_step: 0.05, # 5% per keystroke
- min_ratio: 0.1, # Minimum 10%
- max_ratio: 0.9 # Maximum 90%
-)
-```
-
-### ScrollBar Interaction
-
-Click-to-scroll is unavailable in TTY mode. Scrolling via:
-- Arrow keys (line by line)
-- Page Up/Page Down (page by page)
-- Home/End (jump to start/end)
-
----
-
-## Best Practices for Widget Development
-
-### 1. Always Use Theme for Colors
-
-Never hardcode color values. Use the Theme system for automatic degradation:
-
-```elixir
-# Bad - hardcoded colors
-style = Style.new() |> Style.fg({255, 0, 0})
-
-# Good - theme-based colors
-style = Style.new() |> Style.fg(Theme.get_semantic(:error))
-
-# Good - component styles with monochrome fallback
-style = Theme.get_component_style(:list, :selected)
-```
-
-The Theme system automatically:
-- Converts RGB to 256-color when needed
-- Converts to 16-color palette when needed
-- Provides monochrome fallbacks (reverse, bold, underline)
-
-### 2. Always Use CharacterSet for Special Characters
-
-Never hardcode Unicode characters. Use CharacterSet for automatic ASCII fallback:
-
-```elixir
-# Bad - hardcoded Unicode
-border = "┌" <> String.duplicate("─", width) <> "┐"
-
-# Good - CharacterSet-based
-chars = CharacterSet.current_charset()
-border = chars.tl <> String.duplicate(chars.h_line, width) <> chars.tr
-```
-
-Available character categories:
-- **Box drawing**: `tl`, `tr`, `bl`, `br`, `h_line`, `v_line`, `cross`, etc.
-- **Arrows**: `arrow_up`, `arrow_down`, `arrow_left`, `arrow_right`
-- **Indicators**: `check`, `cross_mark`, `bullet`, `pointer`
-- **Progress**: `bar_full`, `bar_empty`, `bar_levels`, `sparkline_levels`
-- **Icons**: `info`, `warning`, `loading`
-
-### 3. Provide Keyboard Alternatives for Mouse Features
-
-Every mouse interaction should have a keyboard equivalent:
-
-```elixir
-# Handle both mouse and keyboard for selection
-def handle_event(%Event.Mouse{action: :click, y: y}, state) do
- select_item_at(state, y)
-end
-
-def handle_event(%Event.Key{key: :enter}, state) do
- select_current_item(state)
-end
-
-def handle_event(%Event.Key{key: :down}, state) do
- move_cursor(state, 1)
-end
-```
-
-Common keyboard patterns:
-| Mouse Action | Keyboard Alternative |
-|--------------|---------------------|
-| Click to select | Enter/Space |
-| Drag to resize | Ctrl+Arrow keys |
-| Scroll wheel | Arrow keys, Page Up/Down |
-| Right-click menu | Context key, Shift+F10 |
-| Hover tooltip | Focus + delay |
-
-### 4. Test with Both Backends
-
-Always test widgets in both Raw and TTY modes:
-
-```elixir
-# In tests, configure backend explicitly
-defmodule MyWidgetTest do
- use ExUnit.Case
-
- describe "keyboard navigation" do
- test "works in raw mode" do
- Application.put_env(:term_ui, :backend, :raw)
- # Test keyboard navigation
- end
-
- test "works in tty mode" do
- Application.put_env(:term_ui, :backend, :tty)
- # Same navigation should work identically
- end
- end
-end
-```
-
-### 5. Use Appropriate Widget State Patterns
-
-Widgets should use the StatefulComponent pattern:
-
-```elixir
-defmodule MyWidget do
- use TermUI.StatefulComponent
-
- @impl true
- def init(props) do
- state = %{
- # Initialize state from props
- }
- {:ok, state}
- end
-
- @impl true
- def handle_event(event, state) do
- # Handle keyboard and mouse events
- {:ok, new_state}
- end
-
- @impl true
- def render(state, area) do
- # Return render nodes
- stack(:vertical, [...])
- end
-end
-```
-
-### 6. Support Capability Degradation
-
-Check capabilities at runtime when needed:
-
-```elixir
-defp render_with_fallback(state) do
- chars = CharacterSet.current_charset()
-
- # CharacterSet automatically provides ASCII fallback
- # based on :term_ui, :character_set config
-
- border = chars.tl <> String.duplicate(chars.h_line, width) <> chars.tr
- # In Unicode mode: ┌────────┐
- # In ASCII mode: +--------+
-end
-```
-
----
-
-## Color Mode Reference
-
-The Theme system supports multiple color modes:
-
-| Mode | Colors | Use Case |
-|------|--------|----------|
-| `true_color` | 16M (RGB) | Modern terminals |
-| `color_256` | 256 | Most terminals |
-| `color_16` | 16 | Basic terminals |
-| `monochrome` | 2 | No color support |
-
-Monochrome fallbacks:
-- **Selected items**: Reverse video
-- **Focused items**: Bold
-- **Error states**: Underline
-- **Disabled items**: Dim
-
----
-
-## Character Set Reference
-
-Two character sets are available:
-
-| Character | Unicode | ASCII |
-|-----------|---------|-------|
-| Corners | `┌┐└┘` | `+` |
-| Lines | `─│` | `-\|` |
-| Arrows | `↑↓←→` | `^v<>` |
-| Triangles | `▲▼◀▶` | `^v<>` |
-| Progress | `█░` | `#.` |
-| Check | `✓` | `x` |
-| Cross | `✗` | `X` |
-| Bullet | `●○` | `*o` |
-
-Configure at runtime:
-```elixir
-# In config/config.exs
-config :term_ui, :character_set, :unicode # or :ascii
-
-# Or at runtime
-Application.put_env(:term_ui, :character_set, :ascii)
-```
-
----
-
-## Quick Reference
-
-### Creating a Compatible Widget
-
-1. Use `TermUI.StatefulComponent`
-2. Handle keyboard events for all interactions
-3. Use `Theme.get_*` for colors
-4. Use `CharacterSet.current_charset()` for special characters
-5. Test in both Raw and TTY modes
-
-### Checking Current Mode
-
-```elixir
-# Get current backend
-backend = Application.get_env(:term_ui, :backend, :raw)
-
-# Get current character set
-charset = CharacterSet.current() # :unicode or :ascii
-
-# Get current color capabilities
-color_mode = Theme.get_color_mode() # :true_color, :color_256, etc.
-```
diff --git a/examples/README.md b/examples/README.md
index 3bea6521..88643bc3 100644
--- a/examples/README.md
+++ b/examples/README.md
@@ -1,118 +1,9 @@
-# TermUI Examples
+# Examples
-This directory contains example applications demonstrating TermUI widgets and patterns.
+The `iex_counter` directory contains the smallest supported TermUI example. It
+shows the complete application contract without an application-specific
+adapter.
-## Examples Overview
-
-| Example | Description | Key Features |
-|---------|-------------|--------------|
-| [dashboard](./dashboard/) | System monitoring dashboard | Multiple widgets, real-time updates, themes |
-| [gauge](./gauge/) | Progress indicators | Color zones, bar/arc styles, labels |
-| [sparkline](./sparkline/) | Time series visualization | Value-based colors, min/max tracking |
-| [bar_chart](./bar_chart/) | Bar chart visualizations | Horizontal/vertical, colors, labels |
-| [table](./table/) | Data tables | Columns, selection, scrolling, constraints |
-| [line_chart](./line_chart/) | Line chart with Braille graphics | Multiple series, auto-scaling, legends |
-| [menu](./menu/) | Hierarchical menus | Actions, submenus, checkboxes, radio groups |
-| [tabs](./tabs/) | Tabbed interfaces | Tab switching, dynamic tabs, content panels |
-| [dialog](./dialog/) | Modal dialogs | Confirmation, info, warning, error dialogs |
-| [viewport](./viewport/) | Scrollable content areas | Keyboard/mouse scrolling, scrollbars |
-| [canvas](./canvas/) | Custom drawing | Primitives, rectangles, Braille graphics |
-
-## Running Examples
-
-Each example is a standalone Mix project. To run an example:
-
-```bash
-# Navigate to the example directory
-cd examples/
-
-# Install dependencies
-mix deps.get
-
-# Run the example
-mix termui.run
-```
-
-## Requirements
-
-- Elixir 1.15+
-- OTP 28+
-- Terminal with Unicode support
-
-## Example Structure
-
-Each example follows a consistent structure:
-
-```
-example_name/
-├── mix.exs # Mix project file
-├── run.exs # Script to run the example
-├── README.md # Example documentation
-└── lib/
- └── example_name/
- ├── application.ex # OTP application module
- └── app.ex # Main component implementation
-```
-
-## The Elm Architecture
-
-All examples use TermUI's Elm Architecture pattern with four callbacks:
-
-```elixir
-@behaviour TermUI.Component
-
-# Initialize component state
-@impl true
-def init(_opts), do: %{...}
-
-# Convert events to messages
-@impl true
-def event_to_msg(event, state), do: {:msg, message} | :ignore
-
-# Update state based on messages
-@impl true
-def update(message, state), do: {new_state, commands}
-
-# Render state to UI tree
-@impl true
-def view(state), do: stack(:vertical, [...])
-```
-
-## Widget Categories
-
-### Data Display
-- **Gauge** - Show progress or values with visual feedback
-- **Sparkline** - Compact time series visualization
-- **BarChart** - Categorical data comparison
-- **LineChart** - Trend visualization with multiple series
-- **Table** - Structured data with selection
-
-### Navigation
-- **Menu** - Hierarchical command menus
-- **Tabs** - Organize content into switchable panels
-
-### Interaction
-- **Dialog** - Modal prompts and confirmations
-- **Viewport** - Scrollable content containers
-
-### Drawing
-- **Canvas** - Custom graphics with drawing primitives
-
-## Learning Path
-
-For beginners, we recommend exploring examples in this order:
-
-1. **gauge** - Simple widget with basic event handling
-2. **sparkline** - Working with data collections
-3. **table** - Selection and navigation patterns
-4. **menu** - Complex widget interactions
-5. **dashboard** - Combining multiple widgets
-
-## Contributing
-
-When adding new examples:
-
-1. Follow the existing directory structure
-2. Include a comprehensive README.md
-3. Add well-commented code explaining widget usage
-4. Update this README with the new example
+The `showcase` directory contains an interactive catalog of TermUI widgets and
+architecture. It shows composition, application-owned widget state,
+asynchronous live BEAM collection, command effects, and responsive frames.
diff --git a/examples/alert_dialog/README.md b/examples/alert_dialog/README.md
deleted file mode 100644
index a65717b0..00000000
--- a/examples/alert_dialog/README.md
+++ /dev/null
@@ -1,114 +0,0 @@
-# AlertDialog Widget Example
-
-This example demonstrates the TermUI AlertDialog widget, which provides standardized message dialogs and confirmations with predefined button configurations and visual icons.
-
-## Widget Overview
-
-The AlertDialog widget is designed for displaying modal dialogs that require user attention or confirmation. It provides six predefined alert types, each with appropriate icons and button configurations:
-
-- **Info** - General information messages
-- **Success** - Operation success confirmations
-- **Warning** - Caution messages requiring attention
-- **Error** - Error notifications
-- **Confirm** - Yes/No decision dialogs
-- **OK/Cancel** - Cancellable action dialogs
-
-Use AlertDialog when you need to interrupt the user's workflow to display important messages or request confirmation before proceeding with an action.
-
-## Widget Options
-
-The `AlertDialog.new/1` function accepts the following options:
-
-- `:type` - Alert type (required): `:info`, `:success`, `:warning`, `:error`, `:confirm`, `:ok_cancel`
-- `:title` - Dialog title (required)
-- `:message` - Message to display (required)
-- `:on_result` - Callback function to handle result (`:ok`, `:cancel`, `:yes`, `:no`)
-- `:width` - Dialog width in characters (default: 50)
-- `:icon_style` - Custom style for the icon
-- `:message_style` - Custom style for the message text
-- `:button_style` - Custom style for buttons
-- `:focused_button_style` - Custom style for the focused button
-
-## Example Structure
-
-The example consists of:
-
-- `lib/alert_dialog/app.ex` - Main application demonstrating all alert types
- - Handles number keys (1-6) to trigger different alert types
- - Manages alert state and captures user responses
- - Displays the result of the last closed dialog
-
-## Running the Example
-
-### Raw Mode (Full TUI Experience)
-
-For the best experience with full terminal control and alternate screen:
-
-```bash
-cd examples/alert_dialog
-mix termui.run
-```
-
-Or manually:
-
-```bash
-cd examples/alert_dialog
-mix run -e "AlertDialog.App.run()" --no-halt
-```
-
-### TTY Mode (IEx Compatible)
-
-To run from IEx without taking over the shell:
-
-```bash
-cd examples/alert_dialog
-iex -S mix
-```
-
-Then in IEx:
-
-```elixir
-AlertDialog.App.run()
-```
-
-**Note:** TTY mode works inside IEx but has limitations:
-- No alternate screen buffer (output mixes with IEx prompt)
-- Character input works immediately (no Enter needed)
-- For full TUI, use raw mode instead
-
-## Controls
-
-**When no alert is visible:**
-- `1` - Show Info Alert (informational message)
-- `2` - Show Success Alert (operation succeeded)
-- `3` - Show Warning Alert (caution message)
-- `4` - Show Error Alert (error message)
-- `5` - Show Confirm Dialog (Yes/No choice)
-- `6` - Show OK/Cancel Dialog (OK/Cancel choice)
-- `Q` - Quit application
-
-**When alert is visible:**
-- `Tab` / `←` / `→` - Navigate between buttons
-- `Enter` - Select focused button
-- `Y` / `N` - Quick select (in confirm dialogs only)
-- `Escape` - Cancel/Close alert
-
-## Implementation Notes
-
-The example demonstrates:
-- Creating different alert types with appropriate messages
-- Handling alert events and button selection
-- Capturing and displaying dialog results
-- Conditional rendering based on alert visibility
-- The difference between message alerts (OK only) and decision dialogs (Yes/No, OK/Cancel)
-
-### Alert Types Reference
-
-| Type | Icon | Buttons | Use Case |
-|------|------|---------|----------|
-| info | ℹ | OK | Informational messages |
-| success | ✓ | OK | Operation succeeded |
-| warning | ⚠ | OK | Caution messages |
-| error | ✗ | OK | Error messages |
-| confirm | ? | No, Yes | Yes/No decisions |
-| ok_cancel | ? | Cancel, OK | OK/Cancel decisions |
diff --git a/examples/alert_dialog/lib/alert_dialog/app.ex b/examples/alert_dialog/lib/alert_dialog/app.ex
deleted file mode 100644
index 0770958f..00000000
--- a/examples/alert_dialog/lib/alert_dialog/app.ex
+++ /dev/null
@@ -1,194 +0,0 @@
-defmodule AlertDialog.App do
- @moduledoc """
- Alert Dialog Widget Example
-
- This example demonstrates how to use the TermUI.Widgets.AlertDialog widget
- for displaying standardized message dialogs.
-
- Features demonstrated:
- - Info alert (informational message)
- - Success alert (operation succeeded)
- - Warning alert (caution message)
- - Error alert (error message)
- - Confirm dialog (Yes/No choice)
- - OK/Cancel dialog (OK/Cancel choice)
- - Keyboard shortcuts (Y/N for confirm)
-
- Controls:
- - 1: Show Info Alert
- - 2: Show Success Alert
- - 3: Show Warning Alert
- - 4: Show Error Alert
- - 5: Show Confirm Dialog
- - 6: Show OK/Cancel Dialog
- - Tab/Arrow: Navigate buttons (when alert open)
- - Enter: Select button (when alert open)
- - Y/N: Quick select (in confirm dialogs)
- - Escape: Cancel/Close alert
- - Q: Quit the application
- """
-
- use TermUI.Elm
-
- alias TermUI.Event
- alias TermUI.Renderer.Style
- alias TermUI.Widgets.AlertDialog
-
- # ----------------------------------------------------------------------------
- # Component Callbacks
- # ----------------------------------------------------------------------------
-
- @doc """
- Initialize the component state.
- """
- def init(_opts) do
- %{
- # Current alert dialog state (nil when no alert visible)
- alert: nil,
- # Result tracking
- last_result: nil,
- last_alert_type: nil
- }
- end
-
- @doc """
- Convert keyboard events to messages.
- """
- # When no alert is visible, number keys show alerts
- def event_to_msg(%Event.Key{key: "1"}, %{alert: nil}), do: {:msg, :show_info}
- def event_to_msg(%Event.Key{key: "2"}, %{alert: nil}), do: {:msg, :show_success}
- def event_to_msg(%Event.Key{key: "3"}, %{alert: nil}), do: {:msg, :show_warning}
- def event_to_msg(%Event.Key{key: "4"}, %{alert: nil}), do: {:msg, :show_error}
- def event_to_msg(%Event.Key{key: "5"}, %{alert: nil}), do: {:msg, :show_confirm}
- def event_to_msg(%Event.Key{key: "6"}, %{alert: nil}), do: {:msg, :show_ok_cancel}
-
- # When alert is visible, forward events to the alert widget
- def event_to_msg(event, %{alert: alert}) when alert != nil, do: {:msg, {:alert_event, event}}
-
- def event_to_msg(%Event.Key{key: key}, _state) when key in ["q", "Q"], do: {:msg, :quit}
- def event_to_msg(_event, _state), do: :ignore
-
- @doc """
- Update state based on messages.
- """
- def update(:show_info, state), do: {show_alert(state, :info, "Information", "This is an informational message."), []}
- def update(:show_success, state), do: {show_alert(state, :success, "Success", "Operation completed successfully!"), []}
- def update(:show_warning, state), do: {show_alert(state, :warning, "Warning", "Please proceed with caution."), []}
- def update(:show_error, state), do: {show_alert(state, :error, "Error", "An error occurred during the operation."), []}
- def update(:show_confirm, state), do: {show_alert(state, :confirm, "Confirm Action", "Are you sure you want to proceed?"), []}
- def update(:show_ok_cancel, state), do: {show_alert(state, :ok_cancel, "Save Changes", "Do you want to save your changes?"), []}
-
- def update({:alert_event, event}, state) do
- case AlertDialog.handle_event(event, state.alert) do
- {:ok, new_alert} ->
- if AlertDialog.visible?(new_alert) do
- {%{state | alert: new_alert}, []}
- else
- # Alert was closed - capture result
- result = AlertDialog.get_focused_button(new_alert)
- alert_type = AlertDialog.get_type(new_alert)
- {%{state | alert: nil, last_result: result, last_alert_type: alert_type}, []}
- end
- end
- end
-
- def update(:quit, state) do
- {state, [:quit]}
- end
-
- # Helper to create and initialize an alert dialog
- defp show_alert(state, type, title, message) do
- props = AlertDialog.new(type: type, title: title, message: message)
- {:ok, alert} = AlertDialog.init(props)
-
- # Set terminal area for accurate mouse click detection
- # (In a full implementation, this would be obtained from the runtime)
- alert = AlertDialog.update_area(alert, %{width: 80, height: 24})
-
- %{state | alert: alert}
- end
-
- @doc """
- Render the current state to a render tree.
-
- When an alert is visible, return the alert overlay directly (not stacked).
- This allows the overlay to be positioned absolutely over the main content.
- """
- def view(state) do
- main_content = render_main_content(state)
-
- if state.alert != nil do
- # Standard terminal size for this example
- area = %{width: 80, height: 24}
-
- # Render the overlay directly - it will be positioned absolutely
- {:overlay, main_content, AlertDialog.render(state.alert, area)}
- else
- main_content
- end
- end
-
- # ----------------------------------------------------------------------------
- # Private Helpers
- # ----------------------------------------------------------------------------
-
- defp render_main_content(state) do
- stack(:vertical, [
- # Title
- text("Alert Dialog Widget Example", Style.new(fg: :cyan, attrs: [:bold])),
- text("", nil),
-
- # Instructions
- text("Press a number key to show different alert types:", nil),
- text("", nil),
- text(" 1 - Info Alert (informational message)", nil),
- text(" 2 - Success Alert (operation succeeded)", nil),
- text(" 3 - Warning Alert (caution message)", nil),
- text(" 4 - Error Alert (error message)", nil),
- text(" 5 - Confirm Dialog (Yes/No choice)", nil),
- text(" 6 - OK/Cancel (OK/Cancel choice)", nil),
- text("", nil),
-
- # Controls
- render_controls(state)
- ])
- end
-
- defp render_controls(state) do
- box_width = 55
- inner_width = box_width - 2
-
- top_border = "┌─ Controls " <> String.duplicate("─", inner_width - 12) <> "─┐"
- bottom_border = "└" <> String.duplicate("─", inner_width) <> "┘"
-
- result_text = format_result(state.last_result, state.last_alert_type)
-
- stack(:vertical, [
- text("", nil),
- text(top_border, Style.new(fg: :yellow)),
- text("│" <> String.pad_trailing(" 1-6 Show alert type", inner_width) <> "│", nil),
- text("│" <> String.pad_trailing(" Tab/←/→ Navigate buttons (in alert)", inner_width) <> "│", nil),
- text("│" <> String.pad_trailing(" Enter Select button", inner_width) <> "│", nil),
- text("│" <> String.pad_trailing(" Y/N Quick select (confirm only)", inner_width) <> "│", nil),
- text("│" <> String.pad_trailing(" Escape Cancel/Close alert", inner_width) <> "│", nil),
- text("│" <> String.pad_trailing(" Q Quit", inner_width) <> "│", nil),
- text("│" <> String.pad_trailing("", inner_width) <> "│", nil),
- text("│" <> String.pad_trailing(" Last result: #{result_text}", inner_width) <> "│", nil),
- text(bottom_border, Style.new(fg: :yellow))
- ])
- end
-
- defp format_result(nil, _type), do: "(none)"
- defp format_result(result, type), do: "#{type} -> #{result}"
-
- # ----------------------------------------------------------------------------
- # Public API
- # ----------------------------------------------------------------------------
-
- @doc """
- Run the alert dialog example application.
- """
- def run do
- TermUI.Runtime.run(root: __MODULE__)
- end
-end
diff --git a/examples/alert_dialog/lib/alert_dialog/application.ex b/examples/alert_dialog/lib/alert_dialog/application.ex
deleted file mode 100644
index 5953914d..00000000
--- a/examples/alert_dialog/lib/alert_dialog/application.ex
+++ /dev/null
@@ -1,12 +0,0 @@
-defmodule AlertDialog.Application do
- @moduledoc false
-
- use Application
-
- @impl true
- def start(_type, _args) do
- children = []
- opts = [strategy: :one_for_one, name: AlertDialog.Supervisor]
- Supervisor.start_link(children, opts)
- end
-end
diff --git a/examples/alert_dialog/mix.exs b/examples/alert_dialog/mix.exs
deleted file mode 100644
index 8950a2e1..00000000
--- a/examples/alert_dialog/mix.exs
+++ /dev/null
@@ -1,26 +0,0 @@
-defmodule AlertDialog.MixProject do
- use Mix.Project
-
- def project do
- [
- app: :alert_dialog,
- version: "0.1.0",
- elixir: "~> 1.15",
- start_permanent: Mix.env() == :prod,
- deps: deps()
- ]
- end
-
- def application do
- [
- extra_applications: [:logger],
- mod: {AlertDialog.Application, []}
- ]
- end
-
- defp deps do
- [
- {:term_ui, path: "../.."}
- ]
- end
-end
diff --git a/examples/alert_dialog/mix.lock b/examples/alert_dialog/mix.lock
deleted file mode 100644
index ee8761c0..00000000
--- a/examples/alert_dialog/mix.lock
+++ /dev/null
@@ -1,12 +0,0 @@
-%{
- "castore": {:hex, :castore, "1.0.17", "4f9770d2d45fbd91dcf6bd404cf64e7e58fed04fadda0923dc32acca0badffa2", [:mix], [], "hexpm", "12d24b9d80b910dd3953e165636d68f147a31db945d2dcb9365e441f8b5351e5"},
- "gen_stage": {:hex, :gen_stage, "1.3.2", "7c77e5d1e97de2c6c2f78f306f463bca64bf2f4c3cdd606affc0100b89743b7b", [:mix], [], "hexpm", "0ffae547fa777b3ed889a6b9e1e64566217413d018cabd825f786e843ffe63e7"},
- "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
- "lumis": {:hex, :lumis, "0.1.0", "6d3b495457d0608f8fe32fe6fa917eb17c921bd0087583a7f4c420c0009888fd", [:mix], [{:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:rustler, "~> 0.29", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.6", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "4de203bc5811ad21f0c6b0442612a3fc1ae9bea7fbfe7037452db9e9dfdaaab3"},
- "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"},
- "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"},
- "mdex": {:hex, :mdex, "0.11.3", "7b815ae2f474e62ad365dfa4d9df87ddec6a01cfa9b7db523a3b21061d909225", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:lumis, "~> 0.1", [hex: :lumis, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}, {:rustler, "~> 0.32", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.7", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "181bf04b13dcae20ab9f336115facc5096aae333a2408a03e53338ee65d4f564"},
- "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
- "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"},
- "rustler_precompiled": {:hex, :rustler_precompiled, "0.8.4", "700a878312acfac79fb6c572bb8b57f5aae05fe1cf70d34b5974850bbf2c05bf", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "3b33d99b540b15f142ba47944f7a163a25069f6d608783c321029bc1ffb09514"},
-}
diff --git a/examples/alert_dialog/run.exs b/examples/alert_dialog/run.exs
deleted file mode 100644
index fdb6a0f2..00000000
--- a/examples/alert_dialog/run.exs
+++ /dev/null
@@ -1 +0,0 @@
-AlertDialog.App.run()
diff --git a/examples/bar_chart/README.md b/examples/bar_chart/README.md
deleted file mode 100644
index e49c9295..00000000
--- a/examples/bar_chart/README.md
+++ /dev/null
@@ -1,110 +0,0 @@
-# BarChart Widget Example
-
-This example demonstrates the TermUI BarChart widget for displaying comparative values as horizontal or vertical bars with labels and values.
-
-## Widget Overview
-
-The BarChart widget renders visual representations of numeric data as bars, making it easy to compare values at a glance. It supports:
-
-- **Horizontal bars** - Traditional left-to-right bars with labels
-- **Vertical bars** - Column-style charts for different visualization needs
-- **Value display** - Show numeric values alongside bars
-- **Label display** - Identify each bar with text labels
-- **Color coding** - Apply custom colors to individual bars
-- **Simple bars** - Single-value progress bars
-
-Use BarChart when you need to visualize comparative data, show progress, or display statistical information in your TUI application.
-
-## Widget Options
-
-The `BarChart.render/1` function accepts the following options:
-
-- `:data` - List of data points (required), each with:
- - `:label` - Bar label (string)
- - `:value` - Numeric value
-- `:direction` - `:horizontal` or `:vertical` (default: `:horizontal`)
-- `:width` - Chart width in characters (default: 40, max: configurable)
-- `:height` - Chart height for vertical charts (default: 10, max: configurable)
-- `:show_values` - Display numeric values (default: `true`)
-- `:show_labels` - Display bar labels (default: `true`)
-- `:bar_char` - Character for filled bars (default: `"█"`)
-- `:empty_char` - Character for empty space (default: `" "`)
-- `:colors` - List of `Style` structs for bar colors (cycles through list)
-- `:style` - Overall chart style
-
-The `BarChart.bar/1` function for simple single bars accepts:
-
-- `:value` - Current value (required)
-- `:max` - Maximum value (required)
-- `:width` - Bar width (default: 20)
-- `:bar_char` - Filled character (default: `"█"`)
-- `:empty_char` - Empty character (default: `"░"`)
-
-## Example Structure
-
-The example consists of:
-
-- `lib/bar_chart/app.ex` - Main application demonstrating:
- - Dynamic direction switching (horizontal/vertical)
- - Toggle value and label display
- - Data randomization for live updates
- - Multiple chart configurations:
- - Main interactive chart
- - Simple single-bar progress indicator
- - Colored multi-bar chart
-
-## Running the Example
-
-### Raw Mode (Full TUI Experience)
-
-For the best experience with full terminal control and alternate screen:
-
-```bash
-cd examples/bar_chart
-mix termui.run
-```
-
-Or manually:
-
-```bash
-cd examples/bar_chart
-mix run -e "BarChart.App.run()" --no-halt
-```
-
-### TTY Mode (IEx Compatible)
-
-To run from IEx without taking over the shell:
-
-```bash
-cd examples/bar_chart
-iex -S mix
-```
-
-Then in IEx:
-
-```elixir
-BarChart.App.run()
-```
-
-**Note:** TTY mode works inside IEx but has limitations:
-- No alternate screen buffer (output mixes with IEx prompt)
-- Character input works immediately (no Enter needed)
-- For full TUI, use raw mode instead
-
-## Controls
-
-- `D` - Toggle chart direction (horizontal/vertical)
-- `V` - Toggle value display (ON/OFF)
-- `L` - Toggle label display (ON/OFF)
-- `R` - Randomize data values
-- `Q` - Quit application
-
-## Implementation Notes
-
-The example demonstrates:
-- Rendering horizontal bar charts with labels and values
-- Rendering vertical column charts with proper scaling
-- Dynamic chart reconfiguration based on user input
-- Using custom colors for different bars
-- Creating simple single-value progress bars
-- Proper data formatting and scaling to fit available space
diff --git a/examples/bar_chart/lib/bar_chart/app.ex b/examples/bar_chart/lib/bar_chart/app.ex
deleted file mode 100644
index 6c291000..00000000
--- a/examples/bar_chart/lib/bar_chart/app.ex
+++ /dev/null
@@ -1,206 +0,0 @@
-defmodule BarChart.App do
- @moduledoc """
- Bar Chart Widget Example
-
- This example demonstrates how to use the TermUI.Widgets.BarChart widget
- for displaying comparative values as horizontal or vertical bars.
-
- Features demonstrated:
- - Horizontal bar charts
- - Vertical bar charts
- - Custom colors per bar
- - Value and label display options
- - Simple single bar helper
-
- Controls:
- - D: Toggle chart direction (horizontal/vertical)
- - V: Toggle value display
- - L: Toggle label display
- - R: Randomize data
- - Q: Quit the application
- """
-
- use TermUI.Elm
-
- alias TermUI.Widgets.BarChart
- alias TermUI.Event
- alias TermUI.Renderer.Style
-
- # ----------------------------------------------------------------------------
- # Component Callbacks
- # ----------------------------------------------------------------------------
-
- @doc """
- Initialize the component state.
- """
- def init(_opts) do
- %{
- data: sample_data(),
- direction: :horizontal,
- show_values: true,
- show_labels: true
- }
- end
-
- defp sample_data do
- [
- %{label: "Sales", value: 150},
- %{label: "Marketing", value: 85},
- %{label: "Engineering", value: 200},
- %{label: "Support", value: 120},
- %{label: "HR", value: 45}
- ]
- end
-
- @doc """
- Convert keyboard events to messages.
- """
- def event_to_msg(%Event.Key{key: key}, _state) when key in ["d", "D"], do: {:msg, :toggle_direction}
- def event_to_msg(%Event.Key{key: key}, _state) when key in ["v", "V"], do: {:msg, :toggle_values}
- def event_to_msg(%Event.Key{key: key}, _state) when key in ["l", "L"], do: {:msg, :toggle_labels}
- def event_to_msg(%Event.Key{key: key}, _state) when key in ["r", "R"], do: {:msg, :randomize}
- def event_to_msg(%Event.Key{key: key}, _state) when key in ["q", "Q"], do: {:msg, :quit}
- def event_to_msg(_event, _state), do: :ignore
-
- @doc """
- Update state based on messages.
- """
- def update(:toggle_direction, state) do
- new_direction = if state.direction == :horizontal, do: :vertical, else: :horizontal
- {%{state | direction: new_direction}, []}
- end
-
- def update(:toggle_values, state) do
- {%{state | show_values: not state.show_values}, []}
- end
-
- def update(:toggle_labels, state) do
- {%{state | show_labels: not state.show_labels}, []}
- end
-
- def update(:randomize, state) do
- new_data =
- state.data
- |> Enum.map(fn item ->
- %{item | value: :rand.uniform(200) + 20}
- end)
-
- {%{state | data: new_data}, []}
- end
-
- def update(:quit, state) do
- {state, [:quit]}
- end
-
- @doc """
- Render the current state to a render tree.
- """
- def view(state) do
- stack(:vertical, [
- # Title
- text("Bar Chart Widget Example", Style.new(fg: :cyan, attrs: [:bold])),
- text("", nil),
-
- # Main chart based on current direction
- render_main_chart(state),
- text("", nil),
-
- # Simple bar example
- text("Simple single bar:", nil),
- BarChart.bar(
- value: 75,
- max: 100,
- width: 30
- ),
- text("", nil),
-
- # Colored bar chart example
- text("Bar chart with colors:", nil),
- BarChart.render(
- data: [
- %{label: "Red", value: 80},
- %{label: "Green", value: 60},
- %{label: "Blue", value: 90}
- ],
- direction: :horizontal,
- width: 40,
- show_values: true,
- # Colors cycle through this list
- colors: [
- Style.new(fg: :red),
- Style.new(fg: :green),
- Style.new(fg: :blue)
- ]
- ),
- text("", nil),
-
- # Controls
- render_controls(state)
- ])
- end
-
- defp render_controls(state) do
- box_width = 50
- inner_width = box_width - 2
-
- top_border = "┌─ Controls " <> String.duplicate("─", inner_width - 12) <> "─┐"
- bottom_border = "└" <> String.duplicate("─", inner_width) <> "┘"
-
- stack(:vertical, [
- text("", nil),
- text(top_border, Style.new(fg: :yellow)),
- text("│" <> String.pad_trailing(" D Toggle direction (#{state.direction})", inner_width) <> "│", nil),
- text("│" <> String.pad_trailing(" V Toggle values (#{if state.show_values, do: "ON", else: "OFF"})", inner_width) <> "│", nil),
- text("│" <> String.pad_trailing(" L Toggle labels (#{if state.show_labels, do: "ON", else: "OFF"})", inner_width) <> "│", nil),
- text("│" <> String.pad_trailing(" R Randomize data", inner_width) <> "│", nil),
- text("│" <> String.pad_trailing(" Q Quit", inner_width) <> "│", nil),
- text(bottom_border, Style.new(fg: :yellow))
- ])
- end
-
- # ----------------------------------------------------------------------------
- # Private Helpers
- # ----------------------------------------------------------------------------
-
- defp render_main_chart(state) do
- case state.direction do
- :horizontal ->
- stack(:vertical, [
- text("Horizontal Bar Chart:", nil),
- BarChart.render(
- data: state.data,
- direction: :horizontal,
- width: 50,
- show_values: state.show_values,
- show_labels: state.show_labels,
- bar_char: "█"
- )
- ])
-
- :vertical ->
- stack(:vertical, [
- text("Vertical Bar Chart:", nil),
- BarChart.render(
- data: state.data,
- direction: :vertical,
- width: 30,
- height: 8,
- show_values: state.show_values,
- show_labels: state.show_labels,
- bar_char: "█"
- )
- ])
- end
- end
-
- # ----------------------------------------------------------------------------
- # Public API
- # ----------------------------------------------------------------------------
-
- @doc """
- Run the bar chart example application.
- """
- def run do
- TermUI.Runtime.run(root: __MODULE__)
- end
-end
diff --git a/examples/bar_chart/lib/bar_chart/application.ex b/examples/bar_chart/lib/bar_chart/application.ex
deleted file mode 100644
index 650bec62..00000000
--- a/examples/bar_chart/lib/bar_chart/application.ex
+++ /dev/null
@@ -1,12 +0,0 @@
-defmodule BarChart.Application do
- @moduledoc false
-
- use Application
-
- @impl true
- def start(_type, _args) do
- children = []
- opts = [strategy: :one_for_one, name: BarChart.Supervisor]
- Supervisor.start_link(children, opts)
- end
-end
diff --git a/examples/bar_chart/mix.exs b/examples/bar_chart/mix.exs
deleted file mode 100644
index a834650c..00000000
--- a/examples/bar_chart/mix.exs
+++ /dev/null
@@ -1,26 +0,0 @@
-defmodule BarChart.MixProject do
- use Mix.Project
-
- def project do
- [
- app: :bar_chart,
- version: "0.1.0",
- elixir: "~> 1.15",
- start_permanent: Mix.env() == :prod,
- deps: deps()
- ]
- end
-
- def application do
- [
- extra_applications: [:logger],
- mod: {BarChart.Application, []}
- ]
- end
-
- defp deps do
- [
- {:term_ui, path: "../.."}
- ]
- end
-end
diff --git a/examples/bar_chart/mix.lock b/examples/bar_chart/mix.lock
deleted file mode 100644
index ee8761c0..00000000
--- a/examples/bar_chart/mix.lock
+++ /dev/null
@@ -1,12 +0,0 @@
-%{
- "castore": {:hex, :castore, "1.0.17", "4f9770d2d45fbd91dcf6bd404cf64e7e58fed04fadda0923dc32acca0badffa2", [:mix], [], "hexpm", "12d24b9d80b910dd3953e165636d68f147a31db945d2dcb9365e441f8b5351e5"},
- "gen_stage": {:hex, :gen_stage, "1.3.2", "7c77e5d1e97de2c6c2f78f306f463bca64bf2f4c3cdd606affc0100b89743b7b", [:mix], [], "hexpm", "0ffae547fa777b3ed889a6b9e1e64566217413d018cabd825f786e843ffe63e7"},
- "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
- "lumis": {:hex, :lumis, "0.1.0", "6d3b495457d0608f8fe32fe6fa917eb17c921bd0087583a7f4c420c0009888fd", [:mix], [{:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:rustler, "~> 0.29", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.6", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "4de203bc5811ad21f0c6b0442612a3fc1ae9bea7fbfe7037452db9e9dfdaaab3"},
- "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"},
- "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"},
- "mdex": {:hex, :mdex, "0.11.3", "7b815ae2f474e62ad365dfa4d9df87ddec6a01cfa9b7db523a3b21061d909225", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:lumis, "~> 0.1", [hex: :lumis, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}, {:rustler, "~> 0.32", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.7", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "181bf04b13dcae20ab9f336115facc5096aae333a2408a03e53338ee65d4f564"},
- "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
- "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"},
- "rustler_precompiled": {:hex, :rustler_precompiled, "0.8.4", "700a878312acfac79fb6c572bb8b57f5aae05fe1cf70d34b5974850bbf2c05bf", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "3b33d99b540b15f142ba47944f7a163a25069f6d608783c321029bc1ffb09514"},
-}
diff --git a/examples/bar_chart/run.exs b/examples/bar_chart/run.exs
deleted file mode 100644
index 725b44b0..00000000
--- a/examples/bar_chart/run.exs
+++ /dev/null
@@ -1 +0,0 @@
-BarChart.App.run()
diff --git a/examples/canvas/README.md b/examples/canvas/README.md
deleted file mode 100644
index c3494561..00000000
--- a/examples/canvas/README.md
+++ /dev/null
@@ -1,140 +0,0 @@
-# Canvas Widget Example
-
-This example demonstrates the TermUI Canvas widget, which provides a direct character buffer for custom drawing with primitives for lines, rectangles, text, and Braille graphics.
-
-## Widget Overview
-
-The Canvas widget offers a low-level drawing surface for creating custom visualizations, diagrams, charts, and graphics that don't fit standard widget patterns. It provides:
-
-- **Direct buffer access** - Set individual characters at any position
-- **Drawing primitives** - Lines (horizontal, vertical, diagonal), rectangles, text
-- **Braille graphics** - Sub-character resolution (2x4 dots per character cell)
-- **Flexible rendering** - Use callback functions or direct manipulation
-- **Clear and fill operations** - Reset or fill entire canvas
-
-Use Canvas when you need complete control over rendering, want to create custom visualizations, or need higher resolution than standard character-based rendering.
-
-## Widget Options
-
-The `Canvas.new/1` function accepts the following options:
-
-- `:width` - Canvas width in characters (default: 40)
-- `:height` - Canvas height in characters (default: 20)
-- `:default_char` - Character to fill canvas initially (default: `" "`)
-- `:on_draw` - Callback function `fn(state) -> state` to draw on canvas
-
-The `Canvas.draw/3` utility function creates a canvas inline:
-
-```elixir
-Canvas.draw(width, height, fn state ->
- # Draw operations here
-end)
-```
-
-## Drawing Functions
-
-**Character buffer operations:**
-- `clear/1` - Clear canvas with default character
-- `fill/2` - Fill canvas with specific character
-- `set_char/4` - Set character at (x, y) position
-- `get_char/3` - Get character at (x, y) position
-- `draw_text/4` - Draw text string at position
-
-**Line primitives:**
-- `draw_hline/5` - Horizontal line at (x, y) with length
-- `draw_vline/5` - Vertical line at (x, y) with length
-- `draw_line/6` - Arbitrary line between two points (Bresenham's algorithm)
-
-**Rectangle primitives:**
-- `draw_rect/6` - Rectangle outline with customizable border characters
-- `fill_rect/6` - Filled rectangle
-
-**Braille graphics (sub-character resolution):**
-- `set_dot/3` - Set dot at (x, y) in dot space (width*2, height*4)
-- `clear_dot/3` - Clear dot at position
-- `draw_braille_line/5` - Line with sub-character precision
-- `dots_to_braille/1` - Convert dot coordinates to Braille character
-- `braille_resolution/1` - Get canvas resolution in dots
-
-## Example Structure
-
-The example consists of:
-
-- `lib/canvas/app.ex` - Main application with three demos:
- - **Shapes demo** - Basic lines, points, and text
- - **Boxes demo** - Rectangle drawing with different border styles
- - **Braille demo** - Sub-character resolution explanation and patterns
-
-## Running the Example
-
-### Raw Mode (Full TUI Experience)
-
-For the best experience with full terminal control and alternate screen:
-
-```bash
-cd examples/canvas
-mix termui.run
-```
-
-Or manually:
-
-```bash
-cd examples/canvas
-mix run -e "Canvas.App.run()" --no-halt
-```
-
-### TTY Mode (IEx Compatible)
-
-To run from IEx without taking over the shell:
-
-```bash
-cd examples/canvas
-iex -S mix
-```
-
-Then in IEx:
-
-```elixir
-Canvas.App.run()
-```
-
-**Note:** TTY mode works inside IEx but has limitations:
-- No alternate screen buffer (output mixes with IEx prompt)
-- Character input works immediately (no Enter needed)
-- For full TUI, use raw mode instead
-
-## Controls
-
-- `1` - Show basic shapes demo
-- `2` - Show box drawing demo
-- `3` - Show Braille drawing demo
-- `C` - Clear canvas
-- `Q` - Quit application
-
-## Implementation Notes
-
-The example demonstrates:
-- Creating and drawing on a canvas using the `Canvas.draw/3` function
-- Drawing horizontal and vertical lines
-- Drawing diagonal lines with Bresenham's algorithm
-- Drawing rectangles with different border styles (single-line, double-line, rounded)
-- Nested rectangles
-- Text rendering at arbitrary positions
-- Braille graphics for sub-character resolution (each character = 2x4 dots)
-- Converting canvas state to string lines for rendering
-
-### Braille Graphics
-
-Braille characters provide 2x4 dot resolution per character cell:
-- Canvas character resolution: width × height
-- Canvas dot resolution: (width × 2) × (height × 4)
-
-Each Braille dot position is numbered:
-```
-1 4
-2 5
-3 6
-7 8
-```
-
-This enables smooth curves and higher-resolution graphics within the character grid.
diff --git a/examples/canvas/lib/canvas/app.ex b/examples/canvas/lib/canvas/app.ex
deleted file mode 100644
index 4228e076..00000000
--- a/examples/canvas/lib/canvas/app.ex
+++ /dev/null
@@ -1,239 +0,0 @@
-defmodule Canvas.App do
- @moduledoc """
- Canvas Widget Example
-
- This example demonstrates how to use the TermUI.Widgets.Canvas widget
- for custom drawing with direct buffer access.
-
- Features demonstrated:
- - Basic canvas creation
- - Drawing text at positions
- - Drawing lines (horizontal, vertical, diagonal)
- - Drawing rectangles
- - Drawing with Braille characters for sub-character resolution
-
- Controls:
- - 1: Show basic shapes demo
- - 2: Show box drawing demo
- - 3: Show Braille drawing demo
- - C: Clear canvas
- - Q: Quit the application
- """
-
- use TermUI.Elm
-
- alias TermUI.Event
- alias TermUI.Renderer.Style
- alias TermUI.Widgets.Canvas
-
- # Canvas dimensions
- @canvas_width 50
- @canvas_height 15
-
- # ----------------------------------------------------------------------------
- # Component Callbacks
- # ----------------------------------------------------------------------------
-
- @doc """
- Initialize the component state.
- """
- def init(_opts) do
- %{
- demo: :shapes,
- canvas: create_canvas(:shapes)
- }
- end
-
- defp create_canvas(demo) do
- # Use Canvas.draw/3 to create and draw on the canvas
- Canvas.draw(@canvas_width, @canvas_height, fn state ->
- case demo do
- :shapes -> draw_shapes_demo(state)
- :boxes -> draw_boxes_demo(state)
- :braille -> draw_braille_demo(state)
- end
- end)
- end
-
- @doc """
- Convert keyboard events to messages.
- """
- def event_to_msg(%Event.Key{key: "1"}, _state), do: {:msg, {:set_demo, :shapes}}
- def event_to_msg(%Event.Key{key: "2"}, _state), do: {:msg, {:set_demo, :boxes}}
- def event_to_msg(%Event.Key{key: "3"}, _state), do: {:msg, {:set_demo, :braille}}
- def event_to_msg(%Event.Key{key: key}, _state) when key in ["c", "C"], do: {:msg, :clear}
- def event_to_msg(%Event.Key{key: key}, _state) when key in ["q", "Q"], do: {:msg, :quit}
- def event_to_msg(_event, _state), do: :ignore
-
- @doc """
- Update state based on messages.
- """
- def update({:set_demo, demo}, state) do
- {%{state | demo: demo, canvas: create_canvas(demo)}, []}
- end
-
- def update(:clear, state) do
- {%{state | canvas: Canvas.clear(state.canvas)}, []}
- end
-
- def update(:quit, state) do
- {state, [:quit]}
- end
-
- @doc """
- Render the current state to a render tree.
- """
- def view(state) do
- stack(:vertical, [
- # Title
- text("Canvas Widget Example", Style.new(fg: :cyan, attrs: [:bold])),
- text("", nil),
-
- # Canvas area with border
- render_canvas(state.canvas),
- text("", nil),
-
- text("", nil),
-
- # Controls
- render_controls(state)
- ])
- end
-
- defp render_controls(state) do
- box_width = 36
- inner_width = box_width - 2
-
- top_border = "┌─ Controls " <> String.duplicate("─", inner_width - 12) <> "─┐"
- bottom_border = "└" <> String.duplicate("─", inner_width) <> "┘"
-
- stack(:vertical, [
- text("", nil),
- text(top_border, Style.new(fg: :yellow)),
- text("│" <> String.pad_trailing(" 1 Basic shapes demo", inner_width) <> "│", nil),
- text("│" <> String.pad_trailing(" 2 Box drawing demo", inner_width) <> "│", nil),
- text("│" <> String.pad_trailing(" 3 Braille drawing demo", inner_width) <> "│", nil),
- text("│" <> String.pad_trailing(" C Clear canvas", inner_width) <> "│", nil),
- text("│" <> String.pad_trailing(" Q Quit", inner_width) <> "│", nil),
- text("│" <> String.pad_trailing("", inner_width) <> "│", nil),
- text("│" <> String.pad_trailing(" Demo: #{state.demo}", inner_width) <> "│", nil),
- text(bottom_border, Style.new(fg: :yellow))
- ])
- end
-
- # ----------------------------------------------------------------------------
- # Canvas Rendering
- # ----------------------------------------------------------------------------
-
- defp render_canvas(canvas) do
- # Use Canvas.to_strings/1 to convert buffer to lines
- lines =
- canvas
- |> Canvas.to_strings()
- |> Enum.map(fn row -> text("│" <> row <> "│", nil) end)
-
- # Add borders
- top_border = text("┌" <> String.duplicate("─", canvas.width) <> "┐", nil)
- bottom_border = text("└" <> String.duplicate("─", canvas.width) <> "┘", nil)
-
- stack(:vertical, [top_border | lines] ++ [bottom_border])
- end
-
- # ----------------------------------------------------------------------------
- # Demo Drawing Functions
- # ----------------------------------------------------------------------------
-
- defp draw_shapes_demo(state) do
- state
- # Draw title text
- |> Canvas.draw_text(2, 1, "Basic Shapes Demo")
- # Draw horizontal line
- |> Canvas.draw_hline(2, 3, 20, "─")
- # Draw vertical line
- |> Canvas.draw_vline(25, 3, 8, "│")
- # Draw diagonal line using dots
- |> Canvas.draw_line(30, 3, 45, 10, "•")
- # Draw some points
- |> Canvas.draw_text(2, 5, "Points: ")
- |> Canvas.set_char(10, 5, "●")
- |> Canvas.set_char(12, 5, "○")
- |> Canvas.set_char(14, 5, "◆")
- |> Canvas.set_char(16, 5, "◇")
- # Draw labels
- |> Canvas.draw_text(2, 8, "H-Line above")
- |> Canvas.draw_text(27, 6, "V")
- |> Canvas.draw_text(32, 12, "Diagonal")
- end
-
- defp draw_boxes_demo(state) do
- state
- # Draw title
- |> Canvas.draw_text(2, 1, "Box Drawing Demo")
- # Draw a simple box
- |> Canvas.draw_rect(2, 3, 15, 5)
- |> Canvas.draw_text(4, 5, "Box 1")
- # Draw another box with double lines
- |> Canvas.draw_rect(20, 3, 15, 5, %{
- h: "═",
- v: "║",
- tl: "╔",
- tr: "╗",
- bl: "╚",
- br: "╝"
- })
- |> Canvas.draw_text(22, 5, "Box 2")
- # Draw a box with rounded corners
- |> Canvas.draw_rect(2, 9, 15, 5, %{
- h: "─",
- v: "│",
- tl: "╭",
- tr: "╮",
- bl: "╰",
- br: "╯"
- })
- |> Canvas.draw_text(4, 11, "Rounded")
- # Draw nested boxes
- |> Canvas.draw_rect(20, 9, 20, 5)
- |> Canvas.draw_rect(22, 10, 16, 3)
- |> Canvas.draw_text(25, 11, "Nested")
- end
-
- defp draw_braille_demo(state) do
- # For the braille demo, we need to use braille_buffer
- # Each character cell is 2 dots wide x 4 dots high
-
- state
- |> Canvas.draw_text(2, 1, "Braille Drawing Demo")
- |> Canvas.draw_text(2, 3, "Sub-character resolution using Braille patterns:")
- # Show the braille characters
- |> Canvas.draw_text(2, 5, "Empty: " <> Canvas.empty_braille())
- |> Canvas.draw_text(12, 5, "Full: " <> Canvas.full_braille())
- # Show individual dot positions
- |> Canvas.draw_text(2, 7, "Dot positions in a cell:")
- |> Canvas.draw_text(2, 8, "1 4")
- |> Canvas.draw_text(2, 9, "2 5")
- |> Canvas.draw_text(2, 10, "3 6")
- |> Canvas.draw_text(2, 11, "7 8")
- # Draw some braille patterns
- |> Canvas.draw_text(10, 7, "Patterns:")
- |> Canvas.draw_text(10, 8, Canvas.dots_to_braille([{0, 0}]))
- |> Canvas.draw_text(12, 8, Canvas.dots_to_braille([{1, 0}]))
- |> Canvas.draw_text(14, 8, Canvas.dots_to_braille([{0, 1}]))
- |> Canvas.draw_text(16, 8, Canvas.dots_to_braille([{0, 0}, {1, 1}]))
- |> Canvas.draw_text(18, 8, Canvas.dots_to_braille([{0, 0}, {0, 1}, {0, 2}, {0, 3}]))
- |> Canvas.draw_text(20, 8, Canvas.dots_to_braille([{0, 0}, {1, 0}, {0, 1}, {1, 1}]))
- # Resolution info
- |> Canvas.draw_text(2, 13, "Canvas: #{@canvas_width}x#{@canvas_height} chars = #{@canvas_width * 2}x#{@canvas_height * 4} braille dots")
- end
-
- # ----------------------------------------------------------------------------
- # Public API
- # ----------------------------------------------------------------------------
-
- @doc """
- Run the canvas example application.
- """
- def run do
- TermUI.Runtime.run(root: __MODULE__)
- end
-end
diff --git a/examples/canvas/lib/canvas/application.ex b/examples/canvas/lib/canvas/application.ex
deleted file mode 100644
index 64e5b796..00000000
--- a/examples/canvas/lib/canvas/application.ex
+++ /dev/null
@@ -1,12 +0,0 @@
-defmodule Canvas.Application do
- @moduledoc false
-
- use Application
-
- @impl true
- def start(_type, _args) do
- children = []
- opts = [strategy: :one_for_one, name: Canvas.Supervisor]
- Supervisor.start_link(children, opts)
- end
-end
diff --git a/examples/canvas/mix.exs b/examples/canvas/mix.exs
deleted file mode 100644
index d5e080a7..00000000
--- a/examples/canvas/mix.exs
+++ /dev/null
@@ -1,26 +0,0 @@
-defmodule Canvas.MixProject do
- use Mix.Project
-
- def project do
- [
- app: :canvas,
- version: "0.1.0",
- elixir: "~> 1.15",
- start_permanent: Mix.env() == :prod,
- deps: deps()
- ]
- end
-
- def application do
- [
- extra_applications: [:logger],
- mod: {Canvas.Application, []}
- ]
- end
-
- defp deps do
- [
- {:term_ui, path: "../.."}
- ]
- end
-end
diff --git a/examples/canvas/mix.lock b/examples/canvas/mix.lock
deleted file mode 100644
index ee8761c0..00000000
--- a/examples/canvas/mix.lock
+++ /dev/null
@@ -1,12 +0,0 @@
-%{
- "castore": {:hex, :castore, "1.0.17", "4f9770d2d45fbd91dcf6bd404cf64e7e58fed04fadda0923dc32acca0badffa2", [:mix], [], "hexpm", "12d24b9d80b910dd3953e165636d68f147a31db945d2dcb9365e441f8b5351e5"},
- "gen_stage": {:hex, :gen_stage, "1.3.2", "7c77e5d1e97de2c6c2f78f306f463bca64bf2f4c3cdd606affc0100b89743b7b", [:mix], [], "hexpm", "0ffae547fa777b3ed889a6b9e1e64566217413d018cabd825f786e843ffe63e7"},
- "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
- "lumis": {:hex, :lumis, "0.1.0", "6d3b495457d0608f8fe32fe6fa917eb17c921bd0087583a7f4c420c0009888fd", [:mix], [{:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:rustler, "~> 0.29", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.6", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "4de203bc5811ad21f0c6b0442612a3fc1ae9bea7fbfe7037452db9e9dfdaaab3"},
- "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"},
- "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"},
- "mdex": {:hex, :mdex, "0.11.3", "7b815ae2f474e62ad365dfa4d9df87ddec6a01cfa9b7db523a3b21061d909225", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:lumis, "~> 0.1", [hex: :lumis, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}, {:rustler, "~> 0.32", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.7", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "181bf04b13dcae20ab9f336115facc5096aae333a2408a03e53338ee65d4f564"},
- "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
- "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"},
- "rustler_precompiled": {:hex, :rustler_precompiled, "0.8.4", "700a878312acfac79fb6c572bb8b57f5aae05fe1cf70d34b5974850bbf2c05bf", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "3b33d99b540b15f142ba47944f7a163a25069f6d608783c321029bc1ffb09514"},
-}
diff --git a/examples/canvas/run.exs b/examples/canvas/run.exs
deleted file mode 100644
index 405f624e..00000000
--- a/examples/canvas/run.exs
+++ /dev/null
@@ -1 +0,0 @@
-Canvas.App.run()
diff --git a/examples/cluster_dashboard/README.md b/examples/cluster_dashboard/README.md
deleted file mode 100644
index ab78a470..00000000
--- a/examples/cluster_dashboard/README.md
+++ /dev/null
@@ -1,167 +0,0 @@
-# ClusterDashboard Widget Example
-
-This example demonstrates the TermUI ClusterDashboard widget for visualizing and monitoring distributed Erlang/BEAM clusters.
-
-## Widget Overview
-
-The ClusterDashboard widget provides comprehensive cluster monitoring and debugging capabilities for distributed BEAM applications. It displays:
-
-- **Nodes view** - Connected nodes with status indicators and health metrics
-- **Global names** - Cross-node process registry (`:global` module)
-- **PG groups** - Process group membership (`:pg` module)
-- **Events log** - Node connection/disconnection history
-- **Network partition detection** - Alerts when multiple nodes disconnect
-- **Remote inspection** - RPC-based node details
-
-Use ClusterDashboard when building distributed applications that need visibility into cluster topology, node health, process distribution, and connection stability.
-
-## Widget Options
-
-The `ClusterDashboard.new/1` function accepts the following options:
-
-- `:update_interval` - Refresh interval in milliseconds (default: 2000)
-- `:show_health_metrics` - Fetch and display CPU/memory/load (default: `true`)
-- `:show_pg_groups` - Display `:pg` process groups (default: `true`)
-- `:show_global_names` - Display `:global` registered names (default: `true`)
-- `:on_node_select` - Callback function when node is selected
-
-## Example Structure
-
-The example consists of:
-
-- `lib/cluster_dashboard/app.ex` - Main application demonstrating:
- - Cluster monitoring with automatic refresh
- - View switching between nodes, globals, PG groups, and events
- - Interactive navigation and details panels
- - Test functions for spawning global processes and joining PG groups
-
-## Running the Example
-
-### Raw Mode (Full TUI Experience)
-
-For the best experience with full terminal control and alternate screen:
-
-```bash
-cd examples/cluster_dashboard
-mix termui.run
-```
-
-Or manually:
-
-```bash
-cd examples/cluster_dashboard
-mix run -e "ClusterDashboardExample.App.run()" --no-halt
-```
-
-### TTY Mode (IEx Compatible)
-
-To run from IEx without taking over the shell:
-
-```bash
-cd examples/cluster_dashboard
-iex -S mix
-```
-
-Then in IEx:
-
-```elixir
-ClusterDashboardExample.App.run()
-```
-
-**Note:** TTY mode works inside IEx but has limitations:
-- No alternate screen buffer (output mixes with IEx prompt)
-- Character input works immediately (no Enter needed)
-- For full TUI, use raw mode instead
-
-### Multiple Nodes (Distributed)
-
-To see the full cluster capabilities, start multiple nodes:
-
-**Terminal 1:**
-```bash
-iex --sname node1 -S mix
-```
-```elixir
-ClusterDashboardExample.App.run()
-```
-
-**Terminal 2:**
-```bash
-iex --sname node2 -S mix
-```
-```elixir
-Node.connect(:node1@hostname) # Replace hostname with your machine name
-```
-
-**Terminal 3:**
-```bash
-iex --sname node3 -S mix
-```
-```elixir
-Node.connect(:node1@hostname)
-```
-
-The dashboard on node1 will show all connected nodes with their metrics.
-
-## Controls
-
-**View switching:**
-- `n` - Switch to Nodes view
-- `g` - Switch to Global names view
-- `p` - Switch to PG groups view
-- `e` - Switch to Events view
-
-**Navigation:**
-- `↑` / `↓` - Navigate through list items
-- `PageUp` / `PageDown` - Scroll by page
-- `Home` - Jump to first item
-- `End` - Jump to last item
-
-**Actions:**
-- `Enter` - Toggle details panel for selected item
-- `i` - Inspect selected node (in Nodes view)
-- `r` - Refresh data now
-- `Escape` - Close details panel / clear alerts
-- `q` - Quit application
-
-**Testing:**
-- `G` - Register a test global process
-- `P` - Join a test PG group
-
-## Implementation Notes
-
-The example demonstrates:
-
-- **Real-time monitoring** - Automatic data refresh at configurable intervals
-- **Node monitoring** - Subscribe to `:nodeup` and `:nodedown` events
-- **Health metrics** - Fetch process count, memory usage, scheduler info via RPC
-- **Multiple views** - Switch between different cluster aspects
-- **Scrollable lists** - Handle large datasets with viewport scrolling
-- **Details panels** - Show expanded information for selected items
-- **Network partition detection** - Alert when multiple nodes disconnect rapidly
-- **Event logging** - Track connection/disconnection history with timestamps
-
-### Node Health Metrics
-
-The dashboard displays:
-- **Process count** - Number of running processes
-- **Memory usage** - Total and process memory (formatted as B/KB/MB/GB)
-- **Scheduler count** - Number of online schedulers
-- **Uptime** - Node runtime duration
-- **OTP release** - OTP version
-
-### Distributed Features
-
-- **:global names** - Shows processes registered globally across the cluster
-- **:pg groups** - Shows process groups and their membership across nodes
-- **RPC calls** - Remote procedure calls with timeout protection
-- **Partition alerts** - Detects when 2+ nodes disconnect within 5 seconds
-
-## Use Cases
-
-- Monitor cluster health in production
-- Debug distributed system issues
-- Visualize process distribution across nodes
-- Track node connectivity stability
-- Inspect cross-node process registries
-- Detect network partitions early
diff --git a/examples/cluster_dashboard/lib/cluster_dashboard/app.ex b/examples/cluster_dashboard/lib/cluster_dashboard/app.ex
deleted file mode 100644
index 6ce0e8e5..00000000
--- a/examples/cluster_dashboard/lib/cluster_dashboard/app.ex
+++ /dev/null
@@ -1,278 +0,0 @@
-defmodule ClusterDashboardExample.App do
- @moduledoc """
- Example application demonstrating the ClusterDashboard widget.
-
- This example shows:
- - Connected nodes display with status
- - Node health metrics (processes, memory)
- - Global registered names
- - PG process groups
- - Connection events log
- - Network partition detection
-
- ## Running
-
- To test with a single node (non-distributed):
-
- cd examples/cluster_dashboard
- mix deps.get
- iex -S mix
- ClusterDashboardExample.App.run()
-
- To test with multiple nodes (distributed):
-
- Terminal 1:
- iex --sname node1 -S mix
- ClusterDashboardExample.App.run()
-
- Terminal 2:
- iex --sname node2 -S mix
- Node.connect(:node1@hostname)
-
- Terminal 3:
- iex --sname node3 -S mix
- Node.connect(:node1@hostname)
-
- ## Controls
-
- - Up/Down: Navigate list
- - PageUp/PageDown: Scroll by page
- - Enter: Toggle details panel
- - r: Refresh now
- - n: Show nodes view
- - g: Show global names view
- - p: Show :pg groups view
- - e: Show events view
- - i: Inspect selected node (in nodes view)
- - Escape: Close details / clear alerts
- - G: Register a test global process
- - P: Join a test PG group
- - q: Quit
- """
-
- use TermUI.Elm
-
- alias TermUI.Event
- alias TermUI.Renderer.Style
- alias TermUI.Widgets.ClusterDashboard
-
- # ----------------------------------------------------------------------------
- # Component Callbacks
- # ----------------------------------------------------------------------------
-
- @doc """
- Initialize the component state.
- """
- def init(_opts) do
- props =
- ClusterDashboard.new(
- update_interval: 2000,
- show_health_metrics: true,
- show_pg_groups: true,
- show_global_names: true
- )
-
- {:ok, dashboard_state} = ClusterDashboard.init(props)
-
- %{
- dashboard: dashboard_state,
- message: "ClusterDashboard Example - Views: [n]odes [g]lobals [p]g [e]vents"
- }
- end
-
- @doc """
- Convert events to messages.
- """
- # Navigation keys - forward to dashboard
- def event_to_msg(%Event.Key{key: key}, _state)
- when key in [:up, :down, :page_up, :page_down, :home, :end, :enter, :escape] do
- {:msg, {:dashboard_event, %Event.Key{key: key}}}
- end
-
- # View mode switches
- def event_to_msg(%Event.Key{key: "n"}, _state), do: {:msg, {:view_mode, :nodes}}
- def event_to_msg(%Event.Key{key: "g"}, _state), do: {:msg, {:view_mode, :globals}}
- def event_to_msg(%Event.Key{key: "p"}, _state), do: {:msg, {:view_mode, :pg}}
- def event_to_msg(%Event.Key{key: "e"}, _state), do: {:msg, {:view_mode, :events}}
- def event_to_msg(%Event.Key{key: "i"}, _state), do: {:msg, :inspect_node}
- def event_to_msg(%Event.Key{key: "r"}, _state), do: {:msg, :refresh}
-
- # Test actions
- def event_to_msg(%Event.Key{key: "G"}, _state), do: {:msg, :spawn_global}
- def event_to_msg(%Event.Key{key: "P"}, _state), do: {:msg, :join_pg}
-
- # Quit
- def event_to_msg(%Event.Key{key: key}, _state) when key in ["q", "Q"], do: {:msg, :quit}
-
- # Tick for auto-refresh
- def event_to_msg(%Event.Tick{}, _state), do: {:msg, :tick}
-
- def event_to_msg(_event, _state), do: :ignore
-
- @doc """
- Update state based on messages.
- """
- def update({:dashboard_event, event}, state) do
- {:ok, dashboard} = ClusterDashboard.handle_event(event, state.dashboard)
- {%{state | dashboard: dashboard}, []}
- end
-
- def update({:view_mode, mode}, state) do
- # Create a key event to switch view mode
- key = case mode do
- :nodes -> "n"
- :globals -> "g"
- :pg -> "p"
- :events -> "e"
- end
- event = %Event.Key{key: key}
- {:ok, dashboard} = ClusterDashboard.handle_event(event, state.dashboard)
- message = case mode do
- :nodes -> "Nodes view"
- :globals -> "Global names view"
- :pg -> "PG groups view"
- :events -> "Events view"
- end
- {%{state | dashboard: dashboard, message: message}, []}
- end
-
- def update(:inspect_node, state) do
- event = %Event.Key{key: "i"}
- {:ok, dashboard} = ClusterDashboard.handle_event(event, state.dashboard)
- {%{state | dashboard: dashboard, message: "Inspecting node..."}, []}
- end
-
- def update(:refresh, state) do
- {:ok, dashboard} = ClusterDashboard.refresh(state.dashboard)
- {%{state | dashboard: dashboard, message: "Refreshed"}, []}
- end
-
- def update(:spawn_global, state) do
- spawn_global_process()
- {:ok, dashboard} = ClusterDashboard.refresh(state.dashboard)
- {%{state | dashboard: dashboard, message: "Registered global process"}, []}
- end
-
- def update(:join_pg, state) do
- join_pg_group()
- {:ok, dashboard} = ClusterDashboard.refresh(state.dashboard)
- {%{state | dashboard: dashboard, message: "Joined :pg group"}, []}
- end
-
- def update(:tick, state) do
- # Check if dashboard needs refresh based on its update interval
- {:ok, dashboard} = ClusterDashboard.handle_info(:refresh, state.dashboard)
- {%{state | dashboard: dashboard}, []}
- end
-
- def update(:quit, state) do
- {state, [:quit]}
- end
-
- @doc """
- Render the current state to a render tree.
- """
- def view(state) do
- area = %{x: 0, y: 0, width: 100, height: 25}
- dashboard_view = ClusterDashboard.render(state.dashboard, area)
-
- # Pad message to ensure full display (avoid truncation)
- padded_message = String.pad_trailing(state.message, 120)
-
- stack(:vertical, [
- text("ClusterDashboard Widget Example", Style.new(fg: :cyan, attrs: [:bold])),
- text(padded_message, Style.new(fg: :yellow)),
- text("", nil),
- dashboard_view,
- text("", nil),
- render_controls()
- ])
- end
-
- # ----------------------------------------------------------------------------
- # Private Helpers
- # ----------------------------------------------------------------------------
-
- defp render_controls do
- box_width = 55
- inner_width = box_width - 2
-
- top_border = "┌─ Controls " <> String.duplicate("─", inner_width - 12) <> "─┐"
- bottom_border = "└" <> String.duplicate("─", inner_width) <> "┘"
-
- stack(:vertical, [
- text(top_border, Style.new(fg: :yellow)),
- text("│" <> String.pad_trailing(" n/g/p/e Switch views", inner_width) <> "│", nil),
- text("│" <> String.pad_trailing(" Up/Down Navigate list", inner_width) <> "│", nil),
- text("│" <> String.pad_trailing(" Enter Toggle details panel", inner_width) <> "│", nil),
- text("│" <> String.pad_trailing(" i Inspect selected node", inner_width) <> "│", nil),
- text("│" <> String.pad_trailing(" r Refresh now", inner_width) <> "│", nil),
- text("│" <> String.pad_trailing(" G Register test global process", inner_width) <> "│", nil),
- text("│" <> String.pad_trailing(" P Join test PG group", inner_width) <> "│", nil),
- text("│" <> String.pad_trailing(" Escape Close details / clear alerts", inner_width) <> "│", nil),
- text("│" <> String.pad_trailing(" q Quit", inner_width) <> "│", nil),
- text(bottom_border, Style.new(fg: :yellow))
- ])
- end
-
- # Helper to spawn a test globally registered process
- defp spawn_global_process do
- name = :"test_global_#{System.unique_integer([:positive])}"
-
- pid =
- spawn(fn ->
- receive do
- :stop -> :ok
- end
- end)
-
- case :global.register_name(name, pid) do
- :yes -> :ok
- :no -> :error
- end
- rescue
- _ -> :error
- end
-
- # Helper to join a PG group
- defp join_pg_group do
- group = :test_group
-
- # Ensure :pg is started
- case :pg.start_link() do
- {:ok, _} -> :ok
- {:error, {:already_started, _}} -> :ok
- _ -> :ok
- end
-
- pid =
- spawn(fn ->
- receive do
- :stop -> :ok
- end
- end)
-
- :pg.join(group, pid)
- rescue
- _ -> :error
- catch
- _, _ -> :error
- end
-
- # ----------------------------------------------------------------------------
- # Public API
- # ----------------------------------------------------------------------------
-
- @doc """
- Run the example application.
-
- ## Examples
-
- # Run interactively
- ClusterDashboardExample.App.run()
-
- """
- def run do
- TermUI.Runtime.run(root: __MODULE__)
- end
-end
diff --git a/examples/cluster_dashboard/lib/cluster_dashboard/application.ex b/examples/cluster_dashboard/lib/cluster_dashboard/application.ex
deleted file mode 100644
index 35ed0d5f..00000000
--- a/examples/cluster_dashboard/lib/cluster_dashboard/application.ex
+++ /dev/null
@@ -1,12 +0,0 @@
-defmodule ClusterDashboardExample.Application do
- @moduledoc false
-
- use Application
-
- @impl true
- def start(_type, _args) do
- children = []
- opts = [strategy: :one_for_one, name: ClusterDashboardExample.Supervisor]
- Supervisor.start_link(children, opts)
- end
-end
diff --git a/examples/cluster_dashboard/mix.exs b/examples/cluster_dashboard/mix.exs
deleted file mode 100644
index a2da6fe1..00000000
--- a/examples/cluster_dashboard/mix.exs
+++ /dev/null
@@ -1,26 +0,0 @@
-defmodule ClusterDashboardExample.MixProject do
- use Mix.Project
-
- def project do
- [
- app: :cluster_dashboard_example,
- version: "0.1.0",
- elixir: "~> 1.15",
- start_permanent: Mix.env() == :prod,
- deps: deps()
- ]
- end
-
- def application do
- [
- extra_applications: [:logger],
- mod: {ClusterDashboardExample.Application, []}
- ]
- end
-
- defp deps do
- [
- {:term_ui, path: "../.."}
- ]
- end
-end
diff --git a/examples/cluster_dashboard/mix.lock b/examples/cluster_dashboard/mix.lock
deleted file mode 100644
index ee8761c0..00000000
--- a/examples/cluster_dashboard/mix.lock
+++ /dev/null
@@ -1,12 +0,0 @@
-%{
- "castore": {:hex, :castore, "1.0.17", "4f9770d2d45fbd91dcf6bd404cf64e7e58fed04fadda0923dc32acca0badffa2", [:mix], [], "hexpm", "12d24b9d80b910dd3953e165636d68f147a31db945d2dcb9365e441f8b5351e5"},
- "gen_stage": {:hex, :gen_stage, "1.3.2", "7c77e5d1e97de2c6c2f78f306f463bca64bf2f4c3cdd606affc0100b89743b7b", [:mix], [], "hexpm", "0ffae547fa777b3ed889a6b9e1e64566217413d018cabd825f786e843ffe63e7"},
- "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
- "lumis": {:hex, :lumis, "0.1.0", "6d3b495457d0608f8fe32fe6fa917eb17c921bd0087583a7f4c420c0009888fd", [:mix], [{:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:rustler, "~> 0.29", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.6", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "4de203bc5811ad21f0c6b0442612a3fc1ae9bea7fbfe7037452db9e9dfdaaab3"},
- "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"},
- "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"},
- "mdex": {:hex, :mdex, "0.11.3", "7b815ae2f474e62ad365dfa4d9df87ddec6a01cfa9b7db523a3b21061d909225", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:lumis, "~> 0.1", [hex: :lumis, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}, {:rustler, "~> 0.32", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.7", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "181bf04b13dcae20ab9f336115facc5096aae333a2408a03e53338ee65d4f564"},
- "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
- "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"},
- "rustler_precompiled": {:hex, :rustler_precompiled, "0.8.4", "700a878312acfac79fb6c572bb8b57f5aae05fe1cf70d34b5974850bbf2c05bf", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "3b33d99b540b15f142ba47944f7a163a25069f6d608783c321029bc1ffb09514"},
-}
diff --git a/examples/cluster_dashboard/run.exs b/examples/cluster_dashboard/run.exs
deleted file mode 100644
index 4a675b2a..00000000
--- a/examples/cluster_dashboard/run.exs
+++ /dev/null
@@ -1 +0,0 @@
-ClusterDashboardExample.App.run()
diff --git a/examples/command_palette/README.md b/examples/command_palette/README.md
deleted file mode 100644
index f1c7d906..00000000
--- a/examples/command_palette/README.md
+++ /dev/null
@@ -1,136 +0,0 @@
-# CommandPalette Widget Example
-
-This example demonstrates the TermUI CommandPalette widget, a simple command dropdown for filtering and selecting commands with keyboard input.
-
-## Widget Overview
-
-The CommandPalette widget provides a searchable command menu similar to typing `/` in applications like Claude Code, Slack, or Discord to see available commands. It features:
-
-- **Prefix filtering** - Type to narrow down command list
-- **Keyboard navigation** - Arrow keys to select commands
-- **Quick execution** - Enter to select command
-- **Visible/hidden states** - Toggle dropdown display
-- **Scrollable results** - Handle many commands with viewport scrolling
-
-Use CommandPalette when you want to provide a quick-access command menu, implement slash commands, or create a searchable action list without cluttering the UI with buttons or menus.
-
-## Widget Options
-
-The `CommandPalette.new/1` function accepts the following options:
-
-- `:commands` - List of command maps (required), each with:
- - `:id` - Unique identifier (atom)
- - `:label` - Display text shown in dropdown (string)
- - `:action` - Function to execute when selected (0-arity function)
-- `:max_visible` - Maximum visible results in dropdown (default: 8)
-
-## Example Structure
-
-The example consists of:
-
-- `lib/command_palette/app.ex` - Main application demonstrating:
- - Opening palette with `/` key
- - Filtering commands as user types
- - Selecting and "executing" commands
- - Displaying execution results
- - Managing palette visibility state
-
-The example includes sample commands like `/help`, `/save`, `/quit`, `/settings`, etc.
-
-## Running the Example
-
-### Raw Mode (Full TUI Experience)
-
-For the best experience with full terminal control and alternate screen:
-
-```bash
-cd examples/command_palette
-mix termui.run
-```
-
-Or manually:
-
-```bash
-cd examples/command_palette
-mix run -e "CommandPalette.run()" --no-halt
-```
-
-### TTY Mode (IEx Compatible)
-
-To run from IEx without taking over the shell:
-
-```bash
-cd examples/command_palette
-iex -S mix
-```
-
-Then in IEx:
-
-```elixir
-CommandPalette.run()
-```
-
-**Note:** TTY mode works inside IEx but has limitations:
-- No alternate screen buffer (output mixes with IEx prompt)
-- Character input works immediately (no Enter needed)
-- For full TUI, use raw mode instead
-
-## Controls
-
-**When palette is closed:**
-- `/` - Open command dropdown
-
-**When palette is open:**
-- Type any character - Add to search query and filter commands
-- `Backspace` - Remove last character from query
-- `↑` / `↓` - Navigate through filtered results
-- `Enter` - Select command (closes palette and sets query)
-- `Escape` - Close palette without selecting
-
-**General:**
-- `Q` - Quit application (when palette closed)
-
-## Implementation Notes
-
-The example demonstrates:
-
-- **Dynamic filtering** - Commands are filtered in real-time as the user types
-- **State management** - Tracking query, filtered results, selection, and visibility
-- **Keyboard handling** - Different event handling based on palette state
-- **Scroll management** - Keeping selected item visible in viewport
-- **Result display** - Showing last executed command
-
-### Implementation Pattern
-
-The example shows a common pattern for command palettes:
-
-1. User presses trigger key (`/`)
-2. Palette opens with all commands visible
-3. User types to filter commands
-4. Arrow keys navigate filtered results
-5. Enter selects command (in this example, it populates the query rather than executing)
-6. Application handles the selected command
-
-### Extending the Example
-
-To make commands executable immediately (instead of just populating the query):
-
-```elixir
-def update({:palette_event, %Event.Key{key: :enter}}, state) do
- case CommandPalette.get_selected(state.palette) do
- nil ->
- {state, []}
- command ->
- command.action.() # Execute the action
- {state, []}
- end
-end
-```
-
-## Use Cases
-
-- Slash command interfaces (like Slack, Discord)
-- Quick command launchers
-- Action menus without permanent UI elements
-- Searchable function lists
-- Keyboard-driven navigation systems
diff --git a/examples/command_palette/lib/command_palette.ex b/examples/command_palette/lib/command_palette.ex
deleted file mode 100644
index 756d5d5e..00000000
--- a/examples/command_palette/lib/command_palette.ex
+++ /dev/null
@@ -1,7 +0,0 @@
-defmodule CommandPalette do
- @moduledoc """
- CommandPalette example entry point.
- """
-
- defdelegate run, to: CommandPalette.App
-end
diff --git a/examples/command_palette/lib/command_palette/app.ex b/examples/command_palette/lib/command_palette/app.ex
deleted file mode 100644
index 388b6e93..00000000
--- a/examples/command_palette/lib/command_palette/app.ex
+++ /dev/null
@@ -1,158 +0,0 @@
-defmodule CommandPalette.App do
- @moduledoc """
- Command Palette Widget Example
-
- Demonstrates a simple command dropdown triggered by typing `/`.
- Similar to how Claude Code shows available slash commands.
-
- Controls:
- - `/` opens the command dropdown
- - Type to filter commands
- - Up/Down to navigate
- - Enter to execute selected command
- - Escape to close
- - Q (when dropdown closed) to quit
- """
-
- use TermUI.Elm
-
- alias TermUI.Event
- alias TermUI.Renderer.Style
- alias TermUI.Widgets.CommandPalette
-
- # ----------------------------------------------------------------------------
- # Component Callbacks
- # ----------------------------------------------------------------------------
-
- def init(_opts) do
- # Create command palette (initially hidden)
- palette_props = CommandPalette.new(commands: available_commands())
- {:ok, palette} = CommandPalette.init(palette_props)
- palette = CommandPalette.hide(palette)
-
- %{
- palette: palette,
- message: nil
- }
- end
-
- defp available_commands do
- [
- %{id: :help, label: "/help", action: fn -> :ok end},
- %{id: :clear, label: "/clear", action: fn -> :ok end},
- %{id: :save, label: "/save", action: fn -> :ok end},
- %{id: :open, label: "/open", action: fn -> :ok end},
- %{id: :new, label: "/new", action: fn -> :ok end},
- %{id: :quit, label: "/quit", action: fn -> :ok end},
- %{id: :settings, label: "/settings", action: fn -> :ok end},
- %{id: :theme, label: "/theme", action: fn -> :ok end},
- %{id: :format, label: "/format", action: fn -> :ok end},
- %{id: :search, label: "/search", action: fn -> :ok end}
- ]
- end
-
- def event_to_msg(%Event.Key{key: key}, %{palette: palette}) do
- if CommandPalette.visible?(palette) do
- {:msg, {:palette_event, %Event.Key{key: key}}}
- else
- query = CommandPalette.get_query(palette)
-
- case key do
- "/" -> {:msg, :open_palette}
- :enter when query != "" -> {:msg, :execute_command}
- "q" -> {:msg, :quit}
- "Q" -> {:msg, :quit}
- _ -> :ignore
- end
- end
- end
-
- def event_to_msg(_event, _state), do: :ignore
-
- def update(:open_palette, state) do
- palette = CommandPalette.show(state.palette)
- {%{state | palette: palette, message: nil}, []}
- end
-
- def update({:palette_event, event}, state) do
- {:ok, palette} = CommandPalette.handle_event(event, state.palette)
- {%{state | palette: palette}, []}
- end
-
- def update(:execute_command, state) do
- query = CommandPalette.get_query(state.palette)
- # Find matching command
- cmd = Enum.find(available_commands(), fn c -> c.label == query end)
-
- message =
- if cmd do
- if is_function(cmd.action, 0), do: cmd.action.()
- "Executed: #{cmd.label}"
- else
- "Unknown command: #{query}"
- end
-
- # Reset palette
- palette = CommandPalette.show(state.palette)
- palette = CommandPalette.hide(palette)
-
- {%{state | palette: palette, message: message}, []}
- end
-
- def update(:quit, state) do
- {state, [:quit]}
- end
-
- def view(state) do
- stack(:vertical, [
- text("Command Palette Example", Style.new(fg: :cyan, attrs: [:bold])),
- text("", nil),
- text("Press / to open the command dropdown", nil),
- text("", nil),
- render_message(state.message),
- render_palette(state),
- text("", nil),
- render_controls()
- ])
- end
-
- defp render_message(nil), do: text("", nil)
- defp render_message(msg), do: text(msg, Style.new(fg: :green))
-
- defp render_palette(state) do
- query = CommandPalette.get_query(state.palette)
-
- if CommandPalette.visible?(state.palette) do
- stack(:vertical, [
- text("/" <> query, Style.new(fg: :yellow)),
- CommandPalette.render(state.palette, %{})
- ])
- else
- if query != "" do
- text(query <> " (press Enter to execute)", Style.new(fg: :yellow))
- else
- text("", nil)
- end
- end
- end
-
- defp render_controls do
- stack(:vertical, [
- text("Controls:", Style.new(fg: :yellow)),
- text(" / Open command dropdown", nil),
- text(" Type Filter commands", nil),
- text(" Up/Down Navigate", nil),
- text(" Enter Execute command", nil),
- text(" Escape Close dropdown", nil),
- text(" Q Quit", nil)
- ])
- end
-
- # ----------------------------------------------------------------------------
- # Public API
- # ----------------------------------------------------------------------------
-
- def run do
- TermUI.Runtime.run(root: __MODULE__)
- end
-end
diff --git a/examples/command_palette/mix.exs b/examples/command_palette/mix.exs
deleted file mode 100644
index 9cf18812..00000000
--- a/examples/command_palette/mix.exs
+++ /dev/null
@@ -1,25 +0,0 @@
-defmodule CommandPalette.MixProject do
- use Mix.Project
-
- def project do
- [
- app: :command_palette,
- version: "0.1.0",
- elixir: "~> 1.15",
- start_permanent: Mix.env() == :prod,
- deps: deps()
- ]
- end
-
- def application do
- [
- extra_applications: [:logger]
- ]
- end
-
- defp deps do
- [
- {:term_ui, path: "../.."}
- ]
- end
-end
diff --git a/examples/command_palette/mix.lock b/examples/command_palette/mix.lock
deleted file mode 100644
index ee8761c0..00000000
--- a/examples/command_palette/mix.lock
+++ /dev/null
@@ -1,12 +0,0 @@
-%{
- "castore": {:hex, :castore, "1.0.17", "4f9770d2d45fbd91dcf6bd404cf64e7e58fed04fadda0923dc32acca0badffa2", [:mix], [], "hexpm", "12d24b9d80b910dd3953e165636d68f147a31db945d2dcb9365e441f8b5351e5"},
- "gen_stage": {:hex, :gen_stage, "1.3.2", "7c77e5d1e97de2c6c2f78f306f463bca64bf2f4c3cdd606affc0100b89743b7b", [:mix], [], "hexpm", "0ffae547fa777b3ed889a6b9e1e64566217413d018cabd825f786e843ffe63e7"},
- "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
- "lumis": {:hex, :lumis, "0.1.0", "6d3b495457d0608f8fe32fe6fa917eb17c921bd0087583a7f4c420c0009888fd", [:mix], [{:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:rustler, "~> 0.29", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.6", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "4de203bc5811ad21f0c6b0442612a3fc1ae9bea7fbfe7037452db9e9dfdaaab3"},
- "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"},
- "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"},
- "mdex": {:hex, :mdex, "0.11.3", "7b815ae2f474e62ad365dfa4d9df87ddec6a01cfa9b7db523a3b21061d909225", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:lumis, "~> 0.1", [hex: :lumis, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}, {:rustler, "~> 0.32", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.7", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "181bf04b13dcae20ab9f336115facc5096aae333a2408a03e53338ee65d4f564"},
- "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
- "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"},
- "rustler_precompiled": {:hex, :rustler_precompiled, "0.8.4", "700a878312acfac79fb6c572bb8b57f5aae05fe1cf70d34b5974850bbf2c05bf", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "3b33d99b540b15f142ba47944f7a163a25069f6d608783c321029bc1ffb09514"},
-}
diff --git a/examples/command_palette/run.exs b/examples/command_palette/run.exs
deleted file mode 100644
index 3c1f50b0..00000000
--- a/examples/command_palette/run.exs
+++ /dev/null
@@ -1 +0,0 @@
-CommandPalette.App.run()
diff --git a/examples/context_menu/README.md b/examples/context_menu/README.md
deleted file mode 100644
index 76b87b35..00000000
--- a/examples/context_menu/README.md
+++ /dev/null
@@ -1,108 +0,0 @@
-# ContextMenu Widget Example
-
-This example demonstrates the ContextMenu widget for displaying floating menus at cursor position, typically triggered by right-click or keyboard shortcuts.
-
-## Widget Overview
-
-The ContextMenu widget provides context-sensitive menus that appear at a specific screen position. It's ideal for:
-
-- Right-click context menus
-- Location-specific action lists
-- Dropdown menus at arbitrary positions
-- Quick action palettes
-
-**Key Features:**
-- Floating overlay positioned at exact coordinates
-- Keyboard navigation (Up/Down/Enter/Escape)
-- Mouse hover highlighting and click selection
-- Automatic closure on selection or outside click
-- Support for separators and disabled items
-- Shortcut hints display
-- Custom styling for different item states
-
-## Widget Options
-
-The `ContextMenu.new/1` function accepts the following options:
-
-- `:items` (required) - List of menu items created with `ContextMenu.action/3` or `ContextMenu.separator/0`
-- `:position` (required) - `{x, y}` tuple for menu position on screen
-- `:on_select` - Callback function `(item_id -> any)` when item is selected
-- `:on_close` - Callback function `(() -> any)` when menu is closed
-- `:item_style` - Style for normal items
-- `:selected_style` - Style for focused/hovered item
-- `:disabled_style` - Style for disabled items
-
-**Menu Item Helpers:**
-- `ContextMenu.action(id, label, opts)` - Create an action item
- - `:shortcut` - Display shortcut hint (e.g., "Ctrl+X")
- - `:disabled` - Whether item is disabled
-- `ContextMenu.separator()` - Create a separator line
-
-## Example Structure
-
-```
-context_menu/
-├── lib/
-│ └── context_menu/
-│ └── app.ex # Main application component
-├── mix.exs # Project configuration
-└── README.md # This file
-```
-
-**app.ex** - Implements the Elm Architecture pattern:
-- Maintains menu state (position, visibility, selection)
-- Handles right-click events to show menu at mouse position
-- Forwards keyboard/mouse events to menu widget when visible
-- Tracks last selected action for demonstration
-
-## Running the Example
-
-### Raw Mode (Full TUI Experience)
-
-For the best experience with full terminal control and alternate screen:
-
-```bash
-cd examples/context_menu
-mix termui.run
-```
-
-Or manually:
-
-```bash
-cd examples/context_menu
-mix run -e "ContextMenu.App.run()" --no-halt
-```
-
-### TTY Mode (IEx Compatible)
-
-To run from IEx without taking over the shell:
-
-```bash
-cd examples/context_menu
-iex -S mix
-```
-
-Then in IEx:
-
-```elixir
-ContextMenu.App.run()
-```
-
-**Note:** TTY mode works inside IEx but has limitations:
-- No alternate screen buffer (output mixes with IEx prompt)
-- Character input works immediately (no Enter needed)
-- For full TUI, use raw mode instead
-
-## Controls
-
-- **Right-click** - Show context menu at click position
-- **1/2/3** - Show context menu at preset positions (top-left, center, right)
-- **Up/Down** - Navigate menu items (when menu visible)
-- **Enter/Space** - Select highlighted item
-- **Escape** - Close menu without selecting
-- **Q** - Quit the application
-
-**Mouse Support:**
-- Hover over items to highlight them
-- Click on item to select it
-- Click outside menu to close without selecting
diff --git a/examples/context_menu/lib/context_menu/app.ex b/examples/context_menu/lib/context_menu/app.ex
deleted file mode 100644
index b81e32d6..00000000
--- a/examples/context_menu/lib/context_menu/app.ex
+++ /dev/null
@@ -1,208 +0,0 @@
-defmodule ContextMenu.App do
- @moduledoc """
- Context Menu Widget Example
-
- This example demonstrates how to use the TermUI.Widgets.ContextMenu widget
- for displaying floating menus at cursor position.
-
- Features demonstrated:
- - Right-click to show context menu
- - Keyboard navigation (Up/Down)
- - Selection with Enter/Space
- - Close on Escape or outside click
- - Different menu positions
- - Disabled items
-
- Controls:
- - Right-click: Show context menu at click position
- - 1/2/3: Show context menu at different positions
- - Up/Down: Navigate menu items (when menu visible)
- - Enter/Space: Select item (when menu visible)
- - Escape: Close menu
- - Q: Quit the application
- """
-
- use TermUI.Elm
-
- alias TermUI.Event
- alias TermUI.Renderer.Style
- alias TermUI.Widgets.ContextMenu
-
- # ----------------------------------------------------------------------------
- # Component Callbacks
- # ----------------------------------------------------------------------------
-
- @doc """
- Initialize the component state.
- """
- def init(_opts) do
- %{
- # Context menu state (nil when no menu visible)
- menu: nil,
- # Result tracking
- last_action: nil
- }
- end
-
- @doc """
- Convert keyboard events to messages.
- """
- # Menu closed - show menu at different positions
- def event_to_msg(%Event.Key{key: "1"}, %{menu: nil}), do: {:msg, {:show_menu, {5, 5}}}
- def event_to_msg(%Event.Key{key: "2"}, %{menu: nil}), do: {:msg, {:show_menu, {20, 8}}}
- def event_to_msg(%Event.Key{key: "3"}, %{menu: nil}), do: {:msg, {:show_menu, {35, 5}}}
-
- # Mouse events - show menu on right-click (action is :press from terminal)
- def event_to_msg(%Event.Mouse{action: :press, button: :right, x: x, y: y}, %{menu: nil}) do
- {:msg, {:show_menu, {x, y}}}
- end
-
- # When menu is visible, forward events to the menu widget
- def event_to_msg(event, %{menu: menu}) when menu != nil, do: {:msg, {:menu_event, event}}
-
- def event_to_msg(%Event.Key{key: key}, _state) when key in ["q", "Q"], do: {:msg, :quit}
- def event_to_msg(_event, _state), do: :ignore
-
- @doc """
- Update state based on messages.
- """
- def update({:show_menu, position}, state) do
- {show_menu(state, position), []}
- end
-
- def update({:menu_event, event}, state) do
- case ContextMenu.handle_event(event, state.menu) do
- {:ok, new_menu} ->
- if ContextMenu.visible?(new_menu) do
- {%{state | menu: new_menu}, []}
- else
- # Menu was closed - capture result if item was selected
- result = ContextMenu.get_cursor(new_menu)
- {%{state | menu: nil, last_action: format_action(result)}, []}
- end
- end
- end
-
- def update(:quit, state) do
- {state, [:quit]}
- end
-
- # Helper to create and initialize a context menu
- defp show_menu(state, position) do
- props = ContextMenu.new(
- items: menu_items(),
- position: position,
- selected_style: Style.new(fg: :black, bg: :cyan),
- disabled_style: Style.new(fg: :bright_black)
- )
- {:ok, menu} = ContextMenu.init(props)
- %{state | menu: menu}
- end
-
- defp menu_items do
- [
- ContextMenu.action(:cut, "Cut", shortcut: "Ctrl+X"),
- ContextMenu.action(:copy, "Copy", shortcut: "Ctrl+C"),
- ContextMenu.action(:paste, "Paste", shortcut: "Ctrl+V"),
- ContextMenu.separator(),
- ContextMenu.action(:select_all, "Select All", shortcut: "Ctrl+A"),
- ContextMenu.separator(),
- ContextMenu.action(:disabled_item, "Disabled Item", disabled: true),
- ContextMenu.action(:delete, "Delete", shortcut: "Del")
- ]
- end
-
- defp format_action(nil), do: "Cancelled"
- defp format_action(action), do: "Selected: #{action}"
-
- @doc """
- Render the current state to a render tree.
- """
- def view(state) do
- main_content = render_main_content(state)
-
- if state.menu != nil do
- stack(:vertical, [
- main_content,
- text("", nil),
- ContextMenu.render(state.menu, %{width: 80, height: 24})
- ])
- else
- main_content
- end
- end
-
- # ----------------------------------------------------------------------------
- # Private Helpers
- # ----------------------------------------------------------------------------
-
- defp render_main_content(state) do
- stack(:vertical, [
- # Title
- text("Context Menu Widget Example", Style.new(fg: :cyan, attrs: [:bold])),
- text("", nil),
-
- # Instructions
- render_instructions(),
-
- # Controls
- render_controls(state)
- ])
- end
-
- defp render_instructions do
- stack(:vertical, [
- text("Right-click anywhere or press 1/2/3 to show context menu", nil),
- text("", nil),
- text(" Position 1: Top-left area (key: 1)", nil),
- text(" Position 2: Center area (key: 2)", nil),
- text(" Position 3: Right area (key: 3)", nil),
- text("", nil),
- # Large click area for right-click testing
- text("┌" <> String.duplicate("─", 58) <> "┐", Style.new(fg: :bright_black)),
- text("│" <> String.pad_trailing("", 58) <> "│", Style.new(fg: :bright_black)),
- text("│" <> String.pad_trailing(" Right-click in this area to open context menu", 58) <> "│", Style.new(fg: :bright_black)),
- text("│" <> String.pad_trailing("", 58) <> "│", Style.new(fg: :bright_black)),
- text("│" <> String.pad_trailing("", 58) <> "│", Style.new(fg: :bright_black)),
- text("│" <> String.pad_trailing("", 58) <> "│", Style.new(fg: :bright_black)),
- text("│" <> String.pad_trailing("", 58) <> "│", Style.new(fg: :bright_black)),
- text("│" <> String.pad_trailing("", 58) <> "│", Style.new(fg: :bright_black)),
- text("└" <> String.duplicate("─", 58) <> "┘", Style.new(fg: :bright_black)),
- text("", nil)
- ])
- end
-
- defp render_controls(state) do
- box_width = 50
- inner_width = box_width - 2
-
- top_border = "┌─ Controls " <> String.duplicate("─", inner_width - 12) <> "─┐"
- bottom_border = "└" <> String.duplicate("─", inner_width) <> "┘"
-
- stack(:vertical, [
- text("", nil),
- text(top_border, Style.new(fg: :yellow)),
- text("│" <> String.pad_trailing(" Right-click Show context menu", inner_width) <> "│", nil),
- text("│" <> String.pad_trailing(" 1/2/3 Show at preset positions", inner_width) <> "│", nil),
- text("│" <> String.pad_trailing(" ↑/↓ Navigate items (menu open)", inner_width) <> "│", nil),
- text("│" <> String.pad_trailing(" Enter/Space Select item", inner_width) <> "│", nil),
- text("│" <> String.pad_trailing(" Escape Close menu", inner_width) <> "│", nil),
- text("│" <> String.pad_trailing(" Q Quit", inner_width) <> "│", nil),
- text("│" <> String.pad_trailing("", inner_width) <> "│", nil),
- text("│" <> String.pad_trailing(" Last action: #{state.last_action || "(none)"}", inner_width) <> "│", nil),
- text("│" <> String.pad_trailing(" Menu visible: #{state.menu != nil}", inner_width) <> "│", nil),
- text(bottom_border, Style.new(fg: :yellow))
- ])
- end
-
- # ----------------------------------------------------------------------------
- # Public API
- # ----------------------------------------------------------------------------
-
- @doc """
- Run the context menu example application.
- """
- def run do
- TermUI.Runtime.run(root: __MODULE__)
- end
-end
diff --git a/examples/context_menu/lib/context_menu/application.ex b/examples/context_menu/lib/context_menu/application.ex
deleted file mode 100644
index f1e9849d..00000000
--- a/examples/context_menu/lib/context_menu/application.ex
+++ /dev/null
@@ -1,12 +0,0 @@
-defmodule ContextMenu.Application do
- @moduledoc false
-
- use Application
-
- @impl true
- def start(_type, _args) do
- children = []
- opts = [strategy: :one_for_one, name: ContextMenu.Supervisor]
- Supervisor.start_link(children, opts)
- end
-end
diff --git a/examples/context_menu/mix.exs b/examples/context_menu/mix.exs
deleted file mode 100644
index 639cab55..00000000
--- a/examples/context_menu/mix.exs
+++ /dev/null
@@ -1,26 +0,0 @@
-defmodule ContextMenu.MixProject do
- use Mix.Project
-
- def project do
- [
- app: :context_menu,
- version: "0.1.0",
- elixir: "~> 1.15",
- start_permanent: Mix.env() == :prod,
- deps: deps()
- ]
- end
-
- def application do
- [
- extra_applications: [:logger],
- mod: {ContextMenu.Application, []}
- ]
- end
-
- defp deps do
- [
- {:term_ui, path: "../.."}
- ]
- end
-end
diff --git a/examples/context_menu/mix.lock b/examples/context_menu/mix.lock
deleted file mode 100644
index ee8761c0..00000000
--- a/examples/context_menu/mix.lock
+++ /dev/null
@@ -1,12 +0,0 @@
-%{
- "castore": {:hex, :castore, "1.0.17", "4f9770d2d45fbd91dcf6bd404cf64e7e58fed04fadda0923dc32acca0badffa2", [:mix], [], "hexpm", "12d24b9d80b910dd3953e165636d68f147a31db945d2dcb9365e441f8b5351e5"},
- "gen_stage": {:hex, :gen_stage, "1.3.2", "7c77e5d1e97de2c6c2f78f306f463bca64bf2f4c3cdd606affc0100b89743b7b", [:mix], [], "hexpm", "0ffae547fa777b3ed889a6b9e1e64566217413d018cabd825f786e843ffe63e7"},
- "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
- "lumis": {:hex, :lumis, "0.1.0", "6d3b495457d0608f8fe32fe6fa917eb17c921bd0087583a7f4c420c0009888fd", [:mix], [{:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:rustler, "~> 0.29", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.6", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "4de203bc5811ad21f0c6b0442612a3fc1ae9bea7fbfe7037452db9e9dfdaaab3"},
- "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"},
- "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"},
- "mdex": {:hex, :mdex, "0.11.3", "7b815ae2f474e62ad365dfa4d9df87ddec6a01cfa9b7db523a3b21061d909225", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:lumis, "~> 0.1", [hex: :lumis, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}, {:rustler, "~> 0.32", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.7", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "181bf04b13dcae20ab9f336115facc5096aae333a2408a03e53338ee65d4f564"},
- "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
- "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"},
- "rustler_precompiled": {:hex, :rustler_precompiled, "0.8.4", "700a878312acfac79fb6c572bb8b57f5aae05fe1cf70d34b5974850bbf2c05bf", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "3b33d99b540b15f142ba47944f7a163a25069f6d608783c321029bc1ffb09514"},
-}
diff --git a/examples/context_menu/run.exs b/examples/context_menu/run.exs
deleted file mode 100644
index 20e70df9..00000000
--- a/examples/context_menu/run.exs
+++ /dev/null
@@ -1 +0,0 @@
-ContextMenu.App.run()
diff --git a/examples/dashboard/.tool-versions b/examples/dashboard/.tool-versions
deleted file mode 100644
index 21aee5bb..00000000
--- a/examples/dashboard/.tool-versions
+++ /dev/null
@@ -1 +0,0 @@
-erlang 28.1.1
diff --git a/examples/dashboard/README.md b/examples/dashboard/README.md
deleted file mode 100644
index 3612a316..00000000
--- a/examples/dashboard/README.md
+++ /dev/null
@@ -1,121 +0,0 @@
-# Dashboard Example
-
-This example demonstrates building a comprehensive system monitoring dashboard using multiple TermUI widgets including Gauge, Sparkline, and Table components.
-
-## Overview
-
-The dashboard displays real-time system metrics in a terminal-based interface. While this example uses the Dashboard namespace rather than a single widget, it showcases how to compose multiple widgets into a cohesive application.
-
-**Key Features:**
-- CPU and memory usage gauges with color zones
-- Network traffic sparklines (RX/TX)
-- Process table with selection
-- System information display
-- Theme switching (dark/light)
-- Responsive layout with bordered sections
-
-**Widgets Demonstrated:**
-- `TermUI.Widgets.Gauge` - CPU and memory percentage bars
-- `TermUI.Widgets.Sparkline` - Network traffic history
-- `TermUI.Widgets.Table.Column` - Process table formatting
-
-## Example Structure
-
-```
-dashboard/
-├── lib/
-│ ├── dashboard/
-│ │ ├── app.ex # Main dashboard component
-│ │ ├── application.ex # OTP application
-│ │ └── data/
-│ │ └── metrics.ex # Mock metrics generator
-│ └── dashboard.ex # Application entry point
-├── mix.exs # Project configuration
-└── README.md # This file
-```
-
-**app.ex** - Main dashboard implementation:
-- Implements Elm Architecture (init/update/view)
-- Composes gauges, sparklines, and tables
-- Handles theme switching
-- Manages process selection
-
-**metrics.ex** - Provides simulated system metrics:
-- CPU and memory percentages
-- Network RX/TX data streams
-- Process list with stats
-- System info (hostname, uptime, load average)
-
-## Running the Example
-
-### Raw Mode (Full TUI Experience)
-
-For the best experience with full terminal control and alternate screen:
-
-```bash
-cd examples/dashboard
-mix termui.run
-```
-
-Or manually:
-
-```bash
-cd examples/dashboard
-mix run -e "Dashboard.run()" --no-halt
-```
-
-### TTY Mode (IEx Compatible)
-
-To run from IEx without taking over the shell:
-
-```bash
-cd examples/dashboard
-iex -S mix
-```
-
-Then in IEx:
-
-```elixir
-Dashboard.run()
-```
-
-**Note:** TTY mode works inside IEx but has limitations:
-- No alternate screen buffer (output mixes with IEx prompt)
-- Character input works immediately (no Enter needed)
-- For full TUI, use raw mode instead
-
-## Controls
-
-- **Q** - Quit the application
-- **R** - Refresh display (triggers re-render)
-- **T** - Toggle theme between dark and light
-- **Up/Down** - Navigate through process list
-
-## Layout Details
-
-The dashboard uses a fixed-width layout (58 characters) with these sections:
-
-1. **Header** - Title with decorative border
-2. **Gauges Row** - CPU and Memory gauges side-by-side with color zones
-3. **System Info** - Hostname, uptime, and load averages
-4. **Network Section** - RX/TX sparklines showing traffic history
-5. **Process Table** - Sortable process list with PID, name, CPU%, and memory
-6. **Controls Bar** - Help text with keyboard shortcuts
-
-**Color Zones:**
-- CPU Gauge: Green (0-59%), Yellow (60-79%), Red (80-100%)
-- Memory Gauge: Green (0-69%), Yellow (70-84%), Red (85-100%)
-
-## Themes
-
-**Dark Theme:**
-- Cyan borders and headers
-- White text on black background
-- Green/blue sparklines
-- Cyan selection highlight
-
-**Light Theme:**
-- Yellow borders and headers
-- Bright white text
-- Bright green/cyan sparklines
-- Yellow selection highlight
diff --git a/examples/dashboard/lib/dashboard.ex b/examples/dashboard/lib/dashboard.ex
deleted file mode 100644
index df982885..00000000
--- a/examples/dashboard/lib/dashboard.ex
+++ /dev/null
@@ -1,66 +0,0 @@
-defmodule Dashboard do
- @moduledoc """
- A system monitoring dashboard example for TermUI.
-
- This application demonstrates:
- - Multiple widget types (gauges, charts, tables)
- - Layout system with nested constraints
- - Real-time updates using commands
- - Keyboard navigation and shortcuts
- - Theme switching
-
- ## Running
-
- cd examples/dashboard
- mix deps.get
- mix run --no-halt
-
- ## Controls
-
- - `q` - Quit the application
- - `r` - Force refresh data
- - `t` - Toggle theme (dark/light)
- - `Tab` - Navigate between focusable widgets
- - `↑/↓` - Scroll process table
- """
-
- @doc """
- Run the dashboard example application.
-
- This is the main entry point for both IEx and command line use.
-
- ## From IEx
-
- iex> Dashboard.run()
- # Dashboard takes over terminal, press Q to quit
-
- ## From command line
-
- mix termui.run
- """
- def run do
- TermUI.Runtime.run(root: Dashboard.App)
- end
-
- @doc """
- Starts the dashboard interactively, blocking until the user quits.
-
- This is an alias for `run/0` for backward compatibility.
- """
- def start do
- run()
- end
-
- @doc """
- Starts the dashboard as a linked process (non-blocking).
-
- Returns `{:ok, pid}` immediately. Useful for embedding in supervision
- trees or programmatic control. Note: keyboard input will NOT work when
- called from IEx because IEx's prompt competes for terminal input.
-
- For interactive use from IEx, use `start/0` instead.
- """
- def start_link do
- TermUI.Runtime.start_link(root: Dashboard.App)
- end
-end
diff --git a/examples/dashboard/lib/dashboard/app.ex b/examples/dashboard/lib/dashboard/app.ex
deleted file mode 100644
index 0de42d72..00000000
--- a/examples/dashboard/lib/dashboard/app.ex
+++ /dev/null
@@ -1,383 +0,0 @@
-defmodule Dashboard.App do
- @moduledoc """
- Main dashboard application component.
-
- Displays system metrics including CPU, memory, network, and processes
- in a terminal-based dashboard layout.
-
- ## Running
-
- cd examples/dashboard
- mix deps.get
- mix termui.run
-
- ## Controls
-
- - `q` - Quit the application
- - `r` - Force refresh data
- - `t` - Toggle theme (dark/light)
- - `Tab` - Navigate between focusable widgets
- - `↑/↓` - Scroll process table
- """
-
- use TermUI.Elm
-
- @doc """
- Run the dashboard example application.
- """
- def run do
- TermUI.Runtime.run(root: __MODULE__)
- end
-
- alias Dashboard.Data.Metrics
- alias TermUI.Event
- alias TermUI.Layout.Constraint
- alias TermUI.Renderer.Style
- alias TermUI.Widgets.Gauge
- alias TermUI.Widgets.Sparkline
- alias TermUI.Widgets.Table.Column
-
- # Elm callbacks
-
- def init(_opts) do
- %{
- theme: :dark,
- selected_process: 0
- }
- end
-
- def event_to_msg(%Event.Key{key: key}, _state) when key in ["q", "Q"], do: {:msg, :quit}
- def event_to_msg(%Event.Key{key: key}, _state) when key in ["r", "R"], do: {:msg, :refresh}
- def event_to_msg(%Event.Key{key: key}, _state) when key in ["t", "T"], do: {:msg, :toggle_theme}
- def event_to_msg(%Event.Key{key: :down}, _state), do: {:msg, :select_next}
- def event_to_msg(%Event.Key{key: :up}, _state), do: {:msg, :select_prev}
- def event_to_msg(_, _state), do: :ignore
-
- def update(:quit, state) do
- # Return :quit command to trigger runtime shutdown
- {state, [:quit]}
- end
-
- def update(:refresh, state) do
- # Manual refresh just triggers a re-render
- {state, []}
- end
-
- def update(:toggle_theme, state) do
- new_theme = if state.theme == :dark, do: :light, else: :dark
- {%{state | theme: new_theme}, []}
- end
-
- def update(:select_next, state) do
- metrics = Metrics.get_metrics()
- process_count = length(metrics.processes)
- new_selected = min(state.selected_process + 1, max(0, process_count - 1))
- {%{state | selected_process: new_selected}, []}
- end
-
- def update(:select_prev, state) do
- new_selected = max(state.selected_process - 1, 0)
- {%{state | selected_process: new_selected}, []}
- end
-
- def update(_msg, state), do: {state, []}
-
- def view(state) do
- theme = get_theme(state.theme)
- # Fetch fresh metrics on each render
- metrics = Metrics.get_metrics()
- render_dashboard(state, metrics, theme)
- end
-
- # Render helpers
-
- defp render_dashboard(state, metrics, theme) do
-
- # Build dashboard as vertical stack
- stack(:vertical, [
- # Header
- render_header(theme),
-
- # Top row with gauges and system info
- stack(:horizontal, [
- render_cpu_gauge(metrics.cpu, theme),
- render_memory_gauge(metrics.memory, theme),
- render_system_info(theme)
- ]),
-
- # Network section
- render_network(metrics, theme),
-
- # Process table
- render_processes(metrics.processes, state.selected_process, theme),
-
- # Help bar
- render_help(theme)
- ])
- end
-
- @dashboard_width 58
-
- defp render_header(theme) do
- title = " System Dashboard "
- title_len = String.length(title)
- total_padding = @dashboard_width - title_len
- left_padding = div(total_padding, 2)
- right_padding = total_padding - left_padding
-
- line = String.duplicate("═", left_padding) <> title <> String.duplicate("═", right_padding)
- text(line, theme.header)
- end
-
- defp render_cpu_gauge(cpu_value, theme) do
- gauge_width = 12
- inner_width = gauge_width + 2
-
- top_border = "┌─ CPU " <> String.duplicate("─", inner_width - 7) <> "─┐"
- bottom_border = "└" <> String.duplicate("─", inner_width) <> "┘"
-
- stack(:vertical, [
- text(top_border, theme.border),
- stack(:horizontal, [
- text("│ ", theme.border),
- Gauge.render(
- value: cpu_value,
- min: 0,
- max: 100,
- width: gauge_width,
- show_value: false,
- show_range: false,
- zones: [
- {0, Style.new(fg: :green)},
- {60, Style.new(fg: :yellow)},
- {80, Style.new(fg: :red)}
- ]
- ),
- text(" │", theme.border)
- ]),
- text("│" <> String.pad_trailing(format_percent(cpu_value), inner_width) <> "│", theme.text),
- text(bottom_border, theme.border)
- ])
- end
-
- defp render_memory_gauge(memory_value, theme) do
- gauge_width = 12
- inner_width = gauge_width + 2
-
- top_border = "┌─ Memory " <> String.duplicate("─", inner_width - 10) <> "─┐"
- bottom_border = "└" <> String.duplicate("─", inner_width) <> "┘"
-
- stack(:vertical, [
- text(top_border, theme.border),
- stack(:horizontal, [
- text("│ ", theme.border),
- Gauge.render(
- value: memory_value,
- min: 0,
- max: 100,
- width: gauge_width,
- show_value: false,
- show_range: false,
- zones: [
- {0, Style.new(fg: :green)},
- {70, Style.new(fg: :yellow)},
- {85, Style.new(fg: :red)}
- ]
- ),
- text(" │", theme.border)
- ]),
- text("│" <> String.pad_trailing(format_percent(memory_value), inner_width) <> "│", theme.text),
- text(bottom_border, theme.border)
- ])
- end
-
- defp render_system_info(theme) do
- info = Metrics.get_system_info()
- {load1, load2, load3} = info.load_avg
-
- # Calculate content to determine box width
- host_line = " Host: #{info.hostname}"
- up_line = " Up: #{info.uptime}"
- load_line = " Load: #{load1} #{load2} #{load3}"
-
- # Find the widest content line and add padding
- content_width = Enum.max([String.length(host_line), String.length(up_line), String.length(load_line)]) + 1
-
- # Box width includes the border characters
- box_width = content_width + 2
-
- # Build the box
- title = "─ System Info ─"
- top_padding = box_width - String.length(title) - 2
- top_border = "┌" <> title <> String.duplicate("─", top_padding) <> "┐"
- bottom_border = "└" <> String.duplicate("─", box_width - 2) <> "┘"
-
- stack(:vertical, [
- text(top_border, theme.border),
- text(String.pad_trailing(host_line, content_width), theme.text),
- text(String.pad_trailing(up_line, content_width), theme.text),
- text(String.pad_trailing(load_line, content_width), theme.text),
- text(bottom_border, theme.border)
- ])
- end
-
- defp render_network(metrics, theme) do
- # Match process table width
- label_width = 6 # " RX: " or " TX: "
- sparkline_width = @dashboard_width - label_width - 4 # 4 for borders and padding
-
- top_border = "┌─ Network " <> String.duplicate("─", @dashboard_width - 12) <> "┐"
- bottom_border = "└" <> String.duplicate("─", @dashboard_width - 2) <> "┘"
-
- stack(:vertical, [
- text(top_border, theme.border),
- stack(:horizontal, [
- text(" RX: ", theme.label),
- Sparkline.render(
- values: Enum.reverse(metrics.network_rx),
- min: 0,
- max: 100,
- width: sparkline_width,
- style: theme.sparkline_rx
- ),
- text(" ", nil)
- ]),
- stack(:horizontal, [
- text(" TX: ", theme.label),
- Sparkline.render(
- values: Enum.reverse(metrics.network_tx),
- min: 0,
- max: 100,
- width: sparkline_width,
- style: theme.sparkline_tx
- ),
- text(" ", nil)
- ]),
- text(bottom_border, theme.border)
- ])
- end
-
- defp render_processes(processes, selected, theme) do
- # Define columns using the Table.Column helpers
- columns = [
- Column.new(:pid, "PID", width: Constraint.length(7)),
- Column.new(:name, "Name", width: Constraint.length(20)),
- Column.new(:cpu, "CPU%", width: Constraint.length(8), align: :right, render: &format_cpu/1),
- Column.new(:memory, "Memory", width: Constraint.length(12), align: :right, render: &format_memory/1)
- ]
-
- # Render header using Column alignment
- header_text =
- Enum.map_join(columns, " ", fn col ->
- Column.align_text(col.header, get_column_width(col), col.align)
- end)
-
- header = " " <> header_text
-
- # Build separator based on column widths
- separator =
- " " <>
- Enum.map_join(columns, " ", fn col ->
- String.duplicate("─", get_column_width(col))
- end)
-
- # Render rows using Column.render_cell
- rows =
- processes
- |> Enum.with_index()
- |> Enum.map(fn {proc, idx} ->
- row_text =
- " " <>
- Enum.map_join(columns, " ", fn col ->
- cell_value = Column.render_cell(col, proc)
- Column.align_text(cell_value, get_column_width(col), col.align)
- end)
-
- if idx == selected do
- text(row_text, theme.table_selected)
- else
- text(row_text, theme.table_row)
- end
- end)
-
- stack(:vertical, [
- text("┌─ Processes ────────────────────────────────────────────┐", theme.border),
- text(header, theme.table_header),
- text(separator, theme.border)
- | rows
- ] ++ [text("└────────────────────────────────────────────────────────┘", theme.border)])
- end
-
- # Helper to extract column width from constraint
- defp get_column_width(%Column{width: %Constraint.Length{value: v}}), do: v
- defp get_column_width(_), do: 10
-
- defp render_help(theme) do
- controls = " [Q] Quit [R] Refresh [T] Theme [↑/↓] Navigate"
- inner_width = @dashboard_width - 2
-
- top_border = "┌─ Controls " <> String.duplicate("─", inner_width - 12) <> "─┐"
- bottom_border = "└" <> String.duplicate("─", inner_width) <> "┘"
-
- stack(:vertical, [
- text("", nil),
- text(top_border, theme.border),
- text("│" <> String.pad_trailing(controls, inner_width) <> "│", theme.help),
- text(bottom_border, theme.border)
- ])
- end
-
- # Formatting helpers
-
- defp format_percent(value) do
- value
- |> Float.round(1)
- |> to_string()
- |> String.pad_leading(5)
- |> Kernel.<>("%")
- end
-
- defp format_cpu(value) do
- "#{Float.round(value, 1)}%"
- end
-
- defp format_memory(mb) do
- if mb >= 1024 do
- "#{Float.round(mb / 1024, 1)} GB"
- else
- "#{mb} MB"
- end
- end
-
- # Themes
-
- defp get_theme(:dark) do
- %{
- header: Style.new(fg: :cyan, attrs: [:bold]),
- border: Style.new(fg: :cyan),
- text: Style.new(fg: :white),
- label: Style.new(fg: :bright_black),
- help: Style.new(fg: :bright_black),
- sparkline_rx: Style.new(fg: :green),
- sparkline_tx: Style.new(fg: :blue),
- table_header: Style.new(fg: :cyan, attrs: [:bold]),
- table_row: Style.new(fg: :white),
- table_selected: Style.new(fg: :black, bg: :cyan)
- }
- end
-
- defp get_theme(:light) do
- %{
- header: Style.new(fg: :yellow, attrs: [:bold]),
- border: Style.new(fg: :yellow),
- text: Style.new(fg: :bright_white),
- label: Style.new(fg: :bright_black),
- help: Style.new(fg: :bright_black),
- sparkline_rx: Style.new(fg: :bright_green),
- sparkline_tx: Style.new(fg: :bright_cyan),
- table_header: Style.new(fg: :yellow, attrs: [:bold]),
- table_row: Style.new(fg: :bright_white),
- table_selected: Style.new(fg: :black, bg: :yellow)
- }
- end
-end
diff --git a/examples/dashboard/lib/dashboard/application.ex b/examples/dashboard/lib/dashboard/application.ex
deleted file mode 100644
index 122a6efb..00000000
--- a/examples/dashboard/lib/dashboard/application.ex
+++ /dev/null
@@ -1,15 +0,0 @@
-defmodule Dashboard.Application do
- @moduledoc false
-
- use Application
-
- @impl true
- def start(_type, _args) do
- children = [
- Dashboard.Data.Metrics
- ]
-
- opts = [strategy: :one_for_one, name: Dashboard.Supervisor]
- Supervisor.start_link(children, opts)
- end
-end
diff --git a/examples/dashboard/lib/dashboard/data/metrics.ex b/examples/dashboard/lib/dashboard/data/metrics.ex
deleted file mode 100644
index 0a7dda34..00000000
--- a/examples/dashboard/lib/dashboard/data/metrics.ex
+++ /dev/null
@@ -1,255 +0,0 @@
-defmodule Dashboard.Data.Metrics do
- @moduledoc """
- Generates simulated system metrics with realistic patterns.
-
- Metrics follow realistic patterns:
- - CPU varies smoothly with occasional spikes
- - Memory gradually increases then drops (simulating GC)
- - Network has bursty patterns
- - Processes have stable resource usage with slight variations
- """
-
- use GenServer
-
- @update_interval 1000
-
- # State structure
- defstruct [
- :cpu_history,
- :memory_history,
- :network_rx_history,
- :network_tx_history,
- :processes,
- :uptime_seconds,
- :cpu_base,
- :memory_base,
- :tick
- ]
-
- # Public API
-
- def start_link(_opts) do
- GenServer.start_link(__MODULE__, [], name: __MODULE__)
- end
-
- def get_metrics do
- GenServer.call(__MODULE__, :get_metrics)
- end
-
- def get_cpu do
- GenServer.call(__MODULE__, :get_cpu)
- end
-
- def get_memory do
- GenServer.call(__MODULE__, :get_memory)
- end
-
- def get_network do
- GenServer.call(__MODULE__, :get_network)
- end
-
- def get_processes do
- GenServer.call(__MODULE__, :get_processes)
- end
-
- def get_system_info do
- GenServer.call(__MODULE__, :get_system_info)
- end
-
- # GenServer callbacks
-
- @impl true
- def init(_opts) do
- state = %__MODULE__{
- cpu_history: List.duplicate(25.0, 60),
- memory_history: List.duplicate(45.0, 60),
- network_rx_history: List.duplicate(0.0, 30),
- network_tx_history: List.duplicate(0.0, 30),
- processes: generate_initial_processes(),
- uptime_seconds: :rand.uniform(86400 * 7),
- cpu_base: 25.0,
- memory_base: 45.0,
- tick: 0
- }
-
- schedule_update()
- {:ok, state}
- end
-
- @impl true
- def handle_call(:get_metrics, _from, state) do
- metrics = %{
- cpu: current_cpu(state),
- memory: current_memory(state),
- network_rx: state.network_rx_history,
- network_tx: state.network_tx_history,
- processes: state.processes,
- uptime: state.uptime_seconds
- }
-
- {:reply, metrics, state}
- end
-
- def handle_call(:get_cpu, _from, state) do
- {:reply, %{current: current_cpu(state), history: state.cpu_history}, state}
- end
-
- def handle_call(:get_memory, _from, state) do
- {:reply, %{current: current_memory(state), history: state.memory_history}, state}
- end
-
- def handle_call(:get_network, _from, state) do
- {:reply, %{rx: state.network_rx_history, tx: state.network_tx_history}, state}
- end
-
- def handle_call(:get_processes, _from, state) do
- {:reply, state.processes, state}
- end
-
- def handle_call(:get_system_info, _from, state) do
- info = %{
- hostname: "localhost",
- kernel: "Linux 6.8.0",
- uptime: format_uptime(state.uptime_seconds),
- load_avg: generate_load_avg(state)
- }
-
- {:reply, info, state}
- end
-
- @impl true
- def handle_info(:update, state) do
- new_state = update_metrics(state)
- schedule_update()
- {:noreply, new_state}
- end
-
- # Private functions
-
- defp schedule_update do
- Process.send_after(self(), :update, @update_interval)
- end
-
- defp current_cpu(state), do: hd(state.cpu_history)
- defp current_memory(state), do: hd(state.memory_history)
-
- defp update_metrics(state) do
- tick = state.tick + 1
-
- # Update CPU with smooth variations and occasional spikes
- cpu_base = update_cpu_base(state.cpu_base, tick)
- cpu_value = cpu_base + :rand.uniform() * 5 - 2.5 + spike_factor(tick, 0.05) * 30
- cpu_value = clamp(cpu_value, 5.0, 95.0)
-
- # Update memory with gradual increase and periodic drops (GC simulation)
- memory_base = update_memory_base(state.memory_base, tick)
- memory_value = memory_base + :rand.uniform() * 3 - 1.5
- memory_value = clamp(memory_value, 20.0, 85.0)
-
- # Update network with bursty patterns
- rx_value = generate_network_value(tick, 0)
- tx_value = generate_network_value(tick, 100)
-
- # Update processes with slight variations
- processes = update_processes(state.processes)
-
- %{
- state
- | cpu_history: [cpu_value | Enum.take(state.cpu_history, 59)],
- memory_history: [memory_value | Enum.take(state.memory_history, 59)],
- network_rx_history: [rx_value | Enum.take(state.network_rx_history, 29)],
- network_tx_history: [tx_value | Enum.take(state.network_tx_history, 29)],
- processes: processes,
- uptime_seconds: state.uptime_seconds + 1,
- cpu_base: cpu_base,
- memory_base: memory_base,
- tick: tick
- }
- end
-
- defp update_cpu_base(base, tick) do
- # Slow sinusoidal variation
- adjustment = :math.sin(tick / 30) * 5
- new_base = base + adjustment * 0.1 + (:rand.uniform() - 0.5) * 2
- clamp(new_base, 15.0, 60.0)
- end
-
- defp update_memory_base(base, tick) do
- # Gradual increase with periodic drops
- if rem(tick, 60) == 0 do
- # Simulate GC - drop memory
- clamp(base - 10, 35.0, 75.0)
- else
- # Gradual increase
- clamp(base + 0.2, 35.0, 75.0)
- end
- end
-
- defp spike_factor(_tick, probability) do
- if :rand.uniform() < probability do
- 1.0
- else
- 0.0
- end
- end
-
- defp generate_network_value(tick, offset) do
- # Bursty network pattern
- base = :math.sin((tick + offset) / 5) * 30 + 40
- burst = if :rand.uniform() < 0.1, do: :rand.uniform() * 50, else: 0
- clamp(base + burst + :rand.uniform() * 10, 0.0, 100.0)
- end
-
- defp generate_initial_processes do
- [
- %{pid: 1, name: "systemd", cpu: 0.1, memory: 12},
- %{pid: 234, name: "beam.smp", cpu: 8.5, memory: 256},
- %{pid: 456, name: "postgres", cpu: 3.2, memory: 128},
- %{pid: 789, name: "nginx", cpu: 1.1, memory: 48},
- %{pid: 1012, name: "redis-server", cpu: 2.4, memory: 64},
- %{pid: 1234, name: "node", cpu: 5.6, memory: 192},
- %{pid: 1456, name: "docker", cpu: 1.8, memory: 96},
- %{pid: 1678, name: "sshd", cpu: 0.2, memory: 8},
- %{pid: 1890, name: "cron", cpu: 0.0, memory: 4},
- %{pid: 2012, name: "rsyslogd", cpu: 0.3, memory: 16}
- ]
- end
-
- defp update_processes(processes) do
- Enum.map(processes, fn proc ->
- %{
- proc
- | cpu: clamp(proc.cpu + (:rand.uniform() - 0.5) * 1.0, 0.0, 100.0),
- memory: max(proc.memory + round((:rand.uniform() - 0.5) * 4), 1)
- }
- end)
- |> Enum.sort_by(& &1.cpu, :desc)
- end
-
- defp generate_load_avg(state) do
- base = current_cpu(state) / 25
- {
- Float.round(base + :rand.uniform() * 0.3, 2),
- Float.round(base * 0.8 + :rand.uniform() * 0.2, 2),
- Float.round(base * 0.6 + :rand.uniform() * 0.1, 2)
- }
- end
-
- defp format_uptime(seconds) do
- days = div(seconds, 86400)
- hours = div(rem(seconds, 86400), 3600)
- minutes = div(rem(seconds, 3600), 60)
-
- cond do
- days > 0 -> "#{days}d #{hours}h #{minutes}m"
- hours > 0 -> "#{hours}h #{minutes}m"
- true -> "#{minutes}m"
- end
- end
-
- defp clamp(value, min, max) do
- value
- |> max(min)
- |> min(max)
- end
-end
diff --git a/examples/dashboard/mix.exs b/examples/dashboard/mix.exs
deleted file mode 100644
index 1ca4555a..00000000
--- a/examples/dashboard/mix.exs
+++ /dev/null
@@ -1,26 +0,0 @@
-defmodule Dashboard.MixProject do
- use Mix.Project
-
- def project do
- [
- app: :dashboard,
- version: "0.1.0",
- elixir: "~> 1.15",
- start_permanent: Mix.env() == :prod,
- deps: deps()
- ]
- end
-
- def application do
- [
- extra_applications: [:logger],
- mod: {Dashboard.Application, []}
- ]
- end
-
- defp deps do
- [
- {:term_ui, path: "../.."}
- ]
- end
-end
diff --git a/examples/dashboard/mix.lock b/examples/dashboard/mix.lock
deleted file mode 100644
index 1c3df94b..00000000
--- a/examples/dashboard/mix.lock
+++ /dev/null
@@ -1,13 +0,0 @@
-%{
- "autumn": {:hex, :autumn, "0.6.0", "56cba6145da885262ef705e6e7a83d981e1f756d629a6d0e10b79a79243b702b", [:mix], [{:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:rustler, "~> 0.29", [hex: :rustler, repo: "hexpm", optional: false]}, {:rustler_precompiled, "~> 0.6", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "d9f7bad90b462e2e3ae3ce3a6d0dcd128230fec2a276cba0af18ce26165b54ce"},
- "castore": {:hex, :castore, "1.0.17", "4f9770d2d45fbd91dcf6bd404cf64e7e58fed04fadda0923dc32acca0badffa2", [:mix], [], "hexpm", "12d24b9d80b910dd3953e165636d68f147a31db945d2dcb9365e441f8b5351e5"},
- "gen_stage": {:hex, :gen_stage, "1.3.2", "7c77e5d1e97de2c6c2f78f306f463bca64bf2f4c3cdd606affc0100b89743b7b", [:mix], [], "hexpm", "0ffae547fa777b3ed889a6b9e1e64566217413d018cabd825f786e843ffe63e7"},
- "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
- "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"},
- "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"},
- "mdex": {:hex, :mdex, "0.11.2", "7b01f784f38b0dfea92af164b8d1dae6f31f77e344da821b852be7bd8cd67484", [:mix], [{:autumn, ">= 0.6.0", [hex: :autumn, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}, {:rustler, "~> 0.32", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.7", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "3a9d3f7049be6e37793cbe533bc6eea2e4df572aca32a67a857a2e8921964c00"},
- "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
- "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"},
- "rustler": {:hex, :rustler, "0.37.1", "721434020c7f6f8e1cdc57f44f75c490435b01de96384f8ccb96043f12e8a7e0", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "24547e9b8640cf00e6a2071acb710f3e12ce0346692e45098d84d45cdb54fd79"},
- "rustler_precompiled": {:hex, :rustler_precompiled, "0.8.4", "700a878312acfac79fb6c572bb8b57f5aae05fe1cf70d34b5974850bbf2c05bf", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "3b33d99b540b15f142ba47944f7a163a25069f6d608783c321029bc1ffb09514"},
-}
diff --git a/examples/dashboard/run.exs b/examples/dashboard/run.exs
deleted file mode 100644
index fc67ea2a..00000000
--- a/examples/dashboard/run.exs
+++ /dev/null
@@ -1 +0,0 @@
-Dashboard.App.run()
diff --git a/examples/dialog/README.md b/examples/dialog/README.md
deleted file mode 100644
index 590d8bb0..00000000
--- a/examples/dialog/README.md
+++ /dev/null
@@ -1,144 +0,0 @@
-# Dialog Widget Example
-
-This example demonstrates the Dialog widget for displaying modal dialogs with customizable buttons and content.
-
-## Widget Overview
-
-The Dialog widget provides modal overlays that appear centered on screen with focus trapping. It's ideal for:
-
-- Confirmation dialogs (Yes/No, OK/Cancel)
-- Information alerts (single OK button)
-- Warning messages with multiple options
-- Simple forms or prompts
-
-**Key Features:**
-- Centered modal display with backdrop
-- Customizable width and content
-- Multiple button configurations
-- Button navigation with keyboard and mouse
-- Focus trapping (Tab cycles within dialog)
-- Escape to close (configurable)
-- Default button selection
-- Button highlighting for focused state
-
-## Widget Options
-
-The `Dialog.new/1` function accepts the following options:
-
-- `:title` (required) - Dialog title displayed in header
-- `:content` - Dialog body content (render node, default: empty)
-- `:buttons` - List of button definitions (default: single OK button)
- - Each button: `%{id: atom, label: string, default: boolean}`
-- `:width` - Dialog width in characters (default: 40)
-- `:on_close` - Callback function `(() -> any)` when dialog closes
-- `:on_confirm` - Callback function `(button_id -> any)` when button is activated
-- `:closeable` - Whether Escape closes dialog (default: true)
-- `:title_style` - Style for title bar
-- `:content_style` - Style for content area
-- `:button_style` - Style for buttons
-- `:focused_button_style` - Style for focused button
-
-## Example Structure
-
-```
-dialog/
-├── lib/
-│ └── dialog/
-│ └── app.ex # Main application component
-├── mix.exs # Project configuration
-└── README.md # This file
-```
-
-**app.ex** - Implements the Elm Architecture pattern:
-- Maintains dialog state (visibility, button focus, result)
-- Shows different dialog types (info, confirm, warning)
-- Forwards keyboard events to dialog widget when visible
-- Tracks last selected button for demonstration
-
-## Running the Example
-
-### Raw Mode (Full TUI Experience)
-
-For the best experience with full terminal control and alternate screen:
-
-```bash
-cd examples/dialog
-mix termui.run
-```
-
-Or manually:
-
-```bash
-cd examples/dialog
-mix run -e "Dialog.App.run()" --no-halt
-```
-
-### TTY Mode (IEx Compatible)
-
-To run from IEx without taking over the shell:
-
-```bash
-cd examples/dialog
-iex -S mix
-```
-
-Then in IEx:
-
-```elixir
-Dialog.App.run()
-```
-
-**Note:** TTY mode works inside IEx but has limitations:
-- No alternate screen buffer (output mixes with IEx prompt)
-- Character input works immediately (no Enter needed)
-- For full TUI, use raw mode instead
-
-## Controls
-
-- **1** - Show Info Dialog (single "OK" button)
-- **2** - Show Confirm Dialog (Cancel/Confirm buttons)
-- **3** - Show Warning Dialog (Don't Save/Cancel/Save buttons with default)
-- **Tab / Shift+Tab** - Navigate between buttons (when dialog open)
-- **Left/Right** - Navigate between buttons (when dialog open)
-- **Enter** - Select focused button
-- **Space** - Select focused button
-- **Escape** - Close dialog (calls on_close callback)
-- **Q** - Quit the application
-
-## Dialog Types Demonstrated
-
-**Info Dialog:**
-```elixir
-Dialog.new(
- title: "Information",
- content: text("This is an informational message.\nPress OK to continue.", nil),
- buttons: [%{id: :ok, label: "OK"}]
-)
-```
-
-**Confirm Dialog:**
-```elixir
-Dialog.new(
- title: "Confirm Action",
- content: text("Are you sure you want to proceed?", nil),
- buttons: [
- %{id: :cancel, label: "Cancel"},
- %{id: :confirm, label: "Confirm"}
- ]
-)
-```
-
-**Warning Dialog with Default:**
-```elixir
-Dialog.new(
- title: "Warning",
- content: text("Unsaved changes will be lost!", nil),
- buttons: [
- %{id: :dont_save, label: "Don't Save"},
- %{id: :cancel, label: "Cancel"},
- %{id: :save, label: "Save", default: true}
- ]
-)
-```
-
-The `default: true` option sets initial focus to that button.
diff --git a/examples/dialog/lib/dialog/app.ex b/examples/dialog/lib/dialog/app.ex
deleted file mode 100644
index bb992702..00000000
--- a/examples/dialog/lib/dialog/app.ex
+++ /dev/null
@@ -1,190 +0,0 @@
-defmodule Dialog.App do
- @moduledoc """
- Dialog Widget Example
-
- This example demonstrates how to use the TermUI.Widgets.Dialog widget
- for displaying modal dialogs with buttons.
-
- Features demonstrated:
- - Basic dialog with title and content
- - Multiple button options
- - Button navigation
- - Dialog open/close states
- - Different dialog types (info, confirm, warning)
-
- Controls:
- - 1: Show Info Dialog
- - 2: Show Confirm Dialog
- - 3: Show Warning Dialog
- - Tab/Arrow: Navigate buttons (when dialog open)
- - Enter: Select button (when dialog open)
- - Escape: Close dialog
- - Q: Quit the application
- """
-
- use TermUI.Elm
-
- alias TermUI.Event
- alias TermUI.Renderer.Style
- alias TermUI.Widgets.Dialog
-
- # ----------------------------------------------------------------------------
- # Component Callbacks
- # ----------------------------------------------------------------------------
-
- @doc """
- Initialize the component state.
- """
- def init(_opts) do
- %{
- # Current dialog state (nil when no dialog visible)
- dialog: nil,
- # Result tracking
- last_result: nil
- }
- end
-
- @doc """
- Convert keyboard events to messages.
- """
- # When no dialog is visible, number keys show dialogs
- def event_to_msg(%Event.Key{key: "1"}, %{dialog: nil}), do: {:msg, :show_info}
- def event_to_msg(%Event.Key{key: "2"}, %{dialog: nil}), do: {:msg, :show_confirm}
- def event_to_msg(%Event.Key{key: "3"}, %{dialog: nil}), do: {:msg, :show_warning}
-
- # When dialog is visible, forward events to the dialog widget
- def event_to_msg(event, %{dialog: dialog}) when dialog != nil, do: {:msg, {:dialog_event, event}}
-
- def event_to_msg(%Event.Key{key: key}, _state) when key in ["q", "Q"], do: {:msg, :quit}
- def event_to_msg(_event, _state), do: :ignore
-
- @doc """
- Update state based on messages.
- """
- def update(:show_info, state) do
- {show_dialog(state, "Information", "This is an informational message.\nPress OK to continue.", [
- %{id: :ok, label: "OK"}
- ]), []}
- end
-
- def update(:show_confirm, state) do
- {show_dialog(state, "Confirm Action", "Are you sure you want to proceed?\nThis action cannot be undone.", [
- %{id: :cancel, label: "Cancel"},
- %{id: :confirm, label: "Confirm"}
- ]), []}
- end
-
- def update(:show_warning, state) do
- {show_dialog(state, "Warning", "Unsaved changes will be lost!\nDo you want to save before closing?", [
- %{id: :dont_save, label: "Don't Save"},
- %{id: :cancel, label: "Cancel"},
- %{id: :save, label: "Save", default: true}
- ]), []}
- end
-
- def update({:dialog_event, event}, state) do
- case Dialog.handle_event(event, state.dialog) do
- {:ok, new_dialog} ->
- if Dialog.visible?(new_dialog) do
- {%{state | dialog: new_dialog}, []}
- else
- # Dialog was closed - capture result
- result = Dialog.get_focused_button(new_dialog)
- {%{state | dialog: nil, last_result: format_result(result)}, []}
- end
- end
- end
-
- def update(:quit, state) do
- {state, [:quit]}
- end
-
- # Helper to create and initialize a dialog
- defp show_dialog(state, title, content, buttons) do
- props = Dialog.new(
- title: title,
- content: text(content, nil),
- buttons: buttons,
- width: 45
- )
- {:ok, dialog} = Dialog.init(props)
- %{state | dialog: dialog}
- end
-
- defp format_result(nil), do: "Cancelled"
- defp format_result(result), do: "Selected: #{result}"
-
- @doc """
- Render the current state to a render tree.
- """
- def view(state) do
- main_content = render_main_content(state)
-
- if state.dialog != nil do
- stack(:vertical, [
- main_content,
- text("", nil),
- Dialog.render(state.dialog, %{width: 80, height: 24})
- ])
- else
- main_content
- end
- end
-
- # ----------------------------------------------------------------------------
- # Private Helpers
- # ----------------------------------------------------------------------------
-
- defp render_main_content(state) do
- stack(:vertical, [
- # Title
- text("Dialog Widget Example", Style.new(fg: :cyan, attrs: [:bold])),
- text("", nil),
-
- # Instructions
- text("Press a number key to show different dialog types:", nil),
- text("", nil),
- text(" 1 - Info Dialog (single button)", nil),
- text(" 2 - Confirm Dialog (two buttons)", nil),
- text(" 3 - Warning Dialog (three buttons)", nil),
- text("", nil),
-
- text("", nil),
-
- # Controls
- render_controls(state)
- ])
- end
-
- defp render_controls(state) do
- box_width = 50
- inner_width = box_width - 2
-
- top_border = "┌─ Controls " <> String.duplicate("─", inner_width - 12) <> "─┐"
- bottom_border = "└" <> String.duplicate("─", inner_width) <> "┘"
-
- stack(:vertical, [
- text("", nil),
- text(top_border, Style.new(fg: :yellow)),
- text("│" <> String.pad_trailing(" 1/2/3 Show dialog", inner_width) <> "│", nil),
- text("│" <> String.pad_trailing(" Tab/←/→ Navigate buttons (in dialog)", inner_width) <> "│", nil),
- text("│" <> String.pad_trailing(" Enter Select button", inner_width) <> "│", nil),
- text("│" <> String.pad_trailing(" Escape Close dialog", inner_width) <> "│", nil),
- text("│" <> String.pad_trailing(" Q Quit", inner_width) <> "│", nil),
- text("│" <> String.pad_trailing("", inner_width) <> "│", nil),
- text("│" <> String.pad_trailing(" Last result: #{state.last_result || "(none)"}", inner_width) <> "│", nil),
- text(bottom_border, Style.new(fg: :yellow))
- ])
- end
-
- # ----------------------------------------------------------------------------
- # Public API
- # ----------------------------------------------------------------------------
-
- @doc """
- Run the dialog example application.
- """
- def run do
- TermUI.Runtime.run(root: __MODULE__)
- end
-end
diff --git a/examples/dialog/lib/dialog/application.ex b/examples/dialog/lib/dialog/application.ex
deleted file mode 100644
index 1619bc34..00000000
--- a/examples/dialog/lib/dialog/application.ex
+++ /dev/null
@@ -1,12 +0,0 @@
-defmodule Dialog.Application do
- @moduledoc false
-
- use Application
-
- @impl true
- def start(_type, _args) do
- children = []
- opts = [strategy: :one_for_one, name: Dialog.Supervisor]
- Supervisor.start_link(children, opts)
- end
-end
diff --git a/examples/dialog/mix.exs b/examples/dialog/mix.exs
deleted file mode 100644
index 62be0537..00000000
--- a/examples/dialog/mix.exs
+++ /dev/null
@@ -1,26 +0,0 @@
-defmodule Dialog.MixProject do
- use Mix.Project
-
- def project do
- [
- app: :dialog,
- version: "0.1.0",
- elixir: "~> 1.15",
- start_permanent: Mix.env() == :prod,
- deps: deps()
- ]
- end
-
- def application do
- [
- extra_applications: [:logger],
- mod: {Dialog.Application, []}
- ]
- end
-
- defp deps do
- [
- {:term_ui, path: "../.."}
- ]
- end
-end
diff --git a/examples/dialog/mix.lock b/examples/dialog/mix.lock
deleted file mode 100644
index ee8761c0..00000000
--- a/examples/dialog/mix.lock
+++ /dev/null
@@ -1,12 +0,0 @@
-%{
- "castore": {:hex, :castore, "1.0.17", "4f9770d2d45fbd91dcf6bd404cf64e7e58fed04fadda0923dc32acca0badffa2", [:mix], [], "hexpm", "12d24b9d80b910dd3953e165636d68f147a31db945d2dcb9365e441f8b5351e5"},
- "gen_stage": {:hex, :gen_stage, "1.3.2", "7c77e5d1e97de2c6c2f78f306f463bca64bf2f4c3cdd606affc0100b89743b7b", [:mix], [], "hexpm", "0ffae547fa777b3ed889a6b9e1e64566217413d018cabd825f786e843ffe63e7"},
- "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
- "lumis": {:hex, :lumis, "0.1.0", "6d3b495457d0608f8fe32fe6fa917eb17c921bd0087583a7f4c420c0009888fd", [:mix], [{:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:rustler, "~> 0.29", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.6", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "4de203bc5811ad21f0c6b0442612a3fc1ae9bea7fbfe7037452db9e9dfdaaab3"},
- "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"},
- "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"},
- "mdex": {:hex, :mdex, "0.11.3", "7b815ae2f474e62ad365dfa4d9df87ddec6a01cfa9b7db523a3b21061d909225", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:lumis, "~> 0.1", [hex: :lumis, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}, {:rustler, "~> 0.32", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.7", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "181bf04b13dcae20ab9f336115facc5096aae333a2408a03e53338ee65d4f564"},
- "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
- "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"},
- "rustler_precompiled": {:hex, :rustler_precompiled, "0.8.4", "700a878312acfac79fb6c572bb8b57f5aae05fe1cf70d34b5974850bbf2c05bf", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "3b33d99b540b15f142ba47944f7a163a25069f6d608783c321029bc1ffb09514"},
-}
diff --git a/examples/dialog/run.exs b/examples/dialog/run.exs
deleted file mode 100644
index 4ffec9bb..00000000
--- a/examples/dialog/run.exs
+++ /dev/null
@@ -1 +0,0 @@
-Dialog.App.run()
diff --git a/examples/form_builder/README.md b/examples/form_builder/README.md
deleted file mode 100644
index e24caeae..00000000
--- a/examples/form_builder/README.md
+++ /dev/null
@@ -1,207 +0,0 @@
-# FormBuilder Widget Example
-
-This example demonstrates the FormBuilder widget for creating structured forms with multiple field types, validation, and conditional fields.
-
-## Widget Overview
-
-The FormBuilder widget provides comprehensive form handling with automatic layout, validation, and navigation. It's ideal for:
-
-- Registration and login forms
-- Settings and configuration panels
-- Data entry interfaces
-- Multi-step wizards
-- Survey forms
-
-**Key Features:**
-- Multiple field types (text, password, checkbox, radio, select, multi-select)
-- Built-in validation with custom validators
-- Conditional field visibility
-- Field grouping and organization
-- Automatic keyboard navigation
-- Required field indicators
-- Error message display
-- Submit button with validation
-
-## Widget Options
-
-The `FormBuilder.new/1` function accepts the following options:
-
-- `:fields` (required) - List of field definitions
-- `:groups` - List of group definitions for organizing fields
-- `:on_submit` - Callback function `(values -> any)` when form is submitted
-- `:on_change` - Callback function `(field_id, value -> any)` when any field value changes
-- `:values` - Map of initial field values
-- `:show_submit_button` - Whether to show submit button (default: true)
-- `:submit_label` - Label for submit button (default: "Submit")
-- `:validate_on_blur` - Validate when field loses focus (default: true)
-- `:label_width` - Width for field labels (default: 15)
-- `:field_width` - Width for field inputs (default: 30)
-
-**Field Definition:**
-
-Each field is a map with:
-- `:id` (required) - Unique atom identifier
-- `:type` (required) - Field type (see below)
-- `:label` (required) - Display label
-- `:required` - Boolean, whether field is required (default: false)
-- `:validators` - List of validator functions
-- `:visible_when` - Function `(values -> boolean)` for conditional visibility
-- `:placeholder` - Placeholder text for text/password fields
-- `:default` - Default value
-- `:options` - List of `{value, label}` tuples for select/radio/multi-select
-
-**Field Types:**
-- `:text` - Single line text input
-- `:password` - Masked text input
-- `:checkbox` - Boolean toggle
-- `:radio` - Single selection from options
-- `:select` - Dropdown single selection
-- `:multi_select` - Multiple selection from options
-
-## Example Structure
-
-```
-form_builder/
-├── lib/
-│ └── form_builder/
-│ └── app.ex # Main application component
-├── mix.exs # Project configuration
-└── README.md # This file
-```
-
-**app.ex** - Demonstrates comprehensive form features:
-- Text and password fields with validation
-- Checkbox with conditional field (email frequency)
-- Radio buttons for options
-- Select dropdown for country
-- Multi-select for interests
-- Custom validators (password strength, email format)
-- Form submission with validation
-- Display of submitted data
-
-## Running the Example
-
-### Raw Mode (Full TUI Experience)
-
-For the best experience with full terminal control and alternate screen:
-
-```bash
-cd examples/form_builder
-mix termui.run
-```
-
-Or manually:
-
-```bash
-cd examples/form_builder
-mix run -e "FormBuilder.run()" --no-halt
-```
-
-### TTY Mode (IEx Compatible)
-
-To run from IEx without taking over the shell:
-
-```bash
-cd examples/form_builder
-iex -S mix
-```
-
-Then in IEx:
-
-```elixir
-FormBuilder.run()
-```
-
-**Note:** TTY mode works inside IEx but has limitations:
-- No alternate screen buffer (output mixes with IEx prompt)
-- Character input works immediately (no Enter needed)
-- For full TUI, use raw mode instead
-
-## Controls
-
-- **Tab / Shift+Tab** - Navigate between fields and submit button
-- **Up/Down** - Navigate options (radio/select/multi-select fields)
-- **Space** - Toggle checkbox or select option
-- **Enter** - Submit form (when on submit button)
-- **Backspace** - Delete character (text/password fields)
-- **Type characters** - Enter text (text/password fields)
-- **Q** - Quit the application
-
-## Field Behavior
-
-**Text/Password Fields:**
-- Type to enter text
-- Backspace to delete
-- Displays placeholder when empty
-- Password fields show asterisks
-
-**Checkbox:**
-- Space to toggle
-- Shows [x] when checked, [ ] when unchecked
-
-**Radio Buttons:**
-- Up/Down or Space to select option
-- Shows (o) for selected, ( ) for unselected
-- Options displayed horizontally
-
-**Select Dropdown:**
-- Shows selected value with dropdown indicator
-- Expands when focused to show all options
-- Up/Down to navigate, Space/Enter to select
-
-**Multi-Select:**
-- Shows all options with checkboxes
-- Up/Down to navigate
-- Space to toggle selection
-- Multiple options can be selected
-
-## Validation
-
-The example demonstrates custom validators:
-
-**Password Validator:**
-```elixir
-defp validate_password(value) do
- cond do
- String.length(value) < 6 ->
- {:error, "Password must be at least 6 characters"}
- not String.match?(value, ~r/[0-9]/) ->
- {:error, "Password must contain at least one number"}
- true ->
- :ok
- end
-end
-```
-
-**Email Validator:**
-```elixir
-defp validate_email(value) do
- if value == "" or String.match?(value, ~r/^[^\s@]+@[^\s@]+\.[^\s@]+$/) do
- :ok
- else
- {:error, "Please enter a valid email address"}
- end
-end
-```
-
-Errors are displayed in red below the field.
-
-## Conditional Fields
-
-The email frequency field demonstrates conditional visibility:
-
-```elixir
-%{
- id: :frequency,
- type: :radio,
- label: "Email frequency",
- visible_when: fn values -> values[:newsletter] end,
- options: [
- {"daily", "Daily"},
- {"weekly", "Weekly"},
- {"monthly", "Monthly"}
- ]
-}
-```
-
-This field only appears when the newsletter checkbox is checked.
diff --git a/examples/form_builder/lib/form_builder.ex b/examples/form_builder/lib/form_builder.ex
deleted file mode 100644
index 4e0a735f..00000000
--- a/examples/form_builder/lib/form_builder.ex
+++ /dev/null
@@ -1,7 +0,0 @@
-defmodule FormBuilder do
- @moduledoc """
- FormBuilder example entry point.
- """
-
- defdelegate run, to: FormBuilder.App
-end
diff --git a/examples/form_builder/lib/form_builder/app.ex b/examples/form_builder/lib/form_builder/app.ex
deleted file mode 100644
index 03184467..00000000
--- a/examples/form_builder/lib/form_builder/app.ex
+++ /dev/null
@@ -1,268 +0,0 @@
-defmodule FormBuilder.App do
- @moduledoc """
- FormBuilder Widget Example
-
- This example demonstrates how to use the TermUI.Widgets.FormBuilder widget
- for creating structured forms with multiple field types.
-
- Features demonstrated:
- - Text and password fields
- - Checkbox fields
- - Radio button groups
- - Select dropdowns
- - Multi-select fields
- - Field validation
- - Conditional fields
- - Form submission
-
- Controls:
- - Tab/Shift+Tab: Navigate between fields
- - Up/Down: Navigate options (radio/select)
- - Space: Toggle checkbox, select option
- - Enter: Submit form (on submit button)
- - Q: Quit the application
- """
-
- use TermUI.Elm
-
- alias TermUI.Event
- alias TermUI.Renderer.Style
- alias TermUI.Widgets.FormBuilder
-
- # ----------------------------------------------------------------------------
- # Component Callbacks
- # ----------------------------------------------------------------------------
-
- @doc """
- Initialize the component state.
- """
- def init(_opts) do
- props =
- FormBuilder.new(
- fields: [
- # Basic text fields
- %{id: :username, type: :text, label: "Username", required: true,
- placeholder: "Enter username"},
- %{id: :password, type: :password, label: "Password", required: true,
- validators: [&validate_password/1]},
- %{id: :email, type: :text, label: "Email",
- validators: [&validate_email/1]},
-
- # Checkbox
- %{id: :newsletter, type: :checkbox, label: "Subscribe to newsletter"},
-
- # Conditional field - only shown when newsletter is checked
- %{id: :frequency, type: :radio, label: "Email frequency",
- visible_when: fn values -> values[:newsletter] end,
- options: [
- {"daily", "Daily"},
- {"weekly", "Weekly"},
- {"monthly", "Monthly"}
- ]},
-
- # Select dropdown
- %{id: :country, type: :select, label: "Country",
- options: [
- {"us", "United States"},
- {"uk", "United Kingdom"},
- {"ca", "Canada"},
- {"au", "Australia"},
- {"de", "Germany"}
- ]},
-
- # Multi-select
- %{id: :interests, type: :multi_select, label: "Interests",
- options: [
- {"tech", "Technology"},
- {"sports", "Sports"},
- {"music", "Music"},
- {"art", "Art"},
- {"travel", "Travel"}
- ]}
- ],
- submit_label: "Register",
- label_width: 18,
- field_width: 25
- )
-
- {:ok, form_state} = FormBuilder.init(props)
-
- %{
- form: form_state,
- submitted_data: nil,
- message: nil
- }
- end
-
- # Custom validators
- defp validate_password(value) do
- cond do
- String.length(value) < 6 ->
- {:error, "Password must be at least 6 characters"}
- not String.match?(value, ~r/[0-9]/) ->
- {:error, "Password must contain at least one number"}
- true ->
- :ok
- end
- end
-
- defp validate_email(value) do
- if value == "" or String.match?(value, ~r/^[^\s@]+@[^\s@]+\.[^\s@]+$/) do
- :ok
- else
- {:error, "Please enter a valid email address"}
- end
- end
-
- @doc """
- Convert keyboard events to messages.
- """
- def event_to_msg(%Event.Key{key: key}, _state) when key in ["q", "Q"] do
- {:msg, :quit}
- end
-
- def event_to_msg(%Event.Key{key: :enter} = event, state) do
- # Check if submit button is focused - if so, this is a form submission
- if state.form.submit_focused do
- {:msg, :submit_form}
- else
- {:msg, {:form_event, event}}
- end
- end
-
- def event_to_msg(event, _state) do
- # Pass all other events to the form
- {:msg, {:form_event, event}}
- end
-
- @doc """
- Update state based on messages.
- """
- def update(:quit, state) do
- {state, [:quit]}
- end
-
- def update(:submit_form, state) do
- # Let the form handle the enter key (which runs validation)
- {:ok, new_form} = FormBuilder.handle_event(%Event.Key{key: :enter}, state.form)
-
- # Check if there are validation errors
- has_errors = Enum.any?(new_form.errors, fn {_field_id, errors} -> errors != [] end)
-
- if has_errors do
- # Form has errors, just update the form state
- {%{state | form: new_form, message: "Please fix the errors above"}, []}
- else
- # Form is valid, get the values and mark as submitted
- values = FormBuilder.get_values(new_form)
- {%{state | form: new_form, submitted_data: values, message: "Form submitted successfully!"}, []}
- end
- end
-
- def update({:form_event, event}, state) do
- {:ok, new_form} = FormBuilder.handle_event(event, state.form)
- {%{state | form: new_form}, []}
- end
-
- def update(_msg, state) do
- {state, []}
- end
-
- @doc """
- Render the current state to a render tree.
- """
- def view(state) do
- stack(:vertical, [
- # Title
- text("FormBuilder Widget Example", Style.new(fg: :cyan, attrs: [:bold])),
- text(""),
-
- # Instructions
- render_instructions(),
- text(""),
-
- # Form
- render_form_section(state),
- text(""),
-
- # Submitted data (if any)
- render_submitted_data(state),
-
- # Status message
- render_message(state)
- ])
- end
-
- # ----------------------------------------------------------------------------
- # Private Helpers
- # ----------------------------------------------------------------------------
-
- defp render_instructions do
- stack(:vertical, [
- text("Controls:", Style.new(fg: :yellow)),
- text(" Tab/Shift+Tab Navigate between fields"),
- text(" Up/Down Navigate options (radio/select)"),
- text(" Space Toggle checkbox, select option"),
- text(" Enter Submit form (on submit button)"),
- text(" Q Quit")
- ])
- end
-
- defp render_form_section(state) do
- box_style = Style.new(fg: :white)
-
- stack(:vertical, [
- text("--- Registration Form ---", box_style),
- text(""),
- FormBuilder.render(state.form, %{width: 70, height: 20})
- ])
- end
-
- defp render_submitted_data(state) do
- case state.submitted_data do
- nil ->
- empty()
-
- data ->
- stack(:vertical, [
- text("--- Submitted Data ---", Style.new(fg: :green, attrs: [:bold])),
- text(""),
- render_data_row("Username", data[:username]),
- render_data_row("Password", String.duplicate("*", String.length(data[:password] || ""))),
- render_data_row("Email", data[:email]),
- render_data_row("Newsletter", if(data[:newsletter], do: "Yes", else: "No")),
- if data[:newsletter] do
- render_data_row("Frequency", data[:frequency] || "(not set)")
- else
- empty()
- end,
- render_data_row("Country", data[:country]),
- render_data_row("Interests", Enum.join(data[:interests] || [], ", "))
- ])
- end
- end
-
- defp render_data_row(label, value) do
- text(" #{String.pad_trailing(label <> ":", 15)} #{value}")
- end
-
- defp render_message(state) do
- case state.message do
- nil -> empty()
- msg ->
- style = if String.contains?(msg, "error"), do: Style.new(fg: :red), else: Style.new(fg: :green, attrs: [:bold])
- text(msg, style)
- end
- end
-
- # ----------------------------------------------------------------------------
- # Public API
- # ----------------------------------------------------------------------------
-
- @doc """
- Run the form builder example application.
- """
- def run do
- TermUI.Runtime.run(root: __MODULE__)
- end
-end
diff --git a/examples/form_builder/mix.exs b/examples/form_builder/mix.exs
deleted file mode 100644
index 0f2ad652..00000000
--- a/examples/form_builder/mix.exs
+++ /dev/null
@@ -1,25 +0,0 @@
-defmodule FormBuilder.MixProject do
- use Mix.Project
-
- def project do
- [
- app: :form_builder,
- version: "0.1.0",
- elixir: "~> 1.15",
- start_permanent: Mix.env() == :prod,
- deps: deps()
- ]
- end
-
- def application do
- [
- extra_applications: [:logger]
- ]
- end
-
- defp deps do
- [
- {:term_ui, path: "../.."}
- ]
- end
-end
diff --git a/examples/form_builder/mix.lock b/examples/form_builder/mix.lock
deleted file mode 100644
index ee8761c0..00000000
--- a/examples/form_builder/mix.lock
+++ /dev/null
@@ -1,12 +0,0 @@
-%{
- "castore": {:hex, :castore, "1.0.17", "4f9770d2d45fbd91dcf6bd404cf64e7e58fed04fadda0923dc32acca0badffa2", [:mix], [], "hexpm", "12d24b9d80b910dd3953e165636d68f147a31db945d2dcb9365e441f8b5351e5"},
- "gen_stage": {:hex, :gen_stage, "1.3.2", "7c77e5d1e97de2c6c2f78f306f463bca64bf2f4c3cdd606affc0100b89743b7b", [:mix], [], "hexpm", "0ffae547fa777b3ed889a6b9e1e64566217413d018cabd825f786e843ffe63e7"},
- "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
- "lumis": {:hex, :lumis, "0.1.0", "6d3b495457d0608f8fe32fe6fa917eb17c921bd0087583a7f4c420c0009888fd", [:mix], [{:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:rustler, "~> 0.29", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.6", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "4de203bc5811ad21f0c6b0442612a3fc1ae9bea7fbfe7037452db9e9dfdaaab3"},
- "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"},
- "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"},
- "mdex": {:hex, :mdex, "0.11.3", "7b815ae2f474e62ad365dfa4d9df87ddec6a01cfa9b7db523a3b21061d909225", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:lumis, "~> 0.1", [hex: :lumis, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}, {:rustler, "~> 0.32", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.7", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "181bf04b13dcae20ab9f336115facc5096aae333a2408a03e53338ee65d4f564"},
- "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
- "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"},
- "rustler_precompiled": {:hex, :rustler_precompiled, "0.8.4", "700a878312acfac79fb6c572bb8b57f5aae05fe1cf70d34b5974850bbf2c05bf", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "3b33d99b540b15f142ba47944f7a163a25069f6d608783c321029bc1ffb09514"},
-}
diff --git a/examples/form_builder/run.exs b/examples/form_builder/run.exs
deleted file mode 100644
index c3478be3..00000000
--- a/examples/form_builder/run.exs
+++ /dev/null
@@ -1 +0,0 @@
-FormBuilder.App.run()
diff --git a/examples/gauge/README.md b/examples/gauge/README.md
deleted file mode 100644
index 8a740ae4..00000000
--- a/examples/gauge/README.md
+++ /dev/null
@@ -1,193 +0,0 @@
-# Gauge Widget Example
-
-This example demonstrates the Gauge widget for displaying numeric values within a range using visual bars or arcs.
-
-## Widget Overview
-
-The Gauge widget provides visual representation of values with support for color zones and multiple display styles. It's ideal for:
-
-- Progress indicators
-- Resource usage displays (CPU, memory, disk)
-- Percentage visualizations
-- Status meters
-- Loading indicators
-
-**Key Features:**
-- Bar style (horizontal filled bar)
-- Arc style (semi-circular arc)
-- Color zones for visual feedback
-- Customizable characters for bar display
-- Min/max range labels
-- Value display
-- Custom labeling
-
-## Widget Options
-
-The `Gauge.render/1` function accepts the following options:
-
-- `:value` (required) - Current numeric value to display
-- `:min` - Minimum value (default: 0)
-- `:max` - Maximum value (default: 100)
-- `:width` - Gauge width in characters (default: 40)
-- `:type` - Display type, `:bar` or `:arc` (default: `:bar`)
-- `:show_value` - Show numeric value below gauge (default: true)
-- `:show_range` - Show min/max labels (default: true)
-- `:zones` - List of `{threshold, style}` tuples for color zones
-- `:label` - Label text displayed above gauge
-- `:bar_char` - Character for filled portion (default: "█")
-- `:empty_char` - Character for empty portion (default: "░")
-
-**Helper Functions:**
-
-- `Gauge.percentage(value, opts)` - Quick percentage gauge (0-100 range)
-- `Gauge.traffic_light(opts)` - Gauge with green/yellow/red zones
-
-**Color Zones:**
-
-Zones define style changes at thresholds:
-```elixir
-zones: [
- {0, Style.new(fg: :green)}, # Green from 0-59
- {60, Style.new(fg: :yellow)}, # Yellow from 60-79
- {80, Style.new(fg: :red)} # Red from 80-100
-]
-```
-
-## Example Structure
-
-```
-gauge/
-├── lib/
-│ └── gauge/
-│ └── app.ex # Main application component
-├── mix.exs # Project configuration
-└── README.md # This file
-```
-
-**app.ex** - Demonstrates various gauge configurations:
-- Simple percentage gauge using helper
-- Gauge with color zones (green/yellow/red)
-- Gauge with custom characters
-- Interactive value adjustment
-- Style switching (bar/arc)
-
-## Running the Example
-
-### Raw Mode (Full TUI Experience)
-
-For the best experience with full terminal control and alternate screen:
-
-```bash
-cd examples/gauge
-mix termui.run
-```
-
-Or manually:
-
-```bash
-cd examples/gauge
-mix run -e "Gauge.App.run()" --no-halt
-```
-
-### TTY Mode (IEx Compatible)
-
-To run from IEx without taking over the shell:
-
-```bash
-cd examples/gauge
-iex -S mix
-```
-
-Then in IEx:
-
-```elixir
-Gauge.App.run()
-```
-
-**Note:** TTY mode works inside IEx but has limitations:
-- No alternate screen buffer (output mixes with IEx prompt)
-- Character input works immediately (no Enter needed)
-- For full TUI, use raw mode instead
-
-## Controls
-
-- **Up Arrow** - Increase value by 5
-- **Down Arrow** - Decrease value by 5
-- **Right Arrow** - Increase value by 10
-- **Left Arrow** - Decrease value by 10
-- **S** - Toggle between bar and arc display styles
-- **Q** - Quit the application
-
-The value is automatically clamped between 0 and 100.
-
-## Display Styles
-
-**Bar Style:**
-```
-Simple Percentage Gauge:
-████████████████████░░░░░░░░░░
- 50
-```
-
-**Arc Style:**
-```
-╭────────────────────────────╮
-│ ▼ │
-╰────────────────────────────╯
- 50
-```
-
-## Gauge Examples
-
-**Simple Percentage:**
-```elixir
-Gauge.percentage(75, width: 30)
-```
-
-**With Color Zones:**
-```elixir
-Gauge.render(
- value: 75,
- min: 0,
- max: 100,
- width: 30,
- zones: [
- {0, Style.new(fg: :green)},
- {60, Style.new(fg: :yellow)},
- {80, Style.new(fg: :red)}
- ],
- label: "CPU Usage"
-)
-```
-
-**Custom Characters:**
-```elixir
-Gauge.render(
- value: 75,
- min: 0,
- max: 100,
- width: 30,
- bar_char: "▓",
- empty_char: "░"
-)
-```
-
-**Arc Style:**
-```elixir
-Gauge.render(
- value: 75,
- min: 0,
- max: 100,
- width: 30,
- type: :arc,
- show_value: true
-)
-```
-
-## Use Cases
-
-- **System Monitoring:** Display CPU, memory, or disk usage
-- **Progress Tracking:** Show download/upload progress
-- **Resource Limits:** Visualize quota usage
-- **Performance Metrics:** Display response times or throughput
-- **Health Indicators:** Show service health status
diff --git a/examples/gauge/lib/gauge/app.ex b/examples/gauge/lib/gauge/app.ex
deleted file mode 100644
index fe9793fe..00000000
--- a/examples/gauge/lib/gauge/app.ex
+++ /dev/null
@@ -1,168 +0,0 @@
-defmodule Gauge.App do
- @moduledoc """
- Gauge Widget Example
-
- This example demonstrates how to use the TermUI.Widgets.Gauge widget
- for displaying values within a range. The gauge supports:
-
- - Bar style (horizontal bar)
- - Arc style (semi-circular arc)
- - Color zones for visual feedback
- - Custom characters for the bar
-
- Controls:
- - Up/Down arrows: Increase/decrease value
- - Left/Right arrows: Adjust by larger increments
- - S: Toggle between bar and arc styles
- - Q: Quit the application
- """
-
- use TermUI.Elm
-
- # Import the Gauge widget
- alias TermUI.Widgets.Gauge
- alias TermUI.Event
- alias TermUI.Renderer.Style
-
- # ----------------------------------------------------------------------------
- # Component Callbacks
- # ----------------------------------------------------------------------------
-
- @doc """
- Initialize the component state.
-
- We store:
- - value: Current gauge value (0-100)
- - gauge_type: :bar or :arc display style
- """
- def init(_opts) do
- %{
- value: 50,
- gauge_type: :bar
- }
- end
-
- @doc """
- Convert keyboard events to messages.
-
- This is where we map user input to application messages.
- """
- def event_to_msg(%Event.Key{key: :up}, _state), do: {:msg, {:change_value, 5}}
- def event_to_msg(%Event.Key{key: :down}, _state), do: {:msg, {:change_value, -5}}
- def event_to_msg(%Event.Key{key: :right}, _state), do: {:msg, {:change_value, 10}}
- def event_to_msg(%Event.Key{key: :left}, _state), do: {:msg, {:change_value, -10}}
- def event_to_msg(%Event.Key{key: key}, _state) when key in ["s", "S"], do: {:msg, :toggle_style}
- def event_to_msg(%Event.Key{key: key}, _state) when key in ["q", "Q"], do: {:msg, :quit}
- def event_to_msg(_event, _state), do: :ignore
-
- @doc """
- Update state based on messages.
-
- Returns {new_state, commands} where commands is a list of side effects.
- """
- def update({:change_value, delta}, state) do
- # Clamp value between 0 and 100
- new_value = max(0, min(100, state.value + delta))
- {%{state | value: new_value}, []}
- end
-
- def update(:toggle_style, state) do
- # Toggle between :bar and :arc styles
- new_style = if state.gauge_type == :bar, do: :arc, else: :bar
- {%{state | gauge_type: new_style}, []}
- end
-
- def update(:quit, state) do
- # Return :quit command to exit the application
- {state, [:quit]}
- end
-
- @doc """
- Render the current state to a render tree.
-
- This is called every frame to produce the UI.
- """
- def view(state) do
- stack(:vertical, [
- # Title
- text("Gauge Widget Example", Style.new(fg: :cyan, attrs: [:bold])),
- text("", nil),
-
- # Simple percentage gauge
- # The Gauge.percentage/2 helper creates a 0-100 gauge with value display
- text("Simple Percentage Gauge:", nil),
- Gauge.percentage(state.value, width: 30),
- text("", nil),
-
- # Gauge with color zones
- # Zones are {threshold, style} tuples - the color applies when value >= threshold
- text("Gauge with Color Zones:", nil),
- Gauge.render(
- value: state.value,
- min: 0,
- max: 100,
- width: 30,
- type: state.gauge_type,
- show_value: true,
- show_range: true,
- # Define color zones: green (0-59), yellow (60-79), red (80-100)
- zones: [
- {0, Style.new(fg: :green)},
- {60, Style.new(fg: :yellow)},
- {80, Style.new(fg: :red)}
- ],
- label: "CPU Usage"
- ),
- text("", nil),
-
- # Gauge with custom characters
- text("Gauge with Custom Characters:", nil),
- Gauge.render(
- value: state.value,
- min: 0,
- max: 100,
- width: 30,
- # Use custom characters instead of default █ and ░
- bar_char: "▓",
- empty_char: "░",
- show_value: true,
- show_range: false
- ),
- text("", nil),
-
- # Controls
- render_controls(state)
- ])
- end
-
- defp render_controls(state) do
- box_width = 40
- inner_width = box_width - 2
-
- top_border = "┌─ Controls " <> String.duplicate("─", inner_width - 12) <> "─┐"
- bottom_border = "└" <> String.duplicate("─", inner_width) <> "┘"
-
- stack(:vertical, [
- text("", nil),
- text(top_border, Style.new(fg: :yellow)),
- text("│" <> String.pad_trailing(" ↑/↓ Adjust value by 5", inner_width) <> "│", nil),
- text("│" <> String.pad_trailing(" ←/→ Adjust value by 10", inner_width) <> "│", nil),
- text("│" <> String.pad_trailing(" S Toggle bar/arc style", inner_width) <> "│", nil),
- text("│" <> String.pad_trailing(" Q Quit", inner_width) <> "│", nil),
- text("│" <> String.pad_trailing("", inner_width) <> "│", nil),
- text("│" <> String.pad_trailing(" Current style: #{state.gauge_type}", inner_width) <> "│", nil),
- text(bottom_border, Style.new(fg: :yellow))
- ])
- end
-
- # ----------------------------------------------------------------------------
- # Public API
- # ----------------------------------------------------------------------------
-
- @doc """
- Run the gauge example application.
- """
- def run do
- TermUI.Runtime.run(root: __MODULE__)
- end
-end
diff --git a/examples/gauge/lib/gauge/application.ex b/examples/gauge/lib/gauge/application.ex
deleted file mode 100644
index 4c533c65..00000000
--- a/examples/gauge/lib/gauge/application.ex
+++ /dev/null
@@ -1,12 +0,0 @@
-defmodule Gauge.Application do
- @moduledoc false
-
- use Application
-
- @impl true
- def start(_type, _args) do
- children = []
- opts = [strategy: :one_for_one, name: Gauge.Supervisor]
- Supervisor.start_link(children, opts)
- end
-end
diff --git a/examples/gauge/mix.exs b/examples/gauge/mix.exs
deleted file mode 100644
index abd34d1d..00000000
--- a/examples/gauge/mix.exs
+++ /dev/null
@@ -1,26 +0,0 @@
-defmodule Gauge.MixProject do
- use Mix.Project
-
- def project do
- [
- app: :gauge,
- version: "0.1.0",
- elixir: "~> 1.15",
- start_permanent: Mix.env() == :prod,
- deps: deps()
- ]
- end
-
- def application do
- [
- extra_applications: [:logger],
- mod: {Gauge.Application, []}
- ]
- end
-
- defp deps do
- [
- {:term_ui, path: "../.."}
- ]
- end
-end
diff --git a/examples/gauge/mix.lock b/examples/gauge/mix.lock
deleted file mode 100644
index ee8761c0..00000000
--- a/examples/gauge/mix.lock
+++ /dev/null
@@ -1,12 +0,0 @@
-%{
- "castore": {:hex, :castore, "1.0.17", "4f9770d2d45fbd91dcf6bd404cf64e7e58fed04fadda0923dc32acca0badffa2", [:mix], [], "hexpm", "12d24b9d80b910dd3953e165636d68f147a31db945d2dcb9365e441f8b5351e5"},
- "gen_stage": {:hex, :gen_stage, "1.3.2", "7c77e5d1e97de2c6c2f78f306f463bca64bf2f4c3cdd606affc0100b89743b7b", [:mix], [], "hexpm", "0ffae547fa777b3ed889a6b9e1e64566217413d018cabd825f786e843ffe63e7"},
- "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
- "lumis": {:hex, :lumis, "0.1.0", "6d3b495457d0608f8fe32fe6fa917eb17c921bd0087583a7f4c420c0009888fd", [:mix], [{:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:rustler, "~> 0.29", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.6", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "4de203bc5811ad21f0c6b0442612a3fc1ae9bea7fbfe7037452db9e9dfdaaab3"},
- "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"},
- "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"},
- "mdex": {:hex, :mdex, "0.11.3", "7b815ae2f474e62ad365dfa4d9df87ddec6a01cfa9b7db523a3b21061d909225", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:lumis, "~> 0.1", [hex: :lumis, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}, {:rustler, "~> 0.32", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.7", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "181bf04b13dcae20ab9f336115facc5096aae333a2408a03e53338ee65d4f564"},
- "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
- "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"},
- "rustler_precompiled": {:hex, :rustler_precompiled, "0.8.4", "700a878312acfac79fb6c572bb8b57f5aae05fe1cf70d34b5974850bbf2c05bf", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "3b33d99b540b15f142ba47944f7a163a25069f6d608783c321029bc1ffb09514"},
-}
diff --git a/examples/gauge/run.exs b/examples/gauge/run.exs
deleted file mode 100644
index 86aa26b5..00000000
--- a/examples/gauge/run.exs
+++ /dev/null
@@ -1 +0,0 @@
-Gauge.App.run()
diff --git a/examples/iex_counter/README.md b/examples/iex_counter/README.md
index 82963706..d352f9c5 100644
--- a/examples/iex_counter/README.md
+++ b/examples/iex_counter/README.md
@@ -1,59 +1,13 @@
-# IEx Counter Example
+# Counter example
-A simple counter example demonstrating TermUI's IEx compatibility.
+This is the smallest supported TermUI example. It uses one Elm application, typed
+events, data commands, and one `TermUI.Frame` render value.
-## Running
+Run it with:
-### TTY Mode (IEx Compatible)
-
-This example is designed to be run directly in IEx:
-
-```bash
-cd examples/iex_counter
-iex -S mix
-```
-
-Once in IEx, run the counter:
-
-```elixir
-iex> IExCounter.App.run()
-```
-
-### Raw Mode (Full TUI Experience)
-
-You can also run this as a standalone application with full terminal control:
-
-```bash
-cd examples/iex_counter
-mix termui.run
+```sh
+mix deps.get
+mix run run.exs
```
-## Controls
-
-| Key | Action |
-|-----|--------|
-| ↑ | Increment counter |
-| ↓ | Decrement counter |
-| R | Reset counter to 0 |
-| Q | Quit (returns to IEx prompt) |
-
-## What This Demonstrates
-
-1. **No code changes needed** - The same app works in IEx and standalone
-2. **Keyboard input works** - Arrow keys, Q, R all work correctly
-3. **Clean shutdown** - Terminal state is restored when you quit
-4. **Return to IEx** - You're back at the IEx prompt, ready for more commands
-
-## Detection
-
-The app displays whether it's running in IEx or standalone mode at the top.
-
-You can also check programmatically:
-
-```elixir
-iex> TermUI.iex_mode?()
-true
-
-iex> TermUI.running_mode()
-:iex
-```
+Use Up and Down to change the value. Use R to reset it. Use Q to stop it.
diff --git a/examples/iex_counter/lib/iex_counter/app.ex b/examples/iex_counter/lib/iex_counter/app.ex
index 1781b789..234e883d 100644
--- a/examples/iex_counter/lib/iex_counter/app.ex
+++ b/examples/iex_counter/lib/iex_counter/app.ex
@@ -1,148 +1,49 @@
defmodule IExCounter.App do
- @moduledoc """
- Simple counter example for demonstrating IEx compatibility.
-
- This example demonstrates that TermUI applications work directly
- in IEx with no code changes required.
-
- ## Running in IEx
-
- From the project root:
-
- cd examples/iex_counter
- iex -S mix
-
- Then in IEx:
-
- iex> IExCounter.App.run()
-
- Controls:
- - Up arrow: Increment counter
- - Down arrow: Decrement counter
- - R: Reset counter
- - Q: Quit (returns to IEx prompt)
-
- ## Running Standalone
-
- mix termui.run
-
- ## What Works in IEx
-
- - All keyboard input is received by the TUI application
- - Arrow keys work immediately (no Enter required)
- - Terminal state is restored when you quit
- - You return to the IEx prompt ready for next command
-
- ## IEx Detection
-
- In your component code, you can detect if running in IEx:
-
- if TermUI.iex_mode?() do
- # IEx-specific behavior
- end
- """
+ @moduledoc "A small counter that uses the complete TermUI public contract."
use TermUI.Elm
- alias TermUI.Event
- alias TermUI.Renderer.Style
-
- # ----------------------------------------------------------------------------
- # Component Callbacks
- # ----------------------------------------------------------------------------
+ alias TermUI.{Command, Event, Frame, Style}
@impl true
- def init(_opts) do
- %{
- count: 0,
- mode: :normal
- }
+ def init(opts) do
+ %{count: 0, dimensions: Keyword.fetch!(opts, :dimensions)}
end
@impl true
- def event_to_msg(%Event.Key{key: :up}, _state) do
- {:msg, :increment}
- end
-
- def event_to_msg(%Event.Key{key: :down}, _state) do
- {:msg, :decrement}
- end
-
- def event_to_msg(%Event.Key{key: "r"}, _state) do
- {:msg, :reset}
- end
+ def event_to_msg(%Event.Key{key: :up}, _state), do: {:msg, :increment}
+ def event_to_msg(%Event.Key{key: :down}, _state), do: {:msg, :decrement}
+ def event_to_msg(%Event.Text{text: text}, _state) when text in ["r", "R"], do: {:msg, :reset}
+ def event_to_msg(%Event.Text{text: text}, _state) when text in ["q", "Q"], do: {:msg, :quit}
- def event_to_msg(%Event.Key{key: "q"}, _state) do
- {:msg, :quit}
- end
+ def event_to_msg(%Event.Resize{width: width, height: height}, _state),
+ do: {:msg, {:resize, width, height}}
- def event_to_msg(%Event.Key{key: "Q"}, _state) do
- {:msg, :quit}
- end
-
- def event_to_msg(_, _state), do: :ignore
+ def event_to_msg(_event, _state), do: :ignore
@impl true
- def update(:increment, state) do
- {%{state | count: state.count + 1}, []}
- end
-
- def update(:decrement, state) do
- {%{state | count: state.count - 1}, []}
- end
-
- def update(:reset, state) do
- {%{state | count: 0}, []}
- end
-
- def update(:quit, state) do
- {state, [:quit]}
- end
+ def update(:increment, state), do: %{state | count: state.count + 1}
+ def update(:decrement, state), do: %{state | count: state.count - 1}
+ def update(:reset, state), do: %{state | count: 0}
+ def update(:quit, state), do: {state, [Command.shutdown()]}
+ def update({:resize, width, height}, state), do: %{state | dimensions: {width, height}}
@impl true
- def view(state) do
- mode_str = if TermUI.iex_mode?(), do: "IEx", else: "Standalone"
-
- stack(:vertical, [
- # Title
- text("IEx Counter Example", Style.new(fg: :cyan, attrs: [:bold])),
- text("", nil),
-
- # Mode indicator
- text("Running in: #{mode_str} mode", Style.new(fg: :bright_black)),
- text("", nil),
+ def view(%{count: count, dimensions: {width, height}}) do
+ title = Style.new(fg: :cyan, attrs: [:bold])
+ value = Style.new(fg: :green, attrs: [:bold])
- # Counter display
- text("Count: #{state.count}", Style.new(fg: :green, attrs: [:bold])),
- text("", nil),
+ rows = [
+ [{"TermUI counter", title}],
+ "",
+ [{"Count: #{count}", value}],
+ "",
+ "Up/Down: change R: reset Q: quit"
+ ]
- # Instructions
- text("Controls:", Style.new(fg: :yellow, attrs: [:bold])),
- text(" ↑/↓ : Increment/Decrement", nil),
- text(" R : Reset", nil),
- text(" Q : Quit to IEx prompt", nil),
- ])
+ Frame.from_rows(rows, width, height)
end
- # ----------------------------------------------------------------------------
- # Public API
- # ----------------------------------------------------------------------------
-
- @doc """
- Run the counter application.
-
- This is the main entry point for running the application.
- Use `TermUI.App.run/1` which provides the proper runtime setup.
-
- ## Examples
-
- iex> IExCounter.App.run()
- # ... interact with the TUI app ...
- # Press Q to quit, returns to IEx
- {:ok, :exited_normally}
-
- """
- def run(opts \\ []) do
- TermUI.App.run(__MODULE__, opts)
- end
+ @doc "Runs the example in the current terminal."
+ def run(opts \\ []), do: TermUI.run(__MODULE__, opts)
end
diff --git a/examples/iex_counter/mix.exs b/examples/iex_counter/mix.exs
index 8a7fc9ba..8b4dd735 100644
--- a/examples/iex_counter/mix.exs
+++ b/examples/iex_counter/mix.exs
@@ -5,7 +5,7 @@ defmodule IExCounter.MixProject do
[
app: :iex_counter,
version: "0.1.0",
- elixir: "~> 1.15",
+ elixir: ">= 1.18.4 and < 2.0.0",
start_permanent: Mix.env() == :prod,
deps: deps()
]
diff --git a/examples/iex_counter/mix.lock b/examples/iex_counter/mix.lock
index 1c3df94b..d6e198da 100644
--- a/examples/iex_counter/mix.lock
+++ b/examples/iex_counter/mix.lock
@@ -1,13 +1,10 @@
%{
- "autumn": {:hex, :autumn, "0.6.0", "56cba6145da885262ef705e6e7a83d981e1f756d629a6d0e10b79a79243b702b", [:mix], [{:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:rustler, "~> 0.29", [hex: :rustler, repo: "hexpm", optional: false]}, {:rustler_precompiled, "~> 0.6", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "d9f7bad90b462e2e3ae3ce3a6d0dcd128230fec2a276cba0af18ce26165b54ce"},
- "castore": {:hex, :castore, "1.0.17", "4f9770d2d45fbd91dcf6bd404cf64e7e58fed04fadda0923dc32acca0badffa2", [:mix], [], "hexpm", "12d24b9d80b910dd3953e165636d68f147a31db945d2dcb9365e441f8b5351e5"},
- "gen_stage": {:hex, :gen_stage, "1.3.2", "7c77e5d1e97de2c6c2f78f306f463bca64bf2f4c3cdd606affc0100b89743b7b", [:mix], [], "hexpm", "0ffae547fa777b3ed889a6b9e1e64566217413d018cabd825f786e843ffe63e7"},
- "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
- "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"},
- "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"},
- "mdex": {:hex, :mdex, "0.11.2", "7b01f784f38b0dfea92af164b8d1dae6f31f77e344da821b852be7bd8cd67484", [:mix], [{:autumn, ">= 0.6.0", [hex: :autumn, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}, {:rustler, "~> 0.32", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.7", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "3a9d3f7049be6e37793cbe533bc6eea2e4df572aca32a67a857a2e8921964c00"},
+ "elixir_make": {:hex, :elixir_make, "0.10.0", "16577e2583a79bb79237bbff349619ef5d80afffc07eac6e4faf0d00e2ddaf7d", [:mix], [], "hexpm", "dc1f09fb7fa68866b886abd5f0f3c83553b1a19a52359a899e92af1bb3b31982"},
+ "jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"},
+ "mdex": {:hex, :mdex, "0.13.5", "c1c94d230ccaab01ad0c68090d3b31613c10ece1844f32b55895da4ce0c63029", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:lumis, "~> 0.1", [hex: :lumis, repo: "hexpm", optional: true]}, {:mdex_native, ">= 0.2.6", [hex: :mdex_native, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.0 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}], "hexpm", "c57409fb6b34fbc58fbce0a6da670c9a4b5a2e94f86abdc56e9e213ed74620f2"},
+ "mdex_native": {:hex, :mdex_native, "0.2.8", "20b7cbf330c1ca81b8da4132b8d01952cded11f6dfc2abe8fef25c13681b15e4", [:mix], [{:rustler, "~> 0.32", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "004a5565b6c96a06400901eb1e4e603585e00b23262d3f595c3f4aa38b83ef66"},
"nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
"nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"},
- "rustler": {:hex, :rustler, "0.37.1", "721434020c7f6f8e1cdc57f44f75c490435b01de96384f8ccb96043f12e8a7e0", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "24547e9b8640cf00e6a2071acb710f3e12ce0346692e45098d84d45cdb54fd79"},
- "rustler_precompiled": {:hex, :rustler_precompiled, "0.8.4", "700a878312acfac79fb6c572bb8b57f5aae05fe1cf70d34b5974850bbf2c05bf", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "3b33d99b540b15f142ba47944f7a163a25069f6d608783c321029bc1ffb09514"},
+ "rustler_precompiled": {:hex, :rustler_precompiled, "0.9.0", "3a052eda09f3d2436364645cc1f13279cf95db310eb0c17b0d8f25484b233aa0", [:mix], [{:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "471d97315bd3bf7b64623418b3693eedd8e47de3d1cb79a0ac8f9da7d770d94c"},
+ "zoi": {:hex, :zoi, "0.18.7", "0d6b09d19fd1feff4340b7c5660bab04fbc80c1642ee1e5c75f06d527ac326db", [:mix], [{:decimal, "~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "5fedddd755dec84a5e78b3671070a5e595026aa3479f7fa566a42b8c4e4e5ff2"},
}
diff --git a/examples/line_chart/README.md b/examples/line_chart/README.md
deleted file mode 100644
index 425d63f1..00000000
--- a/examples/line_chart/README.md
+++ /dev/null
@@ -1,131 +0,0 @@
-# LineChart Example
-
-A demonstration of the LineChart widget for time series visualization using Braille patterns.
-
-## Widget Overview
-
-The LineChart widget renders line graphs in the terminal using Unicode Braille characters (U+2800-U+28FF), which provide 2x4 dot resolution per character cell. This enables smooth line rendering with sub-character precision, perfect for visualizing metrics, sensor data, and time series.
-
-### Key Features
-
-- Single and multi-series line charts
-- Braille patterns for smooth line rendering (2x4 dots per character)
-- Custom min/max scaling
-- Optional axis display
-- Dynamic data updates
-- Automatic scaling based on data range
-
-### When to Use
-
-Use LineChart when you need to visualize:
-- Time series data (CPU/memory usage, metrics)
-- Trends and patterns in numerical data
-- Multiple data series for comparison
-- Real-time data streams
-
-## Widget Options
-
-The LineChart widget accepts the following options in its `render/1` function:
-
-- `:data` - Single series data (list of numbers), alternative to `:series`
-- `:series` - List of series maps with `:data` and optional `:color` keys
-- `:width` - Chart width in characters (default: 40)
-- `:height` - Chart height in characters (default: 10)
-- `:min` - Minimum Y value (default: auto-calculated from data)
-- `:max` - Maximum Y value (default: auto-calculated from data)
-- `:show_axis` - Show axis lines (default: false)
-- `:style` - Style for the chart
-
-### Example Usage
-
-```elixir
-# Single series
-LineChart.render(
- data: [1, 3, 5, 2, 8],
- width: 40,
- height: 8,
- min: 0,
- max: 100,
- show_axis: true
-)
-
-# Multiple series
-LineChart.render(
- series: [
- %{data: [1, 3, 5, 2, 8], color: Style.new(fg: :cyan)},
- %{data: [2, 4, 3, 6, 4], color: Style.new(fg: :magenta)}
- ],
- width: 40,
- height: 8
-)
-```
-
-## Example Structure
-
-This example contains:
-
-- `lib/line_chart/app.ex` - Main application demonstrating the LineChart widget
- - Simulates CPU and memory usage data
- - Demonstrates single and multi-series charts
- - Shows how to update data dynamically
- - Includes Braille pattern demonstration
-
-## Running the Example
-
-### Raw Mode (Full TUI Experience)
-
-For the best experience with full terminal control and alternate screen:
-
-```bash
-cd examples/line_chart
-mix termui.run
-```
-
-Or manually:
-
-```bash
-cd examples/line_chart
-mix run -e "LineChart.App.run()" --no-halt
-```
-
-### TTY Mode (IEx Compatible)
-
-To run from IEx without taking over the shell:
-
-```bash
-cd examples/line_chart
-iex -S mix
-```
-
-Then in IEx:
-
-```elixir
-LineChart.App.run()
-```
-
-**Note:** TTY mode works inside IEx but has limitations:
-- No alternate screen buffer (output mixes with IEx prompt)
-- Character input works immediately (no Enter needed)
-- For full TUI, use raw mode instead
-
-## Controls
-
-- **Space** - Add new data point to both series (sliding window)
-- **R** - Reset/randomize data with new values
-- **A** - Toggle axis display on/off
-- **Q** - Quit the application
-
-## Features Demonstrated
-
-1. **Single Series Chart** - Shows CPU usage over time with green line
-2. **Multi-Series Chart** - Displays CPU (cyan) and memory (magenta) together
-3. **Braille Pattern Demo** - Shows various Braille characters used for rendering
-4. **Dynamic Updates** - Data can be added in real-time with sliding window
-5. **Axis Control** - Toggle axis display to see coordinate frame
-
-## Implementation Notes
-
-- Data is generated using a random walk algorithm to simulate realistic metrics
-- Each series maintains a maximum of 25 points (sliding window)
-- Values are bounded between 10 and 90 to keep them visible
-- Braille patterns provide 2x horizontal and 4x vertical resolution per character
diff --git a/examples/line_chart/lib/line_chart/app.ex b/examples/line_chart/lib/line_chart/app.ex
deleted file mode 100644
index 97c83d89..00000000
--- a/examples/line_chart/lib/line_chart/app.ex
+++ /dev/null
@@ -1,195 +0,0 @@
-defmodule LineChart.App do
- @moduledoc """
- Line Chart Widget Example
-
- This example demonstrates how to use the TermUI.Widgets.LineChart widget
- for time series visualization using Braille patterns.
-
- The line chart uses Unicode Braille characters (U+2800-U+28FF) which provide
- 2x4 dot resolution per character cell, enabling smooth line rendering.
-
- Features demonstrated:
- - Single series line chart
- - Multiple series comparison
- - Custom min/max scaling
- - Axis display
- - Dynamic data updates
-
- Controls:
- - Space: Add new data point
- - R: Reset/randomize data
- - A: Toggle axis display
- - Q: Quit the application
- """
-
- use TermUI.Elm
-
- alias TermUI.Widgets.LineChart
- alias TermUI.Event
- alias TermUI.Renderer.Style
-
- # ----------------------------------------------------------------------------
- # Component Callbacks
- # ----------------------------------------------------------------------------
-
- @doc """
- Initialize the component state.
- """
- def init(_opts) do
- %{
- # Single series data (simulating CPU usage over time)
- cpu_data: generate_random_series(20),
- # Second series (simulating memory usage)
- memory_data: generate_random_series(20),
- # Display options
- show_axis: true
- }
- end
-
- defp generate_random_series(count) do
- # Generate semi-realistic looking data with some continuity
- Enum.reduce(1..count, [], fn _, acc ->
- last = List.last(acc) || 50
- # Random walk with bounds
- delta = :rand.uniform(21) - 11
- new_value = max(10, min(90, last + delta))
- acc ++ [new_value]
- end)
- end
-
- @doc """
- Convert keyboard events to messages.
- """
- def event_to_msg(%Event.Key{key: " "}, _state), do: {:msg, :add_point}
- def event_to_msg(%Event.Key{key: key}, _state) when key in ["r", "R"], do: {:msg, :reset}
- def event_to_msg(%Event.Key{key: key}, _state) when key in ["a", "A"], do: {:msg, :toggle_axis}
- def event_to_msg(%Event.Key{key: key}, _state) when key in ["q", "Q"], do: {:msg, :quit}
- def event_to_msg(_event, _state), do: :ignore
-
- @doc """
- Update state based on messages.
- """
- def update(:add_point, state) do
- # Add a new point to both series (sliding window)
- cpu_last = List.last(state.cpu_data) || 50
- cpu_new = max(10, min(90, cpu_last + :rand.uniform(21) - 11))
- cpu_data = (state.cpu_data ++ [cpu_new]) |> Enum.take(-25)
-
- mem_last = List.last(state.memory_data) || 50
- mem_new = max(10, min(90, mem_last + :rand.uniform(15) - 8))
- memory_data = (state.memory_data ++ [mem_new]) |> Enum.take(-25)
-
- {%{state | cpu_data: cpu_data, memory_data: memory_data}, []}
- end
-
- def update(:reset, state) do
- {%{state | cpu_data: generate_random_series(20), memory_data: generate_random_series(20)}, []}
- end
-
- def update(:toggle_axis, state) do
- {%{state | show_axis: not state.show_axis}, []}
- end
-
- def update(:quit, state) do
- {state, [:quit]}
- end
-
- @doc """
- Render the current state to a render tree.
- """
- def view(state) do
- stack(:vertical, [
- # Title
- text("Line Chart Widget Example", Style.new(fg: :cyan, attrs: [:bold])),
- text("", nil),
-
- # Single series chart
- text("Single Series (CPU Usage):", nil),
- LineChart.render(
- data: state.cpu_data,
- width: 40,
- height: 8,
- min: 0,
- max: 100,
- show_axis: state.show_axis,
- style: Style.new(fg: :green)
- ),
- text("", nil),
-
- # Multi-series chart
- text("Multi Series (CPU + Memory):", nil),
- LineChart.render(
- series: [
- %{data: state.cpu_data, color: Style.new(fg: :cyan)},
- %{data: state.memory_data, color: Style.new(fg: :magenta)}
- ],
- width: 40,
- height: 8,
- min: 0,
- max: 100,
- show_axis: state.show_axis
- ),
- text(" Cyan = CPU, Magenta = Memory", nil),
- text("", nil),
-
- # Braille pattern demo
- text("Braille characters for line drawing:", nil),
- render_braille_demo(),
- text("", nil),
-
- # Controls
- render_controls(state)
- ])
- end
-
- defp render_controls(state) do
- box_width = 48
- inner_width = box_width - 2
-
- top_border = "┌─ Controls " <> String.duplicate("─", inner_width - 12) <> "─┐"
- bottom_border = "└" <> String.duplicate("─", inner_width) <> "┘"
-
- stack(:vertical, [
- text("", nil),
- text(top_border, Style.new(fg: :yellow)),
- text("│" <> String.pad_trailing(" Space Add new data point", inner_width) <> "│", nil),
- text("│" <> String.pad_trailing(" R Reset/randomize data", inner_width) <> "│", nil),
- text("│" <> String.pad_trailing(" A Toggle axis (#{if state.show_axis, do: "ON", else: "OFF"})", inner_width) <> "│", nil),
- text("│" <> String.pad_trailing(" Q Quit", inner_width) <> "│", nil),
- text("│" <> String.pad_trailing("", inner_width) <> "│", nil),
- text("│" <> String.pad_trailing(" Data points: #{length(state.cpu_data)}", inner_width) <> "│", nil),
- text(bottom_border, Style.new(fg: :yellow))
- ])
- end
-
- # ----------------------------------------------------------------------------
- # Private Helpers
- # ----------------------------------------------------------------------------
-
- defp render_braille_demo do
- # Show some example Braille patterns
- patterns = [
- LineChart.empty_braille(),
- LineChart.dots_to_braille([{0, 3}]),
- LineChart.dots_to_braille([{0, 2}]),
- LineChart.dots_to_braille([{0, 1}]),
- LineChart.dots_to_braille([{0, 0}]),
- LineChart.dots_to_braille([{0, 0}, {1, 0}]),
- LineChart.dots_to_braille([{0, 0}, {0, 1}]),
- LineChart.full_braille()
- ]
-
- text(" " <> Enum.join(patterns, " "), nil)
- end
-
- # ----------------------------------------------------------------------------
- # Public API
- # ----------------------------------------------------------------------------
-
- @doc """
- Run the line chart example application.
- """
- def run do
- TermUI.Runtime.run(root: __MODULE__)
- end
-end
diff --git a/examples/line_chart/lib/line_chart/application.ex b/examples/line_chart/lib/line_chart/application.ex
deleted file mode 100644
index db15adcb..00000000
--- a/examples/line_chart/lib/line_chart/application.ex
+++ /dev/null
@@ -1,12 +0,0 @@
-defmodule LineChart.Application do
- @moduledoc false
-
- use Application
-
- @impl true
- def start(_type, _args) do
- children = []
- opts = [strategy: :one_for_one, name: LineChart.Supervisor]
- Supervisor.start_link(children, opts)
- end
-end
diff --git a/examples/line_chart/mix.exs b/examples/line_chart/mix.exs
deleted file mode 100644
index df554a4a..00000000
--- a/examples/line_chart/mix.exs
+++ /dev/null
@@ -1,26 +0,0 @@
-defmodule LineChart.MixProject do
- use Mix.Project
-
- def project do
- [
- app: :line_chart,
- version: "0.1.0",
- elixir: "~> 1.15",
- start_permanent: Mix.env() == :prod,
- deps: deps()
- ]
- end
-
- def application do
- [
- extra_applications: [:logger],
- mod: {LineChart.Application, []}
- ]
- end
-
- defp deps do
- [
- {:term_ui, path: "../.."}
- ]
- end
-end
diff --git a/examples/line_chart/mix.lock b/examples/line_chart/mix.lock
deleted file mode 100644
index ee8761c0..00000000
--- a/examples/line_chart/mix.lock
+++ /dev/null
@@ -1,12 +0,0 @@
-%{
- "castore": {:hex, :castore, "1.0.17", "4f9770d2d45fbd91dcf6bd404cf64e7e58fed04fadda0923dc32acca0badffa2", [:mix], [], "hexpm", "12d24b9d80b910dd3953e165636d68f147a31db945d2dcb9365e441f8b5351e5"},
- "gen_stage": {:hex, :gen_stage, "1.3.2", "7c77e5d1e97de2c6c2f78f306f463bca64bf2f4c3cdd606affc0100b89743b7b", [:mix], [], "hexpm", "0ffae547fa777b3ed889a6b9e1e64566217413d018cabd825f786e843ffe63e7"},
- "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
- "lumis": {:hex, :lumis, "0.1.0", "6d3b495457d0608f8fe32fe6fa917eb17c921bd0087583a7f4c420c0009888fd", [:mix], [{:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:rustler, "~> 0.29", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.6", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "4de203bc5811ad21f0c6b0442612a3fc1ae9bea7fbfe7037452db9e9dfdaaab3"},
- "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"},
- "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"},
- "mdex": {:hex, :mdex, "0.11.3", "7b815ae2f474e62ad365dfa4d9df87ddec6a01cfa9b7db523a3b21061d909225", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:lumis, "~> 0.1", [hex: :lumis, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}, {:rustler, "~> 0.32", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.7", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "181bf04b13dcae20ab9f336115facc5096aae333a2408a03e53338ee65d4f564"},
- "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
- "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"},
- "rustler_precompiled": {:hex, :rustler_precompiled, "0.8.4", "700a878312acfac79fb6c572bb8b57f5aae05fe1cf70d34b5974850bbf2c05bf", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "3b33d99b540b15f142ba47944f7a163a25069f6d608783c321029bc1ffb09514"},
-}
diff --git a/examples/line_chart/run.exs b/examples/line_chart/run.exs
deleted file mode 100644
index ced896a9..00000000
--- a/examples/line_chart/run.exs
+++ /dev/null
@@ -1 +0,0 @@
-LineChart.App.run()
diff --git a/examples/log_viewer/README.md b/examples/log_viewer/README.md
deleted file mode 100644
index e583664f..00000000
--- a/examples/log_viewer/README.md
+++ /dev/null
@@ -1,170 +0,0 @@
-# LogViewer Example
-
-A demonstration of the LogViewer widget for displaying and analyzing log data with virtual scrolling.
-
-## Widget Overview
-
-The LogViewer widget efficiently displays large log files (millions of lines) using virtual scrolling. It provides powerful features for searching, filtering, and analyzing logs in real-time.
-
-### Key Features
-
-- Virtual scrolling for efficient rendering of large datasets
-- Tail mode for live log monitoring
-- Search with regex support and match highlighting
-- Syntax highlighting for log levels and timestamps
-- Filtering by pattern with regex support
-- Line bookmarking for marking important entries
-- Selection for copy operations
-- Wrap/truncate toggle for long lines
-- Automatic log parsing (timestamp, level, source)
-
-### When to Use
-
-Use LogViewer when you need to:
-- Monitor application logs in real-time
-- Search through large log files efficiently
-- Debug issues by filtering specific patterns
-- Track important log entries with bookmarks
-- Analyze log levels and patterns
-
-## Widget Options
-
-The LogViewer widget accepts the following options in its `new/1` function:
-
-- `:lines` - Initial log lines (strings or log entries)
-- `:max_lines` - Maximum lines to keep in buffer (default: 100,000)
-- `:tail_mode` - Auto-scroll to new lines (default: true)
-- `:wrap_lines` - Wrap long lines instead of truncating (default: false)
-- `:show_line_numbers` - Display line numbers (default: true)
-- `:show_timestamps` - Display timestamps column (default: false)
-- `:show_levels` - Display level column (default: true)
-- `:highlight_levels` - Color-code by level (default: true)
-- `:on_select` - Callback when lines are selected
-- `:on_copy` - Callback when copy is requested
-- `:parser` - Custom log parser function
-
-### Example Usage
-
-```elixir
-LogViewer.new(
- lines: log_lines,
- tail_mode: true,
- highlight_levels: true,
- show_line_numbers: true,
- max_lines: 10_000
-)
-```
-
-## Example Structure
-
-This example contains:
-
-- `lib/log_viewer/app.ex` - Main application demonstrating the LogViewer widget
- - Generates simulated log entries from multiple modules
- - Demonstrates various log levels (debug, info, warning, error)
- - Shows dynamic log addition and clearing
- - Integrates all LogViewer features
-
-## Running the Example
-
-### Raw Mode (Full TUI Experience)
-
-For the best experience with full terminal control and alternate screen:
-
-```bash
-cd examples/log_viewer
-mix termui.run
-```
-
-Or manually:
-
-```bash
-cd examples/log_viewer
-mix run -e "LogViewer.App.run()" --no-halt
-```
-
-### TTY Mode (IEx Compatible)
-
-To run from IEx without taking over the shell:
-
-```bash
-cd examples/log_viewer
-iex -S mix
-```
-
-Then in IEx:
-
-```elixir
-LogViewer.App.run()
-```
-
-**Note:** TTY mode works inside IEx but has limitations:
-- No alternate screen buffer (output mixes with IEx prompt)
-- Character input works immediately (no Enter needed)
-- For full TUI, use raw mode instead
-
-## Controls
-
-### Navigation
-- **Up/Down** - Navigate between lines
-- **PageUp/PageDown** - Scroll by page (20 lines)
-- **Home/End** - Jump to first/last line
-
-### Search
-- **/** - Start search (supports regex)
-- **n/N** - Next/previous search match
-- **Escape** - Clear search
-
-### Filtering
-- **f** - Toggle filter mode (or start filter input)
-- **Escape** - Clear filter
-
-### Bookmarks
-- **b** - Toggle bookmark on current line
-- **B** - Jump to next bookmark
-
-### Display Modes
-- **t** - Toggle tail mode (auto-scroll to new entries)
-- **w** - Toggle wrap mode (wrap vs truncate long lines)
-
-### Selection
-- **Space** - Start or extend selection
-- **Escape** - Clear selection
-
-### Data Management
-- **A** - Add 5 simulated log entries
-- **C** - Clear all logs
-- **Q** - Quit the application
-
-## Features Demonstrated
-
-1. **Automatic Parsing** - Extracts timestamps, log levels, and module names
-2. **Level Highlighting** - Color codes by severity (debug=cyan, info=green, warning=yellow, error=red)
-3. **Virtual Scrolling** - Efficiently renders only visible lines
-4. **Search & Highlight** - Find patterns with regex and highlight matches
-5. **Filtering** - Show only lines matching a pattern
-6. **Tail Mode** - Automatically scrolls to new entries
-7. **Bookmarks** - Mark and jump between important lines
-8. **Status Bar** - Shows current line, filter status, search results
-
-## Log Format
-
-The example generates logs in this format:
-
-```
-2024-01-15T10:30:45.123Z [MyApp.Server] INFO: Request processed successfully
-```
-
-The parser automatically extracts:
-- Timestamp (ISO8601 format)
-- Source module (in brackets)
-- Log level (DEBUG, INFO, WARNING, ERROR)
-- Message text
-
-## Implementation Notes
-
-- Initial dataset contains 50 log entries
-- Each "Add logs" action adds 5 new entries
-- Logs are kept in a circular buffer (max 10,000 lines by default)
-- Virtual scrolling renders only visible lines for performance
-- Search and filter use regex patterns (case-insensitive)
diff --git a/examples/log_viewer/lib/log_viewer/app.ex b/examples/log_viewer/lib/log_viewer/app.ex
deleted file mode 100644
index 22007473..00000000
--- a/examples/log_viewer/lib/log_viewer/app.ex
+++ /dev/null
@@ -1,287 +0,0 @@
-defmodule LogViewer.App do
- @moduledoc """
- LogViewer Widget Example
-
- This example demonstrates how to use the TermUI.Widgets.LogViewer widget
- for displaying and analyzing log data with virtual scrolling.
-
- Features demonstrated:
- - Virtual scrolling for large log datasets
- - Tail mode for live log monitoring
- - Search with regex support
- - Syntax highlighting for log levels
- - Filtering by pattern
- - Line bookmarking
- - Selection for copy operations
- - Wrap/truncate toggle
-
- Controls:
- - Up/Down: Navigate between lines
- - PageUp/PageDown: Scroll by page
- - Home/End: Jump to first/last line
- - /: Start search
- - n/N: Next/previous search match
- - f: Toggle filter mode
- - b: Toggle bookmark on current line
- - B: Jump to next bookmark
- - t: Toggle tail mode
- - w: Toggle wrap mode
- - Space: Start/extend selection
- - Escape: Clear search/filter/selection
- - A: Add simulated log entries
- - C: Clear all logs
- - Q: Quit the application
- """
-
- use TermUI.Elm
-
- alias TermUI.Event
- alias TermUI.Renderer.Style
- alias TermUI.Widgets.LogViewer, as: LV
-
- @modules ["MyApp.Server", "MyApp.Handler", "MyApp.Database", "MyApp.Cache", "MyApp.Auth"]
- @levels [:debug, :info, :warning, :error]
- @messages [
- "Request processed successfully",
- "Connection established",
- "Cache hit for key: user_123",
- "Slow query detected: 250ms",
- "Authentication failed for user",
- "Database connection pool at 80%",
- "Memory usage: 512MB",
- "Rate limit exceeded",
- "Session expired",
- "Config reloaded"
- ]
-
- # ----------------------------------------------------------------------------
- # Component Callbacks
- # ----------------------------------------------------------------------------
-
- @doc """
- Initialize the component state.
- """
- def init(_opts) do
- initial_logs = generate_initial_logs(50)
-
- %{
- log_state: nil,
- initial_logs: initial_logs,
- log_counter: 50,
- status_message: "Use / to search, f to filter, t for tail mode"
- }
- end
-
- defp build_log_state(logs) do
- props =
- LV.new(
- lines: logs,
- tail_mode: true,
- highlight_levels: true,
- show_line_numbers: true,
- show_levels: true,
- max_lines: 10_000
- )
-
- {:ok, state} = LV.init(props)
- state
- end
-
- defp generate_initial_logs(count) do
- base_time = DateTime.utc_now()
-
- for i <- 0..(count - 1) do
- generate_log_line(base_time, i)
- end
- end
-
- defp generate_log_line(base_time, offset) do
- timestamp = DateTime.add(base_time, offset, :second)
- module = Enum.random(@modules)
- level = Enum.random(@levels)
- message = Enum.random(@messages)
-
- level_str = level |> Atom.to_string() |> String.upcase()
- ts_str = DateTime.to_iso8601(timestamp)
-
- "#{ts_str} [#{module}] #{level_str}: #{message}"
- end
-
- @doc """
- Convert keyboard events to messages.
- """
- def event_to_msg(%Event.Key{key: key}, _state) when key in ["q", "Q"], do: {:msg, :quit}
- def event_to_msg(%Event.Key{key: key}, _state) when key in ["a", "A"], do: {:msg, :add_logs}
- def event_to_msg(%Event.Key{key: key}, _state) when key in ["c", "C"], do: {:msg, :clear_logs}
-
- def event_to_msg(event, _state) do
- {:msg, {:log_event, event}}
- end
-
- @doc """
- Update state based on messages.
- """
- def update(:quit, state) do
- {state, [:quit]}
- end
-
- def update(:add_logs, state) do
- log_state = ensure_log_state(state)
- base_time = DateTime.utc_now()
-
- new_logs =
- for i <- 0..4 do
- generate_log_line(base_time, state.log_counter + i)
- end
-
- log_state = LV.add_lines(log_state, new_logs)
- message = "Added 5 log entries (total: #{LV.line_count(log_state)})"
-
- {%{state | log_state: log_state, log_counter: state.log_counter + 5, status_message: message}, []}
- end
-
- def update(:clear_logs, state) do
- log_state = ensure_log_state(state)
- log_state = LV.clear(log_state)
- {%{state | log_state: log_state, log_counter: 0, status_message: "Logs cleared"}, []}
- end
-
- def update({:log_event, event}, state) do
- log_state = ensure_log_state(state)
- {:ok, log_state} = LV.handle_event(event, log_state)
-
- message = get_status_message(log_state)
- {%{state | log_state: log_state, status_message: message}, []}
- end
-
- defp ensure_log_state(state) do
- state.log_state || build_log_state(state.initial_logs)
- end
-
- defp get_status_message(log_state) do
- parts = []
-
- parts =
- if log_state.search do
- match_count = length(log_state.search.matches)
- current = log_state.search.current_match + 1
- parts ++ ["Search: #{current}/#{match_count}"]
- else
- parts
- end
-
- parts =
- if log_state.filter do
- visible = LV.visible_line_count(log_state)
- total = LV.line_count(log_state)
- parts ++ ["Filtered: #{visible}/#{total}"]
- else
- parts
- end
-
- parts =
- if MapSet.size(log_state.bookmarks) > 0 do
- parts ++ ["Bookmarks: #{MapSet.size(log_state.bookmarks)}"]
- else
- parts
- end
-
- parts =
- if log_state.tail_mode do
- parts ++ ["TAIL"]
- else
- parts
- end
-
- if length(parts) > 0 do
- Enum.join(parts, " | ")
- else
- "Use / to search, f to filter, t for tail mode"
- end
- end
-
- @doc """
- Render the current state to a render tree.
- """
- def view(state) do
- log_state = ensure_log_state(state)
-
- stack(:vertical, [
- # Title
- text("LogViewer Widget Example", Style.new(fg: :cyan, attrs: [:bold])),
- text("", nil),
-
- # Log viewer
- render_log_container(log_state),
-
- # Status
- text("", nil),
- text(state.status_message, Style.new(fg: :yellow)),
-
- # Controls
- render_controls(log_state)
- ])
- end
-
- defp render_log_container(log_state) do
- log_render = LV.render(log_state, %{x: 0, y: 0, width: 75, height: 15})
-
- box_width = 77
- inner_width = box_width - 2
-
- line_info = "Lines: #{LV.line_count(log_state)}"
- top_border = "+" <> String.duplicate("-", 3) <> " Log Output " <> String.duplicate("-", inner_width - 16) <> " #{line_info} " <> "+"
- bottom_border = "+" <> String.duplicate("-", inner_width) <> "+"
-
- stack(:vertical, [
- text(top_border, Style.new(fg: :blue)),
- stack(:horizontal, [
- text("| ", nil),
- log_render,
- text(" |", nil)
- ]),
- text(bottom_border, Style.new(fg: :blue))
- ])
- end
-
- defp render_controls(log_state) do
- box_width = 60
- inner_width = box_width - 2
-
- tail_str = if log_state.tail_mode, do: "ON", else: "OFF"
- wrap_str = if log_state.wrap_lines, do: "ON", else: "OFF"
-
- top_border = "+" <> String.duplicate("-", inner_width - 10) <> " Controls " <> "+"
- bottom_border = "+" <> String.duplicate("-", inner_width) <> "+"
-
- stack(:vertical, [
- text("", nil),
- text(top_border, Style.new(fg: :yellow)),
- text("|" <> String.pad_trailing(" Up/Down Navigate lines", inner_width) <> "|", nil),
- text("|" <> String.pad_trailing(" PgUp/PgDn Scroll by page", inner_width) <> "|", nil),
- text("|" <> String.pad_trailing(" Home/End First/last line", inner_width) <> "|", nil),
- text("|" <> String.pad_trailing(" / Start search", inner_width) <> "|", nil),
- text("|" <> String.pad_trailing(" n/N Next/prev match", inner_width) <> "|", nil),
- text("|" <> String.pad_trailing(" f Toggle filter", inner_width) <> "|", nil),
- text("|" <> String.pad_trailing(" b/B Bookmark / Jump to next", inner_width) <> "|", nil),
- text("|" <> String.pad_trailing(" t Toggle tail mode (#{tail_str})", inner_width) <> "|", nil),
- text("|" <> String.pad_trailing(" w Toggle wrap (#{wrap_str})", inner_width) <> "|", nil),
- text("|" <> String.pad_trailing(" Space Start/extend selection", inner_width) <> "|", nil),
- text("|" <> String.pad_trailing(" A/C Add logs / Clear logs", inner_width) <> "|", nil),
- text("|" <> String.pad_trailing(" Escape Clear search/filter/selection", inner_width) <> "|", nil),
- text("|" <> String.pad_trailing(" Q Quit", inner_width) <> "|", nil),
- text(bottom_border, Style.new(fg: :yellow))
- ])
- end
-
- # ----------------------------------------------------------------------------
- # Public API
- # ----------------------------------------------------------------------------
-
- @doc """
- Run the log viewer example application.
- """
- def run do
- TermUI.Runtime.run(root: __MODULE__)
- end
-end
diff --git a/examples/log_viewer/lib/log_viewer/application.ex b/examples/log_viewer/lib/log_viewer/application.ex
deleted file mode 100644
index 8be2dcf1..00000000
--- a/examples/log_viewer/lib/log_viewer/application.ex
+++ /dev/null
@@ -1,12 +0,0 @@
-defmodule LogViewer.Application do
- @moduledoc false
-
- use Application
-
- @impl true
- def start(_type, _args) do
- children = []
- opts = [strategy: :one_for_one, name: LogViewer.Supervisor]
- Supervisor.start_link(children, opts)
- end
-end
diff --git a/examples/log_viewer/mix.exs b/examples/log_viewer/mix.exs
deleted file mode 100644
index 2713d00c..00000000
--- a/examples/log_viewer/mix.exs
+++ /dev/null
@@ -1,26 +0,0 @@
-defmodule LogViewer.MixProject do
- use Mix.Project
-
- def project do
- [
- app: :log_viewer,
- version: "0.1.0",
- elixir: "~> 1.15",
- start_permanent: Mix.env() == :prod,
- deps: deps()
- ]
- end
-
- def application do
- [
- extra_applications: [:logger],
- mod: {LogViewer.Application, []}
- ]
- end
-
- defp deps do
- [
- {:term_ui, path: "../.."}
- ]
- end
-end
diff --git a/examples/log_viewer/mix.lock b/examples/log_viewer/mix.lock
deleted file mode 100644
index ee8761c0..00000000
--- a/examples/log_viewer/mix.lock
+++ /dev/null
@@ -1,12 +0,0 @@
-%{
- "castore": {:hex, :castore, "1.0.17", "4f9770d2d45fbd91dcf6bd404cf64e7e58fed04fadda0923dc32acca0badffa2", [:mix], [], "hexpm", "12d24b9d80b910dd3953e165636d68f147a31db945d2dcb9365e441f8b5351e5"},
- "gen_stage": {:hex, :gen_stage, "1.3.2", "7c77e5d1e97de2c6c2f78f306f463bca64bf2f4c3cdd606affc0100b89743b7b", [:mix], [], "hexpm", "0ffae547fa777b3ed889a6b9e1e64566217413d018cabd825f786e843ffe63e7"},
- "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
- "lumis": {:hex, :lumis, "0.1.0", "6d3b495457d0608f8fe32fe6fa917eb17c921bd0087583a7f4c420c0009888fd", [:mix], [{:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:rustler, "~> 0.29", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.6", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "4de203bc5811ad21f0c6b0442612a3fc1ae9bea7fbfe7037452db9e9dfdaaab3"},
- "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"},
- "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"},
- "mdex": {:hex, :mdex, "0.11.3", "7b815ae2f474e62ad365dfa4d9df87ddec6a01cfa9b7db523a3b21061d909225", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:lumis, "~> 0.1", [hex: :lumis, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}, {:rustler, "~> 0.32", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.7", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "181bf04b13dcae20ab9f336115facc5096aae333a2408a03e53338ee65d4f564"},
- "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
- "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"},
- "rustler_precompiled": {:hex, :rustler_precompiled, "0.8.4", "700a878312acfac79fb6c572bb8b57f5aae05fe1cf70d34b5974850bbf2c05bf", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "3b33d99b540b15f142ba47944f7a163a25069f6d608783c321029bc1ffb09514"},
-}
diff --git a/examples/log_viewer/run.exs b/examples/log_viewer/run.exs
deleted file mode 100644
index d0aa571b..00000000
--- a/examples/log_viewer/run.exs
+++ /dev/null
@@ -1 +0,0 @@
-LogViewer.App.run()
diff --git a/examples/markdown_viewer/README.md b/examples/markdown_viewer/README.md
deleted file mode 100644
index 9fcdc0c2..00000000
--- a/examples/markdown_viewer/README.md
+++ /dev/null
@@ -1,52 +0,0 @@
-# Markdown Viewer Example
-
-Demonstration of the `TermUI.Widgets.MarkdownViewer` widget.
-
-## Running the Example
-
-### Raw Mode (Full TUI Experience)
-
-For the best experience with full terminal control and alternate screen:
-
-```bash
-cd examples/markdown_viewer
-mix termui.run
-```
-
-Or manually:
-
-```bash
-cd examples/markdown_viewer
-mix run -e "MarkdownViewer.App.run()" --no-halt
-```
-
-### TTY Mode (IEx Compatible)
-
-To run from IEx without taking over the shell:
-
-```bash
-cd examples/markdown_viewer
-iex -S mix
-```
-
-Then in IEx:
-
-```elixir
-MarkdownViewer.App.run()
-```
-
-**Note:** TTY mode works inside IEx but has limitations:
-- No alternate screen buffer (output mixes with IEx prompt)
-- Character input works immediately (no Enter needed)
-- For full TUI, use raw mode instead
-
-## Controls
-
-| Key | Action |
-|-----|--------|
-| `↑` / `↓` | Scroll up/down |
-| `Page Up` / `Page Down` | Scroll by page |
-| `Home` / `End` | Jump to top/bottom |
-| `Tab` | Cycle focus through code blocks |
-| `Enter` / `c` | Copy focused code block |
-| `Q` | Quit |
diff --git a/examples/markdown_viewer/lib/markdown_viewer/app.ex b/examples/markdown_viewer/lib/markdown_viewer/app.ex
deleted file mode 100644
index 8873a524..00000000
--- a/examples/markdown_viewer/lib/markdown_viewer/app.ex
+++ /dev/null
@@ -1,300 +0,0 @@
-defmodule MarkdownViewer.App do
- @moduledoc """
- Markdown Viewer Widget Example
-
- Demonstrates the TermUI.Widgets.MarkdownViewer widget.
- """
-
- use TermUI.Elm
-
- alias TermUI.Event
- alias TermUI.Renderer.Style
- alias TermUI.Widgets.MarkdownViewer
-
- @sample_markdown """
- # Markdown Viewer Demo
-
- Welcome to the **Markdown Viewer** widget demonstration. This component
- renders *markdown* content with `syntax highlighting` for code blocks.
-
- ## Features
-
- - Full CommonMark support via MDEx
- - Syntax highlighting for Elixir and Erlang
- - Keyboard navigation and scrolling
- - Focusable code blocks with copy support
-
- ## Code Examples
-
- ### Pattern Matching
-
- ```elixir
- defmodule Calculator do
- def compute({:add, a, b}), do: a + b
- def compute({:subtract, a, b}), do: a - b
- def compute({:multiply, a, b}), do: a * b
- def compute({:divide, _a, 0}), do: {:error, :division_by_zero}
- def compute({:divide, a, b}), do: a / b
- end
-
- # Usage
- Calculator.compute({:add, 10, 5})
- Calculator.compute({:multiply, 3, 7})
- ```
-
- ### Working with GenServer
-
- ```elixir
- defmodule KeyValueStore do
- use GenServer
-
- # Client API
- def start_link(opts \\\\ []) do
- GenServer.start_link(__MODULE__, opts, name: __MODULE__)
- end
-
- def get(key) do
- GenServer.call(__MODULE__, {:get, key})
- end
-
- def put(key, value) do
- GenServer.cast(__MODULE__, {:put, key, value})
- end
-
- # Server Callbacks
- @impl true
- def init(_opts) do
- {:ok, %{}}
- end
-
- @impl true
- def handle_call({:get, key}, _from, state) do
- {:reply, Map.get(state, key), state}
- end
-
- @impl true
- def handle_cast({:put, key, value}, state) do
- {:noreply, Map.put(state, key, value)}
- end
- end
- ```
-
- ### Enum and List Comprehensions
-
- ```elixir
- # Get all even numbers from 1 to 100
- evens = for n <- 1..100, rem(n, 2) == 0, do: n
-
- # Parse a list of strings into integers
- numbers = ["1", "42", "7", "100"]
- parsed = for str <- numbers, into: [] do
- String.to_integer(str)
- end
-
- # Filter and map in one pass
- squared_evens =
- 1..20
- |> Enum.filter(&(rem(&1, 2) == 0))
- |> Enum.map(&(&1 * &1))
-
- # Using Enum.reduce
- sum = Enum.reduce(1..10, 0, fn i, acc -> acc + i end)
- ```
-
- ### Structs and Protocols
-
- ```elixir
- defmodule User do
- @type t :: %__MODULE__{
- name: String.t(),
- age: pos_integer(),
- email: String.t()
- }
-
- defstruct [:name, :age, :email]
-
- def new(name, age, email) do
- %__MODULE__{
- name: name,
- age: age,
- email: email
- }
- end
- end
-
- # Pattern matching on structs
- def is_adult?(%User{age: age}) when age >= 18, do: true
- def is_adult?(%User{}), do: false
- ```
-
- ### Erlang Example
-
- ```erlang
- -module(sorter).
- -export([quicksort/1]).
-
- %% QuickSort implementation in Erlang
- quicksort([]) -> [];
- quicksort([Pivot | Rest]) ->
- {Smaller, Larger} = partition(Pivot, Rest, [], []),
- quicksort(Smaller) ++ [Pivot] ++ quicksort(Larger).
-
- partition(_Pivot, [], Smaller, Larger) ->
- {Smaller, Larger};
- partition(Pivot, [H | T], Smaller, Larger) when H =< Pivot ->
- partition(Pivot, T, [H | Smaller], Larger);
- partition(Pivot, [H | T], Smaller, Larger) ->
- partition(Pivot, T, Smaller, [H | Larger]).
- ```
-
- ## Text Styling
-
- You can use **bold text**, *italic text*, or `inline code`.
- Links are also supported: [TermUI](https://github.com/pcharbon70/term_ui)
-
- ## Lists
-
- ### Unordered List
-
- - First item
- - Second item with **bold**
- - Third item with `code`
-
- ### Ordered List
-
- 1. First step
- 2. Second step
- 3. Third step
-
- ## Blockquotes
-
- > The best way to predict the future is to invent it.
- > — Alan Kay
-
- ---
-
- Enjoy using the Markdown Viewer!
- """
-
- def init(_opts) do
- props = MarkdownViewer.new(
- content: @sample_markdown,
- width: 76,
- height: 20
- )
-
- {:ok, viewer_state} = MarkdownViewer.init(props)
-
- %{
- viewer_state: viewer_state,
- scroll_pos: 0,
- content_height: viewer_state.content_height
- }
- end
-
- def event_to_msg(%Event.Key{key: :up}, _state), do: {:msg, :scroll_up}
- def event_to_msg(%Event.Key{key: :down}, _state), do: {:msg, :scroll_down}
- def event_to_msg(%Event.Key{key: :page_up}, _state), do: {:msg, :page_up}
- def event_to_msg(%Event.Key{key: :page_down}, _state), do: {:msg, :page_down}
- def event_to_msg(%Event.Key{key: :home}, _state), do: {:msg, :scroll_top}
- def event_to_msg(%Event.Key{key: :end}, _state), do: {:msg, :scroll_bottom}
- def event_to_msg(%Event.Key{key: :tab, modifiers: []}, _state), do: {:msg, :next_code_block}
- def event_to_msg(%Event.Key{key: :tab, modifiers: [:shift]}, _state), do: {:msg, :prev_code_block}
- def event_to_msg(%Event.Key{key: :enter}, _state), do: {:msg, :copy_code}
- def event_to_msg(%Event.Key{char: ?c}, _state), do: {:msg, :copy_code}
- def event_to_msg(%Event.Key{key: key}, _state) when key in ["q", "Q"], do: {:msg, :quit}
- def event_to_msg(_event, _state), do: :ignore
-
- def update(:scroll_up, state) do
- {:ok, new_viewer} = MarkdownViewer.handle_event(%Event.Key{key: :up}, state.viewer_state)
- {update_scroll_info(%{state | viewer_state: new_viewer}), []}
- end
-
- def update(:scroll_down, state) do
- {:ok, new_viewer} = MarkdownViewer.handle_event(%Event.Key{key: :down}, state.viewer_state)
- {update_scroll_info(%{state | viewer_state: new_viewer}), []}
- end
-
- def update(:page_up, state) do
- {:ok, new_viewer} = MarkdownViewer.handle_event(%Event.Key{key: :page_up}, state.viewer_state)
- {update_scroll_info(%{state | viewer_state: new_viewer}), []}
- end
-
- def update(:page_down, state) do
- {:ok, new_viewer} = MarkdownViewer.handle_event(%Event.Key{key: :page_down}, state.viewer_state)
- {update_scroll_info(%{state | viewer_state: new_viewer}), []}
- end
-
- def update(:scroll_top, state) do
- {:ok, new_viewer} = MarkdownViewer.handle_event(%Event.Key{key: :home}, state.viewer_state)
- {update_scroll_info(%{state | viewer_state: new_viewer}), []}
- end
-
- def update(:scroll_bottom, state) do
- {:ok, new_viewer} = MarkdownViewer.handle_event(%Event.Key{key: :end}, state.viewer_state)
- {update_scroll_info(%{state | viewer_state: new_viewer}), []}
- end
-
- def update(:next_code_block, state) do
- {:ok, new_viewer} = MarkdownViewer.handle_event(%Event.Key{key: :tab}, state.viewer_state)
- {update_scroll_info(%{state | viewer_state: new_viewer}), []}
- end
-
- def update(:prev_code_block, state) do
- {:ok, new_viewer} = MarkdownViewer.handle_event(%Event.Key{key: :tab, modifiers: [:shift]}, state.viewer_state)
- {update_scroll_info(%{state | viewer_state: new_viewer}), []}
- end
-
- def update(:copy_code, state) do
- {:ok, new_viewer} = MarkdownViewer.handle_event(%Event.Key{key: :enter}, state.viewer_state)
- {update_scroll_info(%{state | viewer_state: new_viewer}), []}
- end
-
- def update(:quit, state) do
- {state, [:quit]}
- end
-
- defp update_scroll_info(state) do
- scroll_y = state.viewer_state.scroll_y
- content_height = state.viewer_state.content_height
- %{state | scroll_pos: scroll_y, content_height: content_height}
- end
-
- def view(state) do
- stack(:vertical, [
- render_title_bar(),
- MarkdownViewer.render(state.viewer_state, %{width: 76, height: 20}),
- render_status_bar(state)
- ])
- end
-
- defp render_title_bar do
- title = " Markdown Viewer Demo "
- padding = String.duplicate("─", div(76 - String.length(title), 2))
- text(padding <> title <> padding, Style.new(fg: :cyan, attrs: [:bold]))
- end
-
- defp render_status_bar(state) do
- scroll_text =
- if state.content_height > 20 do
- pct = min(100, round(state.scroll_pos / max(1, state.content_height - 20) * 100))
- "Line: #{state.scroll_pos + 1}/#{state.content_height} (#{pct}%)"
- else
- "Line: #{state.scroll_pos + 1}/#{state.content_height}"
- end
-
- help = "↑↓:Scroll | PgUp/Dn:Page | Home/End:Top/Bot | Tab:Code | Enter/c:Copy | Q:Quit"
- left_pad = String.pad_trailing(" " <> scroll_text, 54)
- right = " " <> help
- text(left_pad <> right, Style.new(fg: :bright_black))
- end
-
- def run do
- TermUI.Runtime.run(
- root: __MODULE__,
- fps: 60,
- mouse: true,
- title: "Markdown Viewer Demo"
- )
- end
-end
diff --git a/examples/markdown_viewer/lib/markdown_viewer/application.ex b/examples/markdown_viewer/lib/markdown_viewer/application.ex
deleted file mode 100644
index 98db6271..00000000
--- a/examples/markdown_viewer/lib/markdown_viewer/application.ex
+++ /dev/null
@@ -1,12 +0,0 @@
-defmodule MarkdownViewer.Application do
- @moduledoc false
-
- use Application
-
- @impl true
- def start(_type, _args) do
- children = []
- opts = [strategy: :one_for_one, name: MarkdownViewer.Supervisor]
- Supervisor.start_link(children, opts)
- end
-end
diff --git a/examples/markdown_viewer/mix.exs b/examples/markdown_viewer/mix.exs
deleted file mode 100644
index b17c53d1..00000000
--- a/examples/markdown_viewer/mix.exs
+++ /dev/null
@@ -1,26 +0,0 @@
-defmodule MarkdownViewer.MixProject do
- use Mix.Project
-
- def project do
- [
- app: :markdown_viewer,
- version: "0.1.0",
- elixir: "~> 1.15",
- start_permanent: Mix.env() == :prod,
- deps: deps()
- ]
- end
-
- def application do
- [
- extra_applications: [:logger],
- mod: {MarkdownViewer.Application, []}
- ]
- end
-
- defp deps do
- [
- {:term_ui, path: "../.."}
- ]
- end
-end
diff --git a/examples/markdown_viewer/mix.lock b/examples/markdown_viewer/mix.lock
deleted file mode 100644
index fc785621..00000000
--- a/examples/markdown_viewer/mix.lock
+++ /dev/null
@@ -1,14 +0,0 @@
-%{
- "autumn": {:hex, :autumn, "0.5.7", "f6bfdc30d3f8d5e82ba5648489db7a7b6b7479d7be07a8288d4db2437434e26d", [:mix], [{:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:rustler, "~> 0.29", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.6", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "d272bfddeeea863420a8eb994d42af219ca5391191dd765bf045fbacf56a28d1"},
- "castore": {:hex, :castore, "1.0.17", "4f9770d2d45fbd91dcf6bd404cf64e7e58fed04fadda0923dc32acca0badffa2", [:mix], [], "hexpm", "12d24b9d80b910dd3953e165636d68f147a31db945d2dcb9365e441f8b5351e5"},
- "gen_stage": {:hex, :gen_stage, "1.3.2", "7c77e5d1e97de2c6c2f78f306f463bca64bf2f4c3cdd606affc0100b89743b7b", [:mix], [], "hexpm", "0ffae547fa777b3ed889a6b9e1e64566217413d018cabd825f786e843ffe63e7"},
- "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
- "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"},
- "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"},
- "mdex": {:hex, :mdex, "0.10.0", "eae4d3bd4c0b77d6d959146a2d6faaec045686548ad1468630130095dbd93def", [:mix], [{:autumn, ">= 0.5.4", [hex: :autumn, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:rustler, "~> 0.32", [hex: :rustler, repo: "hexpm", optional: false]}, {:rustler_precompiled, "~> 0.7", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "6ad76e32056c44027fe985da7da506e033b07037896d1f130f7d5c332b0d0ac0"},
- "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
- "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"},
- "rustler": {:hex, :rustler, "0.37.1", "721434020c7f6f8e1cdc57f44f75c490435b01de96384f8ccb96043f12e8a7e0", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "24547e9b8640cf00e6a2071acb710f3e12ce0346692e45098d84d45cdb54fd79"},
- "rustler_precompiled": {:hex, :rustler_precompiled, "0.8.4", "700a878312acfac79fb6c572bb8b57f5aae05fe1cf70d34b5974850bbf2c05bf", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "3b33d99b540b15f142ba47944f7a163a25069f6d608783c321029bc1ffb09514"},
- "term_ui": {:path, "../.."},
-}
diff --git a/examples/markdown_viewer/run.exs b/examples/markdown_viewer/run.exs
deleted file mode 100644
index b6c67d72..00000000
--- a/examples/markdown_viewer/run.exs
+++ /dev/null
@@ -1 +0,0 @@
-MarkdownViewer.App.run()
diff --git a/examples/menu/README.md b/examples/menu/README.md
deleted file mode 100644
index d6c23e29..00000000
--- a/examples/menu/README.md
+++ /dev/null
@@ -1,174 +0,0 @@
-# Menu Example
-
-A demonstration of the Menu widget for displaying hierarchical menus with various item types.
-
-## Widget Overview
-
-The Menu widget displays a list of interactive items including actions, submenus, separators, and checkboxes. It supports keyboard navigation, shortcut display, and hierarchical organization.
-
-### Key Features
-
-- Multiple item types (actions, submenus, separators, checkboxes)
-- Keyboard navigation with arrow keys
-- Shortcut display (e.g., "Ctrl+N")
-- Hierarchical submenus with expand/collapse
-- Checkbox items with toggle state
-- Disabled item support
-- Customizable styling for normal, selected, and disabled states
-- Mouse support with hover highlighting
-
-### When to Use
-
-Use Menu when you need to:
-- Present a list of commands or actions
-- Organize options hierarchically in submenus
-- Display shortcuts alongside menu items
-- Provide toggleable settings with checkboxes
-- Create dropdown or context menus
-
-## Widget Options
-
-The Menu widget accepts the following options in its `new/1` function:
-
-- `:items` - List of menu items (required)
-- `:on_select` - Callback when item is selected `fn id -> ... end`
-- `:on_toggle` - Callback when checkbox is toggled `fn id, checked -> ... end`
-- `:width` - Menu width (default: auto-calculated)
-- `:item_style` - Style for normal items
-- `:selected_style` - Style for focused item
-- `:disabled_style` - Style for disabled items
-
-### Item Constructors
-
-```elixir
-# Action item
-Menu.action(:new, "New File", shortcut: "Ctrl+N")
-
-# Submenu with children
-Menu.submenu(:recent, "Recent Files", [
- Menu.action(:file1, "document.txt"),
- Menu.action(:file2, "notes.md")
-])
-
-# Separator (visual divider)
-Menu.separator()
-
-# Checkbox item
-Menu.checkbox(:autosave, "Auto Save", checked: true)
-```
-
-### Example Usage
-
-```elixir
-Menu.new(
- items: [
- Menu.action(:new, "New File", shortcut: "Ctrl+N"),
- Menu.action(:open, "Open...", shortcut: "Ctrl+O"),
- Menu.separator(),
- Menu.submenu(:export, "Export As", [
- Menu.action(:pdf, "PDF"),
- Menu.action(:html, "HTML")
- ]),
- Menu.checkbox(:autosave, "Auto Save", checked: true)
- ],
- selected_style: Style.new(fg: :black, bg: :cyan),
- on_select: fn id -> handle_action(id) end
-)
-```
-
-## Example Structure
-
-This example contains:
-
-- `lib/menu/app.ex` - Main application demonstrating the Menu widget
- - File menu example with New, Open, Save actions
- - Recent Files submenu
- - Export As submenu
- - Settings checkboxes (Auto Save, Dark Mode, Notifications)
- - Displays last action and checkbox states
-
-## Running the Example
-
-### Raw Mode (Full TUI Experience)
-
-For the best experience with full terminal control and alternate screen:
-
-```bash
-cd examples/menu
-mix termui.run
-```
-
-Or manually:
-
-```bash
-cd examples/menu
-mix run -e "Menu.App.run()" --no-halt
-```
-
-### TTY Mode (IEx Compatible)
-
-To run from IEx without taking over the shell:
-
-```bash
-cd examples/menu
-iex -S mix
-```
-
-Then in IEx:
-
-```elixir
-Menu.App.run()
-```
-
-**Note:** TTY mode works inside IEx but has limitations:
-- No alternate screen buffer (output mixes with IEx prompt)
-- Character input works immediately (no Enter needed)
-- For full TUI, use raw mode instead
-
-## Controls
-
-### Navigation
-- **Up/Down** - Navigate between items (skips separators)
-- **Right** - Expand submenu
-- **Left** - Collapse submenu
-
-### Selection
-- **Enter/Space** - Select item or toggle checkbox
-- **Q** - Quit the application
-
-### Mouse
-- **Click** - Select item at position
-- **Hover** - Highlights item under cursor
-
-## Features Demonstrated
-
-1. **Action Items** - New File, Open, Save with shortcuts
-2. **Submenus** - Recent Files and Export As with nested items
-3. **Separators** - Visual dividers between sections
-4. **Checkboxes** - Auto Save, Dark Mode, Notifications with toggle state
-5. **Shortcut Display** - Shows keyboard shortcuts aligned right
-6. **State Tracking** - Displays last action and checkbox states
-7. **Hierarchical Navigation** - Expand/collapse submenus
-
-## Item Types
-
-### Action
-Selectable menu item that triggers an action. Displays label and optional shortcut.
-
-### Submenu
-Item that contains child items. Shows expand/collapse arrow (▶/▼) based on state.
-
-### Separator
-Visual divider (horizontal line) that cannot be selected.
-
-### Checkbox
-Toggleable item showing checked state with [×] or [ ]. Can be toggled with Enter/Space.
-
-## Implementation Notes
-
-- The example tracks checkbox states in the widget state
-- Last action is displayed when an action item is selected
-- Submenus are collapsed by default
-- Disabled items (if configured) cannot be selected
-- Width auto-adjusts to longest item + shortcut
-- Cursor wraps around at list ends
diff --git a/examples/menu/lib/menu/app.ex b/examples/menu/lib/menu/app.ex
deleted file mode 100644
index fffeaa25..00000000
--- a/examples/menu/lib/menu/app.ex
+++ /dev/null
@@ -1,195 +0,0 @@
-defmodule Menu.App do
- @moduledoc """
- Menu Widget Example
-
- This example demonstrates how to use the TermUI.Widgets.Menu widget
- for displaying hierarchical menus with various item types.
-
- Features demonstrated:
- - Action items (selectable menu items)
- - Submenus (nested menus)
- - Separators (visual dividers)
- - Checkboxes (toggleable items)
- - Keyboard navigation
- - Shortcut display
-
- Controls:
- - Up/Down: Navigate between items
- - Right: Expand submenu
- - Left: Collapse submenu
- - Enter/Space: Select item or toggle checkbox
- - Q: Quit the application
- """
-
- use TermUI.Elm
-
- alias TermUI.Event
- alias TermUI.Renderer.Style
- alias TermUI.Widgets.Menu
-
- # ----------------------------------------------------------------------------
- # Component Callbacks
- # ----------------------------------------------------------------------------
-
- @doc """
- Initialize the component state.
- """
- def init(_opts) do
- props =
- Menu.new(
- items: menu_items(),
- selected_style: Style.new(fg: :black, bg: :cyan),
- disabled_style: Style.new(fg: :bright_black)
- )
-
- {:ok, menu_state} = Menu.init(props)
-
- %{
- menu: menu_state,
- last_action: nil
- }
- end
-
- defp menu_items do
- [
- Menu.action(:new, "New File", shortcut: "Ctrl+N"),
- Menu.action(:open, "Open...", shortcut: "Ctrl+O"),
- Menu.action(:save, "Save", shortcut: "Ctrl+S"),
- Menu.separator(),
- Menu.submenu(:recent, "Recent Files", [
- Menu.action(:file1, "document.txt"),
- Menu.action(:file2, "notes.md"),
- Menu.action(:file3, "config.json")
- ]),
- Menu.submenu(:export, "Export As", [
- Menu.action(:export_pdf, "PDF"),
- Menu.action(:export_html, "HTML"),
- Menu.action(:export_md, "Markdown")
- ]),
- Menu.separator(),
- Menu.checkbox(:autosave, "Auto Save", checked: true),
- Menu.checkbox(:dark_mode, "Dark Mode"),
- Menu.checkbox(:notifications, "Notifications", checked: true),
- Menu.separator(),
- Menu.action(:settings, "Settings...", shortcut: "Ctrl+,"),
- Menu.action(:exit, "Exit", shortcut: "Ctrl+Q")
- ]
- end
-
- @doc """
- Convert keyboard events to messages.
- """
- def event_to_msg(%Event.Key{key: key}, _state) when key in ["q", "Q"], do: {:msg, :quit}
-
- def event_to_msg(event, _state) do
- {:msg, {:menu_event, event}}
- end
-
- @doc """
- Update state based on messages.
- """
- def update(:quit, state) do
- {state, [:quit]}
- end
-
- def update({:menu_event, %Event.Key{key: key} = event}, state) when key in [:enter, " "] do
- # Track what item was selected before handling the event
- cursor = Menu.get_cursor(state.menu)
- {:ok, menu} = Menu.handle_event(event, state.menu)
-
- # Update last_action if it was an action item
- last_action =
- case get_item_type(state.menu, cursor) do
- :action -> cursor
- _ -> state.last_action
- end
-
- {%{state | menu: menu, last_action: last_action}, []}
- end
-
- def update({:menu_event, event}, state) do
- {:ok, menu} = Menu.handle_event(event, state.menu)
- {%{state | menu: menu}, []}
- end
-
- defp get_item_type(menu, id) do
- menu.items
- |> find_item(id)
- |> case do
- %{type: type} -> type
- _ -> nil
- end
- end
-
- defp find_item(items, id) do
- Enum.find_value(items, fn item ->
- cond do
- item.id == id -> item
- item.type == :submenu -> find_item(item.children, id)
- true -> nil
- end
- end)
- end
-
- @doc """
- Render the current state to a render tree.
- """
- def view(state) do
- stack(:vertical, [
- # Title
- text("Menu Widget Example", Style.new(fg: :cyan, attrs: [:bold])),
- text("", nil),
-
- # Render the menu
- Menu.render(state.menu, %{width: 40, height: 20}),
-
- # Show last action
- text("", nil),
-
- # Controls
- render_controls(state)
- ])
- end
-
- defp render_controls(state) do
- box_width = 50
- inner_width = box_width - 2
-
- top_border = "+" <> String.duplicate("-", inner_width - 10) <> " Controls " <> "+"
- bottom_border = "+" <> String.duplicate("-", inner_width) <> "+"
-
- # Get checkbox states from the menu widget
- autosave = Menu.checked?(state.menu, :autosave)
- dark_mode = Menu.checked?(state.menu, :dark_mode)
- notifications = Menu.checked?(state.menu, :notifications)
-
- stack(:vertical, [
- text("", nil),
- text(top_border, Style.new(fg: :yellow)),
- text("|" <> String.pad_trailing(" Up/Down Navigate", inner_width) <> "|", nil),
- text("|" <> String.pad_trailing(" Right Expand submenu", inner_width) <> "|", nil),
- text("|" <> String.pad_trailing(" Left Collapse submenu", inner_width) <> "|", nil),
- text("|" <> String.pad_trailing(" Enter Select / Toggle", inner_width) <> "|", nil),
- text("|" <> String.pad_trailing(" Q Quit", inner_width) <> "|", nil),
- text("|" <> String.pad_trailing("", inner_width) <> "|", nil),
- text("|" <> String.pad_trailing(" Last action: #{state.last_action || "(none)"}", inner_width) <> "|", nil),
- text("|" <> String.pad_trailing("", inner_width) <> "|", nil),
- text("|" <> String.pad_trailing(" Checkboxes:", inner_width) <> "|", nil),
- text("|" <> String.pad_trailing(" Auto Save: #{autosave}", inner_width) <> "|", nil),
- text("|" <> String.pad_trailing(" Dark Mode: #{dark_mode}", inner_width) <> "|", nil),
- text("|" <> String.pad_trailing(" Notifications: #{notifications}", inner_width) <> "|", nil),
- text(bottom_border, Style.new(fg: :yellow))
- ])
- end
-
- # ----------------------------------------------------------------------------
- # Public API
- # ----------------------------------------------------------------------------
-
- @doc """
- Run the menu example application.
- """
- def run do
- TermUI.Runtime.run(root: __MODULE__)
- end
-end
diff --git a/examples/menu/lib/menu/application.ex b/examples/menu/lib/menu/application.ex
deleted file mode 100644
index fc7e2b21..00000000
--- a/examples/menu/lib/menu/application.ex
+++ /dev/null
@@ -1,12 +0,0 @@
-defmodule Menu.Application do
- @moduledoc false
-
- use Application
-
- @impl true
- def start(_type, _args) do
- children = []
- opts = [strategy: :one_for_one, name: Menu.Supervisor]
- Supervisor.start_link(children, opts)
- end
-end
diff --git a/examples/menu/mix.exs b/examples/menu/mix.exs
deleted file mode 100644
index ccbb81de..00000000
--- a/examples/menu/mix.exs
+++ /dev/null
@@ -1,26 +0,0 @@
-defmodule Menu.MixProject do
- use Mix.Project
-
- def project do
- [
- app: :menu,
- version: "0.1.0",
- elixir: "~> 1.15",
- start_permanent: Mix.env() == :prod,
- deps: deps()
- ]
- end
-
- def application do
- [
- extra_applications: [:logger],
- mod: {Menu.Application, []}
- ]
- end
-
- defp deps do
- [
- {:term_ui, path: "../.."}
- ]
- end
-end
diff --git a/examples/menu/mix.lock b/examples/menu/mix.lock
deleted file mode 100644
index ee8761c0..00000000
--- a/examples/menu/mix.lock
+++ /dev/null
@@ -1,12 +0,0 @@
-%{
- "castore": {:hex, :castore, "1.0.17", "4f9770d2d45fbd91dcf6bd404cf64e7e58fed04fadda0923dc32acca0badffa2", [:mix], [], "hexpm", "12d24b9d80b910dd3953e165636d68f147a31db945d2dcb9365e441f8b5351e5"},
- "gen_stage": {:hex, :gen_stage, "1.3.2", "7c77e5d1e97de2c6c2f78f306f463bca64bf2f4c3cdd606affc0100b89743b7b", [:mix], [], "hexpm", "0ffae547fa777b3ed889a6b9e1e64566217413d018cabd825f786e843ffe63e7"},
- "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
- "lumis": {:hex, :lumis, "0.1.0", "6d3b495457d0608f8fe32fe6fa917eb17c921bd0087583a7f4c420c0009888fd", [:mix], [{:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:rustler, "~> 0.29", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.6", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "4de203bc5811ad21f0c6b0442612a3fc1ae9bea7fbfe7037452db9e9dfdaaab3"},
- "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"},
- "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"},
- "mdex": {:hex, :mdex, "0.11.3", "7b815ae2f474e62ad365dfa4d9df87ddec6a01cfa9b7db523a3b21061d909225", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:lumis, "~> 0.1", [hex: :lumis, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}, {:rustler, "~> 0.32", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.7", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "181bf04b13dcae20ab9f336115facc5096aae333a2408a03e53338ee65d4f564"},
- "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
- "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"},
- "rustler_precompiled": {:hex, :rustler_precompiled, "0.8.4", "700a878312acfac79fb6c572bb8b57f5aae05fe1cf70d34b5974850bbf2c05bf", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "3b33d99b540b15f142ba47944f7a163a25069f6d608783c321029bc1ffb09514"},
-}
diff --git a/examples/menu/run.exs b/examples/menu/run.exs
deleted file mode 100644
index 50d5d786..00000000
--- a/examples/menu/run.exs
+++ /dev/null
@@ -1 +0,0 @@
-Menu.App.run()
diff --git a/examples/multi_renderer/README.md b/examples/multi_renderer/README.md
deleted file mode 100644
index e28c444d..00000000
--- a/examples/multi_renderer/README.md
+++ /dev/null
@@ -1,215 +0,0 @@
-# TermUI Multi-Renderer Examples
-
-This directory contains example applications demonstrating TermUI's multi-renderer capabilities, including automatic backend selection (raw vs TTY mode) and graceful feature degradation.
-
-## Prerequisites
-
-- Elixir 1.15+
-- OTP 28+ recommended for full raw mode support
-- A terminal emulator (Alacritty, Kitty, WezTerm, iTerm2, GNOME Terminal, etc.)
-
-## Examples
-
-### 1. Basic Example (`basic.ex`)
-
-A simple list navigation application that works identically in both raw and TTY modes.
-
-```bash
-# Run with auto-detection
-elixir -r examples/multi_renderer/basic.ex -e "Basic.run()"
-
-# Force TTY mode
-elixir -r examples/multi_renderer/basic.ex -e "Basic.run(backend: :tty)"
-
-# Force raw mode (requires OTP 28+ and terminal support)
-elixir -r examples/multi_renderer/basic.ex -e "Basic.run(backend: :raw)"
-```
-
-**Features:**
-- Navigate a list using arrow keys or j/k
-- Toggle details view with Enter
-- Shows current backend mode
-- Works in both raw and TTY modes
-
-**Controls:**
-- `↑`/`↓` or `j`/`k` - Navigate list
-- `Enter` - Toggle details view
-- `q` - Quit
-
----
-
-### 2. Text Input Example (`text_input.ex`)
-
-Demonstrates text input behavior differences between raw and TTY modes.
-
-```bash
-# Run with auto-detection
-elixir -r examples/multi_renderer/text_input.ex -e "TextInputExample.run()"
-
-# Force TTY mode
-elixir -r examples/multi_renderer/text_input.ex -e "TextInputExample.run(backend: :tty)"
-```
-
-**Features:**
-- Shows how text input works in different modes
-- Raw mode: Character-by-character input with live editing
-- TTY mode: Line-based input (press Enter to submit)
-
-**Controls:**
-- Type text and press Enter to submit
-- `c` - Clear submitted values
-- `h` - Toggle help
-- `q` - Quit
-
----
-
-### 3. Capabilities Example (`capabilities.ex`)
-
-Displays detected terminal capabilities and backend mode.
-
-```bash
-# Run with auto-detection
-elixir -r examples/multi_renderer/capabilities.ex -e "CapabilitiesExample.run()"
-
-# Run in demo mode (no full UI)
-elixir -r examples/multi_renderer/capabilities.ex -e "CapabilitiesExample.run(demo: true)"
-```
-
-**Features:**
-- Shows detected backend mode (raw/tty)
-- Displays color support level (true_color, color_256, color_16, monochrome)
-- Shows Unicode support status
-- Displays terminal dimensions
-- Interactive tabs for different capability categories
-
-**Controls:**
-- `Tab` - Switch between tabs
-- `1`-`4` - Jump to specific tab
-- `Enter` - Refresh capabilities
-- `q` - Quit
-
----
-
-## Backend Modes
-
-### Raw Mode (OTP 28+)
-
-Full terminal control with:
-- Character-by-character input
-- Arrow key navigation
-- Mouse support (when available)
-- True color and Unicode
-- Live UI updates
-
-### TTY Mode (Fallback)
-
-Graceful degradation with:
-- Line-based input (type and press Enter)
-- Single key commands
-- Reduced but functional UI
-- Works in non-terminal environments
-
-### Auto-Detection
-
-By default, TermUI automatically selects the appropriate backend:
-1. Attempts raw mode first (OTP 28+)
-2. Falls back to TTY mode if:
- - OTP < 28
- - A shell is already running
- - Raw mode activation fails
- - Not in a terminal (piped input, etc.)
-
-## Running Examples in Different Environments
-
-### Local Terminal
-
-```bash
-# Standard terminal (supports raw mode)
-elixir -r examples/multi_renderer/basic.ex -e "Basic.run()"
-```
-
-### SSH Session
-
-```bash
-# Should auto-detect and use appropriate mode
-elixir -r examples/multi_renderer/basic.ex -e "Basic.run()"
-```
-
-### Within IEx
-
-```elixir
-# In IEx, you can run examples directly
-iex> Code.require_file("examples/multi_renderer/basic.ex")
-iex> Basic.run()
-```
-
-### Forcing Specific Mode
-
-```bash
-# Force TTY mode (useful for testing)
-elixir -r examples/multi_renderer/basic.ex -e "Basic.run(backend: :tty)"
-
-# Force raw mode (will fail if unavailable)
-elixir -r examples/multi_renderer/basic.ex -e "Basic.run(backend: :raw)"
-```
-
-## Configuration
-
-You can also configure the default backend in your `config/config.exs`:
-
-```elixir
-# config/config.exs
-config :term_ui,
- backend: :auto # :auto, :raw, or :tty
-```
-
-## Troubleshooting
-
-### "Raw mode unavailable" message
-
-This is expected when:
-- Running on OTP < 28
-- A shell is already running in the terminal
-- The terminal doesn't support raw mode
-
-The system will automatically fall back to TTY mode.
-
-### No colors or wrong colors
-
-TermUI automatically detects color support. If colors aren't displaying correctly:
-1. Check your terminal's color settings
-2. Try setting `COLORTERM=truecolor` environment variable
-3. Some terminals require explicit color enabling
-
-### Unicode characters not displaying
-
-TermUI detects UTF-8 support from locale variables:
-- Ensure `LANG` or `LC_CTYPE` includes "UTF-8"
-- The system will fall back to ASCII if Unicode isn't detected
-
-## Development
-
-### Example Structure
-
-Each example follows the same pattern:
-
-1. Uses `TermUI.Elm` for the Elm Architecture
-2. Implements `init/1`, `event_to_msg/2`, `update/2`, and `view/1`
-3. Provides a `run/1` function that accepts options
-4. Includes comments explaining the code
-
-### Adapting Examples
-
-To create your own application:
-
-1. Copy an example file as a template
-2. Modify the `init/1` function for your initial state
-3. Implement your event handlers in `event_to_msg/2`
-4. Add your state logic in `update/2`
-5. Design your UI in `view/1`
-
-## See Also
-
-- [TermUI Documentation](../../README.md)
-- [Multi-Renderer Planning Document](../../notes/planning/multi-renderer/)
-- [Configuration Guide](../../lib/term_ui/config.ex)
diff --git a/examples/multi_renderer/basic.ex b/examples/multi_renderer/basic.ex
deleted file mode 100644
index 32f91b28..00000000
--- a/examples/multi_renderer/basic.ex
+++ /dev/null
@@ -1,162 +0,0 @@
-# Basic TermUI Example - List Navigation
-#
-# This example demonstrates a simple list navigation application
-# that works identically in both raw mode (full terminal control)
-# and TTY mode (line-based input with graceful degradation).
-#
-# Usage:
-# elixir -r examples/multi_renderer/basic.ex -e "Basic.run()"
-#
-# Or run with specific backend:
-# elixir -r examples/multi_renderer/basic.ex -e "Basic.run(backend: :tty)"
-
-defmodule Basic do
- @moduledoc """
- A simple list navigation example that works in both raw and TTY modes.
-
- In raw mode: Use arrow keys to navigate, Enter to select
- In TTY mode: Type single character commands (j/k, then Enter)
- """
-
- use TermUI.Elm
-
- # Sample list of items
- @items [
- "Item 1: Learn TermUI",
- "Item 2: Build TUI apps",
- "Item 3: Master Elm Architecture",
- "Item 4: Create widgets",
- "Item 5: Test your apps"
- ]
-
- # State structure
- # %{
- # selected_index: integer(),
- # show_details: boolean()
- # }
-
- def init(_opts) do
- %{selected_index: 0, show_details: false}
- end
-
- # Event handling - works in both raw and TTY modes
- def event_to_msg(%TermUI.Event.Key{key: :up}, _state), do: {:msg, :up}
- def event_to_msg(%TermUI.Event.Key{key: :down}, _state), do: {:msg, :down}
- def event_to_msg(%TermUI.Event.Key{key: :enter}, _state), do: {:msg, :toggle_details}
- def event_to_msg(%TermUI.Event.Key{key: ?q}, _state), do: {:msg, :quit}
- def event_to_msg(%TermUI.Event.Key{key: ?j}, _state), do: {:msg, :down}
- def event_to_msg(%TermUI.Event.Key{key: ?k}, _state), do: {:msg, :up}
- def event_to_msg(_event, _state), do: :ignore
-
- # State updates
- def update(:up, state) do
- new_index = max(0, state.selected_index - 1)
- {%{state | selected_index: new_index}, []}
- end
-
- def update(:down, state) do
- new_index = min(length(@items) - 1, state.selected_index + 1)
- {%{state | selected_index: new_index}, []}
- end
-
- def update(:toggle_details, state) do
- {%{state | show_details: not state.show_details}, []}
- end
-
- def update(:quit, state) do
- {state, [:quit]}
- end
-
- # View rendering
- def view(state) do
- selected_item = Enum.at(@items, state.selected_index)
-
- box([
- text("TermUI Basic Example - List Navigation",
- TermUI.Renderer.Style.new()
- |> TermUI.Renderer.Style.fg(:green)
- |> TermUI.Renderer.Style.bright()
- ),
- text(""),
- text("Use ↑/↓ or j/k to navigate, Enter for details, q to quit",
- TermUI.Renderer.Style.new()
- |> TermUI.Renderer.Style.fg(:cyan)
- ),
- text(""),
- text("─" |> String.duplicate(40),
- TermUI.Renderer.Style.new()
- |> TermUI.Renderer.Style.fg(:bright_black)
- ),
- text(""),
- render_list(@items, state.selected_index),
- text(""),
- text("─" |> String.duplicate(40),
- TermUI.Renderer.Style.new()
- |> TermUI.Renderer.Style.fg(:bright_black)
- ),
- text(""),
- render_details(selected_item, state.show_details),
- text(""),
- render_footer(state)
- ])
- end
-
- # Render the list with selection indicator
- defp render_list(items, selected_index) do
- items
- |> Enum.with_index()
- |> Enum.map(fn {item, index} ->
- prefix = if index == selected_index, do: "► ", else: " "
- style =
- if index == selected_index do
- TermUI.Renderer.Style.new()
- |> TermUI.Renderer.Style.fg(:yellow)
- |> TermUI.Renderer.Style.bright()
- else
- TermUI.Renderer.Style.new()
- end
-
- text(prefix <> item, style)
- end)
- end
-
- # Render details section
- defp render_details(_item, false), do: empty()
-
- defp render_details(item, true) do
- box([
- text("Selected:"),
- text(" " <> item,
- TermUI.Renderer.Style.new()
- |> TermUI.Renderer.Style.fg(:yellow)
- )
- ],
- border: :single,
- padding: {0, 1}
- )
- end
-
- # Render footer with backend mode info
- defp render_footer(state) do
- mode = TermUI.App.backend_mode() || :unknown
- mode_text =
- case mode do
- :raw -> "Raw Mode (full terminal control)"
- :tty -> "TTY Mode (line-based input)"
- :skip -> "Test Mode"
- _ -> "Unknown Mode"
- end
-
- text("Mode: " <> mode_text,
- TermUI.Renderer.Style.new()
- |> TermUI.Renderer.Style.fg(:bright_black)
- )
- end
-
- # Run the application
- def run(opts \\ []) do
- # Combine with default options for example
- all_opts = Keyword.put_new(opts, :name, :basic_example)
- TermUI.App.run(__MODULE__, all_opts)
- end
-end
diff --git a/examples/multi_renderer/capabilities.ex b/examples/multi_renderer/capabilities.ex
deleted file mode 100644
index 64c90d57..00000000
--- a/examples/multi_renderer/capabilities.ex
+++ /dev/null
@@ -1,400 +0,0 @@
-# TermUI Capabilities Detection Example
-#
-# This example demonstrates how to query and display
-# detected terminal capabilities.
-#
-# Usage:
-# elixir -r examples/multi_renderer/capabilities.ex -e "CapabilitiesExample.run()"
-#
-# Or run with specific backend:
-# elixir -r examples/multi_renderer/capabilities.ex -e "CapabilitiesExample.run(backend: :tty)"
-
-defmodule CapabilitiesExample do
- @moduledoc """
- Example showing how to query and display terminal capabilities.
-
- Demonstrates:
- - Backend mode detection (raw/tty)
- - Color support (true_color, color_256, color_16, monochrome)
- - Unicode support
- - Terminal dimensions
- - Mouse support
- """
-
- use TermUI.Elm
-
- # State structure
- # %{
- # capabilities: map() | nil,
- # current_tab: :overview | :colors | :unicode | :dimensions
- # }
-
- def init(_opts) do
- # Get capabilities at init
- capabilities = get_capabilities()
- %{capabilities: capabilities, current_tab: :overview}
- end
-
- # Event handling
- def event_to_msg(%TermUI.Event.Key{key: :tab}, state), do: {:msg, :next_tab}
- def event_to_msg(%TermUI.Event.Key{key: ?1}, _state), do: {:msg, :show_overview}
- def event_to_msg(%TermUI.Event.Key{key: ?2}, _state), do: {:msg, :show_colors}
- def event_to_msg(%TermUI.Event.Key{key: ?3}, _state), do: {:msg, :show_unicode}
- def event_to_msg(%TermUI.Event.Key{key: ?4}, _state), do: {:msg, :show_dimensions}
- def event_to_msg(%TermUI.Event.Key{key: :enter}, _state), do: {:msg, :refresh}
- def event_to_msg(%TermUI.Event.Key{key: ?q}, _state), do: {:msg, :quit}
- def event_to_msg(_event, _state), do: :ignore
-
- # State updates
- def update(:next_tab, state) do
- tabs = [:overview, :colors, :unicode, :dimensions]
- current_index = Enum.find_index(tabs, fn t -> t == state.current_tab end)
- next_index = rem(current_index + 1, length(tabs))
- {%{state | current_tab: Enum.at(tabs, next_index)}, []}
- end
-
- def update(:show_overview, state), do: {%{state | current_tab: :overview}, []}
- def update(:show_colors, state), do: {%{state | current_tab: :colors}, []}
- def update(:show_unicode, state), do: {%{state | current_tab: :unicode}, []}
- def update(:show_dimensions, state), do: {%{state | current_tab: :dimensions}, []}
- def update(:refresh, state), do: {%{state | capabilities: get_capabilities()}, []}
- def update(:quit, state), do: {state, [:quit]}
-
- # View rendering
- def view(state) do
- box([
- header(),
- text(""),
- render_tab_content(state),
- text(""),
- render_tabs(state),
- text(""),
- footer()
- ])
- end
-
- defp header do
- box([
- text("TermUI Capabilities Detection",
- TermUI.Renderer.Style.new()
- |> TermUI.Renderer.Style.fg(:green)
- |> TermUI.Renderer.Style.bright()
- ),
- text("Displays detected terminal features and backend mode")
- ])
- end
-
- defp render_tab_content(%{current_tab: :overview, capabilities: caps}) do
- box([
- text("Backend Mode: " <> format_backend_mode(caps),
- text("Terminal: " <> format_terminal(caps)),
- text("Color Support: " <> format_colors(caps),
- text("Unicode: " <> format_unicode(caps)),
- text("Dimensions: " <> format_dimensions(caps)),
- text("Mouse: " <> format_mouse(caps))
- ])
- end
-
- defp render_tab_content(%{current_tab: :colors, capabilities: caps}) do
- color_mode = get_in(caps, [:colors]) || :unknown
-
- box([
- text("Color Capabilities",
- TermUI.Renderer.Style.new()
- |> TermUI.Renderer.Style.fg(:green)
- ),
- text(""),
- color_capability_row("Detected Mode", color_mode, get_color_style(color_mode)),
- text(""),
- text("Support Levels:"),
- text(" • true_color - 24-bit RGB (16.7 million colors)"),
- text(" • color_256 - 256-color palette"),
- text(" • color_16 - 16 basic colors"),
- text(" • monochrome - No color support"),
- text(""),
- color_examples(color_mode)
- ])
- end
-
- defp render_tab_content(%{current_tab: :unicode, capabilities: caps}) do
- unicode_supported = get_in(caps, [:unicode]) == true
-
- box([
- text("Unicode Support",
- TermUI.Renderer.Style.new()
- |> TermUI.Renderer.Style.fg(:green)
- ),
- text(""),
- text("Detected: " <> if(unicode_supported, do: "Yes ✓", else: "No ✗"),
- text(""),
- if(unicode_supported,
- do: text(" Box drawing: ┌─┐│└┘"),
- else: text(" ASCII fallback: +-||")
- ),
- text(""),
- text("Note: TermUI automatically falls back to"),
- text(" ASCII when Unicode is not available.")
- ])
- end
-
- defp render_tab_content(%{current_tab: :dimensions, capabilities: caps}) do
- {rows, cols} = get_in(caps, [:dimensions]) || {nil, nil}
-
- box([
- text("Terminal Dimensions",
- TermUI.Renderer.Style.new()
- |> TermUI.Renderer.Style.fg(:green)
- ),
- text(""),
- text("Rows: " <> format_value(rows)),
- text("Columns: " <> format_value(cols)),
- text(""),
- text("Total Cells: " <> format_total(rows, cols)),
- text(""),
- if(rows && cols,
- do: text("Terminal size: #{rows}×#{cols}"),
- else: text("Dimensions not available")
- )
- ])
- end
-
- defp render_tabs(state) do
- tabs = [
- {"1", :overview, "Overview"},
- {"2", :colors, "Colors"},
- {"3", :unicode, "Unicode"},
- {"4", :dimensions, "Dimensions"}
- ]
-
- tab_text =
- tabs
- |> Enum.map(fn {key, tab, label} ->
- style =
- if state.current_tab == tab do
- TermUI.Renderer.Style.new()
- |> TermUI.Renderer.Style.fg(:yellow)
- |> TermUI.Renderer.Style.bright()
- |> TermUI.Renderer.Style.underline()
- else
- TermUI.Renderer.Style.new()
- |> TermUI.Renderer.Style.fg(:bright_black)
- end
-
- text("[#{key}:#{label}] ", style)
- end)
-
- stack(:horizontal, tab_text)
- end
-
- defp footer do
- text("Tab=switch | 1-4=jump | Enter=refresh | q=quit",
- TermUI.Renderer.Style.new()
- |> TermUI.Renderer.Style.fg(:bright_black)
- )
- end
-
- # Color formatting helpers
- defp get_color_style(:true_color), do: TermUI.Renderer.Style.new() |> TermUI.Renderer.Style.fg(:bright_green)
- defp get_color_style(:color_256), do: TermUI.Renderer.Style.new() |> TermUI.Renderer.Style.fg(:green)
- defp get_color_style(:color_16), do: TermUI.Renderer.Style.new() |> TermUI.Renderer.Style.fg(:yellow)
- defp get_color_style(:monochrome), do: TermUI.Renderer.Style.new() |> TermUI.Renderer.Style.fg(:white)
- defp get_color_style(_), do: TermUI.Renderer.Style.new()
-
- defp color_capability_row(label, value, style) do
- stack(:horizontal, [
- text(label <> ": "),
- text(inspect(value), style)
- ])
- end
-
- defp color_examples(:true_color) do
- box([
- text("True Color Gradient Example:"),
- text(""),
- rainbow_gradient("True Color (24-bit RGB)"),
- text(""),
- text("Your terminal supports over 16 million colors!")
- ])
- end
-
- defp color_examples(:color_256) do
- box([
- text("256-Color Palette Example:"),
- text(""),
- sample_palette_256(),
- text(""),
- text("Your terminal supports 256 colors.")
- ])
- end
-
- defp color_examples(:color_16) do
- box([
- text("16-Color Example:"),
- text(""),
- sample_colors_16(),
- text(""),
- text("Your terminal supports 16 basic colors.")
- ])
- end
-
- defp color_examples(:monochrome) do
- box([
- text("Monochrome Display"),
- text(""),
- text("Your terminal does not support color."),
- text("All output will be in a single color.")
- ])
- end
-
- defp color_examples(_), do: text("Color detection not available")
-
- # Sample color displays
- defp rainbow_gradient(label) do
- colors = [:red, :yellow, :green, :cyan, :blue, :magenta]
-
- colors
- |> Enum.map(fn color ->
- styled("■ ", TermUI.Renderer.Style.new() |> TermUI.Renderer.Style.fg(color))
- end)
- |> prepend_text(label <> ": ")
- end
-
- defp sample_palette_256 do
- # Sample of the 256-color palette
- indices = [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15]
-
- indices
- |> Enum.map(fn i ->
- style = TermUI.Renderer.Style.new() |> TermUI.Renderer.Style.bg(i)
- styled(" ", style)
- end)
- end
-
- defp sample_colors_16 do
- colors = [
- {:black, "K "},
- {:red, "R "},
- {:green, "G "},
- {:yellow, "Y "},
- {:blue, "B "},
- {:magenta, "M "},
- {:cyan, "C "},
- {:white, "W "}
- ]
-
- colors
- |> Enum.map(fn {color, label} ->
- styled(label, TermUI.Renderer.Style.new() |> TermUI.Renderer.Style.bg(color))
- end)
- end
-
- # Formatting helpers
- defp format_backend_mode(%{backend_mode: mode}) when mode, do: inspect(mode)
- defp format_backend_mode(_), do: "unknown"
-
- defp format_terminal(%{terminal: true}), do: "Yes (terminal detected)"
- defp format_terminal(%{terminal: false}), do: "No (piped/file)"
- defp format_terminal(_), do: "unknown"
-
- defp format_colors(%{colors: mode}) when mode, do: inspect(mode)
- defp format_colors(_), do: "unknown"
-
- defp format_unicode(%{unicode: true}), do: "Yes ✓"
- defp format_unicode(%{unicode: false}), do: "No (ASCII fallback)"
- defp format_unicode(_), do: "unknown"
-
- defp format_dimensions(%{dimensions: {rows, cols}}) when rows and cols do
- "#{rows} rows × #{cols} cols"
- end
-
- defp format_dimensions(_), do: "unknown"
-
- defp format_mouse(%{mouse: true}), do: "Available"
- defp format_mouse(%{mouse: false}), do: "Not available"
- defp format_mouse(_), do: "unknown"
-
- defp format_value(nil), do: "N/A"
- defp format_value(value), do: to_string(value)
-
- defp format_total(nil, _), do: "N/A"
- defp format_total(_, nil), do: "N/A"
- defp format_total(rows, cols), do: to_string(rows * cols)
-
- # Get capabilities from the running system
- defp get_capabilities do
- %{
- backend_mode: TermUI.App.backend_mode(),
- colors: get_color_mode(),
- unicode: TermUI.App.supports?(:unicode),
- dimensions: get_dimensions(),
- terminal: get_terminal(),
- mouse: TermUI.App.supports?(:mouse)
- }
- end
-
- defp get_color_mode do
- cond do
- TermUI.App.supports?(:true_color) -> :true_color
- TermUI.App.supports?(:color_256) -> :color_256
- TermUI.App.supports?(:color_16) -> :color_16
- TermUI.App.supports?(:monochrome) -> :monochrome
- true -> nil
- end
- end
-
- defp get_dimensions do
- case TermUI.App.capabilities() do
- %{dimensions: dims} -> dims
- _ -> nil
- end
- end
-
- defp get_terminal do
- case TermUI.App.capabilities() do
- %{terminal: term} when is_boolean(term) -> term
- _ -> nil
- end
- end
-
- # Run the application
- def run(opts \\ []) do
- all_opts = Keyword.put_new(opts, :name, :capabilities_example)
-
- # Check if we should show a demo or run the full app
- if Keyword.get(opts, :demo, false) do
- run_demo()
- else
- try do
- TermUI.App.run(__MODULE__, all_opts)
- rescue
- e ->
- IO.puts("Could not start full UI: #{inspect(e)}")
- IO.puts("\nRunning in demo mode instead...\n")
- run_demo()
- end
- end
- end
-
- # Demo mode that shows capabilities without full UI
- defp run_demo do
- caps = get_capabilities()
-
- IO.puts("""
- TermUI Capabilities Detection Demo
- =================================
-
- Backend Mode: #{format_backend_mode(caps)}
- Terminal: #{format_terminal(caps)}
- Color Support: #{format_colors(caps)}
- Unicode: #{format_unicode(caps)}
- Dimensions: #{format_dimensions(caps)}
- Mouse: #{format_mouse(caps)}
-
- This demo shows the capabilities that would be detected
- when running a full TermUI application.
-
- To run the full interactive example, use OTP 28+ and ensure
- you're in a terminal that supports raw mode.
- """)
- end
-end
diff --git a/examples/multi_renderer/text_input.ex b/examples/multi_renderer/text_input.ex
deleted file mode 100644
index 9c200a82..00000000
--- a/examples/multi_renderer/text_input.ex
+++ /dev/null
@@ -1,251 +0,0 @@
-# TermUI Text Input Example
-#
-# This example demonstrates text input that works in both modes:
-# - Raw mode: Character-by-character input with live editing
-# - TTY mode: Line-based input (press Enter after typing)
-#
-# Usage:
-# elixir -r examples/multi_renderer/text_input.ex -e "TextInputExample.run()"
-#
-# Or run with specific backend:
-# elixir -r examples/multi_renderer/text_input.ex -e "TextInputExample.run(backend: :tty)"
-
-defmodule TextInputExample do
- @moduledoc """
- Text input example demonstrating character vs line input modes.
-
- In raw mode (OTP 28+):
- - See characters appear as you type
- - Use backspace to delete
- - Press Enter to submit
-
- In TTY mode (fallback):
- - Type your input
- - Press Enter to see the result
- - Line-by-line input (no live editing)
- """
-
- use TermUI.Elm
- alias TermUI.Widget.TextInput
-
- # State structure
- # %{
- # input_value: String.t(),
- # submitted_values: [String.t()],
- # show_help: boolean()
- # }
-
- def init(_opts) do
- %{input_value: "", submitted_values: [], show_help: true}
- end
-
- # Event handling
- def event_to_msg(%TermUI.Event.Key{key: :enter}, _state), do: {:msg, :submit}
- def event_to_msg(%TermUI.Event.Key{key: ?c}, _state), do: {:msg, :clear}
- def event_to_msg(%TermUI.Event.Key{key: ?h}, _state), do: {:msg, :toggle_help}
- def event_to_msg(%TermUI.Event.Key{key: ?q}, _state), do: {:msg, :quit}
- def event_to_msg(_event, _state), do: :ignore
-
- # Handle TextInput messages
- def handle_info({:changed, value}, state) do
- {state, []}
- end
-
- def handle_info({:submit, value}, state) do
- new_values = [value | state.submitted_values]
- {%{state | submitted_values: new_values}, []}
- end
-
- # State updates
- def update(:submit, state) do
- # Value is submitted via TextInput's on_submit
- {state, []}
- end
-
- def update(:clear, state) do
- {%{state | submitted_values: []}, []}
- end
-
- def update(:toggle_help, state) do
- {%{state | show_help: not state.show_help}, []}
- end
-
- def update(:quit, state) do
- {state, [:quit]}
- end
-
- # View rendering
- def view(state) do
- backend_mode = TermUI.App.backend_mode()
- mode_label = mode_label(backend_mode)
-
- box([
- # Header
- text("TermUI Text Input Example",
- TermUI.Renderer.Style.new()
- |> TermUI.Renderer.Style.fg(:green)
- |> TermUI.Renderer.Style.bright()
- ),
- text(""),
- text("Mode: " <> mode_label,
- TermUI.Renderer.Style.new()
- |> TermUI.Renderer.Style.fg(:cyan)
- ),
- text(""),
-
- # Instructions
- render_help(state.show_help, backend_mode),
- text(""),
-
- # Text input field
- box([
- text("Enter text: "),
- text(state.input_value || "",
- TermUI.Renderer.Style.new()
- |> TermUI.Renderer.Style.fg(:yellow)
- ),
- text("_" |> String.duplicate(30),
- TermUI.Renderer.Style.new()
- |> TermUI.Renderer.Style.fg(:bright_black)
- )
- ], style: %{
- border: :none,
- padding: {0, 0}
- }),
- text(""),
-
- # Submitted values
- if(state.submitted_values == [], do: empty(), else: render_submitted(state.submitted_values)),
- text(""),
-
- # Footer
- text("c=clear | h=toggle help | q=quit",
- TermUI.Renderer.Style.new()
- |> TermUI.Renderer.Style.fg(:bright_black)
- )
- ])
- end
-
- defp mode_label(:raw), do: "Raw Mode (character input with live editing)"
- defp mode_label(:tty), do: "TTY Mode (line-based input)"
- defp mode_label(:skip), do: "Test Mode"
- defp mode_label(_), do: "Unknown"
-
- defp render_help(false, _mode), do: empty()
-
- defp render_help(true, :raw) do
- box([
- text("Raw Mode Instructions:"),
- text(" • Type to see characters appear"),
- text(" • Press Enter to submit"),
- text(" • Backspace deletes last character"),
- text(" • Arrow keys move cursor")
- ], border: :single)
- end
-
- defp render_help(true, :tty) do
- box([
- text("TTY Mode Instructions:"),
- text(" • Type your text"),
- text(" • Press Enter to submit"),
- text(" • Line-based input (live editing not available)")
- ], border: :single)
- end
-
- defp render_help(true, _) do
- box([text("Run without skip_terminal to see input modes")], border: :single)
- end
-
- defp render_submitted(values) when length(values) > 5 do
- render_submitted(Enum.take(values, 5))
- end
-
- defp render_submitted(values) do
- box([
- text("Submitted Values:",
- TermUI.Renderer.Style.new()
- |> TermUI.Renderer.Style.fg(:green)
- )
- | Enum.concat(
- values
- |> Enum.reverse()
- |> Enum.map(fn v ->
- text(" • " <> v,
- TermUI.Renderer.Style.new()
- |> TermUI.Renderer.Style.fg(:yellow)
- )
- end)
- )
- ], border: :single)
- end
-
- # Run the application
- def run(opts \\ []) do
- # For this example, we'll use a simplified approach
- # The full TextInput integration with StatefulComponent
- # would require more setup
-
- all_opts = Keyword.put_new(opts, :name, :text_input_example)
-
- case Keyword.get(opts, :backend, TermUI.Config.get(:backend, :auto)) do
- :tty ->
- # In TTY mode, we demonstrate line-based input
- run_tty_demo(all_opts)
-
- _ ->
- # In raw mode or auto, try full example
- run_full_example(all_opts)
- end
- end
-
- # Simplified demo for TTY mode
- defp run_tty_demo(opts) do
- IO.puts("""
- TermUI Text Input Example - TTY Mode
- ====================================
-
- In TTY mode, text input is line-based:
- - Type your input and press Enter
- - No live editing (submitted as-is)
-
- This is a simplified demo for TTY mode.
- For full functionality, use raw mode (OTP 28+).
-
- Press Enter to continue...
- """)
-
- IO.gets("> ")
-
- IO.puts("""
-
- Thank you for trying the Text Input example!
-
- In raw mode, you would see:
- - Live character-by-character input
- - Cursor navigation with arrow keys
- - Backspace/delete for editing
- """)
-
- :ok
- end
-
- # Full example for raw mode
- defp run_full_example(opts) do
- # This would use the full TextInput widget
- # For now, return a simplified version
- IO.puts("""
- TermUI Text Input Example
- ==========================
-
- Starting with options: #{inspect(opts)}
-
- Note: This example demonstrates the structure.
- The full TextInput widget integration is shown in the
- widget documentation and test suite.
-
- Press q to quit...
- """)
-
- :ok
- end
-end
diff --git a/examples/pick_list/README.md b/examples/pick_list/README.md
deleted file mode 100644
index e6f3a360..00000000
--- a/examples/pick_list/README.md
+++ /dev/null
@@ -1,162 +0,0 @@
-# PickList Example
-
-A demonstration of the PickList widget for modal selection dialogs with filtering support.
-
-## Widget Overview
-
-The PickList widget displays a centered modal overlay with a scrollable list of items. It provides keyboard navigation and type-ahead filtering, making it ideal for selection dialogs where users choose from a list of options.
-
-### Key Features
-
-- Modal overlay with centered positioning
-- Scrollable list navigation
-- Type-ahead filtering (incremental search)
-- Keyboard navigation (arrows, page up/down, home/end)
-- Selection and cancel callbacks
-- Automatic scroll adjustment to keep selection visible
-- Border and status line display
-- Handles empty results gracefully
-
-### When to Use
-
-Use PickList when you need to:
-- Present a searchable list of options
-- Create file or item picker dialogs
-- Allow users to select from a large dataset
-- Provide quick filtering via typing
-- Create modal selection interfaces
-
-## Widget Options
-
-The PickList widget accepts the following options in its `init/1` function (via props map):
-
-- `:items` - List of items to display (required)
-- `:title` - Modal title (default: "Select")
-- `:width` - Modal width in characters (default: 40)
-- `:height` - Modal height in characters (default: 10)
-- `:style` - Border/text style options (map)
-- `:highlight_style` - Style for selected item (default: `%{fg: :black, bg: :white}`)
-- `:on_select` - Callback when item selected (not used in this example)
-- `:on_cancel` - Callback when cancelled (not used in this example)
-
-### Example Usage
-
-```elixir
-props = %{
- items: ["Apple", "Banana", "Cherry"],
- title: "Select Fruit",
- width: 35,
- height: 12
-}
-
-{:ok, picker_state} = PickList.init(props)
-```
-
-## Example Structure
-
-This example contains:
-
-- `lib/pick_list/app.ex` - Main application demonstrating the PickList widget
- - Three different pickers: Fruits, Colors, and Countries
- - Type-ahead filtering demonstration
- - Selection handling with state updates
- - Cancel handling
-
-The example maintains:
-- Current picker state (which picker is open)
-- Selected values for each picker
-- Status messages for user feedback
-
-## Running the Example
-
-### Raw Mode (Full TUI Experience)
-
-For the best experience with full terminal control and alternate screen:
-
-```bash
-cd examples/pick_list
-mix termui.run
-```
-
-Or manually:
-
-```bash
-cd examples/pick_list
-mix run -e "PickList.App.run()" --no-halt
-```
-
-### TTY Mode (IEx Compatible)
-
-To run from IEx without taking over the shell:
-
-```bash
-cd examples/pick_list
-iex -S mix
-```
-
-Then in IEx:
-
-```elixir
-PickList.App.run()
-```
-
-**Note:** TTY mode works inside IEx but has limitations:
-- No alternate screen buffer (output mixes with IEx prompt)
-- Character input works immediately (no Enter needed)
-- For full TUI, use raw mode instead
-
-## Controls
-
-### Opening Pickers
-- **1** - Open fruit picker (35 items)
-- **2** - Open color picker (20 items)
-- **3** - Open country picker (24 items)
-
-### When Picker is Open
-
-#### Navigation
-- **Up/Down** - Navigate items
-- **Page Up/Down** - Jump 10 items
-- **Home/End** - Jump to first/last item
-
-#### Selection
-- **Enter** - Confirm selection
-- **Escape** - Cancel and close picker
-
-#### Filtering
-- **Type any character** - Start/extend filter (case-insensitive)
-- **Backspace** - Remove last filter character
-
-### General
-- **Q** - Quit the application (only when picker is closed)
-
-## Features Demonstrated
-
-1. **Multiple Pickers** - Three different pickers with different data sets
-2. **Type-Ahead Filtering** - Real-time filtering as you type
-3. **Selection Tracking** - Shows current selections for each picker
-4. **Status Updates** - Displays feedback for actions
-5. **Modal Positioning** - Automatically centers picker in terminal
-6. **Scroll Management** - Keeps selected item visible during navigation
-7. **Empty Results** - Handles "no matches" gracefully
-
-## Sample Data
-
-### Fruit Picker (35 items)
-Apple, Apricot, Avocado, Banana, Blackberry, Blueberry, Cherry, Coconut, and more
-
-### Color Picker (20 items)
-Red, Orange, Yellow, Green, Blue, Indigo, Violet, Pink, Cyan, Magenta, and more
-
-### Country Picker (24 items)
-Argentina, Australia, Brazil, Canada, China, Egypt, France, Germany, India, and more
-
-## Implementation Notes
-
-- Picker state is managed via commands pattern
-- Selection sends `{:send, pid, {:select, item}}` command
-- Cancel sends `{:send, pid, :cancel}` command
-- Filter resets selection to first matching item
-- Modal is rendered as a cell-based overlay
-- Status line shows current position (e.g., "Item 5 of 20")
-- Filter line appears when typing
diff --git a/examples/pick_list/lib/pick_list/app.ex b/examples/pick_list/lib/pick_list/app.ex
deleted file mode 100644
index 2399ab2e..00000000
--- a/examples/pick_list/lib/pick_list/app.ex
+++ /dev/null
@@ -1,304 +0,0 @@
-defmodule PickList.App do
- @moduledoc """
- PickList Widget Example
-
- This example demonstrates how to use the TermUI.Widget.PickList widget
- for modal selection dialogs with filtering support.
-
- Features demonstrated:
- - Modal overlay with centered positioning
- - Scrollable list navigation
- - Type-ahead filtering
- - Selection and cancel callbacks
- - Multiple pick lists for different use cases
-
- Controls:
- - Up/Down: Navigate items
- - Page Up/Down: Jump 10 items
- - Home/End: Jump to first/last item
- - Enter: Confirm selection
- - Escape: Cancel/close picker
- - Typing: Filter items
- - Backspace: Remove filter character
- - 1/2/3: Open different pickers
- - Q: Quit the application
- """
-
- use TermUI.Elm
-
- alias TermUI.Event
- alias TermUI.Renderer.Style
- alias TermUI.Widget.PickList
-
- # Sample data for different pick lists
- @fruits ["Apple", "Apricot", "Avocado", "Banana", "Blackberry", "Blueberry",
- "Cherry", "Coconut", "Cranberry", "Date", "Dragon Fruit", "Fig",
- "Grape", "Grapefruit", "Guava", "Honeydew", "Kiwi", "Lemon",
- "Lime", "Lychee", "Mango", "Melon", "Nectarine", "Orange",
- "Papaya", "Passion Fruit", "Peach", "Pear", "Pineapple", "Plum",
- "Pomegranate", "Raspberry", "Strawberry", "Tangerine", "Watermelon"]
-
- @colors ["Red", "Orange", "Yellow", "Green", "Blue", "Indigo", "Violet",
- "Pink", "Cyan", "Magenta", "Brown", "Black", "White", "Gray",
- "Teal", "Navy", "Maroon", "Olive", "Coral", "Salmon"]
-
- @countries ["Argentina", "Australia", "Brazil", "Canada", "China", "Egypt",
- "France", "Germany", "India", "Italy", "Japan", "Kenya",
- "Mexico", "Netherlands", "Norway", "Portugal", "Russia",
- "Spain", "Sweden", "Thailand", "United Kingdom", "United States",
- "Vietnam", "Zimbabwe"]
-
- # ----------------------------------------------------------------------------
- # Component Callbacks
- # ----------------------------------------------------------------------------
-
- @doc """
- Initialize the component state.
- """
- def init(_opts) do
- %{
- # Current picker state (nil when no picker open)
- picker: nil,
- picker_state: nil,
-
- # Selected values
- selected_fruit: nil,
- selected_color: nil,
- selected_country: nil,
-
- # Status message
- last_action: "Press 1, 2, or 3 to open a picker"
- }
- end
-
- @doc """
- Convert events to messages.
- """
- def event_to_msg(%Event.Key{key: key}, _state) when key in ["q", "Q"] do
- {:msg, :quit}
- end
-
- def event_to_msg(%Event.Key{key: "1"}, %{picker: nil}) do
- {:msg, :open_fruit_picker}
- end
-
- def event_to_msg(%Event.Key{key: "2"}, %{picker: nil}) do
- {:msg, :open_color_picker}
- end
-
- def event_to_msg(%Event.Key{key: "3"}, %{picker: nil}) do
- {:msg, :open_country_picker}
- end
-
- def event_to_msg(event, %{picker: picker}) when picker != nil do
- {:msg, {:picker_event, event}}
- end
-
- def event_to_msg(_event, _state) do
- :ignore
- end
-
- @doc """
- Update state based on messages.
- """
- def update(:quit, state) do
- {state, [:quit]}
- end
-
- def update(:open_fruit_picker, state) do
- props = %{
- items: @fruits,
- title: "Select a Fruit",
- width: 35,
- height: 12
- }
-
- {:ok, picker_state} = PickList.init(props)
-
- {%{state |
- picker: :fruit,
- picker_state: picker_state,
- last_action: "Fruit picker opened - type to filter"
- }, []}
- end
-
- def update(:open_color_picker, state) do
- props = %{
- items: @colors,
- title: "Select a Color",
- width: 30,
- height: 10
- }
-
- {:ok, picker_state} = PickList.init(props)
-
- {%{state |
- picker: :color,
- picker_state: picker_state,
- last_action: "Color picker opened - type to filter"
- }, []}
- end
-
- def update(:open_country_picker, state) do
- props = %{
- items: @countries,
- title: "Select a Country",
- width: 40,
- height: 15
- }
-
- {:ok, picker_state} = PickList.init(props)
-
- {%{state |
- picker: :country,
- picker_state: picker_state,
- last_action: "Country picker opened - type to filter"
- }, []}
- end
-
- def update({:picker_event, event}, state) do
- case PickList.handle_event(event, state.picker_state) do
- {:ok, new_picker_state} ->
- {%{state | picker_state: new_picker_state}, []}
-
- {:ok, new_picker_state, commands} ->
- # Process commands from picker
- process_picker_commands(state, new_picker_state, commands)
- end
- end
-
- def update(_msg, state) do
- {state, []}
- end
-
- defp process_picker_commands(state, new_picker_state, commands) do
- Enum.reduce(commands, {%{state | picker_state: new_picker_state}, []}, fn cmd, {s, cmds} ->
- case cmd do
- {:send, _pid, {:select, item}} ->
- # Handle selection - update the appropriate field and close picker
- new_state =
- case s.picker do
- :fruit -> %{s | selected_fruit: item}
- :color -> %{s | selected_color: item}
- :country -> %{s | selected_country: item}
- end
-
- {%{new_state |
- picker: nil,
- picker_state: nil,
- last_action: "Selected: #{item}"
- }, cmds}
-
- {:send, _pid, :cancel} ->
- # Handle cancel - just close picker
- {%{s |
- picker: nil,
- picker_state: nil,
- last_action: "Selection cancelled"
- }, cmds}
-
- _ ->
- {s, cmds}
- end
- end)
- end
-
- @doc """
- Render the current state.
- """
- def view(state) do
- stack(:vertical, [
- # Title
- text("PickList Widget Example", Style.new(fg: :cyan, attrs: [:bold])),
- text(""),
-
- # Instructions
- render_instructions(),
- text(""),
-
- # Current selections
- render_selections(state),
- text(""),
-
- # Status
- render_status(state),
-
- # Picker overlay (if open)
- render_picker(state)
- ])
- end
-
- # ----------------------------------------------------------------------------
- # Private Helpers
- # ----------------------------------------------------------------------------
-
- defp render_instructions do
- stack(:vertical, [
- text("Controls:", Style.new(fg: :yellow)),
- text(" 1 Open fruit picker"),
- text(" 2 Open color picker"),
- text(" 3 Open country picker"),
- text(""),
- text("When picker is open:", Style.new(fg: :yellow)),
- text(" Up/Down Navigate items"),
- text(" PgUp/PgDn Jump 10 items"),
- text(" Home/End Jump to first/last"),
- text(" Enter Confirm selection"),
- text(" Escape Cancel"),
- text(" Typing Filter items"),
- text(" Backspace Remove filter char"),
- text(""),
- text(" Q Quit")
- ])
- end
-
- defp render_selections(state) do
- stack(:vertical, [
- text("Current Selections:", Style.new(fg: :green, attrs: [:bold])),
- text(""),
- render_selection("Fruit", state.selected_fruit),
- render_selection("Color", state.selected_color),
- render_selection("Country", state.selected_country)
- ])
- end
-
- defp render_selection(label, nil) do
- stack(:horizontal, [
- text(" #{String.pad_trailing(label <> ":", 10)}", Style.new(fg: :white)),
- text("(none)", Style.new(fg: :bright_black))
- ])
- end
-
- defp render_selection(label, value) do
- stack(:horizontal, [
- text(" #{String.pad_trailing(label <> ":", 10)}", Style.new(fg: :white)),
- text(value, Style.new(fg: :cyan, attrs: [:bold]))
- ])
- end
-
- defp render_status(state) do
- stack(:horizontal, [
- text("Status: ", Style.new(fg: :yellow)),
- text(state.last_action, Style.new(fg: :white))
- ])
- end
-
- defp render_picker(%{picker: nil}), do: text("")
-
- defp render_picker(state) do
- # Render the picker with a reasonable area
- area = %{x: 0, y: 0, width: 80, height: 24}
- PickList.render(state.picker_state, area)
- end
-
- # ----------------------------------------------------------------------------
- # Public API
- # ----------------------------------------------------------------------------
-
- @doc """
- Run the pick list example application.
- """
- def run do
- TermUI.Runtime.run(root: __MODULE__)
- end
-end
diff --git a/examples/pick_list/lib/pick_list/application.ex b/examples/pick_list/lib/pick_list/application.ex
deleted file mode 100644
index 7de99f65..00000000
--- a/examples/pick_list/lib/pick_list/application.ex
+++ /dev/null
@@ -1,12 +0,0 @@
-defmodule PickList.Application do
- @moduledoc false
-
- use Application
-
- @impl true
- def start(_type, _args) do
- children = []
- opts = [strategy: :one_for_one, name: PickList.Supervisor]
- Supervisor.start_link(children, opts)
- end
-end
diff --git a/examples/pick_list/mix.exs b/examples/pick_list/mix.exs
deleted file mode 100644
index c15919cc..00000000
--- a/examples/pick_list/mix.exs
+++ /dev/null
@@ -1,26 +0,0 @@
-defmodule PickList.MixProject do
- use Mix.Project
-
- def project do
- [
- app: :pick_list,
- version: "0.1.0",
- elixir: "~> 1.15",
- start_permanent: Mix.env() == :prod,
- deps: deps()
- ]
- end
-
- def application do
- [
- extra_applications: [:logger],
- mod: {PickList.Application, []}
- ]
- end
-
- defp deps do
- [
- {:term_ui, path: "../.."}
- ]
- end
-end
diff --git a/examples/pick_list/mix.lock b/examples/pick_list/mix.lock
deleted file mode 100644
index ee8761c0..00000000
--- a/examples/pick_list/mix.lock
+++ /dev/null
@@ -1,12 +0,0 @@
-%{
- "castore": {:hex, :castore, "1.0.17", "4f9770d2d45fbd91dcf6bd404cf64e7e58fed04fadda0923dc32acca0badffa2", [:mix], [], "hexpm", "12d24b9d80b910dd3953e165636d68f147a31db945d2dcb9365e441f8b5351e5"},
- "gen_stage": {:hex, :gen_stage, "1.3.2", "7c77e5d1e97de2c6c2f78f306f463bca64bf2f4c3cdd606affc0100b89743b7b", [:mix], [], "hexpm", "0ffae547fa777b3ed889a6b9e1e64566217413d018cabd825f786e843ffe63e7"},
- "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
- "lumis": {:hex, :lumis, "0.1.0", "6d3b495457d0608f8fe32fe6fa917eb17c921bd0087583a7f4c420c0009888fd", [:mix], [{:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:rustler, "~> 0.29", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.6", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "4de203bc5811ad21f0c6b0442612a3fc1ae9bea7fbfe7037452db9e9dfdaaab3"},
- "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"},
- "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"},
- "mdex": {:hex, :mdex, "0.11.3", "7b815ae2f474e62ad365dfa4d9df87ddec6a01cfa9b7db523a3b21061d909225", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:lumis, "~> 0.1", [hex: :lumis, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}, {:rustler, "~> 0.32", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.7", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "181bf04b13dcae20ab9f336115facc5096aae333a2408a03e53338ee65d4f564"},
- "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
- "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"},
- "rustler_precompiled": {:hex, :rustler_precompiled, "0.8.4", "700a878312acfac79fb6c572bb8b57f5aae05fe1cf70d34b5974850bbf2c05bf", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "3b33d99b540b15f142ba47944f7a163a25069f6d608783c321029bc1ffb09514"},
-}
diff --git a/examples/pick_list/run.exs b/examples/pick_list/run.exs
deleted file mode 100644
index 76004e07..00000000
--- a/examples/pick_list/run.exs
+++ /dev/null
@@ -1 +0,0 @@
-PickList.App.run()
diff --git a/examples/process_monitor/README.md b/examples/process_monitor/README.md
deleted file mode 100644
index 585a52b0..00000000
--- a/examples/process_monitor/README.md
+++ /dev/null
@@ -1,211 +0,0 @@
-# ProcessMonitor Example
-
-A demonstration of the ProcessMonitor widget for live BEAM process inspection and management.
-
-## Widget Overview
-
-The ProcessMonitor widget provides real-time monitoring of BEAM processes with detailed information including PID, name, reductions, memory usage, and message queue depth. It includes powerful features for debugging and process management.
-
-### Key Features
-
-- Live process list with automatic updates
-- Process information (PID, name, reductions, memory, queue length, status)
-- Configurable update interval
-- Sorting by any field (PID, name, reductions, memory, queue, status)
-- Filtering by name or module (regex support)
-- Process details panel with multiple views
-- Process actions (kill, suspend, resume) with confirmation
-- Stack trace visualization
-- Links and monitors display
-- Warning thresholds for queue depth and memory usage
-- System process filtering
-
-### When to Use
-
-Use ProcessMonitor when you need to:
-- Debug BEAM application performance
-- Identify memory leaks or high CPU usage
-- Monitor message queue buildup
-- Inspect process relationships (links/monitors)
-- Analyze process behavior and stack traces
-- Manage running processes (kill/suspend/resume)
-- Track system resource usage
-
-## Widget Options
-
-The ProcessMonitor widget accepts the following options in its `new/1` function:
-
-- `:update_interval` - Refresh interval in milliseconds (default: 1000)
-- `:show_system_processes` - Include system processes (default: false)
-- `:thresholds` - Warning thresholds map (default: see below)
-- `:on_select` - Callback when process is selected `fn process -> ... end`
-- `:on_action` - Callback when action is performed `fn action -> ... end`
-
-### Default Thresholds
-
-```elixir
-%{
- queue_warning: 1000, # Yellow warning
- queue_critical: 10_000, # Red alert
- memory_warning: 50 * 1024 * 1024, # 50MB warning
- memory_critical: 200 * 1024 * 1024 # 200MB alert
-}
-```
-
-### Example Usage
-
-```elixir
-ProcessMonitor.new(
- update_interval: 1000,
- show_system_processes: false,
- thresholds: %{
- queue_warning: 500,
- queue_critical: 5000
- }
-)
-```
-
-## Example Structure
-
-This example contains:
-
-- `lib/process_monitor/app.ex` - Main application demonstrating the ProcessMonitor widget
- - Spawns test worker processes
- - Demonstrates various process states
- - Shows all monitoring features
- - Handles process actions and confirmations
-
-The example spawns test workers that:
-- Generate reductions (simulate work)
-- Build up message queues
-- Allocate memory
-- Can be filtered by name "Worker"
-
-## Running the Example
-
-### Raw Mode (Full TUI Experience)
-
-For the best experience with full terminal control and alternate screen:
-
-```bash
-cd examples/process_monitor
-mix termui.run
-```
-
-Or manually:
-
-```bash
-cd examples/process_monitor
-mix run -e "ProcessMonitorExample.App.run()" --no-halt
-```
-
-### TTY Mode (IEx Compatible)
-
-To run from IEx without taking over the shell:
-
-```bash
-cd examples/process_monitor
-iex -S mix
-```
-
-Then in IEx:
-
-```elixir
-ProcessMonitorExample.App.run()
-```
-
-**Note:** TTY mode works inside IEx but has limitations:
-- No alternate screen buffer (output mixes with IEx prompt)
-- Character input works immediately (no Enter needed)
-- For full TUI, use raw mode instead
-
-## Controls
-
-### Navigation
-- **Up/Down** - Move selection between processes
-- **PageUp/PageDown** - Scroll by page (20 processes)
-- **Home/End** - Jump to first/last process
-
-### Display & Sorting
-- **r** - Refresh process list immediately
-- **s** - Cycle sort field (PID → name → reductions → memory → queue → status)
-- **S** - Toggle sort direction (ascending/descending)
-- **Enter** - Toggle details panel
-
-### Details Views
-- **l** - Show links and monitors
-- **t** - Show stack trace
-- **Enter** - Toggle general info panel
-
-### Filtering
-- **/** - Start filter input (supports regex)
-- **Type** - Enter filter pattern
-- **Enter** - Apply filter
-- **Escape** - Clear filter
-
-### Process Actions
-- **k** - Kill selected process (requires confirmation)
-- **p** - Pause (suspend) or resume selected process
-- **y** - Confirm action
-- **n** - Cancel action
-
-### Example Actions
-- **w** - Spawn 5 test worker processes
-- **q** - Quit the application
-
-## Features Demonstrated
-
-1. **Live Updates** - Process list refreshes every second
-2. **Sorting** - Sort by any column with direction toggle
-3. **Filtering** - Filter processes by name/module (try "Worker")
-4. **Color Coding** - Highlights processes with high queue/memory (yellow/red)
-5. **Details Panel** - Shows comprehensive process information
-6. **Stack Traces** - Displays current call stack
-7. **Links/Monitors** - Shows process relationships
-8. **Process Actions** - Kill, suspend, resume with confirmation
-9. **Test Workers** - Spawn workers to see monitoring in action
-
-## Process Information Display
-
-### Main List Columns
-- **PID** - Process identifier
-- **Name** - Registered name or initial call
-- **Reductions** - CPU work performed (formatted as K/M/B)
-- **Memory** - Process memory usage (formatted as KB/MB/GB)
-- **Queue** - Message queue length
-- **Status** - Process status (running, waiting, suspended, etc.)
-
-### Details Panel Modes
-
-#### Info View (default)
-- Full PID and registered name
-- Current and initial function calls
-- Process status
-- Link and monitor counts
-
-#### Links View
-- Lists linked processes (up to 5)
-- Lists monitored processes (up to 5)
-- Lists processes monitoring this one (up to 5)
-
-#### Trace View
-- Current stack trace (up to 6 frames)
-- Shows module, function, arity, file, and line number
-
-## Color Coding
-
-- **Blue background** - Selected process
-- **Red** - Critical threshold exceeded (queue ≥ 10,000 or memory ≥ 200MB)
-- **Yellow** - Warning threshold exceeded (queue ≥ 1,000 or memory ≥ 50MB)
-- **Magenta** - Suspended process
-- **White** - Normal process
-
-## Implementation Notes
-
-- System processes are filtered by default (kernel, code server, logger, etc.)
-- Update interval can be changed dynamically
-- Process list is fetched on each refresh
-- Dead processes are automatically removed
-- Actions are confirmed before execution
-- Stack traces are fetched on demand
-- The selected process is preserved across refreshes when possible
diff --git a/examples/process_monitor/lib/process_monitor/app.ex b/examples/process_monitor/lib/process_monitor/app.ex
deleted file mode 100644
index e66427ed..00000000
--- a/examples/process_monitor/lib/process_monitor/app.ex
+++ /dev/null
@@ -1,282 +0,0 @@
-defmodule ProcessMonitorExample.App do
- @moduledoc """
- Example application demonstrating the ProcessMonitor widget.
-
- This example shows:
- - Live BEAM process monitoring
- - Process info (PID, name, reductions, memory, queue)
- - Sorting and filtering
- - Process details and stack traces
- - Process actions (kill, suspend, resume)
-
- ## Controls
-
- - Up/Down: Move selection
- - PageUp/PageDown: Scroll by page
- - Enter: Toggle details panel
- - r: Refresh now
- - s: Cycle sort field
- - S: Toggle sort direction
- - /: Start filter input
- - k: Kill selected process (with confirmation)
- - p: Pause/resume selected process
- - l: Show links/monitors
- - t: Show stack trace
- - w: Spawn worker processes
- - Escape: Clear filter/close details
- - q: Quit
- """
-
- use TermUI.Elm
-
- alias TermUI.Event
- alias TermUI.Widgets.ProcessMonitor
- alias TermUI.Renderer.Style
-
- # ----------------------------------------------------------------------------
- # Component Callbacks
- # ----------------------------------------------------------------------------
-
- @doc """
- Initialize the component state.
- """
- @impl true
- def init(_args) do
- props =
- ProcessMonitor.new(
- update_interval: 1000,
- show_system_processes: false
- )
-
- {:ok, monitor_state} = ProcessMonitor.init(props)
-
- %{
- monitor_state: monitor_state,
- message: "ProcessMonitor Example - Press w to spawn test workers",
- worker_pids: []
- }
- end
-
- @doc """
- Convert events to messages.
- """
- @impl true
- def event_to_msg(%Event.Key{char: "q"}, %{monitor_state: %{filter_input: nil}}) do
- {:msg, :quit}
- end
-
- def event_to_msg(%Event.Key{key: key}, _state)
- when key in [:up, :down, :page_up, :page_down, :home, :end] do
- {:msg, {:monitor_event, %Event.Key{key: key}}}
- end
-
- def event_to_msg(%Event.Key{key: :enter}, _state) do
- {:msg, {:monitor_event, %Event.Key{key: :enter}}}
- end
-
- def event_to_msg(%Event.Key{char: "r"}, _state) do
- {:msg, :refresh_monitor}
- end
-
- def event_to_msg(%Event.Key{char: "s"}, _state) do
- {:msg, {:monitor_event, %Event.Key{char: "s"}}}
- end
-
- def event_to_msg(%Event.Key{char: "S"}, _state) do
- {:msg, {:monitor_event, %Event.Key{char: "S"}}}
- end
-
- def event_to_msg(%Event.Key{char: "/"}, _state) do
- {:msg, {:monitor_event, %Event.Key{char: "/"}}}
- end
-
- def event_to_msg(%Event.Key{char: "l"}, _state) do
- {:msg, {:monitor_event, %Event.Key{char: "l"}}}
- end
-
- def event_to_msg(%Event.Key{char: "t"}, _state) do
- {:msg, {:monitor_event, %Event.Key{char: "t"}}}
- end
-
- def event_to_msg(%Event.Key{char: "k"}, _state) do
- {:msg, {:monitor_event, %Event.Key{char: "k"}}}
- end
-
- def event_to_msg(%Event.Key{char: "p"}, _state) do
- {:msg, {:monitor_event, %Event.Key{char: "p"}}}
- end
-
- def event_to_msg(%Event.Key{char: "y"}, _state) do
- {:msg, {:monitor_event, %Event.Key{char: "y"}}}
- end
-
- def event_to_msg(%Event.Key{char: "n"}, _state) do
- {:msg, {:monitor_event, %Event.Key{char: "n"}}}
- end
-
- def event_to_msg(%Event.Key{key: :escape}, _state) do
- {:msg, {:monitor_event, %Event.Key{key: :escape}}}
- end
-
- def event_to_msg(%Event.Key{char: "w"}, _state) do
- {:msg, :spawn_workers}
- end
-
- def event_to_msg(%Event.Key{char: char}, %{monitor_state: %{filter_input: input}})
- when input != nil and char != nil do
- {:msg, {:monitor_event, %Event.Key{char: char}}}
- end
-
- def event_to_msg(%Event.Key{key: :backspace}, %{monitor_state: %{filter_input: input}})
- when input != nil do
- {:msg, {:monitor_event, %Event.Key{key: :backspace}}}
- end
-
- def event_to_msg(_event, _state) do
- :ignore
- end
-
- @doc """
- Update state based on messages.
- """
- @impl true
- def update(:quit, model) do
- # Cleanup workers
- Enum.each(model.worker_pids, fn pid ->
- if Process.alive?(pid), do: Process.exit(pid, :shutdown)
- end)
-
- {model, [:quit]}
- end
-
- def update(:refresh_monitor, model) do
- {:ok, monitor_state} = ProcessMonitor.refresh(model.monitor_state)
- {%{model | monitor_state: monitor_state, message: "Refreshed"}, []}
- end
-
- def update(:spawn_workers, model) do
- new_pids = spawn_workers(5)
- {:ok, monitor_state} = ProcessMonitor.refresh(model.monitor_state)
-
- {%{
- model
- | monitor_state: monitor_state,
- worker_pids: model.worker_pids ++ new_pids,
- message: "Spawned 5 test workers (filter 'Worker' to see them)"
- }, []}
- end
-
- def update({:monitor_event, event}, model) do
- {:ok, monitor_state} = ProcessMonitor.handle_event(event, model.monitor_state)
-
- # Update message based on event
- message =
- case event do
- %Event.Key{char: "s"} ->
- "Sort: #{monitor_state.sort_field}"
-
- %Event.Key{char: "S"} ->
- dir = if monitor_state.sort_direction == :asc, do: "ascending", else: "descending"
- "Sort direction: #{dir}"
-
- %Event.Key{char: "l"} ->
- "Showing links/monitors"
-
- %Event.Key{char: "t"} ->
- "Showing stack trace"
-
- %Event.Key{char: "y"} ->
- "Action confirmed"
-
- %Event.Key{char: "n"} ->
- "Action cancelled"
-
- _ ->
- model.message
- end
-
- {%{model | monitor_state: monitor_state, message: message}, []}
- end
-
- def update(_msg, model) do
- {model, []}
- end
-
- @doc """
- Render the application view.
- """
- @impl true
- def view(model) do
- area = %{x: 0, y: 0, width: 100, height: 25}
- monitor_view = ProcessMonitor.render(model.monitor_state, area)
-
- stack(:vertical, [
- text("ProcessMonitor Widget Example", Style.new(fg: :cyan, attrs: [:bold])),
- text(model.message, Style.new(fg: :yellow)),
- text("", nil),
- monitor_view,
- text("", nil),
- text("[w] Spawn workers | [q] Quit", Style.new(fg: :white, attrs: [:dim]))
- ])
- end
-
- # ----------------------------------------------------------------------------
- # Helpers
- # ----------------------------------------------------------------------------
-
- # Spawn some test worker processes
- defp spawn_workers(count) do
- Enum.map(1..count, fn i ->
- spawn(fn ->
- Process.register(self(), :"Worker_#{System.unique_integer([:positive])}")
- worker_loop(i)
- end)
- end)
- end
-
- defp worker_loop(id) do
- # Do some work to generate reductions
- _ = Enum.reduce(1..1000, 0, &(&1 + &2))
-
- # Randomly vary behavior
- case rem(id, 3) do
- 0 ->
- # Normal worker
- Process.sleep(100)
-
- 1 ->
- # Worker with message queue buildup
- Enum.each(1..50, fn _ -> send(self(), :work) end)
- Process.sleep(200)
-
- 2 ->
- # Worker with more memory
- _data = :binary.copy(<<0>>, 10_000)
- Process.sleep(150)
- end
-
- # Clear messages
- receive_all()
-
- worker_loop(id)
- end
-
- defp receive_all do
- receive do
- _ -> receive_all()
- after
- 0 -> :ok
- end
- end
-
- # ----------------------------------------------------------------------------
- # Run
- # ----------------------------------------------------------------------------
-
- @doc """
- Run the process monitor example application.
- """
- def run do
- TermUI.Runtime.run(root: __MODULE__)
- end
-end
diff --git a/examples/process_monitor/lib/process_monitor/application.ex b/examples/process_monitor/lib/process_monitor/application.ex
deleted file mode 100644
index ba21dc33..00000000
--- a/examples/process_monitor/lib/process_monitor/application.ex
+++ /dev/null
@@ -1,12 +0,0 @@
-defmodule ProcessMonitorExample.Application do
- @moduledoc false
-
- use Application
-
- @impl true
- def start(_type, _args) do
- children = []
- opts = [strategy: :one_for_one, name: ProcessMonitorExample.Supervisor]
- Supervisor.start_link(children, opts)
- end
-end
diff --git a/examples/process_monitor/mix.exs b/examples/process_monitor/mix.exs
deleted file mode 100644
index cacca977..00000000
--- a/examples/process_monitor/mix.exs
+++ /dev/null
@@ -1,26 +0,0 @@
-defmodule ProcessMonitorExample.MixProject do
- use Mix.Project
-
- def project do
- [
- app: :process_monitor_example,
- version: "0.1.0",
- elixir: "~> 1.15",
- start_permanent: Mix.env() == :prod,
- deps: deps()
- ]
- end
-
- def application do
- [
- extra_applications: [:logger],
- mod: {ProcessMonitorExample.Application, []}
- ]
- end
-
- defp deps do
- [
- {:term_ui, path: "../.."}
- ]
- end
-end
diff --git a/examples/process_monitor/mix.lock b/examples/process_monitor/mix.lock
deleted file mode 100644
index ee8761c0..00000000
--- a/examples/process_monitor/mix.lock
+++ /dev/null
@@ -1,12 +0,0 @@
-%{
- "castore": {:hex, :castore, "1.0.17", "4f9770d2d45fbd91dcf6bd404cf64e7e58fed04fadda0923dc32acca0badffa2", [:mix], [], "hexpm", "12d24b9d80b910dd3953e165636d68f147a31db945d2dcb9365e441f8b5351e5"},
- "gen_stage": {:hex, :gen_stage, "1.3.2", "7c77e5d1e97de2c6c2f78f306f463bca64bf2f4c3cdd606affc0100b89743b7b", [:mix], [], "hexpm", "0ffae547fa777b3ed889a6b9e1e64566217413d018cabd825f786e843ffe63e7"},
- "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
- "lumis": {:hex, :lumis, "0.1.0", "6d3b495457d0608f8fe32fe6fa917eb17c921bd0087583a7f4c420c0009888fd", [:mix], [{:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:rustler, "~> 0.29", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.6", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "4de203bc5811ad21f0c6b0442612a3fc1ae9bea7fbfe7037452db9e9dfdaaab3"},
- "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"},
- "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"},
- "mdex": {:hex, :mdex, "0.11.3", "7b815ae2f474e62ad365dfa4d9df87ddec6a01cfa9b7db523a3b21061d909225", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:lumis, "~> 0.1", [hex: :lumis, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}, {:rustler, "~> 0.32", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.7", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "181bf04b13dcae20ab9f336115facc5096aae333a2408a03e53338ee65d4f564"},
- "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
- "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"},
- "rustler_precompiled": {:hex, :rustler_precompiled, "0.8.4", "700a878312acfac79fb6c572bb8b57f5aae05fe1cf70d34b5974850bbf2c05bf", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "3b33d99b540b15f142ba47944f7a163a25069f6d608783c321029bc1ffb09514"},
-}
diff --git a/examples/process_monitor/run.exs b/examples/process_monitor/run.exs
deleted file mode 100644
index 35842573..00000000
--- a/examples/process_monitor/run.exs
+++ /dev/null
@@ -1 +0,0 @@
-ProcessMonitorExample.App.run()
diff --git a/examples/showcase/.formatter.exs b/examples/showcase/.formatter.exs
new file mode 100644
index 00000000..682fc6c6
--- /dev/null
+++ b/examples/showcase/.formatter.exs
@@ -0,0 +1,3 @@
+[
+ inputs: ["{mix,.formatter}.exs", "{lib,test}/**/*.{ex,exs}"]
+]
diff --git a/examples/showcase/README.md b/examples/showcase/README.md
new file mode 100644
index 00000000..64628de1
--- /dev/null
+++ b/examples/showcase/README.md
@@ -0,0 +1,110 @@
+# Interactive showcase
+
+This application is a representative executable demo of TermUI widgets and architecture.
+It uses only the public Elm application, widget, frame, event, command, and
+clipboard contracts.
+
+## Run it
+
+Use a terminal of at least 80 columns by 24 rows when possible.
+
+```sh
+cd examples/showcase
+mix deps.get
+mix run run.exs
+```
+
+The application also works from IEx:
+
+```elixir
+Showcase.App.run()
+```
+
+Live mode is the default. For fixed output in a test or documentation session,
+use:
+
+```elixir
+Showcase.App.run(data_mode: :snapshot)
+```
+
+## Controls
+
+- Escape opens the command menu. Press 1 through 6 to select a page.
+- In the command menu, N and P select the next or previous page, R refreshes
+ data, T changes the theme, and Q stops the application.
+- Ctrl+N and Ctrl+P select the next or previous page without opening the menu.
+- Ctrl+Left and Ctrl+Right also select pages when the terminal sends those
+ key combinations.
+- Ctrl+R requests an immediate live refresh.
+- Ctrl+T changes the showcase theme.
+- Ctrl+Q stops the application and restores the terminal without opening the
+ menu.
+- Tab changes focus on pages with multiple controls.
+- The footer and page status show local controls.
+
+## Pages
+
+### Overview
+
+Shows gauges, progress, sparklines, bars, and a selectable table driven by live
+BEAM memory, process, scheduler, and run-queue values. Rendering does not
+collect data or perform effects.
+
+### Inputs
+
+Shows single-line and multiline text input, multiple selection, keyboard focus,
+button output, and copy messages. The parent routes each event to one focused
+widget.
+
+### Content
+
+Shows Markdown, diff, and bounded stream widgets. Use `[` and `]` to change the
+active widget. The stream records each live BEAM refresh. The Markdown sample
+includes a copyable code block. The parent converts its copy message to
+serialized clipboard command data.
+
+### BEAM
+
+Shows process, runtime-tree, and cluster widgets. `Showcase.LiveData` collects
+local process details, runtime links, and local or connected-node values. The
+parent supplies each result to the pure widgets. The widgets do not inspect
+processes, monitor nodes, or perform RPC.
+
+### Architecture
+
+Explains the application, widget, frame, and backend ownership seams inside the
+running TermUI application.
+
+### Controls
+
+Shows checkbox, toggle, radio group, select, spinner, and breadcrumb widgets.
+The application owns all Controls page state. Its existing refresh timer
+advances the pure spinner state.
+
+## Structure
+
+`Showcase.App` is the only Elm application. It owns global state, every page
+state, timers, asynchronous collection commands, clipboard commands, terminal
+dimensions, and final frame composition.
+
+Each module in `Showcase.Pages` is a pure page adapter:
+
+```elixir
+{page_state, messages} = Page.update(event, page_state)
+frame = Page.view(page_state, dimensions, theme)
+```
+
+A page does not start a process or a nested runtime. `Showcase.LiveData`
+collects external values inside a runtime-managed asynchronous command.
+`Showcase.Layout` contains only frame composition helpers.
+
+## Tests
+
+```sh
+mix test
+```
+
+The tests use explicit snapshot mode and render every page at normal and compact
+terminal sizes. They also check live collection, state ownership, input routing,
+timers, and clipboard command output. CI compiles and tests this standalone Mix
+application so the documentation cannot silently drift from the public API.
diff --git a/examples/showcase/lib/showcase/app.ex b/examples/showcase/lib/showcase/app.ex
new file mode 100644
index 00000000..b0e42f8d
--- /dev/null
+++ b/examples/showcase/lib/showcase/app.ex
@@ -0,0 +1,330 @@
+defmodule Showcase.App do
+ @moduledoc "The interactive TermUI widget and architecture showcase."
+
+ use TermUI.Elm
+
+ alias TermUI.Event.{Key, Resize, Text}
+ alias TermUI.{Clipboard, Command, Frame, Style}
+ alias Showcase.{LiveData, SnapshotData}
+ alias Showcase.Pages.{Architecture, Beam, Content, Controls, Inputs, Overview}
+
+ @refresh_interval 1_000
+ @pages [
+ {:overview, "Overview", Overview},
+ {:inputs, "Inputs", Inputs},
+ {:content, "Content", Content},
+ {:beam, "BEAM", Beam},
+ {:architecture, "Architecture", Architecture},
+ {:controls, "Controls", Controls}
+ ]
+
+ @impl true
+ def init(opts) do
+ data_mode = Keyword.get(opts, :data_mode, :live)
+
+ unless data_mode in [:live, :snapshot] do
+ raise ArgumentError, "data_mode must be :live or :snapshot"
+ end
+
+ page_states = Map.new(@pages, fn {id, _label, module} -> {id, module.init()} end)
+
+ state = %{
+ command_mode: false,
+ data_mode: data_mode,
+ dimensions: Keyword.fetch!(opts, :dimensions),
+ last_snapshot: nil,
+ page: :overview,
+ page_states: page_states,
+ refreshing: data_mode == :live,
+ theme: :dark,
+ status: "Press Escape, then 1 through 6, to select a page"
+ }
+
+ case data_mode do
+ :live -> {state, [collect_command(self()), refresh_timer()]}
+ :snapshot -> state |> apply_snapshot(SnapshotData.snapshot()) |> snapshot_status()
+ end
+ end
+
+ @impl true
+ def event_to_msg(%Resize{width: width, height: height}, _state),
+ do: {:msg, {:resize, width, height}}
+
+ def event_to_msg(%Key{key: :escape}, _state), do: {:msg, :toggle_command_mode}
+
+ def event_to_msg(%Text{text: key}, %{command_mode: true})
+ when key in ["1", "2", "3", "4", "5", "6", "n", "p", "q", "r", "t"],
+ do: {:msg, {:command_key, key}}
+
+ def event_to_msg(%Key{key: key}, %{command_mode: true}) when key in [:left, :right],
+ do: {:msg, {:command_key, key}}
+
+ def event_to_msg(%Key{key: key, modifiers: modifiers} = event, _state)
+ when key in [:left, :right] do
+ if :ctrl in modifiers,
+ do: {:msg, arrow_message(key)},
+ else: {:msg, {:page_event, event}}
+ end
+
+ def event_to_msg(%Key{key: key, modifiers: modifiers} = event, _state)
+ when key in ["n", "p", "q", "r", "t"] do
+ if :ctrl in modifiers,
+ do: {:msg, control_message(key)},
+ else: {:msg, {:page_event, event}}
+ end
+
+ def event_to_msg(_event, %{command_mode: true}), do: {:msg, :close_command_mode}
+
+ def event_to_msg(event, _state), do: {:msg, {:page_event, event}}
+
+ @impl true
+ def update({:resize, width, height}, state),
+ do: %{state | dimensions: {width, height}, status: "Terminal resized to #{width}x#{height}"}
+
+ def update({:select_page, page}, state), do: select_page(state, page)
+ def update(:next_page, state), do: move_page(state, 1)
+ def update(:previous_page, state), do: move_page(state, -1)
+ def update(:toggle_theme, state), do: toggle_theme(state)
+ def update(:quit, state), do: {state, [Command.shutdown()]}
+ def update(:refresh, state), do: request_refresh(state)
+ def update(:toggle_command_mode, state), do: toggle_command_mode(state)
+ def update(:close_command_mode, state), do: close_command_mode(state)
+ def update({:command_key, key}, state), do: apply_command_key(state, key)
+
+ def update(:refresh_timer, state) do
+ {state, commands} = request_refresh(state)
+ {state, commands ++ [refresh_timer()]}
+ end
+
+ def update({:page_event, event}, state) do
+ {state, messages} = update_current_page(state, event)
+ apply_page_messages(state, messages)
+ end
+
+ def update({:clipboard_result, :ok}, state), do: %{state | status: "Copied to clipboard"}
+
+ def update({:clipboard_result, {:error, reason}}, state),
+ do: %{state | status: "Clipboard error: #{inspect(reason)}"}
+
+ def update({:live_snapshot, {:ok, snapshot}}, state) do
+ state
+ |> apply_snapshot(snapshot)
+ |> Map.put(:refreshing, false)
+ |> live_status()
+ end
+
+ def update({:live_snapshot, {:error, reason}}, state),
+ do: %{state | refreshing: false, status: "Live refresh failed: #{inspect(reason)}"}
+
+ @impl true
+ def view(%{dimensions: {width, height}} = state) do
+ if height < 4 do
+ Frame.from_rows([[{" TermUI Showcase ", header_style(state.theme)}]], width, height)
+ else
+ {_id, label, module} = current_page(state)
+ content_height = height - 3
+
+ content =
+ module.view(
+ Map.fetch!(state.page_states, state.page),
+ {width, content_height},
+ state.theme
+ )
+
+ Frame.new(width, height)
+ |> Frame.put_row(1, header_row(label, state.data_mode, state.theme))
+ |> Frame.put_row(2, page_row(state.page, state.theme))
+ |> Frame.put_row(height, footer_row(state, width))
+ |> Frame.overlay(content, 1, 3)
+ end
+ end
+
+ @doc "Runs the showcase in the current terminal."
+ @spec run(keyword()) :: :ok | {:error, term()}
+ def run(opts \\ []), do: TermUI.run(__MODULE__, opts)
+
+ @doc false
+ def pages, do: @pages
+
+ defp update_current_page(state, event) do
+ {_id, _label, module} = current_page(state)
+ page_state = Map.fetch!(state.page_states, state.page)
+ {page_state, messages} = module.update(event, page_state)
+ {%{state | page_states: Map.put(state.page_states, state.page, page_state)}, messages}
+ end
+
+ defp apply_page_messages(state, messages) do
+ {commands, status} =
+ Enum.reduce(messages, {[], nil}, fn
+ {:copy, text}, {commands, _status} ->
+ {[Clipboard.copy(text) | commands], "Copy requested"}
+
+ :refresh_requested, {commands, _status} ->
+ {[Command.message(:refresh) | commands], "Refresh requested"}
+
+ message, {commands, _status} ->
+ {commands, format_message(message)}
+ end)
+
+ state = if status, do: %{state | status: status}, else: state
+ if commands == [], do: state, else: {state, Enum.reverse(commands)}
+ end
+
+ defp current_page(state),
+ do: Enum.find(@pages, fn {id, _label, _module} -> id == state.page end)
+
+ defp select_page(state, page) do
+ case Enum.find(@pages, fn {id, _label, _module} -> id == page end) do
+ nil ->
+ state
+
+ {_id, label, module} ->
+ %{state | command_mode: false, page: page, status: label <> ": " <> module.help()}
+ end
+ end
+
+ defp move_page(state, delta) do
+ ids = Enum.map(@pages, &elem(&1, 0))
+ index = Enum.find_index(ids, &(&1 == state.page)) || 0
+ next = rem(index + delta + length(ids), length(ids))
+ select_page(state, Enum.at(ids, next))
+ end
+
+ defp toggle_theme(state) do
+ theme = if state.theme == :dark, do: :light, else: :dark
+ %{state | command_mode: false, theme: theme, status: "Theme: #{theme}"}
+ end
+
+ defp header_row(label, data_mode, theme) do
+ [
+ {" TermUI Showcase ", header_style(theme)},
+ {" #{label} · #{data_mode |> Atom.to_string() |> String.upcase()}",
+ Style.new(fg: accent(theme), attrs: [:bold])}
+ ]
+ end
+
+ defp page_row(selected, theme) do
+ @pages
+ |> Enum.with_index(1)
+ |> Enum.flat_map(fn {{id, label, _module}, index} ->
+ style =
+ if id == selected,
+ do: Style.new(fg: :black, bg: accent(theme), attrs: [:bold]),
+ else: Style.new(fg: :bright_black)
+
+ [{" #{index} #{label} ", style}, " "]
+ end)
+ end
+
+ defp footer_row(%{command_mode: true}, width) do
+ menu = "Choose 1-6 page N/P next R refresh T theme Q quit Esc close"
+ [{Frame.fit(menu, width), Style.new(fg: :black, bg: :cyan, attrs: [:bold])}]
+ end
+
+ defp footer_row(%{status: status}, width) do
+ controls = "Esc menu Ctrl+N/P page Ctrl+R refresh Ctrl+Q quit"
+ available = max(width - String.length(controls) - 1, 0)
+
+ [
+ {Frame.fit(status, available), Style.new(fg: :bright_black)},
+ {controls, Style.new(fg: :cyan)}
+ ]
+ end
+
+ defp header_style(:light), do: Style.new(fg: :white, bg: :blue, attrs: [:bold])
+ defp header_style(:dark), do: Style.new(fg: :black, bg: :cyan, attrs: [:bold])
+ defp accent(:light), do: :blue
+ defp accent(:dark), do: :cyan
+
+ defp control_message("n"), do: :next_page
+ defp control_message("p"), do: :previous_page
+ defp control_message("q"), do: :quit
+ defp control_message("r"), do: :refresh
+ defp control_message("t"), do: :toggle_theme
+
+ defp arrow_message(:left), do: :previous_page
+ defp arrow_message(:right), do: :next_page
+
+ defp toggle_command_mode(%{command_mode: false} = state) do
+ %{
+ state
+ | command_mode: true,
+ status: "Choose 1-6 page, N/P next, R refresh, T theme, or Q quit"
+ }
+ end
+
+ defp toggle_command_mode(state), do: close_command_mode(state)
+
+ defp close_command_mode(state),
+ do: %{state | command_mode: false, status: "Command menu closed"}
+
+ defp apply_command_key(state, key) do
+ state = %{state | command_mode: false}
+
+ case command_message(key) do
+ :refresh -> request_refresh(state)
+ message -> update(message, state)
+ end
+ end
+
+ defp command_message("1"), do: {:select_page, :overview}
+ defp command_message("2"), do: {:select_page, :inputs}
+ defp command_message("3"), do: {:select_page, :content}
+ defp command_message("4"), do: {:select_page, :beam}
+ defp command_message("5"), do: {:select_page, :architecture}
+ defp command_message("6"), do: {:select_page, :controls}
+ defp command_message("n"), do: :next_page
+ defp command_message("p"), do: :previous_page
+ defp command_message("q"), do: :quit
+ defp command_message("r"), do: :refresh
+ defp command_message("t"), do: :toggle_theme
+ defp command_message(:left), do: :previous_page
+ defp command_message(:right), do: :next_page
+
+ defp request_refresh(%{data_mode: :live, refreshing: false} = state),
+ do:
+ {%{state | refreshing: true, status: "Collecting live BEAM data"},
+ [collect_command(self())]}
+
+ defp request_refresh(%{data_mode: :live} = state), do: {state, []}
+
+ defp request_refresh(%{data_mode: :snapshot} = state) do
+ state = state |> apply_snapshot(SnapshotData.snapshot()) |> snapshot_status()
+ {state, []}
+ end
+
+ defp apply_snapshot(state, snapshot) do
+ page_states =
+ state.page_states
+ |> Map.update!(:overview, &Overview.set_snapshot(&1, snapshot))
+ |> Map.update!(:content, &Content.set_snapshot(&1, snapshot))
+ |> Map.update!(:beam, &Beam.set_snapshot(&1, snapshot))
+ |> Map.update!(:controls, &Controls.tick/1)
+
+ %{state | last_snapshot: snapshot, page_states: page_states}
+ end
+
+ defp live_status(state) do
+ system = state.last_snapshot.system
+
+ %{
+ state
+ | status:
+ "Live: #{system.process_count} processes, run queue #{system.run_queue}, " <>
+ "#{length(state.last_snapshot.cluster)} nodes"
+ }
+ end
+
+ defp snapshot_status(state), do: %{state | status: "Fixed snapshot mode for tests and docs"}
+
+ defp collect_command(runtime) do
+ Command.async(fn -> LiveData.collect(runtime) end, &{:live_snapshot, &1})
+ end
+
+ defp refresh_timer, do: Command.timer(@refresh_interval, :refresh_timer)
+
+ defp format_message({kind, value}) when kind in [:changed, :submit, :selected, :picked],
+ do: "#{kind}: #{inspect(value, limit: 3)}"
+
+ defp format_message(message), do: inspect(message, limit: 4)
+end
diff --git a/examples/showcase/lib/showcase/layout.ex b/examples/showcase/lib/showcase/layout.ex
new file mode 100644
index 00000000..58501be0
--- /dev/null
+++ b/examples/showcase/lib/showcase/layout.ex
@@ -0,0 +1,47 @@
+defmodule Showcase.Layout do
+ @moduledoc false
+
+ alias TermUI.{Frame, Style}
+ alias TermUI.Widget.Block
+
+ @spec panel(Frame.t(), String.t(), {pos_integer(), pos_integer()}, keyword()) :: Frame.t()
+ def panel(%Frame{} = child, title, dimensions, opts \\ []) do
+ border_style =
+ if Keyword.get(opts, :active, false) do
+ Style.new(fg: theme_color(Keyword.get(opts, :theme, :dark)), attrs: [:bold])
+ else
+ Style.new(fg: :bright_black)
+ end
+
+ [title: title, border_style: border_style]
+ |> Block.init()
+ |> Block.compose(dimensions, child)
+ end
+
+ @spec selector([{term(), String.t()}], term(), pos_integer()) :: Frame.t()
+ def selector(items, selected, width) do
+ active = Style.new(fg: :black, bg: :cyan, attrs: [:bold])
+ normal = Style.new(fg: :bright_black)
+
+ row =
+ Enum.flat_map(items, fn {id, label} ->
+ style = if id == selected, do: active, else: normal
+ [{" " <> label <> " ", style}, " "]
+ end)
+
+ Frame.from_rows([row], width, 1)
+ end
+
+ @spec split_widths(pos_integer(), pos_integer()) :: {pos_integer(), pos_integer()}
+ def split_widths(width, gap \\ 1) do
+ available = max(width - gap, 2)
+ left = div(available, 2)
+ {max(left, 1), max(available - left, 1)}
+ end
+
+ @spec without_cursor(Frame.t()) :: Frame.t()
+ def without_cursor(%Frame{} = frame), do: %{frame | cursor: nil}
+
+ defp theme_color(:light), do: :blue
+ defp theme_color(:dark), do: :cyan
+end
diff --git a/examples/showcase/lib/showcase/live_data.ex b/examples/showcase/lib/showcase/live_data.ex
new file mode 100644
index 00000000..735d5d2d
--- /dev/null
+++ b/examples/showcase/lib/showcase/live_data.ex
@@ -0,0 +1,173 @@
+defmodule Showcase.LiveData do
+ @moduledoc "Collects live BEAM snapshots outside the showcase update and view functions."
+
+ alias TermUI.Widget.TreeView
+
+ @process_limit 40
+ @rpc_timeout 500
+
+ @doc "Collects one live snapshot for the showcase application."
+ @spec collect(pid()) :: map()
+ def collect(runtime) when is_pid(runtime) do
+ system = system_snapshot()
+
+ %{
+ collected_at: System.system_time(:second),
+ system: system,
+ processes: process_snapshots(runtime),
+ runtime_tree: runtime_tree(runtime),
+ cluster: cluster_snapshots()
+ }
+ end
+
+ defp system_snapshot do
+ memory = :erlang.memory() |> Map.new()
+ schedulers = :erlang.system_info(:schedulers)
+ schedulers_online = :erlang.system_info(:schedulers_online)
+ process_count = :erlang.system_info(:process_count)
+ process_limit = :erlang.system_info(:process_limit)
+ run_queue = :erlang.statistics(:run_queue)
+
+ %{
+ memory: memory,
+ process_count: process_count,
+ process_limit: process_limit,
+ run_queue: run_queue,
+ run_queue_load: percent(run_queue, max(schedulers_online, 1)),
+ schedulers: schedulers,
+ schedulers_online: schedulers_online
+ }
+ end
+
+ defp process_snapshots(runtime) do
+ snapshots =
+ Process.list()
+ |> Enum.map(&process_snapshot/1)
+ |> Enum.reject(&is_nil/1)
+ |> Enum.sort_by(& &1.memory, :desc)
+
+ top = Enum.take(snapshots, @process_limit)
+ runtime_pid = inspect(runtime)
+
+ if Enum.any?(top, &(&1.pid == runtime_pid)) do
+ top
+ else
+ runtime_snapshot = Enum.find(snapshots, &(&1.pid == runtime_pid))
+ [runtime_snapshot | Enum.take(top, @process_limit - 1)] |> Enum.reject(&is_nil/1)
+ end
+ end
+
+ defp process_snapshot(pid) do
+ fields = [
+ :registered_name,
+ :memory,
+ :reductions,
+ :message_queue_len,
+ :status,
+ :current_function
+ ]
+
+ case Process.info(pid, fields) do
+ nil ->
+ nil
+
+ info ->
+ %{
+ pid: inspect(pid),
+ name: process_name(info),
+ memory: Keyword.fetch!(info, :memory),
+ reductions: Keyword.fetch!(info, :reductions),
+ message_queue_len: Keyword.fetch!(info, :message_queue_len),
+ status: Keyword.fetch!(info, :status)
+ }
+ end
+ end
+
+ defp process_name(info) do
+ case Keyword.fetch!(info, :registered_name) do
+ name when is_atom(name) -> Atom.to_string(name)
+ _other -> info |> Keyword.fetch!(:current_function) |> elem(0) |> inspect()
+ end
+ end
+
+ defp runtime_tree(runtime) do
+ children =
+ case Process.info(runtime, :links) do
+ {:links, links} -> links |> Enum.filter(&is_pid/1) |> Enum.map(&runtime_link/1)
+ nil -> []
+ end
+
+ [TreeView.branch(:runtime, "TermUI.Runtime #{inspect(runtime)}", children)]
+ end
+
+ defp runtime_link(pid) do
+ label =
+ case Process.info(pid, [:registered_name, :current_function]) do
+ nil ->
+ "stopped #{inspect(pid)}"
+
+ info ->
+ name = process_name(info)
+ "#{name} #{inspect(pid)}"
+ end
+
+ TreeView.leaf(pid, label)
+ end
+
+ defp cluster_snapshots do
+ [node() | Node.list(:connected)]
+ |> Enum.uniq()
+ |> Enum.map(&cluster_snapshot/1)
+ end
+
+ defp cluster_snapshot(node) when node == node() do
+ {uptime, _since_last_call} = :erlang.statistics(:wall_clock)
+
+ %{
+ node: node,
+ status: "up",
+ processes: :erlang.system_info(:process_count),
+ memory: format_bytes(:erlang.memory(:total)),
+ uptime: format_duration(uptime)
+ }
+ end
+
+ defp cluster_snapshot(node) do
+ processes = remote(node, :system_info, [:process_count])
+ memory = remote(node, :memory, [:total])
+ wall_clock = remote(node, :statistics, [:wall_clock])
+
+ if is_integer(processes) and is_integer(memory) and is_tuple(wall_clock) do
+ %{
+ node: node,
+ status: "up",
+ processes: processes,
+ memory: format_bytes(memory),
+ uptime: wall_clock |> elem(0) |> format_duration()
+ }
+ else
+ %{node: node, status: "unavailable", processes: 0, memory: "-", uptime: "-"}
+ end
+ end
+
+ defp remote(node, function, args) do
+ case :rpc.call(node, :erlang, function, args, @rpc_timeout) do
+ {:badrpc, _reason} -> nil
+ value -> value
+ end
+ end
+
+ defp percent(value, total) when total > 0,
+ do: value |> Kernel.*(100) |> Kernel./(total) |> round() |> min(100) |> max(0)
+
+ defp format_bytes(bytes) when bytes < 1_024, do: "#{bytes} B"
+ defp format_bytes(bytes) when bytes < 1_048_576, do: "#{Float.round(bytes / 1_024, 1)} KB"
+ defp format_bytes(bytes), do: "#{Float.round(bytes / 1_048_576, 1)} MB"
+
+ defp format_duration(milliseconds) do
+ seconds = div(milliseconds, 1_000)
+ hours = div(seconds, 3_600)
+ minutes = seconds |> rem(3_600) |> div(60)
+ "#{hours}h #{String.pad_leading(Integer.to_string(minutes), 2, "0")}m"
+ end
+end
diff --git a/examples/showcase/lib/showcase/page.ex b/examples/showcase/lib/showcase/page.ex
new file mode 100644
index 00000000..a743397b
--- /dev/null
+++ b/examples/showcase/lib/showcase/page.ex
@@ -0,0 +1,13 @@
+defmodule Showcase.Page do
+ @moduledoc false
+
+ @type dimensions :: {pos_integer(), pos_integer()}
+ @type state :: term()
+ @type message :: term()
+ @type theme :: :dark | :light
+
+ @callback init() :: state()
+ @callback update(term(), state()) :: {state(), [message()]}
+ @callback view(state(), dimensions(), theme()) :: TermUI.Frame.t()
+ @callback help() :: String.t()
+end
diff --git a/examples/showcase/lib/showcase/pages/architecture.ex b/examples/showcase/lib/showcase/pages/architecture.ex
new file mode 100644
index 00000000..fa2432db
--- /dev/null
+++ b/examples/showcase/lib/showcase/pages/architecture.ex
@@ -0,0 +1,65 @@
+defmodule Showcase.Pages.Architecture do
+ @moduledoc false
+
+ @behaviour Showcase.Page
+
+ alias Showcase.Layout
+ alias TermUI.Widget.MarkdownViewer
+
+ @impl true
+ def init, do: MarkdownViewer.init(content: document(), page_size: 20)
+
+ @impl true
+ def update(event, state), do: MarkdownViewer.update(event, state)
+
+ @impl true
+ def view(state, {width, height}, theme) do
+ content = MarkdownViewer.view(state, {max(width - 2, 1), max(height - 2, 1)})
+ Layout.panel(content, "Why the seams matter", {width, height}, active: true, theme: theme)
+ end
+
+ @impl true
+ def help, do: "Arrows scroll this executable architecture guide."
+
+ defp document do
+ """
+ # TermUI architecture
+
+ The showcase uses the same public contract as an application.
+
+ ## One application owner
+
+ `Showcase.App` owns page selection, theme, dimensions, status, and every
+ widget state. A page is a plain module. A page does not start a process or
+ a nested runtime.
+
+ ## Pure widget boundary
+
+ ```elixir
+ {widget, messages} = Widget.update(event, widget)
+ frame = Widget.view(widget, dimensions)
+ ```
+
+ Widget messages return to the parent. Clipboard writes become command data.
+ A timer requests live data through `Command.async/2`. The command result
+ updates application state before the next render.
+
+ ## One frame boundary
+
+ Each page returns one bounded `TermUI.Frame`. The parent composes it with
+ the header and footer. The backend receives only the final frame.
+
+ ## One terminal owner
+
+ `TermUI.Backend.Manager` serializes input, resize, drawing, clipboard work,
+ and shutdown. Page code cannot write directly to the terminal.
+
+ ## External data
+
+ `Showcase.LiveData` collects local processes, runtime links, VM metrics, and
+ connected-node values outside update and view. The BEAM widgets only format
+ the snapshots that their parent supplies. Snapshot mode keeps tests and
+ documentation output deterministic.
+ """
+ end
+end
diff --git a/examples/showcase/lib/showcase/pages/beam.ex b/examples/showcase/lib/showcase/pages/beam.ex
new file mode 100644
index 00000000..d152852c
--- /dev/null
+++ b/examples/showcase/lib/showcase/pages/beam.ex
@@ -0,0 +1,93 @@
+defmodule Showcase.Pages.Beam do
+ @moduledoc false
+
+ @behaviour Showcase.Page
+
+ alias Showcase.Layout
+ alias TermUI.Event
+ alias TermUI.Frame
+ alias TermUI.Widget.{ClusterDashboard, ProcessMonitor, SupervisionTree}
+
+ @views [:processes, :runtime, :cluster]
+
+ @impl true
+ def init do
+ %{
+ active: :processes,
+ processes: ProcessMonitor.init(snapshots: []),
+ runtime: SupervisionTree.init(nodes: [], expanded: [:runtime]),
+ cluster: ClusterDashboard.init(nodes: [])
+ }
+ end
+
+ @impl true
+ def update(%Event.Key{key: :tab, modifiers: modifiers}, state) do
+ delta = if :shift in modifiers, do: -1, else: 1
+ {move_view(state, delta), []}
+ end
+
+ def update(event, %{active: :processes} = state) do
+ {widget, messages} = ProcessMonitor.update(event, state.processes)
+ {%{state | processes: widget}, messages}
+ end
+
+ def update(event, %{active: :runtime} = state) do
+ {widget, messages} = SupervisionTree.update(event, state.runtime)
+ {%{state | runtime: widget}, messages}
+ end
+
+ def update(event, %{active: :cluster} = state) do
+ {widget, messages} = ClusterDashboard.update(event, state.cluster)
+ {%{state | cluster: widget}, messages}
+ end
+
+ @impl true
+ def view(state, {width, height}, theme) do
+ selector =
+ Layout.selector(
+ [processes: "Processes", runtime: "Runtime links", cluster: "Cluster"],
+ state.active,
+ width
+ )
+
+ panel_height = max(height - 1, 1)
+ inner = {max(width - 2, 1), max(panel_height - 2, 1)}
+
+ {title, content} =
+ case state.active do
+ :processes ->
+ {"Parent-supplied process snapshot", ProcessMonitor.view(state.processes, inner)}
+
+ :runtime ->
+ {"Live runtime process links", SupervisionTree.view(state.runtime, inner)}
+
+ :cluster ->
+ {"Parent-supplied cluster snapshot", ClusterDashboard.view(state.cluster, inner)}
+ end
+
+ panel = Layout.panel(content, title, {width, panel_height}, active: true, theme: theme)
+
+ Frame.new(width, height)
+ |> Frame.overlay(selector, 1, 1)
+ |> Frame.overlay(panel, 1, 2)
+ end
+
+ @impl true
+ def help, do: "Tab changes live snapshots. Collection stays outside the widgets."
+
+ @doc false
+ def set_snapshot(state, snapshot) do
+ %{
+ state
+ | processes: ProcessMonitor.set_snapshots(state.processes, snapshot.processes),
+ runtime: SupervisionTree.set_nodes(state.runtime, snapshot.runtime_tree),
+ cluster: ClusterDashboard.set_nodes(state.cluster, snapshot.cluster)
+ }
+ end
+
+ defp move_view(state, delta) do
+ index = Enum.find_index(@views, &(&1 == state.active)) || 0
+ next = rem(index + delta + length(@views), length(@views))
+ %{state | active: Enum.at(@views, next)}
+ end
+end
diff --git a/examples/showcase/lib/showcase/pages/content.ex b/examples/showcase/lib/showcase/pages/content.ex
new file mode 100644
index 00000000..e604770a
--- /dev/null
+++ b/examples/showcase/lib/showcase/pages/content.ex
@@ -0,0 +1,134 @@
+defmodule Showcase.Pages.Content do
+ @moduledoc false
+
+ @behaviour Showcase.Page
+
+ alias Showcase.Layout
+ alias TermUI.Event
+ alias TermUI.Frame
+ alias TermUI.Widget.{DiffViewer, MarkdownViewer, Stream}
+
+ @views [:markdown, :diff, :stream]
+
+ @impl true
+ def init do
+ %{
+ active: :markdown,
+ refreshes: 0,
+ markdown: MarkdownViewer.init(content: markdown(), page_size: 18),
+ diff:
+ DiffViewer.init(
+ before: "def hello(name) do\n \"hello \#{name}\"\nend\n",
+ after: "def hello(name \\ \"world\") do\n \"Hello, \#{name}!\"\nend\n",
+ old_label: "before.ex",
+ new_label: "after.ex",
+ page_size: 18
+ ),
+ stream:
+ Stream.init(
+ items: ["Waiting for the first live BEAM snapshot"],
+ limit: 40,
+ page_size: 18
+ )
+ }
+ end
+
+ @impl true
+ def update(%Event.Text{text: "]"}, state), do: {move_view(state, 1), []}
+ def update(%Event.Text{text: "["}, state), do: {move_view(state, -1), []}
+
+ def update(event, %{active: :markdown} = state) do
+ {widget, messages} = MarkdownViewer.update(event, state.markdown)
+ {%{state | markdown: widget}, messages}
+ end
+
+ def update(event, %{active: :diff} = state) do
+ {widget, messages} = DiffViewer.update(event, state.diff)
+ {%{state | diff: widget}, messages}
+ end
+
+ def update(event, %{active: :stream} = state) do
+ {widget, messages} = Stream.update(event, state.stream)
+ {%{state | stream: widget}, messages}
+ end
+
+ @impl true
+ def view(state, {width, height}, theme) do
+ selector =
+ Layout.selector([markdown: "Markdown", diff: "Diff", stream: "Stream"], state.active, width)
+
+ panel_height = max(height - 1, 1)
+ inner = {max(width - 2, 1), max(panel_height - 2, 1)}
+
+ {title, content} =
+ case state.active do
+ :markdown -> {"Markdown viewer", MarkdownViewer.view(state.markdown, inner)}
+ :diff -> {"Diff viewer - press S to change mode", DiffViewer.view(state.diff, inner)}
+ :stream -> {"Bounded stream - Space pauses", Stream.view(state.stream, inner)}
+ end
+
+ panel = Layout.panel(content, title, {width, panel_height}, active: true, theme: theme)
+
+ Frame.new(width, height)
+ |> Frame.overlay(selector, 1, 1)
+ |> Frame.overlay(panel, 1, 2)
+ end
+
+ @impl true
+ def help, do: "[ and ] change the content widget. Arrows scroll. Tab focuses Markdown code."
+
+ @doc false
+ def set_snapshot(state, snapshot) do
+ refreshes = state.refreshes + 1
+ system = snapshot.system
+
+ entry =
+ "#{pad(refreshes)} #{format_time(snapshot.collected_at)} " <>
+ "#{system.process_count} processes, #{format_bytes(system.memory.total)}, " <>
+ "run queue #{system.run_queue}"
+
+ %{state | refreshes: refreshes, stream: Stream.push(state.stream, entry)}
+ end
+
+ defp move_view(state, delta) do
+ index = Enum.find_index(@views, &(&1 == state.active)) || 0
+ next = rem(index + delta + length(@views), length(@views))
+ %{state | active: Enum.at(@views, next)}
+ end
+
+ defp pad(value), do: value |> Integer.to_string() |> String.pad_leading(4, "0")
+
+ defp format_time(seconds) do
+ seconds
+ |> DateTime.from_unix!()
+ |> Calendar.strftime("%H:%M:%S")
+ end
+
+ defp format_bytes(bytes) when bytes < 1_048_576, do: "#{Float.round(bytes / 1_024, 1)} KB"
+ defp format_bytes(bytes), do: "#{Float.round(bytes / 1_048_576, 1)} MB"
+
+ defp markdown do
+ """
+ # Rich terminal content
+
+ TermUI uses **MDEx** and renders Markdown as styled terminal cells.
+
+ - [x] CommonMark content
+ - [x] Tables and task lists
+ - [x] Selectable code blocks
+
+ | Boundary | Owner |
+ | --- | --- |
+ | State | Application runtime |
+ | Terminal | Backend manager |
+ | Cells | Frame |
+
+ ```elixir
+ {widget, messages} = Widget.update(event, widget)
+ frame = Widget.view(widget, dimensions)
+ ```
+
+ Press Tab to focus code blocks and Enter to copy one.
+ """
+ end
+end
diff --git a/examples/showcase/lib/showcase/pages/controls.ex b/examples/showcase/lib/showcase/pages/controls.ex
new file mode 100644
index 00000000..da5bc550
--- /dev/null
+++ b/examples/showcase/lib/showcase/pages/controls.ex
@@ -0,0 +1,200 @@
+defmodule Showcase.Pages.Controls do
+ @moduledoc false
+
+ @behaviour Showcase.Page
+
+ alias Showcase.Layout, as: ShowcaseLayout
+ alias TermUI.{Event, Frame, Layout}
+ alias TermUI.Widget.{Breadcrumb, Checkbox, RadioGroup, Select, Spinner, Toggle}
+
+ @focus_order [:checkbox, :toggle, :radio, :select]
+
+ @impl true
+ def init do
+ %{
+ focus: :checkbox,
+ checkbox: Checkbox.init(id: :alerts, label: "Enable alerts", checked: true),
+ toggle: Toggle.init(id: :streaming, label: "Stream updates", checked: true),
+ radio:
+ RadioGroup.init(
+ id: :density,
+ options: [{:compact, "Compact"}, {:comfortable, "Comfortable"}],
+ selected: :compact,
+ orientation: :horizontal
+ ),
+ select:
+ Select.init(
+ id: :region,
+ options: [{:local, "Local node"}, {:cluster, "Cluster"}, {:archive, "Archive"}],
+ selected: :local,
+ page_size: 3
+ ),
+ spinner: Spinner.init(label: "Parent-owned refresh timer"),
+ breadcrumb:
+ Breadcrumb.init(
+ items: [
+ Breadcrumb.item("Showcase", icon: "⌂"),
+ Breadcrumb.item("Widgets"),
+ Breadcrumb.item("Controls", icon: "◆")
+ ]
+ ),
+ status: "Tab changes focus. Enter or Space changes the active control."
+ }
+ end
+
+ @impl true
+ def update(%Event.Key{key: :tab, modifiers: modifiers}, state) do
+ delta = if :shift in modifiers, do: -1, else: 1
+ {move_focus(state, delta), []}
+ end
+
+ def update(event, state) do
+ {state, messages} = update_focused(state, event)
+ {apply_messages(state, messages), messages}
+ end
+
+ @impl true
+ def view(state, {width, height} = dimensions, theme) do
+ if width >= 56 and height >= 14,
+ do: wide_view(state, dimensions, theme),
+ else: compact_view(state, dimensions)
+ end
+
+ @impl true
+ def help, do: "Tab changes focus. Enter or Space changes the active control."
+
+ @doc false
+ def tick(state), do: %{state | spinner: Spinner.tick(state.spinner)}
+
+ defp wide_view(state, {width, height} = dimensions, theme) do
+ [selector_rect, breadcrumb_rect, body_rect, status_rect] =
+ Layout.column(Layout.new(dimensions), [1, 1, :fill, 1])
+
+ cells =
+ Layout.grid(body_rect, 4, columns: 2, rows: 2, column_gap: 1, row_gap: 1)
+
+ selector = ShowcaseLayout.selector(focus_items(), state.focus, width)
+ breadcrumb = Breadcrumb.view(state.breadcrumb, Layout.dimensions(breadcrumb_rect))
+ status = Frame.from_rows([state.status], Layout.dimensions(status_rect) |> elem(0), 1)
+
+ [boolean_cell, radio_cell, select_cell, spinner_cell] = cells
+
+ Frame.new(width, height)
+ |> Layout.place(selector, selector_rect)
+ |> Layout.place(breadcrumb, breadcrumb_rect)
+ |> Layout.place(boolean_panel(state, boolean_cell, theme), boolean_cell)
+ |> Layout.place(radio_panel(state, radio_cell, theme), radio_cell)
+ |> Layout.place(select_panel(state, select_cell, theme), select_cell)
+ |> Layout.place(spinner_panel(state, spinner_cell, theme), spinner_cell)
+ |> Layout.place(status, status_rect)
+ end
+
+ defp compact_view(state, {width, height}) do
+ radio = state.radio |> RadioGroup.focus(state.focus == :radio) |> RadioGroup.view({width, 1})
+ select_height = min(max(height - 7, 1), 4)
+
+ Frame.new(width, height)
+ |> Frame.overlay(ShowcaseLayout.selector(focus_items(), state.focus, width), 1, 1)
+ |> Frame.overlay(Breadcrumb.view(state.breadcrumb, {width, 1}), 1, min(2, height))
+ |> Frame.overlay(Spinner.view(state.spinner, {width, 1}), 1, min(3, height))
+ |> Frame.overlay(checkbox_frame(state, {width, 1}), 1, min(4, height))
+ |> Frame.overlay(toggle_frame(state, {width, 1}), 1, min(5, height))
+ |> Frame.overlay(radio, 1, min(6, height))
+ |> Frame.overlay(select_frame(state, {width, select_height}), 1, min(7, height))
+ |> Frame.put_row(height, [state.status])
+ end
+
+ defp boolean_panel(state, {_x, _y, width, height}, theme) do
+ child_dimensions = {max(width - 2, 1), max(height - 2, 1)}
+ {child_width, child_height} = child_dimensions
+
+ child =
+ Frame.new(child_width, child_height)
+ |> Frame.overlay(checkbox_frame(state, {child_width, 1}), 1, 1)
+ |> Frame.overlay(toggle_frame(state, {child_width, 1}), 1, min(2, child_height))
+
+ ShowcaseLayout.panel(child, "Boolean controls", {width, height},
+ active: state.focus in [:checkbox, :toggle],
+ theme: theme
+ )
+ end
+
+ defp radio_panel(state, {_x, _y, width, height}, theme) do
+ dimensions = {max(width - 2, 1), max(height - 2, 1)}
+ radio = state.radio |> RadioGroup.focus(state.focus == :radio) |> RadioGroup.view(dimensions)
+
+ ShowcaseLayout.panel(radio, "Radio group", {width, height},
+ active: state.focus == :radio,
+ theme: theme
+ )
+ end
+
+ defp select_panel(state, {_x, _y, width, height}, theme) do
+ select = select_frame(state, {max(width - 2, 1), max(height - 2, 1)})
+
+ ShowcaseLayout.panel(select, "Select", {width, height},
+ active: state.focus == :select,
+ theme: theme
+ )
+ end
+
+ defp spinner_panel(state, {_x, _y, width, height}, theme) do
+ spinner = Spinner.view(state.spinner, {max(width - 2, 1), max(height - 2, 1)})
+ ShowcaseLayout.panel(spinner, "Spinner", {width, height}, theme: theme)
+ end
+
+ defp checkbox_frame(state, dimensions) do
+ state.checkbox
+ |> Checkbox.focus(state.focus == :checkbox)
+ |> Checkbox.view(dimensions)
+ end
+
+ defp toggle_frame(state, dimensions) do
+ state.toggle
+ |> Toggle.focus(state.focus == :toggle)
+ |> Toggle.view(dimensions)
+ end
+
+ defp select_frame(state, dimensions) do
+ state.select
+ |> Select.focus(state.focus == :select)
+ |> Select.view(dimensions)
+ end
+
+ defp update_focused(%{focus: :checkbox} = state, event) do
+ {widget, messages} = Checkbox.update(event, Checkbox.focus(state.checkbox))
+ {%{state | checkbox: widget}, messages}
+ end
+
+ defp update_focused(%{focus: :toggle} = state, event) do
+ {widget, messages} = Toggle.update(event, Toggle.focus(state.toggle))
+ {%{state | toggle: widget}, messages}
+ end
+
+ defp update_focused(%{focus: :radio} = state, event) do
+ {widget, messages} = RadioGroup.update(event, RadioGroup.focus(state.radio))
+ {%{state | radio: widget}, messages}
+ end
+
+ defp update_focused(%{focus: :select} = state, event) do
+ {widget, messages} = Select.update(event, Select.focus(state.select))
+ {%{state | select: widget}, messages}
+ end
+
+ defp move_focus(state, delta) do
+ index = Enum.find_index(@focus_order, &(&1 == state.focus)) || 0
+ next = rem(index + delta + length(@focus_order), length(@focus_order))
+ %{state | focus: Enum.at(@focus_order, next), select: Select.close(state.select)}
+ end
+
+ defp apply_messages(state, messages) do
+ Enum.reduce(messages, state, fn
+ {:changed, id, value}, acc -> %{acc | status: "#{id}: #{value}"}
+ {:selected, id, value}, acc -> %{acc | status: "#{id}: #{value}"}
+ _message, acc -> acc
+ end)
+ end
+
+ defp focus_items,
+ do: [checkbox: "Checkbox", toggle: "Toggle", radio: "Radio", select: "Select"]
+end
diff --git a/examples/showcase/lib/showcase/pages/inputs.ex b/examples/showcase/lib/showcase/pages/inputs.ex
new file mode 100644
index 00000000..ec1435d9
--- /dev/null
+++ b/examples/showcase/lib/showcase/pages/inputs.ex
@@ -0,0 +1,191 @@
+defmodule Showcase.Pages.Inputs do
+ @moduledoc false
+
+ @behaviour Showcase.Page
+
+ alias Showcase.Layout
+ alias TermUI.Event
+ alias TermUI.Frame
+ alias TermUI.Widget.{Button, List, TextArea, TextInput}
+
+ @focus_order [:name, :notes, :choices, :submit]
+
+ @impl true
+ def init do
+ %{
+ focus: :name,
+ name: TextInput.init(placeholder: "Type a project name", max_length: 60),
+ notes:
+ TextArea.init(
+ value: "TermUI keeps widget state in the parent application.",
+ max_length: 500
+ ),
+ choices:
+ List.init(
+ items: ["Keyboard navigation", "Mouse input", "Clipboard", "Responsive layout"],
+ mode: :multiple,
+ page_size: 4
+ ),
+ submit: Button.init(id: :save, label: "Save example", message: :save),
+ status: "Tab moves focus. Editing events go only to the focused widget."
+ }
+ end
+
+ @impl true
+ def update(%Event.Key{key: :tab, modifiers: modifiers}, state) do
+ delta = if :shift in modifiers, do: -1, else: 1
+ {move_focus(state, delta), []}
+ end
+
+ def update(event, state) do
+ {state, messages} = update_focused(state, event)
+ {apply_messages(state, messages), messages}
+ end
+
+ @impl true
+ def view(state, {width, height}, theme) do
+ selector = Layout.selector(focus_items(), state.focus, width)
+ body_height = max(height - 1, 1)
+ name_height = min(3, body_height)
+
+ notes_height =
+ min(max(div(body_height - name_height, 2), 3), max(body_height - name_height, 1))
+
+ lower_height = max(body_height - name_height - notes_height, 1)
+
+ name =
+ state.name
+ |> TextInput.view({max(width - 2, 1), 1})
+ |> maybe_hide_cursor(state.focus != :name)
+ |> Layout.panel("Text input", {width, name_height},
+ active: state.focus == :name,
+ theme: theme
+ )
+
+ notes =
+ state.notes
+ |> TextArea.view({max(width - 2, 1), max(notes_height - 2, 1)})
+ |> maybe_hide_cursor(state.focus != :notes)
+ |> Layout.panel("Text area", {width, notes_height},
+ active: state.focus == :notes,
+ theme: theme
+ )
+
+ lower = lower_frame(state, {width, lower_height}, theme)
+
+ Frame.new(width, height)
+ |> Frame.overlay(selector, 1, 1)
+ |> Frame.overlay(name, 1, 2)
+ |> Frame.overlay(notes, 1, name_height + 2)
+ |> Frame.overlay(lower, 1, name_height + notes_height + 2)
+ end
+
+ @impl true
+ def help, do: "Tab changes focus. Enter activates controls. Ctrl+C copies selected text."
+
+ defp update_focused(%{focus: :name} = state, event) do
+ {widget, messages} = TextInput.update(event, state.name)
+ {%{state | name: widget}, messages}
+ end
+
+ defp update_focused(%{focus: :notes} = state, event) do
+ {widget, messages} = TextArea.update(event, state.notes)
+ {%{state | notes: widget}, messages}
+ end
+
+ defp update_focused(%{focus: :choices} = state, event) do
+ {widget, messages} = List.update(event, state.choices)
+ {%{state | choices: widget}, messages}
+ end
+
+ defp update_focused(%{focus: :submit} = state, event) do
+ {widget, messages} = Button.update(event, Button.focus(state.submit))
+ {%{state | submit: widget}, messages}
+ end
+
+ defp lower_frame(state, {width, height}, theme) when width >= 54 do
+ {left_width, right_width} = Layout.split_widths(width)
+
+ choices =
+ state.choices
+ |> List.view({max(left_width - 2, 1), max(height - 2, 1)})
+ |> Layout.panel("Multi-select list", {left_width, height},
+ active: state.focus == :choices,
+ theme: theme
+ )
+
+ action = action_frame(state, {right_width - 2, max(height - 2, 1)})
+
+ action =
+ Layout.panel(action, "Application result", {right_width, height},
+ active: state.focus == :submit,
+ theme: theme
+ )
+
+ Frame.new(width, height)
+ |> Frame.overlay(choices, 1, 1)
+ |> Frame.overlay(action, left_width + 2, 1)
+ end
+
+ defp lower_frame(state, {width, height}, theme) do
+ if state.focus == :choices do
+ if height < 3 do
+ List.view(state.choices, {width, height})
+ else
+ choices = List.view(state.choices, {max(width - 2, 1), height - 2})
+ Layout.panel(choices, "Multi-select list", {width, height}, active: true, theme: theme)
+ end
+ else
+ action = action_frame(state, {max(width - 2, 1), max(height - 2, 1)})
+
+ Layout.panel(action, "Application result", {width, height},
+ active: state.focus == :submit,
+ theme: theme
+ )
+ end
+ end
+
+ defp action_frame(state, {width, height}) do
+ button = state.submit |> Button.focus(state.focus == :submit) |> Button.view({width, 1})
+ status = Frame.from_rows(Frame.wrap(state.status, width), width, max(height - 2, 1))
+
+ Frame.new(width, height)
+ |> Frame.overlay(button, 1, 1)
+ |> Frame.overlay(status, 1, min(3, height))
+ end
+
+ defp move_focus(state, delta) do
+ index = Enum.find_index(@focus_order, &(&1 == state.focus)) || 0
+ next = rem(index + delta + length(@focus_order), length(@focus_order))
+ %{state | focus: Enum.at(@focus_order, next)}
+ end
+
+ defp apply_messages(state, messages) do
+ Enum.reduce(messages, state, fn
+ :save, acc ->
+ %{
+ acc
+ | status:
+ "Saved #{inspect(acc.name.value)} with #{MapSet.size(acc.choices.selected)} choices"
+ }
+
+ {:submit, value}, acc ->
+ %{acc | status: "Submitted #{inspect(value)}"}
+
+ {:selected, value}, acc ->
+ %{acc | status: "Selected #{value}"}
+
+ {:toggled, value}, acc ->
+ %{acc | status: "Toggled #{value}"}
+
+ _message, acc ->
+ acc
+ end)
+ end
+
+ defp focus_items,
+ do: [name: "Name", notes: "Notes", choices: "Choices", submit: "Submit"]
+
+ defp maybe_hide_cursor(frame, true), do: Layout.without_cursor(frame)
+ defp maybe_hide_cursor(frame, false), do: frame
+end
diff --git a/examples/showcase/lib/showcase/pages/overview.ex b/examples/showcase/lib/showcase/pages/overview.ex
new file mode 100644
index 00000000..22e64883
--- /dev/null
+++ b/examples/showcase/lib/showcase/pages/overview.ex
@@ -0,0 +1,187 @@
+defmodule Showcase.Pages.Overview do
+ @moduledoc false
+
+ @behaviour Showcase.Page
+
+ alias Showcase.Layout
+ alias TermUI.{Frame, Style}
+ alias TermUI.Widget.{BarChart, Gauge, Progress, Sparkline, Table}
+ alias TermUI.Widget.Table.Column
+
+ @impl true
+ def init do
+ %{
+ refreshes: 0,
+ run_queue: Gauge.init(label: "Run queue", value: 0, max: 1),
+ processes: Gauge.init(label: "Processes", value: 0, max: 1),
+ schedulers: Progress.init(label: "Schedulers", value: 0, max: 1),
+ history:
+ Sparkline.init(
+ label: "Run queue history",
+ values: [0],
+ min: 0,
+ max: 100,
+ style: Style.new(fg: :cyan)
+ ),
+ bars:
+ BarChart.init(
+ data: [
+ %{label: "Processes", value: 0, color: :cyan},
+ %{label: "Binaries", value: 0, color: :green},
+ %{label: "ETS", value: 0, color: :yellow}
+ ],
+ min: 0,
+ max: 100
+ ),
+ table: Table.init(columns: columns(), rows: [], page_size: 8)
+ }
+ end
+
+ @impl true
+ def update(event, state) do
+ {table, messages} = Table.update(event, state.table)
+ {%{state | table: table}, messages}
+ end
+
+ @impl true
+ def view(state, {width, height} = dimensions, theme) do
+ if width >= 72 and height >= 12 do
+ wide_view(state, dimensions, theme)
+ else
+ compact_view(state, dimensions, theme)
+ end
+ end
+
+ @impl true
+ def help, do: "Live BEAM data. Use Up and Down to move in the process table."
+
+ @doc false
+ def set_snapshot(state, %{system: system, processes: processes}) do
+ load = system.run_queue_load
+
+ %{
+ state
+ | refreshes: state.refreshes + 1,
+ run_queue: %{state.run_queue | value: system.run_queue, maximum: system.schedulers_online},
+ processes: %{
+ state.processes
+ | value: system.process_count,
+ maximum: system.process_limit
+ },
+ schedulers: %{
+ state.schedulers
+ | value: system.schedulers_online,
+ maximum: system.schedulers
+ },
+ history: Sparkline.push(state.history, load, 80),
+ bars: %{state.bars | data: memory_bars(system.memory)},
+ table: Table.set_rows(state.table, process_rows(processes))
+ }
+ end
+
+ defp wide_view(state, {width, height}, theme) do
+ {left_width, right_width} = Layout.split_widths(width)
+ top_height = min(max(div(height, 2), 7), 9)
+ table_height = max(height - top_height - 1, 2)
+
+ metrics =
+ state
+ |> metrics_frame({left_width - 2, top_height - 2})
+ |> Layout.panel("Live metrics", {left_width, top_height}, active: true, theme: theme)
+
+ trends =
+ state
+ |> trends_frame({right_width - 2, top_height - 2})
+ |> Layout.panel("Workload", {right_width, top_height}, theme: theme)
+
+ table =
+ state.table
+ |> Table.view({width - 2, table_height - 2})
+ |> Layout.panel("Process snapshot", {width, table_height}, theme: theme)
+
+ Frame.new(width, height)
+ |> Frame.overlay(metrics, 1, 1)
+ |> Frame.overlay(trends, left_width + 2, 1)
+ |> Frame.overlay(table, 1, top_height + 2)
+ end
+
+ defp compact_view(state, {width, height}, theme) do
+ metrics_height = min(6, max(height, 2))
+ table_height = max(height - metrics_height, 2)
+
+ metrics =
+ state
+ |> metrics_frame({max(width - 2, 1), max(metrics_height - 2, 1)})
+ |> Layout.panel("Live metrics", {width, metrics_height}, active: true, theme: theme)
+
+ table =
+ state.table
+ |> Table.view({max(width - 2, 1), max(table_height - 2, 1)})
+ |> Layout.panel("Processes", {width, table_height}, theme: theme)
+
+ Frame.new(width, height)
+ |> Frame.overlay(metrics, 1, 1)
+ |> Frame.overlay(table, 1, metrics_height + 1)
+ end
+
+ defp metrics_frame(state, {width, height}) do
+ Frame.new(max(width, 1), max(height, 1))
+ |> Frame.overlay(Gauge.view(state.run_queue, {max(width, 1), 1}), 1, 1)
+ |> Frame.overlay(
+ Gauge.view(state.processes, {max(width, 1), 1}),
+ 1,
+ min(2, max(height, 1))
+ )
+ |> Frame.overlay(
+ Progress.view(state.schedulers, {max(width, 1), 1}),
+ 1,
+ min(3, max(height, 1))
+ )
+ end
+
+ defp trends_frame(state, {width, height}) do
+ width = max(width, 1)
+ height = max(height, 1)
+ bar_height = max(height - 2, 1)
+
+ Frame.new(width, height)
+ |> Frame.overlay(Sparkline.view(state.history, {width, 1}), 1, 1)
+ |> Frame.overlay(BarChart.view(state.bars, {width, bar_height}), 1, min(3, height))
+ end
+
+ defp columns do
+ [
+ Column.new(:name, "Process"),
+ Column.new(:memory, "Memory", width: 10, align: :right),
+ Column.new(:queue, "Queue", width: 7, align: :right),
+ Column.new(:status, "Status", width: 10)
+ ]
+ end
+
+ defp process_rows(processes) do
+ Enum.map(processes, fn process ->
+ %{
+ name: process.name,
+ memory: format_bytes(process.memory),
+ queue: process.message_queue_len,
+ status: to_string(process.status)
+ }
+ end)
+ end
+
+ defp memory_bars(memory) do
+ total = max(Map.get(memory, :total, 0), 1)
+
+ [
+ %{label: "Processes", value: percentage(memory, :processes, total), color: :cyan},
+ %{label: "Binaries", value: percentage(memory, :binary, total), color: :green},
+ %{label: "ETS", value: percentage(memory, :ets, total), color: :yellow}
+ ]
+ end
+
+ defp percentage(memory, key, total), do: round(Map.get(memory, key, 0) * 100 / total)
+
+ defp format_bytes(bytes) when bytes < 1_024, do: "#{bytes} B"
+ defp format_bytes(bytes) when bytes < 1_048_576, do: "#{Float.round(bytes / 1_024, 1)} KB"
+ defp format_bytes(bytes), do: "#{Float.round(bytes / 1_048_576, 1)} MB"
+end
diff --git a/examples/showcase/lib/showcase/snapshot_data.ex b/examples/showcase/lib/showcase/snapshot_data.ex
new file mode 100644
index 00000000..acbb51ea
--- /dev/null
+++ b/examples/showcase/lib/showcase/snapshot_data.ex
@@ -0,0 +1,67 @@
+defmodule Showcase.SnapshotData do
+ @moduledoc "Provides deterministic showcase data for tests and explicit snapshot mode."
+
+ alias TermUI.Widget.TreeView
+
+ @doc "Returns one deterministic snapshot with the live-data shape."
+ @spec snapshot() :: map()
+ def snapshot do
+ %{
+ collected_at: 1_700_000_000,
+ system: %{
+ memory: %{total: 96_000_000, processes: 48_000_000, binary: 12_000_000, ets: 8_000_000},
+ process_count: 184,
+ process_limit: 262_144,
+ run_queue: 2,
+ run_queue_load: 13,
+ schedulers: 16,
+ schedulers_online: 16
+ },
+ processes: process_snapshots(),
+ runtime_tree: runtime_tree(),
+ cluster: cluster_snapshots()
+ }
+ end
+
+ defp process_snapshots do
+ [
+ process("<0.184.0>", "TermUI.Runtime", 148_320, 51_204, 0, :waiting),
+ process("<0.185.0>", "Backend.Manager", 92_176, 22_918, 1, :waiting),
+ process("<0.186.0>", "InputReader", 18_624, 4_091, 0, :waiting),
+ process("<0.187.0>", "ProducerAdapter", 27_040, 9_277, 2, :running)
+ ]
+ end
+
+ defp process(pid, name, memory, reductions, queue, status) do
+ %{
+ pid: pid,
+ name: name,
+ memory: memory,
+ reductions: reductions,
+ message_queue_len: queue,
+ status: status
+ }
+ end
+
+ defp runtime_tree do
+ [
+ TreeView.branch(:runtime, "TermUI.Runtime <0.184.0>", [
+ TreeView.leaf(:backend, "TermUI.Backend.Manager <0.185.0>"),
+ TreeView.leaf(:input, "TermUI.Backend.InputReader <0.186.0>")
+ ])
+ ]
+ end
+
+ defp cluster_snapshots do
+ [
+ %{node: :console@local, status: "up", processes: 184, memory: "42.0 MB", uptime: "2h 14m"},
+ %{
+ node: :"worker-a@local",
+ status: "up",
+ processes: 231,
+ memory: "81.0 MB",
+ uptime: "5h 02m"
+ }
+ ]
+ end
+end
diff --git a/examples/showcase/mix.exs b/examples/showcase/mix.exs
new file mode 100644
index 00000000..d978d525
--- /dev/null
+++ b/examples/showcase/mix.exs
@@ -0,0 +1,17 @@
+defmodule Showcase.MixProject do
+ use Mix.Project
+
+ def project do
+ [
+ app: :term_ui_showcase,
+ version: "0.1.0",
+ elixir: ">= 1.18.4 and < 2.0.0",
+ start_permanent: Mix.env() == :prod,
+ deps: [{:term_ui, path: "../.."}]
+ ]
+ end
+
+ def application do
+ [extra_applications: [:logger]]
+ end
+end
diff --git a/examples/showcase/mix.lock b/examples/showcase/mix.lock
new file mode 100644
index 00000000..d6e198da
--- /dev/null
+++ b/examples/showcase/mix.lock
@@ -0,0 +1,10 @@
+%{
+ "elixir_make": {:hex, :elixir_make, "0.10.0", "16577e2583a79bb79237bbff349619ef5d80afffc07eac6e4faf0d00e2ddaf7d", [:mix], [], "hexpm", "dc1f09fb7fa68866b886abd5f0f3c83553b1a19a52359a899e92af1bb3b31982"},
+ "jason": {:hex, :jason, "1.4.5", "2e3a008590b0b8d7388c20293e9dcc9cf3e5d642fd2a114e4cbbb52e595d940a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "b0c823996102bcd0239b3c2444eb00409b72f6a140c1950bc8b457d836b30684"},
+ "mdex": {:hex, :mdex, "0.13.5", "c1c94d230ccaab01ad0c68090d3b31613c10ece1844f32b55895da4ce0c63029", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:lumis, "~> 0.1", [hex: :lumis, repo: "hexpm", optional: true]}, {:mdex_native, ">= 0.2.6", [hex: :mdex_native, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 0.20.0 or ~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}], "hexpm", "c57409fb6b34fbc58fbce0a6da670c9a4b5a2e94f86abdc56e9e213ed74620f2"},
+ "mdex_native": {:hex, :mdex_native, "0.2.8", "20b7cbf330c1ca81b8da4132b8d01952cded11f6dfc2abe8fef25c13681b15e4", [:mix], [{:rustler, "~> 0.32", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.8", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "004a5565b6c96a06400901eb1e4e603585e00b23262d3f595c3f4aa38b83ef66"},
+ "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
+ "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"},
+ "rustler_precompiled": {:hex, :rustler_precompiled, "0.9.0", "3a052eda09f3d2436364645cc1f13279cf95db310eb0c17b0d8f25484b233aa0", [:mix], [{:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "471d97315bd3bf7b64623418b3693eedd8e47de3d1cb79a0ac8f9da7d770d94c"},
+ "zoi": {:hex, :zoi, "0.18.7", "0d6b09d19fd1feff4340b7c5660bab04fbc80c1642ee1e5c75f06d527ac326db", [:mix], [{:decimal, "~> 2.0 or ~> 3.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:phoenix_html, "~> 2.14.2 or ~> 3.0 or ~> 4.1", [hex: :phoenix_html, repo: "hexpm", optional: true]}], "hexpm", "5fedddd755dec84a5e78b3671070a5e595026aa3479f7fa566a42b8c4e4e5ff2"},
+}
diff --git a/examples/showcase/run.exs b/examples/showcase/run.exs
new file mode 100644
index 00000000..dfac80ab
--- /dev/null
+++ b/examples/showcase/run.exs
@@ -0,0 +1,2 @@
+# Run the interactive TermUI showcase.
+Showcase.App.run()
diff --git a/examples/showcase/test/showcase/app_test.exs b/examples/showcase/test/showcase/app_test.exs
new file mode 100644
index 00000000..32227fd9
--- /dev/null
+++ b/examples/showcase/test/showcase/app_test.exs
@@ -0,0 +1,162 @@
+defmodule Showcase.AppTest do
+ use ExUnit.Case, async: true
+
+ alias Showcase.{App, LiveData, SnapshotData}
+ alias TermUI.{Command, Event, Frame}
+
+ test "live mode initializes one state owner, one collection, and a recurring timer" do
+ {state, commands} = App.init(dimensions: {100, 30})
+
+ assert state.page == :overview
+ assert state.data_mode == :live
+ assert state.refreshing
+ assert map_size(state.page_states) == length(App.pages())
+
+ assert [%Command{kind: :async}, %Command{kind: :timer, value: {1_000, :refresh_timer}}] =
+ commands
+
+ frame = App.view(state)
+ assert %Frame{width: 100, height: 30} = frame
+ assert Frame.row_text(frame, 1) =~ "TermUI Showcase"
+ assert Frame.row_text(frame, 2) =~ "1 Overview"
+ end
+
+ test "all pages render at normal and compact terminal sizes" do
+ initial = App.init(dimensions: {100, 30}, data_mode: :snapshot)
+
+ for {page, _label, _module} <- App.pages(), dimensions <- [{100, 30}, {40, 12}] do
+ state = {:select_page, page} |> App.update(initial) |> state_from()
+ state = %{state | dimensions: dimensions}
+ frame = App.view(state)
+
+ assert {frame.width, frame.height} == dimensions
+ assert map_size(frame.cells) > 0
+ end
+ end
+
+ test "routes text only to the active input widget" do
+ state = App.init(dimensions: {90, 26}, data_mode: :snapshot)
+ state = {:select_page, :inputs} |> App.update(state) |> state_from()
+
+ state =
+ {:page_event, Event.text("A")}
+ |> App.update(state)
+ |> state_from()
+
+ assert state.page_states.inputs.name.value == "A"
+ assert state.page_states.inputs.notes.value =~ "TermUI"
+ end
+
+ test "compact input layout shows the focused choice list" do
+ state = App.init(dimensions: {40, 12}, data_mode: :snapshot)
+ state = {:select_page, :inputs} |> App.update(state) |> state_from()
+
+ state = {:page_event, Event.key(:tab)} |> App.update(state) |> state_from()
+ state = {:page_event, Event.key(:tab)} |> App.update(state) |> state_from()
+
+ assert state.page_states.inputs.focus == :choices
+
+ frame = App.view(state)
+ text = Enum.map_join(1..frame.height, "\n", &Frame.row_text(frame, &1))
+ assert text =~ "Keyboard navigation"
+ end
+
+ test "a live snapshot updates every data page" do
+ {state, _commands} = App.init(dimensions: {90, 26})
+ snapshot = SnapshotData.snapshot()
+
+ state = App.update({:live_snapshot, {:ok, snapshot}}, state)
+
+ refute state.refreshing
+ assert state.last_snapshot == snapshot
+ assert state.page_states.overview.refreshes == 1
+ assert state.page_states.content.refreshes == 1
+ assert state.page_states.beam.processes.snapshots == snapshot.processes
+ assert state.page_states.beam.cluster.nodes == snapshot.cluster
+ assert state.page_states.controls.spinner.phase == 1
+ end
+
+ test "the refresh timer starts collection without overlapping work" do
+ {state, _commands} = App.init(dimensions: {90, 26})
+
+ {^state, [%Command{kind: :timer, value: {1_000, :refresh_timer}}]} =
+ App.update(:refresh_timer, state)
+
+ state = App.update({:live_snapshot, {:ok, SnapshotData.snapshot()}}, state)
+ {state, commands} = App.update(:refresh_timer, state)
+
+ assert state.refreshing
+ assert [%Command{kind: :async}, %Command{kind: :timer}] = commands
+ end
+
+ test "Markdown copy output becomes serialized clipboard command data" do
+ state = App.init(dimensions: {90, 26}, data_mode: :snapshot)
+ state = {:select_page, :content} |> App.update(state) |> state_from()
+
+ {state, [%Command{kind: :clipboard}]} =
+ App.update({:page_event, Event.key(:enter)}, state)
+
+ assert state.status == "Copy requested"
+ end
+
+ test "the Escape command menu and control keys replace function-key navigation" do
+ state = App.init(dimensions: {90, 26}, data_mode: :snapshot)
+ assert {:msg, :toggle_command_mode} = App.event_to_msg(Event.key(:escape), state)
+
+ state = App.update(:toggle_command_mode, state)
+ assert state.command_mode
+ assert {:msg, {:command_key, "4"}} = App.event_to_msg(Event.text("4"), state)
+
+ state = App.update({:command_key, "4"}, state)
+ assert state.page == :beam
+ refute state.command_mode
+
+ state = App.update({:command_key, "6"}, state)
+ assert state.page == :controls
+
+ assert {:msg, :next_page} =
+ App.event_to_msg(Event.key("n", modifiers: [:ctrl]), %{})
+
+ assert {:msg, :previous_page} =
+ App.event_to_msg(Event.key("p", modifiers: [:ctrl]), %{})
+
+ assert {:msg, :refresh} = App.event_to_msg(Event.key("r", modifiers: [:ctrl]), %{})
+ assert {:msg, :toggle_theme} = App.event_to_msg(Event.key("t", modifiers: [:ctrl]), %{})
+ assert {:msg, :quit} = App.event_to_msg(Event.key("q", modifiers: [:ctrl]), %{})
+
+ assert {:msg, :next_page} =
+ App.event_to_msg(Event.key(:right, modifiers: [:ctrl]), %{})
+
+ assert {:msg, {:page_event, %Event.Key{key: :f4}}} =
+ App.event_to_msg(Event.key(:f4), %{})
+
+ assert {:msg, {:page_event, %Event.Text{text: "n"}}} =
+ App.event_to_msg(Event.text("n"), %{})
+ end
+
+ test "the collector returns current local BEAM data" do
+ snapshot = LiveData.collect(self())
+
+ assert snapshot.system.process_count > 0
+ assert snapshot.system.memory.total > 0
+ assert Enum.any?(snapshot.processes, &(&1.pid == inspect(self())))
+ assert Enum.any?(snapshot.cluster, &(&1.node == node()))
+ assert [%{id: :runtime, children: children}] = snapshot.runtime_tree
+ assert is_list(children)
+ end
+
+ test "the controls page owns and updates new widget state" do
+ state = App.init(dimensions: {90, 26}, data_mode: :snapshot)
+ state = {:select_page, :controls} |> App.update(state) |> state_from()
+
+ state = {:page_event, Event.key(:space)} |> App.update(state) |> state_from()
+ refute state.page_states.controls.checkbox.checked
+
+ state = {:page_event, Event.key(:tab)} |> App.update(state) |> state_from()
+ state = {:page_event, Event.key(:space)} |> App.update(state) |> state_from()
+ refute state.page_states.controls.toggle.checked
+ end
+
+ defp state_from({state, _commands}), do: state
+ defp state_from(state), do: state
+end
diff --git a/examples/showcase/test/test_helper.exs b/examples/showcase/test/test_helper.exs
new file mode 100644
index 00000000..869559e7
--- /dev/null
+++ b/examples/showcase/test/test_helper.exs
@@ -0,0 +1 @@
+ExUnit.start()
diff --git a/examples/sparkline/README.md b/examples/sparkline/README.md
deleted file mode 100644
index f4dc4957..00000000
--- a/examples/sparkline/README.md
+++ /dev/null
@@ -1,181 +0,0 @@
-# Sparkline Widget Example
-
-A demonstration of the TermUI Sparkline widget for compact inline trend visualization using vertical bar characters.
-
-## Widget Overview
-
-The Sparkline widget displays numeric data as compact inline charts using Unicode vertical bar characters (▁▂▃▄▅▆▇█). It's perfect for showing trends in minimal space, such as CPU usage, memory consumption, or any time-series data that needs quick visual representation without taking up much screen real estate.
-
-**Key Features:**
-- Compact visualization using 8 levels of vertical bars
-- Auto-scaling or fixed min/max ranges
-- Labeled sparklines with min/max values
-- Color-coded sparklines based on value thresholds
-- Simple integration into any text-based layout
-
-**When to Use:**
-- Dashboard displays with multiple metrics
-- Inline trend indicators in tables or lists
-- Resource monitoring (CPU, memory, disk I/O)
-- Real-time data visualization in minimal space
-
-## Widget Options
-
-The `Sparkline.render/1` function accepts these options:
-
-- `:values` - List of numeric values (required)
-- `:min` - Minimum value for scaling (default: auto-calculated from data)
-- `:max` - Maximum value for scaling (default: auto-calculated from data)
-- `:style` - Style for the entire sparkline
-- `:color_ranges` - List of `{threshold, style}` tuples for value-based coloring
-
-The `Sparkline.render_labeled/1` function includes:
-
-- `:values` - List of numeric values (required)
-- `:label` - Label text to display before the sparkline
-- `:show_range` - Show min/max values (default: true)
-
-## Example Structure
-
-This example consists of:
-
-- `lib/sparkline/app.ex` - Main application demonstrating:
- - Basic sparkline rendering
- - Sparkline with fixed scale (0-100)
- - Labeled sparkline with min/max values
- - Styled sparkline with custom colors
- - Color-coded sparkline based on value thresholds
-- `mix.exs` - Mix project configuration
-- `run.exs` - Helper script to run the example
-
-## Running the Example
-
-### Raw Mode (Full TUI Experience)
-
-For the best experience with full terminal control and alternate screen:
-
-```bash
-cd examples/sparkline
-mix termui.run
-```
-
-Or manually:
-
-```bash
-cd examples/sparkline
-mix run -e "Sparkline.App.run()" --no-halt
-```
-
-### TTY Mode (IEx Compatible)
-
-To run from IEx without taking over the shell:
-
-```bash
-cd examples/sparkline
-iex -S mix
-```
-
-Then in IEx:
-
-```elixir
-Sparkline.App.run()
-```
-
-**Note:** TTY mode works inside IEx but has limitations:
-- No alternate screen buffer (output mixes with IEx prompt)
-- Character input works immediately (no Enter needed)
-- For full TUI, use raw mode instead
-
-## Controls
-
-| Key | Action |
-|-----|--------|
-| Space | Add a random data point |
-| R | Reset data to initial values |
-| C | Toggle color mode |
-| Q | Quit |
-
-## Code Examples
-
-### Basic Sparkline
-
-```elixir
-# Just pass a list of values
-Sparkline.render(values: [1, 3, 5, 2, 8, 4, 6])
-```
-
-### Fixed Scale
-
-```elixir
-# Set explicit min/max for consistent scaling
-Sparkline.render(
- values: [35, 42, 55, 48, 62],
- min: 0,
- max: 100
-)
-```
-
-### Labeled Sparkline
-
-```elixir
-# Show label and min/max values
-Sparkline.render_labeled(
- values: data,
- label: "CPU",
- show_range: true
-)
-# Output: CPU 35 ▃▄▆▅▇ 62
-```
-
-### Styled Sparkline
-
-```elixir
-# Apply a single color to the entire sparkline
-Sparkline.render(
- values: data,
- style: Style.new(fg: :green)
-)
-```
-
-### Color-Coded by Value
-
-```elixir
-# Different colors based on value thresholds
-Sparkline.render(
- values: data,
- color_ranges: [
- {0, Style.new(fg: :green)}, # Green when value >= 0
- {50, Style.new(fg: :yellow)}, # Yellow when value >= 50
- {75, Style.new(fg: :red)} # Red when value >= 75
- ]
-)
-```
-
-### Get Sparkline as String
-
-```elixir
-# For embedding in other text
-sparkline_str = Sparkline.to_sparkline([1, 3, 5, 2, 8])
-# Returns: "▁▃▅▂█"
-```
-
-## Bar Characters
-
-Sparklines use 8 levels of vertical bar characters:
-
-```
-▁ (1/8), ▂ (2/8), ▃ (3/8), ▄ (4/8), ▅ (5/8), ▆ (6/8), ▇ (7/8), █ (8/8)
-```
-
-## Color Ranges
-
-When color mode is enabled in the example, values are colored based on thresholds:
-- Green: 0-49 (low values)
-- Yellow: 50-74 (medium values)
-- Red: 75+ (high values)
-
-This demonstrates how sparklines can use color to convey additional information about value ranges.
-
-## Widget API
-
-See `lib/term_ui/widgets/sparkline.ex` for the full API documentation.
diff --git a/examples/sparkline/lib/sparkline/app.ex b/examples/sparkline/lib/sparkline/app.ex
deleted file mode 100644
index cd22a4e8..00000000
--- a/examples/sparkline/lib/sparkline/app.ex
+++ /dev/null
@@ -1,201 +0,0 @@
-defmodule Sparkline.App do
- @moduledoc """
- Sparkline Widget Example
-
- This example demonstrates how to use the TermUI.Widgets.Sparkline widget
- for compact inline trend visualization. Sparklines use vertical bar
- characters (▁▂▃▄▅▆▇█) to display values in minimal space.
-
- Features demonstrated:
- - Basic sparkline rendering
- - Labeled sparklines with min/max values
- - Color-coded sparklines based on value ranges
- - Auto-updating data simulation
-
- Controls:
- - Space: Add a new random data point
- - R: Reset data to initial values
- - C: Toggle color mode
- - Q: Quit the application
- """
-
- use TermUI.Elm
-
- alias TermUI.Widgets.Sparkline
- alias TermUI.Event
- alias TermUI.Renderer.Style
-
- # ----------------------------------------------------------------------------
- # Component Callbacks
- # ----------------------------------------------------------------------------
-
- @doc """
- Initialize the component state.
-
- We maintain:
- - values: List of data points for the sparkline
- - colored: Whether to show color-coded sparkline
- """
- def init(_opts) do
- %{
- # Initial sample data simulating CPU usage over time
- values: [35, 42, 38, 55, 48, 62, 58, 71, 65, 78, 72, 85, 79, 68, 55],
- colored: false
- }
- end
-
- @doc """
- Convert keyboard events to messages.
- """
- def event_to_msg(%Event.Key{key: " "}, _state), do: {:msg, :add_point}
- def event_to_msg(%Event.Key{key: key}, _state) when key in ["r", "R"], do: {:msg, :reset}
- def event_to_msg(%Event.Key{key: key}, _state) when key in ["c", "C"], do: {:msg, :toggle_color}
- def event_to_msg(%Event.Key{key: key}, _state) when key in ["q", "Q"], do: {:msg, :quit}
- def event_to_msg(_event, _state), do: :ignore
-
- @doc """
- Update state based on messages.
- """
- def update(:add_point, state) do
- # Add a random value between 10 and 100
- new_value = :rand.uniform(90) + 10
-
- # Keep only the last 20 values (sliding window)
- new_values =
- (state.values ++ [new_value])
- |> Enum.take(-20)
-
- {%{state | values: new_values}, []}
- end
-
- def update(:reset, state) do
- # Reset to initial data
- initial = [35, 42, 38, 55, 48, 62, 58, 71, 65, 78, 72, 85, 79, 68, 55]
- {%{state | values: initial}, []}
- end
-
- def update(:toggle_color, state) do
- {%{state | colored: not state.colored}, []}
- end
-
- def update(:quit, state) do
- {state, [:quit]}
- end
-
- @doc """
- Render the current state to a render tree.
- """
- def view(state) do
- stack(:vertical, [
- # Title
- text("Sparkline Widget Example", Style.new(fg: :cyan, attrs: [:bold])),
- text("", nil),
-
- # Basic sparkline
- # The simplest usage - just pass a list of values
- text("Basic Sparkline:", nil),
- Sparkline.render(values: state.values),
- text("", nil),
-
- # Sparkline with explicit min/max
- # Useful when you want consistent scaling across multiple sparklines
- text("Sparkline with fixed scale (0-100):", nil),
- Sparkline.render(
- values: state.values,
- min: 0,
- max: 100
- ),
- text("", nil),
-
- # Labeled sparkline
- # Shows label and min/max values alongside the sparkline
- text("Labeled Sparkline:", nil),
- Sparkline.render_labeled(
- values: state.values,
- label: "CPU",
- show_range: true
- ),
- text("", nil),
-
- # Styled sparkline
- # Apply a color to the entire sparkline
- text("Styled Sparkline:", nil),
- Sparkline.render(
- values: state.values,
- style: Style.new(fg: :green)
- ),
- text("", nil),
-
- # Color-coded sparkline (when enabled)
- # Different colors based on value thresholds
- render_colored_sparkline(state),
- text("", nil),
-
- # Show the bar characters used
- text("Sparkline bar characters:", nil),
- text(Enum.join(Sparkline.bar_characters(), " "), nil),
- text("", nil),
-
- # Controls
- render_controls(state)
- ])
- end
-
- defp render_controls(state) do
- box_width = 56
- inner_width = box_width - 2
-
- top_border = "┌─ Controls " <> String.duplicate("─", inner_width - 12) <> "─┐"
- bottom_border = "└" <> String.duplicate("─", inner_width) <> "┘"
-
- stack(:vertical, [
- text("", nil),
- text(top_border, Style.new(fg: :yellow)),
- text("│" <> String.pad_trailing(" Space Add random data point", inner_width) <> "│", nil),
- text("│" <> String.pad_trailing(" R Reset data", inner_width) <> "│", nil),
- text("│" <> String.pad_trailing(" C Toggle color mode (#{if state.colored, do: "ON", else: "OFF"})", inner_width) <> "│", nil),
- text("│" <> String.pad_trailing(" Q Quit", inner_width) <> "│", nil),
- text("│" <> String.pad_trailing("", inner_width) <> "│", nil),
- text("│" <> String.pad_trailing(" Data points: #{length(state.values)}", inner_width) <> "│", nil),
- text(bottom_border, Style.new(fg: :yellow))
- ])
- end
-
- # ----------------------------------------------------------------------------
- # Private Helpers
- # ----------------------------------------------------------------------------
-
- defp render_colored_sparkline(state) do
- if state.colored do
- stack(:vertical, [
- text("Color-coded Sparkline (green < 50 < yellow < 75 < red):", nil),
- Sparkline.render(
- values: state.values,
- # Color ranges: {threshold, color}
- # Colors apply when value >= threshold
- color_ranges: [
- {0, Style.new(fg: :green)},
- {50, Style.new(fg: :yellow)},
- {75, Style.new(fg: :red)}
- ]
- )
- ])
- else
- stack(:vertical, [
- text("Color-coded Sparkline (press C to enable):", nil),
- text("(disabled)", nil)
- ])
- end
- end
-
- # ----------------------------------------------------------------------------
- # Public API
- # ----------------------------------------------------------------------------
-
- @doc """
- Run the sparkline example application.
- """
- def run do
- TermUI.Runtime.run(root: __MODULE__)
- end
-end
diff --git a/examples/sparkline/lib/sparkline/application.ex b/examples/sparkline/lib/sparkline/application.ex
deleted file mode 100644
index 0bb3f601..00000000
--- a/examples/sparkline/lib/sparkline/application.ex
+++ /dev/null
@@ -1,12 +0,0 @@
-defmodule Sparkline.Application do
- @moduledoc false
-
- use Application
-
- @impl true
- def start(_type, _args) do
- children = []
- opts = [strategy: :one_for_one, name: Sparkline.Supervisor]
- Supervisor.start_link(children, opts)
- end
-end
diff --git a/examples/sparkline/mix.exs b/examples/sparkline/mix.exs
deleted file mode 100644
index 3a7d19cd..00000000
--- a/examples/sparkline/mix.exs
+++ /dev/null
@@ -1,26 +0,0 @@
-defmodule Sparkline.MixProject do
- use Mix.Project
-
- def project do
- [
- app: :sparkline,
- version: "0.1.0",
- elixir: "~> 1.15",
- start_permanent: Mix.env() == :prod,
- deps: deps()
- ]
- end
-
- def application do
- [
- extra_applications: [:logger],
- mod: {Sparkline.Application, []}
- ]
- end
-
- defp deps do
- [
- {:term_ui, path: "../.."}
- ]
- end
-end
diff --git a/examples/sparkline/mix.lock b/examples/sparkline/mix.lock
deleted file mode 100644
index ee8761c0..00000000
--- a/examples/sparkline/mix.lock
+++ /dev/null
@@ -1,12 +0,0 @@
-%{
- "castore": {:hex, :castore, "1.0.17", "4f9770d2d45fbd91dcf6bd404cf64e7e58fed04fadda0923dc32acca0badffa2", [:mix], [], "hexpm", "12d24b9d80b910dd3953e165636d68f147a31db945d2dcb9365e441f8b5351e5"},
- "gen_stage": {:hex, :gen_stage, "1.3.2", "7c77e5d1e97de2c6c2f78f306f463bca64bf2f4c3cdd606affc0100b89743b7b", [:mix], [], "hexpm", "0ffae547fa777b3ed889a6b9e1e64566217413d018cabd825f786e843ffe63e7"},
- "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
- "lumis": {:hex, :lumis, "0.1.0", "6d3b495457d0608f8fe32fe6fa917eb17c921bd0087583a7f4c420c0009888fd", [:mix], [{:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:rustler, "~> 0.29", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.6", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "4de203bc5811ad21f0c6b0442612a3fc1ae9bea7fbfe7037452db9e9dfdaaab3"},
- "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"},
- "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"},
- "mdex": {:hex, :mdex, "0.11.3", "7b815ae2f474e62ad365dfa4d9df87ddec6a01cfa9b7db523a3b21061d909225", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:lumis, "~> 0.1", [hex: :lumis, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}, {:rustler, "~> 0.32", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.7", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "181bf04b13dcae20ab9f336115facc5096aae333a2408a03e53338ee65d4f564"},
- "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
- "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"},
- "rustler_precompiled": {:hex, :rustler_precompiled, "0.8.4", "700a878312acfac79fb6c572bb8b57f5aae05fe1cf70d34b5974850bbf2c05bf", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "3b33d99b540b15f142ba47944f7a163a25069f6d608783c321029bc1ffb09514"},
-}
diff --git a/examples/sparkline/run.exs b/examples/sparkline/run.exs
deleted file mode 100644
index a20a45ba..00000000
--- a/examples/sparkline/run.exs
+++ /dev/null
@@ -1 +0,0 @@
-Sparkline.App.run()
diff --git a/examples/split_pane/README.md b/examples/split_pane/README.md
deleted file mode 100644
index 11646d2a..00000000
--- a/examples/split_pane/README.md
+++ /dev/null
@@ -1,130 +0,0 @@
-# SplitPane Widget Example
-
-A demonstration of the TermUI SplitPane widget for creating resizable multi-pane layouts similar to IDE editors.
-
-## Widget Overview
-
-The SplitPane widget divides screen space between multiple panes with resizable dividers, enabling complex layouts like code editors with sidebars and bottom panels. Panes can be arranged horizontally (side-by-side) or vertically (stacked), and can be nested for sophisticated multi-section layouts.
-
-**Key Features:**
-- Horizontal and vertical split orientations
-- Keyboard and mouse-controlled divider resizing
-- Min/max size constraints per pane
-- Collapsible panes for maximizing workspace
-- Nested splits for complex layouts (like IDEs)
-- Layout state persistence
-
-**When to Use:**
-- Multi-panel applications (editors, file browsers, terminals)
-- IDE-style layouts with sidebars and panels
-- Split-screen comparisons
-- Any application requiring flexible, user-adjustable layouts
-
-## Widget Options
-
-The `SplitPane.new/1` function accepts these options:
-
-- `:orientation` - `:horizontal` (side by side) or `:vertical` (stacked) (default: `:horizontal`)
-- `:panes` - List of pane specifications created with `SplitPane.pane/3` (required)
-- `:divider_size` - Divider thickness in characters (default: 1)
-- `:divider_style` - Style for unfocused dividers
-- `:focused_divider_style` - Style for the focused divider
-- `:resizable` - Whether dividers can be dragged (default: true)
-- `:on_resize` - Callback function when panes are resized: `fn panes -> ... end`
-- `:on_collapse` - Callback when pane is collapsed/expanded: `fn {id, collapsed} -> ... end`
-- `:persist_key` - Key for layout persistence (optional)
-
-**Pane Specification** using `SplitPane.pane(id, content, opts)`:
-
-- `id` - Unique identifier for the pane
-- `content` - Render tree or nested SplitPane state
-- `:size` - Size as float (0.0-1.0 proportion) or integer (fixed chars/lines) (default: 1.0)
-- `:min_size` - Minimum size in characters/lines
-- `:max_size` - Maximum size in characters/lines
-- `:collapsed` - Whether pane starts collapsed (default: false)
-
-## Example Structure
-
-This example consists of:
-
-- `lib/split_pane/app.ex` - Main application demonstrating:
- - Horizontal layout (3 panes side-by-side)
- - Vertical layout (3 panes stacked)
- - Nested layout (IDE-style with sidebar and editor/terminal split)
- - Keyboard-controlled divider resizing
- - Min/max size constraints
- - Layout save/restore functionality
-- `mix.exs` - Mix project configuration
-- `run.exs` - Helper script to run the example
-
-## Running the Example
-
-### Raw Mode (Full TUI Experience)
-
-For the best experience with full terminal control and alternate screen:
-
-```bash
-cd examples/split_pane
-mix termui.run
-```
-
-Or manually:
-
-```bash
-cd examples/split_pane
-mix run -e "SplitPane.App.run()" --no-halt
-```
-
-### TTY Mode (IEx Compatible)
-
-To run from IEx without taking over the shell:
-
-```bash
-cd examples/split_pane
-iex -S mix
-```
-
-Then in IEx:
-
-```elixir
-SplitPane.App.run()
-```
-
-**Note:** TTY mode works inside IEx but has limitations:
-- No alternate screen buffer (output mixes with IEx prompt)
-- Character input works immediately (no Enter needed)
-- For full TUI, use raw mode instead
-
-## Controls
-
-### Navigation
-- **Tab** - Focus next divider
-- **Shift+Tab** - Focus previous divider
-
-### Resizing
-- **Left/Up** - Move focused divider left/up (1 unit)
-- **Right/Down** - Move focused divider right/down (1 unit)
-- **Shift+Arrow** - Move divider by larger step (5 units)
-- **Home** - Move divider to minimum position
-- **End** - Move divider to maximum position
-
-### Pane Operations
-- **Enter** - Toggle collapse/expand pane after divider
-
-### Layout Management
-- **H** - Switch to horizontal layout mode
-- **V** - Switch to vertical layout mode
-- **N** - Switch to nested IDE-style layout
-- **S** - Save current layout (pane sizes and states)
-- **R** - Restore previously saved layout
-
-### Application
-- **Q** - Quit
-
-## Layout Modes
-
-The example demonstrates three layout modes:
-
-1. **Horizontal** - Three panes arranged side-by-side with adjustable dividers
-2. **Vertical** - Three panes stacked vertically with adjustable dividers
-3. **Nested (IDE)** - Two-level split with a sidebar and a main area that's further divided into editor and terminal sections
diff --git a/examples/split_pane/lib/split_pane/app.ex b/examples/split_pane/lib/split_pane/app.ex
deleted file mode 100644
index 52de07fb..00000000
--- a/examples/split_pane/lib/split_pane/app.ex
+++ /dev/null
@@ -1,295 +0,0 @@
-defmodule SplitPane.App do
- @moduledoc """
- SplitPane Widget Example
-
- This example demonstrates how to use the TermUI.Widgets.SplitPane widget
- for creating resizable multi-pane layouts like IDEs.
-
- Features demonstrated:
- - Horizontal and vertical split orientations
- - Nested splits for complex layouts
- - Keyboard-controlled divider resizing
- - Pane collapse/expand
- - Min/max size constraints
- - Layout persistence
-
- Controls:
- - Tab: Focus next divider
- - Shift+Tab: Focus previous divider
- - Left/Up: Move divider left/up
- - Right/Down: Move divider right/down
- - Shift+Arrow: Move divider by larger step
- - Enter: Toggle collapse pane after divider
- - Home: Move divider to minimum
- - End: Move divider to maximum
- - H: Switch to horizontal layout
- - V: Switch to vertical layout
- - N: Switch to nested layout (IDE-style)
- - S: Save layout
- - R: Restore layout
- - Q: Quit the application
- """
-
- use TermUI.Elm
-
- alias TermUI.Event
- alias TermUI.Renderer.Style
- alias TermUI.Widgets.SplitPane, as: SP
-
- # ----------------------------------------------------------------------------
- # Component Callbacks
- # ----------------------------------------------------------------------------
-
- @doc """
- Initialize the component state.
- """
- def init(_opts) do
- %{
- split_state: nil,
- layout_mode: :horizontal,
- saved_layout: nil,
- status_message: "Tab to focus divider, arrows to resize"
- }
- end
-
- defp build_split_state(:horizontal) do
- props =
- SP.new(
- orientation: :horizontal,
- panes: [
- SP.pane(:left, build_pane_content("Left Pane", :blue),
- size: 0.3,
- min_size: 10,
- max_size: 50
- ),
- SP.pane(:middle, build_pane_content("Middle Pane", :green), size: 0.4),
- SP.pane(:right, build_pane_content("Right Pane", :magenta), size: 0.3, min_size: 10)
- ]
- )
-
- {:ok, state} = SP.init(props)
- state
- end
-
- defp build_split_state(:vertical) do
- props =
- SP.new(
- orientation: :vertical,
- panes: [
- SP.pane(:top, build_pane_content("Top Pane", :cyan), size: 0.4, min_size: 5),
- SP.pane(:middle, build_pane_content("Middle Pane", :yellow), size: 0.3),
- SP.pane(:bottom, build_pane_content("Bottom Pane", :red), size: 0.3, min_size: 3)
- ]
- )
-
- {:ok, state} = SP.init(props)
- state
- end
-
- defp build_split_state(:nested) do
- # Build an IDE-like layout with nested splits
- # Left sidebar | Main area (top editor / bottom terminal)
-
- # Inner vertical split for main area
- inner_props =
- SP.new(
- orientation: :vertical,
- panes: [
- SP.pane(:editor, build_pane_content("Editor", :green), size: 0.7, min_size: 5),
- SP.pane(:terminal, build_pane_content("Terminal", :white), size: 0.3, min_size: 3)
- ]
- )
-
- {:ok, inner_state} = SP.init(inner_props)
-
- # Outer horizontal split
- outer_props =
- SP.new(
- orientation: :horizontal,
- panes: [
- SP.pane(:sidebar, build_pane_content("Sidebar", :blue), size: 0.2, min_size: 10),
- SP.pane(:main, inner_state, size: 0.8)
- ]
- )
-
- {:ok, outer_state} = SP.init(outer_props)
- outer_state
- end
-
- defp build_pane_content(title, color) do
- lines = [
- title,
- String.duplicate("-", String.length(title)),
- "",
- "Content area",
- "Resize with arrows",
- "Enter to collapse"
- ]
-
- text(Enum.join(lines, "\n"), Style.new(fg: color))
- end
-
- @doc """
- Convert keyboard events to messages.
- """
- def event_to_msg(%Event.Key{key: key}, _state) when key in ["q", "Q"], do: {:msg, :quit}
- def event_to_msg(%Event.Key{key: key}, _state) when key in ["h", "H"], do: {:msg, :horizontal}
- def event_to_msg(%Event.Key{key: key}, _state) when key in ["v", "V"], do: {:msg, :vertical}
- def event_to_msg(%Event.Key{key: key}, _state) when key in ["n", "N"], do: {:msg, :nested}
- def event_to_msg(%Event.Key{key: key}, _state) when key in ["s", "S"], do: {:msg, :save_layout}
- def event_to_msg(%Event.Key{key: key}, _state) when key in ["r", "R"], do: {:msg, :restore_layout}
-
- def event_to_msg(event, _state) do
- {:msg, {:split_event, event}}
- end
-
- @doc """
- Update state based on messages.
- """
- def update(:quit, state) do
- {state, [:quit]}
- end
-
- def update(:horizontal, state) do
- split_state = build_split_state(:horizontal)
- message = "Switched to horizontal layout"
- {%{state | layout_mode: :horizontal, split_state: split_state, status_message: message}, []}
- end
-
- def update(:vertical, state) do
- split_state = build_split_state(:vertical)
- message = "Switched to vertical layout"
- {%{state | layout_mode: :vertical, split_state: split_state, status_message: message}, []}
- end
-
- def update(:nested, state) do
- split_state = build_split_state(:nested)
- message = "Switched to nested IDE layout"
- {%{state | layout_mode: :nested, split_state: split_state, status_message: message}, []}
- end
-
- def update(:save_layout, state) do
- split_state = ensure_split_state(state)
- layout = SP.get_layout(split_state)
- message = "Layout saved!"
- {%{state | saved_layout: layout, status_message: message}, []}
- end
-
- def update(:restore_layout, state) do
- split_state = ensure_split_state(state)
-
- if state.saved_layout do
- split_state = SP.set_layout(split_state, state.saved_layout)
- message = "Layout restored!"
- {%{state | split_state: split_state, status_message: message}, []}
- else
- {%{state | status_message: "No saved layout to restore"}, []}
- end
- end
-
- def update({:split_event, event}, state) do
- split_state = ensure_split_state(state)
- {:ok, split_state} = SP.handle_event(event, split_state)
-
- message = get_status_message(split_state)
- {%{state | split_state: split_state, status_message: message}, []}
- end
-
- defp ensure_split_state(state) do
- state.split_state || build_split_state(state.layout_mode)
- end
-
- defp get_status_message(split_state) do
- focused = SP.get_focused_divider(split_state)
-
- if focused != nil do
- "Divider #{focused + 1} focused - arrows to resize, Enter to collapse"
- else
- "Tab to focus divider, arrows to resize"
- end
- end
-
- @doc """
- Render the current state to a render tree.
- """
- def view(state) do
- split_state = ensure_split_state(state)
-
- stack(:vertical, [
- # Title
- text("SplitPane Widget Example", Style.new(fg: :cyan, attrs: [:bold])),
- text("", nil),
-
- # Split pane
- render_split_container(split_state),
-
- # Status
- text("", nil),
- text(state.status_message, Style.new(fg: :yellow)),
-
- # Controls
- render_controls(state)
- ])
- end
-
- defp render_split_container(split_state) do
- # Render the split pane
- split_render = SP.render(split_state, %{x: 0, y: 0, width: 70, height: 15})
-
- box_width = 72
- inner_width = box_width - 2
-
- top_border = "+" <> String.duplicate("-", inner_width) <> "+"
- bottom_border = "+" <> String.duplicate("-", inner_width) <> "+"
-
- stack(:vertical, [
- text(top_border, Style.new(fg: :blue)),
- stack(:horizontal, [
- text("| ", nil),
- split_render,
- text(" |", nil)
- ]),
- text(bottom_border, Style.new(fg: :blue))
- ])
- end
-
- defp render_controls(state) do
- box_width = 55
- inner_width = box_width - 2
-
- mode_str =
- case state.layout_mode do
- :horizontal -> "horizontal"
- :vertical -> "vertical"
- :nested -> "nested (IDE)"
- end
-
- top_border = "+" <> String.duplicate("-", inner_width - 10) <> " Controls " <> "+"
- bottom_border = "+" <> String.duplicate("-", inner_width) <> "+"
-
- stack(:vertical, [
- text("", nil),
- text(top_border, Style.new(fg: :yellow)),
- text("|" <> String.pad_trailing(" Tab/S-Tab Focus dividers", inner_width) <> "|", nil),
- text("|" <> String.pad_trailing(" Arrows Resize focused divider", inner_width) <> "|", nil),
- text("|" <> String.pad_trailing(" Shift+Arr Large resize step", inner_width) <> "|", nil),
- text("|" <> String.pad_trailing(" Enter Collapse/expand pane", inner_width) <> "|", nil),
- text("|" <> String.pad_trailing(" Home/End Min/max position", inner_width) <> "|", nil),
- text("|" <> String.pad_trailing(" H/V/N Switch layout (#{mode_str})", inner_width) <> "|", nil),
- text("|" <> String.pad_trailing(" S/R Save/Restore layout", inner_width) <> "|", nil),
- text("|" <> String.pad_trailing(" Q Quit", inner_width) <> "|", nil),
- text(bottom_border, Style.new(fg: :yellow))
- ])
- end
-
- # ----------------------------------------------------------------------------
- # Public API
- # ----------------------------------------------------------------------------
-
- @doc """
- Run the split pane example application.
- """
- def run do
- TermUI.Runtime.run(root: __MODULE__)
- end
-end
diff --git a/examples/split_pane/lib/split_pane/application.ex b/examples/split_pane/lib/split_pane/application.ex
deleted file mode 100644
index 3a4b08c9..00000000
--- a/examples/split_pane/lib/split_pane/application.ex
+++ /dev/null
@@ -1,12 +0,0 @@
-defmodule SplitPane.Application do
- @moduledoc false
-
- use Application
-
- @impl true
- def start(_type, _args) do
- children = []
- opts = [strategy: :one_for_one, name: SplitPane.Supervisor]
- Supervisor.start_link(children, opts)
- end
-end
diff --git a/examples/split_pane/mix.exs b/examples/split_pane/mix.exs
deleted file mode 100644
index a8068de6..00000000
--- a/examples/split_pane/mix.exs
+++ /dev/null
@@ -1,26 +0,0 @@
-defmodule SplitPane.MixProject do
- use Mix.Project
-
- def project do
- [
- app: :split_pane,
- version: "0.1.0",
- elixir: "~> 1.15",
- start_permanent: Mix.env() == :prod,
- deps: deps()
- ]
- end
-
- def application do
- [
- extra_applications: [:logger],
- mod: {SplitPane.Application, []}
- ]
- end
-
- defp deps do
- [
- {:term_ui, path: "../.."}
- ]
- end
-end
diff --git a/examples/split_pane/mix.lock b/examples/split_pane/mix.lock
deleted file mode 100644
index ee8761c0..00000000
--- a/examples/split_pane/mix.lock
+++ /dev/null
@@ -1,12 +0,0 @@
-%{
- "castore": {:hex, :castore, "1.0.17", "4f9770d2d45fbd91dcf6bd404cf64e7e58fed04fadda0923dc32acca0badffa2", [:mix], [], "hexpm", "12d24b9d80b910dd3953e165636d68f147a31db945d2dcb9365e441f8b5351e5"},
- "gen_stage": {:hex, :gen_stage, "1.3.2", "7c77e5d1e97de2c6c2f78f306f463bca64bf2f4c3cdd606affc0100b89743b7b", [:mix], [], "hexpm", "0ffae547fa777b3ed889a6b9e1e64566217413d018cabd825f786e843ffe63e7"},
- "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
- "lumis": {:hex, :lumis, "0.1.0", "6d3b495457d0608f8fe32fe6fa917eb17c921bd0087583a7f4c420c0009888fd", [:mix], [{:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:rustler, "~> 0.29", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.6", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "4de203bc5811ad21f0c6b0442612a3fc1ae9bea7fbfe7037452db9e9dfdaaab3"},
- "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"},
- "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"},
- "mdex": {:hex, :mdex, "0.11.3", "7b815ae2f474e62ad365dfa4d9df87ddec6a01cfa9b7db523a3b21061d909225", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:lumis, "~> 0.1", [hex: :lumis, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}, {:rustler, "~> 0.32", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.7", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "181bf04b13dcae20ab9f336115facc5096aae333a2408a03e53338ee65d4f564"},
- "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
- "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"},
- "rustler_precompiled": {:hex, :rustler_precompiled, "0.8.4", "700a878312acfac79fb6c572bb8b57f5aae05fe1cf70d34b5974850bbf2c05bf", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "3b33d99b540b15f142ba47944f7a163a25069f6d608783c321029bc1ffb09514"},
-}
diff --git a/examples/split_pane/run.exs b/examples/split_pane/run.exs
deleted file mode 100644
index dee2c6d1..00000000
--- a/examples/split_pane/run.exs
+++ /dev/null
@@ -1 +0,0 @@
-SplitPane.App.run()
diff --git a/examples/stream_widget/README.md b/examples/stream_widget/README.md
deleted file mode 100644
index e8b3b9b5..00000000
--- a/examples/stream_widget/README.md
+++ /dev/null
@@ -1,139 +0,0 @@
-# StreamWidget Example
-
-A demonstration of the TermUI StreamWidget for displaying backpressure-aware streaming data with GenStage integration.
-
-## Widget Overview
-
-The StreamWidget provides real-time display of streaming data with built-in buffer management and GenStage integration. It handles backpressure automatically and provides controls for stream management, making it ideal for applications that need to display continuous data flows like logs, events, or sensor readings.
-
-**Key Features:**
-- GenStage integration for demand-based streaming
-- Configurable buffer with overflow strategies
-- Pause/resume controls
-- Real-time statistics (items/sec, buffer usage)
-- Scrollable buffer navigation
-- Multiple overflow strategies (drop oldest, drop newest, block, sliding)
-
-**When to Use:**
-- Log viewers and monitoring applications
-- Real-time event streams
-- Data pipeline visualization
-- Any application displaying continuous data flows
-
-## Widget Options
-
-The `StreamWidget.new/1` function accepts these options:
-
-- `:buffer_size` - Maximum items in buffer (default: 1000)
-- `:overflow_strategy` - What to do when buffer is full (default: `:drop_oldest`)
- - `:drop_oldest` - Remove oldest items to make room
- - `:drop_newest` - Discard new items when full
- - `:block` - Stop accepting items until space is available
- - `:sliding` - Same as `:drop_oldest`
-- `:demand` - How many items to request at a time from producer (default: 10)
-- `:show_stats` - Display statistics bar (default: true)
-- `:render_rate_ms` - Minimum time between renders in ms (default: 100)
-- `:item_renderer` - Function to render each item: `fn item -> String.t()`
-- `:on_item` - Callback when item is received: `fn item -> ... end`
-- `:on_error` - Callback when error occurs: `fn error -> ... end`
-
-## Example Structure
-
-This example consists of:
-
-- `lib/stream_widget/app.ex` - Main application demonstrating:
- - StreamWidget initialization
- - GenStage producer/consumer integration
- - Pause/resume controls
- - Buffer management
- - Overflow strategy switching
- - Real-time statistics display
-- `lib/stream_widget/producer.ex` - GenStage producer that generates sample events
-- `lib/stream_widget/application.ex` - Application supervisor
-- `mix.exs` - Mix project configuration
-- `run.exs` - Helper script to run the example
-
-## Running the Example
-
-### Raw Mode (Full TUI Experience)
-
-For the best experience with full terminal control and alternate screen:
-
-```bash
-cd examples/stream_widget
-mix termui.run
-```
-
-Or manually:
-
-```bash
-cd examples/stream_widget
-mix run -e "StreamWidget.App.run()" --no-halt
-```
-
-### TTY Mode (IEx Compatible)
-
-To run from IEx without taking over the shell:
-
-```bash
-cd examples/stream_widget
-iex -S mix
-```
-
-Then in IEx:
-
-```elixir
-StreamWidget.App.run()
-```
-
-**Note:** TTY mode works inside IEx but has limitations:
-- No alternate screen buffer (output mixes with IEx prompt)
-- Character input works immediately (no Enter needed)
-- For full TUI, use raw mode instead
-
-## Controls
-
-### Stream Control
-- **Space** - Start/pause/resume streaming
-
-### Buffer Management
-- **c** - Clear buffer
-- **s** - Toggle statistics display
-
-### Overflow Strategy
-- **1** - Set strategy to drop oldest items
-- **2** - Set strategy to drop newest items
-- **3** - Set strategy to block when full
-- **4** - Set strategy to sliding window
-
-### Event Rate
-- **+** - Increase event rate (decrease interval)
-- **-** - Decrease event rate (increase interval)
-
-### Navigation
-- **Up/Down** - Scroll through buffer items
-- **Page Up/Page Down** - Scroll by page
-- **Home** - Jump to first item
-- **End** - Jump to last item
-
-### Application
-- **Q** or **Escape** - Quit
-
-## Statistics Display
-
-When enabled, the widget shows:
-- **Status** - Current stream state (IDLE, RUNNING, PAUSED)
-- **Buffer** - Current items / maximum capacity
-- **Strategy** - Active overflow strategy
-- **Received** - Total items received
-- **Dropped** - Total items dropped due to overflow
-- **Rate** - Current items per second
-
-## GenStage Integration
-
-The example demonstrates proper GenStage integration:
-
-1. A Producer (`StreamWidgetExample.Producer`) generates events at a configurable interval
-2. A Consumer (`StreamWidget.Consumer`) subscribes to the producer
-3. The StreamWidget manages demand and backpressure
-4. Events flow through the pipeline respecting the buffer capacity and overflow strategy
diff --git a/examples/stream_widget/lib/stream_widget/app.ex b/examples/stream_widget/lib/stream_widget/app.ex
deleted file mode 100644
index 9cdfaffa..00000000
--- a/examples/stream_widget/lib/stream_widget/app.ex
+++ /dev/null
@@ -1,218 +0,0 @@
-defmodule StreamWidget.App do
- @moduledoc """
- Example application demonstrating the StreamWidget.
-
- This example shows:
- - GenStage producer integration
- - Real-time data streaming
- - Pause/resume controls
- - Buffer management
- - Statistics display
- - Overflow strategy switching
-
- ## Controls
-
- - Space: Pause/resume stream
- - c: Clear buffer
- - s: Toggle stats display
- - 1-4: Change overflow strategy
- - +/-: Increase/decrease event rate
- - Up/Down: Scroll through buffer
- - PageUp/PageDown: Scroll by page
- - q/Escape: Quit
- """
-
- use TermUI.Elm
-
- alias TermUI.Widgets.StreamWidget
- alias TermUI.Widgets.StreamWidget.Consumer
- alias TermUI.Event
- alias TermUI.Renderer.Style
- alias StreamWidgetExample.Producer
-
- # TermUI.Elm Callbacks
-
- def init(_args) do
- # Create stream widget props
- props =
- StreamWidget.new(
- buffer_size: 500,
- overflow_strategy: :drop_oldest,
- show_stats: true,
- item_renderer: &render_item/1
- )
-
- {:ok, widget_state} = StreamWidget.init(props)
-
- %{
- widget_state: widget_state,
- producer_pid: nil,
- consumer_pid: nil,
- interval_ms: 100,
- message: "Press Space to start streaming, q to quit"
- }
- end
-
- def event_to_msg(%Event.Key{key: " "}, _state), do: {:msg, :toggle_stream}
- def event_to_msg(%Event.Key{key: "c"}, _state), do: {:msg, :clear}
- def event_to_msg(%Event.Key{key: "s"}, _state), do: {:msg, :toggle_stats}
- def event_to_msg(%Event.Key{key: "1"}, _state), do: {:msg, {:strategy, :drop_oldest}}
- def event_to_msg(%Event.Key{key: "2"}, _state), do: {:msg, {:strategy, :drop_newest}}
- def event_to_msg(%Event.Key{key: "3"}, _state), do: {:msg, {:strategy, :block}}
- def event_to_msg(%Event.Key{key: "4"}, _state), do: {:msg, {:strategy, :sliding}}
- def event_to_msg(%Event.Key{key: "+"}, _state), do: {:msg, :faster}
- def event_to_msg(%Event.Key{key: "-"}, _state), do: {:msg, :slower}
- def event_to_msg(%Event.Key{key: key}, _state) when key in ["q", "Q"], do: {:msg, :quit}
- def event_to_msg(%Event.Key{key: :escape}, _state), do: {:msg, :quit}
-
- def event_to_msg(%Event.Key{key: key}, _state)
- when key in [:up, :down, :page_up, :page_down, :home, :end] do
- {:msg, {:widget_event, %Event.Key{key: key}}}
- end
-
- def event_to_msg(_event, _state), do: :ignore
-
- def update(:quit, state) do
- # Stop producer and consumer
- if state.producer_pid, do: GenStage.stop(state.producer_pid)
- if state.consumer_pid, do: GenStage.stop(state.consumer_pid)
- {state, [:quit]}
- end
-
- def update(:toggle_stream, state) when state.producer_pid == nil do
- # Start streaming
- {:ok, producer} = Producer.start_link(interval_ms: state.interval_ms)
- {:ok, consumer} = Consumer.start_link(self())
- Consumer.subscribe(consumer, producer)
-
- # Update widget state to reflect running
- {:ok, widget_state} =
- StreamWidget.handle_info({:consumer_started, consumer}, state.widget_state)
-
- {%{state |
- producer_pid: producer,
- consumer_pid: consumer,
- widget_state: widget_state,
- message: "Streaming... Space to pause, q to quit"
- }, []}
- end
-
- def update(:toggle_stream, state) do
- # Pause/resume when streaming
- if StreamWidget.paused?(state.widget_state) do
- Producer.resume(state.producer_pid)
- {:ok, widget_state} = StreamWidget.resume(state.widget_state)
- {%{state | widget_state: widget_state, message: "Resumed streaming"}, []}
- else
- Producer.pause(state.producer_pid)
- {:ok, widget_state} = StreamWidget.pause(state.widget_state)
- {%{state | widget_state: widget_state, message: "Paused streaming"}, []}
- end
- end
-
- def update(:clear, state) do
- {:ok, widget_state} = StreamWidget.clear(state.widget_state)
- {%{state | widget_state: widget_state, message: "Buffer cleared"}, []}
- end
-
- def update(:toggle_stats, state) do
- {:ok, widget_state} = StreamWidget.handle_event(%Event.Key{key: "s"}, state.widget_state)
- {%{state | widget_state: widget_state}, []}
- end
-
- def update({:strategy, strategy}, state) do
- {:ok, widget_state} = StreamWidget.set_overflow_strategy(state.widget_state, strategy)
- {%{state | widget_state: widget_state, message: "Strategy: #{strategy}"}, []}
- end
-
- def update(:faster, state) do
- new_interval = max(10, state.interval_ms - 10)
- if state.producer_pid, do: Producer.set_interval(state.producer_pid, new_interval)
- {%{state | interval_ms: new_interval, message: "Interval: #{new_interval}ms"}, []}
- end
-
- def update(:slower, state) do
- new_interval = min(1000, state.interval_ms + 10)
- if state.producer_pid, do: Producer.set_interval(state.producer_pid, new_interval)
- {%{state | interval_ms: new_interval, message: "Interval: #{new_interval}ms"}, []}
- end
-
- def update({:widget_event, event}, state) do
- {:ok, widget_state} = StreamWidget.handle_event(event, state.widget_state)
- {%{state | widget_state: widget_state}, []}
- end
-
- def update(_msg, state) do
- {state, []}
- end
-
- # Handle info messages from the consumer
- def handle_info({:stream_items, items}, state) do
- {:ok, widget_state} = StreamWidget.handle_info({:stream_items, items}, state.widget_state)
- {%{state | widget_state: widget_state}, []}
- end
-
- def handle_info({:consumer_started, pid}, state) do
- {:ok, widget_state} = StreamWidget.handle_info({:consumer_started, pid}, state.widget_state)
- {%{state | widget_state: widget_state}, []}
- end
-
- def handle_info(_msg, state) do
- {state, []}
- end
-
- def view(state) do
- # Use fixed dimensions for the widget
- area = %{x: 0, y: 0, width: 78, height: 15}
-
- widget_view = StreamWidget.render(state.widget_state, area)
-
- help_text = "[Space] Start/Pause | [c] Clear | [s] Stats | [1-4] Strategy | [+/-] Rate | [q] Quit"
-
- stack(:vertical, [
- text("StreamWidget Example", Style.new(fg: :cyan, attrs: [:bold])),
- text(state.message, Style.new(fg: :yellow)),
- text("", nil),
- render_widget_container(widget_view, state),
- text("", nil),
- text(help_text, Style.new(fg: :white, attrs: [:dim]))
- ])
- end
-
- defp render_widget_container(widget_view, state) do
- box_width = 80
- inner_width = box_width - 2
-
- stats = StreamWidget.get_stats(state.widget_state)
- buffer_info = "Buffer: #{stats.buffer_size}/#{stats.buffer_capacity}"
-
- top_border = "+" <> String.duplicate("-", 3) <> " Stream " <> String.duplicate("-", inner_width - 14 - String.length(buffer_info)) <> " #{buffer_info} +"
- bottom_border = "+" <> String.duplicate("-", inner_width) <> "+"
-
- stack(:vertical, [
- text(top_border, Style.new(fg: :blue)),
- stack(:horizontal, [
- text("| ", nil),
- widget_view,
- text(" |", nil)
- ]),
- text(bottom_border, Style.new(fg: :blue))
- ])
- end
-
- # Custom item renderer for display
- defp render_item(item) do
- data = item.data
-
- cond do
- is_binary(data) -> data
- true -> inspect(data)
- end
- end
-
- # Public API
-
- def run do
- TermUI.Runtime.run(root: __MODULE__)
- end
-end
diff --git a/examples/stream_widget/lib/stream_widget/application.ex b/examples/stream_widget/lib/stream_widget/application.ex
deleted file mode 100644
index e0ff368a..00000000
--- a/examples/stream_widget/lib/stream_widget/application.ex
+++ /dev/null
@@ -1,12 +0,0 @@
-defmodule StreamWidgetExample.Application do
- @moduledoc false
-
- use Application
-
- @impl true
- def start(_type, _args) do
- children = []
- opts = [strategy: :one_for_one, name: StreamWidgetExample.Supervisor]
- Supervisor.start_link(children, opts)
- end
-end
diff --git a/examples/stream_widget/lib/stream_widget/producer.ex b/examples/stream_widget/lib/stream_widget/producer.ex
deleted file mode 100644
index b93bafa5..00000000
--- a/examples/stream_widget/lib/stream_widget/producer.ex
+++ /dev/null
@@ -1,127 +0,0 @@
-defmodule StreamWidgetExample.Producer do
- @moduledoc """
- A GenStage producer that generates streaming data events.
- """
-
- use GenStage
-
- defstruct [:counter, :interval_ms, :paused, :timer_ref]
-
- @doc """
- Start the producer.
-
- ## Options
-
- - `:interval_ms` - Time between events in milliseconds (default: 100)
- """
- def start_link(opts \\ []) do
- GenStage.start_link(__MODULE__, opts, name: __MODULE__)
- end
-
- @doc """
- Set the event generation interval.
- """
- def set_interval(producer \\ __MODULE__, interval_ms) do
- GenStage.cast(producer, {:set_interval, interval_ms})
- end
-
- @doc """
- Pause event generation.
- """
- def pause(producer \\ __MODULE__) do
- GenStage.cast(producer, :pause)
- end
-
- @doc """
- Resume event generation.
- """
- def resume(producer \\ __MODULE__) do
- GenStage.cast(producer, :resume)
- end
-
- # GenStage Callbacks
-
- @impl true
- def init(opts) do
- interval_ms = Keyword.get(opts, :interval_ms, 100)
-
- state = %__MODULE__{
- counter: 0,
- interval_ms: interval_ms,
- paused: false,
- timer_ref: nil
- }
-
- # Schedule first tick
- timer_ref = Process.send_after(self(), :tick, interval_ms)
-
- {:producer, %{state | timer_ref: timer_ref}}
- end
-
- @impl true
- def handle_demand(_demand, state) do
- # We produce on timer, not on demand
- {:noreply, [], state}
- end
-
- @impl true
- def handle_cast({:set_interval, interval_ms}, state) do
- {:noreply, [], %{state | interval_ms: interval_ms}}
- end
-
- def handle_cast(:pause, state) do
- if state.timer_ref do
- Process.cancel_timer(state.timer_ref)
- end
-
- {:noreply, [], %{state | paused: true, timer_ref: nil}}
- end
-
- def handle_cast(:resume, state) do
- if state.paused do
- timer_ref = Process.send_after(self(), :tick, state.interval_ms)
- {:noreply, [], %{state | paused: false, timer_ref: timer_ref}}
- else
- {:noreply, [], state}
- end
- end
-
- @impl true
- def handle_info(:tick, %{paused: true} = state) do
- {:noreply, [], state}
- end
-
- def handle_info(:tick, state) do
- # Generate an event
- event = generate_event(state.counter)
-
- # Schedule next tick
- timer_ref = Process.send_after(self(), :tick, state.interval_ms)
-
- new_state = %{state | counter: state.counter + 1, timer_ref: timer_ref}
-
- {:noreply, [event], new_state}
- end
-
- defp generate_event(counter) do
- type = Enum.random([:info, :warning, :error, :debug, :data])
-
- case type do
- :info ->
- "[INFO] Event ##{counter}: System status OK"
-
- :warning ->
- "[WARN] Event ##{counter}: Memory usage at #{:rand.uniform(100)}%"
-
- :error ->
- "[ERROR] Event ##{counter}: Connection timeout after #{:rand.uniform(5000)}ms"
-
- :debug ->
- "[DEBUG] Event ##{counter}: Processing batch of #{:rand.uniform(100)} items"
-
- :data ->
- value = :rand.uniform(1000) / 10
- "[DATA] Event ##{counter}: Metric value = #{value}"
- end
- end
-end
diff --git a/examples/stream_widget/mix.exs b/examples/stream_widget/mix.exs
deleted file mode 100644
index 4015de00..00000000
--- a/examples/stream_widget/mix.exs
+++ /dev/null
@@ -1,27 +0,0 @@
-defmodule StreamWidgetExample.MixProject do
- use Mix.Project
-
- def project do
- [
- app: :stream_widget_example,
- version: "0.1.0",
- elixir: "~> 1.15",
- start_permanent: Mix.env() == :prod,
- deps: deps()
- ]
- end
-
- def application do
- [
- extra_applications: [:logger],
- mod: {StreamWidgetExample.Application, []}
- ]
- end
-
- defp deps do
- [
- {:term_ui, path: "../.."},
- {:gen_stage, "~> 1.2"}
- ]
- end
-end
diff --git a/examples/stream_widget/mix.lock b/examples/stream_widget/mix.lock
deleted file mode 100644
index ee8761c0..00000000
--- a/examples/stream_widget/mix.lock
+++ /dev/null
@@ -1,12 +0,0 @@
-%{
- "castore": {:hex, :castore, "1.0.17", "4f9770d2d45fbd91dcf6bd404cf64e7e58fed04fadda0923dc32acca0badffa2", [:mix], [], "hexpm", "12d24b9d80b910dd3953e165636d68f147a31db945d2dcb9365e441f8b5351e5"},
- "gen_stage": {:hex, :gen_stage, "1.3.2", "7c77e5d1e97de2c6c2f78f306f463bca64bf2f4c3cdd606affc0100b89743b7b", [:mix], [], "hexpm", "0ffae547fa777b3ed889a6b9e1e64566217413d018cabd825f786e843ffe63e7"},
- "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
- "lumis": {:hex, :lumis, "0.1.0", "6d3b495457d0608f8fe32fe6fa917eb17c921bd0087583a7f4c420c0009888fd", [:mix], [{:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:rustler, "~> 0.29", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.6", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "4de203bc5811ad21f0c6b0442612a3fc1ae9bea7fbfe7037452db9e9dfdaaab3"},
- "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"},
- "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"},
- "mdex": {:hex, :mdex, "0.11.3", "7b815ae2f474e62ad365dfa4d9df87ddec6a01cfa9b7db523a3b21061d909225", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:lumis, "~> 0.1", [hex: :lumis, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}, {:rustler, "~> 0.32", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.7", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "181bf04b13dcae20ab9f336115facc5096aae333a2408a03e53338ee65d4f564"},
- "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
- "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"},
- "rustler_precompiled": {:hex, :rustler_precompiled, "0.8.4", "700a878312acfac79fb6c572bb8b57f5aae05fe1cf70d34b5974850bbf2c05bf", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "3b33d99b540b15f142ba47944f7a163a25069f6d608783c321029bc1ffb09514"},
-}
diff --git a/examples/stream_widget/run.exs b/examples/stream_widget/run.exs
deleted file mode 100644
index ede45bfc..00000000
--- a/examples/stream_widget/run.exs
+++ /dev/null
@@ -1 +0,0 @@
-StreamWidget.App.run()
diff --git a/examples/supervision_tree_viewer/README.md b/examples/supervision_tree_viewer/README.md
deleted file mode 100644
index efc3746a..00000000
--- a/examples/supervision_tree_viewer/README.md
+++ /dev/null
@@ -1,170 +0,0 @@
-# SupervisionTreeViewer Widget Example
-
-A demonstration of the TermUI SupervisionTreeViewer widget for visualizing OTP supervision hierarchies in real-time.
-
-## Widget Overview
-
-The SupervisionTreeViewer displays live OTP supervision trees with status indicators, process information, and management controls. It provides an interactive view of your application's supervisor hierarchy, making it easy to understand process relationships and monitor system health.
-
-**Key Features:**
-- Tree view of supervision hierarchy
-- Live status indicators (running, restarting, terminated)
-- Process information display (memory, reductions, message queue)
-- Supervisor strategy visualization (one_for_one, one_for_all, etc.)
-- Process restart/terminate controls with confirmation
-- Tree filtering by process name
-- Auto-refresh capability
-
-**When to Use:**
-- Debugging OTP application structure
-- Monitoring process health in development
-- Understanding supervisor hierarchies
-- Process management during development
-- Educational demonstrations of OTP supervision
-
-## Widget Options
-
-The `SupervisionTreeViewer.new/1` function accepts these options:
-
-- `:root` - Root supervisor (pid, registered name, or module) (required)
-- `:update_interval` - Refresh interval in milliseconds (default: 2000)
-- `:on_select` - Callback when node is selected: `fn node -> ... end`
-- `:on_action` - Callback when action is performed: `fn {:restarted | :terminated, pid} -> ... end`
-- `:show_workers` - Show worker processes (default: true)
-- `:auto_expand` - Expand all nodes initially (default: true)
-
-## Example Structure
-
-This example consists of:
-
-- `lib/supervision_tree_viewer/app.ex` - Main application demonstrating:
- - SupervisionTreeViewer initialization
- - Tree navigation and expansion
- - Process information display
- - Process restart/terminate operations
- - Filter functionality
-- `lib/supervision_tree_viewer/sample_tree.ex` - Sample supervision tree for demonstration
-- `lib/supervision_tree_viewer/application.ex` - Application supervisor
-- `mix.exs` - Mix project configuration
-- `run.exs` - Helper script to run the example
-
-## Running the Example
-
-### Raw Mode (Full TUI Experience)
-
-For the best experience with full terminal control and alternate screen:
-
-```bash
-cd examples/supervision_tree_viewer
-mix termui.run
-```
-
-Or manually:
-
-```bash
-cd examples/supervision_tree_viewer
-mix run -e "SupervisionTreeViewerExample.App.run()" --no-halt
-```
-
-### TTY Mode (IEx Compatible)
-
-To run from IEx without taking over the shell:
-
-```bash
-cd examples/supervision_tree_viewer
-iex -S mix
-```
-
-Then in IEx:
-
-```elixir
-SupervisionTreeViewerExample.App.run()
-```
-
-**Note:** TTY mode works inside IEx but has limitations:
-- No alternate screen buffer (output mixes with IEx prompt)
-- Character input works immediately (no Enter needed)
-- For full TUI, use raw mode instead
-
-## Controls
-
-### Navigation
-- **Up/Down** - Move selection up/down in tree
-- **Left** - Collapse node or move to parent
-- **Right** - Expand node or move to first child
-- **Page Up/Page Down** - Scroll by page
-- **Home** - Jump to first node
-- **End** - Jump to last node
-
-### Tree Operations
-- **Enter** - Toggle expand/collapse for selected node
-
-### Information
-- **i** - Show/hide process info panel for selected process
-
-### Process Management (with confirmation)
-- **r** - Restart selected process (prompts for confirmation)
-- **k** - Terminate selected process (prompts for confirmation)
-- **y** - Confirm pending action
-- **n** - Cancel pending action
-
-### Filtering
-- **/** - Start filter input mode
-- Type to filter by process name
-- **Enter** - Apply filter
-- **Escape** - Clear filter or cancel input
-
-### Refresh
-- **R** - Force refresh tree
-
-### Application
-- **q** - Quit (only when not in filter input mode)
-- **Escape** - Clear filter/close info panel/cancel action
-
-## Status Indicators
-
-The tree view uses color-coded icons to show process status:
-
-- **● (green)** - Process is running normally
-- **◐ (yellow)** - Process is restarting
-- **○ (red)** - Process is terminated
-- **? (white)** - Process status is undefined
-
-## Node Types
-
-- **□** - Supervisor node
-- **◇** - Worker node
-
-## Supervisor Strategies
-
-Supervisor strategies are displayed with compact indicators:
-
-- **[1:1]** - `:one_for_one` - Restart only the failed child
-- **[1:*]** - `:one_for_all` - Restart all children when one fails
-- **[1:→]** - `:rest_for_one` - Restart failed child and those started after it
-- **[1:1+]** - `:simple_one_for_one` - Dynamically add children of the same type
-
-## Process Information Panel
-
-When opened with **i**, the panel displays:
-
-- **ID** - Process identifier
-- **PID** - Process ID
-- **Name** - Registered name (if any)
-- **Type** - Supervisor or worker
-- **Status** - Current process status
-- **Strategy** - Supervisor strategy (supervisors only)
-- **Max restarts** - Restart intensity and period (supervisors only)
-- **Memory** - Current memory usage
-- **Reductions** - Total reductions (execution steps)
-- **Msg Queue** - Message queue length
-
-## Sample Tree
-
-The example includes a sample supervision tree that demonstrates:
-- Multiple levels of supervisors
-- Various supervisor strategies
-- Worker processes
-- Nested supervision hierarchies
-
-This provides a realistic example for exploring the widget's capabilities.
diff --git a/examples/supervision_tree_viewer/lib/supervision_tree_viewer/app.ex b/examples/supervision_tree_viewer/lib/supervision_tree_viewer/app.ex
deleted file mode 100644
index 5540e8dd..00000000
--- a/examples/supervision_tree_viewer/lib/supervision_tree_viewer/app.ex
+++ /dev/null
@@ -1,190 +0,0 @@
-defmodule SupervisionTreeViewerExample.App do
- @moduledoc """
- Example application demonstrating the SupervisionTreeViewer widget.
-
- This example shows:
- - Live supervision tree visualization
- - Process status indicators (running/restarting/terminated)
- - Supervisor strategy display (1:1, 1:*, 1:→)
- - Process info panel
- - Restart/terminate controls
-
- ## Controls
-
- - Up/Down: Navigate tree
- - Left: Collapse or move to parent
- - Right: Expand or move to first child
- - Enter: Toggle expand/collapse
- - i: Show process info panel
- - r: Restart selected process (with confirmation)
- - k: Terminate selected process (with confirmation)
- - R: Refresh tree
- - /: Filter by name
- - Escape: Clear filter/close panel
- - q: Quit
- """
-
- use TermUI.Elm
-
- alias TermUI.Event
- alias TermUI.Widgets.SupervisionTreeViewer
- alias TermUI.Renderer.Style
-
- # ----------------------------------------------------------------------------
- # Component Callbacks
- # ----------------------------------------------------------------------------
-
- @doc """
- Initialize the component state.
- """
- @impl true
- def init(_args) do
- # Start with the example application's sample tree
- root = SupervisionTreeViewerExample.SampleTree
-
- props =
- SupervisionTreeViewer.new(
- root: root,
- update_interval: 2000,
- auto_expand: true
- )
-
- {:ok, viewer_state} = SupervisionTreeViewer.init(props)
-
- %{
- viewer_state: viewer_state,
- message: "SupervisionTreeViewer Example - Press 'i' for process info"
- }
- end
-
- @doc """
- Convert events to messages.
- """
- @impl true
- def event_to_msg(%Event.Key{char: "q"}, %{viewer_state: %{filter_input: nil}}) do
- {:msg, :quit}
- end
-
- def event_to_msg(%Event.Key{key: key}, _state)
- when key in [:up, :down, :left, :right, :page_up, :page_down, :home, :end] do
- {:msg, {:viewer_event, %Event.Key{key: key}}}
- end
-
- def event_to_msg(%Event.Key{key: :enter}, _state) do
- {:msg, {:viewer_event, %Event.Key{key: :enter}}}
- end
-
- def event_to_msg(%Event.Key{char: "i"}, _state) do
- {:msg, {:viewer_event, %Event.Key{char: "i"}}}
- end
-
- def event_to_msg(%Event.Key{char: "R"}, _state) do
- {:msg, :refresh_tree}
- end
-
- def event_to_msg(%Event.Key{char: "r"}, %{viewer_state: %{filter_input: nil}}) do
- {:msg, {:viewer_event, %Event.Key{char: "r"}}}
- end
-
- def event_to_msg(%Event.Key{char: "k"}, %{viewer_state: %{filter_input: nil}}) do
- {:msg, {:viewer_event, %Event.Key{char: "k"}}}
- end
-
- def event_to_msg(%Event.Key{char: "y"}, %{viewer_state: %{pending_action: action}})
- when action != nil do
- {:msg, {:viewer_event, %Event.Key{char: "y"}}}
- end
-
- def event_to_msg(%Event.Key{char: "n"}, %{viewer_state: %{pending_action: action}})
- when action != nil do
- {:msg, {:viewer_event, %Event.Key{char: "n"}}}
- end
-
- def event_to_msg(%Event.Key{char: "/"}, %{viewer_state: %{filter_input: nil}}) do
- {:msg, {:viewer_event, %Event.Key{char: "/"}}}
- end
-
- def event_to_msg(%Event.Key{char: char}, %{viewer_state: %{filter_input: input}})
- when input != nil and char != nil do
- {:msg, {:viewer_event, %Event.Key{char: char}}}
- end
-
- def event_to_msg(%Event.Key{key: :backspace}, %{viewer_state: %{filter_input: input}})
- when input != nil do
- {:msg, {:viewer_event, %Event.Key{key: :backspace}}}
- end
-
- def event_to_msg(%Event.Key{key: :escape}, _state) do
- {:msg, {:viewer_event, %Event.Key{key: :escape}}}
- end
-
- def event_to_msg(_event, _state) do
- :ignore
- end
-
- @doc """
- Update state based on messages.
- """
- @impl true
- def update(:quit, state) do
- {state, [:quit]}
- end
-
- def update(:refresh_tree, state) do
- {:ok, viewer_state} = SupervisionTreeViewer.refresh(state.viewer_state)
- {%{state | viewer_state: viewer_state, message: "Tree refreshed"}, []}
- end
-
- def update({:viewer_event, event}, state) do
- {:ok, viewer_state} = SupervisionTreeViewer.handle_event(event, state.viewer_state)
-
- # Update message based on viewer state changes
- message =
- cond do
- viewer_state.show_info != state.viewer_state.show_info ->
- if viewer_state.show_info, do: "Info panel opened", else: "Info panel closed"
-
- viewer_state.pending_action != state.viewer_state.pending_action and
- viewer_state.pending_action == nil ->
- "Action completed"
-
- true ->
- state.message
- end
-
- {%{state | viewer_state: viewer_state, message: message}, []}
- end
-
- def update(_msg, state) do
- {state, []}
- end
-
- @doc """
- Render the application view.
- """
- @impl true
- def view(model) do
- area = %{x: 0, y: 0, width: 100, height: 25}
- viewer_view = SupervisionTreeViewer.render(model.viewer_state, area)
-
- stack(:vertical, [
- text("SupervisionTreeViewer Widget Example", Style.new(fg: :cyan, attrs: [:bold])),
- text(model.message, Style.new(fg: :yellow)),
- text("", nil),
- viewer_view,
- text("", nil),
- text("[q] Quit", Style.new(fg: :white, attrs: [:dim]))
- ])
- end
-
- # ----------------------------------------------------------------------------
- # Run
- # ----------------------------------------------------------------------------
-
- @doc """
- Run the supervision tree viewer example application.
- """
- def run do
- TermUI.Runtime.run(root: __MODULE__)
- end
-end
diff --git a/examples/supervision_tree_viewer/lib/supervision_tree_viewer/application.ex b/examples/supervision_tree_viewer/lib/supervision_tree_viewer/application.ex
deleted file mode 100644
index 5fea20f6..00000000
--- a/examples/supervision_tree_viewer/lib/supervision_tree_viewer/application.ex
+++ /dev/null
@@ -1,16 +0,0 @@
-defmodule SupervisionTreeViewerExample.Application do
- @moduledoc false
-
- use Application
-
- @impl true
- def start(_type, _args) do
- children = [
- # Sample supervision tree for demonstration
- SupervisionTreeViewerExample.SampleTree
- ]
-
- opts = [strategy: :one_for_one, name: SupervisionTreeViewerExample.Supervisor]
- Supervisor.start_link(children, opts)
- end
-end
diff --git a/examples/supervision_tree_viewer/lib/supervision_tree_viewer/sample_tree.ex b/examples/supervision_tree_viewer/lib/supervision_tree_viewer/sample_tree.ex
deleted file mode 100644
index fee1cbc5..00000000
--- a/examples/supervision_tree_viewer/lib/supervision_tree_viewer/sample_tree.ex
+++ /dev/null
@@ -1,171 +0,0 @@
-defmodule SupervisionTreeViewerExample.SampleTree do
- @moduledoc """
- Creates a sample supervision tree for demonstration purposes.
-
- Tree structure:
- - SampleTree (supervisor, one_for_all)
- ├── DatabasePool (supervisor, one_for_one)
- │ ├── Connection1 (worker)
- │ ├── Connection2 (worker)
- │ └── Connection3 (worker)
- ├── WebServer (supervisor, rest_for_one)
- │ ├── Router (worker)
- │ ├── Handler1 (worker)
- │ └── Handler2 (worker)
- └── BackgroundJobs (supervisor, one_for_one)
- ├── JobRunner1 (worker)
- └── JobRunner2 (worker)
- """
-
- use Supervisor
-
- def start_link(_opts) do
- Supervisor.start_link(__MODULE__, [], name: __MODULE__)
- end
-
- @impl true
- def init(_opts) do
- children = [
- {SupervisionTreeViewerExample.DatabasePool, []},
- {SupervisionTreeViewerExample.WebServer, []},
- {SupervisionTreeViewerExample.BackgroundJobs, []}
- ]
-
- Supervisor.init(children, strategy: :one_for_all)
- end
-end
-
-defmodule SupervisionTreeViewerExample.DatabasePool do
- use Supervisor
-
- def start_link(_opts) do
- Supervisor.start_link(__MODULE__, [], name: __MODULE__)
- end
-
- @impl true
- def init(_opts) do
- children =
- for i <- 1..3 do
- %{
- id: :"connection_#{i}",
- start: {SupervisionTreeViewerExample.Worker, :start_link, [[name: :"Connection#{i}", type: :database]]}
- }
- end
-
- Supervisor.init(children, strategy: :one_for_one)
- end
-end
-
-defmodule SupervisionTreeViewerExample.WebServer do
- use Supervisor
-
- def start_link(_opts) do
- Supervisor.start_link(__MODULE__, [], name: __MODULE__)
- end
-
- @impl true
- def init(_opts) do
- children = [
- %{
- id: :router,
- start: {SupervisionTreeViewerExample.Worker, :start_link, [[name: :Router, type: :web]]}
- },
- %{
- id: :handler_1,
- start: {SupervisionTreeViewerExample.Worker, :start_link, [[name: :Handler1, type: :web]]}
- },
- %{
- id: :handler_2,
- start: {SupervisionTreeViewerExample.Worker, :start_link, [[name: :Handler2, type: :web]]}
- }
- ]
-
- Supervisor.init(children, strategy: :rest_for_one)
- end
-end
-
-defmodule SupervisionTreeViewerExample.BackgroundJobs do
- use Supervisor
-
- def start_link(_opts) do
- Supervisor.start_link(__MODULE__, [], name: __MODULE__)
- end
-
- @impl true
- def init(_opts) do
- children =
- for i <- 1..2 do
- %{
- id: :"job_runner_#{i}",
- start: {SupervisionTreeViewerExample.Worker, :start_link, [[name: :"JobRunner#{i}", type: :background]]}
- }
- end
-
- Supervisor.init(children, strategy: :one_for_one)
- end
-end
-
-defmodule SupervisionTreeViewerExample.Worker do
- @moduledoc """
- A sample worker that simulates different workloads.
- """
-
- use GenServer
-
- def start_link(opts) do
- name = Keyword.get(opts, :name)
- GenServer.start_link(__MODULE__, opts, name: name)
- end
-
- @impl true
- def init(opts) do
- type = Keyword.get(opts, :type, :generic)
-
- # Start work simulation
- schedule_work(type)
-
- {:ok,
- %{
- type: type,
- work_count: 0,
- started_at: DateTime.utc_now()
- }}
- end
-
- @impl true
- def handle_info(:work, state) do
- # Simulate work
- work_intensity =
- case state.type do
- :database -> 1..100
- :web -> 1..500
- :background -> 1..1000
- _ -> 1..50
- end
-
- # Do some computation to generate reductions
- _ = Enum.reduce(work_intensity, 0, &(&1 + &2))
-
- # Schedule next work
- schedule_work(state.type)
-
- {:noreply, %{state | work_count: state.work_count + 1}}
- end
-
- @impl true
- def handle_call(:get_stats, _from, state) do
- {:reply, state, state}
- end
-
- defp schedule_work(type) do
- interval =
- case type do
- :database -> 200
- :web -> 100
- :background -> 500
- _ -> 300
- end
-
- Process.send_after(self(), :work, interval)
- end
-end
diff --git a/examples/supervision_tree_viewer/mix.exs b/examples/supervision_tree_viewer/mix.exs
deleted file mode 100644
index bbb6af6f..00000000
--- a/examples/supervision_tree_viewer/mix.exs
+++ /dev/null
@@ -1,26 +0,0 @@
-defmodule SupervisionTreeViewerExample.MixProject do
- use Mix.Project
-
- def project do
- [
- app: :supervision_tree_viewer_example,
- version: "0.1.0",
- elixir: "~> 1.15",
- start_permanent: Mix.env() == :prod,
- deps: deps()
- ]
- end
-
- def application do
- [
- extra_applications: [:logger],
- mod: {SupervisionTreeViewerExample.Application, []}
- ]
- end
-
- defp deps do
- [
- {:term_ui, path: "../.."}
- ]
- end
-end
diff --git a/examples/supervision_tree_viewer/mix.lock b/examples/supervision_tree_viewer/mix.lock
deleted file mode 100644
index ee8761c0..00000000
--- a/examples/supervision_tree_viewer/mix.lock
+++ /dev/null
@@ -1,12 +0,0 @@
-%{
- "castore": {:hex, :castore, "1.0.17", "4f9770d2d45fbd91dcf6bd404cf64e7e58fed04fadda0923dc32acca0badffa2", [:mix], [], "hexpm", "12d24b9d80b910dd3953e165636d68f147a31db945d2dcb9365e441f8b5351e5"},
- "gen_stage": {:hex, :gen_stage, "1.3.2", "7c77e5d1e97de2c6c2f78f306f463bca64bf2f4c3cdd606affc0100b89743b7b", [:mix], [], "hexpm", "0ffae547fa777b3ed889a6b9e1e64566217413d018cabd825f786e843ffe63e7"},
- "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
- "lumis": {:hex, :lumis, "0.1.0", "6d3b495457d0608f8fe32fe6fa917eb17c921bd0087583a7f4c420c0009888fd", [:mix], [{:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:rustler, "~> 0.29", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.6", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "4de203bc5811ad21f0c6b0442612a3fc1ae9bea7fbfe7037452db9e9dfdaaab3"},
- "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"},
- "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"},
- "mdex": {:hex, :mdex, "0.11.3", "7b815ae2f474e62ad365dfa4d9df87ddec6a01cfa9b7db523a3b21061d909225", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:lumis, "~> 0.1", [hex: :lumis, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}, {:rustler, "~> 0.32", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.7", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "181bf04b13dcae20ab9f336115facc5096aae333a2408a03e53338ee65d4f564"},
- "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
- "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"},
- "rustler_precompiled": {:hex, :rustler_precompiled, "0.8.4", "700a878312acfac79fb6c572bb8b57f5aae05fe1cf70d34b5974850bbf2c05bf", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "3b33d99b540b15f142ba47944f7a163a25069f6d608783c321029bc1ffb09514"},
-}
diff --git a/examples/supervision_tree_viewer/run.exs b/examples/supervision_tree_viewer/run.exs
deleted file mode 100644
index d7817b1c..00000000
--- a/examples/supervision_tree_viewer/run.exs
+++ /dev/null
@@ -1 +0,0 @@
-SupervisionTreeViewerExample.App.run()
diff --git a/examples/table/README.md b/examples/table/README.md
deleted file mode 100644
index 6c2dd03d..00000000
--- a/examples/table/README.md
+++ /dev/null
@@ -1,230 +0,0 @@
-# Table Widget Example
-
-A demonstration of the TermUI Table widget for displaying tabular data with selection, sorting, and scrolling.
-
-## Widget Overview
-
-The Table widget provides efficient display of structured data in a tabular format with virtual scrolling, making it suitable for both small datasets and large collections (10,000+ rows). It supports flexible column layouts, custom cell rendering, row selection, and keyboard/mouse navigation.
-
-**Key Features:**
-- Virtual scrolling for large datasets
-- Flexible column layout (fixed, proportional, percentage widths)
-- Row selection (single or multi-select)
-- Custom cell rendering functions
-- Keyboard and mouse navigation
-- Alternating row styles
-- Header and row styling
-
-**When to Use:**
-- Displaying lists of records
-- Data browsers and explorers
-- Log viewers
-- Database query results
-- Any structured data display
-
-## Widget Options
-
-The `Table.new/1` function accepts these options:
-
-- `:columns` - List of Column specifications (required)
-- `:data` - List of row maps (required)
-- `:selection_mode` - `:none`, `:single`, or `:multi` (default: `:single`)
-- `:sortable` - Enable column sorting (default: true)
-- `:on_select` - Callback when selection changes: `fn selected_rows -> ... end`
-- `:on_sort` - Callback when sort changes: `fn {column, direction} -> ... end`
-- `:header_style` - Style for header row
-- `:row_style` - Style for data rows
-- `:selected_style` - Style for selected rows
-- `:alternating` - Alternating row backgrounds (default: false)
-
-**Column Specification** using `Column.new(key, header, opts)`:
-
-- `key` - Map key to extract value from row data
-- `header` - Header text to display
-- `:width` - Column width constraint:
- - `Constraint.length(n)` - Fixed width in characters
- - `Constraint.percentage(p)` - Percentage of total width
- - `Constraint.fill()` - Fill remaining space
- - `Constraint.ratio(r)` - Proportional width
-- `:align` - Text alignment: `:left`, `:right`, or `:center` (default: `:left`)
-- `:render` - Custom render function: `fn value -> String.t()`
-
-## Example Structure
-
-This example consists of:
-
-- `lib/table/app.ex` - Main application demonstrating:
- - Basic table with multiple columns
- - Mixed column widths (fixed and fill)
- - Custom cell rendering (status with icons)
- - Row selection and navigation
- - Scrolling through data
-- `mix.exs` - Mix project configuration
-- `run.exs` - Helper script to run the example
-
-## Running the Example
-
-### Raw Mode (Full TUI Experience)
-
-For the best experience with full terminal control and alternate screen:
-
-```bash
-cd examples/table
-mix termui.run
-```
-
-Or manually:
-
-```bash
-cd examples/table
-mix run -e "Table.App.run()" --no-halt
-```
-
-### TTY Mode (IEx Compatible)
-
-To run from IEx without taking over the shell:
-
-```bash
-cd examples/table
-iex -S mix
-```
-
-Then in IEx:
-
-```elixir
-Table.App.run()
-```
-
-**Note:** TTY mode works inside IEx but has limitations:
-- No alternate screen buffer (output mixes with IEx prompt)
-- Character input works immediately (no Enter needed)
-- For full TUI, use raw mode instead
-
-## Controls
-
-| Key | Action |
-|-----|--------|
-| ↑/↓ | Move selection up/down |
-| Page Up/Down | Scroll by 5 rows |
-| Home/End | Jump to first/last row |
-| Q | Quit |
-
-## Code Examples
-
-### Defining Columns
-
-```elixir
-alias TermUI.Widgets.Table.Column
-alias TermUI.Layout.Constraint
-
-columns = [
- # Fixed width column
- Column.new(:id, "ID", width: Constraint.length(4), align: :right),
-
- # Fill remaining space
- Column.new(:name, "Name", width: Constraint.fill()),
-
- # Custom render function
- Column.new(:status, "Status",
- width: Constraint.length(10),
- render: fn
- :active -> "● Active"
- :inactive -> "○ Inactive"
- _ -> "Unknown"
- end
- )
-]
-```
-
-### Column Options
-
-```elixir
-Column.new(key, header,
- width: Constraint.length(20), # Width constraint
- align: :left, # :left, :center, or :right
- render: &custom_formatter/1, # Custom render function
- sortable: true # Enable sorting
-)
-```
-
-### Width Constraints
-
-```elixir
-# Fixed width
-Constraint.length(20)
-
-# Proportional (ratio of available space)
-Constraint.ratio(2)
-
-# Percentage of total width
-Constraint.percentage(50)
-
-# Fill remaining space
-Constraint.fill()
-```
-
-### Data Format
-
-Data is a list of maps where keys match column keys:
-
-```elixir
-data = [
- %{id: 1, name: "Alice", email: "alice@example.com", status: :active},
- %{id: 2, name: "Bob", email: "bob@example.com", status: :inactive}
-]
-```
-
-### Rendering a Cell
-
-```elixir
-# Extract and format cell value from a row
-cell_text = Column.render_cell(column, row)
-
-# Align text within column width
-aligned = Column.align_text(cell_text, width, :left)
-```
-
-### Using the Full Table Widget
-
-For interactive tables with built-in selection and sorting:
-
-```elixir
-Table.new(
- columns: columns,
- data: data,
- selection_mode: :single, # :none, :single, or :multi
- sortable: true,
- header_style: Style.new(attrs: [:bold]),
- selected_style: Style.new(bg: :blue)
-)
-```
-
-## Column Layout
-
-The example demonstrates different column width strategies:
-
-1. **ID Column** - Fixed width (4 characters, right-aligned)
-2. **Name Column** - Fills remaining space
-3. **Email Column** - Fixed width (25 characters)
-4. **Role Column** - Fixed width (12 characters)
-5. **Status Column** - Fixed width (10 characters) with custom rendering
-
-## Custom Cell Rendering
-
-The Status column demonstrates custom rendering with icons:
-
-- **● Active** - Green indicator for active users
-- **○ Inactive** - White indicator for inactive users
-- **◐ Pending** - Half-filled indicator for pending users
-
-This shows how to transform data values into formatted display text with visual indicators.
-
-## Note on Implementation
-
-This example demonstrates a simplified approach where the Table widget is rendered as a static display with manual state management in the app. For production use with stateful components, the Table widget can be integrated as a StatefulComponent with automatic state handling for selection, sorting, and scrolling.
-
-## Widget API
-
-See the following files for full API documentation:
-- `lib/term_ui/widgets/table.ex` - Main Table widget
-- `lib/term_ui/widgets/table/column.ex` - Column specification
diff --git a/examples/table/lib/table/app.ex b/examples/table/lib/table/app.ex
deleted file mode 100644
index 9ed064fe..00000000
--- a/examples/table/lib/table/app.ex
+++ /dev/null
@@ -1,257 +0,0 @@
-defmodule Table.App do
- @moduledoc """
- Table Widget Example
-
- This example demonstrates how to use the TermUI.Widgets.Table widget
- for displaying tabular data with selection, sorting, and scrolling.
-
- Features demonstrated:
- - Column definitions with different widths
- - Row selection and navigation
- - Custom cell rendering
- - Header and row styling
-
- Note: The Table widget is a StatefulComponent, but in this example
- we demonstrate the simpler approach of rendering it as a static display
- with manual state management.
-
- Controls:
- - Up/Down: Move selection
- - Page Up/Down: Scroll by page
- - Home/End: Jump to first/last row
- - Q: Quit the application
- """
-
- use TermUI.Elm
-
- alias TermUI.Widgets.Table.Column
- alias TermUI.Layout.Constraint
- alias TermUI.Event
- alias TermUI.Renderer.Style
-
- # ----------------------------------------------------------------------------
- # Component Callbacks
- # ----------------------------------------------------------------------------
-
- @doc """
- Initialize the component state.
- """
- def init(_opts) do
- %{
- data: sample_data(),
- selected: 0,
- scroll_offset: 0,
- visible_rows: 10
- }
- end
-
- defp sample_data do
- [
- %{id: 1, name: "Alice Johnson", email: "alice@example.com", role: "Admin", status: :active},
- %{id: 2, name: "Bob Smith", email: "bob@example.com", role: "User", status: :active},
- %{id: 3, name: "Charlie Brown", email: "charlie@example.com", role: "User", status: :inactive},
- %{id: 4, name: "Diana Prince", email: "diana@example.com", role: "Moderator", status: :active},
- %{id: 5, name: "Eve Wilson", email: "eve@example.com", role: "User", status: :pending},
- %{id: 6, name: "Frank Miller", email: "frank@example.com", role: "User", status: :active},
- %{id: 7, name: "Grace Lee", email: "grace@example.com", role: "Admin", status: :active},
- %{id: 8, name: "Henry Davis", email: "henry@example.com", role: "User", status: :inactive},
- %{id: 9, name: "Ivy Chen", email: "ivy@example.com", role: "Moderator", status: :active},
- %{id: 10, name: "Jack Taylor", email: "jack@example.com", role: "User", status: :pending},
- %{id: 11, name: "Kate Morgan", email: "kate@example.com", role: "User", status: :active},
- %{id: 12, name: "Leo Anderson", email: "leo@example.com", role: "User", status: :active}
- ]
- end
-
- @doc """
- Convert keyboard events to messages.
- """
- def event_to_msg(%Event.Key{key: :up}, _state), do: {:msg, {:move, -1}}
- def event_to_msg(%Event.Key{key: :down}, _state), do: {:msg, {:move, 1}}
- def event_to_msg(%Event.Key{key: :page_up}, _state), do: {:msg, {:move, -5}}
- def event_to_msg(%Event.Key{key: :page_down}, _state), do: {:msg, {:move, 5}}
- def event_to_msg(%Event.Key{key: :home}, _state), do: {:msg, :home}
- def event_to_msg(%Event.Key{key: :end}, _state), do: {:msg, :end}
- def event_to_msg(%Event.Key{key: key}, _state) when key in ["q", "Q"], do: {:msg, :quit}
- def event_to_msg(_event, _state), do: :ignore
-
- @doc """
- Update state based on messages.
- """
- def update({:move, delta}, state) do
- max_index = length(state.data) - 1
- new_selected = max(0, min(max_index, state.selected + delta))
-
- # Adjust scroll offset to keep selection visible
- new_offset =
- cond do
- new_selected < state.scroll_offset ->
- new_selected
-
- new_selected >= state.scroll_offset + state.visible_rows ->
- new_selected - state.visible_rows + 1
-
- true ->
- state.scroll_offset
- end
-
- {%{state | selected: new_selected, scroll_offset: new_offset}, []}
- end
-
- def update(:home, state) do
- {%{state | selected: 0, scroll_offset: 0}, []}
- end
-
- def update(:end, state) do
- max_index = length(state.data) - 1
- new_offset = max(0, max_index - state.visible_rows + 1)
- {%{state | selected: max_index, scroll_offset: new_offset}, []}
- end
-
- def update(:quit, state) do
- {state, [:quit]}
- end
-
- @doc """
- Render the current state to a render tree.
- """
- def view(state) do
- # Define columns with different width constraints
- columns = [
- # Fixed width column for ID
- Column.new(:id, "ID", width: Constraint.length(4), align: :right),
-
- # Fill remaining space for name
- Column.new(:name, "Name", width: Constraint.fill()),
-
- # Fixed width for email
- Column.new(:email, "Email", width: Constraint.length(25)),
-
- # Fixed width for role
- Column.new(:role, "Role", width: Constraint.length(12)),
-
- # Custom render function for status
- Column.new(:status, "Status",
- width: Constraint.length(10),
- render: &format_status/1
- )
- ]
-
- stack(:vertical, [
- # Title
- text("Table Widget Example", Style.new(fg: :cyan, attrs: [:bold])),
- text("", nil),
-
- # Header row
- render_header(columns),
-
- # Separator
- text(String.duplicate("─", 80), nil),
-
- # Data rows
- render_rows(state, columns),
-
- # Blank line before controls
- text("", nil),
-
- # Controls
- render_controls(state)
- ])
- end
-
- defp render_controls(state) do
- box_width = 44
- inner_width = box_width - 2
-
- top_border = "┌─ Controls " <> String.duplicate("─", inner_width - 12) <> "─┐"
- bottom_border = "└" <> String.duplicate("─", inner_width) <> "┘"
-
- stack(:vertical, [
- text("", nil),
- text(top_border, Style.new(fg: :yellow)),
- text("│" <> String.pad_trailing(" ↑/↓ Move selection", inner_width) <> "│", nil),
- text("│" <> String.pad_trailing(" Page Up/Down Scroll by 5", inner_width) <> "│", nil),
- text("│" <> String.pad_trailing(" Home/End Jump to first/last", inner_width) <> "│", nil),
- text("│" <> String.pad_trailing(" Q Quit", inner_width) <> "│", nil),
- text("│" <> String.pad_trailing("", inner_width) <> "│", nil),
- text("│" <> String.pad_trailing(" Row #{state.selected + 1} of #{length(state.data)}", inner_width) <> "│", nil),
- text(bottom_border, Style.new(fg: :yellow))
- ])
- end
-
- # ----------------------------------------------------------------------------
- # Private Helpers
- # ----------------------------------------------------------------------------
-
- # Format status values with icons
- defp format_status(:active), do: "● Active"
- defp format_status(:inactive), do: "○ Inactive"
- defp format_status(:pending), do: "◐ Pending"
- defp format_status(other), do: to_string(other)
-
- # Render the header row
- defp render_header(columns) do
- header_text =
- columns
- |> Enum.map(fn col ->
- width = get_column_width(col)
- Column.align_text(col.header, width, col.align)
- end)
- |> Enum.join(" ")
-
- text(header_text, Style.new(fg: :white, attrs: [:bold]))
- end
-
- # Render visible data rows
- defp render_rows(state, columns) do
- visible_data =
- state.data
- |> Enum.with_index()
- |> Enum.slice(state.scroll_offset, state.visible_rows)
-
- rows =
- Enum.map(visible_data, fn {row, index} ->
- render_row(row, index, columns, state)
- end)
-
- stack(:vertical, rows)
- end
-
- # Render a single row
- defp render_row(row, index, columns, state) do
- row_text =
- columns
- |> Enum.map(fn col ->
- width = get_column_width(col)
- cell_text = Column.render_cell(col, row)
- Column.align_text(cell_text, width, col.align)
- end)
- |> Enum.join(" ")
-
- # Highlight selected row
- if index == state.selected do
- text(row_text, Style.new(fg: :black, bg: :cyan))
- else
- text(row_text, nil)
- end
- end
-
- # Get column width (simplified - in real usage would use Constraint.resolve)
- defp get_column_width(col) do
- case col.width do
- %Constraint.Length{value: v} -> v
- %Constraint.Fill{} -> 20
- _ -> 15
- end
- end
-
- # ----------------------------------------------------------------------------
- # Public API
- # ----------------------------------------------------------------------------
-
- @doc """
- Run the table example application.
- """
- def run do
- TermUI.Runtime.run(root: __MODULE__)
- end
-end
diff --git a/examples/table/lib/table/application.ex b/examples/table/lib/table/application.ex
deleted file mode 100644
index 2de7f5b7..00000000
--- a/examples/table/lib/table/application.ex
+++ /dev/null
@@ -1,12 +0,0 @@
-defmodule Table.Application do
- @moduledoc false
-
- use Application
-
- @impl true
- def start(_type, _args) do
- children = []
- opts = [strategy: :one_for_one, name: Table.Supervisor]
- Supervisor.start_link(children, opts)
- end
-end
diff --git a/examples/table/mix.exs b/examples/table/mix.exs
deleted file mode 100644
index c6abba44..00000000
--- a/examples/table/mix.exs
+++ /dev/null
@@ -1,26 +0,0 @@
-defmodule Table.MixProject do
- use Mix.Project
-
- def project do
- [
- app: :table,
- version: "0.1.0",
- elixir: "~> 1.15",
- start_permanent: Mix.env() == :prod,
- deps: deps()
- ]
- end
-
- def application do
- [
- extra_applications: [:logger],
- mod: {Table.Application, []}
- ]
- end
-
- defp deps do
- [
- {:term_ui, path: "../.."}
- ]
- end
-end
diff --git a/examples/table/mix.lock b/examples/table/mix.lock
deleted file mode 100644
index ee8761c0..00000000
--- a/examples/table/mix.lock
+++ /dev/null
@@ -1,12 +0,0 @@
-%{
- "castore": {:hex, :castore, "1.0.17", "4f9770d2d45fbd91dcf6bd404cf64e7e58fed04fadda0923dc32acca0badffa2", [:mix], [], "hexpm", "12d24b9d80b910dd3953e165636d68f147a31db945d2dcb9365e441f8b5351e5"},
- "gen_stage": {:hex, :gen_stage, "1.3.2", "7c77e5d1e97de2c6c2f78f306f463bca64bf2f4c3cdd606affc0100b89743b7b", [:mix], [], "hexpm", "0ffae547fa777b3ed889a6b9e1e64566217413d018cabd825f786e843ffe63e7"},
- "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
- "lumis": {:hex, :lumis, "0.1.0", "6d3b495457d0608f8fe32fe6fa917eb17c921bd0087583a7f4c420c0009888fd", [:mix], [{:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:rustler, "~> 0.29", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.6", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "4de203bc5811ad21f0c6b0442612a3fc1ae9bea7fbfe7037452db9e9dfdaaab3"},
- "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"},
- "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"},
- "mdex": {:hex, :mdex, "0.11.3", "7b815ae2f474e62ad365dfa4d9df87ddec6a01cfa9b7db523a3b21061d909225", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:lumis, "~> 0.1", [hex: :lumis, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}, {:rustler, "~> 0.32", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.7", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "181bf04b13dcae20ab9f336115facc5096aae333a2408a03e53338ee65d4f564"},
- "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
- "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"},
- "rustler_precompiled": {:hex, :rustler_precompiled, "0.8.4", "700a878312acfac79fb6c572bb8b57f5aae05fe1cf70d34b5974850bbf2c05bf", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "3b33d99b540b15f142ba47944f7a163a25069f6d608783c321029bc1ffb09514"},
-}
diff --git a/examples/table/run.exs b/examples/table/run.exs
deleted file mode 100644
index 3f0f75e0..00000000
--- a/examples/table/run.exs
+++ /dev/null
@@ -1 +0,0 @@
-Table.App.run()
diff --git a/examples/tabs/README.md b/examples/tabs/README.md
deleted file mode 100644
index 1566dd27..00000000
--- a/examples/tabs/README.md
+++ /dev/null
@@ -1,143 +0,0 @@
-# Tabs Widget Example
-
-This example demonstrates how to use the `TermUI.Widgets.Tabs` widget for organizing content into switchable panels.
-
-## Features Demonstrated
-
-- Tab bar with multiple tabs
-- Content switching on tab selection
-- Disabled tabs
-- Focus and selection states
-- Dynamic tab addition and removal
-- Keyboard navigation
-
-## Installation
-
-```bash
-cd examples/tabs
-mix deps.get
-```
-
-## Running the Example
-
-### Raw Mode (Full TUI Experience)
-
-For the best experience with full terminal control and alternate screen:
-
-```bash
-cd examples/tabs
-mix termui.run
-```
-
-Or manually:
-
-```bash
-cd examples/tabs
-mix run -e "Tabs.App.run()" --no-halt
-```
-
-### TTY Mode (IEx Compatible)
-
-To run from IEx without taking over the shell:
-
-```bash
-cd examples/tabs
-iex -S mix
-```
-
-Then in IEx:
-
-```elixir
-Tabs.App.run()
-```
-
-**Note:** TTY mode works inside IEx but has limitations:
-- No alternate screen buffer (output mixes with IEx prompt)
-- Character input works immediately (no Enter needed)
-- For full TUI, use raw mode instead
-
-## Controls
-
-| Key | Action |
-|-----|--------|
-| ←/→ | Navigate between tabs |
-| Enter/Space | Select focused tab |
-| Home/End | Jump to first/last tab |
-| A | Add a new tab |
-| D | Remove current tab |
-| Q | Quit |
-
-## Code Overview
-
-### Creating Tabs
-
-```elixir
-Tabs.new(
- tabs: [
- %{id: :home, label: "Home", content: home_content()},
- %{id: :settings, label: "Settings", content: settings_content()},
- %{id: :about, label: "About", disabled: true}
- ],
- selected: :home, # Initially selected tab
- on_change: fn tab_id ->
- IO.puts("Selected: #{tab_id}")
- end
-)
-```
-
-### Tab Options
-
-```elixir
-%{
- id: :home, # Unique identifier (required)
- label: "Home", # Display text (required)
- content: render_node, # Content when selected
- disabled: false, # Whether tab is disabled
- closeable: false # Whether tab shows close button
-}
-```
-
-### Styling Options
-
-```elixir
-Tabs.new(
- tabs: tabs,
- tab_style: Style.new(fg: :white),
- selected_style: Style.new(fg: :cyan, attrs: [:bold]),
- disabled_style: Style.new(fg: :bright_black)
-)
-```
-
-### Tab API
-
-```elixir
-# Get selected tab
-Tabs.get_selected(state)
-
-# Select a tab programmatically
-Tabs.select(state, :settings)
-
-# Add a new tab
-Tabs.add_tab(state, %{id: :new, label: "New Tab"})
-
-# Remove a tab
-Tabs.remove_tab(state, :old_tab)
-
-# Get tab count
-Tabs.tab_count(state)
-```
-
-## Visual States
-
-Tabs have three visual states:
-
-| State | Decoration | Description |
-|-------|------------|-------------|
-| Selected | `[Tab]` | Currently showing content |
-| Focused | `(Tab)` | Keyboard focus but not selected |
-| Normal | ` Tab ` | Neither selected nor focused |
-| Disabled | ` Tab ` (dimmed) | Cannot be selected |
-
-## Widget API
-
-See `lib/term_ui/widgets/tabs.ex` for the full API documentation.
diff --git a/examples/tabs/lib/tabs/app.ex b/examples/tabs/lib/tabs/app.ex
deleted file mode 100644
index c5b2a569..00000000
--- a/examples/tabs/lib/tabs/app.ex
+++ /dev/null
@@ -1,291 +0,0 @@
-defmodule Tabs.App do
- @moduledoc """
- Tabs Widget Example
-
- This example demonstrates how to use the TermUI.Widgets.Tabs widget
- for organizing content into switchable panels.
-
- Features demonstrated:
- - Tab bar with multiple tabs
- - Content switching on tab selection
- - Disabled tabs
- - Keyboard navigation
- - Dynamic tab management
-
- Controls:
- - Left/Right: Navigate between tabs
- - Enter/Space: Select focused tab
- - Home/End: Jump to first/last tab
- - A: Add a new tab
- - D: Remove current tab
- - Q: Quit the application
- """
-
- use TermUI.Elm
-
- alias TermUI.Event
- alias TermUI.Renderer.Style
-
- # ----------------------------------------------------------------------------
- # Component Callbacks
- # ----------------------------------------------------------------------------
-
- @doc """
- Initialize the component state.
- """
- def init(_opts) do
- %{
- tabs: initial_tabs(),
- selected: :home,
- focused: :home,
- tab_counter: 0
- }
- end
-
- defp initial_tabs do
- [
- %{id: :home, label: "Home", disabled: false},
- %{id: :profile, label: "Profile", disabled: false},
- %{id: :settings, label: "Settings", disabled: false},
- %{id: :disabled, label: "Disabled", disabled: true}
- ]
- end
-
- @doc """
- Convert keyboard events to messages.
- """
- def event_to_msg(%Event.Key{key: :left}, _state), do: {:msg, {:move_focus, -1}}
- def event_to_msg(%Event.Key{key: :right}, _state), do: {:msg, {:move_focus, 1}}
- def event_to_msg(%Event.Key{key: :home}, _state), do: {:msg, :focus_first}
- def event_to_msg(%Event.Key{key: :end}, _state), do: {:msg, :focus_last}
- def event_to_msg(%Event.Key{key: :enter}, _state), do: {:msg, :select_focused}
- def event_to_msg(%Event.Key{key: " "}, _state), do: {:msg, :select_focused}
- def event_to_msg(%Event.Key{key: key}, _state) when key in ["a", "A"], do: {:msg, :add_tab}
- def event_to_msg(%Event.Key{key: key}, _state) when key in ["d", "D"], do: {:msg, :remove_tab}
- def event_to_msg(%Event.Key{key: key}, _state) when key in ["q", "Q"], do: {:msg, :quit}
- def event_to_msg(_event, _state), do: :ignore
-
- @doc """
- Update state based on messages.
- """
- def update({:move_focus, delta}, state) do
- enabled_tabs = Enum.filter(state.tabs, fn t -> not t.disabled end)
- ids = Enum.map(enabled_tabs, & &1.id)
-
- case Enum.find_index(ids, &(&1 == state.focused)) do
- nil ->
- {state, []}
-
- current_idx ->
- new_idx = rem(current_idx + delta + length(ids), length(ids))
- new_focused = Enum.at(ids, new_idx)
- {%{state | focused: new_focused}, []}
- end
- end
-
- def update(:focus_first, state) do
- first =
- state.tabs
- |> Enum.find(fn t -> not t.disabled end)
- |> case do
- nil -> state.focused
- tab -> tab.id
- end
-
- {%{state | focused: first}, []}
- end
-
- def update(:focus_last, state) do
- last =
- state.tabs
- |> Enum.filter(fn t -> not t.disabled end)
- |> List.last()
- |> case do
- nil -> state.focused
- tab -> tab.id
- end
-
- {%{state | focused: last}, []}
- end
-
- def update(:select_focused, state) do
- tab = Enum.find(state.tabs, &(&1.id == state.focused))
-
- if tab && not tab.disabled do
- {%{state | selected: state.focused}, []}
- else
- {state, []}
- end
- end
-
- def update(:add_tab, state) do
- counter = state.tab_counter + 1
- new_tab = %{id: :"tab_#{counter}", label: "Tab #{counter}", disabled: false}
- tabs = state.tabs ++ [new_tab]
- {%{state | tabs: tabs, tab_counter: counter}, []}
- end
-
- def update(:remove_tab, state) do
- # Don't remove if only one enabled tab left
- enabled_count = Enum.count(state.tabs, fn t -> not t.disabled end)
-
- if enabled_count > 1 do
- tabs = Enum.reject(state.tabs, &(&1.id == state.selected))
-
- # Select a new tab if needed
- {selected, focused} =
- if Enum.any?(tabs, &(&1.id == state.selected)) do
- {state.selected, state.focused}
- else
- first_enabled = Enum.find(tabs, fn t -> not t.disabled end)
- id = if first_enabled, do: first_enabled.id, else: nil
- {id, id}
- end
-
- {%{state | tabs: tabs, selected: selected, focused: focused}, []}
- else
- {state, []}
- end
- end
-
- def update(:quit, state) do
- {state, [:quit]}
- end
-
- @doc """
- Render the current state to a render tree.
- """
- def view(state) do
- stack(:vertical, [
- # Title
- text("Tabs Widget Example", Style.new(fg: :cyan, attrs: [:bold])),
- text("", nil),
-
- # Tab bar
- render_tab_bar(state),
-
- # Content area border
- text("┌" <> String.duplicate("─", 50) <> "┐", nil),
-
- # Content for selected tab
- render_content(state),
-
- # Content area border
- text("└" <> String.duplicate("─", 50) <> "┘", nil),
- text("", nil),
-
- # Status
- text("", nil),
-
- # Controls
- render_controls(state)
- ])
- end
-
- defp render_controls(state) do
- box_width = 44
- inner_width = box_width - 2
-
- top_border = "┌─ Controls " <> String.duplicate("─", inner_width - 12) <> "─┐"
- bottom_border = "└" <> String.duplicate("─", inner_width) <> "┘"
-
- stack(:vertical, [
- text("", nil),
- text(top_border, Style.new(fg: :yellow)),
- text("│" <> String.pad_trailing(" ←/→ Navigate tabs", inner_width) <> "│", nil),
- text("│" <> String.pad_trailing(" Enter Select focused tab", inner_width) <> "│", nil),
- text("│" <> String.pad_trailing(" Home/End Jump to first/last", inner_width) <> "│", nil),
- text("│" <> String.pad_trailing(" A Add new tab", inner_width) <> "│", nil),
- text("│" <> String.pad_trailing(" D Remove current tab", inner_width) <> "│", nil),
- text("│" <> String.pad_trailing(" Q Quit", inner_width) <> "│", nil),
- text("│" <> String.pad_trailing("", inner_width) <> "│", nil),
- text("│" <> String.pad_trailing(" Selected: #{state.selected} | Focused: #{state.focused}", inner_width) <> "│", nil),
- text("│" <> String.pad_trailing(" Tab count: #{length(state.tabs)}", inner_width) <> "│", nil),
- text(bottom_border, Style.new(fg: :yellow))
- ])
- end
-
- # ----------------------------------------------------------------------------
- # Private Helpers
- # ----------------------------------------------------------------------------
-
- defp render_tab_bar(state) do
- tabs =
- Enum.map(state.tabs, fn tab ->
- render_tab(tab, state)
- end)
-
- stack(:horizontal, tabs)
- end
-
- defp render_tab(tab, state) do
- label = " #{tab.label} "
-
- {decorated, style} =
- cond do
- tab.disabled ->
- {" #{label} ", Style.new(fg: :bright_black)}
-
- tab.id == state.selected ->
- {"[#{label}]", Style.new(fg: :cyan, attrs: [:bold])}
-
- tab.id == state.focused ->
- {"(#{label})", Style.new(fg: :white)}
-
- true ->
- {" #{label} ", Style.new(fg: :white)}
- end
-
- text(decorated, style)
- end
-
- defp render_content(state) do
- content_text =
- case state.selected do
- :home ->
- [
- "│ Welcome to the Home tab! │",
- "│ │",
- "│ This example demonstrates the Tabs widget. │",
- "│ Use arrow keys to navigate between tabs. │"
- ]
-
- :profile ->
- [
- "│ Profile Tab │",
- "│ │",
- "│ Username: demo_user │",
- "│ Email: demo@example.com │"
- ]
-
- :settings ->
- [
- "│ Settings Tab │",
- "│ │",
- "│ Theme: Dark │",
- "│ Language: English │"
- ]
-
- other ->
- [
- "│ #{String.pad_trailing("Content for #{other}", 48)} │",
- "│ │",
- "│ This is a dynamically created tab. │",
- "│ │"
- ]
- end
-
- stack(:vertical, Enum.map(content_text, &text(&1, nil)))
- end
-
- # ----------------------------------------------------------------------------
- # Public API
- # ----------------------------------------------------------------------------
-
- @doc """
- Run the tabs example application.
- """
- def run do
- TermUI.Runtime.run(root: __MODULE__)
- end
-end
diff --git a/examples/tabs/lib/tabs/application.ex b/examples/tabs/lib/tabs/application.ex
deleted file mode 100644
index 69a74218..00000000
--- a/examples/tabs/lib/tabs/application.ex
+++ /dev/null
@@ -1,12 +0,0 @@
-defmodule Tabs.Application do
- @moduledoc false
-
- use Application
-
- @impl true
- def start(_type, _args) do
- children = []
- opts = [strategy: :one_for_one, name: Tabs.Supervisor]
- Supervisor.start_link(children, opts)
- end
-end
diff --git a/examples/tabs/mix.exs b/examples/tabs/mix.exs
deleted file mode 100644
index a1893045..00000000
--- a/examples/tabs/mix.exs
+++ /dev/null
@@ -1,26 +0,0 @@
-defmodule Tabs.MixProject do
- use Mix.Project
-
- def project do
- [
- app: :tabs,
- version: "0.1.0",
- elixir: "~> 1.15",
- start_permanent: Mix.env() == :prod,
- deps: deps()
- ]
- end
-
- def application do
- [
- extra_applications: [:logger],
- mod: {Tabs.Application, []}
- ]
- end
-
- defp deps do
- [
- {:term_ui, path: "../.."}
- ]
- end
-end
diff --git a/examples/tabs/mix.lock b/examples/tabs/mix.lock
deleted file mode 100644
index ee8761c0..00000000
--- a/examples/tabs/mix.lock
+++ /dev/null
@@ -1,12 +0,0 @@
-%{
- "castore": {:hex, :castore, "1.0.17", "4f9770d2d45fbd91dcf6bd404cf64e7e58fed04fadda0923dc32acca0badffa2", [:mix], [], "hexpm", "12d24b9d80b910dd3953e165636d68f147a31db945d2dcb9365e441f8b5351e5"},
- "gen_stage": {:hex, :gen_stage, "1.3.2", "7c77e5d1e97de2c6c2f78f306f463bca64bf2f4c3cdd606affc0100b89743b7b", [:mix], [], "hexpm", "0ffae547fa777b3ed889a6b9e1e64566217413d018cabd825f786e843ffe63e7"},
- "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
- "lumis": {:hex, :lumis, "0.1.0", "6d3b495457d0608f8fe32fe6fa917eb17c921bd0087583a7f4c420c0009888fd", [:mix], [{:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:rustler, "~> 0.29", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.6", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "4de203bc5811ad21f0c6b0442612a3fc1ae9bea7fbfe7037452db9e9dfdaaab3"},
- "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"},
- "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"},
- "mdex": {:hex, :mdex, "0.11.3", "7b815ae2f474e62ad365dfa4d9df87ddec6a01cfa9b7db523a3b21061d909225", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:lumis, "~> 0.1", [hex: :lumis, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}, {:rustler, "~> 0.32", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.7", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "181bf04b13dcae20ab9f336115facc5096aae333a2408a03e53338ee65d4f564"},
- "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
- "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"},
- "rustler_precompiled": {:hex, :rustler_precompiled, "0.8.4", "700a878312acfac79fb6c572bb8b57f5aae05fe1cf70d34b5974850bbf2c05bf", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "3b33d99b540b15f142ba47944f7a163a25069f6d608783c321029bc1ffb09514"},
-}
diff --git a/examples/tabs/run.exs b/examples/tabs/run.exs
deleted file mode 100644
index 492b4347..00000000
--- a/examples/tabs/run.exs
+++ /dev/null
@@ -1 +0,0 @@
-Tabs.App.run()
diff --git a/examples/text_input/README.md b/examples/text_input/README.md
deleted file mode 100644
index 76088ea2..00000000
--- a/examples/text_input/README.md
+++ /dev/null
@@ -1,208 +0,0 @@
-# TextInput Widget Example
-
-This example demonstrates how to use the `TermUI.Widgets.TextInput` widget for single-line and multi-line text input.
-
-## Features Demonstrated
-
-- Single-line text input with Enter to submit
-- Multi-line text input with auto-growing height
-- Chat-style input with Enter to submit (Ctrl+Enter for newlines)
-- Scrollable area after max_visible_lines
-- Placeholder text
-- Focus states with visual feedback
-- Cursor positioning and movement
-- Text editing operations
-
-## Installation
-
-```bash
-cd examples/text_input
-mix deps.get
-```
-
-## Running the Example
-
-### Raw Mode (Full TUI Experience)
-
-For the best experience with full terminal control and alternate screen:
-
-```bash
-cd examples/text_input
-mix termui.run
-```
-
-Or manually:
-
-```bash
-cd examples/text_input
-mix run -e "TextInput.run()" --no-halt
-```
-
-### TTY Mode (IEx Compatible)
-
-To run from IEx without taking over the shell:
-
-```bash
-cd examples/text_input
-iex -S mix
-```
-
-Then in IEx:
-
-```elixir
-TextInput.run()
-```
-
-**Note:** TTY mode works inside IEx but has limitations:
-- No alternate screen buffer (output mixes with IEx prompt)
-- Character input works immediately (no Enter needed)
-- For full TUI, use raw mode instead
-
-## Controls
-
-| Key | Action |
-|-----|--------|
-| Arrow keys | Move cursor |
-| Home/End | Move to start/end of line |
-| Ctrl+Home/End | Move to start/end of text (multiline) |
-| Backspace/Delete | Delete characters |
-| Ctrl+Enter | Insert newline (multiline mode) |
-| Enter | Submit (single-line) or newline (multiline) |
-| Tab | Switch between inputs |
-| Escape | Blur input (remove focus) |
-| Q | Quit (when input is empty) |
-
-## Code Overview
-
-### Creating a Single-Line Input
-
-```elixir
-alias TermUI.Widgets.TextInput
-
-props = TextInput.new(
- placeholder: "Enter your name...",
- width: 40,
- on_submit: fn value ->
- IO.puts("Submitted: #{value}")
- end
-)
-
-{:ok, state} = TextInput.init(props)
-```
-
-### Creating a Multi-Line Input
-
-```elixir
-props = TextInput.new(
- placeholder: "Enter your message...",
- width: 50,
- multiline: true,
- max_visible_lines: 5,
- on_change: fn value ->
- IO.puts("Current text: #{value}")
- end
-)
-
-{:ok, state} = TextInput.init(props)
-```
-
-### Chat-Style Input (Enter Submits)
-
-```elixir
-props = TextInput.new(
- placeholder: "Type a message and press Enter...",
- width: 50,
- multiline: true,
- max_visible_lines: 3,
- enter_submits: true, # Enter submits, Ctrl+Enter inserts newline
- on_submit: fn value ->
- send_message(value)
- end
-)
-
-{:ok, state} = TextInput.init(props)
-```
-
-### Widget Options
-
-```elixir
-TextInput.new(
- value: "", # Initial text value
- placeholder: "Enter text...", # Placeholder when empty
- width: 40, # Widget width in characters
- multiline: false, # Enable multi-line mode
- max_lines: nil, # Max lines allowed (nil = unlimited)
- max_visible_lines: 5, # Lines visible before scrolling
- enter_submits: false, # Enter submits instead of newline
- disabled: false, # Disable input
- style: nil, # Text style
- focused_style: nil, # Style when focused
- placeholder_style: nil, # Placeholder text style
- on_change: fn value -> ... end, # Value change callback
- on_submit: fn value -> ... end # Submit callback
-)
-```
-
-## TextInput API
-
-```elixir
-# Get current value
-value = TextInput.get_value(state)
-
-# Set value programmatically
-state = TextInput.set_value(state, "New text")
-
-# Clear the input
-state = TextInput.clear(state)
-
-# Set focus state
-state = TextInput.set_focused(state, true)
-
-# Get line count
-lines = TextInput.get_line_count(state)
-
-# Get cursor position
-{row, col} = TextInput.get_cursor(state)
-```
-
-## Features
-
-### Auto-Growing Height
-
-Multi-line inputs automatically grow their height as you type, up to `max_visible_lines`. After that, the content becomes scrollable with a scroll indicator showing position.
-
-### Scrolling
-
-When content exceeds `max_visible_lines`, a scroll indicator appears showing:
-- Current position (e.g., "↓ 6-10/25")
-- Scroll arrows (↑, ↓, or ↕)
-
-### Focus States
-
-Inputs have different visual states:
-- **Focused**: Shows cursor and active style
-- **Unfocused**: Shows content without cursor
-- **Empty & Unfocused**: Shows placeholder text (dimmed)
-
-### Text Editing
-
-Supports standard text editing operations:
-- Character insertion at cursor
-- Backspace/Delete character removal
-- Line joining on backspace at line start
-- Newline insertion (multiline mode)
-- Cursor movement with arrow keys
-
-## Example Modes
-
-The example demonstrates three different input configurations:
-
-1. **Single-line Input**: Traditional text field that submits on Enter
-2. **Multi-line Input**: Text area with Ctrl+Enter for newlines, Enter also adds newlines
-3. **Chat Input**: Chat-style with Enter to submit and Ctrl+Enter for newlines
-
-Use Tab to cycle between the three inputs and see how they behave differently.
-
-## Widget API
-
-See `lib/term_ui/widgets/text_input.ex` for the full API documentation.
diff --git a/examples/text_input/lib/text_input.ex b/examples/text_input/lib/text_input.ex
deleted file mode 100644
index 6c5f336c..00000000
--- a/examples/text_input/lib/text_input.ex
+++ /dev/null
@@ -1,7 +0,0 @@
-defmodule TextInput do
- @moduledoc """
- TextInput example entry point.
- """
-
- defdelegate run, to: TextInput.App
-end
diff --git a/examples/text_input/lib/text_input/app.ex b/examples/text_input/lib/text_input/app.ex
deleted file mode 100644
index 6b05b6f7..00000000
--- a/examples/text_input/lib/text_input/app.ex
+++ /dev/null
@@ -1,412 +0,0 @@
-defmodule TextInput.App do
- @moduledoc """
- TextInput Widget Example
-
- This example demonstrates how to use the TermUI.Widgets.TextInput widget
- for single-line and multi-line text input.
-
- Features demonstrated:
- - Single-line text input
- - Multi-line text input with Ctrl+Enter for newlines
- - Auto-growing height
- - Scrollable area after max_visible_lines
- - Placeholder text
- - Focus states
- - Reading current value with get_value/1
-
- Controls:
- - Arrow keys: Move cursor
- - Home/End: Move to start/end of line
- - Ctrl+Home/End: Move to start/end of text (multiline)
- - Backspace/Delete: Delete characters
- - Ctrl+Enter: Insert newline (multiline mode)
- - Enter: Submit (single-line) or newline (multiline)
- - Tab: Switch between inputs
- - Escape: Blur input
- - Q: Quit the application
- """
-
- use TermUI.Elm
-
- alias TermUI.Event
- alias TermUI.Renderer.Style
- alias TermUI.Widgets.TextInput, as: TI
-
- # ----------------------------------------------------------------------------
- # Component Callbacks
- # ----------------------------------------------------------------------------
-
- @doc """
- Initialize the component state.
- """
- def init(_opts) do
- # Single-line input
- single_props =
- TI.new(
- placeholder: "Enter your name...",
- width: 40
- )
-
- {:ok, single_state} = TI.init(single_props)
-
- # Multi-line input (with scrolling after 5 lines)
- multi_props =
- TI.new(
- placeholder: "Enter your message...",
- width: 50,
- multiline: true,
- max_visible_lines: 5
- )
-
- {:ok, multi_state} = TI.init(multi_props)
-
- # Multi-line with enter_submits (like a chat input)
- chat_props =
- TI.new(
- placeholder: "Type a message and press Enter...",
- width: 50,
- multiline: true,
- max_visible_lines: 3,
- enter_submits: true
- )
-
- {:ok, chat_state} = TI.init(chat_props)
-
- %{
- # Input states
- single_input: TI.set_focused(single_state, true),
- multi_input: multi_state,
- chat_input: chat_state,
-
- # Track which input is focused
- focused_input: :single,
-
- # Chat messages history
- chat_messages: [],
- last_action: "Ready"
- }
- end
-
- @doc """
- Convert keyboard events to messages.
- """
- def event_to_msg(%Event.Key{key: key}, %{focused_input: nil}) when key in ["q", "Q"] do
- {:msg, :quit}
- end
-
- def event_to_msg(%Event.Key{key: key}, %{focused_input: :single}) when key in ["q", "Q"] do
- # Only quit if input is empty
- {:msg, :check_quit_single}
- end
-
- def event_to_msg(%Event.Key{key: key}, _state) when key in ["q", "Q"] do
- # In multi/chat, Q is just a character
- {:msg, {:input_event, %Event.Key{key: key, char: key}}}
- end
-
- def event_to_msg(%Event.Key{key: :tab}, _state) do
- {:msg, :next_input}
- end
-
- def event_to_msg(%Event.Key{key: :enter}, %{focused_input: :single} = state) do
- # Submit single-line input
- {:msg, {:submit_single, TI.get_value(state.single_input)}}
- end
-
- def event_to_msg(%Event.Key{key: :enter}, %{focused_input: :chat} = state) do
- # Submit chat message (enter_submits is true)
- {:msg, {:submit_chat, TI.get_value(state.chat_input)}}
- end
-
- def event_to_msg(event, _state) do
- {:msg, {:input_event, event}}
- end
-
- @doc """
- Update state based on messages.
- """
- def update(:quit, state) do
- {state, [:quit]}
- end
-
- def update(:check_quit_single, state) do
- # Only quit if single input is empty, otherwise treat as character
- if TI.get_value(state.single_input) == "" do
- {state, [:quit]}
- else
- # Pass Q as a character to the input
- {:ok, new_input} = TI.handle_event(%Event.Key{key: "q", char: "q"}, state.single_input)
- {%{state | single_input: new_input, last_action: "Typing..."}, []}
- end
- end
-
- def update(:next_input, state) do
- # Cycle through inputs: single -> multi -> chat -> single
- {next_focused, state} =
- case state.focused_input do
- :single ->
- {:multi,
- %{
- state
- | single_input: TI.set_focused(state.single_input, false),
- multi_input: TI.set_focused(state.multi_input, true)
- }}
-
- :multi ->
- {:chat,
- %{
- state
- | multi_input: TI.set_focused(state.multi_input, false),
- chat_input: TI.set_focused(state.chat_input, true)
- }}
-
- :chat ->
- {:single,
- %{
- state
- | chat_input: TI.set_focused(state.chat_input, false),
- single_input: TI.set_focused(state.single_input, true)
- }}
- end
-
- {%{state | focused_input: next_focused, last_action: "Switched to #{next_focused} input"}, []}
- end
-
- def update({:submit_single, value}, state) do
- action =
- if value == "" do
- "Single input: (empty - nothing to submit)"
- else
- "Submitted: \"#{value}\""
- end
-
- {%{state | last_action: action}, []}
- end
-
- def update({:submit_chat, value}, state) do
- if String.trim(value) != "" do
- messages = state.chat_messages ++ [value]
- # Clear the chat input
- chat_input = TI.clear(state.chat_input)
-
- {%{
- state
- | chat_messages: Enum.take(messages, -5),
- chat_input: chat_input,
- last_action: "Message sent: #{String.slice(value, 0, 20)}..."
- }, []}
- else
- {%{state | last_action: "Chat: (empty - nothing to send)"}, []}
- end
- end
-
- def update({:input_event, event}, state) do
- # Route event to focused input
- case state.focused_input do
- :single ->
- {:ok, new_input} = TI.handle_event(event, state.single_input)
- {%{state | single_input: new_input, last_action: "Typing..."}, []}
-
- :multi ->
- {:ok, new_input} = TI.handle_event(event, state.multi_input)
- {%{state | multi_input: new_input, last_action: "Typing..."}, []}
-
- :chat ->
- {:ok, new_input} = TI.handle_event(event, state.chat_input)
- {%{state | chat_input: new_input, last_action: "Typing..."}, []}
- end
- end
-
- def update(_msg, state) do
- {state, []}
- end
-
- @doc """
- Render the current state to a render tree.
- """
- def view(state) do
- stack(:vertical, [
- # Title
- text("TextInput Widget Example", Style.new(fg: :cyan, attrs: [:bold])),
- text(""),
-
- # Instructions
- render_instructions(),
- text(""),
-
- # Single-line input section
- render_single_input(state),
- text(""),
-
- # Multi-line input section
- render_multi_input(state),
- text(""),
-
- # Chat-style input section
- render_chat_input(state),
- text(""),
-
- # Status
- render_status(state)
- ])
- end
-
- # ----------------------------------------------------------------------------
- # Private Helpers
- # ----------------------------------------------------------------------------
-
- # Border character sets (matching TermUI.Widget.Block)
- @border_rounded %{tl: "╭", tr: "╮", bl: "╰", br: "╯", h: "─", v: "│"}
- @border_single %{tl: "┌", tr: "┐", bl: "└", br: "┘", h: "─", v: "│"}
-
- defp render_instructions do
- b = @border_rounded
- inner_width = 53
-
- label = " Controls "
- left_pad = 2
- right_pad = inner_width - left_pad - String.length(label)
-
- top = b.tl <> String.duplicate(b.h, left_pad) <> label <> String.duplicate(b.h, right_pad) <> b.tr
- bot = b.bl <> String.duplicate(b.h, inner_width) <> b.br
-
- stack(:vertical, [
- text(top, Style.new(fg: :yellow)),
- text(b.v <> String.pad_trailing(" Arrow keys Move cursor", inner_width) <> b.v, nil),
- text(b.v <> String.pad_trailing(" Home/End Move to start/end of line", inner_width) <> b.v, nil),
- text(b.v <> String.pad_trailing(" Ctrl+Home/End Move to start/end of text", inner_width) <> b.v, nil),
- text(b.v <> String.pad_trailing(" Backspace/Del Delete characters", inner_width) <> b.v, nil),
- text(b.v <> String.pad_trailing(" Ctrl+Enter Insert newline (multiline)", inner_width) <> b.v, nil),
- text(b.v <> String.pad_trailing(" Enter Submit (single/chat) or newline", inner_width) <> b.v, nil),
- text(b.v <> String.pad_trailing(" Tab Switch between inputs", inner_width) <> b.v, nil),
- text(b.v <> String.pad_trailing(" Q Quit (when input is empty)", inner_width) <> b.v, nil),
- text(bot, Style.new(fg: :yellow))
- ])
- end
-
- defp render_single_input(state) do
- focused = state.focused_input == :single
- b = @border_single
- border_style = if focused, do: Style.new(fg: :green), else: Style.new(fg: :blue)
-
- current_value = TI.get_value(state.single_input)
- inner_width = 52
-
- label = " Single-line Input (Enter to submit) "
- left_pad = 2
- right_pad = inner_width - left_pad - String.length(label)
-
- top = b.tl <> String.duplicate(b.h, left_pad) <> label <> String.duplicate(b.h, right_pad) <> b.tr
- bot = b.bl <> String.duplicate(b.h, inner_width) <> b.br
-
- stack(:vertical, [
- text(top, border_style),
- stack(:horizontal, [
- text(b.v <> " ", border_style),
- TI.render(state.single_input, %{width: 50, height: 1}),
- text(" " <> b.v, border_style)
- ]),
- text(b.v <> String.pad_trailing(" Value: \"#{String.slice(current_value, 0, 40)}\"", inner_width) <> b.v, Style.new(fg: :bright_black)),
- text(bot, border_style)
- ])
- end
-
- defp render_multi_input(state) do
- focused = state.focused_input == :multi
- b = @border_single
- border_style = if focused, do: Style.new(fg: :green), else: Style.new(fg: :blue)
-
- line_count = TI.get_line_count(state.multi_input)
- {cursor_row, cursor_col} = TI.get_cursor(state.multi_input)
-
- inner_width = 62
-
- label = " Multi-line Input (Ctrl+Enter for newline) "
- left_pad = 2
- right_pad = inner_width - left_pad - String.length(label)
-
- top = b.tl <> String.duplicate(b.h, left_pad) <> label <> String.duplicate(b.h, right_pad) <> b.tr
- bot = b.bl <> String.duplicate(b.h, inner_width) <> b.br
-
- input_view = TI.render(state.multi_input, %{width: 60, height: 5})
-
- stack(:vertical, [
- text(top, border_style),
- stack(:horizontal, [
- text(b.v <> " ", border_style),
- input_view,
- text(" " <> b.v, border_style)
- ]),
- text(b.v <> String.pad_trailing(" Lines: #{line_count}, Cursor: row #{cursor_row + 1}, col #{cursor_col + 1}", inner_width) <> b.v, Style.new(fg: :bright_black)),
- text(bot, border_style)
- ])
- end
-
- defp render_chat_input(state) do
- focused = state.focused_input == :chat
- b = @border_single
- border_style = if focused, do: Style.new(fg: :green), else: Style.new(fg: :blue)
-
- inner_width = 62
-
- label = " Chat Input (Enter submits, Ctrl+Enter for newline) "
- left_pad = 2
- right_pad = inner_width - left_pad - String.length(label)
-
- top = b.tl <> String.duplicate(b.h, left_pad) <> label <> String.duplicate(b.h, right_pad) <> b.tr
- bot = b.bl <> String.duplicate(b.h, inner_width) <> b.br
-
- input_view = TI.render(state.chat_input, %{width: 60, height: 3})
-
- stack(:vertical, [
- text(top, border_style),
- render_chat_messages(state.chat_messages, inner_width, b, border_style),
- stack(:horizontal, [
- text(b.v <> " ", border_style),
- input_view,
- text(" " <> b.v, border_style)
- ]),
- text(bot, border_style)
- ])
- end
-
- defp render_chat_messages([], inner_width, b, border_style) do
- stack(:vertical, [
- text(b.v <> String.pad_trailing(" (no messages yet)", inner_width) <> b.v, border_style)
- ])
- end
-
- defp render_chat_messages(messages, inner_width, b, _border_style) do
- message_nodes =
- Enum.map(messages, fn msg ->
- # Truncate long messages
- display_msg =
- if String.length(msg) > 50,
- do: String.slice(msg, 0, 47) <> "...",
- else: msg
-
- content = " > #{display_msg}"
- text(b.v <> String.pad_trailing(content, inner_width) <> b.v, Style.new(fg: :cyan))
- end)
-
- stack(:vertical, message_nodes)
- end
-
- defp render_status(state) do
- stack(:horizontal, [
- text("Status: ", Style.new(fg: :yellow)),
- text(state.last_action, Style.new(fg: :white))
- ])
- end
-
- # ----------------------------------------------------------------------------
- # Public API
- # ----------------------------------------------------------------------------
-
- @doc """
- Run the text input example application.
- """
- def run do
- TermUI.Runtime.run(root: __MODULE__)
- end
-end
diff --git a/examples/text_input/mix.exs b/examples/text_input/mix.exs
deleted file mode 100644
index bf53fae6..00000000
--- a/examples/text_input/mix.exs
+++ /dev/null
@@ -1,25 +0,0 @@
-defmodule TextInput.MixProject do
- use Mix.Project
-
- def project do
- [
- app: :text_input,
- version: "0.1.0",
- elixir: "~> 1.15",
- start_permanent: Mix.env() == :prod,
- deps: deps()
- ]
- end
-
- def application do
- [
- extra_applications: [:logger]
- ]
- end
-
- defp deps do
- [
- {:term_ui, path: "../.."}
- ]
- end
-end
diff --git a/examples/text_input/mix.lock b/examples/text_input/mix.lock
deleted file mode 100644
index ee8761c0..00000000
--- a/examples/text_input/mix.lock
+++ /dev/null
@@ -1,12 +0,0 @@
-%{
- "castore": {:hex, :castore, "1.0.17", "4f9770d2d45fbd91dcf6bd404cf64e7e58fed04fadda0923dc32acca0badffa2", [:mix], [], "hexpm", "12d24b9d80b910dd3953e165636d68f147a31db945d2dcb9365e441f8b5351e5"},
- "gen_stage": {:hex, :gen_stage, "1.3.2", "7c77e5d1e97de2c6c2f78f306f463bca64bf2f4c3cdd606affc0100b89743b7b", [:mix], [], "hexpm", "0ffae547fa777b3ed889a6b9e1e64566217413d018cabd825f786e843ffe63e7"},
- "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
- "lumis": {:hex, :lumis, "0.1.0", "6d3b495457d0608f8fe32fe6fa917eb17c921bd0087583a7f4c420c0009888fd", [:mix], [{:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:rustler, "~> 0.29", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.6", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "4de203bc5811ad21f0c6b0442612a3fc1ae9bea7fbfe7037452db9e9dfdaaab3"},
- "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"},
- "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"},
- "mdex": {:hex, :mdex, "0.11.3", "7b815ae2f474e62ad365dfa4d9df87ddec6a01cfa9b7db523a3b21061d909225", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:lumis, "~> 0.1", [hex: :lumis, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}, {:rustler, "~> 0.32", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.7", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "181bf04b13dcae20ab9f336115facc5096aae333a2408a03e53338ee65d4f564"},
- "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
- "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"},
- "rustler_precompiled": {:hex, :rustler_precompiled, "0.8.4", "700a878312acfac79fb6c572bb8b57f5aae05fe1cf70d34b5974850bbf2c05bf", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "3b33d99b540b15f142ba47944f7a163a25069f6d608783c321029bc1ffb09514"},
-}
diff --git a/examples/text_input/run.exs b/examples/text_input/run.exs
deleted file mode 100644
index 1bd513e6..00000000
--- a/examples/text_input/run.exs
+++ /dev/null
@@ -1 +0,0 @@
-TextInput.App.run()
diff --git a/examples/toast/README.md b/examples/toast/README.md
deleted file mode 100644
index 7568a972..00000000
--- a/examples/toast/README.md
+++ /dev/null
@@ -1,144 +0,0 @@
-# Toast Widget Example
-
-This example demonstrates the `TermUI.Widgets.Toast` and `TermUI.Widgets.ToastManager` widgets for displaying auto-dismissing notifications.
-
-## Features Demonstrated
-
-- Info, Success, Warning, Error toast types
-- Different screen positions (6 positions)
-- Auto-dismiss after configurable duration (3 seconds)
-- Toast stacking when multiple appear
-- Click or Escape to dismiss manually
-- ToastManager for handling multiple toasts
-
-## Running the Example
-
-### Raw Mode (Full TUI Experience)
-
-For the best experience with full terminal control and alternate screen:
-
-```bash
-cd examples/toast
-mix termui.run
-```
-
-Or manually:
-
-```bash
-cd examples/toast
-mix run -e "Toast.App.run()" --no-halt
-```
-
-### TTY Mode (IEx Compatible)
-
-To run from IEx without taking over the shell:
-
-```bash
-cd examples/toast
-iex -S mix
-```
-
-Then in IEx:
-
-```elixir
-Toast.App.run()
-```
-
-**Note:** TTY mode works inside IEx but has limitations:
-- No alternate screen buffer (output mixes with IEx prompt)
-- Character input works immediately (no Enter needed)
-- For full TUI, use raw mode instead
-
-## Controls
-
-| Key | Action |
-|-----|--------|
-| 1 | Show Info Toast |
-| 2 | Show Success Toast |
-| 3 | Show Warning Toast |
-| 4 | Show Error Toast |
-| 5 | Show Multiple Toasts (stacking demo) |
-| P | Cycle through positions |
-| C | Clear all toasts |
-| Q | Quit |
-
-## Toast Types
-
-| Type | Icon | Color |
-|------|------|-------|
-| info | ℹ | cyan/blue |
-| success | ✓ | green |
-| warning | ⚠ | yellow |
-| error | ✗ | red |
-
-## Toast Positions
-
-| Position | Location |
-|----------|----------|
-| top_left | Upper left corner |
-| top_center | Upper center |
-| top_right | Upper right corner |
-| bottom_left | Lower left corner |
-| bottom_center | Lower center |
-| bottom_right | Lower right corner (default) |
-
-## Widget Usage
-
-### Single Toast
-
-```elixir
-alias TermUI.Widgets.Toast
-
-# Create a toast
-props = Toast.new(
- message: "File saved successfully",
- type: :success,
- duration: 3000,
- position: :bottom_right,
- on_dismiss: fn -> handle_dismiss() end
-)
-
-# Initialize state
-{:ok, state} = Toast.init(props)
-
-# Check if should auto-dismiss
-if Toast.should_dismiss?(state) do
- state = Toast.dismiss_toast(state)
-end
-```
-
-### Multiple Toasts with ToastManager
-
-```elixir
-alias TermUI.Widgets.ToastManager
-
-# Create manager
-manager = ToastManager.new(
- position: :bottom_right,
- max_toasts: 5,
- default_duration: 3000
-)
-
-# Add toasts
-manager = ToastManager.add_toast(manager, "First message", :info)
-manager = ToastManager.add_toast(manager, "Second message", :success)
-
-# Update on tick (removes expired toasts)
-manager = ToastManager.tick(manager)
-
-# Get visible toasts
-toasts = ToastManager.get_toasts(manager)
-
-# Clear all
-manager = ToastManager.clear_all(manager)
-```
-
-## Features
-
-- **Auto-dismiss**: Toasts automatically disappear after duration (default 3s)
-- **Manual dismiss**: Click on toast or press Escape to dismiss early
-- **Stacking**: Multiple toasts stack vertically at the chosen position
-- **Max limit**: ToastManager limits number of simultaneous toasts (default 5)
-- **Type icons**: Each type has a distinctive icon
-- **Z-Order**: Toasts render above other content (z: 150)
-- **Non-blocking**: Toasts don't capture focus or block interaction
diff --git a/examples/toast/lib/toast/app.ex b/examples/toast/lib/toast/app.ex
deleted file mode 100644
index 13ad9e98..00000000
--- a/examples/toast/lib/toast/app.ex
+++ /dev/null
@@ -1,225 +0,0 @@
-defmodule Toast.App do
- @moduledoc """
- Toast Widget Example
-
- This example demonstrates how to use the TermUI.Widgets.Toast and
- ToastManager widgets for displaying auto-dismissing notifications.
-
- Features demonstrated:
- - Info, Success, Warning, Error toast types
- - Different screen positions (6 positions)
- - Auto-dismiss after configurable duration
- - Toast stacking when multiple appear
- - Click or Escape to dismiss manually
- - ToastManager for handling multiple toasts
-
- Controls:
- - 1: Show Info Toast
- - 2: Show Success Toast
- - 3: Show Warning Toast
- - 4: Show Error Toast
- - 5: Show Multiple Toasts (stacking demo)
- - P: Cycle through positions
- - C: Clear all toasts
- - Q: Quit the application
- """
-
- use TermUI.Elm
-
- alias TermUI.Event
- alias TermUI.Renderer.Style
- alias TermUI.Widgets.ToastManager
-
- @positions [
- :bottom_right,
- :bottom_center,
- :bottom_left,
- :top_right,
- :top_center,
- :top_left
- ]
-
- @position_names %{
- bottom_right: "Bottom Right",
- bottom_center: "Bottom Center",
- bottom_left: "Bottom Left",
- top_right: "Top Right",
- top_center: "Top Center",
- top_left: "Top Left"
- }
-
- # ----------------------------------------------------------------------------
- # Component Callbacks
- # ----------------------------------------------------------------------------
-
- @doc """
- Initialize the component state.
- """
- def init(_opts) do
- %{
- toast_manager: ToastManager.new(position: :bottom_right, default_duration: 3000),
- current_position: :bottom_right,
- position_index: 0,
- toast_count: 0,
- last_action: nil
- }
- end
-
- @doc """
- Convert keyboard events to messages.
- """
- def event_to_msg(%Event.Key{key: "1"}, _state), do: {:msg, {:show_toast, :info}}
- def event_to_msg(%Event.Key{key: "2"}, _state), do: {:msg, {:show_toast, :success}}
- def event_to_msg(%Event.Key{key: "3"}, _state), do: {:msg, {:show_toast, :warning}}
- def event_to_msg(%Event.Key{key: "4"}, _state), do: {:msg, {:show_toast, :error}}
- def event_to_msg(%Event.Key{key: "5"}, _state), do: {:msg, :show_multiple}
- def event_to_msg(%Event.Key{key: key}, _state) when key in ["p", "P"], do: {:msg, :cycle_position}
- def event_to_msg(%Event.Key{key: key}, _state) when key in ["c", "C"], do: {:msg, :clear_toasts}
- def event_to_msg(%Event.Key{key: key}, _state) when key in ["q", "Q"], do: {:msg, :quit}
-
- # Tick event for auto-dismiss
- def event_to_msg(%Event.Tick{}, _state), do: {:msg, :tick}
-
- def event_to_msg(_event, _state), do: :ignore
-
- @doc """
- Update state based on messages.
- """
- def update({:show_toast, type}, state) do
- message = get_message_for_type(type)
- manager = ToastManager.add_toast(state.toast_manager, message, type)
-
- {%{state |
- toast_manager: manager,
- toast_count: state.toast_count + 1,
- last_action: "Showed #{type} toast"
- }, []}
- end
-
- def update(:show_multiple, state) do
- # Add multiple toasts to demonstrate stacking
- manager = state.toast_manager
- manager = ToastManager.add_toast(manager, "First notification", :info)
- manager = ToastManager.add_toast(manager, "Second notification", :success)
- manager = ToastManager.add_toast(manager, "Third notification", :warning)
-
- {%{state |
- toast_manager: manager,
- toast_count: state.toast_count + 3,
- last_action: "Showed 3 stacked toasts"
- }, []}
- end
-
- def update(:cycle_position, state) do
- new_index = rem(state.position_index + 1, length(@positions))
- new_position = Enum.at(@positions, new_index)
-
- # Update manager position
- manager = %{state.toast_manager | position: new_position}
-
- {%{state |
- toast_manager: manager,
- current_position: new_position,
- position_index: new_index,
- last_action: "Changed position to #{@position_names[new_position]}"
- }, []}
- end
-
- def update(:clear_toasts, state) do
- manager = ToastManager.clear_all(state.toast_manager)
-
- {%{state |
- toast_manager: manager,
- last_action: "Cleared all toasts"
- }, []}
- end
-
- def update(:tick, state) do
- # Update toast manager to remove expired toasts
- manager = ToastManager.tick(state.toast_manager)
- {%{state | toast_manager: manager}, []}
- end
-
- def update(:quit, state) do
- {state, [:quit]}
- end
-
- @doc """
- Render the current state to a render tree.
- """
- def view(state) do
- stack(:vertical, [
- render_main_content(state),
- ToastManager.render(state.toast_manager, %{width: 80, height: 24, x: 0, y: 0})
- ])
- end
-
- # ----------------------------------------------------------------------------
- # Private Helpers
- # ----------------------------------------------------------------------------
-
- defp get_message_for_type(:info), do: "This is an informational message"
- defp get_message_for_type(:success), do: "Operation completed successfully!"
- defp get_message_for_type(:warning), do: "Warning: Please review this action"
- defp get_message_for_type(:error), do: "Error: Something went wrong"
-
- defp render_main_content(state) do
- stack(:vertical, [
- # Title
- text("Toast Widget Example", Style.new(fg: :cyan, attrs: [:bold])),
- text("", nil),
-
- # Instructions
- text("Press a number key to show different toast types:", nil),
- text("", nil),
- text(" 1 - Info Toast (ℹ blue)", nil),
- text(" 2 - Success Toast (✓ green)", nil),
- text(" 3 - Warning Toast (⚠ yellow)", nil),
- text(" 4 - Error Toast (✗ red)", nil),
- text(" 5 - Multiple Toasts (stacking demo)", nil),
- text("", nil),
-
- # Controls
- render_controls(state)
- ])
- end
-
- defp render_controls(state) do
- box_width = 55
- inner_width = box_width - 2
-
- top_border = "┌─ Controls " <> String.duplicate("─", inner_width - 12) <> "─┐"
- bottom_border = "└" <> String.duplicate("─", inner_width) <> "┘"
-
- position_name = @position_names[state.current_position]
- active_toasts = ToastManager.toast_count(state.toast_manager)
-
- stack(:vertical, [
- text("", nil),
- text(top_border, Style.new(fg: :yellow)),
- text("│" <> String.pad_trailing(" 1-5 Show toast(s)", inner_width) <> "│", nil),
- text("│" <> String.pad_trailing(" P Cycle position", inner_width) <> "│", nil),
- text("│" <> String.pad_trailing(" C Clear all toasts", inner_width) <> "│", nil),
- text("│" <> String.pad_trailing(" Q Quit", inner_width) <> "│", nil),
- text("│" <> String.pad_trailing("", inner_width) <> "│", nil),
- text("│" <> String.pad_trailing(" Position: #{position_name}", inner_width) <> "│", nil),
- text("│" <> String.pad_trailing(" Active toasts: #{active_toasts}", inner_width) <> "│", nil),
- text("│" <> String.pad_trailing(" Total shown: #{state.toast_count}", inner_width) <> "│", nil),
- text("│" <> String.pad_trailing(" Last action: #{state.last_action || "(none)"}", inner_width) <> "│", nil),
- text(bottom_border, Style.new(fg: :yellow)),
- text("", nil),
- text("Toasts auto-dismiss after 3 seconds. Click or Escape to dismiss early.", Style.new(fg: :white, attrs: [:dim]))
- ])
- end
-
- # ----------------------------------------------------------------------------
- # Public API
- # ----------------------------------------------------------------------------
-
- @doc """
- Run the toast example application.
- """
- def run do
- TermUI.Runtime.run(root: __MODULE__)
- end
-end
diff --git a/examples/toast/lib/toast/application.ex b/examples/toast/lib/toast/application.ex
deleted file mode 100644
index 5c525826..00000000
--- a/examples/toast/lib/toast/application.ex
+++ /dev/null
@@ -1,12 +0,0 @@
-defmodule Toast.Application do
- @moduledoc false
-
- use Application
-
- @impl true
- def start(_type, _args) do
- children = []
- opts = [strategy: :one_for_one, name: Toast.Supervisor]
- Supervisor.start_link(children, opts)
- end
-end
diff --git a/examples/toast/mix.exs b/examples/toast/mix.exs
deleted file mode 100644
index 024539bc..00000000
--- a/examples/toast/mix.exs
+++ /dev/null
@@ -1,26 +0,0 @@
-defmodule Toast.MixProject do
- use Mix.Project
-
- def project do
- [
- app: :toast,
- version: "0.1.0",
- elixir: "~> 1.15",
- start_permanent: Mix.env() == :prod,
- deps: deps()
- ]
- end
-
- def application do
- [
- extra_applications: [:logger],
- mod: {Toast.Application, []}
- ]
- end
-
- defp deps do
- [
- {:term_ui, path: "../.."}
- ]
- end
-end
diff --git a/examples/toast/mix.lock b/examples/toast/mix.lock
deleted file mode 100644
index ee8761c0..00000000
--- a/examples/toast/mix.lock
+++ /dev/null
@@ -1,12 +0,0 @@
-%{
- "castore": {:hex, :castore, "1.0.17", "4f9770d2d45fbd91dcf6bd404cf64e7e58fed04fadda0923dc32acca0badffa2", [:mix], [], "hexpm", "12d24b9d80b910dd3953e165636d68f147a31db945d2dcb9365e441f8b5351e5"},
- "gen_stage": {:hex, :gen_stage, "1.3.2", "7c77e5d1e97de2c6c2f78f306f463bca64bf2f4c3cdd606affc0100b89743b7b", [:mix], [], "hexpm", "0ffae547fa777b3ed889a6b9e1e64566217413d018cabd825f786e843ffe63e7"},
- "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
- "lumis": {:hex, :lumis, "0.1.0", "6d3b495457d0608f8fe32fe6fa917eb17c921bd0087583a7f4c420c0009888fd", [:mix], [{:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:rustler, "~> 0.29", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.6", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "4de203bc5811ad21f0c6b0442612a3fc1ae9bea7fbfe7037452db9e9dfdaaab3"},
- "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"},
- "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"},
- "mdex": {:hex, :mdex, "0.11.3", "7b815ae2f474e62ad365dfa4d9df87ddec6a01cfa9b7db523a3b21061d909225", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:lumis, "~> 0.1", [hex: :lumis, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}, {:rustler, "~> 0.32", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.7", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "181bf04b13dcae20ab9f336115facc5096aae333a2408a03e53338ee65d4f564"},
- "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
- "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"},
- "rustler_precompiled": {:hex, :rustler_precompiled, "0.8.4", "700a878312acfac79fb6c572bb8b57f5aae05fe1cf70d34b5974850bbf2c05bf", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "3b33d99b540b15f142ba47944f7a163a25069f6d608783c321029bc1ffb09514"},
-}
diff --git a/examples/toast/run.exs b/examples/toast/run.exs
deleted file mode 100644
index 4a34038b..00000000
--- a/examples/toast/run.exs
+++ /dev/null
@@ -1 +0,0 @@
-Toast.App.run()
diff --git a/examples/tree_view/README.md b/examples/tree_view/README.md
deleted file mode 100644
index 11a5f9eb..00000000
--- a/examples/tree_view/README.md
+++ /dev/null
@@ -1,275 +0,0 @@
-# TreeView Widget Example
-
-This example demonstrates how to use the `TermUI.Widgets.TreeView` widget for displaying hierarchical data with expand/collapse functionality.
-
-## Features Demonstrated
-
-- Hierarchical tree structure with indentation
-- Expand/collapse nodes with keyboard
-- Single and multi-selection modes
-- Custom node icons
-- Search/filter with path highlighting
-- Lazy loading simulation
-- Keyboard navigation (arrows, Home/End, Page Up/Down)
-
-## Installation
-
-```bash
-cd examples/tree_view
-mix deps.get
-```
-
-## Running the Example
-
-### Raw Mode (Full TUI Experience)
-
-For the best experience with full terminal control and alternate screen:
-
-```bash
-cd examples/tree_view
-mix termui.run
-```
-
-Or manually:
-
-```bash
-cd examples/tree_view
-mix run -e "TreeView.App.run()" --no-halt
-```
-
-### TTY Mode (IEx Compatible)
-
-To run from IEx without taking over the shell:
-
-```bash
-cd examples/tree_view
-iex -S mix
-```
-
-Then in IEx:
-
-```elixir
-TreeView.App.run()
-```
-
-**Note:** TTY mode works inside IEx but has limitations:
-- No alternate screen buffer (output mixes with IEx prompt)
-- Character input works immediately (no Enter needed)
-- For full TUI, use raw mode instead
-
-## Controls
-
-| Key | Action |
-|-----|--------|
-| ↑/↓ | Navigate between visible nodes |
-| ← | Collapse node or move to parent |
-| → | Expand node or move to first child |
-| Enter/Space | Toggle expand or select |
-| Home/End | Jump to first/last node |
-| Page Up/Down | Jump by 10 nodes |
-| / | Start search filter |
-| Escape | Clear filter or selection |
-| Backspace | Delete character from filter |
-| M | Toggle multi-select mode |
-| E | Expand all nodes |
-| C | Collapse all nodes |
-| L | Load lazy node children (for nodes with 📦) |
-| Q | Quit |
-
-## Code Overview
-
-### Creating Tree Nodes
-
-```elixir
-alias TermUI.Widgets.TreeView
-
-# Leaf node (no children)
-TreeView.leaf(:id, "Label", icon: "📄")
-
-# Branch node with children
-TreeView.branch(:parent, "Parent", [
- TreeView.leaf(:child1, "Child 1"),
- TreeView.leaf(:child2, "Child 2")
-], icon: "📁")
-
-# Lazy-loading node (children loaded on demand)
-TreeView.lazy(:deps, "Dependencies", icon: "📦")
-```
-
-### Creating a TreeView
-
-```elixir
-props = TreeView.new(
- nodes: [
- TreeView.branch(:root, "Root", [
- TreeView.branch(:folder1, "Folder 1", [
- TreeView.leaf(:file1, "file1.txt", icon: "📄"),
- TreeView.leaf(:file2, "file2.txt", icon: "📄")
- ], icon: "📁"),
- TreeView.lazy(:deps, "Dependencies", icon: "📦")
- ], icon: "📁")
- ],
- selection_mode: :single, # :single, :multi, or :none
- initially_expanded: [:root], # Node IDs to expand initially
- on_select: fn node -> IO.puts("Selected: #{node.label}") end,
- on_expand: fn node -> load_children(node) end
-)
-
-{:ok, state} = TreeView.init(props)
-```
-
-### Widget Options
-
-```elixir
-TreeView.new(
- nodes: [], # List of root nodes (required)
- selection_mode: :single, # :single, :multi, :none
- show_root: true, # Show root nodes
- indent_size: 2, # Characters per indent level
- icons: %{ # Icon configuration
- expanded: "▼",
- collapsed: "▶",
- leaf: " ",
- loading: "⟳"
- },
- initially_expanded: [], # Node IDs to expand initially
- initially_selected: [], # Node IDs to select initially
- on_select: fn node -> ... end, # Selection callback
- on_expand: fn node -> ... end, # Expand callback
- on_collapse: fn node -> ... end # Collapse callback
-)
-```
-
-### Node Structure
-
-Each node is a map with:
-
-```elixir
-%{
- id: :unique_id, # Unique identifier (required)
- label: "Display Name", # Display text (required)
- icon: "📄", # Optional icon string
- children: [child_nodes], # List of children, :lazy, or nil for leaf
- disabled: false, # Whether node is disabled
- metadata: %{} # User-defined data
-}
-```
-
-## TreeView API
-
-```elixir
-# Get selected node IDs
-selected = TreeView.get_selected(state) # Returns MapSet
-
-# Get focused node
-node = TreeView.get_focused(state)
-
-# Get expanded node IDs
-expanded = TreeView.get_expanded(state)
-
-# Expand/collapse nodes
-state = TreeView.expand(state, node_id)
-state = TreeView.collapse(state, node_id)
-state = TreeView.expand_all(state)
-state = TreeView.collapse_all(state)
-
-# Selection operations
-state = TreeView.set_selected(state, [node_id1, node_id2])
-state = TreeView.clear_selection(state)
-
-# Filter operations
-state = TreeView.set_filter(state, "search term")
-state = TreeView.clear_filter(state)
-
-# Lazy loading
-state = TreeView.set_children(state, node_id, [child_nodes])
-state = TreeView.finish_loading(state, node_id)
-```
-
-## Features
-
-### Selection Modes
-
-- **Single**: Select one node at a time (default)
-- **Multi**: Select multiple nodes with Space, extend selection with Shift+arrows
-- **None**: No selection allowed
-
-### Search/Filter
-
-Press `/` to enter filter mode. Type to search node labels:
-- Matching nodes are highlighted in yellow
-- Non-matching nodes are hidden
-- Parent paths to matches are automatically expanded
-- Filter text and match count shown at top
-- Press Escape to clear filter
-
-### Lazy Loading
-
-Nodes with `children: :lazy` show a loading icon (⟳) and can load children on demand:
-
-```elixir
-# Mark node as lazy
-TreeView.lazy(:deps, "Dependencies", icon: "📦")
-
-# In your on_expand callback:
-on_expand: fn node ->
- if node.children == :lazy do
- # Load children asynchronously
- children = load_children_from_api(node.id)
- send(self(), {:set_children, node.id, children})
- end
-end
-
-# When children are loaded:
-state = TreeView.set_children(state, node_id, children)
-```
-
-### Visual Indicators
-
-| Indicator | Meaning |
-|-----------|---------|
-| ► | Collapsed branch |
-| ▼ | Expanded branch |
-| ● | Cursor + selected |
-| ► | Cursor (not selected) |
-| ○ | Selected (not at cursor) |
-| (space) | Normal node |
-| (yellow) | Filter match |
-| (dimmed) | Disabled node |
-
-### Keyboard Navigation
-
-The TreeView supports efficient keyboard navigation:
-
-- **Arrow keys**: Navigate through visible nodes
-- **Left/Right**: Smart navigation (collapse/expand or move to parent/child)
-- **Home/End**: Jump to boundaries
-- **Page Up/Down**: Fast scrolling
-- **Enter/Space**: Context-aware action (expand/collapse or select)
-
-### Multi-Selection
-
-In multi-select mode:
-- **Space**: Toggle individual node selection
-- **Shift+Up/Down**: Extend selection range
-- **Ctrl+A**: Select all nodes
-- **Escape**: Clear selection
-
-## Example Structure
-
-The example creates a simulated file browser with:
-
-- **my_project** (root folder)
- - **src** (source code)
- - **lib** (library code with .ex files)
- - **test** (test files with .exs files)
- - **docs** (documentation with .md files)
- - **deps** (lazy-loaded dependencies)
- - **config** (configuration files)
- - Various project files (.gitignore, mix.exs, etc.)
-
-Each file type has a custom icon (📄, 🧪, 📝, ⚙️, 📦, etc.) to demonstrate icon support.
-
-## Widget API
-
-See `lib/term_ui/widgets/tree_view.ex` for the full API documentation.
diff --git a/examples/tree_view/lib/tree_view/app.ex b/examples/tree_view/lib/tree_view/app.ex
deleted file mode 100644
index 083e8125..00000000
--- a/examples/tree_view/lib/tree_view/app.ex
+++ /dev/null
@@ -1,274 +0,0 @@
-defmodule TreeView.App do
- @moduledoc """
- TreeView Widget Example
-
- This example demonstrates how to use the TermUI.Widgets.TreeView widget
- for displaying hierarchical data with expand/collapse functionality.
-
- Features demonstrated:
- - Hierarchical tree structure with indentation
- - Expand/collapse with keyboard
- - Single and multi-selection modes
- - Custom node icons
- - Search/filter with path highlighting
- - Lazy loading simulation
-
- Controls:
- - Up/Down: Navigate between nodes
- - Left: Collapse node or move to parent
- - Right: Expand node or move to first child
- - Enter/Space: Toggle expand or select
- - Home/End: Jump to first/last node
- - /: Start search filter
- - Escape: Clear filter or selection
- - M: Toggle multi-select mode
- - E: Expand all
- - C: Collapse all
- - L: Load lazy node children
- - Q: Quit the application
- """
-
- use TermUI.Elm
-
- alias TermUI.Event
- alias TermUI.Renderer.Style
- alias TermUI.Widgets.TreeView, as: TV
-
- # ----------------------------------------------------------------------------
- # Component Callbacks
- # ----------------------------------------------------------------------------
-
- @doc """
- Initialize the component state.
- """
- def init(_opts) do
- %{
- tree_state: nil,
- selection_mode: :single,
- status_message: "Navigate with arrows, Enter to expand/select"
- }
- end
-
- defp build_tree_state(selection_mode) do
- nodes = build_file_tree()
-
- props = TV.new(
- nodes: nodes,
- selection_mode: selection_mode,
- initially_expanded: [:root, :src],
- icons: %{
- expanded: "▼",
- collapsed: "▶",
- leaf: " ",
- loading: "⟳"
- }
- )
-
- {:ok, tree_state} = TV.init(props)
- tree_state
- end
-
- defp build_file_tree do
- [
- TV.branch(:root, "my_project", [
- TV.branch(:src, "src", [
- TV.branch(:lib, "lib", [
- TV.leaf(:main, "main.ex", icon: "📄"),
- TV.leaf(:utils, "utils.ex", icon: "📄"),
- TV.leaf(:config, "config.ex", icon: "📄")
- ]),
- TV.branch(:test, "test", [
- TV.leaf(:main_test, "main_test.exs", icon: "🧪"),
- TV.leaf(:utils_test, "utils_test.exs", icon: "🧪")
- ])
- ]),
- TV.branch(:docs, "docs", [
- TV.leaf(:readme, "README.md", icon: "📝"),
- TV.leaf(:changelog, "CHANGELOG.md", icon: "📝"),
- TV.leaf(:license, "LICENSE", icon: "📋")
- ]),
- TV.lazy(:deps, "deps (lazy)", icon: "📦"),
- TV.branch(:config_dir, "config", [
- TV.leaf(:config_exs, "config.exs", icon: "⚙️"),
- TV.leaf(:dev_exs, "dev.exs", icon: "⚙️"),
- TV.leaf(:prod_exs, "prod.exs", icon: "⚙️")
- ]),
- TV.leaf(:mix_exs, "mix.exs", icon: "📄"),
- TV.leaf(:mix_lock, "mix.lock", icon: "🔒"),
- TV.leaf(:gitignore, ".gitignore", icon: "🚫")
- ], icon: "📁")
- ]
- end
-
- @doc """
- Convert keyboard events to messages.
- """
- def event_to_msg(%Event.Key{key: key}, _state) when key in ["q", "Q"], do: {:msg, :quit}
- def event_to_msg(%Event.Key{key: key}, _state) when key in ["m", "M"], do: {:msg, :toggle_mode}
- def event_to_msg(%Event.Key{key: key}, _state) when key in ["e", "E"], do: {:msg, :expand_all}
- def event_to_msg(%Event.Key{key: key}, _state) when key in ["c", "C"], do: {:msg, :collapse_all}
- def event_to_msg(%Event.Key{key: key}, _state) when key in ["l", "L"], do: {:msg, :load_lazy}
- def event_to_msg(event, _state) do
- # Forward other events to tree
- {:msg, {:tree_event, event}}
- end
-
- @doc """
- Update state based on messages.
- """
- def update(:quit, state) do
- {state, [:quit]}
- end
-
- def update(:toggle_mode, state) do
- new_mode = if state.selection_mode == :single, do: :multi, else: :single
- tree_state = build_tree_state(new_mode)
- message = "Selection mode: #{new_mode}"
- {%{state | selection_mode: new_mode, tree_state: tree_state, status_message: message}, []}
- end
-
- def update(:expand_all, state) do
- tree_state = ensure_tree_state(state)
- tree_state = TV.expand_all(tree_state)
- {%{state | tree_state: tree_state, status_message: "Expanded all nodes"}, []}
- end
-
- def update(:collapse_all, state) do
- tree_state = ensure_tree_state(state)
- tree_state = TV.collapse_all(tree_state)
- {%{state | tree_state: tree_state, status_message: "Collapsed all nodes"}, []}
- end
-
- def update(:load_lazy, state) do
- tree_state = ensure_tree_state(state)
- focused = TV.get_focused(tree_state)
-
- if focused && focused.children == :lazy do
- # Simulate loading children
- children = [
- TV.leaf(:dep1, "jason", icon: "📦"),
- TV.leaf(:dep2, "plug", icon: "📦"),
- TV.leaf(:dep3, "ecto", icon: "📦"),
- TV.leaf(:dep4, "phoenix", icon: "📦")
- ]
- tree_state = TV.set_children(tree_state, focused.id, children)
- {%{state | tree_state: tree_state, status_message: "Loaded children for #{focused.label}"}, []}
- else
- {%{state | status_message: "Focus a lazy node (📦) and press L to load"}, []}
- end
- end
-
- def update({:tree_event, event}, state) do
- tree_state = ensure_tree_state(state)
- {:ok, tree_state} = TV.handle_event(event, tree_state)
-
- # Update status based on state
- message = get_status_message(tree_state)
- {%{state | tree_state: tree_state, status_message: message}, []}
- end
-
- defp ensure_tree_state(state) do
- state.tree_state || build_tree_state(state.selection_mode)
- end
-
- defp get_status_message(tree_state) do
- focused = TV.get_focused(tree_state)
- selected = TV.get_selected(tree_state)
- filter = tree_state.filter
-
- cond do
- filter != nil ->
- "Filter: #{filter} (#{MapSet.size(tree_state.filter_matches)} matches)"
-
- MapSet.size(selected) > 0 ->
- "Selected: #{MapSet.size(selected)} node(s)"
-
- focused ->
- "Focused: #{focused.label}"
-
- true ->
- "Navigate with arrows"
- end
- end
-
- @doc """
- Render the current state to a render tree.
- """
- def view(state) do
- tree_state = ensure_tree_state(state)
-
- stack(:vertical, [
- # Title
- text("TreeView Widget Example", Style.new(fg: :cyan, attrs: [:bold])),
- text("", nil),
-
- # Tree view
- render_tree_container(tree_state),
-
- # Status
- text("", nil),
- text(state.status_message, Style.new(fg: :yellow)),
-
- # Controls
- render_controls(state)
- ])
- end
-
- defp render_tree_container(tree_state) do
- # Render the tree
- tree_render = TV.render(tree_state, %{x: 0, y: 0, width: 60, height: 20})
-
- box_width = 62
- inner_width = box_width - 2
-
- top_border = "┌─ File Browser " <> String.duplicate("─", inner_width - 16) <> "┐"
- bottom_border = "└" <> String.duplicate("─", inner_width) <> "┘"
-
- stack(:vertical, [
- text(top_border, Style.new(fg: :blue)),
- stack(:horizontal, [
- text("│ ", nil),
- tree_render,
- text(" │", nil)
- ]),
- text(bottom_border, Style.new(fg: :blue))
- ])
- end
-
- defp render_controls(state) do
- box_width = 50
- inner_width = box_width - 2
-
- mode_str = if state.selection_mode == :single, do: "single", else: "multi"
-
- top_border = "┌─ Controls " <> String.duplicate("─", inner_width - 12) <> "─┐"
- bottom_border = "└" <> String.duplicate("─", inner_width) <> "┘"
-
- stack(:vertical, [
- text("", nil),
- text(top_border, Style.new(fg: :yellow)),
- text("│" <> String.pad_trailing(" ↑/↓ Navigate", inner_width) <> "│", nil),
- text("│" <> String.pad_trailing(" ←/→ Collapse/Expand", inner_width) <> "│", nil),
- text("│" <> String.pad_trailing(" Enter Toggle expand/select", inner_width) <> "│", nil),
- text("│" <> String.pad_trailing(" Home/End First/Last node", inner_width) <> "│", nil),
- text("│" <> String.pad_trailing(" / Start search filter", inner_width) <> "│", nil),
- text("│" <> String.pad_trailing(" Escape Clear filter/selection", inner_width) <> "│", nil),
- text("│" <> String.pad_trailing(" M Toggle mode (#{mode_str})", inner_width) <> "│", nil),
- text("│" <> String.pad_trailing(" E/C Expand/Collapse all", inner_width) <> "│", nil),
- text("│" <> String.pad_trailing(" L Load lazy node", inner_width) <> "│", nil),
- text("│" <> String.pad_trailing(" Q Quit", inner_width) <> "│", nil),
- text(bottom_border, Style.new(fg: :yellow))
- ])
- end
-
- # ----------------------------------------------------------------------------
- # Public API
- # ----------------------------------------------------------------------------
-
- @doc """
- Run the tree view example application.
- """
- def run do
- TermUI.Runtime.run(root: __MODULE__)
- end
-end
diff --git a/examples/tree_view/lib/tree_view/application.ex b/examples/tree_view/lib/tree_view/application.ex
deleted file mode 100644
index e57a031f..00000000
--- a/examples/tree_view/lib/tree_view/application.ex
+++ /dev/null
@@ -1,12 +0,0 @@
-defmodule TreeView.Application do
- @moduledoc false
-
- use Application
-
- @impl true
- def start(_type, _args) do
- children = []
- opts = [strategy: :one_for_one, name: TreeView.Supervisor]
- Supervisor.start_link(children, opts)
- end
-end
diff --git a/examples/tree_view/mix.exs b/examples/tree_view/mix.exs
deleted file mode 100644
index 677b9cc4..00000000
--- a/examples/tree_view/mix.exs
+++ /dev/null
@@ -1,26 +0,0 @@
-defmodule TreeView.MixProject do
- use Mix.Project
-
- def project do
- [
- app: :tree_view,
- version: "0.1.0",
- elixir: "~> 1.15",
- start_permanent: Mix.env() == :prod,
- deps: deps()
- ]
- end
-
- def application do
- [
- extra_applications: [:logger],
- mod: {TreeView.Application, []}
- ]
- end
-
- defp deps do
- [
- {:term_ui, path: "../.."}
- ]
- end
-end
diff --git a/examples/tree_view/mix.lock b/examples/tree_view/mix.lock
deleted file mode 100644
index ee8761c0..00000000
--- a/examples/tree_view/mix.lock
+++ /dev/null
@@ -1,12 +0,0 @@
-%{
- "castore": {:hex, :castore, "1.0.17", "4f9770d2d45fbd91dcf6bd404cf64e7e58fed04fadda0923dc32acca0badffa2", [:mix], [], "hexpm", "12d24b9d80b910dd3953e165636d68f147a31db945d2dcb9365e441f8b5351e5"},
- "gen_stage": {:hex, :gen_stage, "1.3.2", "7c77e5d1e97de2c6c2f78f306f463bca64bf2f4c3cdd606affc0100b89743b7b", [:mix], [], "hexpm", "0ffae547fa777b3ed889a6b9e1e64566217413d018cabd825f786e843ffe63e7"},
- "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
- "lumis": {:hex, :lumis, "0.1.0", "6d3b495457d0608f8fe32fe6fa917eb17c921bd0087583a7f4c420c0009888fd", [:mix], [{:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:rustler, "~> 0.29", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.6", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "4de203bc5811ad21f0c6b0442612a3fc1ae9bea7fbfe7037452db9e9dfdaaab3"},
- "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"},
- "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"},
- "mdex": {:hex, :mdex, "0.11.3", "7b815ae2f474e62ad365dfa4d9df87ddec6a01cfa9b7db523a3b21061d909225", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:lumis, "~> 0.1", [hex: :lumis, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}, {:rustler, "~> 0.32", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.7", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "181bf04b13dcae20ab9f336115facc5096aae333a2408a03e53338ee65d4f564"},
- "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
- "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"},
- "rustler_precompiled": {:hex, :rustler_precompiled, "0.8.4", "700a878312acfac79fb6c572bb8b57f5aae05fe1cf70d34b5974850bbf2c05bf", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "3b33d99b540b15f142ba47944f7a163a25069f6d608783c321029bc1ffb09514"},
-}
diff --git a/examples/tree_view/run.exs b/examples/tree_view/run.exs
deleted file mode 100644
index 74a621f7..00000000
--- a/examples/tree_view/run.exs
+++ /dev/null
@@ -1 +0,0 @@
-TreeView.App.run()
diff --git a/examples/viewport/README.md b/examples/viewport/README.md
deleted file mode 100644
index 5a7abb93..00000000
--- a/examples/viewport/README.md
+++ /dev/null
@@ -1,157 +0,0 @@
-# Viewport Widget Example
-
-This example demonstrates how to use the `TermUI.Widgets.Viewport` widget for displaying scrollable content.
-
-## Features Demonstrated
-
-- Vertical scrolling through large content
-- Scroll position tracking
-- Visual scroll bar indicator
-- Keyboard navigation (arrows, Page Up/Down, Home/End)
-
-## Installation
-
-```bash
-cd examples/viewport
-mix deps.get
-```
-
-## Running the Example
-
-### Raw Mode (Full TUI Experience)
-
-For the best experience with full terminal control and alternate screen:
-
-```bash
-cd examples/viewport
-mix termui.run
-```
-
-Or manually:
-
-```bash
-cd examples/viewport
-mix run -e "Viewport.App.run()" --no-halt
-```
-
-### TTY Mode (IEx Compatible)
-
-To run from IEx without taking over the shell:
-
-```bash
-cd examples/viewport
-iex -S mix
-```
-
-Then in IEx:
-
-```elixir
-Viewport.App.run()
-```
-
-**Note:** TTY mode works inside IEx but has limitations:
-- No alternate screen buffer (output mixes with IEx prompt)
-- Character input works immediately (no Enter needed)
-- For full TUI, use raw mode instead
-
-## Controls
-
-| Key | Action |
-|-----|--------|
-| ↑/↓ | Scroll one line |
-| Page Up/Down | Scroll by 5 lines |
-| Home/End | Jump to top/bottom |
-| Q | Quit |
-
-## Code Overview
-
-### Creating a Viewport
-
-```elixir
-Viewport.new(
- content: large_content_tree(),
- width: 40,
- height: 20,
- content_width: 100, # Total content width
- content_height: 200, # Total content height
- scroll_bars: :both # :none, :vertical, :horizontal, :both
-)
-```
-
-### Viewport Options
-
-```elixir
-Viewport.new(
- content: render_node, # Content to display
- content_width: 100, # Total content width
- content_height: 200, # Total content height
- width: 40, # Viewport width
- height: 20, # Viewport height
- scroll_x: 0, # Initial horizontal scroll
- scroll_y: 0, # Initial vertical scroll
- scroll_bars: :both, # Scroll bar display
- scroll_step: 1, # Lines per scroll step
- page_step: 20, # Lines per page scroll
- on_scroll: fn x, y -> ... end # Scroll callback
-)
-```
-
-### Scroll Bar Options
-
-| Value | Description |
-|-------|-------------|
-| `:none` | No scroll bars |
-| `:vertical` | Vertical scroll bar only |
-| `:horizontal` | Horizontal scroll bar only |
-| `:both` | Both scroll bars |
-
-### Viewport API
-
-```elixir
-# Get scroll position
-{x, y} = Viewport.get_scroll(state)
-
-# Set scroll position
-state = Viewport.set_scroll(state, 0, 50)
-
-# Scroll to make position visible
-state = Viewport.scroll_into_view(state, 100, 150)
-
-# Update content
-state = Viewport.set_content(state, new_content)
-
-# Update content dimensions
-state = Viewport.set_content_size(state, 200, 500)
-
-# Check if scrollable
-Viewport.can_scroll_vertical?(state)
-Viewport.can_scroll_horizontal?(state)
-
-# Get visible fraction (for scroll bar thumb size)
-Viewport.visible_fraction_vertical(state) # 0.0 - 1.0
-Viewport.visible_fraction_horizontal(state) # 0.0 - 1.0
-```
-
-### Keyboard Navigation
-
-The Viewport widget handles these keys automatically:
-
-| Key | Action |
-|-----|--------|
-| ↑/↓ | Scroll by scroll_step |
-| ←/→ | Horizontal scroll |
-| Page Up/Down | Scroll by page_step |
-| Home | Scroll to top |
-| End | Scroll to bottom |
-| Ctrl+Home | Scroll to top-left |
-| Ctrl+End | Scroll to bottom-right |
-
-### Mouse Support
-
-- Mouse wheel: Scroll vertically
-- Click scroll bar track: Page scroll
-- Drag scroll bar thumb: Direct scroll
-
-## Widget API
-
-See `lib/term_ui/widgets/viewport.ex` for the full API documentation.
diff --git a/examples/viewport/lib/viewport/app.ex b/examples/viewport/lib/viewport/app.ex
deleted file mode 100644
index f58cb2cd..00000000
--- a/examples/viewport/lib/viewport/app.ex
+++ /dev/null
@@ -1,208 +0,0 @@
-defmodule Viewport.App do
- @moduledoc """
- Viewport Widget Example
-
- This example demonstrates how to use the TermUI.Widgets.Viewport widget
- for displaying scrollable content larger than the view area.
-
- Features demonstrated:
- - Vertical scrolling through large content
- - Scroll position tracking
- - Visual scroll position indicator
- - Keyboard navigation
-
- Note: The actual Viewport widget is a StatefulComponent with scroll bar
- rendering. This example shows the scrolling concept with simpler rendering.
-
- Controls:
- - Up/Down: Scroll by one line
- - Page Up/Down: Scroll by page (5 lines)
- - Home/End: Jump to top/bottom
- - Q: Quit the application
- """
-
- use TermUI.Elm
-
- alias TermUI.Event
- alias TermUI.Renderer.Style
-
- # Content configuration
- @content_height 50
- @viewport_height 10
-
- # ----------------------------------------------------------------------------
- # Component Callbacks
- # ----------------------------------------------------------------------------
-
- @doc """
- Initialize the component state.
- """
- def init(_opts) do
- %{
- scroll_y: 0,
- content: generate_content()
- }
- end
-
- defp generate_content do
- # Generate 50 lines of content
- for i <- 1..@content_height do
- line_content =
- case rem(i, 10) do
- 0 -> "────────── Section #{div(i, 10)} ──────────"
- _ -> "Line #{String.pad_leading(to_string(i), 2, "0")}: Lorem ipsum dolor sit amet"
- end
-
- {i, line_content}
- end
- end
-
- @doc """
- Convert keyboard events to messages.
- """
- def event_to_msg(%Event.Key{key: :up}, _state), do: {:msg, {:scroll, -1}}
- def event_to_msg(%Event.Key{key: :down}, _state), do: {:msg, {:scroll, 1}}
- def event_to_msg(%Event.Key{key: :page_up}, _state), do: {:msg, {:scroll, -5}}
- def event_to_msg(%Event.Key{key: :page_down}, _state), do: {:msg, {:scroll, 5}}
- def event_to_msg(%Event.Key{key: :home}, _state), do: {:msg, :scroll_top}
- def event_to_msg(%Event.Key{key: :end}, _state), do: {:msg, :scroll_bottom}
- def event_to_msg(%Event.Key{key: key}, _state) when key in ["q", "Q"], do: {:msg, :quit}
- def event_to_msg(_event, _state), do: :ignore
-
- @doc """
- Update state based on messages.
- """
- def update({:scroll, delta}, state) do
- max_scroll = @content_height - @viewport_height
- new_scroll = max(0, min(max_scroll, state.scroll_y + delta))
- {%{state | scroll_y: new_scroll}, []}
- end
-
- def update(:scroll_top, state) do
- {%{state | scroll_y: 0}, []}
- end
-
- def update(:scroll_bottom, state) do
- max_scroll = @content_height - @viewport_height
- {%{state | scroll_y: max_scroll}, []}
- end
-
- def update(:quit, state) do
- {state, [:quit]}
- end
-
- @doc """
- Render the current state to a render tree.
- """
- def view(state) do
- stack(:vertical, [
- # Title
- text("Viewport Widget Example", Style.new(fg: :cyan, attrs: [:bold])),
- text("", nil),
-
- # Content area with scroll bar
- render_viewport_area(state),
- text("", nil),
-
- # Scroll position info
- text("", nil),
-
- # Controls
- render_controls(state)
- ])
- end
-
- defp render_controls(state) do
- box_width = 56
- inner_width = box_width - 2
-
- top_border = "┌─ Controls " <> String.duplicate("─", inner_width - 12) <> "─┐"
- bottom_border = "└" <> String.duplicate("─", inner_width) <> "┘"
-
- stack(:vertical, [
- text("", nil),
- text(top_border, Style.new(fg: :yellow)),
- text("│" <> String.pad_trailing(" ↑/↓ Scroll one line", inner_width) <> "│", nil),
- text("│" <> String.pad_trailing(" Page Up/Down Scroll by 5", inner_width) <> "│", nil),
- text("│" <> String.pad_trailing(" Home/End Jump to top/bottom", inner_width) <> "│", nil),
- text("│" <> String.pad_trailing(" Q Quit", inner_width) <> "│", nil),
- text("│" <> String.pad_trailing("", inner_width) <> "│", nil),
- text("│" <> String.pad_trailing(" Scroll: #{state.scroll_y}/#{@content_height - @viewport_height}", inner_width) <> "│", nil),
- text("│" <> String.pad_trailing(" Showing lines #{state.scroll_y + 1}-#{state.scroll_y + @viewport_height} of #{@content_height}", inner_width) <> "│", nil),
- text(bottom_border, Style.new(fg: :yellow))
- ])
- end
-
- # ----------------------------------------------------------------------------
- # Private Helpers
- # ----------------------------------------------------------------------------
-
- defp render_viewport_area(state) do
- # Get visible content lines
- visible_lines =
- state.content
- |> Enum.slice(state.scroll_y, @viewport_height)
-
- # Render content lines
- content_rows =
- Enum.map(visible_lines, fn {_line_num, content} ->
- # Truncate to fit viewport width
- truncated = String.slice(content, 0, 50)
- padded = String.pad_trailing(truncated, 50)
- text("│ " <> padded <> " ", nil)
- end)
-
- # Render scroll bar
- scroll_bar = render_scroll_bar(state)
-
- # Combine content and scroll bar
- rows_with_bar =
- Enum.zip(content_rows, scroll_bar)
- |> Enum.map(fn {content_row, bar_char} ->
- stack(:horizontal, [content_row, text(bar_char, nil), text("│", nil)])
- end)
-
- # Add top and bottom borders
- top_border = text("┌" <> String.duplicate("─", 52) <> "┬─┐", nil)
- bottom_border = text("└" <> String.duplicate("─", 52) <> "┴─┘", nil)
-
- stack(:vertical, [top_border | rows_with_bar] ++ [bottom_border])
- end
-
- defp render_scroll_bar(state) do
- max_scroll = @content_height - @viewport_height
-
- # Calculate thumb position and size
- visible_fraction = @viewport_height / @content_height
- thumb_size = max(1, round(@viewport_height * visible_fraction))
-
- scroll_fraction =
- if max_scroll > 0 do
- state.scroll_y / max_scroll
- else
- 0.0
- end
-
- thumb_pos = round((@viewport_height - thumb_size) * scroll_fraction)
-
- # Build scroll bar characters
- for i <- 0..(@viewport_height - 1) do
- if i >= thumb_pos and i < thumb_pos + thumb_size do
- "█"
- else
- "░"
- end
- end
- end
-
- # ----------------------------------------------------------------------------
- # Public API
- # ----------------------------------------------------------------------------
-
- @doc """
- Run the viewport example application.
- """
- def run do
- TermUI.Runtime.run(root: __MODULE__)
- end
-end
diff --git a/examples/viewport/lib/viewport/application.ex b/examples/viewport/lib/viewport/application.ex
deleted file mode 100644
index 92746787..00000000
--- a/examples/viewport/lib/viewport/application.ex
+++ /dev/null
@@ -1,12 +0,0 @@
-defmodule Viewport.Application do
- @moduledoc false
-
- use Application
-
- @impl true
- def start(_type, _args) do
- children = []
- opts = [strategy: :one_for_one, name: Viewport.Supervisor]
- Supervisor.start_link(children, opts)
- end
-end
diff --git a/examples/viewport/mix.exs b/examples/viewport/mix.exs
deleted file mode 100644
index 11982597..00000000
--- a/examples/viewport/mix.exs
+++ /dev/null
@@ -1,26 +0,0 @@
-defmodule Viewport.MixProject do
- use Mix.Project
-
- def project do
- [
- app: :viewport,
- version: "0.1.0",
- elixir: "~> 1.15",
- start_permanent: Mix.env() == :prod,
- deps: deps()
- ]
- end
-
- def application do
- [
- extra_applications: [:logger],
- mod: {Viewport.Application, []}
- ]
- end
-
- defp deps do
- [
- {:term_ui, path: "../.."}
- ]
- end
-end
diff --git a/examples/viewport/mix.lock b/examples/viewport/mix.lock
deleted file mode 100644
index ee8761c0..00000000
--- a/examples/viewport/mix.lock
+++ /dev/null
@@ -1,12 +0,0 @@
-%{
- "castore": {:hex, :castore, "1.0.17", "4f9770d2d45fbd91dcf6bd404cf64e7e58fed04fadda0923dc32acca0badffa2", [:mix], [], "hexpm", "12d24b9d80b910dd3953e165636d68f147a31db945d2dcb9365e441f8b5351e5"},
- "gen_stage": {:hex, :gen_stage, "1.3.2", "7c77e5d1e97de2c6c2f78f306f463bca64bf2f4c3cdd606affc0100b89743b7b", [:mix], [], "hexpm", "0ffae547fa777b3ed889a6b9e1e64566217413d018cabd825f786e843ffe63e7"},
- "jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
- "lumis": {:hex, :lumis, "0.1.0", "6d3b495457d0608f8fe32fe6fa917eb17c921bd0087583a7f4c420c0009888fd", [:mix], [{:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:rustler, "~> 0.29", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.6", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "4de203bc5811ad21f0c6b0442612a3fc1ae9bea7fbfe7037452db9e9dfdaaab3"},
- "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"},
- "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"},
- "mdex": {:hex, :mdex, "0.11.3", "7b815ae2f474e62ad365dfa4d9df87ddec6a01cfa9b7db523a3b21061d909225", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:lumis, "~> 0.1", [hex: :lumis, repo: "hexpm", optional: false]}, {:nimble_options, "~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:phoenix_live_view, "~> 1.0", [hex: :phoenix_live_view, repo: "hexpm", optional: true]}, {:rustler, "~> 0.32", [hex: :rustler, repo: "hexpm", optional: true]}, {:rustler_precompiled, "~> 0.7", [hex: :rustler_precompiled, repo: "hexpm", optional: false]}], "hexpm", "181bf04b13dcae20ab9f336115facc5096aae333a2408a03e53338ee65d4f564"},
- "nimble_options": {:hex, :nimble_options, "1.1.1", "e3a492d54d85fc3fd7c5baf411d9d2852922f66e69476317787a7b2bb000a61b", [:mix], [], "hexpm", "821b2470ca9442c4b6984882fe9bb0389371b8ddec4d45a9504f00a66f650b44"},
- "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"},
- "rustler_precompiled": {:hex, :rustler_precompiled, "0.8.4", "700a878312acfac79fb6c572bb8b57f5aae05fe1cf70d34b5974850bbf2c05bf", [:mix], [{:castore, "~> 0.1 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: false]}, {:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "3b33d99b540b15f142ba47944f7a163a25069f6d608783c321029bc1ffb09514"},
-}
diff --git a/examples/viewport/run.exs b/examples/viewport/run.exs
deleted file mode 100644
index c8f8cd4d..00000000
--- a/examples/viewport/run.exs
+++ /dev/null
@@ -1 +0,0 @@
-Viewport.App.run()
diff --git a/guides/api_reference.md b/guides/api_reference.md
deleted file mode 100644
index aaf7703f..00000000
--- a/guides/api_reference.md
+++ /dev/null
@@ -1,355 +0,0 @@
-# Phase 3 API Reference
-
-Quick reference for the TermUI component system API.
-
-## Component Behaviours
-
-### TermUI.Component
-
-Base behaviour for stateless components.
-
-```elixir
-use TermUI.Component
-
-# Required
-@callback render(props :: map(), area :: rect()) :: render_tree()
-
-# Optional
-@callback describe() :: component_info()
-@callback default_props() :: map()
-```
-
-### TermUI.StatefulComponent
-
-Behaviour for stateful, interactive components.
-
-```elixir
-use TermUI.StatefulComponent
-
-# Required
-@callback init(props :: map()) :: {:ok, state()}
-@callback handle_event(event :: term(), state()) :: event_result()
-@callback render(state(), area :: rect()) :: render_tree()
-
-# Optional
-@callback mount(state()) :: {:ok, state()} | {:ok, state(), [command()]}
-@callback unmount(state()) :: :ok
-@callback handle_info(msg :: term(), state()) :: {:noreply, state()}
-@callback handle_call(request :: term(), from :: GenServer.from(), state()) :: {:reply, reply, state()}
-@callback terminate(reason :: term(), state()) :: term()
-```
-
-### TermUI.Container
-
-Behaviour for components that manage children.
-
-```elixir
-use TermUI.Container
-
-# Required (in addition to StatefulComponent)
-@callback children(state()) :: [child_spec()]
-@callback layout(children :: [component_ref()], area :: rect(), state()) :: [{component_ref(), rect()}]
-
-# Optional
-@callback handle_child_message(child_id :: term(), msg :: term(), state()) :: {:ok, state()}
-@callback route_event(event :: term(), state()) :: {:route, component_id()} | :self
-```
-
-## ComponentServer
-
-Manages individual component lifecycle.
-
-```elixir
-# Start and mount
-{:ok, pid} = ComponentSupervisor.start_component(Module, props, id: :id)
-:ok = ComponentServer.mount(pid)
-
-# Query state
-state = ComponentServer.get_state(pid)
-
-# Send events
-:ok = ComponentServer.send_event(pid, event)
-
-# Update props
-:ok = ComponentServer.update_props(pid, new_props)
-
-# Request render
-render_tree = ComponentServer.render(pid, area)
-```
-
-## ComponentSupervisor
-
-Supervises all component processes.
-
-```elixir
-# Start component
-{:ok, pid} = ComponentSupervisor.start_component(Module, props, opts)
-
-# Options
-opts = [
- id: :component_id, # Required
- restart: :transient, # :transient | :permanent | :temporary
- recovery: :reset, # :reset | :last_state | :last_props
- max_restarts: 3, # Restart limit
- max_seconds: 5 # Time window for restarts
-]
-
-# Stop component
-:ok = ComponentSupervisor.stop_component(:id)
-:ok = ComponentSupervisor.stop_component(:id, cascade: true)
-
-# Query
-count = ComponentSupervisor.count_children()
-tree = ComponentSupervisor.get_tree()
-{:ok, info} = ComponentSupervisor.get_component_info(:id)
-tree_text = ComponentSupervisor.format_tree()
-```
-
-## ComponentRegistry
-
-Tracks components and their relationships.
-
-```elixir
-# Lookup
-{:ok, pid} = ComponentRegistry.lookup(:id)
-
-# Relationships
-:ok = ComponentRegistry.set_parent(:child, :parent)
-{:ok, parent_id} = ComponentRegistry.get_parent(:id)
-children = ComponentRegistry.get_children(:id)
-
-# All components
-components = ComponentRegistry.list_all()
-```
-
-## EventRouter
-
-Routes events to components.
-
-```elixir
-# Route event to appropriate target
-:handled | :unhandled = EventRouter.route(event)
-
-# Route to specific component
-:handled | :unhandled = EventRouter.route_to(:id, event)
-
-# Broadcast to all
-{:ok, count} = EventRouter.broadcast(event)
-
-# Focus management
-:ok = EventRouter.set_focus(:id)
-{:ok, id} = EventRouter.get_focus()
-:ok = EventRouter.clear_focus()
-
-# Fallback handler
-:ok = EventRouter.set_fallback_handler(fn event -> :ok end)
-:ok = EventRouter.clear_fallback_handler()
-```
-
-## FocusManager
-
-Manages focus state and traversal.
-
-```elixir
-# Current focus
-{:ok, id | nil} = FocusManager.get_focused()
-:ok = FocusManager.set_focused(:id)
-:ok = FocusManager.clear_focus()
-
-# Traversal
-:ok = FocusManager.focus_next()
-:ok = FocusManager.focus_prev()
-
-# Focus stack (for modals)
-:ok = FocusManager.push_focus(:modal_component)
-:ok = FocusManager.pop_focus()
-
-# Focus groups and trapping
-:ok = FocusManager.register_group(:group, [:id1, :id2, :id3])
-:ok = FocusManager.trap_focus(:group)
-:ok = FocusManager.release_focus()
-:ok = FocusManager.unregister_group(:group)
-```
-
-## SpatialIndex
-
-Maps screen positions to components for mouse routing.
-
-```elixir
-# Register bounds
-:ok = SpatialIndex.update(:id, pid, %{x: 0, y: 0, width: 20, height: 5})
-:ok = SpatialIndex.update(:id, pid, bounds, z_index: 100)
-
-# Query
-{:ok, {id, pid}} = SpatialIndex.find_at(x, y)
-{:error, :not_found} = SpatialIndex.find_at(x, y)
-
-# Remove
-:ok = SpatialIndex.remove(:id)
-```
-
-## Event Types
-
-```elixir
-# Keyboard
-%TermUI.Event.Key{
- key: :enter | :tab | :up | :down | :left | :right | :backspace | :delete | :escape | :home | :end | :page_up | :page_down | :f1..f12 | atom(),
- char: String.t() | nil,
- modifiers: [:ctrl | :alt | :shift],
- timestamp: integer()
-}
-
-# Mouse
-%TermUI.Event.Mouse{
- action: :click | :release | :move | :scroll_up | :scroll_down,
- button: :left | :right | :middle | nil,
- x: integer(),
- y: integer(),
- modifiers: [:ctrl | :alt | :shift],
- timestamp: integer()
-}
-
-# Focus
-%TermUI.Event.Focus{
- type: :gained | :lost
-}
-
-# Custom
-%TermUI.Event.Custom{
- name: atom(),
- payload: term()
-}
-```
-
-## StatePersistence
-
-Persists state for crash recovery.
-
-```elixir
-# Manual persistence
-:ok = StatePersistence.persist(:id, state)
-
-# Recovery
-{:ok, state} = StatePersistence.recover(:id, :last_state)
-:not_found = StatePersistence.recover(:id, :reset)
-
-# Restart tracking
-count = StatePersistence.get_restart_count(:id)
-:ok = StatePersistence.increment_restart_count(:id)
-```
-
-## Essential Widgets
-
-### Block
-
-Container with border and title.
-
-```elixir
-%{
- border: :none | :single | :double | :rounded | :thick,
- title: String.t() | nil,
- title_align: :left | :center | :right,
- padding: integer() | %{top: i, bottom: i, left: i, right: i}
-}
-```
-
-### Label
-
-Text display.
-
-```elixir
-%{
- text: String.t(),
- style: Style.t(),
- align: :left | :center | :right,
- wrap: boolean(),
- truncate: boolean()
-}
-```
-
-### Button
-
-Clickable action trigger.
-
-```elixir
-%{
- label: String.t(),
- on_click: (-> any()),
- disabled: boolean(),
- style: Style.t(),
- focus_style: Style.t()
-}
-```
-
-### TextInput
-
-Single-line text entry.
-
-```elixir
-%{
- value: String.t(),
- placeholder: String.t(),
- on_change: (String.t() -> any()),
- on_submit: (String.t() -> any()),
- password: boolean(),
- max_length: integer() | nil
-}
-```
-
-### List
-
-Selectable item list.
-
-```elixir
-%{
- items: [String.t() | {String.t(), term()}],
- selected: integer() | [integer()],
- on_select: (term() -> any()),
- multi_select: boolean(),
- highlight_style: Style.t()
-}
-```
-
-### Progress
-
-Progress indicator.
-
-```elixir
-%{
- value: float(), # 0.0 to 1.0
- mode: :bar | :spinner,
- show_percent: boolean(),
- bar_char: String.t(),
- empty_char: String.t()
-}
-```
-
-## Type Reference
-
-```elixir
-@type rect :: %{x: integer(), y: integer(), width: integer(), height: integer()}
-@type render_tree :: RenderNode.t() | [render_tree()] | String.t()
-@type child_spec :: {module(), props :: map()} | {module(), props :: map(), id :: term()}
-@type event_result :: {:ok, state()} | {:ok, state(), [command()]} | {:stop, reason, state()}
-@type command :: {:send, pid(), term()} | {:timer, ms, term()} | {:focus, term()} | term()
-```
-
-## Running Tests
-
-```bash
-# All Phase 3 tests
-mix test test/term_ui/component* test/term_ui/event* test/term_ui/focus* test/term_ui/widget test/term_ui/spatial* test/term_ui/integration/
-
-# Integration tests only
-mix test test/term_ui/integration/
-
-# Specific module
-mix test test/term_ui/focus_manager_test.exs
-```
-
-## Generating Documentation
-
-```bash
-mix docs
-open doc/index.html
-```
diff --git a/guides/architecture.md b/guides/architecture.md
new file mode 100644
index 00000000..7d7e159f
--- /dev/null
+++ b/guides/architecture.md
@@ -0,0 +1,106 @@
+# Architecture
+
+TermUI has three boundaries.
+
+## Application
+
+One runtime process owns one application state. It serializes terminal events,
+application messages, command results, render timers, resize, and shutdown.
+
+The application implements `TermUI.Elm`:
+
+- `init/1` creates state.
+- `event_to_msg/2` converts terminal input to an application message.
+- `update/2` creates the next state and command data.
+- `view/1` creates one complete `TermUI.Frame`.
+- `handle_info/2` can convert an external process message to state and commands.
+- `terminate/2` can release application resources.
+
+The runtime does not store widget or component processes. It does not accept a
+render tree, a buffer, or a list of backend cells from an application.
+
+## Async commands
+
+`Command.async/2` runs a zero-argument function outside the runtime process.
+The function can return any term. The runtime, not the function, creates the
+result tag that the mapper receives: a normal return becomes `{:ok, value}`;
+a raise, throw, or exit becomes `{:error, reason}`. This creates exactly one
+outer result tag. For example, a function return of `{:ok, value}` becomes
+`{:ok, {:ok, value}}` for the mapper.
+
+## Widgets
+
+A widget is plain state plus `init/1`, `update/2`, and `view/2`. The parent
+application stores that state. `view/2` returns a `TermUI.Frame`, which the
+parent can place with `TermUI.Frame.overlay/4`.
+
+The optional `mouse/3` widget callback receives local, zero-based coordinates.
+The application creates pure mouse regions from its layout and owns hover,
+drag, focus, and text selection state. Clipboard output is command data. The
+runtime sends it through the same backend owner that draws frames.
+
+Widgets that show processes, supervision trees, streams, or cluster nodes only
+format snapshots. The parent application owns polling, subscriptions, RPC, and
+other effects.
+
+The optional `TermUI.Snapshot` provider modules perform one synchronous,
+bounded collection only when the parent calls them. Their output separates
+usable items from partial source errors. Remote cluster RPC needs an explicit
+parent-supplied function. Providers do not monitor, retry, or schedule work.
+
+An external stream can use `TermUI.Stream.ProducerAdapter`. This adapter is not
+a widget owner. It bounds queued items and permits only one unacknowledged
+batch in the application mailbox. The application remains the only owner of
+the stream widget and applies each delivered batch in update order.
+
+Themes, focus traversal, shortcut sequences, viewport drag, and pane collapse
+are pure values stored by the application. TermUI does not register them or
+start a service for them. The [UI context decision](ui-context.md) records why
+TermUI keeps theme, focus, shortcut, and mouse values independent and shows the
+preferred explicit wiring pattern.
+
+## Data schemas
+
+Zoi schemas define public data that can cross an application, runtime,
+backend, or configuration boundary. The schema is the source of the struct
+fields, defaults, and enforced keys for these boundary values.
+
+`TermUI.Cell`, `TermUI.Style`, `TermUI.Frame`, `TermUI.Event`,
+`TermUI.Command`, `TermUI.Clipboard.Operation`, `TermUI.Mouse.Region`,
+`TermUI.Selection`, and `TermUI.Widget.Table.Column` expose `schema/0` for
+explicit validation. Frame and command schemas also validate their nested
+boundary data.
+
+Private backend state, parser state, stream delivery state, and parent-owned
+widget state use plain structs. They are not serialization or trust
+boundaries, so a Zoi schema adds no useful contract. TermUI does not parse each
+frame mutation or widget update through Zoi. Constructors and guards keep
+these hot paths small. Applications must parse untrusted external data before
+they use it as a boundary value.
+
+## Frame
+
+`TermUI.Frame` is a bounded, sparse cell map. Missing cells are blank. It clips
+content to its dimensions and records the second column of wide graphemes. A
+backend can compare the current frame with its last frame.
+
+`TermUI.Frame.overlay/4` composes child frames without adding another render
+representation. `TermUI.Frame.diff/2` remains the one backend cell comparison.
+
+## Backend
+
+A backend owns all terminal state. Setup is transactional. After successful
+setup, every stop path calls `shutdown/2`. The backend normalizes input to
+`TermUI.Event` values and accepts only `TermUI.Frame` for rendering.
+
+One backend owner serializes input polling, size checks, drawing, flushing,
+resize, and shutdown against one current backend state. Backend state stays
+opaque to the runtime. An input failure stops the application after a final
+meaningful render.
+
+## Shutdown
+
+Shutdown has three states: running, final render pending, and stopping. A
+shutdown command or external shutdown request cancels a pending timer, renders
+the newest dirty state, stops effect processes, calls the application terminate
+callback, and closes the backend owner so that it restores the terminal.
diff --git a/guides/backend.md b/guides/backend.md
new file mode 100644
index 00000000..faf0c611
--- /dev/null
+++ b/guides/backend.md
@@ -0,0 +1,142 @@
+# Backend contract
+
+A backend implements `TermUI.Backend`.
+
+```elixir
+@callback init(keyword()) :: {:ok, state()} | {:error, term()}
+@callback size(state()) :: {:ok, {rows, columns}} | {:error, term()}
+@callback capabilities(state()) :: map()
+@callback draw(state(), TermUI.Frame.t()) :: {:ok, state()} | {:error, term()}
+@callback flush(state()) :: {:ok, state()} | {:error, term()}
+@callback clipboard(state(), TermUI.Clipboard.Operation.t()) ::
+ {:ok, state()} | {:error, term()}
+@callback poll_event(state(), non_neg_integer()) ::
+ {:ok, TermUI.Event.t(), state()} | {:timeout, state()} | {:error, term(), state()}
+@callback resize(state(), {rows, columns}) :: {:ok, state()} | {:error, term()}
+@callback shutdown(state(), term()) :: :ok
+```
+
+`clipboard/2` is optional. The runtime returns a structured unsupported error
+when a custom backend does not implement it. The callback must return the next
+backend state so clipboard output stays in sequence with draw and cleanup.
+
+The size at the backend boundary is `{rows, columns}`. The runtime converts it
+to application dimensions `{columns, rows}`.
+
+`init/1` must not leave partial terminal state after an error. `shutdown/2`
+must be safe during error cleanup. `draw/2` must retain the last successful
+frame or equivalent backend state so that a later frame can clear old cells.
+
+`TermUI.Test.DeterministicBackend` is the public v2 test boundary. It uses a
+fixed size, reports explicit capabilities, accepts normalized event and resize
+injection, and captures every complete frame. It does not open a terminal or
+call the TTY NIF.
+
+```elixir
+alias TermUI.Test.DeterministicBackend
+
+{:ok, runtime} =
+ TermUI.start_link(MyApp,
+ backend: {
+ DeterministicBackend,
+ owner: self(),
+ size: {12, 40},
+ capabilities: %{colors: :ansi_16, unicode: true}
+ },
+ backend_opts: [size_poll_interval: :disabled]
+ )
+
+assert_receive {:backend, :draw, %TermUI.Frame{} = initial}
+
+:ok = DeterministicBackend.send_event(runtime, TermUI.Event.key(:enter))
+:ok = DeterministicBackend.resize(runtime, 60, 20)
+
+assert_receive {:backend, :resize, {20, 60}}
+assert_receive {:backend, :draw, %TermUI.Frame{width: 60, height: 20}}
+
+TermUI.Runtime.shutdown(runtime)
+assert_receive {:backend, :shutdown_snapshot, snapshot}
+```
+
+The snapshot contains `:frames` in draw order, the final `:size`, the explicit
+`:capabilities`, pending queued events, clipboard operations, flush count, and
+`:shutdown_reason`. This test path needs no native terminal state. Use normal
+ExUnit message assertions. No v1 component harness or test renderer is used.
+
+The runtime puts each backend behind one serialized owner. State returned by
+input, size, draw, flush, and resize callbacks becomes the state for the next
+callback and for final cleanup.
+
+## Native build policy
+
+Only the local raw backend can need the TTY NIF. OTP 28 and OTP 29 need this
+small native helper to stop the terminal driver from consuming Ctrl+O,
+Ctrl+C, Ctrl+S, and Ctrl+Q. The `:tty` backend is the pure BEAM local fallback.
+The SSH and deterministic backends also use only BEAM code.
+
+The `TERM_UI_TTY_NIF` build setting has these values:
+
+| Value | Build and runtime behavior |
+| --- | --- |
+| `auto` | Build from source when `make` and a C compiler exist. Otherwise, do not build the NIF. |
+| `source` | Require a source build. Stop with a clear list of missing tools when the toolchain is incomplete. |
+| `disabled` | Do not build the NIF. Keep TTY, SSH, deterministic, and custom backends available. |
+
+`auto` is the default. TermUI does not ship precompiled artifacts. The local
+raw path loads the NIF on demand. Backend selection falls back to `:tty` when
+the NIF is absent and OTP cannot manage control signals. An explicit `:raw`
+selection returns a structured `:raw_mode_unavailable` error. It does not
+leave the terminal in raw mode.
+
+Size polling uses a 200 ms interval when direct terminal or environment size
+checks are available. It uses a 1 second interval when detection must start
+`stty`. Set `backend_opts: [size_poll_interval: milliseconds]` to use an
+interval of at least 50 ms. Use `:disabled` when the application supplies all
+resize events through its backend input stream.
+
+## SSH sessions
+
+`TermUI.Backend.SSH` owns one remote terminal session and one v2 runtime. It
+does not start an SSH daemon. Thus, the host application keeps control of
+authentication, host keys, network policy, and connection limits.
+
+An application that already owns an SSH server can use the direct session API:
+
+```elixir
+{:ok, session} =
+ TermUI.Backend.SSH.start_session(MyApp,
+ size: {24, 80},
+ output: fn data -> MySSHTransport.send(data) end
+ )
+
+:ok = TermUI.Backend.SSH.input(session, remote_bytes)
+:ok = TermUI.Backend.SSH.resize(session, 40, 120)
+:ok = TermUI.Backend.SSH.stop_session(session)
+```
+
+Some SSH libraries require output from the channel process. Set `:output` to
+that process. It receives this message:
+
+```elixir
+{:term_ui_ssh_output, session, token, data}
+```
+
+After it sends the data, it must call
+`TermUI.Backend.SSH.ack_output(session, token, result)`. Only one output is in
+flight. One newer frame can wait, and each later frame replaces the stale
+waiting frame. Frame diffs use the last confirmed frame, so a slow client does
+not receive an invalid diff.
+
+OTP SSH daemons can use the supplied channel callback:
+
+```elixir
+:ssh.daemon(port,
+ system_dir: system_dir,
+ pwdfun: password_fun,
+ ssh_cli: {TermUI.Backend.SSH.Channel, [MyApp, runtime_options: []]}
+)
+```
+
+The callback accepts PTY input and window changes. It sends Unicode text,
+bracketed paste, mouse, focus, and resize values through the normal v2 event
+contract. The SSH path does not select raw mode or call the local terminal NIF.
diff --git a/guides/component_system.md b/guides/component_system.md
deleted file mode 100644
index 80932f61..00000000
--- a/guides/component_system.md
+++ /dev/null
@@ -1,546 +0,0 @@
-# Component System Guide
-
-This guide covers how to build TUI applications using TermUI's component system. By the end, you'll understand how to create components, handle events, manage focus, and build hierarchical UIs.
-
-## Table of Contents
-
-1. [Core Concepts](#core-concepts)
-2. [Creating Components](#creating-components)
-3. [Component Lifecycle](#component-lifecycle)
-4. [Event Handling](#event-handling)
-5. [Focus Management](#focus-management)
-6. [Building Hierarchies](#building-hierarchies)
-7. [Fault Tolerance](#fault-tolerance)
-8. [Best Practices](#best-practices)
-
-## Core Concepts
-
-TermUI's component system is built on OTP processes. Each component is a GenServer that:
-- Maintains its own state
-- Receives events as messages
-- Produces render trees
-- Is supervised for fault tolerance
-
-### Component Behaviours
-
-Three behaviours define component types:
-
-| Behaviour | Use Case | Key Callbacks |
-|-----------|----------|---------------|
-| `Component` | Stateless display | `render/2` |
-| `StatefulComponent` | Interactive widgets | `init/1`, `handle_event/2`, `render/2` |
-| `Container` | Layout with children | All above + `children/1`, `layout/3` |
-
-## Creating Components
-
-### Stateless Components
-
-Use `Component` for display-only widgets:
-
-```elixir
-defmodule MyApp.Divider do
- use TermUI.Component
-
- @impl true
- def render(props, area) do
- char = props[:char] || "-"
- String.duplicate(char, area.width)
- end
-end
-```
-
-### Stateful Components
-
-Use `StatefulComponent` for interactive widgets:
-
-```elixir
-defmodule MyApp.Counter do
- use TermUI.StatefulComponent
-
- @impl true
- def init(props) do
- {:ok, %{
- count: props[:initial] || 0,
- step: props[:step] || 1
- }}
- end
-
- @impl true
- def handle_event(%TermUI.Event.Key{key: :up}, state) do
- {:ok, %{state | count: state.count + state.step}}
- end
-
- def handle_event(%TermUI.Event.Key{key: :down}, state) do
- {:ok, %{state | count: max(0, state.count - state.step)}}
- end
-
- def handle_event(_event, state) do
- {:ok, state}
- end
-
- @impl true
- def render(state, _area) do
- text("Count: #{state.count}")
- end
-end
-```
-
-### Container Components
-
-Use `Container` to manage children:
-
-```elixir
-defmodule MyApp.Panel do
- use TermUI.Container
-
- @impl true
- def init(props) do
- {:ok, %{
- title: props[:title],
- children: props[:children] || []
- }}
- end
-
- @impl true
- def children(state) do
- state.children
- end
-
- @impl true
- def layout(children, area, _state) do
- # Stack children vertically
- Enum.with_index(children)
- |> Enum.map(fn {child, idx} ->
- {child, %{x: area.x, y: area.y + idx, width: area.width, height: 1}}
- end)
- end
-
- @impl true
- def handle_event(_event, state) do
- {:ok, state}
- end
-
- @impl true
- def render(state, area) do
- box(border: :single, title: state.title) do
- # Children render here
- end
- end
-end
-```
-
-## Component Lifecycle
-
-Components go through defined lifecycle stages:
-
-```
-┌─────────────────────────────────────────┐
-│ init/1 │
-│ └─ Called with props │
-│ Returns {:ok, initial_state} │
-└─────────────┬───────────────────────────┘
- │
- ▼
-┌─────────────────────────────────────────┐
-│ mount/1 (optional) │
-│ └─ Component added to tree │
-│ Start timers, fetch data │
-│ Returns {:ok, state} │
-└─────────────┬───────────────────────────┘
- │
- ▼
-┌─────────────────────────────────────────┐
-│ handle_event/2 (loop) │
-│ └─ Process user input │
-│ Returns {:ok, new_state} │
-└─────────────┬───────────────────────────┘
- │
- ▼
-┌─────────────────────────────────────────┐
-│ unmount/1 (optional) │
-│ └─ Component removed from tree │
-│ Cleanup resources │
-│ Returns :ok │
-└─────────────────────────────────────────┘
-```
-
-### Lifecycle Hooks
-
-Register hooks for lifecycle events:
-
-```elixir
-defmodule MyApp.Widget do
- use TermUI.StatefulComponent
-
- @impl true
- def init(props) do
- {:ok, %{value: props[:value]}}
- end
-
- # Called after mount completes
- @impl true
- def mount(state) do
- # Start a timer, register handlers, etc.
- {:ok, state}
- end
-
- # Called before unmount
- @impl true
- def unmount(state) do
- # Cleanup resources
- :ok
- end
-end
-```
-
-## Event Handling
-
-### Event Types
-
-TermUI supports these event types:
-
-```elixir
-# Keyboard events
-%TermUI.Event.Key{
- key: :enter, # Key symbol
- char: nil, # Character if printable
- modifiers: [:ctrl] # Active modifiers
-}
-
-# Mouse events
-%TermUI.Event.Mouse{
- action: :click, # :click, :move, :scroll
- button: :left, # :left, :right, :middle
- x: 10, y: 5, # Screen coordinates
- modifiers: []
-}
-
-# Focus events
-%TermUI.Event.Focus{
- type: :gained # :gained or :lost
-}
-
-# Custom events
-%TermUI.Event.Custom{
- name: :my_event,
- payload: %{data: "value"}
-}
-```
-
-### Handling Events
-
-Components receive events via `handle_event/2`:
-
-```elixir
-@impl true
-def handle_event(%Event.Key{key: :enter}, state) do
- # Handle Enter key
- {:ok, %{state | submitted: true}}
-end
-
-def handle_event(%Event.Key{char: char}, state) when char != nil do
- # Handle character input
- {:ok, %{state | text: state.text <> char}}
-end
-
-def handle_event(%Event.Mouse{action: :click}, state) do
- # Handle mouse click
- {:ok, %{state | clicked: true}}
-end
-
-def handle_event(_event, state) do
- # Ignore other events
- {:ok, state}
-end
-```
-
-### Event Routing
-
-Events are routed automatically:
-- **Keyboard events** → Focused component
-- **Mouse events** → Component at click position
-- **Focus events** → Component gaining/losing focus
-
-Use `EventRouter` to route events:
-
-```elixir
-# Route to focused component
-EventRouter.route(%Event.Key{key: :tab})
-
-# Route to specific component
-EventRouter.route_to(:my_component, event)
-
-# Broadcast to all components
-EventRouter.broadcast({:resize, 80, 24})
-```
-
-## Focus Management
-
-### Setting Focus
-
-```elixir
-# Set focus to a component
-FocusManager.set_focused(:my_input)
-
-# Get currently focused component
-{:ok, focused_id} = FocusManager.get_focused()
-
-# Clear focus
-FocusManager.clear_focus()
-```
-
-### Tab Navigation
-
-Focus traversal follows spatial order (left-to-right, top-to-bottom):
-
-```elixir
-# Move to next focusable component
-FocusManager.focus_next()
-
-# Move to previous focusable component
-FocusManager.focus_prev()
-```
-
-### Focus Stack for Modals
-
-When opening modals, push/pop focus to restore properly:
-
-```elixir
-# Open modal - save current focus
-def open_modal(modal_component) do
- FocusManager.push_focus(modal_component)
-end
-
-# Close modal - restore previous focus
-def close_modal() do
- FocusManager.pop_focus()
-end
-```
-
-### Focus Trapping
-
-Keep Tab within a group (e.g., modal dialog):
-
-```elixir
-# Register a focus group
-FocusManager.register_group(:dialog, [:ok_button, :cancel_button, :input])
-
-# Trap focus in the group
-FocusManager.trap_focus(:dialog)
-
-# Release trap when modal closes
-FocusManager.release_focus()
-```
-
-## Building Hierarchies
-
-### Component Registration
-
-Components must be registered to work with the system:
-
-```elixir
-# Start a component under supervision
-{:ok, pid} = ComponentSupervisor.start_component(
- MyApp.Counter,
- %{initial: 0},
- id: :my_counter
-)
-
-# Mount the component
-ComponentServer.mount(pid)
-
-# Register spatial bounds for mouse events
-SpatialIndex.update(:my_counter, pid, %{x: 0, y: 0, width: 20, height: 1})
-```
-
-### Parent-Child Relationships
-
-```elixir
-# Set up hierarchy
-ComponentRegistry.set_parent(:child_id, :parent_id)
-
-# Query hierarchy
-{:ok, parent} = ComponentRegistry.get_parent(:child_id)
-children = ComponentRegistry.get_children(:parent_id)
-```
-
-### Stopping Components
-
-```elixir
-# Stop single component
-ComponentSupervisor.stop_component(:my_counter)
-
-# Stop with cascade (stops children too)
-ComponentSupervisor.stop_component(:parent, cascade: true)
-```
-
-## Fault Tolerance
-
-### Restart Strategies
-
-Components can specify restart behavior:
-
-```elixir
-# Restart on crash (default)
-ComponentSupervisor.start_component(Module, props,
- id: :id, restart: :transient)
-
-# Always restart
-ComponentSupervisor.start_component(Module, props,
- id: :id, restart: :permanent)
-
-# Never restart
-ComponentSupervisor.start_component(Module, props,
- id: :id, restart: :temporary)
-```
-
-### State Recovery
-
-Persist state for recovery after crash:
-
-```elixir
-ComponentSupervisor.start_component(Module, props,
- id: :id,
- restart: :transient,
- recovery: :last_state # Recover previous state
-)
-```
-
-Recovery options:
-- `:reset` - Start fresh (default)
-- `:last_state` - Recover previous state
-- `:last_props` - Restart with same props
-
-### Supervision Introspection
-
-Monitor the component tree:
-
-```elixir
-# Get tree structure
-tree = ComponentSupervisor.get_tree()
-
-# Get component info
-{:ok, info} = ComponentSupervisor.get_component_info(:my_counter)
-# => %{pid: #PID<...>, restart_count: 0, uptime_ms: 12345, ...}
-
-# Count children
-count = ComponentSupervisor.count_children()
-
-# Format tree for display
-IO.puts(ComponentSupervisor.format_tree())
-```
-
-## Best Practices
-
-### 1. Keep Components Focused
-
-Each component should do one thing well:
-
-```elixir
-# Good - focused component
-defmodule MyApp.EmailInput do
- # Only handles email input
-end
-
-# Bad - doing too much
-defmodule MyApp.UserForm do
- # Handles multiple inputs, validation, submission...
-end
-```
-
-### 2. Initialize Fast
-
-Defer expensive operations to `mount/1`:
-
-```elixir
-def init(props) do
- # Fast - just set up state
- {:ok, %{data: nil, loading: true}}
-end
-
-def mount(state) do
- # Slow operations here
- data = fetch_data()
- {:ok, %{state | data: data, loading: false}}
-end
-```
-
-### 3. Handle All Events
-
-Always have a catch-all clause:
-
-```elixir
-def handle_event(%Event.Key{key: :enter}, state) do
- {:ok, handle_submit(state)}
-end
-
-def handle_event(_event, state) do
- # Important! Don't crash on unexpected events
- {:ok, state}
-end
-```
-
-### 4. Clean Up Resources
-
-Use `unmount/1` for cleanup:
-
-```elixir
-def mount(state) do
- timer_ref = :timer.send_interval(1000, self(), :tick)
- {:ok, %{state | timer: timer_ref}}
-end
-
-def unmount(state) do
- if state.timer, do: :timer.cancel(state.timer)
- :ok
-end
-```
-
-### 5. Use Commands for Side Effects
-
-Don't perform side effects directly - return commands:
-
-```elixir
-def handle_event(%Event.Key{key: :enter}, state) do
- # Don't do this:
- # send(parent, {:submitted, state.value})
-
- # Do this:
- {:ok, state, [{:send, parent, {:submitted, state.value}}]}
-end
-```
-
-### 6. Leverage Supervision
-
-Structure your app for fault isolation:
-
-```elixir
-# Critical components
-ComponentSupervisor.start_component(Core, props,
- restart: :permanent)
-
-# User components that can fail
-ComponentSupervisor.start_component(UserWidget, props,
- restart: :transient, recovery: :last_state)
-```
-
-## Essential Widgets Reference
-
-TermUI provides these built-in widgets:
-
-| Widget | Purpose | Key Props |
-|--------|---------|-----------|
-| `Block` | Container with border | `border`, `title`, `padding` |
-| `Label` | Text display | `text`, `align`, `wrap` |
-| `Button` | Clickable action | `label`, `on_click`, `disabled` |
-| `TextInput` | Text entry | `value`, `on_change`, `on_submit` |
-| `List` | Selectable items | `items`, `selected`, `on_select` |
-| `Progress` | Progress indicator | `value`, `mode`, `show_percent` |
-
-See individual widget documentation for full details.
-
-## Next Steps
-
-- Explore the widget source code in `lib/term_ui/widget/`
-- Check integration tests in `test/term_ui/integration/` for examples
-- Read module documentation with `mix docs`
diff --git a/guides/developer/01-architecture-overview.md b/guides/developer/01-architecture-overview.md
deleted file mode 100644
index 8f199c6b..00000000
--- a/guides/developer/01-architecture-overview.md
+++ /dev/null
@@ -1,327 +0,0 @@
-# Architecture Overview
-
-This guide provides a high-level view of TermUI's internal architecture for developers contributing to the framework.
-
-## System Layers
-
-TermUI is organized into distinct layers, each with clear responsibilities:
-
-```mermaid
-graph TB
- subgraph "Application Layer"
- App[User Application]
- Elm[Elm Components]
- end
-
- subgraph "Framework Layer"
- Runtime[Runtime
GenServer]
- MQ[MessageQueue]
- Cmd[Command Executor]
- end
-
- subgraph "Rendering Layer"
- NR[NodeRenderer]
- BM[BufferManager
ETS]
- Diff[Diff Algorithm]
- SB[SequenceBuffer]
- end
-
- subgraph "Terminal Layer"
- Term[Terminal
GenServer]
- IR[InputReader]
- EP[EscapeParser]
- end
-
- subgraph "System"
- TTY[/dev/tty]
- STDIN[stdin]
- STDOUT[stdout]
- end
-
- App --> Elm
- Elm --> Runtime
- Runtime --> MQ
- Runtime --> Cmd
- Runtime --> NR
- NR --> BM
- BM --> Diff
- Diff --> SB
- SB --> Term
- Term --> TTY
- Term --> STDOUT
- IR --> STDIN
- IR --> EP
- EP --> Runtime
-```
-
-## Layer Responsibilities
-
-### Application Layer
-
-**User code** that defines the UI behavior:
-- Component modules using `use TermUI.Elm`
-- State management via init/update/view
-- Event handling via event_to_msg
-
-### Framework Layer
-
-**Core orchestration** managing the application lifecycle:
-
-| Module | Responsibility |
-|--------|----------------|
-| `Runtime` | Event dispatch loop, component lifecycle, render scheduling |
-| `MessageQueue` | FIFO queue for component messages |
-| `Command` | Side effect execution (timers, I/O) |
-
-### Rendering Layer
-
-**Visual output** transforming state to terminal sequences:
-
-| Module | Responsibility |
-|--------|----------------|
-| `NodeRenderer` | Traverses render tree, produces cells |
-| `BufferManager` | Double-buffered ETS tables |
-| `Diff` | Computes minimal update operations |
-| `SequenceBuffer` | Batches ANSI escape sequences |
-
-### Terminal Layer
-
-**Low-level I/O** interfacing with the terminal:
-
-| Module | Responsibility |
-|--------|----------------|
-| `Terminal` | Raw mode, screen control, cursor |
-| `InputReader` | Reads stdin in raw mode |
-| `EscapeParser` | Converts bytes to Event structs |
-
-## Key Design Decisions
-
-### 1. GenServer-Based Runtime
-
-The Runtime is a GenServer that:
-- Serializes event processing
-- Manages component state
-- Schedules rendering at 60 FPS
-- Handles graceful shutdown
-
-```elixir
-# Simplified runtime state
-%Runtime.State{
- root_module: MyApp,
- root_state: %{...},
- components: %{root: %{module: MyApp, state: %{...}}},
- message_queue: %MessageQueue{},
- dirty: true,
- render_interval: 16
-}
-```
-
-### 2. ETS-Based Buffers
-
-Screen buffers use ETS for:
-- Lock-free concurrent reads
-- O(1) cell access
-- Atomic batch updates
-- Memory efficiency
-
-```elixir
-# Cell storage: {{row, col}, cell}
-:ets.insert(buffer.table, {{5, 10}, %Cell{char: "X", fg: :red}})
-```
-
-### 3. Differential Rendering
-
-Only changed cells are sent to the terminal:
-
-```mermaid
-graph LR
- A[Current Buffer] --> D{Diff}
- B[Previous Buffer] --> D
- D --> O[Operations]
- O --> S[SequenceBuffer]
- S --> T[Terminal]
-```
-
-### 4. Message-Based Architecture
-
-All communication uses messages:
-- Events → Messages via `event_to_msg/2`
-- Commands execute async, return messages
-- No direct state mutation
-
-## Module Dependency Graph
-
-```mermaid
-graph TD
- subgraph "Public API"
- TUI[TermUI]
- Runtime[Runtime]
- end
-
- subgraph "Components"
- Elm[Elm]
- Component[Component]
- Container[Container]
- end
-
- subgraph "Events"
- Event[Event]
- EventKey[Event.Key]
- EventMouse[Event.Mouse]
- end
-
- subgraph "Rendering"
- Style[Style]
- Cell[Cell]
- Buffer[Buffer]
- BufferMgr[BufferManager]
- Diff[Diff]
- NodeRenderer[NodeRenderer]
- SeqBuffer[SequenceBuffer]
- end
-
- subgraph "Terminal"
- Terminal[Terminal]
- InputReader[InputReader]
- EscapeParser[EscapeParser]
- ANSI[ANSI]
- end
-
- TUI --> Runtime
- Runtime --> Elm
- Runtime --> BufferMgr
- Runtime --> Terminal
- Runtime --> InputReader
-
- Elm --> Component
- Elm --> Event
-
- InputReader --> EscapeParser
- EscapeParser --> Event
-
- NodeRenderer --> Buffer
- NodeRenderer --> Cell
- NodeRenderer --> Style
-
- BufferMgr --> Buffer
- Buffer --> Cell
-
- Diff --> Buffer
- Diff --> Cell
-
- SeqBuffer --> Style
- SeqBuffer --> ANSI
-
- Terminal --> ANSI
-```
-
-## Process Architecture
-
-At runtime, TermUI spawns these processes:
-
-```mermaid
-graph TB
- subgraph "Supervision Tree"
- App[Application Supervisor]
- Runtime[Runtime GenServer]
- Terminal[Terminal GenServer]
- BufferMgr[BufferManager GenServer]
- InputReader[InputReader Process]
- end
-
- App --> Runtime
- App --> Terminal
- App --> BufferMgr
- Runtime -.->|spawns| InputReader
-
- InputReader -->|{:input, event}| Runtime
- Runtime -->|render| BufferMgr
- Runtime -->|escape sequences| Terminal
-```
-
-## Data Flow
-
-### Input Path
-
-```mermaid
-sequenceDiagram
- participant TTY as Terminal
- participant IR as InputReader
- participant EP as EscapeParser
- participant RT as Runtime
- participant Comp as Component
-
- TTY->>IR: Raw bytes
- IR->>EP: Binary data
- EP->>RT: Event struct
- RT->>Comp: event_to_msg()
- Comp->>RT: {:msg, message}
- RT->>Comp: update()
- Comp->>RT: {new_state, commands}
- RT->>RT: Mark dirty
-```
-
-### Output Path
-
-```mermaid
-sequenceDiagram
- participant RT as Runtime
- participant Comp as Component
- participant NR as NodeRenderer
- participant BM as BufferManager
- participant Diff as Diff
- participant SB as SequenceBuffer
- participant Term as Terminal
-
- RT->>Comp: view()
- Comp->>RT: Render tree
- RT->>NR: Render to buffer
- NR->>BM: Write cells
- RT->>BM: Get buffers
- BM->>RT: Current, Previous
- RT->>Diff: diff()
- Diff->>RT: Operations
- RT->>SB: Build sequences
- SB->>Term: ANSI output
-```
-
-## File Organization
-
-```
-lib/term_ui/
-├── term_ui.ex # Public API
-├── runtime.ex # Core event loop
-├── elm.ex # Elm Architecture macro
-├── event.ex # Event types
-├── command.ex # Command types
-├── message_queue.ex # Message queueing
-│
-├── terminal/
-│ ├── terminal.ex # Terminal GenServer
-│ ├── input_reader.ex # Stdin reader
-│ └── escape_parser.ex # Sequence parser
-│
-├── renderer/
-│ ├── style.ex # Style struct
-│ ├── cell.ex # Cell struct
-│ ├── buffer.ex # Buffer operations
-│ ├── buffer_manager.ex # Double buffering
-│ ├── diff.ex # Diff algorithm
-│ ├── sequence_buffer.ex # ANSI batching
-│ └── node_renderer.ex # Tree → cells
-│
-├── layout/
-│ ├── constraint.ex # Size constraints
-│ └── solver.ex # Constraint solver
-│
-└── widgets/
- ├── gauge.ex # Gauge widget
- ├── sparkline.ex # Sparkline widget
- └── table.ex # Table widget
-```
-
-## Next Steps
-
-- [Runtime Internals](02-runtime-internals.md) - Deep dive into the event loop
-- [Rendering Pipeline](03-rendering-pipeline.md) - How frames are produced
-- [Event System](04-event-system.md) - Input handling details
diff --git a/guides/developer/02-runtime-internals.md b/guides/developer/02-runtime-internals.md
deleted file mode 100644
index 817e9a70..00000000
--- a/guides/developer/02-runtime-internals.md
+++ /dev/null
@@ -1,408 +0,0 @@
-# Runtime Internals
-
-The Runtime (`TermUI.Runtime`) is the central orchestrator of a TermUI application. This guide explains its internal workings.
-
-## Overview
-
-The Runtime is a GenServer that:
-1. Manages component state
-2. Dispatches events to components
-3. Processes messages through the update cycle
-4. Executes commands
-5. Schedules and performs rendering
-
-## State Structure
-
-```elixir
-%TermUI.Runtime.State{
- # Component configuration
- root_module: MyApp.Counter, # Root component module
- root_state: %{count: 0}, # Root component state
-
- # Component registry
- components: %{
- root: %{module: MyApp.Counter, state: %{count: 0}}
- },
-
- # Message processing
- message_queue: %MessageQueue{}, # Pending messages
- pending_commands: %{}, # Executing commands
-
- # Rendering
- dirty: false, # Needs re-render?
- render_interval: 16, # ~60 FPS
- buffer_manager: #PID<...>, # BufferManager process
- dimensions: {80, 24}, # {cols, rows}
-
- # Terminal
- terminal_started: true, # Terminal available?
- input_reader: #PID<...>, # InputReader process
-
- # Lifecycle
- focused_component: :root, # Currently focused
- shutting_down: false # Shutdown in progress?
-}
-```
-
-## Lifecycle
-
-```mermaid
-stateDiagram-v2
- [*] --> Initializing: start_link/1
- Initializing --> Running: init complete
- Running --> Running: events/messages
- Running --> ShuttingDown: shutdown/1
- ShuttingDown --> [*]: terminate/2
-```
-
-### Initialization
-
-```elixir
-def init(opts) do
- # 1. Trap exits for cleanup
- Process.flag(:trap_exit, true)
-
- # 2. Initialize terminal
- {terminal_started, buffer_manager, dimensions} = initialize_terminal()
-
- # 3. Initialize root component
- root_state = root_module.init(opts)
-
- # 4. Start input reader
- {:ok, reader} = InputReader.start_link(target: self())
-
- # 5. Schedule first render
- schedule_render(render_interval)
-
- {:ok, state}
-end
-```
-
-### Main Loop
-
-The Runtime handles these message types:
-
-```mermaid
-graph TD
- subgraph "GenServer Callbacks"
- CI[handle_cast :event] --> DE[dispatch_event]
- CM[handle_cast :message] --> EM[enqueue_message]
- CR[handle_cast :shutdown] --> IS[initiate_shutdown]
- IR[handle_info :render] --> PR[process_render_tick]
- II[handle_info :input] --> DE
- end
-
- DE --> ETM[event_to_msg]
- ETM --> EM
- EM --> MQ[MessageQueue]
- MQ --> PM[process_messages]
- PM --> UP[component.update]
- UP --> EC[execute_commands]
- UP --> MD[mark_dirty]
-
- PR --> PM
- PR --> DR[do_render]
- PR --> SR[schedule_render]
-```
-
-## Event Dispatch
-
-Events are routed based on type:
-
-```elixir
-defp dispatch_event(%Event.Key{} = event, state) do
- # Keyboard → focused component
- dispatch_to_component(state.focused_component, event, state)
-end
-
-defp dispatch_event(%Event.Mouse{} = event, state) do
- # Mouse → component at position (future: spatial index)
- dispatch_to_component(:root, event, state)
-end
-
-defp dispatch_event(%Event.Resize{} = event, state) do
- # Resize → broadcast to all
- broadcast_event(event, state)
-end
-```
-
-### Component Dispatch
-
-```elixir
-defp dispatch_to_component(component_id, event, state) do
- %{module: module, state: component_state} = state.components[component_id]
-
- case module.event_to_msg(event, component_state) do
- {:msg, message} ->
- enqueue_message(component_id, message, state)
-
- :ignore ->
- state
-
- :propagate ->
- # Would bubble to parent
- state
- end
-end
-```
-
-## Message Processing
-
-Messages are processed in FIFO order:
-
-```mermaid
-sequenceDiagram
- participant Q as MessageQueue
- participant RT as Runtime
- participant C as Component
-
- RT->>Q: flush()
- Q->>RT: [messages]
-
- loop For each message
- RT->>C: update(msg, state)
- C->>RT: {new_state, commands}
- RT->>RT: Update component state
- RT->>RT: Mark dirty if changed
- RT->>RT: Collect commands
- end
-
- RT->>RT: execute_commands(all_commands)
-```
-
-```elixir
-defp process_messages(state) do
- {messages, queue} = MessageQueue.flush(state.message_queue)
-
- {state, commands} =
- Enum.reduce(messages, {state, []}, fn {component_id, msg}, {acc, cmds} ->
- {new_state, new_cmds} = process_message(component_id, msg, acc)
- {new_state, cmds ++ new_cmds}
- end)
-
- execute_commands(commands, state)
-end
-```
-
-## Command Execution
-
-Commands are side effects returned from `update/2`:
-
-```elixir
-defp execute_commands(commands, state) do
- # Check for quit command
- if has_quit_command?(commands) do
- GenServer.cast(self(), :shutdown)
- %{state | shutting_down: true}
- else
- # Track pending commands
- pending = Enum.reduce(commands, state.pending_commands, fn cmd, acc ->
- command_id = make_ref()
- Map.put(acc, command_id, cmd)
- end)
-
- %{state | pending_commands: pending}
- end
-end
-```
-
-### Timer Commands
-
-Timer commands use `Process.send_after/3`:
-
-```elixir
-# When timer fires, result delivered as message
-def handle_info({:command_result, component_id, cmd_id, result}, state) do
- state = handle_command_result(component_id, cmd_id, result, state)
- {:noreply, state}
-end
-```
-
-## Render Cycle
-
-Rendering is scheduled at a fixed interval (default 16ms ≈ 60 FPS):
-
-```elixir
-defp process_render_tick(state) do
- # 1. Process pending messages
- state = process_messages(state)
-
- # 2. Render if dirty
- state = if state.dirty and not state.shutting_down do
- do_render(state)
- else
- state
- end
-
- # 3. Schedule next tick
- unless state.shutting_down do
- schedule_render(state.render_interval)
- end
-
- state
-end
-
-defp schedule_render(interval) do
- Process.send_after(self(), :render, interval)
-end
-```
-
-### Render Flow
-
-```elixir
-defp do_render(state) do
- # 1. Get render tree from component
- %{module: module, state: comp_state} = state.components[:root]
- render_tree = module.view(comp_state)
-
- # 2. Clear current buffer
- BufferManager.clear_current(state.buffer_manager)
-
- # 3. Render tree to buffer
- NodeRenderer.render_to_buffer(render_tree, state.buffer_manager)
-
- # 4. Diff against previous
- current = BufferManager.get_current_buffer(state.buffer_manager)
- previous = BufferManager.get_previous_buffer(state.buffer_manager)
- operations = Diff.diff(current, previous)
-
- # 5. Output to terminal
- render_operations(operations)
-
- # 6. Swap buffers
- BufferManager.swap_buffers(state.buffer_manager)
-
- %{state | dirty: false}
-end
-```
-
-## Shutdown
-
-Graceful shutdown preserves terminal state:
-
-```mermaid
-sequenceDiagram
- participant App as Application
- participant RT as Runtime
- participant IR as InputReader
- participant Term as Terminal
-
- App->>RT: shutdown()
- RT->>RT: shutting_down = true
- RT->>RT: Stop render scheduling
- RT->>IR: stop()
- RT->>RT: Clear components
- RT->>RT: send(:stop_runtime)
- RT->>Term: restore()
- Term->>Term: Disable raw mode
- Term->>Term: Leave alt screen
- Term->>Term: Show cursor
- RT->>App: :normal exit
-```
-
-```elixir
-def terminate(_reason, state) do
- # Stop input reader
- if state.input_reader do
- InputReader.stop(state.input_reader)
- end
-
- # Restore terminal
- if state.terminal_started do
- Terminal.restore()
- end
-
- :ok
-end
-```
-
-## Error Handling
-
-The Runtime protects against component crashes:
-
-```elixir
-# In event_to_msg
-try do
- module.event_to_msg(event, component_state)
-rescue
- error ->
- Logger.error("Component crashed in event_to_msg: #{inspect(error)}")
- state # Return unchanged
-end
-
-# In update
-try do
- module.update(message, component_state)
-rescue
- error ->
- Logger.error("Component crashed in update: #{inspect(error)}")
- {state, []} # Return unchanged, no commands
-end
-
-# In view
-try do
- module.view(component_state)
-rescue
- error ->
- Logger.error("Component crashed in view: #{inspect(error)}")
- {:text, "[Render Error]"} # Fallback render
-end
-```
-
-## Performance Considerations
-
-### Message Batching
-
-Multiple events arriving between render ticks are batched:
-
-```
-Event 1 → Queue
-Event 2 → Queue
-Event 3 → Queue
-Render tick → Process all 3 → Single render
-```
-
-### Dirty Tracking
-
-Components are only re-rendered when state changes:
-
-```elixir
-dirty = state.dirty or new_component_state != component_state
-```
-
-### Buffer Swapping
-
-Double buffering avoids copying:
-
-```elixir
-# O(1) pointer swap, not O(rows*cols) copy
-def swap_buffers(state) do
- %{state | current: state.previous, previous: state.current}
-end
-```
-
-## Testing the Runtime
-
-```elixir
-# Start without terminal for testing
-{:ok, runtime} = Runtime.start_link(
- root: TestComponent,
- skip_terminal: true
-)
-
-# Send events
-Runtime.send_event(runtime, Event.key(:enter))
-
-# Wait for processing
-Runtime.sync(runtime)
-
-# Check state
-state = Runtime.get_state(runtime)
-assert state.root_state.submitted == true
-```
-
-## Next Steps
-
-- [Rendering Pipeline](03-rendering-pipeline.md) - Detailed render flow
-- [Event System](04-event-system.md) - Input handling
-- [Buffer Management](05-buffer-management.md) - ETS buffers
diff --git a/guides/developer/03-rendering-pipeline.md b/guides/developer/03-rendering-pipeline.md
deleted file mode 100644
index eb002e72..00000000
--- a/guides/developer/03-rendering-pipeline.md
+++ /dev/null
@@ -1,440 +0,0 @@
-# Rendering Pipeline
-
-This guide explains how TermUI transforms component state into terminal output.
-
-## Pipeline Overview
-
-```mermaid
-graph LR
- subgraph "1. View"
- S[State] --> V[view/1]
- V --> RT[Render Tree]
- end
-
- subgraph "2. Rasterize"
- RT --> NR[NodeRenderer]
- NR --> CB[Current Buffer]
- end
-
- subgraph "3. Diff"
- CB --> D{Diff}
- PB[Previous Buffer] --> D
- D --> OPS[Operations]
- end
-
- subgraph "4. Serialize"
- OPS --> SB[SequenceBuffer]
- SB --> ANSI[ANSI Sequences]
- end
-
- subgraph "5. Output"
- ANSI --> IO[IO.write]
- IO --> T[Terminal]
- end
-```
-
-## Stage 1: View
-
-The component's `view/1` function produces a render tree:
-
-```elixir
-def view(state) do
- stack(:vertical, [
- text("Counter", Style.new(fg: :cyan, attrs: [:bold])),
- text("Value: #{state.count}")
- ])
-end
-```
-
-### Render Tree Nodes
-
-The tree consists of tuples describing content:
-
-```elixir
-# Text node
-{:text, "Hello", %Style{}}
-
-# Stack (layout container)
-{:stack, :vertical, [child1, child2, ...]}
-{:stack, :horizontal, [child1, child2, ...]}
-
-# Styled wrapper
-{:styled, %Style{}, child}
-
-# Fragment (multiple nodes)
-{:fragment, [child1, child2, ...]}
-
-# Raw cells
-{:cells, [%Cell{}, %Cell{}, ...]}
-
-# Viewport (scrollable clipped region)
-%{
- type: :viewport,
- content: child_node, # Content to render
- scroll_x: 0, # Horizontal scroll offset
- scroll_y: 0, # Vertical scroll offset
- width: 40, # Viewport width
- height: 20 # Viewport height
-}
-```
-
-## Stage 2: Rasterize
-
-`NodeRenderer` traverses the tree and writes cells to the buffer:
-
-```mermaid
-graph TD
- RT[Render Tree] --> NR[NodeRenderer]
-
- subgraph "NodeRenderer.render_to_buffer/2"
- NR --> Walk[Walk Tree]
- Walk --> Pos[Track Position]
- Pos --> Style[Apply Styles]
- Style --> Write[Write Cells]
- end
-
- Write --> BM[BufferManager]
- BM --> ETS[(ETS Table)]
-```
-
-### Node Rendering
-
-```elixir
-defp render_node({:text, content, style}, row, col, buffer) do
- # Convert each grapheme to a styled cell
- cells = content
- |> String.graphemes()
- |> Enum.with_index()
- |> Enum.map(fn {char, i} ->
- {row, col + i, Style.to_cell(style, char)}
- end)
-
- BufferManager.set_cells(buffer, cells)
- {row, col + String.length(content)}
-end
-
-defp render_node({:stack, :vertical, children}, row, col, buffer) do
- Enum.reduce(children, {row, col}, fn child, {r, c} ->
- {new_row, _} = render_node(child, r, c, buffer)
- {new_row + 1, col} # Move to next row
- end)
-end
-
-defp render_node({:stack, :horizontal, children}, row, col, buffer) do
- Enum.reduce(children, {row, col}, fn child, {r, c} ->
- {_, new_col} = render_node(child, r, c, buffer)
- {row, new_col} # Move to next column
- end)
-end
-```
-
-### Viewport Rendering
-
-Viewport nodes clip content to a visible region with scroll offsets:
-
-```elixir
-defp render_viewport(content, buffer, dest_row, dest_col, style,
- scroll_x, scroll_y, vp_width, vp_height) do
- # 1. Create temporary buffer for full content
- {:ok, temp_buffer} = Buffer.new(content_height, content_width)
-
- # 2. Render content to temporary buffer
- render_node(content, temp_buffer, 1, 1, style)
-
- # 3. Copy visible region to destination buffer
- for dy <- 0..(vp_height - 1), dx <- 0..(vp_width - 1) do
- src_row = scroll_y + 1 + dy
- src_col = scroll_x + 1 + dx
- cell = Buffer.get_cell(temp_buffer, src_row, src_col)
- Buffer.set_cell(buffer, dest_row + dy, dest_col + dx, cell)
- end
-
- # 4. Clean up temporary buffer
- Buffer.destroy(temp_buffer)
-
- {vp_width, vp_height}
-end
-```
-
-This approach:
-- Renders full content to an off-screen buffer
-- Copies only the visible portion based on scroll offsets
-- Clips content automatically to viewport dimensions
-
-## Stage 3: Diff
-
-The diff algorithm compares current and previous buffers:
-
-```mermaid
-graph TB
- subgraph "Diff Algorithm"
- CB[Current Buffer] --> GR[Get Rows]
- PB[Previous Buffer] --> GR
- GR --> CR[Compare Rows]
- CR --> FS[Find Spans]
- FS --> MS[Merge Spans]
- MS --> GO[Generate Ops]
- end
-
- GO --> OPS[Operations List]
-```
-
-### Diff Process
-
-```elixir
-def diff(current, previous) do
- {rows, cols} = Buffer.dimensions(current)
-
- 1..rows
- |> Enum.flat_map(fn row ->
- diff_row(current, previous, row, cols)
- end)
- |> optimize_operations()
-end
-```
-
-### Finding Changed Spans
-
-```elixir
-def find_changed_spans(current_cells, previous_cells, row) do
- current_cells
- |> Enum.zip(previous_cells)
- |> Enum.reduce({[], nil}, fn {{col, curr}, {_, prev}}, acc ->
- if Cell.equal?(curr, prev) do
- close_span(acc)
- else
- extend_span(acc, col, curr, row)
- end
- end)
- |> finalize()
-end
-```
-
-### Span Merging
-
-Small gaps between spans are merged to reduce cursor movements:
-
-```
-Before: [CHANGED]...[CHANGED] (3 char gap)
-After: [CHANGED...CHANGED] (merged)
-```
-
-```elixir
-@merge_gap_threshold 3
-
-defp merge_spans(spans) do
- Enum.reduce(spans, [], fn span, acc ->
- case acc do
- [prev | rest] when span.start_col - prev.end_col <= @merge_gap_threshold ->
- [merge(prev, span) | rest]
- _ ->
- [span | acc]
- end
- end)
-end
-```
-
-### Operation Types
-
-```elixir
-@type operation ::
- {:move, row, col} # Move cursor
- | {:style, Style.t()} # Set SGR attributes
- | {:text, String.t()} # Output text
- | :reset # Reset all attributes
-```
-
-## Stage 4: Serialize
-
-`SequenceBuffer` converts operations to ANSI escape sequences:
-
-```mermaid
-graph LR
- subgraph "SequenceBuffer"
- OPS[Operations] --> P[Process]
- P --> M[Move: ESC row;col H]
- P --> S[Style: ESC params m]
- P --> T[Text: raw chars]
- M --> B[Buffer]
- S --> B
- T --> B
- B --> F[Flush]
- end
-
- F --> IO[iodata]
-```
-
-### Style Delta Encoding
-
-Only changed style attributes are emitted:
-
-```elixir
-defp style_to_sgr_params(style, last_style) do
- params = []
-
- # Only emit fg if changed
- params = if style.fg != last_style.fg do
- [color_to_sgr(:fg, style.fg) | params]
- else
- params
- end
-
- # Only emit bg if changed
- params = if style.bg != last_style.bg do
- [color_to_sgr(:bg, style.bg) | params]
- else
- params
- end
-
- # Handle attribute changes
- # ...
-
- params
-end
-```
-
-### SGR Sequence Building
-
-```elixir
-defp build_sgr_sequence(params) do
- # ESC[param1;param2;...m
- ["\e[", Enum.intersperse(params, ";"), "m"]
-end
-
-# Examples:
-# Red foreground: \e[31m
-# Bold + blue: \e[1;34m
-# Reset: \e[0m
-```
-
-## Stage 5: Output
-
-The final iodata is written to the terminal:
-
-```elixir
-defp render_operations(operations) do
- seq_buffer = SequenceBuffer.new()
-
- seq_buffer =
- Enum.reduce(operations, seq_buffer, fn op, buf ->
- apply_operation(op, buf)
- end)
-
- # Reset at end to avoid style bleeding
- seq_buffer = SequenceBuffer.append!(seq_buffer, "\e[0m")
-
- {output, _} = SequenceBuffer.flush(seq_buffer)
- IO.write(output)
-end
-```
-
-## Optimization Techniques
-
-### 1. Cursor Movement Optimization
-
-Choose shortest cursor movement sequence:
-
-```elixir
-# Absolute: \e[row;colH (variable length)
-# Relative: \e[nA/B/C/D (if small delta)
-
-defp optimal_move(from_row, from_col, to_row, to_col) do
- # Calculate costs and choose cheapest
-end
-```
-
-### 2. Batch Cell Writes
-
-ETS batch insert for multiple cells:
-
-```elixir
-def set_cells(buffer, cells) do
- entries = Enum.map(cells, fn {row, col, cell} ->
- {{row, col}, cell}
- end)
- :ets.insert(buffer.table, entries)
-end
-```
-
-### 3. Style Deduplication
-
-Adjacent cells with same style share one SGR sequence:
-
-```elixir
-# Instead of:
-# \e[31mH\e[31me\e[31ml\e[31ml\e[31mo
-# Produces:
-# \e[31mHello
-```
-
-### 4. Frame Rate Limiting
-
-Rendering capped at 60 FPS (16ms intervals):
-
-```elixir
-# Even if 100 events arrive, max 60 renders/sec
-schedule_render(16) # milliseconds
-```
-
-## Performance Metrics
-
-### Typical Frame Budget
-
-For 60 FPS, each frame has ~16ms:
-
-| Stage | Typical Time |
-|-------|-------------|
-| View | 0.1-1ms |
-| Rasterize | 0.5-2ms |
-| Diff | 0.2-1ms |
-| Serialize | 0.1-0.5ms |
-| Output | 0.5-2ms |
-| **Total** | **1.4-6.5ms** |
-
-### Scaling Factors
-
-| Factor | Impact |
-|--------|--------|
-| Screen size | O(rows × cols) for full diff |
-| Changed cells | O(n) where n = changed |
-| Style changes | More SGR sequences |
-| Unicode width | Display width calculation |
-
-## Debugging Rendering
-
-### Inspect Render Tree
-
-```elixir
-def view(state) do
- tree = build_tree(state)
- IO.inspect(tree, label: "Render Tree")
- tree
-end
-```
-
-### Inspect Operations
-
-```elixir
-# In Runtime.do_render/1
-operations = Diff.diff(current, previous)
-IO.inspect(operations, label: "Diff Operations")
-```
-
-### Buffer Contents
-
-```elixir
-buffer = BufferManager.get_current_buffer()
-{rows, cols} = Buffer.dimensions(buffer)
-
-for row <- 1..rows do
- cells = Buffer.get_row(buffer, row)
- line = Enum.map_join(cells, & &1.char)
- IO.puts(line)
-end
-```
-
-## Next Steps
-
-- [Buffer Management](05-buffer-management.md) - ETS buffer details
-- [Terminal Layer](06-terminal-layer.md) - ANSI sequence handling
-- [Event System](04-event-system.md) - Input processing
diff --git a/guides/developer/04-event-system.md b/guides/developer/04-event-system.md
deleted file mode 100644
index 137927b4..00000000
--- a/guides/developer/04-event-system.md
+++ /dev/null
@@ -1,406 +0,0 @@
-# Event System
-
-This guide explains how TermUI captures, parses, and dispatches terminal input events.
-
-## Event Flow Overview
-
-```mermaid
-graph LR
- subgraph "Terminal"
- KB[Keyboard] --> TTY[/dev/tty]
- MS[Mouse] --> TTY
- end
-
- subgraph "Input Layer"
- TTY --> IR[InputReader]
- IR --> EP[EscapeParser]
- EP --> EV[Event Structs]
- end
-
- subgraph "Dispatch"
- EV --> RT[Runtime]
- RT --> R[Route]
- R --> C[Component]
- end
-
- subgraph "Processing"
- C --> ETM[event_to_msg]
- ETM --> MSG[Message]
- MSG --> UPD[update]
- end
-```
-
-## Input Reader
-
-`TermUI.Terminal.InputReader` reads raw bytes from stdin:
-
-```elixir
-defmodule TermUI.Terminal.InputReader do
- use GenServer
-
- def init(target) do
- # Spawn reader process that uses IO.getn
- parent = self()
- reader_pid = spawn_link(fn -> io_reader_loop(parent) end)
- {:ok, %{target: target, reader: reader_pid}}
- end
-
- defp io_reader_loop(parent) do
- case IO.getn("", 1) do
- :eof ->
- send(parent, {:io_data, :eof})
-
- data when is_binary(data) ->
- send(parent, {:io_data, data})
- io_reader_loop(parent)
- end
- end
-
- def handle_info({:io_data, data}, state) do
- # Buffer data, parse sequences, emit events
- # ...
- end
-end
-```
-
-### Why IO.getn?
-
-- Integrates with OTP's terminal handling
-- Works in raw mode
-- Cross-platform (Unix/Windows)
-- Non-blocking when data available
-
-## Escape Parser
-
-`TermUI.Terminal.EscapeParser` converts bytes to events:
-
-```mermaid
-graph TD
- subgraph "Parser State Machine"
- B[Bytes] --> C{First Byte?}
- C -->|ESC 0x1B| E[Escape Sequence]
- C -->|0x00-0x1F| CTRL[Control Char]
- C -->|0x20-0x7E| PRINT[Printable]
- C -->|0x80+| UTF8[UTF-8]
-
- E --> E2{Second Byte?}
- E2 -->|[| CSI[CSI Sequence]
- E2 -->|O| SS3[SS3 Sequence]
- E2 -->|other| ALT[Alt+Key]
-
- CSI --> CSIP[Parse Params]
- CSIP --> CSIF{Final Byte?}
- CSIF -->|A-D| ARROW[Arrow Keys]
- CSIF -->|~| SPECIAL[Special Keys]
- CSIF -->|M/m| MOUSE[Mouse Event]
- end
-```
-
-### Sequence Types
-
-| Prefix | Name | Example | Event |
-|--------|------|---------|-------|
-| `ESC[A` | CSI | Arrow up | `%Event.Key{key: :up}` |
-| `ESC[<0;10;5M` | SGR Mouse | Click at 10,5 | `%Event.Mouse{...}` |
-| `ESCOP` | SS3 | F1 | `%Event.Key{key: :f1}` |
-| `ESCa` | Alt | Alt+a | `%Event.Key{key: "a", modifiers: [:alt]}` |
-
-### Parsing Implementation
-
-```elixir
-def parse(<<0x1B, rest::binary>>) do
- parse_escape_sequence(rest)
-end
-
-def parse(<>) when char in 32..126 do
- # Printable ASCII
- event = Event.key(<>)
- {[event], rest}
-end
-
-defp parse_escape_sequence(<<"[", rest::binary>>) do
- parse_csi_sequence(rest)
-end
-
-defp parse_escape_sequence(<<"O", rest::binary>>) do
- parse_ss3_sequence(rest)
-end
-```
-
-### CSI Sequence Parsing
-
-```elixir
-# Arrow keys
-defp parse_csi_sequence(<<"A", rest::binary>>), do: {:ok, Event.key(:up), rest}
-defp parse_csi_sequence(<<"B", rest::binary>>), do: {:ok, Event.key(:down), rest}
-defp parse_csi_sequence(<<"C", rest::binary>>), do: {:ok, Event.key(:right), rest}
-defp parse_csi_sequence(<<"D", rest::binary>>), do: {:ok, Event.key(:left), rest}
-
-# Special keys with tilde
-defp parse_csi_sequence(<<"1~", rest::binary>>), do: {:ok, Event.key(:home), rest}
-defp parse_csi_sequence(<<"3~", rest::binary>>), do: {:ok, Event.key(:delete), rest}
-defp parse_csi_sequence(<<"5~", rest::binary>>), do: {:ok, Event.key(:page_up), rest}
-defp parse_csi_sequence(<<"6~", rest::binary>>), do: {:ok, Event.key(:page_down), rest}
-
-# Mouse (SGR format)
-defp parse_csi_sequence(<<"<", rest::binary>>) do
- parse_sgr_mouse(rest)
-end
-```
-
-### Mouse Event Parsing
-
-SGR mouse format: `ESC[ {:scroll_up, nil}
- is_scroll and button_code == 1 -> {:scroll_down, nil}
- is_motion -> {:drag, decode_button(button_code)}
- terminator == :release -> {:release, :left}
- true -> {:press, decode_button(button_code)}
- end
-
- # Extract modifiers from bits 2-4
- modifiers = []
- modifiers = if (cb &&& 4) != 0, do: [:shift | modifiers], else: modifiers
- modifiers = if (cb &&& 8) != 0, do: [:alt | modifiers], else: modifiers
- modifiers = if (cb &&& 16) != 0, do: [:ctrl | modifiers], else: modifiers
-
- Event.mouse(action, button, cx - 1, cy - 1, modifiers: modifiers)
-end
-```
-
-## Escape Sequence Timeout
-
-Lone ESC key vs ESC sequence start:
-
-```mermaid
-sequenceDiagram
- participant U as User
- participant IR as InputReader
- participant EP as Parser
- participant T as Timer
-
- U->>IR: ESC key
- IR->>EP: 0x1B
- EP->>T: Start 50ms timer
- Note over EP: Buffer: ESC
-
- alt More bytes arrive
- U->>IR: [ key
- IR->>EP: 0x5B
- EP->>T: Cancel timer
- EP->>EP: Parse CSI sequence
- else Timeout
- T->>EP: Timeout!
- EP->>EP: Emit Event.key(:escape)
- end
-```
-
-```elixir
-@escape_timeout 50 # milliseconds
-
-def handle_info({:io_data, data}, state) do
- state = cancel_timer(state)
- buffer = state.buffer <> data
- {events, remaining} = EscapeParser.parse(buffer)
-
- # Send complete events
- Enum.each(events, &send(state.target, {:input, &1}))
-
- # Set timeout if partial escape sequence
- state = if EscapeParser.partial_sequence?(remaining) do
- ref = Process.send_after(self(), :escape_timeout, @escape_timeout)
- %{state | buffer: remaining, timer_ref: ref}
- else
- %{state | buffer: remaining}
- end
-
- {:noreply, state}
-end
-
-def handle_info(:escape_timeout, state) do
- # Emit buffered bytes as individual events
- # ...
-end
-```
-
-## Event Structs
-
-### Key Event
-
-```elixir
-defmodule TermUI.Event.Key do
- defstruct [
- :key, # Atom (:enter, :up) or String ("a")
- :char, # Character or nil
- :modifiers, # [:ctrl, :alt, :shift]
- :timestamp # System.monotonic_time(:millisecond)
- ]
-end
-```
-
-### Mouse Event
-
-```elixir
-defmodule TermUI.Event.Mouse do
- defstruct [
- :action, # :press, :release, :click, :drag, :scroll_up, :scroll_down
- :button, # :left, :middle, :right, nil
- :x, :y, # 0-indexed coordinates
- :modifiers,
- :timestamp
- ]
-end
-```
-
-### Other Events
-
-```elixir
-# Window resize
-defmodule TermUI.Event.Resize do
- defstruct [:width, :height, :timestamp]
-end
-
-# Terminal focus
-defmodule TermUI.Event.Focus do
- defstruct [:action, :timestamp] # :gained or :lost
-end
-
-# Bracketed paste
-defmodule TermUI.Event.Paste do
- defstruct [:content, :timestamp]
-end
-```
-
-## Event Dispatch
-
-The Runtime routes events to components:
-
-```elixir
-defp dispatch_event(%Event.Key{} = event, state) do
- # Keyboard → focused component
- dispatch_to_component(state.focused_component, event, state)
-end
-
-defp dispatch_event(%Event.Mouse{x: x, y: y} = event, state) do
- # Mouse → component at position
- # Future: use spatial index
- dispatch_to_component(:root, event, state)
-end
-
-defp dispatch_event(%Event.Resize{} = event, state) do
- # Resize → broadcast to all
- broadcast_event(event, state)
-end
-
-defp dispatch_event(%Event.Focus{} = event, state) do
- # Focus → broadcast to all
- broadcast_event(event, state)
-end
-```
-
-## Event to Message
-
-Components convert events to messages:
-
-```elixir
-defp dispatch_to_component(component_id, event, state) do
- %{module: module, state: comp_state} = state.components[component_id]
-
- case module.event_to_msg(event, comp_state) do
- {:msg, message} ->
- # Enqueue for processing
- enqueue_message(component_id, message, state)
-
- :ignore ->
- # Discard event
- state
-
- :propagate ->
- # Bubble to parent (future)
- state
- end
-end
-```
-
-## Enabling Terminal Features
-
-### Mouse Tracking
-
-```elixir
-# Enable SGR mouse tracking
-Terminal.enable_mouse_tracking(:click)
-
-# Sequences sent:
-# \e[?1000h - Enable X11 mouse
-# \e[?1006h - Enable SGR format
-```
-
-### Focus Events
-
-```elixir
-# Enable focus reporting
-Terminal.enable_focus_events()
-
-# Sequence: \e[?1004h
-# Terminal sends: \e[I (focus) or \e[O (blur)
-```
-
-### Bracketed Paste
-
-```elixir
-# Enable bracketed paste
-Terminal.enable_bracketed_paste()
-
-# Sequence: \e[?2004h
-# Pasted text wrapped: \e[200~ ... \e[201~
-```
-
-## Testing Events
-
-### Create Events Programmatically
-
-```elixir
-# Key events
-event = Event.key(:enter)
-event = Event.key("a", modifiers: [:ctrl])
-
-# Mouse events
-event = Event.mouse(:click, :left, 10, 5)
-event = Event.mouse(:scroll_up, nil, 10, 5)
-
-# Other
-event = Event.Resize.new(120, 40)
-event = Event.Focus.new(:gained)
-```
-
-### Test Event Handling
-
-```elixir
-defmodule MyComponentTest do
- use ExUnit.Case
-
- test "up arrow increments" do
- state = %{count: 0}
- event = Event.key(:up)
-
- assert {:msg, :increment} = MyComponent.event_to_msg(event, state)
-
- {new_state, []} = MyComponent.update(:increment, state)
- assert new_state.count == 1
- end
-end
-```
-
-## Next Steps
-
-- [Terminal Layer](06-terminal-layer.md) - Raw mode and escape sequences
-- [Runtime Internals](02-runtime-internals.md) - Event dispatch
-- [Buffer Management](05-buffer-management.md) - Screen buffers
diff --git a/guides/developer/05-buffer-management.md b/guides/developer/05-buffer-management.md
deleted file mode 100644
index 5c7203cc..00000000
--- a/guides/developer/05-buffer-management.md
+++ /dev/null
@@ -1,423 +0,0 @@
-# Buffer Management
-
-This guide explains TermUI's screen buffer system using ETS for efficient cell storage and double buffering for flicker-free updates.
-
-## Architecture
-
-```mermaid
-graph TB
- subgraph "BufferManager GenServer"
- BM[BufferManager]
- BM --> PT[(persistent_term)]
- end
-
- subgraph "Buffer References"
- PT --> CB[Current Buffer]
- PT --> PB[Previous Buffer]
- PT --> DF[Dirty Flag]
- end
-
- subgraph "ETS Storage"
- CB --> ETSC[(ETS Table
Current)]
- PB --> ETSP[(ETS Table
Previous)]
- end
-
- subgraph "Atomic"
- DF --> AT[atomics ref]
- end
-
- NR[NodeRenderer] --> CB
- Diff[Diff] --> CB
- Diff --> PB
-```
-
-## Buffer Structure
-
-A Buffer wraps an ETS table:
-
-```elixir
-defmodule TermUI.Renderer.Buffer do
- defstruct [
- :table, # ETS table reference
- :rows, # Number of rows
- :cols # Number of columns
- ]
-
- @type t :: %__MODULE__{
- table: :ets.tid(),
- rows: pos_integer(),
- cols: pos_integer()
- }
-end
-```
-
-### Cell Storage
-
-Cells are stored as `{{row, col}, cell}` tuples:
-
-```elixir
-# Cell at row 5, column 10
-:ets.insert(buffer.table, {{5, 10}, %Cell{char: "X", fg: :red}})
-
-# Lookup
-[{_, cell}] = :ets.lookup(buffer.table, {5, 10})
-```
-
-### ETS Configuration
-
-```elixir
-def new(rows, cols) do
- table = :ets.new(:screen_buffer, [
- :set, # Key-value storage
- :public, # Any process can read/write
- read_concurrency: true, # Optimized for concurrent reads
- write_concurrency: true # Optimized for concurrent writes
- ])
-
- # Initialize with empty cells
- buffer = %__MODULE__{table: table, rows: rows, cols: cols}
- clear(buffer)
-
- {:ok, buffer}
-end
-```
-
-## Double Buffering
-
-Two buffers swap roles each frame:
-
-```mermaid
-sequenceDiagram
- participant NR as NodeRenderer
- participant C as Current
- participant P as Previous
- participant D as Diff
-
- Note over C,P: Frame N
-
- NR->>C: Write cells
- D->>C: Read current
- D->>P: Read previous
- D->>D: Compute diff
-
- Note over C,P: Swap
-
- C->>P: Becomes previous
- P->>C: Becomes current
-
- Note over C,P: Frame N+1
-
- NR->>C: Write cells (was P)
-```
-
-### BufferManager Implementation
-
-```elixir
-defmodule TermUI.Renderer.BufferManager do
- use GenServer
-
- def init(opts) do
- rows = Keyword.fetch!(opts, :rows)
- cols = Keyword.fetch!(opts, :cols)
-
- {:ok, current} = Buffer.new(rows, cols)
- {:ok, previous} = Buffer.new(rows, cols)
-
- # Dirty flag using atomics for lock-free access
- dirty = :atomics.new(1, signed: false)
-
- # Store in persistent_term for direct access
- :persistent_term.put({__MODULE__, :current}, current)
- :persistent_term.put({__MODULE__, :previous}, previous)
- :persistent_term.put({__MODULE__, :dirty}, dirty)
-
- {:ok, %{current: current, previous: previous, dirty: dirty}}
- end
-end
-```
-
-### Buffer Swap
-
-```elixir
-def handle_call(:swap_buffers, _from, state) do
- # O(1) pointer swap
- new_state = %{state | current: state.previous, previous: state.current}
-
- # Update persistent_term references
- :persistent_term.put({__MODULE__, :current}, new_state.current)
- :persistent_term.put({__MODULE__, :previous}, new_state.previous)
-
- {:reply, :ok, new_state}
-end
-```
-
-## Direct Access
-
-Most buffer operations bypass the GenServer for performance:
-
-```elixir
-# These read from persistent_term (no GenServer call)
-def get_current_buffer do
- :persistent_term.get({__MODULE__, :current})
-end
-
-def get_previous_buffer do
- :persistent_term.get({__MODULE__, :previous})
-end
-
-def dirty? do
- dirty = :persistent_term.get({__MODULE__, :dirty})
- :atomics.get(dirty, 1) == 1
-end
-
-def mark_dirty do
- dirty = :persistent_term.get({__MODULE__, :dirty})
- :atomics.put(dirty, 1, 1)
- :ok
-end
-```
-
-## Buffer Operations
-
-### Writing Cells
-
-```elixir
-def set_cell(buffer, row, col, cell) do
- if in_bounds?(buffer, row, col) do
- :ets.insert(buffer.table, {{row, col}, cell})
- :ok
- else
- {:error, :out_of_bounds}
- end
-end
-
-def set_cells(buffer, cells) do
- entries = Enum.map(cells, fn {row, col, cell} ->
- {{row, col}, cell}
- end)
- :ets.insert(buffer.table, entries)
- :ok
-end
-```
-
-### Reading Cells
-
-```elixir
-def get_cell(buffer, row, col) do
- case :ets.lookup(buffer.table, {row, col}) do
- [{_, cell}] -> cell
- [] -> Cell.empty()
- end
-end
-
-def get_row(buffer, row) do
- # Match all cells in row
- pattern = {{row, :_}, :_}
- cells = :ets.match_object(buffer.table, pattern)
-
- # Sort by column and extract cells
- cells
- |> Enum.sort_by(fn {{_, col}, _} -> col end)
- |> Enum.map(fn {_, cell} -> cell end)
-end
-```
-
-### Clearing
-
-```elixir
-def clear(buffer) do
- clear_region(buffer, 1, 1, buffer.cols, buffer.rows)
-end
-
-def clear_region(buffer, start_row, start_col, width, height) do
- empty = Cell.empty()
-
- entries =
- for row <- start_row..(start_row + height - 1),
- col <- start_col..(start_col + width - 1),
- in_bounds?(buffer, row, col) do
- {{row, col}, empty}
- end
-
- :ets.insert(buffer.table, entries)
- :ok
-end
-```
-
-## Cell Structure
-
-```elixir
-defmodule TermUI.Renderer.Cell do
- defstruct [
- char: " ", # Single grapheme
- fg: :default, # Foreground color
- bg: :default, # Background color
- attrs: MapSet.new(), # Text attributes
- width: 1, # Display width (1 or 2)
- wide_placeholder: false
- ]
-end
-```
-
-### Cell Comparison
-
-Used by the diff algorithm:
-
-```elixir
-def equal?(a, b) do
- a.char == b.char and
- a.fg == b.fg and
- a.bg == b.bg and
- MapSet.equal?(a.attrs, b.attrs) and
- a.width == b.width and
- a.wide_placeholder == b.wide_placeholder
-end
-```
-
-### Wide Characters
-
-CJK and emoji characters take 2 cells:
-
-```elixir
-# Primary cell
-primary = %Cell{char: "中", width: 2}
-
-# Placeholder for second column
-placeholder = %Cell{char: "", width: 0, wide_placeholder: true}
-
-# Both must be written
-:ets.insert(buffer.table, [
- {{row, col}, primary},
- {{row, col + 1}, placeholder}
-])
-```
-
-## Dirty Flag
-
-Tracks whether re-render is needed:
-
-```mermaid
-graph LR
- subgraph "Write Path"
- W[Write Cell] --> MD[mark_dirty]
- MD --> A[(atomics)]
- end
-
- subgraph "Render Path"
- RT[Render Tick] --> CD{dirty?}
- CD -->|true| R[Render]
- CD -->|false| S[Skip]
- R --> CL[clear_dirty]
- end
-```
-
-Using atomics for lock-free access:
-
-```elixir
-# Mark dirty (from any process)
-def mark_dirty do
- dirty = :persistent_term.get({__MODULE__, :dirty})
- :atomics.put(dirty, 1, 1)
-end
-
-# Check dirty (from render loop)
-def dirty? do
- dirty = :persistent_term.get({__MODULE__, :dirty})
- :atomics.get(dirty, 1) == 1
-end
-
-# Clear dirty (after render)
-def clear_dirty do
- dirty = :persistent_term.get({__MODULE__, :dirty})
- :atomics.put(dirty, 1, 0)
-end
-```
-
-## Resize Handling
-
-```elixir
-def handle_call({:resize, rows, cols}, _from, state) do
- # Create new buffers with new dimensions
- {:ok, new_current} = Buffer.resize(state.current, rows, cols)
- {:ok, new_previous} = Buffer.resize(state.previous, rows, cols)
-
- new_state = %{state | current: new_current, previous: new_previous}
-
- # Update persistent_term
- :persistent_term.put({__MODULE__, :current}, new_current)
- :persistent_term.put({__MODULE__, :previous}, new_previous)
-
- {:reply, :ok, new_state}
-end
-```
-
-### Content Preservation
-
-```elixir
-def resize(buffer, new_rows, new_cols) do
- {:ok, new_buffer} = new(new_rows, new_cols)
-
- # Copy cells that fit in new dimensions
- old_entries = :ets.tab2list(buffer.table)
-
- entries_to_copy =
- old_entries
- |> Enum.filter(fn {{row, col}, _} ->
- row <= new_rows and col <= new_cols
- end)
-
- :ets.insert(new_buffer.table, entries_to_copy)
-
- # Clean up old table
- :ets.delete(buffer.table)
-
- {:ok, new_buffer}
-end
-```
-
-## Performance Characteristics
-
-| Operation | Complexity | Notes |
-|-----------|------------|-------|
-| `get_cell` | O(1) | ETS hash lookup |
-| `set_cell` | O(1) | ETS insert |
-| `set_cells` | O(n) | Batch insert |
-| `get_row` | O(cols) | Match + sort |
-| `clear` | O(rows × cols) | Full buffer |
-| `swap_buffers` | O(1) | Pointer swap |
-| `dirty?` | O(1) | Atomic read |
-
-## Memory Usage
-
-Each cell: ~100-200 bytes depending on content
-
-For 80×24 terminal: ~200KB per buffer (400KB total)
-For 200×50 terminal: ~2MB per buffer (4MB total)
-
-## Cleanup
-
-```elixir
-def terminate(_reason, state) do
- # Remove persistent_term entries
- :persistent_term.erase({__MODULE__, :current})
- :persistent_term.erase({__MODULE__, :previous})
- :persistent_term.erase({__MODULE__, :dirty})
-
- # Delete ETS tables
- Buffer.destroy(state.current)
- Buffer.destroy(state.previous)
-
- :ok
-end
-
-# Buffer.destroy/1
-def destroy(buffer) do
- :ets.delete(buffer.table)
-end
-```
-
-## Next Steps
-
-- [Rendering Pipeline](03-rendering-pipeline.md) - How buffers are used
-- [Terminal Layer](06-terminal-layer.md) - Output to terminal
-- [Architecture Overview](01-architecture-overview.md) - System context
diff --git a/guides/developer/06-terminal-layer.md b/guides/developer/06-terminal-layer.md
deleted file mode 100644
index 90b33710..00000000
--- a/guides/developer/06-terminal-layer.md
+++ /dev/null
@@ -1,515 +0,0 @@
-# Terminal Layer
-
-This guide covers TermUI's low-level terminal interface, including raw mode, escape sequences, and platform handling.
-
-## Components
-
-```mermaid
-graph TB
- subgraph "Terminal Layer"
- TG[Terminal GenServer]
- IR[InputReader]
- EP[EscapeParser]
- ANSI[ANSI Module]
- end
-
- subgraph "System"
- TTY[/dev/tty]
- STDIN[stdin]
- STDOUT[stdout]
- end
-
- TG --> TTY
- TG --> STDOUT
- IR --> STDIN
- IR --> EP
- ANSI --> TG
-```
-
-## Terminal GenServer
-
-`TermUI.Terminal` manages terminal state:
-
-```elixir
-defmodule TermUI.Terminal do
- use GenServer
-
- defstruct [
- :original_mode, # Saved terminal state
- :raw_mode_enabled, # Currently in raw mode?
- :mouse_mode, # Mouse tracking mode
- :resize_callbacks # Processes to notify on resize
- ]
-end
-```
-
-### Initialization
-
-```elixir
-def init(_opts) do
- state = %__MODULE__{
- original_mode: nil,
- raw_mode_enabled: false,
- mouse_mode: nil,
- resize_callbacks: []
- }
- {:ok, state}
-end
-```
-
-## Raw Mode
-
-### Enabling Raw Mode
-
-OTP 28+ uses the native shell API:
-
-```elixir
-def enable_raw_mode do
- if terminal?() do
- # OTP 28+ native raw mode
- :shell.start_interactive({:noshell, :raw})
- :ok
- else
- {:error, :not_a_terminal}
- end
-end
-```
-
-### Terminal Detection
-
-Multiple methods for SSH compatibility:
-
-```elixir
-defp terminal? do
- cond do
- io_has_terminal?() -> true
- File.exists?("/dev/tty") -> true
- check_tty() -> true
- true -> false
- end
-end
-
-defp io_has_terminal? do
- case :io.getopts(:standard_io) do
- {:ok, opts} -> Keyword.get(opts, :terminal, false) == true
- _ -> false
- end
-end
-
-defp check_tty do
- case System.cmd("test", ["-t", "0"], stderr_to_stdout: true) do
- {_, 0} -> true
- _ -> false
- end
-rescue
- _ -> false
-end
-```
-
-### Restoring Terminal
-
-```elixir
-def restore do
- # Disable raw mode
- disable_raw_mode()
-
- # Leave alternate screen
- leave_alternate_screen()
-
- # Show cursor
- show_cursor()
-
- # Disable mouse tracking
- disable_mouse_tracking()
-
- # Reset all attributes
- write_to_terminal("\e[0m")
-
- :ok
-end
-```
-
-## Escape Sequences
-
-### ANSI Module
-
-`TermUI.ANSI` generates escape sequences:
-
-```elixir
-defmodule TermUI.ANSI do
- # Cursor movement
- def cursor_position(row, col), do: "\e[#{row};#{col}H"
- def cursor_up(n \\ 1), do: "\e[#{n}A"
- def cursor_down(n \\ 1), do: "\e[#{n}B"
- def cursor_forward(n \\ 1), do: "\e[#{n}C"
- def cursor_back(n \\ 1), do: "\e[#{n}D"
-
- # Cursor visibility
- def hide_cursor, do: "\e[?25l"
- def show_cursor, do: "\e[?25h"
-
- # Screen control
- def clear_screen, do: "\e[2J"
- def clear_line, do: "\e[2K"
- def enter_alternate_screen, do: "\e[?1049h"
- def leave_alternate_screen, do: "\e[?1049l"
-
- # Style reset
- def reset, do: "\e[0m"
-end
-```
-
-### SGR (Select Graphic Rendition)
-
-Text styling sequences:
-
-```elixir
-# Colors
-defp color_to_sgr(:fg, :default), do: "39"
-defp color_to_sgr(:fg, :black), do: "30"
-defp color_to_sgr(:fg, :red), do: "31"
-defp color_to_sgr(:fg, :green), do: "32"
-# ... etc
-
-defp color_to_sgr(:bg, :default), do: "49"
-defp color_to_sgr(:bg, :black), do: "40"
-# ... etc
-
-# 256 colors
-defp color_to_sgr(:fg, n) when is_integer(n), do: "38;5;#{n}"
-defp color_to_sgr(:bg, n) when is_integer(n), do: "48;5;#{n}"
-
-# True color
-defp color_to_sgr(:fg, {r, g, b}), do: "38;2;#{r};#{g};#{b}"
-defp color_to_sgr(:bg, {r, g, b}), do: "48;2;#{r};#{g};#{b}"
-
-# Attributes
-defp attr_to_sgr(:bold), do: "1"
-defp attr_to_sgr(:dim), do: "2"
-defp attr_to_sgr(:italic), do: "3"
-defp attr_to_sgr(:underline), do: "4"
-defp attr_to_sgr(:blink), do: "5"
-defp attr_to_sgr(:reverse), do: "7"
-defp attr_to_sgr(:hidden), do: "8"
-defp attr_to_sgr(:strikethrough), do: "9"
-
-# Attribute off
-defp attr_off_sgr(:bold), do: "22"
-defp attr_off_sgr(:underline), do: "24"
-# ... etc
-```
-
-### Sequence Buffer
-
-Batches sequences for efficient output:
-
-```elixir
-defmodule TermUI.Renderer.SequenceBuffer do
- defstruct [
- buffer: [], # Accumulated iodata
- size: 0, # Current size
- threshold: 4096, # Auto-flush threshold
- last_style: nil # For delta encoding
- ]
-
- def append(buffer, data) do
- new_size = buffer.size + IO.iodata_length(data)
- new_buffer = %{buffer | buffer: [data | buffer.buffer], size: new_size}
-
- if new_size >= buffer.threshold do
- {flushed, reset} = flush(new_buffer)
- {:flush, flushed, reset}
- else
- {:ok, new_buffer}
- end
- end
-
- def flush(buffer) do
- data = buffer.buffer |> Enum.reverse()
- {data, %{buffer | buffer: [], size: 0}}
- end
-end
-```
-
-### Style Delta Encoding
-
-Only emit changed attributes:
-
-```elixir
-def append_style(buffer, style) do
- params = style_to_sgr_params(style, buffer.last_style)
-
- if params == [] do
- buffer
- else
- sequence = build_sgr_sequence(params)
- buffer = append!(buffer, sequence)
- %{buffer | last_style: style}
- end
-end
-
-defp style_to_sgr_params(style, nil) do
- # No previous - emit all
- build_full_sgr_params(style)
-end
-
-defp style_to_sgr_params(style, last) do
- params = []
-
- # Only emit if changed
- params = if style.fg != last.fg do
- fg = style.fg || :default
- [color_to_sgr(:fg, fg) | params]
- else
- params
- end
-
- params = if style.bg != last.bg do
- bg = style.bg || :default
- [color_to_sgr(:bg, bg) | params]
- else
- params
- end
-
- # Handle attribute changes...
- params
-end
-```
-
-## Mouse Tracking
-
-### Modes
-
-```elixir
-def enable_mouse_tracking(mode) do
- sequences = case mode do
- :click ->
- # X11 mouse button events
- ["\e[?1000h", "\e[?1006h"]
-
- :drag ->
- # Button events + motion while pressed
- ["\e[?1002h", "\e[?1006h"]
-
- :all ->
- # All mouse events including motion
- ["\e[?1003h", "\e[?1006h"]
- end
-
- Enum.each(sequences, &write_to_terminal/1)
- :ok
-end
-
-def disable_mouse_tracking do
- sequences = [
- "\e[?1000l", # Disable X11
- "\e[?1002l", # Disable drag
- "\e[?1003l", # Disable all
- "\e[?1006l" # Disable SGR
- ]
- Enum.each(sequences, &write_to_terminal/1)
- :ok
-end
-```
-
-### SGR Mouse Format
-
-More precise than X10 format:
-
-```
-ESC [ < Cb ; Cx ; Cy M (button press)
-ESC [ < Cb ; Cx ; Cy m (button release)
-
-Cb = button info (bits encode button, modifiers, motion)
-Cx = column (1-indexed)
-Cy = row (1-indexed)
-```
-
-## Focus Events
-
-```elixir
-def enable_focus_events do
- write_to_terminal("\e[?1004h")
-end
-
-def disable_focus_events do
- write_to_terminal("\e[?1004l")
-end
-
-# Terminal sends:
-# \e[I - Focus gained
-# \e[O - Focus lost
-```
-
-## Terminal Size
-
-### Query Size
-
-```elixir
-def get_terminal_size do
- case :io.columns() do
- {:ok, cols} ->
- case :io.rows() do
- {:ok, rows} -> {:ok, {rows, cols}}
- _ -> {:error, :unknown}
- end
- _ ->
- {:error, :unknown}
- end
-end
-```
-
-### Resize Detection
-
-```elixir
-# Register for SIGWINCH
-def register_resize_callback(pid) do
- GenServer.cast(__MODULE__, {:register_resize, pid})
-end
-
-# On resize signal
-def handle_info(:sigwinch, state) do
- case get_terminal_size() do
- {:ok, {rows, cols}} ->
- # Notify all registered processes
- Enum.each(state.resize_callbacks, fn pid ->
- send(pid, {:terminal_resize, {rows, cols}})
- end)
- _ ->
- :ok
- end
- {:noreply, state}
-end
-```
-
-## Alternate Screen
-
-```mermaid
-sequenceDiagram
- participant App as Application
- participant Term as Terminal
- participant Scr as Screen
-
- App->>Term: enter_alternate_screen()
- Term->>Scr: ESC[?1049h
- Note over Scr: Switch to alt buffer
-
- Note over App: TUI runs...
-
- App->>Term: leave_alternate_screen()
- Term->>Scr: ESC[?1049l
- Note over Scr: Restore main buffer
-```
-
-```elixir
-def enter_alternate_screen do
- write_to_terminal("\e[?1049h")
-end
-
-def leave_alternate_screen do
- write_to_terminal("\e[?1049l")
-end
-```
-
-## Bracketed Paste
-
-```elixir
-def enable_bracketed_paste do
- write_to_terminal("\e[?2004h")
-end
-
-def disable_bracketed_paste do
- write_to_terminal("\e[?2004l")
-end
-
-# Pasted text arrives as:
-# \e[200~ \e[201~
-```
-
-## Platform Differences
-
-### Unix/Linux/macOS
-
-- `/dev/tty` for terminal access
-- `stty` for fallback mode control
-- SIGWINCH for resize detection
-
-### Windows
-
-- ConPTY for modern terminals
-- Different escape sequence support
-- Windows Terminal provides full ANSI support
-
-```elixir
-defp platform do
- case :os.type() do
- {:unix, _} -> :unix
- {:win32, _} -> :windows
- end
-end
-```
-
-## Error Recovery
-
-### Terminal Restoration
-
-Always restore on exit:
-
-```elixir
-def terminate(_reason, state) do
- # Best-effort restoration
- try do
- restore()
- rescue
- _ -> :ok
- end
- :ok
-end
-```
-
-### Crash Recovery
-
-The runtime traps exits:
-
-```elixir
-def init(opts) do
- Process.flag(:trap_exit, true)
- # ...
-end
-
-def terminate(_reason, state) do
- # Terminal.restore() always called
- if state.terminal_started do
- Terminal.restore()
- end
- :ok
-end
-```
-
-## Debugging
-
-### Raw Escape Sequences
-
-```elixir
-# See actual bytes
-IO.inspect(data, binaries: :as_binaries)
-
-# Example output:
-# <<27, 91, 49, 59, 51, 49, 109>>
-# = ESC [ 1 ; 3 1 m
-# = bold + red foreground
-```
-
-### Terminal State
-
-```elixir
-# Check if in raw mode
-:io.getopts(:standard_io)
-# => {:ok, [terminal: true, ...]}
-```
-
-## Next Steps
-
-- [Event System](04-event-system.md) - Input parsing
-- [Rendering Pipeline](03-rendering-pipeline.md) - Output flow
-- [Buffer Management](05-buffer-management.md) - Screen buffers
diff --git a/guides/developer/07-elm-implementation.md b/guides/developer/07-elm-implementation.md
deleted file mode 100644
index c454b9d5..00000000
--- a/guides/developer/07-elm-implementation.md
+++ /dev/null
@@ -1,602 +0,0 @@
-# Elm Architecture Implementation
-
-This guide explains how TermUI implements The Elm Architecture (TEA) pattern adapted for OTP/Elixir.
-
-## The Pattern
-
-```mermaid
-graph TD
- subgraph "Elm Architecture"
- S[State] --> V[view/1]
- V --> RT[Render Tree]
- RT --> T[Terminal]
-
- E[Event] --> ETM[event_to_msg/2]
- ETM --> M[Message]
- M --> U[update/2]
- U --> NS[New State]
- NS --> S
- U --> CMD[Commands]
- CMD --> EX[Execute]
- EX --> M
- end
-```
-
-## Component Behaviour
-
-Every TermUI component implements the `TermUI.Component` behaviour:
-
-```elixir
-defmodule TermUI.Component do
- @callback init(opts :: keyword()) :: state :: term()
- @callback event_to_msg(event :: Event.t(), state :: term()) ::
- {:msg, msg :: term()} | :ignore | :propagate
- @callback update(msg :: term(), state :: term()) ::
- {new_state :: term(), commands :: [command()]}
- @callback view(state :: term()) :: render_tree :: term()
-end
-```
-
-### Example Component
-
-```elixir
-defmodule Counter do
- @behaviour TermUI.Component
-
- import TermUI.View
- alias TermUI.Event
-
- # Initialize state
- @impl true
- def init(_opts), do: %{count: 0}
-
- # Convert events to messages
- @impl true
- def event_to_msg(%Event.Key{key: :up}, _state), do: {:msg, :increment}
- def event_to_msg(%Event.Key{key: :down}, _state), do: {:msg, :decrement}
- def event_to_msg(%Event.Key{key: "q"}, _state), do: {:msg, :quit}
- def event_to_msg(_event, _state), do: :ignore
-
- # Update state based on messages
- @impl true
- def update(:increment, state), do: {%{state | count: state.count + 1}, []}
- def update(:decrement, state), do: {%{state | count: state.count - 1}, []}
- def update(:quit, state), do: {state, [:quit]}
-
- # Render current state
- @impl true
- def view(state) do
- stack(:vertical, [
- text("Counter: #{state.count}"),
- text("↑/↓ to change, q to quit")
- ])
- end
-end
-```
-
-## Data Flow
-
-### 1. Init Phase
-
-```mermaid
-sequenceDiagram
- participant U as User
- participant RT as Runtime
- participant C as Component
- participant BM as BufferManager
-
- U->>RT: start_link(root: Counter)
- RT->>C: init(opts)
- C->>RT: initial_state
- RT->>BM: allocate buffers
- RT->>RT: schedule_render
- RT->>C: view(state)
- C->>RT: render_tree
- RT->>BM: render to buffer
-```
-
-```elixir
-# Runtime.init/1
-def init(opts) do
- root_module = Keyword.fetch!(opts, :root)
-
- # Call component's init
- root_state = root_module.init(opts)
-
- state = %State{
- root_module: root_module,
- root_state: root_state,
- # ...
- }
-
- # Schedule first render
- schedule_render(state.render_interval)
-
- {:ok, state}
-end
-```
-
-### 2. Event Phase
-
-```mermaid
-sequenceDiagram
- participant T as Terminal
- participant IR as InputReader
- participant RT as Runtime
- participant C as Component
-
- T->>IR: raw bytes
- IR->>IR: parse escape sequences
- IR->>RT: Event struct
- RT->>C: event_to_msg(event, state)
-
- alt {:msg, message}
- C->>RT: {:msg, :increment}
- RT->>RT: enqueue message
- else :ignore
- C->>RT: :ignore
- Note over RT: Event discarded
- else :propagate
- C->>RT: :propagate
- Note over RT: Bubble to parent
- end
-```
-
-```elixir
-# Runtime handles input from InputReader
-def handle_info({:input, event}, state) do
- state = dispatch_event(event, state)
- {:noreply, state}
-end
-
-defp dispatch_event(event, state) do
- case state.root_module.event_to_msg(event, state.root_state) do
- {:msg, message} ->
- enqueue_message(:root, message, state)
-
- :ignore ->
- state
-
- :propagate ->
- # Future: bubble to parent component
- state
- end
-end
-```
-
-### 3. Update Phase
-
-```mermaid
-sequenceDiagram
- participant RT as Runtime
- participant MQ as MessageQueue
- participant C as Component
- participant CMD as CommandExecutor
-
- RT->>MQ: flush()
- MQ->>RT: [messages]
-
- loop Each message
- RT->>C: update(msg, state)
- C->>RT: {new_state, commands}
- RT->>RT: mark_dirty if changed
- end
-
- RT->>CMD: execute(commands)
- CMD->>RT: schedule results
-```
-
-```elixir
-defp process_messages(state) do
- {messages, queue} = MessageQueue.flush(state.message_queue)
-
- {state, all_commands} =
- Enum.reduce(messages, {state, []}, fn {component_id, msg}, {acc, cmds} ->
- {new_state, new_cmds} = process_single_message(component_id, msg, acc)
- {new_state, cmds ++ new_cmds}
- end)
-
- execute_commands(all_commands, %{state | message_queue: queue})
-end
-
-defp process_single_message(:root, msg, state) do
- {new_root_state, commands} = state.root_module.update(msg, state.root_state)
-
- dirty = state.dirty or new_root_state != state.root_state
-
- {%{state | root_state: new_root_state, dirty: dirty}, commands}
-end
-```
-
-### 4. View Phase
-
-```mermaid
-sequenceDiagram
- participant RT as Runtime
- participant C as Component
- participant NR as NodeRenderer
- participant BM as BufferManager
- participant D as Diff
- participant T as Terminal
-
- RT->>C: view(state)
- C->>RT: render_tree
- RT->>BM: clear current
- RT->>NR: render_to_buffer(tree)
- NR->>BM: set_cells(cells)
- RT->>D: diff(current, previous)
- D->>RT: operations
- RT->>T: write operations
- RT->>BM: swap_buffers
-```
-
-```elixir
-defp do_render(state) do
- # 1. Get render tree
- render_tree = state.root_module.view(state.root_state)
-
- # 2. Clear and render to buffer
- BufferManager.clear_current()
- NodeRenderer.render_to_buffer(render_tree)
-
- # 3. Diff against previous
- current = BufferManager.get_current_buffer()
- previous = BufferManager.get_previous_buffer()
- operations = Diff.diff(current, previous)
-
- # 4. Output to terminal
- render_operations(operations)
-
- # 5. Swap buffers for next frame
- BufferManager.swap_buffers()
-
- %{state | dirty: false}
-end
-```
-
-## Commands
-
-Commands are side effects returned from `update/2`:
-
-```mermaid
-graph LR
- subgraph "Command Types"
- Q[:quit] --> Shutdown
- T[{:timer, ms, msg}] --> TimerProcess
- A[{:async, fun, msg}] --> TaskProcess
- end
-
- TimerProcess --> MQ[MessageQueue]
- TaskProcess --> MQ
-```
-
-### Built-in Commands
-
-```elixir
-# Quit application
-def update(:quit, state), do: {state, [:quit]}
-
-# Set timer
-def update(:start_timer, state) do
- {state, [{:timer, 1000, :tick}]}
-end
-
-# Async operation
-def update(:fetch_data, state) do
- task = fn -> HTTP.get!("/api/data") end
- {state, [{:async, task, :data_received}]}
-end
-```
-
-### Command Execution
-
-```elixir
-defp execute_commands(commands, state) do
- Enum.reduce(commands, state, fn cmd, acc ->
- execute_command(cmd, acc)
- end)
-end
-
-defp execute_command(:quit, state) do
- GenServer.cast(self(), :shutdown)
- %{state | shutting_down: true}
-end
-
-defp execute_command({:timer, ms, msg}, state) do
- command_id = make_ref()
- Process.send_after(self(), {:command_result, :root, command_id, msg}, ms)
-
- pending = Map.put(state.pending_commands, command_id, {:timer, msg})
- %{state | pending_commands: pending}
-end
-
-defp execute_command({:async, fun, msg_wrapper}, state) do
- command_id = make_ref()
- parent = self()
-
- Task.start(fn ->
- result = fun.()
- send(parent, {:command_result, :root, command_id, {msg_wrapper, result}})
- end)
-
- pending = Map.put(state.pending_commands, command_id, {:async, msg_wrapper})
- %{state | pending_commands: pending}
-end
-```
-
-### Command Results
-
-```elixir
-def handle_info({:command_result, component_id, cmd_id, result}, state) do
- state = %{state | pending_commands: Map.delete(state.pending_commands, cmd_id)}
- state = enqueue_message(component_id, result, state)
- {:noreply, state}
-end
-```
-
-## Message Queue
-
-FIFO ordering with component targeting:
-
-```elixir
-defmodule TermUI.Runtime.MessageQueue do
- defstruct queue: :queue.new()
-
- def enqueue(mq, component_id, message) do
- %{mq | queue: :queue.in({component_id, message}, mq.queue)}
- end
-
- def flush(mq) do
- messages = :queue.to_list(mq.queue)
- {messages, %{mq | queue: :queue.new()}}
- end
-
- def empty?(mq) do
- :queue.is_empty(mq.queue)
- end
-end
-```
-
-## Render Tree Nodes
-
-The `view/1` function returns a tree of render nodes:
-
-```elixir
-# Text with optional style
-{:text, "Hello", %Style{fg: :red}}
-
-# Vertical or horizontal stack
-{:stack, :vertical, [child1, child2]}
-{:stack, :horizontal, [child1, child2]}
-
-# Style wrapper
-{:styled, %Style{bg: :blue}, child}
-
-# Fragment (no container)
-{:fragment, [child1, child2, child3]}
-
-# Raw cells
-{:cells, [%Cell{char: "█", fg: :green}, ...]}
-```
-
-### View Helpers
-
-```elixir
-defmodule TermUI.View do
- def text(content), do: {:text, content, Style.new()}
- def text(content, style), do: {:text, content, style}
-
- def stack(direction, children) when direction in [:vertical, :horizontal] do
- {:stack, direction, List.flatten(children)}
- end
-
- def styled(style, child), do: {:styled, style, child}
-
- def fragment(children), do: {:fragment, List.flatten(children)}
-end
-```
-
-## State Immutability
-
-All state updates create new values:
-
-```elixir
-# Good - create new state
-def update(:increment, state) do
- {%{state | count: state.count + 1}, []}
-end
-
-# Bad - mutation (doesn't work in Elixir anyway)
-def update(:increment, state) do
- state.count = state.count + 1 # Compile error!
- {state, []}
-end
-```
-
-### Nested State Updates
-
-```elixir
-def update({:set_user_name, name}, state) do
- # Update nested map
- new_user = %{state.user | name: name}
- {%{state | user: new_user}, []}
-end
-
-# Or with put_in
-def update({:set_user_name, name}, state) do
- {put_in(state, [:user, :name], name), []}
-end
-```
-
-## Error Handling
-
-Components are wrapped in error protection:
-
-```elixir
-defp safe_event_to_msg(module, event, state) do
- try do
- module.event_to_msg(event, state)
- rescue
- error ->
- Logger.error("event_to_msg crashed: #{inspect(error)}")
- :ignore
- end
-end
-
-defp safe_update(module, msg, state) do
- try do
- module.update(msg, state)
- rescue
- error ->
- Logger.error("update crashed: #{inspect(error)}")
- {state, []}
- end
-end
-
-defp safe_view(module, state) do
- try do
- module.view(state)
- rescue
- error ->
- Logger.error("view crashed: #{inspect(error)}")
- {:text, "[Render Error]", Style.new(fg: :red)}
- end
-end
-```
-
-## Comparison with Original Elm
-
-| Aspect | Elm | TermUI |
-|--------|-----|--------|
-| Language | Elm (ML-style) | Elixir |
-| Runtime | Browser/JavaScript | BEAM/OTP |
-| Model | `Model` type | Component state (any term) |
-| Msg | `Msg` union type | Any Elixir term |
-| Cmd | `Cmd Msg` | List of command tuples |
-| Sub | `Sub Msg` | Commands + Input events |
-| view | Virtual DOM | Render tree |
-| update | Pure function | Pure function |
-| Side effects | Elm runtime | Runtime + Commands |
-
-### Key Differences
-
-1. **No subscriptions**: TermUI uses commands and the InputReader instead
-2. **Commands as data**: Commands are simple tuples, not opaque types
-3. **event_to_msg**: Additional callback to separate event parsing from state updates
-4. **Process model**: Components could be separate processes (future)
-
-## Testing Components
-
-```elixir
-defmodule CounterTest do
- use ExUnit.Case
-
- alias TermUI.Event
-
- test "init returns zero count" do
- assert Counter.init([]) == %{count: 0}
- end
-
- test "up arrow increments" do
- state = %{count: 5}
- event = Event.key(:up)
-
- assert {:msg, :increment} = Counter.event_to_msg(event, state)
-
- {new_state, commands} = Counter.update(:increment, state)
- assert new_state.count == 6
- assert commands == []
- end
-
- test "quit returns quit command" do
- state = %{count: 0}
-
- {^state, commands} = Counter.update(:quit, state)
- assert :quit in commands
- end
-
- test "view renders count" do
- state = %{count: 42}
- tree = Counter.view(state)
-
- # Inspect tree structure
- {:stack, :vertical, [text_node | _]} = tree
- {:text, content, _style} = text_node
- assert content =~ "42"
- end
-end
-```
-
-## Best Practices
-
-### 1. Keep State Minimal
-
-```elixir
-# Good - only essential data
-%{
- items: [...],
- selected_index: 0,
- filter: ""
-}
-
-# Avoid - derived data in state
-%{
- items: [...],
- filtered_items: [...], # Derive in view instead
- item_count: 10 # Derive from items
-}
-```
-
-### 2. Use Pattern Matching in event_to_msg
-
-```elixir
-# Good - specific patterns
-def event_to_msg(%Event.Key{key: :enter}, _state), do: {:msg, :submit}
-def event_to_msg(%Event.Key{key: :escape}, _state), do: {:msg, :cancel}
-def event_to_msg(%Event.Key{key: key}, _state) when key in ["q", "Q"], do: {:msg, :quit}
-def event_to_msg(_event, _state), do: :ignore
-
-# Avoid - complex logic in event_to_msg
-def event_to_msg(event, state) do
- cond do
- event.key == :enter and state.mode == :edit -> {:msg, :save}
- event.key == :enter and state.mode == :view -> {:msg, :edit}
- # ... many conditions
- end
-end
-```
-
-### 3. Messages as Intent
-
-```elixir
-# Good - messages describe intent
-:increment
-:decrement
-{:select_item, index}
-{:set_filter, text}
-
-# Avoid - messages that are too low-level
-{:set_count, 5} # Doesn't express why
-{:keypress, :up} # Already handled by event_to_msg
-```
-
-### 4. Commands for Side Effects
-
-```elixir
-# Good - side effects via commands
-def update(:refresh, state) do
- {%{state | loading: true}, [{:async, &fetch_data/0, :data_loaded}]}
-end
-
-# Avoid - side effects in update
-def update(:refresh, state) do
- data = HTTP.get!("/api") # Blocks, can crash
- {%{state | data: data}, []}
-end
-```
-
-## Next Steps
-
-- [Architecture Overview](01-architecture-overview.md) - System layers
-- [Runtime Internals](02-runtime-internals.md) - Event loop details
-- [Event System](04-event-system.md) - Input handling
diff --git a/guides/developer/08-creating-widgets.md b/guides/developer/08-creating-widgets.md
deleted file mode 100644
index 4097c664..00000000
--- a/guides/developer/08-creating-widgets.md
+++ /dev/null
@@ -1,409 +0,0 @@
-# Creating New Widgets
-
-This guide explains how to create new widgets for TermUI and contribute them to the project.
-
-## Widget Types
-
-TermUI supports two types of widgets:
-
-### 1. Stateless Widgets (Display Only)
-
-Simple widgets that render based on input props without maintaining internal state.
-
-**Examples**: Gauge, Sparkline, BarChart, LineChart
-
-**Use when**: The widget only displays data and doesn't need to track interactions.
-
-### 2. Stateful Widgets (Interactive)
-
-Widgets that maintain internal state and handle user events.
-
-**Examples**: Menu, Table, Tabs, Dialog, Viewport
-
-**Use when**: The widget needs to track selection, focus, scroll position, or other interactive state.
-
-## Creating a Stateless Widget
-
-### Step 1: Create the Widget Module
-
-Create a new file in `lib/term_ui/widgets/`:
-
-```elixir
-defmodule TermUI.Widgets.MyWidget do
- @moduledoc """
- MyWidget displays [description].
-
- ## Usage
-
- MyWidget.render(
- value: 42,
- width: 20,
- style: Style.new(fg: :cyan)
- )
-
- ## Options
-
- - `:value` - The value to display (required)
- - `:width` - Widget width (default: 20)
- - `:style` - Style for the widget
- """
-
- import TermUI.Component.RenderNode
-
- @doc """
- Renders the widget.
-
- ## Options
-
- - `:value` - Required. The value to display.
- - `:width` - Optional. Width in characters (default: 20).
- - `:style` - Optional. Style to apply.
- """
- @spec render(keyword()) :: TermUI.Component.RenderNode.t()
- def render(opts) do
- value = Keyword.fetch!(opts, :value)
- width = Keyword.get(opts, :width, 20)
- style = Keyword.get(opts, :style)
-
- # Build your render tree
- content = format_value(value, width)
-
- if style do
- styled(text(content), style)
- else
- text(content)
- end
- end
-
- # Helper function for convenience
- @doc """
- Renders with default styling.
- """
- def simple(value, opts \\ []) do
- render([{:value, value} | opts])
- end
-
- # Private helpers
- defp format_value(value, width) do
- value
- |> to_string()
- |> String.pad_trailing(width)
- end
-end
-```
-
-### Key Points for Stateless Widgets
-
-1. **Import RenderNode helpers**: `import TermUI.Component.RenderNode`
-2. **Use `Keyword.fetch!/2`** for required options
-3. **Use `Keyword.get/3`** for optional options with defaults
-4. **Return a RenderNode struct** from `render/1`
-5. **Provide convenience functions** like `simple/2` for common use cases
-
-## Creating a Stateful Widget
-
-### Step 1: Create the Widget Module
-
-```elixir
-defmodule TermUI.Widgets.MyStatefulWidget do
- @moduledoc """
- MyStatefulWidget provides [description].
-
- ## Usage
-
- MyStatefulWidget.new(
- items: ["one", "two", "three"],
- on_select: fn item -> handle_selection(item) end
- )
-
- ## Keyboard Controls
-
- - Up/Down: Navigate items
- - Enter: Select current item
- - Escape: Close
- """
-
- use TermUI.StatefulComponent
-
- alias TermUI.Event
-
- # Constructor for props
- @doc """
- Creates widget props.
-
- ## Options
-
- - `:items` - List of items (required)
- - `:on_select` - Callback when item is selected
- - `:style` - Style for normal items
- - `:selected_style` - Style for selected item
- """
- @spec new(keyword()) :: map()
- def new(opts) do
- %{
- items: Keyword.fetch!(opts, :items),
- on_select: Keyword.get(opts, :on_select),
- style: Keyword.get(opts, :style),
- selected_style: Keyword.get(opts, :selected_style)
- }
- end
-
- # Initialize state from props
- @impl true
- def init(props) do
- state = %{
- items: props.items,
- cursor: 0,
- on_select: props.on_select,
- style: props.style,
- selected_style: props.selected_style
- }
-
- {:ok, state}
- end
-
- # Handle keyboard events
- @impl true
- def handle_event(%Event.Key{key: :up}, state) do
- new_cursor = max(0, state.cursor - 1)
- {:ok, %{state | cursor: new_cursor}}
- end
-
- def handle_event(%Event.Key{key: :down}, state) do
- max_index = length(state.items) - 1
- new_cursor = min(max_index, state.cursor + 1)
- {:ok, %{state | cursor: new_cursor}}
- end
-
- def handle_event(%Event.Key{key: :enter}, state) do
- if state.on_select do
- item = Enum.at(state.items, state.cursor)
- state.on_select.(item)
- end
-
- {:ok, state}
- end
-
- def handle_event(_event, state) do
- {:ok, state}
- end
-
- # Render the widget
- @impl true
- def render(state, _area) do
- rows =
- state.items
- |> Enum.with_index()
- |> Enum.map(fn {item, index} ->
- render_item(item, index, state)
- end)
-
- stack(:vertical, rows)
- end
-
- defp render_item(item, index, state) do
- is_selected = index == state.cursor
- style = if is_selected, do: state.selected_style, else: state.style
-
- if style do
- styled(text(item), style)
- else
- text(item)
- end
- end
-end
-```
-
-### Key Points for Stateful Widgets
-
-1. **Use the behaviour**: `use TermUI.StatefulComponent`
-2. **Provide `new/1`** to create props from options
-3. **Implement `init/1`** to initialize state from props
-4. **Implement `handle_event/2`** for user interactions
-5. **Implement `render/2`** to produce the render tree
-6. **Return `{:ok, state}` or `{:ok, state, commands}`** from event handlers
-
-## Writing Tests
-
-**Tests are required for all new widgets.** See [Testing Framework](09-testing-framework.md) for comprehensive testing documentation.
-
-Create a test file in `test/term_ui/widgets/`:
-
-```elixir
-defmodule TermUI.Widgets.MyWidgetTest do
- use ExUnit.Case, async: true
-
- alias TermUI.Widgets.MyWidget
-
- describe "render/1" do
- test "renders with required options" do
- result = MyWidget.render(value: 42)
-
- assert result.type == :text
- assert result.content =~ "42"
- end
-
- test "applies custom width" do
- result = MyWidget.render(value: 1, width: 10)
-
- assert String.length(result.content) == 10
- end
-
- test "applies style when provided" do
- style = TermUI.Renderer.Style.new(fg: :red)
- result = MyWidget.render(value: 42, style: style)
-
- assert result.type == :box
- assert result.style == style
- end
-
- test "raises on missing required option" do
- assert_raise KeyError, fn ->
- MyWidget.render([])
- end
- end
- end
-
- describe "simple/2" do
- test "creates widget with defaults" do
- result = MyWidget.simple(100)
-
- assert result.type == :text
- end
- end
-end
-```
-
-### Test Categories to Cover
-
-1. **Required options** - Verify required params raise on missing
-2. **Default values** - Test behavior with minimal options
-3. **All options** - Test each option individually
-4. **Edge cases** - Empty data, zero values, extreme values
-5. **Styling** - Verify styles are applied correctly
-6. **For stateful widgets**:
- - Initial state from props
- - Event handling (keyboard, mouse)
- - State transitions
- - Callback invocation
-
-## File Organization
-
-```
-lib/term_ui/widgets/
-├── my_widget.ex # Your widget module
-
-test/term_ui/widgets/
-├── my_widget_test.exs # Your widget tests
-
-examples/my_widget/ # Optional: example application
-├── mix.exs
-├── run.exs
-├── README.md
-└── lib/my_widget/
- ├── application.ex
- └── app.ex
-```
-
-## Checklist Before Submitting a PR
-
-### Code Quality
-
-- [ ] Widget has comprehensive `@moduledoc` with usage examples
-- [ ] All public functions have `@doc` and `@spec`
-- [ ] Follows existing code style (run `mix format`)
-- [ ] No compiler warnings (`mix compile --warnings-as-errors`)
-
-### Testing
-
-- [ ] Test file exists in `test/term_ui/widgets/`
-- [ ] Tests cover all public functions
-- [ ] Tests cover edge cases
-- [ ] All tests pass (`mix test`)
-- [ ] Tests are async when possible (`use ExUnit.Case, async: true`)
-
-### Documentation
-
-- [ ] Module documentation explains the widget's purpose
-- [ ] Usage examples in `@moduledoc`
-- [ ] All options documented in `render/1` or `new/1`
-- [ ] Keyboard controls documented for stateful widgets
-
-### Optional but Appreciated
-
-- [ ] Example application in `examples/`
-- [ ] Example has README with installation instructions
-
-## Submitting Your PR
-
-### 1. Fork and Branch
-
-```bash
-git checkout -b feature/my-widget
-```
-
-### 2. Implement and Test
-
-```bash
-# Run tests
-mix test test/term_ui/widgets/my_widget_test.exs
-
-# Run all tests
-mix test
-
-# Check formatting
-mix format --check-formatted
-
-# Check for warnings
-mix compile --warnings-as-errors
-```
-
-### 3. Commit with Clear Message
-
-```bash
-git add lib/term_ui/widgets/my_widget.ex test/term_ui/widgets/my_widget_test.exs
-git commit -m "Add MyWidget for [purpose]
-
-- Implements [feature 1]
-- Supports [feature 2]
-- Includes comprehensive tests"
-```
-
-### 4. Create Pull Request
-
-Your PR description should include:
-
-- **What**: Brief description of the widget
-- **Why**: Use case or motivation
-- **How**: Key implementation details
-- **Testing**: How to test the widget
-- **Screenshots**: If applicable, show the widget in action
-
-### PR Requirements
-
-1. **Tests must pass** - CI will verify this
-2. **Tests must be included** - PRs without tests will not be merged
-3. **Code must be formatted** - Run `mix format`
-4. **No new warnings** - Compile with `--warnings-as-errors`
-
-## Examples of Good PRs
-
-Look at existing widgets for reference:
-
-- **Simple stateless**: `lib/term_ui/widgets/gauge.ex`
-- **Data visualization**: `lib/term_ui/widgets/sparkline.ex`
-- **Interactive stateful**: `lib/term_ui/widgets/menu.ex`
-- **Complex stateful**: `lib/term_ui/widgets/table.ex`
-
-## Getting Help
-
-- Open an issue to discuss your widget idea before implementing
-- Ask questions in the PR if you need guidance
-- Review existing widget implementations for patterns
-
-## Next Steps
-
-- [Testing Framework](09-testing-framework.md) - Comprehensive testing guide
-- [Architecture Overview](01-architecture-overview.md) - Understand the system
-- [Elm Implementation](07-elm-implementation.md) - Learn the component model
-- [Rendering Pipeline](03-rendering-pipeline.md) - How widgets become output
diff --git a/guides/developer/09-testing-framework.md b/guides/developer/09-testing-framework.md
deleted file mode 100644
index 9f92b903..00000000
--- a/guides/developer/09-testing-framework.md
+++ /dev/null
@@ -1,472 +0,0 @@
-# Testing Framework
-
-This guide covers TermUI's testing framework for component and widget testing.
-
-## Overview
-
-TermUI provides a comprehensive testing framework in `TermUI.Test.*` with four key modules:
-
-| Module | Purpose |
-|--------|---------|
-| `ComponentHarness` | Mount and test components in isolation |
-| `TestRenderer` | Capture rendered output for inspection |
-| `EventSimulator` | Create synthetic events for testing |
-| `Assertions` | TUI-specific test assertions |
-
-## Quick Start
-
-```elixir
-defmodule MyWidgetTest do
- use ExUnit.Case, async: true
- use TermUI.Test.Assertions
-
- alias TermUI.Test.{ComponentHarness, EventSimulator, TestRenderer}
-
- test "widget renders and responds to events" do
- # Mount component
- {:ok, harness} = ComponentHarness.mount_test(MyWidget, initial_value: 0)
-
- # Render and check output
- harness = ComponentHarness.render(harness)
- renderer = ComponentHarness.get_renderer(harness)
- assert_text_exists(renderer, "Value: 0")
-
- # Send event and verify state change
- harness = ComponentHarness.send_event(harness, EventSimulator.simulate_key(:up))
- harness = ComponentHarness.render(harness)
- assert_text_exists(renderer, "Value: 1")
-
- # Cleanup
- ComponentHarness.unmount(harness)
- end
-end
-```
-
-## Component Harness
-
-The `ComponentHarness` mounts components in isolation for testing without the full runtime.
-
-### Mounting Components
-
-```elixir
-# Basic mount
-{:ok, harness} = ComponentHarness.mount_test(MyComponent)
-
-# With props
-{:ok, harness} = ComponentHarness.mount_test(MyButton, label: "Click me")
-
-# With custom dimensions
-{:ok, harness} = ComponentHarness.mount_test(MyWidget, width: 40, height: 10)
-```
-
-### Rendering
-
-```elixir
-# Render component
-harness = ComponentHarness.render(harness)
-
-# Get render result (the render tree)
-render_tree = ComponentHarness.get_render(harness)
-
-# Get all renders (most recent first)
-all_renders = ComponentHarness.get_renders(harness)
-```
-
-### Sending Events
-
-```elixir
-# Single event
-harness = ComponentHarness.send_event(harness, event)
-
-# Multiple events
-harness = ComponentHarness.send_events(harness, [event1, event2, event3])
-
-# Event + render cycle (common pattern)
-harness = ComponentHarness.event_cycle(harness, event)
-```
-
-### Inspecting State
-
-```elixir
-# Get full state
-state = ComponentHarness.get_state(harness)
-
-# Get state at path
-value = ComponentHarness.get_state_at(harness, [:counter, :value])
-
-# Direct state manipulation (use sparingly)
-harness = ComponentHarness.set_state(harness, %{count: 10})
-harness = ComponentHarness.update_state(harness, fn s -> %{s | count: s.count + 1} end)
-```
-
-### Cleanup
-
-```elixir
-# Always unmount when done
-ComponentHarness.unmount(harness)
-
-# Or reset to initial state
-{:ok, harness} = ComponentHarness.reset(harness)
-```
-
-## Test Renderer
-
-The `TestRenderer` captures rendered output to a buffer for inspection.
-
-### Creating a Renderer
-
-```elixir
-{:ok, renderer} = TestRenderer.new(24, 80) # 24 rows, 80 columns
-```
-
-### Writing Content
-
-```elixir
-# Write a string
-TestRenderer.write_string(renderer, 1, 1, "Hello, World!")
-
-# Set individual cell
-TestRenderer.set_cell(renderer, 1, 1, Cell.new("X", fg: :red))
-
-# Clear buffer
-TestRenderer.clear(renderer)
-```
-
-### Reading Content
-
-```elixir
-# Get text at position
-text = TestRenderer.get_text_at(renderer, 1, 1, 5) # "Hello"
-
-# Get entire row
-row_text = TestRenderer.get_row_text(renderer, 1)
-
-# Get cell
-cell = TestRenderer.get_cell(renderer, 1, 1)
-
-# Get style at position
-style = TestRenderer.get_style_at(renderer, 1, 1)
-# => %{fg: :red, bg: :default, attrs: MapSet.new([:bold])}
-```
-
-### Searching Content
-
-```elixir
-# Check if text exists at position
-TestRenderer.text_at?(renderer, 1, 1, "Hello") # true/false
-
-# Check if region contains text
-TestRenderer.text_contains?(renderer, 1, 1, 80, "Error")
-
-# Find all occurrences
-positions = TestRenderer.find_text(renderer, "Error")
-# => [{5, 10}, {12, 3}]
-```
-
-### Snapshots
-
-Snapshots capture buffer state for comparison:
-
-```elixir
-# Take snapshot
-snapshot = TestRenderer.snapshot(renderer)
-
-# Compare to snapshot
-TestRenderer.matches_snapshot?(renderer, snapshot) # true/false
-
-# Get differences
-diffs = TestRenderer.diff_snapshot(renderer, snapshot)
-# => [{row, col, expected_cell, actual_cell}, ...]
-
-# Convert to string for debugging
-TestRenderer.to_string(renderer)
-TestRenderer.snapshot_to_string(snapshot)
-```
-
-### Cleanup
-
-```elixir
-TestRenderer.destroy(renderer)
-```
-
-## Event Simulator
-
-The `EventSimulator` creates synthetic events without terminal input.
-
-### Keyboard Events
-
-```elixir
-# Basic key press
-event = EventSimulator.simulate_key(:enter)
-event = EventSimulator.simulate_key(:up)
-event = EventSimulator.simulate_key(:escape)
-
-# Key with character
-event = EventSimulator.simulate_key(:a, char: "a")
-
-# Key with modifiers
-event = EventSimulator.simulate_key(:c, modifiers: [:ctrl])
-event = EventSimulator.simulate_key(:s, modifiers: [:ctrl, :shift])
-
-# Function keys
-event = EventSimulator.simulate_function_key(1) # F1
-event = EventSimulator.simulate_function_key(12) # F12
-
-# Navigation keys
-event = EventSimulator.simulate_navigation(:up)
-event = EventSimulator.simulate_navigation(:page_down)
-event = EventSimulator.simulate_navigation(:home)
-```
-
-### Common Shortcuts
-
-```elixir
-EventSimulator.simulate_shortcut(:copy) # Ctrl+C
-EventSimulator.simulate_shortcut(:paste) # Ctrl+V
-EventSimulator.simulate_shortcut(:cut) # Ctrl+X
-EventSimulator.simulate_shortcut(:save) # Ctrl+S
-EventSimulator.simulate_shortcut(:quit) # Ctrl+Q
-EventSimulator.simulate_shortcut(:undo) # Ctrl+Z
-EventSimulator.simulate_shortcut(:redo) # Ctrl+Shift+Z
-EventSimulator.simulate_shortcut(:select_all) # Ctrl+A
-```
-
-### Typing Text
-
-```elixir
-# Simulate typing a string (returns list of events)
-events = EventSimulator.simulate_type("Hello")
-# => [%Key{key: :h, char: "H"}, %Key{key: :e, char: "e"}, ...]
-
-# Send all events
-harness = ComponentHarness.send_events(harness, events)
-```
-
-### Key Sequences
-
-```elixir
-# Simulate sequence of keys
-events = EventSimulator.simulate_sequence([:tab, :tab, :enter])
-
-# With options
-events = EventSimulator.simulate_sequence([
- {:a, char: "a"},
- :tab,
- :enter
-])
-```
-
-### Mouse Events
-
-```elixir
-# Click
-event = EventSimulator.simulate_click(10, 20) # left click
-event = EventSimulator.simulate_click(10, 20, :right) # right click
-event = EventSimulator.simulate_click(10, 20, :left, modifiers: [:ctrl])
-
-# Double click
-event = EventSimulator.simulate_double_click(10, 20)
-
-# Mouse movement
-event = EventSimulator.simulate_move(15, 25)
-
-# Drag
-event = EventSimulator.simulate_drag(10, 20, :left)
-
-# Scroll
-event = EventSimulator.simulate_scroll_up(10, 20)
-event = EventSimulator.simulate_scroll_down(10, 20)
-```
-
-### Other Events
-
-```elixir
-# Focus events
-event = EventSimulator.simulate_focus_gained()
-event = EventSimulator.simulate_focus_lost()
-
-# Resize
-event = EventSimulator.simulate_resize(120, 40)
-
-# Paste
-event = EventSimulator.simulate_paste("Pasted content")
-```
-
-## Assertions
-
-Import assertions with `use TermUI.Test.Assertions`.
-
-### Text Assertions
-
-```elixir
-# Assert exact text at position
-assert_text(renderer, 1, 1, "Hello")
-
-# Assert text does NOT appear
-refute_text(renderer, 1, 1, "Goodbye")
-
-# Assert region contains text
-assert_text_contains(renderer, 1, 1, 80, "Error")
-refute_text_contains(renderer, 1, 1, 80, "Success")
-
-# Assert text exists anywhere in buffer
-assert_text_exists(renderer, "Error")
-refute_text_exists(renderer, "Secret")
-
-# Assert entire row matches
-assert_row(renderer, 1, "Hello, World!")
-```
-
-### Style Assertions
-
-```elixir
-# Assert foreground color
-assert_style(renderer, 1, 1, fg: :red)
-
-# Assert background color
-assert_style(renderer, 1, 1, bg: :white)
-
-# Assert multiple style properties
-assert_style(renderer, 1, 1, fg: :red, bg: :white, attrs: [:bold])
-
-# Assert single attribute
-assert_attr(renderer, 1, 1, :bold)
-refute_attr(renderer, 1, 1, :underline)
-```
-
-### State Assertions
-
-```elixir
-# Assert state at path
-assert_state(state, [:counter, :value], 42)
-refute_state(state, [:counter, :value], 0)
-
-# Assert state exists (not nil)
-assert_state_exists(state, [:user, :name])
-```
-
-### Snapshot Assertions
-
-```elixir
-# Take snapshot
-snapshot = TestRenderer.snapshot(renderer)
-
-# ... perform operations ...
-
-# Assert matches snapshot
-assert_snapshot(renderer, snapshot)
-```
-
-### Buffer Assertions
-
-```elixir
-# Assert buffer is empty
-assert_empty(renderer)
-```
-
-## Testing Patterns
-
-### Testing State Transitions
-
-```elixir
-test "counter increments on up arrow" do
- {:ok, harness} = ComponentHarness.mount_test(Counter, initial: 0)
-
- # Initial state
- assert ComponentHarness.get_state(harness).count == 0
-
- # Send event
- harness = ComponentHarness.send_event(harness, EventSimulator.simulate_key(:up))
-
- # Verify state changed
- assert ComponentHarness.get_state(harness).count == 1
-end
-```
-
-### Testing Rendered Output
-
-```elixir
-test "displays current count" do
- {:ok, harness} = ComponentHarness.mount_test(Counter, initial: 42)
- harness = ComponentHarness.render(harness)
-
- renderer = ComponentHarness.get_renderer(harness)
- assert_text_exists(renderer, "Count: 42")
-end
-```
-
-### Testing Event Sequences
-
-```elixir
-test "navigation through menu" do
- {:ok, harness} = ComponentHarness.mount_test(Menu, items: ["A", "B", "C"])
-
- # Navigate down twice
- harness =
- harness
- |> ComponentHarness.event_cycle(EventSimulator.simulate_key(:down))
- |> ComponentHarness.event_cycle(EventSimulator.simulate_key(:down))
-
- # Should be on third item
- assert ComponentHarness.get_state(harness).selected == 2
-end
-```
-
-### Testing with Snapshots
-
-```elixir
-test "render output matches expected" do
- {:ok, harness} = ComponentHarness.mount_test(MyWidget)
- harness = ComponentHarness.render(harness)
-
- renderer = ComponentHarness.get_renderer(harness)
- snapshot = TestRenderer.snapshot(renderer)
-
- # Store snapshot for regression testing
- # In real tests, you'd load this from a file
- expected = %{
- rows: 24,
- cols: 80,
- cells: %{...}
- }
-
- assert_snapshot(renderer, expected)
-end
-```
-
-### Testing Edge Cases
-
-```elixir
-test "handles empty list" do
- {:ok, harness} = ComponentHarness.mount_test(List, items: [])
- harness = ComponentHarness.render(harness)
-
- renderer = ComponentHarness.get_renderer(harness)
- assert_text_exists(renderer, "No items")
-end
-
-test "handles boundary navigation" do
- {:ok, harness} = ComponentHarness.mount_test(List, items: ["Only item"])
-
- # Try to go down when already at bottom
- harness = ComponentHarness.send_event(harness, EventSimulator.simulate_key(:down))
-
- # Should stay at 0
- assert ComponentHarness.get_state(harness).selected == 0
-end
-```
-
-## Best Practices
-
-1. **Use `async: true`** for isolated tests
-2. **Always call `unmount/1`** to clean up resources
-3. **Test state and render separately** for clarity
-4. **Use `event_cycle/2`** for common send-event-then-render pattern
-5. **Prefer event simulation** over direct state manipulation
-6. **Use assertions** for clear failure messages
-7. **Test edge cases**: empty data, boundaries, invalid input
-
-## Next Steps
-
-- [Creating Widgets](08-creating-widgets.md) - Widget implementation guide
-- [Architecture Overview](01-architecture-overview.md) - System architecture
diff --git a/guides/developer/README.md b/guides/developer/README.md
deleted file mode 100644
index 8d15ada0..00000000
--- a/guides/developer/README.md
+++ /dev/null
@@ -1,69 +0,0 @@
-# Developer Guides
-
-Technical documentation for TermUI internals and architecture.
-
-## Guides
-
-| Guide | Description |
-|-------|-------------|
-| [01-architecture-overview.md](01-architecture-overview.md) | System layers, process hierarchy, data flow |
-| [02-runtime-internals.md](02-runtime-internals.md) | GenServer event loop, state management, lifecycle |
-| [03-rendering-pipeline.md](03-rendering-pipeline.md) | View → Buffer → Diff → Output stages |
-| [04-event-system.md](04-event-system.md) | Input parsing, escape sequences, dispatch |
-| [05-buffer-management.md](05-buffer-management.md) | ETS double buffering, cell storage |
-| [06-terminal-layer.md](06-terminal-layer.md) | Raw mode, ANSI sequences, platform handling |
-| [07-elm-implementation.md](07-elm-implementation.md) | The Elm Architecture adapted for OTP |
-| [08-creating-widgets.md](08-creating-widgets.md) | How to create and contribute new widgets |
-| [09-testing-framework.md](09-testing-framework.md) | Component and widget testing framework |
-
-## Reading Order
-
-For new contributors:
-
-1. **Architecture Overview** - Understand the layers
-2. **Elm Implementation** - Learn the component model
-3. **Runtime Internals** - See how components are orchestrated
-4. **Event System** - Follow input from terminal to component
-5. **Rendering Pipeline** - Follow output from component to terminal
-6. **Buffer Management** - Understand the ETS buffer system
-7. **Terminal Layer** - Low-level terminal details
-
-## Key Concepts
-
-### Three-Layer Architecture
-
-```
-┌─────────────────────────────────────┐
-│ Widget Layer │ ← Components (Elm Architecture)
-├─────────────────────────────────────┤
-│ Renderer Layer │ ← Buffers, Diff, Output
-├─────────────────────────────────────┤
-│ Port Layer │ ← Terminal I/O
-└─────────────────────────────────────┘
-```
-
-### Data Flow
-
-```
-Event → event_to_msg → Message → update → State → view → Render Tree → Buffer → Diff → Terminal
-```
-
-### Key Files
-
-| File | Purpose |
-|------|---------|
-| `lib/term_ui/runtime.ex` | Central GenServer orchestrating everything |
-| `lib/term_ui/renderer/buffer.ex` | ETS-backed screen buffer |
-| `lib/term_ui/renderer/diff.ex` | Differential rendering algorithm |
-| `lib/term_ui/renderer/sequence_buffer.ex` | ANSI sequence batching |
-| `lib/term_ui/terminal.ex` | Raw mode and terminal control |
-| `lib/term_ui/terminal/input_reader.ex` | Stdin reading and event parsing |
-| `lib/term_ui/terminal/escape_parser.ex` | Escape sequence parsing |
-
-## Diagrams
-
-All guides include Mermaid diagrams. To view them:
-
-- GitHub renders Mermaid automatically
-- VS Code with Markdown Preview Mermaid extension
-- [Mermaid Live Editor](https://mermaid.live/)
diff --git a/guides/feature-parity.md b/guides/feature-parity.md
new file mode 100644
index 00000000..45411077
--- /dev/null
+++ b/guides/feature-parity.md
@@ -0,0 +1,45 @@
+# Advanced feature parity
+
+This comparison uses the source archive published to Hex as `term_ui
+1.0.0-rc` for the before state. A feature in that archive is not proof that the
+feature was correct in a real terminal. The rewrite removed shared processes
+and global services that could break state ownership and terminal cleanup.
+
+The after state is `1.0.0-rc.1` on the rewrite branch. `Retained` means that the
+user capability remains. `Replaced` means that the capability has a new API.
+`Partial` means that useful behavior remains, but an advanced function is
+missing. `Deferred` means that the old form is not safe to restore.
+
+## Before and after
+
+| Feature | Before: published `1.0.0-rc` | After: rewrite `1.0.0-rc.1` | Status and gap |
+| --- | --- | --- | --- |
+| Runtime and widget ownership | Component servers, registries, queues, routers, and process-owned widgets | One Elm runtime with parent-owned pure widgets and command data | Replaced. Do not restore the old ownership model. |
+| Streaming input | `StreamWidget` accepted messages through a GenStage consumer and tracked demand | `ProducerAdapter` bounds queued data and sends one acknowledged batch at a time; the parent applies it to the pure widget | Replaced. Slow consumers cannot receive an unbounded series of adapter batches. |
+| Stream buffer controls | Four overflow modes, pause, clear, rate display, callbacks, and a render-rate setting | Batch push, clear, lifetime counters, drop-oldest, drop-newest, whole-batch reject, pause, follow, scroll, and formatting | Replaced with pure state. Rate calculation belongs in application data. |
+| Streaming Markdown | The viewer replaced its full content through a GenServer call | A bounded incremental document parses completed top-level blocks once and retains the last extensible block as a pending tail | Improved and retained without a viewer process. |
+| Markdown grammar | MDEx headings, emphasis, links, quotes, lists, code blocks, and rules | MDEx plus strike-through, task lists, autolinks, images, tables, and terminal-safe raw HTML | Improved and retained. |
+| Code syntax highlighting | Makeup highlighted Elixir and Erlang fenced code | A bounded library-neutral adapter maps tokens to terminal styles; the optional Makeup adapter supports Elixir and Erlang | Replaced. Plain code has no Makeup dependency, and unknown or failed tokens fall back safely. |
+| Code block copy | The widget callback wrote through caller code from a component process | The pure widget returns `{:copy, code}` and the parent can issue a bounded clipboard command | Replaced with safer effect ownership. |
+| Diff viewing | No dedicated diff viewer | Unified and side-by-side views, Myers line comparison, input bounds, and mode switching | Added in the rewrite. |
+| Tables | Sorting, single or multi-selection, callbacks, and constraint widths | Pure stable sorting and filtering, identity-based single or multiple selection, vertical scrolling, mouse or keyboard selection, and row replacement | Replaced for data interaction. Callbacks are parent messages, and v2 uses direct column widths. |
+| Menus | Actions, checkboxes, separators, and nested submenus | Actions, separators, disabled items, nested submenu data, pure open-path state, keyboard and mouse control, and edge-fitted context positions | Replaced for nested navigation. Checkbox items remain reduced, and no submenu process exists. |
+| Forms | Six field types, custom validators, groups, visibility rules, reset, and submit callbacks | Text, checkbox, select, pure field, group, and submit validators, field errors, first-error focus, paste cleaning, mouse focus, and submit messages | Replaced for validation flow. Some field types and conditional visibility remain reduced. |
+| Viewports | Scroll bars, drag control, `scroll_into_view/3`, content dimensions, and visible fractions | Pure geometry, visible ranges, `scroll_into_view/3`, optional local scrollbars, keyboard and wheel scroll, and drag state | Retained through a pure replacement. |
+| Split panes | Multiple panes, collapse, keyboard resize, persistence, and mouse drag | Named multi-pane weights, collapse state, measured layout, keyboard resize, local separator drag, and versioned pure layout serialization | Retained. The parent selects storage and persistence time. TermUI performs no layout file IO. |
+| Toasts | Timed toast collection with callback-style control | Pure bounded independent managers support add, replace, dismiss, count limits, manual ticks, and token-safe timer commands | Retained through a pure replacement with application-owned timing. |
+| Process and cluster views | Widgets performed process inspection, polling, node monitoring, and distributed RPC | Pure widgets accept stable output from optional one-shot process, supervision, and cluster providers | Replaced. The parent selects sources, RPC, authorization, polling, and failure policy. |
+| Themes and capability fallback | Global theme registry and semantic component styles | `TermUI.Theme` stores named styles, variants, values, merge data, and a capability-limited copy | Replaced. The global registry remains removed. |
+| Focus and shortcuts | Global focus manager, traversal groups, and shortcut sequence service | `TermUI.Focus` routes enabled traversal order; `TermUI.Shortcut` routes chords and timestamp-bounded sequences | Replaced with application-owned pure values. |
+| Layout solver | Constraint solver, alignment values, and a layout cache | Direct frame composition and pure row, column, and grid tracks for fixed, fill, percentage, ratio, bounds, and measured content | Replaced for common constraints. The general solver and global cache remain removed. |
+| Developer tools | Hot reload, UI and state inspectors, and a performance monitor | Deterministic backend tests and runtime instrumentation through normal application state | Partial. Add optional inspection data APIs before any live tool. |
+| Test toolkit | Component harness, event simulator, and test renderer shipped in `lib/` | Public deterministic v2 backend with event injection, complete frames, and shutdown snapshots | Replaced. Tests use the runtime, event, command, and frame contracts without component processes. |
+| Platform and SSH adapters | Unix and Windows adapter modules; the published Hex archive did not contain an SSH backend | Raw, TTY, and SSH backends own complete sessions | Replaced. SSH has a direct host API and an OTP SSH channel callback with isolated runtimes and bounded frame output. |
+
+## Recommended order
+
+1. Add richer form field data when a Jido Console use case needs it.
+2. Add optional inspection data APIs before any live developer tool.
+
+Bounded assistant output and rich Markdown no longer need the old component
+ownership model. Optional syntax highlighting also keeps pure widget state.
diff --git a/guides/interaction.md b/guides/interaction.md
new file mode 100644
index 00000000..f3087c07
--- /dev/null
+++ b/guides/interaction.md
@@ -0,0 +1,138 @@
+# Clipboard, selection, and mouse interaction
+
+TermUI keeps interaction state in the Elm application. It does not use a
+global mouse registry, a selection process, or direct clipboard writes.
+
+## External input normalization
+
+Use `TermUI.Input` when an external UI adapter sends input to TermUI. Keep
+printable text, committed input-method composition, paste, and special keys
+explicit:
+
+```elixir
+text = TermUI.Input.text("Jido 👩💻")
+composition = TermUI.Input.composition("e\u0301")
+paste = TermUI.Input.paste("first\nsecond")
+enter = TermUI.Input.special_key("Return")
+save = TermUI.Input.special_key("s", modifiers: [:control])
+```
+
+Pass `text`, `composition`, or `paste` to a focused text widget. `text/2` and
+`composition/2` keep a Unicode or multi-codepoint string in one
+`TermUI.Event.Text` value. Call `composition/2` only for committed text, not
+for an input method's partial display value. Paste stays a
+`TermUI.Event.Paste` value.
+
+Pass `save` to `TermUI.Shortcut.route/2`. The key helper converts common names
+such as `Return` and `ArrowUp`, and modifiers such as `control` and `option`.
+It rejects unmodified printable input. Use `text/2` for that input. No helper
+turns all printable text into legacy key-character values.
+
+## Clipboard commands
+
+`TermUI.Clipboard.copy/2` and `TermUI.Clipboard.clear/1` create command data.
+The runtime sends each operation through the backend owner. Thus, clipboard
+output is in sequence with frame output and terminal cleanup.
+
+```elixir
+def update({:copy, text}, state) do
+ {state, [TermUI.Clipboard.copy(text, on_result: &{:clipboard_done, &1})]}
+end
+
+def update({:clipboard_done, :ok}, state), do: state
+def update({:clipboard_done, {:error, reason}}, state), do: put_error(state, reason)
+```
+
+The result mapper receives `:ok` or `{:error, reason}`. A backend that does not
+implement clipboard output returns an error. Clipboard data has a 100,000-byte
+default limit. Use `:max_bytes` to set a smaller or larger positive limit.
+
+The implementation uses OSC 52. The available targets are `:clipboard`,
+`:primary`, and `:secondary`. `osc52_supported?/0` is only a terminal
+heuristic. A terminal can still refuse the operation.
+
+Bracketed paste is separate from clipboard output. A backend enables bracketed
+paste and reports its content as `TermUI.Event.Paste`.
+
+## Text selection
+
+`TermUI.Selection` is pure data. Positions are zero-based Unicode grapheme
+offsets. A range is half-open: `{start, finish}` includes `start` and excludes
+`finish`.
+
+```elixir
+selection =
+ TermUI.Selection.new()
+ |> TermUI.Selection.start(1)
+ |> TermUI.Selection.extend(3)
+
+TermUI.Selection.extract(selection, "a界🙂z")
+#=> "界🙂"
+```
+
+The module supports forward and backward ranges, replacement, select all,
+word selection, and line selection. `TextInput` and `TextArea` support:
+
+- Shift with Left, Right, Home, and End.
+- Shift with Up and Down in `TextArea`.
+- Ctrl+A, Ctrl+C, and Ctrl+X.
+- Mouse press and drag selection.
+- Paste or text replacement of the selected range.
+- Selection removal with Backspace or Delete.
+
+Copy and cut actions return `{:copy, text}` to the parent. The parent can
+convert this message to `TermUI.Clipboard.copy/2`.
+
+## Mouse routing
+
+Terminal mouse coordinates are zero-based. Build regions from the same layout
+that creates the frame. Then route the event before you call a child widget.
+
+Raw terminals do not enable mouse reporting by default because it changes the
+terminal's native text selection. Enable the smallest mode that your
+application needs:
+
+```elixir
+TermUI.run(MyApp,
+ backend: :raw,
+ backend_opts: [mouse_tracking: :drag]
+)
+```
+
+The modes are `:none`, `:click`, `:drag`, and `:all`. Use `:click` for press
+and release events. Use `:drag` for button motion. Use `:all` only when hover
+motion is necessary.
+
+```elixir
+regions = [
+ TermUI.Mouse.region(:list, 2, 3, 30, 10),
+ TermUI.Mouse.region(:dialog, 8, 5, 40, 12, z_index: 10)
+]
+
+case TermUI.Mouse.route(regions, event) do
+ {:ok, :list, local_event} ->
+ TermUI.Widget.mouse(TermUI.Widget.List, local_event, state.list, {30, 10})
+
+ {:ok, :dialog, local_event} ->
+ handle_dialog_mouse(local_event, state)
+
+ :none ->
+ {state, []}
+end
+```
+
+The highest `:z_index` wins. The later region wins when two regions have the
+same z-index. `route_all/2` returns all matches in front-to-back order.
+
+`TermUI.Mouse.Tracker` gives pure hover and drag state. Its default drag
+threshold is one terminal cell. Store the tracker in the application state.
+Reset it when focus is lost.
+
+Widgets can implement the optional `mouse/3` callback. Call it through
+`TermUI.Widget.mouse/4`. The helper uses the widget's `mouse/3` callback when
+it exists. Otherwise, it sends the event to `update/2`.
+
+Interactive catalog widgets support local mouse input. This includes text
+inputs, buttons, lists, menus, pick lists, command palettes, dialogs, forms,
+tabs, tables, trees, scrollbars, and split panes. Scrollable content widgets
+also accept mouse wheel events through `update/2`.
diff --git a/guides/markdown-and-diffs.md b/guides/markdown-and-diffs.md
new file mode 100644
index 00000000..2f3202fb
--- /dev/null
+++ b/guides/markdown-and-diffs.md
@@ -0,0 +1,76 @@
+# Markdown and diff viewers
+
+## Markdown
+
+`TermUI.Markdown` parses Markdown with MDEx. It returns styled frame rows.
+
+```elixir
+rows = TermUI.Markdown.render(markdown, 80)
+frame = TermUI.Frame.from_rows(rows, 80, 24)
+```
+
+Use `TermUI.Widget.MarkdownViewer` for scrolling and code-block selection. A
+copy action returns `{:copy, code}` to the parent. The widget does not access
+the system clipboard.
+
+```elixir
+viewer = TermUI.Widget.MarkdownViewer.init(content: markdown)
+{viewer, messages} = TermUI.Widget.MarkdownViewer.update(event, viewer)
+frame = TermUI.Widget.MarkdownViewer.view(viewer, {80, 24})
+```
+
+For streaming content, `append/2` uses a bounded `TermUI.Markdown.Document`.
+Completed top-level blocks are parsed once. The final paragraph, list, or fenced
+code block remains pending because more source can still extend it. Rendering
+reparses only that unfinished tail.
+
+```elixir
+viewer = TermUI.Widget.MarkdownViewer.init(content_limit: 2_000_000)
+viewer = TermUI.Widget.MarkdownViewer.append(viewer, token_fragment)
+```
+
+The viewer supports headings, emphasis, strong and strike-through text, inline
+code, links, images, quotes, ordered and unordered lists, task lists, code
+blocks, rules, and tables. Raw HTML is reduced to terminal-safe text.
+
+### Optional syntax highlighting
+
+Plain code rendering has no lexer dependency. To enable highlighting, set a
+module that implements `TermUI.SyntaxHighlighter`:
+
+```elixir
+viewer =
+ TermUI.Widget.MarkdownViewer.init(
+ content: markdown,
+ highlighter: MyApp.SyntaxAdapter,
+ highlight_limit: 100_000
+ )
+```
+
+An adapter returns `{:ok, [{token_type, text}]}`, `:skip`, or
+`{:error, reason}`. Token text must reproduce the complete input. Known token
+families receive terminal styles. Unknown token types keep the plain code
+style. An absent or failed adapter also falls back to plain code.
+
+`TermUI.SyntaxHighlighter.Makeup` supports Elixir and Erlang when the host
+application installs `:makeup`, `:makeup_elixir`, and `:makeup_erlang`. TermUI
+does not require those packages. The default `:highlight_limit` is 100,000
+bytes per code block. Larger blocks remain complete but bypass the adapter.
+
+## Diffs
+
+Create a diff from two texts:
+
+```elixir
+viewer =
+ TermUI.Widget.DiffViewer.init(
+ before: old_text,
+ after: new_text,
+ old_label: "a/file.ex",
+ new_label: "b/file.ex"
+ )
+```
+
+Or supply an existing unified diff with `:unified_diff`. Press `s` to switch
+between unified and side-by-side views. The viewer uses line-based Myers
+comparison and bounds input to 5,000 lines by default.
diff --git a/guides/migration-1.0.md b/guides/migration-1.0.md
new file mode 100644
index 00000000..b5f35815
--- /dev/null
+++ b/guides/migration-1.0.md
@@ -0,0 +1,132 @@
+# Migration to TermUI 1.0.0-rc.1
+
+TermUI 1.0.0-rc.1 is a breaking redesign of the published 1.0.0-rc package.
+It removes the earlier release candidate's component and render systems. It
+does not provide compatibility aliases for systems that no longer match the
+runtime design.
+
+## Public replacements
+
+| 1.0.0-rc and earlier | 1.0.0-rc.1 |
+| --- | --- |
+| `TermUI.App` | `TermUI.run/2`, `TermUI.start_link/2`, or `TermUI.Runtime` |
+| `TermUI.Component` and `TermUI.StatefulComponent` | One `TermUI.Elm` application or a pure `TermUI.Widget` |
+| Component servers, registry, and supervisor | Parent-owned state in the root application |
+| `TermUI.Component.RenderNode` | `TermUI.Frame` |
+| Renderer buffers and tuple nodes | `TermUI.Frame` |
+| `TermUI.Renderer.Cell` | `TermUI.Cell` |
+| `TermUI.Renderer.Style` | `TermUI.Style` |
+| `TermUI.Input.*` and terminal input readers | The `TermUI.Backend` input callback |
+| External-input SSH adapter | `TermUI.Backend.SSH` complete session backend |
+| Printable `Event.Key.char` input | `TermUI.Event.Text` |
+| Component command tuples | `TermUI.Command` constructors |
+| `TermUI.Widgets.Sparkline` | Deprecated facade for `TermUI.Widget.Sparkline` |
+| Other `TermUI.Widgets.*` modules | Use the [widget parity table](widget-parity.md); no plural facade exists unless the row is `Direct` |
+| `TermUI.Layout.Constraint` values and solver | Direct pure `TermUI.Layout` tracks |
+
+The widget feature set is available under the singular namespace, but a name
+match does not prove behavior parity. For example, the v2 table and Markdown
+viewer have different state or effect contracts, so their plural names are
+not available. Use the [widget parity table](widget-parity.md) to select the
+documented replacement. Widgets now return `TermUI.Frame` and never require a
+component PID.
+
+`TermUI.Widgets.Sparkline` is the only deprecated plural facade. Its stateless
+numeric mapping has a direct pure replacement. The facade returns a v2 frame,
+not a v1 render node. All its deprecation messages name
+`TermUI.Widget.Sparkline`.
+
+`TermUI.App.start/2`, `TermUI.App.run/2`, and `TermUI.App.shutdown/1` remain as
+deprecated v2 runtime delegates for all v2 releases. `TermUI.App.run/2` keeps
+the v1 `{:ok, :exited_normally}` result. New code must use the replacements in
+the table. The facade does not include the v1 global `backend_mode/0` and
+`supports?/1` queries. Use `TermUI.Runtime.capabilities/1` for one runtime.
+
+`TermUI.Command.quit/0` and `quit/1` are deprecated aliases for
+`TermUI.Command.shutdown/0` and `shutdown/1`. The deprecated
+`TermUI.Runtime.send_message/3` accepts only the old `:root` target and sends
+the value through `send_message/2`. A component target returns a migration
+error. Move that routing into the root application's `update/2` function.
+
+## Layout constraint replacements
+
+The v2 layout allocator covers the common v1 constraint inputs directly. It
+does not use the v1 constraint structs, solver, or cache.
+
+| v1 input | v2 track |
+| --- | --- |
+| `Constraint.length(20)` | `Layout.fixed(20)` or `20` |
+| `Constraint.fill()` | `Layout.fill()` or `:fill` |
+| `Constraint.percentage(30)` | `Layout.percentage(30)` |
+| `Constraint.ratio(2)` | `Layout.ratio(2)` or `{:weight, 2}` |
+| `constraint |> Constraint.with_min(10)` | `Layout.bounded(track, min: 10)` |
+| `constraint |> Constraint.with_max(50)` | `Layout.bounded(track, max: 50)` |
+| Measured content with bounds | `Layout.content(measured_size, min: 5, max: 50)` |
+
+Use the tracks in `row/3` and `column/3`. A constrained grid accepts
+`:column_tracks` and `:row_tracks`.
+
+```elixir
+root = TermUI.Layout.new({100, 30})
+[header, body] = TermUI.Layout.column(root, [3, :fill])
+
+[navigation, main] =
+ TermUI.Layout.row(body, [
+ TermUI.Layout.percentage(25),
+ TermUI.Layout.bounded(:fill, min: 30)
+ ])
+
+cells =
+ TermUI.Layout.grid(main, 4,
+ column_tracks: [TermUI.Layout.content(label_width, max: 20), :fill],
+ row_tracks: [TermUI.Layout.ratio(1), TermUI.Layout.ratio(1)]
+ )
+```
+
+Minimum bounds apply when the parent has sufficient space. If all minimums
+are larger than the parent, the allocator reduces them proportionally. Thus,
+all rectangles stay inside the parent.
+
+## Temporary v1 configuration
+
+The v2 entry points read these v1 application environment keys when the
+matching explicit option is absent:
+
+| v1 application environment key | Temporary v2 mapping |
+| --- | --- |
+| `:backend` | `:backend` runtime option |
+| `:color_mode` | `:backend_opts` `:color_mode` preference |
+| `:character_set` | `:backend_opts` `:character_set` preference |
+| `:render_interval` | `:render_interval` runtime option |
+| `:iex_compatible` | `:tty` for `true`; automatic backend selection for `false` or `:auto` |
+
+Each used key emits one deprecation warning for the life of the VM. Explicit
+v2 options take precedence. Built-in backends can reduce detected color and
+Unicode support from these preferences. They do not increase reported
+capabilities. Invalid old values return an `:invalid_legacy_config` error.
+
+Public boundary structs derive their fields and defaults from Zoi schemas.
+Private runtime and widget state uses plain structs. Direct struct update
+syntax still works. Use the public `schema/0` functions when untrusted data
+enters TermUI from an external source.
+
+## Required application changes
+
+1. Select one root module and use `TermUI.Elm`.
+2. Move child process state into the root state or a normal domain process.
+3. Convert terminal events in `event_to_msg/2`.
+4. Return command structs from `update/2`.
+5. Replace render nodes and buffers with `TermUI.Frame.from_rows/4` or cell writes.
+6. Handle `Event.Resize` and store the new `{columns, rows}`.
+7. Start with `TermUI.run(MyApp)` or `TermUI.start_link(MyApp)`.
+
+## Backend changes
+
+Replace cursor, clear, and cell-list render callbacks with `draw/2`. The value
+passed to `draw/2` is the complete frame. Keep terminal input, output, size,
+cursor, capability detection, setup, and cleanup inside the backend.
+
+The old external-input SSH adapter becomes `TermUI.Backend.SSH`. Start one
+session for each remote channel. The backend owns parsing, size, rendering,
+output bounds, and cleanup. Use `TermUI.Backend.SSH.Channel` with OTP SSH, or
+use the direct session API when the application already owns an SSH server.
diff --git a/guides/package-quality.md b/guides/package-quality.md
new file mode 100644
index 00000000..ec60600c
--- /dev/null
+++ b/guides/package-quality.md
@@ -0,0 +1,67 @@
+# Package quality
+
+TermUI follows the [Jido package quality standard](https://jido.run/docs/contributors/package-quality-standards)
+where it does not conflict with the public TermUI contract. The shared
+[`agentjido/github-actions`](https://github.com/agentjido/github-actions)
+repository is the source of truth for workflow versions. TermUI uses the v5
+callers because v5 is the current stable workflow contract. The standards page
+still contains some older v4 examples.
+
+## Compliance record
+
+| Area | TermUI decision |
+| --- | --- |
+| Package identity | README, Hex metadata, guides, and module docs describe the Elm runtime and its use by Jido Console. |
+| Repository files | `AGENTS.md`, `CHANGELOG.md`, `CONTRIBUTING.md`, `LICENSE`, `usage-rules.md`, examples, guides, and environment config files are present. |
+| Public data | Public boundary values use Zoi schemas. Private runtime and widget state uses plain structs. |
+| Quality command | `mix quality` runs format, warning-free compile, xref cycle checks, strict Credo, Dialyzer, and Doctor. |
+| Tests and coverage | `mix coveralls` runs deterministic tests and enforces at least 90% line coverage without excluding production modules. |
+| CI | The v5 shared CI caller tests each declared Elixir and OTP pair. The native-policy jobs also test the source NIF on the minimum pair. CI checks audit, docs, package content, and unused dependencies. |
+| Dependency updates | Dependabot checks Mix and GitHub Actions dependencies each week with Conventional Commit titles. |
+| Releases | The v5 shared release caller uses `git_ops` for release preparation and Hex publication. |
+| Review | The v5 shared advisory review caller checks pull requests to `develop`. |
+| Examples | The runnable counter and showcase examples are outside `lib/` and have their own Mix projects. |
+| Worktree safety | The package does not auto-install Git hooks and does not store local worktree paths. |
+
+## Documented exceptions
+
+- The public namespace stays `TermUI`, not `Jido.TermUI`. This preserves the
+ upstream API and the Jido Console contract.
+- TermUI keeps small tagged error tuples at backend and runtime boundaries. It
+ does not add Splode during the release candidate because that would change
+ the public failure contract.
+- TermUI has no installer. It needs no project files, configuration, database
+ changes, or generated code, so an Igniter installer would have no work to do.
+- `develop` is the default branch, so CI push and review filters use `develop`
+ instead of the shared examples' `main` branch.
+
+Review these exceptions before a stable 1.0 release. Do not remove them by
+changing the runtime or public namespace in a quality-only change.
+
+## Supported runtime matrix
+
+The package requires Elixir `>= 1.18.4 and < 2.0.0`. TermUI declares these CI
+pairs:
+
+| Erlang/OTP | Elixir | Purpose |
+| --- | --- | --- |
+| 28 | 1.18.4 | Minimum supported pair and minimum source-NIF test |
+| 28 | 1.19 | Supported Elixir line |
+| 28 | 1.20 | Supported Elixir line on the minimum OTP release |
+| 29 | 1.20 | Newest supported OTP release and source/disabled NIF policies |
+
+The minimum is the intersection of two technical limits:
+
+- OTP 28 is required by the raw terminal contract. The real PTY test must
+ receive Ctrl+O, Ctrl+C, Ctrl+S, and Ctrl+Q as data. OTP 26 and OTP 27 compile
+ the source NIF and pass the non-terminal suite, but the PTY probe does not
+ receive those bytes. TermUI does not claim support for a runtime that loses
+ input bytes.
+- Elixir 1.18.4 is the first Elixir 1.18 patch with initial OTP 28 support. The
+ [Elixir 1.18 changelog](https://elixir.hexdocs.pm/1.18/changelog.html#v1-18-4-2025-05-21)
+ documents that compatibility. Older Elixir lines can compile much of TermUI,
+ but their supported OTP ranges end before OTP 28.
+
+The CI matrix and the package requirement must change together. A new minimum
+must pass the complete suite and the source-NIF PTY test. A compile-only result
+or a run with `TERM_UI_TTY_NIF=disabled` is not enough.
diff --git a/guides/removed-and-deferred.md b/guides/removed-and-deferred.md
new file mode 100644
index 00000000..f7913f68
--- /dev/null
+++ b/guides/removed-and-deferred.md
@@ -0,0 +1,66 @@
+# Removed and deferred features
+
+The 1.0 release candidate has one runtime, one input path, one frame type, and
+one widget namespace. Some old features did not fit this design.
+
+## Intentional architecture removals
+
+These systems will not return in their old form:
+
+| Removed system | Replacement |
+| --- | --- |
+| Component server, registry, supervisor, containers, and state persistence | One Elm runtime and parent-owned pure widget state |
+| Event router, event queue, message queue, and global focus manager | The Elm application serializes events and owns focus |
+| Legacy raw, TTY, selector, and line-reader input modules | The selected backend owns one input path |
+| Render nodes, renderer buffers, buffer managers, and renderer style/cell copies | `TermUI.Frame`, `TermUI.Cell`, and backend rendering |
+| `TermUI.Widgets` and process-based widgets | `TermUI.Widget` pure state transitions |
+| Global spatial index and mouse tracker | Pure `TermUI.Mouse.Region` lists and application-owned `TermUI.Mouse.Tracker` data |
+| Global configuration and persistent-term caches | Runtime and widget options stored by their owner |
+
+## Deferred optional features
+
+These features are not in the new package:
+
+- A global theme registry.
+- A generic theme, focus, shortcut, and mouse context. The
+ [UI context decision](ui-context.md) keeps these values in their smallest
+ application-owned scopes.
+- A constraint layout solver, layout cache, and alignment objects.
+- A shared shortcut service and global focus traversal groups.
+- Development hot reload, UI inspection, state inspection, and performance tools.
+- The component test harness and mutable test renderer. Use
+ `TermUI.Test.DeterministicBackend` for v2 runtime tests.
+- Dedicated Unix and Windows platform adapter modules.
+- The old set of one application for each widget. The counter is the retained
+ general example.
+
+SSH has returned as a normal backend that owns one complete remote session.
+`TermUI.Backend.SSH.Channel` connects OTP SSH channel data and PTY changes to
+that backend. It does not own daemon authentication or connection policy.
+`TermUI.Theme`, `TermUI.Focus`, and `TermUI.Shortcut` now provide the safe pure
+forms. The global service forms remain removed. These values keep the core
+design unchanged.
+
+Clipboard, selection, and mouse support have returned in refined forms.
+Clipboard writes are bounded command data. Selection uses Unicode grapheme
+positions. Mouse regions, hit testing, hover, and drag state are pure data.
+
+## Widget behavior that became smaller
+
+The widget names are present, but some old adapters and services are not:
+
+| Widget area | Current behavior | Removed behavior |
+| --- | --- | --- |
+| Stream | Bounded batches, counters, overflow policies, and an optional acknowledged producer adapter | Widget-owned GenStage consumer |
+| Process, supervision, and cluster views | Parent-supplied data or optional bounded one-shot providers | Widget-owned polling, implicit distributed RPC, and monitoring processes |
+| Toast | Independent pure managers with explicit timer commands, safe expiry messages, and manual `tick/2` | Timer process and global stack service |
+| Line input | Pure event-driven input | Blocking shell `IO.gets/1` adapter |
+| Markdown code blocks | Emits copy data and supports bounded optional syntax-highlighter adapters | Direct clipboard writes and required Makeup integration |
+| Menu and context menu | Nested data, pure open-path state, keyboard and mouse control, and edge-fitted overlay positions | Submenu processes and inline service variants |
+| Table | Pure sorting, filtering, scrolling, columns, and identity-based row selection | Callback execution and a sorting service process |
+| Form | Text, checkbox, select, and pure field, group, and submit validation | Field processes and callback execution |
+| Split pane and viewport | Pure multi-pane state, versioned layout serialization, geometry, scrollbars, and local drag state | Global mouse routing and automatic persistence services |
+
+Applications can add these effects in their Elm update function. A reusable
+adapter belongs in TermUI only when it can stay pure or follow the backend and
+command contracts.
diff --git a/guides/showcase.md b/guides/showcase.md
new file mode 100644
index 00000000..0d685495
--- /dev/null
+++ b/guides/showcase.md
@@ -0,0 +1,59 @@
+# Interactive showcase
+
+The TermUI showcase is a representative executable demo of the public
+application, widget, frame, event, command, and clipboard contracts. It also
+explains the ownership rules from inside a running terminal application.
+
+## Run the application
+
+Use a terminal of at least 80 columns by 24 rows when possible.
+
+```sh
+git clone https://github.com/pcharbon70/term_ui.git
+cd term_ui/examples/showcase
+mix deps.get
+mix run run.exs
+```
+
+See the [showcase source and full control
+list](https://github.com/pcharbon70/term_ui/tree/develop/examples/showcase).
+
+Press Escape and then 1 through 6 to select a page. This command menu does not
+depend on terminal function-key settings.
+
+## What it demonstrates
+
+- The Overview page composes live BEAM gauges, progress, a sparkline, bars, and
+ a process table.
+- The Inputs page routes events to parent-owned text, list, and button state.
+- The Content page shows Markdown, diff, a live refresh stream, and clipboard
+ command output.
+- The BEAM page renders live parent-supplied process, runtime-link, and cluster
+ snapshots.
+- The Architecture page explains the active application, frame, and backend
+ seams.
+- The Controls page shows checkbox, toggle, radio group, select, spinner, and
+ breadcrumb widgets with parent-owned state.
+
+## Application structure
+
+`Showcase.App` is the only Elm application. It owns global state, all page and
+widget state, timers, asynchronous collection commands, clipboard commands,
+terminal dimensions, and final frame composition.
+
+Each page is a pure adapter:
+
+```elixir
+{page_state, messages} = Page.update(event, page_state)
+frame = Page.view(page_state, dimensions, theme)
+```
+
+A page does not start a process or nested runtime. `Showcase.LiveData` collects
+VM metrics, process details, runtime links, and connected-node data in a
+runtime-managed asynchronous command. The command result updates parent-owned
+widget state. The widgets do not perform process inspection or RPC.
+
+Use `Showcase.App.run(data_mode: :snapshot)` when deterministic output is
+required. The showcase tests use this mode. CI renders every page at normal and
+compact sizes and checks live collection, input routing, timers, and clipboard
+command output.
diff --git a/guides/ui-context.md b/guides/ui-context.md
new file mode 100644
index 00000000..8fdcf145
--- /dev/null
+++ b/guides/ui-context.md
@@ -0,0 +1,92 @@
+# UI context decision
+
+TermUI does not provide a generic `TermUI.Context` value in the 1.0 release
+candidate. The v2 examples do not show repeated root wiring across theme,
+focus, shortcuts, and mouse state.
+
+## Evidence from the v2 examples
+
+The review used both runnable applications and every showcase page.
+
+| Scope | Theme state | Focus or active state | Shortcut state | Mouse tracker state |
+| --- | ---: | ---: | ---: | ---: |
+| `IExCounter.App` root | 0 | 0 | 0 | 0 |
+| `Showcase.App` root | 1 | 0 | 0 | 0 |
+| Showcase page states | 0 | 4 local values | 0 | 0 |
+
+The showcase passes its theme through one root-to-page boundary,
+`Showcase.Page.view/3`. Six page modules implement that boundary. The root does
+not pass focus, shortcut, or mouse state through it.
+
+The Inputs and Controls pages each own a small focus order. The Content and
+BEAM pages each own an active view selector. These four values have different
+lifetimes and meanings. Moving them to the application root would make the
+root know details that belong to each page.
+
+The showcase has two command-key mapping groups with five common keys. It does
+not store shortcut sequence state. An application that needs chords or key
+sequences can store a `TermUI.Shortcut` value directly. A generic context does
+not remove those mappings.
+
+Neither example uses mouse tracker state. Widgets receive local mouse events
+only when the parent that owns their layout routes those events.
+
+## Decision
+
+A library context would not reduce the measured boundary count. It would
+replace the one theme argument with a larger value that has unused fields. It
+would also invite the root application to own page-local focus and mouse data.
+
+TermUI keeps the four pure values independent:
+
+- Store `TermUI.Theme` at the highest parent that shares the theme.
+- Store `TermUI.Focus` at the parent that owns that focus order.
+- Store `TermUI.Shortcut` at the scope that owns the bindings.
+- Store `TermUI.Mouse.Tracker` at the parent that draws and routes the matching
+ regions.
+
+This decision can change when a real v2 application must pass at least three
+of these values together through several of the same boundaries. A future
+proposal must include that application evidence and must remain a pure,
+application-owned value.
+
+## Preferred explicit wiring
+
+Update each value with its own pure function and put the returned value back in
+the owning model:
+
+```elixir
+{focus, focus_messages} = TermUI.Focus.route(event, state.focus)
+{shortcuts, shortcut_messages} = TermUI.Shortcut.route(event, state.shortcuts)
+
+state = %{state | focus: focus, shortcuts: shortcuts}
+messages = focus_messages ++ shortcut_messages
+```
+
+Query only the value that a child needs:
+
+```elixir
+focused? = TermUI.Focus.focused?(state.focus, :editor)
+panel_style = TermUI.Theme.style(state.theme, :panel)
+
+child_frame = Editor.view(state.editor, dimensions,
+ focused: focused?,
+ panel_style: panel_style
+)
+```
+
+For mouse input, build regions from the same layout that creates the frame.
+Keep the tracker beside that layout state:
+
+```elixir
+{mouse, drag_messages} = TermUI.Mouse.Tracker.update(state.mouse, event)
+
+case TermUI.Mouse.route(regions, event) do
+ {:ok, id, local_event} -> route_child_mouse(id, local_event, %{state | mouse: mouse})
+ :none -> {%{state | mouse: mouse}, drag_messages}
+end
+```
+
+An application can define its own struct when its values always move together.
+TermUI does not standardize that application-specific shape and does not store
+it in a process, registry, application environment, or persistent term.
diff --git a/guides/user/01-overview.md b/guides/user/01-overview.md
deleted file mode 100644
index 48241355..00000000
--- a/guides/user/01-overview.md
+++ /dev/null
@@ -1,139 +0,0 @@
-# TermUI Overview
-
-TermUI is a direct-mode Terminal UI framework for Elixir/BEAM applications. It enables building rich, interactive terminal interfaces that leverage the BEAM's unique strengths: fault tolerance, the actor model, hot code reloading, and distribution.
-
-## What is TermUI?
-
-TermUI provides everything you need to build terminal-based user interfaces:
-
-- **The Elm Architecture** - A proven pattern for building interactive UIs with predictable state management
-- **Rich Widget Library** - Pre-built components like gauges, tables, sparklines, and more
-- **Declarative Styling** - Fluent API for colors, attributes, and themes
-- **Flexible Layout** - Constraint-based layout system with automatic sizing
-- **Full Input Support** - Keyboard, mouse, paste, and focus events
-- **High Performance** - Differential rendering at 60 FPS with minimal terminal updates
-
-## Architecture Overview
-
-```
-┌─────────────────────────────────────────────────────────┐
-│ Your Application │
-│ ┌─────────────────────────────────────────────────┐ │
-│ │ Elm Components │ │
-│ │ init → event_to_msg → update → view │ │
-│ └─────────────────────────────────────────────────┘ │
-├─────────────────────────────────────────────────────────┤
-│ TermUI Runtime │
-│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
-│ │ Events │ │ Commands │ │ Renderer │ │
-│ └──────────┘ └──────────┘ └──────────┘ │
-├─────────────────────────────────────────────────────────┤
-│ Terminal Layer │
-│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
-│ │ Raw Mode │ │ Mouse │ │ Screen │ │
-│ └──────────┘ └──────────┘ └──────────┘ │
-└─────────────────────────────────────────────────────────┘
-```
-
-## Core Concepts
-
-### The Elm Architecture
-
-TermUI uses The Elm Architecture, a pattern for building interactive programs:
-
-1. **Model** - Your application state (a plain Elixir map or struct)
-2. **Update** - A function that takes a message and state, returns new state
-3. **View** - A function that renders state to the screen
-
-```elixir
-defmodule Counter do
- use TermUI.Elm
-
- def init(_opts), do: %{count: 0}
-
- def event_to_msg(%Event.Key{key: :up}, _state), do: {:msg, :increment}
- def event_to_msg(%Event.Key{key: :down}, _state), do: {:msg, :decrement}
- def event_to_msg(_, _), do: :ignore
-
- def update(:increment, state), do: {%{state | count: state.count + 1}, []}
- def update(:decrement, state), do: {%{state | count: state.count - 1}, []}
-
- def view(state) do
- text("Count: #{state.count}")
- end
-end
-```
-
-### Events and Messages
-
-Terminal input (keys, mouse, resize) arrives as **events**. Your component converts events to **messages** via `event_to_msg/2`. Messages drive state changes through `update/2`.
-
-### Commands
-
-Side effects (timers, file I/O, etc.) are represented as **commands** returned from `update/2`. The runtime executes them asynchronously and delivers results back as messages.
-
-### Rendering
-
-The `view/1` function returns a **render tree** - a declarative description of what should appear on screen. TermUI diffs this against the previous frame and sends only the changes to the terminal.
-
-## Key Features
-
-### Widgets
-
-Pre-built components for common UI patterns:
-
-| Widget | Description |
-|--------|-------------|
-| `Gauge` | Progress bar with color zones |
-| `Sparkline` | Compact inline trend graph |
-| `Table` | Scrollable data table |
-| `Menu` | Selectable menu items |
-| `TextInput` | Text entry field |
-| `Dialog` | Modal dialog box |
-
-### Styling
-
-Rich styling with colors and attributes:
-
-```elixir
-Style.new(fg: :cyan, bg: :black, attrs: [:bold, :underline])
-```
-
-Supports 16 colors, 256-color palette, and true color (24-bit RGB).
-
-### Layout
-
-Declarative constraints for flexible layouts:
-
-```elixir
-stack(:horizontal, [
- {gauge, Constraint.percentage(30)},
- {table, Constraint.fill()}
-])
-```
-
-### Terminal Features
-
-TermUI supports two backend modes with automatic selection:
-
-- **Raw Mode** - Full TUI experience with alternate screen, character-by-character input, and mouse support
-- **TTY Mode** - IEx-compatible mode for development and debugging
-
-See [Getting Started: Backends](02-getting-started.md#understanding-backends-raw-vs-tty) for details on when each mode is used.
-
-## Requirements
-
-- Elixir 1.15+
-- OTP 28+
-- A terminal emulator with ANSI support
-
-## Next Steps
-
-- [Getting Started](02-getting-started.md) - Build your first TermUI app
-- [The Elm Architecture](03-elm-architecture.md) - Deep dive into the component model
-- [Events](04-events.md) - Handle keyboard, mouse, and other input
-- [Styling](05-styling.md) - Colors, attributes, and themes
-- [Layout](06-layout.md) - Positioning and sizing components
-- [Widgets](07-widgets.md) - Using built-in widgets
-- [Terminal](08-terminal.md) - Low-level terminal control
-- [Commands](09-commands.md) - Side effects and async operations
diff --git a/guides/user/02-getting-started.md b/guides/user/02-getting-started.md
deleted file mode 100644
index 3baa484c..00000000
--- a/guides/user/02-getting-started.md
+++ /dev/null
@@ -1,293 +0,0 @@
-# Getting Started
-
-This guide walks you through creating your first TermUI application.
-
-## Installation
-
-Add TermUI to your dependencies in `mix.exs`:
-
-```elixir
-def deps do
- [
- {:term_ui, path: "../term_ui"} # Or from Hex when published
- ]
-end
-```
-
-Then fetch dependencies:
-
-```bash
-mix deps.get
-```
-
-## Understanding Backends: Raw vs TTY
-
-TermUI supports two terminal backends that are automatically selected based on your environment:
-
-### Raw Mode (Full TUI Experience)
-
-Raw mode provides complete terminal control:
-
-- **Alternate screen buffer** - Preserves your shell history
-- **Character-by-character input** - No line buffering
-- **Full mouse support** - Click, drag, and scroll events
-- **Live UI updates** - Smooth 60 FPS rendering
-
-**When it's used:**
-- Running from command line (`mix run`, `mix termui.run`)
-- Terminal supports raw mode (OTP 28+)
-- No other shell is running
-
-### TTY Mode (IEx Compatible)
-
-TTY mode works inside IEx and other constrained environments:
-
-- **No alternate screen** - Output appears directly in terminal
-- **Immediate character input** - Uses `:io.get_chars/2` for IEx compatibility
-- **Reduced feature set** - Mouse support may be limited
-- **Works in IEx** - Perfect for development and debugging
-
-**When it's used:**
-- Running inside IEx
-- A shell is already running
-- Raw mode activation fails
-
-### Automatic Backend Selection
-
-TermUI automatically selects the appropriate backend:
-
-1. Attempts raw mode first
-2. Falls back to TTY mode if:
- - IEx is detected
- - A shell is already running
- - Raw mode is unavailable
-
-You can also force a specific mode:
-
-```elixir
-# Force raw mode
-TermUI.Runtime.run(root: MyApp.Counter, backend: :raw)
-
-# Force TTY mode
-TermUI.Runtime.run(root: MyApp.Counter, backend: :tty)
-```
-
-### Which Should You Use?
-
-| Scenario | Recommended Mode |
-|----------|------------------|
-| Production application | Raw (auto-detected) |
-| Development in IEx | TTY (auto-detected) |
-| Testing/Debugging | TTY for IEx convenience |
-| SSH sessions | Auto (usually TTY) |
-
-The same code works in both modes - no changes needed!
-
-## Your First Application
-
-Let's build a simple counter that responds to keyboard input.
-
-### Step 1: Create the Component
-
-Create `lib/my_app/counter.ex`:
-
-```elixir
-defmodule MyApp.Counter do
- @moduledoc """
- A simple counter component demonstrating TermUI basics.
- """
-
- use TermUI.Elm
-
- alias TermUI.Event
- alias TermUI.Renderer.Style
-
- # Initialize state
- def init(_opts) do
- %{count: 0}
- end
-
- # Convert events to messages
- def event_to_msg(%Event.Key{key: key}, _state) when key in ["q", "Q"] do
- {:msg, :quit}
- end
-
- def event_to_msg(%Event.Key{key: :up}, _state), do: {:msg, :increment}
- def event_to_msg(%Event.Key{key: :down}, _state), do: {:msg, :decrement}
- def event_to_msg(_, _state), do: :ignore
-
- # Update state based on messages
- def update(:quit, state) do
- {state, [:quit]}
- end
-
- def update(:increment, state) do
- {%{state | count: state.count + 1}, []}
- end
-
- def update(:decrement, state) do
- {%{state | count: state.count - 1}, []}
- end
-
- # Render the view
- def view(state) do
- stack(:vertical, [
- text("Simple Counter", Style.new(fg: :cyan, attrs: [:bold])),
- text(""),
- text("Count: #{state.count}", Style.new(fg: :white)),
- text(""),
- text("[↑] Increment [↓] Decrement [Q] Quit", Style.new(fg: :bright_black))
- ])
- end
-end
-```
-
-### Step 2: Create the Entry Point
-
-Create `lib/my_app.ex`:
-
-```elixir
-defmodule MyApp do
- @moduledoc """
- Entry point for the counter application.
- """
-
- def run do
- TermUI.Runtime.run(root: MyApp.Counter)
- end
-
- def start do
- TermUI.Runtime.start_link(root: MyApp.Counter)
- end
-end
-```
-
-### Step 3: Run the Application
-
-```bash
-mix termui.run
-```
-
-The `mix termui.run` command will automatically discover and run your root module (`MyApp` in this case).
-
-You should see your counter application. Press `↑` to increment, `↓` to decrement, and `Q` to quit.
-
-## Understanding the Code
-
-### The `use TermUI.Elm` Macro
-
-This sets up your module as an Elm Architecture component, importing necessary functions like `text/1`, `text/2`, and `stack/2`.
-
-### The Four Callbacks
-
-1. **`init/1`** - Called once when the component starts. Returns initial state.
-
-2. **`event_to_msg/2`** - Converts terminal events to application messages. Return values:
- - `{:msg, message}` - Send message to `update/2`
- - `:ignore` - Discard the event
- - `:propagate` - Pass to parent component
-
-3. **`update/2`** - Handles messages and returns `{new_state, commands}`. Commands are side effects like timers or quit requests.
-
-4. **`view/1`** - Returns a render tree describing what to display.
-
-### Render Tree Primitives
-
-- `text(string)` - Plain text
-- `text(string, style)` - Styled text
-- `stack(:vertical, children)` - Vertical layout
-- `stack(:horizontal, children)` - Horizontal layout
-
-## Adding More Features
-
-### Color Based on Value
-
-```elixir
-def view(state) do
- count_style = cond do
- state.count > 0 -> Style.new(fg: :green)
- state.count < 0 -> Style.new(fg: :red)
- true -> Style.new(fg: :white)
- end
-
- stack(:vertical, [
- text("Count: #{state.count}", count_style),
- # ...
- ])
-end
-```
-
-### Reset Functionality
-
-Add to `event_to_msg/2`:
-
-```elixir
-def event_to_msg(%Event.Key{key: key}, _state) when key in ["r", "R"] do
- {:msg, :reset}
-end
-```
-
-Add to `update/2`:
-
-```elixir
-def update(:reset, state) do
- {%{state | count: 0}, []}
-end
-```
-
-### Using Widgets
-
-```elixir
-alias TermUI.Widgets.Gauge
-
-def view(state) do
- # Normalize count to 0-100 range for gauge
- gauge_value = max(0, min(100, state.count + 50))
-
- stack(:vertical, [
- text("Counter with Gauge"),
- text(""),
- Gauge.render(value: gauge_value, width: 30),
- text(""),
- text("Count: #{state.count}")
- ])
-end
-```
-
-## Running in IEx
-
-For development and debugging, you can run your app in IEx using TTY mode:
-
-```bash
-iex -S mix
-```
-
-Then in IEx:
-
-```elixir
-iex> MyApp.run()
-```
-
-The app will run in TTY mode, which:
-- Works inside IEx without taking over the shell completely
-- Provides immediate character input (no Enter needed)
-- Displays output directly in the terminal
-
-For the full TUI experience with alternate screen, run from command line instead:
-
-```bash
-mix termui.run
-```
-
-or
-
-```bash
-mix run -e "MyApp.run()" --no-halt
-```
-
-## Next Steps
-
-- [The Elm Architecture](03-elm-architecture.md) - Learn the pattern in depth
-- [Events](04-events.md) - Handle all types of input
-- [Styling](05-styling.md) - Make your app visually appealing
-- [Widgets](07-widgets.md) - Use pre-built components
diff --git a/guides/user/03-elm-architecture.md b/guides/user/03-elm-architecture.md
deleted file mode 100644
index b8df543d..00000000
--- a/guides/user/03-elm-architecture.md
+++ /dev/null
@@ -1,352 +0,0 @@
-# The Elm Architecture
-
-The Elm Architecture (TEA) is the core pattern used by TermUI for building interactive applications. It provides predictable state management and a clear separation of concerns.
-
-## Overview
-
-The architecture consists of three parts:
-
-1. **Model** - The state of your application
-2. **Update** - How state changes in response to messages
-3. **View** - How state is rendered to the screen
-
-```
- ┌─────────────────────────────────────────┐
- │ │
- │ ┌─────────┐ message ┌──────────┐ │
- │ │ View │ ◄────────── │ Update │ │
- │ └────┬────┘ └────▲─────┘ │
- │ │ │ │
- │ │ render tree │ msg │
- │ ▼ │ │
- │ ┌─────────┐ event ┌────┴─────┐ │
- │ │ Runtime │ ──────────►│event_to_ │ │
- │ │ │ │ msg │ │
- │ └─────────┘ └──────────┘ │
- │ │
- └─────────────────────────────────────────┘
-```
-
-## The Four Callbacks
-
-### `init/1` - Initialize State
-
-Called once when your component starts. Receives options and returns initial state.
-
-```elixir
-def init(opts) do
- name = Keyword.get(opts, :name, "World")
- %{
- name: name,
- count: 0,
- items: []
- }
-end
-```
-
-State is typically a map, but can be any Elixir term.
-
-### `event_to_msg/2` - Convert Events to Messages
-
-Transforms terminal events into application-specific messages.
-
-```elixir
-def event_to_msg(%Event.Key{key: :enter}, state) do
- {:msg, {:submit, state.input}}
-end
-
-def event_to_msg(%Event.Key{key: :escape}, _state) do
- {:msg, :cancel}
-end
-
-def event_to_msg(%Event.Mouse{action: :click, x: x, y: y}, _state) do
- {:msg, {:clicked, x, y}}
-end
-
-def event_to_msg(_event, _state) do
- :ignore
-end
-```
-
-**Return values:**
-
-| Return | Effect |
-|--------|--------|
-| `{:msg, message}` | Send message to `update/2` |
-| `:ignore` | Discard the event |
-| `:propagate` | Pass to parent component |
-
-### `update/2` - Handle Messages
-
-Receives a message and current state, returns new state and commands.
-
-```elixir
-def update(:increment, state) do
- {%{state | count: state.count + 1}, []}
-end
-
-def update({:set_name, name}, state) do
- {%{state | name: name}, []}
-end
-
-def update(:save, state) do
- # Use timer with 0 delay to perform side effect on next tick
- {state, [Command.timer(0, :do_save)]}
-end
-
-def update(:do_save, state) do
- # Perform the file write synchronously
- File.write("data.txt", state.data)
- {%{state | saved: true}, []}
-end
-```
-
-**Return format:** `{new_state, commands}`
-
-- `new_state` - The updated state
-- `commands` - List of side effects to execute (can be empty `[]`)
-
-### `view/1` - Render State
-
-Transforms state into a render tree describing what to display.
-
-```elixir
-def view(state) do
- stack(:vertical, [
- text("Hello, #{state.name}!", Style.new(fg: :cyan)),
- text(""),
- text("Count: #{state.count}"),
- render_items(state.items)
- ])
-end
-
-defp render_items([]), do: text("No items")
-defp render_items(items) do
- stack(:vertical, Enum.map(items, fn item ->
- text("• #{item}")
- end))
-end
-```
-
-The view function should be **pure** - given the same state, it always returns the same render tree.
-
-## Message Flow
-
-Here's the complete flow when a user presses a key:
-
-1. **Input** - User presses `↑` key
-2. **Event** - Runtime creates `%Event.Key{key: :up}`
-3. **Routing** - Event sent to focused component
-4. **Transform** - `event_to_msg(%Event.Key{key: :up}, state)` returns `{:msg, :increment}`
-5. **Update** - `update(:increment, state)` returns `{new_state, []}`
-6. **Dirty** - Component marked for re-render
-7. **Render** - On next frame, `view(new_state)` called
-8. **Diff** - Render tree compared to previous
-9. **Output** - Only changes sent to terminal
-
-## Commands
-
-Commands represent side effects that happen outside the pure update cycle.
-
-```elixir
-def update(:start_timer, state) do
- {state, [Command.timer(1000, :timer_tick)]}
-end
-
-def update(:timer_tick, state) do
- {%{state | ticks: state.ticks + 1}, []}
-end
-```
-
-See [Commands](09-commands.md) for full documentation.
-
-## State Design
-
-### Keep State Minimal
-
-Only store what you need to render and respond to events:
-
-```elixir
-# Good - minimal state
-%{
- selected_index: 0,
- items: ["a", "b", "c"]
-}
-
-# Avoid - derived data in state
-%{
- selected_index: 0,
- items: ["a", "b", "c"],
- selected_item: "a", # Can be derived
- item_count: 3 # Can be derived
-}
-```
-
-### Derive Values in View
-
-Compute derived values when rendering:
-
-```elixir
-def view(state) do
- selected_item = Enum.at(state.items, state.selected_index)
- item_count = length(state.items)
-
- stack(:vertical, [
- text("Selected: #{selected_item}"),
- text("Total: #{item_count} items")
- ])
-end
-```
-
-### Normalize State Updates
-
-Use helper functions for complex state changes:
-
-```elixir
-def update(:next_item, state) do
- {select_next(state), []}
-end
-
-def update(:prev_item, state) do
- {select_prev(state), []}
-end
-
-defp select_next(state) do
- max_index = length(state.items) - 1
- new_index = min(state.selected_index + 1, max_index)
- %{state | selected_index: new_index}
-end
-
-defp select_prev(state) do
- new_index = max(state.selected_index - 1, 0)
- %{state | selected_index: new_index}
-end
-```
-
-## Patterns
-
-### Loading States
-
-```elixir
-def init(_opts) do
- %{status: :loading, data: nil, error: nil}
-end
-
-def update(:load, state) do
- # Use timer to trigger loading on next tick
- {%{state | status: :loading}, [Command.timer(0, :do_load)]}
-end
-
-def update(:do_load, state) do
- # Perform the fetch synchronously (or spawn a Task for async)
- case fetch_data() do
- {:ok, data} ->
- {%{state | status: :ready, data: data}, []}
- {:error, reason} ->
- {%{state | status: :error, error: reason}, []}
- end
-end
-
-def view(state) do
- case state.status do
- :loading -> text("Loading...")
- :error -> text("Error: #{state.error}", Style.new(fg: :red))
- :ready -> render_data(state.data)
- end
-end
-```
-
-### Form Input
-
-```elixir
-def init(_opts) do
- %{name: "", email: "", focused: :name}
-end
-
-def event_to_msg(%Event.Key{key: :tab}, _state), do: {:msg, :next_field}
-def event_to_msg(%Event.Key{char: char}, state) when is_binary(char) do
- {:msg, {:input, state.focused, char}}
-end
-
-def update(:next_field, state) do
- next = case state.focused do
- :name -> :email
- :email -> :name
- end
- {%{state | focused: next}, []}
-end
-
-def update({:input, field, char}, state) do
- current = Map.get(state, field)
- {Map.put(state, field, current <> char), []}
-end
-```
-
-### Confirmation Dialogs
-
-```elixir
-def init(_opts) do
- %{items: [...], confirm_delete: nil}
-end
-
-def update({:request_delete, item}, state) do
- {%{state | confirm_delete: item}, []}
-end
-
-def update(:confirm_delete, state) do
- items = List.delete(state.items, state.confirm_delete)
- {%{state | items: items, confirm_delete: nil}, []}
-end
-
-def update(:cancel_delete, state) do
- {%{state | confirm_delete: nil}, []}
-end
-
-def view(state) do
- if state.confirm_delete do
- render_confirm_dialog(state.confirm_delete)
- else
- render_items(state.items)
- end
-end
-```
-
-## Testing
-
-The Elm Architecture makes testing straightforward:
-
-```elixir
-defmodule MyApp.CounterTest do
- use ExUnit.Case
-
- alias MyApp.Counter
-
- test "init returns zero count" do
- state = Counter.init([])
- assert state.count == 0
- end
-
- test "increment increases count" do
- state = %{count: 5}
- {new_state, []} = Counter.update(:increment, state)
- assert new_state.count == 6
- end
-
- test "up key sends increment message" do
- event = %Event.Key{key: :up}
- assert {:msg, :increment} = Counter.event_to_msg(event, %{})
- end
-
- test "view renders count" do
- state = %{count: 42}
- tree = Counter.view(state)
- # Assert on render tree structure
- end
-end
-```
-
-## Next Steps
-
-- [Events](04-events.md) - All event types and handling
-- [Commands](09-commands.md) - Side effects in detail
-- [Widgets](07-widgets.md) - Pre-built components
diff --git a/guides/user/04-events.md b/guides/user/04-events.md
deleted file mode 100644
index 992a3a28..00000000
--- a/guides/user/04-events.md
+++ /dev/null
@@ -1,362 +0,0 @@
-# Events
-
-TermUI delivers terminal input as structured events to your components. This guide covers all event types and how to handle them.
-
-## Event Types
-
-### Key Events
-
-Keyboard input including regular characters, special keys, and modifier combinations.
-
-```elixir
-%Event.Key{
- key: :enter, # Atom for special keys, string for characters
- char: nil, # Character string (nil for special keys)
- modifiers: [:ctrl], # List of :ctrl, :alt, :shift
- timestamp: 123456789 # Monotonic time in milliseconds
-}
-```
-
-**Special Keys:**
-
-| Key | Atom |
-|-----|------|
-| Enter | `:enter` |
-| Escape | `:escape` |
-| Tab | `:tab` |
-| Backspace | `:backspace` |
-| Delete | `:delete` |
-| Insert | `:insert` |
-| Home | `:home` |
-| End | `:end` |
-| Page Up | `:page_up` |
-| Page Down | `:page_down` |
-| Arrow Up | `:up` |
-| Arrow Down | `:down` |
-| Arrow Left | `:left` |
-| Arrow Right | `:right` |
-| F1-F12 | `:f1` through `:f12` |
-
-**Character Keys:**
-
-Regular characters are delivered as strings:
-
-```elixir
-%Event.Key{key: "a", char: "a"} # Lowercase a
-%Event.Key{key: "A", char: "A"} # Uppercase A (shift held)
-%Event.Key{key: " ", char: " "} # Space
-%Event.Key{key: "1", char: "1"} # Number 1
-```
-
-**Handling Key Events:**
-
-```elixir
-# Match special keys
-def event_to_msg(%Event.Key{key: :enter}, _state), do: {:msg, :submit}
-def event_to_msg(%Event.Key{key: :escape}, _state), do: {:msg, :cancel}
-
-# Match characters (case-insensitive)
-def event_to_msg(%Event.Key{key: key}, _state) when key in ["q", "Q"] do
- {:msg, :quit}
-end
-
-# Match with modifiers
-def event_to_msg(%Event.Key{key: "s", modifiers: [:ctrl]}, _state) do
- {:msg, :save}
-end
-
-# Match any character for text input
-def event_to_msg(%Event.Key{char: char}, state) when is_binary(char) do
- {:msg, {:char_input, char}}
-end
-
-# Ignore unhandled keys
-def event_to_msg(%Event.Key{}, _state), do: :ignore
-```
-
-### Mouse Events
-
-Mouse clicks, movement, and scrolling.
-
-```elixir
-%Event.Mouse{
- action: :click, # :click, :double_click, :press, :release, :drag, :move
- button: :left, # :left, :middle, :right, or nil
- x: 10, # Column (0-indexed)
- y: 5, # Row (0-indexed)
- modifiers: [], # :ctrl, :alt, :shift
- timestamp: 123456789
-}
-```
-
-**Mouse Actions:**
-
-| Action | Description |
-|--------|-------------|
-| `:press` | Button pressed down |
-| `:release` | Button released |
-| `:click` | Press and release |
-| `:double_click` | Two clicks in quick succession |
-| `:drag` | Movement with button held |
-| `:move` | Movement without button |
-| `:scroll_up` | Scroll wheel up |
-| `:scroll_down` | Scroll wheel down |
-
-**Handling Mouse Events:**
-
-```elixir
-def event_to_msg(%Event.Mouse{action: :click, x: x, y: y}, _state) do
- {:msg, {:click, x, y}}
-end
-
-def event_to_msg(%Event.Mouse{action: :scroll_up}, _state) do
- {:msg, :scroll_up}
-end
-
-def event_to_msg(%Event.Mouse{action: :scroll_down}, _state) do
- {:msg, :scroll_down}
-end
-
-def event_to_msg(%Event.Mouse{action: :drag, x: x, y: y}, _state) do
- {:msg, {:drag, x, y}}
-end
-```
-
-**Mouse Tracking Modes:**
-
-Mouse events require enabling mouse tracking:
-
-```elixir
-# In Terminal setup (done automatically by Runtime)
-Terminal.enable_mouse_tracking(:click) # Click events only
-Terminal.enable_mouse_tracking(:drag) # Click and drag
-Terminal.enable_mouse_tracking(:all) # All movement
-```
-
-### Resize Events
-
-Terminal window size changes.
-
-```elixir
-%Event.Resize{
- width: 120, # New column count
- height: 40, # New row count
- timestamp: 123456789
-}
-```
-
-**Handling Resize:**
-
-```elixir
-def event_to_msg(%Event.Resize{width: w, height: h}, _state) do
- {:msg, {:resize, w, h}}
-end
-
-def update({:resize, width, height}, state) do
- {%{state | width: width, height: height}, []}
-end
-```
-
-### Focus Events
-
-Terminal window focus changes.
-
-```elixir
-%Event.Focus{
- action: :gained, # :gained or :lost
- timestamp: 123456789
-}
-```
-
-**Handling Focus:**
-
-```elixir
-def event_to_msg(%Event.Focus{action: :gained}, _state) do
- {:msg, :focus_gained}
-end
-
-def event_to_msg(%Event.Focus{action: :lost}, _state) do
- {:msg, :focus_lost}
-end
-
-def update(:focus_lost, state) do
- # Pause animations, save state, etc.
- {%{state | paused: true}, []}
-end
-```
-
-### Paste Events
-
-Text pasted from clipboard (with bracketed paste mode).
-
-```elixir
-%Event.Paste{
- content: "pasted text",
- timestamp: 123456789
-}
-```
-
-**Handling Paste:**
-
-```elixir
-def event_to_msg(%Event.Paste{content: text}, _state) do
- {:msg, {:paste, text}}
-end
-
-def update({:paste, text}, state) do
- {%{state | input: state.input <> text}, []}
-end
-```
-
-### Tick Events
-
-Timer-based periodic events.
-
-```elixir
-%Event.Tick{
- interval: 1000, # Interval in milliseconds
- timestamp: 123456789
-}
-```
-
-These are typically generated by commands rather than received directly.
-
-### Custom Events
-
-Application-defined events.
-
-```elixir
-%Event.Custom{
- name: :data_loaded,
- payload: %{items: [...]},
- timestamp: 123456789
-}
-```
-
-## Event Handling Patterns
-
-### Catch-All Handler
-
-Always include a catch-all to handle unexpected events:
-
-```elixir
-def event_to_msg(_, _state), do: :ignore
-```
-
-### Conditional Handling
-
-Handle events differently based on state:
-
-```elixir
-def event_to_msg(%Event.Key{key: :enter}, %{mode: :edit}) do
- {:msg, :confirm_edit}
-end
-
-def event_to_msg(%Event.Key{key: :enter}, %{mode: :view}) do
- {:msg, :start_edit}
-end
-```
-
-### Key Sequences
-
-Track key sequences for shortcuts:
-
-```elixir
-def init(_opts) do
- %{key_buffer: []}
-end
-
-def event_to_msg(%Event.Key{key: "g"}, %{key_buffer: ["g"]}) do
- {:msg, :go_to_top} # gg command
-end
-
-def event_to_msg(%Event.Key{key: key}, _state) when is_binary(key) do
- {:msg, {:key_pressed, key}}
-end
-
-def update({:key_pressed, key}, state) do
- buffer = [key | state.key_buffer] |> Enum.take(2)
- {%{state | key_buffer: buffer}, [Command.timer(500, :clear_buffer)]}
-end
-
-def update(:clear_buffer, state) do
- {%{state | key_buffer: []}, []}
-end
-```
-
-### Modal Input
-
-Different handling for different modes:
-
-```elixir
-def event_to_msg(event, %{mode: :normal} = state) do
- handle_normal_mode(event, state)
-end
-
-def event_to_msg(event, %{mode: :insert} = state) do
- handle_insert_mode(event, state)
-end
-
-defp handle_normal_mode(%Event.Key{key: "i"}, _state), do: {:msg, :enter_insert}
-defp handle_normal_mode(%Event.Key{key: "j"}, _state), do: {:msg, :move_down}
-defp handle_normal_mode(%Event.Key{key: "k"}, _state), do: {:msg, :move_up}
-defp handle_normal_mode(_, _), do: :ignore
-
-defp handle_insert_mode(%Event.Key{key: :escape}, _state), do: {:msg, :exit_insert}
-defp handle_insert_mode(%Event.Key{char: char}, _state) when is_binary(char) do
- {:msg, {:insert_char, char}}
-end
-defp handle_insert_mode(_, _), do: :ignore
-```
-
-## Event Constructors
-
-Create events programmatically (useful for testing):
-
-```elixir
-# Key events
-Event.key(:enter)
-Event.key("a")
-Event.key("s", modifiers: [:ctrl])
-
-# Mouse events
-Event.mouse(:click, :left, 10, 5)
-Event.mouse(:scroll_up, nil, 10, 5)
-
-# Other events
-Event.Resize.new(120, 40)
-Event.Focus.new(:gained)
-Event.Paste.new("text")
-```
-
-## Testing Events
-
-```elixir
-defmodule MyApp.ComponentTest do
- use ExUnit.Case
- alias TermUI.Event
-
- test "enter key submits form" do
- state = %{input: "test"}
- event = Event.key(:enter)
-
- assert {:msg, :submit} = MyApp.Component.event_to_msg(event, state)
- end
-
- test "ctrl+s saves" do
- event = Event.key("s", modifiers: [:ctrl])
- assert {:msg, :save} = MyApp.Component.event_to_msg(event, %{})
- end
-
- test "click selects item" do
- event = Event.mouse(:click, :left, 5, 10)
- assert {:msg, {:select, 5, 10}} = MyApp.Component.event_to_msg(event, %{})
- end
-end
-```
-
-## Next Steps
-
-- [Styling](05-styling.md) - Visual styling and themes
-- [Commands](09-commands.md) - Timers and side effects
-- [Terminal](08-terminal.md) - Low-level terminal control
diff --git a/guides/user/05-styling.md b/guides/user/05-styling.md
deleted file mode 100644
index eaedc383..00000000
--- a/guides/user/05-styling.md
+++ /dev/null
@@ -1,342 +0,0 @@
-# Styling
-
-TermUI provides a comprehensive styling system for colors, text attributes, and themes.
-
-## Style Basics
-
-Create styles using `Style.new/1`:
-
-```elixir
-alias TermUI.Renderer.Style
-
-# Basic style
-style = Style.new(fg: :cyan, bg: :black)
-
-# With attributes
-style = Style.new(fg: :red, attrs: [:bold, :underline])
-
-# Apply to text
-text("Hello, World!", style)
-```
-
-## Colors
-
-### Named Colors (16 colors)
-
-Standard terminal colors supported everywhere:
-
-| Color | Normal | Bright |
-|-------|--------|--------|
-| Black | `:black` | `:bright_black` |
-| Red | `:red` | `:bright_red` |
-| Green | `:green` | `:bright_green` |
-| Yellow | `:yellow` | `:bright_yellow` |
-| Blue | `:blue` | `:bright_blue` |
-| Magenta | `:magenta` | `:bright_magenta` |
-| Cyan | `:cyan` | `:bright_cyan` |
-| White | `:white` | `:bright_white` |
-
-```elixir
-Style.new(fg: :cyan)
-Style.new(fg: :bright_yellow, bg: :blue)
-```
-
-### 256-Color Palette
-
-Extended palette for more color options:
-
-```elixir
-# Color index 0-255
-Style.new(fg: 196) # Bright red
-Style.new(bg: 236) # Dark gray
-```
-
-Color ranges:
-- 0-15: Standard colors (same as named)
-- 16-231: 6×6×6 color cube
-- 232-255: Grayscale ramp
-
-### True Color (24-bit RGB)
-
-Full RGB support on modern terminals:
-
-```elixir
-Style.new(fg: {255, 128, 0}) # Orange
-Style.new(bg: {30, 30, 30}) # Dark gray
-```
-
-### Default Color
-
-Use terminal's default foreground/background:
-
-```elixir
-Style.new(fg: :default)
-Style.new(bg: :default)
-```
-
-## Text Attributes
-
-Modify text appearance:
-
-| Attribute | Effect |
-|-----------|--------|
-| `:bold` | Bold/bright text |
-| `:dim` | Dimmed/faint text |
-| `:italic` | Italic text |
-| `:underline` | Underlined text |
-| `:blink` | Blinking text |
-| `:reverse` | Swap foreground/background |
-| `:hidden` | Hidden text |
-| `:strikethrough` | Strikethrough text |
-
-```elixir
-Style.new(attrs: [:bold])
-Style.new(attrs: [:bold, :underline])
-Style.new(fg: :red, attrs: [:bold, :italic])
-```
-
-**Note:** Not all terminals support all attributes. `bold`, `underline`, and `reverse` have the widest support.
-
-## Fluent API
-
-Build styles with method chaining:
-
-```elixir
-style = Style.new()
- |> Style.fg(:blue)
- |> Style.bg(:white)
- |> Style.bold()
- |> Style.underline()
-```
-
-Available methods:
-- `Style.fg(style, color)` - Set foreground
-- `Style.bg(style, color)` - Set background
-- `Style.bold(style)` - Add bold
-- `Style.dim(style)` - Add dim
-- `Style.italic(style)` - Add italic
-- `Style.underline(style)` - Add underline
-- `Style.blink(style)` - Add blink
-- `Style.reverse(style)` - Add reverse
-- `Style.hidden(style)` - Add hidden
-- `Style.strikethrough(style)` - Add strikethrough
-
-## Style Merging
-
-Combine styles with later values overriding earlier:
-
-```elixir
-base = Style.new(fg: :white, bg: :black)
-highlight = Style.new(fg: :yellow, attrs: [:bold])
-
-merged = Style.merge(base, highlight)
-# Result: fg: :yellow, bg: :black, attrs: [:bold]
-```
-
-## Using Styles in Views
-
-### Styled Text
-
-```elixir
-def view(state) do
- title_style = Style.new(fg: :cyan, attrs: [:bold])
- body_style = Style.new(fg: :white)
-
- stack(:vertical, [
- text("My Application", title_style),
- text(""),
- text("Welcome!", body_style)
- ])
-end
-```
-
-### Conditional Styling
-
-```elixir
-def view(state) do
- status_style = case state.status do
- :ok -> Style.new(fg: :green)
- :warning -> Style.new(fg: :yellow)
- :error -> Style.new(fg: :red, attrs: [:bold])
- end
-
- text("Status: #{state.status}", status_style)
-end
-```
-
-### Style Variables
-
-Define reusable styles:
-
-```elixir
-defmodule MyApp.Styles do
- alias TermUI.Renderer.Style
-
- def header, do: Style.new(fg: :cyan, attrs: [:bold])
- def label, do: Style.new(fg: :bright_black)
- def value, do: Style.new(fg: :white)
- def error, do: Style.new(fg: :red, attrs: [:bold])
- def success, do: Style.new(fg: :green)
- def selected, do: Style.new(fg: :black, bg: :cyan)
-end
-```
-
-Usage:
-
-```elixir
-alias MyApp.Styles
-
-def view(state) do
- stack(:vertical, [
- text("Dashboard", Styles.header()),
- text("CPU:", Styles.label()),
- text("#{state.cpu}%", Styles.value())
- ])
-end
-```
-
-## Themes
-
-Create theme maps for consistent styling:
-
-```elixir
-defmodule MyApp.Theme do
- alias TermUI.Renderer.Style
-
- def dark do
- %{
- header: Style.new(fg: :cyan, attrs: [:bold]),
- border: Style.new(fg: :cyan),
- text: Style.new(fg: :white),
- muted: Style.new(fg: :bright_black),
- selected: Style.new(fg: :black, bg: :cyan),
- error: Style.new(fg: :red),
- success: Style.new(fg: :green)
- }
- end
-
- def light do
- %{
- header: Style.new(fg: :blue, attrs: [:bold]),
- border: Style.new(fg: :blue),
- text: Style.new(fg: :black),
- muted: Style.new(fg: :bright_black),
- selected: Style.new(fg: :white, bg: :blue),
- error: Style.new(fg: :red),
- success: Style.new(fg: :green)
- }
- end
-end
-```
-
-Usage with theme switching:
-
-```elixir
-def init(_opts) do
- %{theme: :dark}
-end
-
-def event_to_msg(%Event.Key{key: "t"}, _state), do: {:msg, :toggle_theme}
-
-def update(:toggle_theme, state) do
- new_theme = if state.theme == :dark, do: :light, else: :dark
- {%{state | theme: new_theme}, []}
-end
-
-def view(state) do
- theme = case state.theme do
- :dark -> MyApp.Theme.dark()
- :light -> MyApp.Theme.light()
- end
-
- stack(:vertical, [
- text("My App", theme.header),
- text("Press T to toggle theme", theme.muted)
- ])
-end
-```
-
-## Widget Styling
-
-Widgets accept styles in their options:
-
-```elixir
-alias TermUI.Widgets.Gauge
-
-Gauge.render(
- value: 75,
- width: 20,
- style: Style.new(fg: :green)
-)
-```
-
-### Color Zones
-
-Some widgets support color zones based on value:
-
-```elixir
-Gauge.render(
- value: cpu_percent,
- width: 20,
- zones: [
- {0, Style.new(fg: :green)}, # 0-59%: green
- {60, Style.new(fg: :yellow)}, # 60-79%: yellow
- {80, Style.new(fg: :red)} # 80-100%: red
- ]
-)
-```
-
-## Best Practices
-
-### 1. Use Semantic Names
-
-```elixir
-# Good - semantic meaning
-error_style = Style.new(fg: :red)
-success_style = Style.new(fg: :green)
-
-# Avoid - color-focused
-red_style = Style.new(fg: :red)
-```
-
-### 2. Consider Accessibility
-
-- Ensure sufficient contrast between foreground and background
-- Don't rely solely on color to convey information
-- Use bold/underline for emphasis in addition to color
-
-### 3. Support Light and Dark
-
-Design themes that work on both light and dark terminal backgrounds:
-
-```elixir
-# Works on dark background
-Style.new(fg: :white)
-
-# Works on light background
-Style.new(fg: :black)
-
-# Works on both (terminal default)
-Style.new(fg: :cyan) # Typically visible on both
-```
-
-### 4. Minimize Style Changes
-
-The renderer optimizes style changes, but fewer changes means better performance:
-
-```elixir
-# Good - one style for the whole line
-text("Label: Value", Style.new(fg: :white))
-
-# Less efficient - multiple style changes
-stack(:horizontal, [
- text("Label: ", Style.new(fg: :bright_black)),
- text("Value", Style.new(fg: :white))
-])
-```
-
-## Next Steps
-
-- [Layout](06-layout.md) - Positioning and sizing
-- [Widgets](07-widgets.md) - Pre-built styled components
-- [Terminal](08-terminal.md) - Terminal capabilities
diff --git a/guides/user/06-layout.md b/guides/user/06-layout.md
deleted file mode 100644
index fdaa7801..00000000
--- a/guides/user/06-layout.md
+++ /dev/null
@@ -1,414 +0,0 @@
-# Layout
-
-TermUI provides a declarative layout system for positioning and sizing components.
-
-## Basic Layout
-
-### Vertical Stacking
-
-Stack elements from top to bottom:
-
-```elixir
-stack(:vertical, [
- text("Header"),
- text("Body"),
- text("Footer")
-])
-```
-
-Output:
-```
-Header
-Body
-Footer
-```
-
-### Horizontal Stacking
-
-Stack elements from left to right:
-
-```elixir
-stack(:horizontal, [
- text("Left"),
- text(" | "),
- text("Right")
-])
-```
-
-Output:
-```
-Left | Right
-```
-
-### Nested Layouts
-
-Combine stacks for complex layouts:
-
-```elixir
-stack(:vertical, [
- text("=== Header ==="),
- stack(:horizontal, [
- text("[Sidebar]"),
- text(" "),
- text("[Main Content]")
- ]),
- text("=== Footer ===")
-])
-```
-
-Output:
-```
-=== Header ===
-[Sidebar] [Main Content]
-=== Footer ===
-```
-
-## Constraints
-
-Control how space is allocated using constraints.
-
-### Fixed Size
-
-Exact number of cells:
-
-```elixir
-alias TermUI.Layout.Constraint
-
-stack(:horizontal, [
- {text("Fixed"), Constraint.length(10)},
- {text("Rest"), Constraint.fill()}
-])
-```
-
-### Percentage
-
-Proportion of available space:
-
-```elixir
-stack(:horizontal, [
- {left_panel, Constraint.percentage(30)},
- {right_panel, Constraint.percentage(70)}
-])
-```
-
-### Fill
-
-Take all remaining space:
-
-```elixir
-stack(:horizontal, [
- {sidebar, Constraint.length(20)}, # Fixed 20 columns
- {content, Constraint.fill()} # Rest of the space
-])
-```
-
-### Ratio
-
-Proportional distribution:
-
-```elixir
-stack(:horizontal, [
- {panel_a, Constraint.ratio(1)}, # 1 part
- {panel_b, Constraint.ratio(2)}, # 2 parts
- {panel_c, Constraint.ratio(1)} # 1 part
-])
-# Results in 25%, 50%, 25% distribution
-```
-
-### Min and Max
-
-Set bounds on size:
-
-```elixir
-# At least 10, at most 50
-Constraint.percentage(30)
- |> Constraint.with_min(10)
- |> Constraint.with_max(50)
-```
-
-## Common Layout Patterns
-
-### Header-Body-Footer
-
-```elixir
-def view(state) do
- stack(:vertical, [
- {render_header(state), Constraint.length(3)},
- {render_body(state), Constraint.fill()},
- {render_footer(state), Constraint.length(1)}
- ])
-end
-
-defp render_header(state) do
- text("=== My Application ===", Style.new(fg: :cyan, attrs: [:bold]))
-end
-
-defp render_body(state) do
- stack(:vertical, [
- text("Main content here"),
- text("..."),
- ])
-end
-
-defp render_footer(state) do
- text("[Q]uit [H]elp", Style.new(fg: :bright_black))
-end
-```
-
-### Sidebar Layout
-
-```elixir
-def view(state) do
- stack(:horizontal, [
- {render_sidebar(state), Constraint.length(25)},
- {render_main(state), Constraint.fill()}
- ])
-end
-
-defp render_sidebar(state) do
- stack(:vertical, [
- text("Navigation", Style.new(attrs: [:bold])),
- text(""),
- text("• Dashboard"),
- text("• Settings"),
- text("• Help")
- ])
-end
-
-defp render_main(state) do
- text("Main content area")
-end
-```
-
-### Two-Column Layout
-
-```elixir
-def view(state) do
- stack(:horizontal, [
- {left_column(state), Constraint.percentage(50)},
- {right_column(state), Constraint.percentage(50)}
- ])
-end
-```
-
-### Dashboard Grid
-
-```elixir
-def view(state) do
- stack(:vertical, [
- # Top row - three equal panels
- {stack(:horizontal, [
- {cpu_gauge(state), Constraint.ratio(1)},
- {memory_gauge(state), Constraint.ratio(1)},
- {disk_gauge(state), Constraint.ratio(1)}
- ]), Constraint.length(5)},
-
- # Bottom row - two panels
- {stack(:horizontal, [
- {process_list(state), Constraint.percentage(60)},
- {network_stats(state), Constraint.percentage(40)}
- ]), Constraint.fill()}
- ])
-end
-```
-
-### Centered Content
-
-```elixir
-def view(state) do
- # Horizontal centering with fill on both sides
- stack(:horizontal, [
- {text(""), Constraint.fill()},
- {render_dialog(state), Constraint.length(40)},
- {text(""), Constraint.fill()}
- ])
-end
-```
-
-## Text Alignment
-
-Align text within available space:
-
-```elixir
-alias TermUI.Layout.Alignment
-
-# Left aligned (default)
-text("Left", alignment: :left)
-
-# Center aligned
-text("Center", alignment: :center)
-
-# Right aligned
-text("Right", alignment: :right)
-```
-
-## Box Drawing
-
-Create bordered containers:
-
-```elixir
-def render_box(title, content) do
- stack(:vertical, [
- text("┌─ #{title} " <> String.duplicate("─", 20) <> "┐"),
- stack(:horizontal, [
- text("│ "),
- content,
- text(" │")
- ]),
- text("└" <> String.duplicate("─", 24) <> "┘")
- ])
-end
-```
-
-## Responsive Layouts
-
-Adapt layout based on terminal size:
-
-```elixir
-def view(%{width: width} = state) when width < 80 do
- # Narrow layout - vertical stacking
- stack(:vertical, [
- render_sidebar(state),
- render_main(state)
- ])
-end
-
-def view(state) do
- # Wide layout - horizontal stacking
- stack(:horizontal, [
- {render_sidebar(state), Constraint.length(25)},
- {render_main(state), Constraint.fill()}
- ])
-end
-```
-
-Handle resize events:
-
-```elixir
-def event_to_msg(%Event.Resize{width: w, height: h}, _state) do
- {:msg, {:resize, w, h}}
-end
-
-def update({:resize, width, height}, state) do
- {%{state | width: width, height: height}, []}
-end
-```
-
-## Empty Space
-
-Add spacing between elements:
-
-```elixir
-# Empty line
-text("")
-
-# Multiple empty lines
-stack(:vertical, [
- text("First"),
- text(""),
- text(""),
- text("Second")
-])
-
-# Horizontal space
-stack(:horizontal, [
- text("Label:"),
- text(" "), # 3 spaces
- text("Value")
-])
-```
-
-## Conditional Rendering
-
-Show/hide elements based on state:
-
-```elixir
-def view(state) do
- stack(:vertical, [
- text("Header"),
- if state.show_details do
- render_details(state)
- else
- text("")
- end,
- text("Footer")
- ])
-end
-```
-
-Or use list filtering:
-
-```elixir
-def view(state) do
- elements = [
- text("Header"),
- state.show_details && render_details(state),
- text("Footer")
- ]
-
- stack(:vertical, Enum.filter(elements, & &1))
-end
-```
-
-## Performance Tips
-
-### 1. Avoid Deep Nesting
-
-Flatten layouts where possible:
-
-```elixir
-# Less efficient
-stack(:vertical, [
- stack(:vertical, [
- stack(:vertical, [
- text("Deeply nested")
- ])
- ])
-])
-
-# More efficient
-stack(:vertical, [
- text("Flat")
-])
-```
-
-### 2. Use Constraints Sparingly
-
-Only specify constraints when needed:
-
-```elixir
-# Simple case - no constraints needed
-stack(:vertical, [
- text("Line 1"),
- text("Line 2")
-])
-
-# Complex case - constraints needed
-stack(:horizontal, [
- {sidebar, Constraint.length(20)},
- {content, Constraint.fill()}
-])
-```
-
-### 3. Memoize Complex Layouts
-
-For layouts that don't change often:
-
-```elixir
-def view(state) do
- stack(:vertical, [
- render_static_header(), # Cached internally
- render_dynamic_content(state) # Recomputed each frame
- ])
-end
-
-# Static content can be module attribute
-@header text("My Application", Style.new(fg: :cyan))
-defp render_static_header, do: @header
-```
-
-## Next Steps
-
-- [Widgets](07-widgets.md) - Pre-built layout-aware components
-- [Styling](05-styling.md) - Visual styling
-- [Events](04-events.md) - Handle resize events
diff --git a/guides/user/07-widgets.md b/guides/user/07-widgets.md
deleted file mode 100644
index cbd70acf..00000000
--- a/guides/user/07-widgets.md
+++ /dev/null
@@ -1,583 +0,0 @@
-# Widgets
-
-TermUI includes pre-built widgets for common UI patterns. This guide covers the available widgets and how to use them.
-
-## Widget Types
-
-TermUI has two types of widgets:
-
-1. **Simple Widgets** - Stateless, render with keyword options (Gauge, Sparkline)
-2. **Stateful Widgets** - Use the StatefulComponent pattern with `new/init/handle_event/render`
-
-## Simple Widgets
-
-### Gauge
-
-> **Example:** See [`examples/gauge/`](../../examples/gauge/) for a complete demonstration.
-
-Displays a value as a progress bar with optional color zones.
-
-```elixir
-alias TermUI.Widgets.Gauge
-alias TermUI.Renderer.Style
-
-# Basic gauge
-Gauge.render(value: 75, width: 20)
-
-# With color zones
-Gauge.render(
- value: cpu_percent,
- width: 20,
- zones: [
- {0, Style.new(fg: :green)}, # 0-59: green
- {60, Style.new(fg: :yellow)}, # 60-79: yellow
- {80, Style.new(fg: :red)} # 80-100: red
- ]
-)
-
-# With value display
-Gauge.render(
- value: 42,
- width: 30,
- show_value: true,
- show_range: true
-)
-```
-
-**Options:**
-
-| Option | Type | Default | Description |
-|--------|------|---------|-------------|
-| `value` | number | required | Current value (0-100) |
-| `width` | integer | 20 | Width in characters |
-| `zones` | list | `[]` | Color zones `[{threshold, style}]` |
-| `show_value` | boolean | `false` | Display numeric value |
-| `show_range` | boolean | `false` | Display min/max |
-| `style` | Style | default | Base style |
-
-**Example Output:**
-```
-[████████████░░░░░░░░] 60%
-```
-
-### Sparkline
-
-> **Example:** See [`examples/sparkline/`](../../examples/sparkline/) for a complete demonstration.
-
-Compact inline graph showing trends.
-
-```elixir
-alias TermUI.Widgets.Sparkline
-
-# Basic sparkline
-Sparkline.render(values: [10, 25, 40, 30, 50, 45, 60])
-
-# With range
-Sparkline.render(
- values: history,
- min: 0,
- max: 100,
- style: Style.new(fg: :cyan)
-)
-```
-
-**Options:**
-
-| Option | Type | Default | Description |
-|--------|------|---------|-------------|
-| `values` | list | required | List of numeric values |
-| `min` | number | auto | Minimum value for scaling |
-| `max` | number | auto | Maximum value for scaling |
-| `style` | Style | default | Color style |
-
-**Example Output:**
-```
-▁▂▄▃▆▅█
-```
-
-Uses Unicode block characters (▁▂▃▄▅▆▇█) to show 8 levels of height.
-
-## Stateful Widgets
-
-Stateful widgets follow the StatefulComponent pattern:
-
-```elixir
-# 1. Create props with Widget.new(opts)
-props = Widget.new(option: value)
-
-# 2. Initialize state with Widget.init(props)
-{:ok, widget_state} = Widget.init(props)
-
-# 3. Handle events with Widget.handle_event(event, state)
-{:ok, widget_state} = Widget.handle_event(event, widget_state)
-
-# 4. Render with Widget.render(state, area)
-node = Widget.render(widget_state, %{width: 80, height: 24})
-```
-
-### Table
-
-> **Example:** See [`examples/table/`](../../examples/table/) for a complete demonstration.
-
-Scrollable data table with selection and sorting.
-
-```elixir
-alias TermUI.Widgets.Table
-alias TermUI.Widgets.Table.Column
-
-# Create props
-props = Table.new(
- columns: [
- Column.new(:name, "Name"),
- Column.new(:age, "Age", width: 10, align: :right),
- Column.new(:city, "City", width: 15)
- ],
- data: [
- %{name: "Alice", age: 30, city: "NYC"},
- %{name: "Bob", age: 25, city: "LA"},
- %{name: "Carol", age: 35, city: "Chicago"}
- ],
- selection_mode: :single,
- on_select: fn row -> IO.inspect(row) end
-)
-
-# Initialize
-{:ok, table_state} = Table.init(props)
-
-# In your component's event handler
-def update({:table_event, event}, state) do
- {:ok, new_table} = Table.handle_event(event, state.table)
- {%{state | table: new_table}, []}
-end
-
-# In your view
-def view(state) do
- Table.render(state.table, %{width: 60, height: 15})
-end
-```
-
-**Options:**
-
-| Option | Type | Default | Description |
-|--------|------|---------|-------------|
-| `columns` | list | required | Column definitions |
-| `data` | list | required | List of row maps |
-| `selection_mode` | atom | `:single` | `:none`, `:single`, or `:multi` |
-| `sortable` | boolean | `true` | Enable column sorting |
-| `on_select` | function | `nil` | Selection callback |
-| `header_style` | Style | default | Header row style |
-| `selected_style` | Style | reverse | Selected row style |
-
-**Keyboard Navigation:**
-- Arrow keys: Move selection
-- Page Up/Down: Scroll by page
-- Home/End: Jump to first/last row
-- Enter: Confirm selection
-- Space: Toggle selection (multi mode)
-
-### Menu
-
-> **Example:** See [`examples/menu/`](../../examples/menu/) for a complete demonstration.
-
-Hierarchical menu with submenus and keyboard navigation.
-
-```elixir
-alias TermUI.Widgets.Menu
-
-# Create props with item constructors
-props = Menu.new(
- items: [
- Menu.action(:new, "New File", shortcut: "Ctrl+N"),
- Menu.action(:open, "Open...", shortcut: "Ctrl+O"),
- Menu.separator(),
- Menu.submenu(:recent, "Recent Files", [
- Menu.action(:file1, "document.txt"),
- Menu.action(:file2, "notes.md")
- ]),
- Menu.separator(),
- Menu.checkbox(:autosave, "Auto Save", checked: true),
- Menu.action(:exit, "Exit", shortcut: "Ctrl+Q")
- ],
- on_select: fn id -> handle_menu_action(id) end
-)
-
-# Initialize
-{:ok, menu_state} = Menu.init(props)
-
-# Handle events and render
-{:ok, menu_state} = Menu.handle_event(event, menu_state)
-Menu.render(menu_state, %{width: 30, height: 20})
-```
-
-**Item Types:**
-
-| Constructor | Description |
-|------------|-------------|
-| `Menu.action(id, label, opts)` | Selectable menu item |
-| `Menu.submenu(id, label, children)` | Item with nested menu |
-| `Menu.separator()` | Visual divider |
-| `Menu.checkbox(id, label, opts)` | Toggleable item |
-
-**Keyboard Navigation:**
-- Up/Down: Move between items
-- Enter/Space: Select or expand submenu
-- Left: Collapse submenu
-- Right: Expand submenu
-- Escape: Close menu
-
-### TextInput
-
-> **Example:** See [`examples/text_input/`](../../examples/text_input/) for a complete demonstration.
-
-Single-line and multi-line text input with cursor movement.
-
-```elixir
-alias TermUI.Widgets.TextInput
-
-# Create props
-props = TextInput.new(
- placeholder: "Enter your name...",
- width: 40,
- multiline: false
-)
-
-# Initialize
-{:ok, input_state} = TextInput.init(props)
-
-# Handle events
-{:ok, input_state} = TextInput.handle_event(event, input_state)
-
-# Get current value
-value = TextInput.get_value(input_state)
-
-# Render
-TextInput.render(input_state, %{width: 50, height: 1})
-```
-
-**Options:**
-
-| Option | Type | Default | Description |
-|--------|------|---------|-------------|
-| `value` | string | `""` | Initial text value |
-| `placeholder` | string | `""` | Placeholder text |
-| `width` | integer | 40 | Field width |
-| `multiline` | boolean | `false` | Enable multi-line mode |
-| `max_visible_lines` | integer | 5 | Lines before scrolling |
-| `enter_submits` | boolean | `false` | Enter submits vs newline |
-| `on_change` | function | `nil` | Value change callback |
-| `on_submit` | function | `nil` | Submit callback |
-
-**Keyboard Controls:**
-- Left/Right: Move cursor
-- Up/Down: Move between lines (multiline)
-- Home/End: Start/end of line
-- Ctrl+Home/End: Start/end of text
-- Backspace/Delete: Delete characters
-- Ctrl+Enter: Insert newline (multiline)
-- Enter: Submit or newline
-
-**Helper Functions:**
-
-```elixir
-# Get current value
-TextInput.get_value(state) # => "current text"
-
-# Get cursor position
-TextInput.get_cursor(state) # => {row, col}
-
-# Get line count
-TextInput.get_line_count(state) # => 3
-
-# Set focus
-state = TextInput.set_focused(state, true)
-
-# Clear input
-state = TextInput.clear(state)
-```
-
-### Dialog
-
-> **Example:** See [`examples/dialog/`](../../examples/dialog/) for a complete demonstration.
-
-Modal dialog with buttons.
-
-```elixir
-alias TermUI.Widgets.Dialog
-
-# Create props
-props = Dialog.new(
- title: "Confirm Delete",
- content: text("Are you sure you want to delete this file?"),
- buttons: [
- %{id: :cancel, label: "Cancel"},
- %{id: :confirm, label: "Delete", style: :danger}
- ],
- width: 50,
- on_confirm: fn button_id -> handle_action(button_id) end
-)
-
-# Initialize and use
-{:ok, dialog_state} = Dialog.init(props)
-{:ok, dialog_state} = Dialog.handle_event(event, dialog_state)
-Dialog.render(dialog_state, %{width: 80, height: 24})
-```
-
-**Options:**
-
-| Option | Type | Default | Description |
-|--------|------|---------|-------------|
-| `title` | string | required | Dialog title |
-| `content` | node | `nil` | Dialog body content |
-| `buttons` | list | `[{id: :ok, label: "OK"}]` | Button definitions |
-| `width` | integer | 40 | Dialog width |
-| `closeable` | boolean | `true` | Escape closes dialog |
-| `on_close` | function | `nil` | Close callback |
-| `on_confirm` | function | `nil` | Button activation callback |
-
-**Keyboard Navigation:**
-- Tab/Shift+Tab: Move between buttons
-- Enter/Space: Activate focused button
-- Escape: Close dialog
-
-### PickList
-
-> **Example:** See [`examples/pick_list/`](../../examples/pick_list/) for a complete demonstration.
-
-Modal selection dialog with type-ahead filtering.
-
-```elixir
-alias TermUI.Widget.PickList
-
-# Create props
-props = %{
- items: ["Apple", "Banana", "Cherry", "Date", "Elderberry"],
- title: "Select Fruit",
- width: 40,
- height: 12,
- on_select: fn item -> handle_selection(item) end,
- on_cancel: fn -> handle_cancel() end
-}
-
-# Initialize
-{:ok, picklist_state} = PickList.init(props)
-
-# Handle events
-{:ok, picklist_state} = PickList.handle_event(event, picklist_state)
-
-# Render
-PickList.render(picklist_state, %{width: 80, height: 24})
-```
-
-**Options:**
-
-| Option | Type | Default | Description |
-|--------|------|---------|-------------|
-| `items` | list | required | List of items to display |
-| `title` | string | `"Select"` | Modal title |
-| `width` | integer | 40 | Modal width |
-| `height` | integer | 10 | Modal height |
-| `on_select` | function | `nil` | Selection callback `fn item -> ... end` |
-| `on_cancel` | function | `nil` | Cancel callback `fn -> ... end` |
-| `style` | map | `%{}` | Border/text style |
-| `highlight_style` | map | inverted | Selected item style |
-
-**Keyboard Controls:**
-- Up/Down: Navigate items
-- Page Up/Down: Jump 10 items
-- Home/End: Jump to first/last
-- Enter: Confirm selection
-- Escape: Cancel
-- Typing: Filter items (type-ahead)
-- Backspace: Remove filter character
-
-## Building Custom Widgets
-
-Create reusable widgets as functions:
-
-```elixir
-defmodule MyApp.Widgets do
- import TermUI.Component.Helpers
- alias TermUI.Renderer.Style
-
- @doc """
- Renders a labeled value pair.
- """
- def labeled_value(label, value, opts \\ []) do
- label_style = Keyword.get(opts, :label_style, Style.new(fg: :bright_black))
- value_style = Keyword.get(opts, :value_style, Style.new(fg: :white))
-
- stack(:horizontal, [
- text("#{label}: ", label_style),
- text(to_string(value), value_style)
- ])
- end
-
- @doc """
- Renders a bordered box with title.
- """
- def box(title, content, opts \\ []) do
- width = Keyword.get(opts, :width, 40)
- border_style = Keyword.get(opts, :border_style, Style.new(fg: :cyan))
-
- inner_width = width - 4
- top_border = "┌─ " <> title <> " " <> String.duplicate("─", inner_width - String.length(title) - 1) <> "┐"
- bottom_border = "└" <> String.duplicate("─", width - 2) <> "┘"
-
- stack(:vertical, [
- text(top_border, border_style),
- stack(:horizontal, [
- text("│ ", border_style),
- content,
- text(" │", border_style)
- ]),
- text(bottom_border, border_style)
- ])
- end
-
- @doc """
- Renders a status indicator.
- """
- def status_indicator(status) do
- {symbol, style} = case status do
- :ok -> {"●", Style.new(fg: :green)}
- :warning -> {"●", Style.new(fg: :yellow)}
- :error -> {"●", Style.new(fg: :red)}
- :unknown -> {"○", Style.new(fg: :bright_black)}
- end
-
- text(symbol, style)
- end
-end
-```
-
-Usage:
-
-```elixir
-import MyApp.Widgets
-
-def view(state) do
- stack(:vertical, [
- box("System Status", stack(:vertical, [
- stack(:horizontal, [
- status_indicator(:ok),
- text(" "),
- labeled_value("CPU", "#{state.cpu}%")
- ]),
- stack(:horizontal, [
- status_indicator(:warning),
- text(" "),
- labeled_value("Memory", "#{state.memory}%")
- ])
- ]))
- ])
-end
-```
-
-## Widget Composition
-
-Combine widgets for complex UIs:
-
-```elixir
-alias TermUI.Widgets.{Gauge, Sparkline, Table}
-
-def view(state) do
- stack(:vertical, [
- # Header with gauges
- stack(:horizontal, [
- box("CPU", Gauge.render(value: state.cpu, width: 15)),
- box("Memory", Gauge.render(value: state.mem, width: 15))
- ]),
-
- # Sparkline history
- box("Network", stack(:vertical, [
- stack(:horizontal, [
- text("RX: "),
- Sparkline.render(values: state.rx_history)
- ]),
- stack(:horizontal, [
- text("TX: "),
- Sparkline.render(values: state.tx_history)
- ])
- ])),
-
- # Process table (stateful widget)
- Table.render(state.table, %{width: 60, height: 10})
- ])
-end
-```
-
-## Full Example: Component with TextInput
-
-```elixir
-defmodule MyApp.SearchForm do
- use TermUI.Elm
-
- alias TermUI.Event
- alias TermUI.Widgets.TextInput
-
- def init(_opts) do
- props = TextInput.new(
- placeholder: "Search...",
- width: 40
- )
- {:ok, input_state} = TextInput.init(props)
-
- %{
- input: TextInput.set_focused(input_state, true),
- results: []
- }
- end
-
- def event_to_msg(%Event.Key{key: :enter}, state) do
- query = TextInput.get_value(state.input)
- {:msg, {:search, query}}
- end
-
- def event_to_msg(%Event.Key{key: "q"}, %{input: input}) do
- # Only quit if input is empty
- if TextInput.get_value(input) == "" do
- {:msg, :quit}
- else
- {:msg, {:input_event, %Event.Key{key: "q", char: "q"}}}
- end
- end
-
- def event_to_msg(event, _state) do
- {:msg, {:input_event, event}}
- end
-
- def update(:quit, state), do: {state, [:quit]}
-
- def update({:input_event, event}, state) do
- {:ok, new_input} = TextInput.handle_event(event, state.input)
- {%{state | input: new_input}, []}
- end
-
- def update({:search, query}, state) do
- results = perform_search(query)
- {%{state | results: results}, []}
- end
-
- def view(state) do
- stack(:vertical, [
- text("Search:", Style.new(fg: :cyan)),
- TextInput.render(state.input, %{width: 50, height: 1}),
- text(""),
- render_results(state.results)
- ])
- end
-
- defp perform_search(query), do: []
- defp render_results([]), do: text("No results")
- defp render_results(results) do
- stack(:vertical, Enum.map(results, &text(&1)))
- end
-end
-```
-
-## Next Steps
-
-- [Advanced Widgets](10-advanced-widgets.md) - Navigation, visualization, streaming, and BEAM introspection widgets
-- [Styling](05-styling.md) - Customize widget appearance
-- [Layout](06-layout.md) - Position widgets
-- [Events](04-events.md) - Handle widget interactions
diff --git a/guides/user/08-terminal.md b/guides/user/08-terminal.md
deleted file mode 100644
index 0d16d81b..00000000
--- a/guides/user/08-terminal.md
+++ /dev/null
@@ -1,310 +0,0 @@
-# Terminal
-
-TermUI manages low-level terminal operations automatically, but understanding these features helps you build better applications.
-
-## Terminal Modes
-
-### Cooked Mode (Default)
-
-Normal terminal operation:
-- Line buffering (input sent on Enter)
-- Character echoing
-- Signal handling (Ctrl+C sends SIGINT)
-
-### Raw Mode
-
-TermUI's operating mode:
-- Character-by-character input
-- No echoing
-- No signal handling
-- Full control over display
-
-The runtime enables raw mode automatically. It's restored when your app exits.
-
-## Alternate Screen
-
-Terminals have two screen buffers:
-
-- **Main screen** - The normal scrollback buffer
-- **Alternate screen** - A separate buffer for full-screen apps
-
-TermUI uses the alternate screen, preserving the user's shell history. When your app exits, the terminal returns to the main screen with history intact.
-
-```
-┌─────────────────────┐ ┌─────────────────────┐
-│ $ ls │ │ ┌─────────────────┐ │
-│ file1.txt │ │ │ Your TermUI │ │
-│ file2.txt │ --> │ │ Application │ │
-│ $ my_app │ │ │ │ │
-│ │ │ └─────────────────┘ │
-│ Main Screen │ │ Alternate Screen │
-└─────────────────────┘ └─────────────────────┘
- │
- │ (exit)
- ▼
- ┌─────────────────────┐
- │ $ ls │
- │ file1.txt │
- │ file2.txt │
- │ $ my_app │
- │ $ │
- │ Back to Main │
- └─────────────────────┘
-```
-
-## Mouse Tracking
-
-TermUI can capture mouse events.
-
-### Tracking Modes
-
-| Mode | Events Captured |
-|------|-----------------|
-| `:click` | Button press/release |
-| `:drag` | Click + drag movements |
-| `:all` | All mouse movement |
-
-The runtime enables click tracking by default.
-
-### Mouse Coordinates
-
-Mouse positions are 0-indexed:
-- `x` = column (0 = leftmost)
-- `y` = row (0 = topmost)
-
-```elixir
-def event_to_msg(%Event.Mouse{action: :click, x: x, y: y}, state) do
- # Check if click is within a region
- if x >= 10 and x < 30 and y >= 5 and y < 10 do
- {:msg, :button_clicked}
- else
- :ignore
- end
-end
-```
-
-### Scroll Events
-
-Mouse wheel generates scroll events:
-
-```elixir
-def event_to_msg(%Event.Mouse{action: :scroll_up}, _state) do
- {:msg, :scroll_up}
-end
-
-def event_to_msg(%Event.Mouse{action: :scroll_down}, _state) do
- {:msg, :scroll_down}
-end
-```
-
-## Focus Events
-
-Know when the terminal window gains or loses focus:
-
-```elixir
-def event_to_msg(%Event.Focus{action: :gained}, _state) do
- {:msg, :focus_gained}
-end
-
-def event_to_msg(%Event.Focus{action: :lost}, _state) do
- {:msg, :focus_lost}
-end
-
-def update(:focus_lost, state) do
- # Pause updates, dim display, etc.
- {%{state | paused: true}, []}
-end
-
-def update(:focus_gained, state) do
- # Resume updates
- {%{state | paused: false}, []}
-end
-```
-
-**Note:** Focus events require terminal support. They work on most modern terminals (xterm, iTerm2, Alacritty, Kitty, Windows Terminal).
-
-## Terminal Size
-
-### Getting Size
-
-Query current dimensions:
-
-```elixir
-{:ok, {rows, cols}} = TermUI.Terminal.get_terminal_size()
-```
-
-### Handling Resize
-
-Respond to window size changes:
-
-```elixir
-def event_to_msg(%Event.Resize{width: w, height: h}, _state) do
- {:msg, {:resize, w, h}}
-end
-
-def update({:resize, width, height}, state) do
- {%{state | width: width, height: height}, []}
-end
-
-def view(state) do
- if state.width < 80 do
- render_compact_layout(state)
- else
- render_full_layout(state)
- end
-end
-```
-
-## Cursor Control
-
-The runtime manages cursor visibility and position. The cursor is hidden during normal operation to avoid flicker.
-
-For text input widgets that need a visible cursor:
-
-```elixir
-# The cursor position is managed by the renderer
-# Your TextInput widget indicates where the cursor should be
-TextInput.render(
- value: state.text,
- cursor_position: state.cursor_pos,
- focused: true # Shows cursor
-)
-```
-
-## Color Support
-
-### Detection
-
-TermUI detects terminal color capabilities:
-- 16 colors (basic)
-- 256 colors (extended)
-- True color (24-bit RGB)
-
-### Graceful Degradation
-
-Use named colors for maximum compatibility:
-
-```elixir
-# Works everywhere
-Style.new(fg: :red)
-
-# Requires 256-color support
-Style.new(fg: 196)
-
-# Requires true color support
-Style.new(fg: {255, 100, 50})
-```
-
-The renderer automatically degrades colors for less capable terminals.
-
-## Clipboard
-
-### Paste Events
-
-Bracketed paste mode delivers pasted text as a single event:
-
-```elixir
-def event_to_msg(%Event.Paste{content: text}, _state) do
- {:msg, {:paste, text}}
-end
-
-def update({:paste, text}, state) do
- # Insert pasted text at cursor
- new_text = state.text <> text
- {%{state | text: new_text}, []}
-end
-```
-
-Without bracketed paste, pasted text would arrive as individual key events, which is slower and may trigger unintended shortcuts.
-
-## Terminal Requirements
-
-### Minimum Requirements
-
-- ANSI escape sequence support
-- UTF-8 encoding
-- 80x24 minimum size
-
-### Recommended
-
-- 256-color or true color support
-- Mouse tracking support
-- Focus event support
-- Unicode box drawing characters
-
-### Supported Terminals
-
-Tested and working:
-
-| Terminal | Platform | Notes |
-|----------|----------|-------|
-| Alacritty | Cross-platform | Full support |
-| Kitty | Linux/macOS | Full support |
-| iTerm2 | macOS | Full support |
-| WezTerm | Cross-platform | Full support |
-| GNOME Terminal | Linux | Full support |
-| Windows Terminal | Windows | Full support |
-| Terminal.app | macOS | Limited mouse |
-| xterm | Cross-platform | Full support |
-
-### SSH Sessions
-
-TermUI works over SSH when the remote terminal supports required features. The runtime detects terminal capabilities through multiple methods to ensure SSH compatibility.
-
-## Error Handling
-
-### Terminal Not Available
-
-Handle cases where no terminal is present:
-
-```elixir
-case TermUI.Runtime.start_link(root: MyApp) do
- {:ok, pid} ->
- # Running normally
- pid
-
- {:error, :not_a_terminal} ->
- IO.puts("Error: Must run in a terminal")
- System.halt(1)
-end
-```
-
-### Cleanup on Crash
-
-The runtime traps exits and restores terminal state even if your app crashes:
-
-```elixir
-# In Runtime.init/1
-Process.flag(:trap_exit, true)
-
-# In Runtime.terminate/2
-Terminal.restore() # Always runs
-```
-
-This ensures users don't get stuck in raw mode with no echo.
-
-## Direct Terminal Access
-
-For advanced use cases, access terminal functions directly:
-
-```elixir
-alias TermUI.Terminal
-
-# These are managed by Runtime, but available if needed:
-Terminal.enable_raw_mode()
-Terminal.disable_raw_mode()
-Terminal.enter_alternate_screen()
-Terminal.leave_alternate_screen()
-Terminal.show_cursor()
-Terminal.hide_cursor()
-Terminal.clear_screen()
-Terminal.set_cursor_position(row, col)
-```
-
-**Warning:** Direct terminal access can interfere with the runtime. Use only when necessary.
-
-## Next Steps
-
-- [Events](04-events.md) - Handle terminal input
-- [Commands](09-commands.md) - Async operations
-- [Styling](05-styling.md) - Colors and attributes
diff --git a/guides/user/09-commands.md b/guides/user/09-commands.md
deleted file mode 100644
index 93e295cc..00000000
--- a/guides/user/09-commands.md
+++ /dev/null
@@ -1,377 +0,0 @@
-# Commands
-
-Commands represent side effects in TermUI applications. They're returned from `update/2` and executed asynchronously by the runtime.
-
-## Why Commands?
-
-The Elm Architecture keeps `update/2` pure - it only transforms state based on messages. Side effects like timers, file I/O, and HTTP requests are described as commands and executed by the runtime.
-
-Benefits:
-- **Testable** - Test state logic without mocking side effects
-- **Predictable** - State changes are synchronous and traceable
-- **Composable** - Combine multiple commands easily
-
-## Command Basics
-
-Return commands from `update/2`:
-
-```elixir
-def update(:start_timer, state) do
- # Return new state AND a list of commands
- {state, [Command.timer(1000, :timer_done)]}
-end
-
-def update(:timer_done, state) do
- # Handle the result
- {%{state | timer_fired: true}, []}
-end
-```
-
-## Available Commands
-
-### Timer
-
-Execute a message after a delay:
-
-```elixir
-# Fire :timeout message after 5 seconds
-Command.timer(5000, :timeout)
-
-# With data in the message
-Command.timer(1000, {:delayed_action, some_data})
-```
-
-### Quit
-
-Request application shutdown:
-
-```elixir
-def update(:quit, state) do
- {state, [:quit]}
-end
-
-# Or using Command module
-def update(:quit, state) do
- {state, [Command.quit()]}
-end
-```
-
-The runtime will:
-1. Stop accepting new events
-2. Clean up resources
-3. Restore terminal state
-4. Exit the process
-
-### None
-
-Explicit no-op (useful for conditional commands):
-
-```elixir
-def update(:maybe_save, state) do
- cmd = if state.dirty do
- Command.timer(0, :do_save)
- else
- Command.none()
- end
- {state, [cmd]}
-end
-```
-
-## Command Patterns
-
-### Debouncing
-
-Delay action until input stops:
-
-```elixir
-def init(_opts) do
- %{search: "", debounce_ref: nil}
-end
-
-def update({:search_input, text}, state) do
- # Cancel previous timer if any
- commands = if state.debounce_ref do
- [] # Previous timer will be ignored
- else
- []
- end
-
- # Start new debounce timer
- ref = make_ref()
- commands = commands ++ [Command.timer(300, {:do_search, ref})]
-
- {%{state | search: text, debounce_ref: ref}, commands}
-end
-
-def update({:do_search, ref}, %{debounce_ref: ref} = state) do
- # Ref matches - this is the latest search
- # Perform search...
- {%{state | results: search(state.search)}, []}
-end
-
-def update({:do_search, _old_ref}, state) do
- # Ref doesn't match - ignore stale search
- {state, []}
-end
-```
-
-### Chained Operations
-
-Sequence multiple async operations:
-
-```elixir
-def update(:start_workflow, state) do
- {%{state | step: :loading}, [Command.timer(0, :step_1)]}
-end
-
-def update(:step_1, state) do
- # Do step 1...
- {%{state | step: :step_1_done}, [Command.timer(100, :step_2)]}
-end
-
-def update(:step_2, state) do
- # Do step 2...
- {%{state | step: :step_2_done}, [Command.timer(100, :step_3)]}
-end
-
-def update(:step_3, state) do
- {%{state | step: :complete}, []}
-end
-```
-
-### Polling
-
-Periodic updates:
-
-```elixir
-def init(_opts) do
- # Start polling immediately
- %{data: nil}
-end
-
-def update(:init, state) do
- {state, [Command.timer(0, :poll)]}
-end
-
-def update(:poll, state) do
- # Fetch new data
- new_data = fetch_data()
-
- # Schedule next poll
- {%{state | data: new_data}, [Command.timer(5000, :poll)]}
-end
-```
-
-### Conditional Commands
-
-Build command list based on state:
-
-```elixir
-def update(:save, state) do
- commands = []
-
- # Always show saving indicator
- commands = commands ++ [Command.timer(0, :show_saving)]
-
- # Maybe backup first
- commands = if state.backup_enabled do
- commands ++ [Command.timer(0, :backup)]
- else
- commands
- end
-
- # Do the save
- commands = commands ++ [Command.timer(100, :do_save)]
-
- {state, commands}
-end
-```
-
-### Error Handling
-
-Handle command failures:
-
-```elixir
-def update(:load_data, state) do
- {%{state | loading: true}, [Command.timer(0, :do_load)]}
-end
-
-def update(:do_load, state) do
- case fetch_data() do
- {:ok, data} ->
- {%{state | loading: false, data: data, error: nil}, []}
-
- {:error, reason} ->
- {%{state | loading: false, error: reason}, []}
- end
-end
-
-def view(state) do
- cond do
- state.loading -> text("Loading...")
- state.error -> text("Error: #{state.error}", Style.new(fg: :red))
- true -> render_data(state.data)
- end
-end
-```
-
-### Animation
-
-Frame-based animation:
-
-```elixir
-@frame_interval 50 # ~20 FPS
-
-def init(_opts) do
- %{frame: 0, animating: false}
-end
-
-def update(:start_animation, state) do
- {%{state | animating: true, frame: 0}, [Command.timer(@frame_interval, :animate)]}
-end
-
-def update(:animate, %{animating: true} = state) do
- next_frame = state.frame + 1
-
- if next_frame >= 60 do
- # Animation complete
- {%{state | animating: false}, []}
- else
- # Continue animation
- {%{state | frame: next_frame}, [Command.timer(@frame_interval, :animate)]}
- end
-end
-
-def update(:animate, state) do
- # Animation was stopped
- {state, []}
-end
-
-def update(:stop_animation, state) do
- {%{state | animating: false}, []}
-end
-```
-
-### Spinner
-
-Indeterminate progress indicator:
-
-```elixir
-@spinner_frames ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]
-@spinner_interval 80
-
-def init(_opts) do
- %{loading: false, spinner_frame: 0}
-end
-
-def update(:start_loading, state) do
- {%{state | loading: true}, [Command.timer(@spinner_interval, :spin)]}
-end
-
-def update(:spin, %{loading: true} = state) do
- next_frame = rem(state.spinner_frame + 1, length(@spinner_frames))
- {%{state | spinner_frame: next_frame}, [Command.timer(@spinner_interval, :spin)]}
-end
-
-def update(:spin, state) do
- {state, []}
-end
-
-def update(:stop_loading, state) do
- {%{state | loading: false}, []}
-end
-
-def view(state) do
- if state.loading do
- frame = Enum.at(@spinner_frames, state.spinner_frame)
- text("#{frame} Loading...")
- else
- text("Ready")
- end
-end
-```
-
-## Multiple Commands
-
-Return multiple commands at once:
-
-```elixir
-def update(:initialize, state) do
- commands = [
- Command.timer(0, :load_config),
- Command.timer(0, :load_data),
- Command.timer(0, :start_heartbeat)
- ]
- {state, commands}
-end
-```
-
-Commands execute concurrently. Results arrive as separate messages.
-
-## Testing Commands
-
-Test that correct commands are returned:
-
-```elixir
-defmodule MyApp.ComponentTest do
- use ExUnit.Case
- alias TermUI.Command
-
- test "quit returns quit command" do
- state = %{count: 0}
- {_new_state, commands} = MyApp.Component.update(:quit, state)
-
- assert :quit in commands
- end
-
- test "start timer returns timer command" do
- state = %{}
- {_new_state, commands} = MyApp.Component.update(:start, state)
-
- assert [Command.timer(1000, :tick)] == commands
- end
-end
-```
-
-## Custom Commands
-
-For operations not covered by built-in commands, use timer with immediate execution:
-
-```elixir
-def update(:custom_operation, state) do
- # Timer with 0 delay executes on next message loop
- {state, [Command.timer(0, :do_custom)]}
-end
-
-def update(:do_custom, state) do
- # Perform the operation synchronously
- result = perform_custom_operation()
- {%{state | result: result}, []}
-end
-```
-
-For truly async operations (HTTP, file I/O), spawn a task:
-
-```elixir
-def update(:fetch_data, state) do
- # Start async task
- Task.start(fn ->
- result = HTTPClient.get(url)
- # Send result back to runtime
- send(self(), {:data_loaded, result})
- end)
-
- {%{state | loading: true}, []}
-end
-
-# In event_to_msg or handle_info
-def event_to_msg({:data_loaded, result}, _state) do
- {:msg, {:data_loaded, result}}
-end
-```
-
-## Next Steps
-
-- [Elm Architecture](03-elm-architecture.md) - How commands fit in
-- [Events](04-events.md) - Handle command results
-- [Widgets](07-widgets.md) - Animated widgets
diff --git a/guides/user/10-advanced-widgets.md b/guides/user/10-advanced-widgets.md
deleted file mode 100644
index f6621546..00000000
--- a/guides/user/10-advanced-widgets.md
+++ /dev/null
@@ -1,992 +0,0 @@
-# Advanced Widgets
-
-TermUI includes advanced widgets for complex UI patterns including navigation, overlays, visualization, data streaming, and BEAM introspection. This guide covers these widgets and how to use them.
-
-All advanced widgets use the StatefulComponent pattern:
-
-```elixir
-# 1. Create props with Widget.new(opts)
-props = Widget.new(option: value)
-
-# 2. Initialize state with Widget.init(props)
-{:ok, widget_state} = Widget.init(props)
-
-# 3. Handle events with Widget.handle_event(event, state)
-{:ok, widget_state} = Widget.handle_event(event, widget_state)
-
-# 4. Render with Widget.render(state, area)
-node = Widget.render(widget_state, %{width: 80, height: 24})
-```
-
-## Navigation Widgets
-
-### Tabs
-
-> **Example:** See [`examples/tabs/`](../../examples/tabs/) for a complete demonstration.
-
-Tabbed interface for organizing content into switchable panels.
-
-```elixir
-alias TermUI.Widgets.Tabs
-
-# Create props
-props = Tabs.new(
- tabs: ["Overview", "Details", "Settings"],
- on_change: fn index -> handle_tab_change(index) end
-)
-
-# Initialize and use
-{:ok, tabs_state} = Tabs.init(props)
-{:ok, tabs_state} = Tabs.handle_event(event, tabs_state)
-Tabs.render(tabs_state, %{width: 60, height: 1})
-```
-
-**Options:**
-
-| Option | Type | Default | Description |
-|--------|------|---------|-------------|
-| `tabs` | list | required | Tab labels |
-| `on_change` | function | `nil` | Tab change callback |
-| `style` | Style | default | Tab bar style |
-| `selected_style` | Style | reverse | Selected tab style |
-| `closeable` | boolean | `false` | Show close buttons |
-
-### Context Menu
-
-> **Example:** See [`examples/context_menu/`](../../examples/context_menu/) for a complete demonstration.
-
-Right-click context menu that appears at cursor position.
-
-```elixir
-alias TermUI.Widgets.ContextMenu
-
-# Create props
-props = ContextMenu.new(
- items: [
- %{label: "Cut", shortcut: "Ctrl+X", action: :cut},
- %{label: "Copy", shortcut: "Ctrl+C", action: :copy},
- %{label: "Paste", shortcut: "Ctrl+V", action: :paste},
- :separator,
- %{label: "Delete", action: :delete}
- ],
- position: {10, 5},
- on_select: fn action -> handle_menu_action(action) end
-)
-
-# Initialize and use
-{:ok, menu_state} = ContextMenu.init(props)
-{:ok, menu_state} = ContextMenu.handle_event(event, menu_state)
-ContextMenu.render(menu_state, %{width: 30, height: 10})
-```
-
-**Item Structure:**
-```elixir
-%{
- label: "Menu Item", # Display text
- shortcut: "Ctrl+X", # Optional shortcut hint
- action: :action_atom, # Action identifier
- disabled: false # Optional disabled state
-}
-```
-
-## Overlay Widgets
-
-### Alert Dialog
-
-> **Example:** See [`examples/alert_dialog/`](../../examples/alert_dialog/) for a complete demonstration.
-
-Modal dialog for confirmations and messages with standard button configurations.
-
-```elixir
-alias TermUI.Widgets.AlertDialog
-alias TermUI.Renderer.Style
-
-# Create props
-props = AlertDialog.new(
- type: :confirm,
- title: "Delete File",
- message: "Are you sure you want to delete this file?",
- on_result: fn result -> handle_result(result) end
-)
-
-# With custom styling
-props = AlertDialog.new(
- type: :error,
- title: "Error",
- message: "Something went wrong",
- background_style: Style.new(bg: :bright_black),
- border_style: Style.new(fg: :red, attrs: [:bold]),
- message_style: Style.new(fg: :white),
- on_result: fn result -> handle_result(result) end
-)
-
-# Initialize and use
-{:ok, dialog_state} = AlertDialog.init(props)
-{:ok, dialog_state} = AlertDialog.handle_event(event, dialog_state)
-AlertDialog.render(dialog_state, %{width: 80, height: 24})
-```
-
-**Options:**
-
-| Option | Type | Default | Description |
-|--------|------|---------|-------------|
-| `type` | atom | required | `:info`, `:success`, `:warning`, `:error`, `:confirm`, `:ok_cancel` |
-| `title` | string | required | Dialog title |
-| `message` | string | required | Dialog message |
-| `on_result` | function | `nil` | Callback with result (`:ok`, `:cancel`, `:yes`, `:no`) |
-| `width` | integer | `50` | Dialog width |
-| `background_style` | `Style.t()` | `Style.new(bg: :black)` | Dialog background style |
-| `border_style` | `Style.t()` | `Style.new(fg: :cyan)` | Border and title style |
-| `icon_style` | `Style.t()` | `nil` | Style for the icon |
-| `message_style` | `Style.t()` | `nil` | Style for the message |
-| `button_style` | `Style.t()` | `nil` | Style for buttons |
-| `focused_button_style` | `Style.t()` | `nil` | Style for focused button |
-
-**Type Icons:**
-- `:info` - ℹ (information)
-- `:warning` - ⚠ (warning)
-- `:error` - ✖ (error)
-- `:success` - ✔ (success)
-- `:confirm` - ? (confirmation)
-- `:ok_cancel` - ? (OK/Cancel)
-
-**Keyboard Navigation:**
-- `Tab` / `Shift+Tab` - Move between buttons
-- `Enter` / `Space` - Activate focused button
-- `Escape` - Close (same as Cancel/No)
-- `Y` / `N` - Yes/No (in confirm dialogs)
-
-### Toast
-
-> **Example:** See [`examples/toast/`](../../examples/toast/) for a complete demonstration.
-
-Non-blocking notification that auto-dismisses. Use `ToastManager` to manage multiple toasts with stacking.
-
-```elixir
-alias TermUI.Widgets.ToastManager
-
-# Create manager in your init
-def init(_opts) do
- %{
- toast_manager: ToastManager.new(
- position: :bottom_right,
- default_duration: 3000,
- max_toasts: 5
- )
- }
-end
-
-# Add toasts
-def update({:show_toast, type, message}, state) do
- manager = ToastManager.add_toast(state.toast_manager, message, type)
- {%{state | toast_manager: manager}, []}
-end
-
-# Update on tick (removes expired toasts)
-def update(:tick, state) do
- manager = ToastManager.tick(state.toast_manager)
- {%{state | toast_manager: manager}, []}
-end
-
-# Render in view
-def view(state) do
- stack(:vertical, [
- render_main_content(state),
- ToastManager.render(state.toast_manager, %{width: 80, height: 24, x: 0, y: 0})
- ])
-end
-```
-
-**ToastManager Options:**
-
-| Option | Type | Default | Description |
-|--------|------|---------|-------------|
-| `position` | atom | `:bottom_right` | Toast position (see below) |
-| `max_toasts` | integer | 5 | Maximum simultaneous toasts |
-| `default_duration` | integer | 3000 | Default duration in ms |
-| `spacing` | integer | 1 | Vertical spacing between toasts |
-
-**Positions:** `:top_left`, `:top_center`, `:top_right`, `:bottom_left`, `:bottom_center`, `:bottom_right`
-
-**Toast Types:** `:info` (ℹ blue), `:success` (✓ green), `:warning` (⚠ yellow), `:error` (✗ red)
-
-**ToastManager Functions:**
-
-```elixir
-# Add a toast
-manager = ToastManager.add_toast(manager, "Message", :success)
-manager = ToastManager.add_toast(manager, "Message", :warning, duration: 5000)
-
-# Update (removes expired toasts)
-manager = ToastManager.tick(manager)
-
-# Get visible toast count
-count = ToastManager.toast_count(manager)
-
-# Clear all toasts
-manager = ToastManager.clear_all(manager)
-```
-
-## Visualization Widgets
-
-### Bar Chart
-
-> **Example:** See [`examples/bar_chart/`](../../examples/bar_chart/) for a complete demonstration.
-
-Horizontal or vertical bar chart for categorical data.
-
-```elixir
-alias TermUI.Widgets.BarChart
-
-# Render directly (simple widget)
-BarChart.render(
- data: [
- %{label: "Sales", value: 150},
- %{label: "Marketing", value: 80},
- %{label: "Engineering", value: 200}
- ],
- width: 40,
- show_values: true,
- show_labels: true
-)
-```
-
-**Options:**
-
-| Option | Type | Default | Description |
-|--------|------|---------|-------------|
-| `data` | list | required | List of `%{label, value}` maps |
-| `direction` | atom | `:horizontal` | `:horizontal` or `:vertical` |
-| `width` | integer | 40 | Chart width |
-| `height` | integer | 10 | Chart height (vertical only) |
-| `show_values` | boolean | `true` | Display values |
-| `show_labels` | boolean | `true` | Display labels |
-
-**Example Output:**
-```
-Sales ████████████████ 150
-Marketing ████████ 80
-Engineering █████████████████████ 200
-```
-
-### Line Chart
-
-> **Example:** See [`examples/line_chart/`](../../examples/line_chart/) for a complete demonstration.
-
-Line chart using Braille characters for sub-character resolution.
-
-```elixir
-alias TermUI.Widgets.LineChart
-
-# Single series
-LineChart.render(
- data: [10, 25, 18, 30, 22, 35, 28],
- width: 40,
- height: 8
-)
-
-# Multiple series
-LineChart.render(
- series: [
- %{data: cpu_history, style: Style.new(fg: :green)},
- %{data: mem_history, style: Style.new(fg: :yellow)}
- ],
- width: 60,
- height: 10,
- min: 0,
- max: 100
-)
-```
-
-**Options:**
-
-| Option | Type | Default | Description |
-|--------|------|---------|-------------|
-| `data` | list | - | Single series data |
-| `series` | list | - | Multiple series with styles |
-| `width` | integer | 40 | Chart width |
-| `height` | integer | 8 | Chart height |
-| `min` | number | auto | Y-axis minimum |
-| `max` | number | auto | Y-axis maximum |
-
-### Canvas
-
-> **Example:** See [`examples/canvas/`](../../examples/canvas/) for a complete demonstration.
-
-Direct drawing surface for custom visualizations.
-
-```elixir
-alias TermUI.Widgets.Canvas
-
-# Create canvas props
-props = Canvas.new(
- width: 60,
- height: 20
-)
-
-{:ok, canvas_state} = Canvas.init(props)
-
-# Draw on canvas
-canvas_state = canvas_state
- |> Canvas.draw_rect(0, 0, 59, 19)
- |> Canvas.draw_line(0, 10, 59, 10)
- |> Canvas.draw_text(25, 0, "Title", Style.new(fg: :cyan))
-
-Canvas.render(canvas_state, %{width: 60, height: 20})
-```
-
-**Drawing Functions:**
-
-| Function | Description |
-|----------|-------------|
-| `draw_text(x, y, text, style)` | Draw text at position |
-| `draw_line(x1, y1, x2, y2)` | Draw line between points |
-| `draw_rect(x, y, w, h, opts)` | Draw rectangle |
-| `fill_rect(x, y, w, h, char)` | Fill rectangle with character |
-| `clear()` | Clear canvas |
-
-## Layout Widgets
-
-### Markdown Viewer
-
-> **Example:** See [`examples/markdown_viewer/`](../../examples/markdown_viewer/) for a complete demonstration.
-
-Scrollable markdown viewer with syntax highlighting for code blocks.
-
-```elixir
-alias TermUI.Widgets.MarkdownViewer
-
-# Create props
-props = MarkdownViewer.new(
- content: "# Hello World\n\nThis is **bold** and `code`.\n\n```elixir\ndef hello do\n :world\nend\n```",
- width: 80,
- height: 24,
- on_copy: fn code -> IO.puts("Copied: #{code}") end
-)
-
-# Initialize
-{:ok, viewer_state} = MarkdownViewer.init(props)
-
-# Handle events and render
-{:ok, viewer_state} = MarkdownViewer.handle_event(event, viewer_state)
-MarkdownViewer.render(viewer_state, %{width: 80, height: 24})
-
-# Update content dynamically
-MarkdownViewer.set_content(viewer_pid, "# New content")
-```
-
-**Features:**
-- CommonMark compliant markdown rendering via mdex
-- Syntax highlighting for code blocks (Elixir, Erlang, and many more)
-- Scrollable viewport with keyboard navigation
-- Focusable code blocks with copy functionality
-
-**Keyboard Controls:**
-- `↑/↓` - Scroll by line
-- `Page Up/Page Down` - Scroll by page
-- `Home/End` - Jump to top/bottom
-- `Tab` - Cycle focus through code blocks
-- `Shift+Tab` - Reverse cycle through code blocks
-- `Enter` / `c` - Copy focused code block
-- Mouse wheel - Scroll
-
-**Supported Markdown:**
-- Headings (`#`, `##`, etc.)
-- Bold (`**text**`), italic (`*text*`)
-- Code (`` `inline` ``) and code blocks (fenced with ` ``` `)
-- Lists (ordered and unordered)
-- Blockquotes (`>`)
-- Links and images
-
-**Options:**
-
-| Option | Type | Default | Description |
-|--------|------|---------|-------------|
-| `content` | string | required | Markdown content to display |
-| `width` | integer | 80 | Display width |
-| `height` | integer | 24 | Display height |
-| `on_copy` | function | `nil` | Callback when code block copied |
-
-**Helper Functions:**
-
-```elixir
-# Update content dynamically (from another process)
-MarkdownViewer.set_content(viewer_pid, "# Updated content")
-```
-
-### Viewport
-
-> **Example:** See [`examples/viewport/`](../../examples/viewport/) for a complete demonstration.
-
-Scrollable view of content larger than the display area. The Viewport widget clips content to a visible region and supports both keyboard and mouse scrolling.
-
-```elixir
-alias TermUI.Widgets.Viewport
-
-# Create props
-props = Viewport.new(
- content: my_large_content(), # The content to scroll (render node)
- content_width: 200, # Total width of content
- content_height: 100, # Total height of content
- width: 60, # Viewport width
- height: 20, # Viewport height
- scroll_x: 0, # Initial horizontal scroll
- scroll_y: 0, # Initial vertical scroll
- scroll_bars: :both # :none, :vertical, :horizontal, or :both
-)
-
-{:ok, viewport_state} = Viewport.init(props)
-{:ok, viewport_state} = Viewport.handle_event(scroll_event, viewport_state)
-Viewport.render(viewport_state, %{width: 60, height: 20})
-```
-
-**Keyboard Navigation:**
-- Arrow keys: Scroll by one line/column
-- Page Up/Down: Scroll by viewport height
-- Home/End: Scroll to top/bottom
-- Ctrl+Home/End: Scroll to top-left/bottom-right
-
-**Mouse Support:**
-- Mouse wheel: Scroll vertically
-- Click on scroll bar track: Page scroll
-- Drag scroll bar thumb: Direct scroll positioning
-
-**Helper Functions:**
-
-```elixir
-# Get current scroll position
-{x, y} = Viewport.get_scroll(state)
-
-# Set scroll position (clamped to valid range)
-state = Viewport.set_scroll(state, 50, 100)
-
-# Scroll to make a position visible
-state = Viewport.scroll_into_view(state, target_x, target_y)
-
-# Update content
-state = Viewport.set_content(state, new_content)
-
-# Update content dimensions
-state = Viewport.set_content_size(state, new_width, new_height)
-
-# Check if scrollable
-Viewport.can_scroll_vertical?(state) # true/false
-Viewport.can_scroll_horizontal?(state) # true/false
-```
-
-**Complete Example:**
-
-```elixir
-defmodule MyApp do
- use TermUI.Elm
- alias TermUI.Widgets.Viewport
-
- def init(_opts) do
- # Create large scrollable content
- content = generate_large_content()
-
- props = Viewport.new(
- content: content,
- content_width: 200,
- content_height: 500,
- width: 60,
- height: 20,
- scroll_bars: :both
- )
-
- {:ok, viewport} = Viewport.init(props)
- %{viewport: viewport}
- end
-
- def event_to_msg(event, _state) do
- {:msg, {:viewport_event, event}}
- end
-
- def update({:viewport_event, event}, state) do
- {:ok, new_viewport} = Viewport.handle_event(event, state.viewport)
- {%{state | viewport: new_viewport}, []}
- end
-
- def view(state) do
- Viewport.render(state.viewport, %{width: 60, height: 20})
- end
-
- defp generate_large_content do
- lines = for i <- 1..500 do
- {:text, "Line #{i}: Lorem ipsum dolor sit amet, consectetur adipiscing elit"}
- end
- stack(:vertical, lines)
- end
-end
-```
-
-### Split Pane
-
-> **Example:** See [`examples/split_pane/`](../../examples/split_pane/) for a complete demonstration.
-
-Resizable split layout for IDE-style interfaces.
-
-```elixir
-alias TermUI.Widgets.SplitPane
-
-# Create props
-props = SplitPane.new(
- direction: :horizontal,
- initial_ratio: 0.3,
- min_size: 10,
- max_size: 50,
- on_resize: fn ratio -> handle_resize(ratio) end
-)
-
-{:ok, pane_state} = SplitPane.init(props)
-{:ok, pane_state} = SplitPane.handle_event(event, pane_state)
-SplitPane.render(pane_state, %{width: 100, height: 30})
-```
-
-**Options:**
-
-| Option | Type | Default | Description |
-|--------|------|---------|-------------|
-| `direction` | atom | `:horizontal` | `:horizontal` or `:vertical` |
-| `initial_ratio` | float | 0.5 | Split ratio (0.0-1.0) |
-| `min_size` | integer | 5 | Minimum pane size |
-| `max_size` | integer | `nil` | Maximum pane size |
-| `draggable` | boolean | `true` | Allow resize |
-
-### Tree View
-
-> **Example:** See [`examples/tree_view/`](../../examples/tree_view/) for a complete demonstration.
-
-Hierarchical data with expand/collapse.
-
-```elixir
-alias TermUI.Widgets.TreeView
-
-# Create props
-props = TreeView.new(
- data: [
- %{
- id: :src,
- label: "src",
- icon: "📁",
- children: [
- %{id: :main, label: "main.ex", icon: "📄"},
- %{id: :utils, label: "utils.ex", icon: "📄"}
- ]
- },
- %{id: :readme, label: "README.md", icon: "📄"}
- ],
- on_select: fn node_id -> handle_select(node_id) end
-)
-
-{:ok, tree_state} = TreeView.init(props)
-{:ok, tree_state} = TreeView.handle_event(event, tree_state)
-TreeView.render(tree_state, %{width: 40, height: 20})
-```
-
-**Node Structure:**
-```elixir
-%{
- id: unique_id, # Required
- label: "Node Name",
- icon: "📁", # Optional icon
- children: [...] # Optional child nodes
-}
-```
-
-## Input Widgets
-
-### Form Builder
-
-> **Example:** See [`examples/form_builder/`](../../examples/form_builder/) for a complete demonstration.
-
-Structured forms with validation and multiple field types.
-
-```elixir
-alias TermUI.Widgets.FormBuilder
-
-# Create props
-props = FormBuilder.new(
- fields: [
- %{id: :username, type: :text, label: "Username", required: true},
- %{id: :password, type: :password, label: "Password", required: true,
- validators: [&validate_password/1]},
- %{id: :role, type: :select, label: "Role",
- options: [{"admin", "Admin"}, {"user", "User"}]},
- %{id: :notifications, type: :checkbox, label: "Email notifications"},
- %{id: :theme, type: :radio, label: "Theme",
- options: [{"light", "Light"}, {"dark", "Dark"}]}
- ],
- submit_label: "Register",
- label_width: 15,
- field_width: 30
-)
-
-{:ok, form_state} = FormBuilder.init(props)
-
-# Handle events
-{:ok, form_state} = FormBuilder.handle_event(event, form_state)
-
-# Get form values
-values = FormBuilder.get_values(form_state)
-
-# Render
-FormBuilder.render(form_state, %{width: 60, height: 20})
-```
-
-**Field Types:**
-
-| Type | Description |
-|------|-------------|
-| `:text` | Single-line text input |
-| `:password` | Masked password input |
-| `:checkbox` | Boolean checkbox |
-| `:radio` | Radio button group |
-| `:select` | Dropdown selection |
-| `:multi_select` | Multiple selection |
-
-**Field Options:**
-```elixir
-%{
- id: :field_name,
- type: :text,
- label: "Field Label",
- required: true,
- placeholder: "Enter value...",
- validators: [&custom_validator/1],
- visible_when: fn values -> values[:other_field] == true end
-}
-```
-
-### Command Palette
-
-> **Example:** See [`examples/command_palette/`](../../examples/command_palette/) for a complete demonstration.
-
-VS Code-style command interface with fuzzy search.
-
-```elixir
-alias TermUI.Widgets.CommandPalette
-
-# Create props
-props = CommandPalette.new(
- commands: [
- %{id: :save, label: "Save File", shortcut: "Ctrl+S", category: :file},
- %{id: :open, label: "Open File", shortcut: "Ctrl+O", category: :file},
- %{id: :find, label: "Find", shortcut: "Ctrl+F", category: :edit},
- %{id: :replace, label: "Find and Replace", shortcut: "Ctrl+H", category: :edit}
- ],
- on_select: fn command_id -> execute_command(command_id) end,
- on_close: fn -> hide_palette() end,
- placeholder: "Type a command..."
-)
-
-{:ok, palette_state} = CommandPalette.init(props)
-{:ok, palette_state} = CommandPalette.handle_event(event, palette_state)
-CommandPalette.render(palette_state, %{width: 80, height: 24})
-```
-
-**Command Structure:**
-```elixir
-%{
- id: :command_id,
- label: "Command Label",
- shortcut: "Ctrl+K", # Optional
- category: :file, # Optional, for grouping
- description: "Details" # Optional
-}
-```
-
-## Data Streaming Widgets
-
-### Log Viewer
-
-> **Example:** See [`examples/log_viewer/`](../../examples/log_viewer/) for a complete demonstration.
-
-High-performance log viewer with virtual scrolling, search, and filtering.
-
-```elixir
-alias TermUI.Widgets.LogViewer
-
-# Create props
-props = LogViewer.new(
- max_lines: 10000,
- wrap_lines: false,
- show_line_numbers: true,
- show_timestamps: true
-)
-
-{:ok, viewer_state} = LogViewer.init(props)
-
-# Add log lines
-viewer_state = LogViewer.append_line(viewer_state, %{
- timestamp: DateTime.utc_now(),
- level: :info,
- message: "Application started",
- source: "MyApp"
-})
-
-# Handle events and render
-{:ok, viewer_state} = LogViewer.handle_event(event, viewer_state)
-LogViewer.render(viewer_state, %{width: 100, height: 30})
-```
-
-**Log Line Structure:**
-```elixir
-%{
- timestamp: ~U[2024-01-15 10:30:00Z],
- level: :info, # :debug, :info, :warning, :error
- message: "Log message",
- source: "MyApp.Worker" # Optional
-}
-```
-
-**Keyboard Controls:**
-- `↑/↓` - Scroll line by line
-- `PgUp/PgDn` - Scroll by page
-- `Home/End` - Jump to start/end
-- `/` - Start search
-- `f` - Toggle filter
-- `t` - Toggle tail mode
-- `w` - Toggle line wrap
-
-### Stream Widget
-
-> **Example:** See [`examples/stream_widget/`](../../examples/stream_widget/) for a complete demonstration.
-
-GenStage-integrated widget for real-time data streams with backpressure.
-
-```elixir
-alias TermUI.Widgets.StreamWidget
-
-# Create props
-props = StreamWidget.new(
- buffer_size: 1000,
- rate_limit: 60, # updates per second
- overflow: :drop_oldest
-)
-
-{:ok, stream_state} = StreamWidget.init(props)
-
-# Push data to stream
-stream_state = StreamWidget.push(stream_state, data_item)
-
-# Handle events and render
-{:ok, stream_state} = StreamWidget.handle_event(event, stream_state)
-StreamWidget.render(stream_state, %{width: 80, height: 20})
-```
-
-**Options:**
-
-| Option | Type | Default | Description |
-|--------|------|---------|-------------|
-| `buffer_size` | integer | 1000 | Maximum buffered items |
-| `rate_limit` | integer | 60 | Max renders per second |
-| `overflow` | atom | `:drop_oldest` | `:drop_oldest`, `:drop_newest` |
-
-## BEAM Introspection Widgets
-
-These widgets leverage Erlang's runtime introspection capabilities for live system visualization.
-
-### Process Monitor
-
-> **Example:** See [`examples/process_monitor/`](../../examples/process_monitor/) for a complete demonstration.
-
-Live BEAM process inspection with sorting, filtering, and process control.
-
-```elixir
-alias TermUI.Widgets.ProcessMonitor
-
-props = ProcessMonitor.new(
- update_interval: 1000,
- show_system_processes: false,
- thresholds: %{
- queue_warning: 1000,
- queue_critical: 10_000,
- memory_warning: 50_000_000,
- memory_critical: 200_000_000
- }
-)
-
-{:ok, monitor_state} = ProcessMonitor.init(props)
-
-# Handle timer messages for auto-refresh
-{:ok, monitor_state} = ProcessMonitor.handle_info(:refresh, monitor_state)
-
-# Handle events and render
-{:ok, monitor_state} = ProcessMonitor.handle_event(event, monitor_state)
-ProcessMonitor.render(monitor_state, %{width: 100, height: 30})
-```
-
-**Keyboard Controls:**
-- `↑/↓` - Navigate processes
-- `Enter` - Toggle details panel
-- `s/S` - Cycle sort field / Toggle direction
-- `/` - Filter by name
-- `k` - Kill process (with confirmation)
-- `r` - Refresh
-
-**Display Columns:**
-- PID
-- Name (registered or initial call)
-- Reductions
-- Memory
-- Message Queue
-- Status
-
-### Supervision Tree Viewer
-
-> **Example:** See [`examples/supervision_tree_viewer/`](../../examples/supervision_tree_viewer/) for a complete demonstration.
-
-Visualize supervision hierarchies with live status.
-
-```elixir
-alias TermUI.Widgets.SupervisionTreeViewer
-
-props = SupervisionTreeViewer.new(
- root: MyApp.Supervisor,
- update_interval: 2000,
- show_pids: true,
- expand_all: false
-)
-
-{:ok, tree_state} = SupervisionTreeViewer.init(props)
-
-# Handle timer messages for auto-refresh
-{:ok, tree_state} = SupervisionTreeViewer.handle_info(:refresh, tree_state)
-
-# Handle events and render
-{:ok, tree_state} = SupervisionTreeViewer.handle_event(event, tree_state)
-SupervisionTreeViewer.render(tree_state, %{width: 80, height: 25})
-```
-
-**Keyboard Controls:**
-- `↑/↓` - Navigate tree
-- `Enter` - Expand/collapse node
-- `e/c` - Expand/collapse all
-- `i` - Inspect process state
-- `r` - Restart process (with confirmation)
-- `/` - Filter tree
-- `Escape` - Clear filter
-
-**Status Indicators:**
-- `●` Running (green)
-- `↻` Restarting (yellow)
-- `✖` Terminated (red)
-- `?` Undefined (gray)
-
-**Strategy Display:**
-- `1:1` - one_for_one
-- `1:*` - one_for_all
-- `1:→` - rest_for_one
-
-### Cluster Dashboard
-
-> **Example:** See [`examples/cluster_dashboard/`](../../examples/cluster_dashboard/) for a complete demonstration.
-
-Distributed Erlang cluster visualization.
-
-```elixir
-alias TermUI.Widgets.ClusterDashboard
-
-props = ClusterDashboard.new(
- update_interval: 2000,
- show_health_metrics: true,
- show_pg_groups: true,
- show_global_names: true
-)
-
-{:ok, dashboard_state} = ClusterDashboard.init(props)
-
-# Handle timer messages for auto-refresh
-{:ok, dashboard_state} = ClusterDashboard.handle_info(:refresh, dashboard_state)
-
-# Handle events and render
-{:ok, dashboard_state} = ClusterDashboard.handle_event(event, dashboard_state)
-ClusterDashboard.render(dashboard_state, %{width: 100, height: 30})
-```
-
-**View Modes:**
-- **Nodes** - Connected nodes with status and metrics
-- **Globals** - `:global` registered names
-- **PG Groups** - `:pg` process groups
-- **Events** - Connection/disconnection log
-
-**Keyboard Controls:**
-- `↑/↓` - Navigate list
-- `Enter` - Toggle details
-- `n` - Nodes view
-- `g` - Globals view
-- `p` - PG groups view
-- `e` - Events view
-- `r` - Refresh
-
-**Features:**
-- Network partition detection
-- Node health metrics (memory, processes, schedulers)
-- Connection event history
-
-## Full Example: Using BEAM Introspection Widgets
-
-```elixir
-defmodule MyApp.SystemMonitor do
- use TermUI.Elm
-
- alias TermUI.Event
- alias TermUI.Widgets.ProcessMonitor
- alias TermUI.Renderer.Style
-
- def init(_opts) do
- props = ProcessMonitor.new(
- update_interval: 1000,
- show_system_processes: false
- )
- {:ok, monitor_state} = ProcessMonitor.init(props)
-
- %{
- monitor: monitor_state,
- last_refresh: DateTime.utc_now()
- }
- end
-
- def event_to_msg(%Event.Key{key: "q"}, _state), do: {:msg, :quit}
- def event_to_msg(%Event.Key{key: "r"}, _state), do: {:msg, :refresh}
- def event_to_msg(event, _state), do: {:msg, {:monitor_event, event}}
-
- def update(:quit, state), do: {state, [:quit]}
-
- def update(:refresh, state) do
- {:ok, monitor} = ProcessMonitor.handle_info(:refresh, state.monitor)
- {%{state | monitor: monitor, last_refresh: DateTime.utc_now()}, []}
- end
-
- def update({:monitor_event, event}, state) do
- {:ok, monitor} = ProcessMonitor.handle_event(event, state.monitor)
- {%{state | monitor: monitor}, []}
- end
-
- # Auto-refresh timer
- def handle_info(:tick, state) do
- {:ok, monitor} = ProcessMonitor.handle_info(:refresh, state.monitor)
- {%{state | monitor: monitor, last_refresh: DateTime.utc_now()},
- [Command.timer(1000, :tick)]}
- end
-
- def view(state) do
- stack(:vertical, [
- text("System Monitor", Style.new(fg: :cyan, attrs: [:bold])),
- text("Last refresh: #{state.last_refresh}", Style.new(fg: :bright_black)),
- text(""),
- ProcessMonitor.render(state.monitor, %{width: 100, height: 25}),
- text(""),
- text("[R] Refresh [Q] Quit", Style.new(fg: :bright_black))
- ])
- end
-end
-```
-
-## Next Steps
-
-- [Widgets](07-widgets.md) - Basic widgets guide
-- [Styling](05-styling.md) - Customize widget appearance
-- [Layout](06-layout.md) - Position widgets
-- [Events](04-events.md) - Handle widget interactions
diff --git a/guides/user/README.md b/guides/user/README.md
deleted file mode 100644
index 74054fdd..00000000
--- a/guides/user/README.md
+++ /dev/null
@@ -1,51 +0,0 @@
-# TermUI User Guides
-
-Welcome to the TermUI documentation. These guides cover everything you need to build terminal user interfaces with Elixir.
-
-## Guides
-
-1. **[Overview](01-overview.md)** - Introduction to TermUI and its architecture
-2. **[Getting Started](02-getting-started.md)** - Build your first TermUI application
-3. **[The Elm Architecture](03-elm-architecture.md)** - Understanding the component model
-4. **[Events](04-events.md)** - Handling keyboard, mouse, and other input
-5. **[Styling](05-styling.md)** - Colors, attributes, and themes
-6. **[Layout](06-layout.md)** - Positioning and sizing components
-7. **[Widgets](07-widgets.md)** - Using pre-built components
-8. **[Terminal](08-terminal.md)** - Terminal modes and capabilities
-9. **[Commands](09-commands.md)** - Side effects and async operations
-10. **[Advanced Widgets](10-advanced-widgets.md)** - Navigation, visualization, data streaming, and BEAM introspection widgets
-
-## Quick Start
-
-```elixir
-defmodule MyApp do
- use TermUI.Elm
-
- def init(_opts), do: %{count: 0}
-
- def event_to_msg(%Event.Key{key: :up}, _), do: {:msg, :inc}
- def event_to_msg(%Event.Key{key: :down}, _), do: {:msg, :dec}
- def event_to_msg(%Event.Key{key: "q"}, _), do: {:msg, :quit}
- def event_to_msg(_, _), do: :ignore
-
- def update(:inc, s), do: {%{s | count: s.count + 1}, []}
- def update(:dec, s), do: {%{s | count: s.count - 1}, []}
- def update(:quit, s), do: {s, [:quit]}
-
- def view(state), do: text("Count: #{state.count}")
-end
-
-# Run with: TermUI.Runtime.run(root: MyApp)
-```
-
-## Requirements
-
-- Elixir 1.15+
-- OTP 28+
-- Terminal with ANSI support
-
-## Examples
-
-See the `examples/` directory for complete applications:
-
-- **dashboard** - System monitoring dashboard with gauges, sparklines, and tables
diff --git a/guides/widget-parity.md b/guides/widget-parity.md
new file mode 100644
index 00000000..85a270f2
--- /dev/null
+++ b/guides/widget-parity.md
@@ -0,0 +1,71 @@
+# Widget migration parity
+
+This table compares the public plural widget modules in `term_ui 1.0.0-rc`
+with the pure widgets in the v2 architecture. A direct facade is safe only
+when one old value can map to one pure v2 value without a process, callback,
+selection change, or hidden effect.
+
+The status terms have these meanings:
+
+- **Direct**: The v2 state and user behavior match. A deprecated plural
+ facade is available.
+- **Reduced**: The main view exists, but some options, state, events, or
+ results differ. Use the singular module directly.
+- **Deferred**: A known behavior gap must close before a facade can be safe.
+- **Application-owned**: The parent application now owns polling, routing, or
+ another effect.
+- **Removed**: The old helper or process has no public v2 replacement.
+
+## Public table
+
+| v1 plural module | Direct v2 replacement | Status | Facade and migration note |
+| --- | --- | --- | --- |
+| `TermUI.Widgets.AlertDialog` | `TermUI.Widget.AlertDialog` | Reduced | No facade. Alert types, callback results, and focus state differ. |
+| `TermUI.Widgets.BarChart` | `TermUI.Widget.BarChart` | Reduced | No facade. V2 is a pure horizontal chart and does not claim old vertical-chart parity. |
+| `TermUI.Widgets.Canvas` | `TermUI.Widget.Canvas` | Reduced | No facade. Core character, line, rectangle, and Braille drawing remain, but the old read, fill, and render-node helpers do not. |
+| `TermUI.Widgets.ClusterDashboard` | `TermUI.Widget.ClusterDashboard`, `TermUI.Snapshot.ClusterProvider`, and application commands | Application-owned | No facade. The parent selects nodes, enables RPC, handles refresh requests, and applies stable provider items. |
+| `TermUI.Widgets.CommandPalette` | `TermUI.Widget.CommandPalette` | Reduced | No facade. Selection returns parent messages and uses pure pick-list state. |
+| `TermUI.Widgets.ContextMenu` | `TermUI.Widget.ContextMenu` | Reduced | No facade. V2 has nested parent-owned menu data and pure edge placement, but no process variants. |
+| `TermUI.Widgets.ContextMenu.Behavior`, `TermUI.Widgets.ContextMenu.Factory`, and `TermUI.Widgets.ContextMenu.Inline` | `TermUI.Widget.ContextMenu` and `TermUI.Widget.Menu` | Removed | No facade. The process and factory variants are not part of v2. |
+| `TermUI.Widgets.Dialog` | `TermUI.Widget.Dialog` | Reduced | No facade. V2 returns messages instead of calling close and confirm callbacks. |
+| `TermUI.Widgets.FormBuilder` | `TermUI.Widget.FormBuilder` | Reduced | No facade. Pure field, group, and submit validation restore the error flow, but field types and callback results do not match. |
+| `TermUI.Widgets.Gauge` | `TermUI.Widget.Gauge` | Reduced | No facade. V2 uses horizontal or vertical bars and does not claim old arc or traffic-light parity. |
+| `TermUI.Widgets.LineChart` | `TermUI.Widget.LineChart` | Reduced | No facade. V2 keeps the pure series view but has a smaller option set. |
+| `TermUI.Widgets.LogViewer` | `TermUI.Widget.LogViewer` | Reduced | No facade. Search, bookmark, selection, and callback state differ. |
+| `TermUI.Widgets.MarkdownViewer` | `TermUI.Widget.MarkdownViewer` | Reduced | No facade. V2 uses bounded documents, parent-owned copy messages, and an optional bounded syntax-highlighter adapter. The old process API does not match. |
+| `TermUI.Widgets.Menu` | `TermUI.Widget.Menu` | Reduced | No facade. Nested open-path behavior is pure, but v1 checkbox items and callback results do not match. |
+| `TermUI.Widgets.ProcessMonitor` | `TermUI.Widget.ProcessMonitor`, `TermUI.Snapshot.ProcessProvider`, and application commands | Application-owned | No facade. The parent selects processes, invokes bounded collection, and owns actions and polling. |
+| `TermUI.Widgets.ScrollBar` | `TermUI.Widget.ScrollBar` | Reduced | No facade. Size fields and callback results changed to pure state and messages. |
+| `TermUI.Widgets.Sparkline` | `TermUI.Widget.Sparkline` | Direct | Deprecated facade available. Both paths use the same pure state, scaling, character mapping, and frame view. |
+| `TermUI.Widgets.SplitPane` | `TermUI.Widget.SplitPane` | Reduced | No facade. V2 has explicit versioned layout serialization and safe restore, but it does not restore the process API or automatic file persistence. |
+| `TermUI.Widgets.StreamWidget` | `TermUI.Widget.Stream` and `TermUI.Stream.ProducerAdapter` | Application-owned | No facade. The parent owns the pure buffer and connects producers through bounded batches. |
+| `TermUI.Widgets.StreamWidget.Consumer` | `TermUI.Stream.ProducerAdapter` | Removed | No facade. The hidden GenStage consumer process is not restored. |
+| `TermUI.Widgets.SupervisionTreeViewer` | `TermUI.Widget.SupervisionTree`, `TermUI.Snapshot.SupervisionTreeProvider`, and application commands | Application-owned | No facade. The parent selects the root, invokes collection, and owns process actions and failure policy. |
+| `TermUI.Widgets.Table` and `TermUI.Widgets.Table.Column` | `TermUI.Widget.Table` and `TermUI.Widget.Table.Column` | Reduced | No facade. Sorting, filtering, and identity-based multi-selection are pure. V1 callbacks and constraint values do not match the v2 state and message API. |
+| `TermUI.Widgets.Tabs` | `TermUI.Widget.Tabs` | Reduced | No facade. Dynamic tab callbacks and state do not match the pure v2 selection contract. |
+| `TermUI.Widgets.TextInput` | `TermUI.Widget.TextInput` and `TermUI.Widget.TextArea` | Reduced | No facade. V2 separates single-line and multiline state and uses normalized text events. |
+| `TermUI.Widgets.TextInput.Line` | `TermUI.Widget.LineInput` | Reduced | No facade. Validation returns parent messages and no blocking read occurs inside the widget. |
+| `TermUI.Widgets.Toast` | `TermUI.Widget.Toast` | Reduced | No facade. Position and callback state do not match. |
+| `TermUI.Widgets.ToastManager` | `TermUI.Widget.Toast.Manager` | Reduced | No facade. V2 returns explicit token-safe timer commands and supports independent pure areas, but it does not restore a named global service. |
+| `TermUI.Widgets.TreeView` | `TermUI.Widget.TreeView` | Reduced | No facade. Lazy loading, filtering, and multi-selection state differ. |
+| `TermUI.Widgets.Viewport` | `TermUI.Widget.Viewport` | Reduced | No facade. The scrolling capability remains, but v2 measures frame rows and owns local scrollbar drag state. |
+| `TermUI.Widgets.VisualizationHelper` and `TermUI.Widgets.WidgetHelpers` | Direct `TermUI.Frame`, `TermUI.Style`, and widget functions | Removed | No facade. These implementation helpers are not a compatibility boundary. |
+
+## Direct sparkline migration
+
+The deprecated plural facade returns a v2 `TermUI.Frame`. It does not create a
+v1 render node.
+
+```elixir
+# Temporary source bridge
+frame = TermUI.Widgets.Sparkline.render(values: [1, 3, 2], width: 3)
+
+# Direct v2 replacement
+state = TermUI.Widget.Sparkline.init(values: [1, 3, 2])
+frame = TermUI.Widget.Sparkline.view(state, {3, 1})
+```
+
+The facade also delegates `value_to_bar/3`, `bar_characters/0`,
+`to_sparkline/2`, and `push/3` to the singular module. Each deprecated
+function names its direct replacement. No plural facade starts a process or
+returns a render node.
diff --git a/guides/widgets.md b/guides/widgets.md
new file mode 100644
index 00000000..eb8e9907
--- /dev/null
+++ b/guides/widgets.md
@@ -0,0 +1,392 @@
+# Pure widgets
+
+Every TermUI widget is plain data. The parent application owns its state and
+passes normalized events to it.
+
+```elixir
+state = TermUI.Widget.Table.init(columns: columns, rows: rows)
+{state, messages} = TermUI.Widget.Table.update(event, state)
+child = TermUI.Widget.Table.view(state, {60, 12})
+frame = TermUI.Frame.overlay(frame, child, 1, 3)
+```
+
+`update/2` does not perform effects. It returns messages for the parent. The
+parent can convert those messages to application updates or `TermUI.Command`
+values.
+
+### Table state and row identity
+
+The table keeps sorting, filtering, cursor, and selection in its pure state.
+Use a stable row identity when rows can move or change:
+
+```elixir
+table =
+ TermUI.Widget.Table.init(
+ columns: [{:name, "Name"}, {:score, "Score"}],
+ rows: users,
+ row_id: :id,
+ selection_mode: :multiple
+ )
+
+table = TermUI.Widget.Table.sort_by(table, :score, :desc)
+table = TermUI.Widget.Table.set_filter(table, & &1.active)
+selected_users = TermUI.Widget.Table.selected_rows(table)
+```
+
+Each row ID must be unique. It must not change when other row values change.
+The table stores selected IDs, so sorting and filtering do not change the
+selection. `set_rows/2` removes a selected ID only when the new row list no
+longer contains that ID. Without `:row_id`, the complete row value is the ID.
+Use that default only for unique rows whose values do not change.
+
+Selection mode can be `:none`, `:single`, or `:multiple`. Enter selects the
+cursor row. Space toggles the cursor row in multiple mode. A left-button
+release uses the same selection transition. The parent can also use
+`set_selection/2` and `clear_selection/1`.
+
+Use `TermUI.Widget.mouse/4` after the parent routes a mouse event to local
+widget coordinates. A widget can implement the optional `mouse/3` callback.
+The helper uses `update/2` as the fallback.
+
+Text input selection emits `{:copy, text}` for copy and cut actions. Convert
+that message to `TermUI.Clipboard.copy/2` in the parent application. See the
+[interaction guide](interaction.md).
+
+## Parent-owned child routing
+
+`TermUI.Widget.Router` removes repeated state access and message mapping for
+nested widgets. Each route has an explicit child ID and parent-state path.
+The route is data. It does not own state or use a process.
+
+```elixir
+alias TermUI.Widget.{Checkbox, Router}
+
+model = %{
+ save: Checkbox.init(id: :save),
+ publish: Checkbox.init(id: :publish)
+}
+
+save = Router.new(:save, Checkbox, [:save])
+publish = Router.new(:publish, Checkbox, [:publish])
+
+{model, messages} = Router.update(save, event, model)
+# messages use the form {:widget, :save, child_message}
+
+focus = TermUI.Focus.new([:save, :publish], current: :save)
+true = Router.focused?(save, focus)
+
+regions = [
+ Router.region(save, 0, 0, 20, 1),
+ Router.region(publish, 0, 2, 20, 1)
+]
+
+routed = TermUI.Mouse.route(regions, mouse_event)
+{model, messages} = Router.mouse(save, routed, model, {20, 1})
+```
+
+Call `mouse/4` for each possible route, or select one route by the returned
+ID. A route that does not own the returned ID leaves the parent unchanged.
+Use `:map_message` in `new/4` when the parent needs a different message form.
+Two child routes can use the same widget module because their IDs and state
+paths are independent.
+
+## Text and content
+
+- `TermUI.Widget.Label`
+- `TermUI.Widget.TextInput`
+- `TermUI.Widget.LineInput`
+- `TermUI.Widget.TextArea`
+- `TermUI.Widget.MarkdownViewer`
+- `TermUI.Widget.LogViewer`
+- `TermUI.Widget.Stream`
+- `TermUI.Widget.DiffViewer`
+
+## Selection and data entry
+
+- `TermUI.Widget.Button`
+- `TermUI.Widget.Checkbox`
+- `TermUI.Widget.List`
+- `TermUI.Widget.PickList`
+- `TermUI.Widget.Menu`
+- `TermUI.Widget.ContextMenu`
+- `TermUI.Widget.CommandPalette`
+- `TermUI.Widget.RadioGroup`
+- `TermUI.Widget.Select`
+- `TermUI.Widget.Tabs`
+- `TermUI.Widget.Table`
+- `TermUI.Widget.Toggle`
+- `TermUI.Widget.TreeView`
+- `TermUI.Widget.FormBuilder`
+
+## Layout and feedback
+
+- `TermUI.Widget.Block`
+- `TermUI.Widget.Breadcrumb`
+- `TermUI.Widget.Dialog`
+- `TermUI.Widget.AlertDialog`
+- `TermUI.Widget.SplitPane`
+- `TermUI.Widget.Viewport`
+- `TermUI.Widget.ScrollBar`
+- `TermUI.Widget.Spinner`
+- `TermUI.Widget.Toast`
+
+## Visualization and snapshots
+
+- `TermUI.Widget.Progress`
+- `TermUI.Widget.Gauge`
+- `TermUI.Widget.Sparkline`
+- `TermUI.Widget.BarChart`
+- `TermUI.Widget.LineChart`
+- `TermUI.Widget.Canvas`
+- `TermUI.Widget.ProcessMonitor`
+- `TermUI.Widget.SupervisionTree`
+- `TermUI.Widget.ClusterDashboard`
+
+Snapshot widgets do not call `Process.list/0`, monitor nodes, perform RPC, or
+subscribe to streams. The parent performs those effects and supplies bounded
+data with each widget's setter function.
+
+### Optional snapshot providers
+
+The one-shot providers return `%TermUI.Snapshot{status, items, errors}`.
+`status` is `:ok`, `:partial`, or `:error`. Each error has the stable shape
+`%{source: source, reason: reason}`. Provider items can go directly to the
+matching widget setter:
+
+```elixir
+processes = TermUI.Snapshot.ProcessProvider.collect(selected_pids)
+monitor = TermUI.Widget.ProcessMonitor.set_snapshots(monitor, processes.items)
+
+tree = TermUI.Snapshot.SupervisionTreeProvider.collect(root_supervisor)
+viewer = TermUI.Widget.SupervisionTree.set_nodes(viewer, tree.items)
+
+cluster = TermUI.Snapshot.ClusterProvider.collect(selected_nodes, rpc: rpc_fun)
+dashboard = TermUI.Widget.ClusterDashboard.set_nodes(dashboard, cluster.items)
+```
+
+Process items always contain `:pid`, `:name`, `:memory`, `:reductions`, and
+`:message_queue_len`. Supervision items use the public tree-node shape with a
+path-based ID, label, children, and disabled flag. Cluster items always contain
+`:node`, `:status`, `:processes`, `:memory`, and `:uptime`.
+
+`ProcessProvider.local/1` is an explicit one-shot convenience call.
+`ClusterProvider` does not call `Node.list/0`, and remote RPC is disabled until
+the parent supplies a five-argument `:rpc` function. The supervision root and
+child lookup callback are also explicit. Each provider returns unavailable and
+partial source errors instead of selecting a retry policy.
+
+The application can run a provider in `TermUI.Command.async/2`. If it wants
+polling, it can schedule the next request with `TermUI.Command.timer/2` after it
+handles the result. TermUI starts no provider process and chooses no polling
+interval, node policy, authorization policy, or failure policy.
+
+## Controls and messages
+
+Checkboxes and toggles emit `{:changed, id, checked}`. Radio groups and select
+controls emit `{:selected, id, value}`. Disabled options do not receive focus
+and do not emit messages.
+
+### Form validation
+
+`TermUI.Widget.FormBuilder` stores values, errors, and the active field in its
+pure state. Fields can have one-argument `:validators`. Validation groups
+receive a map for their configured field IDs. Submit validators receive all
+form values.
+
+```elixir
+form =
+ TermUI.Widget.FormBuilder.init(
+ fields: [
+ %{id: :password, label: "Password", required: true},
+ %{id: :confirmation, label: "Confirm", required: true}
+ ],
+ groups: [
+ %{
+ id: :passwords,
+ fields: [:password, :confirmation],
+ validators: [fn values ->
+ if values.password == values.confirmation,
+ do: :ok,
+ else: {:error, :confirmation, "does not match"}
+ end]
+ }
+ ]
+ )
+```
+
+Field rules return `:ok` or `{:error, message}`. Group and submit rules can
+return `{:error, field_id, message}` or `{:error, errors_by_field}`. Enter runs
+field, group, and submit validation in that order. If validation fails,
+`active` points to the first invalid field and the form returns
+`{:invalid, errors}`. When a displayed rule succeeds after an edit, only that
+rule's errors clear. The form does not start a process or call a registry.
+
+A select control renders its option list in its own frame while it is open.
+Give it more than one row when the option list must be visible.
+
+A spinner does not start a timer. The parent calls
+`TermUI.Widget.Spinner.tick/1` from its timer update.
+
+### Toast timing
+
+Each `TermUI.Widget.Toast.Manager` is pure application state. Give each toast
+area a distinct ID. `add_with_timer/4` and `replace_with_timer/5` return the
+new manager and zero or one `TermUI.Command.timer/2` values:
+
+```elixir
+manager = TermUI.Widget.Toast.Manager.new(id: :top_right, limit: 5)
+
+{manager, commands} =
+ TermUI.Widget.Toast.Manager.add_with_timer(
+ manager,
+ "Saved",
+ :success,
+ id: :save,
+ duration: 3_000
+ )
+```
+
+The timer message contains the manager ID, toast ID, and a unique expiry
+token. Pass it to `Manager.expire/2` in the application update function. A
+message for a dismissed, replaced, count-limited, or different-area toast is a
+safe no-op. `:infinity` returns no timer command. `dismiss/2`, `replace/5`,
+`set_limit/2`, and the existing manual `tick/2` path are also pure.
+
+The application stores each manager independently and returns its timer
+commands through the normal v2 update result. No toast registry, named process,
+or hidden timer service exists.
+
+## Layout and composition
+
+`TermUI.Layout` allocates zero-based rectangles. Fixed tracks use an integer.
+Flexible tracks use `:fill` or `{:weight, value}`. Pure helpers also create
+percentage, ratio, minimum, maximum, and bounded-content tracks.
+
+```elixir
+root = TermUI.Layout.new({80, 24})
+[nav, main] = TermUI.Layout.row(root, [24, :fill], gap: 1)
+
+frame =
+ TermUI.Frame.new(80, 24)
+ |> TermUI.Layout.place(nav_frame, nav)
+ |> TermUI.Layout.place(main_frame, main)
+```
+
+Use `column/3` for vertical tracks. Use `grid/3` for equal grid cells. Set
+`:column_tracks` and `:row_tracks` when grid tracks need different sizing
+modes. The `:columns` and optional `:rows` values keep equal configured tracks
+when the grid has fewer items.
+`Block.compose/3`, `Dialog.compose/3`, and `Tabs.compose/3` can render a frame,
+a `{widget_module, widget_state}` pair, or a one-argument renderer function.
+
+Buttons accept `:prefix` and `:suffix` decorations. List items, menu actions,
+tree nodes, and tabs can include icons. Lists, menus, and tabs can show
+shortcuts. Menus support vertical and horizontal orientation. Tabs support
+left, center, and right alignment. Tabs, menus, radio groups, selects, trees,
+and dialogs skip disabled choices during keyboard navigation.
+
+### Nested menus
+
+Use `TermUI.Widget.Menu.submenu/4` to keep command structure in the menu data.
+The `open_path` field contains submenu IDs from the root to the active level.
+
+```elixir
+menu =
+ TermUI.Widget.Menu.init(
+ items: [
+ TermUI.Widget.Menu.submenu(:file, "File", [
+ TermUI.Widget.Menu.action(:new, "New"),
+ TermUI.Widget.Menu.submenu(:recent, "Recent", [
+ TermUI.Widget.Menu.action(:notes, "notes.md")
+ ])
+ ])
+ ]
+ )
+```
+
+Right or Enter opens the current submenu. Left closes one level. Escape closes
+one level, or dismisses the root menu when no submenu is open. Mouse release
+uses the same open or action transition. The menu frame clips nested rows to
+its dimensions.
+
+The parent can use `Menu.fit_overlay/3` to fit a root menu inside terminal
+dimensions. `Menu.fit_submenu/3` opens a submenu on the right when it fits. It
+opens on the left at the right edge. Both helpers move content up at the
+bottom edge and clip a menu that is larger than the terminal.
+
+## Bounded streams
+
+The pure stream supports batches, counters, clear, and three overflow modes:
+
+```elixir
+stream = TermUI.Widget.Stream.init(limit: 1_000, overflow: :drop_oldest)
+stream = TermUI.Widget.Stream.push_many(stream, token_batch)
+stats = TermUI.Widget.Stream.stats(stream)
+```
+
+Use `TermUI.Stream.ProducerAdapter` when an external producer needs a bounded
+process boundary. The adapter sends only one unacknowledged batch:
+
+```elixir
+{:ok, adapter} =
+ TermUI.Stream.ProducerAdapter.start_link(consumer: self(), batch_size: 25)
+
+:ok = TermUI.Stream.ProducerAdapter.push(adapter, token)
+
+# In the application process:
+receive do
+ {:term_ui_stream, ^adapter, reference, items} ->
+ stream = TermUI.Widget.Stream.push_many(stream, items)
+ :ok = TermUI.Stream.ProducerAdapter.ack(adapter, reference)
+end
+```
+
+## Viewport and panes
+
+Set `scrollbars: :both` on a viewport to reserve local scrollbar tracks.
+`geometry/2` returns content size, viewport size, maximum offsets, and visible
+ranges. `scroll_into_view/3` reveals a zero-based content position.
+
+`SplitPane.init/1` still accepts `:first`, `:second`, and `:ratio`. For a
+multi-pane layout, use named panes and weights:
+
+```elixir
+panes =
+ TermUI.Widget.SplitPane.init(
+ panes: [nav: nav, main: main, inspector: inspector],
+ ratios: [1, 3, 1],
+ collapsed: [:inspector]
+ )
+
+panes = TermUI.Widget.SplitPane.expand(panes, :inspector)
+layout = TermUI.Widget.SplitPane.layout(panes, dimensions)
+```
+
+Use `serialize/1` to get only the persistent layout fields. The returned
+Elixir map has `version: 1`, the ordered pane IDs, direction, weights,
+collapsed IDs, separator focus, minimum size, and keyboard-resize setting. It
+does not contain pane content or drag state.
+
+```elixir
+saved_layout = TermUI.Widget.SplitPane.serialize(panes)
+# The application selects the encoding, storage, and write time.
+
+case TermUI.Widget.SplitPane.restore(panes, saved_layout) do
+ {:ok, panes} -> panes
+ {:error, reason} -> handle_invalid_layout(reason)
+end
+```
+
+Restore requires the same ordered pane IDs and mode. It accepts only format
+version 1. A missing, invalid, or unknown version returns an error and does not
+change the widget value. Future incompatible formats must increment the
+version. The host application must migrate old data explicitly. TermUI does
+not read or write layout files.
+
+## Theme, focus, and shortcuts
+
+`TermUI.Theme` stores named styles, style variants, and non-style values. Use
+`Theme.for_capabilities/2` to create a color-limited copy. `TermUI.Focus`
+routes traversal events through an application-owned order. `TermUI.Shortcut`
+routes chords and timestamp-bounded sequences to application messages. These
+modules do not use registries or services.
diff --git a/lib/term_ui.ex b/lib/term_ui.ex
index b19f3ad5..76e36a16 100644
--- a/lib/term_ui.ex
+++ b/lib/term_ui.ex
@@ -1,162 +1,27 @@
defmodule TermUI do
@moduledoc """
- TermUI - A direct-mode Terminal UI framework for Elixir/BEAM.
+ A small terminal runtime for Elm applications on the BEAM.
- This module provides the main entry point for terminal operations.
+ The runtime owns application state and terminal lifecycle. An application
+ receives normalized `TermUI.Event` values, returns `TermUI.Command` data,
+ and renders one `TermUI.Frame`.
"""
- alias TermUI.Terminal
+ alias TermUI.{Config, Runtime}
- # Dialyzer: Functions with unmatched return values
- @dialyzer {:nowarn_function, size: 0}
-
- @doc """
- Enables raw mode and sets up the terminal for TUI operation.
-
- This is a convenience function that:
- 1. Starts the Terminal GenServer if needed
- 2. Enables raw mode
- 3. Enters the alternate screen
- 4. Hides the cursor
-
- Returns `{:ok, state}` on success or `{:error, reason}` on failure.
- """
- @spec init() :: {:ok, Terminal.State.t()} | {:error, term()}
- def init do
- with {:ok, _pid} <- ensure_terminal_started(),
- {:ok, state} <- Terminal.enable_raw_mode(),
- :ok <- Terminal.enter_alternate_screen(),
- :ok <- Terminal.hide_cursor() do
- {:ok, state}
+ @doc "Runs an Elm application until it stops."
+ @spec run(module(), keyword()) :: :ok | {:error, term()}
+ def run(root, opts \\ []) when is_atom(root) and is_list(opts) do
+ with {:ok, opts} <- Config.merge_runtime_options(opts) do
+ Runtime.run(Keyword.put(opts, :root, root))
end
end
- @doc """
- Restores the terminal to its original state.
-
- This is a convenience function that performs complete terminal restoration.
- """
- @spec shutdown() :: :ok
- def shutdown do
- Terminal.restore()
- end
-
- @doc """
- Gets the current terminal size.
-
- Returns `{:ok, {rows, cols}}` or `{:error, reason}`.
- """
- @spec size() :: {:ok, {pos_integer(), pos_integer()}} | {:error, term()}
- def size do
- ensure_terminal_started()
- Terminal.get_terminal_size()
- end
-
- @doc """
- Returns whether the application is running inside IEx.
-
- This function checks multiple indicators to determine if the code is
- executing within an IEx session:
-
- 1. Whether the IEx module is loaded
- 2. Whether the current process is an IEx evaluator
- 3. Configuration overrides (config or environment variable)
-
- The result can be overridden by:
- - Setting `config :term_ui, iex_compatible: true` in config
- - Setting the `TERM_UI_IEX_MODE` environment variable to `"true"` or `"false"`
-
- ## Examples
-
- iex> TermUI.iex_mode?()
- true
-
- # In a standalone script:
- TermUI.iex_mode?()
- false
-
- ## Configuration
-
- To force IEx-compatible mode (useful for testing):
-
- # config/config.exs
- config :term_ui, iex_compatible: true
-
- To override via environment variable:
-
- export TERM_UI_IEX_MODE=true
-
- """
- @spec iex_mode?() :: boolean()
- def iex_mode? do
- cond do
- # Environment variable override takes precedence
- env_var = System.get_env("TERM_UI_IEX_MODE") ->
- env_var in ["true", "1", "yes"]
-
- # Config override
- config = Application.get_env(:term_ui, :iex_compatible) ->
- config == true
-
- # Auto-detection
- true ->
- iex_running?()
- end
- end
-
- @doc """
- Returns the current execution mode.
-
- Returns `:iex` if running inside IEx, `:standalone` otherwise.
-
- ## Examples
-
- iex> TermUI.running_mode()
- :iex
-
- # In a standalone script:
- TermUI.running_mode()
- :standalone
-
- """
- @spec running_mode() :: :iex | :standalone
- def running_mode do
- if iex_mode?(), do: :iex, else: :standalone
- end
-
- # Check if IEx is actually running (not just loaded)
- defp iex_running? do
- # Check if IEx module is available and loaded
- # Check if we're in an IEx evaluator process
- Code.ensure_loaded?(IEx) and
- iex_evaluator_process?()
- end
-
- # Check if current process or any ancestor is an IEx evaluator
- defp iex_evaluator_process? do
- # Get the current process's dictionary and check for IEx-specific keys
- # IEx evaluator processes have the :iex_server key in their dictionary
- Process.info(self(), :dictionary)
- |> case do
- {:dictionary, dictionary} ->
- # Check for IEx evaluator indicator
- Enum.any?(dictionary, fn
- {:iex_server, _} -> true
- _ -> false
- end)
-
- _ ->
- false
- end
- end
-
- defp ensure_terminal_started do
- case Process.whereis(Terminal) do
- nil ->
- Terminal.start_link()
-
- pid ->
- {:ok, pid}
+ @doc "Starts a linked Elm application runtime."
+ @spec start_link(module(), keyword()) :: GenServer.on_start()
+ def start_link(root, opts \\ []) when is_atom(root) and is_list(opts) do
+ with {:ok, opts} <- Config.merge_runtime_options(opts) do
+ Runtime.start_link(Keyword.put(opts, :root, root))
end
end
end
diff --git a/lib/term_ui/ansi.ex b/lib/term_ui/ansi.ex
index 984d9001..7a96e727 100644
--- a/lib/term_ui/ansi.ex
+++ b/lib/term_ui/ansi.ex
@@ -1,22 +1,12 @@
defmodule TermUI.ANSI do
- @moduledoc """
- ANSI escape sequence generation for terminal control.
+ @moduledoc false
- This module provides functions to generate ANSI escape sequences for cursor
- control, screen manipulation, colors, styles, and special terminal modes.
- All functions return iolists for efficient concatenation.
- """
+ @type sequence :: nonempty_list(binary())
- # Dialyzer: All functions in this module are pure data constructors that return
- # specific iolist structures. The iolist() spec is correct for the API, but
- # Dialyzer's success typing infers more specific types. We suppress these
- # warnings since the specs provide the right level of abstraction for users.
+ # These constructors return fixed two-part iolists. Dialyzer cannot express a
+ # fixed-length list type and reports the correct sequence/0 contract as a
+ # supertype when :underspecs is enabled.
@dialyzer {:nowarn_function,
- cursor_position: 2,
- cursor_up: 1,
- cursor_down: 1,
- cursor_forward: 1,
- cursor_back: 1,
cursor_show: 0,
cursor_hide: 0,
save_cursor: 0,
@@ -27,15 +17,6 @@ defmodule TermUI.ANSI do
clear_line: 0,
clear_line_from_cursor: 0,
clear_line_to_cursor: 0,
- set_scroll_region: 2,
- scroll_up: 1,
- scroll_down: 1,
- foreground: 1,
- background: 1,
- foreground_256: 1,
- background_256: 1,
- foreground_rgb: 3,
- background_rgb: 3,
bold: 0,
dim: 0,
italic: 0,
@@ -46,7 +27,6 @@ defmodule TermUI.ANSI do
strikethrough: 0,
reset: 0,
reset_style: 0,
- format: 1,
enable_bracketed_paste: 0,
disable_bracketed_paste: 0,
enable_focus_events: 0,
@@ -82,7 +62,7 @@ defmodule TermUI.ANSI do
iex> TermUI.ANSI.cursor_position(1, 1) |> IO.iodata_to_binary()
"\\e[1;1H"
"""
- @spec cursor_position(pos_integer(), pos_integer()) :: iolist()
+ @spec cursor_position(pos_integer(), pos_integer()) :: sequence()
def cursor_position(row, col)
when is_integer(row) and is_integer(col) and row > 0 and col > 0 do
[@csi, Integer.to_string(row), ";", Integer.to_string(col), "H"]
@@ -99,7 +79,7 @@ defmodule TermUI.ANSI do
iex> TermUI.ANSI.cursor_up(1) |> IO.iodata_to_binary()
"\\e[A"
"""
- @spec cursor_up(pos_integer()) :: iolist()
+ @spec cursor_up(pos_integer()) :: sequence()
def cursor_up(n \\ 1)
def cursor_up(1), do: [@csi, "A"]
def cursor_up(n) when is_integer(n) and n > 0, do: [@csi, Integer.to_string(n), "A"]
@@ -115,7 +95,7 @@ defmodule TermUI.ANSI do
iex> TermUI.ANSI.cursor_down(1) |> IO.iodata_to_binary()
"\\e[B"
"""
- @spec cursor_down(pos_integer()) :: iolist()
+ @spec cursor_down(pos_integer()) :: sequence()
def cursor_down(n \\ 1)
def cursor_down(1), do: [@csi, "B"]
def cursor_down(n) when is_integer(n) and n > 0, do: [@csi, Integer.to_string(n), "B"]
@@ -131,7 +111,7 @@ defmodule TermUI.ANSI do
iex> TermUI.ANSI.cursor_forward(1) |> IO.iodata_to_binary()
"\\e[C"
"""
- @spec cursor_forward(pos_integer()) :: iolist()
+ @spec cursor_forward(pos_integer()) :: sequence()
def cursor_forward(n \\ 1)
def cursor_forward(1), do: [@csi, "C"]
def cursor_forward(n) when is_integer(n) and n > 0, do: [@csi, Integer.to_string(n), "C"]
@@ -147,7 +127,7 @@ defmodule TermUI.ANSI do
iex> TermUI.ANSI.cursor_back(1) |> IO.iodata_to_binary()
"\\e[D"
"""
- @spec cursor_back(pos_integer()) :: iolist()
+ @spec cursor_back(pos_integer()) :: sequence()
def cursor_back(n \\ 1)
def cursor_back(1), do: [@csi, "D"]
def cursor_back(n) when is_integer(n) and n > 0, do: [@csi, Integer.to_string(n), "D"]
@@ -160,7 +140,7 @@ defmodule TermUI.ANSI do
iex> TermUI.ANSI.cursor_show() |> IO.iodata_to_binary()
"\\e[?25h"
"""
- @spec cursor_show() :: iolist()
+ @spec cursor_show() :: sequence()
def cursor_show, do: [@csi, "?25h"]
@doc """
@@ -171,7 +151,7 @@ defmodule TermUI.ANSI do
iex> TermUI.ANSI.cursor_hide() |> IO.iodata_to_binary()
"\\e[?25l"
"""
- @spec cursor_hide() :: iolist()
+ @spec cursor_hide() :: sequence()
def cursor_hide, do: [@csi, "?25l"]
@doc """
@@ -182,7 +162,7 @@ defmodule TermUI.ANSI do
iex> TermUI.ANSI.save_cursor() |> IO.iodata_to_binary()
"\\e[s"
"""
- @spec save_cursor() :: iolist()
+ @spec save_cursor() :: sequence()
def save_cursor, do: [@csi, "s"]
@doc """
@@ -193,7 +173,7 @@ defmodule TermUI.ANSI do
iex> TermUI.ANSI.restore_cursor() |> IO.iodata_to_binary()
"\\e[u"
"""
- @spec restore_cursor() :: iolist()
+ @spec restore_cursor() :: sequence()
def restore_cursor, do: [@csi, "u"]
# =============================================================================
@@ -208,7 +188,7 @@ defmodule TermUI.ANSI do
iex> TermUI.ANSI.clear_screen() |> IO.iodata_to_binary()
"\\e[2J"
"""
- @spec clear_screen() :: iolist()
+ @spec clear_screen() :: sequence()
def clear_screen, do: [@csi, "2J"]
@doc """
@@ -219,7 +199,7 @@ defmodule TermUI.ANSI do
iex> TermUI.ANSI.clear_screen_from_cursor() |> IO.iodata_to_binary()
"\\e[0J"
"""
- @spec clear_screen_from_cursor() :: iolist()
+ @spec clear_screen_from_cursor() :: sequence()
def clear_screen_from_cursor, do: [@csi, "0J"]
@doc """
@@ -230,7 +210,7 @@ defmodule TermUI.ANSI do
iex> TermUI.ANSI.clear_screen_to_cursor() |> IO.iodata_to_binary()
"\\e[1J"
"""
- @spec clear_screen_to_cursor() :: iolist()
+ @spec clear_screen_to_cursor() :: sequence()
def clear_screen_to_cursor, do: [@csi, "1J"]
@doc """
@@ -241,7 +221,7 @@ defmodule TermUI.ANSI do
iex> TermUI.ANSI.clear_line() |> IO.iodata_to_binary()
"\\e[2K"
"""
- @spec clear_line() :: iolist()
+ @spec clear_line() :: sequence()
def clear_line, do: [@csi, "2K"]
@doc """
@@ -252,7 +232,7 @@ defmodule TermUI.ANSI do
iex> TermUI.ANSI.clear_line_from_cursor() |> IO.iodata_to_binary()
"\\e[K"
"""
- @spec clear_line_from_cursor() :: iolist()
+ @spec clear_line_from_cursor() :: sequence()
def clear_line_from_cursor, do: [@csi, "K"]
@doc """
@@ -263,7 +243,7 @@ defmodule TermUI.ANSI do
iex> TermUI.ANSI.clear_line_to_cursor() |> IO.iodata_to_binary()
"\\e[1K"
"""
- @spec clear_line_to_cursor() :: iolist()
+ @spec clear_line_to_cursor() :: sequence()
def clear_line_to_cursor, do: [@csi, "1K"]
@doc """
@@ -274,7 +254,7 @@ defmodule TermUI.ANSI do
iex> TermUI.ANSI.set_scroll_region(5, 20) |> IO.iodata_to_binary()
"\\e[5;20r"
"""
- @spec set_scroll_region(pos_integer(), pos_integer()) :: iolist()
+ @spec set_scroll_region(pos_integer(), pos_integer()) :: sequence()
def set_scroll_region(top, bottom)
when is_integer(top) and is_integer(bottom) and top > 0 and bottom > 0 do
[@csi, Integer.to_string(top), ";", Integer.to_string(bottom), "r"]
@@ -291,7 +271,7 @@ defmodule TermUI.ANSI do
iex> TermUI.ANSI.scroll_up(1) |> IO.iodata_to_binary()
"\\e[S"
"""
- @spec scroll_up(pos_integer()) :: iolist()
+ @spec scroll_up(pos_integer()) :: sequence()
def scroll_up(n \\ 1)
def scroll_up(1), do: [@csi, "S"]
def scroll_up(n) when is_integer(n) and n > 0, do: [@csi, Integer.to_string(n), "S"]
@@ -307,7 +287,7 @@ defmodule TermUI.ANSI do
iex> TermUI.ANSI.scroll_down(1) |> IO.iodata_to_binary()
"\\e[T"
"""
- @spec scroll_down(pos_integer()) :: iolist()
+ @spec scroll_down(pos_integer()) :: sequence()
def scroll_down(n \\ 1)
def scroll_down(1), do: [@csi, "T"]
def scroll_down(n) when is_integer(n) and n > 0, do: [@csi, Integer.to_string(n), "T"]
@@ -327,7 +307,7 @@ defmodule TermUI.ANSI do
iex> TermUI.ANSI.foreground(:bright_blue) |> IO.iodata_to_binary()
"\\e[94m"
"""
- @spec foreground(atom()) :: iolist()
+ @spec foreground(atom()) :: sequence()
def foreground(color) when is_atom(color) do
code = color_to_foreground_code(color)
[@csi, Integer.to_string(code), "m"]
@@ -344,7 +324,7 @@ defmodule TermUI.ANSI do
iex> TermUI.ANSI.background(:bright_red) |> IO.iodata_to_binary()
"\\e[101m"
"""
- @spec background(atom()) :: iolist()
+ @spec background(atom()) :: sequence()
def background(color) when is_atom(color) do
code = color_to_background_code(color)
[@csi, Integer.to_string(code), "m"]
@@ -358,7 +338,7 @@ defmodule TermUI.ANSI do
iex> TermUI.ANSI.foreground_256(196) |> IO.iodata_to_binary()
"\\e[38;5;196m"
"""
- @spec foreground_256(0..255) :: iolist()
+ @spec foreground_256(0..255) :: sequence()
def foreground_256(index) when is_integer(index) and index >= 0 and index <= 255 do
[@csi, "38;5;", Integer.to_string(index), "m"]
end
@@ -371,7 +351,7 @@ defmodule TermUI.ANSI do
iex> TermUI.ANSI.background_256(196) |> IO.iodata_to_binary()
"\\e[48;5;196m"
"""
- @spec background_256(0..255) :: iolist()
+ @spec background_256(0..255) :: sequence()
def background_256(index) when is_integer(index) and index >= 0 and index <= 255 do
[@csi, "48;5;", Integer.to_string(index), "m"]
end
@@ -384,7 +364,7 @@ defmodule TermUI.ANSI do
iex> TermUI.ANSI.foreground_rgb(255, 128, 0) |> IO.iodata_to_binary()
"\\e[38;2;255;128;0m"
"""
- @spec foreground_rgb(0..255, 0..255, 0..255) :: iolist()
+ @spec foreground_rgb(0..255, 0..255, 0..255) :: sequence()
def foreground_rgb(r, g, b)
when is_integer(r) and r >= 0 and r <= 255 and
is_integer(g) and g >= 0 and g <= 255 and
@@ -409,7 +389,7 @@ defmodule TermUI.ANSI do
iex> TermUI.ANSI.background_rgb(255, 128, 0) |> IO.iodata_to_binary()
"\\e[48;2;255;128;0m"
"""
- @spec background_rgb(0..255, 0..255, 0..255) :: iolist()
+ @spec background_rgb(0..255, 0..255, 0..255) :: sequence()
def background_rgb(r, g, b)
when is_integer(r) and r >= 0 and r <= 255 and
is_integer(g) and g >= 0 and g <= 255 and
@@ -427,35 +407,35 @@ defmodule TermUI.ANSI do
end
@doc "Generates bold text attribute sequence."
- @spec bold() :: iolist()
+ @spec bold() :: sequence()
def bold, do: [@csi, "1m"]
@doc "Generates dim text attribute sequence."
- @spec dim() :: iolist()
+ @spec dim() :: sequence()
def dim, do: [@csi, "2m"]
@doc "Generates italic text attribute sequence."
- @spec italic() :: iolist()
+ @spec italic() :: sequence()
def italic, do: [@csi, "3m"]
@doc "Generates underline text attribute sequence."
- @spec underline() :: iolist()
+ @spec underline() :: sequence()
def underline, do: [@csi, "4m"]
@doc "Generates blink text attribute sequence."
- @spec blink() :: iolist()
+ @spec blink() :: sequence()
def blink, do: [@csi, "5m"]
@doc "Generates reverse video text attribute sequence."
- @spec reverse() :: iolist()
+ @spec reverse() :: sequence()
def reverse, do: [@csi, "7m"]
@doc "Generates hidden text attribute sequence."
- @spec hidden() :: iolist()
+ @spec hidden() :: sequence()
def hidden, do: [@csi, "8m"]
@doc "Generates strikethrough text attribute sequence."
- @spec strikethrough() :: iolist()
+ @spec strikethrough() :: sequence()
def strikethrough, do: [@csi, "9m"]
@doc """
@@ -466,11 +446,11 @@ defmodule TermUI.ANSI do
iex> TermUI.ANSI.reset() |> IO.iodata_to_binary()
"\\e[0m"
"""
- @spec reset() :: iolist()
+ @spec reset() :: sequence()
def reset, do: [@csi, "0m"]
@doc "Alias for reset/0."
- @spec reset_style() :: iolist()
+ @spec reset_style() :: sequence()
def reset_style, do: reset()
@doc """
@@ -510,7 +490,7 @@ defmodule TermUI.ANSI do
iex> TermUI.ANSI.enable_bracketed_paste() |> IO.iodata_to_binary()
"\\e[?2004h"
"""
- @spec enable_bracketed_paste() :: iolist()
+ @spec enable_bracketed_paste() :: sequence()
def enable_bracketed_paste, do: [@csi, "?2004h"]
@doc """
@@ -521,7 +501,7 @@ defmodule TermUI.ANSI do
iex> TermUI.ANSI.disable_bracketed_paste() |> IO.iodata_to_binary()
"\\e[?2004l"
"""
- @spec disable_bracketed_paste() :: iolist()
+ @spec disable_bracketed_paste() :: sequence()
def disable_bracketed_paste, do: [@csi, "?2004l"]
@doc """
@@ -532,7 +512,7 @@ defmodule TermUI.ANSI do
iex> TermUI.ANSI.enable_focus_events() |> IO.iodata_to_binary()
"\\e[?1004h"
"""
- @spec enable_focus_events() :: iolist()
+ @spec enable_focus_events() :: sequence()
def enable_focus_events, do: [@csi, "?1004h"]
@doc """
@@ -543,7 +523,7 @@ defmodule TermUI.ANSI do
iex> TermUI.ANSI.disable_focus_events() |> IO.iodata_to_binary()
"\\e[?1004l"
"""
- @spec disable_focus_events() :: iolist()
+ @spec disable_focus_events() :: sequence()
def disable_focus_events, do: [@csi, "?1004l"]
@doc """
@@ -554,7 +534,7 @@ defmodule TermUI.ANSI do
iex> TermUI.ANSI.enable_app_cursor() |> IO.iodata_to_binary()
"\\e[?1h"
"""
- @spec enable_app_cursor() :: iolist()
+ @spec enable_app_cursor() :: sequence()
def enable_app_cursor, do: [@csi, "?1h"]
@doc """
@@ -565,7 +545,7 @@ defmodule TermUI.ANSI do
iex> TermUI.ANSI.disable_app_cursor() |> IO.iodata_to_binary()
"\\e[?1l"
"""
- @spec disable_app_cursor() :: iolist()
+ @spec disable_app_cursor() :: sequence()
def disable_app_cursor, do: [@csi, "?1l"]
@doc """
@@ -585,7 +565,7 @@ defmodule TermUI.ANSI do
iex> TermUI.ANSI.enable_mouse_tracking(:all) |> IO.iodata_to_binary()
"\\e[?1003h"
"""
- @spec enable_mouse_tracking(:x10 | :normal | :button | :all) :: iolist()
+ @spec enable_mouse_tracking(:x10 | :normal | :button | :all) :: sequence()
def enable_mouse_tracking(:x10), do: [@csi, "?9h"]
def enable_mouse_tracking(:normal), do: [@csi, "?1000h"]
def enable_mouse_tracking(:button), do: [@csi, "?1002h"]
@@ -602,7 +582,7 @@ defmodule TermUI.ANSI do
iex> TermUI.ANSI.disable_mouse_tracking(:all) |> IO.iodata_to_binary()
"\\e[?1003l"
"""
- @spec disable_mouse_tracking(:x10 | :normal | :button | :all) :: iolist()
+ @spec disable_mouse_tracking(:x10 | :normal | :button | :all) :: sequence()
def disable_mouse_tracking(:x10), do: [@csi, "?9l"]
def disable_mouse_tracking(:normal), do: [@csi, "?1000l"]
def disable_mouse_tracking(:button), do: [@csi, "?1002l"]
@@ -616,7 +596,7 @@ defmodule TermUI.ANSI do
iex> TermUI.ANSI.enable_sgr_mouse() |> IO.iodata_to_binary()
"\\e[?1006h"
"""
- @spec enable_sgr_mouse() :: iolist()
+ @spec enable_sgr_mouse() :: sequence()
def enable_sgr_mouse, do: [@csi, "?1006h"]
@doc """
@@ -627,7 +607,7 @@ defmodule TermUI.ANSI do
iex> TermUI.ANSI.disable_sgr_mouse() |> IO.iodata_to_binary()
"\\e[?1006l"
"""
- @spec disable_sgr_mouse() :: iolist()
+ @spec disable_sgr_mouse() :: sequence()
def disable_sgr_mouse, do: [@csi, "?1006l"]
@doc """
@@ -638,7 +618,7 @@ defmodule TermUI.ANSI do
iex> TermUI.ANSI.enter_alternate_screen() |> IO.iodata_to_binary()
"\\e[?1049h"
"""
- @spec enter_alternate_screen() :: iolist()
+ @spec enter_alternate_screen() :: sequence()
def enter_alternate_screen, do: [@csi, "?1049h"]
@doc """
@@ -649,7 +629,7 @@ defmodule TermUI.ANSI do
iex> TermUI.ANSI.leave_alternate_screen() |> IO.iodata_to_binary()
"\\e[?1049l"
"""
- @spec leave_alternate_screen() :: iolist()
+ @spec leave_alternate_screen() :: sequence()
def leave_alternate_screen, do: [@csi, "?1049l"]
# =============================================================================
diff --git a/lib/term_ui/app.ex b/lib/term_ui/app.ex
index ebb5f65a..da312c5c 100644
--- a/lib/term_ui/app.ex
+++ b/lib/term_ui/app.ex
@@ -1,388 +1,37 @@
defmodule TermUI.App do
@moduledoc """
- High-level application API for TermUI applications.
+ Deprecated v1 entry points for the v2 runtime.
- This module provides a convenient API for starting and running TermUI
- applications with automatic backend selection (raw mode or TTY mode).
+ This facade starts only `TermUI.Runtime`. It does not provide the v1
+ component process model. These functions will remain available for all v2
+ releases so applications can move to the direct API in small steps.
- ## Application Lifecycle
-
- TermUI applications follow The Elm Architecture:
- 1. Model (state) - Application state
- 2. View - Renders UI based on state
- 3. Update - Handles events, returns new state
- 4. Messages - Events that trigger updates
-
- ## Backend Selection
-
- The API automatically selects the appropriate backend:
- - **Raw mode**: Full terminal control (mouse, colors, Unicode) - OTP 28+
- - **TTY mode**: Line-based input with graceful degradation
-
- ## IEx Compatibility
-
- TermUI applications work directly in IEx with no code changes. This enables:
- - Interactive debugging and development
- - Admin tools and dashboards in production IEx sessions
- - Prototyping and testing TUI interfaces
-
- ### Running in IEx
-
- Start any TermUI application from an IEx session:
-
- iex> TermUI.App.run(MyApp.Counter)
- # Use keyboard input, press Q to quit
- # Returns to IEx prompt when done
-
- All keyboard input works correctly in IEx:
- - Arrow keys for navigation (no Enter required)
- - Tab for field switching
- - Function keys (F1-F12)
- - Ctrl+key combinations
- - Alt+key combinations
-
- ### IEx Detection
-
- Detect if your application is running in IEx:
-
- iex> TermUI.iex_mode?()
- true
-
- iex> TermUI.running_mode()
- :iex
-
- ### Configuration
-
- Force IEx-compatible mode via configuration:
-
- # config/config.exs
- config :term_ui,
- iex_compatible: true
-
- Or via environment variable:
-
- export TERM_UI_IEX_MODE=true
-
- ### Troubleshooting IEx Issues
-
- **Input not reaching the application:**
- - Ensure the application is started from IEx (not `mix run`)
- - Check that `TermUI.iex_mode?()` returns `true`
- - Try forcing IEx mode with `TERM_UI_IEX_MODE=true`
-
- **Terminal state not restored after exit:**
- - The Runtime should restore terminal state automatically
- - If problems persist, call `TermUI.shutdown()` manually
-
- **Performance issues in IEx:**
- - IEx adds some overhead due to process inspection
- - Use `backend: :raw` for better performance (when OTP 28+ is available)
-
- ## Usage
-
- ### Non-blocking start (for supervisors)
-
- {:ok, pid} = TermUI.App.start(MyApp.Root, backend: :auto)
-
- ### Blocking run (for scripts and CLI apps)
-
- final_state = TermUI.App.run(MyApp.Root, backend: :auto)
-
- ### Query backend capabilities
-
- :raw = TermUI.App.backend_mode()
- true = TermUI.App.supports?(:unicode)
- true = TermUI.App.supports?(:mouse)
-
- ### Shutdown
-
- :ok = TermUI.App.shutdown()
-
- ## Configuration Options
-
- - `:backend` - Backend selection: `:auto` (default), `:raw`, `:tty`
- - `:name` - GenServer name for the Runtime process
- - `:render_interval` - Milliseconds between renders (default: 16, ~60 FPS)
-
- ## Example
-
- defmodule MyApp.Counter do
- @moduledoc \"\"\"
- A simple counter application.
- \"\"\"
-
- @impl true
- def init(_opts) do
- {:ok, %{count: 0}}
- end
-
- @impl true
- def view(model) do
- [
- {:text, "Count: \" <> to_string(model.count)},
- {:text, "\\nPress + to increment, - to decrement, q to quit"}
- ]
- end
-
- @impl true
- def update(msg, model) do
- case msg do
- {:key, ?+} -> {:ok, %{model | count: model.count + 1}}
- {:key, ?-} -> {:ok, %{model | count: model.count - 1}}
- {:key, ?q} -> {:quit, model}
- _ -> {:ok, model}
- end
- end
- end
-
- # Run the application
- TermUI.App.run(MyApp.Counter, backend: :auto)
-
- ## Component Protocol
-
- Your root component must implement the following callbacks:
-
- - `init/1` - Initialize the model, called once at startup
- - `view/1` - Render the UI based on current model
- - `update/2` - Handle messages, return `{:ok, new_model}` or `{:quit, model}`
-
- See `TermUI.Component` for full protocol documentation.
+ The v1 global `backend_mode/0` and `supports?/1` queries are not available.
+ Their meaning is not valid when more than one v2 runtime is active. Use
+ `TermUI.Runtime.capabilities/1` for one runtime.
"""
- alias TermUI.PersistentTerms
alias TermUI.Runtime
- # Dialyzer: Functions return specific types
- @dialyzer {:nowarn_function, run: 2, shutdown: 1}
-
@type root_module :: module()
- @type option ::
- {:backend, :auto | :raw | :tty}
- | {:name, GenServer.name()}
- | {:render_interval, pos_integer()}
- | {:skip_terminal, boolean()}
- @type supports_query ::
- :unicode
- | :mouse
- | :colors
- | :true_color
- | :color_256
- | :color_16
- | :monochrome
-
- @doc """
- Starts a TermUI application non-blocking.
-
- Returns `{:ok, pid}` where pid is the Runtime process.
- Use this when you want to manage the process yourself
- (e.g., in a supervisor tree).
-
- ## Options
-
- - `:backend` - Backend selection: `:auto` (default), `:raw`, `:tty`
- - `:name` - GenServer name for the Runtime process
- - `:render_interval` - Milliseconds between renders (default: 16)
-
- ## Examples
-
- {:ok, pid} = TermUI.App.start(MyApp.Root)
-
- {:ok, pid} = TermUI.App.start(MyApp.Root, backend: :tty)
-
- # With a named process
- {:ok, _pid} = TermUI.App.start(MyApp.Root, name: :my_app)
-
- """
- @spec start(root_module(), [option()]) :: {:ok, pid()} | {:error, term()}
- def start(root_module, opts \\ []) do
- runtime_opts = [
- {:root, root_module}
- | Keyword.take(opts, [:name, :backend, :render_interval, :skip_terminal, :use_input_handler])
- ]
-
- Runtime.start_link(runtime_opts)
- end
-
- @doc """
- Runs a TermUI application blocking until completion.
-
- This is the simplest way to run a TermUI application.
- It starts the runtime, waits for the application to exit,
- cleans up terminal state, and returns the final result.
-
- Returns `{:ok, final_model}` on successful completion or
- `{:error, reason}` if the application crashes.
-
- ## Options
- - `:backend` - Backend selection: `:auto` (default), `:raw`, `:tty`
- - `:render_interval` - Milliseconds between renders (default: 16)
-
- ## Examples
-
- {:ok, final_state} = TermUI.App.run(MyApp.Root)
-
- {:ok, final_state} = TermUI.App.run(MyApp.Root, backend: :tty)
-
- ## Exit Conditions
-
- The application exits when:
- - The root component returns `{:quit, model}` from update/2
- - The Runtime process crashes (returns error)
- - User interrupts with Ctrl+C (handled by Runtime)
-
- """
- @spec run(root_module(), [option()]) :: {:ok, term()} | {:error, term()}
- def run(root_module, opts \\ []) do
- # Start the runtime
- case start(root_module, opts) do
- {:ok, pid} ->
- # Monitor and wait for exit
- ref = Process.monitor(pid)
-
- receive do
- {:DOWN, ^ref, :process, ^pid, :normal} ->
- {:ok, :exited_normally}
-
- {:DOWN, ^ref, :process, ^pid, reason} ->
- # Ensure terminal cleanup even on crash
- _ = ensure_terminal_cleanup()
- {:error, reason}
- end
-
- {:error, reason} ->
- {:error, reason}
+ @doc "Starts a linked v2 runtime. Use `TermUI.start_link/2` for new code."
+ @deprecated "Use TermUI.start_link/2 instead."
+ @spec start(root_module(), keyword()) :: GenServer.on_start()
+ def start(root, opts \\ []), do: TermUI.start_link(root, opts)
+
+ @doc "Runs a v2 runtime and keeps the v1 normal-exit value. Use `TermUI.run/2` for new code."
+ @deprecated "Use TermUI.run/2 instead. It returns :ok after a normal exit."
+ @spec run(root_module(), keyword()) :: {:ok, :exited_normally} | {:error, term()}
+ def run(root, opts \\ []) do
+ case TermUI.run(root, opts) do
+ :ok -> {:ok, :exited_normally}
+ {:error, _reason} = error -> error
end
end
- @doc """
- Returns the current backend mode.
-
- Possible values:
- - `:raw` - Full terminal control (OTP 28+)
- - `:tty` - Line-based input (fallback)
- - `:nil` - No app running or backend not initialized
-
- ## Examples
-
- case TermUI.App.backend_mode() do
- :raw -> IO.puts("Running in raw mode - full features available")
- :tty -> IO.puts("Running in TTY mode - limited features")
- nil -> IO.puts("No app running")
- end
-
- """
- @spec backend_mode() :: :raw | :tty | nil
- def backend_mode, do: PersistentTerms.backend_mode()
-
- @doc """
- Checks if a capability is supported by the current terminal.
-
- Supported queries:
- - `:unicode` - Unicode character support (box drawing, etc.)
- - `:mouse` - Mouse event support
- - `:colors` - Any color support (not monochrome)
- - `:true_color` - 24-bit RGB color support
- - `:color_256` - 256-color palette support
- - `:color_16` - 16-color palette support
- - `:monochrome` - No color support
-
- Returns `true` if the capability is supported, `false` otherwise.
- Returns `false` if no app is running.
-
- ## Examples
-
- if TermUI.App.supports?(:unicode) do
- # Use Unicode box drawing characters
- else
- # Fall back to ASCII
- end
-
- if TermUI.App.supports?(:true_color) do
- # Use RGB colors for smooth gradients
- elsif TermUI.App.supports?(:color_256) do
- # Use 256-color palette
- else
- # Use basic 16 colors
- end
-
- """
- @spec supports?(supports_query()) :: boolean()
- def supports?(query) do
- capabilities = PersistentTerms.capabilities() || %{}
- supports?(query, capabilities)
- end
-
- defp supports?(:unicode, capabilities), do: Map.get(capabilities, :unicode, true)
- defp supports?(:mouse, capabilities), do: Map.get(capabilities, :mouse, false)
- defp supports?(:colors, capabilities), do: get_color_mode(capabilities) != :monochrome
- defp supports?(:true_color, capabilities), do: get_color_mode(capabilities) == :true_color
-
- defp supports?(:color_256, capabilities),
- do: get_color_mode(capabilities) in [:color_256, :true_color]
-
- defp supports?(:color_16, capabilities),
- do: get_color_mode(capabilities) in [:color_16, :color_256, :true_color]
-
- defp supports?(:monochrome, capabilities), do: get_color_mode(capabilities) == :monochrome
- defp supports?(_, _capabilities), do: false
-
- defp get_color_mode(capabilities), do: Map.get(capabilities, :colors, :true_color)
-
- @doc """
- Shuts down a running TermUI application.
-
- If a named Runtime process was started with `name: :my_app`,
- you can shut it down by passing the name. Otherwise, this
- function attempts to find and shut down the Runtime process.
-
- ## Examples
-
- # Shutdown by finding the process
- :ok = TermUI.App.shutdown()
-
- # Shutdown by name
- :ok = TermUI.App.shutdown(:my_app)
-
- """
- @spec shutdown(GenServer.name() | pid()) :: :ok | {:error, term()}
- def shutdown(name_or_pid \\ nil)
-
- def shutdown(nil) do
- # Try to find a running Runtime process
- case Process.whereis(TermUI.Runtime) do
- nil -> {:error, :not_found}
- pid -> shutdown(pid)
- end
- end
-
- def shutdown(name) when is_atom(name) do
- case Process.whereis(name) do
- nil -> {:error, :not_found}
- pid -> Runtime.shutdown(pid)
- end
- end
-
- def shutdown(pid) when is_pid(pid) do
- Runtime.shutdown(pid)
- end
-
- # Private helper to ensure terminal cleanup on crash
- defp ensure_terminal_cleanup do
- # Try to restore terminal via direct escape sequences
- # This ensures cleanup even if Runtime GenServer is dead
- # Disable mouse tracking
- IO.write("\e[?1006l\e[?1003l\e[?1002l\e[?1000l")
- # Show cursor
- IO.write("\e[?25h")
- # Reset colors
- IO.write("\e[0m")
- # Clear screen
- IO.write("\e[2J")
- # Move cursor to home
- IO.write("\e[H")
- :ok
- rescue
- _ -> :error
- end
+ @doc "Stops one v2 runtime. Use `TermUI.Runtime.shutdown/1` for new code."
+ @deprecated "Use TermUI.Runtime.shutdown/1 instead."
+ @spec shutdown(GenServer.server()) :: :ok
+ def shutdown(runtime), do: Runtime.shutdown(runtime)
end
diff --git a/lib/term_ui/backend.ex b/lib/term_ui/backend.ex
index 88535409..00945ca8 100644
--- a/lib/term_ui/backend.ex
+++ b/lib/term_ui/backend.ex
@@ -1,274 +1,32 @@
defmodule TermUI.Backend do
@moduledoc """
- Behaviour defining the contract for terminal backends.
+ The terminal backend contract.
- The `TermUI.Backend` behaviour establishes a common interface for all terminal
- rendering backends in TermUI. This abstraction enables the framework to support
- multiple terminal environments:
-
- - **Raw mode** (`TermUI.Backend.Raw`): Direct terminal control with immediate
- keystroke detection, used when `:shell.start_interactive({:noshell, :raw})`
- succeeds (OTP 28+)
-
- - **TTY mode** (`TermUI.Backend.TTY`): Fallback rendering for constrained
- environments where raw mode is unavailable (Nerves devices, SSH sessions,
- remote IEx consoles)
-
- ## Implementing a Backend
-
- To implement a backend, define a module that uses this behaviour:
-
- defmodule MyBackend do
- @behaviour TermUI.Backend
-
- @impl true
- def init(opts) do
- # Initialize backend state
- {:ok, %{}}
- end
-
- @impl true
- def shutdown(state) do
- # Clean up resources
- :ok
- end
-
- # ... implement remaining callbacks
- end
-
- ## Backend Selection
-
- Backend selection is handled by `TermUI.Backend.Selector`, which uses the
- "try raw mode first" strategy. Applications typically don't interact with
- backends directly - the runtime handles backend lifecycle.
-
- ## Type Conventions
-
- - **Positions** are 1-indexed `{row, col}` tuples matching terminal standards
- - **Colors** can be `:default`, named atoms, 256-color indices, or RGB tuples
- - **Cells** are simplified tuples for the backend interface; the full
- `TermUI.Renderer.Cell` struct is used internally
-
- ## Callback Categories
-
- The callbacks are organized into categories:
-
- - **Lifecycle**: `init/1`, `shutdown/1` - backend setup and teardown
- - **Queries**: `size/1` - terminal state queries
- - **Cursor**: `move_cursor/2`, `hide_cursor/1`, `show_cursor/1` - cursor control
- - **Rendering**: `clear/1`, `draw_cells/2`, `flush/1` - screen output
- - **Input**: `poll_event/2` - keyboard/mouse input
- """
-
- # Type Definitions
-
- @typedoc """
- Cursor position as a 1-indexed `{row, col}` tuple.
-
- Row 1 is the top of the screen, column 1 is the left edge.
- This matches standard terminal addressing (ANSI escape sequences use 1-indexed positions).
-
- Note: Positions use `pos_integer()` (minimum 1) since terminal coordinates are 1-indexed.
- Position `{0, 0}` is invalid in terminal addressing.
- """
- @type position :: {row :: pos_integer(), col :: pos_integer()}
-
- @typedoc """
- Terminal dimensions as `{rows, cols}`.
-
- Represents the current terminal size in character cells.
- Terminals always have at least 1 row and 1 column.
+ A backend owns input, output, size, capabilities, cursor state, terminal
+ setup, and cleanup. The runtime treats backend state as opaque data.
"""
- @type size :: {rows :: pos_integer(), cols :: pos_integer()}
- @typedoc """
- Color specification for foreground or background.
-
- Supports multiple color formats:
- - `:default` - Terminal default color
- - Named atoms - Basic colors (`:red`, `:green`, `:blue`, etc.)
- - `0..255` - 256-color palette index
- - `{r, g, b}` - True color RGB values (0-255 each)
- """
- @type color :: :default | atom() | 0..255 | {r :: 0..255, g :: 0..255, b :: 0..255}
-
- @typedoc """
- A terminal cell for backend rendering.
-
- Simplified tuple format for the backend interface:
- - `char` - The character to display (grapheme cluster)
- - `fg` - Foreground color
- - `bg` - Background color
- - `attrs` - Style attributes (`:bold`, `:underline`, etc.)
-
- This is a simplified representation for backend communication. The full
- `TermUI.Renderer.Cell` struct is used internally by the renderer.
- """
- @type cell :: {char :: String.t(), fg :: color(), bg :: color(), attrs :: [atom()]}
-
- @typedoc """
- Input event from the terminal.
-
- Alias for `TermUI.Event.t()` which includes key, mouse, focus, and other events.
- """
+ @type position :: {row :: pos_integer(), column :: pos_integer()}
+ @type size :: {rows :: pos_integer(), columns :: pos_integer()}
+ @type color :: :default | atom() | 0..255 | {0..255, 0..255, 0..255}
+ @type cell :: {String.t(), color(), color(), [atom()]}
@type event :: TermUI.Event.t()
-
- @typedoc """
- Backend-specific internal state.
-
- Each backend implementation maintains its own state structure.
- This is opaque to callers - only the backend module interprets it.
- """
@type state :: term()
-
- # Lifecycle Callbacks
-
- @doc """
- Initializes the backend with the given options.
-
- Called once during runtime startup. The options may include:
- - `:capabilities` - Map of detected terminal capabilities (TTY mode)
- - Backend-specific options
-
- Returns `{:ok, state}` on success or `{:error, reason}` on failure.
-
- ## Implementation Notes
-
- - Raw backend receives options from successful `:shell.start_interactive/1`
- - TTY backend receives capabilities map from `Backend.Selector`
- - Should set up terminal state (alternate screen, cursor hiding, etc.)
- """
- @callback init(opts :: keyword()) :: {:ok, state()} | {:error, reason :: term()}
-
- @doc """
- Shuts down the backend and restores terminal state.
-
- Called during runtime shutdown. Must:
- - Restore terminal to its original state
- - Release any held resources
- - Be idempotent (safe to call multiple times)
- - Handle errors gracefully (always return `:ok`)
-
- ## Implementation Notes
-
- - Should restore cursor visibility
- - Should exit alternate screen if entered
- - Should reset all attributes
- """
- @callback shutdown(state()) :: :ok
-
- # Query Callbacks
-
- @doc """
- Returns the current terminal dimensions.
-
- Returns `{:ok, {rows, cols}}` with the terminal size.
- Returns `{:error, :enotsup}` if size cannot be determined.
-
- ## Implementation Notes
-
- - Size may be cached and require explicit refresh after resize events
- - TTY backend may use `:io.columns/0` and `:io.rows/0`
- - Raw backend may query terminal directly
- """
- @callback size(state()) :: {:ok, size()} | {:error, :enotsup}
-
- # Cursor Callbacks
-
- @doc """
- Moves the cursor to the specified position.
-
- Position is 1-indexed: `{1, 1}` is the top-left corner.
-
- Returns `{:ok, updated_state}` after positioning.
-
- ## Implementation Notes
-
- - Maps to ANSI CSI sequence `ESC[row;colH`
- - Position should be clamped to terminal bounds
- """
- @callback move_cursor(state(), position()) :: {:ok, state()}
-
- @doc """
- Hides the terminal cursor.
-
- Returns `{:ok, updated_state}` after hiding cursor.
-
- Typically called before rendering to prevent cursor flicker.
- Maps to ANSI CSI sequence `ESC[?25l`.
- """
- @callback hide_cursor(state()) :: {:ok, state()}
-
- @doc """
- Shows the terminal cursor.
-
- Returns `{:ok, updated_state}` after showing cursor.
-
- Called after rendering or when cursor visibility is needed.
- Maps to ANSI CSI sequence `ESC[?25h`.
- """
- @callback show_cursor(state()) :: {:ok, state()}
-
- # Rendering Callbacks
-
- @doc """
- Clears the entire screen.
-
- Returns `{:ok, updated_state}` after clearing.
-
- Typically resets cursor to home position as well.
- Maps to ANSI CSI sequence `ESC[2J` followed by `ESC[H`.
- """
- @callback clear(state()) :: {:ok, state()}
-
- @doc """
- Draws cells to the terminal at specified positions.
-
- Receives a list of `{position, cell}` tuples. Cells are sorted by position
- (row-major order) for efficient sequential output.
-
- Returns `{:ok, updated_state}` after drawing.
-
- ## Implementation Notes
-
- - Raw backend uses differential rendering (only changed cells)
- - TTY backend may use full redraw depending on configuration
- - Should optimize cursor movement between cells
- """
- @callback draw_cells(state(), [{position(), cell()}]) :: {:ok, state()}
-
- @doc """
- Flushes pending output to the terminal.
-
- Ensures all buffered output is sent to the terminal device.
- Returns `{:ok, updated_state}` after flushing.
-
- ## Implementation Notes
-
- - May be a no-op if output is unbuffered
- - Should be called after `draw_cells/2` to ensure visibility
- """
- @callback flush(state()) :: {:ok, state()}
-
- # Input Callback
-
- @doc """
- Polls for input events with the specified timeout.
-
- - `timeout` - Milliseconds to wait for input (0 for non-blocking)
-
- Returns:
- - `{:ok, event, updated_state}` - Event received
- - `{:timeout, updated_state}` - No input within timeout
- - `{:error, reason, state}` - Error occurred
-
- ## Implementation Notes
-
- - Raw backend provides immediate keystroke detection
- - TTY backend uses `IO.getn/2` for character-by-character input
- - Timeout may not be honored precisely in TTY mode (blocking IO)
- - Events should be parsed into `TermUI.Event` structs
- """
- @callback poll_event(state(), timeout :: non_neg_integer()) ::
- {:ok, event(), state()} | {:timeout, state()} | {:error, reason :: term(), state()}
+ @type spec :: :auto | :raw | :tty | module() | {module(), keyword()}
+
+ @callback init(keyword()) :: {:ok, state()} | {:error, term()}
+ @callback size(state()) :: {:ok, size()} | {:error, term()}
+ @callback capabilities(state()) :: map()
+ @callback draw(state(), TermUI.Frame.t()) :: {:ok, state()} | {:error, term()}
+ @callback flush(state()) :: {:ok, state()} | {:error, term()}
+ @callback clipboard(state(), TermUI.Clipboard.Operation.t()) ::
+ {:ok, state()} | {:error, term()}
+ @callback poll_event(state(), non_neg_integer()) ::
+ {:ok, event(), state()}
+ | {:timeout, state()}
+ | {:error, term(), state()}
+ @callback resize(state(), size()) :: {:ok, state()} | {:error, term()}
+ @callback shutdown(state(), term()) :: :ok
+
+ @optional_callbacks clipboard: 2
end
diff --git a/lib/term_ui/backend/capability_filter.ex b/lib/term_ui/backend/capability_filter.ex
new file mode 100644
index 00000000..64c700da
--- /dev/null
+++ b/lib/term_ui/backend/capability_filter.ex
@@ -0,0 +1,43 @@
+defmodule TermUI.Backend.CapabilityFilter do
+ @moduledoc false
+
+ @color_rank %{monochrome: 0, color_16: 1, color_256: 2, true_color: 3}
+
+ @doc false
+ @spec filter(map(), keyword()) :: map()
+ def filter(capabilities, opts) when is_map(capabilities) and is_list(opts) do
+ actual_color = normalize_color(Map.get(capabilities, :colors, :true_color))
+ actual_unicode = Map.get(capabilities, :unicode, true) == true
+
+ capabilities
+ |> Map.put(:colors, effective_color(actual_color, Keyword.get(opts, :color_mode, :auto)))
+ |> Map.put(
+ :unicode,
+ effective_unicode(actual_unicode, Keyword.get(opts, :character_set, :auto))
+ )
+ end
+
+ def filter(capabilities, _invalid_opts) when is_map(capabilities), do: capabilities
+
+ defp effective_color(actual, :auto), do: actual
+
+ defp effective_color(actual, requested) when is_map_key(@color_rank, requested) do
+ if @color_rank[requested] <= @color_rank[actual], do: requested, else: actual
+ end
+
+ defp effective_color(actual, _invalid), do: actual
+
+ defp effective_unicode(actual, :auto), do: actual
+ defp effective_unicode(_actual, :ascii), do: false
+ defp effective_unicode(actual, :unicode), do: actual
+ defp effective_unicode(actual, _invalid), do: actual
+
+ defp normalize_color(:true_color), do: :true_color
+ defp normalize_color(:color_256), do: :color_256
+ defp normalize_color(:color_16), do: :color_16
+ defp normalize_color(:monochrome), do: :monochrome
+ defp normalize_color(count) when is_integer(count) and count >= 16_777_216, do: :true_color
+ defp normalize_color(count) when is_integer(count) and count >= 256, do: :color_256
+ defp normalize_color(count) when is_integer(count) and count >= 16, do: :color_16
+ defp normalize_color(_other), do: :monochrome
+end
diff --git a/lib/term_ui/backend/config.ex b/lib/term_ui/backend/config.ex
deleted file mode 100644
index 8e074168..00000000
--- a/lib/term_ui/backend/config.ex
+++ /dev/null
@@ -1,422 +0,0 @@
-defmodule TermUI.Backend.Config do
- @moduledoc """
- Configuration handling for terminal backends.
-
- The Config module provides a clean interface for reading backend configuration
- from the application environment. All configuration options have sensible
- defaults, allowing TermUI to work out of the box without explicit configuration.
-
- ## Configuration Options
-
- Configure TermUI in your `config/config.exs`:
-
- config :term_ui,
- backend: :auto,
- character_set: :unicode,
- fallback_character_set: :ascii,
- tty_opts: [line_mode: :full_redraw],
- raw_opts: [alternate_screen: true]
-
- ### Backend Selection
-
- The `:backend` option controls how the terminal backend is selected:
-
- - `:auto` (default) - Automatically detect the best backend using the selector
- - `TermUI.Backend.Raw` - Force raw mode backend
- - `TermUI.Backend.TTY` - Force TTY mode backend
- - `TermUI.Backend.Test` - Use test backend for testing
-
- ### Character Set
-
- The `:character_set` option specifies the preferred character set for
- rendering box-drawing characters and other UI elements:
-
- - `:unicode` (default) - Use Unicode box-drawing characters
- - `:ascii` - Use ASCII-only characters
-
- The `:fallback_character_set` option specifies what to use when the
- preferred character set is not available:
-
- - `:ascii` (default) - Fall back to ASCII
- - `:unicode` - Fall back to Unicode (rarely useful)
-
- ### Backend Options
-
- The `:tty_opts` and `:raw_opts` options pass backend-specific configuration:
-
- **TTY Options:**
- - `:line_mode` - Rendering mode (`:full_redraw` or `:incremental`)
-
- **Raw Options:**
- - `:alternate_screen` - Whether to use alternate screen buffer (boolean)
-
- ## Usage
-
- # Get individual configuration values
- backend = Config.get_backend()
- char_set = Config.get_character_set()
-
- # Get backend-specific options
- tty_opts = Config.get_tty_opts()
- raw_opts = Config.get_raw_opts()
-
- ## Validation
-
- Use `validate!/0` to check configuration at application startup:
-
- # In your Application.start/2
- TermUI.Backend.Config.validate!()
-
- Or use `valid?/0` to check without raising:
-
- if Config.valid?() do
- # proceed
- else
- # handle invalid config
- end
- """
-
- @app :term_ui
-
- # Valid configuration values
- @valid_backends [:auto, TermUI.Backend.Raw, TermUI.Backend.TTY, TermUI.Backend.Test]
- @valid_character_sets [:unicode, :ascii]
- @valid_line_modes [:full_redraw, :incremental]
-
- @typedoc """
- Complete runtime configuration map.
-
- Contains all configuration values needed to initialize and operate the backend system.
- """
- @type config :: %{
- backend: :auto | module(),
- character_set: :unicode | :ascii,
- fallback_character_set: :unicode | :ascii,
- tty_opts: keyword(),
- raw_opts: keyword()
- }
-
- @doc """
- Returns the configured backend selection mode.
-
- ## Returns
-
- - `:auto` - Use automatic backend detection (default)
- - A module atom - Use the specified backend module
-
- ## Examples
-
- iex> Config.get_backend()
- :auto
-
- # With config: [backend: TermUI.Backend.Raw]
- iex> Config.get_backend()
- TermUI.Backend.Raw
- """
- @spec get_backend() :: :auto | module()
- def get_backend do
- Application.get_env(@app, :backend, :auto)
- end
-
- @doc """
- Returns the configured character set for UI rendering.
-
- ## Returns
-
- - `:unicode` - Use Unicode characters (default)
- - `:ascii` - Use ASCII-only characters
-
- ## Examples
-
- iex> Config.get_character_set()
- :unicode
-
- # With config: [character_set: :ascii]
- iex> Config.get_character_set()
- :ascii
- """
- @spec get_character_set() :: :unicode | :ascii
- def get_character_set do
- Application.get_env(@app, :character_set, :unicode)
- end
-
- @doc """
- Returns the configured fallback character set.
-
- Used when the preferred character set is not available on the terminal.
-
- ## Returns
-
- - `:ascii` - Fall back to ASCII (default)
- - `:unicode` - Fall back to Unicode
-
- ## Examples
-
- iex> Config.get_fallback_character_set()
- :ascii
-
- # With config: [fallback_character_set: :unicode]
- iex> Config.get_fallback_character_set()
- :unicode
- """
- @spec get_fallback_character_set() :: :unicode | :ascii
- def get_fallback_character_set do
- Application.get_env(@app, :fallback_character_set, :ascii)
- end
-
- @doc """
- Returns the configured TTY backend options.
-
- ## Returns
-
- A keyword list of TTY-specific options. Defaults to `[line_mode: :full_redraw]`.
-
- ## Options
-
- - `:line_mode` - Rendering mode
- - `:full_redraw` - Redraw entire screen each frame (default)
- - `:incremental` - Only redraw changed lines
-
- ## Examples
-
- iex> Config.get_tty_opts()
- [line_mode: :full_redraw]
-
- # With config: [tty_opts: [line_mode: :incremental]]
- iex> Config.get_tty_opts()
- [line_mode: :incremental]
- """
- @spec get_tty_opts() :: keyword()
- def get_tty_opts do
- Application.get_env(@app, :tty_opts, line_mode: :full_redraw)
- end
-
- @doc """
- Returns the configured raw backend options.
-
- ## Returns
-
- A keyword list of raw mode-specific options. Defaults to `[alternate_screen: true]`.
-
- ## Options
-
- - `:alternate_screen` - Whether to use the alternate screen buffer
- - `true` - Use alternate screen, restoring original on exit (default)
- - `false` - Use main screen buffer
-
- ## Examples
-
- iex> Config.get_raw_opts()
- [alternate_screen: true]
-
- # With config: [raw_opts: [alternate_screen: false]]
- iex> Config.get_raw_opts()
- [alternate_screen: false]
- """
- @spec get_raw_opts() :: keyword()
- def get_raw_opts do
- Application.get_env(@app, :raw_opts, alternate_screen: true)
- end
-
- # ============================================================================
- # Validation Functions
- # ============================================================================
-
- @doc """
- Validates the current configuration, raising on errors.
-
- Checks that all configuration values are valid. Call this at application
- startup to catch configuration errors early.
-
- ## Returns
-
- - `:ok` if configuration is valid
-
- ## Raises
-
- - `ArgumentError` with a descriptive message if any configuration is invalid
-
- ## Examples
-
- iex> Config.validate!()
- :ok
-
- # With invalid config: [backend: :invalid]
- iex> Config.validate!()
- ** (ArgumentError) invalid :backend value: :invalid, expected one of [:auto, TermUI.Backend.Raw, TermUI.Backend.TTY, TermUI.Backend.Test]
- """
- @spec validate!() :: :ok
- def validate! do
- validate_backend!()
- validate_character_set!()
- validate_fallback_character_set!()
- validate_tty_opts!()
- validate_raw_opts!()
- :ok
- end
-
- @doc """
- Checks if the current configuration is valid.
-
- Returns `true` if all configuration values are valid, `false` otherwise.
- Does not raise exceptions.
-
- ## Returns
-
- - `true` if configuration is valid
- - `false` if any configuration value is invalid
-
- ## Examples
-
- iex> Config.valid?()
- true
-
- # With invalid config: [backend: :invalid]
- iex> Config.valid?()
- false
- """
- @spec valid?() :: boolean()
- def valid? do
- validate!()
- true
- rescue
- ArgumentError -> false
- end
-
- @doc """
- Returns the complete runtime configuration as a map.
-
- This function validates the configuration before returning. If any
- configuration value is invalid, an `ArgumentError` is raised.
-
- ## Returns
-
- A map containing all configuration values:
- - `:backend` - Backend selection mode
- - `:character_set` - Preferred character set
- - `:fallback_character_set` - Fallback character set
- - `:tty_opts` - TTY backend options
- - `:raw_opts` - Raw backend options
-
- ## Raises
-
- - `ArgumentError` if any configuration value is invalid
-
- ## Examples
-
- iex> Config.runtime_config()
- %{
- backend: :auto,
- character_set: :unicode,
- fallback_character_set: :ascii,
- tty_opts: [line_mode: :full_redraw],
- raw_opts: [alternate_screen: true]
- }
-
- # With custom config
- iex> Config.runtime_config()
- %{
- backend: TermUI.Backend.Raw,
- character_set: :ascii,
- fallback_character_set: :ascii,
- tty_opts: [line_mode: :incremental],
- raw_opts: [alternate_screen: false]
- }
- """
- @spec runtime_config() :: config()
- def runtime_config do
- validate!()
-
- %{
- backend: get_backend(),
- character_set: get_character_set(),
- fallback_character_set: get_fallback_character_set(),
- tty_opts: get_tty_opts(),
- raw_opts: get_raw_opts()
- }
- end
-
- # Private validation helpers
-
- @spec validate_backend!() :: :ok
- defp validate_backend! do
- backend = get_backend()
-
- unless backend in @valid_backends do
- raise ArgumentError,
- "invalid :backend value: #{inspect(backend)}, " <>
- "expected one of #{inspect(@valid_backends)}"
- end
-
- :ok
- end
-
- @spec validate_character_set!() :: :ok
- defp validate_character_set! do
- char_set = get_character_set()
-
- unless char_set in @valid_character_sets do
- raise ArgumentError,
- "invalid :character_set value: #{inspect(char_set)}, " <>
- "expected one of #{inspect(@valid_character_sets)}"
- end
-
- :ok
- end
-
- @spec validate_fallback_character_set!() :: :ok
- defp validate_fallback_character_set! do
- fallback = get_fallback_character_set()
-
- unless fallback in @valid_character_sets do
- raise ArgumentError,
- "invalid :fallback_character_set value: #{inspect(fallback)}, " <>
- "expected one of #{inspect(@valid_character_sets)}"
- end
-
- :ok
- end
-
- @spec validate_tty_opts!() :: :ok
- defp validate_tty_opts! do
- opts = get_tty_opts()
-
- unless Keyword.keyword?(opts) do
- raise ArgumentError,
- "invalid :tty_opts value: #{inspect(opts)}, expected a keyword list"
- end
-
- if Keyword.has_key?(opts, :line_mode) do
- line_mode = Keyword.get(opts, :line_mode)
-
- unless line_mode in @valid_line_modes do
- raise ArgumentError,
- "invalid :line_mode value in :tty_opts: #{inspect(line_mode)}, " <>
- "expected one of #{inspect(@valid_line_modes)}"
- end
- end
-
- :ok
- end
-
- @spec validate_raw_opts!() :: :ok
- defp validate_raw_opts! do
- opts = get_raw_opts()
-
- unless Keyword.keyword?(opts) do
- raise ArgumentError,
- "invalid :raw_opts value: #{inspect(opts)}, expected a keyword list"
- end
-
- if Keyword.has_key?(opts, :alternate_screen) do
- alt = Keyword.get(opts, :alternate_screen)
-
- unless is_boolean(alt) do
- raise ArgumentError,
- "invalid :alternate_screen value in :raw_opts: #{inspect(alt)}, expected boolean"
- end
- end
-
- :ok
- end
-end
diff --git a/lib/term_ui/backend/event_stream.ex b/lib/term_ui/backend/event_stream.ex
new file mode 100644
index 00000000..c1f13df1
--- /dev/null
+++ b/lib/term_ui/backend/event_stream.ex
@@ -0,0 +1,113 @@
+defmodule TermUI.Backend.EventStream do
+ @moduledoc false
+
+ alias TermUI.Backend.{InputBuffer, InputReader}
+ alias TermUI.Event
+ alias TermUI.Terminal.EscapeParser
+
+ @escape_timeout 50
+ @max_reads_per_poll 256
+ @max_event_queue 100
+
+ @doc "Polls one backend input source and returns one parsed event or timeout result."
+ @spec poll(map(), non_neg_integer(), (-> InputReader.result()), module()) ::
+ {:ok, TermUI.Backend.event(), map()}
+ | {:timeout, map()}
+ | {:error, term(), map()}
+ def poll(state, timeout, read_fun, source) do
+ case parse_buffer(state) do
+ {:ok, event, state} ->
+ {:ok, event, state}
+
+ {:need_more, state} ->
+ state = ensure_reader(state, read_fun)
+ read_and_parse(state, timeout, source, 0)
+ end
+ end
+
+ @doc "Stops the input reader stored in backend state, when one exists."
+ @spec stop(map()) :: :ok
+ def stop(state), do: InputReader.stop(Map.get(state, :input_reader))
+
+ defp read_and_parse(state, timeout, source, reads) do
+ case InputReader.take(state.input_reader, timeout) do
+ {:ok, data} ->
+ state =
+ InputBuffer.append_with_limit(state, data, :input_buffer,
+ source: source,
+ paste_aware: true
+ )
+
+ case parse_buffer(state) do
+ {:ok, event, state} ->
+ {:ok, event, state}
+
+ {:need_more, state} when reads + 1 < @max_reads_per_poll ->
+ read_and_parse(state, partial_timeout(state), source, reads + 1)
+
+ {:need_more, state} ->
+ {:timeout, state}
+ end
+
+ :timeout ->
+ resolve_timeout(state)
+
+ :eof ->
+ resolve_end_of_input(state)
+
+ {:error, reason} ->
+ {:error, reason, state}
+ end
+ end
+
+ defp parse_buffer(%{event_queue: [event | rest]} = state) do
+ {:ok, event, %{state | event_queue: rest}}
+ end
+
+ defp parse_buffer(%{input_buffer: ""} = state), do: {:need_more, state}
+
+ defp parse_buffer(state) do
+ case EscapeParser.parse(state.input_buffer) do
+ {[event | rest], remaining} ->
+ {:ok, event, queue_events(%{state | input_buffer: remaining}, rest)}
+
+ {[], remaining} ->
+ {:need_more, %{state | input_buffer: remaining}}
+ end
+ end
+
+ defp resolve_timeout(%{input_buffer: <<0x1B>>} = state) do
+ {:ok, Event.key(:escape), %{state | input_buffer: ""}}
+ end
+
+ defp resolve_timeout(%{input_buffer: "\e[200~" <> _paste} = state) do
+ {:timeout, state}
+ end
+
+ defp resolve_timeout(%{input_buffer: ""} = state), do: {:timeout, state}
+ defp resolve_timeout(state), do: {:timeout, %{state | input_buffer: ""}}
+
+ defp resolve_end_of_input(%{input_buffer: <<0x1B>>} = state) do
+ {:ok, Event.key(:escape), %{state | input_buffer: ""}}
+ end
+
+ defp resolve_end_of_input(%{input_buffer: ""} = state), do: {:error, :eof, state}
+ defp resolve_end_of_input(state), do: {:error, :eof, %{state | input_buffer: ""}}
+
+ defp partial_timeout(%{input_buffer: ""}), do: 0
+ defp partial_timeout(_state), do: @escape_timeout
+
+ defp ensure_reader(%{input_reader: nil} = state, read_fun) do
+ {:ok, reader} = InputReader.start_link(read_fun)
+ %{state | input_reader: reader}
+ end
+
+ defp ensure_reader(state, _read_fun), do: state
+
+ defp queue_events(state, []), do: state
+
+ defp queue_events(state, events) do
+ queue = Enum.take(state.event_queue ++ events, -@max_event_queue)
+ %{state | event_queue: queue}
+ end
+end
diff --git a/lib/term_ui/backend/input_buffer.ex b/lib/term_ui/backend/input_buffer.ex
index c022b352..1ec12adc 100644
--- a/lib/term_ui/backend/input_buffer.ex
+++ b/lib/term_ui/backend/input_buffer.ex
@@ -1,48 +1,5 @@
defmodule TermUI.Backend.InputBuffer do
- @moduledoc """
- Shared input buffer management for terminal backends.
-
- This module provides secure input buffer handling with:
- - Size limits to prevent memory exhaustion
- - Rate-limited logging to prevent log flooding
- - Consistent behavior across backends
-
- ## Security
-
- The input buffer protects against memory exhaustion attacks where
- malformed input streams send unterminated escape sequences. Without
- protection, the buffer would grow indefinitely.
-
- ### Buffer Size Limits
-
- - Maximum buffer size: 1024 bytes
- - Keep size on truncation: 256 bytes
-
- The 256-byte keep size preserves potential partial escape sequences
- (typical sequences are 8-20 bytes, max CSI is ~100 bytes).
-
- ### Rate-Limited Logging
-
- Buffer overflow warnings are rate-limited to prevent log flooding
- attacks. Maximum one warning every 5 seconds per backend instance.
-
- ## Usage
-
- Backends should use this module instead of implementing their own
- buffer management:
-
- # In your backend module
- alias TermUI.Backend.InputBuffer
-
- # Appending data
- new_buffer = InputBuffer.append(state.input_buffer, data)
-
- # Applying limit (returns {buffer, overflow_occurred?})
- {limited_buffer, overflowed} = InputBuffer.apply_limit(new_buffer)
-
- # Or use the combined function that handles state
- new_state = InputBuffer.append_with_limit(state, data, :input_buffer)
- """
+ @moduledoc false
require Logger
@@ -51,6 +8,9 @@ defmodule TermUI.Backend.InputBuffer do
# Number of bytes to keep when truncating (preserves partial sequences)
@keep_size 256
+ @paste_start "\e[200~"
+ @paste_end "\e[201~"
+ @max_paste_size 8 * 1024 * 1024
# Minimum time between overflow warnings (5 seconds in milliseconds)
@warning_interval_ms 5_000
@@ -58,9 +18,6 @@ defmodule TermUI.Backend.InputBuffer do
# ETS table for tracking last warning times (created on first use)
@warning_table :term_ui_input_buffer_warnings
- # Dialyzer: Functions return specific types or constants
- @dialyzer {:nowarn_function, max_size: 0, keep_size: 0, ensure_table_exists: 0}
-
@doc """
Returns the maximum buffer size allowed.
@@ -69,7 +26,7 @@ defmodule TermUI.Backend.InputBuffer do
iex> TermUI.Backend.InputBuffer.max_size()
1024
"""
- @spec max_size() :: pos_integer()
+ @spec max_size() :: 1024
def max_size, do: @max_buffer_size
@doc """
@@ -80,7 +37,7 @@ defmodule TermUI.Backend.InputBuffer do
iex> TermUI.Backend.InputBuffer.keep_size()
256
"""
- @spec keep_size() :: pos_integer()
+ @spec keep_size() :: 256
def keep_size, do: @keep_size
@doc """
@@ -187,10 +144,162 @@ defmodule TermUI.Backend.InputBuffer do
"""
@spec append_with_limit(map(), binary(), atom(), keyword()) :: map()
def append_with_limit(state, data, field, opts \\ []) when is_map(state) and is_atom(field) do
- current = Map.get(state, field, "")
- new_buffer = append(current, data)
- {limited, _overflowed} = apply_limit(new_buffer, opts)
- Map.put(state, field, limited)
+ if Keyword.get(opts, :paste_aware, false) do
+ append_terminal_input(state, data, field, opts)
+ else
+ current = Map.get(state, field, "")
+ new_buffer = append(current, data)
+ {limited, _overflowed} = apply_limit(new_buffer, opts)
+ Map.put(state, field, limited)
+ end
+ end
+
+ defp append_terminal_input(state, data, field, opts) do
+ case Map.get(state, :paste_state) do
+ %{mode: :collecting} = paste -> append_paste_data(state, paste, data, field, opts)
+ %{mode: :discarding} = paste -> discard_paste_data(state, paste, data, field, opts)
+ _ -> append_regular_terminal_input(state, data, field, opts)
+ end
+ end
+
+ defp append_regular_terminal_input(state, data, field, opts) do
+ buffer = append(Map.get(state, field, ""), data)
+
+ cond do
+ String.starts_with?(buffer, @paste_start) ->
+ body =
+ binary_part(
+ buffer,
+ byte_size(@paste_start),
+ byte_size(buffer) - byte_size(@paste_start)
+ )
+
+ state
+ |> Map.put(field, @paste_start)
+ |> Map.put(:paste_state, %{mode: :collecting, chunks: [], size: 0, end_buffer: ""})
+ |> append_terminal_input(body, field, opts)
+
+ byte_size(buffer) > @max_buffer_size ->
+ maybe_log_terminal_overflow(opts, byte_size(buffer))
+ Map.put(state, field, "")
+
+ true ->
+ Map.put(state, field, buffer)
+ end
+ end
+
+ defp append_paste_data(state, paste, data, field, opts) do
+ data = paste.end_buffer <> data
+
+ case :binary.match(data, @paste_end) do
+ {position, marker_size} ->
+ body = binary_part(data, 0, position)
+
+ trailing =
+ binary_part(data, position + marker_size, byte_size(data) - position - marker_size)
+
+ complete_paste(state, paste, body, trailing, field, opts)
+
+ :nomatch ->
+ {body, end_buffer} = split_end_marker_prefix(data)
+ body_size = paste.size + byte_size(body)
+
+ if body_size > @max_paste_size do
+ discard_paste(state, body_size, end_buffer, field, opts)
+ else
+ state
+ |> Map.put(field, @paste_start)
+ |> Map.put(:paste_state, %{
+ paste
+ | chunks: prepend_chunk(paste.chunks, body),
+ size: body_size,
+ end_buffer: end_buffer
+ })
+ end
+ end
+ end
+
+ defp complete_paste(state, paste, body, trailing, field, opts) do
+ body_size = paste.size + byte_size(body)
+
+ if body_size > @max_paste_size do
+ discard_paste(state, body_size, "", field, opts, @paste_end <> trailing)
+ else
+ content =
+ paste.chunks
+ |> prepend_chunk(body)
+ |> Enum.reverse()
+ |> IO.iodata_to_binary()
+
+ {trailing, _overflowed} = apply_limit(trailing, opts)
+
+ state
+ |> Map.put(field, @paste_start <> content <> @paste_end <> trailing)
+ |> Map.put(:paste_state, nil)
+ end
+ end
+
+ defp discard_paste(state, body_size, end_buffer, field, opts, remaining \\ "") do
+ maybe_log_terminal_overflow(opts, body_size)
+
+ state =
+ state
+ |> Map.put(field, "")
+ |> Map.put(:paste_state, %{mode: :discarding, end_buffer: end_buffer})
+
+ if remaining == "" do
+ state
+ else
+ discard_paste_data(state, state.paste_state, remaining, field, opts)
+ end
+ end
+
+ defp discard_paste_data(state, paste, data, field, opts) do
+ data = paste.end_buffer <> data
+
+ case :binary.match(data, @paste_end) do
+ {position, marker_size} ->
+ trailing =
+ binary_part(data, position + marker_size, byte_size(data) - position - marker_size)
+
+ state
+ |> Map.put(field, "")
+ |> Map.put(:paste_state, nil)
+ |> append_terminal_input(trailing, field, opts)
+
+ :nomatch ->
+ {_discarded, end_buffer} = split_end_marker_prefix(data)
+
+ state
+ |> Map.put(field, "")
+ |> Map.put(:paste_state, %{mode: :discarding, end_buffer: end_buffer})
+ end
+ end
+
+ defp split_end_marker_prefix(data) do
+ max_prefix_size = min(byte_size(data), byte_size(@paste_end) - 1)
+
+ prefix_size =
+ if max_prefix_size == 0 do
+ 0
+ else
+ Enum.find(Range.new(max_prefix_size, 1, -1), 0, fn size ->
+ suffix = binary_part(data, byte_size(data) - size, size)
+ String.starts_with?(@paste_end, suffix)
+ end)
+ end
+
+ body_size = byte_size(data) - prefix_size
+ {binary_part(data, 0, body_size), binary_part(data, body_size, prefix_size)}
+ end
+
+ defp prepend_chunk(chunks, ""), do: chunks
+ defp prepend_chunk(chunks, chunk), do: [chunk | chunks]
+
+ defp maybe_log_terminal_overflow(opts, size) do
+ if Keyword.get(opts, :log, true) do
+ maybe_log_overflow(Keyword.get(opts, :source, :unknown), size, 0)
+ end
end
# ===========================================================================
@@ -198,7 +307,7 @@ defmodule TermUI.Backend.InputBuffer do
# ===========================================================================
# Logs a warning if enough time has passed since the last warning.
- @spec maybe_log_overflow(term(), pos_integer(), pos_integer()) :: :ok
+ @spec maybe_log_overflow(term(), pos_integer(), non_neg_integer()) :: :ok
defp maybe_log_overflow(source, original_size, keep_size) do
now = System.monotonic_time(:millisecond)
@@ -251,7 +360,8 @@ defmodule TermUI.Backend.InputBuffer do
:undefined ->
# Create the table - it might race with another process
try do
- :ets.new(@warning_table, [:set, :public, :named_table])
+ _table = :ets.new(@warning_table, [:set, :public, :named_table])
+ :ok
rescue
ArgumentError ->
# Table already exists (race condition), that's fine
diff --git a/lib/term_ui/backend/input_reader.ex b/lib/term_ui/backend/input_reader.ex
new file mode 100644
index 00000000..6e322213
--- /dev/null
+++ b/lib/term_ui/backend/input_reader.ex
@@ -0,0 +1,97 @@
+defmodule TermUI.Backend.InputReader do
+ @moduledoc false
+
+ use GenServer
+
+ @type result :: {:ok, binary()} | :eof | {:error, term()}
+
+ @doc "Starts a serialized reader for a blocking input function."
+ @spec start_link((-> result())) :: GenServer.on_start()
+ def start_link(read_fun) when is_function(read_fun, 0) do
+ GenServer.start_link(__MODULE__, read_fun)
+ end
+
+ @doc "Gets the next input result or returns `:timeout`."
+ @spec take(pid(), non_neg_integer()) :: result() | :timeout
+ def take(reader, timeout) do
+ GenServer.call(reader, {:take, timeout}, timeout + 1_000)
+ end
+
+ @doc "Stops an input reader. A `nil` reader is already stopped."
+ @spec stop(pid() | nil) :: :ok
+ def stop(nil), do: :ok
+
+ def stop(reader) when is_pid(reader) do
+ if Process.alive?(reader), do: GenServer.stop(reader, :normal)
+ :ok
+ catch
+ :exit, _reason -> :ok
+ end
+
+ @impl true
+ @doc false
+ def init(read_fun) do
+ owner = self()
+ worker = spawn_link(fn -> read_loop(owner, read_fun) end)
+ {:ok, %{worker: worker, result: nil, waiter: nil}}
+ end
+
+ @impl true
+ @doc false
+ def handle_call({:take, _timeout}, _from, %{result: result} = state) when not is_nil(result) do
+ continue_reader(state.worker, result)
+ {:reply, result, %{state | result: nil}}
+ end
+
+ def handle_call({:take, 0}, _from, state), do: {:reply, :timeout, state}
+
+ def handle_call({:take, timeout}, from, %{waiter: nil} = state) do
+ token = make_ref()
+ timer = Process.send_after(self(), {:take_timeout, token}, timeout)
+ {:noreply, %{state | waiter: {from, token, timer}}}
+ end
+
+ @impl true
+ @doc false
+ def handle_info({:input_result, worker, result}, %{worker: worker, waiter: nil} = state) do
+ {:noreply, %{state | result: result}}
+ end
+
+ def handle_info(
+ {:input_result, worker, result},
+ %{worker: worker, waiter: {from, _token, timer}} = state
+ ) do
+ _cancelled = Process.cancel_timer(timer)
+ GenServer.reply(from, result)
+ continue_reader(worker, result)
+ {:noreply, %{state | waiter: nil}}
+ end
+
+ def handle_info({:take_timeout, token}, %{waiter: {from, token, _timer}} = state) do
+ GenServer.reply(from, :timeout)
+ {:noreply, %{state | waiter: nil}}
+ end
+
+ def handle_info({:take_timeout, _old_token}, state), do: {:noreply, state}
+
+ @impl true
+ @doc false
+ def terminate(_reason, state) do
+ if Process.alive?(state.worker), do: Process.exit(state.worker, :kill)
+ :ok
+ end
+
+ defp read_loop(owner, read_fun) do
+ result = read_fun.()
+ send(owner, {:input_result, self(), result})
+
+ if match?({:ok, _data}, result) do
+ receive do
+ :continue -> read_loop(owner, read_fun)
+ end
+ end
+ end
+
+ defp continue_reader(worker, {:ok, _data}), do: send(worker, :continue)
+ defp continue_reader(_worker, _result), do: :ok
+end
diff --git a/lib/term_ui/backend/manager.ex b/lib/term_ui/backend/manager.ex
new file mode 100644
index 00000000..7be3e6c0
--- /dev/null
+++ b/lib/term_ui/backend/manager.ex
@@ -0,0 +1,442 @@
+defmodule TermUI.Backend.Manager do
+ @moduledoc false
+
+ use GenServer
+
+ alias TermUI.Backend
+ alias TermUI.Backend.{Raw, Selector, TTY}
+ alias TermUI.Clipboard.Operation
+ alias TermUI.Frame
+ alias TermUI.Terminal.{RawMode, SizeDetector}
+
+ @input_poll_timeout 10
+ @fast_size_poll_interval 200
+ @fallback_size_poll_interval 1_000
+ @minimum_size_poll_interval 50
+
+ @type info :: %{
+ backend: module(),
+ size: Backend.size(),
+ capabilities: map()
+ }
+
+ @doc "Starts the serialized owner for one backend session."
+ @spec start_link(pid(), Backend.spec(), keyword()) :: GenServer.on_start()
+ def start_link(owner, spec, opts) when is_pid(owner) do
+ GenServer.start_link(__MODULE__, {owner, spec, opts})
+ end
+
+ @doc "Returns the selected backend, size, and capabilities."
+ @spec info(pid()) :: info()
+ def info(manager), do: GenServer.call(manager, :info)
+
+ @doc "Starts input and size polling after the runtime is ready."
+ @spec activate(pid()) :: :ok
+ def activate(manager), do: GenServer.call(manager, :activate)
+
+ @doc "Draws one complete frame through the selected backend."
+ @spec draw(pid(), Frame.t()) :: :ok | {:error, term()}
+ def draw(manager, frame), do: GenServer.call(manager, {:draw, frame})
+
+ @doc "Flushes pending backend output."
+ @spec flush(pid()) :: :ok | {:error, term()}
+ def flush(manager), do: GenServer.call(manager, :flush)
+
+ @doc "Runs one serialized clipboard operation."
+ @spec clipboard(pid(), Operation.t()) :: :ok | {:error, term()}
+ def clipboard(manager, %Operation{} = operation),
+ do: GenServer.call(manager, {:clipboard, operation})
+
+ @doc "Updates the backend size after a resize event."
+ @spec resize(pid(), Backend.size()) :: :ok | {:error, term()}
+ def resize(manager, size), do: GenServer.call(manager, {:resize, size})
+
+ @doc "Closes the backend session and waits for cleanup."
+ @spec close(pid(), term()) :: :ok
+ def close(manager, reason) do
+ GenServer.call(manager, {:close, reason}, 5_000)
+ catch
+ :exit, _reason -> :ok
+ end
+
+ @impl true
+ @doc false
+ def init({owner, spec, opts}) do
+ Process.flag(:trap_exit, true)
+ opts = Keyword.put_new(opts, :runtime, owner)
+
+ with {:ok, requested_size_poll_interval} <- parse_size_poll_interval(opts),
+ {:ok, backend, backend_state} <- open_backend(spec, opts),
+ {:ok, size} <- query_size(backend, backend_state),
+ {:ok, capabilities} <- query_capabilities(backend, backend_state) do
+ {:ok,
+ %{
+ owner: owner,
+ backend: backend,
+ backend_state: backend_state,
+ size: size,
+ capabilities: capabilities,
+ size_poll_interval: resolve_size_poll_interval(backend, requested_size_poll_interval),
+ active?: false,
+ closed?: false
+ }}
+ else
+ {:opened_error, backend, backend_state, reason} ->
+ close_backend(backend, backend_state, reason)
+ {:stop, reason}
+
+ {:error, reason} ->
+ {:stop, reason}
+ end
+ end
+
+ @impl true
+ @doc false
+ def handle_call(:info, _from, state) do
+ {:reply, %{backend: state.backend, size: state.size, capabilities: state.capabilities}, state}
+ end
+
+ def handle_call(:activate, _from, %{active?: false} = state) do
+ send(self(), :poll_input)
+ schedule_size_poll(state)
+ {:reply, :ok, %{state | active?: true}}
+ end
+
+ def handle_call(:activate, _from, state), do: {:reply, :ok, state}
+
+ def handle_call({:draw, %Frame{} = frame}, _from, state) do
+ case invoke_state_callback(state, :draw, [frame]) do
+ {:ok, backend_state} -> {:reply, :ok, %{state | backend_state: backend_state}}
+ {:error, reason} -> {:reply, {:error, reason}, state}
+ end
+ end
+
+ def handle_call(:flush, _from, state) do
+ case invoke_state_callback(state, :flush, []) do
+ {:ok, backend_state} -> {:reply, :ok, %{state | backend_state: backend_state}}
+ {:error, reason} -> {:reply, {:error, reason}, state}
+ end
+ end
+
+ def handle_call({:clipboard, %Operation{} = operation}, _from, state) do
+ if function_exported?(state.backend, :clipboard, 2) do
+ case invoke_state_callback(state, :clipboard, [operation]) do
+ {:ok, backend_state} -> {:reply, :ok, %{state | backend_state: backend_state}}
+ {:error, reason} -> {:reply, {:error, reason}, state}
+ end
+ else
+ {:reply, {:error, backend_error(state.backend, :clipboard, :unsupported)}, state}
+ end
+ end
+
+ def handle_call({:resize, size}, _from, state) do
+ case invoke_state_callback(state, :resize, [size]) do
+ {:ok, backend_state} ->
+ {:reply, :ok, %{state | backend_state: backend_state, size: size}}
+
+ {:error, reason} ->
+ {:reply, {:error, reason}, state}
+ end
+ end
+
+ def handle_call({:close, reason}, _from, state) do
+ close_backend(state.backend, state.backend_state, reason)
+ {:stop, :normal, :ok, %{state | closed?: true, active?: false}}
+ end
+
+ @impl true
+ @doc false
+ def handle_info(:poll_input, %{active?: true} = state) do
+ case invoke_poll(state) do
+ {:ok, event, backend_state} ->
+ send(state.owner, {:backend_event, event})
+ send(self(), :poll_input)
+ {:noreply, %{state | backend_state: backend_state}}
+
+ {:timeout, backend_state} ->
+ send(self(), :poll_input)
+ {:noreply, %{state | backend_state: backend_state}}
+
+ {:error, reason, backend_state} ->
+ send(state.owner, {:backend_failed, reason})
+ {:noreply, %{state | backend_state: backend_state, active?: false}}
+ end
+ end
+
+ def handle_info(:poll_input, state), do: {:noreply, state}
+
+ def handle_info(:poll_size, %{active?: true} = state) do
+ state =
+ case refresh_size(state) do
+ {:ok, size, backend_state} ->
+ if size != state.size, do: send(state.owner, {:backend_size, size})
+ %{state | size: size, backend_state: backend_state}
+
+ {:error, _reason} ->
+ state
+ end
+
+ schedule_size_poll(state)
+ {:noreply, state}
+ end
+
+ def handle_info(:poll_size, state), do: {:noreply, state}
+
+ def handle_info({:EXIT, owner, reason}, %{owner: owner} = state) do
+ {:stop, reason, state}
+ end
+
+ def handle_info({:EXIT, reader, reason}, state) do
+ if reader == input_reader(state.backend_state) and state.active? do
+ failure = backend_error(state.backend, :input, {:reader_exit, reason})
+ send(state.owner, {:backend_failed, failure})
+ {:noreply, %{state | active?: false}}
+ else
+ {:noreply, state}
+ end
+ end
+
+ @impl true
+ @doc false
+ def terminate(reason, %{closed?: false} = state) do
+ close_backend(state.backend, state.backend_state, reason)
+ :ok
+ end
+
+ def terminate(_reason, _state), do: :ok
+
+ defp open_backend(:auto, opts) do
+ case Selector.select() do
+ {:raw, raw_opts} ->
+ start_backend(Raw, Keyword.merge(opts, Map.to_list(raw_opts)),
+ raw_mode_session: raw_opts.raw_mode_session
+ )
+
+ {:tty, capabilities} ->
+ start_backend(TTY, Keyword.put(opts, :capabilities, capabilities))
+ end
+ end
+
+ defp open_backend(:raw, opts) do
+ case Selector.attempt_raw_mode() do
+ {:raw, raw_opts} ->
+ start_backend(Raw, Keyword.merge(opts, Map.to_list(raw_opts)),
+ raw_mode_session: raw_opts.raw_mode_session
+ )
+
+ {:tty, capabilities} ->
+ {:error,
+ {:raw_mode_unavailable, Map.get(capabilities, :raw_mode_error, :already_started)}}
+ end
+ end
+
+ defp open_backend(:tty, opts), do: start_backend(TTY, opts)
+
+ defp open_backend({module, backend_opts}, opts)
+ when is_atom(module) and is_list(backend_opts) do
+ start_backend(module, Keyword.merge(opts, backend_opts))
+ end
+
+ defp open_backend(module, opts) when is_atom(module), do: start_backend(module, opts)
+
+ defp start_backend(module, opts, open_opts \\ []) do
+ case module.init(opts) do
+ {:ok, state} ->
+ {:ok, module, state}
+
+ {:error, reason} ->
+ maybe_restore_raw(open_opts)
+ {:error, {:backend_init_failed, module, reason}}
+
+ other ->
+ maybe_restore_raw(open_opts)
+ {:error, {:invalid_backend_init, module, other}}
+ end
+ rescue
+ exception ->
+ maybe_restore_raw(open_opts)
+ {:error, {:backend_init_failed, module, exception}}
+ catch
+ kind, reason ->
+ maybe_restore_raw(open_opts)
+ {:error, {:backend_init_failed, module, {kind, reason}}}
+ end
+
+ defp query_size(backend, backend_state) do
+ case backend.size(backend_state) do
+ {:ok, {rows, columns} = size}
+ when is_integer(rows) and rows > 0 and is_integer(columns) and columns > 0 ->
+ {:ok, size}
+
+ {:error, reason} ->
+ {:opened_error, backend, backend_state, backend_error(backend, :size, reason)}
+
+ other ->
+ {:opened_error, backend, backend_state,
+ backend_error(backend, :size, {:invalid_result, other})}
+ end
+ rescue
+ exception ->
+ {:opened_error, backend, backend_state, backend_error(backend, :size, exception)}
+ catch
+ kind, reason ->
+ {:opened_error, backend, backend_state, backend_error(backend, :size, {kind, reason})}
+ end
+
+ defp query_capabilities(backend, backend_state) do
+ case backend.capabilities(backend_state) do
+ capabilities when is_map(capabilities) ->
+ {:ok, capabilities}
+
+ other ->
+ {:opened_error, backend, backend_state,
+ backend_error(backend, :capabilities, {:invalid_result, other})}
+ end
+ rescue
+ exception ->
+ {:opened_error, backend, backend_state, backend_error(backend, :capabilities, exception)}
+ catch
+ kind, reason ->
+ {:opened_error, backend, backend_state,
+ backend_error(backend, :capabilities, {kind, reason})}
+ end
+
+ defp invoke_state_callback(state, stage, args) do
+ result = apply(state.backend, stage, [state.backend_state | args])
+
+ case result do
+ {:ok, backend_state} -> {:ok, backend_state}
+ {:error, reason} -> {:error, backend_error(state.backend, stage, reason)}
+ other -> {:error, backend_error(state.backend, stage, {:invalid_result, other})}
+ end
+ rescue
+ exception -> {:error, backend_error(state.backend, stage, exception)}
+ catch
+ kind, reason -> {:error, backend_error(state.backend, stage, {kind, reason})}
+ end
+
+ defp invoke_poll(state) do
+ case state.backend.poll_event(state.backend_state, @input_poll_timeout) do
+ {:ok, event, backend_state} ->
+ {:ok, event, backend_state}
+
+ {:timeout, backend_state} ->
+ {:timeout, backend_state}
+
+ {:error, reason, backend_state} ->
+ {:error, backend_error(state.backend, :input, reason), backend_state}
+
+ other ->
+ {:error, backend_error(state.backend, :input, {:invalid_result, other}),
+ state.backend_state}
+ end
+ rescue
+ exception ->
+ {:error, backend_error(state.backend, :input, exception), state.backend_state}
+ catch
+ kind, reason ->
+ {:error, backend_error(state.backend, :input, {kind, reason}), state.backend_state}
+ end
+
+ defp refresh_size(state) do
+ state
+ |> size_result()
+ |> normalize_size_result(state.backend)
+ rescue
+ exception -> {:error, backend_error(state.backend, :size, exception)}
+ catch
+ kind, reason -> {:error, backend_error(state.backend, :size, {kind, reason})}
+ end
+
+ defp size_result(state) do
+ if function_exported?(state.backend, :refresh_size, 1) do
+ state.backend.refresh_size(state.backend_state)
+ else
+ state.backend.size(state.backend_state)
+ |> add_backend_state(state.backend_state)
+ end
+ end
+
+ defp add_backend_state({:ok, size}, backend_state), do: {:ok, size, backend_state}
+ defp add_backend_state(other, _backend_state), do: other
+
+ defp normalize_size_result(
+ {:ok, {rows, columns} = size, backend_state},
+ _backend
+ )
+ when is_integer(rows) and rows > 0 and is_integer(columns) and columns > 0,
+ do: {:ok, size, backend_state}
+
+ defp normalize_size_result({:error, reason}, backend),
+ do: {:error, backend_error(backend, :size, reason)}
+
+ defp normalize_size_result(other, backend),
+ do: {:error, backend_error(backend, :size, {:invalid_result, other})}
+
+ defp close_backend(module, state, reason) do
+ module.shutdown(state, reason)
+ rescue
+ _exception -> :ok
+ catch
+ _kind, _reason -> :ok
+ end
+
+ defp backend_error(backend, stage, reason), do: {:backend, backend, stage, reason}
+
+ defp input_reader(%{input_reader: reader}), do: reader
+ defp input_reader(_backend_state), do: nil
+
+ defp parse_size_poll_interval(opts) do
+ case Keyword.get(opts, :size_poll_interval, :auto) do
+ :auto ->
+ {:ok, :auto}
+
+ :disabled ->
+ {:ok, nil}
+
+ interval when is_integer(interval) and interval >= @minimum_size_poll_interval ->
+ {:ok, interval}
+
+ invalid ->
+ {:error, {:invalid_size_poll_interval, invalid}}
+ end
+ end
+
+ defp resolve_size_poll_interval(_backend, nil), do: nil
+ defp resolve_size_poll_interval(_backend, interval) when is_integer(interval), do: interval
+
+ defp resolve_size_poll_interval(backend, :auto) when backend in [Raw, TTY] do
+ if fast_size_detection_available?(),
+ do: @fast_size_poll_interval,
+ else: @fallback_size_poll_interval
+ end
+
+ defp resolve_size_poll_interval(_backend, :auto), do: @fast_size_poll_interval
+
+ defp fast_size_detection_available? do
+ match?({:ok, _size}, SizeDetector.detect_from_io()) or
+ match?({:ok, _size}, SizeDetector.detect_from_env())
+ end
+
+ defp schedule_size_poll(%{size_poll_interval: nil}), do: :ok
+
+ defp schedule_size_poll(%{size_poll_interval: interval}) do
+ Process.send_after(self(), :poll_size, interval)
+ :ok
+ end
+
+ defp maybe_restore_raw(opts) do
+ case Keyword.get(opts, :raw_mode_session) do
+ nil ->
+ :ok
+
+ session ->
+ _result = RawMode.exit(session)
+ :ok
+ end
+ rescue
+ _exception -> :ok
+ catch
+ _kind, _reason -> :ok
+ end
+end
diff --git a/lib/term_ui/backend/raw.ex b/lib/term_ui/backend/raw.ex
index d4f3382e..35912064 100644
--- a/lib/term_ui/backend/raw.ex
+++ b/lib/term_ui/backend/raw.ex
@@ -1,1535 +1,254 @@
defmodule TermUI.Backend.Raw do
- @moduledoc """
- Raw terminal backend providing full terminal control.
-
- The Raw backend is the primary high-fidelity rendering path in TermUI. It provides
- direct terminal control with immediate keystroke detection, true color support,
- mouse tracking, and all advanced terminal features.
-
- ## Requirements
-
- - **OTP 28+**: Raw mode is activated via `:shell.start_interactive({:noshell, :raw})`
- - **Terminal access**: Requires a real terminal (not pipes or redirected I/O)
-
- ## How It Works
-
- The Raw backend assumes raw mode has already been activated by `TermUI.Backend.Selector`
- before `init/1` is called. The selector uses `:shell.start_interactive({:noshell, :raw})`
- to enter raw mode, and on success, routes to this backend.
-
- **Important**: The `init/1` callback does NOT activate raw mode itself. It only performs
- terminal setup (alternate screen, cursor hiding, etc.) assuming raw mode is already active.
-
- ## Features
-
- When raw mode is active, this backend provides:
-
- - **Alternate screen buffer**: Preserves original terminal content, restored on exit
- - **Cursor control**: Hide/show cursor, precise positioning
- - **True color rendering**: Full 24-bit RGB color support (`{r, g, b}` tuples)
- - **256-color palette**: Extended color support (0-255 indices)
- - **Mouse tracking**: Click, drag, and movement detection
- - **Immediate input**: Character-by-character keystroke detection
- - **Escape sequence handling**: Function keys, arrow keys, modifiers
-
- ## Initialization Flow
-
- ```
- 1. Selector calls :shell.start_interactive({:noshell, :raw})
- └── Returns :ok (raw mode active)
-
- 2. Runtime creates Raw backend state
- └── Calls Raw.init(opts)
-
- 3. Raw.init/1 performs terminal setup:
- ├── Enter alternate screen buffer (optional)
- ├── Hide cursor
- ├── Enable mouse tracking (optional)
- └── Clear screen
- ```
-
- ## Configuration Options
-
- The `init/1` callback accepts these options:
-
- - `:alternate_screen` - Use alternate screen buffer (default: `true`)
- - `:hide_cursor` - Hide cursor during rendering (default: `true`)
- - `:mouse_tracking` - Mouse tracking mode (default: `:none`)
- - `:none` - No mouse tracking
- - `:click` - Track button clicks only
- - `:drag` - Track clicks and drag events
- - `:all` - Track all mouse movement
- - `:size` - Explicit terminal dimensions `{rows, cols}` (default: auto-detect)
-
- ## Shutdown Behavior
-
- The `shutdown/1` callback restores the terminal to its pre-init state:
-
- 1. Disable mouse tracking (if enabled)
- 2. Show cursor
- 3. Reset all text attributes
- 4. Leave alternate screen (if entered)
- 5. Return to cooked mode via `:shell.start_interactive({:noshell, :cooked})`
-
- Shutdown is designed to be error-safe - individual failures don't prevent
- subsequent cleanup steps from running.
-
- ## Usage Example
-
- This backend is typically used via the runtime, not directly:
-
- # Automatic backend selection (recommended)
- {:ok, runtime} = TermUI.Runtime.start_link()
-
- # The runtime handles:
- # 1. Backend selection via Selector
- # 2. Backend initialization
- # 3. Rendering via draw_cells/2
- # 4. Input polling via poll_event/2
- # 5. Clean shutdown
-
- ## Mouse Tracking Modes
-
- The Raw backend uses intuitive mode names that map to underlying ANSI protocol modes:
-
- | Raw Backend | ANSI Protocol | Escape Sequence | Description |
- |-------------|---------------|-----------------|-------------|
- | `:none` | (disabled) | - | No mouse tracking |
- | `:click` | Normal (1000) | `ESC[?1000h` | Button press/release only |
- | `:drag` | Button (1002) | `ESC[?1002h` | Press/release + motion while pressed |
- | `:all` | Any (1003) | `ESC[?1003h` | All mouse motion events |
-
- When mouse tracking is enabled, SGR extended mode (`ESC[?1006h`) is also activated
- for accurate coordinate encoding beyond column 223.
-
- Note: The `TermUI.ANSI` module uses protocol names (`:normal`, `:button`, `:all`),
- while this backend uses user-friendly names (`:click`, `:drag`, `:all`). The mapping
- is handled internally when emitting sequences.
-
- ## Style Delta Optimization
-
- The `current_style` field in the backend state tracks the last-emitted SGR (Select
- Graphic Rendition) attributes. This enables **style delta optimization** in
- `draw_cells/2`:
-
- Instead of emitting full style sequences for every cell:
- ```
- ESC[0;38;2;255;0;0;48;2;0;0;0mA <- 25 bytes per cell
- ESC[0;38;2;255;0;0;48;2;0;0;0mB
- ```
-
- We only emit changes from the previous style:
- ```
- ESC[38;2;255;0;0;48;2;0;0;0mA <- Full style for first cell
- B <- No escape needed, same style!
- ESC[38;2;0;255;0mC <- Only foreground changed
- ```
-
- This optimization can reduce escape sequence output by 80-90% for typical UIs
- where adjacent cells share styles (text blocks, borders, backgrounds).
-
- The `current_style` map tracks:
- - `:fg` - Current foreground color
- - `:bg` - Current background color
- - `:attrs` - Current text attributes (`:bold`, `:underline`, `:reverse`, etc.)
-
- ## See Also
-
- - `TermUI.Backend` - Behaviour definition
- - `TermUI.Backend.Selector` - Backend selection logic
- - `TermUI.Backend.TTY` - Fallback backend for non-raw environments
- - `TermUI.ANSI` - Escape sequence generation
- """
+ @moduledoc false
@behaviour TermUI.Backend
- alias TermUI.ANSI
- alias TermUI.Backend.InputBuffer
- alias TermUI.Renderer.CursorOptimizer
- alias TermUI.Terminal.SizeDetector
- alias TermUI.TerminalOutput
- alias TermUI.TermUtils
- require Logger
-
- # Dialyzer: Functions with unmatched return values
- @dialyzer {:nowarn_function, shutdown: 1, safe_write: 1, safe_cooked_mode: 0}
+ alias TermUI.{ANSI, Clipboard, Frame}
+ alias TermUI.Backend.{CapabilityFilter, EventStream, Renderer}
+ alias TermUI.Terminal.{RawMode, SizeDetector}
+ alias TermUI.{TerminalOutput, TermUtils}
- # Comprehensive mouse disable sequence - disables ALL mouse modes defensively
- # This ensures cleanup even if state is inconsistent
- @all_mouse_off "\e[?1006l\e[?1003l\e[?1002l\e[?1000l"
-
- # Input buffer management is handled by TermUI.Backend.InputBuffer module
- # which provides rate-limited logging and consistent behavior across backends.
-
- # Maximum event queue size to prevent memory exhaustion when events
- # are parsed faster than they're consumed.
- @max_event_queue_size 100
-
- # ===========================================================================
- # Type Definitions and State Structure
- # ===========================================================================
-
- @typedoc """
- Mouse tracking mode for the terminal.
-
- These are user-friendly names that map to ANSI protocol modes internally:
-
- - `:none` - No mouse tracking (disabled)
- - `:click` - Track button press/release only (ANSI "normal" mode, 1000)
- - `:drag` - Track clicks and motion while button pressed (ANSI "button" mode, 1002)
- - `:all` - Track all mouse movement (ANSI "any" mode, 1003)
+ require Logger
- See the "Mouse Tracking Modes" section in the module documentation for details.
- """
@type mouse_mode :: :none | :click | :drag | :all
-
- @typedoc """
- Current SGR (Select Graphic Rendition) style state.
-
- Tracks the current foreground color, background color, and text attributes
- to enable style delta optimization - only emitting escape sequences for
- changed attributes.
-
- ## Fields
-
- - `:fg` - Current foreground color (see `TermUI.Backend.color()`)
- - `:bg` - Current background color (see `TermUI.Backend.color()`)
- - `:attrs` - List of active text attributes:
- - `:bold` - Bold/bright text
- - `:dim` - Dimmed text
- - `:italic` - Italic text
- - `:underline` - Underlined text
- - `:blink` - Blinking text
- - `:reverse` - Swapped foreground/background
- - `:hidden` - Hidden text
- - `:strikethrough` - Struck-through text
-
- See the "Style Delta Optimization" section in the module documentation for
- how this enables efficient rendering.
- """
- @type style_state :: %{
- fg: TermUI.Backend.color(),
- bg: TermUI.Backend.color(),
- attrs: [atom()]
- }
-
- @typedoc """
- Internal state for the Raw backend.
-
- Tracks all terminal state needed for rendering and input handling.
-
- ## Fields
-
- - `:size` - Terminal dimensions as `{rows, cols}`
- - `:cursor_visible` - Whether cursor is currently visible (default: `false`)
- - `:cursor_position` - Current cursor position as `{row, col}` or `nil`
- - `:alternate_screen` - Whether alternate screen buffer is active
- - `:mouse_mode` - Current mouse tracking mode
- - `:current_style` - Current SGR state for style delta tracking
- - `:optimize_cursor` - Whether to use cursor movement optimization (default: `true`)
- - `:input_buffer` - Buffer for partial escape sequences during input parsing
- - `:event_queue` - Queue of parsed events waiting to be returned
- """
@type t :: %__MODULE__{
- size: {pos_integer(), pos_integer()},
- cursor_visible: boolean(),
- cursor_position: {pos_integer(), pos_integer()} | nil,
+ size: TermUI.Backend.size(),
+ capabilities: map(),
alternate_screen: boolean(),
mouse_mode: mouse_mode(),
- current_style: style_state() | nil,
- optimize_cursor: boolean(),
input_buffer: binary(),
event_queue: [TermUI.Backend.event()],
- events_dropped: non_neg_integer()
+ paste_state: map() | nil,
+ input_reader: pid() | nil,
+ last_frame: Frame.t() | nil,
+ bracketed_paste: boolean(),
+ focus_events: boolean(),
+ raw_mode_started: boolean(),
+ raw_mode_session: RawMode.session() | nil
}
defstruct size: {24, 80},
- cursor_visible: false,
- cursor_position: nil,
- alternate_screen: false,
+ capabilities: %{colors: :true_color, unicode: true, mouse: true},
+ alternate_screen: true,
mouse_mode: :none,
- current_style: nil,
- optimize_cursor: true,
- input_buffer: <<>>,
+ input_buffer: "",
event_queue: [],
- events_dropped: 0
-
- # ===========================================================================
- # Behaviour Callbacks - Lifecycle, Queries, Cursor, Rendering, Input
- # ===========================================================================
- # Full implementations will be added in subsequent tasks
+ paste_state: nil,
+ input_reader: nil,
+ last_frame: nil,
+ bracketed_paste: true,
+ focus_events: true,
+ raw_mode_started: false,
+ raw_mode_session: nil
@impl true
- @doc """
- Initializes the Raw backend with terminal setup.
-
- Assumes raw mode is already active (started by Selector). Performs terminal
- configuration including alternate screen, cursor hiding, and mouse tracking.
-
- ## Options
-
- - `:alternate_screen` - Use alternate screen buffer (default: `true`)
- - `:hide_cursor` - Hide cursor during rendering (default: `true`)
- - `:mouse_tracking` - Mouse tracking mode (default: `:none`)
- - `:size` - Explicit dimensions `{rows, cols}` (default: auto-detect)
- - `:optimize_cursor` - Use cursor movement optimization (default: `true`)
-
- ## Returns
-
- - `{:ok, state}` on success
- - `{:error, :invalid_size}` if size option is malformed
- - `{:error, :terminal_setup_failed}` if terminal configuration fails
- - `{:error, :size_detection_failed}` if auto-detect fails and no size provided
-
- ## Examples
-
- # Default initialization
- {:ok, state} = Raw.init([])
-
- # With explicit options
- {:ok, state} = Raw.init(
- alternate_screen: true,
- hide_cursor: true,
- mouse_tracking: :click,
- size: {24, 80}
- )
- """
@spec init(keyword()) :: {:ok, t()} | {:error, term()}
- def init(opts \\ []) do
- # Parse options with defaults
- alternate_screen = Keyword.get(opts, :alternate_screen, true)
- hide_cursor = Keyword.get(opts, :hide_cursor, true)
- mouse_tracking = Keyword.get(opts, :mouse_tracking, :none)
- size_opt = Keyword.get(opts, :size, nil)
- optimize_cursor = Keyword.get(opts, :optimize_cursor, true)
-
- # Validate and get terminal size
- with {:ok, size} <- get_terminal_size(size_opt) do
- # Perform terminal setup sequence
- # Order: alternate screen -> hide cursor -> mouse tracking -> clear
- if alternate_screen do
- write_to_terminal(ANSI.enter_alternate_screen())
- end
+ def init(opts) do
+ with {:ok, size} <- SizeDetector.detect(size: Keyword.get(opts, :size)) do
+ capabilities =
+ %{colors: :true_color, unicode: true, mouse: true, size: size}
+ |> CapabilityFilter.filter(opts)
- if hide_cursor do
- write_to_terminal(ANSI.cursor_hide())
- end
-
- # Skip mouse tracking on WSL/ConPTY -- mouse-off sequences are silently
- # ignored, so enabling mouse tracking leads to escape code leaks
- if mouse_tracking != :none and not TerminalOutput.needs_hard_reset?() do
- ansi_mode = mouse_mode_to_ansi(mouse_tracking)
-
- if ansi_mode do
- write_to_terminal(ANSI.enable_mouse_tracking(ansi_mode))
- write_to_terminal(ANSI.enable_sgr_mouse())
- end
- end
-
- # Clear screen and home cursor
- write_to_terminal(ANSI.clear_screen())
- write_to_terminal(ANSI.cursor_position(1, 1))
-
- # Build initial state
state = %__MODULE__{
size: size,
- cursor_visible: not hide_cursor,
- cursor_position: {1, 1},
- alternate_screen: alternate_screen,
- mouse_mode: mouse_tracking,
- current_style: nil,
- optimize_cursor: optimize_cursor
+ capabilities: capabilities,
+ alternate_screen: Keyword.get(opts, :alternate_screen, true),
+ mouse_mode: Keyword.get(opts, :mouse_tracking, :none),
+ bracketed_paste: Keyword.get(opts, :bracketed_paste, true),
+ focus_events: Keyword.get(opts, :focus_events, true),
+ raw_mode_started: Keyword.get(opts, :raw_mode_started, false),
+ raw_mode_session: Keyword.get(opts, :raw_mode_session)
}
- {:ok, state}
+ case TerminalOutput.write(setup_sequence(state, Keyword.get(opts, :hide_cursor, true))) do
+ :ok ->
+ {:ok, state}
+
+ {:error, reason} ->
+ cleanup_terminal(state)
+ {:error, {:terminal_write_failed, reason}}
+ end
end
end
@impl true
- @doc """
- Shuts down the backend and restores terminal state.
-
- Performs cleanup in order: disable mouse, show cursor, reset attributes,
- leave alternate screen, return to cooked mode.
-
- ## Error Safety
-
- This function is designed to be error-safe:
- - Each cleanup step is wrapped in try/rescue
- - Individual failures are logged but don't prevent subsequent steps
- - Always returns `:ok` regardless of individual step failures
- - Idempotent: safe to call multiple times
-
- ## Cleanup Sequence
-
- 1. Disable mouse tracking (if enabled)
- 2. Show cursor (ANSI: `ESC[?25h`)
- 3. Reset all text attributes (ANSI: `ESC[0m`)
- 4. Leave alternate screen (ANSI: `ESC[?1049l`)
- 5. Return to cooked mode via `:shell.start_interactive({:noshell, :cooked})`
- """
- @spec shutdown(t()) :: :ok
- def shutdown(state) do
- # Phase 1: Direct-to-TTY write (most reliable, bypasses Erlang IO)
- TerminalOutput.write_to_tty(TerminalOutput.cleanup_sequence())
-
- # Phase 2: Erlang IO backup (in case /dev/tty write failed)
- safe_write(@all_mouse_off)
- safe_write(ANSI.cursor_show())
- safe_write(ANSI.reset())
-
- if state.alternate_screen do
- safe_write(ANSI.leave_alternate_screen())
- end
-
- # Phase 3: Drain pending input (mouse events buffered during shutdown)
+ @spec shutdown(t(), term()) :: :ok
+ def shutdown(state, _reason) do
+ EventStream.stop(state)
+ cleanup_terminal(state)
drain_pending_input()
-
- # Phase 4: Return to cooked mode
- safe_cooked_mode()
-
+ restore_raw_mode(state)
:ok
end
@impl true
- @doc """
- Returns the current terminal dimensions.
-
- Returns the cached size from state as `{rows, cols}`. This does not
- re-query the terminal - it returns the dimensions captured at `init/1`
- or last updated by `refresh_size/1`.
-
- ## Return Value
-
- - `{:ok, {rows, cols}}` - Terminal dimensions (rows first, then columns)
-
- ## Examples
-
- {:ok, {24, 80}} = Raw.size(state) # Standard 80x24 terminal
- {:ok, {50, 120}} = Raw.size(state) # Larger terminal
-
- ## See Also
-
- - `refresh_size/1` - Re-query terminal dimensions (call after SIGWINCH)
- - `init/1` - Initial size detection
- """
- # Note: The error case `{:error, :enotsup}` is included in the typespec for future-proofing
- # and consistency with the Backend behaviour, even though this implementation always returns
- # the cached size. A future backend might need to report unsupported size queries.
@spec size(t()) :: {:ok, TermUI.Backend.size()}
- def size(state) do
- {:ok, state.size}
- end
-
- @doc """
- Re-queries terminal dimensions and updates state.
-
- This function queries the terminal for its current size using `:io.rows/0`
- and `:io.columns/0`, then updates the cached size in state. It should be
- called after receiving a SIGWINCH signal to handle terminal resize events.
-
- ## Return Value
-
- - `{:ok, {rows, cols}, updated_state}` - New dimensions and updated state
- - `{:error, :size_detection_failed}` - Failed to query terminal dimensions
-
- ## SIGWINCH Handling
-
- Terminal resize events are delivered via SIGWINCH. Your application should:
-
- 1. Register a signal handler for SIGWINCH
- 2. Call `refresh_size/1` when the signal is received
- 3. Trigger a re-render with the new dimensions
+ def size(state), do: {:ok, state.size}
- Example integration:
-
- def handle_info({:signal, :sigwinch}, state) do
- case Raw.refresh_size(state.backend_state) do
- {:ok, new_size, new_backend_state} ->
- # Update state and trigger re-render
- {:noreply, %{state | backend_state: new_backend_state, size: new_size}}
- {:error, _reason} ->
- # Keep existing size
- {:noreply, state}
- end
- end
-
- ## Size Detection
-
- Uses the same detection logic as `init/1`:
- 1. Query `:io.rows/0` and `:io.columns/0`
- 2. Fall back to LINES and COLUMNS environment variables
- 3. Return error if all methods fail
-
- ## See Also
+ @impl true
+ @spec capabilities(t()) :: map()
+ def capabilities(state), do: Map.put(state.capabilities, :size, state.size)
- - `size/1` - Return cached dimensions without re-querying
- - `init/1` - Initial size detection during initialization
- """
- @spec refresh_size(t()) :: {:ok, TermUI.Backend.size(), t()} | {:error, :size_detection_failed}
+ @spec refresh_size(t()) :: {:ok, TermUI.Backend.size(), t()} | {:error, term()}
def refresh_size(state) do
- case get_terminal_size(nil) do
- {:ok, new_size} ->
- {:ok, new_size, %{state | size: new_size}}
-
- {:error, _reason} ->
- {:error, :size_detection_failed}
+ case SizeDetector.detect() do
+ {:ok, size} -> {:ok, size, %{state | size: size}}
+ {:error, reason} -> {:error, reason}
end
end
@impl true
- @doc """
- Moves the cursor to the specified position.
-
- Position is 1-indexed: `{1, 1}` is the top-left corner.
-
- ## Cursor Optimization
-
- When `optimize_cursor: true` (default), this function uses `CursorOptimizer`
- to select the cheapest movement sequence. This can reduce cursor movement
- overhead by 40%+ compared to always using absolute positioning.
-
- Movement options considered:
- - Absolute positioning: `ESC[{row};{col}H` (6-10 bytes)
- - Relative moves: up/down/left/right (3-6 bytes)
- - Carriage return + vertical (1 + 3-6 bytes)
- - Home position: `ESC[H` (3 bytes)
- - Literal spaces for small rightward moves (1 byte each)
-
- ## Position Validation
-
- Positions must have positive integer coordinates. This function does NOT
- validate positions against terminal bounds - positions beyond the terminal
- dimensions are accepted and recorded in state. Most terminals silently clamp
- out-of-bounds positions, which may cause state-reality divergence.
-
- **Callers should validate positions before calling** using `valid_position?/2`:
-
- if Raw.valid_position?(state, position) do
- Raw.move_cursor(state, position)
- else
- {:error, :out_of_bounds}
- end
-
- This design allows the renderer layer to handle bounds checking appropriately
- for its use case (e.g., scrolling, wrapping, or clamping).
-
- ## See Also
-
- - `hide_cursor/1` - Hide cursor during rendering
- - `show_cursor/1` - Show cursor after rendering
- - `valid_position?/2` - Check if position is within terminal bounds
-
- ## Examples
-
- {:ok, state} = Raw.move_cursor(state, {1, 1}) # Top-left
- {:ok, state} = Raw.move_cursor(state, {24, 80}) # Bottom-right (80x24)
- """
- @spec move_cursor(t(), TermUI.Backend.position()) :: {:ok, t()}
- def move_cursor(state, {row, col} = position)
- when is_integer(row) and is_integer(col) and row > 0 and col > 0 do
- # Generate movement sequence (optimized or absolute based on state)
- sequence = generate_cursor_sequence(state, row, col)
- write_to_terminal(sequence)
-
- # Update state with new cursor position
- updated_state = %{state | cursor_position: position}
-
- {:ok, updated_state}
- end
-
- # Generates cursor movement sequence, using optimization when enabled.
- # Clauses ordered from most specific to general:
- # 1. Optimization disabled - always absolute (most restrictive)
- # 2. No previous position - absolute (can't optimize without from position)
- # 3. Optimization enabled with position - use optimizer
- @spec generate_cursor_sequence(t(), pos_integer(), pos_integer()) :: iodata()
- defp generate_cursor_sequence(%__MODULE__{optimize_cursor: false}, row, col) do
- # Optimization disabled - always use absolute positioning
- ANSI.cursor_position(row, col)
- end
-
- defp generate_cursor_sequence(%__MODULE__{cursor_position: nil}, row, col) do
- # No previous position known - use absolute positioning
- # (applies regardless of optimize_cursor setting)
- ANSI.cursor_position(row, col)
- end
-
- defp generate_cursor_sequence(
- %__MODULE__{optimize_cursor: true, cursor_position: {from_row, from_col}},
- to_row,
- to_col
- ) do
- # Use optimizer to find cheapest movement, with error recovery
- # Only catch expected exceptions, not system-level errors
- {sequence, _cost} = CursorOptimizer.optimal_move(from_row, from_col, to_row, to_col)
- sequence
- rescue
- e in [ArgumentError, ArithmeticError, FunctionClauseError] ->
- # Fall back to absolute positioning if optimizer fails
- Logger.warning(
- "CursorOptimizer failed (#{Exception.message(e)}), falling back to absolute positioning: from #{inspect({from_row, from_col})} to #{inspect({to_row, to_col})}"
- )
-
- ANSI.cursor_position(to_row, to_col)
- end
-
- @impl true
- @doc """
- Hides the terminal cursor.
-
- Uses ANSI sequence `ESC[?25l` (DECTCEM off).
-
- ## Idempotent Behavior
-
- This operation is idempotent. When the cursor is already hidden:
- - No escape sequence is written to the terminal
- - The exact same state object is returned unchanged
- - Callers cannot distinguish a no-op from an actual state change
-
- This design prevents redundant ANSI writes and allows callers to call
- without tracking current visibility state.
-
- ## See Also
-
- - `show_cursor/1` - Show the cursor
- - `move_cursor/2` - Move cursor to position
- """
- @spec hide_cursor(t()) :: {:ok, t()}
- def hide_cursor(%__MODULE__{cursor_visible: false} = state) do
- # Already hidden - idempotent no-op
- {:ok, state}
- end
-
- def hide_cursor(state) do
- # Write hide cursor sequence
- write_to_terminal(ANSI.cursor_hide())
-
- # Update state
- updated_state = %{state | cursor_visible: false}
-
- {:ok, updated_state}
- end
-
- @impl true
- @doc """
- Shows the terminal cursor.
-
- Uses ANSI sequence `ESC[?25h` (DECTCEM on).
-
- ## Idempotent Behavior
-
- This operation is idempotent. When the cursor is already visible:
- - No escape sequence is written to the terminal
- - The exact same state object is returned unchanged
- - Callers cannot distinguish a no-op from an actual state change
-
- This design prevents redundant ANSI writes and allows callers to call
- without tracking current visibility state.
-
- ## See Also
-
- - `hide_cursor/1` - Hide the cursor
- - `move_cursor/2` - Move cursor to position
- """
- @spec show_cursor(t()) :: {:ok, t()}
- def show_cursor(%__MODULE__{cursor_visible: true} = state) do
- # Already visible - idempotent no-op
- {:ok, state}
- end
-
- def show_cursor(state) do
- # Write show cursor sequence
- write_to_terminal(ANSI.cursor_show())
-
- # Update state
- updated_state = %{state | cursor_visible: true}
-
- {:ok, updated_state}
- end
-
- @impl true
- @doc """
- Clears the entire screen and moves cursor to home position.
-
- Uses ANSI sequences:
- - `ESC[2J` - ED (Erase Display) parameter 2: clear entire screen
- - `ESC[1;1H` - CUP (Cursor Position): move to row 1, column 1
-
- ## State Changes
-
- After clear:
- - `cursor_position` is set to `{1, 1}` (home position)
- - `current_style` is reset to `nil` (terminal style state is unknown after clear)
-
- All other state fields are preserved.
-
- ## Idempotency
-
- This operation is idempotent - calling `clear/1` multiple times in succession
- is safe and will result in the same state each time.
-
- ## Examples
-
- {:ok, state} = Raw.init(size: {24, 80})
- {:ok, moved} = Raw.move_cursor(state, {10, 20})
- {:ok, cleared} = Raw.clear(moved)
-
- cleared.cursor_position # => {1, 1}
- cleared.current_style # => nil
-
- ## See Also
-
- - `move_cursor/2` - Move cursor to specific position
- - `draw_cells/2` - Draw content to screen
- """
- @spec clear(t()) :: {:ok, t()}
- def clear(state) do
- # Write clear screen sequence followed by cursor home
- write_to_terminal([ANSI.clear_screen(), ANSI.cursor_position(1, 1)])
-
- # Reset style state (unknown after clear) and set cursor to home
- updated_state = %{state | current_style: nil, cursor_position: {1, 1}}
-
- {:ok, updated_state}
- end
-
- @impl true
- @doc """
- Draws cells to the terminal at specified positions.
-
- Cells are rendered with optimized cursor movement and style delta tracking
- to minimize escape sequence output. See the "Style Delta Optimization" section
- in the module documentation for details on how this works.
-
- ## Cell Format
-
- Each cell is a tuple `{position, cell_data}` where:
- - `position` is `{row, col}` (1-indexed)
- - `cell_data` is `{char, fg, bg, attrs}`
-
- ## Performance
-
- This function uses several optimizations:
- - Style delta tracking (only emit changed attributes)
- - Relative cursor movement when cheaper than absolute
- - Batched I/O writes
-
- ## Examples
-
- # Draw a single red "A" at position {1, 1}
- cells = [{{1, 1}, {"A", :red, :default, []}}]
- {:ok, state} = Raw.draw_cells(state, cells)
-
- # Draw multiple cells with different styles
- cells = [
- {{1, 1}, {"H", :green, :default, [:bold]}},
- {{1, 2}, {"i", :green, :default, [:bold]}},
- {{2, 1}, {"!", :yellow, :blue, []}}
- ]
- {:ok, state} = Raw.draw_cells(state, cells)
- """
- @spec draw_cells(t(), [{TermUI.Backend.position(), TermUI.Backend.cell()}]) :: {:ok, t()}
- def draw_cells(state, []) do
- # Empty list - no-op
- {:ok, state}
- end
-
- def draw_cells(state, cells) when is_list(cells) do
- # Sort cells in row-major order, then by column within each row
- sorted_cells =
- Enum.sort_by(cells, fn {{row, col}, _cell} -> {row, col} end)
-
- # Split into contiguous runs and render each with a single cursor position
- runs = detect_runs(sorted_cells)
-
- {output, final_pos, final_style} =
- render_runs(runs, state.cursor_position, state.current_style)
-
- # Write batched output to terminal
- write_to_terminal(output)
-
- # Update state with final cursor position and style
- updated_state = %{state | cursor_position: final_pos, current_style: final_style}
-
- {:ok, updated_state}
- end
-
- # Detects contiguous runs of cells: same row with consecutive columns.
- # Returns a list of runs, where each run is a non-empty list of cells
- # that can be rendered with a single cursor positioning.
- defp detect_runs([]), do: []
-
- defp detect_runs([first | rest]) do
- {current_run, runs} =
- Enum.reduce(rest, {[first], []}, fn {{row, col}, _cell_data} = cell,
- {current_run, completed_runs} ->
- # Get the last cell in the current run to check adjacency
- [{{prev_row, prev_col}, _} | _] = current_run
-
- if row == prev_row and col == prev_col + 1 do
- # Adjacent: extend current run (prepend for efficiency, reversed later)
- {[cell | current_run], completed_runs}
- else
- # Gap or new row: finalize current run, start new one
- {[cell], [Enum.reverse(current_run) | completed_runs]}
- end
- end)
-
- # Don't forget the final run
- Enum.reverse([Enum.reverse(current_run) | runs])
- end
-
- # Renders a list of runs into an iolist. Each run gets one cursor position
- # at its start, then streams characters with inline style deltas only when
- # the style changes. Style state is tracked continuously across runs (no
- # per-row resets).
- defp render_runs(runs, initial_pos, initial_style) do
- Enum.reduce(runs, {[], initial_pos, initial_style}, fn run, {output_acc, cursor_pos, style} ->
- {run_output, run_end_pos, run_end_style} =
- render_single_run(run, cursor_pos, style)
-
- {[output_acc, run_output], run_end_pos, run_end_style}
- end)
- end
-
- # Renders a single contiguous run. Emits one cursor position at the start,
- # then for each cell: style delta (if changed) + character.
- defp render_single_run([{{row, col}, _} | _] = run, cursor_pos, style) do
- # Position cursor at run start
- cursor_output = cursor_move_output(cursor_pos, {row, col})
-
- # Stream characters with inline style changes
- {chars_output, end_col, end_style} =
- Enum.reduce(run, {[], col, style}, fn {{_row, _col}, {char, fg, bg, attrs}},
- {out_acc, cur_col, cur_style} ->
- new_style = %{fg: fg, bg: bg, attrs: normalize_attrs(attrs)}
- style_output = style_delta_output(cur_style, new_style)
-
- {[out_acc, style_output, char], cur_col + 1, new_style}
- end)
-
- {[cursor_output, chars_output], {row, end_col}, end_style}
- end
-
- # Normalizes attributes to a sorted list for consistent comparison.
- #
- # Accepts both list and MapSet input formats to support:
- # - Direct cell tuples from Backend.cell() which use lists
- # - Internal Cell struct which uses MapSet for attributes
- #
- # Sorting ensures consistent comparison regardless of input order,
- # enabling reliable style delta detection.
- @spec normalize_attrs([atom()] | MapSet.t()) :: [atom()]
- defp normalize_attrs(attrs) when is_list(attrs), do: Enum.sort(attrs)
- defp normalize_attrs(%MapSet{} = attrs), do: attrs |> MapSet.to_list() |> Enum.sort()
-
- # Generates cursor movement escape sequence if position has changed.
- #
- # Returns empty iolist if no move needed (cursor already at target position).
- # Uses absolute positioning for all moves.
- #
- # Note on cursor advancement: After writing a character, the cursor automatically
- # advances one column. This function assumes single-width characters. Multi-width
- # characters (CJK, emoji) would require grapheme width tracking - a future enhancement.
- #
- # Note: Using CursorOptimizer could provide ~40% byte savings on cursor movement.
- # Current absolute positioning is simple and correct but not optimal.
- # See move_cursor/2 for example of CursorOptimizer integration.
- @spec cursor_move_output({pos_integer(), pos_integer()} | nil, {pos_integer(), pos_integer()}) ::
- iolist()
- defp cursor_move_output(nil, {row, col}) do
- # No previous position known - must use absolute
- ANSI.cursor_position(row, col)
- end
-
- defp cursor_move_output({cur_row, cur_col}, {target_row, target_col})
- when cur_row == target_row and cur_col == target_col do
- # Already at target position - no move needed
- []
- end
-
- defp cursor_move_output({_cur_row, _cur_col}, {target_row, target_col}) do
- # Need to move cursor - use absolute positioning
- ANSI.cursor_position(target_row, target_col)
- end
-
- # Generates style delta escape sequences - only emits what has changed.
- #
- # Style delta optimization reduces escape sequence output by 80-90% for typical
- # UIs where adjacent cells share styles. Instead of emitting full style for every
- # cell, we only emit changes from the previous style.
- #
- # When attributes are removed (e.g., transitioning from [:bold, :italic] to [:bold]),
- # we must reset with ESC[0m and rebuild the full style, since ANSI doesn't have
- # efficient individual attribute removal for all attributes.
- #
- # Note: This uses ANSI module for sequence generation. For parameter-level SGR
- # operations (e.g., combining into single sequence), see TermUI.SGR module.
- @spec style_delta_output(style_state() | nil, style_state()) :: iolist()
- defp style_delta_output(nil, new_style) do
- # No previous style - emit full style
- build_full_style(new_style)
- end
-
- defp style_delta_output(current_style, new_style) when current_style == new_style do
- # Styles are identical - no output needed
- []
- end
-
- defp style_delta_output(current_style, new_style) do
- # Check if we need a full reset (removing attributes is complex)
- # Strategy: if new style has fewer or different attrs, reset and rebuild
- current_attrs = MapSet.new(current_style.attrs)
- new_attrs = MapSet.new(new_style.attrs)
-
- # Attributes being removed require a reset
- removed_attrs = MapSet.difference(current_attrs, new_attrs)
-
- if MapSet.size(removed_attrs) > 0 do
- # Reset and apply full new style
- [ANSI.reset(), build_full_style(new_style)]
- else
- # Build delta - only add new attributes and changed colors
- build_style_delta(current_style, new_style)
- end
- end
-
- # Builds a complete style sequence from scratch.
- #
- # Used when:
- # 1. First cell being rendered (no previous style)
- # 2. After a style reset when attributes were removed
- #
- # Generates escape sequences for foreground color, background color, and all
- # text attributes in that order.
- @spec build_full_style(style_state()) :: iolist()
- defp build_full_style(%{fg: fg, bg: bg, attrs: attrs}) do
- [
- color_sequence(:fg, fg),
- color_sequence(:bg, bg),
- attr_sequences(attrs)
+ @spec draw(t(), Frame.t()) :: {:ok, t()} | {:error, term()}
+ def draw(state, %Frame{} = frame) do
+ reset? = dimensions_changed?(state.last_frame, frame)
+ changes = if reset?, do: Frame.cells(frame), else: Frame.diff(state.last_frame, frame)
+
+ output = [
+ ANSI.cursor_hide(),
+ if(reset?, do: [ANSI.clear_screen(), ANSI.cursor_position(1, 1)], else: []),
+ Renderer.render(
+ changes,
+ state.capabilities.colors,
+ if(state.capabilities.unicode, do: :unicode, else: :ascii)
+ ),
+ cursor_sequence(frame.cursor)
]
- end
-
- # Builds style delta - only emits escape sequences for changes.
- #
- # Compares current and new styles, emitting only:
- # - Foreground color sequence if fg changed
- # - Background color sequence if bg changed
- # - Attribute sequences for newly added attributes
- #
- # Note: This function is only called when no attributes were removed
- # (removal requires full reset, handled by style_delta_output/2).
- @spec build_style_delta(style_state(), style_state()) :: iolist()
- defp build_style_delta(current, new) do
- fg_output = if current.fg != new.fg, do: color_sequence(:fg, new.fg), else: []
- bg_output = if current.bg != new.bg, do: color_sequence(:bg, new.bg), else: []
-
- # New attributes that weren't in current
- new_attr_set = MapSet.new(new.attrs)
- current_attr_set = MapSet.new(current.attrs)
- added_attrs = MapSet.difference(new_attr_set, current_attr_set) |> MapSet.to_list()
- attr_output = attr_sequences(added_attrs)
-
- [fg_output, bg_output, attr_output]
- end
-
- # Generate color sequence for foreground or background
- defp color_sequence(:fg, :default), do: ["\e[39m"]
- defp color_sequence(:bg, :default), do: ["\e[49m"]
-
- defp color_sequence(:fg, {r, g, b}) when is_integer(r) and is_integer(g) and is_integer(b) do
- ANSI.foreground_rgb(r, g, b)
- end
-
- defp color_sequence(:bg, {r, g, b}) when is_integer(r) and is_integer(g) and is_integer(b) do
- ANSI.background_rgb(r, g, b)
- end
-
- defp color_sequence(:fg, index) when is_integer(index) and index >= 0 and index <= 255 do
- ANSI.foreground_256(index)
- end
- defp color_sequence(:bg, index) when is_integer(index) and index >= 0 and index <= 255 do
- ANSI.background_256(index)
- end
-
- defp color_sequence(:fg, color) when is_atom(color) do
- ANSI.foreground(color)
- end
-
- defp color_sequence(:bg, color) when is_atom(color) do
- ANSI.background(color)
- end
-
- # Catch-all for invalid colors - log warning and return empty sequence
- defp color_sequence(:fg, unknown) do
- Logger.warning("Unknown foreground color: #{inspect(unknown)}")
- []
- end
-
- defp color_sequence(:bg, unknown) do
- Logger.warning("Unknown background color: #{inspect(unknown)}")
- []
- end
-
- # Generate attribute sequences
- defp attr_sequences([]), do: []
-
- defp attr_sequences(attrs) when is_list(attrs) do
- Enum.map(attrs, &attr_sequence/1)
+ case TerminalOutput.write(output) do
+ :ok -> {:ok, %{state | last_frame: frame}}
+ {:error, reason} -> {:error, {:terminal_write_failed, reason}}
+ end
end
- defp attr_sequence(:bold), do: ANSI.bold()
- defp attr_sequence(:dim), do: ANSI.dim()
- defp attr_sequence(:italic), do: ANSI.italic()
- defp attr_sequence(:underline), do: ANSI.underline()
- defp attr_sequence(:blink), do: ANSI.blink()
- defp attr_sequence(:reverse), do: ANSI.reverse()
- defp attr_sequence(:hidden), do: ANSI.hidden()
- defp attr_sequence(:strikethrough), do: ANSI.strikethrough()
- defp attr_sequence(_unknown), do: []
-
@impl true
- @doc """
- Flushes pending output to the terminal.
-
- For the Raw backend, this is a no-op because `IO.write/1` is synchronous -
- output is written directly to the terminal without buffering. The callback
- exists for API completeness and compatibility with backends that may use
- buffered I/O.
-
- This function is idempotent and safe to call multiple times.
-
- ## Returns
-
- - `{:ok, state}` - Always succeeds, returning state unchanged
- """
@spec flush(t()) :: {:ok, t()}
- def flush(state) do
- # IO.write/1 is synchronous in Erlang/OTP - no buffering to flush.
- # For backends with buffered output, this would call :erlang.port_command/3
- # with the :nosuspend option or similar synchronization mechanism.
- {:ok, state}
- end
-
- # ===========================================================================
- # Mouse Tracking
- # ===========================================================================
-
- @doc """
- Enables mouse tracking with the specified mode.
-
- Changes the mouse tracking mode, enabling detection of mouse events.
- This function can be called after initialization to change the tracking mode.
-
- ## Parameters
-
- - `state` - Current backend state
- - `mode` - Mouse tracking mode:
- - `:click` - Track button press/release only (ANSI "normal" mode, 1000)
- - `:drag` - Track clicks and motion while button pressed (ANSI "button" mode, 1002)
- - `:all` - Track all mouse movement (ANSI "any" mode, 1003)
-
- ## Escape Sequences
-
- This function emits:
- 1. The appropriate mouse tracking mode sequence:
- - `:click` → `ESC[?1000h`
- - `:drag` → `ESC[?1002h`
- - `:all` → `ESC[?1003h`
- 2. SGR extended mode (`ESC[?1006h`) for accurate coordinate encoding
-
- ## Idempotent Behavior
+ def flush(state), do: {:ok, state}
- If the requested mode matches the current mode, no escape sequences are
- written and the same state is returned.
-
- ## Returns
-
- - `{:ok, updated_state}` with `mouse_mode` set to the new mode
-
- ## Examples
-
- # Enable click tracking
- {:ok, state} = Raw.enable_mouse(state, :click)
-
- # Enable all movement tracking
- {:ok, state} = Raw.enable_mouse(state, :all)
-
- ## See Also
-
- - `disable_mouse/1` - Disable mouse tracking
- - `init/1` - Can set initial mouse tracking mode via `:mouse_tracking` option
- """
- @spec enable_mouse(t(), :click | :drag | :all) :: {:ok, t()}
- def enable_mouse(%__MODULE__{mouse_mode: mode} = state, mode) do
- # Already in requested mode - idempotent no-op
- {:ok, state}
- end
-
- def enable_mouse(state, mode) when mode in [:click, :drag, :all] do
- # Skip mouse tracking on WSL/ConPTY
- if TerminalOutput.needs_hard_reset?() do
+ @impl true
+ @spec clipboard(t(), Clipboard.Operation.t()) :: {:ok, t()} | {:error, term()}
+ def clipboard(state, %Clipboard.Operation{} = operation) do
+ with {:ok, sequence} <- Clipboard.sequence(operation),
+ :ok <- TerminalOutput.write(sequence) do
{:ok, state}
else
- # Disable current mode if active (to avoid stacking modes)
- if state.mouse_mode != :none do
- disable_current_mouse_mode(state.mouse_mode)
- end
-
- # Enable new mode
- ansi_mode = mouse_mode_to_ansi(mode)
- write_to_terminal(ANSI.enable_mouse_tracking(ansi_mode))
- write_to_terminal(ANSI.enable_sgr_mouse())
-
- {:ok, %{state | mouse_mode: mode}}
+ {:error, {:clipboard_too_large, _size, _maximum} = reason} -> {:error, reason}
+ {:error, reason} -> {:error, {:terminal_write_failed, reason}}
end
end
- @doc """
- Disables mouse tracking.
-
- Turns off mouse event reporting, returning the terminal to normal operation
- where mouse actions are not reported to the application.
-
- ## Escape Sequences
-
- This function emits:
- 1. Disable SGR extended mode (`ESC[?1006l`)
- 2. Disable the current tracking mode:
- - `:click` → `ESC[?1000l`
- - `:drag` → `ESC[?1002l`
- - `:all` → `ESC[?1003l`
-
- ## Idempotent Behavior
-
- If mouse tracking is already disabled (`:none`), no escape sequences are
- written and the same state is returned.
-
- ## Returns
-
- - `{:ok, updated_state}` with `mouse_mode` set to `:none`
-
- ## Examples
-
- # Disable after enabling
- {:ok, state} = Raw.enable_mouse(state, :click)
- {:ok, state} = Raw.disable_mouse(state)
- state.mouse_mode # => :none
-
- # Idempotent - safe to call when already disabled
- {:ok, same_state} = Raw.disable_mouse(state)
-
- ## See Also
-
- - `enable_mouse/2` - Enable mouse tracking
- - `shutdown/1` - Automatically disables mouse tracking during cleanup
- """
- @spec disable_mouse(t()) :: {:ok, t()}
- def disable_mouse(%__MODULE__{mouse_mode: :none} = state) do
- # Already disabled - idempotent no-op
- {:ok, state}
- end
-
- def disable_mouse(state) do
- # Disable SGR mode first
- write_to_terminal(ANSI.disable_sgr_mouse())
-
- # Disable the current tracking mode
- ansi_mode = mouse_mode_to_ansi(state.mouse_mode)
-
- if ansi_mode do
- write_to_terminal(ANSI.disable_mouse_tracking(ansi_mode))
- end
-
- {:ok, %{state | mouse_mode: :none}}
- end
-
@impl true
- @doc """
- Polls for input events with the specified timeout.
-
- In raw mode, input arrives character-by-character enabling real-time
- keyboard and mouse event handling. This function uses the `EscapeParser`
- module to parse escape sequences into `TermUI.Event` structs.
-
- ## Parameters
-
- - `state` - Current backend state
- - `timeout` - Milliseconds to wait (0 for non-blocking)
-
- ## Returns
-
- - `{:ok, event, state}` - Event received and parsed
- - `{:timeout, state}` - No input within timeout period
- - `{:error, reason, state}` - Terminal I/O error occurred
-
- ## Escape Sequence Handling
-
- Some sequences are ambiguous (ESC alone vs ESC followed by another key).
- The function buffers partial sequences and uses the timeout to disambiguate.
- If the buffer contains a partial escape sequence and the timeout expires,
- the escape key is emitted and remaining bytes are re-parsed.
-
- ## Examples
-
- # Non-blocking poll (timeout = 0)
- {:timeout, state} = Raw.poll_event(state, 0)
-
- # Block up to 100ms for input
- case Raw.poll_event(state, 100) do
- {:ok, %Event.Key{key: :enter}, state} -> handle_enter(state)
- {:ok, %Event.Mouse{action: :click}, state} -> handle_click(state)
- {:timeout, state} -> handle_idle(state)
- end
- """
@spec poll_event(t(), non_neg_integer()) ::
{:ok, TermUI.Backend.event(), t()}
| {:timeout, t()}
| {:error, term(), t()}
def poll_event(state, timeout) do
- # First, try to parse any buffered input
- case try_parse_buffer(state) do
- {:ok, event, new_state} ->
- {:ok, event, new_state}
-
- {:need_more, state} ->
- # Try to read more input with timeout
- read_input_with_timeout(state, timeout)
- end
- end
-
- # Attempts to parse an event from the current buffer or event queue.
- # Returns {:ok, event, state} if a complete event is available,
- # or {:need_more, state} if more input is needed.
- @spec try_parse_buffer(t()) :: {:ok, TermUI.Backend.event(), t()} | {:need_more, t()}
- defp try_parse_buffer(%{event_queue: [event | rest]} = state) do
- # Return queued event first
- {:ok, event, %{state | event_queue: rest}}
- end
-
- defp try_parse_buffer(%{input_buffer: <<>>, event_queue: []} = state) do
- {:need_more, state}
- end
-
- defp try_parse_buffer(%{input_buffer: buffer, event_queue: []} = state) do
- alias TermUI.Terminal.EscapeParser
-
- case EscapeParser.parse(buffer) do
- {[event], remaining} ->
- # Single event - simple case
- {:ok, event, %{state | input_buffer: remaining}}
-
- {[event | rest_events], remaining} ->
- # Multiple events parsed - return first, queue the rest (with size limit)
- new_state = queue_events(%{state | input_buffer: remaining}, rest_events)
- {:ok, event, new_state}
-
- {[], remaining} when remaining != <<>> ->
- # Partial sequence - check if it's a potential escape sequence
- if EscapeParser.partial_sequence?(remaining) do
- {:need_more, %{state | input_buffer: remaining}}
- else
- # Unknown data - clear buffer
- {:need_more, %{state | input_buffer: <<>>}}
- end
-
- {[], <<>>} ->
- {:need_more, state}
- end
- end
-
- # Reads input from the terminal with a timeout.
- # Uses a Task to avoid blocking indefinitely on IO.getn/2.
- @spec read_input_with_timeout(t(), non_neg_integer()) ::
- {:ok, TermUI.Backend.event(), t()} | {:timeout, t()} | {:error, term(), t()}
- defp read_input_with_timeout(state, timeout) do
- alias TermUI.Event
- alias TermUI.Terminal.EscapeParser
-
- # For zero timeout, just check if there's input ready
- # Unfortunately, IO.getn blocks, so we use a Task with timeout
- task = Task.async(fn -> read_one_byte() end)
-
- case Task.yield(task, timeout) || Task.shutdown(task) do
- {:ok, {:ok, data}} ->
- # Got input - add to buffer and try to parse (with size limit)
- new_state = append_to_input_buffer(state, data)
- try_parse_or_continue(new_state, timeout)
-
- {:ok, :eof} ->
- {:error, :eof, state}
-
- {:ok, {:error, reason}} ->
- {:error, reason, state}
-
- nil ->
- # Timeout - if we have a partial escape sequence, handle it
- handle_timeout(state)
- end
+ EventStream.poll(state, timeout, &read_one_byte/0, __MODULE__)
end
- # After reading new input, try to parse it. If we get a partial sequence,
- # continue reading with remaining timeout (simplified: just try once more).
- @spec try_parse_or_continue(t(), non_neg_integer()) ::
- {:ok, TermUI.Backend.event(), t()} | {:timeout, t()} | {:error, term(), t()}
- defp try_parse_or_continue(state, _timeout) do
- alias TermUI.Event
- alias TermUI.Terminal.EscapeParser
-
- buffer = state.input_buffer
-
- case EscapeParser.parse(buffer) do
- {[event | _rest], remaining} ->
- {:ok, event, %{state | input_buffer: remaining}}
-
- {[], remaining} when remaining != <<>> ->
- # Partial sequence - for escape sequences, use a short timeout
- if EscapeParser.partial_sequence?(remaining) do
- # Wait a bit more for the rest of the escape sequence
- wait_for_escape_completion(state, remaining)
- else
- {:timeout, %{state | input_buffer: remaining}}
- end
-
- {[], <<>>} ->
- {:timeout, state}
+ @impl true
+ @spec resize(t(), TermUI.Backend.size()) :: {:ok, t()} | {:error, term()}
+ def resize(state, {rows, columns} = size)
+ when is_integer(rows) and rows > 0 and is_integer(columns) and columns > 0 do
+ case TerminalOutput.write([ANSI.clear_screen(), ANSI.cursor_position(1, 1)]) do
+ :ok ->
+ capabilities = Map.put(state.capabilities, :size, size)
+ {:ok, %{state | size: size, capabilities: capabilities, last_frame: nil}}
+
+ {:error, reason} ->
+ {:error, {:terminal_write_failed, reason}}
end
end
- # Short timeout to wait for escape sequence completion.
- @escape_timeout 50
-
- @spec wait_for_escape_completion(t(), binary()) ::
- {:ok, TermUI.Backend.event(), t()} | {:timeout, t()} | {:error, term(), t()}
- defp wait_for_escape_completion(state, buffer) do
- alias TermUI.Event
- alias TermUI.Terminal.EscapeParser
-
- task = Task.async(fn -> read_one_byte() end)
-
- case Task.yield(task, @escape_timeout) || Task.shutdown(task) do
- {:ok, {:ok, data}} ->
- # Got more data - try to parse again
- new_buffer = buffer <> data
- handle_parse_result(EscapeParser.parse(new_buffer), state, new_buffer)
-
- {:ok, :eof} ->
- # EOF during escape sequence - emit what we have
- emit_partial_escape(state, buffer)
-
- {:ok, {:error, _reason}} ->
- emit_partial_escape(state, buffer)
+ defp setup_sequence(state, hide_cursor?) do
+ mouse =
+ if state.mouse_mode != :none and not TerminalOutput.needs_hard_reset?() do
+ mode = mouse_mode_to_ansi(state.mouse_mode)
+ [ANSI.enable_mouse_tracking(mode), ANSI.enable_sgr_mouse()]
+ else
+ []
+ end
- nil ->
- # Timeout - emit partial escape sequence
- emit_partial_escape(state, buffer)
- end
+ [
+ if(state.alternate_screen, do: ANSI.enter_alternate_screen(), else: []),
+ if(hide_cursor?, do: ANSI.cursor_hide(), else: []),
+ mouse,
+ if(state.bracketed_paste, do: ANSI.enable_bracketed_paste(), else: []),
+ if(state.focus_events, do: ANSI.enable_focus_events(), else: []),
+ ANSI.clear_screen(),
+ ANSI.cursor_position(1, 1)
+ ]
end
- # Handles the result of parsing escape sequence data.
- defp handle_parse_result({[event | _], remaining}, state, _buffer) do
- {:ok, event, %{state | input_buffer: remaining}}
- end
+ defp cursor_sequence(nil), do: ANSI.cursor_hide()
+ defp cursor_sequence({column, row}), do: [ANSI.cursor_position(row, column), ANSI.cursor_show()]
- defp handle_parse_result({[], remaining}, state, _buffer) do
- alias TermUI.Terminal.EscapeParser
+ defp dimensions_changed?(nil, _frame), do: false
- if EscapeParser.partial_sequence?(remaining) do
- wait_for_escape_completion(state, remaining)
- else
- {:timeout, %{state | input_buffer: remaining}}
- end
- end
-
- # Handles timeout when we have a partial escape sequence.
- @spec handle_timeout(t()) :: {:timeout, t()} | {:ok, TermUI.Backend.event(), t()}
- defp handle_timeout(%{input_buffer: <<>>} = state) do
- {:timeout, state}
+ defp dimensions_changed?(previous, current) do
+ previous.width != current.width or previous.height != current.height
end
- defp handle_timeout(%{input_buffer: buffer} = state) do
- alias TermUI.Terminal.EscapeParser
+ defp mouse_mode_to_ansi(:click), do: :normal
+ defp mouse_mode_to_ansi(:drag), do: :button
+ defp mouse_mode_to_ansi(:all), do: :all
- if EscapeParser.partial_sequence?(buffer) do
- emit_partial_escape(state, buffer)
- else
- {:timeout, state}
- end
- end
-
- # Emits events from a partial escape sequence (timeout disambiguation).
- @spec emit_partial_escape(t(), binary()) :: {:ok, TermUI.Backend.event(), t()}
- defp emit_partial_escape(state, buffer) do
- alias TermUI.Event
- alias TermUI.Terminal.EscapeParser
-
- # Handle known partial sequences
- case buffer do
- # Lone ESC
- <<0x1B>> ->
- {:ok, Event.key(:escape), %{state | input_buffer: <<>>}}
-
- # ESC[ without terminator - emit ESC, keep [ for next parse
- <<0x1B, ?[>> ->
- {:ok, Event.key(:escape), %{state | input_buffer: "["}}
-
- # ESC O without terminator
- <<0x1B, ?O>> ->
- {:ok, Event.key(:escape), %{state | input_buffer: "O"}}
-
- # Other partial sequences starting with ESC
- <<0x1B, rest::binary>> ->
- {:ok, Event.key(:escape), %{state | input_buffer: rest}}
-
- # Non-escape partial - just clear buffer
- _ ->
- {:timeout, %{state | input_buffer: <<>>}}
- end
+ defp cleanup_terminal(state) do
+ TerminalOutput.write_to_tty(
+ TerminalOutput.cleanup_sequence(
+ mouse: state.mouse_mode != :none,
+ bracketed_paste: state.bracketed_paste,
+ focus_events: state.focus_events,
+ alternate_screen: state.alternate_screen
+ )
+ )
end
- # Reads one byte from stdin.
- @spec read_one_byte() :: {:ok, binary()} | :eof | {:error, term()}
defp read_one_byte do
case IO.getn("", 1) do
:eof -> :eof
{:error, reason} -> {:error, reason}
data when is_binary(data) -> {:ok, data}
+ [byte] when is_integer(byte) -> {:ok, <>}
+ other -> {:error, {:unexpected_io_return, other}}
end
end
- # ===========================================================================
- # Helper Functions
- # ===========================================================================
-
- @doc """
- Checks if a position is valid within the terminal bounds.
-
- Returns `true` if the position has positive coordinates and is within
- the terminal dimensions stored in state.
-
- ## Examples
-
- iex> state = %Raw{size: {24, 80}}
- iex> Raw.valid_position?(state, {1, 1})
- true
- iex> Raw.valid_position?(state, {24, 80})
- true
- iex> Raw.valid_position?(state, {25, 1})
- false
- iex> Raw.valid_position?(state, {0, 1})
- false
- """
- @spec valid_position?(t(), {integer(), integer()}) :: boolean()
- def valid_position?(%__MODULE__{size: {max_rows, max_cols}}, {row, col})
- when is_integer(row) and is_integer(col) do
- row > 0 and col > 0 and row <= max_rows and col <= max_cols
- end
-
- def valid_position?(_state, _position), do: false
-
- @doc """
- Maps a Raw backend mouse mode to the corresponding ANSI protocol mode.
-
- This is used internally when emitting mouse tracking escape sequences.
-
- ## Examples
-
- iex> Raw.mouse_mode_to_ansi(:click)
- :normal
- iex> Raw.mouse_mode_to_ansi(:drag)
- :button
- iex> Raw.mouse_mode_to_ansi(:all)
- :all
- """
- @spec mouse_mode_to_ansi(mouse_mode()) :: :normal | :button | :all | nil
- def mouse_mode_to_ansi(:none), do: nil
- def mouse_mode_to_ansi(:click), do: :normal
- def mouse_mode_to_ansi(:drag), do: :button
- def mouse_mode_to_ansi(:all), do: :all
-
- # Provides access to the ANSI module for escape sequence generation
- @doc false
- def ansi_module, do: ANSI
-
- # ===========================================================================
- # Private Functions
- # ===========================================================================
-
- # Gets terminal size from explicit option or auto-detection.
- # Delegates to SizeDetector for consistent detection across backends.
- @spec get_terminal_size({pos_integer(), pos_integer()} | nil) ::
- {:ok, {pos_integer(), pos_integer()}} | {:error, term()}
- defp get_terminal_size(size_opt) do
- SizeDetector.detect(size: size_opt)
- end
-
- # Appends data to the input buffer with size limit protection.
- # Uses the shared InputBuffer module for rate-limited logging.
- @spec append_to_input_buffer(t(), binary()) :: t()
- defp append_to_input_buffer(state, data) do
- InputBuffer.append_with_limit(state, data, :input_buffer, source: __MODULE__)
- end
-
- # Queues events with size limit protection.
- # If the queue exceeds @max_event_queue_size, drops oldest events.
- # This prevents memory exhaustion when events are parsed faster than consumed.
- # Dropped events are counted in the events_dropped field for monitoring.
- @spec queue_events(t(), [TermUI.Backend.event()]) :: t()
- defp queue_events(state, []), do: state
-
- defp queue_events(state, new_events) do
- combined = state.event_queue ++ new_events
- queue_size = length(combined)
-
- if queue_size > @max_event_queue_size do
- # Keep newest events, drop oldest
- to_drop = queue_size - @max_event_queue_size
- new_total_dropped = state.events_dropped + to_drop
-
- # Only log on first drop to prevent log flooding
- if state.events_dropped == 0 do
- Logger.warning(
- "Event queue overflow (#{queue_size} events), dropping #{to_drop} oldest events"
- )
- end
+ defp restore_raw_mode(%{raw_mode_session: session}) when not is_nil(session) do
+ case RawMode.exit(session) do
+ :ok ->
+ :ok
- %{state | event_queue: Enum.drop(combined, to_drop), events_dropped: new_total_dropped}
- else
- %{state | event_queue: combined}
+ {:error, reason} ->
+ Logger.warning("TermUI: Raw mode restoration failed: #{inspect(reason)}")
end
end
- # Writes data to the terminal via TerminalOutput (ONLCR-aware).
- defp write_to_terminal(data) do
- TerminalOutput.write(data)
- rescue
- e ->
- Logger.debug("Terminal write failed: #{Exception.message(e)}")
- :ok
- end
+ defp restore_raw_mode(%{raw_mode_started: true}), do: restore_legacy_raw_mode()
+ defp restore_raw_mode(_state), do: :ok
- # Error-safe write for shutdown - logs errors but continues
- defp safe_write(data) do
- TerminalOutput.write(data)
+ defp restore_legacy_raw_mode do
+ _result = :shell.start_interactive({:noshell, :cooked})
+ :ok
rescue
- _ -> :ok
+ _exception -> :ok
+ catch
+ _kind, _reason -> :ok
end
- # Drains pending input bytes (e.g. mouse events buffered during shutdown).
- # Sets stty to non-blocking, reads and discards pending bytes, then restores.
defp drain_pending_input do
- # Set non-blocking read: min 0 chars, timeout 0.1s
- _ = TermUtils.safe_stty(["min", "0", "time", "1"])
-
- drain_input_loop(0, 20)
+ case TermUtils.safe_stty(["min", "0", "time", "1"]) do
+ {:ok, _output} ->
+ try do
+ drain_input_loop(0, 20)
+ after
+ _result = TermUtils.safe_stty(["min", "1", "time", "0"])
+ end
- # Restore blocking read
- _ = TermUtils.safe_stty(["min", "1", "time", "0"])
+ {:error, _reason} ->
+ :ok
+ end
rescue
- _ -> :ok
+ _exception -> :ok
end
defp drain_input_loop(iteration, max) when iteration >= max, do: :ok
@@ -1539,40 +258,10 @@ defmodule TermUI.Backend.Raw do
data when is_binary(data) and byte_size(data) > 0 ->
drain_input_loop(iteration + 1, max)
- _ ->
+ _other ->
:ok
end
rescue
- _ -> :ok
- end
-
- # Disables the current mouse tracking mode
- defp disable_current_mouse_mode(mode) do
- ansi_mode = mouse_mode_to_ansi(mode)
-
- if ansi_mode do
- write_to_terminal(ANSI.disable_mouse_tracking(ansi_mode))
- end
- end
-
- # Error-safe cooked mode restoration
- defp safe_cooked_mode do
- :shell.start_interactive({:noshell, :cooked})
- rescue
- e in UndefinedFunctionError ->
- # :shell.start_interactive/1 not available (pre-OTP 28)
- Logger.warning(
- "Cooked mode restoration not available (OTP 28+ required): #{Exception.message(e)}"
- )
-
- :ok
-
- e ->
- Logger.warning("Failed to restore cooked mode: #{Exception.message(e)}")
- :ok
- catch
- kind, reason ->
- Logger.warning("Failed to restore cooked mode: #{kind} - #{inspect(reason)}")
- :ok
+ _exception -> :ok
end
end
diff --git a/lib/term_ui/backend/renderer.ex b/lib/term_ui/backend/renderer.ex
new file mode 100644
index 00000000..be2eb4fc
--- /dev/null
+++ b/lib/term_ui/backend/renderer.ex
@@ -0,0 +1,163 @@
+defmodule TermUI.Backend.Renderer do
+ @moduledoc false
+
+ alias TermUI.{ANSI, Cell}
+ alias TermUI.Color.Converter
+
+ @unicode_chars TermUI.CharacterSet.get(:unicode)
+ @ascii_chars TermUI.CharacterSet.get(:ascii)
+
+ @unicode_to_ascii_map (
+ list_keys = [:bar_levels, :sparkline_levels, :spinner_frames]
+ keys = TermUI.CharacterSet.keys() -- list_keys
+
+ base =
+ Map.new(keys, fn key -> {@unicode_chars[key], @ascii_chars[key]} end)
+
+ levels =
+ [:bar_levels, :sparkline_levels]
+ |> Enum.flat_map(fn key ->
+ unicode_levels = @unicode_chars[key]
+ ascii_levels = @ascii_chars[key]
+ unicode_max = max(length(unicode_levels) - 1, 1)
+ ascii_max = max(length(ascii_levels) - 1, 0)
+
+ unicode_levels
+ |> Enum.with_index()
+ |> Enum.map(fn {character, index} ->
+ ascii_index = round(index * ascii_max / unicode_max)
+ {character, Enum.at(ascii_levels, ascii_index)}
+ end)
+ end)
+
+ spinner_frames =
+ Enum.zip(
+ @unicode_chars.spinner_frames,
+ Stream.cycle(@ascii_chars.spinner_frames)
+ )
+
+ Map.new(levels ++ spinner_frames ++ Map.to_list(base))
+ )
+
+ @doc "Converts changed backend cells to a minimal ANSI output sequence."
+ @spec render(
+ [{TermUI.Backend.position(), TermUI.Backend.cell()}],
+ :true_color | :color_256 | :color_16 | :monochrome,
+ :unicode | :ascii
+ ) :: iolist()
+ def render(changes, color_mode, character_set) do
+ [
+ changes
+ |> contiguous_runs(character_set)
+ |> render_runs(color_mode),
+ ANSI.reset()
+ ]
+ end
+
+ defp contiguous_runs(changes, character_set) do
+ {runs, current} =
+ Enum.reduce(changes, {[], nil}, fn change, {runs, current} ->
+ cell = render_cell(change, character_set)
+
+ if adjacent?(current, cell) do
+ {runs, append_to_run(current, cell)}
+ else
+ {complete_run(runs, current), new_run(cell)}
+ end
+ end)
+
+ runs
+ |> complete_run(current)
+ |> Enum.reverse()
+ end
+
+ defp complete_run(runs, nil), do: runs
+ defp complete_run(runs, run), do: [run | runs]
+
+ defp render_cell({{row, column}, {char, foreground, background, attrs}}, character_set) do
+ cell = Cell.new(char)
+ char = map_character(cell.char, character_set)
+ width = if char == cell.char, do: Cell.width(cell), else: char |> Cell.new() |> Cell.width()
+
+ %{row: row, column: column, char: char, width: width, style: {foreground, background, attrs}}
+ end
+
+ defp adjacent?(nil, _cell), do: false
+
+ defp adjacent?(run, cell) do
+ run.row == cell.row and run.last_column + run.last_width == cell.column
+ end
+
+ defp new_run(cell) do
+ %{
+ row: cell.row,
+ column: cell.column,
+ last_column: cell.column,
+ last_width: cell.width,
+ cells: [cell]
+ }
+ end
+
+ defp append_to_run(run, cell) do
+ %{run | last_column: cell.column, last_width: cell.width, cells: [cell | run.cells]}
+ end
+
+ defp render_runs(runs, color_mode) do
+ {_style, output} =
+ Enum.reduce(runs, {nil, []}, fn run, {previous_style, output} ->
+ {style, cells} = render_run_cells(Enum.reverse(run.cells), color_mode, previous_style)
+
+ {style, [output, ANSI.cursor_position(run.row, run.column), cells]}
+ end)
+
+ output
+ end
+
+ defp render_run_cells(cells, color_mode, initial_style) do
+ Enum.reduce(cells, {initial_style, []}, fn cell, {previous_style, output} ->
+ style = cell.style
+ sequence = if style == previous_style, do: [], else: style_sequence(style, color_mode)
+ {style, [output, sequence, cell.char]}
+ end)
+ end
+
+ defp style_sequence({foreground, background, attrs}, color_mode) do
+ [
+ ANSI.reset(),
+ color(:fg, foreground, color_mode),
+ color(:bg, background, color_mode),
+ Enum.map(attrs, &attribute/1)
+ ]
+ end
+
+ defp map_character(char, :unicode), do: char
+ defp map_character(char, :ascii), do: Map.get(@unicode_to_ascii_map, char, char)
+
+ defp color(_type, _color, :monochrome), do: []
+ defp color(_type, :default, _mode), do: []
+ defp color(:fg, color, _mode) when is_atom(color), do: ANSI.foreground(color)
+ defp color(:bg, color, _mode) when is_atom(color), do: ANSI.background(color)
+ defp color(:fg, index, _mode) when is_integer(index), do: ANSI.foreground_256(index)
+ defp color(:bg, index, _mode) when is_integer(index), do: ANSI.background_256(index)
+ defp color(:fg, {red, green, blue}, :true_color), do: ANSI.foreground_rgb(red, green, blue)
+ defp color(:bg, {red, green, blue}, :true_color), do: ANSI.background_rgb(red, green, blue)
+
+ defp color(:fg, rgb, :color_256), do: rgb |> Converter.rgb_to_256() |> ANSI.foreground_256()
+ defp color(:bg, rgb, :color_256), do: rgb |> Converter.rgb_to_256() |> ANSI.background_256()
+
+ defp color(:fg, rgb, :color_16),
+ do: ["\e[", Integer.to_string(Converter.rgb_to_16(rgb, :fg)), "m"]
+
+ defp color(:bg, rgb, :color_16),
+ do: ["\e[", Integer.to_string(Converter.rgb_to_16(rgb, :bg)), "m"]
+
+ defp attribute(:bold), do: ANSI.bold()
+ defp attribute(:dim), do: ANSI.dim()
+ defp attribute(:italic), do: ANSI.italic()
+ defp attribute(:underline), do: ANSI.underline()
+ defp attribute(:blink), do: ANSI.blink()
+ defp attribute(:reverse), do: ANSI.reverse()
+ defp attribute(:hidden), do: ANSI.hidden()
+ defp attribute(:strikethrough), do: ANSI.strikethrough()
+ defp attribute(_unknown), do: []
+end
diff --git a/lib/term_ui/backend/selector.ex b/lib/term_ui/backend/selector.ex
index 0afd037e..9e7cc57b 100644
--- a/lib/term_ui/backend/selector.ex
+++ b/lib/term_ui/backend/selector.ex
@@ -1,95 +1,13 @@
defmodule TermUI.Backend.Selector do
- @moduledoc """
- Determines which terminal backend to use at runtime.
+ @moduledoc false
- The Selector module implements a "try raw mode first" strategy for backend
- selection. This approach is the **only reliable method** for determining
- whether raw terminal mode is available.
-
- ## Why Not Use Heuristics?
-
- Environment-based detection (checking `$TERM`, `IO.getopts/0`, etc.) cannot
- reliably detect all cases where raw mode is unavailable:
-
- - **Nerves devices**: The erlinit process may have already started a shell,
- making raw mode unavailable even though `$TERM` suggests a capable terminal
-
- - **SSH sessions**: Remote SSH connections often have a shell already running
- in the PTY, preventing raw mode activation
-
- - **Remote IEx**: Connecting to a running node via `--remsh` or distributed
- Erlang inherits the remote node's terminal state
-
- - **Docker containers**: Terminal allocation varies by configuration; a TTY
- may be allocated but a shell may already be running
-
- - **IDE terminals**: Integrated terminals may report capabilities they don't
- fully support in raw mode
-
- ## The Selection Strategy
-
- The selector attempts to start raw mode using OTP 28's
- `:shell.start_interactive({:noshell, :raw})`:
-
- 1. **If raw mode succeeds** (returns `:ok`):
- - The terminal is now in raw mode
- - Return `{:raw, state}` for the Raw backend
-
- 2. **If raw mode fails** with `{:error, :already_started}`:
- - A shell is already running, raw mode unavailable
- - Detect terminal capabilities for graceful degradation
- - Return `{:tty, capabilities}` for the TTY backend
-
- 3. **If the function is undefined** (pre-OTP 28):
- - Fall back to TTY mode
- - Return `{:tty, capabilities}` with detected capabilities
-
- ## Return Values
-
- The `select/0` function returns one of:
-
- - `{:raw, state}` - Raw mode is active. The `state` map contains:
- - `:raw_mode_started` - `true` indicating raw mode was activated
-
- - `{:tty, capabilities}` - TTY mode should be used. The `capabilities` map contains:
- - `:colors` - Color depth (`:true_color`, `:color_256`, `:color_16`, `:monochrome`)
- - `:unicode` - Boolean indicating Unicode support
- - `:dimensions` - `{rows, cols}` tuple or `nil` if unknown
- - `:terminal` - Boolean indicating terminal presence
-
- ## Explicit Selection
-
- For testing or configuration override, use `select/1`:
-
- # Force TTY mode
- {:tty, caps} = Selector.select(TermUI.Backend.TTY)
-
- # Force raw mode (will fail if unavailable)
- {:raw, state} = Selector.select(TermUI.Backend.Raw)
-
- # Auto-detect (same as select/0)
- result = Selector.select(:auto)
-
- ## Examples
-
- # Typical usage in runtime initialization
- case TermUI.Backend.Selector.select() do
- {:raw, state} ->
- # Initialize raw backend
- TermUI.Backend.Raw.init(state)
-
- {:tty, capabilities} ->
- # Initialize TTY backend with detected capabilities
- TermUI.Backend.TTY.init(capabilities: capabilities)
- end
-
- ## OTP Version Requirements
+ require Logger
- - **OTP 28+**: Full support with `:shell.start_interactive/1`
- - **OTP 27 and earlier**: Automatic fallback to TTY mode
- """
+ alias TermUI.Terminal.RawMode
- require Logger
+ # OTP types start_interactive/1 as :ok, but it also returns runtime errors
+ # such as :already_started. This function must handle those results.
+ @dialyzer {:nowarn_function, attempt_raw_mode: 0}
@typedoc """
Result of backend selection.
@@ -106,7 +24,10 @@ defmodule TermUI.Backend.Selector do
@typedoc """
State returned when raw mode is successfully activated.
"""
- @type raw_state :: %{raw_mode_started: boolean()}
+ @type raw_state :: %{
+ raw_mode_started: true,
+ raw_mode_session: RawMode.session()
+ }
@typedoc """
Detected terminal capabilities for TTY mode.
@@ -198,20 +119,14 @@ defmodule TermUI.Backend.Selector do
@doc false
@spec attempt_raw_mode() :: {:raw, raw_state()} | {:tty, capabilities()}
def attempt_raw_mode do
- case :shell.start_interactive({:noshell, :raw}) do
- :ok ->
- # Raw mode successfully activated
- {:raw, %{raw_mode_started: true}}
+ case RawMode.enter() do
+ {:ok, session} ->
+ {:raw, %{raw_mode_started: true, raw_mode_session: session}}
{:error, :already_started} ->
- # A shell is already running, fall back to TTY mode
{:tty, detect_capabilities()}
{:error, reason} ->
- # Defensive programming: handle unexpected errors from :shell.start_interactive/1.
- # While OTP 28 documentation only specifies :ok and {:error, :already_started},
- # we gracefully handle other error conditions for forward compatibility and
- # robustness. The error reason is preserved in the capabilities map for debugging.
{:tty, Map.put(detect_capabilities(), :raw_mode_error, reason)}
end
end
@@ -309,20 +224,13 @@ defmodule TermUI.Backend.Selector do
end
# Detects if we're connected to a terminal
- @dialyzer {:nowarn_function,
- detect_terminal_presence: 0,
- select: 0,
- select: 1,
- try_raw_mode: 0,
- attempt_raw_mode: 0,
- detect_capabilities: 0}
- @spec detect_terminal_presence() :: term()
+ @spec detect_terminal_presence() :: boolean()
defp detect_terminal_presence do
case :io.getopts() do
- {:ok, opts} ->
- Keyword.get(opts, :terminal, false)
+ opts when is_list(opts) ->
+ Keyword.get(opts, :terminal, false) == true
- _ ->
+ {:error, _reason} ->
false
end
end
diff --git a/lib/term_ui/backend/ssh.ex b/lib/term_ui/backend/ssh.ex
index 03c6985a..f3a1e499 100644
--- a/lib/term_ui/backend/ssh.ex
+++ b/lib/term_ui/backend/ssh.ex
@@ -1,500 +1,203 @@
defmodule TermUI.Backend.SSH do
@moduledoc """
- SSH terminal backend for remote terminal sessions.
+ A complete backend for remote SSH terminal sessions.
- The SSH backend renders to an Erlang SSH channel IO device, enabling TermUI
- applications to run over SSH connections via OTP's `:ssh` application.
+ Each session owns one `TermUI.Runtime`. The backend parses remote input,
+ tracks PTY size, draws complete `TermUI.Frame` values, and restores the
+ remote terminal when the runtime stops. It does not start an SSH daemon or
+ define authentication and connection limits. The host application keeps
+ control of those policies.
- ## How It Works
+ ## Direct session API
- When an SSH client connects and requests a PTY, the `:ssh` application creates
- an IO device (the channel's group leader) that implements the Erlang IO protocol.
- This backend writes ANSI escape sequences to that device and reads input from it.
+ Applications that already own an SSH server can start a session directly.
+ The `:output` option can be a one-argument function or a process.
- SSH channels are already in raw mode from the client side — no `stty` or
- `:shell.start_interactive` is needed.
+ {:ok, session} =
+ TermUI.Backend.SSH.start_session(MyApp,
+ size: {24, 80},
+ output: fn data -> MySSHTransport.send(data) end
+ )
- ## Usage
+ :ok = TermUI.Backend.SSH.input(session, remote_bytes)
+ :ok = TermUI.Backend.SSH.resize(session, 40, 120)
+ :ok = TermUI.Backend.SSH.stop_session(session)
- Start a TermUI Runtime with an explicit SSH backend:
+ A process output target receives one bounded output request at a time:
- device = Process.group_leader() # SSH channel's IO device
- {rows, cols} = get_pty_size(device)
+ {:term_ui_ssh_output, session, token, data}
- {:ok, runtime} = TermUI.Runtime.start_link(
- root: MyApp.Root,
- backend: {TermUI.Backend.SSH, device: device, size: {rows, cols}}
- )
-
- ## Input Handling
-
- SSH input is delivered externally. The host process reads bytes from the SSH
- device, parses escape sequences, and sends events to the Runtime:
-
- send(runtime, {:ssh_input, %TermUI.Event.Key{key: :enter}})
-
- The `poll_event/2` callback returns `{:timeout, state}` since input is external.
-
- ## Resize Events
-
- Terminal size changes arrive as SSH `window_change` channel requests. Forward
- them to the Runtime:
+ The owner must call `ack_output/3` after it sends that data. This form is
+ useful for SSH implementations that require channel output to come from the
+ channel process.
- send(runtime, {:ssh_resize, new_rows, new_cols})
+ ## OTP SSH
- ## Multiple Sessions
+ `TermUI.Backend.SSH.Channel` is an `:ssh_server_channel` callback for OTP
+ SSH. Configure it as the daemon's `:ssh_cli` value. The host application
+ still supplies all authentication, key, and session-limit options.
- Each SSH connection gets its own Backend.SSH instance with its own device.
- There is no global state — multiple concurrent sessions work independently.
-
- ## See Also
+ :ssh.daemon(port,
+ system_dir: system_dir,
+ pwdfun: password_fun,
+ ssh_cli: {TermUI.Backend.SSH.Channel, [MyApp, runtime_options: []]}
+ )
- - `TermUI.Backend` — Behaviour definition
- - `TermUI.Backend.Raw` — Local terminal backend (raw mode)
- - `TermUI.Backend.TTY` — Local terminal backend (cooked mode)
+ Output is bounded to one in-flight frame and one waiting frame. A new frame
+ replaces a stale waiting frame. The next diff is always calculated from the
+ last frame that the SSH transport confirmed, so coalescing cannot corrupt
+ the remote screen.
"""
@behaviour TermUI.Backend
- alias TermUI.ANSI
-
- # ANSI escape sequence constants
- @cursor_hide "\e[?25l"
- @cursor_show "\e[?25h"
- @clear_screen "\e[2J"
- @cursor_home "\e[H"
- @alt_screen_enter "\e[?1049h"
- @alt_screen_leave "\e[?1049l"
- @reset_attrs "\e[0m"
-
- # Mouse tracking sequences
- @mouse_sgr_on "\e[?1006h"
- @mouse_normal_on "\e[?1000h"
- @mouse_button_on "\e[?1002h"
- @mouse_any_on "\e[?1003h"
- @all_mouse_off "\e[?1006l\e[?1003l\e[?1002l\e[?1000l"
-
- @typedoc """
- Mouse tracking mode.
-
- - `:none` — No mouse tracking
- - `:click` — Button press/release only (mode 1000)
- - `:drag` — Press/release + motion while pressed (mode 1002)
- - `:all` — All mouse movement (mode 1003)
- """
- @type mouse_mode :: :none | :click | :drag | :all
-
- @typedoc """
- Current SGR style state for delta optimization.
- """
- @type style_state :: %{
- fg: TermUI.Backend.color(),
- bg: TermUI.Backend.color(),
- attrs: [atom()]
- }
-
- @typedoc """
- Internal state for the SSH backend.
+ alias TermUI.Frame
- ## Fields
+ @typedoc "Remote mouse tracking mode."
+ @type mouse_mode :: :none | :click | :drag | :all
- - `:device` — SSH channel IO device PID
- - `:size` — Terminal dimensions as `{rows, cols}`
- - `:cursor_visible` — Whether cursor is currently visible
- - `:cursor_position` — Current cursor position as `{row, col}` or `nil`
- - `:alternate_screen` — Whether alternate screen buffer is active
- - `:mouse_mode` — Current mouse tracking mode
- - `:current_style` — Current SGR state for style delta tracking
- """
@type t :: %__MODULE__{
- device: IO.device(),
- size: {pos_integer(), pos_integer()},
- cursor_visible: boolean(),
- cursor_position: {pos_integer(), pos_integer()} | nil,
- alternate_screen: boolean(),
- mouse_mode: mouse_mode(),
- current_style: style_state() | nil
+ session: pid(),
+ size: TermUI.Backend.size(),
+ capabilities: map()
}
- defstruct device: nil,
- size: {24, 80},
- cursor_visible: false,
- cursor_position: nil,
- alternate_screen: false,
- mouse_mode: :none,
- current_style: nil
-
- # ===========================================================================
- # Lifecycle Callbacks
- # ===========================================================================
-
- @impl true
- @doc """
- Initializes the SSH backend with the given device and terminal size.
+ @enforce_keys [:session, :size, :capabilities]
+ defstruct [:session, :size, :capabilities]
- ## Options
-
- - `:device` (required) — SSH channel IO device (from `Process.group_leader()` in SSH shell)
- - `:size` — Terminal dimensions as `{rows, cols}` from PTY negotiation (default: `{24, 80}`)
- - `:alternate_screen` — Use alternate screen buffer (default: `true`)
- - `:hide_cursor` — Hide cursor during rendering (default: `true`)
- - `:mouse_tracking` — Mouse tracking mode (default: `:none`)
- """
- @spec init(keyword()) :: {:ok, t()} | {:error, term()}
- def init(opts) do
- device = Keyword.fetch!(opts, :device)
- size = Keyword.get(opts, :size, {24, 80})
- alternate_screen = Keyword.get(opts, :alternate_screen, true)
- hide_cursor = Keyword.get(opts, :hide_cursor, true)
- mouse_tracking = Keyword.get(opts, :mouse_tracking, :none)
-
- state = %__MODULE__{
- device: device,
- size: size,
- cursor_visible: not hide_cursor
- }
-
- # Enter alternate screen buffer
- state =
- if alternate_screen do
- device_write(device, @alt_screen_enter)
- %{state | alternate_screen: true}
- else
- state
- end
-
- # Hide cursor
- if hide_cursor do
- device_write(device, @cursor_hide)
- end
-
- # Enable mouse tracking
- state = enable_mouse(state, mouse_tracking)
-
- # Clear screen
- device_write(device, @clear_screen <> @cursor_home)
-
- {:ok, state}
+ @doc "Starts one SSH session and one isolated TermUI runtime."
+ @spec start_session(module(), keyword()) :: GenServer.on_start()
+ def start_session(root, opts \\ []) when is_atom(root) and is_list(opts) do
+ GenServer.start(session_module(), {root, Keyword.put_new(opts, :owner, self())})
end
- @impl true
- @doc """
- Shuts down the SSH backend and restores terminal state.
+ @doc "Sends remote terminal bytes to a session."
+ @spec input(GenServer.server(), iodata()) :: :ok | {:error, term()}
+ def input(session, data), do: GenServer.call(session, {:input, data})
- Writes cleanup sequences to the SSH device. Silently handles errors
- since the SSH channel may already be closed on disconnect.
- """
- @spec shutdown(t()) :: :ok
- def shutdown(%__MODULE__{device: device} = state) do
- # Disable mouse tracking
- device_write(device, @all_mouse_off)
-
- # Reset attributes
- device_write(device, @reset_attrs)
+ @doc "Sends one normalized terminal event to a session."
+ @spec send_event(GenServer.server(), TermUI.Event.t()) :: :ok | {:error, term()}
+ def send_event(session, event), do: GenServer.call(session, {:event, event})
- # Show cursor
- device_write(device, @cursor_show)
-
- # Leave alternate screen
- if state.alternate_screen do
- device_write(device, @alt_screen_leave)
- end
+ @doc "Updates a remote PTY size in rows and columns."
+ @spec resize(GenServer.server(), pos_integer(), pos_integer()) :: :ok | {:error, term()}
+ def resize(session, rows, columns), do: GenServer.call(session, {:resize, rows, columns})
+ @doc "Confirms a process-target output request."
+ @spec ack_output(GenServer.server(), reference(), :ok | {:error, term()}) :: :ok
+ def ack_output(session, token, result) do
+ GenServer.cast(session, {:output_result, token, result})
:ok
end
- # ===========================================================================
- # Query Callbacks
- # ===========================================================================
-
- @impl true
- @doc """
- Returns the cached terminal dimensions.
-
- SSH terminal size is provided at init from PTY negotiation and updated
- externally via `update_size/3` when window_change events arrive.
- """
- @spec size(t()) :: {:ok, {pos_integer(), pos_integer()}}
- def size(%__MODULE__{size: size}) do
- {:ok, size}
+ @doc "Requests a final render and a clean session stop."
+ @spec stop_session(GenServer.server(), term()) :: :ok
+ def stop_session(session, reason \\ :normal) do
+ GenServer.call(session, {:stop, reason})
+ catch
+ :exit, _reason -> :ok
end
- @doc """
- Updates the cached terminal size.
+ @doc "Reports runtime, size, and bounded queue state for a session."
+ @spec session_info(GenServer.server()) :: map()
+ def session_info(session), do: GenServer.call(session, :info)
- Called when an SSH `window_change` event arrives with new dimensions.
- Returns the updated state.
- """
- @spec update_size(t(), pos_integer(), pos_integer()) :: {:ok, t()}
- def update_size(%__MODULE__{} = state, rows, cols)
- when is_integer(rows) and rows > 0 and is_integer(cols) and cols > 0 do
- {:ok, %{state | size: {rows, cols}}}
+ @doc "Reports that the remote channel disconnected."
+ @spec disconnect(GenServer.server(), term()) :: :ok
+ def disconnect(session, reason \\ :disconnected) do
+ GenServer.cast(session, {:disconnect, reason})
+ :ok
end
- # ===========================================================================
- # Cursor Callbacks
- # ===========================================================================
-
@impl true
- @spec move_cursor(t(), {pos_integer(), pos_integer()}) :: {:ok, t()}
- def move_cursor(%__MODULE__{device: device, size: {max_rows, max_cols}} = state, {row, col}) do
- clamped_row = max(1, min(row, max_rows))
- clamped_col = max(1, min(col, max_cols))
- device_write(device, "\e[#{clamped_row};#{clamped_col}H")
- {:ok, %{state | cursor_position: {clamped_row, clamped_col}}}
+ @doc false
+ def init(opts) do
+ with {:ok, session} <- fetch_session(opts),
+ {:ok, size} <- valid_size(Keyword.get(opts, :size, {24, 80})),
+ true <- Process.alive?(session) do
+ capabilities = Keyword.get(opts, :capabilities, default_capabilities(size, opts))
+ {:ok, %__MODULE__{session: session, size: size, capabilities: capabilities}}
+ else
+ false -> {:error, :session_not_alive}
+ {:error, reason} -> {:error, reason}
+ end
end
@impl true
- @spec hide_cursor(t()) :: {:ok, t()}
- def hide_cursor(%__MODULE__{cursor_visible: false} = state), do: {:ok, state}
-
- def hide_cursor(%__MODULE__{device: device} = state) do
- device_write(device, @cursor_hide)
- {:ok, %{state | cursor_visible: false}}
- end
+ @doc false
+ def size(%__MODULE__{size: size}), do: {:ok, size}
@impl true
- @spec show_cursor(t()) :: {:ok, t()}
- def show_cursor(%__MODULE__{cursor_visible: true} = state), do: {:ok, state}
-
- def show_cursor(%__MODULE__{device: device} = state) do
- device_write(device, @cursor_show)
- {:ok, %{state | cursor_visible: true}}
- end
-
- # ===========================================================================
- # Rendering Callbacks
- # ===========================================================================
+ @doc false
+ def capabilities(%__MODULE__{capabilities: capabilities}), do: capabilities
@impl true
- @spec clear(t()) :: {:ok, t()}
- def clear(%__MODULE__{device: device} = state) do
- device_write(device, @clear_screen <> @cursor_home)
- {:ok, %{state | cursor_position: {1, 1}, current_style: nil}}
+ @doc false
+ def draw(%__MODULE__{} = state, %Frame{} = frame) do
+ case GenServer.call(state.session, {:frame, frame}) do
+ :ok -> {:ok, state}
+ {:error, reason} -> {:error, reason}
+ end
end
@impl true
- @doc """
- Draws cells to the SSH terminal at specified positions.
-
- Uses style delta optimization — only emits SGR escape sequences when
- the style changes from the previous cell. Cells should be sorted by
- position (row-major) for efficient cursor movement.
- """
- @spec draw_cells(t(), [{TermUI.Backend.position(), TermUI.Backend.cell()}]) :: {:ok, t()}
- def draw_cells(%__MODULE__{} = state, []), do: {:ok, state}
-
- def draw_cells(%__MODULE__{device: device} = state, cells) when is_list(cells) do
- # Sort cells by position for sequential rendering
- sorted = Enum.sort_by(cells, fn {{row, col}, _cell} -> {row, col} end)
-
- # Render with style delta tracking
- {iodata, new_style, last_pos} =
- Enum.reduce(sorted, {[], state.current_style, state.cursor_position}, fn
- {{row, col}, {char, fg, bg, attrs}}, {acc, prev_style, prev_pos} ->
- # Cursor movement — skip if already at the right position
- move_seq = cursor_move_sequence(prev_pos, {row, col})
-
- # Style delta — only emit changes
- {style_seq, new_style} = style_delta_sequence(prev_style, fg, bg, attrs)
-
- # Sanitize character
- safe_char = sanitize_char(char)
-
- new_acc = [acc, move_seq, style_seq, safe_char]
- {new_acc, new_style, {row, col + String.length(safe_char)}}
- end)
-
- # Flush all accumulated output in a single write
- device_write(device, iodata)
-
- {:ok, %{state | current_style: new_style, cursor_position: last_pos}}
- end
+ @doc false
+ def flush(%__MODULE__{} = state), do: {:ok, state}
@impl true
- @spec flush(t()) :: {:ok, t()}
- def flush(%__MODULE__{} = state) do
- # Output is written immediately in draw_cells — nothing to flush
- {:ok, state}
+ @doc false
+ def poll_event(%__MODULE__{} = state, timeout) do
+ case GenServer.call(state.session, {:poll_event, timeout}, timeout + 1_000) do
+ {:ok, event} -> {:ok, event, state}
+ :timeout -> {:timeout, state}
+ {:error, reason} -> {:error, reason, state}
+ end
+ catch
+ :exit, reason -> {:error, {:session_exit, reason}, state}
end
- # ===========================================================================
- # Input Callback
- # ===========================================================================
-
@impl true
- @doc """
- Returns timeout — SSH input is delivered externally.
-
- The host process reads from the SSH device and sends parsed events
- to the Runtime via `send(runtime, {:ssh_input, event})`.
- """
- @spec poll_event(t(), non_neg_integer()) :: {:timeout, t()}
- def poll_event(%__MODULE__{} = state, _timeout) do
- {:timeout, state}
- end
-
- # ===========================================================================
- # Private — Device IO
- # ===========================================================================
-
- @spec device_write(IO.device(), iodata()) :: :ok
- defp device_write(device, data) do
- IO.write(device, data)
- rescue
- _ -> :ok
- end
-
- # ===========================================================================
- # Private — Mouse Tracking
- # ===========================================================================
-
- @spec enable_mouse(t(), mouse_mode()) :: t()
- defp enable_mouse(state, :none), do: %{state | mouse_mode: :none}
-
- defp enable_mouse(%__MODULE__{device: device} = state, mode) do
- seq =
- case mode do
- :click -> @mouse_normal_on <> @mouse_sgr_on
- :drag -> @mouse_button_on <> @mouse_sgr_on
- :all -> @mouse_any_on <> @mouse_sgr_on
- end
-
- device_write(device, seq)
- %{state | mouse_mode: mode}
- end
-
- # ===========================================================================
- # Private — Cursor Movement
- # ===========================================================================
-
- # Generate minimal cursor movement sequence
- @spec cursor_move_sequence(
- {pos_integer(), pos_integer()} | nil,
- {pos_integer(), pos_integer()}
- ) :: iodata()
- defp cursor_move_sequence(nil, {row, col}) do
- "\e[#{row};#{col}H"
- end
-
- defp cursor_move_sequence({cur_row, cur_col}, {row, col}) do
- cond do
- cur_row == row and cur_col == col ->
- []
-
- cur_row == row and col == cur_col + 1 ->
- # Next column — cursor advances naturally after char write
- []
-
- cur_row == row ->
- # Same row, different column
- "\e[#{row};#{col}H"
-
- true ->
- # Different row
- "\e[#{row};#{col}H"
+ @doc false
+ def resize(%__MODULE__{} = state, size) do
+ case valid_size(size) do
+ {:ok, size} -> {:ok, %{state | size: size}}
+ {:error, reason} -> {:error, reason}
end
end
- # ===========================================================================
- # Private — Style Delta
- # ===========================================================================
-
- # Compute minimal SGR sequence for style change
- @spec style_delta_sequence(
- style_state() | nil,
- TermUI.Backend.color(),
- TermUI.Backend.color(),
- [atom()]
- ) ::
- {iodata(), style_state()}
- defp style_delta_sequence(nil, fg, bg, attrs) do
- # No previous style — emit full style
- new_style = %{fg: fg, bg: bg, attrs: attrs}
- seq = build_full_style(fg, bg, attrs)
- {seq, new_style}
- end
-
- defp style_delta_sequence(%{fg: fg, bg: bg, attrs: attrs} = prev, fg, bg, attrs) do
- # Same style — no sequence needed
- {[], prev}
- end
-
- defp style_delta_sequence(prev, fg, bg, attrs) do
- new_style = %{fg: fg, bg: bg, attrs: attrs}
-
- # Check if attributes changed (requires full reset)
- if prev.attrs != attrs do
- seq = build_full_style(fg, bg, attrs)
- {seq, new_style}
- else
- # Only colors changed — emit delta
- parts = []
- parts = if prev.fg != fg, do: [parts | fg_sequence(fg)], else: parts
- parts = if prev.bg != bg, do: [parts | bg_sequence(bg)], else: parts
- {parts, new_style}
+ @impl true
+ @doc false
+ def shutdown(%__MODULE__{} = state, reason) do
+ GenServer.call(state.session, {:backend_shutdown, reason})
+ catch
+ :exit, _reason -> :ok
+ end
+
+ defp fetch_session(opts) do
+ case Keyword.fetch(opts, :session) do
+ {:ok, session} when is_pid(session) -> {:ok, session}
+ :error -> {:error, {:missing_option, :session}}
+ {:ok, invalid} -> {:error, {:invalid_option, :session, invalid}}
end
end
- @spec build_full_style(TermUI.Backend.color(), TermUI.Backend.color(), [atom()]) :: iodata()
- defp build_full_style(fg, bg, attrs) do
- parts = [@reset_attrs]
- parts = parts ++ attr_sequences(attrs)
- parts = parts ++ [fg_sequence(fg)]
- parts = parts ++ [bg_sequence(bg)]
- parts
- end
-
- @spec fg_sequence(TermUI.Backend.color()) :: iodata()
- defp fg_sequence(:default), do: "\e[39m"
+ defp valid_size({rows, columns} = size)
+ when is_integer(rows) and rows > 0 and is_integer(columns) and columns > 0,
+ do: {:ok, size}
- defp fg_sequence({r, g, b}) when is_integer(r) and is_integer(g) and is_integer(b) do
- "\e[38;2;#{r};#{g};#{b}m"
- end
+ defp valid_size(invalid), do: {:error, {:invalid_size, invalid}}
- defp fg_sequence(index) when is_integer(index) and index in 0..255 do
- "\e[38;5;#{index}m"
- end
-
- defp fg_sequence(name) when is_atom(name) do
- ANSI.foreground(name)
- end
-
- @spec bg_sequence(TermUI.Backend.color()) :: iodata()
- defp bg_sequence(:default), do: "\e[49m"
-
- defp bg_sequence({r, g, b}) when is_integer(r) and is_integer(g) and is_integer(b) do
- "\e[48;2;#{r};#{g};#{b}m"
- end
-
- defp bg_sequence(index) when is_integer(index) and index in 0..255 do
- "\e[48;5;#{index}m"
- end
-
- defp bg_sequence(name) when is_atom(name) do
- ANSI.background(name)
- end
-
- @spec attr_sequences([atom()]) :: [iodata()]
- defp attr_sequences(attrs) do
- Enum.map(attrs, fn
- :bold -> "\e[1m"
- :dim -> "\e[2m"
- :italic -> "\e[3m"
- :underline -> "\e[4m"
- :blink -> "\e[5m"
- :reverse -> "\e[7m"
- :hidden -> "\e[8m"
- :strikethrough -> "\e[9m"
- _ -> []
- end)
+ defp default_capabilities(size, opts) do
+ %{
+ colors: :true_color,
+ unicode: true,
+ mouse: Keyword.get(opts, :mouse_tracking, :none) != :none,
+ paste: Keyword.get(opts, :bracketed_paste, true),
+ focus: Keyword.get(opts, :focus_events, true),
+ dimensions: size,
+ remote: :ssh
+ }
end
- # ===========================================================================
- # Private — Character Sanitization
- # ===========================================================================
-
- @spec sanitize_char(String.t()) :: String.t()
- defp sanitize_char(""), do: " "
- defp sanitize_char(char), do: char
+ defp session_module, do: Module.concat(__MODULE__, "Session")
end
diff --git a/lib/term_ui/backend/ssh/channel.ex b/lib/term_ui/backend/ssh/channel.ex
new file mode 100644
index 00000000..1f74f78e
--- /dev/null
+++ b/lib/term_ui/backend/ssh/channel.ex
@@ -0,0 +1,229 @@
+defmodule TermUI.Backend.SSH.Channel do
+ @moduledoc """
+ An OTP `:ssh_server_channel` callback for TermUI sessions.
+
+ Use this module as an SSH daemon `:ssh_cli` callback. The daemon owner must
+ configure host keys, authentication, connection limits, and all network
+ policy.
+
+ :ssh.daemon(port,
+ system_dir: system_dir,
+ pwdfun: password_fun,
+ ssh_cli: {TermUI.Backend.SSH.Channel, [MyApp, runtime_options: []]}
+ )
+
+ One callback process starts one isolated `TermUI.Backend.SSH` session when
+ the client requests a shell. PTY input and window changes stay scoped to
+ that channel.
+ """
+
+ @behaviour :ssh_server_channel
+
+ alias TermUI.Backend.SSH
+
+ @default_size {24, 80}
+ @default_send_timeout 5_000
+ @failure_status 1
+
+ @impl true
+ def init([root, opts]) when is_atom(root) and is_list(opts) do
+ {:ok,
+ %{
+ root: root,
+ options: opts,
+ connection: nil,
+ channel: nil,
+ session: nil,
+ size: @default_size,
+ terminal: nil,
+ send_timeout: Keyword.get(opts, :send_timeout, @default_send_timeout)
+ }}
+ end
+
+ def init([root]) when is_atom(root), do: init([root, []])
+ def init(args), do: {:stop, {:invalid_ssh_channel_options, args}}
+
+ @impl true
+ def handle_msg({:ssh_channel_up, channel, connection}, state) do
+ {:ok, %{state | channel: channel, connection: connection}}
+ end
+
+ def handle_msg(
+ {:term_ui_ssh_output, session, token, data},
+ %{session: session, connection: connection, channel: channel} = state
+ ) do
+ result = :ssh_connection.send(connection, channel, 0, data, state.send_timeout)
+ :ok = SSH.ack_output(session, token, result)
+
+ case result do
+ :ok ->
+ {:ok, state}
+
+ {:error, reason} ->
+ SSH.disconnect(session, {:ssh_send_failed, reason})
+ {:stop, channel, state}
+ end
+ end
+
+ def handle_msg(
+ {:term_ui_ssh_closed, session, reason},
+ %{session: session, connection: connection, channel: channel} = state
+ ) do
+ _status = :ssh_connection.exit_status(connection, channel, exit_status(reason))
+ _eof = :ssh_connection.send_eof(connection, channel)
+ {:stop, channel, state}
+ end
+
+ def handle_msg({:EXIT, session, reason}, %{session: session, channel: channel} = state)
+ when not is_nil(session) do
+ {:stop, channel, %{state | session: nil, options: Keyword.put(state.options, :exit, reason)}}
+ end
+
+ def handle_msg(_message, state), do: {:ok, state}
+
+ @impl true
+ def handle_ssh_msg(
+ {:ssh_cm, connection, {:pty, channel, want_reply, pty}},
+ state
+ ) do
+ {_terminal, width, height, _pixel_width, _pixel_height, _modes} = pty
+ size = {nonzero(height, 24), nonzero(width, 80)}
+ terminal = elem(pty, 0)
+ _reply = :ssh_connection.reply_request(connection, want_reply, :success, channel)
+ {:ok, %{state | connection: connection, channel: channel, size: size, terminal: terminal}}
+ end
+
+ def handle_ssh_msg(
+ {:ssh_cm, connection, {:shell, channel, want_reply}},
+ %{session: nil} = state
+ ) do
+ session_opts =
+ state.options
+ |> Keyword.put(:output, self())
+ |> Keyword.put(:owner, self())
+ |> Keyword.put(:size, state.size)
+ |> Keyword.put(:capabilities, channel_capabilities(state))
+
+ case SSH.start_session(state.root, session_opts) do
+ {:ok, session} ->
+ _reply = :ssh_connection.reply_request(connection, want_reply, :success, channel)
+ {:ok, %{state | connection: connection, channel: channel, session: session}}
+
+ {:error, reason} ->
+ _reply = :ssh_connection.reply_request(connection, want_reply, :failure, channel)
+
+ _output =
+ :ssh_connection.send(connection, channel, 1, inspect(reason), state.send_timeout)
+
+ _status = :ssh_connection.exit_status(connection, channel, @failure_status)
+ _eof = :ssh_connection.send_eof(connection, channel)
+ {:stop, channel, state}
+ end
+ end
+
+ def handle_ssh_msg(
+ {:ssh_cm, connection, {:shell, channel, want_reply}},
+ state
+ ) do
+ _reply = :ssh_connection.reply_request(connection, want_reply, :failure, channel)
+ {:ok, state}
+ end
+
+ def handle_ssh_msg(
+ {:ssh_cm, _connection, {:data, channel, 0, data}},
+ %{session: session, channel: channel} = state
+ )
+ when is_pid(session) do
+ case SSH.input(session, data) do
+ :ok -> {:ok, state}
+ {:error, _reason} -> {:stop, channel, state}
+ end
+ end
+
+ def handle_ssh_msg(
+ {:ssh_cm, _connection,
+ {:window_change, channel, width, height, _pixel_width, _pixel_height}},
+ %{session: session, channel: channel} = state
+ )
+ when is_pid(session) do
+ size = {nonzero(height, elem(state.size, 0)), nonzero(width, elem(state.size, 1))}
+
+ case SSH.resize(session, elem(size, 0), elem(size, 1)) do
+ :ok -> {:ok, %{state | size: size}}
+ {:error, _reason} -> {:ok, state}
+ end
+ end
+
+ def handle_ssh_msg(
+ {:ssh_cm, _connection, {:eof, channel}},
+ %{session: session, channel: channel} = state
+ ) do
+ if is_pid(session), do: SSH.disconnect(session, :eof)
+ {:stop, channel, state}
+ end
+
+ def handle_ssh_msg(
+ {:ssh_cm, connection, {:exec, channel, want_reply, _command}},
+ state
+ ) do
+ _reply = :ssh_connection.reply_request(connection, want_reply, :failure, channel)
+ _status = :ssh_connection.exit_status(connection, channel, @failure_status)
+ _eof = :ssh_connection.send_eof(connection, channel)
+ {:stop, channel, state}
+ end
+
+ def handle_ssh_msg(
+ {:ssh_cm, connection, {:env, channel, want_reply, _name, _value}},
+ state
+ ) do
+ _reply = :ssh_connection.reply_request(connection, want_reply, :failure, channel)
+ {:ok, state}
+ end
+
+ def handle_ssh_msg({:ssh_cm, _connection, {:signal, _channel, _signal}}, state),
+ do: {:ok, state}
+
+ def handle_ssh_msg({:ssh_cm, _connection, {:data, _channel, _type, _data}}, state),
+ do: {:ok, state}
+
+ def handle_ssh_msg(_message, state), do: {:ok, state}
+
+ @impl true
+ def terminate(reason, state) do
+ if is_pid(state.session), do: SSH.disconnect(state.session, {:channel_terminated, reason})
+ :ok
+ end
+
+ defp channel_capabilities(state) do
+ configured = Keyword.get(state.options, :capabilities, %{})
+
+ defaults =
+ if dumb_terminal?(state.terminal) do
+ %{colors: :monochrome, unicode: false, mouse: false}
+ else
+ %{
+ colors: :true_color,
+ unicode: true,
+ mouse: Keyword.get(state.options, :mouse_tracking, :none) != :none
+ }
+ end
+
+ defaults
+ |> Map.merge(configured)
+ |> Map.put(:paste, Keyword.get(state.options, :bracketed_paste, true))
+ |> Map.put(:focus, Keyword.get(state.options, :focus_events, true))
+ |> Map.put(:dimensions, state.size)
+ |> Map.put(:remote, :ssh)
+ end
+
+ defp dumb_terminal?(terminal) when is_binary(terminal), do: terminal == "dumb"
+ defp dumb_terminal?(terminal) when is_list(terminal), do: terminal == ~c"dumb"
+ defp dumb_terminal?(_terminal), do: false
+
+ defp nonzero(value, _fallback) when is_integer(value) and value > 0, do: value
+ defp nonzero(_value, fallback), do: fallback
+
+ defp exit_status(reason) when reason in [:normal, :shutdown], do: 0
+ defp exit_status({:shutdown, _reason}), do: 0
+ defp exit_status(_reason), do: @failure_status
+end
diff --git a/lib/term_ui/backend/ssh/renderer.ex b/lib/term_ui/backend/ssh/renderer.ex
new file mode 100644
index 00000000..4bbb6b94
--- /dev/null
+++ b/lib/term_ui/backend/ssh/renderer.ex
@@ -0,0 +1,93 @@
+defmodule TermUI.Backend.SSH.Renderer do
+ @moduledoc false
+
+ alias TermUI.{ANSI, Frame}
+ alias TermUI.Backend.Renderer, as: CellRenderer
+
+ @doc false
+ @spec setup_sequence(keyword(), map()) :: binary()
+ def setup_sequence(opts, capabilities) do
+ mouse_mode = supported_mouse_mode(opts, capabilities)
+
+ [
+ if(Keyword.get(opts, :alternate_screen, true), do: ANSI.enter_alternate_screen(), else: []),
+ if(Keyword.get(opts, :hide_cursor, true), do: ANSI.cursor_hide(), else: []),
+ mouse_setup(mouse_mode),
+ if(Keyword.get(opts, :bracketed_paste, true), do: ANSI.enable_bracketed_paste(), else: []),
+ if(Keyword.get(opts, :focus_events, true), do: ANSI.enable_focus_events(), else: []),
+ ANSI.clear_screen(),
+ ANSI.cursor_position(1, 1)
+ ]
+ |> IO.iodata_to_binary()
+ end
+
+ @doc false
+ @spec cleanup_sequence(keyword(), map()) :: binary()
+ def cleanup_sequence(opts, capabilities) do
+ mouse_mode = supported_mouse_mode(opts, capabilities)
+
+ [
+ mouse_cleanup(mouse_mode),
+ if(Keyword.get(opts, :bracketed_paste, true), do: ANSI.disable_bracketed_paste(), else: []),
+ if(Keyword.get(opts, :focus_events, true), do: ANSI.disable_focus_events(), else: []),
+ ANSI.cursor_show(),
+ ANSI.reset(),
+ if(Keyword.get(opts, :alternate_screen, true), do: ANSI.leave_alternate_screen(), else: [])
+ ]
+ |> IO.iodata_to_binary()
+ end
+
+ @doc false
+ @spec frame_sequence(Frame.t() | nil, Frame.t(), map()) :: binary()
+ def frame_sequence(previous, %Frame{} = current, capabilities) do
+ full? = is_nil(previous) or dimensions_changed?(previous, current)
+ changes = if full?, do: Frame.cells(current), else: Frame.diff(previous, current)
+
+ [
+ ANSI.cursor_hide(),
+ if(full?, do: [ANSI.clear_screen(), ANSI.cursor_position(1, 1)], else: []),
+ CellRenderer.render(changes, color_mode(capabilities), character_set(capabilities)),
+ cursor_sequence(current.cursor)
+ ]
+ |> IO.iodata_to_binary()
+ end
+
+ defp supported_mouse_mode(opts, capabilities) do
+ requested = Keyword.get(opts, :mouse_tracking, :none)
+ if Map.get(capabilities, :mouse, true), do: requested, else: :none
+ end
+
+ defp mouse_setup(:none), do: []
+ defp mouse_setup(:click), do: [ANSI.enable_mouse_tracking(:normal), ANSI.enable_sgr_mouse()]
+ defp mouse_setup(:drag), do: [ANSI.enable_mouse_tracking(:button), ANSI.enable_sgr_mouse()]
+ defp mouse_setup(:all), do: [ANSI.enable_mouse_tracking(:all), ANSI.enable_sgr_mouse()]
+
+ defp mouse_cleanup(:none), do: []
+ defp mouse_cleanup(_mode), do: "\e[?1006l\e[?1003l\e[?1002l\e[?1000l"
+
+ defp cursor_sequence(nil), do: ANSI.cursor_hide()
+ defp cursor_sequence({column, row}), do: [ANSI.cursor_position(row, column), ANSI.cursor_show()]
+
+ defp dimensions_changed?(previous, current) do
+ previous.width != current.width or previous.height != current.height
+ end
+
+ defp character_set(capabilities) do
+ if Map.get(capabilities, :unicode, true), do: :unicode, else: :ascii
+ end
+
+ defp color_mode(capabilities) do
+ capabilities
+ |> Map.get(:colors, :true_color)
+ |> normalize_color_mode()
+ end
+
+ defp normalize_color_mode(:true_color), do: :true_color
+ defp normalize_color_mode(:color_256), do: :color_256
+ defp normalize_color_mode(:color_16), do: :color_16
+ defp normalize_color_mode(:monochrome), do: :monochrome
+ defp normalize_color_mode(count) when is_integer(count) and count >= 16_777_216, do: :true_color
+ defp normalize_color_mode(count) when is_integer(count) and count >= 256, do: :color_256
+ defp normalize_color_mode(count) when is_integer(count) and count >= 16, do: :color_16
+ defp normalize_color_mode(_other), do: :monochrome
+end
diff --git a/lib/term_ui/backend/ssh/session.ex b/lib/term_ui/backend/ssh/session.ex
new file mode 100644
index 00000000..f382c125
--- /dev/null
+++ b/lib/term_ui/backend/ssh/session.ex
@@ -0,0 +1,615 @@
+defmodule TermUI.Backend.SSH.Session do
+ @moduledoc false
+
+ use GenServer
+
+ alias TermUI.Backend.{CapabilityFilter, InputBuffer}
+ alias TermUI.Backend.SSH
+ alias TermUI.Backend.SSH.Renderer
+ alias TermUI.Event
+ alias TermUI.Frame
+ alias TermUI.Runtime
+ alias TermUI.Terminal.EscapeParser
+
+ @escape_timeout 50
+ @default_output_timeout 5_000
+ @maximum_events 1_024
+
+ @type output_target :: pid() | (binary() -> :ok | {:error, term()})
+
+ @impl true
+ def init({root, opts}) do
+ Process.flag(:trap_exit, true)
+
+ with {:ok, output} <- fetch_output(opts),
+ {:ok, owner} <- fetch_owner(opts),
+ {:ok, size} <- valid_size(Keyword.get(opts, :size, {24, 80})),
+ {:ok, output_timeout} <- output_timeout(opts) do
+ capabilities = capabilities(size, opts)
+ owner_monitor = Process.monitor(owner)
+
+ case start_runtime(root, opts, size, capabilities) do
+ {:ok, runtime} ->
+ state = %{
+ owner: owner,
+ owner_monitor: owner_monitor,
+ runtime: runtime,
+ output: output,
+ output_timeout: output_timeout,
+ options: opts,
+ capabilities: capabilities,
+ size: size,
+ status: :running,
+ stop_reason: :normal,
+ runtime_stopped?: false,
+ notified?: false,
+ connected?: true,
+ input_buffer: "",
+ paste_state: nil,
+ events: [],
+ poll_waiter: nil,
+ escape_timer: nil,
+ in_flight: nil,
+ pending_frame: nil,
+ cleanup: nil,
+ last_frame: nil
+ }
+
+ {:ok, start_output(state, :setup, Renderer.setup_sequence(opts, capabilities), nil)}
+
+ {:error, reason} ->
+ Process.demonitor(owner_monitor, [:flush])
+ {:stop, reason}
+ end
+ end
+ end
+
+ @impl true
+ def handle_call({:input, _data}, _from, %{connected?: false} = state) do
+ {:reply, {:error, :disconnected}, state}
+ end
+
+ def handle_call({:input, data}, _from, state) do
+ case to_binary(data) do
+ {:ok, binary} ->
+ state = parse_input(state, binary)
+ {:reply, :ok, deliver_waiter(state)}
+
+ {:error, reason} ->
+ {:reply, {:error, reason}, state}
+ end
+ end
+
+ def handle_call({:event, event}, _from, state) do
+ if valid_event?(event) do
+ state = state |> enqueue_events([event]) |> deliver_waiter()
+ {:reply, :ok, state}
+ else
+ {:reply, {:error, {:invalid_event, event}}, state}
+ end
+ end
+
+ def handle_call({:resize, rows, columns}, _from, state) do
+ case valid_size({rows, columns}) do
+ {:ok, size} ->
+ event = Event.resize(columns, rows)
+
+ state =
+ state
+ |> Map.put(:size, size)
+ |> Map.update!(:capabilities, &Map.put(&1, :dimensions, size))
+ |> enqueue_events([event])
+ |> deliver_waiter()
+
+ {:reply, :ok, state}
+
+ {:error, reason} ->
+ {:reply, {:error, reason}, state}
+ end
+ end
+
+ def handle_call({:frame, _frame}, _from, %{connected?: false} = state) do
+ {:reply, :ok, state}
+ end
+
+ def handle_call({:frame, %Frame{} = frame}, _from, state) do
+ state = queue_frame(state, frame)
+ {:reply, :ok, state}
+ end
+
+ def handle_call({:poll_event, _timeout}, _from, %{events: [event | rest]} = state) do
+ {:reply, {:ok, event}, %{state | events: rest}}
+ end
+
+ def handle_call({:poll_event, _timeout}, _from, %{connected?: false} = state) do
+ {:reply, :timeout, state}
+ end
+
+ def handle_call({:poll_event, 0}, _from, state), do: {:reply, :timeout, state}
+
+ def handle_call({:poll_event, timeout}, from, %{poll_waiter: nil} = state) do
+ token = make_ref()
+ timer = Process.send_after(self(), {:poll_timeout, token}, timeout)
+ {:noreply, %{state | poll_waiter: {from, token, timer}}}
+ end
+
+ def handle_call({:poll_event, _timeout}, _from, state) do
+ {:reply, {:error, :poll_in_progress}, state}
+ end
+
+ def handle_call({:stop, reason}, _from, %{status: :running} = state) do
+ Runtime.shutdown(state.runtime)
+ {:reply, :ok, %{state | status: :stopping, stop_reason: reason}}
+ end
+
+ def handle_call({:stop, _reason}, _from, state), do: {:reply, :ok, state}
+
+ def handle_call({:backend_shutdown, reason}, _from, state) do
+ state =
+ state
+ |> Map.put(:status, :stopping)
+ |> Map.put(:stop_reason, reason)
+ |> queue_cleanup()
+
+ {:reply, :ok, state}
+ end
+
+ def handle_call(:info, _from, state) do
+ info = %{
+ runtime: state.runtime,
+ size: state.size,
+ capabilities: state.capabilities,
+ connected?: state.connected?,
+ status: state.status,
+ queued_events: length(state.events),
+ output_queue: %{
+ capacity: 2,
+ in_flight: if(is_nil(state.in_flight), do: 0, else: 1),
+ pending_frames: if(is_nil(state.pending_frame), do: 0, else: 1)
+ }
+ }
+
+ {:reply, info, state}
+ end
+
+ @impl true
+ def handle_cast({:output_result, token, result}, state) do
+ state |> complete_output(token, normalize_output_result(result)) |> session_reply()
+ end
+
+ def handle_cast({:disconnect, reason}, state) do
+ disconnect_state(state, reason)
+ end
+
+ @impl true
+ def handle_info({:output_result, worker, token, result}, state) do
+ case state.in_flight do
+ %{worker: ^worker, token: ^token} ->
+ state |> complete_output(token, normalize_output_result(result)) |> session_reply()
+
+ _other ->
+ {:noreply, state}
+ end
+ end
+
+ def handle_info({:DOWN, reference, :process, pid, reason}, state) do
+ cond do
+ reference == state.owner_monitor ->
+ disconnect_state(state, {:owner_exit, reason})
+
+ match?(%{worker_monitor: ^reference, worker: ^pid}, state.in_flight) ->
+ case reason do
+ :normal -> {:noreply, state}
+ _other -> {:noreply, fail_output(state, {:writer_exit, reason})}
+ end
+
+ true ->
+ {:noreply, state}
+ end
+ end
+
+ def handle_info({:poll_timeout, token}, %{poll_waiter: {from, token, _timer}} = state) do
+ GenServer.reply(from, :timeout)
+ {:noreply, %{state | poll_waiter: nil}}
+ end
+
+ def handle_info({:poll_timeout, _old_token}, state), do: {:noreply, state}
+
+ def handle_info({:escape_timeout, token}, %{escape_timer: {_timer, token}} = state) do
+ state =
+ case state.input_buffer do
+ "\e" -> state |> Map.put(:input_buffer, "") |> enqueue_events([Event.key(:escape)])
+ "\e" <> _partial -> %{state | input_buffer: ""}
+ _other -> state
+ end
+
+ {:noreply, state |> Map.put(:escape_timer, nil) |> deliver_waiter()}
+ end
+
+ def handle_info({:escape_timeout, _old_token}, state), do: {:noreply, state}
+
+ def handle_info({:output_timeout, token}, state) do
+ case state.in_flight do
+ %{token: ^token} -> state |> fail_output(:output_timeout) |> session_reply()
+ _other -> {:noreply, state}
+ end
+ end
+
+ def handle_info({:EXIT, runtime, reason}, %{runtime: runtime} = state) do
+ state =
+ state
+ |> release_poll_waiter()
+ |> Map.put(:runtime_stopped?, true)
+ |> Map.put(:status, :stopping)
+ |> Map.update!(:stop_reason, fn current ->
+ if current == :normal, do: reason, else: current
+ end)
+ |> ensure_cleanup_after_runtime_exit()
+ |> maybe_finish()
+
+ session_reply(state)
+ end
+
+ def handle_info({:EXIT, owner, reason}, %{owner: owner} = state) do
+ disconnect_state(state, {:owner_exit, reason})
+ end
+
+ def handle_info(_message, state), do: {:noreply, state}
+
+ @impl true
+ def terminate(_reason, state) do
+ cancel_timer(state.escape_timer)
+ cancel_poll_waiter(state.poll_waiter)
+ stop_in_flight(state.in_flight)
+ Process.demonitor(state.owner_monitor, [:flush])
+
+ if Process.alive?(state.runtime) do
+ Process.unlink(state.runtime)
+ Process.exit(state.runtime, :shutdown)
+ end
+
+ :ok
+ end
+
+ defp start_runtime(root, opts, size, capabilities) do
+ runtime_options = Keyword.get(opts, :runtime_options, [])
+ backend_options = [session: self(), size: size, capabilities: capabilities]
+
+ runtime_options =
+ runtime_options
+ |> Keyword.put(:backend, {SSH, backend_options})
+ |> Keyword.update(:backend_opts, [size_poll_interval: :disabled], fn backend_opts ->
+ Keyword.put(backend_opts, :size_poll_interval, :disabled)
+ end)
+
+ TermUI.start_link(root, runtime_options)
+ end
+
+ defp fetch_output(opts) do
+ case Keyword.fetch(opts, :output) do
+ {:ok, output} when is_pid(output) or is_function(output, 1) -> {:ok, output}
+ :error -> {:error, {:missing_option, :output}}
+ {:ok, invalid} -> {:error, {:invalid_option, :output, invalid}}
+ end
+ end
+
+ defp fetch_owner(opts) do
+ case Keyword.fetch(opts, :owner) do
+ {:ok, owner} when is_pid(owner) -> {:ok, owner}
+ :error -> {:error, {:missing_option, :owner}}
+ {:ok, invalid} -> {:error, {:invalid_option, :owner, invalid}}
+ end
+ end
+
+ defp valid_size({rows, columns} = size)
+ when is_integer(rows) and rows > 0 and is_integer(columns) and columns > 0,
+ do: {:ok, size}
+
+ defp valid_size(invalid), do: {:error, {:invalid_size, invalid}}
+
+ defp output_timeout(opts) do
+ case Keyword.get(opts, :output_timeout, @default_output_timeout) do
+ timeout when is_integer(timeout) and timeout > 0 -> {:ok, timeout}
+ invalid -> {:error, {:invalid_output_timeout, invalid}}
+ end
+ end
+
+ defp capabilities(size, opts) do
+ defaults = %{
+ colors: :true_color,
+ unicode: true,
+ mouse: Keyword.get(opts, :mouse_tracking, :none) != :none,
+ paste: Keyword.get(opts, :bracketed_paste, true),
+ focus: Keyword.get(opts, :focus_events, true),
+ dimensions: size,
+ remote: :ssh
+ }
+
+ preferences =
+ opts
+ |> Keyword.get(:runtime_options, [])
+ |> Keyword.get(:backend_opts, [])
+ |> Keyword.merge(Keyword.take(opts, [:color_mode, :character_set]))
+
+ defaults
+ |> Map.merge(Keyword.get(opts, :capabilities, %{}))
+ |> Map.put(:dimensions, size)
+ |> CapabilityFilter.filter(preferences)
+ end
+
+ defp to_binary(data) do
+ {:ok, IO.iodata_to_binary(data)}
+ rescue
+ _exception -> {:error, {:invalid_input, data}}
+ end
+
+ defp parse_input(state, data) do
+ state = cancel_escape_timer(state)
+
+ state =
+ InputBuffer.append_with_limit(state, data, :input_buffer,
+ source: __MODULE__,
+ paste_aware: true
+ )
+
+ {events, remaining} = EscapeParser.parse(state.input_buffer)
+
+ state
+ |> Map.put(:input_buffer, remaining)
+ |> enqueue_events(events)
+ |> schedule_escape_timeout()
+ end
+
+ defp enqueue_events(state, []), do: state
+
+ defp enqueue_events(state, events) do
+ %{state | events: Enum.take(state.events ++ events, -@maximum_events)}
+ end
+
+ defp deliver_waiter(%{poll_waiter: {from, _token, timer}, events: [event | rest]} = state) do
+ _cancelled = Process.cancel_timer(timer)
+ GenServer.reply(from, {:ok, event})
+ %{state | events: rest, poll_waiter: nil}
+ end
+
+ defp deliver_waiter(state), do: state
+
+ defp schedule_escape_timeout(%{paste_state: paste_state} = state)
+ when not is_nil(paste_state),
+ do: state
+
+ defp schedule_escape_timeout(%{input_buffer: "\e" <> _partial} = state) do
+ token = make_ref()
+ timer = Process.send_after(self(), {:escape_timeout, token}, @escape_timeout)
+ %{state | escape_timer: {timer, token}}
+ end
+
+ defp schedule_escape_timeout(state), do: state
+
+ defp cancel_escape_timer(%{escape_timer: nil} = state), do: state
+
+ defp cancel_escape_timer(%{escape_timer: {timer, _token}} = state) do
+ _cancelled = Process.cancel_timer(timer)
+ %{state | escape_timer: nil}
+ end
+
+ defp valid_event?(%Event.Key{}), do: true
+ defp valid_event?(%Event.Text{}), do: true
+ defp valid_event?(%Event.Paste{}), do: true
+ defp valid_event?(%Event.Mouse{}), do: true
+ defp valid_event?(%Event.Resize{}), do: true
+ defp valid_event?(%Event.Focus{}), do: true
+ defp valid_event?(_event), do: false
+
+ defp queue_frame(%{in_flight: nil} = state, frame) do
+ start_frame_output(state, frame)
+ end
+
+ defp queue_frame(state, frame), do: %{state | pending_frame: frame}
+
+ defp queue_cleanup(%{connected?: false} = state), do: state
+ defp queue_cleanup(%{cleanup: cleanup} = state) when not is_nil(cleanup), do: state
+
+ defp queue_cleanup(state) do
+ state = %{state | cleanup: Renderer.cleanup_sequence(state.options, state.capabilities)}
+ if is_nil(state.in_flight), do: dispatch_next(state), else: state
+ end
+
+ defp start_frame_output(state, frame) do
+ data = Renderer.frame_sequence(state.last_frame, frame, state.capabilities)
+ start_output(state, :frame, data, frame)
+ end
+
+ defp start_output(state, kind, data, frame) do
+ token = make_ref()
+ timer = Process.send_after(self(), {:output_timeout, token}, state.output_timeout)
+
+ packet = %{
+ token: token,
+ timer: timer,
+ kind: kind,
+ frame: frame,
+ worker: nil,
+ worker_monitor: nil
+ }
+
+ case state.output do
+ output when is_pid(output) ->
+ send(output, {:term_ui_ssh_output, self(), token, data})
+ %{state | in_flight: packet}
+
+ output when is_function(output, 1) ->
+ parent = self()
+
+ {worker, worker_monitor} =
+ spawn_monitor(fn ->
+ result = invoke_output(output, data)
+ send(parent, {:output_result, self(), token, result})
+ end)
+
+ %{state | in_flight: %{packet | worker: worker, worker_monitor: worker_monitor}}
+ end
+ end
+
+ defp complete_output(%{in_flight: %{token: token} = packet} = state, token, :ok) do
+ state = clear_in_flight(state, packet)
+ state = if packet.kind == :frame, do: %{state | last_frame: packet.frame}, else: state
+ dispatch_next(state)
+ end
+
+ defp complete_output(%{in_flight: %{token: token} = packet} = state, token, {:error, reason}) do
+ state |> clear_in_flight(packet) |> fail_output(reason)
+ end
+
+ defp complete_output(state, _token, _result), do: state
+
+ defp clear_in_flight(state, packet) do
+ _cancelled = Process.cancel_timer(packet.timer)
+
+ if packet.worker_monitor do
+ Process.demonitor(packet.worker_monitor, [:flush])
+ end
+
+ %{state | in_flight: nil}
+ end
+
+ defp dispatch_next(%{pending_frame: %Frame{} = frame} = state) do
+ state |> Map.put(:pending_frame, nil) |> start_frame_output(frame)
+ end
+
+ defp dispatch_next(%{cleanup: cleanup} = state) when is_binary(cleanup) do
+ state |> Map.put(:cleanup, nil) |> start_output(:cleanup, cleanup, nil)
+ end
+
+ defp dispatch_next(state), do: maybe_finish(state)
+
+ defp fail_output(state, reason) do
+ stop_in_flight(state.in_flight)
+
+ state = %{
+ state
+ | connected?: false,
+ status: :stopping,
+ stop_reason: {:output_failed, reason},
+ in_flight: nil,
+ pending_frame: nil,
+ cleanup: nil
+ }
+
+ state = fail_poll_waiter(state, {:output_failed, reason})
+ send_backend_failure(state, {:output_failed, reason})
+ maybe_finish(state)
+ end
+
+ defp disconnect_state(state, reason) do
+ stop_in_flight(state.in_flight)
+
+ state = %{
+ state
+ | connected?: false,
+ status: :stopping,
+ stop_reason: reason,
+ in_flight: nil,
+ pending_frame: nil,
+ cleanup: nil
+ }
+
+ state = release_poll_waiter(state)
+ if not state.runtime_stopped?, do: Runtime.shutdown(state.runtime)
+ state = maybe_finish(state)
+ session_reply(state)
+ end
+
+ defp send_backend_failure(%{runtime_stopped?: false, runtime: runtime}, reason) do
+ send(runtime, {:backend_failed, {:backend, SSH, :input, reason}})
+ :ok
+ end
+
+ defp send_backend_failure(_state, _reason), do: :ok
+
+ defp fail_poll_waiter(%{poll_waiter: {from, _token, timer}} = state, reason) do
+ _cancelled = Process.cancel_timer(timer)
+ GenServer.reply(from, {:error, reason})
+ %{state | poll_waiter: nil}
+ end
+
+ defp fail_poll_waiter(state, _reason), do: state
+
+ defp release_poll_waiter(%{poll_waiter: {from, _token, timer}} = state) do
+ _cancelled = Process.cancel_timer(timer)
+ GenServer.reply(from, :timeout)
+ %{state | poll_waiter: nil}
+ end
+
+ defp release_poll_waiter(state), do: state
+
+ defp ensure_cleanup_after_runtime_exit(%{connected?: true, cleanup: nil} = state),
+ do: queue_cleanup(state)
+
+ defp ensure_cleanup_after_runtime_exit(state), do: state
+
+ defp maybe_finish(
+ %{
+ status: :stopping,
+ runtime_stopped?: true,
+ in_flight: nil,
+ pending_frame: nil,
+ cleanup: nil
+ } = state
+ ) do
+ unless state.notified? do
+ send(state.owner, {:term_ui_ssh_closed, self(), state.stop_reason})
+ end
+
+ %{state | notified?: true}
+ end
+
+ defp maybe_finish(state), do: state
+
+ defp session_reply(%{notified?: true} = state), do: {:stop, :normal, state}
+ defp session_reply(state), do: {:noreply, state}
+
+ defp invoke_output(output, data) do
+ output.(data)
+ |> normalize_output_result()
+ rescue
+ exception -> {:error, exception}
+ catch
+ kind, reason -> {:error, {kind, reason}}
+ end
+
+ defp normalize_output_result(:ok), do: :ok
+ defp normalize_output_result({:error, _reason} = error), do: error
+ defp normalize_output_result(other), do: {:error, {:invalid_output_result, other}}
+
+ defp stop_in_flight(nil), do: :ok
+
+ defp stop_in_flight(packet) do
+ _cancelled = Process.cancel_timer(packet.timer)
+
+ if is_pid(packet.worker) and Process.alive?(packet.worker) do
+ Process.exit(packet.worker, :kill)
+ end
+
+ if packet.worker_monitor do
+ Process.demonitor(packet.worker_monitor, [:flush])
+ end
+
+ :ok
+ end
+
+ defp cancel_poll_waiter(nil), do: :ok
+
+ defp cancel_poll_waiter({from, _token, timer}) do
+ _cancelled = Process.cancel_timer(timer)
+ GenServer.reply(from, :timeout)
+ :ok
+ end
+
+ defp cancel_timer(nil), do: :ok
+
+ defp cancel_timer({timer, _token}) do
+ _cancelled = Process.cancel_timer(timer)
+ :ok
+ end
+end
diff --git a/lib/term_ui/backend/state.ex b/lib/term_ui/backend/state.ex
deleted file mode 100644
index 1d74b92c..00000000
--- a/lib/term_ui/backend/state.ex
+++ /dev/null
@@ -1,312 +0,0 @@
-defmodule TermUI.Backend.State do
- @moduledoc """
- Shared state structure for terminal backends.
-
- The State module provides a consistent wrapper around backend-specific state,
- enabling uniform state management across different backend implementations
- (Raw and TTY modes).
-
- ## Purpose
-
- When the backend selector determines which mode to use, it returns initialization
- data that gets wrapped in this state struct. This provides:
-
- - **Consistent interface**: All backends expose the same state structure
- - **Mode tracking**: Easy identification of current terminal mode
- - **Capability access**: Unified access to detected terminal capabilities
- - **Size caching**: Cached terminal dimensions to avoid repeated queries
- - **Lifecycle tracking**: Initialization status for proper cleanup
-
- ## Usage
-
- State structs are typically created by the runtime initialization code after
- backend selection:
-
- case Selector.select() do
- {:raw, raw_state} ->
- %State{
- backend_module: TermUI.Backend.Raw,
- backend_state: raw_state,
- backend_mode: :raw,
- capabilities: %{},
- initialized: false
- }
-
- {:tty, capabilities} ->
- %State{
- backend_module: TermUI.Backend.TTY,
- backend_state: nil,
- backend_mode: :tty,
- capabilities: capabilities,
- initialized: false
- }
- end
-
- ## Fields
-
- - `:backend_module` - The backend implementation module (required)
- - `:backend_state` - Backend-specific internal state
- - `:backend_mode` - Current terminal mode, `:raw` or `:tty` (required)
- - `:capabilities` - Map of detected terminal capabilities
- - `:size` - Cached terminal dimensions as `{rows, cols}` or `nil`
- - `:initialized` - Whether the backend has been fully initialized
-
- ## Naming Convention
-
- This field is named `:backend_mode` (not `:mode`) to be consistent with
- `Runtime.State.backend_mode` and to avoid confusion with other mode fields
- throughout the codebase (e.g., `line_mode`, `mouse_mode`, `color_mode`).
-
- ## Constructors
-
- Instead of creating structs directly, use the constructor functions:
-
- # General constructor with explicit backend module
- State.new(MyBackend, backend_mode: :tty, capabilities: %{colors: :true_color})
-
- # Convenience constructor for raw mode
- State.new_raw()
- State.new_raw(%{raw_mode_started: true})
-
- # Convenience constructor for TTY mode
- State.new_tty(%{colors: :color_256, unicode: true})
-
- ## State Updates
-
- State structs are immutable. Use update functions for convenience:
-
- state = State.new_tty(%{colors: :true_color})
- state = State.put_size(state, {24, 80})
- state = State.mark_initialized(state)
- """
-
- @typedoc """
- Terminal mode indicating which backend type is active.
- """
- @type backend_mode :: :raw | :tty
-
- @typedoc """
- Cached terminal dimensions as `{rows, cols}`.
- """
- @type dimensions :: {pos_integer(), pos_integer()} | nil
-
- @typedoc """
- The backend state struct.
-
- Contains all metadata needed to manage a terminal backend instance.
- """
- @type t :: %__MODULE__{
- backend_module: module(),
- backend_state: term(),
- backend_mode: backend_mode(),
- capabilities: map(),
- size: dimensions(),
- initialized: boolean()
- }
-
- @enforce_keys [:backend_module, :backend_mode]
-
- # Dialyzer: Functions return specific struct types
- @dialyzer {:nowarn_function, new_raw: 1, new_tty: 2}
-
- defstruct [
- :backend_module,
- :backend_state,
- :backend_mode,
- capabilities: %{},
- size: nil,
- initialized: false
- ]
-
- @doc """
- Creates a new backend state with the given module and options.
-
- ## Arguments
-
- - `backend_module` - The backend implementation module
- - `opts` - Keyword list of options:
- - `:backend_mode` - Required. The terminal mode (`:raw` or `:tty`)
- - `:backend_state` - Optional. Backend-specific internal state
- - `:capabilities` - Optional. Map of terminal capabilities (default: `%{}`)
- - `:size` - Optional. Cached dimensions as `{rows, cols}` (default: `nil`)
- - `:initialized` - Optional. Initialization status (default: `false`)
-
- ## Examples
-
- iex> State.new(MyBackend, backend_mode: :tty)
- %State{backend_module: MyBackend, backend_mode: :tty, ...}
-
- iex> State.new(MyBackend, backend_mode: :tty, capabilities: %{colors: :true_color})
- %State{backend_module: MyBackend, backend_mode: :tty, capabilities: %{colors: :true_color}, ...}
-
- ## Raises
-
- - `ArgumentError` if `:backend_mode` is not provided in options
- """
- @spec new(module(), keyword()) :: t()
- def new(backend_module, opts \\ []) do
- unless Keyword.has_key?(opts, :backend_mode) do
- raise ArgumentError, "the :backend_mode option is required"
- end
-
- struct!(__MODULE__, [{:backend_module, backend_module} | opts])
- end
-
- @doc """
- Creates a new raw mode backend state.
-
- This is a convenience function that sets:
- - `backend_module` to `TermUI.Backend.Raw`
- - `backend_mode` to `:raw`
- - `capabilities` to `%{}`
-
- ## Arguments
-
- - `backend_state` - Optional. Backend-specific internal state (default: `nil`)
-
- ## Examples
-
- iex> State.new_raw()
- %State{backend_module: TermUI.Backend.Raw, backend_mode: :raw, ...}
-
- iex> State.new_raw(%{raw_mode_started: true})
- %State{backend_module: TermUI.Backend.Raw, backend_mode: :raw, backend_state: %{raw_mode_started: true}, ...}
- """
- @spec new_raw(term()) :: t()
- def new_raw(backend_state \\ nil) do
- %__MODULE__{
- backend_module: TermUI.Backend.Raw,
- backend_state: backend_state,
- backend_mode: :raw,
- capabilities: %{},
- size: nil,
- initialized: false
- }
- end
-
- @doc """
- Creates a new TTY mode backend state with the given capabilities.
-
- This is a convenience function that sets:
- - `backend_module` to `TermUI.Backend.TTY`
- - `backend_mode` to `:tty`
-
- ## Arguments
-
- - `capabilities` - Map of detected terminal capabilities
- - `backend_state` - Optional. Backend-specific internal state (default: `nil`)
-
- ## Examples
-
- iex> State.new_tty(%{colors: :color_256, unicode: true})
- %State{backend_module: TermUI.Backend.TTY, backend_mode: :tty, capabilities: %{colors: :color_256, unicode: true}, ...}
-
- iex> State.new_tty(%{colors: :true_color}, %{some: :state})
- %State{backend_module: TermUI.Backend.TTY, backend_mode: :tty, capabilities: %{colors: :true_color}, backend_state: %{some: :state}, ...}
- """
- @spec new_tty(map(), term()) :: t()
- def new_tty(capabilities, backend_state \\ nil) when is_map(capabilities) do
- %__MODULE__{
- backend_module: TermUI.Backend.TTY,
- backend_state: backend_state,
- backend_mode: :tty,
- capabilities: capabilities,
- size: nil,
- initialized: false
- }
- end
-
- # ============================================================================
- # Update Functions
- # ============================================================================
-
- @doc """
- Updates the backend-specific state.
-
- ## Arguments
-
- - `state` - The current state struct
- - `backend_state` - The new backend-specific state value
-
- ## Examples
-
- iex> state = State.new_raw()
- iex> state = State.put_backend_state(state, %{cursor: {1, 1}})
- iex> state.backend_state
- %{cursor: {1, 1}}
- """
- @spec put_backend_state(t(), term()) :: t()
- def put_backend_state(%__MODULE__{} = state, backend_state) do
- %{state | backend_state: backend_state}
- end
-
- @doc """
- Updates the cached terminal dimensions.
-
- ## Arguments
-
- - `state` - The current state struct
- - `size` - The new size as `{rows, cols}` tuple or `nil`
-
- ## Examples
-
- iex> state = State.new_tty(%{})
- iex> state = State.put_size(state, {24, 80})
- iex> state.size
- {24, 80}
-
- iex> state = State.put_size(state, nil)
- iex> state.size
- nil
- """
- @spec put_size(t(), dimensions()) :: t()
- def put_size(%__MODULE__{} = state, size) do
- %{state | size: size}
- end
-
- @doc """
- Updates the capabilities map.
-
- Note: This replaces the entire capabilities map, it does not merge.
-
- ## Arguments
-
- - `state` - The current state struct
- - `capabilities` - The new capabilities map
-
- ## Examples
-
- iex> state = State.new_tty(%{colors: :basic})
- iex> state = State.put_capabilities(state, %{colors: :true_color, unicode: true})
- iex> state.capabilities
- %{colors: :true_color, unicode: true}
- """
- @spec put_capabilities(t(), map()) :: t()
- def put_capabilities(%__MODULE__{} = state, capabilities) when is_map(capabilities) do
- %{state | capabilities: capabilities}
- end
-
- @doc """
- Marks the state as initialized.
-
- This function is idempotent - calling it on an already initialized state
- has no effect.
-
- ## Arguments
-
- - `state` - The current state struct
-
- ## Examples
-
- iex> state = State.new_tty(%{})
- iex> state.initialized
- false
- iex> state = State.mark_initialized(state)
- iex> state.initialized
- true
- """
- @spec mark_initialized(t()) :: t()
- def mark_initialized(%__MODULE__{} = state) do
- %{state | initialized: true}
- end
-end
diff --git a/lib/term_ui/backend/tty.ex b/lib/term_ui/backend/tty.ex
index c96e4110..273ab498 100644
--- a/lib/term_ui/backend/tty.ex
+++ b/lib/term_ui/backend/tty.ex
@@ -1,1241 +1,216 @@
defmodule TermUI.Backend.TTY do
- @moduledoc """
- TTY terminal backend for constrained environments.
-
- The TTY backend provides terminal rendering when raw mode is unavailable. This
- includes Nerves devices, SSH sessions, remote IEx consoles, and other scenarios
- where `:shell.start_interactive({:noshell, :raw})` returns `{:error, :already_started}`.
-
- ## When This Backend is Selected
-
- The `TermUI.Backend.Selector` chooses this backend when:
- 1. Raw mode activation fails with `:already_started` (a shell is already running)
- 2. The environment is detected as constrained (Nerves, remote IEx)
- 3. Explicit TTY mode is requested via configuration
-
- ## Key Difference from Raw Backend
-
- **This backend is still fully interactive.** Even without raw mode, we can:
- - Read individual characters and escape sequences using `IO.getn/2`
- - Process arrow keys, Tab, function keys, and control sequences
- - Position the cursor and render styled text
-
- The main differences from raw mode are:
- - **No terminal mode control** - Cannot switch terminal modes (shell already running)
- - **Potential interference** - The existing shell's line editing may occasionally interfere
- - **Capability uncertainty** - Must detect and adapt to available features
- - **Limited mouse support** - Mouse events may not be available or reliable
-
- ## Rendering Modes
-
- This backend supports two rendering modes via the `:line_mode` option:
-
- - **`:full_redraw`** (default) - Clears the screen and redraws everything on each
- frame. This is reliable but may cause visible flicker on slow connections.
-
- - **`:incremental`** - Only updates cells that changed since the last frame.
- This is faster and reduces flicker but may have artifacts if the terminal
- state becomes out of sync.
-
- ## Color Degradation
-
- The TTY backend automatically degrades colors based on detected capabilities:
-
- | Mode | Description | Escape Format |
- |------|-------------|---------------|
- | `:true_color` | Full 24-bit RGB | `ESC[38;2;r;g;bm` |
- | `:color_256` | 256-color palette | `ESC[38;5;nm` |
- | `:color_16` | Basic 16 colors | `ESC[31m` etc. |
- | `:monochrome` | No colors | Attributes only |
-
- ## Character Set Handling
-
- When Unicode is unavailable, box-drawing characters are automatically mapped
- to ASCII equivalents. The `:character_set` field tracks the current mode:
-
- - `:unicode` - Full Unicode box-drawing characters
- - `:ascii` - ASCII fallback (`+`, `-`, `|` for corners and lines)
-
- ## Configuration Options
-
- The `init/1` callback accepts these options:
-
- - `:capabilities` - Map of detected terminal capabilities (from Selector)
- - `:line_mode` - Rendering strategy (`:full_redraw` or `:incremental`)
- - `:alternate_screen` - Whether to use alternate screen buffer (default: `false`)
-
- ## Example
-
- This backend is typically used via the runtime, not directly:
-
- # Automatic backend selection (recommended)
- {:ok, runtime} = TermUI.Runtime.start_link()
-
- # The runtime handles backend selection based on environment
-
- ## See Also
-
- - `TermUI.Backend` - Behaviour definition
- - `TermUI.Backend.Selector` - Backend selection logic
- - `TermUI.Backend.Raw` - Full-featured backend for raw mode
- - `TermUI.CharacterSet` - Unicode/ASCII character mapping
- """
+ @moduledoc false
@behaviour TermUI.Backend
- alias TermUI.Backend.InputBuffer
- alias TermUI.Color.Converter
- alias TermUI.Terminal.EscapeParser
-
- # Dialyzer: Functions return specific struct types
- @dialyzer {:nowarn_function, init: 1, map_character: 2, sanitize_char: 1}
-
- # ===========================================================================
- # ANSI Escape Sequence Constants
- # ===========================================================================
-
- # Cursor control sequences
- @cursor_hide "\e[?25l"
- @cursor_show "\e[?25h"
-
- # Screen control sequences
- @clear_screen "\e[2J"
- @cursor_home "\e[H"
- @alt_screen_enter "\e[?1049h"
- @alt_screen_leave "\e[?1049l"
-
- # Attribute control sequences
- @reset_attrs "\e[0m"
-
- # Input buffer management is handled by TermUI.Backend.InputBuffer module
- # which provides rate-limited logging and consistent behavior across backends.
-
- # ===========================================================================
- # Type Definitions and State Structure
- # ===========================================================================
-
- @typedoc """
- Color rendering mode based on terminal capabilities.
-
- Determines how colors are encoded in escape sequences:
-
- - `:true_color` - Full 24-bit RGB colors (`ESC[38;2;r;g;bm`)
- - `:color_256` - 256-color palette (`ESC[38;5;nm`)
- - `:color_16` - Basic 16 ANSI colors (`ESC[31m` etc.)
- - `:monochrome` - No color support, attributes only
- """
- @type color_mode :: :true_color | :color_256 | :color_16 | :monochrome
-
- @typedoc """
- Rendering strategy for frame updates.
+ alias TermUI.{ANSI, Clipboard, Frame}
+ alias TermUI.Backend.{CapabilityFilter, EventStream, Renderer}
+ alias TermUI.Terminal.SizeDetector
+ alias TermUI.TerminalOutput
- - `:full_redraw` - Clear and redraw entire screen each frame (reliable)
- - `:incremental` - Only update changed cells (faster but may have artifacts)
- """
@type line_mode :: :full_redraw | :incremental
-
- @typedoc """
- Character set for box-drawing and special characters.
-
- - `:unicode` - Full Unicode box-drawing characters
- - `:ascii` - ASCII fallback characters
- """
+ @type color_mode :: :true_color | :color_256 | :color_16 | :monochrome
@type character_set :: :unicode | :ascii
- @typedoc """
- Internal state for the TTY backend.
-
- Tracks terminal configuration and rendering state.
-
- ## Fields
-
- - `:size` - Terminal dimensions as `{rows, cols}`
- - `:capabilities` - Map of detected terminal capabilities from Selector
- - `:line_mode` - Rendering strategy (`:full_redraw` or `:incremental`)
- - `:last_frame` - Previous frame for incremental rendering comparison
- - `:character_set` - Unicode or ASCII character set
- - `:color_mode` - Color capability level
- - `:alternate_screen` - Whether alternate screen buffer is active
- - `:cursor_visible` - Whether cursor is currently visible
- - `:cursor_position` - Current cursor position as `{row, col}` or `nil`
- - `:input_buffer` - Buffer for partial escape sequences between poll_event calls
- """
@type t :: %__MODULE__{
- size: {pos_integer(), pos_integer()},
+ size: TermUI.Backend.size(),
capabilities: map(),
line_mode: line_mode(),
- last_frame: map() | nil,
character_set: character_set(),
color_mode: color_mode(),
alternate_screen: boolean(),
- cursor_visible: boolean(),
- cursor_position: {pos_integer(), pos_integer()} | nil,
- input_buffer: binary()
+ input_buffer: binary(),
+ event_queue: [TermUI.Backend.event()],
+ paste_state: map() | nil,
+ input_reader: pid() | nil,
+ rendered_frame: Frame.t() | nil,
+ bracketed_paste: boolean(),
+ focus_events: boolean()
}
defstruct size: {24, 80},
capabilities: %{},
line_mode: :full_redraw,
- last_frame: nil,
character_set: :unicode,
color_mode: :true_color,
alternate_screen: false,
- cursor_visible: true,
- cursor_position: nil,
- input_buffer: <<>>
-
- # ===========================================================================
- # Lifecycle Callbacks
- # ===========================================================================
+ input_buffer: "",
+ event_queue: [],
+ paste_state: nil,
+ input_reader: nil,
+ rendered_frame: nil,
+ bracketed_paste: true,
+ focus_events: true
@impl true
- @doc """
- Initializes the TTY backend with detected capabilities.
-
- Accepts options from the Selector including terminal capabilities.
-
- ## Options
-
- - `:capabilities` - Map of detected terminal capabilities
- - `:line_mode` - Rendering strategy (default: `:full_redraw`)
- - `:alternate_screen` - Use alternate screen buffer (default: `false`)
- - `:size` - Explicit terminal dimensions (default: from capabilities or `{24, 80}`)
-
- ## Returns
-
- - `{:ok, state}` - Successfully initialized
- - `{:error, reason}` - Initialization failed
- """
- @spec init(keyword()) :: {:ok, t()}
- def init(opts \\ []) do
- capabilities = Keyword.get(opts, :capabilities, %{})
- line_mode = Keyword.get(opts, :line_mode, :full_redraw)
- alternate_screen = Keyword.get(opts, :alternate_screen, false)
-
- # Determine color mode from capabilities
- color_mode = determine_color_mode(capabilities)
-
- # Determine character set from capabilities
- character_set = determine_character_set(capabilities)
-
- # Get terminal size from capabilities or option or default
- size = determine_size(opts, capabilities)
+ @spec init(keyword()) :: {:ok, t()} | {:error, term()}
+ def init(opts) do
+ capabilities =
+ opts
+ |> Keyword.get(:capabilities, %{})
+ |> CapabilityFilter.filter(opts)
state = %__MODULE__{
- size: size,
+ size: determine_size(opts, capabilities),
capabilities: capabilities,
- line_mode: line_mode,
- character_set: character_set,
- color_mode: color_mode,
- alternate_screen: alternate_screen
+ line_mode: Keyword.get(opts, :line_mode, :full_redraw),
+ character_set: if(capabilities.unicode, do: :unicode, else: :ascii),
+ color_mode: determine_color_mode(capabilities),
+ alternate_screen: Keyword.get(opts, :alternate_screen, false),
+ bracketed_paste: Keyword.get(opts, :bracketed_paste, true),
+ focus_events: Keyword.get(opts, :focus_events, true)
}
- # Perform terminal setup
- state = setup_terminal(state)
-
- {:ok, state}
- end
-
- @impl true
- @doc """
- Shuts down the TTY backend and restores terminal state.
-
- Performs the following cleanup sequence:
- 1. Reset all text attributes (colors, bold, underline, etc.)
- 2. Show the cursor (in case it was hidden)
- 3. Leave alternate screen buffer (if it was entered)
-
- ## Idempotent Behavior
-
- This function is safe to call multiple times. Each call will emit the same
- cleanup sequences, which is harmless since terminal state converges to the
- same result regardless of prior state.
-
- ## Error Handling
-
- All terminal writes use `safe_write/1` which catches and ignores errors.
- This ensures cleanup completes even if the terminal is in an error state
- or has been disconnected. We prioritize best-effort cleanup over failing
- on individual write errors.
-
- ## No Cooked Mode Restoration
-
- Unlike the Raw backend, the TTY backend never takes the terminal out of
- cooked mode (the shell is already running). Therefore, no mode restoration
- is needed during shutdown.
+ case TerminalOutput.write(setup_sequence(state)) do
+ :ok ->
+ {:ok, state}
- ## Returns
-
- Always returns `:ok`.
- """
- @spec shutdown(t()) :: :ok
- def shutdown(%__MODULE__{} = state) do
- # Reset all attributes (colors, styles)
- safe_write(@reset_attrs)
-
- # Show cursor
- safe_write(@cursor_show)
-
- # Leave alternate screen if it was entered
- if state.alternate_screen do
- safe_write(@alt_screen_leave)
+ {:error, reason} ->
+ shutdown(state, {:init_failed, reason})
+ {:error, {:terminal_write_failed, reason}}
end
-
- :ok
end
- # ===========================================================================
- # Query Callbacks
- # ===========================================================================
-
@impl true
- @doc """
- Returns the current terminal dimensions.
+ @spec shutdown(t(), term()) :: :ok
+ def shutdown(state, _reason) do
+ EventStream.stop(state)
+
+ TerminalOutput.write_to_tty(
+ TerminalOutput.cleanup_sequence(
+ bracketed_paste: state.bracketed_paste,
+ focus_events: state.focus_events,
+ alternate_screen: state.alternate_screen
+ )
+ )
- ## Returns
-
- - `{:ok, {rows, cols}}` - Terminal size
- """
- @spec size(t()) :: {:ok, {pos_integer(), pos_integer()}}
- def size(%__MODULE__{size: size}) do
- {:ok, size}
- end
-
- @doc """
- Updates the terminal size and clears the frame buffer.
-
- When the terminal is resized, the previous frame is no longer valid since
- positions may now be out of bounds or content may need to be reflowed.
- This function updates the size and clears `last_frame` to force a full
- redraw on the next `draw_cells/2` call.
-
- ## Parameters
-
- - `state` - Current backend state
- - `new_size` - New terminal dimensions as `{rows, cols}`
-
- ## Returns
-
- `{:ok, updated_state}` with new size and cleared last_frame.
- """
- @spec set_size(t(), {pos_integer(), pos_integer()}) :: {:ok, t()}
- def set_size(%__MODULE__{} = state, {rows, cols} = new_size)
- when is_integer(rows) and rows > 0 and is_integer(cols) and cols > 0 do
- {:ok, %{state | size: new_size, last_frame: nil}}
- end
-
- @doc """
- Queries the terminal for its current size and updates state.
-
- Uses `:io.rows/0` and `:io.columns/0` to get the current terminal dimensions.
- If the query fails (e.g., not connected to a terminal), the current size is preserved.
-
- This function also clears `last_frame` to force a full redraw, since the
- terminal dimensions may have changed.
-
- Note: This is a TTY-specific extension function, not part of the Backend behaviour.
- The return signature matches `TermUI.Backend.Raw.refresh_size/1` for consistency.
-
- ## Returns
-
- `{:ok, {rows, cols}, updated_state}` with refreshed size and cleared last_frame.
-
- ## Example
-
- {:ok, {rows, cols}, state} = TTY.refresh_size(state)
- """
- @spec refresh_size(t()) :: {:ok, TermUI.Backend.size(), t()}
- def refresh_size(%__MODULE__{} = state) do
- new_size = query_terminal_size(state.size)
- new_state = %{state | size: new_size, last_frame: nil}
- {:ok, new_size, new_state}
- end
-
- # Queries the terminal for its current dimensions.
- # Falls back to the provided default if the query fails.
- @spec query_terminal_size({pos_integer(), pos_integer()}) :: {pos_integer(), pos_integer()}
- defp query_terminal_size(default) do
- rows =
- case :io.rows() do
- {:ok, r} when is_integer(r) and r > 0 -> r
- _ -> elem(default, 0)
- end
-
- cols =
- case :io.columns() do
- {:ok, c} when is_integer(c) and c > 0 -> c
- _ -> elem(default, 1)
- end
-
- {rows, cols}
+ :ok
end
- # ===========================================================================
- # Cursor Callbacks
- # ===========================================================================
-
@impl true
- @doc """
- Moves the cursor to the specified position.
-
- Position is 1-indexed: `{1, 1}` is the top-left corner.
- Outputs `\\e[row;colH` escape sequence.
- Position is clamped to terminal bounds.
- """
- @spec move_cursor(t(), {pos_integer(), pos_integer()}) :: {:ok, t()}
- def move_cursor(%__MODULE__{size: {max_rows, max_cols}} = state, {row, col}) do
- # Clamp position to terminal bounds
- clamped_row = max(1, min(row, max_rows))
- clamped_col = max(1, min(col, max_cols))
-
- # Output cursor positioning sequence
- safe_write("\e[#{clamped_row};#{clamped_col}H")
-
- {:ok, %{state | cursor_position: {clamped_row, clamped_col}}}
- end
+ @spec size(t()) :: {:ok, TermUI.Backend.size()}
+ def size(state), do: {:ok, state.size}
@impl true
- @doc """
- Hides the terminal cursor.
-
- Outputs `\\e[?25l` escape sequence.
-
- This operation is idempotent - if the cursor is already hidden,
- no escape sequence is written.
- """
- @spec hide_cursor(t()) :: {:ok, t()}
- def hide_cursor(%__MODULE__{cursor_visible: false} = state) do
- # Already hidden - idempotent no-op
- {:ok, state}
- end
-
- def hide_cursor(%__MODULE__{} = state) do
- safe_write(@cursor_hide)
- {:ok, %{state | cursor_visible: false}}
+ @spec capabilities(t()) :: map()
+ def capabilities(state), do: Map.put_new(state.capabilities, :dimensions, state.size)
+
+ @spec refresh_size(t()) :: {:ok, TermUI.Backend.size(), t()} | {:error, term()}
+ def refresh_size(state) do
+ case SizeDetector.detect() do
+ {:ok, size} -> {:ok, size, %{state | size: size}}
+ {:error, reason} -> {:error, reason}
+ end
end
@impl true
- @doc """
- Shows the terminal cursor.
-
- Outputs `\\e[?25h` escape sequence.
-
- This operation is idempotent - if the cursor is already visible,
- no escape sequence is written.
- """
- @spec show_cursor(t()) :: {:ok, t()}
- def show_cursor(%__MODULE__{cursor_visible: true} = state) do
- # Already visible - idempotent no-op
- {:ok, state}
- end
-
- def show_cursor(%__MODULE__{} = state) do
- safe_write(@cursor_show)
- {:ok, %{state | cursor_visible: true}}
+ @spec draw(t(), Frame.t()) :: {:ok, t()} | {:error, term()}
+ def draw(state, %Frame{} = frame) do
+ full? =
+ state.line_mode == :full_redraw or is_nil(state.rendered_frame) or
+ dimensions_changed?(state.rendered_frame, frame)
+
+ changes = if full?, do: Frame.cells(frame), else: Frame.diff(state.rendered_frame, frame)
+
+ output = [
+ ANSI.cursor_hide(),
+ if(full?, do: [ANSI.clear_screen(), ANSI.cursor_position(1, 1)], else: []),
+ Renderer.render(changes, state.color_mode, state.character_set),
+ cursor_sequence(frame.cursor)
+ ]
+
+ case TerminalOutput.write(output) do
+ :ok -> {:ok, %{state | rendered_frame: frame}}
+ {:error, reason} -> {:error, {:terminal_write_failed, reason}}
+ end
end
- # ===========================================================================
- # Rendering Callbacks
- # ===========================================================================
-
@impl true
- @doc """
- Clears the entire screen and moves cursor to home position.
-
- Outputs the following escape sequences:
- 1. `\\e[2J` - Clear entire screen
- 2. `\\e[H` - Move cursor to home position (1,1)
-
- Also clears `last_frame` in state, which forces a full redraw on the next
- `draw_cells/2` call when in incremental mode.
-
- ## Returns
-
- `{:ok, updated_state}` with cursor_position set to `{1, 1}` and last_frame cleared.
- """
- @spec clear(t()) :: {:ok, t()}
- def clear(state) do
- # Clear entire screen and move cursor to home position
- safe_write(@clear_screen <> @cursor_home)
-
- # Update state: clear last_frame for incremental mode, reset cursor position
- {:ok, %{state | last_frame: nil, cursor_position: {1, 1}}}
- end
+ @spec flush(t()) :: {:ok, t()}
+ def flush(state), do: {:ok, state}
@impl true
- @doc """
- Draws cells to the terminal at specified positions.
-
- In `:full_redraw` mode (default), clears the screen first then renders all cells.
- In `:incremental` mode, only renders the provided cells without clearing.
-
- ## Cell Format
-
- Each cell is a tuple of `{position, cell_data}` where:
- - `position` is `{row, col}` (1-indexed)
- - `cell_data` is `{char, fg_color, bg_color, attrs}`
-
- ## Rendering Process
-
- 1. In full_redraw mode, clear screen and home cursor
- 2. Group cells by row for efficient rendering
- 3. For each row, position cursor and output styled characters
- 4. Apply color degradation based on `color_mode`
-
- ## Returns
-
- `{:ok, updated_state}` with `last_frame` updated for incremental mode.
- """
- @spec draw_cells(t(), [{TermUI.Backend.position(), TermUI.Backend.cell()}]) :: {:ok, t()}
- def draw_cells(%__MODULE__{} = state, cells) do
- case state.line_mode do
- :full_redraw ->
- # Always clear and redraw everything
- do_full_redraw(cells, state)
-
- :incremental ->
- if is_nil(state.last_frame) do
- # First frame in incremental mode - do full redraw to establish baseline
- do_full_redraw(cells, state)
- else
- # Subsequent frames - only render changes
- do_incremental_render(cells, state)
- end
+ @spec clipboard(t(), Clipboard.Operation.t()) :: {:ok, t()} | {:error, term()}
+ def clipboard(state, %Clipboard.Operation{} = operation) do
+ with {:ok, sequence} <- Clipboard.sequence(operation),
+ :ok <- TerminalOutput.write(sequence) do
+ {:ok, state}
+ else
+ {:error, {:clipboard_too_large, _size, _maximum} = reason} -> {:error, reason}
+ {:error, reason} -> {:error, {:terminal_write_failed, reason}}
end
end
- # Performs a full redraw: clears screen and renders all cells.
- @spec do_full_redraw(
- [{TermUI.Backend.position(), TermUI.Backend.cell()}],
- t()
- ) :: {:ok, t()}
- defp do_full_redraw(cells, state) do
- # Clear screen and home cursor
- safe_write(@clear_screen <> @cursor_home)
-
- # Group cells by row and render
- cells
- |> group_cells_by_row()
- |> render_rows(state)
-
- # Build frame map for incremental mode tracking
- frame =
- if state.line_mode == :incremental do
- build_frame_map(cells)
- else
- nil
- end
-
- {:ok, %{state | last_frame: frame, cursor_position: nil}}
- end
-
- # Performs incremental rendering: only updates changed/removed cells.
- #
- # Optimizations applied:
- # 1. Sort cells by position (row, then col) for sequential access
- # 2. Group adjacent cells on same row to minimize cursor moves
- # 3. Batch render grouped cells with single cursor positioning
- @spec do_incremental_render(
- [{TermUI.Backend.position(), TermUI.Backend.cell()}],
- t()
- ) :: {:ok, t()}
- defp do_incremental_render(cells, state) do
- # Compare current frame with last frame
- {changed, removed} = compare_frames(state.last_frame, cells)
-
- # Optimize: sort and group changed cells by row for efficient rendering
- # This reduces cursor positioning overhead
- changed
- |> sort_cells_by_position()
- |> group_cells_by_row()
- |> render_incremental_rows(state)
-
- # Clear removed cells (sorted for sequential access)
- removed
- |> Enum.sort()
- |> Enum.each(&clear_cell_at(&1, state))
-
- # Update last_frame with current frame
- frame = build_frame_map(cells)
-
- {:ok, %{state | last_frame: frame, cursor_position: nil}}
- end
-
- # Sorts cells by position (row first, then column) for optimal cursor movement.
- @spec sort_cells_by_position([{TermUI.Backend.position(), TermUI.Backend.cell()}]) ::
- [{TermUI.Backend.position(), TermUI.Backend.cell()}]
- defp sort_cells_by_position(cells) do
- Enum.sort_by(cells, fn {{row, col}, _cell} -> {row, col} end)
- end
-
- # Renders grouped cells for incremental mode with cursor optimization.
- #
- # For each row, positions cursor once at the first cell, then renders
- # cells in sequence. Adjacent cells benefit from implicit cursor advance.
- # Rows outside terminal bounds are skipped.
- @spec render_incremental_rows([{pos_integer(), [{pos_integer(), TermUI.Backend.cell()}]}], t()) ::
- :ok
- defp render_incremental_rows(grouped_rows, state) do
- {max_rows, _max_cols} = state.size
-
- Enum.each(grouped_rows, fn {row, row_cells} ->
- # Skip rows outside terminal bounds
- if row >= 1 and row <= max_rows do
- [{start_col, _} | _] = row_cells
- render_row_at_column(row, start_col, row_cells, state)
- end
- end)
- end
-
- # Clears a cell at a specific position by writing a space.
- #
- # Used for incremental rendering to clear cells that were in the
- # previous frame but not in the current frame. Positions outside
- # terminal bounds are silently skipped.
- @spec clear_cell_at(TermUI.Backend.position(), t()) :: :ok
- defp clear_cell_at({row, col}, state) do
- {max_rows, max_cols} = state.size
-
- # Validate position is within terminal bounds
- if row >= 1 and row <= max_rows and col >= 1 and col <= max_cols do
- cursor = "\e[#{row};#{col}H"
- safe_write([cursor, @reset_attrs, " "])
- end
-
- :ok
- end
-
@impl true
- @doc """
- Flushes pending output to the terminal.
-
- For TTY mode, output is synchronous so this is largely a no-op.
- """
- @spec flush(t()) :: {:ok, t()}
- def flush(state) do
- {:ok, state}
- end
-
- # ===========================================================================
- # Input Callbacks
- # ===========================================================================
-
- @impl true
- @doc """
- Polls for input events with the specified timeout.
-
- Uses `IO.getn/2` for character-by-character input. Note that the timeout
- parameter may not be honored precisely since `IO.getn/2` is blocking.
-
- Input is parsed using `TermUI.Terminal.EscapeParser` to handle escape
- sequences like arrow keys, function keys, and mouse events.
-
- Partial escape sequences are buffered in the state's `input_buffer` field
- and will be completed on subsequent calls.
-
- ## Returns
-
- - `{:ok, event, state}` - An input event was received
- - `{:timeout, state}` - No input available (rare with blocking IO)
- - `{:error, reason, state}` - An error occurred
-
- ## Note
-
- The timeout parameter is not honored due to the blocking nature of `IO.getn/2`.
- For non-blocking input, consider using the Raw backend when available.
- """
@spec poll_event(t(), non_neg_integer()) ::
{:ok, TermUI.Backend.event(), t()}
| {:timeout, t()}
| {:error, term(), t()}
- def poll_event(%__MODULE__{input_buffer: buffer} = state, _timeout) do
- # First check if we have buffered events from a previous partial parse
- case parse_buffered_input(buffer) do
- {:event, event, remaining} ->
- {:ok, event, %{state | input_buffer: remaining}}
-
- :need_more ->
- # Read a single character from input
- case read_input_char() do
- {:ok, char_data} ->
- # Combine with buffer (with size limit protection) and parse
- new_state = append_to_input_buffer(state, char_data)
- parse_and_return_event(new_state, new_state.input_buffer)
-
- :eof ->
- {:error, :eof, state}
-
- {:error, reason} ->
- {:error, reason, state}
- end
- end
- end
-
- # Attempts to parse an event from buffered input.
- @spec parse_buffered_input(binary()) :: {:event, TermUI.Backend.event(), binary()} | :need_more
- defp parse_buffered_input(<<>>) do
- :need_more
- end
-
- defp parse_buffered_input(buffer) do
- case EscapeParser.parse(buffer) do
- {[event | _rest_events], remaining} ->
- # Return first event, keep remaining in buffer
- # Note: We discard rest_events; they'll be re-parsed on next call
- {:event, event, remaining}
-
- {[], _remaining} ->
- # No complete events parsed - might be partial sequence
- :need_more
- end
- end
-
- # Reads a single character from standard input.
- @spec read_input_char() :: {:ok, binary()} | :eof | {:error, term()}
- defp read_input_char do
- case IO.getn("", 1) do
- :eof ->
- :eof
-
- {:error, reason} ->
- {:error, reason}
-
- char when is_binary(char) ->
- {:ok, char}
-
- # IO.getn can return a charlist in some contexts
- [char] when is_integer(char) ->
- {:ok, <>}
-
- other ->
- # Unexpected return type - return error instead of masking it
- {:error, {:unexpected_io_return, other}}
- end
+ def poll_event(state, timeout) do
+ EventStream.poll(state, timeout, &read_one_character/0, __MODULE__)
end
- # Parses combined input and returns an event or timeout.
- @spec parse_and_return_event(t(), binary()) ::
- {:ok, TermUI.Backend.event(), t()}
- | {:timeout, t()}
- defp parse_and_return_event(state, input) do
- case EscapeParser.parse(input) do
- {[event | _rest], remaining} ->
- {:ok, event, %{state | input_buffer: remaining}}
-
- {[], remaining} ->
- # No complete event - buffer the input for next call
- # This happens with partial escape sequences
- # Apply buffer size limit to prevent memory exhaustion
- new_state = apply_buffer_limit(%{state | input_buffer: remaining})
- {:timeout, new_state}
+ @impl true
+ @spec resize(t(), TermUI.Backend.size()) :: {:ok, t()} | {:error, term()}
+ def resize(state, {rows, columns} = size)
+ when is_integer(rows) and rows > 0 and is_integer(columns) and columns > 0 do
+ case TerminalOutput.write([ANSI.clear_screen(), ANSI.cursor_position(1, 1)]) do
+ :ok -> {:ok, %{state | size: size, rendered_frame: nil}}
+ {:error, reason} -> {:error, {:terminal_write_failed, reason}}
end
end
- # Appends data to the input buffer with size limit protection.
- # Uses the shared InputBuffer module for rate-limited logging.
- @spec append_to_input_buffer(t(), binary()) :: t()
- defp append_to_input_buffer(state, data) do
- InputBuffer.append_with_limit(state, data, :input_buffer, source: __MODULE__)
- end
-
- # Applies buffer size limit, truncating if necessary.
- # Uses the shared InputBuffer module for rate-limited logging.
- @spec apply_buffer_limit(t()) :: t()
- defp apply_buffer_limit(%{input_buffer: buffer} = state) do
- {limited, _overflowed} = InputBuffer.apply_limit(buffer, source: __MODULE__)
- %{state | input_buffer: limited}
- end
-
- # ===========================================================================
- # Private Functions
- # ===========================================================================
-
- # Determines color mode from capabilities map.
- @spec determine_color_mode(map()) :: color_mode()
- defp determine_color_mode(capabilities) do
- case Map.get(capabilities, :colors) do
- :true_color -> :true_color
- :color_256 -> :color_256
- :color_16 -> :color_16
- :monochrome -> :monochrome
- n when is_integer(n) -> color_mode_from_integer(n)
- _ -> :true_color
- end
+ defp setup_sequence(state) do
+ [
+ if(state.alternate_screen, do: ANSI.enter_alternate_screen(), else: []),
+ ANSI.cursor_hide(),
+ ANSI.clear_screen(),
+ ANSI.cursor_position(1, 1),
+ if(state.bracketed_paste, do: ANSI.enable_bracketed_paste(), else: []),
+ if(state.focus_events, do: ANSI.enable_focus_events(), else: [])
+ ]
end
- defp color_mode_from_integer(n) when n >= 16_777_216, do: :true_color
- defp color_mode_from_integer(n) when n >= 256, do: :color_256
- defp color_mode_from_integer(n) when n >= 16, do: :color_16
- defp color_mode_from_integer(_), do: :true_color
+ defp cursor_sequence(nil), do: ANSI.cursor_hide()
+ defp cursor_sequence({column, row}), do: [ANSI.cursor_position(row, column), ANSI.cursor_show()]
- # Determines character set from capabilities map.
- @spec determine_character_set(map()) :: character_set()
- defp determine_character_set(capabilities) do
- case Map.get(capabilities, :unicode, true) do
- true -> :unicode
- false -> :ascii
- _ -> :unicode
- end
+ defp dimensions_changed?(previous, current) do
+ previous.width != current.width or previous.height != current.height
end
- # Determines terminal size from options, capabilities, or defaults.
- @spec determine_size(keyword(), map()) :: {pos_integer(), pos_integer()}
defp determine_size(opts, capabilities) do
- case Keyword.get(opts, :size) do
- {rows, cols} when is_integer(rows) and is_integer(cols) and rows > 0 and cols > 0 ->
- {rows, cols}
-
- nil ->
- size_from_capabilities_or_default(capabilities)
+ case Keyword.get(opts, :size, Map.get(capabilities, :dimensions, {24, 80})) do
+ {rows, columns}
+ when is_integer(rows) and rows > 0 and is_integer(columns) and columns > 0 ->
+ {rows, columns}
- _ ->
+ _invalid ->
{24, 80}
end
end
- defp size_from_capabilities_or_default(capabilities) do
- case Map.get(capabilities, :dimensions) do
- {rows, cols} when is_integer(rows) and is_integer(cols) and rows > 0 and cols > 0 ->
- {rows, cols}
-
- _ ->
- {24, 80}
- end
- end
-
- # Performs terminal setup during initialization.
- #
- # Outputs ANSI escape sequences to prepare the terminal for rendering:
- # - Optionally enters alternate screen buffer if configured
- # - Hides cursor for cleaner rendering
- # - Clears screen and moves cursor to home position
- #
- # Note: No raw mode activation - the shell is already running in TTY mode.
- @spec setup_terminal(t()) :: t()
- defp setup_terminal(state) do
- # Enter alternate screen if configured
- if state.alternate_screen do
- IO.write(@alt_screen_enter)
- end
-
- # Hide cursor for cleaner rendering
- IO.write(@cursor_hide)
-
- # Clear screen and move cursor to home position
- IO.write(@clear_screen <> @cursor_home)
-
- # Update state to reflect cursor is hidden
- %{state | cursor_visible: false, cursor_position: {1, 1}}
- end
-
- # ===========================================================================
- # Cell Rendering Helpers
- # ===========================================================================
-
- # Groups cells by row number and sorts by column within each row.
- @spec group_cells_by_row([{TermUI.Backend.position(), TermUI.Backend.cell()}]) ::
- [{pos_integer(), [{pos_integer(), TermUI.Backend.cell()}]}]
- defp group_cells_by_row(cells) do
- cells
- |> Enum.group_by(fn {{row, _col}, _cell} -> row end, fn {{_row, col}, cell} -> {col, cell} end)
- |> Enum.sort_by(fn {row, _cells} -> row end)
- |> Enum.map(fn {row, row_cells} ->
- {row, Enum.sort_by(row_cells, fn {col, _cell} -> col end)}
- end)
- end
-
- # Renders all rows to the terminal.
- @spec render_rows([{pos_integer(), [{pos_integer(), TermUI.Backend.cell()}]}], t()) :: :ok
- defp render_rows(rows, state) do
- Enum.each(rows, fn {row, row_cells} ->
- render_row_at_column(row, 1, row_cells, state)
- end)
- end
-
- # Shared row rendering function for both full redraw and incremental modes.
- #
- # Renders a row of cells starting at a specified column with style delta tracking.
- # Tracks the current style and only outputs SGR sequences when the style
- # changes between cells. Uses iolist append pattern (no reverse needed).
- #
- # Parameters:
- # - row: The row number (1-indexed)
- # - start_col: The column to position cursor at (1 for full redraw, first cell col for incremental)
- # - cells: List of {col, cell} tuples sorted by column
- # - state: Backend state with color_mode and character_set
- @spec render_row_at_column(
- pos_integer(),
- pos_integer(),
- [{pos_integer(), TermUI.Backend.cell()}],
- t()
- ) :: :ok
- defp render_row_at_column(row, start_col, cells, state) do
- # Track current column, current style, and accumulated iolist
- # Initial style is nil (no style set yet)
- initial_state = {start_col, nil, []}
-
- {_col, _style, iolist} =
- Enum.reduce(cells, initial_state, fn {col, cell}, {cur_col, cur_style, acc} ->
- # Fill gap with spaces if needed
- gap =
- if col > cur_col do
- String.duplicate(" ", col - cur_col)
- else
- ""
- end
-
- # Render the cell with style delta tracking
- {new_style, cell_io} = render_cell_with_delta(cell, cur_style, state)
-
- # Append to iolist (append pattern - no reverse needed for iolists)
- new_acc = [acc, gap, cell_io]
-
- # Return next column position and new style
- {col + 1, new_style, new_acc}
- end)
-
- # Build final iolist: cursor position + content + reset
- final_io = ["\e[#{row};#{start_col}H", iolist, @reset_attrs]
-
- # Single write for entire row
- safe_write(final_io)
-
- :ok
- end
-
- # Renders a single cell with style delta tracking.
- #
- # Only outputs SGR sequences when the style differs from the previous cell.
- # Returns the new style and the iodata for this cell.
- @spec render_cell_with_delta(
- TermUI.Backend.cell(),
- {TermUI.Backend.color(), TermUI.Backend.color(), [atom()]} | nil,
- t()
- ) :: {{TermUI.Backend.color(), TermUI.Backend.color(), [atom()]}, iodata()}
- defp render_cell_with_delta({char, fg, bg, attrs}, cur_style, state) do
- new_style = {fg, bg, attrs}
-
- # Only output SGR if style changed
- sgr =
- if new_style != cur_style do
- build_sgr_sequence(fg, bg, attrs, state.color_mode)
- else
- ""
- end
-
- # Map character (with potential character set mapping and sanitization)
- mapped_char = map_character(char, state.character_set)
- sanitized_char = sanitize_char(mapped_char)
-
- {new_style, [sgr, sanitized_char]}
- end
-
- # Builds SGR (Select Graphic Rendition) sequence for colors and attributes.
- #
- # Combines reset, attributes, foreground color, and background color into
- # a single efficient escape sequence string.
- @spec build_sgr_sequence(
- TermUI.Backend.color(),
- TermUI.Backend.color(),
- [atom()],
- color_mode()
- ) :: String.t()
- defp build_sgr_sequence(fg, bg, attrs, color_mode) do
- # Build each component
- reset_part = @reset_attrs
- attrs_part = build_attrs_sgr(attrs)
- fg_part = build_fg_sgr(fg, color_mode)
- bg_part = build_bg_sgr(bg, color_mode)
-
- # Combine non-empty parts
- [reset_part, attrs_part, fg_part, bg_part]
- |> Enum.reject(&(&1 == ""))
- |> Enum.join("")
- end
-
- # Builds SGR sequence for text attributes (bold, italic, etc.).
- @spec build_attrs_sgr([atom()]) :: String.t()
- defp build_attrs_sgr(attrs) do
- attrs
- |> Enum.map(&attr_to_sgr/1)
- |> Enum.reject(&is_nil/1)
- |> Enum.join("")
- end
-
- # Builds SGR sequence for foreground color.
- @spec build_fg_sgr(TermUI.Backend.color(), color_mode()) :: String.t()
- defp build_fg_sgr(color, color_mode) do
- color_to_sgr(color, :fg, color_mode)
- end
-
- # Builds SGR sequence for background color.
- @spec build_bg_sgr(TermUI.Backend.color(), color_mode()) :: String.t()
- defp build_bg_sgr(color, color_mode) do
- color_to_sgr(color, :bg, color_mode)
- end
-
- # Converts an attribute to its SGR sequence.
- @spec attr_to_sgr(atom()) :: String.t() | nil
- defp attr_to_sgr(:bold), do: "\e[1m"
- defp attr_to_sgr(:dim), do: "\e[2m"
- defp attr_to_sgr(:italic), do: "\e[3m"
- defp attr_to_sgr(:underline), do: "\e[4m"
- defp attr_to_sgr(:blink), do: "\e[5m"
- defp attr_to_sgr(:reverse), do: "\e[7m"
- defp attr_to_sgr(:strikethrough), do: "\e[9m"
- defp attr_to_sgr(_), do: nil
-
- # Converts a color to its SGR sequence based on color mode.
- @spec color_to_sgr(TermUI.Backend.color(), :fg | :bg, color_mode()) :: String.t()
- defp color_to_sgr(:default, :fg, _mode), do: "\e[39m"
- defp color_to_sgr(:default, :bg, _mode), do: "\e[49m"
- defp color_to_sgr(nil, _type, _mode), do: ""
-
- # True color mode - output RGB directly (with validation)
- defp color_to_sgr({r, g, b}, :fg, :true_color)
- when is_integer(r) and r >= 0 and r <= 255 and
- is_integer(g) and g >= 0 and g <= 255 and
- is_integer(b) and b >= 0 and b <= 255 do
- "\e[38;2;#{r};#{g};#{b}m"
- end
-
- defp color_to_sgr({r, g, b}, :bg, :true_color)
- when is_integer(r) and r >= 0 and r <= 255 and
- is_integer(g) and g >= 0 and g <= 255 and
- is_integer(b) and b >= 0 and b <= 255 do
- "\e[48;2;#{r};#{g};#{b}m"
- end
-
- # 256-color mode - convert RGB to palette index (with validation)
- defp color_to_sgr({r, g, b}, :fg, :color_256)
- when is_integer(r) and r >= 0 and r <= 255 and
- is_integer(g) and g >= 0 and g <= 255 and
- is_integer(b) and b >= 0 and b <= 255 do
- "\e[38;5;#{Converter.rgb_to_256({r, g, b})}m"
- end
-
- defp color_to_sgr({r, g, b}, :bg, :color_256)
- when is_integer(r) and r >= 0 and r <= 255 and
- is_integer(g) and g >= 0 and g <= 255 and
- is_integer(b) and b >= 0 and b <= 255 do
- "\e[48;5;#{Converter.rgb_to_256({r, g, b})}m"
- end
-
- # 16-color mode - convert RGB to basic color (with validation)
- defp color_to_sgr({r, g, b}, :fg, :color_16)
- when is_integer(r) and r >= 0 and r <= 255 and
- is_integer(g) and g >= 0 and g <= 255 and
- is_integer(b) and b >= 0 and b <= 255 do
- "\e[#{Converter.rgb_to_16({r, g, b}, :fg)}m"
- end
-
- defp color_to_sgr({r, g, b}, :bg, :color_16)
- when is_integer(r) and r >= 0 and r <= 255 and
- is_integer(g) and g >= 0 and g <= 255 and
- is_integer(b) and b >= 0 and b <= 255 do
- "\e[#{Converter.rgb_to_16({r, g, b}, :bg)}m"
- end
-
- # Monochrome mode - skip colors entirely
- defp color_to_sgr({_r, _g, _b}, _type, :monochrome), do: ""
-
- # Invalid RGB values fall through to catch-all clause (returns "")
-
- # Monochrome mode - skip all colors (named and palette)
- defp color_to_sgr(name, _type, :monochrome) when is_atom(name), do: ""
- defp color_to_sgr(n, _type, :monochrome) when is_integer(n), do: ""
-
- # Named colors (for all other modes)
- defp color_to_sgr(name, :fg, _mode) when is_atom(name), do: named_color_to_sgr(name, :fg)
- defp color_to_sgr(name, :bg, _mode) when is_atom(name), do: named_color_to_sgr(name, :bg)
-
- # Palette index (0-255)
- defp color_to_sgr(n, :fg, _mode) when is_integer(n) and n >= 0 and n <= 255,
- do: "\e[38;5;#{n}m"
-
- defp color_to_sgr(n, :bg, _mode) when is_integer(n) and n >= 0 and n <= 255,
- do: "\e[48;5;#{n}m"
-
- defp color_to_sgr(_, _, _), do: ""
-
- # Named color SGR code mappings (foreground base codes)
- @named_color_codes %{
- black: 30,
- red: 31,
- green: 32,
- yellow: 33,
- blue: 34,
- magenta: 35,
- cyan: 36,
- white: 37,
- bright_black: 90,
- bright_red: 91,
- bright_green: 92,
- bright_yellow: 93,
- bright_blue: 94,
- bright_magenta: 95,
- bright_cyan: 96,
- bright_white: 97
- }
-
- # Named color to SGR sequence using map lookup
- @spec named_color_to_sgr(atom(), :fg | :bg) :: String.t()
- defp named_color_to_sgr(name, type) do
- case Map.get(@named_color_codes, name) do
- nil ->
- ""
-
- code when type == :fg ->
- "\e[#{code}m"
-
- code when type == :bg ->
- # Background codes are foreground + 10
- bg_code = code + 10
- "\e[#{bg_code}m"
- end
- end
-
- # ===========================================================================
- # Character Set Mapping (Unicode to ASCII)
- # ===========================================================================
-
- # Compile-time mapping from Unicode box-drawing characters to ASCII equivalents.
- # Built from CharacterSet definitions to ensure consistency and automatic adaptation
- # when new character keys are added.
- @unicode_chars TermUI.CharacterSet.get(:unicode)
- @ascii_chars TermUI.CharacterSet.get(:ascii)
-
- # Build the mapping in a single expression:
- # 1. Map all single-character keys (excluding bar_levels) from unicode to ascii
- # 2. Add bar_levels mapping (Unicode has 8 levels, ASCII has 5 - cycle ASCII to match)
- # 3. Override bar_full to ensure it maps correctly (it appears in both bar_levels and standalone)
- @unicode_to_ascii_map (
- # Single-character keys (all keys except bar_levels)
- single_keys = TermUI.CharacterSet.keys() -- [:bar_levels]
-
- base =
- Map.new(single_keys, fn key ->
- {@unicode_chars[key], @ascii_chars[key]}
- end)
-
- # Add bar_levels with cycling (8 Unicode levels → 5 ASCII levels cycled)
- bar_map =
- @unicode_chars.bar_levels
- |> Enum.zip(Stream.cycle(@ascii_chars.bar_levels))
- |> Map.new()
-
- # Merge bar_map first, then base - this ensures bar_full gets the standalone value
- # since it appears last in single_keys and overwrites the cycled bar_levels value
- Map.merge(bar_map, base)
- )
-
- # Maps characters based on character set.
- #
- # When character_set is :unicode, passes through unchanged.
- # When character_set is :ascii, replaces Unicode box-drawing and special
- # characters with their ASCII equivalents for terminals that don't support Unicode.
- @spec map_character(String.t(), character_set()) :: String.t()
- defp map_character(char, :unicode), do: char
-
- defp map_character(char, :ascii) do
- Map.get(@unicode_to_ascii_map, char, char)
- end
-
- # Sanitizes characters to prevent escape sequence injection (defense-in-depth).
- #
- # This is the last line of defense against terminal escape injection.
- # Cells should be pre-sanitized by TermUI.Renderer.Cell which provides
- # comprehensive sanitization (CSI sequences, OSC sequences, control chars).
- # This function provides minimal ESC removal as a safety net in case
- # unsanitized content somehow reaches the rendering layer.
- #
- # For comprehensive sanitization, see TermUI.Renderer.Cell.sanitize/1.
- @spec sanitize_char(String.t()) :: String.t()
- defp sanitize_char(char) when is_binary(char) do
- String.replace(char, "\e", "")
- end
-
- defp sanitize_char(char), do: char
-
- # Builds a frame map from cells for incremental mode tracking.
- @spec build_frame_map([{TermUI.Backend.position(), TermUI.Backend.cell()}]) :: map()
- defp build_frame_map(cells) do
- Map.new(cells, fn {pos, cell} -> {pos, cell} end)
- end
-
- # ===========================================================================
- # Frame Comparison for Incremental Rendering
- # ===========================================================================
-
- # Compares the current frame with the previous frame to identify changes.
- #
- # Core diffing algorithm for incremental rendering. Identifies which cells
- # need to be updated (new or changed) and which positions need to be cleared.
- #
- @doc """
- Compares two frames to find changed and removed cells.
-
- This is a testing helper function exposed for unit testing the incremental
- rendering logic. It is not part of the Backend behaviour API.
-
- Uses MapSet for efficient position lookup when finding removed cells,
- avoiding the need to build a full frame map just for membership testing.
-
- ## Parameters
-
- - `last_frame` - Map of `{row, col}` => `{char, fg, bg, attrs}` from previous frame
- - `current_cells` - List of `{{row, col}, {char, fg, bg, attrs}}` tuples for current frame
-
- ## Returns
-
- Tuple of `{changed_cells, removed_positions}`:
- - `changed_cells` - Cells that are new or different from last frame
- - `removed_positions` - Positions that were in last frame but not in current
- """
- @spec compare_frames(
- map(),
- [{TermUI.Backend.position(), TermUI.Backend.cell()}]
- ) :: {[{TermUI.Backend.position(), TermUI.Backend.cell()}], [TermUI.Backend.position()]}
- def compare_frames(last_frame, current_cells) do
- # Find changed cells: new or different from last frame
- changed =
- Enum.filter(current_cells, fn {pos, cell} ->
- case Map.get(last_frame, pos) do
- nil -> true
- ^cell -> false
- _different -> true
- end
- end)
-
- # Build position set for efficient membership testing (cheaper than full frame map)
- current_positions = MapSet.new(current_cells, fn {pos, _cell} -> pos end)
-
- # Find removed positions: in last frame but not in current
- removed =
- last_frame
- |> Map.keys()
- |> Enum.reject(&MapSet.member?(current_positions, &1))
-
- {changed, removed}
+ defp determine_color_mode(capabilities) do
+ capabilities
+ |> Map.get(:colors, :true_color)
+ |> color_mode()
end
- # ===========================================================================
- # Terminal I/O Helpers
- # ===========================================================================
+ defp color_mode(:true_color), do: :true_color
+ defp color_mode(:color_256), do: :color_256
+ defp color_mode(:color_16), do: :color_16
+ defp color_mode(:monochrome), do: :monochrome
+ defp color_mode(count) when is_integer(count) and count >= 16_777_216, do: :true_color
+ defp color_mode(count) when is_integer(count) and count >= 256, do: :color_256
+ defp color_mode(count) when is_integer(count) and count >= 16, do: :color_16
+ defp color_mode(_other), do: :monochrome
- # Writes data to the terminal, ignoring any errors.
- #
- # This provides bulletproof writes for shutdown sequences where we want
- # to attempt terminal cleanup even if the terminal is in an error state.
- # Errors are silently ignored since we're cleaning up anyway.
- @spec safe_write(iodata()) :: :ok
- defp safe_write(data) do
- try do
- IO.write(data)
- rescue
- _ -> :ok
+ defp read_one_character do
+ case IO.getn("", 1) do
+ :eof -> :eof
+ {:error, reason} -> {:error, reason}
+ data when is_binary(data) -> {:ok, data}
+ [byte] when is_integer(byte) -> {:ok, <>}
+ other -> {:error, {:unexpected_io_return, other}}
end
-
- :ok
end
end
diff --git a/lib/term_ui/capabilities.ex b/lib/term_ui/capabilities.ex
deleted file mode 100644
index 22b797bb..00000000
--- a/lib/term_ui/capabilities.ex
+++ /dev/null
@@ -1,404 +0,0 @@
-defmodule TermUI.Capabilities do
- @moduledoc """
- Terminal capability detection and management.
-
- Detects terminal capabilities through multiple methods:
- - Environment variables ($TERM, $COLORTERM, $TERM_PROGRAM, $LANG)
- - Terminfo database queries
- - Conservative VT100 fallbacks
-
- Results are cached in ETS for fast concurrent access.
- """
-
- @type color_mode :: :true_color | :color_256 | :color_16 | :monochrome
-
- @type t :: %__MODULE__{
- color_mode: color_mode(),
- max_colors: non_neg_integer(),
- unicode: boolean(),
- mouse: boolean(),
- bracketed_paste: boolean(),
- focus_events: boolean(),
- alternate_screen: boolean(),
- terminal_type: String.t() | nil,
- terminal_program: String.t() | nil
- }
-
- defstruct color_mode: :color_16,
- max_colors: 16,
- unicode: false,
- mouse: false,
- bracketed_paste: false,
- focus_events: false,
- alternate_screen: true,
- terminal_type: nil,
- terminal_program: nil
-
- @ets_table :term_ui_capabilities
-
- # Dialyzer: Functions with unmatched return values
- @dialyzer {:nowarn_function,
- ensure_table_exists: 0,
- clear_cache: 0,
- get: 0,
- detect_from_term: 1,
- detect_from_colorterm: 1,
- detect_from_term_program: 1,
- detect_from_terminfo: 1,
- supports_true_color?: 0,
- supports_256_color?: 0,
- cache_capabilities: 1,
- get_cached: 0}
-
- # Known terminal emulators with their capabilities
- @true_color_terminals ~w(iTerm.app vscode WezTerm kitty Alacritty Hyper)
- @color_256_terminals ~w(Apple_Terminal gnome-terminal konsole xfce4-terminal)
-
- # Terminal type patterns for color detection
- @term_patterns [
- {"truecolor", :true_color, 16_777_216},
- {"24bit", :true_color, 16_777_216},
- {"256color", :color_256, 256}
- ]
-
- @term_prefixes [
- {"xterm", :color_256, 256},
- {"screen", :color_256, 256},
- {"tmux", :color_256, 256}
- ]
-
- @doc """
- Detects terminal capabilities and caches them in ETS.
-
- Returns the detected capabilities struct.
- """
- @spec detect() :: t()
- def detect do
- capabilities = do_detect()
- cache_capabilities(capabilities)
- capabilities
- end
-
- @doc """
- Returns cached capabilities, detecting if not yet cached.
- """
- @spec get() :: t()
- def get do
- case get_cached() do
- nil -> detect()
- caps -> caps
- end
- end
-
- @doc """
- Clears the cached capabilities.
- """
- @spec clear_cache() :: :ok
- def clear_cache do
- ensure_table_exists()
-
- try do
- :ets.delete(@ets_table, :capabilities)
- rescue
- ArgumentError -> :ok
- end
-
- :ok
- end
-
- # Capability accessors
-
- @doc """
- Returns true if terminal supports true-color (24-bit RGB).
- """
- @spec supports_true_color?() :: boolean()
- def supports_true_color? do
- get().color_mode == :true_color
- end
-
- @doc """
- Returns true if terminal supports 256 colors or better.
- """
- @spec supports_256_color?() :: boolean()
- def supports_256_color? do
- get().color_mode in [:true_color, :color_256]
- end
-
- @doc """
- Returns true if terminal supports mouse tracking.
- """
- @spec supports_mouse?() :: boolean()
- def supports_mouse? do
- get().mouse
- end
-
- @doc """
- Returns true if terminal supports bracketed paste mode.
- """
- @spec supports_bracketed_paste?() :: boolean()
- def supports_bracketed_paste? do
- get().bracketed_paste
- end
-
- @doc """
- Returns true if terminal supports focus event reporting.
- """
- @spec supports_focus_events?() :: boolean()
- def supports_focus_events? do
- get().focus_events
- end
-
- @doc """
- Returns true if terminal supports Unicode.
- """
- @spec supports_unicode?() :: boolean()
- def supports_unicode? do
- get().unicode
- end
-
- @doc """
- Returns true if terminal supports alternate screen buffer.
- """
- @spec supports_alternate_screen?() :: boolean()
- def supports_alternate_screen? do
- get().alternate_screen
- end
-
- @doc """
- Returns the maximum number of colors supported.
- """
- @spec max_colors() :: non_neg_integer()
- def max_colors do
- get().max_colors
- end
-
- @doc """
- Returns the color mode.
- """
- @spec color_mode() :: color_mode()
- def color_mode do
- get().color_mode
- end
-
- # Private implementation
-
- defp do_detect do
- # Start with VT100 baseline
- base = %__MODULE__{
- color_mode: :color_16,
- max_colors: 16,
- unicode: false,
- mouse: false,
- bracketed_paste: false,
- focus_events: false,
- alternate_screen: true,
- terminal_type: nil,
- terminal_program: nil
- }
-
- base
- |> detect_from_term()
- |> detect_from_colorterm()
- |> detect_from_term_program()
- |> detect_unicode()
- |> detect_from_terminfo()
- |> finalize_capabilities()
- end
-
- defp detect_from_term(caps) do
- case System.get_env("TERM") do
- nil ->
- caps
-
- term ->
- caps = %{caps | terminal_type: term}
- detect_term_colors(caps, term)
- end
- end
-
- defp detect_term_colors(caps, term) do
- # Check for exact matches first
- case term do
- "linux" -> %{caps | color_mode: :color_16, max_colors: 16}
- "dumb" -> %{caps | color_mode: :monochrome, max_colors: 2}
- _ -> detect_term_patterns(caps, term)
- end
- end
-
- defp detect_term_patterns(caps, term) do
- # Check patterns (contains)
- pattern_match =
- Enum.find(@term_patterns, fn {pattern, _mode, _colors} ->
- String.contains?(term, pattern)
- end)
-
- case pattern_match do
- {_, mode, colors} ->
- update_color_mode(caps, mode, colors)
-
- nil ->
- detect_term_prefixes(caps, term)
- end
- end
-
- defp detect_term_prefixes(caps, term) do
- # Check prefixes (starts_with)
- prefix_match =
- Enum.find(@term_prefixes, fn {prefix, _mode, _colors} ->
- String.starts_with?(term, prefix)
- end)
-
- case prefix_match do
- {_, mode, colors} -> update_color_mode(caps, mode, colors)
- nil -> caps
- end
- end
-
- defp detect_from_colorterm(caps) do
- case System.get_env("COLORTERM") do
- nil ->
- caps
-
- colorterm ->
- if colorterm in ["truecolor", "24bit"] do
- %{caps | color_mode: :true_color, max_colors: 16_777_216}
- else
- caps
- end
- end
- end
-
- defp detect_from_term_program(caps) do
- case System.get_env("TERM_PROGRAM") do
- nil ->
- caps
-
- program ->
- caps = %{caps | terminal_program: program}
-
- cond do
- program in @true_color_terminals ->
- %{
- caps
- | color_mode: :true_color,
- max_colors: 16_777_216,
- mouse: true,
- bracketed_paste: true,
- focus_events: true
- }
-
- program in @color_256_terminals ->
- caps = update_color_mode(caps, :color_256, 256)
- %{caps | mouse: true, bracketed_paste: true}
-
- true ->
- caps
- end
- end
- end
-
- defp detect_unicode(caps) do
- lang = System.get_env("LC_ALL") || System.get_env("LC_CTYPE") || System.get_env("LANG") || ""
-
- unicode =
- String.contains?(String.downcase(lang), "utf-8") or
- String.contains?(String.downcase(lang), "utf8")
-
- %{caps | unicode: unicode}
- end
-
- defp detect_from_terminfo(caps) do
- case query_terminfo_colors() do
- {:ok, colors} when colors >= 16_777_216 ->
- update_color_mode(caps, :true_color, colors)
-
- {:ok, colors} when colors >= 256 ->
- update_color_mode(caps, :color_256, colors)
-
- {:ok, colors} when colors >= 16 ->
- update_color_mode(caps, :color_16, colors)
-
- {:ok, colors} when colors >= 8 ->
- # Only update max_colors, keep existing mode
- %{caps | max_colors: max(caps.max_colors, colors)}
-
- _ ->
- caps
- end
- end
-
- defp query_terminfo_colors do
- case System.cmd("infocmp", ["-1"], stderr_to_stdout: true) do
- {output, 0} ->
- parse_terminfo_colors(output)
-
- _ ->
- :error
- end
- rescue
- _ -> :error
- end
-
- defp parse_terminfo_colors(output) do
- # Look for colors#N or colors=N pattern
- case Regex.run(~r/colors[#=](\d+)/, output) do
- [_, count] ->
- {:ok, String.to_integer(count)}
-
- nil ->
- :error
- end
- end
-
- defp finalize_capabilities(caps) do
- # Enable features for any terminal with 256+ colors
- # as these are typically modern terminals
- if caps.max_colors >= 256 do
- %{
- caps
- | mouse: caps.mouse || true,
- bracketed_paste: caps.bracketed_paste || true,
- focus_events: caps.focus_events || caps.max_colors >= 16_777_216
- }
- else
- caps
- end
- end
-
- defp update_color_mode(caps, new_mode, new_colors) do
- # Only upgrade color mode, never downgrade
- current_rank = color_mode_rank(caps.color_mode)
- new_rank = color_mode_rank(new_mode)
-
- if new_rank > current_rank do
- %{caps | color_mode: new_mode, max_colors: max(caps.max_colors, new_colors)}
- else
- %{caps | max_colors: max(caps.max_colors, new_colors)}
- end
- end
-
- defp color_mode_rank(:monochrome), do: 0
- defp color_mode_rank(:color_16), do: 1
- defp color_mode_rank(:color_256), do: 2
- defp color_mode_rank(:true_color), do: 3
-
- defp ensure_table_exists do
- if :ets.whereis(@ets_table) == :undefined do
- :ets.new(@ets_table, [:named_table, :public, :set, read_concurrency: true])
- end
- end
-
- defp cache_capabilities(capabilities) do
- ensure_table_exists()
- :ets.insert(@ets_table, {:capabilities, capabilities})
- end
-
- defp get_cached do
- ensure_table_exists()
-
- case :ets.lookup(@ets_table, :capabilities) do
- [{:capabilities, caps}] -> caps
- [] -> nil
- end
- end
-end
diff --git a/lib/term_ui/capabilities/fallbacks.ex b/lib/term_ui/capabilities/fallbacks.ex
deleted file mode 100644
index b910bee9..00000000
--- a/lib/term_ui/capabilities/fallbacks.ex
+++ /dev/null
@@ -1,249 +0,0 @@
-defmodule TermUI.Capabilities.Fallbacks do
- @moduledoc """
- Graceful degradation utilities for terminal capabilities.
-
- Provides fallback chains for:
- - Colors: true-color → 256-color → 16-color → monochrome
- - Characters: Unicode box-drawing → ASCII art
- """
-
- # Standard 16 ANSI colors as RGB
- @ansi_colors %{
- # Black
- 0 => {0, 0, 0},
- # Red
- 1 => {128, 0, 0},
- # Green
- 2 => {0, 128, 0},
- # Yellow
- 3 => {128, 128, 0},
- # Blue
- 4 => {0, 0, 128},
- # Magenta
- 5 => {128, 0, 128},
- # Cyan
- 6 => {0, 128, 128},
- # White
- 7 => {192, 192, 192},
- # Bright Black
- 8 => {128, 128, 128},
- # Bright Red
- 9 => {255, 0, 0},
- # Bright Green
- 10 => {0, 255, 0},
- # Bright Yellow
- 11 => {255, 255, 0},
- # Bright Blue
- 12 => {0, 0, 255},
- # Bright Magenta
- 13 => {255, 0, 255},
- # Bright Cyan
- 14 => {0, 255, 255},
- # Bright White
- 15 => {255, 255, 255}
- }
-
- # Box-drawing character fallbacks
- @box_drawing_fallbacks %{
- # Single line box drawing
- "─" => "-",
- "│" => "|",
- "┌" => "+",
- "┐" => "+",
- "└" => "+",
- "┘" => "+",
- "├" => "+",
- "┤" => "+",
- "┬" => "+",
- "┴" => "+",
- "┼" => "+",
- # Double line box drawing
- "═" => "=",
- "║" => "|",
- "╔" => "+",
- "╗" => "+",
- "╚" => "+",
- "╝" => "+",
- "╠" => "+",
- "╣" => "+",
- "╦" => "+",
- "╩" => "+",
- "╬" => "+",
- # Rounded corners
- "╭" => "+",
- "╮" => "+",
- "╯" => "+",
- "╰" => "+",
- # Block elements
- "█" => "#",
- "▀" => "^",
- "▄" => "_",
- "▌" => "|",
- "▐" => "|",
- "░" => ".",
- "▒" => ":",
- "▓" => "#",
- # Arrows
- "←" => "<",
- "→" => ">",
- "↑" => "^",
- "↓" => "v",
- # Other symbols
- "•" => "*",
- "·" => ".",
- "…" => "...",
- "×" => "x",
- "÷" => "/",
- "≠" => "!=",
- "≤" => "<=",
- "≥" => ">=",
- "✓" => "[x]",
- "✗" => "[ ]"
- }
-
- @doc """
- Converts an RGB color to the nearest 256-color palette index.
-
- Returns an integer 0-255.
- """
- @spec rgb_to_256(non_neg_integer(), non_neg_integer(), non_neg_integer()) :: 0..255
- def rgb_to_256(r, g, b) when r in 0..255 and g in 0..255 and b in 0..255 do
- # Check grayscale first (232-255)
- if grayscale?(r, g, b) do
- gray_index = round((r + g + b) / 3 / 255 * 23)
- 232 + min(23, gray_index)
- else
- # Use 6x6x6 color cube (16-231)
- r_idx = color_to_cube_index(r)
- g_idx = color_to_cube_index(g)
- b_idx = color_to_cube_index(b)
- 16 + 36 * r_idx + 6 * g_idx + b_idx
- end
- end
-
- @doc """
- Converts an RGB color to the nearest 16-color ANSI index.
-
- Returns an integer 0-15.
- """
- @spec rgb_to_16(non_neg_integer(), non_neg_integer(), non_neg_integer()) :: 0..15
- def rgb_to_16(r, g, b) when r in 0..255 and g in 0..255 and b in 0..255 do
- {best_index, _distance} =
- @ansi_colors
- |> Enum.map(fn {index, {ar, ag, ab}} ->
- distance = color_distance(r, g, b, ar, ag, ab)
- {index, distance}
- end)
- |> Enum.min_by(fn {_index, distance} -> distance end)
-
- best_index
- end
-
- @doc """
- Converts a 256-color index to the nearest 16-color ANSI index.
-
- Returns an integer 0-15.
- """
- @spec color_256_to_16(0..255) :: 0..15
- def color_256_to_16(index) when index in 0..15 do
- # Already a 16-color index
- index
- end
-
- def color_256_to_16(index) when index in 16..231 do
- # 6x6x6 color cube
- cube_index = index - 16
- r = rem(div(cube_index, 36), 6) * 51
- g = rem(div(cube_index, 6), 6) * 51
- b = rem(cube_index, 6) * 51
- rgb_to_16(r, g, b)
- end
-
- def color_256_to_16(index) when index in 232..255 do
- # Grayscale ramp
- gray = (index - 232) * 10 + 8
- rgb_to_16(gray, gray, gray)
- end
-
- @doc """
- Converts a Unicode character to its ASCII fallback.
-
- Returns the original character if no fallback is defined.
- """
- @spec unicode_to_ascii(String.t()) :: String.t()
- def unicode_to_ascii(char) do
- Map.get(@box_drawing_fallbacks, char, char)
- end
-
- @doc """
- Converts a string containing Unicode to ASCII-safe version.
-
- Replaces all known Unicode characters with their ASCII fallbacks.
- """
- @spec string_to_ascii(String.t()) :: String.t()
- def string_to_ascii(string) do
- string
- |> String.graphemes()
- |> Enum.map_join(&unicode_to_ascii/1)
- end
-
- @doc """
- Returns the appropriate color based on terminal capabilities.
-
- Automatically degrades RGB to 256 to 16 based on capability.
- """
- @spec degrade_color(
- non_neg_integer(),
- non_neg_integer(),
- non_neg_integer(),
- TermUI.Capabilities.color_mode()
- ) ::
- {:rgb, non_neg_integer(), non_neg_integer(), non_neg_integer()}
- | {:index_256, 0..255}
- | {:index_16, 0..15}
- | :none
- def degrade_color(r, g, b, color_mode) do
- case color_mode do
- :true_color ->
- {:rgb, r, g, b}
-
- :color_256 ->
- {:index_256, rgb_to_256(r, g, b)}
-
- :color_16 ->
- {:index_16, rgb_to_16(r, g, b)}
-
- :monochrome ->
- :none
- end
- end
-
- # Private helpers
-
- defp grayscale?(r, g, b) do
- # Consider it grayscale if all components are within 8 of each other
- max_val = max(r, max(g, b))
- min_val = min(r, min(g, b))
- max_val - min_val <= 8
- end
-
- defp color_to_cube_index(value) do
- # Map 0-255 to 0-5 for the 6x6x6 color cube
- cond do
- value < 48 -> 0
- value < 115 -> 1
- value < 155 -> 2
- value < 195 -> 3
- value < 235 -> 4
- true -> 5
- end
- end
-
- defp color_distance(r1, g1, b1, r2, g2, b2) do
- # Euclidean distance in RGB space
- dr = r1 - r2
- dg = g1 - g2
- db = b1 - b2
- dr * dr + dg * dg + db * db
- end
-end
diff --git a/lib/term_ui/renderer/cell.ex b/lib/term_ui/cell.ex
similarity index 78%
rename from lib/term_ui/renderer/cell.ex
rename to lib/term_ui/cell.ex
index e67ed4f1..521f0b2e 100644
--- a/lib/term_ui/renderer/cell.ex
+++ b/lib/term_ui/cell.ex
@@ -1,4 +1,4 @@
-defmodule TermUI.Renderer.Cell do
+defmodule TermUI.Cell do
@moduledoc """
Represents a single cell in the terminal screen buffer.
@@ -27,12 +27,11 @@ defmodule TermUI.Renderer.Cell do
- `:strikethrough` - Strikethrough text
"""
- # Dialyzer: named_colors/0 and valid_attributes/0 return specific lists
- # from module attributes, not general atom() lists.
- # wide_placeholder/1 returns specific struct, not general t().
- @dialyzer {:nowarn_function, named_colors: 0, valid_attributes: 0, wide_placeholder: 1}
+ # Dialyzer does not preserve MapSet's opaque type through the generated
+ # defstruct defaults.
+ @dialyzer {:nowarn_function, empty: 0}
- @type color :: :default | atom() | 0..255 | {0..255, 0..255, 0..255}
+ @type color :: TermUI.Style.named_color() | 0..255 | {0..255, 0..255, 0..255}
@type attribute ::
:bold | :dim | :italic | :underline | :blink | :reverse | :hidden | :strikethrough
@@ -42,17 +41,10 @@ defmodule TermUI.Renderer.Cell do
fg: color(),
bg: color(),
attrs: MapSet.t(attribute()),
- width: 1 | 2,
+ width: 0 | 1 | 2,
wide_placeholder: boolean()
}
- defstruct char: " ",
- fg: :default,
- bg: :default,
- attrs: MapSet.new(),
- width: 1,
- wide_placeholder: false
-
@valid_attributes [:bold, :dim, :italic, :underline, :blink, :reverse, :hidden, :strikethrough]
@named_colors [
@@ -74,6 +66,51 @@ defmodule TermUI.Renderer.Cell do
:bright_white
]
+ @color_channel Zoi.integer() |> Zoi.gte(0) |> Zoi.lte(255)
+ @color_schema Zoi.union([
+ Zoi.enum([:default | @named_colors]),
+ @color_channel,
+ Zoi.tuple({@color_channel, @color_channel, @color_channel})
+ ])
+
+ @schema Zoi.struct(__MODULE__, %{
+ char: Zoi.string() |> Zoi.default(" "),
+ fg: @color_schema |> Zoi.default(:default),
+ bg: @color_schema |> Zoi.default(:default),
+ attrs:
+ Zoi.map_set(Zoi.enum(@valid_attributes))
+ |> Zoi.default(MapSet.new()),
+ width: Zoi.enum([0, 1, 2]) |> Zoi.default(1),
+ wide_placeholder: Zoi.boolean() |> Zoi.default(false)
+ })
+ |> Zoi.refine({__MODULE__, :validate_schema, []})
+
+ @enforce_keys Zoi.Struct.enforce_keys(@schema)
+ defstruct Zoi.Struct.struct_fields(@schema)
+
+ @doc "Returns the Zoi schema for terminal cells."
+ @spec schema() :: Zoi.schema()
+ def schema, do: @schema
+
+ @doc false
+ @spec validate_schema(t(), keyword()) :: :ok | {:error, String.t()}
+ def validate_schema(%__MODULE__{char: "", width: 0, wide_placeholder: true}, _opts), do: :ok
+
+ def validate_schema(%__MODULE__{char: char, width: width, wide_placeholder: false}, _opts) do
+ case String.graphemes(char) do
+ [_grapheme] ->
+ if width in [1, 2] and width == calculate_width(char),
+ do: :ok,
+ else: {:error, "cell character, width, and placeholder fields are inconsistent"}
+
+ _other ->
+ {:error, "cell character, width, and placeholder fields are inconsistent"}
+ end
+ end
+
+ def validate_schema(%__MODULE__{}, _opts),
+ do: {:error, "cell character, width, and placeholder fields are inconsistent"}
+
@doc """
Creates a new cell with the given character and optional styling.
@@ -140,7 +177,7 @@ defmodule TermUI.Renderer.Cell do
# Calculate display width using DisplayWidth module
defp calculate_width(char) do
- alias TermUI.Renderer.DisplayWidth
+ alias TermUI.DisplayWidth
width = DisplayWidth.width(char)
# Clamp to 1 or 2 for cell width
cond do
@@ -160,10 +197,9 @@ defmodule TermUI.Renderer.Cell do
iex> Cell.empty()
%Cell{char: " ", fg: :default, bg: :default, attrs: MapSet.new()}
"""
- @dialyzer {:nowarn_function, empty: 0}
@spec empty() :: t()
def empty do
- %__MODULE__{}
+ %__MODULE__{attrs: MapSet.new()}
end
@doc """
@@ -220,7 +256,8 @@ defmodule TermUI.Renderer.Cell do
"""
@spec put_char(t(), String.t()) :: t()
def put_char(%__MODULE__{} = cell, char) when is_binary(char) do
- %{cell | char: sanitize_char(char)}
+ char = sanitize_char(char)
+ %{cell | char: char, width: calculate_width(char), wide_placeholder: false}
end
@doc """
@@ -266,13 +303,13 @@ defmodule TermUI.Renderer.Cell do
@doc """
Returns list of valid color names.
"""
- @spec named_colors() :: [atom()]
+ @spec named_colors() :: nonempty_list(atom())
def named_colors, do: @named_colors
@doc """
Returns list of valid attributes.
"""
- @spec valid_attributes() :: [attribute()]
+ @spec valid_attributes() :: nonempty_list(attribute())
def valid_attributes, do: @valid_attributes
# Private helpers
@@ -304,14 +341,16 @@ defmodule TermUI.Renderer.Cell do
# Sanitize character to prevent escape sequence injection
# Removes control characters (0x00-0x1F except space, 0x7F) and escape sequences
defp sanitize_char(char) when is_binary(char) do
- char
- # Strip ANSI escape sequences first
- |> strip_escape_sequences()
- # Remove control characters while preserving valid Unicode
- |> filter_control_chars()
- |> case do
- "" -> " "
- sanitized -> sanitized
+ sanitized =
+ char
+ # Strip ANSI escape sequences first
+ |> strip_escape_sequences()
+ # Remove control characters while preserving valid Unicode
+ |> filter_control_chars()
+
+ case String.graphemes(sanitized) do
+ [grapheme | _rest] -> grapheme
+ [] -> " "
end
end
diff --git a/lib/term_ui/character_set.ex b/lib/term_ui/character_set.ex
index 9e54d3b1..5b256bff 100644
--- a/lib/term_ui/character_set.ex
+++ b/lib/term_ui/character_set.ex
@@ -39,6 +39,7 @@ defmodule TermUI.CharacterSet do
- `bar_empty` - Empty/light block for unfilled progress
- `bar_levels` - List of characters for fractional progress (8 levels Unicode, 5 ASCII)
- `sparkline_levels` - List of vertical bar characters for sparklines
+ - `spinner_frames` - List of animation frames for spinners
### Indicators
- `check` - Check mark for success/selected
@@ -78,9 +79,6 @@ defmodule TermUI.CharacterSet do
- `:ascii` - ASCII fallback characters
"""
- # Dialyzer: Functions return specific list types
- @dialyzer {:nowarn_function, keys: 0}
-
@type charset :: :unicode | :ascii
@typedoc """
@@ -138,6 +136,7 @@ defmodule TermUI.CharacterSet do
info: String.t(),
warning: String.t(),
loading: String.t(),
+ spinner_frames: [String.t()],
# Misc
ellipsis: String.t(),
dot: String.t()
@@ -197,6 +196,7 @@ defmodule TermUI.CharacterSet do
info: "ℹ",
warning: "⚠",
loading: "⟳",
+ spinner_frames: ["⣾", "⣽", "⣻", "⢿", "⡿", "⣟", "⣯", "⣷"],
# Misc
ellipsis: "…",
dot: "•"
@@ -255,6 +255,7 @@ defmodule TermUI.CharacterSet do
info: "i",
warning: "!",
loading: "*",
+ spinner_frames: ["|", "/", "-", "\\"],
# Misc (ASCII)
ellipsis: "...",
dot: "*"
@@ -295,8 +296,7 @@ defmodule TermUI.CharacterSet do
@doc """
Returns the currently configured character set type.
- Reads from persistent_term via PersistentTerms (set by Runtime),
- falling back to application config. Defaults to `:unicode` if neither is configured.
+ Reads application configuration and defaults to `:unicode`.
## Returns
@@ -307,13 +307,18 @@ defmodule TermUI.CharacterSet do
iex> TermUI.CharacterSet.current()
:unicode
- # After Runtime sets it based on capabilities
- iex> :persistent_term.put(:term_ui_character_set, :ascii)
+ iex> Application.put_env(:term_ui, :character_set, :ascii)
iex> TermUI.CharacterSet.current()
:ascii
+ iex> Application.delete_env(:term_ui, :character_set)
"""
@spec current() :: charset()
- def current, do: TermUI.PersistentTerms.character_set()
+ def current do
+ case Application.get_env(:term_ui, :character_set, :unicode) do
+ character_set when character_set in [:unicode, :ascii] -> character_set
+ _invalid -> :unicode
+ end
+ end
@doc """
Returns the current character set as a map.
@@ -357,7 +362,7 @@ defmodule TermUI.CharacterSet do
iex> :tl in TermUI.CharacterSet.keys()
true
"""
- @spec keys() :: [atom()]
+ @spec keys() :: nonempty_list(atom())
def keys, do: @charset_keys
# ----------------------------------------------------------------------------
diff --git a/lib/term_ui/clipboard.ex b/lib/term_ui/clipboard.ex
index d144e8f5..5908e881 100644
--- a/lib/term_ui/clipboard.ex
+++ b/lib/term_ui/clipboard.ex
@@ -1,247 +1,101 @@
defmodule TermUI.Clipboard do
@moduledoc """
- Clipboard integration for TermUI applications.
+ Bounded OSC 52 clipboard commands.
- Provides clipboard writing via OSC 52 escape sequences and
- paste event handling. Clipboard operations work across terminals
- that support these features.
+ `copy/2` and `clear/1` return `TermUI.Command` data. The runtime sends the
+ operation to its backend owner, so clipboard output cannot race with frame
+ output. This module never writes directly to an IO device. The result mapper
+ receives `:ok` or `{:error, reason}`.
- ## Usage
-
- # Write to clipboard
- Clipboard.write("text to copy")
-
- # Check OSC 52 support
- if Clipboard.osc52_supported?() do
- Clipboard.write(content)
- end
-
- # Enable bracketed paste mode
- IO.write(Clipboard.bracketed_paste_on())
+ Clipboard content has a default 100,000-byte limit. Set `:max_bytes` to a
+ positive integer to change the limit. Set `:target` to `:clipboard`,
+ `:primary`, or `:secondary`.
"""
- # OSC 52 clipboard sequence
- # Format: ESC ] 52 ; ; ST
- # Target: c = clipboard, p = primary selection
+ alias TermUI.Clipboard.Operation
+ alias TermUI.Command
+
@osc52_prefix "\e]52;"
@osc52_suffix "\e\\"
-
- # Bracketed paste mode
- @bracketed_paste_on "\e[?2004h"
- @bracketed_paste_off "\e[?2004l"
-
- # Paste markers
- @paste_start "\e[200~"
- @paste_end "\e[201~"
-
- @doc """
- Returns escape sequence to enable bracketed paste mode.
- """
- @spec bracketed_paste_on() :: String.t()
- def bracketed_paste_on, do: @bracketed_paste_on
-
- @doc """
- Returns escape sequence to disable bracketed paste mode.
- """
- @spec bracketed_paste_off() :: String.t()
- def bracketed_paste_off, do: @bracketed_paste_off
-
- @doc """
- Returns the paste start marker sequence.
- """
- @spec paste_start_marker() :: String.t()
- def paste_start_marker, do: @paste_start
-
- @doc """
- Returns the paste end marker sequence.
- """
- @spec paste_end_marker() :: String.t()
- def paste_end_marker, do: @paste_end
-
- @doc """
- Generates OSC 52 escape sequence to write to clipboard.
-
- Returns the escape sequence string that should be written to
- the terminal to set the clipboard content.
-
- ## Options
-
- - `:target` - Clipboard target: `:clipboard` (default) or `:primary`
-
- ## Examples
-
- iex> Clipboard.write_sequence("hello")
- "\\e]52;c;aGVsbG8=\\e\\\\"
-
- iex> Clipboard.write_sequence("test", target: :primary)
- "\\e]52;p;dGVzdA==\\e\\\\"
- """
- @spec write_sequence(String.t(), keyword()) :: String.t()
- def write_sequence(content, opts \\ []) do
- target = Keyword.get(opts, :target, :clipboard)
- target_char = target_to_char(target)
- encoded = Base.encode64(content)
-
- @osc52_prefix <> target_char <> ";" <> encoded <> @osc52_suffix
+ @default_max_bytes 100_000
+ @targets [:clipboard, :primary, :secondary]
+
+ @doc "Creates a bounded clipboard write operation."
+ @spec operation(term(), keyword()) :: Operation.t()
+ def operation(content, opts \\ []) do
+ %Operation{
+ kind: :write,
+ target: target!(opts),
+ content: to_string(content),
+ max_bytes: max_bytes!(opts)
+ }
end
- @doc """
- Writes content to the system clipboard via OSC 52.
-
- This writes the escape sequence directly to the terminal.
- Returns `:ok` on success.
-
- ## Options
-
- - `:target` - Clipboard target: `:clipboard` (default) or `:primary`
- """
- @spec write(String.t(), keyword()) :: :ok
- def write(content, opts \\ []) do
- sequence = write_sequence(content, opts)
- IO.write(sequence)
- :ok
+ @doc "Creates a clipboard clear operation."
+ @spec clear_operation(keyword()) :: Operation.t()
+ def clear_operation(opts \\ []) do
+ %Operation{
+ kind: :clear,
+ target: target!(opts),
+ content: "",
+ max_bytes: max_bytes!(opts)
+ }
end
- @doc """
- Checks if OSC 52 clipboard is likely supported.
-
- This is a heuristic check based on terminal type. Some terminals
- support OSC 52 but don't advertise it; others advertise but block it.
-
- Known supporting terminals:
- - xterm (with allowWindowOps)
- - Alacritty
- - Kitty
- - WezTerm
- - iTerm2
- - foot
- """
- @spec osc52_supported?() :: boolean()
- def osc52_supported? do
- term = System.get_env("TERM", "")
- term_program = System.get_env("TERM_PROGRAM", "")
-
- cond do
- # Known good terminals
- String.contains?(term_program, "iTerm") -> true
- String.contains?(term_program, "Alacritty") -> true
- String.contains?(term_program, "WezTerm") -> true
- System.get_env("KITTY_WINDOW_ID") != nil -> true
- # xterm and derivatives often support it
- String.starts_with?(term, "xterm") -> true
- # foot terminal
- term == "foot" or term == "foot-extra" -> true
- # Conservative default - assume not supported
- true -> false
- end
+ @doc "Creates a runtime command that copies text through the active backend."
+ @spec copy(term(), keyword()) :: Command.clipboard_command()
+ def copy(content, opts \\ []) do
+ {mapper, operation_opts} = Keyword.pop(opts, :on_result, &{:clipboard_result, &1})
+ Command.clipboard(operation(content, operation_opts), mapper)
end
- @doc """
- Generates OSC 52 sequence to clear the clipboard.
- """
- @spec clear_sequence(keyword()) :: String.t()
- def clear_sequence(opts \\ []) do
- target = Keyword.get(opts, :target, :clipboard)
- target_char = target_to_char(target)
-
- # Empty base64 clears the selection
- @osc52_prefix <> target_char <> ";" <> @osc52_suffix
- end
-
- @doc """
- Clears the system clipboard via OSC 52.
- """
- @spec clear(keyword()) :: :ok
+ @doc "Creates a runtime command that clears a terminal clipboard target."
+ @spec clear(keyword()) :: Command.clipboard_command()
def clear(opts \\ []) do
- sequence = clear_sequence(opts)
- IO.write(sequence)
- :ok
+ {mapper, operation_opts} = Keyword.pop(opts, :on_result, &{:clipboard_result, &1})
+ Command.clipboard(clear_operation(operation_opts), mapper)
end
- # Private functions
-
- defp target_to_char(:clipboard), do: "c"
- defp target_to_char(:primary), do: "p"
- defp target_to_char(:secondary), do: "s"
- defp target_to_char(target) when is_binary(target), do: target
-end
+ @doc "Encodes an OSC 52 operation without performing IO."
+ @spec sequence(Operation.t()) :: {:ok, String.t()} | {:error, term()}
+ def sequence(%Operation{kind: kind, content: content, max_bytes: maximum} = operation) do
+ size = byte_size(content)
-defmodule TermUI.Clipboard.PasteAccumulator do
- @moduledoc """
- Accumulates bracketed paste content.
-
- Handles the state machine for collecting paste content between
- paste start and end markers. Supports timeout for incomplete pastes.
- """
-
- @type t :: %__MODULE__{
- accumulating: boolean(),
- content: String.t(),
- started_at: integer() | nil
- }
-
- defstruct accumulating: false,
- content: "",
- started_at: nil
-
- @doc """
- Creates a new paste accumulator.
- """
- @spec new() :: t()
- def new do
- %__MODULE__{}
+ if size > maximum do
+ {:error, {:clipboard_too_large, size, maximum}}
+ else
+ payload = if kind == :clear, do: "", else: Base.encode64(content)
+ {:ok, @osc52_prefix <> target_code(operation.target) <> ";" <> payload <> @osc52_suffix}
+ end
end
- @doc """
- Starts accumulating paste content.
- """
- @spec start(t()) :: t()
- def start(%__MODULE__{} = acc) do
- %{acc | accumulating: true, content: "", started_at: System.monotonic_time(:millisecond)}
- end
+ @doc "Returns true when the current terminal is likely to support OSC 52."
+ @spec osc52_supported?() :: boolean()
+ def osc52_supported? do
+ term = System.get_env("TERM", "")
+ program = System.get_env("TERM_PROGRAM", "")
- @doc """
- Adds content to the accumulator.
- """
- @spec add(t(), String.t()) :: t()
- def add(%__MODULE__{accumulating: true} = acc, content) do
- %{acc | content: acc.content <> content}
+ String.contains?(program, ["iTerm", "Alacritty", "WezTerm", "Apple_Terminal"]) or
+ System.get_env("KITTY_WINDOW_ID") != nil or String.starts_with?(term, "xterm") or
+ term in ["foot", "foot-extra"]
end
- def add(%__MODULE__{} = acc, _content), do: acc
+ defp target!(opts) do
+ target = Keyword.get(opts, :target, :clipboard)
- @doc """
- Completes accumulation and returns the content.
- """
- @spec complete(t()) :: {String.t(), t()}
- def complete(%__MODULE__{accumulating: true, content: content} = _acc) do
- {content, new()}
+ if target in @targets,
+ do: target,
+ else: raise(ArgumentError, "clipboard target must be :clipboard, :primary, or :secondary")
end
- def complete(%__MODULE__{} = acc), do: {"", acc}
-
- @doc """
- Checks if currently accumulating.
- """
- @spec accumulating?(t()) :: boolean()
- def accumulating?(%__MODULE__{accumulating: acc}), do: acc
-
- @doc """
- Checks if paste has timed out.
-
- Default timeout is 5000ms.
- """
- @spec timed_out?(t(), integer()) :: boolean()
- def timed_out?(%__MODULE__{accumulating: false}, _timeout), do: false
-
- def timed_out?(%__MODULE__{started_at: started_at}, timeout) do
- now = System.monotonic_time(:millisecond)
- now - started_at >= timeout
+ defp max_bytes!(opts) do
+ case Keyword.get(opts, :max_bytes, @default_max_bytes) do
+ maximum when is_integer(maximum) and maximum > 0 -> maximum
+ other -> raise ArgumentError, "clipboard max_bytes must be positive, got: #{inspect(other)}"
+ end
end
- @doc """
- Resets the accumulator, discarding any partial content.
- """
- @spec reset(t()) :: t()
- def reset(%__MODULE__{} = _acc), do: new()
+ defp target_code(:clipboard), do: "c"
+ defp target_code(:primary), do: "p"
+ defp target_code(:secondary), do: "s"
end
diff --git a/lib/term_ui/clipboard/operation.ex b/lib/term_ui/clipboard/operation.ex
new file mode 100644
index 00000000..92b7e47c
--- /dev/null
+++ b/lib/term_ui/clipboard/operation.ex
@@ -0,0 +1,25 @@
+defmodule TermUI.Clipboard.Operation do
+ @moduledoc "Clipboard operation data for one terminal backend."
+
+ @type target :: :clipboard | :primary | :secondary
+ @type t :: %__MODULE__{
+ kind: :write | :clear,
+ target: target(),
+ content: String.t(),
+ max_bytes: pos_integer()
+ }
+
+ @schema Zoi.struct(__MODULE__, %{
+ kind: Zoi.enum([:write, :clear]),
+ target: Zoi.enum([:clipboard, :primary, :secondary]) |> Zoi.default(:clipboard),
+ content: Zoi.string() |> Zoi.default(""),
+ max_bytes: Zoi.integer() |> Zoi.positive() |> Zoi.default(100_000)
+ })
+
+ @enforce_keys Zoi.Struct.enforce_keys(@schema)
+ defstruct Zoi.Struct.struct_fields(@schema)
+
+ @doc "Returns the Zoi schema for clipboard operations."
+ @spec schema() :: Zoi.schema()
+ def schema, do: @schema
+end
diff --git a/lib/term_ui/clipboard/selection.ex b/lib/term_ui/clipboard/selection.ex
deleted file mode 100644
index d808a13a..00000000
--- a/lib/term_ui/clipboard/selection.ex
+++ /dev/null
@@ -1,329 +0,0 @@
-defmodule TermUI.Clipboard.Selection do
- @moduledoc """
- Selection state management for clipboard operations.
-
- Tracks text selection with start and end positions, supporting
- selection expansion with Shift+arrow keys and clearing on
- navigation without Shift.
-
- ## Usage
-
- # Create selection
- selection = Selection.new()
-
- # Start selection at cursor
- selection = Selection.start(selection, 5)
-
- # Extend selection
- selection = Selection.extend(selection, 10)
-
- # Get selected range
- {start, finish} = Selection.range(selection)
-
- # Extract content
- selected_text = Selection.extract(selection, "Hello World")
- """
-
- # Dialyzer: Functions return specific struct types
- @dialyzer {:nowarn_function, new: 0}
-
- @type t :: %__MODULE__{
- start_pos: integer() | nil,
- end_pos: integer() | nil,
- anchor: integer() | nil,
- active: boolean()
- }
-
- defstruct start_pos: nil,
- end_pos: nil,
- anchor: nil,
- active: false
-
- @doc """
- Creates a new empty selection.
- """
- @spec new() :: t()
- def new do
- %__MODULE__{}
- end
-
- @doc """
- Starts a new selection at the given position.
-
- This sets the anchor point for the selection.
- """
- @spec start(t(), integer()) :: t()
- def start(%__MODULE__{} = _selection, position) do
- %__MODULE__{
- start_pos: position,
- end_pos: position,
- anchor: position,
- active: true
- }
- end
-
- @doc """
- Extends the selection to a new position.
-
- The selection extends from the anchor to the new position.
- """
- @spec extend(t(), integer()) :: t()
- def extend(%__MODULE__{active: false} = selection, position) do
- start(selection, position)
- end
-
- def extend(%__MODULE__{anchor: anchor} = selection, position) do
- {start_pos, end_pos} = if position < anchor, do: {position, anchor}, else: {anchor, position}
-
- %{selection | start_pos: start_pos, end_pos: end_pos}
- end
-
- @doc """
- Clears the selection.
- """
- @spec clear(t()) :: t()
- def clear(%__MODULE__{} = _selection) do
- new()
- end
-
- @doc """
- Checks if there is an active selection.
- """
- @spec active?(t()) :: boolean()
- def active?(%__MODULE__{active: active}), do: active
-
- @doc """
- Checks if the selection is empty (start equals end).
- """
- @spec empty?(t()) :: boolean()
- def empty?(%__MODULE__{active: false}), do: true
- def empty?(%__MODULE__{start_pos: start, end_pos: finish}), do: start == finish
-
- @doc """
- Returns the selection range as {start, end}.
-
- Returns `nil` if no selection is active.
- """
- @spec range(t()) :: {integer(), integer()} | nil
- def range(%__MODULE__{active: false}), do: nil
- def range(%__MODULE__{start_pos: start, end_pos: finish}), do: {start, finish}
-
- @doc """
- Returns the length of the selection.
- """
- @spec length(t()) :: integer()
- def length(%__MODULE__{active: false}), do: 0
- def length(%__MODULE__{start_pos: start, end_pos: finish}), do: finish - start
-
- @doc """
- Extracts selected content from a string.
-
- Returns empty string if no selection is active.
- """
- @spec extract(t(), String.t()) :: String.t()
- def extract(%__MODULE__{active: false}, _text), do: ""
-
- def extract(%__MODULE__{start_pos: start, end_pos: finish}, text) do
- String.slice(text, start, finish - start)
- end
-
- @doc """
- Checks if a position is within the selection.
- """
- @spec contains?(t(), integer()) :: boolean()
- def contains?(%__MODULE__{active: false}, _position), do: false
-
- def contains?(%__MODULE__{start_pos: start, end_pos: finish}, position) do
- position >= start and position < finish
- end
-
- @doc """
- Moves the selection by a delta.
-
- Both start and end positions are adjusted.
- """
- @spec move(t(), integer()) :: t()
- def move(%__MODULE__{active: false} = selection, _delta), do: selection
-
- def move(%__MODULE__{start_pos: start, end_pos: finish, anchor: anchor} = selection, delta) do
- %{selection | start_pos: start + delta, end_pos: finish + delta, anchor: anchor + delta}
- end
-
- @doc """
- Expands the selection in a direction.
-
- Direction can be `:left`, `:right`, `:word_left`, `:word_right`,
- `:line_start`, `:line_end`, `:all`.
- """
- @spec expand(t(), atom(), String.t(), integer()) :: t()
- def expand(%__MODULE__{} = selection, direction, text, cursor_pos) do
- new_pos = calculate_expansion(direction, text, cursor_pos)
-
- if active?(selection) do
- extend(selection, new_pos)
- else
- selection
- |> start(cursor_pos)
- |> extend(new_pos)
- end
- end
-
- @doc """
- Selects all text.
- """
- @spec select_all(t(), String.t()) :: t()
- def select_all(%__MODULE__{} = _selection, text) do
- len = String.length(text)
-
- %__MODULE__{
- start_pos: 0,
- end_pos: len,
- anchor: 0,
- active: true
- }
- end
-
- @doc """
- Selects a word at the given position.
- """
- @spec select_word(t(), String.t(), integer()) :: t()
- def select_word(%__MODULE__{} = _selection, text, position) do
- {word_start, word_end} = find_word_bounds(text, position)
-
- %__MODULE__{
- start_pos: word_start,
- end_pos: word_end,
- anchor: word_start,
- active: true
- }
- end
-
- # Private functions
-
- defp calculate_expansion(:left, _text, cursor_pos) do
- max(0, cursor_pos - 1)
- end
-
- defp calculate_expansion(:right, text, cursor_pos) do
- min(String.length(text), cursor_pos + 1)
- end
-
- defp calculate_expansion(:word_left, text, cursor_pos) do
- find_word_boundary_left(text, cursor_pos)
- end
-
- defp calculate_expansion(:word_right, text, cursor_pos) do
- find_word_boundary_right(text, cursor_pos)
- end
-
- defp calculate_expansion(:line_start, _text, _cursor_pos) do
- 0
- end
-
- defp calculate_expansion(:line_end, text, _cursor_pos) do
- String.length(text)
- end
-
- defp calculate_expansion(:all, text, _cursor_pos) do
- String.length(text)
- end
-
- defp find_word_boundary_left(text, position) do
- text
- |> String.slice(0, position)
- |> String.reverse()
- |> find_word_start()
- |> then(&(position - &1))
- end
-
- defp find_word_boundary_right(text, position) do
- text
- |> String.slice(position, String.length(text) - position)
- |> find_word_end()
- |> then(&(position + &1))
- end
-
- defp find_word_start(reversed_text) do
- # Skip whitespace, then find word characters
- reversed_text
- |> String.graphemes()
- |> Enum.reduce_while({0, :skip_space}, fn char, {count, state} ->
- cond do
- state == :skip_space and whitespace?(char) ->
- {:cont, {count + 1, :skip_space}}
-
- state == :skip_space and word_char?(char) ->
- {:cont, {count + 1, :in_word}}
-
- state == :in_word and word_char?(char) ->
- {:cont, {count + 1, :in_word}}
-
- true ->
- {:halt, {count, :done}}
- end
- end)
- |> elem(0)
- end
-
- defp find_word_end(text) do
- text
- |> String.graphemes()
- |> Enum.reduce_while({0, :skip_space}, fn char, {count, state} ->
- cond do
- state == :skip_space and whitespace?(char) ->
- {:cont, {count + 1, :skip_space}}
-
- state == :skip_space and word_char?(char) ->
- {:cont, {count + 1, :in_word}}
-
- state == :in_word and word_char?(char) ->
- {:cont, {count + 1, :in_word}}
-
- true ->
- {:halt, {count, :done}}
- end
- end)
- |> elem(0)
- end
-
- defp find_word_bounds(text, position) do
- # Find start of word
- word_start =
- text
- |> String.slice(0, position)
- |> String.reverse()
- |> then(fn prefix ->
- len =
- prefix
- |> String.graphemes()
- |> Enum.take_while(&word_char?/1)
- |> Kernel.length()
-
- position - len
- end)
-
- # Find end of word
- word_end =
- text
- |> String.slice(position, String.length(text) - position)
- |> then(fn suffix ->
- len =
- suffix
- |> String.graphemes()
- |> Enum.take_while(&word_char?/1)
- |> Kernel.length()
-
- position + len
- end)
-
- {word_start, word_end}
- end
-
- defp word_char?(char) do
- String.match?(char, ~r/\w/)
- end
-
- defp whitespace?(char) do
- String.match?(char, ~r/\s/)
- end
-end
diff --git a/lib/term_ui/command.ex b/lib/term_ui/command.ex
index 5878dcd6..ca0b47c5 100644
--- a/lib/term_ui/command.ex
+++ b/lib/term_ui/command.ex
@@ -1,231 +1,118 @@
defmodule TermUI.Command do
@moduledoc """
- Commands represent side effects to be performed by the runtime.
+ Data that asks the runtime to do work outside an Elm update.
- Commands are data describing effects - they don't execute immediately.
- The runtime interprets commands and performs the actual effects,
- sending result messages back to components.
-
- ## Command Types
-
- - `:timer` - Deliver message after delay
- - `:interval` - Deliver repeated messages at interval
- - `:file_read` - Read file contents
- - `:send_after` - Send message to component after delay
- - `:quit` - Request application shutdown
- - `:none` - No-op command (useful for conditional commands)
-
- ## Usage
-
- # In component update function
- def update(:start_timer, state) do
- cmd = Command.timer(1000, :timer_fired)
- {%{state | timer_active: true}, [cmd]}
- end
-
- def update(:timer_fired, state) do
- {%{state | timer_active: false, count: state.count + 1}, []}
- end
+ Commands do not contain component identifiers. A runtime delivers command
+ results to its one application state.
"""
- # Dialyzer: Command constructors return specific struct types with known
- # type: atoms, but the public spec uses the general t() type for API clarity.
- @dialyzer {:nowarn_function,
- timer: 2, interval: 2, file_read: 2, send_after: 3, quit: 1, none: 0, valid?: 1}
-
- @type t :: %__MODULE__{
- id: reference() | nil,
- type: atom(),
- payload: term(),
- on_result: term(),
- timeout: pos_integer() | :infinity
+ @type kind :: :message | :send | :timer | :async | :clipboard | :shutdown
+ @type message_command :: %__MODULE__{kind: :message, value: term()}
+ @type send_command :: %__MODULE__{kind: :send, value: {pid(), term()}}
+ @type timer_command :: %__MODULE__{kind: :timer, value: {non_neg_integer(), term()}}
+ @type async_result :: {:ok, term()} | {:error, term()}
+ @type async_command :: %__MODULE__{
+ kind: :async,
+ value: {(-> term()), (async_result() -> term())}
}
-
- @type command_type :: :timer | :interval | :file_read | :send_after | :quit | :none
-
- defstruct [
- :id,
- :type,
- :payload,
- :on_result,
- timeout: :infinity
- ]
-
- @doc """
- Creates a timer command that delivers a message after delay.
-
- ## Examples
-
- Command.timer(1000, :timer_done)
- Command.timer(500, {:tick, 1})
- """
- @spec timer(non_neg_integer(), term()) :: t()
- def timer(delay_ms, on_result) when is_integer(delay_ms) and delay_ms >= 0 do
- %__MODULE__{
- type: :timer,
- payload: delay_ms,
- on_result: on_result
- }
- end
-
- @doc """
- Creates an interval command that delivers repeated messages.
-
- The interval continues until cancelled. Each tick delivers
- the on_result message.
-
- ## Examples
-
- Command.interval(100, :tick)
- """
- @spec interval(pos_integer(), term()) :: t()
- def interval(interval_ms, on_result) when is_integer(interval_ms) and interval_ms > 0 do
- %__MODULE__{
- type: :interval,
- payload: interval_ms,
- on_result: on_result
- }
- end
-
- @doc """
- Creates a file read command.
-
- Returns `{:ok, content}` or `{:error, reason}` wrapped in the on_result message.
-
- ## Examples
-
- Command.file_read("/path/to/file", :file_loaded)
- # Results in: {:file_loaded, {:ok, "contents"}}
- # or: {:file_loaded, {:error, :enoent}}
- """
- @spec file_read(Path.t(), term()) :: t()
- def file_read(path, on_result) when is_binary(path) do
- %__MODULE__{
- type: :file_read,
- payload: path,
- on_result: on_result
- }
- end
-
- @doc """
- Creates a send_after command that sends a message to a component after delay.
-
- Unlike timer which sends to the originating component, send_after
- can target any component.
-
- ## Examples
-
- Command.send_after(:other_component, :wake_up, 1000)
- """
- @spec send_after(atom(), term(), pos_integer()) :: t()
- def send_after(component_id, message, delay_ms)
- when is_atom(component_id) and is_integer(delay_ms) and delay_ms > 0 do
- %__MODULE__{
- type: :send_after,
- payload: {component_id, message, delay_ms},
- on_result: :send_after_complete
- }
- end
-
- @doc """
- Creates a quit command to request application shutdown.
-
- The runtime will initiate graceful shutdown, cleaning up all resources
- and restoring the terminal to its original state.
-
- ## Examples
-
- # Simple quit
- Command.quit()
-
- # Quit with reason
- Command.quit(:normal)
- Command.quit(:user_requested)
- """
- @spec quit(term()) :: t()
- def quit(reason \\ :normal) do
- %__MODULE__{
- type: :quit,
- payload: reason,
- on_result: nil
- }
- end
-
- @doc """
- Creates a no-op command.
-
- Useful for conditional commands where you might not need an effect.
-
- ## Examples
-
- cmd = if should_fetch?, do: Command.timer(100, :fetch), else: Command.none()
- """
- @spec none() :: t()
- def none do
- %__MODULE__{
- type: :none,
- payload: nil,
- on_result: nil
- }
- end
-
- @doc """
- Sets a timeout for command execution.
-
- If the command takes longer than the timeout, it's cancelled
- and an error message is sent.
-
- ## Examples
-
- Command.file_read(path, :loaded)
- |> Command.with_timeout(5000)
- """
- @spec with_timeout(t(), pos_integer()) :: t()
- def with_timeout(%__MODULE__{} = command, timeout_ms)
- when is_integer(timeout_ms) and timeout_ms > 0 do
- %{command | timeout: timeout_ms}
- end
-
- @doc """
- Validates a command structure.
-
- Returns `:ok` if valid, `{:error, reason}` otherwise.
- """
- @spec validate(t()) :: :ok | {:error, term()}
- def validate(%__MODULE__{type: :none}), do: :ok
-
- def validate(%__MODULE__{type: :timer, payload: delay}) when is_integer(delay) and delay >= 0,
- do: :ok
-
- def validate(%__MODULE__{type: :interval, payload: interval})
- when is_integer(interval) and interval > 0,
- do: :ok
-
- def validate(%__MODULE__{type: :file_read, payload: path}) when is_binary(path), do: :ok
-
- def validate(%__MODULE__{type: :send_after, payload: {id, _msg, delay}})
- when is_atom(id) and is_integer(delay) and delay > 0,
- do: :ok
-
- def validate(%__MODULE__{type: :quit}), do: :ok
-
- def validate(%__MODULE__{type: type, payload: payload}) do
- {:error, {:invalid_command, type, payload}}
- end
-
- def validate(_), do: {:error, :not_a_command}
+ @type clipboard_command :: %__MODULE__{
+ kind: :clipboard,
+ value: {TermUI.Clipboard.Operation.t(), (term() -> term())}
+ }
+ @type shutdown_command :: %__MODULE__{kind: :shutdown, value: term()}
+
+ @type t ::
+ message_command()
+ | send_command()
+ | timer_command()
+ | async_command()
+ | clipboard_command()
+ | shutdown_command()
+
+ @struct_schema Zoi.struct(__MODULE__, %{
+ kind: Zoi.enum([:message, :send, :timer, :async, :clipboard, :shutdown]),
+ value: Zoi.any()
+ })
+
+ @enforce_keys Zoi.Struct.enforce_keys(@struct_schema)
+ defstruct Zoi.Struct.struct_fields(@struct_schema)
+
+ @schema Zoi.union([
+ Zoi.struct(__MODULE__, %{
+ kind: Zoi.literal(:message),
+ value: Zoi.any()
+ }),
+ Zoi.struct(__MODULE__, %{
+ kind: Zoi.literal(:send),
+ value: Zoi.tuple({Zoi.pid(), Zoi.any()})
+ }),
+ Zoi.struct(__MODULE__, %{
+ kind: Zoi.literal(:timer),
+ value: Zoi.tuple({Zoi.integer() |> Zoi.non_negative(), Zoi.any()})
+ }),
+ Zoi.struct(__MODULE__, %{
+ kind: Zoi.literal(:async),
+ value: Zoi.tuple({Zoi.function(arity: 0), Zoi.function(arity: 1)})
+ }),
+ Zoi.struct(__MODULE__, %{
+ kind: Zoi.literal(:clipboard),
+ value: Zoi.tuple({TermUI.Clipboard.Operation.schema(), Zoi.function(arity: 1)})
+ }),
+ Zoi.struct(__MODULE__, %{
+ kind: Zoi.literal(:shutdown),
+ value: Zoi.any()
+ })
+ ])
+
+ @doc "Returns the Zoi schema for runtime commands."
+ @spec schema() :: Zoi.schema()
+ def schema, do: @schema
+
+ @doc "Delivers a message to the application on the next runtime turn."
+ @spec message(term()) :: message_command()
+ def message(message), do: %__MODULE__{kind: :message, value: message}
+
+ @doc "Sends a message to another process."
+ @spec send(pid(), term()) :: send_command()
+ def send(pid, message) when is_pid(pid),
+ do: %__MODULE__{kind: :send, value: {pid, message}}
+
+ @doc "Delivers a message to the application after a delay."
+ @spec timer(non_neg_integer(), term()) :: timer_command()
+ def timer(milliseconds, message) when is_integer(milliseconds) and milliseconds >= 0,
+ do: %__MODULE__{kind: :timer, value: {milliseconds, message}}
@doc """
- Checks if a term is a valid command.
- """
- @spec valid?(term()) :: boolean()
- def valid?(term), do: validate(term) == :ok
+ Runs a function and maps one runtime-produced result to an application message.
- @doc """
- Assigns a unique ID to a command for tracking.
+ The function can return any term. The runtime wraps a normal return value as
+ `{:ok, value}` and wraps a raised, thrown, or exited function as
+ `{:error, reason}`. The mapper always receives this one outer result tag. A
+ function return such as `{:ok, value}` therefore reaches the mapper as
+ `{:ok, {:ok, value}}`.
"""
- @spec assign_id(t()) :: t()
- def assign_id(%__MODULE__{} = command) do
- %{command | id: make_ref()}
- end
+ @spec async((-> term()), (async_result() -> term())) :: async_command()
+ def async(function, on_result \\ &{:async_result, &1})
+ when is_function(function, 0) and is_function(on_result, 1),
+ do: %__MODULE__{kind: :async, value: {function, on_result}}
+
+ @doc "Requests a serialized clipboard operation and maps its `:ok` or error result to a message."
+ @spec clipboard(TermUI.Clipboard.Operation.t(), (term() -> term())) :: clipboard_command()
+ def clipboard(%TermUI.Clipboard.Operation{} = operation, on_result \\ &{:clipboard_result, &1})
+ when is_function(on_result, 1),
+ do: %__MODULE__{kind: :clipboard, value: {operation, on_result}}
+
+ @doc "Requests a final render and runtime shutdown."
+ @spec shutdown(term()) :: shutdown_command()
+ def shutdown(reason \\ :normal), do: %__MODULE__{kind: :shutdown, value: reason}
+
+ @doc "Deprecated alias for `shutdown/0`."
+ @deprecated "Use TermUI.Command.shutdown/0 instead."
+ @spec quit() :: shutdown_command()
+ def quit, do: shutdown()
+
+ @doc "Deprecated alias for `shutdown/1`."
+ @deprecated "Use TermUI.Command.shutdown/1 instead."
+ @spec quit(term()) :: shutdown_command()
+ def quit(reason), do: shutdown(reason)
end
diff --git a/lib/term_ui/command/executor.ex b/lib/term_ui/command/executor.ex
deleted file mode 100644
index fb5bbe08..00000000
--- a/lib/term_ui/command/executor.ex
+++ /dev/null
@@ -1,362 +0,0 @@
-defmodule TermUI.Command.Executor do
- @moduledoc """
- Executes commands asynchronously under a Task.Supervisor.
-
- The executor runs commands in isolated tasks, preventing failures
- from crashing the runtime. Results are sent back as messages to
- the originating component.
-
- ## Usage
-
- # Start the executor (usually in application supervision tree)
- {:ok, executor} = Executor.start_link()
-
- # Execute a command
- {:ok, command_id} = Executor.execute(executor, command, runtime_pid, component_id)
-
- # Cancel a running command
- :ok = Executor.cancel(executor, command_id)
- """
-
- use GenServer
-
- alias TermUI.Command
-
- # Dialyzer: Functions with unmatched return values in side-effect calls
- @dialyzer {:nowarn_function, execute_command: 4, handle_call: 3, handle_info: 2}
-
- @type t :: pid()
-
- # Default max concurrent commands
- @default_max_concurrent 100
-
- # --- Public API ---
-
- @doc """
- Starts the command executor.
-
- ## Options
-
- - `:name` - GenServer name (optional)
- - `:max_concurrent` - Maximum concurrent commands (default: 100)
- """
- @spec start_link(keyword()) :: GenServer.on_start()
- def start_link(opts \\ []) do
- {name, opts} = Keyword.pop(opts, :name)
-
- if name do
- GenServer.start_link(__MODULE__, opts, name: name)
- else
- GenServer.start_link(__MODULE__, opts)
- end
- end
-
- @doc """
- Executes a command asynchronously.
-
- Returns the command ID that can be used for cancellation.
- Results are sent to the runtime as `{:command_result, component_id, command_id, result}`.
- """
- @spec execute(t(), Command.t(), pid(), atom()) :: {:ok, reference()} | {:error, term()}
- def execute(executor, %Command{} = command, runtime_pid, component_id) do
- GenServer.call(executor, {:execute, command, runtime_pid, component_id})
- end
-
- @doc """
- Cancels a running command by ID.
- """
- @spec cancel(t(), reference()) :: :ok | {:error, :not_found}
- def cancel(executor, command_id) do
- GenServer.call(executor, {:cancel, command_id})
- end
-
- @doc """
- Cancels all commands for a component.
-
- Used when a component unmounts.
- """
- @spec cancel_all_for_component(t(), atom()) :: :ok
- def cancel_all_for_component(executor, component_id) do
- GenServer.call(executor, {:cancel_all_for_component, component_id})
- end
-
- @doc """
- Returns the number of currently running commands.
- """
- @spec running_count(t()) :: non_neg_integer()
- def running_count(executor) do
- GenServer.call(executor, :running_count)
- end
-
- # --- GenServer Callbacks ---
-
- @impl true
- def init(opts) do
- max_concurrent = Keyword.get(opts, :max_concurrent, @default_max_concurrent)
-
- # Start Task.Supervisor for command execution
- {:ok, task_sup} = Task.Supervisor.start_link()
-
- state = %{
- task_supervisor: task_sup,
- running: %{},
- intervals: %{},
- max_concurrent: max_concurrent
- }
-
- {:ok, state}
- end
-
- @impl true
- def handle_call({:execute, command, runtime_pid, component_id}, _from, state) do
- # Check concurrent limit
- if map_size(state.running) >= state.max_concurrent do
- {:reply, {:error, :max_concurrent_reached}, state}
- else
- # Assign ID if not already assigned
- command = if command.id, do: command, else: Command.assign_id(command)
-
- case execute_command(command, runtime_pid, component_id, state) do
- {:ok, new_state} ->
- {:reply, {:ok, command.id}, new_state}
-
- {:error, reason} ->
- {:reply, {:error, reason}, state}
- end
- end
- end
-
- @impl true
- def handle_call({:cancel, command_id}, _from, state) do
- case Map.get(state.running, command_id) do
- nil ->
- # Check intervals
- case Map.get(state.intervals, command_id) do
- nil ->
- {:reply, {:error, :not_found}, state}
-
- %{timer_ref: timer_ref} ->
- Process.cancel_timer(timer_ref)
- intervals = Map.delete(state.intervals, command_id)
- {:reply, :ok, %{state | intervals: intervals}}
- end
-
- info ->
- Task.Supervisor.terminate_child(state.task_supervisor, info.task.pid)
- running = Map.delete(state.running, command_id)
- {:reply, :ok, %{state | running: running}}
- end
- end
-
- @impl true
- def handle_call({:cancel_all_for_component, component_id}, _from, state) do
- # Cancel all running tasks for the component
- {to_cancel, to_keep} =
- Enum.split_with(state.running, fn {_id, info} ->
- info.component_id == component_id
- end)
-
- Enum.each(to_cancel, fn {_id, info} ->
- Task.Supervisor.terminate_child(state.task_supervisor, info.task.pid)
- end)
-
- # Cancel all intervals for the component
- {intervals_to_cancel, intervals_to_keep} =
- Enum.split_with(state.intervals, fn {_id, info} ->
- is_map(info) and info.component_id == component_id
- end)
-
- Enum.each(intervals_to_cancel, fn {_id, info} ->
- if is_map(info), do: Process.cancel_timer(info.timer_ref)
- end)
-
- state = %{
- state
- | running: Map.new(to_keep),
- intervals: Map.new(intervals_to_keep)
- }
-
- {:reply, :ok, state}
- end
-
- @impl true
- def handle_call(:running_count, _from, state) do
- {:reply, map_size(state.running), state}
- end
-
- @impl true
- def handle_info({ref, result}, state) when is_reference(ref) do
- # Task completed successfully
- case find_by_task_ref(state.running, ref) do
- {command_id, info} ->
- # Demonitor and flush
- Process.demonitor(ref, [:flush])
-
- # Send result to runtime
- send_result(info.runtime_pid, info.component_id, command_id, result)
-
- running = Map.delete(state.running, command_id)
- {:noreply, %{state | running: running}}
-
- nil ->
- {:noreply, state}
- end
- end
-
- @impl true
- def handle_info({:DOWN, ref, :process, _pid, reason}, state) do
- # Task crashed
- case find_by_task_ref(state.running, ref) do
- {command_id, info} ->
- # Send error to runtime
- send_result(info.runtime_pid, info.component_id, command_id, {:error, reason})
-
- running = Map.delete(state.running, command_id)
- {:noreply, %{state | running: running}}
-
- nil ->
- {:noreply, state}
- end
- end
-
- @impl true
- def handle_info(
- {:interval_tick, command_id, runtime_pid, component_id, message, interval_ms},
- state
- ) do
- # Deliver interval message
- send_result(runtime_pid, component_id, command_id, message)
-
- # Schedule next tick
- timer_ref =
- Process.send_after(
- self(),
- {:interval_tick, command_id, runtime_pid, component_id, message, interval_ms},
- interval_ms
- )
-
- intervals =
- Map.put(state.intervals, command_id, %{
- timer_ref: timer_ref,
- component_id: component_id
- })
-
- {:noreply, %{state | intervals: intervals}}
- end
-
- @impl true
- def handle_info({:timeout, command_id}, state) do
- # Command timed out
- case Map.get(state.running, command_id) do
- nil ->
- {:noreply, state}
-
- info ->
- Task.Supervisor.terminate_child(state.task_supervisor, info.task.pid)
- send_result(info.runtime_pid, info.component_id, command_id, {:error, :timeout})
- running = Map.delete(state.running, command_id)
- {:noreply, %{state | running: running}}
- end
- end
-
- # --- Private Functions ---
-
- defp execute_command(%Command{type: :none}, _runtime_pid, _component_id, state) do
- {:ok, state}
- end
-
- defp execute_command(%Command{type: :timer} = cmd, runtime_pid, component_id, state) do
- task =
- Task.Supervisor.async_nolink(state.task_supervisor, fn ->
- Process.sleep(cmd.payload)
- cmd.on_result
- end)
-
- running =
- Map.put(state.running, cmd.id, %{
- task: task,
- runtime_pid: runtime_pid,
- component_id: component_id
- })
-
- # Set timeout if specified
- if cmd.timeout != :infinity do
- Process.send_after(self(), {:timeout, cmd.id}, cmd.timeout)
- end
-
- {:ok, %{state | running: running}}
- end
-
- defp execute_command(%Command{type: :interval} = cmd, runtime_pid, component_id, state) do
- # Schedule first tick
- timer_ref =
- Process.send_after(
- self(),
- {:interval_tick, cmd.id, runtime_pid, component_id, cmd.on_result, cmd.payload},
- cmd.payload
- )
-
- intervals =
- Map.put(state.intervals, cmd.id, %{
- timer_ref: timer_ref,
- component_id: component_id
- })
-
- {:ok, %{state | intervals: intervals}}
- end
-
- defp execute_command(%Command{type: :file_read} = cmd, runtime_pid, component_id, state) do
- task =
- Task.Supervisor.async_nolink(state.task_supervisor, fn ->
- case File.read(cmd.payload) do
- {:ok, content} -> {cmd.on_result, {:ok, content}}
- {:error, reason} -> {cmd.on_result, {:error, reason}}
- end
- end)
-
- running =
- Map.put(state.running, cmd.id, %{
- task: task,
- runtime_pid: runtime_pid,
- component_id: component_id
- })
-
- if cmd.timeout != :infinity do
- Process.send_after(self(), {:timeout, cmd.id}, cmd.timeout)
- end
-
- {:ok, %{state | running: running}}
- end
-
- defp execute_command(%Command{type: :send_after} = cmd, runtime_pid, component_id, state) do
- {target_component, message, delay_ms} = cmd.payload
-
- task =
- Task.Supervisor.async_nolink(state.task_supervisor, fn ->
- Process.sleep(delay_ms)
- # Return the target and message for the runtime to route
- {:send_to, target_component, message}
- end)
-
- running =
- Map.put(state.running, cmd.id, %{
- task: task,
- runtime_pid: runtime_pid,
- component_id: component_id
- })
-
- {:ok, %{state | running: running}}
- end
-
- defp execute_command(%Command{type: type}, _runtime_pid, _component_id, _state) do
- {:error, {:unknown_command_type, type}}
- end
-
- defp send_result(runtime_pid, component_id, command_id, result) do
- send(runtime_pid, {:command_result, component_id, command_id, result})
- end
-
- defp find_by_task_ref(running, ref) do
- Enum.find(running, fn {_id, info} -> info.task.ref == ref end)
- end
-end
diff --git a/lib/term_ui/component.ex b/lib/term_ui/component.ex
deleted file mode 100644
index 37a33fb5..00000000
--- a/lib/term_ui/component.ex
+++ /dev/null
@@ -1,179 +0,0 @@
-defmodule TermUI.Component do
- @moduledoc """
- Base behaviour for all TermUI components.
-
- Components are the building blocks of TermUI applications. This behaviour
- defines the minimal interface that all components must implement.
-
- ## Basic Usage
-
- The simplest component only needs to implement `render/2`:
-
- defmodule MyApp.Label do
- use TermUI.Component
-
- @impl true
- def render(props, _area) do
- text(props[:text] || "")
- end
- end
-
- ## Optional Callbacks
-
- Components can also implement:
-
- - `describe/0` - Returns metadata about the component
- - `default_props/0` - Returns default prop values
-
- ## Render Tree
-
- The `render/2` callback returns a render tree, which can be:
-
- - A `RenderNode` struct
- - A list of render nodes
- - A plain string (converted to text node)
-
- ## Props
-
- Props are passed as a map to the `render/2` callback. Use `default_props/0`
- to define defaults that are merged with passed props.
-
- ## Area
-
- The area parameter defines the available space for rendering:
-
- %{x: integer(), y: integer(), width: integer(), height: integer()}
-
- Components should respect these bounds when producing render output.
- """
-
- alias TermUI.Component.RenderNode
-
- # Type definitions
-
- @typedoc "Render tree output - can be a node, list of nodes, or string"
- @type render_tree :: RenderNode.t() | [render_tree()] | String.t()
-
- @typedoc "Component props passed to render"
- @type props :: map()
-
- @typedoc "Available rendering area"
- @type rect :: %{x: integer(), y: integer(), width: integer(), height: integer()}
-
- @typedoc "Component metadata"
- @type component_info :: %{
- name: String.t(),
- description: String.t() | nil,
- version: String.t() | nil
- }
-
- # Required callbacks
-
- @doc """
- Renders the component given props and available area.
-
- This is the only required callback. It receives the component's props
- and the available rendering area, and must return a render tree.
-
- ## Parameters
-
- - `props` - Map of properties passed to the component
- - `area` - Available rendering area with x, y, width, height
-
- ## Returns
-
- A render tree (RenderNode, list, or string).
-
- ## Examples
-
- @impl true
- def render(props, area) do
- text = props[:text] || ""
- style = props[:style]
-
- if style do
- styled_text(text, style)
- else
- text(text)
- end
- end
- """
- @callback render(props(), rect()) :: render_tree()
-
- # Optional callbacks
-
- @doc """
- Returns metadata about the component.
-
- Useful for introspection, debugging, and documentation generation.
-
- ## Examples
-
- @impl true
- def describe do
- %{
- name: "Label",
- description: "A simple text display component",
- version: "1.0.0"
- }
- end
- """
- @callback describe() :: component_info()
-
- @doc """
- Returns default prop values for the component.
-
- These defaults are merged with props passed to `render/2`,
- with passed props taking precedence.
-
- ## Examples
-
- @impl true
- def default_props do
- %{
- text: "",
- style: nil,
- align: :left
- }
- end
- """
- @callback default_props() :: props()
-
- @optional_callbacks describe: 0, default_props: 0
-
- @doc false
- defmacro __using__(_opts) do
- quote do
- @behaviour TermUI.Component
-
- alias TermUI.Component.RenderNode
- alias TermUI.Renderer.Style
-
- import TermUI.Component.Helpers
-
- # Default implementations for optional callbacks
-
- @doc false
- def describe do
- %{
- name: inspect(__MODULE__),
- description: nil,
- version: nil
- }
- end
-
- @doc false
- def default_props do
- %{}
- end
-
- defoverridable describe: 0, default_props: 0
-
- # Helper to merge default props with passed props
- @doc false
- def merge_props(props) do
- Map.merge(default_props(), props)
- end
- end
- end
-end
diff --git a/lib/term_ui/component/helpers.ex b/lib/term_ui/component/helpers.ex
deleted file mode 100644
index cceeb893..00000000
--- a/lib/term_ui/component/helpers.ex
+++ /dev/null
@@ -1,332 +0,0 @@
-defmodule TermUI.Component.Helpers do
- @moduledoc """
- Common helper functions and macros for TermUI components.
-
- This module is automatically imported when you `use TermUI.Component`.
- It provides convenience functions for building render trees and
- working with props and styles.
-
- ## Render Tree Builders
-
- - `text/1`, `text/2` - Create text nodes
- - `box/1`, `box/2` - Create box containers
- - `stack/2`, `stack/3` - Create stacked layouts
-
- ## Props Helpers
-
- - `props!/2` - Validate and extract required props
-
- ## Style Helpers
-
- - `merge_styles/2` - Merge multiple styles
- - `compute_size/2` - Calculate content dimensions
- """
-
- alias TermUI.Component.RenderNode
- alias TermUI.Renderer.Style
-
- # Render tree builders - delegate to RenderNode
-
- @doc """
- Creates a text node.
-
- ## Examples
-
- text("Hello, World!")
- text("Styled", Style.new() |> Style.fg(:red))
- """
- @spec text(String.t(), Style.t() | nil) :: RenderNode.t()
- defdelegate text(content, style \\ nil), to: RenderNode
-
- @doc """
- Creates a box container.
-
- ## Examples
-
- box([text("Content")])
- box([text("Styled")], style: Style.new() |> Style.bg(:blue))
- """
- @spec box([RenderNode.t()], keyword()) :: RenderNode.t()
- defdelegate box(children, opts \\ []), to: RenderNode
-
- @doc """
- Creates a stack layout.
-
- ## Examples
-
- stack(:vertical, [text("Top"), text("Bottom")])
- stack(:horizontal, [text("Left"), text("Right")])
- """
- @spec stack(RenderNode.direction(), [RenderNode.t()], keyword()) :: RenderNode.t()
- defdelegate stack(direction, children, opts \\ []), to: RenderNode
-
- @doc """
- Creates a styled wrapper around a node.
-
- ## Examples
-
- styled(text("Hello"), Style.new() |> Style.fg(:red))
- """
- @spec styled(RenderNode.t(), Style.t()) :: RenderNode.t()
- defdelegate styled(node, style), to: RenderNode
-
- @doc """
- Creates an empty node.
-
- ## Examples
-
- empty()
- """
- @spec empty() :: RenderNode.t()
- defdelegate empty(), to: RenderNode
-
- # Props validation
-
- @doc """
- Validates and extracts props with type checking and defaults.
-
- Raises `ArgumentError` if required props are missing or types don't match.
-
- ## Spec Format
-
- Each prop spec is a tuple: `{name, type, opts}`
-
- Types: `:string`, `:integer`, `:boolean`, `:atom`, `:any`, `:style`
-
- Options:
- - `:required` - Prop must be present (default: false)
- - `:default` - Default value if not provided
-
- ## Examples
-
- props!(props, [
- {:text, :string, required: true},
- {:count, :integer, default: 0},
- {:enabled, :boolean, default: true}
- ])
- # Returns %{text: "...", count: 0, enabled: true}
- """
- @spec props!(map(), [{atom(), atom(), keyword()}]) :: map()
- def props!(props, specs) when is_map(props) and is_list(specs) do
- Enum.reduce(specs, %{}, fn {name, type, opts}, acc ->
- required = Keyword.get(opts, :required, false)
- default = Keyword.get(opts, :default)
-
- value = extract_prop_value(props, name, type, required, default)
- Map.put(acc, name, value)
- end)
- end
-
- defp extract_prop_value(props, name, type, required, default) do
- case Map.fetch(props, name) do
- {:ok, val} ->
- validate_prop_type!(name, val, type)
- val
-
- :error ->
- handle_missing_prop(name, required, default)
- end
- end
-
- defp handle_missing_prop(name, true, _default) do
- raise ArgumentError, "Required prop #{inspect(name)} is missing"
- end
-
- defp handle_missing_prop(_name, false, default), do: default
-
- defp validate_prop_type!(_name, nil, _type), do: :ok
-
- defp validate_prop_type!(name, value, :string) do
- unless is_binary(value) do
- raise ArgumentError,
- "Prop #{inspect(name)} must be a string, got: #{inspect(value)}"
- end
- end
-
- defp validate_prop_type!(name, value, :integer) do
- unless is_integer(value) do
- raise ArgumentError,
- "Prop #{inspect(name)} must be an integer, got: #{inspect(value)}"
- end
- end
-
- defp validate_prop_type!(name, value, :boolean) do
- unless is_boolean(value) do
- raise ArgumentError,
- "Prop #{inspect(name)} must be a boolean, got: #{inspect(value)}"
- end
- end
-
- defp validate_prop_type!(name, value, :atom) do
- unless is_atom(value) do
- raise ArgumentError,
- "Prop #{inspect(name)} must be an atom, got: #{inspect(value)}"
- end
- end
-
- defp validate_prop_type!(name, value, :style) do
- unless match?(%Style{}, value) do
- raise ArgumentError,
- "Prop #{inspect(name)} must be a Style, got: #{inspect(value)}"
- end
- end
-
- defp validate_prop_type!(_name, _value, :any), do: :ok
-
- # Style helpers
-
- @doc """
- Merges multiple styles in order, with later styles overriding earlier ones.
-
- Follows CSS cascade rules - later values take precedence, attributes combine.
-
- ## Examples
-
- base = Style.new() |> Style.fg(:white)
- override = Style.new() |> Style.fg(:red) |> Style.bold()
- merge_styles([base, override])
- # Result: fg: :red, attrs: [:bold]
- """
- @spec merge_styles([Style.t() | nil]) :: Style.t()
- def merge_styles(styles) when is_list(styles) do
- styles
- |> Enum.reject(&is_nil/1)
- |> Enum.reduce(Style.new(), &Style.merge(&2, &1))
- end
-
- @doc """
- Computes the display size of text content.
-
- Returns `{width, height}` where width is the maximum line length
- and height is the number of lines.
-
- ## Examples
-
- compute_size("Hello")
- # {5, 1}
-
- compute_size("Line 1\\nLine 2")
- # {6, 2}
- """
- @spec compute_size(String.t()) :: {non_neg_integer(), non_neg_integer()}
- def compute_size(text) when is_binary(text) do
- lines = String.split(text, "\n")
- height = length(lines)
-
- width =
- lines
- |> Enum.map(&String.length/1)
- |> Enum.max(fn -> 0 end)
-
- {width, height}
- end
-
- @doc """
- Computes the size of a render node.
-
- For text nodes, returns the text dimensions.
- For containers, returns explicit size or `:auto`.
-
- ## Examples
-
- compute_node_size(text("Hello"))
- # {5, 1}
-
- compute_node_size(box([], width: 20, height: 10))
- # {20, 10}
- """
- @spec compute_node_size(RenderNode.t()) ::
- {non_neg_integer() | :auto, non_neg_integer() | :auto}
- def compute_node_size(%RenderNode{type: :text, content: content}) do
- compute_size(content || "")
- end
-
- def compute_node_size(%RenderNode{type: :empty}) do
- {0, 0}
- end
-
- def compute_node_size(%RenderNode{width: w, height: h}) do
- {w || :auto, h || :auto}
- end
-
- @doc """
- Checks if a value fits within a rect.
-
- ## Examples
-
- fits_in_rect?({10, 5}, %{x: 0, y: 0, width: 20, height: 10})
- # true
-
- fits_in_rect?({30, 5}, %{x: 0, y: 0, width: 20, height: 10})
- # false
- """
- @spec fits_in_rect?({non_neg_integer(), non_neg_integer()}, TermUI.Component.rect()) ::
- boolean()
- def fits_in_rect?({width, height}, %{width: max_width, height: max_height}) do
- width <= max_width and height <= max_height
- end
-
- @doc """
- Truncates text to fit within a given width.
-
- ## Examples
-
- truncate_text("Hello, World!", 5)
- # "Hello"
-
- truncate_text("Hi", 10)
- # "Hi"
- """
- @spec truncate_text(String.t(), non_neg_integer()) :: String.t()
- def truncate_text(text, max_width) when is_binary(text) and is_integer(max_width) do
- if String.length(text) <= max_width do
- text
- else
- String.slice(text, 0, max_width)
- end
- end
-
- @doc """
- Creates a positioned cell for use with RenderNode.cells/2.
-
- ## Examples
-
- cell = positioned_cell(0, 0, "A", Style.new() |> Style.fg(:red))
- # %{x: 0, y: 0, cell: %Cell{char: "A", fg: :red}}
- """
- @spec positioned_cell(non_neg_integer(), non_neg_integer(), String.t(), Style.t() | nil) ::
- RenderNode.positioned_cell()
- def positioned_cell(x, y, char, style \\ nil) do
- alias TermUI.Renderer.Cell
-
- cell_opts =
- if style do
- # Only include non-default/non-nil values
- opts = []
- opts = if style.fg in [nil, :default], do: opts, else: [{:fg, style.fg} | opts]
- opts = if style.bg in [nil, :default], do: opts, else: [{:bg, style.bg} | opts]
-
- opts =
- if MapSet.size(style.attrs) > 0,
- do: [{:attrs, MapSet.to_list(style.attrs)} | opts],
- else: opts
-
- opts
- else
- []
- end
-
- %{x: x, y: y, cell: Cell.new(char, cell_opts)}
- end
-
- @doc """
- Delegates to RenderNode.cells/2 for creating cell-based render nodes.
-
- ## Examples
-
- cells = [positioned_cell(0, 0, "H"), positioned_cell(1, 0, "i")]
- cells(cells)
- """
- @spec cells([RenderNode.positioned_cell()], keyword()) :: RenderNode.t()
- defdelegate cells(cells, opts \\ []), to: RenderNode
-end
diff --git a/lib/term_ui/component/introspection.ex b/lib/term_ui/component/introspection.ex
deleted file mode 100644
index fd391e15..00000000
--- a/lib/term_ui/component/introspection.ex
+++ /dev/null
@@ -1,338 +0,0 @@
-defmodule TermUI.Component.Introspection do
- @moduledoc """
- Supervision introspection tools for debugging and monitoring.
-
- Provides visibility into the component tree structure, component states,
- and supervision metrics for debugging and monitoring purposes.
-
- ## Usage
-
- # Get tree structure
- tree = Introspection.get_component_tree()
-
- # Get component info
- info = Introspection.get_component_info(:my_component)
-
- # Print tree visualization
- Introspection.print_tree()
-
- # Get supervision metrics
- metrics = Introspection.get_metrics(:my_component)
- """
-
- alias TermUI.Component.StatePersistence
- alias TermUI.ComponentRegistry
- alias TermUI.ComponentServer
-
- # Dialyzer: Functions return specific map types
- @dialyzer {:nowarn_function,
- get_component_info: 1,
- get_metrics: 1,
- aggregate_stats: 0,
- print_tree: 1,
- format_tree: 0}
-
- @doc """
- Returns the component tree structure.
-
- ## Returns
-
- A map with tree structure:
- ```
- %{
- id: term(),
- pid: pid(),
- module: module(),
- children: [...]
- }
- ```
- """
- @spec get_component_tree() :: [map()]
- def get_component_tree do
- # Get all components
- components = ComponentRegistry.list_all()
-
- # Build parent-child relationships
- components
- |> Enum.map(fn component ->
- children = get_children_tree(component.id, components)
-
- %{
- id: component.id,
- pid: component.pid,
- module: component.module,
- children: children
- }
- end)
- |> Enum.filter(fn component ->
- # Only include root components (those without parents)
- case ComponentRegistry.get_parent(component.id) do
- {:ok, nil} -> true
- {:ok, _parent} -> false
- {:error, :not_found} -> true
- end
- end)
- end
-
- defp get_children_tree(parent_id, all_components) do
- child_ids = ComponentRegistry.get_children(parent_id)
-
- Enum.map(child_ids, fn child_id ->
- component = Enum.find(all_components, fn c -> c.id == child_id end)
-
- if component do
- %{
- id: component.id,
- pid: component.pid,
- module: component.module,
- children: get_children_tree(child_id, all_components)
- }
- else
- nil
- end
- end)
- |> Enum.reject(&is_nil/1)
- end
-
- @doc """
- Returns detailed information about a component.
-
- ## Parameters
-
- - `component_id` - Component identifier
-
- ## Returns
-
- - `{:ok, info}` - Component information
- - `{:error, :not_found}` - Component not found
- """
- @spec get_component_info(term()) :: {:ok, map()} | {:error, :not_found}
- def get_component_info(component_id) do
- case ComponentRegistry.get_info(component_id) do
- {:ok, info} ->
- pid = info.pid
-
- # Get additional info from the component server
- {state, props, lifecycle} =
- try do
- state = ComponentServer.get_state(pid)
- props = ComponentServer.get_props(pid)
- lifecycle = ComponentServer.get_lifecycle(pid)
- {state, props, lifecycle}
- catch
- :exit, _ -> {nil, nil, :unknown}
- end
-
- # Get metrics
- restart_count = StatePersistence.get_restart_count(component_id)
- child_count = length(ComponentRegistry.get_children(component_id))
-
- # Calculate uptime (use reductions as proxy since start_time not always available)
- uptime_ms =
- case Process.info(pid, :reductions) do
- # Can't reliably calculate uptime
- {:reductions, _} -> 0
- nil -> 0
- end
-
- enhanced_info =
- Map.merge(info, %{
- state: state,
- props: props,
- lifecycle: lifecycle,
- restart_count: restart_count,
- child_count: child_count,
- uptime_ms: uptime_ms
- })
-
- {:ok, enhanced_info}
-
- {:error, :not_found} ->
- {:error, :not_found}
- end
- end
-
- @doc """
- Returns supervision metrics for a component.
-
- ## Parameters
-
- - `component_id` - Component identifier
-
- ## Returns
-
- - `{:ok, metrics}` - Metrics map
- - `{:error, :not_found}` - Component not found
- """
- @spec get_metrics(term()) :: {:ok, map()} | {:error, :not_found}
- def get_metrics(component_id) do
- case ComponentRegistry.lookup(component_id) do
- {:ok, pid} ->
- restart_count = StatePersistence.get_restart_count(component_id)
- child_count = length(ComponentRegistry.get_children(component_id))
-
- # Get process info
- info =
- Process.info(pid, [
- :memory,
- :message_queue_len,
- :reductions,
- :status
- ]) || []
-
- # uptime_ms not reliably available
- uptime_ms = 0
-
- metrics = %{
- restart_count: restart_count,
- child_count: child_count,
- uptime_ms: uptime_ms,
- memory_bytes: Keyword.get(info, :memory, 0),
- message_queue_len: Keyword.get(info, :message_queue_len, 0),
- reductions: Keyword.get(info, :reductions, 0),
- status: Keyword.get(info, :status, :unknown)
- }
-
- {:ok, metrics}
-
- {:error, :not_found} ->
- {:error, :not_found}
- end
- end
-
- @doc """
- Prints a text visualization of the component tree.
-
- ## Options
-
- - `:io` - IO device to print to (default: `:stdio`)
- """
- @spec print_tree(keyword()) :: :ok
- def print_tree(opts \\ []) do
- io = Keyword.get(opts, :io, :stdio)
- tree = get_component_tree()
-
- if Enum.empty?(tree) do
- IO.puts(io, "(no components)")
- else
- Enum.each(tree, fn node ->
- print_node(io, node, "")
- end)
- end
-
- :ok
- end
-
- defp print_node(io, node, prefix) do
- pid_str = inspect(node.pid)
- module_str = inspect(node.module) |> String.replace("Elixir.", "")
-
- IO.puts(io, "#{prefix}#{node.id} (#{pid_str}) - #{module_str}")
-
- children = node.children
- child_count = length(children)
-
- Enum.with_index(children, fn child, index ->
- is_last = index == child_count - 1
- print_child_node(io, child, prefix, is_last)
- end)
- end
-
- defp print_child_node(io, child, prefix, is_last) do
- child_prefix = get_child_prefix(prefix, is_last)
- cont_prefix = get_continuation_prefix(prefix, is_last)
-
- # Print child with its prefix
- pid_str = inspect(child.pid)
- module_str = inspect(child.module) |> String.replace("Elixir.", "")
- IO.puts(io, "#{child_prefix}#{child.id} (#{pid_str}) - #{module_str}")
-
- # Recursively print grandchildren
- grand_children = child.children
- grand_count = length(grand_children)
-
- Enum.with_index(grand_children, fn grandchild, gindex ->
- is_last_grand = gindex == grand_count - 1
- grand_prefix = get_child_prefix(cont_prefix, is_last_grand)
- print_node(io, grandchild, grand_prefix)
- end)
- end
-
- defp get_child_prefix(prefix, true), do: "#{prefix}└── "
- defp get_child_prefix(prefix, false), do: "#{prefix}├── "
-
- defp get_continuation_prefix(prefix, true), do: "#{prefix} "
- defp get_continuation_prefix(prefix, false), do: "#{prefix}│ "
-
- @doc """
- Returns the tree as a formatted string.
- """
- @spec format_tree() :: String.t()
- def format_tree do
- {:ok, io} = StringIO.open("")
-
- print_tree(io: io)
-
- {_input, output} = StringIO.contents(io)
- StringIO.close(io)
-
- output
- end
-
- @doc """
- Returns aggregate statistics for all components.
- """
- @spec aggregate_stats() :: map()
- def aggregate_stats do
- components = ComponentRegistry.list_all()
-
- total_count = length(components)
-
- total_restarts =
- Enum.reduce(components, 0, fn c, acc ->
- acc + StatePersistence.get_restart_count(c.id)
- end)
-
- total_memory =
- Enum.reduce(components, 0, fn c, acc ->
- case :erlang.process_info(c.pid, :memory) do
- {:memory, mem} -> acc + mem
- nil -> acc
- end
- end)
-
- %{
- component_count: total_count,
- total_restarts: total_restarts,
- total_memory_bytes: total_memory,
- persisted_state_count: StatePersistence.count()
- }
- end
-
- @doc """
- Finds components by module.
- """
- @spec find_by_module(module()) :: [map()]
- def find_by_module(module) do
- ComponentRegistry.list_all()
- |> Enum.filter(fn c -> c.module == module end)
- end
-
- @doc """
- Finds components with high restart counts.
-
- ## Parameters
-
- - `threshold` - Minimum restart count (default: 1)
- """
- @spec find_unstable(non_neg_integer()) :: [map()]
- def find_unstable(threshold \\ 1) do
- ComponentRegistry.list_all()
- |> Enum.map(fn c ->
- restart_count = StatePersistence.get_restart_count(c.id)
- Map.put(c, :restart_count, restart_count)
- end)
- |> Enum.filter(fn c -> c.restart_count >= threshold end)
- |> Enum.sort_by(fn c -> -c.restart_count end)
- end
-end
diff --git a/lib/term_ui/component/render_node.ex b/lib/term_ui/component/render_node.ex
deleted file mode 100644
index 20977ddc..00000000
--- a/lib/term_ui/component/render_node.ex
+++ /dev/null
@@ -1,256 +0,0 @@
-defmodule TermUI.Component.RenderNode do
- @moduledoc """
- Represents a node in the render tree.
-
- RenderNodes are the output of component rendering. They form a tree structure
- that the renderer converts to terminal buffer cells. Each node has content,
- styling, and optional children.
-
- ## Node Types
-
- - **Text nodes**: Simple text content with optional styling
- - **Box nodes**: Rectangular regions that can contain children
- - **Stack nodes**: Vertical or horizontal arrangements of children
-
- ## Examples
-
- # Simple text node
- RenderNode.text("Hello, World!")
-
- # Styled text
- style = Style.new() |> Style.fg(:red) |> Style.bold()
- RenderNode.text("Error!", style)
-
- # Box with children
- RenderNode.box([
- RenderNode.text("Header"),
- RenderNode.text("Content")
- ])
-
- # Horizontal stack
- RenderNode.stack(:horizontal, [
- RenderNode.text("Left"),
- RenderNode.text("Right")
- ])
- """
-
- alias TermUI.Renderer.Cell
- alias TermUI.Renderer.Style
-
- @type node_type :: :text | :box | :stack | :empty | :cells
-
- @typedoc "A cell with position information for the :cells node type"
- @type positioned_cell :: %{x: non_neg_integer(), y: non_neg_integer(), cell: Cell.t()}
- @type direction :: :vertical | :horizontal
-
- @type t :: %__MODULE__{
- type: node_type(),
- content: String.t() | nil,
- style: Style.t() | nil,
- children: [t()],
- direction: direction() | nil,
- width: non_neg_integer() | :auto | nil,
- height: non_neg_integer() | :auto | nil,
- cells: [positioned_cell()] | nil
- }
-
- defstruct type: :empty,
- content: nil,
- style: nil,
- children: [],
- direction: nil,
- width: nil,
- height: nil,
- cells: nil
-
- # Dialyzer: Functions return specific struct types
- @dialyzer {:nowarn_function, empty: 0}
-
- @doc """
- Creates an empty render node.
-
- ## Examples
-
- iex> RenderNode.empty()
- %RenderNode{type: :empty}
- """
- @spec empty() :: t()
- def empty do
- %__MODULE__{type: :empty}
- end
-
- @doc """
- Creates a text node with optional styling.
-
- ## Examples
-
- iex> RenderNode.text("Hello")
- %RenderNode{type: :text, content: "Hello"}
-
- iex> style = Style.new() |> Style.fg(:red)
- iex> node = RenderNode.text("Error", style)
- iex> node.style.fg
- :red
- """
- @spec text(String.t(), Style.t() | nil) :: t()
- def text(content, style \\ nil) when is_binary(content) do
- %__MODULE__{
- type: :text,
- content: content,
- style: style
- }
- end
-
- @doc """
- Creates a box node that can contain children.
-
- ## Options
-
- - `:style` - Style to apply to the box background
- - `:width` - Fixed width or `:auto`
- - `:height` - Fixed height or `:auto`
-
- ## Examples
-
- iex> RenderNode.box([RenderNode.text("Content")])
- %RenderNode{type: :box, children: [%RenderNode{type: :text, content: "Content"}]}
-
- iex> RenderNode.box([RenderNode.text("Styled")], style: Style.new() |> Style.bg(:blue))
- %RenderNode{type: :box, style: %Style{bg: :blue}}
- """
- @spec box([t()], keyword()) :: t()
- def box(children, opts \\ []) when is_list(children) do
- %__MODULE__{
- type: :box,
- children: children,
- style: Keyword.get(opts, :style),
- width: Keyword.get(opts, :width),
- height: Keyword.get(opts, :height)
- }
- end
-
- @doc """
- Creates a stack node that arranges children in a direction.
-
- ## Examples
-
- iex> RenderNode.stack(:vertical, [RenderNode.text("Top"), RenderNode.text("Bottom")])
- %RenderNode{type: :stack, direction: :vertical, children: [...]}
-
- iex> RenderNode.stack(:horizontal, [RenderNode.text("Left"), RenderNode.text("Right")])
- %RenderNode{type: :stack, direction: :horizontal, children: [...]}
- """
- @spec stack(direction(), [t()], keyword()) :: t()
- def stack(direction, children, opts \\ [])
- when direction in [:vertical, :horizontal] and is_list(children) do
- %__MODULE__{
- type: :stack,
- direction: direction,
- children: children,
- style: Keyword.get(opts, :style),
- width: Keyword.get(opts, :width),
- height: Keyword.get(opts, :height)
- }
- end
-
- @doc """
- Creates a cells node with pre-rendered cells.
-
- This is used by widgets that need fine-grained control over cell positioning.
- The cells list should contain Cell structs with absolute positions.
-
- ## Examples
-
- iex> cells = [%{x: 0, y: 0, cell: Cell.new("H")}, %{x: 1, y: 0, cell: Cell.new("i")}]
- iex> RenderNode.cells(cells)
- %RenderNode{type: :cells, cells: [...]}
- """
- @spec cells([positioned_cell()], keyword()) :: t()
- def cells(cells, opts \\ []) when is_list(cells) do
- %__MODULE__{
- type: :cells,
- cells: cells,
- children: Keyword.get(opts, :children, []),
- width: Keyword.get(opts, :width),
- height: Keyword.get(opts, :height)
- }
- end
-
- @doc """
- Creates a styled wrapper around a node.
-
- Applies additional styling to an existing node without changing its structure.
-
- ## Examples
-
- iex> node = RenderNode.text("Hello")
- iex> styled = RenderNode.styled(node, Style.new() |> Style.fg(:red))
- iex> styled.children
- [%RenderNode{type: :text, content: "Hello"}]
- """
- @spec styled(t(), Style.t()) :: t()
- def styled(%__MODULE__{} = node, %Style{} = style) do
- %__MODULE__{
- type: :box,
- style: style,
- children: [node]
- }
- end
-
- @doc """
- Sets the width of a node.
-
- ## Examples
-
- iex> RenderNode.box([]) |> RenderNode.width(20)
- %RenderNode{type: :box, width: 20}
- """
- @spec width(t(), non_neg_integer() | :auto) :: t()
- def width(%__MODULE__{} = node, w) when (is_integer(w) and w >= 0) or w == :auto do
- %{node | width: w}
- end
-
- @doc """
- Sets the height of a node.
-
- ## Examples
-
- iex> RenderNode.box([]) |> RenderNode.height(10)
- %RenderNode{type: :box, height: 10}
- """
- @spec height(t(), non_neg_integer() | :auto) :: t()
- def height(%__MODULE__{} = node, h) when (is_integer(h) and h >= 0) or h == :auto do
- %{node | height: h}
- end
-
- @doc """
- Checks if a node is empty.
-
- ## Examples
-
- iex> RenderNode.empty?(RenderNode.empty())
- true
-
- iex> RenderNode.empty?(RenderNode.text("Hello"))
- false
- """
- @spec empty?(t()) :: boolean()
- def empty?(%__MODULE__{type: :empty}), do: true
- def empty?(%__MODULE__{}), do: false
-
- @doc """
- Returns the number of direct children of a node.
-
- ## Examples
-
- iex> RenderNode.child_count(RenderNode.text("Hello"))
- 0
-
- iex> RenderNode.child_count(RenderNode.box([RenderNode.text("A"), RenderNode.text("B")]))
- 2
- """
- @spec child_count(t()) :: non_neg_integer()
- def child_count(%__MODULE__{children: children}) do
- length(children)
- end
-end
diff --git a/lib/term_ui/component/state_persistence.ex b/lib/term_ui/component/state_persistence.ex
deleted file mode 100644
index 5e8ec248..00000000
--- a/lib/term_ui/component/state_persistence.ex
+++ /dev/null
@@ -1,305 +0,0 @@
-defmodule TermUI.Component.StatePersistence do
- @moduledoc """
- ETS-based state persistence for crash recovery.
-
- This module allows components to persist their state before crashes
- and recover it on restart. State is stored in an ETS table that survives
- component process crashes.
-
- ## Usage
-
- # Persist state (typically called on state changes)
- StatePersistence.persist(:my_component, state)
-
- # Recover state on restart
- case StatePersistence.recover(:my_component) do
- {:ok, state} -> {:ok, state}
- :not_found -> {:ok, initial_state}
- end
-
- # Clear persisted state
- StatePersistence.clear(:my_component)
- """
-
- use GenServer
-
- @table_name :term_ui_component_states
- @metadata_table :term_ui_persistence_metadata
-
- # Dialyzer: Functions return specific types
- @dialyzer {:nowarn_function, init: 1, recover: 2, get_metadata: 1}
-
- # Client API
-
- @doc """
- Starts the state persistence server.
- """
- @spec start_link(keyword()) :: GenServer.on_start()
- def start_link(opts \\ []) do
- name = Keyword.get(opts, :name, __MODULE__)
- GenServer.start_link(__MODULE__, opts, name: name)
- end
-
- @doc """
- Persists component state to ETS.
-
- ## Parameters
-
- - `component_id` - Component identifier
- - `state` - State to persist
- - `opts` - Options
- - `:props` - Original props for last_props recovery mode
-
- ## Returns
-
- - `:ok` - State persisted successfully
- """
- @spec persist(term(), term(), keyword()) :: :ok
- def persist(component_id, state, opts \\ []) do
- props = Keyword.get(opts, :props)
-
- entry = %{
- state: state,
- props: props,
- persisted_at: System.system_time(:millisecond)
- }
-
- :ets.insert(@table_name, {component_id, entry})
- :ok
- end
-
- @doc """
- Recovers persisted state for a component.
-
- ## Parameters
-
- - `component_id` - Component identifier
- - `mode` - Recovery mode (default: `:last_state`)
- - `:last_state` - Return the full persisted state
- - `:last_props` - Return only the persisted props
- - `:reset` - Return :not_found (forces re-initialization)
-
- ## Returns
-
- - `{:ok, state}` - State found and returned
- - `:not_found` - No state persisted for this component
- """
- @spec recover(term(), atom()) :: {:ok, term()} | :not_found
- def recover(component_id, mode \\ :last_state) do
- case mode do
- :reset ->
- # Clear any persisted state and return not found
- clear(component_id)
- :not_found
-
- :last_state ->
- case :ets.lookup(@table_name, component_id) do
- [{^component_id, %{state: state}}] -> {:ok, state}
- [] -> :not_found
- end
-
- :last_props ->
- case :ets.lookup(@table_name, component_id) do
- [{^component_id, %{props: props}}] when not is_nil(props) -> {:ok, props}
- _ -> :not_found
- end
- end
- end
-
- @doc """
- Clears persisted state for a component.
-
- ## Parameters
-
- - `component_id` - Component identifier
-
- ## Returns
-
- - `:ok` - State cleared (or was not present)
- """
- @spec clear(term()) :: :ok
- def clear(component_id) do
- :ets.delete(@table_name, component_id)
- :ok
- end
-
- @doc """
- Clears all persisted state.
-
- Mainly useful for testing.
- """
- @spec clear_all() :: :ok
- def clear_all do
- :ets.delete_all_objects(@table_name)
- :ok
- end
-
- @doc """
- Gets metadata about persisted state.
-
- ## Returns
-
- - `{:ok, metadata}` - Metadata including persisted_at timestamp
- - `:not_found` - No state persisted for this component
- """
- @spec get_metadata(term()) :: {:ok, map()} | :not_found
- def get_metadata(component_id) do
- case :ets.lookup(@table_name, component_id) do
- [{^component_id, entry}] ->
- {:ok,
- %{
- persisted_at: entry.persisted_at,
- has_props: not is_nil(entry.props)
- }}
-
- [] ->
- :not_found
- end
- end
-
- @doc """
- Lists all component IDs with persisted state.
- """
- @spec list_persisted() :: [term()]
- def list_persisted do
- @table_name
- |> :ets.tab2list()
- |> Enum.map(fn {id, _entry} -> id end)
- end
-
- @doc """
- Returns the count of persisted states.
- """
- @spec count() :: non_neg_integer()
- def count do
- :ets.info(@table_name, :size)
- end
-
- @doc """
- Records restart event for a component.
-
- Used for tracking restart counts and detecting restart storms.
- """
- @spec record_restart(term()) :: :ok
- def record_restart(component_id) do
- now = System.system_time(:second)
-
- case :ets.lookup(@metadata_table, component_id) do
- [{^component_id, metadata}] ->
- # Remove restarts older than max_seconds window (default 5 seconds)
- max_seconds = Map.get(metadata, :max_seconds, 5)
- cutoff = now - max_seconds
-
- restarts =
- metadata.restarts
- |> Enum.filter(fn ts -> ts > cutoff end)
- |> then(fn list -> list ++ [now] end)
-
- new_metadata = %{metadata | restarts: restarts}
- :ets.insert(@metadata_table, {component_id, new_metadata})
-
- [] ->
- metadata = %{
- restarts: [now],
- max_restarts: 3,
- max_seconds: 5
- }
-
- :ets.insert(@metadata_table, {component_id, metadata})
- end
-
- :ok
- end
-
- @doc """
- Gets the restart count for a component within the time window.
- """
- @spec get_restart_count(term()) :: non_neg_integer()
- def get_restart_count(component_id) do
- case :ets.lookup(@metadata_table, component_id) do
- [{^component_id, metadata}] -> length(metadata.restarts)
- [] -> 0
- end
- end
-
- @doc """
- Checks if restart intensity limit has been reached.
-
- ## Returns
-
- - `true` - Restart limit exceeded
- - `false` - Within limits
- """
- @spec restart_limit_reached?(term()) :: boolean()
- def restart_limit_reached?(component_id) do
- case :ets.lookup(@metadata_table, component_id) do
- [{^component_id, metadata}] ->
- length(metadata.restarts) >= metadata.max_restarts
-
- [] ->
- false
- end
- end
-
- @doc """
- Sets restart intensity limits for a component.
-
- ## Parameters
-
- - `component_id` - Component identifier
- - `max_restarts` - Maximum restarts allowed
- - `max_seconds` - Time window in seconds
- """
- @spec set_restart_limits(term(), non_neg_integer(), non_neg_integer()) :: :ok
- def set_restart_limits(component_id, max_restarts, max_seconds) do
- case :ets.lookup(@metadata_table, component_id) do
- [{^component_id, metadata}] ->
- new_metadata = %{metadata | max_restarts: max_restarts, max_seconds: max_seconds}
- :ets.insert(@metadata_table, {component_id, new_metadata})
-
- [] ->
- metadata = %{
- restarts: [],
- max_restarts: max_restarts,
- max_seconds: max_seconds
- }
-
- :ets.insert(@metadata_table, {component_id, metadata})
- end
-
- :ok
- end
-
- @doc """
- Clears restart history for a component.
- """
- @spec clear_restart_history(term()) :: :ok
- def clear_restart_history(component_id) do
- :ets.delete(@metadata_table, component_id)
- :ok
- end
-
- # Server Callbacks
-
- @impl true
- def init(_opts) do
- # Create ETS tables
- :ets.new(@table_name, [
- :named_table,
- :set,
- :public,
- read_concurrency: true,
- write_concurrency: true
- ])
-
- :ets.new(@metadata_table, [
- :named_table,
- :set,
- :public,
- read_concurrency: true,
- write_concurrency: true
- ])
-
- {:ok, %{}}
- end
-end
diff --git a/lib/term_ui/component_registry.ex b/lib/term_ui/component_registry.ex
deleted file mode 100644
index c8f538ed..00000000
--- a/lib/term_ui/component_registry.ex
+++ /dev/null
@@ -1,309 +0,0 @@
-defmodule TermUI.ComponentRegistry do
- @moduledoc """
- ETS-based registry for component lookup.
-
- The registry enables fast lookup of component processes by id,
- which is essential for event routing and focus management.
- Components register on mount and unregister on unmount.
-
- ## Usage
-
- # Register a component
- ComponentRegistry.register(:my_button, pid, Button)
-
- # Lookup by id
- {:ok, pid} = ComponentRegistry.lookup(:my_button)
-
- # Lookup by pid
- {:ok, id} = ComponentRegistry.lookup_id(pid)
-
- # List all
- components = ComponentRegistry.list_all()
- """
-
- use GenServer
-
- @table_name :term_ui_component_registry
- @pid_index :term_ui_component_pid_index
- @parent_table :term_ui_component_parents
-
- # Dialyzer: Functions return specific types
- @dialyzer {:nowarn_function, init: 1, get_info: 1, handle_call: 3}
-
- # Client API
-
- @doc """
- Starts the component registry.
- """
- @spec start_link(keyword()) :: GenServer.on_start()
- def start_link(opts \\ []) do
- name = Keyword.get(opts, :name, __MODULE__)
- GenServer.start_link(__MODULE__, opts, name: name)
- end
-
- @doc """
- Registers a component in the registry.
-
- ## Parameters
-
- - `id` - Unique identifier for the component
- - `pid` - Process pid of the component
- - `module` - Component module
-
- ## Returns
-
- - `:ok` - Successfully registered
- - `{:error, :already_registered}` - Id already taken
- """
- @spec register(term(), pid(), module()) :: :ok | {:error, :already_registered}
- def register(id, pid, module) when is_pid(pid) and is_atom(module) do
- GenServer.call(__MODULE__, {:register, id, pid, module})
- end
-
- @doc """
- Unregisters a component from the registry.
-
- ## Parameters
-
- - `id` - Component identifier to unregister
-
- ## Returns
-
- - `:ok` - Successfully unregistered (or wasn't registered)
- """
- @spec unregister(term()) :: :ok
- def unregister(id) do
- GenServer.call(__MODULE__, {:unregister, id})
- end
-
- @doc """
- Looks up a component by id.
-
- ## Returns
-
- - `{:ok, pid}` - Component found
- - `{:error, :not_found}` - Component not registered
- """
- @spec lookup(term()) :: {:ok, pid()} | {:error, :not_found}
- def lookup(id) do
- case :ets.lookup(@table_name, id) do
- [{^id, pid, _module}] -> {:ok, pid}
- [] -> {:error, :not_found}
- end
- end
-
- @doc """
- Looks up a component id by pid.
-
- ## Returns
-
- - `{:ok, id}` - Component found
- - `{:error, :not_found}` - Component not registered
- """
- @spec lookup_id(pid()) :: {:ok, term()} | {:error, :not_found}
- def lookup_id(pid) when is_pid(pid) do
- case :ets.lookup(@pid_index, pid) do
- [{^pid, id}] -> {:ok, id}
- [] -> {:error, :not_found}
- end
- end
-
- @doc """
- Gets full component info by id.
-
- ## Returns
-
- - `{:ok, %{id: term(), pid: pid(), module: module()}}` - Component found
- - `{:error, :not_found}` - Component not registered
- """
- @spec get_info(term()) :: {:ok, map()} | {:error, :not_found}
- def get_info(id) do
- case :ets.lookup(@table_name, id) do
- [{^id, pid, module}] ->
- {:ok, %{id: id, pid: pid, module: module}}
-
- [] ->
- {:error, :not_found}
- end
- end
-
- @doc """
- Lists all registered components.
-
- ## Returns
-
- List of `%{id: term(), pid: pid(), module: module()}`
- """
- @spec list_all() :: [map()]
- def list_all do
- @table_name
- |> :ets.tab2list()
- |> Enum.map(fn {id, pid, module} ->
- %{id: id, pid: pid, module: module}
- end)
- end
-
- @doc """
- Returns the count of registered components.
- """
- @spec count() :: non_neg_integer()
- def count do
- :ets.info(@table_name, :size)
- end
-
- @doc """
- Checks if a component is registered.
- """
- @spec registered?(term()) :: boolean()
- def registered?(id) do
- :ets.member(@table_name, id)
- end
-
- @doc """
- Clears all registrations.
-
- Mainly useful for testing.
- """
- @spec clear() :: :ok
- def clear do
- GenServer.call(__MODULE__, :clear)
- end
-
- @doc """
- Sets the parent of a component for propagation.
-
- ## Parameters
-
- - `id` - Component id
- - `parent_id` - Parent component id (or nil for root)
- """
- @spec set_parent(term(), term() | nil) :: :ok
- def set_parent(id, parent_id) do
- :ets.insert(@parent_table, {id, parent_id})
- :ok
- end
-
- @doc """
- Gets the parent of a component.
-
- ## Returns
-
- - `{:ok, parent_id}` - Parent found (nil if root)
- - `{:error, :not_found}` - Component not in parent table
- """
- @spec get_parent(term()) :: {:ok, term() | nil} | {:error, :not_found}
- def get_parent(id) do
- case :ets.lookup(@parent_table, id) do
- [{^id, parent_id}] -> {:ok, parent_id}
- [] -> {:error, :not_found}
- end
- end
-
- @doc """
- Gets all children of a component.
-
- ## Returns
-
- List of child component ids.
- """
- @spec get_children(term()) :: [term()]
- def get_children(parent_id) do
- @parent_table
- |> :ets.tab2list()
- |> Enum.filter(fn {_id, pid} -> pid == parent_id end)
- |> Enum.map(fn {id, _pid} -> id end)
- end
-
- # Server Callbacks
-
- @impl true
- def init(_opts) do
- # Create ETS tables
- :ets.new(@table_name, [:set, :public, :named_table, read_concurrency: true])
- :ets.new(@pid_index, [:set, :public, :named_table, read_concurrency: true])
- :ets.new(@parent_table, [:set, :public, :named_table, read_concurrency: true])
-
- {:ok, %{monitors: %{}}}
- end
-
- @impl true
- def handle_call({:register, id, pid, module}, _from, state) do
- case :ets.lookup(@table_name, id) do
- [] ->
- # Insert into both tables
- :ets.insert(@table_name, {id, pid, module})
- :ets.insert(@pid_index, {pid, id})
-
- # Monitor the process for automatic cleanup
- ref = Process.monitor(pid)
- monitors = Map.put(state.monitors, ref, id)
-
- {:reply, :ok, %{state | monitors: monitors}}
-
- [_existing] ->
- {:reply, {:error, :already_registered}, state}
- end
- end
-
- @impl true
- def handle_call({:unregister, id}, _from, state) do
- case :ets.lookup(@table_name, id) do
- [{^id, pid, _module}] ->
- # Remove from both tables
- :ets.delete(@table_name, id)
- :ets.delete(@pid_index, pid)
-
- # Find and remove monitor
- {ref, monitors} = find_and_remove_monitor(state.monitors, id)
-
- if ref, do: Process.demonitor(ref, [:flush])
-
- {:reply, :ok, %{state | monitors: monitors}}
-
- [] ->
- {:reply, :ok, state}
- end
- end
-
- @impl true
- def handle_call(:clear, _from, state) do
- :ets.delete_all_objects(@table_name)
- :ets.delete_all_objects(@pid_index)
- :ets.delete_all_objects(@parent_table)
-
- # Demonitor all
- Enum.each(state.monitors, fn {ref, _id} ->
- Process.demonitor(ref, [:flush])
- end)
-
- {:reply, :ok, %{state | monitors: %{}}}
- end
-
- @impl true
- def handle_info({:DOWN, ref, :process, _pid, _reason}, state) do
- case Map.pop(state.monitors, ref) do
- {nil, monitors} ->
- {:noreply, %{state | monitors: monitors}}
-
- {id, monitors} ->
- # Clean up the registration
- case :ets.lookup(@table_name, id) do
- [{^id, pid, _module}] ->
- :ets.delete(@table_name, id)
- :ets.delete(@pid_index, pid)
-
- [] ->
- :ok
- end
-
- {:noreply, %{state | monitors: monitors}}
- end
- end
-
- defp find_and_remove_monitor(monitors, id) do
- monitors
- |> Enum.find_value(fn {ref, monitored_id} ->
- if monitored_id == id, do: {ref, Map.delete(monitors, ref)}
- end) || {nil, monitors}
- end
-end
diff --git a/lib/term_ui/component_server.ex b/lib/term_ui/component_server.ex
deleted file mode 100644
index b581938d..00000000
--- a/lib/term_ui/component_server.ex
+++ /dev/null
@@ -1,485 +0,0 @@
-defmodule TermUI.ComponentServer do
- @moduledoc """
- GenServer that manages the lifecycle of a component.
-
- ComponentServer wraps any component implementing TermUI behaviours,
- managing its lifecycle stages: init, mount, update, and unmount.
- It handles prop validation, timeout enforcement, and command execution.
-
- ## Lifecycle Stages
-
- 1. **Init** - Create initial state from props
- 2. **Mount** - Component enters active tree, ready for events
- 3. **Update** - Props changed, state may update
- 4. **Unmount** - Component removed, cleanup performed
-
- ## Usage
-
- Components are typically started via `ComponentSupervisor`:
-
- {:ok, pid} = ComponentSupervisor.start_component(MyButton, %{label: "OK"})
-
- Direct usage:
-
- {:ok, pid} = ComponentServer.start_link(MyButton, %{label: "OK"}, [])
- """
-
- use GenServer
-
- require Logger
-
- alias TermUI.Component.StatePersistence
- alias TermUI.ComponentRegistry
-
- @default_init_timeout 5_000
- @default_unmount_timeout 5_000
-
- # Dialyzer: Functions with unmatched return values
- @dialyzer {:nowarn_function,
- execute_hooks: 2,
- execute_commands: 2,
- props_changed?: 2,
- handle_call: 3,
- handle_info: 2}
-
- @type state :: %{
- module: module(),
- component_state: term(),
- props: map(),
- lifecycle: :initialized | :mounted | :unmounted,
- id: term(),
- hooks: %{atom() => [function()]},
- recovery: :reset | :last_props | :last_state
- }
-
- # Client API
-
- @doc """
- Starts a component server.
-
- ## Parameters
-
- - `module` - Component module
- - `props` - Initial properties
- - `opts` - Options (`:id`, `:timeout`)
- """
- @spec start_link(module(), map(), keyword()) :: GenServer.on_start()
- def start_link(module, props, opts \\ []) do
- id = Keyword.get(opts, :id, make_ref())
- name = Keyword.get(opts, :name)
-
- gen_opts = if name, do: [name: name], else: []
-
- GenServer.start_link(__MODULE__, {module, props, id, opts}, gen_opts)
- end
-
- @doc """
- Returns a child specification for starting a component in a supervisor.
- """
- @spec child_spec(module(), map(), keyword()) :: Supervisor.child_spec()
- def child_spec(module, props, opts \\ []) do
- %{
- id: opts[:id] || module,
- start: {__MODULE__, :start_link, [module, props, opts]},
- restart: :permanent,
- shutdown: 5000,
- type: :worker
- }
- end
-
- @doc """
- Triggers the mount lifecycle stage.
-
- Called when the component is added to the active component tree.
- """
- @spec mount(pid()) :: :ok | {:error, term()}
- def mount(pid) do
- GenServer.call(pid, :mount)
- end
-
- @doc """
- Updates the component's props.
-
- Triggers the update callback if props have changed.
- """
- @spec update_props(pid(), map()) :: :ok | {:error, term()}
- def update_props(pid, new_props) do
- GenServer.call(pid, {:update_props, new_props})
- end
-
- @doc """
- Triggers the unmount lifecycle stage.
-
- Called when the component is removed from the tree.
- """
- @spec unmount(pid()) :: :ok
- def unmount(pid) do
- GenServer.call(pid, :unmount, @default_unmount_timeout)
- end
-
- @doc """
- Gets the current component state.
- """
- @spec get_state(pid()) :: term()
- def get_state(pid) do
- GenServer.call(pid, :get_state)
- end
-
- @doc """
- Gets the current props.
- """
- @spec get_props(pid()) :: map()
- def get_props(pid) do
- GenServer.call(pid, :get_props)
- end
-
- @doc """
- Gets the lifecycle state.
- """
- @spec get_lifecycle(pid()) :: :initialized | :mounted | :unmounted
- def get_lifecycle(pid) do
- GenServer.call(pid, :get_lifecycle)
- end
-
- @doc """
- Sends an event to the component.
- """
- @spec send_event(pid(), term()) :: :ok | {:error, term()}
- def send_event(pid, event) do
- GenServer.call(pid, {:event, event})
- end
-
- @doc """
- Registers a lifecycle hook.
-
- ## Hook Types
-
- - `:after_mount` - Called after successful mount
- - `:before_unmount` - Called before unmount cleanup
- - `:on_prop_change` - Called when props change
- """
- @spec register_hook(pid(), atom(), function()) :: :ok
- def register_hook(pid, hook_type, fun) when is_function(fun, 1) do
- GenServer.call(pid, {:register_hook, hook_type, fun})
- end
-
- # Server Callbacks
-
- @impl true
- def init({module, props, id, opts}) do
- if valid_component_module?(module) do
- do_init(module, props, id, opts)
- else
- {:stop, {:error, :invalid_component_module}}
- end
- end
-
- defp valid_component_module?(module) do
- function_exported?(module, :init, 1) or function_exported?(module, :render, 2)
- end
-
- defp do_init(module, props, id, opts) do
- timeout = Keyword.get(opts, :timeout, @default_init_timeout)
- recovery = Keyword.get(opts, :recovery, :last_state)
- recovered_state = try_recover_state(id, recovery, props)
-
- task = Task.async(fn -> init_component(module, props, recovered_state) end)
-
- case Task.yield(task, timeout) || Task.shutdown(task) do
- {:ok, result} -> handle_init_result(result, module, props, id, recovery)
- nil -> {:stop, {:init_timeout, timeout}}
- end
- end
-
- defp init_component(module, props, recovered_state) do
- case recovered_state do
- {:ok, recovered} ->
- {:ok, recovered}
-
- :not_found ->
- call_module_init(module, props)
- end
- rescue
- e -> {:error, {:init_error, e, __STACKTRACE__}}
- end
-
- defp call_module_init(module, props) do
- if function_exported?(module, :init, 1) do
- module.init(props)
- else
- {:ok, props}
- end
- end
-
- defp handle_init_result({:ok, component_state}, module, props, id, recovery) do
- state = build_initial_state(module, component_state, props, id, recovery)
- {:ok, state}
- end
-
- defp handle_init_result({:ok, component_state, commands}, module, props, id, recovery) do
- state = build_initial_state(module, component_state, props, id, recovery)
- execute_commands(commands, state)
- {:ok, state}
- end
-
- defp handle_init_result({:stop, reason}, _module, _props, _id, _recovery) do
- {:stop, reason}
- end
-
- defp handle_init_result({:error, reason}, _module, _props, _id, _recovery) do
- {:stop, reason}
- end
-
- defp build_initial_state(module, component_state, props, id, recovery) do
- %{
- module: module,
- component_state: component_state,
- props: props,
- lifecycle: :initialized,
- id: id,
- hooks: %{
- after_mount: [],
- before_unmount: [],
- on_prop_change: []
- },
- recovery: recovery
- }
- end
-
- defp try_recover_state(id, recovery, _props) do
- case StatePersistence.recover(id, recovery) do
- {:ok, state} ->
- # Record this as a restart
- StatePersistence.record_restart(id)
- Logger.debug("Recovered state for component #{inspect(id)}")
- {:ok, state}
-
- :not_found ->
- :not_found
- end
- end
-
- @impl true
- def handle_call(:mount, _from, %{lifecycle: :initialized} = state) do
- module = state.module
-
- result =
- if function_exported?(module, :mount, 1) do
- try do
- module.mount(state.component_state)
- rescue
- e ->
- Logger.error("Mount error in #{inspect(module)}: #{inspect(e)}")
- {:error, {:mount_error, e}}
- end
- else
- {:ok, state.component_state}
- end
-
- case result do
- {:ok, new_component_state} ->
- new_state = %{state | component_state: new_component_state, lifecycle: :mounted}
- # Register in registry
- ComponentRegistry.register(state.id, self(), state.module)
- # Execute after_mount hooks
- execute_hooks(:after_mount, new_state)
- {:reply, :ok, new_state}
-
- {:ok, new_component_state, commands} ->
- new_state = %{state | component_state: new_component_state, lifecycle: :mounted}
- ComponentRegistry.register(state.id, self(), state.module)
- execute_commands(commands, new_state)
- execute_hooks(:after_mount, new_state)
- {:reply, :ok, new_state}
-
- {:stop, reason} ->
- {:stop, reason, {:error, reason}, state}
-
- {:error, reason} ->
- {:reply, {:error, reason}, state}
- end
- end
-
- def handle_call(:mount, _from, %{lifecycle: lifecycle} = state) do
- {:reply, {:error, {:invalid_lifecycle, lifecycle, :expected_initialized}}, state}
- end
-
- @impl true
- def handle_call({:update_props, new_props}, _from, %{lifecycle: :mounted} = state) do
- if props_changed?(state.props, new_props) do
- module = state.module
-
- result =
- if function_exported?(module, :update, 2) do
- try do
- module.update(new_props, state.component_state)
- rescue
- e ->
- Logger.error("Update error in #{inspect(module)}: #{inspect(e)}")
- {:error, {:update_error, e}}
- end
- else
- # Default: just update props, keep state
- {:ok, state.component_state}
- end
-
- case result do
- {:ok, new_component_state} ->
- new_state = %{state | component_state: new_component_state, props: new_props}
- execute_hooks(:on_prop_change, new_state)
- {:reply, :ok, new_state}
-
- {:ok, new_component_state, commands} ->
- new_state = %{state | component_state: new_component_state, props: new_props}
- execute_commands(commands, new_state)
- execute_hooks(:on_prop_change, new_state)
- {:reply, :ok, new_state}
-
- {:error, reason} ->
- {:reply, {:error, reason}, state}
- end
- else
- # Props unchanged, no update needed
- {:reply, :ok, state}
- end
- end
-
- def handle_call({:update_props, _new_props}, _from, %{lifecycle: lifecycle} = state) do
- {:reply, {:error, {:invalid_lifecycle, lifecycle, :expected_mounted}}, state}
- end
-
- @impl true
- def handle_call(:unmount, _from, %{lifecycle: :mounted} = state) do
- # Execute before_unmount hooks
- execute_hooks(:before_unmount, state)
-
- module = state.module
-
- if function_exported?(module, :unmount, 1) do
- try do
- module.unmount(state.component_state)
- rescue
- e ->
- Logger.error("Unmount error in #{inspect(module)}: #{inspect(e)}")
- end
- end
-
- # Unregister from registry
- ComponentRegistry.unregister(state.id)
-
- new_state = %{state | lifecycle: :unmounted}
- {:reply, :ok, new_state}
- end
-
- def handle_call(:unmount, _from, %{lifecycle: lifecycle} = state) do
- {:reply, {:error, {:invalid_lifecycle, lifecycle, :expected_mounted}}, state}
- end
-
- @impl true
- def handle_call(:get_state, _from, state) do
- {:reply, state.component_state, state}
- end
-
- @impl true
- def handle_call(:get_props, _from, state) do
- {:reply, state.props, state}
- end
-
- @impl true
- def handle_call(:get_lifecycle, _from, state) do
- {:reply, state.lifecycle, state}
- end
-
- @impl true
- def handle_call({:event, event}, _from, %{lifecycle: :mounted} = state) do
- module = state.module
-
- if function_exported?(module, :handle_event, 2) do
- case module.handle_event(event, state.component_state) do
- {:ok, new_component_state} ->
- {:reply, :ok, %{state | component_state: new_component_state}}
-
- {:ok, new_component_state, commands} ->
- execute_commands(commands, state)
- {:reply, :ok, %{state | component_state: new_component_state}}
-
- {:stop, reason, new_component_state} ->
- {:stop, reason, :ok, %{state | component_state: new_component_state}}
- end
- else
- {:reply, {:error, :no_event_handler}, state}
- end
- end
-
- def handle_call({:event, _event}, _from, %{lifecycle: lifecycle} = state) do
- {:reply, {:error, {:invalid_lifecycle, lifecycle, :expected_mounted}}, state}
- end
-
- @impl true
- def handle_call({:register_hook, hook_type, fun}, _from, state) do
- hooks = Map.update!(state.hooks, hook_type, fn existing -> existing ++ [fun] end)
- {:reply, :ok, %{state | hooks: hooks}}
- end
-
- @impl true
- def terminate(reason, state) do
- # Persist state for potential recovery on crash
- if reason != :normal and reason != :shutdown do
- StatePersistence.persist(state.id, state.component_state, props: state.props)
- Logger.debug("Persisted state for component #{inspect(state.id)} before crash")
- end
-
- # Ensure cleanup happens even on crash
- if state.lifecycle == :mounted do
- execute_hooks(:before_unmount, state)
-
- module = state.module
-
- if function_exported?(module, :unmount, 1) do
- try do
- module.unmount(state.component_state)
- rescue
- e ->
- Logger.error("Unmount error during terminate in #{inspect(module)}: #{inspect(e)}")
- end
- end
-
- ComponentRegistry.unregister(state.id)
- end
-
- Logger.debug("Component #{inspect(state.module)} terminating: #{inspect(reason)}")
- :ok
- end
-
- # Private Functions
-
- defp props_changed?(old_props, new_props) do
- old_props != new_props
- end
-
- defp execute_commands(commands, _state) when is_list(commands) do
- Enum.each(commands, fn
- {:send, pid, message} ->
- send(pid, message)
-
- {:timer, ms, message} ->
- Process.send_after(self(), message, ms)
-
- other ->
- Logger.warning("Unknown command: #{inspect(other)}")
- end)
- end
-
- defp execute_hooks(hook_type, state) do
- hooks = Map.get(state.hooks, hook_type, [])
-
- Enum.each(hooks, fn fun ->
- try do
- fun.(state.component_state)
- rescue
- e ->
- Logger.error("Hook error (#{hook_type}): #{inspect(e)}")
- end
- end)
- end
-end
diff --git a/lib/term_ui/component_supervisor.ex b/lib/term_ui/component_supervisor.ex
deleted file mode 100644
index 6db14947..00000000
--- a/lib/term_ui/component_supervisor.ex
+++ /dev/null
@@ -1,422 +0,0 @@
-defmodule TermUI.ComponentSupervisor do
- @moduledoc """
- Dynamic supervisor for managing component processes.
-
- Components are spawned as child processes under this supervisor,
- providing fault isolation and automatic cleanup. Each component
- runs as a GenServer managed by `TermUI.ComponentServer`.
-
- ## Usage
-
- # Start a component under the supervisor
- {:ok, pid} = ComponentSupervisor.start_component(MyComponent, %{text: "Hello"})
-
- # Stop a component
- :ok = ComponentSupervisor.stop_component(pid)
-
- # Stop with cascade (stops all children)
- :ok = ComponentSupervisor.stop_component(pid, cascade: true)
-
- ## Supervision Strategy
-
- Uses `:one_for_one` strategy - each component is independent.
- Default restart is `:transient` - restart only on crash, not normal exit.
-
- ## Restart Strategies
-
- - `:transient` (default) - Restart only on abnormal termination
- - `:permanent` - Always restart on termination
- - `:temporary` - Never restart
-
- ## Shutdown Options
-
- - `:shutdown` - Timeout in ms (default 5000) or `:brutal_kill`
- - `:recovery` - Recovery mode: `:reset`, `:last_props`, `:last_state`
- """
-
- use DynamicSupervisor
-
- require Logger
-
- alias TermUI.Component.StatePersistence
- alias TermUI.ComponentRegistry
-
- # Dialyzer: Functions return specific types
- @dialyzer {:nowarn_function, get_component_info: 1}
-
- @default_shutdown_timeout 5_000
-
- @doc """
- Starts the component supervisor.
-
- Called by the application supervisor during startup.
- """
- @spec start_link(keyword()) :: Supervisor.on_start()
- def start_link(opts \\ []) do
- name = Keyword.get(opts, :name, __MODULE__)
- DynamicSupervisor.start_link(__MODULE__, opts, name: name)
- end
-
- @impl true
- def init(opts) do
- max_restarts = Keyword.get(opts, :max_restarts, 3)
- max_seconds = Keyword.get(opts, :max_seconds, 5)
-
- DynamicSupervisor.init(
- strategy: :one_for_one,
- max_restarts: max_restarts,
- max_seconds: max_seconds
- )
- end
-
- @doc """
- Starts a component under the supervisor.
-
- ## Parameters
-
- - `module` - The component module implementing a behaviour
- - `props` - Initial properties for the component
- - `opts` - Options including `:id` for component identification
-
- ## Options
-
- - `:id` - Component identifier for registry lookup
- - `:name` - Process name registration
- - `:timeout` - Init timeout in milliseconds (default 5000)
- - `:restart` - Restart strategy: `:transient`, `:permanent`, `:temporary` (default `:transient`)
- - `:shutdown` - Shutdown timeout in ms or `:brutal_kill` (default 5000)
- - `:recovery` - Recovery mode: `:reset`, `:last_props`, `:last_state` (default `:last_state`)
-
- ## Returns
-
- - `{:ok, pid}` - Component started successfully
- - `{:error, reason}` - Failed to start
-
- ## Examples
-
- {:ok, pid} = ComponentSupervisor.start_component(Label, %{text: "Hello"})
-
- {:ok, pid} = ComponentSupervisor.start_component(
- Button,
- %{label: "Click"},
- id: :submit_button
- )
- """
- @spec start_component(module(), map(), keyword()) :: DynamicSupervisor.on_start_child()
- def start_component(module, props, opts \\ []) do
- component_id = Keyword.get(opts, :id, make_ref())
- restart = Keyword.get(opts, :restart, :transient)
- shutdown = Keyword.get(opts, :shutdown, @default_shutdown_timeout)
- recovery = Keyword.get(opts, :recovery, :last_state)
-
- # Store recovery mode for state persistence
- full_opts = Keyword.put(opts, :recovery, recovery)
-
- # Set restart limits for this component if specified
- if Keyword.has_key?(opts, :max_restarts) do
- max_restarts = Keyword.get(opts, :max_restarts, 3)
- max_seconds = Keyword.get(opts, :max_seconds, 5)
- StatePersistence.set_restart_limits(component_id, max_restarts, max_seconds)
- end
-
- child_spec = %{
- id: component_id,
- start: {TermUI.ComponentServer, :start_link, [module, props, full_opts]},
- restart: restart,
- shutdown: shutdown,
- type: :worker
- }
-
- DynamicSupervisor.start_child(__MODULE__, child_spec)
- end
-
- @doc """
- Stops a component gracefully.
-
- Triggers the unmount lifecycle before termination.
-
- ## Parameters
-
- - `pid_or_id` - The component process pid or id
- - `opts` - Options
- - `:cascade` - Also stop all child components (default: false)
-
- ## Returns
-
- - `:ok` - Component stopped successfully
- - `{:error, :not_found}` - Component not found
- """
- @spec stop_component(pid() | term(), keyword()) :: :ok | {:error, :not_found}
- def stop_component(pid_or_id, opts \\ [])
-
- def stop_component(pid, opts) when is_pid(pid) do
- cascade = Keyword.get(opts, :cascade, false)
-
- # If cascade, find and stop children first
- if cascade do
- case ComponentRegistry.lookup_id(pid) do
- {:ok, id} ->
- stop_children(id)
-
- _ ->
- :ok
- end
- end
-
- case DynamicSupervisor.terminate_child(__MODULE__, pid) do
- :ok -> :ok
- {:error, :not_found} -> {:error, :not_found}
- end
- end
-
- def stop_component(id, opts) do
- case ComponentRegistry.lookup(id) do
- {:ok, pid} -> stop_component(pid, opts)
- {:error, :not_found} -> {:error, :not_found}
- end
- end
-
- defp stop_children(parent_id) do
- children = ComponentRegistry.get_children(parent_id)
-
- # Stop children in reverse order (depth-first)
- Enum.each(children, fn child_id ->
- # Recursively stop grandchildren first
- stop_children(child_id)
-
- # Then stop the child
- case ComponentRegistry.lookup(child_id) do
- {:ok, pid} ->
- DynamicSupervisor.terminate_child(__MODULE__, pid)
-
- _ ->
- :ok
- end
- end)
- end
-
- @doc """
- Returns the count of running components.
- """
- @spec count_children() :: non_neg_integer()
- def count_children do
- %{workers: count} = DynamicSupervisor.count_children(__MODULE__)
- count
- end
-
- @doc """
- Returns all component pids.
- """
- @spec which_children() :: [pid()]
- def which_children do
- __MODULE__
- |> DynamicSupervisor.which_children()
- |> Enum.map(fn {_, pid, _, _} -> pid end)
- |> Enum.filter(&is_pid/1)
- end
-
- @doc """
- Returns the component tree structure.
-
- Builds a hierarchical view of all components based on their
- parent-child relationships in the registry.
-
- ## Returns
-
- A list of tree nodes, where each node contains:
- - `:id` - Component identifier
- - `:pid` - Process identifier
- - `:module` - Component module
- - `:children` - List of child nodes
-
- ## Examples
-
- tree = ComponentSupervisor.get_tree()
- # [
- # %{id: :root, pid: #PID<0.123.0>, module: MyApp.Root, children: [
- # %{id: :child1, pid: #PID<0.124.0>, module: MyApp.Child, children: []}
- # ]}
- # ]
- """
- @spec get_tree() :: [map()]
- def get_tree do
- # Get all components
- all_components = ComponentRegistry.list_all()
-
- # Find root components (no parent)
- roots =
- Enum.filter(all_components, fn {id, _pid} ->
- case ComponentRegistry.get_parent(id) do
- {:ok, nil} -> true
- {:error, :not_found} -> true
- _ -> false
- end
- end)
-
- # Build tree recursively from roots
- Enum.map(roots, fn {id, pid} ->
- build_tree_node(id, pid)
- end)
- end
-
- defp build_tree_node(id, pid) do
- # Get component module from server state
- module =
- try do
- state = TermUI.ComponentServer.get_state(pid)
- Map.get(state, :__module__, :unknown)
- catch
- _, _ -> :unknown
- end
-
- # Get children
- children = ComponentRegistry.get_children(id)
-
- child_nodes =
- Enum.flat_map(children, fn child_id ->
- case ComponentRegistry.lookup(child_id) do
- {:ok, child_pid} -> [build_tree_node(child_id, child_pid)]
- _ -> []
- end
- end)
-
- %{
- id: id,
- pid: pid,
- module: module,
- children: child_nodes
- }
- end
-
- @doc """
- Returns detailed information about a component.
-
- ## Parameters
-
- - `id` - Component identifier
-
- ## Returns
-
- - `{:ok, info}` - Component information map
- - `{:error, :not_found}` - Component not found
-
- The info map contains:
- - `:id` - Component identifier
- - `:pid` - Process identifier
- - `:module` - Component module
- - `:lifecycle` - Current lifecycle stage
- - `:restart_count` - Number of times restarted
- - `:uptime_ms` - Milliseconds since process started
- - `:state` - Current component state
- - `:props` - Current props
-
- ## Examples
-
- {:ok, info} = ComponentSupervisor.get_component_info(:my_button)
- info.uptime_ms
- # => 12345
- """
- @spec get_component_info(term()) :: {:ok, map()} | {:error, :not_found}
- def get_component_info(id) do
- case ComponentRegistry.lookup(id) do
- {:ok, pid} ->
- info = build_component_info(id, pid)
- {:ok, info}
-
- {:error, :not_found} ->
- {:error, :not_found}
- end
- end
-
- defp build_component_info(id, pid) do
- # Get basic info from ComponentServer
- {state, props, lifecycle, module} =
- try do
- server_state = :sys.get_state(pid)
-
- {
- Map.get(server_state, :component_state, %{}),
- Map.get(server_state, :props, %{}),
- Map.get(server_state, :lifecycle, :unknown),
- Map.get(server_state, :module, :unknown)
- }
- catch
- _, _ -> {%{}, %{}, :unknown, :unknown}
- end
-
- # Get restart count from persistence
- restart_count = StatePersistence.get_restart_count(id)
-
- # Calculate uptime from process info
- uptime_ms =
- case Process.info(pid, :start_time) do
- {:start_time, start_time} ->
- # start_time is in native time units since VM start
- current = :erlang.monotonic_time(:millisecond)
- start_ms = :erlang.convert_time_unit(start_time, :native, :millisecond)
- current - start_ms
-
- nil ->
- 0
- end
-
- %{
- id: id,
- pid: pid,
- module: module,
- lifecycle: lifecycle,
- restart_count: restart_count,
- uptime_ms: uptime_ms,
- state: state,
- props: props
- }
- end
-
- @doc """
- Returns a text visualization of the component tree.
-
- Useful for debugging and logging.
-
- ## Examples
-
- IO.puts(ComponentSupervisor.format_tree())
- # └─ :root (MyApp.Root) #PID<0.123.0>
- # ├─ :sidebar (MyApp.Sidebar) #PID<0.124.0>
- # └─ :content (MyApp.Content) #PID<0.125.0>
- """
- @spec format_tree() :: String.t()
- def format_tree do
- tree = get_tree()
- do_format_tree(tree)
- end
-
- defp do_format_tree([]), do: "(no components)"
-
- defp do_format_tree(tree) do
- formatted = Enum.map(tree, fn node -> format_tree_node(node, "", true) end)
- Enum.join(formatted, "\n")
- end
-
- defp format_tree_node(node, prefix, is_last) do
- connector = if is_last, do: "└─ ", else: "├─ "
-
- line =
- "#{prefix}#{connector}#{inspect(node.id)} (#{inspect(node.module)}) #{inspect(node.pid)}"
-
- if Enum.empty?(node.children) do
- line
- else
- child_prefix = prefix <> if(is_last, do: " ", else: "│ ")
-
- child_lines =
- node.children
- |> Enum.with_index()
- |> Enum.map_join("\n", fn {child, idx} ->
- is_last_child = idx == length(node.children) - 1
- format_tree_node(child, child_prefix, is_last_child)
- end)
-
- line <> "\n" <> child_lines
- end
- end
-end
diff --git a/lib/term_ui/config.ex b/lib/term_ui/config.ex
index d1e0f966..b08a8c03 100644
--- a/lib/term_ui/config.ex
+++ b/lib/term_ui/config.ex
@@ -1,242 +1,185 @@
defmodule TermUI.Config do
@moduledoc """
- Configuration reading and defaults for TermUI applications.
-
- This module provides application-level configuration for TermUI.
- Configuration is read from the application environment and can be
- overridden by runtime options.
-
- ## Configuration
-
- Add to your `config/config.exs`:
-
- import Config
-
- config :term_ui,
- backend: :auto,
- color_mode: :auto,
- character_set: :auto,
- render_interval: 16,
- iex_compatible: :auto
-
- ## Options
-
- ### `:backend`
-
- Controls which terminal backend to use.
-
- - `:auto` - (default) Automatically detect and use the best available backend
- - `:raw` - Force raw mode (requires OTP 28+, error if unavailable)
- - `:tty` - Force TTY mode (line-based input, no raw mode attempt)
-
- Example:
- config :term_ui, backend: :tty
-
- ### `:color_mode`
-
- Controls color depth preference.
-
- - `:auto` - (default) Detect terminal color support
- - `:true_color` - Force 24-bit RGB color
- - `:color_256` - Force 256-color palette
- - `:color_16` - Force 16-color palette
- - `:monochrome` - Force monochrome (no color)
-
- Example:
- config :term_ui, color_mode: :color_256
-
- ### `:character_set`
-
- Controls character set preference.
-
- - `:auto` - (default) Detect Unicode support
- - `:unicode` - Force Unicode character set
- - `:ascii` - Force ASCII character set
-
- Example:
- config :term_ui, character_set: :ascii
-
- ### `:render_interval`
-
- Milliseconds between renders.
-
- - Default: `16` (~60 FPS)
- - Lower values = smoother animations but more CPU usage
- - Higher values = less CPU but choppier animations
-
- Example:
- config :term_ui, render_interval: 33 # ~30 FPS
-
- ### `:iex_compatible`
-
- Controls IEx compatibility mode detection.
-
- - `:auto` - (default) Automatically detect if running in IEx
- - `true` - Force IEx-compatible mode
- - `false` - Force standalone mode
-
- This can also be controlled via the `TERM_UI_IEX_MODE` environment variable.
-
- Example:
- config :term_ui, iex_compatible: true
-
- To override via environment variable:
- export TERM_UI_IEX_MODE=true
-
- See `TermUI.iex_mode?/0` for more details on IEx detection.
-
- ## Runtime Options Override
-
- Runtime options passed to `TermUI.App.start/2` or `TermUI.App.run/2`
- always take precedence over configuration:
-
- # Config says :tty, but runtime option says :raw
- {:ok, _pid} = TermUI.App.start(MyApp, backend: :raw)
-
- ## Per-Environment Configuration
-
- You can configure different settings per environment:
-
- # config/dev.exs
- config :term_ui, backend: :raw
-
- # config/test.exs
- config :term_ui, backend: :tty
-
- # config/prod.exs
- config :term_ui, backend: :auto
+ Adapts known v1 application environment values to v2 runtime options.
+ Explicit runtime and backend options always take precedence. Each v1 key
+ emits one deprecation warning for the life of the VM when TermUI uses it.
"""
- @type option_key ::
- :backend
- | :color_mode
- | :character_set
- | :render_interval
- | :skip_terminal
- | :use_input_handler
- | :name
-
- @type option :: {option_key(), term()}
-
- @default_backend :auto
- @default_color_mode :auto
- @default_character_set :auto
- @default_render_interval 16
-
- @doc """
- Gets a configuration value by key with an optional default.
-
- ## Examples
-
- iex> TermUI.Config.get(:backend)
- :auto
-
- iex> TermUI.Config.get(:render_interval)
- 16
-
- iex> Application.put_env(:term_ui, :backend, :tty)
- iex> TermUI.Config.get(:backend)
- :tty
-
- """
- @spec get(option_key(), term()) :: term()
- def get(key, default \\ nil)
+ require Logger
+
+ @warning_key {__MODULE__, :deprecated_warnings}
+ @backend_values [:auto, :raw, :tty]
+ @color_values [:auto, :true_color, :color_256, :color_16, :monochrome]
+ @character_values [:auto, :unicode, :ascii]
+ @iex_values [:auto, true, false]
+
+ @type error :: {:invalid_legacy_config, atom(), term(), String.t()}
+
+ @doc "Merges supported v1 application environment values into v2 runtime options."
+ @spec merge_runtime_options(keyword()) :: {:ok, keyword()} | {:error, error()}
+ def merge_runtime_options(opts) when is_list(opts) do
+ with {:ok, opts} <- merge_backend(opts),
+ {:ok, opts} <- merge_color_mode(opts),
+ {:ok, opts} <- merge_character_set(opts),
+ {:ok, opts} <- merge_render_interval(opts) do
+ merge_iex_mode(opts)
+ end
+ end
- def get(:backend, default) do
- Application.get_env(:term_ui, :backend, default || @default_backend)
+ @doc false
+ @spec reset_deprecation_warnings() :: :ok
+ def reset_deprecation_warnings do
+ :persistent_term.erase(@warning_key)
+ :ok
end
- def get(:color_mode, default) do
- Application.get_env(:term_ui, :color_mode, default || @default_color_mode)
+ defp merge_backend(opts) do
+ merge_runtime_option(opts, :backend, :backend, @backend_values, & &1)
end
- def get(:character_set, default) do
- Application.get_env(:term_ui, :character_set, default || @default_character_set)
+ defp merge_color_mode(opts) do
+ merge_backend_option(opts, :color_mode, :color_mode, @color_values)
end
- def get(:render_interval, default) do
- Application.get_env(:term_ui, :render_interval, default || @default_render_interval)
+ defp merge_character_set(opts) do
+ merge_backend_option(opts, :character_set, :character_set, @character_values)
end
- def get(key, default) do
- Application.get_env(:term_ui, key, default)
+ defp merge_render_interval(opts) do
+ if Keyword.has_key?(opts, :render_interval) do
+ {:ok, opts}
+ else
+ case Application.fetch_env(:term_ui, :render_interval) do
+ {:ok, interval} when is_integer(interval) and interval > 0 ->
+ warn_once(:render_interval, ":render_interval runtime option")
+ {:ok, Keyword.put(opts, :render_interval, interval)}
+
+ {:ok, invalid} ->
+ invalid(:render_interval, invalid, "expected a positive integer")
+
+ :error ->
+ {:ok, opts}
+ end
+ end
end
- @doc """
- Gets all configuration values as a keyword list.
+ defp merge_iex_mode(opts) do
+ if Keyword.has_key?(opts, :backend) do
+ {:ok, opts}
+ else
+ case Application.fetch_env(:term_ui, :iex_compatible) do
+ {:ok, value} ->
+ merge_iex_value(opts, value)
+
+ :error ->
+ {:ok, opts}
+ end
+ end
+ end
- Returns the current application configuration merged with defaults.
+ defp merge_runtime_option(opts, new_key, old_key, valid_values, mapper) do
+ if Keyword.has_key?(opts, new_key) do
+ {:ok, opts}
+ else
+ case Application.fetch_env(:term_ui, old_key) do
+ {:ok, value} ->
+ merge_validated_runtime_option(opts, new_key, old_key, value, valid_values, mapper)
+
+ :error ->
+ {:ok, opts}
+ end
+ end
+ end
- ## Examples
+ defp merge_backend_option(opts, old_key, new_key, valid_values) do
+ if backend_option_present?(opts, new_key) do
+ {:ok, opts}
+ else
+ case Application.fetch_env(:term_ui, old_key) do
+ {:ok, value} ->
+ merge_validated_backend_option(opts, old_key, new_key, value, valid_values)
+
+ :error ->
+ {:ok, opts}
+ end
+ end
+ end
- iex> Keyword.keys(TermUI.Config.all())
- [:backend, :color_mode, :character_set, :render_interval]
+ defp merge_iex_value(opts, value) when value in @iex_values do
+ warn_once(:iex_compatible, ":backend runtime option")
+ backend = if value == true, do: :tty, else: :auto
+ {:ok, Keyword.put(opts, :backend, backend)}
+ end
- """
- @spec all() :: keyword()
- def all do
- [
- backend: get(:backend),
- color_mode: get(:color_mode),
- character_set: get(:character_set),
- render_interval: get(:render_interval)
- ]
+ defp merge_iex_value(_opts, invalid) do
+ invalid(:iex_compatible, invalid, "expected :auto, true, or false")
end
- @doc """
- Merges application configuration with runtime options.
+ defp merge_validated_runtime_option(opts, new_key, old_key, value, valid_values, mapper) do
+ if value in valid_values do
+ warn_once(old_key, ":#{new_key} runtime option")
+ {:ok, Keyword.put(opts, new_key, mapper.(value))}
+ else
+ invalid_value(old_key, value, valid_values)
+ end
+ end
- Runtime options take precedence over application configuration.
- This allows users to override config for specific cases.
+ defp merge_validated_backend_option(opts, old_key, new_key, value, valid_values) do
+ if value in valid_values do
+ warn_once(old_key, ":backend_opts option :#{new_key}")
+ {:ok, put_backend_option(opts, new_key, value)}
+ else
+ invalid_value(old_key, value, valid_values)
+ end
+ end
- ## Priority
+ defp invalid_value(key, value, valid_values) do
+ expected = Enum.map_join(valid_values, ", ", &inspect/1)
+ invalid(key, value, "expected one of #{expected}")
+ end
- 1. Runtime options (highest)
- 2. Application configuration
- 3. Module defaults (lowest)
+ defp backend_option_present?(opts, key) do
+ explicit_backend_opts?(Keyword.fetch(opts, :backend_opts), key) or
+ explicit_backend_spec_opts?(Keyword.get(opts, :backend), key)
+ end
- ## Examples
+ defp explicit_backend_opts?({:ok, opts}, key) when is_list(opts),
+ do: Keyword.has_key?(opts, key)
- iex> TermUI.Config.merge_options([backend: :auto])
- [backend: :auto, render_interval: 16, ...]
+ defp explicit_backend_opts?({:ok, _invalid}, _key), do: true
+ defp explicit_backend_opts?(:error, _key), do: false
- iex> TermUI.Config.merge_options(backend: :raw, render_interval: 33)
- [backend: :raw, render_interval: 33, ...]
+ defp explicit_backend_spec_opts?({_module, opts}, key) when is_list(opts),
+ do: Keyword.has_key?(opts, key)
- # Runtime option overrides config
- iex> Application.put_env(:term_ui, :backend, :tty)
- iex> opts = TermUI.Config.merge_options(backend: :raw)
- iex> opts[:backend]
- :raw
+ defp explicit_backend_spec_opts?(_backend, _key), do: false
- """
- @spec merge_options(keyword()) :: keyword()
- def merge_options(runtime_opts \\ []) do
- config_opts = all()
+ defp put_backend_option(opts, key, value) do
+ Keyword.update(opts, :backend_opts, [{key, value}], &Keyword.put_new(&1, key, value))
+ end
- # Runtime options take precedence
- Keyword.merge(config_opts, runtime_opts)
+ defp invalid(key, value, expectation) do
+ warn_once(key, "the matching v2 runtime or backend option")
+ {:error, {:invalid_legacy_config, key, value, expectation}}
end
- @doc """
- Returns the default options without reading from application config.
+ defp warn_once(key, replacement) do
+ lock = {{__MODULE__, key}, self()}
- This is useful for testing or when you want to ignore application config.
+ _result =
+ :global.trans(lock, fn ->
+ warned = :persistent_term.get(@warning_key, MapSet.new())
- ## Examples
+ unless MapSet.member?(warned, key) do
+ Logger.warning(
+ "config :term_ui, #{inspect(key)} is deprecated; use the #{replacement} instead"
+ )
- iex> TermUI.Config.defaults()
- [backend: :auto, color_mode: :auto, character_set: :auto, render_interval: 16]
+ :persistent_term.put(@warning_key, MapSet.put(warned, key))
+ end
+ end)
- """
- @spec defaults() :: keyword()
- def defaults do
- [
- backend: @default_backend,
- color_mode: @default_color_mode,
- character_set: @default_character_set,
- render_interval: @default_render_interval
- ]
+ :ok
end
end
diff --git a/lib/term_ui/container.ex b/lib/term_ui/container.ex
deleted file mode 100644
index bfb817e8..00000000
--- a/lib/term_ui/container.ex
+++ /dev/null
@@ -1,333 +0,0 @@
-defmodule TermUI.Container do
- @moduledoc """
- Behaviour for container components that manage children.
-
- Container extends StatefulComponent with child management capabilities.
- Use this for components that contain and organize other components,
- like panels, forms, tabs, or split views.
-
- ## Basic Usage
-
- defmodule MyApp.Panel do
- use TermUI.Container
-
- @impl true
- def init(props) do
- {:ok, %{title: props[:title] || "Panel"}}
- end
-
- @impl true
- def children(_state) do
- [
- {MyApp.Label, %{text: "Header"}, :header},
- {MyApp.Content, %{}, :content}
- ]
- end
-
- @impl true
- def layout(children, state, area) do
- # Arrange children within available area
- header_area = %{area | height: 1}
- content_area = %{area | y: area.y + 1, height: area.height - 1}
-
- [
- {Enum.at(children, 0), header_area},
- {Enum.at(children, 1), content_area}
- ]
- end
-
- @impl true
- def render(state, _area) do
- # Container render is called after children
- # Return empty if children handle all rendering
- empty()
- end
-
- @impl true
- def handle_event(_event, state) do
- {:ok, state}
- end
- end
-
- ## Child Specifications
-
- Children are specified as tuples:
-
- - `{Module, props}` - Child with auto-generated ID
- - `{Module, props, id}` - Child with explicit ID
-
- IDs are used for event routing and child lookup.
-
- ## Layout
-
- The `layout/3` callback positions children within the container's area.
- It receives the list of child specs and must return tuples of
- `{child_spec, area}` assigning each child its rendering bounds.
-
- ## Event Routing
-
- Containers can route events to specific children or handle them directly.
- Override `route_event/2` to customize event routing.
- """
-
- alias TermUI.Component.RenderNode
-
- # Type definitions
-
- @typedoc "Component state"
- @type state :: term()
-
- @typedoc "Available rendering area"
- @type rect :: %{x: integer(), y: integer(), width: integer(), height: integer()}
-
- @typedoc "Render tree output"
- @type render_tree :: RenderNode.t() | [render_tree()] | String.t()
-
- @typedoc "Event from user input"
- @type event :: term()
-
- @typedoc "Command for side effects"
- @type command :: term()
-
- @typedoc "Child specification"
- @type child_spec ::
- {module(), props :: map()}
- | {module(), props :: map(), id :: term()}
-
- @typedoc "Child with assigned area"
- @type child_layout :: {child_spec(), rect()}
-
- @typedoc "Event routing target"
- @type route_target ::
- :self
- | {:child, id :: term()}
- | :broadcast
-
- # Required callbacks (inherited from StatefulComponent)
-
- @doc """
- Initializes container state from props.
-
- Same as `StatefulComponent.init/1`.
- """
- @callback init(props :: map()) ::
- {:ok, state()}
- | {:ok, state(), [command()]}
- | {:stop, term()}
-
- @doc """
- Returns the list of child components.
-
- Called to determine which children the container should manage.
- Children are specified as tuples with module, props, and optional ID.
-
- ## Parameters
-
- - `state` - Current container state
-
- ## Returns
-
- List of child specifications.
-
- ## Examples
-
- @impl true
- def children(state) do
- [
- {Label, %{text: state.title}, :title},
- {Button, %{label: "OK"}, :ok_button},
- {Button, %{label: "Cancel"}, :cancel_button}
- ]
- end
- """
- @callback children(state()) :: [child_spec()]
-
- @doc """
- Lays out children within the available area.
-
- Determines the position and size of each child component.
- The default implementation stacks children vertically.
-
- ## Parameters
-
- - `children` - List of child specifications from `children/1`
- - `state` - Current container state
- - `area` - Available area for the container
-
- ## Returns
-
- List of `{child_spec, area}` tuples.
-
- ## Examples
-
- @impl true
- def layout(children, _state, area) do
- # Horizontal layout with equal widths
- child_width = div(area.width, length(children))
-
- children
- |> Enum.with_index()
- |> Enum.map(fn {child, i} ->
- child_area = %{
- x: area.x + i * child_width,
- y: area.y,
- width: child_width,
- height: area.height
- }
- {child, child_area}
- end)
- end
- """
- @callback layout([child_spec()], state(), rect()) :: [child_layout()]
-
- @doc """
- Handles input events.
-
- Same as `StatefulComponent.handle_event/2`.
- """
- @callback handle_event(event(), state()) ::
- {:ok, state()}
- | {:ok, state(), [command()]}
- | {:stop, term(), state()}
-
- @doc """
- Renders the container.
-
- Called after children are rendered. Can render container chrome
- (borders, titles) or return empty if children handle everything.
-
- Same signature as `StatefulComponent.render/2`.
- """
- @callback render(state(), rect()) :: render_tree()
-
- # Optional callbacks
-
- @doc """
- Routes an event to the appropriate handler.
-
- Override to customize how events are distributed to children.
- Default routes all events to self.
-
- ## Parameters
-
- - `event` - The input event
- - `state` - Current container state
-
- ## Returns
-
- - `:self` - Handle event in this container
- - `{:child, id}` - Route to specific child
- - `:broadcast` - Send to all children
- """
- @callback route_event(event(), state()) :: route_target()
-
- @doc """
- Called when a child emits a message.
-
- Use to handle messages bubbling up from child components.
-
- ## Parameters
-
- - `child_id` - ID of the child that sent the message
- - `message` - The message from the child
- - `state` - Current container state
- """
- @callback handle_child_message(child_id :: term(), message :: term(), state()) ::
- {:ok, state()}
- | {:ok, state(), [command()]}
-
- @optional_callbacks route_event: 2, handle_child_message: 3
-
- @doc false
- defmacro __using__(_opts) do
- quote do
- @behaviour TermUI.Container
-
- alias TermUI.Component.RenderNode
- alias TermUI.Renderer.Style
-
- import TermUI.Component.Helpers
-
- # Default implementations
-
- @doc false
- def terminate(_reason, _state), do: :ok
-
- @doc false
- def handle_info(_message, state), do: {:ok, state}
-
- @doc false
- def handle_call(_request, _from, state), do: {:reply, :ok, state}
-
- @doc false
- def route_event(_event, _state), do: :self
-
- @doc false
- def handle_child_message(_child_id, _message, state), do: {:ok, state}
-
- @doc """
- Default layout: stack children vertically.
- """
- def layout(children, _state, area) do
- child_count = length(children)
-
- if child_count == 0 do
- []
- else
- child_height = div(area.height, child_count)
-
- children
- |> Enum.with_index()
- |> Enum.map(fn {child, i} ->
- child_area = %{
- x: area.x,
- y: area.y + i * child_height,
- width: area.width,
- height: child_height
- }
-
- {child, child_area}
- end)
- end
- end
-
- defoverridable terminate: 2,
- handle_info: 2,
- handle_call: 3,
- route_event: 2,
- handle_child_message: 3,
- layout: 3
-
- # Helper functions for child management
-
- @doc """
- Normalizes a child spec to always have an ID.
- """
- def normalize_child_spec({module, props}) when is_atom(module) and is_map(props) do
- {module, props, make_ref()}
- end
-
- def normalize_child_spec({module, props, id}) when is_atom(module) and is_map(props) do
- {module, props, id}
- end
-
- @doc """
- Gets the ID from a child spec.
- """
- def child_id({_module, _props, id}), do: id
- def child_id({_module, _props}), do: nil
-
- @doc """
- Gets the module from a child spec.
- """
- def child_module({module, _props, _id}), do: module
- def child_module({module, _props}), do: module
-
- @doc """
- Gets the props from a child spec.
- """
- def child_props({_module, props, _id}), do: props
- def child_props({_module, props}), do: props
- end
- end
-end
diff --git a/lib/term_ui/renderer/cursor_optimizer.ex b/lib/term_ui/cursor_optimizer.ex
similarity index 86%
rename from lib/term_ui/renderer/cursor_optimizer.ex
rename to lib/term_ui/cursor_optimizer.ex
index 64632125..473c28aa 100644
--- a/lib/term_ui/renderer/cursor_optimizer.ex
+++ b/lib/term_ui/cursor_optimizer.ex
@@ -1,36 +1,5 @@
-defmodule TermUI.Renderer.CursorOptimizer do
- @moduledoc """
- Optimizes cursor movement by selecting the cheapest movement option.
-
- Instead of always using absolute positioning (`ESC[{row};{col}H`), this module
- calculates the byte cost of various movement options and selects the minimum.
- This can reduce cursor movement overhead by 40%+ compared to naive positioning.
-
- ## Movement Options
-
- * Absolute positioning: `ESC[{r};{c}H` (6-10 bytes)
- * Relative up/down/left/right: `ESC[{n}A/B/C/D` (4-6 bytes)
- * Carriage return: `\\r` (1 byte)
- * Newline: `\\n` (1 byte)
- * Home: `ESC[H` (3 bytes)
- * Literal spaces for small rightward moves (1 byte each)
-
- ## Usage
-
- # Create optimizer with initial position
- optimizer = CursorOptimizer.new()
-
- # Get optimal movement sequence
- {sequence, new_optimizer} = CursorOptimizer.move_to(optimizer, 5, 10)
-
- # After text output, advance cursor
- new_optimizer = CursorOptimizer.advance(optimizer, 5)
- """
-
- # Dialyzer: Functions return specific struct types or specific integers,
- # but the public spec uses general types for API clarity.
- @dialyzer {:nowarn_function,
- new: 0, new: 2, max_position: 0, cost_cr: 0, cost_lf: 0, cost_home: 0}
+defmodule TermUI.CursorOptimizer do
+ @moduledoc false
@type t :: %__MODULE__{
row: pos_integer(),
@@ -53,9 +22,9 @@ defmodule TermUI.Renderer.CursorOptimizer do
@doc """
Creates a new cursor optimizer with cursor at position (1, 1).
"""
- @spec new() :: t()
+ @spec new() :: %__MODULE__{row: 1, col: 1, bytes_saved: 0}
def new do
- %__MODULE__{}
+ %__MODULE__{row: 1, col: 1, bytes_saved: 0}
end
@doc """
@@ -142,7 +111,7 @@ defmodule TermUI.Renderer.CursorOptimizer do
Positions beyond this value may cause undefined behavior on some terminals.
"""
- @spec max_position() :: pos_integer()
+ @spec max_position() :: 9999
def max_position, do: @max_cursor_pos
# Cost calculation functions
@@ -198,19 +167,19 @@ defmodule TermUI.Renderer.CursorOptimizer do
@doc """
Calculates the byte cost of carriage return (move to column 1).
"""
- @spec cost_cr() :: pos_integer()
+ @spec cost_cr() :: 1
def cost_cr, do: 1
@doc """
Calculates the byte cost of newline (move down one row).
"""
- @spec cost_lf() :: pos_integer()
+ @spec cost_lf() :: 1
def cost_lf, do: 1
@doc """
Calculates the byte cost of home (move to 1,1).
"""
- @spec cost_home() :: pos_integer()
+ @spec cost_home() :: 3
def cost_home, do: 3
# Optimal movement selection
diff --git a/lib/term_ui/dev/dev_mode.ex b/lib/term_ui/dev/dev_mode.ex
deleted file mode 100644
index fa7fea02..00000000
--- a/lib/term_ui/dev/dev_mode.ex
+++ /dev/null
@@ -1,464 +0,0 @@
-defmodule TermUI.Dev.DevMode do
- @moduledoc """
- Central coordinator for development mode features.
-
- DevMode manages the lifecycle and state of all development tools:
- - UI Inspector - Shows component boundaries
- - State Inspector - Displays component state tree
- - Hot Reload - Updates code without restart
- - Performance Monitor - Shows FPS, memory, frame times
-
- ## Usage
-
- # Enable development mode
- DevMode.enable()
-
- # Toggle individual features
- DevMode.toggle_ui_inspector()
- DevMode.toggle_state_inspector()
- DevMode.toggle_perf_monitor()
-
- # Check status
- DevMode.enabled?()
- DevMode.ui_inspector_enabled?()
-
- ## Keyboard Shortcuts (when enabled)
-
- - Ctrl+Shift+I: Toggle UI Inspector
- - Ctrl+Shift+S: Toggle State Inspector
- - Ctrl+Shift+P: Toggle Performance Monitor
- """
-
- use GenServer
-
- alias TermUI.Dev.HotReload
- alias TermUI.Dev.PerfMonitor
- alias TermUI.Dev.StateInspector
- alias TermUI.Dev.UIInspector
-
- @type state :: %{
- enabled: boolean(),
- ui_inspector: boolean(),
- state_inspector: boolean(),
- perf_monitor: boolean(),
- hot_reload: boolean(),
- selected_component: term() | nil,
- components: %{term() => component_info()},
- metrics: metrics()
- }
-
- @type component_info :: %{
- module: module(),
- state: term(),
- render_time: integer(),
- bounds: bounds()
- }
-
- @type bounds :: %{x: integer(), y: integer(), width: integer(), height: integer()}
-
- @type metrics :: %{
- fps: float(),
- frame_times: [integer()],
- memory: integer(),
- process_count: integer()
- }
-
- # Client API
-
- @doc """
- Starts the DevMode server.
- """
- def start_link(opts \\ []) do
- GenServer.start_link(__MODULE__, opts, name: __MODULE__)
- end
-
- @doc """
- Enables development mode.
- """
- @spec enable() :: :ok
- def enable do
- GenServer.call(__MODULE__, :enable)
- end
-
- @doc """
- Disables development mode.
- """
- @spec disable() :: :ok
- def disable do
- GenServer.call(__MODULE__, :disable)
- end
-
- @doc """
- Returns whether development mode is enabled.
- """
- @spec enabled?() :: boolean()
- def enabled? do
- GenServer.call(__MODULE__, :enabled?)
- end
-
- @doc """
- Toggles UI inspector overlay.
- """
- @spec toggle_ui_inspector() :: boolean()
- def toggle_ui_inspector do
- GenServer.call(__MODULE__, :toggle_ui_inspector)
- end
-
- @doc """
- Returns whether UI inspector is enabled.
- """
- @spec ui_inspector_enabled?() :: boolean()
- def ui_inspector_enabled? do
- GenServer.call(__MODULE__, :ui_inspector_enabled?)
- end
-
- @doc """
- Toggles state inspector panel.
- """
- @spec toggle_state_inspector() :: boolean()
- def toggle_state_inspector do
- GenServer.call(__MODULE__, :toggle_state_inspector)
- end
-
- @doc """
- Returns whether state inspector is enabled.
- """
- @spec state_inspector_enabled?() :: boolean()
- def state_inspector_enabled? do
- GenServer.call(__MODULE__, :state_inspector_enabled?)
- end
-
- @doc """
- Toggles performance monitor.
- """
- @spec toggle_perf_monitor() :: boolean()
- def toggle_perf_monitor do
- GenServer.call(__MODULE__, :toggle_perf_monitor)
- end
-
- @doc """
- Returns whether performance monitor is enabled.
- """
- @spec perf_monitor_enabled?() :: boolean()
- def perf_monitor_enabled? do
- GenServer.call(__MODULE__, :perf_monitor_enabled?)
- end
-
- @doc """
- Toggles hot reload.
- """
- @spec toggle_hot_reload() :: boolean()
- def toggle_hot_reload do
- GenServer.call(__MODULE__, :toggle_hot_reload)
- end
-
- @doc """
- Registers a component for inspection.
- """
- @spec register_component(term(), module(), term(), bounds()) :: :ok
- def register_component(id, module, state, bounds) do
- GenServer.cast(__MODULE__, {:register_component, id, module, state, bounds})
- end
-
- @doc """
- Unregisters a component.
- """
- @spec unregister_component(term()) :: :ok
- def unregister_component(id) do
- GenServer.cast(__MODULE__, {:unregister_component, id})
- end
-
- @doc """
- Updates component state for inspection.
- """
- @spec update_component_state(term(), term()) :: :ok
- def update_component_state(id, state) do
- GenServer.cast(__MODULE__, {:update_component_state, id, state})
- end
-
- @doc """
- Records component render time.
- """
- @spec record_render_time(term(), integer()) :: :ok
- def record_render_time(id, time_us) do
- GenServer.cast(__MODULE__, {:record_render_time, id, time_us})
- end
-
- @doc """
- Selects a component for detailed inspection.
- """
- @spec select_component(term()) :: :ok
- def select_component(id) do
- GenServer.cast(__MODULE__, {:select_component, id})
- end
-
- @doc """
- Gets the currently selected component.
- """
- @spec get_selected_component() :: term() | nil
- def get_selected_component do
- GenServer.call(__MODULE__, :get_selected_component)
- end
-
- @doc """
- Gets all registered components.
- """
- @spec get_components() :: %{term() => component_info()}
- def get_components do
- GenServer.call(__MODULE__, :get_components)
- end
-
- @doc """
- Gets current performance metrics.
- """
- @spec get_metrics() :: metrics()
- def get_metrics do
- GenServer.call(__MODULE__, :get_metrics)
- end
-
- @doc """
- Records a frame for FPS calculation.
- """
- @spec record_frame(integer()) :: :ok
- def record_frame(frame_time_us) do
- GenServer.cast(__MODULE__, {:record_frame, frame_time_us})
- end
-
- @doc """
- Handles keyboard shortcut for development mode.
- """
- @spec handle_shortcut(atom(), [atom()]) :: :handled | :not_handled
- def handle_shortcut(key, modifiers) do
- GenServer.call(__MODULE__, {:handle_shortcut, key, modifiers})
- end
-
- @doc """
- Gets the current state for rendering overlays.
- """
- @spec get_state() :: state()
- def get_state do
- GenServer.call(__MODULE__, :get_state)
- end
-
- # Server callbacks
-
- @impl true
- def init(_opts) do
- state = %{
- enabled: false,
- ui_inspector: false,
- state_inspector: false,
- perf_monitor: false,
- hot_reload: false,
- selected_component: nil,
- components: %{},
- metrics: %{
- fps: 0.0,
- frame_times: [],
- memory: 0,
- process_count: 0
- }
- }
-
- {:ok, state}
- end
-
- @impl true
- def handle_call(:enable, _from, state) do
- {:reply, :ok, %{state | enabled: true}}
- end
-
- def handle_call(:disable, _from, state) do
- {:reply, :ok, %{state | enabled: false}}
- end
-
- def handle_call(:enabled?, _from, state) do
- {:reply, state.enabled, state}
- end
-
- def handle_call(:toggle_ui_inspector, _from, state) do
- new_value = not state.ui_inspector
- {:reply, new_value, %{state | ui_inspector: new_value}}
- end
-
- def handle_call(:ui_inspector_enabled?, _from, state) do
- {:reply, state.ui_inspector, state}
- end
-
- def handle_call(:toggle_state_inspector, _from, state) do
- new_value = not state.state_inspector
- {:reply, new_value, %{state | state_inspector: new_value}}
- end
-
- def handle_call(:state_inspector_enabled?, _from, state) do
- {:reply, state.state_inspector, state}
- end
-
- def handle_call(:toggle_perf_monitor, _from, state) do
- new_value = not state.perf_monitor
- {:reply, new_value, %{state | perf_monitor: new_value}}
- end
-
- def handle_call(:perf_monitor_enabled?, _from, state) do
- {:reply, state.perf_monitor, state}
- end
-
- def handle_call(:toggle_hot_reload, _from, state) do
- new_value = not state.hot_reload
-
- if new_value do
- HotReload.start()
- else
- HotReload.stop()
- end
-
- {:reply, new_value, %{state | hot_reload: new_value}}
- end
-
- def handle_call(:get_selected_component, _from, state) do
- {:reply, state.selected_component, state}
- end
-
- def handle_call(:get_components, _from, state) do
- {:reply, state.components, state}
- end
-
- def handle_call(:get_metrics, _from, state) do
- {:reply, state.metrics, state}
- end
-
- def handle_call(:get_state, _from, state) do
- {:reply, state, state}
- end
-
- def handle_call({:handle_shortcut, key, modifiers}, _from, state) do
- if state.enabled and :ctrl in modifiers and :shift in modifiers do
- case key do
- :i ->
- new_value = not state.ui_inspector
- {:reply, :handled, %{state | ui_inspector: new_value}}
-
- :s ->
- new_value = not state.state_inspector
- {:reply, :handled, %{state | state_inspector: new_value}}
-
- :p ->
- new_value = not state.perf_monitor
- {:reply, :handled, %{state | perf_monitor: new_value}}
-
- _ ->
- {:reply, :not_handled, state}
- end
- else
- {:reply, :not_handled, state}
- end
- end
-
- @impl true
- def handle_cast({:register_component, id, module, comp_state, bounds}, state) do
- component_info = %{
- module: module,
- state: comp_state,
- render_time: 0,
- bounds: bounds
- }
-
- components = Map.put(state.components, id, component_info)
- {:noreply, %{state | components: components}}
- end
-
- def handle_cast({:unregister_component, id}, state) do
- components = Map.delete(state.components, id)
- selected = if state.selected_component == id, do: nil, else: state.selected_component
- {:noreply, %{state | components: components, selected_component: selected}}
- end
-
- def handle_cast({:update_component_state, id, comp_state}, state) do
- components =
- update_in(state.components, [id], fn
- nil -> nil
- info -> %{info | state: comp_state}
- end)
-
- {:noreply, %{state | components: components}}
- end
-
- def handle_cast({:record_render_time, id, time_us}, state) do
- components =
- update_in(state.components, [id], fn
- nil -> nil
- info -> %{info | render_time: time_us}
- end)
-
- {:noreply, %{state | components: components}}
- end
-
- def handle_cast({:select_component, id}, state) do
- {:noreply, %{state | selected_component: id}}
- end
-
- def handle_cast({:record_frame, frame_time_us}, state) do
- # Keep last 60 frame times for rolling average
- frame_times = [frame_time_us | state.metrics.frame_times] |> Enum.take(60)
-
- # Calculate FPS from average frame time
- avg_time =
- if length(frame_times) > 0 do
- Enum.sum(frame_times) / length(frame_times)
- else
- # Default to ~60 FPS
- 16_666
- end
-
- fps = if avg_time > 0, do: 1_000_000 / avg_time, else: 0.0
-
- # Get memory and process count
- memory = :erlang.memory(:total)
- process_count = length(Process.list())
-
- metrics = %{
- fps: fps,
- frame_times: frame_times,
- memory: memory,
- process_count: process_count
- }
-
- {:noreply, %{state | metrics: metrics}}
- end
-
- # Rendering helpers
-
- @doc """
- Renders development mode overlays.
-
- Returns render nodes for UI inspector, state inspector, and performance monitor.
- """
- @spec render_overlays(state(), bounds()) :: term()
- def render_overlays(state, area) do
- overlays = []
-
- overlays =
- if state.ui_inspector do
- [UIInspector.render(state.components, state.selected_component, area) | overlays]
- else
- overlays
- end
-
- overlays =
- if state.state_inspector do
- selected = state.components[state.selected_component]
- [StateInspector.render(selected, area) | overlays]
- else
- overlays
- end
-
- overlays =
- if state.perf_monitor do
- [PerfMonitor.render(state.metrics, area) | overlays]
- else
- overlays
- end
-
- overlays
- end
-end
diff --git a/lib/term_ui/dev/hot_reload.ex b/lib/term_ui/dev/hot_reload.ex
deleted file mode 100644
index 544b9681..00000000
--- a/lib/term_ui/dev/hot_reload.ex
+++ /dev/null
@@ -1,367 +0,0 @@
-defmodule TermUI.Dev.HotReload do
- @moduledoc """
- Hot Reload integration for development mode.
-
- Watches .ex files for changes and reloads modules without restarting
- the application. State is preserved across reloads where possible.
-
- ## Usage
-
- # Start hot reload
- HotReload.start()
-
- # Stop hot reload
- HotReload.stop()
-
- # Manually reload a module
- HotReload.reload_module(MyModule)
-
- ## How It Works
-
- 1. File watcher monitors lib/ directory for .ex changes
- 2. On change, affected modules are identified
- 3. Modules are recompiled using Mix
- 4. Old code is purged and new code loaded
- 5. Notification sent to UI
-
- Note: Uses polling-based approach for compatibility.
- """
-
- use GenServer
-
- require Logger
-
- # 1 second
- @poll_interval 1000
-
- @type state :: %{
- enabled: boolean(),
- watched_dirs: [String.t()],
- file_mtimes: %{String.t() => integer()},
- on_reload: (module() -> any()) | nil
- }
-
- # Dialyzer: Pattern match coverage warnings
- @dialyzer {:nowarn_function,
- handle_info: 2, reload_module: 1, check_for_changes: 1, recompile_file: 1}
-
- # Client API
-
- @doc """
- Starts the hot reload watcher.
- """
- def start_link(opts \\ []) do
- GenServer.start_link(__MODULE__, opts, name: __MODULE__)
- end
-
- @doc """
- Starts watching for file changes.
- """
- @spec start() :: :ok
- def start do
- GenServer.call(__MODULE__, :start)
- end
-
- @doc """
- Stops watching for file changes.
- """
- @spec stop() :: :ok
- def stop do
- GenServer.call(__MODULE__, :stop)
- end
-
- @doc """
- Returns whether hot reload is running.
- """
- @spec running?() :: boolean()
- def running? do
- GenServer.call(__MODULE__, :running?)
- end
-
- @doc """
- Manually reloads a specific module.
- """
- @spec reload_module(module()) :: :ok | {:error, term()}
- def reload_module(module) do
- GenServer.call(__MODULE__, {:reload_module, module})
- end
-
- @doc """
- Sets callback for reload notifications.
- """
- @spec on_reload((module() -> any())) :: :ok
- def on_reload(callback) do
- GenServer.cast(__MODULE__, {:on_reload, callback})
- end
-
- @doc """
- Gets recently reloaded modules.
- """
- @spec get_recent_reloads() :: [{module(), DateTime.t()}]
- def get_recent_reloads do
- GenServer.call(__MODULE__, :get_recent_reloads)
- end
-
- # Server callbacks
-
- @impl true
- def init(opts) do
- state = %{
- enabled: false,
- watched_dirs: Keyword.get(opts, :dirs, ["lib"]),
- file_mtimes: %{},
- on_reload: nil,
- recent_reloads: []
- }
-
- {:ok, state}
- end
-
- @impl true
- def handle_call(:start, _from, state) do
- if state.enabled do
- {:reply, :ok, state}
- else
- # Initial file scan
- file_mtimes = scan_files(state.watched_dirs)
-
- # Start polling
- schedule_poll()
-
- Logger.info("Hot reload started, watching #{length(state.watched_dirs)} directories")
- {:reply, :ok, %{state | enabled: true, file_mtimes: file_mtimes}}
- end
- end
-
- def handle_call(:stop, _from, state) do
- Logger.info("Hot reload stopped")
- {:reply, :ok, %{state | enabled: false}}
- end
-
- def handle_call(:running?, _from, state) do
- {:reply, state.enabled, state}
- end
-
- def handle_call({:reload_module, module}, _from, state) do
- result = do_reload_module(module)
-
- state =
- case result do
- :ok ->
- notify_reload(state.on_reload, module)
- add_recent_reload(state, module)
-
- _ ->
- state
- end
-
- {:reply, result, state}
- end
-
- def handle_call(:get_recent_reloads, _from, state) do
- {:reply, state.recent_reloads, state}
- end
-
- @impl true
- def handle_cast({:on_reload, callback}, state) do
- {:noreply, %{state | on_reload: callback}}
- end
-
- @impl true
- def handle_info(:poll, state) do
- if state.enabled do
- state = check_for_changes(state)
- schedule_poll()
- {:noreply, state}
- else
- {:noreply, state}
- end
- end
-
- def handle_info(_msg, state) do
- {:noreply, state}
- end
-
- # Private functions
-
- defp schedule_poll do
- Process.send_after(self(), :poll, @poll_interval)
- end
-
- defp scan_files(dirs) do
- dirs
- |> Enum.flat_map(&find_ex_files/1)
- |> Enum.map(fn path ->
- mtime = get_file_mtime(path)
- {path, mtime}
- end)
- |> Map.new()
- end
-
- defp find_ex_files(dir) do
- if File.dir?(dir) do
- Path.wildcard(Path.join(dir, "**/*.ex"))
- else
- []
- end
- end
-
- defp get_file_mtime(path) do
- case File.stat(path, time: :posix) do
- {:ok, %{mtime: mtime}} -> mtime
- _ -> 0
- end
- end
-
- defp check_for_changes(state) do
- current_mtimes = scan_files(state.watched_dirs)
-
- # Find changed files
- changed_files =
- current_mtimes
- |> Enum.filter(fn {path, mtime} ->
- old_mtime = Map.get(state.file_mtimes, path, 0)
- mtime > old_mtime
- end)
- |> Enum.map(fn {path, _} -> path end)
-
- if length(changed_files) > 0 do
- Logger.debug("Hot reload detected changes in #{length(changed_files)} files")
-
- # Reload changed files
- state =
- Enum.reduce(changed_files, state, fn path, acc ->
- reload_file(path, acc)
- end)
-
- %{state | file_mtimes: current_mtimes}
- else
- state
- end
- end
-
- defp reload_file(path, state) do
- Logger.info("Hot reloading: #{path}")
-
- case recompile_file(path) do
- {:ok, modules} ->
- reload_modules(modules, state)
-
- {:error, reason} ->
- Logger.error("Failed to recompile #{path}: #{inspect(reason)}")
- state
- end
- end
-
- defp reload_modules(modules, state) do
- Enum.reduce(modules, state, fn module, acc ->
- reload_single_module(module, acc, state.on_reload)
- end)
- end
-
- defp reload_single_module(module, state, on_reload) do
- case do_reload_module(module) do
- :ok ->
- notify_reload(on_reload, module)
- add_recent_reload(state, module)
-
- {:error, reason} ->
- Logger.error("Failed to reload #{module}: #{inspect(reason)}")
- state
- end
- end
-
- defp recompile_file(path) do
- # Get modules defined in the file before recompilation
- _old_modules = get_modules_in_file(path)
-
- # Recompile using Code module
- case Code.compile_file(path) do
- modules when is_list(modules) ->
- module_names = Enum.map(modules, fn {name, _binary} -> name end)
- {:ok, module_names}
-
- _ ->
- {:error, :compilation_failed}
- end
- rescue
- e ->
- {:error, e}
- end
-
- defp get_modules_in_file(path) do
- # Parse file to find module definitions
- case File.read(path) do
- {:ok, content} ->
- Regex.scan(~r/defmodule\s+([\w.]+)/, content)
- |> Enum.map(fn [_, name] ->
- String.to_atom("Elixir.#{name}")
- end)
-
- _ ->
- []
- end
- end
-
- defp do_reload_module(module) do
- # Purge old code
- :code.purge(module)
-
- # Delete old code if still loaded
- :code.delete(module)
-
- # The module should already be loaded from compilation
- # Just ensure it's available
- case Code.ensure_loaded(module) do
- {:module, ^module} -> :ok
- {:error, reason} -> {:error, reason}
- end
- rescue
- e -> {:error, e}
- end
-
- defp notify_reload(nil, _module), do: :ok
-
- defp notify_reload(callback, module) when is_function(callback, 1) do
- callback.(module)
- rescue
- e ->
- Logger.error("Hot reload callback failed: #{inspect(e)}")
- end
-
- defp add_recent_reload(state, module) do
- reload = {module, DateTime.utc_now()}
- recent = [reload | state.recent_reloads] |> Enum.take(20)
- %{state | recent_reloads: recent}
- end
-
- # Public helpers
-
- @doc """
- Gets the source file path for a module.
- """
- @spec get_module_source(module()) :: String.t() | nil
- def get_module_source(module) do
- case module.__info__(:compile)[:source] do
- source when is_list(source) -> List.to_string(source)
- _ -> nil
- end
- rescue
- _ -> nil
- end
-
- @doc """
- Checks if a module can be hot reloaded.
-
- Some modules (like those with NIFs or ports) may not reload properly.
- """
- @spec can_reload?(module()) :: boolean()
- def can_reload?(module) do
- # Check if module exists and is loaded
- # Check if it has source info (not a native module)
- Code.ensure_loaded?(module) and
- is_list(module.__info__(:compile)[:source])
- rescue
- _ -> false
- end
-end
diff --git a/lib/term_ui/dev/perf_monitor.ex b/lib/term_ui/dev/perf_monitor.ex
deleted file mode 100644
index ca27dd72..00000000
--- a/lib/term_ui/dev/perf_monitor.ex
+++ /dev/null
@@ -1,235 +0,0 @@
-defmodule TermUI.Dev.PerfMonitor do
- @moduledoc """
- Performance Monitor for development mode.
-
- Displays real-time performance metrics: FPS, frame time, memory usage,
- and process count. Toggle with Ctrl+Shift+P when dev mode is enabled.
-
- ## Metrics
-
- - **FPS**: Frames per second (rolling average)
- - **Frame Time**: Time to render each frame (graph)
- - **Memory**: Total BEAM memory usage
- - **Processes**: Number of BEAM processes
- """
-
- import TermUI.Component.Helpers
-
- # Dialyzer: Functions return specific map types
- @dialyzer {:nowarn_function, get_memory_breakdown: 0}
-
- @panel_width 35
- @graph_height 5
-
- @doc """
- Renders the performance monitor panel.
-
- Returns render nodes for the metrics display.
- """
- @spec render(map(), map()) :: term()
- def render(metrics, _area) do
- # Build panel content
- header = render_header()
- fps_line = render_fps(metrics.fps)
- frame_graph = render_frame_graph(metrics.frame_times)
- memory_line = render_memory(metrics.memory)
- process_line = render_processes(metrics.process_count)
- footer = render_footer()
-
- content = [header, fps_line] ++ frame_graph ++ [memory_line, process_line, footer]
-
- panel = stack(:vertical, content)
-
- # Position at bottom-left
- %{
- type: :positioned,
- content: panel,
- x: 0,
- # Will be adjusted by renderer
- y: 0,
- # Below inspectors but above content
- z: 195
- }
- end
-
- defp render_header do
- title = " Performance Monitor "
- remaining = @panel_width - String.length(title)
- left = div(remaining, 2)
- right = remaining - left
-
- text(
- "┌" <> String.duplicate("─", left - 1) <> title <> String.duplicate("─", right - 1) <> "┐"
- )
- end
-
- defp render_footer do
- text("└" <> String.duplicate("─", @panel_width - 2) <> "┘")
- end
-
- defp render_fps(fps) do
- fps_str = Float.round(fps, 1) |> to_string()
- label = "FPS: #{fps_str}"
- padded = String.pad_trailing(label, @panel_width - 4)
- text("│ " <> padded <> " │")
- end
-
- defp render_memory(bytes) do
- memory_str = format_bytes(bytes)
- label = "Memory: #{memory_str}"
- padded = String.pad_trailing(label, @panel_width - 4)
- text("│ " <> padded <> " │")
- end
-
- defp render_processes(count) do
- label = "Processes: #{count}"
- padded = String.pad_trailing(label, @panel_width - 4)
- text("│ " <> padded <> " │")
- end
-
- defp render_frame_graph(frame_times) when frame_times == [] do
- # Empty graph
- for _i <- 1..@graph_height do
- text("│" <> String.duplicate(" ", @panel_width - 2) <> "│")
- end
- end
-
- defp render_frame_graph(frame_times) do
- # Normalize frame times to graph height
- max_time = Enum.max(frame_times)
- min_time = Enum.min(frame_times)
- range = max(1, max_time - min_time)
-
- # Take last N frame times that fit in width
- graph_width = @panel_width - 4
- times = frame_times |> Enum.take(graph_width) |> Enum.reverse()
-
- # Create graph rows (top to bottom)
- for row <- (@graph_height - 1)..0//-1 do
- threshold = min_time + row / @graph_height * range
-
- chars =
- Enum.map_join(times, "", fn time ->
- if time >= threshold, do: "▄", else: " "
- end)
-
- padded = String.pad_trailing(chars, graph_width)
- text("│ " <> padded <> " │")
- end
- end
-
- @doc """
- Formats bytes into human-readable string.
- """
- @spec format_bytes(integer()) :: String.t()
- def format_bytes(bytes) when bytes < 1024 do
- "#{bytes} B"
- end
-
- def format_bytes(bytes) when bytes < 1024 * 1024 do
- kb = Float.round(bytes / 1024, 1)
- "#{kb} KB"
- end
-
- def format_bytes(bytes) when bytes < 1024 * 1024 * 1024 do
- mb = Float.round(bytes / (1024 * 1024), 1)
- "#{mb} MB"
- end
-
- def format_bytes(bytes) do
- gb = Float.round(bytes / (1024 * 1024 * 1024), 2)
- "#{gb} GB"
- end
-
- @doc """
- Formats microseconds into human-readable string.
- """
- @spec format_time(integer()) :: String.t()
- def format_time(us) when us < 1000 do
- "#{us}μs"
- end
-
- def format_time(us) when us < 1_000_000 do
- ms = Float.round(us / 1000, 1)
- "#{ms}ms"
- end
-
- def format_time(us) do
- s = Float.round(us / 1_000_000, 2)
- "#{s}s"
- end
-
- @doc """
- Gets detailed BEAM memory breakdown.
- """
- @spec get_memory_breakdown() :: map()
- def get_memory_breakdown do
- %{
- total: :erlang.memory(:total),
- processes: :erlang.memory(:processes),
- atom: :erlang.memory(:atom),
- binary: :erlang.memory(:binary),
- code: :erlang.memory(:code),
- ets: :erlang.memory(:ets)
- }
- end
-
- @doc """
- Gets scheduler utilization.
- """
- @spec get_scheduler_utilization() :: [float()]
- def get_scheduler_utilization do
- # The :scheduler.utilization/1 function is only available in OTP 28+
- # Using apply/3 to avoid compiler warning about undefined function
- if function_exported?(:scheduler, :utilization, 1) do
- try do
- # credo:disable-for-next-line Credo.Check.Refactor.Apply
- case apply(:scheduler, :utilization, [1]) do
- [{:total, _, total} | _] -> [total]
- _ -> []
- end
- rescue
- _ -> []
- end
- else
- []
- end
- end
-
- @doc """
- Gets message queue length for a process.
- """
- @spec get_message_queue_length(pid()) :: integer()
- def get_message_queue_length(pid) do
- case Process.info(pid, :message_queue_len) do
- {:message_queue_len, len} -> len
- _ -> 0
- end
- end
-
- @doc """
- Gets reduction count for a process (rough CPU usage indicator).
- """
- @spec get_reductions(pid()) :: integer()
- def get_reductions(pid) do
- case Process.info(pid, :reductions) do
- {:reductions, count} -> count
- _ -> 0
- end
- end
-
- @doc """
- Calculates sparkline characters for a list of values.
- """
- @spec values_to_sparkline([number()], number(), number()) :: String.t()
- def values_to_sparkline(values, min_val, max_val) do
- bars = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"]
- range = max(1, max_val - min_val)
-
- Enum.map_join(values, "", fn value ->
- normalized = (value - min_val) / range
- index = min(7, trunc(normalized * 8))
- Enum.at(bars, index)
- end)
- end
-end
diff --git a/lib/term_ui/dev/state_inspector.ex b/lib/term_ui/dev/state_inspector.ex
deleted file mode 100644
index fc8ee319..00000000
--- a/lib/term_ui/dev/state_inspector.ex
+++ /dev/null
@@ -1,298 +0,0 @@
-defmodule TermUI.Dev.StateInspector do
- @moduledoc """
- State Inspector panel for development mode.
-
- Shows detailed component state in a side panel with expandable tree view.
- Toggle with Ctrl+Shift+S when dev mode is enabled.
-
- ## Features
-
- - Tree view of component state
- - Expand/collapse nested values
- - State change highlighting
- - Type information display
- """
-
- import TermUI.Component.Helpers
-
- @default_width 40
-
- @doc """
- Renders the state inspector panel.
-
- Returns render nodes for the side panel with state tree.
- """
- @spec render(map() | nil, map()) :: term()
- def render(nil, _area) do
- render_empty_panel()
- end
-
- def render(component_info, area) do
- panel_width = min(@default_width, div(area.width, 3))
- panel_x = area.width - panel_width
-
- # Render state tree
- state_tree = render_state_tree(component_info.state, 0)
-
- # Create panel
- header = render_panel_header(component_info.module, panel_width)
- content = render_panel_content(state_tree, panel_width)
-
- panel = stack(:vertical, [header | content])
-
- %{
- type: :positioned,
- content: panel,
- x: panel_x,
- y: 0,
- # Below UI inspector but above content
- z: 190
- }
- end
-
- defp render_empty_panel do
- %{
- type: :empty
- }
- end
-
- defp render_panel_header(module, width) do
- module_name = get_module_name(module)
- title = " State: #{module_name} "
-
- # Center title
- remaining = width - String.length(title)
- left = div(remaining, 2)
- right = remaining - left
-
- header_text = String.duplicate("─", left) <> title <> String.duplicate("─", right)
- text(header_text)
- end
-
- defp render_panel_content(tree_lines, width) do
- tree_lines
- |> Enum.map(fn line ->
- # Pad or truncate to panel width
- padded = String.pad_trailing(line, width - 2)
- truncated = String.slice(padded, 0, width - 2)
- text("│" <> truncated <> "│")
- end)
- end
-
- @doc """
- Renders state as a tree of lines.
- """
- @spec render_state_tree(term(), integer()) :: [String.t()]
- def render_state_tree(value, depth) do
- render_value_by_type(value, depth, String.duplicate(" ", depth))
- end
-
- defp render_value_by_type(value, depth, indent) when is_map(value) do
- render_map_value(value, depth, indent)
- end
-
- defp render_value_by_type(value, depth, indent) when is_list(value) do
- render_list_value(value, depth, indent)
- end
-
- defp render_value_by_type(value, depth, indent) when is_tuple(value) do
- render_tuple_tree(value, depth)
- |> ensure_indent_applied(indent)
- end
-
- defp render_value_by_type(value, depth, indent) do
- if struct_value?(value) do
- render_struct_tree(value, depth)
- else
- [indent <> format_value(value)]
- end
- end
-
- defp render_map_value(value, _depth, indent) when map_size(value) == 0 do
- [indent <> "%{}"]
- end
-
- defp render_map_value(value, depth, _indent) do
- render_map_tree(value, depth)
- end
-
- defp render_list_value([], _depth, indent), do: [indent <> "[]"]
- defp render_list_value(value, depth, _indent), do: render_list_tree(value, depth)
-
- defp ensure_indent_applied(lines, _indent), do: lines
-
- defp render_map_tree(map, depth) do
- indent = String.duplicate(" ", depth)
-
- header = [indent <> "%{"]
-
- entries =
- map
- |> Enum.flat_map(fn {key, value} ->
- key_str = format_key(key)
-
- if simple_value?(value) do
- [indent <> " #{key_str}: #{format_value(value)}"]
- else
- [indent <> " #{key_str}:" | render_state_tree(value, depth + 2)]
- end
- end)
-
- footer = [indent <> "}"]
-
- header ++ entries ++ footer
- end
-
- defp render_list_tree(list, depth) do
- indent = String.duplicate(" ", depth)
-
- if length(list) > 10 do
- # Truncate long lists
- first_items =
- list
- |> Enum.take(5)
- |> Enum.with_index()
- |> Enum.flat_map(fn {item, idx} ->
- render_indexed_item(item, idx, indent, depth, "[")
- end)
-
- [indent <> "["] ++
- first_items ++ [indent <> " ... (#{length(list) - 5} more)", indent <> "]"]
- else
- entries =
- list
- |> Enum.with_index()
- |> Enum.flat_map(fn {item, idx} ->
- render_indexed_item(item, idx, indent, depth, "[")
- end)
-
- [indent <> "["] ++ entries ++ [indent <> "]"]
- end
- end
-
- defp render_tuple_tree(tuple, depth) do
- indent = String.duplicate(" ", depth)
- elements = Tuple.to_list(tuple)
-
- if tuple_size(tuple) <= 3 and Enum.all?(elements, &simple_value?/1) do
- # Inline small tuples
- values = Enum.map_join(elements, ", ", &format_value/1)
- [indent <> "{#{values}}"]
- else
- entries =
- elements
- |> Enum.with_index()
- |> Enum.flat_map(fn {item, idx} ->
- render_indexed_item(item, idx, indent, depth, ".")
- end)
-
- [indent <> "{"] ++ entries ++ [indent <> "}"]
- end
- end
-
- defp render_struct_tree(struct, depth) do
- indent = String.duplicate(" ", depth)
- struct_name = struct.__struct__ |> get_module_name()
-
- map = Map.from_struct(struct)
-
- if map_size(map) == 0 do
- [indent <> "%#{struct_name}{}"]
- else
- entries =
- map
- |> Enum.flat_map(fn {key, value} ->
- render_keyed_item(value, to_string(key), indent, depth)
- end)
-
- [indent <> "%#{struct_name}{"] ++ entries ++ [indent <> "}"]
- end
- end
-
- defp render_indexed_item(item, idx, indent, depth, prefix) do
- if simple_value?(item) do
- [indent <> " #{prefix}#{idx}]: #{format_value(item)}"]
- else
- [indent <> " #{prefix}#{idx}]:" | render_state_tree(item, depth + 2)]
- end
- end
-
- defp render_keyed_item(value, key_str, indent, depth) do
- if simple_value?(value) do
- [indent <> " #{key_str}: #{format_value(value)}"]
- else
- [indent <> " #{key_str}:" | render_state_tree(value, depth + 2)]
- end
- end
-
- defp struct_value?(%{__struct__: _}), do: true
- defp struct_value?(_), do: false
-
- defp simple_value?(value) do
- is_atom(value) or is_number(value) or is_binary(value) or
- is_boolean(value) or is_nil(value) or is_pid(value) or is_reference(value)
- end
-
- defp format_key(key) when is_atom(key), do: to_string(key)
- defp format_key(key), do: inspect(key)
-
- defp format_value(nil), do: "nil"
- defp format_value(true), do: "true"
- defp format_value(false), do: "false"
- defp format_value(value) when is_atom(value), do: ":#{value}"
- defp format_value(value) when is_integer(value), do: to_string(value)
- defp format_value(value) when is_float(value), do: Float.to_string(value)
-
- defp format_value(value) when is_binary(value) do
- if String.printable?(value) do
- if String.length(value) > 30 do
- "\"#{String.slice(value, 0, 27)}...\""
- else
- "\"#{value}\""
- end
- else
- "<>"
- end
- end
-
- defp format_value(value) when is_pid(value), do: inspect(value)
- defp format_value(value) when is_reference(value), do: "#Ref<...>"
-
- defp format_value(value) when is_function(value) do
- info = Function.info(value)
- "#Function<#{info[:arity]}>"
- end
-
- defp format_value(value), do: inspect(value, limit: 10)
-
- defp get_module_name(module) when is_atom(module) do
- module
- |> Atom.to_string()
- |> String.split(".")
- |> List.last()
- end
-
- defp get_module_name(_), do: "Unknown"
-
- @doc """
- Compares two states and returns paths that changed.
- """
- @spec diff_states(term(), term()) :: [list()]
- def diff_states(old_state, new_state) do
- diff_values(old_state, new_state, [])
- end
-
- defp diff_values(old, new, _path) when old == new, do: []
-
- defp diff_values(old, new, path) when is_map(old) and is_map(new) do
- all_keys = MapSet.union(MapSet.new(Map.keys(old)), MapSet.new(Map.keys(new)))
-
- Enum.flat_map(all_keys, fn key ->
- old_val = Map.get(old, key)
- new_val = Map.get(new, key)
- diff_values(old_val, new_val, path ++ [key])
- end)
- end
-
- defp diff_values(_old, _new, path), do: [path]
-end
diff --git a/lib/term_ui/dev/ui_inspector.ex b/lib/term_ui/dev/ui_inspector.ex
deleted file mode 100644
index 0cfaf3ea..00000000
--- a/lib/term_ui/dev/ui_inspector.ex
+++ /dev/null
@@ -1,180 +0,0 @@
-defmodule TermUI.Dev.UIInspector do
- @moduledoc """
- UI Inspector overlay for development mode.
-
- Shows component boundaries, names, types, and render times as an overlay
- on top of the application. Toggle with Ctrl+Shift+I when dev mode is enabled.
-
- ## Features
-
- - Component boundary outlines
- - Component name and type labels
- - Render time display
- - Click to select component for state inspection
- """
-
- import TermUI.Component.Helpers
-
- @doc """
- Renders the UI inspector overlay.
-
- Returns render nodes for component boundaries and labels.
- """
- @spec render(map(), term() | nil, map()) :: term()
- def render(components, selected_id, _area) do
- # Render boundaries for all components
- boundaries =
- components
- |> Enum.map(fn {id, info} ->
- render_component_boundary(id, info, id == selected_id)
- end)
-
- # Create overlay container
- %{
- type: :overlay,
- content: stack(:vertical, boundaries),
- x: 0,
- y: 0,
- # Above normal content
- z: 200
- }
- end
-
- @doc """
- Renders a single component's boundary and label.
- """
- @spec render_component_boundary(term(), map(), boolean()) :: term()
- def render_component_boundary(id, info, selected?) do
- bounds = info.bounds
- module_name = get_module_name(info.module)
- render_time = format_render_time(info.render_time)
-
- # Create boundary outline
- border_char = if selected?, do: "█", else: "░"
- border_style = if selected?, do: :selected, else: :normal
-
- # Top border with label
- label = "#{module_name} (#{render_time})"
- top_line = create_labeled_border(label, bounds.width, border_char)
-
- # Side borders
- side_lines =
- for _y <- 1..(bounds.height - 2) do
- border_char <> String.duplicate(" ", bounds.width - 2) <> border_char
- end
-
- # Bottom border
- bottom_line = String.duplicate(border_char, bounds.width)
-
- # Combine into positioned element
- content = [top_line | side_lines] ++ [bottom_line]
- lines = Enum.map(content, &text/1)
-
- %{
- type: :positioned,
- content: stack(:vertical, lines),
- x: bounds.x,
- y: bounds.y,
- id: {:inspector_boundary, id},
- style: border_style
- }
- end
-
- @doc """
- Creates a top border line with embedded label.
- """
- @spec create_labeled_border(String.t(), integer(), String.t()) :: String.t()
- def create_labeled_border(label, width, char) do
- label_with_brackets = "[ #{label} ]"
- label_len = String.length(label_with_brackets)
-
- if label_len >= width - 2 do
- # Label too long, truncate
- truncated = String.slice(label_with_brackets, 0, width - 2)
- char <> truncated <> char
- else
- # Center the label
- remaining = width - label_len
- left = div(remaining, 2)
- right = remaining - left
- String.duplicate(char, left) <> label_with_brackets <> String.duplicate(char, right)
- end
- end
-
- @doc """
- Extracts short module name from full module atom.
- """
- @spec get_module_name(module()) :: String.t()
- def get_module_name(module) when is_atom(module) do
- module
- |> Atom.to_string()
- |> String.split(".")
- |> List.last()
- end
-
- def get_module_name(_), do: "Unknown"
-
- @doc """
- Formats render time for display.
- """
- @spec format_render_time(integer()) :: String.t()
- def format_render_time(time_us) when time_us < 1000 do
- "#{time_us}μs"
- end
-
- def format_render_time(time_us) when time_us < 1_000_000 do
- ms = Float.round(time_us / 1000, 1)
- "#{ms}ms"
- end
-
- def format_render_time(time_us) do
- s = Float.round(time_us / 1_000_000, 2)
- "#{s}s"
- end
-
- @doc """
- Finds component at screen position for selection.
- """
- @spec find_component_at(map(), integer(), integer()) :: term() | nil
- def find_component_at(components, x, y) do
- components
- |> Enum.filter(fn {_id, info} ->
- bounds = info.bounds
-
- x >= bounds.x and x < bounds.x + bounds.width and
- y >= bounds.y and y < bounds.y + bounds.height
- end)
- |> Enum.sort_by(fn {_id, info} ->
- # Prefer smaller (more specific) components
- info.bounds.width * info.bounds.height
- end)
- |> case do
- [{id, _} | _] -> id
- [] -> nil
- end
- end
-
- @doc """
- Gets summary of component state for quick display.
- """
- @spec get_state_summary(term()) :: String.t()
- def get_state_summary(state) when is_map(state) do
- keys = Map.keys(state)
- count = length(keys)
-
- if count <= 3 do
- Enum.map_join(keys, ", ", &to_string/1)
- else
- first_three = keys |> Enum.take(3) |> Enum.map_join(", ", &to_string/1)
- "#{first_three}... (+#{count - 3})"
- end
- end
-
- def get_state_summary(state) when is_list(state) do
- "List[#{length(state)}]"
- end
-
- def get_state_summary(state) do
- inspect(state, limit: 50)
- end
-end
diff --git a/lib/term_ui/renderer/display_width.ex b/lib/term_ui/display_width.ex
similarity index 99%
rename from lib/term_ui/renderer/display_width.ex
rename to lib/term_ui/display_width.ex
index b5e712cb..0325499b 100644
--- a/lib/term_ui/renderer/display_width.ex
+++ b/lib/term_ui/display_width.ex
@@ -1,4 +1,4 @@
-defmodule TermUI.Renderer.DisplayWidth do
+defmodule TermUI.DisplayWidth do
@moduledoc """
Calculates display width of Unicode characters and strings.
diff --git a/lib/term_ui/elm.ex b/lib/term_ui/elm.ex
index da9084bc..edeb9b1d 100644
--- a/lib/term_ui/elm.ex
+++ b/lib/term_ui/elm.ex
@@ -1,285 +1,53 @@
defmodule TermUI.Elm do
@moduledoc """
- The Elm Architecture implementation for TermUI components.
+ The application contract for TermUI.
- This module provides the core callbacks for implementing components
- using The Elm Architecture pattern: `update/2` for state changes and
- `view/1` for rendering.
-
- ## The Pattern
-
- 1. **Events** arrive from terminal input
- 2. **event_to_msg/2** converts events to component-specific messages
- 3. **update/2** transforms state based on messages, returns new state + commands
- 4. **view/1** renders current state to a render tree
- 5. **Commands** execute asynchronously, sending result messages back
-
- ## Usage
-
- defmodule Counter do
- use TermUI.Elm
-
- def init(_opts), do: %{count: 0}
-
- def event_to_msg(%Event.Key{key: :up}, _state), do: {:msg, :increment}
- def event_to_msg(%Event.Key{key: :down}, _state), do: {:msg, :decrement}
- def event_to_msg(_, _), do: :ignore
-
- def update(:increment, state), do: {%{state | count: state.count + 1}, []}
- def update(:decrement, state), do: {%{state | count: state.count - 1}, []}
-
- def view(state) do
- text("Count: \#{state.count}")
- end
- end
+ One runtime owns one application state. `event_to_msg/2`, `update/2`, and
+ `view/1` are pure. Effects are returned as `TermUI.Command` data.
"""
- alias TermUI.Event
- alias TermUI.Message
+ alias TermUI.{Command, Event, Frame}
@type state :: term()
- @type msg :: Message.t()
- @type command :: term()
- @type render_tree :: term()
- @type init_result ::
- state()
- | {state(), [command()]}
- | {:ok, state()}
- | {:ok, state(), [command()]}
-
- @type update_result ::
- {state(), [command()]}
- | {state()}
- | :noreply
-
- @type event_to_msg_result ::
- {:msg, msg()}
- | :ignore
- | :propagate
-
- @doc """
- Converts an event to a component-specific message.
-
- This callback transforms raw terminal events into domain-specific messages
- that have semantic meaning for the component.
-
- ## Parameters
-
- - `event` - The terminal event (Key, Mouse, Resize, etc.)
- - `state` - Current component state
-
- ## Returns
-
- - `{:msg, message}` - Event converted to a message for update
- - `:ignore` - Event not handled by this component
- - `:propagate` - Pass event to parent component
- """
- @callback event_to_msg(Event.t(), state()) :: event_to_msg_result()
-
- @doc """
- Updates component state based on a message.
-
- This is the core logic of the component. It receives the current state
- and a message, and returns the new state plus any commands to execute.
-
- Update functions must be pure—no side effects, no external calls.
- Side effects are performed through commands returned in the result.
-
- ## Parameters
-
- - `msg` - The message to handle
- - `state` - Current component state
-
- ## Returns
-
- - `{new_state, commands}` - New state and commands to execute
- - `{new_state}` - Shorthand for `{new_state, []}`
- - `:noreply` - Keep state unchanged, no commands
+ @type message :: term()
+ @type update_result :: state() | {state(), [Command.t()]} | :noreply
- ## Examples
+ @callback init(keyword()) :: state() | {state(), [Command.t()]}
+ @callback event_to_msg(Event.t(), state()) :: {:msg, message()} | :ignore
+ @callback update(message(), state()) :: update_result()
+ @callback view(state()) :: Frame.t()
+ @callback handle_info(term(), state()) :: update_result()
+ @callback terminate(term(), state()) :: term()
- def update(:increment, state) do
- {%{state | count: state.count + 1}, []}
- end
-
- def update({:fetch_data, url}, state) do
- cmd = Command.http_get(url, {:data_loaded, :response})
- {%{state | loading: true}, [cmd]}
- end
-
- def update(:noop, _state), do: :noreply
- """
- @callback update(msg(), state()) :: update_result()
-
- @doc """
- Renders the current state to a render tree.
-
- View functions must be pure—given the same state, they always produce
- the same output. View functions should be fast since they run every frame.
-
- ## Parameters
-
- - `state` - Current component state
-
- ## Returns
-
- A render tree structure that will be processed into terminal output.
-
- ## Examples
-
- def view(state) do
- box(border: true) do
- text("Count: \#{state.count}")
- end
- end
- """
- @callback view(state()) :: render_tree()
-
- @doc """
- Initializes component state from options.
-
- Called once when the component is created.
-
- ## Parameters
-
- - `opts` - Options passed to the component
-
- ## Returns
-
- Initial state for the component.
- """
- @callback init(opts :: keyword()) :: init_result()
-
- @optional_callbacks [init: 1]
+ @optional_callbacks init: 1, handle_info: 2, terminate: 2
defmacro __using__(_opts) do
quote do
@behaviour TermUI.Elm
- # Import Component.Helpers for RenderNode-based view building
- # (text/1, text/2, box/1, box/2, stack/2, stack/3, styled/2, empty/0)
- import TermUI.Component.Helpers
-
- # Import Elm.Helpers for macros that don't conflict
- # Exclude text, styled, box which are provided by Component.Helpers
- import TermUI.Elm.Helpers, except: [text: 1, styled: 2, box: 1, box: 2]
-
- # Default implementations
-
- @doc false
+ @impl TermUI.Elm
def init(_opts), do: %{}
- @doc false
- def event_to_msg(_event, _state), do: :ignore
+ @impl TermUI.Elm
+ def handle_info(_message, _state), do: :noreply
- defoverridable init: 1, event_to_msg: 2
- end
- end
-
- @doc """
- Normalizes init result to standard form.
-
- Supports plain state as well as state with startup commands.
- """
- @spec normalize_init_result(init_result()) :: {state(), [command()]}
- def normalize_init_result({:ok, state}), do: {state, []}
-
- def normalize_init_result({:ok, state, commands}) when is_list(commands) do
- {state, commands}
- end
-
- def normalize_init_result({state, commands}) when is_list(commands) do
- {state, commands}
- end
-
- def normalize_init_result(state), do: {state, []}
-
- @doc """
- Normalizes update result to standard form.
-
- Converts shorthand forms to the full `{state, commands}` tuple.
- """
- @spec normalize_update_result(update_result(), state()) :: {state(), [command()]}
- def normalize_update_result({state, commands}, _old_state) when is_list(commands) do
- {state, commands}
- end
-
- def normalize_update_result({state}, _old_state) do
- {state, []}
- end
-
- def normalize_update_result(:noreply, old_state) do
- {old_state, []}
- end
+ @impl TermUI.Elm
+ def terminate(_reason, _state), do: :ok
- @doc """
- Validates that an update function is pure (best effort).
-
- Returns warnings if the update function appears to have side effects.
- This is a heuristic check, not a guarantee.
- """
- @spec validate_update_purity(module()) :: :ok | {:warnings, [String.t()]}
- def validate_update_purity(_module) do
- # This would require compile-time analysis or runtime tracing
- # For now, we document the requirement and trust the developer
- :ok
- end
-end
-
-defmodule TermUI.Elm.Helpers do
- @moduledoc """
- Helper functions for Elm Architecture components.
- """
-
- @doc """
- Creates a text render node.
- """
- def text(content) when is_binary(content) do
- {:text, content}
- end
-
- def text(content) do
- {:text, to_string(content)}
- end
-
- @doc """
- Creates a styled text render node.
- """
- def styled(content, style) do
- {:styled, content, style}
- end
-
- @doc """
- Creates a box container.
- """
- defmacro box(opts \\ [], do: block) do
- quote do
- {:box, unquote(opts), unquote(block)}
+ defoverridable init: 1, handle_info: 2, terminate: 2
end
end
- @doc """
- Creates a row container (horizontal layout).
- """
- defmacro row(opts \\ [], do: block) do
- quote do
- {:row, unquote(opts), unquote(block)}
- end
- end
+ @doc false
+ @spec normalize_init_result(term()) :: {state(), [Command.t()]}
+ def normalize_init_result({state, commands}) when is_list(commands), do: {state, commands}
+ def normalize_init_result(state), do: {state, []}
- @doc """
- Creates a column container (vertical layout).
- """
- defmacro column(opts \\ [], do: block) do
- quote do
- {:column, unquote(opts), unquote(block)}
- end
- end
+ @doc false
+ @spec normalize_update_result(term(), state()) :: {state(), [Command.t()]}
+ def normalize_update_result({state, commands}, _old_state) when is_list(commands),
+ do: {state, commands}
- @doc """
- Groups multiple render nodes.
- """
- def fragment(children) when is_list(children) do
- {:fragment, children}
- end
+ def normalize_update_result(:noreply, old_state), do: {old_state, []}
+ def normalize_update_result(state, _old_state), do: {state, []}
end
diff --git a/lib/term_ui/error.ex b/lib/term_ui/error.ex
deleted file mode 100644
index ff7695e5..00000000
--- a/lib/term_ui/error.ex
+++ /dev/null
@@ -1,184 +0,0 @@
-defmodule TermUI.Error do
- @moduledoc """
- Standardized error types for TermUI.
-
- This module provides a consistent set of error types that are used throughout
- the TermUI codebase. Using standardized error types makes error handling
- more predictable and allows for better error messages to users.
-
- ## Error Types
-
- The following error types are defined:
-
- - `:invalid_argument` - A required argument was missing or invalid
- - `:not_found` - A requested resource was not found
- - `:not_supported` - An operation is not supported in the current context
- - `:timeout` - An operation timed out
- - `:terminal_setup_failed` - Failed to initialize the terminal
- - `:size_detection_failed` - Failed to detect terminal dimensions
- - `:invalid_size` - Terminal dimensions were invalid
- - `:out_of_bounds` - An operation exceeded valid bounds
- - `:backend_unavailable` - The requested backend is not available
- - `:command_failed` - An external command failed
- - `:command_not_found` - An external command was not found
- - `:command_not_allowed` - An external command is not in the whitelist
- - `:invalid_configuration` - Application configuration is invalid
- - `:component_crashed` - A component process crashed
- - `:component_unavailable` - A component is not available
-
- ## Usage
-
- When returning errors from functions, use these standardized reasons:
-
- def init(opts) do
- case Keyword.get(opts, :size) do
- nil -> {:error, {:invalid_size, "size is required"}}
- size when is_integer(size) and size > 0 -> {:ok, size}
- _ -> {:error, {:invalid_size, "size must be a positive integer"}}
- end
- end
-
- ## Error Reasons
-
- Error reasons are either:
- - An atom from the list above (simple error)
- - A tuple `{error_type, details}` (error with additional context)
-
- ## Examples
-
- {:error, :not_found}
- {:error, {:invalid_size, "dimensions must be positive"}}
- {:error, {:command_failed, {:exit_code, 1}}}
- """
-
- @type error_reason ::
- :invalid_argument
- | :not_found
- | :not_supported
- | :timeout
- | :terminal_setup_failed
- | :size_detection_failed
- | :invalid_size
- | :out_of_bounds
- | :backend_unavailable
- | :command_failed
- | :command_not_found
- | :command_not_allowed
- | :invalid_configuration
- | :component_crashed
- | :component_unavailable
- | {atom(), term()}
-
- @type result :: {:ok, term()} | {:error, error_reason()}
-
- @doc """
- Formats an error reason into a human-readable string.
-
- ## Examples
-
- iex> TermUI.Error.format(:not_found)
- "not found"
-
- iex> TermUI.Error.format({:invalid_size, "must be positive"})
- "invalid size: must be positive"
-
- iex> TermUI.Error.format({:command_failed, {:exit_code, 1}})
- "command failed: {:exit_code, 1}"
- """
- @spec format(error_reason()) :: String.t()
- def format(:invalid_argument), do: "invalid argument"
- def format(:not_found), do: "not found"
- def format(:not_supported), do: "not supported"
- def format(:timeout), do: "operation timed out"
- def format(:terminal_setup_failed), do: "terminal setup failed"
- def format(:size_detection_failed), do: "failed to detect terminal size"
- def format(:invalid_size), do: "invalid size"
- def format(:out_of_bounds), do: "out of bounds"
- def format(:backend_unavailable), do: "backend unavailable"
- def format(:command_failed), do: "command failed"
- def format(:command_not_found), do: "command not found"
- def format(:command_not_allowed), do: "command not allowed"
- def format(:invalid_configuration), do: "invalid configuration"
- def format(:component_crashed), do: "component crashed"
- def format(:component_unavailable), do: "component unavailable"
-
- def format({type, details}) when is_binary(details) do
- "#{format(type)}: #{details}"
- end
-
- def format({type, details}) do
- "#{format(type)}: #{inspect(details)}"
- end
-
- @doc """
- Creates an error reason with details.
-
- ## Examples
-
- iex> TermUI.Error.error(:invalid_size, "dimensions must be positive")
- {:invalid_size, "dimensions must be positive"}
-
- """
- @spec error(atom(), term()) :: {atom(), term()}
- def error(type, details), do: {type, details}
-
- @doc """
- Returns true if the given term is an error reason.
-
- ## Examples
-
- iex> TermUI.Error.error_reason?(:not_found)
- true
-
- iex> TermUI.Error.error_reason?({:invalid_size, "too small"})
- true
-
- iex> TermUI.Error.error_reason?(:ok)
- false
-
- iex> TermUI.Error.error_reason?({:ok, "result"})
- false
-
- """
- @spec error_reason?(term()) :: boolean()
- def error_reason?(:invalid_argument), do: true
- def error_reason?(:not_found), do: true
- def error_reason?(:not_supported), do: true
- def error_reason?(:timeout), do: true
- def error_reason?(:terminal_setup_failed), do: true
- def error_reason?(:size_detection_failed), do: true
- def error_reason?(:invalid_size), do: true
- def error_reason?(:out_of_bounds), do: true
- def error_reason?(:backend_unavailable), do: true
- def error_reason?(:command_failed), do: true
- def error_reason?(:command_not_found), do: true
- def error_reason?(:command_not_allowed), do: true
- def error_reason?(:invalid_configuration), do: true
- def error_reason?(:component_crashed), do: true
- def error_reason?(:component_unavailable), do: true
-
- def error_reason?({type, _}) when is_atom(type) do
- error_reason?(type)
- end
-
- def error_reason?(_), do: false
-
- @doc """
- Returns the error type from an error reason.
-
- For simple error reasons (atoms), returns the atom itself.
- For tuple error reasons, returns the first element (the type).
-
- ## Examples
-
- iex> TermUI.Error.error_type(:not_found)
- :not_found
-
- iex> TermUI.Error.error_type({:invalid_size, "too small"})
- :invalid_size
-
- """
- @spec error_type(error_reason()) :: atom()
- def error_type({type, _}), do: type
- def error_type(type), do: type
-end
diff --git a/lib/term_ui/event.ex b/lib/term_ui/event.ex
index 5a280cee..9b3f5954 100644
--- a/lib/term_ui/event.ex
+++ b/lib/term_ui/event.ex
@@ -1,206 +1,148 @@
defmodule TermUI.Event do
@moduledoc """
- Event type definitions for TermUI.
+ Normalized input from a terminal backend.
- Events represent user input from the terminal: keyboard presses,
- mouse actions, and focus changes. Events are routed to components
- by the EventRouter based on focus state and position.
-
- ## Event Types
-
- - `Key` - Keyboard input (key press, char input)
- - `Mouse` - Mouse actions (click, move, scroll)
- - `Focus` - Focus changes (gained, lost)
- - `Custom` - Application-defined events
-
- ## Examples
-
- # Key event
- event = Event.key(:enter)
- event = Event.key(:a, char: "a")
- event = Event.key(:c, modifiers: [:ctrl])
-
- # Mouse event
- event = Event.mouse(:click, :left, 10, 20)
- event = Event.mouse(:move, nil, 15, 25)
-
- # Focus event
- event = Event.focus(:gained)
- event = Event.focus(:lost)
+ Printable input is `Text`. Named or modified keys are `Key`. Paste, mouse,
+ resize, and focus input have separate types. Use `TermUI.Input` to normalize
+ data from an external input adapter. Applications do not parse terminal byte
+ sequences.
"""
- @typedoc "Union type for all event types"
@type t ::
__MODULE__.Key.t()
+ | __MODULE__.Text.t()
+ | __MODULE__.Paste.t()
| __MODULE__.Mouse.t()
- | __MODULE__.Focus.t()
- | __MODULE__.Custom.t()
| __MODULE__.Resize.t()
- | __MODULE__.Paste.t()
- | __MODULE__.Tick.t()
-
- # Key Event
+ | __MODULE__.Focus.t()
defmodule Key do
- @moduledoc """
- Keyboard input event.
-
- Represents a key press with optional character and modifiers.
- """
-
- @type t :: %__MODULE__{
- key: atom(),
- char: String.t() | nil,
- modifiers: [atom()],
- timestamp: integer()
- }
-
- defstruct key: nil,
- char: nil,
- modifiers: [],
- timestamp: 0
-
- @doc """
- Creates a new key event.
- """
- def new(key, opts \\ []) do
+ @moduledoc "A named or modified key press."
+ @type t :: %__MODULE__{key: atom() | String.t(), modifiers: [atom()], timestamp: integer()}
+ @schema Zoi.struct(__MODULE__, %{
+ key: Zoi.union([Zoi.atom(), Zoi.string()]),
+ modifiers: Zoi.array(Zoi.atom()) |> Zoi.default([]),
+ timestamp: Zoi.integer() |> Zoi.default(0)
+ })
+ @enforce_keys Zoi.Struct.enforce_keys(@schema)
+ defstruct Zoi.Struct.struct_fields(@schema)
+
+ @doc false
+ def schema, do: @schema
+
+ @doc false
+ def new(key, opts) when is_atom(key) or is_binary(key) do
%__MODULE__{
key: key,
- char: Keyword.get(opts, :char),
- modifiers: Keyword.get(opts, :modifiers, []),
+ modifiers: opts |> Keyword.get(:modifiers, []) |> Enum.uniq(),
timestamp: Keyword.get(opts, :timestamp, System.monotonic_time(:millisecond))
}
end
end
- # Mouse Event
-
- defmodule Mouse do
- @moduledoc """
- Mouse input event.
-
- Represents mouse actions with position and button info.
- """
-
- @type action ::
- :click | :double_click | :move | :drag | :scroll_up | :scroll_down | :press | :release
- @type button :: :left | :middle | :right | nil
-
- @type t :: %__MODULE__{
- action: action(),
- button: button(),
- x: integer(),
- y: integer(),
- modifiers: [atom()],
- timestamp: integer()
- }
-
- defstruct action: :click,
- button: :left,
- x: 0,
- y: 0,
- modifiers: [],
- timestamp: 0
-
- @doc """
- Creates a new mouse event.
- """
- def new(action, button, x, y, opts \\ []) do
+ defmodule Text do
+ @moduledoc "Printable Unicode text input."
+ @type t :: %__MODULE__{text: String.t(), timestamp: integer()}
+ @schema Zoi.struct(__MODULE__, %{
+ text: Zoi.string() |> Zoi.min(1),
+ timestamp: Zoi.integer() |> Zoi.default(0)
+ })
+ @enforce_keys Zoi.Struct.enforce_keys(@schema)
+ defstruct Zoi.Struct.struct_fields(@schema)
+
+ @doc false
+ def schema, do: @schema
+
+ @doc false
+ def new(text, opts) when is_binary(text) and text != "" do
%__MODULE__{
- action: action,
- button: button,
- x: x,
- y: y,
- modifiers: Keyword.get(opts, :modifiers, []),
+ text: text,
timestamp: Keyword.get(opts, :timestamp, System.monotonic_time(:millisecond))
}
end
end
- # Focus Event
-
- defmodule Focus do
- @moduledoc """
- Focus change event.
-
- Sent to components when they gain or lose focus.
- """
-
- @type action :: :gained | :lost
-
- @type t :: %__MODULE__{
- action: action(),
- timestamp: integer()
- }
-
- defstruct action: :gained,
- timestamp: 0
-
- @doc """
- Creates a new focus event.
- """
- def new(action, opts \\ []) when action in [:gained, :lost] do
+ defmodule Paste do
+ @moduledoc "Text received in one bracketed-paste operation."
+ @type t :: %__MODULE__{content: String.t(), timestamp: integer()}
+ @schema Zoi.struct(__MODULE__, %{
+ content: Zoi.string(),
+ timestamp: Zoi.integer() |> Zoi.default(0)
+ })
+ @enforce_keys Zoi.Struct.enforce_keys(@schema)
+ defstruct Zoi.Struct.struct_fields(@schema)
+
+ @doc false
+ def schema, do: @schema
+
+ @doc false
+ def new(content, opts) when is_binary(content) do
%__MODULE__{
- action: action,
+ content: content,
timestamp: Keyword.get(opts, :timestamp, System.monotonic_time(:millisecond))
}
end
end
- # Custom Event
-
- defmodule Custom do
- @moduledoc """
- Application-defined custom event.
-
- For app-specific events not covered by standard types.
- """
-
+ defmodule Mouse do
+ @moduledoc "A normalized mouse action."
+ @type action :: :press | :release | :move | :drag | :scroll_up | :scroll_down
+ @type button :: :left | :middle | :right | nil
@type t :: %__MODULE__{
- name: atom(),
- payload: term(),
+ action: action(),
+ button: button(),
+ x: non_neg_integer(),
+ y: non_neg_integer(),
+ modifiers: [atom()],
timestamp: integer()
}
-
- defstruct name: nil,
- payload: nil,
- timestamp: 0
-
- @doc """
- Creates a new custom event.
- """
- def new(name, payload \\ nil, opts \\ []) do
+ @schema Zoi.struct(__MODULE__, %{
+ action: Zoi.enum([:press, :release, :move, :drag, :scroll_up, :scroll_down]),
+ button: Zoi.enum([:left, :middle, :right, nil]),
+ x: Zoi.integer() |> Zoi.non_negative(),
+ y: Zoi.integer() |> Zoi.non_negative(),
+ modifiers: Zoi.array(Zoi.atom()) |> Zoi.default([]),
+ timestamp: Zoi.integer() |> Zoi.default(0)
+ })
+ @enforce_keys Zoi.Struct.enforce_keys(@schema)
+ defstruct Zoi.Struct.struct_fields(@schema)
+
+ @doc false
+ def schema, do: @schema
+
+ @doc false
+ def new(action, button, x, y, opts)
+ when action in [:press, :release, :move, :drag, :scroll_up, :scroll_down] and
+ button in [:left, :middle, :right, nil] and is_integer(x) and x >= 0 and
+ is_integer(y) and y >= 0 do
%__MODULE__{
- name: name,
- payload: payload,
+ action: action,
+ button: button,
+ x: x,
+ y: y,
+ modifiers: opts |> Keyword.get(:modifiers, []) |> Enum.uniq(),
timestamp: Keyword.get(opts, :timestamp, System.monotonic_time(:millisecond))
}
end
end
- # Resize Event
-
defmodule Resize do
- @moduledoc """
- Terminal resize event.
-
- Sent when the terminal window dimensions change.
- """
-
- @type t :: %__MODULE__{
- width: pos_integer(),
- height: pos_integer(),
- timestamp: integer()
- }
-
- defstruct width: 80,
- height: 24,
- timestamp: 0
-
- @doc """
- Creates a new resize event.
- """
- def new(width, height, opts \\ []) when is_integer(width) and is_integer(height) do
+ @moduledoc "A terminal size change in columns and rows."
+ @type t :: %__MODULE__{width: pos_integer(), height: pos_integer(), timestamp: integer()}
+ @schema Zoi.struct(__MODULE__, %{
+ width: Zoi.integer() |> Zoi.positive(),
+ height: Zoi.integer() |> Zoi.positive(),
+ timestamp: Zoi.integer() |> Zoi.default(0)
+ })
+ @enforce_keys Zoi.Struct.enforce_keys(@schema)
+ defstruct Zoi.Struct.struct_fields(@schema)
+
+ @doc false
+ def schema, do: @schema
+
+ @doc false
+ def new(width, height, opts)
+ when is_integer(width) and width > 0 and is_integer(height) and height > 0 do
%__MODULE__{
width: width,
height: height,
@@ -209,229 +151,76 @@ defmodule TermUI.Event do
end
end
- # Paste Event
-
- defmodule Paste do
- @moduledoc """
- Clipboard paste event.
-
- Sent when content is pasted from the clipboard via bracketed paste mode.
- """
-
- @type t :: %__MODULE__{
- content: String.t(),
- timestamp: integer()
- }
-
- defstruct content: "",
- timestamp: 0
-
- @doc """
- Creates a new paste event.
- """
- def new(content, opts \\ []) when is_binary(content) do
- %__MODULE__{
- content: content,
- timestamp: Keyword.get(opts, :timestamp, System.monotonic_time(:millisecond))
- }
- end
- end
-
- # Tick Event
-
- defmodule Tick do
- @moduledoc """
- Timer tick event.
-
- Represents a periodic timer event for animations and time-based updates.
- """
-
- @type t :: %__MODULE__{
- interval: pos_integer(),
- timestamp: integer()
- }
-
- defstruct interval: 16,
- timestamp: 0
-
- @doc """
- Creates a new tick event.
- """
- def new(interval, opts \\ []) when is_integer(interval) and interval > 0 do
+ defmodule Focus do
+ @moduledoc "A terminal focus change."
+ @type t :: %__MODULE__{action: :gained | :lost, timestamp: integer()}
+ @schema Zoi.struct(__MODULE__, %{
+ action: Zoi.enum([:gained, :lost]),
+ timestamp: Zoi.integer() |> Zoi.default(0)
+ })
+ @enforce_keys Zoi.Struct.enforce_keys(@schema)
+ defstruct Zoi.Struct.struct_fields(@schema)
+
+ @doc false
+ def schema, do: @schema
+
+ @doc false
+ def new(action, opts) when action in [:gained, :lost] do
%__MODULE__{
- interval: interval,
+ action: action,
timestamp: Keyword.get(opts, :timestamp, System.monotonic_time(:millisecond))
}
end
-
- @doc """
- Returns the tick rate in Hz (ticks per second).
- """
- def rate(%__MODULE__{interval: interval}) do
- 1000 / interval
- end
end
- # Convenience constructors
-
- @doc """
- Creates a key event.
-
- ## Examples
-
- Event.key(:enter)
- Event.key(:a, char: "a")
- Event.key(:c, modifiers: [:ctrl])
- """
- @spec key(atom(), keyword()) :: __MODULE__.Key.t()
- def key(key, opts \\ []) do
- Key.new(key, opts)
+ @doc "Creates a named or modified key event."
+ @spec key(atom() | String.t(), keyword()) :: Key.t()
+ def key(key, opts \\ []), do: Key.new(key, opts)
+
+ @doc "Creates a printable text event."
+ @spec text(String.t(), keyword()) :: Text.t()
+ def text(text, opts \\ []), do: Text.new(text, opts)
+
+ @doc "Creates a bracketed-paste event."
+ @spec paste(String.t(), keyword()) :: Paste.t()
+ def paste(content, opts \\ []), do: Paste.new(content, opts)
+
+ @doc "Creates a mouse event."
+ @spec mouse(Mouse.action(), Mouse.button(), non_neg_integer(), non_neg_integer(), keyword()) ::
+ Mouse.t()
+ def mouse(action, button, x, y, opts \\ []), do: Mouse.new(action, button, x, y, opts)
+
+ @doc "Creates a resize event."
+ @spec resize(pos_integer(), pos_integer(), keyword()) :: Resize.t()
+ def resize(width, height, opts \\ []), do: Resize.new(width, height, opts)
+
+ @doc "Creates a focus event."
+ @spec focus(:gained | :lost, keyword()) :: Focus.t()
+ def focus(action, opts \\ []), do: Focus.new(action, opts)
+
+ @doc "Returns the Zoi schema for all normalized terminal events."
+ @spec schema() :: Zoi.schema()
+ def schema do
+ Zoi.union([
+ Key.schema(),
+ Text.schema(),
+ Paste.schema(),
+ Mouse.schema(),
+ Resize.schema(),
+ Focus.schema()
+ ])
end
- @doc """
- Creates a mouse event.
-
- ## Examples
-
- Event.mouse(:click, :left, 10, 20)
- Event.mouse(:move, nil, x, y)
- """
- @spec mouse(
- __MODULE__.Mouse.action(),
- __MODULE__.Mouse.button(),
- integer(),
- integer(),
- keyword()
- ) :: __MODULE__.Mouse.t()
- def mouse(action, button, x, y, opts \\ []) do
- Mouse.new(action, button, x, y, opts)
- end
-
- @doc """
- Creates a focus event.
-
- ## Examples
-
- Event.focus(:gained)
- Event.focus(:lost)
- """
- @spec focus(__MODULE__.Focus.action(), keyword()) :: __MODULE__.Focus.t()
- def focus(action, opts \\ []) do
- Focus.new(action, opts)
- end
-
- @doc """
- Creates a custom event.
-
- ## Examples
-
- Event.custom(:submit, %{value: "hello"})
- """
- @spec custom(atom(), term(), keyword()) :: __MODULE__.Custom.t()
- def custom(name, payload \\ nil, opts \\ []) do
- Custom.new(name, payload, opts)
- end
-
- @doc """
- Creates a resize event.
-
- ## Examples
-
- Event.resize(120, 40)
- """
- @spec resize(pos_integer(), pos_integer(), keyword()) :: __MODULE__.Resize.t()
- def resize(width, height, opts \\ []) do
- Resize.new(width, height, opts)
- end
-
- @doc """
- Creates a paste event.
-
- ## Examples
-
- Event.paste("Hello, World!")
- """
- @spec paste(String.t(), keyword()) :: __MODULE__.Paste.t()
- def paste(content, opts \\ []) do
- Paste.new(content, opts)
- end
-
- @doc """
- Creates a tick event.
-
- ## Examples
-
- Event.tick(16) # ~60 FPS
- Event.tick(1000) # 1 second
- """
- @spec tick(pos_integer(), keyword()) :: __MODULE__.Tick.t()
- def tick(interval, opts \\ []) do
- Tick.new(interval, opts)
- end
-
- # Type checks
-
- @doc "Returns true if event is a key event"
- @spec key?(term()) :: boolean()
- def key?(%Key{}), do: true
- def key?(_), do: false
-
- @doc "Returns true if event is a mouse event"
- @spec mouse?(term()) :: boolean()
- def mouse?(%Mouse{}), do: true
- def mouse?(_), do: false
-
- @doc "Returns true if event is a focus event"
- @spec focus?(term()) :: boolean()
- def focus?(%Focus{}), do: true
- def focus?(_), do: false
-
- @doc "Returns true if event is a custom event"
- @spec custom?(term()) :: boolean()
- def custom?(%Custom{}), do: true
- def custom?(_), do: false
-
- @doc "Returns true if event is a resize event"
- @spec resize?(term()) :: boolean()
- def resize?(%Resize{}), do: true
- def resize?(_), do: false
-
- @doc "Returns true if event is a paste event"
- @spec paste?(term()) :: boolean()
- def paste?(%Paste{}), do: true
- def paste?(_), do: false
-
- @doc "Returns true if event is a tick event"
- @spec tick?(term()) :: boolean()
- def tick?(%Tick{}), do: true
- def tick?(_), do: false
-
- @doc """
- Returns the event type as an atom.
- """
- @spec type(
- __MODULE__.Key.t()
- | __MODULE__.Mouse.t()
- | __MODULE__.Focus.t()
- | __MODULE__.Custom.t()
- | __MODULE__.Resize.t()
- | __MODULE__.Paste.t()
- | __MODULE__.Tick.t()
- ) ::
- :key | :mouse | :focus | :custom | :resize | :paste | :tick
+ @doc "Returns the event type."
+ @spec type(t()) :: :key | :text | :paste | :mouse | :resize | :focus
def type(%Key{}), do: :key
+ def type(%Text{}), do: :text
+ def type(%Paste{}), do: :paste
def type(%Mouse{}), do: :mouse
- def type(%Focus{}), do: :focus
- def type(%Custom{}), do: :custom
def type(%Resize{}), do: :resize
- def type(%Paste{}), do: :paste
- def type(%Tick{}), do: :tick
+ def type(%Focus{}), do: :focus
- @doc """
- Checks if a modifier is present in the event.
- """
- @spec has_modifier?(__MODULE__.Key.t() | __MODULE__.Mouse.t(), atom()) :: boolean()
- def has_modifier?(%{modifiers: modifiers}, modifier) do
- modifier in modifiers
- end
+ @doc "Returns true when the event contains a modifier."
+ @spec has_modifier?(Key.t() | Mouse.t(), atom()) :: boolean()
+ def has_modifier?(%{modifiers: modifiers}, modifier), do: modifier in modifiers
end
diff --git a/lib/term_ui/event/propagation.ex b/lib/term_ui/event/propagation.ex
deleted file mode 100644
index ce549d29..00000000
--- a/lib/term_ui/event/propagation.ex
+++ /dev/null
@@ -1,208 +0,0 @@
-defmodule TermUI.Event.Propagation do
- @moduledoc """
- Event propagation utilities for the component tree.
-
- Handles bubbling and capturing phases of event propagation.
- Events bubble up from target to root until handled.
-
- ## Propagation Phases
-
- 1. **Capture** - Event travels from root to target (optional)
- 2. **Target** - Event delivered to target component
- 3. **Bubble** - Event travels from target to root (default)
-
- ## Usage
-
- # Propagate event up through parent chain
- Propagation.bubble(event, component_id)
-
- # Build parent chain for propagation
- parents = Propagation.get_parent_chain(component_id)
- """
-
- alias TermUI.ComponentRegistry
-
- @type phase :: :capture | :target | :bubble
- @type propagation_result :: :handled | :unhandled | :stopped
-
- @doc """
- Bubbles an event up through the parent chain.
-
- Starts from the given component and propagates up to parents
- until a component handles the event or the root is reached.
-
- ## Parameters
-
- - `event` - The event to propagate
- - `start_id` - Component to start bubbling from
- - `opts` - Options:
- - `:skip_start` - Skip the starting component (default: false)
-
- ## Returns
-
- - `:handled` - A component handled the event
- - `:unhandled` - No component handled the event
- """
- @spec bubble(term(), term(), keyword()) :: propagation_result()
- def bubble(event, start_id, opts \\ []) do
- skip_start = Keyword.get(opts, :skip_start, false)
-
- parent_chain = get_parent_chain(start_id)
-
- chain =
- if skip_start do
- parent_chain
- else
- [start_id | parent_chain]
- end
-
- propagate_through(event, chain)
- end
-
- @doc """
- Captures an event down through the parent chain to target.
-
- Starts from the root and propagates down to the target component.
- Each component can intercept before reaching target.
-
- ## Parameters
-
- - `event` - The event to propagate
- - `target_id` - Target component
-
- ## Returns
-
- - `:handled` - A component handled the event
- - `:unhandled` - No component handled the event
- """
- @spec capture(term(), term()) :: propagation_result()
- def capture(event, target_id) do
- parent_chain = get_parent_chain(target_id)
- chain = Enum.reverse(parent_chain) ++ [target_id]
- propagate_through(event, chain)
- end
-
- @doc """
- Gets the parent chain for a component.
-
- Returns list of parent component ids from immediate parent to root.
-
- ## Example
-
- # If component tree is: root -> container -> button
- get_parent_chain(:button)
- # => [:container, :root]
- """
- @spec get_parent_chain(term()) :: [term()]
- def get_parent_chain(component_id) do
- case ComponentRegistry.get_parent(component_id) do
- {:ok, nil} ->
- []
-
- {:ok, parent_id} ->
- [parent_id | get_parent_chain(parent_id)]
-
- {:error, :not_found} ->
- []
- end
- end
-
- @doc """
- Sets the parent for a component.
-
- Used to build the component tree for propagation.
-
- ## Parameters
-
- - `component_id` - Child component
- - `parent_id` - Parent component (or nil for root)
- """
- @spec set_parent(term(), term() | nil) :: :ok
- def set_parent(component_id, parent_id) do
- ComponentRegistry.set_parent(component_id, parent_id)
- end
-
- @doc """
- Gets children of a component.
-
- ## Returns
-
- List of child component ids.
- """
- @spec get_children(term()) :: [term()]
- def get_children(component_id) do
- ComponentRegistry.get_children(component_id)
- end
-
- @doc """
- Adds metadata about propagation phase to event.
-
- ## Parameters
-
- - `event` - The event
- - `phase` - Current propagation phase
-
- ## Returns
-
- Event with `:propagation_phase` metadata.
- """
- @spec with_phase(term(), phase()) :: map()
- def with_phase(event, phase) when is_map(event) do
- Map.put(event, :propagation_phase, phase)
- end
-
- @doc """
- Checks if an event should stop propagating.
-
- Events can be marked to stop propagation by returning
- `:stop` from handle_event.
- """
- @spec stopped?(term()) :: boolean()
- def stopped?(result) do
- result == :stopped || result == :stop
- end
-
- # Private Functions
-
- defp propagate_through(_event, []) do
- :unhandled
- end
-
- defp propagate_through(event, [component_id | rest]) do
- case send_to_component(component_id, event) do
- :handled ->
- :handled
-
- :stopped ->
- :stopped
-
- :unhandled ->
- propagate_through(event, rest)
-
- {:error, _} ->
- propagate_through(event, rest)
- end
- end
-
- defp send_to_component(component_id, event) do
- case ComponentRegistry.lookup(component_id) do
- {:ok, pid} -> call_component(pid, event)
- {:error, :not_found} -> {:error, :not_found}
- end
- end
-
- defp call_component(pid, event) do
- pid
- |> GenServer.call({:event, event}, 5000)
- |> normalize_event_result()
- catch
- :exit, _ -> {:error, :component_unavailable}
- end
-
- defp normalize_event_result(:handled), do: :handled
- defp normalize_event_result(:stop), do: :stopped
- defp normalize_event_result(:stopped), do: :stopped
- defp normalize_event_result(:unhandled), do: :unhandled
- defp normalize_event_result({:ok, _}), do: :handled
- defp normalize_event_result(_), do: :unhandled
-end
diff --git a/lib/term_ui/event/transformation.ex b/lib/term_ui/event/transformation.ex
deleted file mode 100644
index d91337b8..00000000
--- a/lib/term_ui/event/transformation.ex
+++ /dev/null
@@ -1,231 +0,0 @@
-defmodule TermUI.Event.Transformation do
- @moduledoc """
- Event transformation utilities.
-
- Transforms events as they route to components, including:
- - Coordinate transformation (screen to component-local)
- - Event metadata enrichment
- - Event filtering
-
- ## Usage
-
- # Transform mouse coordinates to component-local
- local_event = Transformation.to_local(event, component_bounds)
-
- # Add metadata to event
- enriched = Transformation.with_metadata(event, %{target: :button})
- """
-
- alias TermUI.Event.Mouse
-
- # Dialyzer: Functions return specific map types
- @dialyzer {:nowarn_function, with_metadata: 2, envelope: 2}
-
- @doc """
- Transforms screen coordinates to component-local coordinates.
-
- For mouse events, subtracts the component's position from the
- event coordinates so the component receives coordinates relative
- to its own origin (0, 0).
-
- ## Parameters
-
- - `event` - Mouse event with screen coordinates
- - `bounds` - Component bounds with x, y position
-
- ## Returns
-
- Event with transformed coordinates, or unchanged event if not a mouse event.
-
- ## Example
-
- event = %Mouse{x: 15, y: 10, ...}
- bounds = %{x: 10, y: 5, width: 20, height: 10}
- local = to_local(event, bounds)
- # local.x = 5, local.y = 5
- """
- @spec to_local(Mouse.t() | term(), map()) :: Mouse.t() | term()
- def to_local(%Mouse{x: x, y: y} = event, %{x: bx, y: by}) do
- %{event | x: x - bx, y: y - by}
- end
-
- def to_local(event, _bounds), do: event
-
- @doc """
- Transforms component-local coordinates back to screen coordinates.
-
- Inverse of `to_local/2`.
-
- ## Parameters
-
- - `event` - Mouse event with local coordinates
- - `bounds` - Component bounds with x, y position
-
- ## Returns
-
- Event with screen coordinates.
- """
- @spec to_screen(Mouse.t() | term(), map()) :: Mouse.t() | term()
- def to_screen(%Mouse{x: x, y: y} = event, %{x: bx, y: by}) do
- %{event | x: x + bx, y: y + by}
- end
-
- def to_screen(event, _bounds), do: event
-
- @doc """
- Adds metadata to an event.
-
- Creates or updates a `:metadata` field on the event struct.
-
- ## Parameters
-
- - `event` - The event to enrich
- - `metadata` - Map of metadata to add
-
- ## Returns
-
- Event with metadata merged.
-
- ## Example
-
- event = with_metadata(key_event, %{target: :input, phase: :bubble})
- """
- @spec with_metadata(map(), map()) :: map()
- def with_metadata(event, metadata) when is_map(event) and is_map(metadata) do
- existing = Map.get(event, :metadata, %{})
- Map.put(event, :metadata, Map.merge(existing, metadata))
- end
-
- @doc """
- Gets metadata from an event.
-
- ## Parameters
-
- - `event` - The event
- - `key` - Metadata key to get
- - `default` - Default value if key not found
-
- ## Returns
-
- The metadata value or default.
- """
- @spec get_metadata(map(), atom(), term()) :: term()
- def get_metadata(event, key, default \\ nil) when is_map(event) do
- event
- |> Map.get(:metadata, %{})
- |> Map.get(key, default)
- end
-
- @doc """
- Checks if an event matches a filter.
-
- ## Filter Options
-
- - `:type` - Event type (:key, :mouse, :focus, :custom)
- - `:key` - Specific key (for key events)
- - `:action` - Specific action (for mouse/focus events)
- - `:button` - Specific button (for mouse events)
- - `:modifiers` - Required modifiers (any or all)
- - `:modifiers_all` - All modifiers must be present
- - `:modifiers_any` - Any modifier must be present
-
- ## Example
-
- # Match Ctrl+C
- matches?(event, type: :key, key: :c, modifiers_all: [:ctrl])
-
- # Match any click
- matches?(event, type: :mouse, action: :click)
- """
- @spec matches?(term(), keyword()) :: boolean()
- def matches?(event, filters) when is_list(filters) do
- Enum.all?(filters, fn {key, value} ->
- matches_filter?(event, key, value)
- end)
- end
-
- @doc """
- Filters a list of events based on criteria.
-
- ## Parameters
-
- - `events` - List of events
- - `filters` - Filter criteria (see `matches?/2`)
-
- ## Returns
-
- List of events matching all filters.
- """
- @spec filter(list(), keyword()) :: list()
- def filter(events, filters) when is_list(events) do
- Enum.filter(events, &matches?(&1, filters))
- end
-
- @doc """
- Creates a standard event envelope with routing metadata.
-
- ## Parameters
-
- - `event` - The raw event
- - `opts` - Options:
- - `:source` - Source of the event
- - `:target` - Target component id
- - `:timestamp` - Override timestamp
-
- ## Returns
-
- Event with envelope metadata.
- """
- @spec envelope(term(), keyword()) :: map()
- def envelope(event, opts \\ []) when is_map(event) do
- metadata = %{
- source: Keyword.get(opts, :source),
- target: Keyword.get(opts, :target),
- routed_at: Keyword.get(opts, :timestamp, System.monotonic_time(:millisecond))
- }
-
- with_metadata(event, metadata)
- end
-
- # Private Functions
-
- defp matches_filter?(%{__struct__: struct}, :type, type) do
- case type do
- :key -> struct == TermUI.Event.Key
- :mouse -> struct == TermUI.Event.Mouse
- :focus -> struct == TermUI.Event.Focus
- :custom -> struct == TermUI.Event.Custom
- _ -> false
- end
- end
-
- defp matches_filter?(%{key: event_key}, :key, key) do
- event_key == key
- end
-
- defp matches_filter?(%{action: event_action}, :action, action) do
- event_action == action
- end
-
- defp matches_filter?(%{button: event_button}, :button, button) do
- event_button == button
- end
-
- defp matches_filter?(%{modifiers: event_mods}, :modifiers_all, required) do
- Enum.all?(required, &(&1 in event_mods))
- end
-
- defp matches_filter?(%{modifiers: event_mods}, :modifiers_any, required) do
- Enum.any?(required, &(&1 in event_mods))
- end
-
- defp matches_filter?(%{modifiers: event_mods}, :modifiers, required) do
- # Default to all modifiers required
- Enum.all?(required, &(&1 in event_mods))
- end
-
- defp matches_filter?(_event, _key, _value) do
- # Unknown filter or field not present
- false
- end
-end
diff --git a/lib/term_ui/event_queue.ex b/lib/term_ui/event_queue.ex
deleted file mode 100644
index 9aa15ad2..00000000
--- a/lib/term_ui/event_queue.ex
+++ /dev/null
@@ -1,282 +0,0 @@
-defmodule TermUI.EventQueue do
- @moduledoc """
- Bounded event queue for preventing DoS via event flooding.
-
- This module implements a fixed-size queue with a drop-oldest strategy
- to prevent unbounded memory growth from rapid event input.
-
- ## Design
-
- The queue uses Erlang's `:queue` module for efficient operations:
- - O(1) amortized for enqueue/dequeue
- - O(1) for length checks
-
- When the queue is full and a new event arrives, the oldest event is
- dropped and a warning is logged (rate-limited).
-
- ## Example
-
- # Create a new queue with max size
- queue = EventQueue.new(max_size: 1000)
-
- # Add an event
- {:ok, queue} = EventQueue.push(queue, :some_event)
-
- # Drop oldest when full
- {{:dropped, oldest_event}, queue} = EventQueue.push(queue, :new_event)
-
- # Take next event
- {{:value, event}, queue} = EventQueue.pop(queue)
- {:empty, queue} = EventQueue.pop(queue)
- """
-
- require Logger
-
- @typedoc "Event queue structure"
- @type t :: %__MODULE__{
- queue: :queue.queue(),
- size: non_neg_integer(),
- max_size: pos_integer(),
- dropped_count: non_neg_integer(),
- last_warning: integer() | nil
- }
-
- @typedoc "Push result - either success or dropped event"
- @type push_result :: {:ok, t()} | {{:dropped, term()}, t()}
-
- @typedoc "Pop result - value, empty, or timeout"
- @type pop_result :: {{:value, term()}, t()} | {:empty, t()}
-
- defstruct [:queue, :size, :max_size, :dropped_count, :last_warning]
-
- # Dialyzer: Functions with unmatched return values
- @dialyzer {:nowarn_function, maybe_log_overflow: 1, push: 2, drop_oldest_and_push: 2}
-
- @doc """
- Default maximum queue size.
-
- This value balances memory usage with responsiveness:
- - At 60 FPS, 1000 events = ~16 seconds of input buffer
- - Typical key presses are <100 events/sec
- """
- def max_size, do: 1000
-
- @doc """
- Warning rate limit in milliseconds (log once per 5 seconds max).
- """
- def warning_interval, do: 5000
-
- @doc """
- Creates a new event queue with the given options.
-
- ## Options
-
- - `:max_size` - Maximum number of events in queue (default: 1000)
-
- ## Example
-
- queue = EventQueue.new()
- queue = EventQueue.new(max_size: 500)
- """
- @spec new(keyword()) :: t()
- def new(opts \\ []) do
- max_size = Keyword.get(opts, :max_size, max_size())
-
- %__MODULE__{
- queue: :queue.new(),
- size: 0,
- max_size: max_size,
- dropped_count: 0,
- last_warning: nil
- }
- end
-
- @doc """
- Returns the current size of the queue.
- """
- @spec size(t()) :: non_neg_integer()
- def size(%__MODULE__{size: size}), do: size
-
- @doc """
- Returns the maximum size of the queue.
- """
- @spec max_size(t()) :: pos_integer()
- def max_size(%__MODULE__{max_size: max_size}), do: max_size
-
- @doc """
- Returns whether the queue is empty.
- """
- @spec empty?(t()) :: boolean()
- def empty?(%__MODULE__{size: 0}), do: true
- def empty?(%__MODULE__{}), do: false
-
- @doc """
- Returns whether the queue is full.
- """
- @spec full?(t()) :: boolean()
- def full?(%__MODULE__{size: size, max_size: max_size}), do: size >= max_size
-
- @doc """
- Pushes an event onto the queue.
-
- If the queue is full, the oldest event is dropped and returned.
-
- ## Returns
-
- - `{:ok, queue}` - Event was added
- - `{{:dropped, oldest_event}, queue}` - Queue was full, oldest event dropped
-
- ## Example
-
- {:ok, queue} = EventQueue.push(queue, :event)
- {{:dropped, oldest}, queue} = EventQueue.push(queue, :new_event)
- """
- @spec push(t(), term()) :: push_result()
- def push(%__MODULE__{} = q, event) do
- if full?(q) do
- drop_oldest_and_push(q, event)
- else
- new_queue = :queue.in(event, q.queue)
- {:ok, %{q | queue: new_queue, size: q.size + 1}}
- end
- end
-
- @doc """
- Pushes an event onto the queue, dropping oldest if full.
-
- Similar to `push/2` but always returns the updated queue without
- indicating whether a drop occurred. Use `dropped_count/1` to check
- for drops.
- """
- @spec push!(t(), term()) :: t()
- def push!(%__MODULE__{} = q, event) do
- case push(q, event) do
- {:ok, new_q} -> new_q
- {{:dropped, _}, new_q} -> new_q
- end
- end
-
- @doc """
- Pops the next event from the queue.
-
- ## Returns
-
- - `{{:value, event}, queue}` - Next event
- - `{:empty, queue}` - Queue is empty
-
- ## Example
-
- {{:value, event}, queue} = EventQueue.pop(queue)
- {:empty, queue} = EventQueue.pop(queue)
- """
- @spec pop(t()) :: pop_result()
- def pop(%__MODULE__{size: 0} = q) do
- {:empty, q}
- end
-
- def pop(%__MODULE__{} = q) do
- case :queue.out(q.queue) do
- {{:value, event}, new_queue} ->
- {{:value, event}, %{q | queue: new_queue, size: q.size - 1}}
-
- {:empty, _} ->
- {:empty, q}
- end
- end
-
- @doc """
- Peeks at the next event without removing it.
-
- ## Returns
-
- - `{{:value, event}, queue}` - Next event
- - `{:empty, queue}` - Queue is empty
- """
- @spec peek(t()) :: pop_result()
- def peek(%__MODULE__{size: 0} = q) do
- {:empty, q}
- end
-
- def peek(%__MODULE__{} = q) do
- case :queue.peek(q.queue) do
- {:value, event} -> {{:value, event}, q}
- :empty -> {:empty, q}
- end
- end
-
- @doc """
- Returns the number of events that have been dropped due to overflow.
-
- This counter is cumulative for the lifetime of the queue.
- """
- @spec dropped_count(t()) :: non_neg_integer()
- def dropped_count(%__MODULE__{dropped_count: count}), do: count
-
- @doc """
- Resets the dropped event counter to zero.
- """
- @spec reset_dropped_count(t()) :: t()
- def reset_dropped_count(%__MODULE__{} = q), do: %{q | dropped_count: 0}
-
- @doc """
- Clears all events from the queue.
- """
- @spec clear(t()) :: t()
- def clear(%__MODULE__{} = q) do
- %{q | queue: :queue.new(), size: 0}
- end
-
- @doc """
- Converts the queue to a list for inspection/testing.
-
- Events are ordered from oldest to newest (front to back).
- """
- @spec to_list(t()) :: [term()]
- def to_list(%__MODULE__{} = q) do
- :queue.to_list(q.queue)
- end
-
- # Private functions
-
- # Drops the oldest event and pushes a new one.
- # Logs a warning if rate limit allows.
- defp drop_oldest_and_push(%__MODULE__{} = q, new_event) do
- # Drop oldest from front
- {{:value, oldest}, queue_after_drop} = :queue.out(q.queue)
-
- # Add new event at back
- new_queue = :queue.in(new_event, queue_after_drop)
-
- new_q = %{q | queue: new_queue, dropped_count: q.dropped_count + 1}
-
- # Log warning with rate limiting
- maybe_log_overflow(new_q)
-
- # Return dropped event and new queue
- {{:dropped, oldest}, new_q}
- end
-
- # Logs overflow warning if rate limit allows.
- defp maybe_log_overflow(%__MODULE__{dropped_count: count} = q) do
- now = System.monotonic_time(:millisecond)
- should_log = should_log_warning?(q, now)
-
- if should_log do
- Logger.warning(
- "TermUI.EventQueue: Overflow! Dropped events (total: #{count}). " <>
- "Input arriving faster than processing. Events are being dropped."
- )
-
- %{q | last_warning: now}
- else
- q
- end
- end
-
- # Determines if we should log a warning based on rate limit.
- defp should_log_warning?(%__MODULE__{last_warning: nil}, _now), do: true
-
- defp should_log_warning?(%__MODULE__{last_warning: last}, now) do
- now - last >= warning_interval()
- end
-end
diff --git a/lib/term_ui/event_router.ex b/lib/term_ui/event_router.ex
deleted file mode 100644
index 005a65ed..00000000
--- a/lib/term_ui/event_router.ex
+++ /dev/null
@@ -1,308 +0,0 @@
-defmodule TermUI.EventRouter do
- @moduledoc """
- Central event routing for TermUI components.
-
- The EventRouter manages event distribution to components based on:
- - Focus state for keyboard events
- - Spatial index for mouse events
- - Broadcast for system events (resize)
-
- ## Usage
-
- # Route a keyboard event to focused component
- EventRouter.route(%Event.Key{key: :enter})
-
- # Route a mouse event to component at position
- EventRouter.route(%Event.Mouse{action: :click, x: 10, y: 5})
-
- # Set focused component
- EventRouter.set_focus(:my_input)
-
- # Broadcast to all components
- EventRouter.broadcast({:resize, 80, 24})
-
- ## Event Flow
-
- 1. Event received by router
- 2. Router determines target based on event type
- 3. Event delivered to target component
- 4. If unhandled, event bubbles to parent (if propagation enabled)
- """
-
- use GenServer
-
- alias TermUI.ComponentRegistry
- alias TermUI.Event
- alias TermUI.SpatialIndex
-
- # Dialyzer: Functions with unmatched return values in side-effect calls
- @dialyzer {:nowarn_function, handle_call: 3, send_focus_event: 2}
-
- @type route_result :: :handled | :unhandled | {:error, term()}
-
- # Client API
-
- @doc """
- Starts the event router.
- """
- @spec start_link(keyword()) :: GenServer.on_start()
- def start_link(opts \\ []) do
- name = Keyword.get(opts, :name, __MODULE__)
- GenServer.start_link(__MODULE__, opts, name: name)
- end
-
- @doc """
- Routes an event to the appropriate component.
-
- Keyboard and focus events go to the focused component.
- Mouse events go to the component at the mouse position.
-
- ## Returns
-
- - `:handled` - Event was processed by a component
- - `:unhandled` - No component handled the event
- - `{:error, reason}` - Routing failed
- """
- @spec route(Event.Key.t() | Event.Mouse.t() | Event.Focus.t() | Event.Custom.t()) ::
- route_result()
- def route(event) do
- GenServer.call(__MODULE__, {:route, event})
- end
-
- @doc """
- Sets the currently focused component.
-
- Sends focus lost event to previous focus and focus gained to new focus.
-
- ## Parameters
-
- - `component_id` - The component to focus, or nil to clear focus
- """
- @spec set_focus(term() | nil) :: :ok
- def set_focus(component_id) do
- GenServer.call(__MODULE__, {:set_focus, component_id})
- end
-
- @doc """
- Gets the currently focused component.
-
- ## Returns
-
- - `{:ok, component_id}` - The focused component
- - `{:ok, nil}` - No component focused
- """
- @spec get_focus() :: {:ok, term() | nil}
- def get_focus do
- GenServer.call(__MODULE__, :get_focus)
- end
-
- @doc """
- Clears the current focus.
- """
- @spec clear_focus() :: :ok
- def clear_focus do
- set_focus(nil)
- end
-
- @doc """
- Broadcasts an event to all registered components.
-
- Useful for system-wide events like resize.
-
- ## Returns
-
- - `{:ok, count}` - Number of components that received the event
- """
- @spec broadcast(term()) :: {:ok, non_neg_integer()}
- def broadcast(event) do
- GenServer.call(__MODULE__, {:broadcast, event})
- end
-
- @doc """
- Routes an event directly to a specific component by id.
-
- ## Returns
-
- - `:handled` - Component handled the event
- - `:unhandled` - Component did not handle the event
- - `{:error, :not_found}` - Component not found
- """
- @spec route_to(term(), term()) :: route_result()
- def route_to(component_id, event) do
- GenServer.call(__MODULE__, {:route_to, component_id, event})
- end
-
- @doc """
- Registers a global event handler for events that no component handles.
-
- The handler receives unhandled events and can process them as needed.
-
- ## Parameters
-
- - `handler` - Function that receives events: `fn event -> :ok end`
- """
- @spec set_fallback_handler((term() -> :ok)) :: :ok
- def set_fallback_handler(handler) when is_function(handler, 1) do
- GenServer.call(__MODULE__, {:set_fallback_handler, handler})
- end
-
- @doc """
- Clears the fallback handler.
- """
- @spec clear_fallback_handler() :: :ok
- def clear_fallback_handler do
- GenServer.call(__MODULE__, :clear_fallback_handler)
- end
-
- # Server Callbacks
-
- @impl true
- def init(_opts) do
- state = %{
- focus: nil,
- fallback_handler: nil
- }
-
- {:ok, state}
- end
-
- @impl true
- def handle_call({:route, event}, _from, state) do
- result = do_route(event, state)
- {:reply, result, state}
- end
-
- @impl true
- def handle_call({:set_focus, component_id}, _from, state) do
- old_focus = state.focus
-
- # Send focus lost to old component
- if old_focus && old_focus != component_id do
- send_focus_event(old_focus, :lost)
- end
-
- # Send focus gained to new component
- if component_id && component_id != old_focus do
- send_focus_event(component_id, :gained)
- end
-
- {:reply, :ok, %{state | focus: component_id}}
- end
-
- @impl true
- def handle_call(:get_focus, _from, state) do
- {:reply, {:ok, state.focus}, state}
- end
-
- @impl true
- def handle_call({:broadcast, event}, _from, state) do
- components = ComponentRegistry.list_all()
- count = length(components)
-
- Enum.each(components, fn %{pid: pid} ->
- send_event(pid, event)
- end)
-
- {:reply, {:ok, count}, state}
- end
-
- @impl true
- def handle_call({:route_to, component_id, event}, _from, state) do
- result =
- case ComponentRegistry.lookup(component_id) do
- {:ok, pid} ->
- send_event(pid, event)
-
- {:error, :not_found} ->
- {:error, :not_found}
- end
-
- {:reply, result, state}
- end
-
- @impl true
- def handle_call({:set_fallback_handler, handler}, _from, state) do
- {:reply, :ok, %{state | fallback_handler: handler}}
- end
-
- @impl true
- def handle_call(:clear_fallback_handler, _from, state) do
- {:reply, :ok, %{state | fallback_handler: nil}}
- end
-
- # Private Functions
-
- defp do_route(%Event.Key{} = event, state) do
- route_to_focus(event, state)
- end
-
- defp do_route(%Event.Focus{} = event, state) do
- route_to_focus(event, state)
- end
-
- defp do_route(%Event.Mouse{} = event, state) do
- route_to_position(event, state)
- end
-
- defp do_route(%Event.Custom{} = event, state) do
- # Custom events go to focused component by default
- route_to_focus(event, state)
- end
-
- defp route_to_focus(event, state) do
- case state.focus do
- nil ->
- handle_unrouted(event, state)
-
- component_id ->
- case ComponentRegistry.lookup(component_id) do
- {:ok, pid} ->
- send_event(pid, event)
-
- {:error, :not_found} ->
- handle_unrouted(event, state)
- end
- end
- end
-
- defp route_to_position(%Event.Mouse{x: x, y: y} = event, state) do
- case SpatialIndex.find_at(x, y) do
- {:ok, {_id, pid}} ->
- send_event(pid, event)
-
- {:error, :not_found} ->
- handle_unrouted(event, state)
- end
- end
-
- defp handle_unrouted(event, %{fallback_handler: handler}) when is_function(handler) do
- handler.(event)
- :unhandled
- end
-
- defp handle_unrouted(_event, _state) do
- :unhandled
- end
-
- defp send_event(pid, event) do
- case GenServer.call(pid, {:event, event}, 5000) do
- :handled -> :handled
- :unhandled -> :unhandled
- {:ok, _} -> :handled
- _ -> :unhandled
- end
- catch
- :exit, _ -> {:error, :component_unavailable}
- end
-
- defp send_focus_event(component_id, action) do
- case ComponentRegistry.lookup(component_id) do
- {:ok, pid} ->
- event = Event.focus(action)
- send_event(pid, event)
-
- {:error, :not_found} ->
- :ok
- end
- end
-end
diff --git a/lib/term_ui/focus.ex b/lib/term_ui/focus.ex
index 7d3867c6..bafb8668 100644
--- a/lib/term_ui/focus.ex
+++ b/lib/term_ui/focus.ex
@@ -1,371 +1,138 @@
defmodule TermUI.Focus do
@moduledoc """
- Focus event utilities for terminal window focus tracking.
+ Pure focus traversal state for an Elm application.
- Provides escape sequences and utilities for detecting when the
- terminal window gains or loses system focus. This enables optimization
- opportunities like pausing animations when backgrounded.
-
- ## Usage
-
- # Enable focus reporting
- IO.write(Focus.enable())
-
- # Check if focus reporting is supported
- if Focus.supported?() do
- IO.write(Focus.enable())
- end
-
- # Disable focus reporting
- IO.write(Focus.disable())
- """
-
- # Focus reporting mode
- # ESC [ ? 1004 h - Enable focus reporting
- # ESC [ ? 1004 l - Disable focus reporting
- @focus_enable "\e[?1004h"
- @focus_disable "\e[?1004l"
-
- # Focus event sequences
- # ESC [ I - Focus gained
- # ESC [ O - Focus lost
- @focus_gained "\e[I"
- @focus_lost "\e[O"
-
- @doc """
- Returns escape sequence to enable focus reporting.
- """
- @spec enable() :: String.t()
- def enable, do: @focus_enable
-
- @doc """
- Returns escape sequence to disable focus reporting.
- """
- @spec disable() :: String.t()
- def disable, do: @focus_disable
-
- @doc """
- Returns the focus gained sequence.
- """
- @spec gained_sequence() :: String.t()
- def gained_sequence, do: @focus_gained
-
- @doc """
- Returns the focus lost sequence.
- """
- @spec lost_sequence() :: String.t()
- def lost_sequence, do: @focus_lost
-
- @doc """
- Checks if focus reporting is likely supported.
-
- This is a heuristic check based on terminal type. Many modern
- terminals support focus reporting but don't advertise it.
-
- Known supporting terminals:
- - xterm (with allowWindowOps)
- - iTerm2
- - Alacritty
- - Kitty
- - WezTerm
- - foot
- - GNOME Terminal
- - Windows Terminal
- """
- @spec supported?() :: boolean()
- def supported? do
- term = System.get_env("TERM", "")
- term_program = System.get_env("TERM_PROGRAM", "")
-
- known_terminal_program?(term_program) or
- known_terminal_env?() or
- known_terminal_type?(term)
- end
-
- defp known_terminal_program?(term_program) do
- String.contains?(term_program, "iTerm") or
- String.contains?(term_program, "Alacritty") or
- String.contains?(term_program, "WezTerm")
- end
-
- defp known_terminal_env? do
- System.get_env("KITTY_WINDOW_ID") != nil or
- System.get_env("WT_SESSION") != nil or
- System.get_env("VTE_VERSION") != nil
- end
-
- defp known_terminal_type?(term) do
- String.starts_with?(term, "xterm") or
- term == "foot" or
- term == "foot-extra"
- end
-
- @doc """
- Parses input to detect focus events.
-
- Returns `{:focus, :gained}`, `{:focus, :lost}`, or `nil` if not a focus event.
- """
- @spec parse(String.t()) :: {:focus, :gained | :lost} | nil
- def parse(@focus_gained), do: {:focus, :gained}
- def parse(@focus_lost), do: {:focus, :lost}
- def parse(_), do: nil
-end
-
-defmodule TermUI.Focus.Tracker do
- @moduledoc """
- Focus state tracker with action registration.
-
- Maintains focus state and executes registered actions when
- focus changes. Supports optimization hooks for reducing work
- when the application is backgrounded.
-
- ## Usage
-
- {:ok, tracker} = Focus.Tracker.start_link()
-
- # Register focus actions
- Focus.Tracker.on_focus_lost(tracker, fn ->
- save_state()
- end)
-
- Focus.Tracker.on_focus_gained(tracker, fn ->
- refresh_content()
- end)
-
- # Update focus state
- Focus.Tracker.set_focus(tracker, true)
-
- # Query focus state
- Focus.Tracker.has_focus?(tracker)
+ The router handles Tab, Shift+Tab, Home, End, and terminal focus events. It
+ returns parent messages and never sends events or stores global state.
"""
- use GenServer
+ alias TermUI.Event
@type t :: %__MODULE__{
- has_focus: boolean(),
- on_gained: [(-> any())],
- on_lost: [(-> any())],
- paused: boolean(),
- reduced_framerate: boolean(),
- auto_pause: boolean(),
- auto_reduce_framerate: boolean()
+ order: [term()],
+ current: term() | nil,
+ disabled: [term()],
+ wrap: boolean(),
+ active: boolean()
}
- defstruct has_focus: true,
- on_gained: [],
- on_lost: [],
- paused: false,
- reduced_framerate: false,
- auto_pause: false,
- auto_reduce_framerate: false
-
- # --- Public API ---
-
- @doc """
- Starts the focus tracker.
- """
- @spec start_link(keyword()) :: GenServer.on_start()
- def start_link(opts \\ []) do
- {name, opts} = Keyword.pop(opts, :name)
-
- if name do
- GenServer.start_link(__MODULE__, opts, name: name)
- else
- GenServer.start_link(__MODULE__, opts)
- end
- end
-
- @doc """
- Sets the focus state.
- """
- @spec set_focus(GenServer.server(), boolean()) :: :ok
- def set_focus(tracker, focused) when is_boolean(focused) do
- GenServer.call(tracker, {:set_focus, focused})
- end
-
- @doc """
- Returns true if the application has focus.
- """
- @spec has_focus?(GenServer.server()) :: boolean()
- def has_focus?(tracker) do
- GenServer.call(tracker, :has_focus?)
- end
-
- @doc """
- Registers an action to execute when focus is gained.
- """
- @spec on_focus_gained(GenServer.server(), (-> any())) :: :ok
- def on_focus_gained(tracker, action) when is_function(action, 0) do
- GenServer.call(tracker, {:on_focus_gained, action})
- end
-
- @doc """
- Registers an action to execute when focus is lost.
- """
- @spec on_focus_lost(GenServer.server(), (-> any())) :: :ok
- def on_focus_lost(tracker, action) when is_function(action, 0) do
- GenServer.call(tracker, {:on_focus_lost, action})
+ defstruct order: [],
+ current: nil,
+ disabled: [],
+ wrap: true,
+ active: true
+
+ @doc "Creates focus state from ids or `%{id: id, disabled: boolean}` entries."
+ @spec new(Enumerable.t(), keyword()) :: t()
+ def new(items \\ [], opts \\ []) do
+ {order, item_disabled} = normalize_items(items)
+ disabled = Enum.uniq(item_disabled ++ Keyword.get(opts, :disabled, []))
+ requested = Keyword.get(opts, :current)
+
+ current =
+ if requested in order and requested not in disabled,
+ do: requested,
+ else: first_enabled(order, disabled)
+
+ %__MODULE__{
+ order: order,
+ current: current,
+ disabled: disabled,
+ wrap: Keyword.get(opts, :wrap, true),
+ active: Keyword.get(opts, :active, true)
+ }
end
- @doc """
- Clears all registered actions.
- """
- @spec clear_actions(GenServer.server()) :: :ok
- def clear_actions(tracker) do
- GenServer.call(tracker, :clear_actions)
- end
+ @doc "Routes one normalized event and returns focus messages."
+ @spec route(Event.t(), t()) :: {t(), [term()]}
+ def route(%Event.Key{key: :tab, modifiers: modifiers}, state),
+ do: move_with_message(state, if(:shift in modifiers, do: -1, else: 1))
- @doc """
- Returns true if animations should be paused.
+ def route(%Event.Key{key: :home}, state),
+ do: change(state, first_enabled(state.order, state.disabled))
- This is set when focus is lost and auto_pause is enabled.
- """
- @spec paused?(GenServer.server()) :: boolean()
- def paused?(tracker) do
- GenServer.call(tracker, :paused?)
- end
+ def route(%Event.Key{key: :end}, state),
+ do: change(state, last_enabled(state.order, state.disabled))
- @doc """
- Sets the paused state manually.
- """
- @spec set_paused(GenServer.server(), boolean()) :: :ok
- def set_paused(tracker, paused) when is_boolean(paused) do
- GenServer.call(tracker, {:set_paused, paused})
- end
+ def route(%Event.Focus{action: :lost}, state),
+ do: {%{state | active: false}, [{:focus_active, false}]}
- @doc """
- Returns true if framerate should be reduced.
-
- This is set when focus is lost and auto_reduce_framerate is enabled.
- """
- @spec reduced_framerate?(GenServer.server()) :: boolean()
- def reduced_framerate?(tracker) do
- GenServer.call(tracker, :reduced_framerate?)
- end
+ def route(%Event.Focus{action: :gained}, state),
+ do: {%{state | active: true}, [{:focus_active, true}]}
- @doc """
- Sets the reduced framerate state manually.
- """
- @spec set_reduced_framerate(GenServer.server(), boolean()) :: :ok
- def set_reduced_framerate(tracker, reduced) when is_boolean(reduced) do
- GenServer.call(tracker, {:set_reduced_framerate, reduced})
- end
+ def route(_event, state), do: {state, []}
- @doc """
- Enables automatic pause when focus is lost.
- """
- @spec enable_auto_pause(GenServer.server()) :: :ok
- def enable_auto_pause(tracker) do
- GenServer.call(tracker, :enable_auto_pause)
- end
+ @doc "Moves focus forward by one enabled item."
+ @spec next(t()) :: t()
+ def next(state), do: state |> move(1) |> elem(0)
- @doc """
- Enables automatic framerate reduction when focus is lost.
- """
- @spec enable_auto_reduce_framerate(GenServer.server()) :: :ok
- def enable_auto_reduce_framerate(tracker) do
- GenServer.call(tracker, :enable_auto_reduce_framerate)
- end
+ @doc "Moves focus backward by one enabled item."
+ @spec previous(t()) :: t()
+ def previous(state), do: state |> move(-1) |> elem(0)
- # --- GenServer Callbacks ---
+ @doc "Focuses one enabled id."
+ @spec focus(t(), term()) :: t()
+ def focus(state, id), do: state |> change(id) |> elem(0)
- @impl true
- def init(opts) do
- state = %__MODULE__{
- has_focus: Keyword.get(opts, :initial_focus, true)
- }
+ @doc "Disables one id and moves away from it when needed."
+ @spec disable(t(), term()) :: t()
+ def disable(state, id) do
+ state =
+ if id in state.order,
+ do: %{state | disabled: Enum.uniq(state.disabled ++ [id])},
+ else: state
- {:ok, state}
+ if state.current == id, do: next(state), else: state
end
- @impl true
- def handle_call({:set_focus, focused}, _from, state) do
- if focused == state.has_focus do
- {:reply, :ok, state}
- else
- # Update focus state
- state = %{state | has_focus: focused}
+ @doc "Enables one id."
+ @spec enable(t(), term()) :: t()
+ def enable(state, id), do: %{state | disabled: List.delete(state.disabled, id)}
- # Update auto-pause and auto-reduce states
- state =
- if state.auto_pause do
- %{state | paused: not focused}
- else
- state
- end
+ @doc "Returns true when an id owns active focus."
+ @spec focused?(t(), term()) :: boolean()
+ def focused?(state, id), do: state.active and state.current == id
- state =
- if state.auto_reduce_framerate do
- %{state | reduced_framerate: not focused}
- else
- state
- end
+ defp move_with_message(state, delta), do: move(state, delta)
- # Execute actions
- actions = if focused, do: state.on_gained, else: state.on_lost
+ defp move(state, delta) do
+ enabled = Enum.reject(state.order, &(&1 in state.disabled))
- Enum.each(actions, fn action ->
- try do
- action.()
- rescue
- _ -> :ok
- end
- end)
+ case enabled do
+ [] ->
+ change(state, nil)
- {:reply, :ok, state}
+ _items ->
+ current_index = Enum.find_index(enabled, &(&1 == state.current))
+ next_index = next_index(current_index, delta, length(enabled), state.wrap)
+ change(state, Enum.at(enabled, next_index))
end
end
- @impl true
- def handle_call(:has_focus?, _from, state) do
- {:reply, state.has_focus, state}
- end
-
- @impl true
- def handle_call({:on_focus_gained, action}, _from, state) do
- state = %{state | on_gained: state.on_gained ++ [action]}
- {:reply, :ok, state}
- end
-
- @impl true
- def handle_call({:on_focus_lost, action}, _from, state) do
- state = %{state | on_lost: state.on_lost ++ [action]}
- {:reply, :ok, state}
- end
-
- @impl true
- def handle_call(:clear_actions, _from, state) do
- state = %{state | on_gained: [], on_lost: []}
- {:reply, :ok, state}
- end
-
- @impl true
- def handle_call(:paused?, _from, state) do
- {:reply, state.paused, state}
- end
+ defp change(state, id) do
+ valid? = is_nil(id) or (id in state.order and id not in state.disabled)
- @impl true
- def handle_call({:set_paused, paused}, _from, state) do
- {:reply, :ok, %{state | paused: paused}}
+ cond do
+ not valid? -> {state, []}
+ state.current == id -> {state, []}
+ true -> {%{state | current: id}, [{:focus_changed, state.current, id}]}
+ end
end
- @impl true
- def handle_call(:reduced_framerate?, _from, state) do
- {:reply, state.reduced_framerate, state}
- end
+ defp next_index(nil, delta, count, _wrap), do: if(delta < 0, do: count - 1, else: 0)
+ defp next_index(index, delta, count, true), do: rem(index + delta + count, count)
+ defp next_index(index, delta, count, false), do: min(max(index + delta, 0), count - 1)
- @impl true
- def handle_call({:set_reduced_framerate, reduced}, _from, state) do
- {:reply, :ok, %{state | reduced_framerate: reduced}}
+ defp normalize_items(items) do
+ items
+ |> Enum.reduce({[], []}, fn
+ %{id: id, disabled: true}, {order, disabled} -> {order ++ [id], disabled ++ [id]}
+ %{id: id}, {order, disabled} -> {order ++ [id], disabled}
+ id, {order, disabled} -> {order ++ [id], disabled}
+ end)
+ |> then(fn {order, disabled} -> {Enum.uniq(order), Enum.uniq(disabled)} end)
end
- @impl true
- def handle_call(:enable_auto_pause, _from, state) do
- {:reply, :ok, %{state | auto_pause: true}}
- end
-
- @impl true
- def handle_call(:enable_auto_reduce_framerate, _from, state) do
- {:reply, :ok, %{state | auto_reduce_framerate: true}}
- end
+ defp first_enabled(order, disabled), do: Enum.find(order, &(&1 not in disabled))
+ defp last_enabled(order, disabled), do: order |> Enum.reverse() |> first_enabled(disabled)
end
diff --git a/lib/term_ui/focus/indicator.ex b/lib/term_ui/focus/indicator.ex
deleted file mode 100644
index 5571f3dc..00000000
--- a/lib/term_ui/focus/indicator.ex
+++ /dev/null
@@ -1,199 +0,0 @@
-defmodule TermUI.Focus.Indicator do
- @moduledoc """
- Focus indicator styles for visual focus feedback.
-
- Provides default and customizable styles for indicating
- which component has focus.
-
- ## Usage
-
- # Get default focus style
- style = Indicator.default_style()
-
- # Get focus style for component
- style = Indicator.get_style(:my_button, opts)
-
- # Apply focus styling to a cell
- cell = Indicator.apply_focus_style(cell)
- """
-
- alias TermUI.Renderer.Style
-
- # Dialyzer: Functions return specific atom types
- @dialyzer {:nowarn_function, focus_border_color: 0}
-
- @type border_style :: :none | :single | :double | :rounded | :thick
-
- @type indicator_style :: %{
- border: border_style() | nil,
- fg: Style.color() | nil,
- bg: Style.color() | nil,
- bold: boolean()
- }
-
- @doc """
- Returns the default focus indicator style.
-
- Default style uses a highlighted border color.
- """
- @spec default_style() :: indicator_style()
- def default_style do
- %{
- border: :single,
- fg: :cyan,
- bg: nil,
- bold: true
- }
- end
-
- @doc """
- Gets the focus indicator style for a component.
-
- Merges default style with component-specific overrides.
-
- ## Parameters
-
- - `component_id` - Component to get style for
- - `opts` - Options:
- - `:styles` - Map of component_id => indicator_style
-
- ## Returns
-
- Focus indicator style map.
- """
- @spec get_style(term(), keyword()) :: indicator_style()
- def get_style(component_id, opts \\ []) do
- styles = Keyword.get(opts, :styles, %{})
- custom = Map.get(styles, component_id, %{})
-
- Map.merge(default_style(), custom)
- end
-
- @doc """
- Creates a Style struct from focus indicator style.
-
- ## Parameters
-
- - `indicator` - Focus indicator style map
-
- ## Returns
-
- A Style struct suitable for rendering.
- """
- @spec to_render_style(indicator_style()) :: Style.t()
- def to_render_style(indicator) do
- opts = []
-
- opts =
- if indicator[:fg] do
- [{:fg, indicator[:fg]} | opts]
- else
- opts
- end
-
- opts =
- if indicator[:bg] do
- [{:bg, indicator[:bg]} | opts]
- else
- opts
- end
-
- opts =
- if indicator[:bold] do
- [{:attrs, [:bold]} | opts]
- else
- opts
- end
-
- Style.new(opts)
- end
-
- @doc """
- Gets focus border color.
-
- Returns the color to use for focused component borders.
-
- ## Returns
-
- Color atom (e.g., :cyan, :blue).
- """
- @spec focus_border_color() :: atom()
- def focus_border_color do
- :cyan
- end
-
- @doc """
- Checks if focus indicators should animate.
-
- Some terminals support blinking or pulsing focus indicators.
-
- ## Returns
-
- Boolean indicating animation support.
- """
- @spec animate?() :: false
- def animate? do
- # Animation disabled by default for simplicity
- false
- end
-
- @doc """
- Returns predefined focus indicator themes.
-
- ## Available Themes
-
- - `:default` - Cyan border with bold
- - `:subtle` - Dim border color change
- - `:bold` - Bright yellow with background
- - `:minimal` - No border, just cursor
-
- ## Returns
-
- Map of theme name to indicator style.
- """
- @spec themes() :: %{atom() => indicator_style()}
- def themes do
- %{
- default: %{
- border: :single,
- fg: :cyan,
- bg: nil,
- bold: true
- },
- subtle: %{
- border: :single,
- fg: :white,
- bg: nil,
- bold: false
- },
- bold: %{
- border: :double,
- fg: :yellow,
- bg: :blue,
- bold: true
- },
- minimal: %{
- border: nil,
- fg: nil,
- bg: nil,
- bold: false
- }
- }
- end
-
- @doc """
- Gets a predefined theme by name.
-
- ## Parameters
-
- - `theme_name` - Name of the theme
-
- ## Returns
-
- Indicator style for the theme, or default if not found.
- """
- @spec get_theme(atom()) :: indicator_style()
- def get_theme(theme_name) do
- Map.get(themes(), theme_name, default_style())
- end
-end
diff --git a/lib/term_ui/focus/traversal.ex b/lib/term_ui/focus/traversal.ex
deleted file mode 100644
index 15fc9fe0..00000000
--- a/lib/term_ui/focus/traversal.ex
+++ /dev/null
@@ -1,184 +0,0 @@
-defmodule TermUI.Focus.Traversal do
- @moduledoc """
- Focus traversal utilities for calculating tab order.
-
- Provides utilities for determining the order in which components
- receive focus during Tab/Shift+Tab navigation.
-
- ## Tab Order
-
- Components are ordered by:
- 1. Explicit `tab_index` (lower numbers first)
- 2. Screen position (top-to-bottom, left-to-right)
-
- ## Usage
-
- # Get tab order for components
- order = Traversal.calculate_order(component_ids)
-
- # Check if component should be skipped
- Traversal.should_skip?(component_id)
- """
-
- alias TermUI.SpatialIndex
-
- @doc """
- Calculates the tab order for a list of components.
-
- Returns components sorted by tab index, then by position.
-
- ## Parameters
-
- - `component_ids` - List of component ids
- - `opts` - Options:
- - `:tab_indices` - Map of component_id => tab_index
-
- ## Returns
-
- Sorted list of component ids.
- """
- @spec calculate_order([term()], keyword()) :: [term()]
- def calculate_order(component_ids, opts \\ []) do
- tab_indices = Keyword.get(opts, :tab_indices, %{})
-
- component_ids
- |> Enum.map(fn id ->
- tab_index = Map.get(tab_indices, id)
- position = get_position(id)
- {id, tab_index, position}
- end)
- |> Enum.sort_by(fn {_id, tab_index, {x, y}} ->
- # nil tab_index sorts last
- index = tab_index || 999_999
- {index, y, x}
- end)
- |> Enum.map(fn {id, _, _} -> id end)
- end
-
- @doc """
- Gets the next component in tab order.
-
- ## Parameters
-
- - `ordered_list` - Components in tab order
- - `current` - Currently focused component (or nil)
-
- ## Returns
-
- Next component id, wrapping to first if at end.
- """
- @spec next([term()], term() | nil) :: term() | nil
- def next([], _current), do: nil
-
- def next(ordered_list, nil) do
- List.first(ordered_list)
- end
-
- def next(ordered_list, current) do
- case Enum.find_index(ordered_list, &(&1 == current)) do
- nil ->
- List.first(ordered_list)
-
- idx ->
- next_idx = rem(idx + 1, length(ordered_list))
- Enum.at(ordered_list, next_idx)
- end
- end
-
- @doc """
- Gets the previous component in tab order.
-
- ## Parameters
-
- - `ordered_list` - Components in tab order
- - `current` - Currently focused component (or nil)
-
- ## Returns
-
- Previous component id, wrapping to last if at beginning.
- """
- @spec prev([term()], term() | nil) :: term() | nil
- def prev([], _current), do: nil
-
- def prev(ordered_list, nil) do
- List.last(ordered_list)
- end
-
- def prev(ordered_list, current) do
- case Enum.find_index(ordered_list, &(&1 == current)) do
- nil ->
- List.last(ordered_list)
-
- 0 ->
- List.last(ordered_list)
-
- idx ->
- Enum.at(ordered_list, idx - 1)
- end
- end
-
- @doc """
- Checks if a component should be skipped during traversal.
-
- A component is skipped if:
- - It has `focusable: false`
- - It has `disabled: true`
- - It has a negative `tab_index`
-
- ## Parameters
-
- - `component_id` - Component to check
- - `opts` - Options:
- - `:focusable` - Map of component_id => boolean
- - `:disabled` - Map of component_id => boolean
- - `:tab_indices` - Map of component_id => integer
-
- ## Returns
-
- Boolean indicating if component should be skipped.
- """
- @spec should_skip?(term(), keyword()) :: boolean()
- def should_skip?(component_id, opts \\ []) do
- focusable_map = Keyword.get(opts, :focusable, %{})
- disabled_map = Keyword.get(opts, :disabled, %{})
- tab_indices = Keyword.get(opts, :tab_indices, %{})
-
- # Check focusable (default true)
- focusable = Map.get(focusable_map, component_id, true)
-
- # Check disabled (default false)
- disabled = Map.get(disabled_map, component_id, false)
-
- # Check negative tab_index
- tab_index = Map.get(tab_indices, component_id)
- negative_tab = is_integer(tab_index) && tab_index < 0
-
- !focusable || disabled || negative_tab
- end
-
- @doc """
- Filters a list to only focusable components.
-
- ## Parameters
-
- - `component_ids` - List of component ids
- - `opts` - Options passed to `should_skip?/2`
-
- ## Returns
-
- Filtered list of focusable component ids.
- """
- @spec filter_focusable([term()], keyword()) :: [term()]
- def filter_focusable(component_ids, opts \\ []) do
- Enum.reject(component_ids, &should_skip?(&1, opts))
- end
-
- # Private Functions
-
- defp get_position(component_id) do
- case SpatialIndex.get_bounds(component_id) do
- {:ok, %{x: x, y: y}} -> {x, y}
- _ -> {0, 0}
- end
- end
-end
diff --git a/lib/term_ui/focus_manager.ex b/lib/term_ui/focus_manager.ex
deleted file mode 100644
index f6fcb7a8..00000000
--- a/lib/term_ui/focus_manager.ex
+++ /dev/null
@@ -1,565 +0,0 @@
-defmodule TermUI.FocusManager do
- @moduledoc """
- Central focus management for TermUI components.
-
- The FocusManager tracks which component receives keyboard input,
- provides focus traversal (Tab/Shift+Tab), and manages focus
- trapping for modal contexts.
-
- ## Usage
-
- # Get current focus
- {:ok, component_id} = FocusManager.get_focused()
-
- # Set focus to component
- :ok = FocusManager.set_focused(:my_input)
-
- # Tab navigation
- :ok = FocusManager.focus_next()
- :ok = FocusManager.focus_prev()
-
- # Focus trapping for modals
- :ok = FocusManager.trap_focus(:modal_group)
- :ok = FocusManager.release_focus()
-
- ## Focus Stack
-
- The FocusManager maintains a focus stack for modal contexts.
- When a modal opens, it pushes the current focus and sets new focus.
- When closed, focus pops back to the previous component.
- """
-
- use GenServer
-
- alias TermUI.ComponentRegistry
- alias TermUI.Event
- alias TermUI.EventRouter
- alias TermUI.SpatialIndex
-
- # Dialyzer: Pattern match and unmatched return warnings
- @dialyzer {:nowarn_function,
- get_focused: 0,
- set_focused: 1,
- find_next: 2,
- find_prev: 2,
- handle_call: 3,
- clear_focus: 0}
-
- # Client API
-
- @doc """
- Starts the focus manager.
- """
- @spec start_link(keyword()) :: GenServer.on_start()
- def start_link(opts \\ []) do
- name = Keyword.get(opts, :name, __MODULE__)
- GenServer.start_link(__MODULE__, opts, name: name)
- end
-
- @doc """
- Gets the currently focused component.
-
- ## Returns
-
- - `{:ok, component_id}` - The focused component
- - `{:ok, nil}` - No component focused
- """
- @spec get_focused() :: {:ok, term() | nil}
- def get_focused do
- GenServer.call(__MODULE__, :get_focused)
- end
-
- @doc """
- Sets focus to a specific component.
-
- Sends blur event to the previously focused component and
- focus event to the new component.
-
- ## Parameters
-
- - `component_id` - Component to focus, or nil to clear focus
-
- ## Returns
-
- - `:ok` - Focus changed successfully
- - `{:error, :not_focusable}` - Component cannot receive focus
- - `{:error, :not_found}` - Component not registered
- """
- @spec set_focused(term() | nil) :: :ok | {:error, atom()}
- def set_focused(component_id) do
- GenServer.call(__MODULE__, {:set_focused, component_id})
- end
-
- @doc """
- Clears the current focus.
- """
- @spec clear_focus() :: :ok
- def clear_focus do
- set_focused(nil)
- :ok
- end
-
- @doc """
- Moves focus to the next focusable component in tab order.
-
- ## Returns
-
- - `:ok` - Focus moved to next component
- - `{:error, :no_focusable}` - No focusable components available
- """
- @spec focus_next() :: :ok | {:error, atom()}
- def focus_next do
- GenServer.call(__MODULE__, :focus_next)
- end
-
- @doc """
- Moves focus to the previous focusable component in tab order.
-
- ## Returns
-
- - `:ok` - Focus moved to previous component
- - `{:error, :no_focusable}` - No focusable components available
- """
- @spec focus_prev() :: :ok | {:error, atom()}
- def focus_prev do
- GenServer.call(__MODULE__, :focus_prev)
- end
-
- @doc """
- Pushes current focus to stack and sets new focus.
-
- Useful for modal dialogs that need to restore focus when closed.
-
- ## Parameters
-
- - `component_id` - Component to focus
- """
- @spec push_focus(term()) :: :ok | {:error, atom()}
- def push_focus(component_id) do
- GenServer.call(__MODULE__, {:push_focus, component_id})
- end
-
- @doc """
- Pops focus from stack, restoring previous focus.
-
- ## Returns
-
- - `:ok` - Focus restored
- - `{:error, :empty_stack}` - No focus to restore
- """
- @spec pop_focus() :: :ok | {:error, atom()}
- def pop_focus do
- GenServer.call(__MODULE__, :pop_focus)
- end
-
- @doc """
- Registers a focus group for focus trapping.
-
- ## Parameters
-
- - `group_id` - Unique identifier for the group
- - `component_ids` - List of component ids in the group
- """
- @spec register_group(term(), [term()]) :: :ok
- def register_group(group_id, component_ids) do
- GenServer.call(__MODULE__, {:register_group, group_id, component_ids})
- end
-
- @doc """
- Unregisters a focus group.
- """
- @spec unregister_group(term()) :: :ok
- def unregister_group(group_id) do
- GenServer.call(__MODULE__, {:unregister_group, group_id})
- end
-
- @doc """
- Traps focus within a group.
-
- Tab navigation will cycle within the group instead of
- escaping to other components.
-
- ## Parameters
-
- - `group_id` - Group to trap focus within
- """
- @spec trap_focus(term()) :: :ok | {:error, atom()}
- def trap_focus(group_id) do
- GenServer.call(__MODULE__, {:trap_focus, group_id})
- end
-
- @doc """
- Releases the current focus trap.
- """
- @spec release_focus() :: :ok
- def release_focus do
- GenServer.call(__MODULE__, :release_focus)
- end
-
- @doc """
- Checks if a component is currently focused.
- """
- @spec focused?(term()) :: boolean()
- def focused?(component_id) do
- case get_focused() do
- {:ok, ^component_id} -> true
- _ -> false
- end
- end
-
- @doc """
- Requests auto-focus for a component on mount.
-
- Should be called from component mount if auto_focus prop is true.
- """
- @spec request_auto_focus(term()) :: :ok
- def request_auto_focus(component_id) do
- GenServer.cast(__MODULE__, {:request_auto_focus, component_id})
- end
-
- @doc """
- Gets all registered focus groups.
- """
- @spec get_groups() :: %{term() => [term()]}
- def get_groups do
- GenServer.call(__MODULE__, :get_groups)
- end
-
- @doc """
- Gets the current focus stack.
- """
- @spec get_stack() :: [term()]
- def get_stack do
- GenServer.call(__MODULE__, :get_stack)
- end
-
- # Server Callbacks
-
- @impl true
- def init(_opts) do
- state = %{
- current: nil,
- stack: [],
- groups: %{},
- trapped_group: nil
- }
-
- {:ok, state}
- end
-
- @impl true
- def handle_call(:get_focused, _from, state) do
- {:reply, {:ok, state.current}, state}
- end
-
- @impl true
- def handle_call({:set_focused, component_id}, _from, state) do
- case do_set_focused(component_id, state) do
- {:ok, new_state} ->
- {:reply, :ok, new_state}
-
- {:error, reason} ->
- {:reply, {:error, reason}, state}
- end
- end
-
- @impl true
- def handle_call(:focus_next, _from, state) do
- case do_focus_next(state) do
- {:ok, new_state} ->
- {:reply, :ok, new_state}
-
- {:error, reason} ->
- {:reply, {:error, reason}, state}
- end
- end
-
- @impl true
- def handle_call(:focus_prev, _from, state) do
- case do_focus_prev(state) do
- {:ok, new_state} ->
- {:reply, :ok, new_state}
-
- {:error, reason} ->
- {:reply, {:error, reason}, state}
- end
- end
-
- @impl true
- def handle_call({:push_focus, component_id}, _from, state) do
- # Push current to stack
- new_stack =
- if state.current do
- [state.current | state.stack]
- else
- state.stack
- end
-
- state = %{state | stack: new_stack}
-
- case do_set_focused(component_id, state) do
- {:ok, new_state} ->
- {:reply, :ok, new_state}
-
- {:error, reason} ->
- {:reply, {:error, reason}, state}
- end
- end
-
- @impl true
- def handle_call(:pop_focus, _from, state) do
- case state.stack do
- [] ->
- {:reply, {:error, :empty_stack}, state}
-
- [prev | rest] ->
- state = %{state | stack: rest}
-
- case do_set_focused(prev, state) do
- {:ok, new_state} ->
- {:reply, :ok, new_state}
-
- {:error, _reason} ->
- # If we can't restore focus, just clear it
- {:reply, :ok, %{state | current: nil}}
- end
- end
- end
-
- @impl true
- def handle_call({:register_group, group_id, component_ids}, _from, state) do
- groups = Map.put(state.groups, group_id, component_ids)
- {:reply, :ok, %{state | groups: groups}}
- end
-
- @impl true
- def handle_call({:unregister_group, group_id}, _from, state) do
- groups = Map.delete(state.groups, group_id)
-
- # Release trap if we're removing the trapped group
- trapped =
- if state.trapped_group == group_id do
- nil
- else
- state.trapped_group
- end
-
- {:reply, :ok, %{state | groups: groups, trapped_group: trapped}}
- end
-
- @impl true
- def handle_call({:trap_focus, group_id}, _from, state) do
- if Map.has_key?(state.groups, group_id) do
- {:reply, :ok, %{state | trapped_group: group_id}}
- else
- {:reply, {:error, :group_not_found}, state}
- end
- end
-
- @impl true
- def handle_call(:release_focus, _from, state) do
- {:reply, :ok, %{state | trapped_group: nil}}
- end
-
- @impl true
- def handle_call(:get_groups, _from, state) do
- {:reply, state.groups, state}
- end
-
- @impl true
- def handle_call(:get_stack, _from, state) do
- {:reply, state.stack, state}
- end
-
- @impl true
- def handle_cast({:request_auto_focus, component_id}, state) do
- # Only auto-focus if nothing is currently focused
- if state.current == nil do
- case do_set_focused(component_id, state) do
- {:ok, new_state} -> {:noreply, new_state}
- {:error, _} -> {:noreply, state}
- end
- else
- {:noreply, state}
- end
- end
-
- # Private Functions
-
- defp do_set_focused(nil, state) do
- old_focus = state.current
-
- # Send blur to old
- if old_focus do
- send_focus_event(old_focus, :lost)
- end
-
- # Update EventRouter
- EventRouter.set_focus(nil)
-
- {:ok, %{state | current: nil}}
- end
-
- defp do_set_focused(component_id, state) do
- # Check if component exists and is focusable
- with {:ok, _pid} <- ComponentRegistry.lookup(component_id),
- true <- focusable?(component_id) do
- update_focus(component_id, state)
- else
- {:error, :not_found} -> {:error, :not_found}
- false -> {:error, :not_focusable}
- end
- end
-
- defp update_focus(component_id, %{current: old_focus} = state) when component_id != old_focus do
- # Update EventRouter - this sends focus events
- EventRouter.set_focus(component_id)
- {:ok, %{state | current: component_id}}
- end
-
- defp update_focus(component_id, state) do
- {:ok, %{state | current: component_id}}
- end
-
- defp do_focus_next(state) do
- focusable = get_focusable_list(state)
-
- case focusable do
- [] ->
- {:error, :no_focusable}
-
- list ->
- next = find_next(list, state.current)
- do_set_focused(next, state)
- end
- end
-
- defp do_focus_prev(state) do
- focusable = get_focusable_list(state)
-
- case focusable do
- [] ->
- {:error, :no_focusable}
-
- list ->
- prev = find_prev(list, state.current)
- do_set_focused(prev, state)
- end
- end
-
- defp get_focusable_list(state) do
- # If trapped, only include group components
- components =
- if state.trapped_group do
- Map.get(state.groups, state.trapped_group, [])
- |> Enum.filter(&component_exists?/1)
- else
- ComponentRegistry.list_all()
- |> Enum.map(& &1.id)
- end
-
- # Filter to focusable and sort by tab order
- components
- |> Enum.filter(&focusable?/1)
- |> sort_by_tab_order()
- end
-
- defp sort_by_tab_order(component_ids) do
- component_ids
- |> Enum.map(fn id ->
- {tab_index, position} = get_tab_info(id)
- {id, tab_index, position}
- end)
- |> Enum.sort_by(fn {_id, tab_index, {x, y}} ->
- # Sort by tab_index first (nil = max), then by position (y, x)
- {tab_index || 999_999, y, x}
- end)
- |> Enum.map(fn {id, _, _} -> id end)
- end
-
- defp get_tab_info(component_id) do
- # Get tab_index from component props if available
- # Get position from spatial index
- tab_index = get_component_tab_index(component_id)
-
- position =
- case SpatialIndex.get_bounds(component_id) do
- {:ok, %{x: x, y: y}} -> {x, y}
- _ -> {0, 0}
- end
-
- {tab_index, position}
- end
-
- defp get_component_tab_index(_component_id) do
- # Return nil to use position-based ordering
- nil
- end
-
- defp find_next([], _current), do: nil
-
- defp find_next(list, nil) do
- # No current focus, return first
- List.first(list)
- end
-
- defp find_next(list, current) do
- case Enum.find_index(list, &(&1 == current)) do
- nil ->
- List.first(list)
-
- idx ->
- next_idx = rem(idx + 1, length(list))
- Enum.at(list, next_idx)
- end
- end
-
- defp find_prev([], _current), do: nil
-
- defp find_prev(list, nil) do
- # No current focus, return last
- List.last(list)
- end
-
- defp find_prev(list, current) do
- case Enum.find_index(list, &(&1 == current)) do
- nil ->
- List.last(list)
-
- 0 ->
- List.last(list)
-
- idx ->
- Enum.at(list, idx - 1)
- end
- end
-
- defp focusable?(component_id) do
- # Check if component is focusable
- # Components are focusable by default unless explicitly disabled
- component_exists?(component_id)
- end
-
- defp component_exists?(component_id) do
- case ComponentRegistry.lookup(component_id) do
- {:ok, _} -> true
- _ -> false
- end
- end
-
- defp send_focus_event(component_id, action) do
- case ComponentRegistry.lookup(component_id) do
- {:ok, pid} ->
- event = Event.focus(action)
-
- try do
- GenServer.call(pid, {:event, event}, 5000)
- catch
- :exit, _ -> :ok
- end
-
- {:error, _} ->
- :ok
- end
- end
-end
diff --git a/lib/term_ui/frame.ex b/lib/term_ui/frame.ex
new file mode 100644
index 00000000..7d2d6b9a
--- /dev/null
+++ b/lib/term_ui/frame.ex
@@ -0,0 +1,431 @@
+defmodule TermUI.Frame do
+ @moduledoc """
+ A complete terminal frame.
+
+ A frame is the only render value accepted by `TermUI.Runtime`. It stores
+ terminal cells in a sparse map. Missing positions are default blank cells.
+ Frame dimensions and cursor coordinates are one-based. The cursor tuple is
+ `{column, row}`.
+ """
+
+ alias TermUI.{Cell, DisplayWidth, Style}
+
+ @max_rows 500
+ @max_columns 1000
+
+ @type cursor :: {pos_integer(), pos_integer()} | nil
+ @type position :: {row :: pos_integer(), column :: pos_integer()}
+ @type span :: String.t() | {iodata(), Style.t()}
+ @type row :: iodata() | [span()]
+
+ @type t :: %__MODULE__{
+ width: pos_integer(),
+ height: pos_integer(),
+ cells: %{optional(position()) => Cell.t()},
+ cursor: cursor()
+ }
+
+ @coordinate_schema Zoi.integer() |> Zoi.positive()
+ @position_schema Zoi.tuple({@coordinate_schema, @coordinate_schema})
+ @cursor_schema Zoi.union([@position_schema, Zoi.literal(nil)])
+
+ @schema Zoi.struct(__MODULE__, %{
+ width: @coordinate_schema |> Zoi.lte(@max_columns),
+ height: @coordinate_schema |> Zoi.lte(@max_rows),
+ cells: Zoi.map(@position_schema, Cell.schema()) |> Zoi.default(%{}),
+ cursor: @cursor_schema |> Zoi.default(nil)
+ })
+ |> Zoi.refine({__MODULE__, :validate_schema, []})
+
+ @enforce_keys Zoi.Struct.enforce_keys(@schema)
+ defstruct Zoi.Struct.struct_fields(@schema)
+
+ @doc "Returns the Zoi schema for complete terminal frames."
+ @spec schema() :: Zoi.schema()
+ def schema, do: @schema
+
+ @doc false
+ @spec validate_schema(t(), keyword()) :: :ok | {:error, String.t()}
+ def validate_schema(%__MODULE__{} = frame, _opts) do
+ cursor_in_bounds? =
+ case frame.cursor do
+ nil -> true
+ {column, row} -> column <= frame.width and row <= frame.height
+ end
+
+ cells_in_bounds? =
+ Enum.all?(frame.cells, fn {{row, column}, _cell} ->
+ row <= frame.height and column <= frame.width
+ end)
+
+ if cursor_in_bounds? and cells_in_bounds?,
+ do: :ok,
+ else: {:error, "frame cursor and cells must be inside the frame dimensions"}
+ end
+
+ @doc "Creates an empty frame."
+ @spec new(pos_integer(), pos_integer(), keyword()) :: t()
+ def new(width, height, opts \\ []) do
+ validate_dimensions!(width, height)
+
+ %__MODULE__{
+ width: width,
+ height: height,
+ cells: normalize_cells(Keyword.get(opts, :cells, %{}), width, height),
+ cursor: normalize_cursor(Keyword.get(opts, :cursor), width, height)
+ }
+ end
+
+ @doc false
+ @spec clamp_dimensions({pos_integer(), pos_integer()}) :: {pos_integer(), pos_integer()}
+ def clamp_dimensions({width, height})
+ when is_integer(width) and width > 0 and is_integer(height) and height > 0,
+ do: {min(width, @max_columns), min(height, @max_rows)}
+
+ @doc "Builds a frame from plain rows or styled spans."
+ @spec from_rows([row()], pos_integer(), pos_integer(), keyword()) :: t()
+ def from_rows(rows, width, height, opts \\ []) when is_list(rows) do
+ frame = new(width, height, opts)
+
+ rows
+ |> Enum.take(height)
+ |> Enum.with_index(1)
+ |> Enum.reduce(frame, fn {row, row_index}, acc -> put_row(acc, row_index, row) end)
+ end
+
+ @doc "Writes one row. Content outside the frame is clipped."
+ @spec put_row(t(), pos_integer(), row()) :: t()
+ def put_row(%__MODULE__{} = frame, row, content) when row >= 1 and row <= frame.height do
+ spans = normalize_spans(content)
+ cells = clear_row(frame.cells, row)
+
+ {cells, _column} =
+ Enum.reduce(spans, {cells, 1}, fn {text, style}, {cells, column} ->
+ write_text(cells, frame.width, row, column, IO.iodata_to_binary(text), style)
+ end)
+
+ %{frame | cells: cells}
+ end
+
+ def put_row(%__MODULE__{} = frame, _row, _content), do: frame
+
+ @doc "Puts one cell. Empty default cells remain implicit."
+ @spec put_cell(t(), pos_integer(), pos_integer(), Cell.t()) :: t()
+ def put_cell(%__MODULE__{} = frame, row, column, %Cell{} = cell)
+ when row >= 1 and row <= frame.height and column >= 1 and column <= frame.width do
+ cells = put_bounded_cell(frame.cells, row, column, normalize_cell(cell), frame.width)
+ %{frame | cells: cells}
+ end
+
+ def put_cell(%__MODULE__{} = frame, _row, _column, %Cell{}), do: frame
+
+ @doc "Writes consecutive rows starting at a one-based row."
+ @spec put_rows(t(), pos_integer(), [row()]) :: t()
+ def put_rows(%__MODULE__{} = frame, start_row, rows)
+ when is_integer(start_row) and start_row > 0 and is_list(rows) do
+ rows
+ |> Enum.with_index(start_row)
+ |> Enum.reduce(frame, fn {content, row}, acc -> put_row(acc, row, content) end)
+ end
+
+ @doc "Overlays one frame at a one-based column and row."
+ @spec overlay(t(), t(), pos_integer(), pos_integer()) :: t()
+ def overlay(%__MODULE__{} = base, %__MODULE__{} = child, column, row)
+ when is_integer(column) and column > 0 and is_integer(row) and row > 0 do
+ if column > base.width or row > base.height do
+ base
+ else
+ do_overlay(base, child, column, row)
+ end
+ end
+
+ defp do_overlay(base, child, column, row) do
+ cleared = clear_region(base, column, row, child.width, child.height)
+
+ frame =
+ Enum.reduce(child.cells, cleared, fn {{child_row, child_column}, cell}, acc ->
+ put_cell(acc, row + child_row - 1, column + child_column - 1, cell)
+ end)
+
+ case child.cursor do
+ nil ->
+ frame
+
+ {child_column, child_row} ->
+ %{
+ frame
+ | cursor:
+ normalize_cursor(
+ {column + child_column - 1, row + child_row - 1},
+ base.width,
+ base.height
+ )
+ }
+ end
+ end
+
+ @doc "Gets one cell."
+ @spec cell(t(), pos_integer(), pos_integer()) :: Cell.t()
+ def cell(%__MODULE__{} = frame, row, column) do
+ Map.get(frame.cells, {row, column}, Cell.empty())
+ end
+
+ @doc "Returns one row as terminal text, including trailing blanks."
+ @spec row_text(t(), pos_integer()) :: String.t()
+ def row_text(%__MODULE__{} = frame, row) when row >= 1 and row <= frame.height do
+ 1..frame.width
+ |> Enum.map_join(fn column ->
+ case cell(frame, row, column) do
+ %Cell{wide_placeholder: true} -> ""
+ %Cell{char: char} -> char
+ end
+ end)
+ |> DisplayWidth.pad(frame.width)
+ end
+
+ def row_text(%__MODULE__{}, _row), do: ""
+
+ @doc "Returns all visible cells in backend row and column format."
+ @spec cells(t()) :: [{position(), TermUI.Backend.cell()}]
+ def cells(%__MODULE__{} = frame) do
+ frame.cells
+ |> Enum.flat_map(fn
+ {_position, %Cell{wide_placeholder: true}} -> []
+ {position, cell} -> [{position, backend_cell(cell)}]
+ end)
+ |> Enum.sort_by(&elem(&1, 0))
+ end
+
+ @doc "Returns the changed backend cells between two frames."
+ @spec diff(t() | nil, t()) :: [{position(), TermUI.Backend.cell()}]
+ def diff(nil, %__MODULE__{} = current), do: cells(current)
+
+ def diff(%__MODULE__{} = previous, %__MODULE__{} = current) do
+ previous.cells
+ |> Map.keys()
+ |> Kernel.++(Map.keys(current.cells))
+ |> Enum.uniq()
+ |> Enum.filter(fn {row, column} -> row <= current.height and column <= current.width end)
+ |> Enum.reduce([], fn position, changes ->
+ old_cell = Map.get(previous.cells, position, Cell.empty())
+ new_cell = Map.get(current.cells, position, Cell.empty())
+
+ cond do
+ Cell.equal?(old_cell, new_cell) ->
+ changes
+
+ new_cell.wide_placeholder ->
+ changes
+
+ Cell.empty?(new_cell) ->
+ [{position, {" ", :default, :default, []}} | changes]
+
+ true ->
+ [{position, backend_cell(new_cell)} | changes]
+ end
+ end)
+ |> Enum.sort_by(&elem(&1, 0))
+ end
+
+ @doc "Fits text to one display row."
+ @spec fit(iodata(), non_neg_integer()) :: String.t()
+ def fit(_text, 0), do: ""
+
+ def fit(text, width) when width > 0 do
+ text = text |> IO.iodata_to_binary() |> String.replace(["\r", "\n"], " ")
+ {content, _width} = DisplayWidth.truncate(text, width)
+ DisplayWidth.pad(content, width)
+ end
+
+ @doc "Wraps text at display-width boundaries."
+ @spec wrap(String.t(), pos_integer()) :: [String.t()]
+ def wrap(text, width) when is_binary(text) and width > 0 do
+ text
+ |> String.split("\n", trim: false)
+ |> Enum.flat_map(&wrap_line(&1, width))
+ end
+
+ defp normalize_spans(content) when is_binary(content), do: [{content, Style.new()}]
+
+ defp normalize_spans(content) when is_list(content) do
+ if :io_lib.printable_unicode_list(content) do
+ [{IO.iodata_to_binary(content), Style.new()}]
+ else
+ Enum.map(content, fn
+ {text, %Style{} = style} -> {text, style}
+ text -> {text, Style.new()}
+ end)
+ end
+ end
+
+ defp normalize_spans(content), do: [{to_string(content), Style.new()}]
+
+ defp write_text(cells, width, row, start_column, text, style) do
+ text
+ |> String.replace(["\r", "\n"], " ")
+ |> String.graphemes()
+ |> Enum.reduce_while({cells, start_column}, fn grapheme, {cells, column} ->
+ cell = Style.to_cell(style, grapheme)
+ cell_width = Cell.width(cell)
+
+ cond do
+ column > width ->
+ {:halt, {cells, column}}
+
+ cell_width == 2 and column == width ->
+ {:halt, {cells, column}}
+
+ cell_width == 2 ->
+ cells = put_bounded_cell(cells, row, column, cell, width)
+ {:cont, {cells, column + 2}}
+
+ true ->
+ {:cont, {put_bounded_cell(cells, row, column, cell, width), column + 1}}
+ end
+ end)
+ end
+
+ defp normalize_cells(cells, width, height) when is_map(cells) do
+ cells
+ |> Enum.sort_by(&elem(&1, 0))
+ |> Enum.reduce(%{}, fn
+ {{row, column}, %Cell{} = cell}, acc
+ when row >= 1 and row <= height and column >= 1 and column <= width ->
+ put_bounded_cell(acc, row, column, normalize_cell(cell), width)
+
+ _entry, acc ->
+ acc
+ end)
+ end
+
+ defp normalize_cells(cells, width, height) when is_list(cells) do
+ cells
+ |> Map.new(fn
+ {row, column, %Cell{} = cell} -> {{row, column}, cell}
+ {{row, column}, %Cell{} = cell} -> {{row, column}, cell}
+ end)
+ |> normalize_cells(width, height)
+ end
+
+ defp put_sparse(cells, position, %Cell{} = cell) do
+ if Cell.empty?(cell), do: Map.delete(cells, position), else: Map.put(cells, position, cell)
+ end
+
+ defp put_bounded_cell(cells, row, column, %Cell{wide_placeholder: true}, _width) do
+ case Map.get(cells, {row, column - 1}) do
+ %Cell{width: 2, wide_placeholder: false} = primary ->
+ put_sparse(cells, {row, column}, Cell.wide_placeholder(primary))
+
+ _other ->
+ Map.delete(cells, {row, column})
+ end
+ end
+
+ defp put_bounded_cell(cells, row, column, %Cell{width: 2} = cell, width) do
+ cells = clear_cell_footprint(cells, row, column)
+
+ if column < width do
+ cells
+ |> clear_cell_footprint(row, column + 1)
+ |> put_sparse({row, column}, cell)
+ |> put_sparse({row, column + 1}, Cell.wide_placeholder(cell))
+ else
+ cells
+ end
+ end
+
+ defp put_bounded_cell(cells, row, column, %Cell{} = cell, _width) do
+ cells
+ |> clear_cell_footprint(row, column)
+ |> put_sparse({row, column}, cell)
+ end
+
+ defp clear_cell_footprint(cells, row, column) do
+ target = Map.get(cells, {row, column})
+ previous = Map.get(cells, {row, column - 1})
+ target_wide? = wide_primary?(target)
+ previous_wide? = wide_primary?(previous)
+
+ positions =
+ [{row, column}]
+ |> maybe_add_position(target_wide?, {row, column + 1})
+ |> maybe_add_position(previous_wide?, {row, column - 1})
+
+ Map.drop(cells, positions)
+ end
+
+ defp wide_primary?(%Cell{width: 2, wide_placeholder: false}), do: true
+ defp wide_primary?(_cell), do: false
+
+ defp maybe_add_position(positions, true, position), do: [position | positions]
+ defp maybe_add_position(positions, false, _position), do: positions
+
+ defp clear_row(cells, row) do
+ Map.reject(cells, fn {{cell_row, _column}, _cell} -> cell_row == row end)
+ end
+
+ defp clear_region(frame, column, row, width, height) do
+ positions =
+ for target_row <- row..min(row + height - 1, frame.height),
+ target_column <- column..min(column + width - 1, frame.width),
+ do: {target_row, target_column}
+
+ cells =
+ Enum.reduce(positions, frame.cells, fn {target_row, target_column}, cells ->
+ clear_cell_footprint(cells, target_row, target_column)
+ end)
+
+ %{frame | cells: cells}
+ end
+
+ defp normalize_cell(%Cell{wide_placeholder: true} = cell) do
+ " "
+ |> Cell.new(fg: cell.fg, bg: cell.bg, attrs: cell.attrs)
+ |> Cell.wide_placeholder()
+ end
+
+ defp normalize_cell(%Cell{} = cell) do
+ Cell.new(cell.char, fg: cell.fg, bg: cell.bg, attrs: cell.attrs)
+ end
+
+ defp normalize_cursor(nil, _width, _height), do: nil
+
+ defp normalize_cursor({column, row}, width, height)
+ when is_integer(column) and is_integer(row) do
+ {column |> max(1) |> min(width), row |> max(1) |> min(height)}
+ end
+
+ defp normalize_cursor(_cursor, _width, _height), do: nil
+
+ defp backend_cell(%Cell{char: char, fg: foreground, bg: background, attrs: attrs}) do
+ {char, foreground || :default, background || :default,
+ attrs |> MapSet.to_list() |> Enum.sort()}
+ end
+
+ defp wrap_line("", _width), do: [""]
+
+ defp wrap_line(line, width) do
+ line
+ |> String.graphemes()
+ |> Enum.reduce({[], "", 0}, fn grapheme, {lines, current, current_width} ->
+ grapheme_width = max(DisplayWidth.width(grapheme), 0)
+
+ if current != "" and current_width + grapheme_width > width do
+ {[current | lines], grapheme, grapheme_width}
+ else
+ {lines, current <> grapheme, current_width + grapheme_width}
+ end
+ end)
+ |> then(fn {lines, current, _current_width} -> Enum.reverse([current | lines]) end)
+ end
+
+ defp validate_dimensions!(width, height)
+ when is_integer(width) and width > 0 and width <= @max_columns and
+ is_integer(height) and height > 0 and height <= @max_rows,
+ do: :ok
+
+ defp validate_dimensions!(width, height) do
+ raise ArgumentError,
+ "frame dimensions must be within 1..#{@max_columns} by 1..#{@max_rows}, got #{inspect({width, height})}"
+ end
+end
diff --git a/lib/term_ui/helpers/border_helper.ex b/lib/term_ui/helpers/border_helper.ex
deleted file mode 100644
index 92985d69..00000000
--- a/lib/term_ui/helpers/border_helper.ex
+++ /dev/null
@@ -1,317 +0,0 @@
-defmodule TermUI.Helpers.BorderHelper do
- @moduledoc """
- Helper functions for rendering borders using CharacterSet.
-
- This module provides convenience functions for common border rendering
- patterns, eliminating code duplication across widgets that draw borders.
-
- All functions use the current CharacterSet to ensure correct character
- selection based on terminal capabilities (Unicode or ASCII).
-
- ## Usage
-
- import TermUI.Helpers.BorderHelper
-
- # Draw a horizontal line
- line = horizontal_line(20)
- # => "────────────────────" (Unicode) or "--------------------" (ASCII)
-
- # Draw a box top
- top = box_top(20)
- # => "┌──────────────────┐" (Unicode) or "+------------------+" (ASCII)
-
- ## Integration with Widgets
-
- Widgets can use these helpers to render borders consistently:
-
- def render_border(state, area) do
- import TermUI.Helpers.BorderHelper
-
- stack(:vertical, [
- text(box_top(area.width)),
- # ... content ...
- text(box_bottom(area.width))
- ])
- end
- """
-
- alias TermUI.CharacterSet
-
- # Dialyzer: Functions return specific string types
- @dialyzer {:nowarn_function, bordered_row: 3}
-
- @doc """
- Renders a horizontal line of the specified width.
-
- Uses the current CharacterSet's horizontal line character.
-
- ## Parameters
-
- - `width` - Width of the line in characters
-
- ## Examples
-
- iex> horizontal_line(5)
- "─────" # Unicode mode
-
- iex> Application.put_env(:term_ui, :character_set, :ascii)
- iex> horizontal_line(5)
- "-----"
- """
- @spec horizontal_line(non_neg_integer()) :: String.t()
- def horizontal_line(width) when is_integer(width) and width >= 0 do
- chars = CharacterSet.current_charset()
- String.duplicate(chars.h_line, width)
- end
-
- @doc """
- Renders a heavy horizontal line of the specified width.
-
- Uses the current CharacterSet's heavy horizontal line character.
-
- ## Parameters
-
- - `width` - Width of the line in characters
-
- ## Examples
-
- iex> horizontal_line_heavy(5)
- "━━━━━" # Unicode mode
- """
- @spec horizontal_line_heavy(non_neg_integer()) :: String.t()
- def horizontal_line_heavy(width) when is_integer(width) and width >= 0 do
- chars = CharacterSet.current_charset()
- String.duplicate(chars.h_line_heavy, width)
- end
-
- @doc """
- Renders a vertical line of the specified height.
-
- Returns a list of strings, one per line.
-
- ## Parameters
-
- - `height` - Height of the line in characters
-
- ## Examples
-
- iex> vertical_line(3)
- ["│", "│", "│"] # Unicode mode
- """
- @spec vertical_line(non_neg_integer()) :: [String.t()]
- def vertical_line(height) when is_integer(height) and height >= 0 do
- chars = CharacterSet.current_charset()
- List.duplicate(chars.v_line, height)
- end
-
- @doc """
- Renders the top border of a box.
-
- Format: `┌` + horizontal line + `┐`
-
- ## Parameters
-
- - `width` - Total width including corners (minimum 2)
-
- ## Examples
-
- iex> box_top(10)
- "┌────────┐" # Unicode mode
-
- iex> Application.put_env(:term_ui, :character_set, :ascii)
- iex> box_top(10)
- "+--------+"
- """
- @spec box_top(non_neg_integer()) :: String.t()
- def box_top(width) when is_integer(width) and width >= 2 do
- chars = CharacterSet.current_charset()
- inner_width = max(0, width - 2)
- chars.tl <> String.duplicate(chars.h_line, inner_width) <> chars.tr
- end
-
- def box_top(width) when is_integer(width) and width >= 0 do
- chars = CharacterSet.current_charset()
- String.duplicate(chars.h_line, width)
- end
-
- @doc """
- Renders the bottom border of a box.
-
- Format: `└` + horizontal line + `┘`
-
- ## Parameters
-
- - `width` - Total width including corners (minimum 2)
-
- ## Examples
-
- iex> box_bottom(10)
- "└────────┘" # Unicode mode
-
- iex> Application.put_env(:term_ui, :character_set, :ascii)
- iex> box_bottom(10)
- "+--------+"
- """
- @spec box_bottom(non_neg_integer()) :: String.t()
- def box_bottom(width) when is_integer(width) and width >= 2 do
- chars = CharacterSet.current_charset()
- inner_width = max(0, width - 2)
- chars.bl <> String.duplicate(chars.h_line, inner_width) <> chars.br
- end
-
- def box_bottom(width) when is_integer(width) and width >= 0 do
- chars = CharacterSet.current_charset()
- String.duplicate(chars.h_line, width)
- end
-
- @doc """
- Renders the top border of a box with rounded corners.
-
- Format: `╭` + horizontal line + `╮`
-
- ## Parameters
-
- - `width` - Total width including corners (minimum 2)
-
- ## Examples
-
- iex> box_top_round(10)
- "╭────────╮" # Unicode mode
- """
- @spec box_top_round(non_neg_integer()) :: String.t()
- def box_top_round(width) when is_integer(width) and width >= 2 do
- chars = CharacterSet.current_charset()
- inner_width = max(0, width - 2)
- chars.tl_round <> String.duplicate(chars.h_line, inner_width) <> chars.tr_round
- end
-
- def box_top_round(width) when is_integer(width) and width >= 0 do
- chars = CharacterSet.current_charset()
- String.duplicate(chars.h_line, width)
- end
-
- @doc """
- Renders the bottom border of a box with rounded corners.
-
- Format: `╰` + horizontal line + `╯`
-
- ## Parameters
-
- - `width` - Total width including corners (minimum 2)
-
- ## Examples
-
- iex> box_bottom_round(10)
- "╰────────╯" # Unicode mode
- """
- @spec box_bottom_round(non_neg_integer()) :: String.t()
- def box_bottom_round(width) when is_integer(width) and width >= 2 do
- chars = CharacterSet.current_charset()
- inner_width = max(0, width - 2)
- chars.bl_round <> String.duplicate(chars.h_line, inner_width) <> chars.br_round
- end
-
- def box_bottom_round(width) when is_integer(width) and width >= 0 do
- chars = CharacterSet.current_charset()
- String.duplicate(chars.h_line, width)
- end
-
- @doc """
- Renders a left border character with optional content.
-
- Format: `│` + content
-
- ## Parameters
-
- - `content` - Optional content to append after the border (default: "")
-
- ## Examples
-
- iex> left_border()
- "│"
-
- iex> left_border(" Hello")
- "│ Hello"
- """
- @spec left_border(String.t()) :: String.t()
- def left_border(content \\ "") do
- chars = CharacterSet.current_charset()
- chars.v_line <> content
- end
-
- @doc """
- Renders a right border character with optional content.
-
- Format: content + `│`
-
- ## Parameters
-
- - `content` - Optional content to prepend before the border (default: "")
-
- ## Examples
-
- iex> right_border()
- "│"
-
- iex> right_border("Hello ")
- "Hello │"
- """
- @spec right_border(String.t()) :: String.t()
- def right_border(content \\ "") do
- chars = CharacterSet.current_charset()
- content <> chars.v_line
- end
-
- @doc """
- Renders a complete row with left and right borders.
-
- Format: `│` + padded content + `│`
-
- The content is padded to fill the inner width.
-
- ## Parameters
-
- - `content` - Content to display between borders
- - `width` - Total width including borders (minimum 2)
- - `opts` - Options:
- - `:pad` - Padding character (default: " ")
- - `:align` - `:left`, `:right`, or `:center` (default: `:left`)
-
- ## Examples
-
- iex> bordered_row("Hello", 12)
- "│Hello │"
-
- iex> bordered_row("Hi", 10, align: :center)
- "│ Hi │"
- """
- @spec bordered_row(String.t(), non_neg_integer(), keyword()) :: String.t()
- def bordered_row(content, width, opts \\ []) when is_integer(width) and width >= 2 do
- chars = CharacterSet.current_charset()
- pad_char = Keyword.get(opts, :pad, " ")
- align = Keyword.get(opts, :align, :left)
-
- inner_width = max(0, width - 2)
- content_len = String.length(content)
- padding_needed = max(0, inner_width - content_len)
-
- padded_content =
- case align do
- :left ->
- content <> String.duplicate(pad_char, padding_needed)
-
- :right ->
- String.duplicate(pad_char, padding_needed) <> content
-
- :center ->
- left_pad = div(padding_needed, 2)
- right_pad = padding_needed - left_pad
- String.duplicate(pad_char, left_pad) <> content <> String.duplicate(pad_char, right_pad)
- end
-
- # Truncate if content is too long
- padded_content = String.slice(padded_content, 0, inner_width)
-
- chars.v_line <> padded_content <> chars.v_line
- end
-end
diff --git a/lib/term_ui/helpers/cursor_helper.ex b/lib/term_ui/helpers/cursor_helper.ex
deleted file mode 100644
index cfc7261b..00000000
--- a/lib/term_ui/helpers/cursor_helper.ex
+++ /dev/null
@@ -1,269 +0,0 @@
-defmodule TermUI.Helpers.CursorHelper do
- @moduledoc """
- Helper functions for cursor navigation within lists.
-
- This module provides convenience functions for managing cursor positions
- in widgets with selectable items (menus, lists, tables, tree views, etc.).
-
- ## Usage
-
- import TermUI.Helpers.CursorHelper
-
- # Move cursor down with wrapping
- new_cursor = move_down(cursor, 1, item_count, wrap: true)
-
- # Move cursor up with clamping
- new_cursor = move_up(cursor, 1, item_count)
-
- # Clamp cursor to valid range
- new_cursor = clamp_cursor(cursor, 0, item_count - 1)
-
- ## Common Patterns
-
- All functions work with 0-based cursor indices. The `max` parameter
- is typically `length(items) - 1` for the last valid index.
- """
-
- @doc """
- Moves the cursor down (towards higher indices).
-
- ## Parameters
-
- - `cursor` - Current cursor position (0-based)
- - `step` - Number of positions to move (default: 1)
- - `max` - Maximum valid cursor position (inclusive)
- - `opts` - Options:
- - `:wrap` - If true, wraps from max to 0 (default: false)
-
- ## Examples
-
- iex> move_down(0, 1, 4)
- 1
-
- iex> move_down(4, 1, 4) # At max, clamped
- 4
-
- iex> move_down(4, 1, 4, wrap: true) # At max, wraps to 0
- 0
-
- iex> move_down(2, 3, 4) # Move 3 positions, clamped to max
- 4
- """
- @spec move_down(non_neg_integer(), non_neg_integer(), non_neg_integer(), keyword()) ::
- non_neg_integer()
- def move_down(cursor, step \\ 1, max, opts \\ [])
- when is_integer(cursor) and is_integer(step) and is_integer(max) do
- wrap = Keyword.get(opts, :wrap, false)
- new_pos = cursor + step
-
- cond do
- new_pos > max and wrap -> rem(new_pos, max + 1)
- new_pos > max -> max
- true -> new_pos
- end
- end
-
- @doc """
- Moves the cursor up (towards lower indices).
-
- ## Parameters
-
- - `cursor` - Current cursor position (0-based)
- - `step` - Number of positions to move (default: 1)
- - `max` - Maximum valid cursor position (used for wrapping)
- - `opts` - Options:
- - `:wrap` - If true, wraps from 0 to max (default: false)
-
- ## Examples
-
- iex> move_up(2, 1, 4)
- 1
-
- iex> move_up(0, 1, 4) # At 0, clamped
- 0
-
- iex> move_up(0, 1, 4, wrap: true) # At 0, wraps to max
- 4
-
- iex> move_up(1, 3, 4) # Move 3 positions, clamped to 0
- 0
- """
- @spec move_up(non_neg_integer(), non_neg_integer(), non_neg_integer(), keyword()) ::
- non_neg_integer()
- def move_up(cursor, step \\ 1, max, opts \\ [])
- when is_integer(cursor) and is_integer(step) and is_integer(max) do
- wrap = Keyword.get(opts, :wrap, false)
- new_pos = cursor - step
-
- cond do
- new_pos < 0 and wrap -> max + 1 + new_pos
- new_pos < 0 -> 0
- true -> new_pos
- end
- end
-
- @doc """
- Clamps the cursor to valid bounds.
-
- Ensures cursor is within [min, max] range.
-
- ## Parameters
-
- - `cursor` - Current cursor position
- - `min` - Minimum valid position (default: 0)
- - `max` - Maximum valid position
-
- ## Examples
-
- iex> clamp_cursor(5, 0, 3)
- 3
-
- iex> clamp_cursor(-2, 0, 3)
- 0
-
- iex> clamp_cursor(2, 0, 3)
- 2
- """
- @spec clamp_cursor(integer(), integer(), integer()) :: integer()
- def clamp_cursor(cursor, min \\ 0, max) when is_integer(cursor) do
- cursor
- |> max(min)
- |> min(max)
- end
-
- @doc """
- Wraps cursor position within valid range.
-
- Unlike clamp, wrap treats the range as circular.
-
- ## Parameters
-
- - `cursor` - Current cursor position (can be negative or > max)
- - `min` - Minimum valid position (default: 0)
- - `max` - Maximum valid position
-
- ## Examples
-
- iex> wrap_cursor(5, 0, 3) # 5 wraps to 1 (5 mod 4 = 1)
- 1
-
- iex> wrap_cursor(-1, 0, 3) # -1 wraps to 3
- 3
-
- iex> wrap_cursor(4, 0, 3) # 4 wraps to 0
- 0
- """
- @spec wrap_cursor(integer(), integer(), integer()) :: integer()
- def wrap_cursor(cursor, min \\ 0, max) when is_integer(cursor) do
- range = max - min + 1
-
- if range <= 0 do
- min
- else
- result = rem(cursor - min, range)
-
- if result < 0 do
- min + range + result
- else
- min + result
- end
- end
- end
-
- @doc """
- Finds the next valid cursor position, skipping invalid positions.
-
- Useful for skipping separators or disabled items in menus.
-
- ## Parameters
-
- - `cursor` - Current cursor position
- - `direction` - `:up` or `:down`
- - `max` - Maximum valid position
- - `valid?` - Function that returns true if position is valid
- - `opts` - Options:
- - `:wrap` - If true, wraps at boundaries (default: false)
- - `:max_attempts` - Maximum positions to try (default: max + 1)
-
- ## Examples
-
- # Skip disabled items (positions 1 and 2)
- valid? = fn pos -> pos not in [1, 2] end
- move_to_next_valid(0, :down, 4, valid?)
- # => 3 (skips 1 and 2)
- """
- @spec move_to_next_valid(
- non_neg_integer(),
- :up | :down,
- non_neg_integer(),
- (non_neg_integer() -> boolean()),
- keyword()
- ) :: non_neg_integer() | nil
- def move_to_next_valid(cursor, direction, max, valid?, opts \\ []) do
- wrap = Keyword.get(opts, :wrap, false)
- max_attempts = Keyword.get(opts, :max_attempts, max + 1)
-
- move_fn =
- case direction do
- :down -> &move_down(&1, 1, max, wrap: wrap)
- :up -> &move_up(&1, 1, max, wrap: wrap)
- end
-
- find_next_valid(cursor, move_fn, valid?, max_attempts, cursor)
- end
-
- defp find_next_valid(_cursor, _move_fn, _valid?, 0, _start), do: nil
-
- defp find_next_valid(cursor, move_fn, valid?, attempts, start) do
- next = move_fn.(cursor)
-
- cond do
- next == start and attempts < start + 1 -> nil
- valid?.(next) -> next
- true -> find_next_valid(next, move_fn, valid?, attempts - 1, start)
- end
- end
-
- @doc """
- Moves cursor to first valid position from the beginning.
-
- ## Parameters
-
- - `max` - Maximum valid position
- - `valid?` - Function that returns true if position is valid
-
- ## Examples
-
- # Find first non-disabled item
- valid? = fn pos -> pos not in [0, 1] end
- first_valid(4, valid?)
- # => 2
- """
- @spec first_valid(non_neg_integer(), (non_neg_integer() -> boolean())) ::
- non_neg_integer() | nil
- def first_valid(max, valid?) do
- Enum.find(0..max, valid?)
- end
-
- @doc """
- Moves cursor to last valid position from the end.
-
- ## Parameters
-
- - `max` - Maximum valid position
- - `valid?` - Function that returns true if position is valid
-
- ## Examples
-
- # Find last non-disabled item
- valid? = fn pos -> pos not in [3, 4] end
- last_valid(4, valid?)
- # => 2
- """
- @spec last_valid(non_neg_integer(), (non_neg_integer() -> boolean())) ::
- non_neg_integer() | nil
- def last_valid(max, valid?) do
- max..0//-1
- |> Enum.find(valid?)
- end
-end
diff --git a/lib/term_ui/input.ex b/lib/term_ui/input.ex
index 8220ab2e..defb350d 100644
--- a/lib/term_ui/input.ex
+++ b/lib/term_ui/input.ex
@@ -1,227 +1,204 @@
defmodule TermUI.Input do
@moduledoc """
- Behaviour defining the input abstraction for TermUI.
+ Normalizes input from adapters into the v2 event types.
- This module establishes a unified interface for reading terminal input,
- regardless of whether the application is running with the Raw backend
- or the TTY backend.
+ This module is the boundary for input that does not come from a TermUI
+ terminal backend. Printable text, committed input-method composition,
+ special keys, and paste stay different at this boundary.
- ## Input Modes
+ ## Text fields
- TermUI supports two input approaches:
+ Send printable text and committed composition to a text widget as
+ `TermUI.Event.Text`. Send paste as `TermUI.Event.Paste` so the widget can
+ apply its paste policy:
- ### Character Mode (Default)
+ {field, messages} =
+ TermUI.Widget.TextInput.update(
+ TermUI.Input.text("Jido 👩💻"),
+ field
+ )
- Both Raw and TTY backends use character-by-character input via `IO.getn/2`.
- This means keyboard navigation (arrow keys, Tab, Enter, function keys) works
- **identically** in both modes. The shell only provides line editing for
- `IO.gets/1` calls—single character reads are immediate in both modes.
+ {field, messages} =
+ TermUI.Widget.TextInput.update(
+ TermUI.Input.composition("e\u0301"),
+ field
+ )
- This is the primary input mode used by most widgets:
- - `Menu`, `PickList`, `Table` - navigation with arrows, selection with Enter
- - `Dialog`, `AlertDialog` - button navigation with Tab
- - `Tabs`, `TreeView` - keyboard navigation
+ {field, messages} =
+ TermUI.Widget.TextInput.update(
+ TermUI.Input.paste("first\nsecond"),
+ field
+ )
- ### Line Mode (TextInput.Line Only)
+ Call `composition/2` only after an input method commits text. Do not send
+ partial composition updates to a widget.
- The `TermUI.Input.LineReader` module provides line-based input using
- `IO.gets/1`. This is **only** used by the `TextInput.Line` widget, which
- benefits from shell line editing features:
- - Backspace, delete, cursor movement
- - Command history (if shell supports it)
- - Input submitted on Enter
+ ## Shortcuts
- Most applications should use character mode. Line mode is a specialized
- feature for free-form text entry where shell editing is desirable.
+ Use `special_key/2` for named keys and modified printable keys. Common
+ adapter names and modifier names are converted to the TermUI names:
- ## Implementing the Behaviour
+ shortcuts = TermUI.Shortcut.new([{"ctrl+s", :save}])
+ event = TermUI.Input.special_key("s", modifiers: [:control])
+ {_shortcuts, [:save]} = TermUI.Shortcut.route(event, shortcuts)
- Input handlers must implement three callbacks:
+ TermUI.Input.special_key("ArrowUp")
+ #=> %TermUI.Event.Key{key: :up}
- - `poll/2` - Read input with optional timeout
- - `mode/1` - Return the input mode (`:raw` or `:tty`)
- - `stop/1` - Cleanup and release resources
-
- ## Example Implementation
-
- defmodule MyApp.CustomInput do
- @behaviour TermUI.Input
-
- @impl true
- def poll(state, timeout) do
- # Read input, return {:ok, event}, :timeout, or :eof
- {:ok, event, state}
- end
-
- @impl true
- def mode(_state), do: :custom
- end
-
- ## Built-in Handlers
-
- - `TermUI.Input.Raw` - Wraps `TermUI.Terminal.InputReader` for raw mode
- - `TermUI.Input.TTY` - Uses `IO.getn/2` for TTY mode character input
-
- Use `TermUI.Input.Selector` to automatically choose the appropriate handler
- based on the active backend.
+ An unmodified printable value is rejected by `special_key/2`. Use `text/2`
+ for that value. There is no option that converts all printable text to key
+ events.
"""
alias TermUI.Event
- # Type Definitions
-
- @typedoc """
- Key event returned from input polling.
-
- This is the standard key event type from `TermUI.Event.Key`.
- """
- @type key_event :: Event.Key.t()
-
- @typedoc """
- Result of an input polling operation.
-
- - `{:ok, key_event()}` - A key event was received
- - `{:ok, Event.Mouse.t()}` - A mouse event was received
- - `{:ok, Event.Paste.t()}` - A paste event was received (bracketed paste)
- - `:timeout` - No input within the timeout period
- - `:eof` - End of input stream
- """
- @type input_result ::
- {:ok, key_event() | Event.Mouse.t() | Event.Paste.t()}
- | :timeout
- | :eof
-
- @typedoc """
- Result of poll/2 including updated state.
- """
- @type poll_result :: {input_result(), state()}
-
- @typedoc """
- Opaque state maintained by the input handler.
-
- Each handler implementation defines its own state structure.
- """
- @type state :: term()
-
- @typedoc """
- Input mode indicator.
-
- - `:raw` - Raw mode with full terminal control
- - `:tty` - TTY mode with shell present
- """
- @type mode :: :raw | :tty
-
- # Callbacks
-
- @doc """
- Poll for input with an optional timeout.
-
- Reads input from the terminal and returns a parsed event. The timeout
- specifies the maximum time to wait for input in milliseconds.
-
- ## Parameters
-
- - `state` - Handler-specific state (escape sequence buffer, etc.)
- - `timeout` - Maximum wait time in milliseconds (0 for non-blocking)
-
- ## Returns
-
- - `{{:ok, event}, new_state}` - An event was received
- - `{:timeout, new_state}` - No input within timeout
- - `{:eof, new_state}` - End of input stream
-
- ## Timeout Semantics
-
- The timeout is best-effort:
-
- - **Raw mode**: Supports non-blocking reads; timeout is honored accurately
- - **TTY mode**: Uses blocking `IO.getn/2`; timeout may not be honored
-
- Components should not rely on precise timeout behavior. Use `:timeout`
- results for periodic updates, but design for the blocking case.
-
- ## Escape Sequences
-
- Handlers are responsible for buffering and parsing escape sequences.
- Multi-byte sequences (arrow keys, function keys) should be assembled
- before returning an event. Incomplete sequences should be buffered
- in the state and completed on subsequent calls.
-
- ## Examples
-
- # Non-blocking poll
- {result, new_state} = MyInput.poll(state, 0)
-
- # Wait up to 100ms
- {result, new_state} = MyInput.poll(state, 100)
-
- # Process result
- case result do
- {:ok, %Event.Key{key: :enter}} -> handle_enter()
- {:ok, %Event.Key{key: :up}} -> handle_up()
- :timeout -> continue_animation()
- :eof -> shutdown()
+ @type option :: {:timestamp, integer()} | {:modifiers, [atom()]}
+
+ @named_keys Map.merge(
+ %{
+ "arrowdown" => :down,
+ "arrowleft" => :left,
+ "arrowright" => :right,
+ "arrowup" => :up,
+ "backspace" => :backspace,
+ "delete" => :delete,
+ "down" => :down,
+ "end" => :end,
+ "enter" => :enter,
+ "esc" => :escape,
+ "escape" => :escape,
+ "home" => :home,
+ "insert" => :insert,
+ "left" => :left,
+ "pagedown" => :page_down,
+ "pageup" => :page_up,
+ "return" => :enter,
+ "right" => :right,
+ "tab" => :tab,
+ "up" => :up
+ },
+ Map.new(1..24, &{"f#{&1}", String.to_atom("f#{&1}")})
+ )
+
+ @modifier_names %{
+ alt: :alt,
+ command: :meta,
+ control: :ctrl,
+ ctrl: :ctrl,
+ meta: :meta,
+ option: :alt,
+ shift: :shift,
+ super: :super
+ }
+
+ @doc "Creates one text event and keeps all Unicode code points together."
+ @spec text(String.t(), [option()]) :: Event.Text.t()
+ def text(value, opts \\ []) do
+ validate_text!(value, false)
+ Event.text(value, timestamp_opts(opts))
+ end
+
+ @doc "Creates one text event for text that an input method has committed."
+ @spec composition(String.t(), [option()]) :: Event.Text.t()
+ def composition(value, opts \\ []) do
+ validate_text!(value, false)
+ Event.text(value, timestamp_opts(opts))
+ end
+
+ @doc "Creates one paste event without splitting or changing its content."
+ @spec paste(String.t(), [option()]) :: Event.Paste.t()
+ def paste(content, opts \\ []) do
+ validate_text!(content, true)
+ Event.paste(content, timestamp_opts(opts))
+ end
+
+ @doc "Creates a named-key event or a modified one-grapheme key event."
+ @spec special_key(atom() | String.t(), [option()]) :: Event.Key.t()
+ def special_key(key, opts \\ []) do
+ modifiers = opts |> Keyword.get(:modifiers, []) |> normalize_modifiers!()
+ key = normalize_key!(key, modifiers)
+ Event.key(key, Keyword.put(timestamp_opts(opts), :modifiers, modifiers))
+ end
+
+ defp normalize_key!(key, _modifiers) when is_atom(key), do: key
+
+ defp normalize_key!(key, modifiers) when is_binary(key) do
+ validate_text!(key, false)
+
+ case Map.fetch(@named_keys, normalize_key_name(key)) do
+ {:ok, named_key} ->
+ named_key
+
+ :error ->
+ if modifiers != [] and one_grapheme?(key) do
+ key
+ else
+ raise ArgumentError,
+ "unmodified printable input must use TermUI.Input.text/2"
+ end
+ end
+ end
+
+ defp normalize_key!(key, _modifiers) do
+ raise ArgumentError, "special key must be an atom or a string, got: #{inspect(key)}"
+ end
+
+ defp normalize_key_name(key) do
+ key
+ |> String.downcase()
+ |> String.replace(~r/[\s_-]/u, "")
+ end
+
+ defp normalize_modifiers!(modifiers) when is_list(modifiers) do
+ modifiers
+ |> Enum.map(fn modifier ->
+ case Map.fetch(@modifier_names, modifier) do
+ {:ok, normalized} -> normalized
+ :error -> raise ArgumentError, "unsupported input modifier: #{inspect(modifier)}"
end
- """
- @callback poll(state(), timeout :: non_neg_integer()) :: poll_result()
-
- @doc """
- Return the input mode for this handler.
-
- Returns `:raw` or `:tty` to indicate which mode the handler operates in.
- This allows components to adapt their behavior if needed, though most
- widgets work identically in both modes.
-
- ## Use Cases
-
- Most widgets do not need to check the mode—input events are normalized
- across both handlers. However, some specialized components might use this:
-
- - Displaying mode indicator in status bar
- - Adjusting behavior for mode-specific features
- - Debugging and logging
-
- ## Examples
-
- mode = MyInput.mode(state)
- # => :raw or :tty
- """
- @callback mode(state()) :: mode()
-
- @doc """
- Stop the input handler and release any resources.
-
- This callback is called during runtime shutdown to allow the handler
- to perform cleanup operations such as:
-
- - Restoring terminal IO options
- - Stopping any background processes
- - Closing file descriptors or ports
-
- The function should be idempotent—calling it multiple times should
- have the same effect as calling it once.
-
- ## Parameters
-
- - `state` - Handler-specific state
-
- ## Returns
-
- - `:ok` - Cleanup completed successfully
-
- ## Examples
-
- :ok = MyInput.stop(state)
-
- ## Implementation Notes
-
- - **Raw handler**: Typically a no-op since InputReader is managed separately
- - **TTY handler**: Should restore IO options (echo, binary mode)
- - Custom handlers: Implement any necessary cleanup
-
- This callback is always called during runtime shutdown, even if
- the handler was never successfully started or has already been
- stopped due to EOF.
- """
- @callback stop(state()) :: :ok
+ end)
+ |> Enum.uniq()
+ |> Enum.sort()
+ end
+
+ defp normalize_modifiers!(modifiers) do
+ raise ArgumentError, "input modifiers must be a list, got: #{inspect(modifiers)}"
+ end
+
+ defp validate_text!(value, allow_empty?) when is_binary(value) do
+ cond do
+ not String.valid?(value) ->
+ raise ArgumentError, "input text must be valid UTF-8"
+
+ value == "" and not allow_empty? ->
+ raise ArgumentError, "input text must not be empty"
+
+ true ->
+ :ok
+ end
+ end
+
+ defp validate_text!(value, _allow_empty?) do
+ raise ArgumentError, "input text must be a string, got: #{inspect(value)}"
+ end
+
+ defp one_grapheme?(value) do
+ case String.graphemes(value) do
+ [_grapheme] -> true
+ _other -> false
+ end
+ end
+
+ defp timestamp_opts(opts) do
+ case Keyword.fetch(opts, :timestamp) do
+ {:ok, timestamp} when is_integer(timestamp) ->
+ [timestamp: timestamp]
+
+ {:ok, timestamp} ->
+ raise ArgumentError, "input timestamp must be an integer: #{inspect(timestamp)}"
+
+ :error ->
+ []
+ end
+ end
end
diff --git a/lib/term_ui/input/line_reader.ex b/lib/term_ui/input/line_reader.ex
deleted file mode 100644
index 5ef0be66..00000000
--- a/lib/term_ui/input/line_reader.ex
+++ /dev/null
@@ -1,265 +0,0 @@
-defmodule TermUI.Input.LineReader do
- @moduledoc """
- Line-based input module for the `TextInput.Line` widget.
-
- This module provides line-oriented input using `IO.gets/1`, which enables
- shell line editing features. It is specifically designed for the `TextInput.Line`
- widget, where users enter free-form text and submit with Enter.
-
- > #### Not a Behaviour Implementation {: .info}
- >
- > Unlike `TermUI.Input.Raw` and `TermUI.Input.TTY`, this module does **not**
- > implement the `TermUI.Input` behaviour. It is a standalone utility module
- > for line-based input, not character-by-character polling. Use this module
- > directly when you need line input with shell editing; use the behaviour
- > implementations for immediate character input.
-
- ## When to Use LineReader
-
- Use `LineReader` when you need:
- - **Free-form text entry**: User types arbitrary text
- - **Shell line editing**: Backspace, cursor movement, etc.
- - **Submit on Enter**: Input is complete when user presses Enter
-
- Most TermUI widgets use character-by-character input (`Input.Raw` or `Input.TTY`)
- for immediate key response. Use `LineReader` only for text fields that benefit
- from shell editing.
-
- ## Security Considerations
-
- This module provides raw line input and does not perform sanitization:
-
- - **Input length**: No length limits are enforced by this module. The shell
- and terminal typically impose their own limits (commonly 4KB-128KB depending
- on configuration). If your application has specific length requirements,
- validate after reading. For concurrent usage, consider that each pending
- read could hold up to the shell's maximum line length in memory.
-
- - **Input sanitization**: Input is returned as-is from `IO.gets/1`. The
- application is responsible for any sanitization (escaping, filtering
- special characters, etc.) appropriate for its use case.
-
- - **No injection protection**: This module does not filter or escape input.
- If the input will be used in shell commands, SQL queries, or other
- security-sensitive contexts, proper escaping must be applied by the caller.
-
- - **Blocking I/O**: `read_line/1` blocks indefinitely until input is received.
- This could be exploited in a DoS scenario if many concurrent reads are
- started. For server applications, consider using timeouts at a higher level.
-
- ## Shell Line Editing Features
-
- When using `LineReader`, the shell provides (depending on terminal):
- - **Backspace**: Delete character before cursor
- - **Delete**: Delete character at cursor
- - **Left/Right arrows**: Move cursor within line
- - **Home/End**: Jump to start/end of line
- - **Ctrl+A/E**: Jump to start/end (Emacs-style)
- - **Ctrl+K**: Kill to end of line
- - **History**: Up/Down for command history (if shell supports)
-
- These features are provided by the shell, not by TermUI. The exact features
- available depend on the user's shell configuration.
-
- ## Usage
-
- # Simple line input
- case LineReader.read_line("Enter name: ") do
- {:ok, name} -> process_name(name)
- :eof -> handle_eof()
- end
-
- # With validation
- validator = fn input ->
- if String.length(input) >= 3 do
- :ok
- else
- {:error, "Name must be at least 3 characters"}
- end
- end
-
- case LineReader.read_line("Enter name: ", validator) do
- {:ok, name} -> process_name(name)
- {:error, reason} -> show_error(reason)
- :eof -> handle_eof()
- end
-
- ## Comparison with Character Input
-
- | Feature | LineReader | Input.Raw/TTY |
- |---------|------------|---------------|
- | Input style | Line-based | Character-by-character |
- | Submit | Enter key | Immediate |
- | Editing | Shell-provided | Application-handled |
- | Use case | TextInput.Line | Menu, PickList, etc. |
-
- ## Important Notes
-
- - **Blocking**: `read_line/1` blocks until the user presses Enter or EOF
- - **No timeout**: Cannot interrupt or timeout the read
- - **Raw mode**: If running in raw mode, line editing may not work as expected
- - **TTY only**: Best used with the TTY backend for full shell editing support
- - **Error handling**: IO errors from `IO.gets/1` are converted to `:eof` for
- simplified error handling. Most callers don't need to distinguish between
- "stream ended" and "read error" scenarios.
-
- ## TextInput.Line Widget
-
- This module is the input backend for `TextInput.Line`. The widget:
- 1. Displays a prompt and current value
- 2. Calls `LineReader.read_line/1` to get user input
- 3. Validates and processes the result
-
- For character-by-character text input with custom editing, use `TextInput`
- (without `.Line`) which uses `Input.Raw` or `Input.TTY`.
- """
-
- @typedoc """
- Result of a line read operation.
-
- - `{:ok, line}` - Successfully read a line (trimmed of trailing newline)
- - `:eof` - End of input stream
- """
- @type read_result :: {:ok, String.t()} | :eof
-
- @typedoc """
- Result of a validated line read operation.
-
- - `{:ok, value}` - Line was read and validation passed
- - `{:error, reason}` - Line was read but validation failed
- - `:eof` - End of input stream
- """
- @type validated_result :: {:ok, term()} | {:error, term()} | :eof
-
- @typedoc """
- Validator function for input validation.
-
- Should accept the trimmed input string and return:
- - `:ok` - Input is valid (original string is returned)
- - `{:ok, transformed}` - Input is valid, return transformed value
- - `{:error, reason}` - Input is invalid with given reason
- """
- @type validator :: (String.t() -> :ok | {:ok, term()} | {:error, term()})
-
- @doc """
- Reads a line of input with an optional prompt.
-
- Displays the prompt (if provided) and reads a complete line of input from
- stdin. The trailing newline is automatically trimmed from the result.
-
- ## Parameters
-
- - `prompt` - Optional prompt string to display (default: `""`)
-
- ## Returns
-
- - `{:ok, line}` - The line that was entered (without trailing newline)
- - `:eof` - End of input stream
-
- ## Examples
-
- # With prompt
- {:ok, name} = LineReader.read_line("Enter your name: ")
-
- # Without prompt
- {:ok, input} = LineReader.read_line()
-
- # Handling EOF
- case LineReader.read_line("Input: ") do
- {:ok, line} -> process(line)
- :eof -> shutdown()
- end
-
- ## Notes
-
- - This function blocks until the user presses Enter or EOF is received
- - Empty input (just Enter) returns `{:ok, ""}`
- - The prompt is written to stdout before reading
- """
- @spec read_line(String.t()) :: read_result()
- def read_line(prompt \\ "") do
- case IO.gets(prompt) do
- :eof ->
- :eof
-
- {:error, _reason} ->
- :eof
-
- line when is_binary(line) ->
- {:ok, String.trim_trailing(line, "\n")}
- end
- end
-
- @doc """
- Reads a line of input with validation.
-
- Displays the prompt, reads a line, and validates it using the provided
- validator function. The validator receives the trimmed input and should
- return validation status.
-
- ## Parameters
-
- - `prompt` - Prompt string to display
- - `validator` - Function to validate the input
-
- ## Validator Function
-
- The validator should accept a string and return one of:
- - `:ok` - Input is valid, return original string
- - `{:ok, transformed}` - Input is valid, return transformed value
- - `{:error, reason}` - Input is invalid
-
- ## Returns
-
- - `{:ok, value}` - Input was valid (original or transformed value)
- - `{:error, reason}` - Input was invalid
- - `:eof` - End of input stream
-
- ## Examples
-
- # Simple validation
- validator = fn input ->
- if String.length(input) > 0, do: :ok, else: {:error, "Cannot be empty"}
- end
- {:ok, name} = LineReader.read_line("Name: ", validator)
-
- # Transforming validation (parse to integer)
- int_validator = fn input ->
- case Integer.parse(input) do
- {num, ""} -> {:ok, num}
- _ -> {:error, "Must be a valid integer"}
- end
- end
- {:ok, age} = LineReader.read_line("Age: ", int_validator)
-
- # Regex validation
- email_validator = fn input ->
- if String.match?(input, ~r/^[^@]+@[^@]+\\.[^@]+$/) do
- :ok
- else
- {:error, "Invalid email format"}
- end
- end
- {:ok, email} = LineReader.read_line("Email: ", email_validator)
-
- ## Notes
-
- - Validation is only performed if a line was successfully read
- - EOF bypasses validation and returns `:eof` directly
- - The validator receives the trimmed input (no trailing newline)
- """
- @spec read_line(String.t(), validator()) :: validated_result()
- def read_line(prompt, validator) when is_function(validator, 1) do
- case read_line(prompt) do
- {:ok, line} ->
- case validator.(line) do
- :ok -> {:ok, line}
- {:ok, transformed} -> {:ok, transformed}
- {:error, reason} -> {:error, reason}
- end
-
- :eof ->
- :eof
- end
- end
-end
diff --git a/lib/term_ui/input/raw.ex b/lib/term_ui/input/raw.ex
deleted file mode 100644
index aa2de349..00000000
--- a/lib/term_ui/input/raw.ex
+++ /dev/null
@@ -1,377 +0,0 @@
-defmodule TermUI.Input.Raw do
- @moduledoc """
- Raw mode input handler implementing the `TermUI.Input` behaviour.
-
- This module provides synchronous input polling with timeout support for
- applications running with the Raw backend. It reads single characters
- from stdin and parses escape sequences into `TermUI.Event` structs.
-
- ## Features
-
- - **Non-blocking input**: Supports timeout-based polling (including 0ms for
- non-blocking checks)
- - **Escape sequence parsing**: Handles arrow keys, function keys, mouse events,
- and other terminal escape sequences
- - **Buffer management**: Maintains partial escape sequences between poll calls
- - **Security**: Buffer and queue size limits prevent memory exhaustion
-
- ## Usage
-
- # Create initial state
- state = TermUI.Input.Raw.new()
-
- # Poll for input with 100ms timeout
- case TermUI.Input.Raw.poll(state, 100) do
- {{:ok, event}, new_state} -> handle_event(event, new_state)
- {:timeout, new_state} -> handle_idle(new_state)
- {:eof, new_state} -> handle_shutdown(new_state)
- end
-
- ## How It Works
-
- The module spawns a Task to read from stdin using `:io.get_chars/2` (Erlang's
- IO module directly). This is critical for compatibility with raw mode activated
- via `:shell.start_interactive({:noshell, :raw})`, which redirects standard
- input. Elixir's `IO.getn/2` wrapper cannot access the redirected input, but
- `:io.get_chars/2` works correctly.
-
- Since `:io.get_chars/2` blocks until input is available, using a Task allows
- us to implement timeout semantics via `Task.yield/2`.
-
- When an escape sequence spans multiple reads (e.g., arrow keys send multiple
- bytes), the partial sequence is buffered and completed on subsequent polls.
-
- ## Escape Sequence Timeout
-
- When a partial escape sequence is detected (e.g., lone ESC), the handler waits
- up to 50ms for completion. This matches standard terminal emulator behavior
- and distinguishes ESC key presses from escape sequences. The 50ms timeout is
- the same value used by `TermUI.Terminal.InputReader`.
-
- ## Comparison with InputReader
-
- Unlike `TermUI.Terminal.InputReader` which is a GenServer that asynchronously
- sends events to a target process, this module provides synchronous polling
- suitable for use with the `TermUI.Input` behaviour interface. This module
- uses direct `:io.get_chars/2` calls wrapped in Tasks for timeout support, rather
- than delegating to InputReader, because InputReader's async message-based
- design is incompatible with the synchronous polling contract.
-
- Both modules use the same underlying approach (`:io.get_chars/2`) for reading
- from stdin, ensuring compatibility with raw mode's redirected input.
- """
-
- @behaviour TermUI.Input
-
- require Logger
-
- alias TermUI.Backend.InputBuffer
- alias TermUI.Event
- alias TermUI.Terminal.EscapeParser
-
- # Dialyzer: Functions return specific struct types
- # Dialyzer: emit_partial_escape/2 calls Event.key with string args for partial escape chars
- # Key.new/2 spec says atom() but the function works with strings too
- @dialyzer {:nowarn_function,
- new: 0,
- emit_partial_escape: 2,
- read_char: 0,
- poll: 2,
- handle_escape_timeout: 2,
- do_read_with_timeout: 2}
-
- # Escape sequence bytes
- @esc 0x1B
- @left_bracket ?[
- @letter_o ?O
-
- # Timeout for escape sequence completion (ms).
- # This matches terminal emulator behavior for distinguishing ESC key
- # presses from escape sequences. The same value is used by InputReader.
- @escape_timeout 50
-
- # Note: InputBuffer.apply_limit/2 uses its own limit (1KB) and truncates
- # to 256 bytes when exceeded. This provides security against memory
- # exhaustion from malformed escape sequences. We don't need a separate
- # buffer size constant here since InputBuffer handles the limiting.
-
- # Maximum event queue size to prevent memory exhaustion.
- @max_queue_size 1000
-
- defstruct buffer: <<>>,
- event_queue: []
-
- @typedoc """
- State for the Raw input handler.
-
- - `:buffer` - Binary buffer for partial escape sequences
- - `:event_queue` - Queue of parsed events waiting to be returned
- """
- @type t :: %__MODULE__{
- buffer: binary(),
- event_queue: [Event.t()]
- }
-
- @doc """
- Creates a new Raw input handler state.
-
- ## Examples
-
- state = TermUI.Input.Raw.new()
- """
- @spec new() :: t()
- def new do
- %__MODULE__{
- buffer: <<>>,
- event_queue: []
- }
- end
-
- @doc """
- Polls for input with the specified timeout.
-
- Reads input from stdin and parses it into events. The timeout specifies
- the maximum time to wait for input in milliseconds. Use 0 for non-blocking
- polls.
-
- ## Parameters
-
- - `state` - Current handler state
- - `timeout` - Maximum wait time in milliseconds
-
- ## Returns
-
- - `{{:ok, event}, new_state}` - An event was received
- - `{:timeout, new_state}` - No input within timeout
- - `{:eof, new_state}` - End of input stream
-
- ## Examples
-
- # Non-blocking check
- {result, state} = Raw.poll(state, 0)
-
- # Wait up to 100ms
- {result, state} = Raw.poll(state, 100)
- """
- @impl TermUI.Input
- @spec poll(t(), non_neg_integer()) :: TermUI.Input.poll_result()
- def poll(%__MODULE__{} = state, timeout) when is_integer(timeout) and timeout >= 0 do
- # First, check if we have queued events from a previous parse
- case state.event_queue do
- [event | rest] ->
- {{:ok, event}, %{state | event_queue: rest}}
-
- [] ->
- # Try to get an event from the buffer
- case try_parse_buffer(state) do
- {:ok, event, new_state} ->
- {{:ok, event}, new_state}
-
- :need_more ->
- # Need to read more input
- read_with_timeout(state, timeout)
- end
- end
- end
-
- @doc """
- Returns the input mode for this handler.
-
- Always returns `:raw` for the Raw input handler.
-
- ## Examples
-
- mode = Raw.mode(state)
- # => :raw
- """
- @impl TermUI.Input
- @spec mode(t()) :: :raw
- def mode(%__MODULE__{}), do: :raw
-
- @doc """
- Stops the Raw input handler.
-
- For the Raw handler, this is a no-op since the InputReader GenServer
- is managed separately by the Runtime. This function exists for
- compatibility with the `TermUI.Input` behaviour.
-
- ## Examples
-
- :ok = Raw.stop(state)
- """
- @impl TermUI.Input
- @spec stop(t()) :: :ok
- def stop(%__MODULE__{}), do: :ok
-
- # Private Functions
-
- # Try to parse a complete event from the buffer
- @spec try_parse_buffer(t()) :: {:ok, Event.t(), t()} | :need_more
- defp try_parse_buffer(%__MODULE__{buffer: <<>>}), do: :need_more
-
- defp try_parse_buffer(%__MODULE__{buffer: buffer} = state) do
- case EscapeParser.parse(buffer) do
- {[event | rest_events], remaining} ->
- # Got at least one event
- # Queue any additional events for subsequent polls (with size limit)
- queued_events = limit_queue(rest_events)
- new_state = %{state | buffer: remaining, event_queue: queued_events}
- {:ok, event, new_state}
-
- {[], _remaining} ->
- # No complete events yet, need more input
- :need_more
- end
- end
-
- # Limit queue size to prevent memory exhaustion
- @spec limit_queue([Event.t()]) :: [Event.t()]
- defp limit_queue(events) when length(events) <= @max_queue_size, do: events
-
- defp limit_queue(events) do
- Logger.warning(
- "Input.Raw: Event queue overflow, dropping #{length(events) - @max_queue_size} events"
- )
-
- Enum.take(events, @max_queue_size)
- end
-
- # Read input with timeout using a Task
- @spec read_with_timeout(t(), non_neg_integer()) :: TermUI.Input.poll_result()
- defp read_with_timeout(%__MODULE__{} = state, timeout) do
- # Check if we have a partial escape sequence that needs timeout handling
- if EscapeParser.partial_sequence?(state.buffer) and timeout > @escape_timeout do
- # Wait a short time for escape sequence completion
- handle_escape_timeout(state, timeout)
- else
- # Normal read with full timeout
- do_read_with_timeout(state, timeout)
- end
- end
-
- # Handle the case where we have a partial escape sequence
- @spec handle_escape_timeout(t(), non_neg_integer()) :: TermUI.Input.poll_result()
- defp handle_escape_timeout(%__MODULE__{} = state, timeout) do
- # First try to complete the escape sequence with a short timeout
- case do_read_with_timeout(state, @escape_timeout) do
- {:timeout, state_after_short} ->
- # Escape sequence didn't complete, emit what we have
- emit_partial_escape(state_after_short, timeout - @escape_timeout)
-
- # Success or EOF - return as-is
- result ->
- result
- end
- end
-
- # Emit partial escape sequence as individual key events
- @spec emit_partial_escape(t(), non_neg_integer()) :: TermUI.Input.poll_result()
- defp emit_partial_escape(%__MODULE__{buffer: buffer} = state, remaining_timeout) do
- events =
- cond do
- # Lone ESC
- buffer == <<@esc>> ->
- [Event.key(:escape)]
-
- # ESC[ without terminator
- buffer == <<@esc, @left_bracket>> ->
- [Event.key(:escape), Event.key("[", char: "[")]
-
- # ESC O without terminator
- buffer == <<@esc, @letter_o>> ->
- [Event.key(:escape), Event.key("O", char: "O")]
-
- # Other partial sequences starting with ESC
- String.starts_with?(buffer, <<@esc>>) ->
- <<@esc, rest::binary>> = buffer
- {rest_events, _} = EscapeParser.parse(rest)
- [Event.key(:escape) | rest_events]
-
- true ->
- []
- end
-
- case events do
- [event | rest] ->
- # Return first event, queue remaining events, clear buffer
- queued_events = limit_queue(rest)
- {{:ok, event}, %{state | buffer: <<>>, event_queue: queued_events}}
-
- [] ->
- # No events to emit, continue waiting with remaining timeout
- if remaining_timeout > 0 do
- do_read_with_timeout(%{state | buffer: <<>>}, remaining_timeout)
- else
- {:timeout, %{state | buffer: <<>>}}
- end
- end
- end
-
- # Perform the actual read with timeout
- @spec do_read_with_timeout(t(), non_neg_integer()) :: TermUI.Input.poll_result()
- defp do_read_with_timeout(%__MODULE__{} = state, timeout) do
- # Spawn a task to read input
- task = Task.async(fn -> read_char() end)
-
- # Use explicit Task.yield and Task.shutdown for clarity
- case Task.yield(task, timeout) do
- {:ok, {:ok, data}} ->
- # Got input, add to buffer with size limit and try to parse
- new_buffer = state.buffer <> data
- # InputBuffer.apply_limit uses rate-limited logging via the :source option
- {limited_buffer, _truncated} = InputBuffer.apply_limit(new_buffer, source: :input_raw)
-
- new_state = %{state | buffer: limited_buffer}
-
- case try_parse_buffer(new_state) do
- {:ok, event, final_state} ->
- {{:ok, event}, final_state}
-
- :need_more ->
- # Still need more, but we've used our timeout
- {:timeout, new_state}
- end
-
- {:ok, :eof} ->
- {:eof, state}
-
- {:ok, {:error, reason}} ->
- # Log IO errors at debug level for troubleshooting
- Logger.debug("Input.Raw: IO read error: #{inspect(reason)}")
- {:eof, state}
-
- nil ->
- # Timeout - no input received, shut down the task
- Task.shutdown(task)
- {:timeout, state}
- end
- end
-
- # Read a single character from stdin
- # Uses :io.get_chars/2 (Erlang's IO module) for compatibility with
- # :shell.start_interactive({:noshell, :raw}) which redirects standard input.
- # Elixir's IO.getn/2 cannot access the redirected input.
- @spec read_char() :: {:ok, binary()} | :eof | {:error, term()}
- defp read_char do
- case :io.get_chars(~c"", 1) do
- :eof ->
- :eof
-
- chars when is_list(chars) ->
- # Convert charlist to binary
- case :unicode.characters_to_binary(chars) do
- binary when is_binary(binary) ->
- {:ok, binary}
-
- :error ->
- {:error, :invalid_unicode}
- end
-
- {:error, reason} ->
- {:error, reason}
-
- other ->
- {:error, {:unexpected_io_return, other}}
- end
- end
-end
diff --git a/lib/term_ui/input/selector.ex b/lib/term_ui/input/selector.ex
deleted file mode 100644
index 89b30af1..00000000
--- a/lib/term_ui/input/selector.ex
+++ /dev/null
@@ -1,181 +0,0 @@
-defmodule TermUI.Input.Selector do
- @moduledoc """
- Selects the appropriate input handler based on the active backend mode.
-
- This module bridges the gap between backend selection and input handling,
- providing a way to choose the correct input handler for the current
- terminal mode.
-
- ## Relationship with Backend.Selector
-
- The `TermUI.Backend.Selector` determines which terminal backend to use
- (Raw or TTY). This module, `TermUI.Input.Selector`, then selects the
- corresponding input handler:
-
- | Backend Mode | Backend Module | Input Handler |
- |--------------|----------------|---------------|
- | `:raw` | `TermUI.Backend.Raw` | `TermUI.Input.Raw` |
- | `:tty` | `TermUI.Backend.TTY` | `TermUI.Input.TTY` |
-
- ## Usage
-
- There are two ways to select an input handler:
-
- ### Explicit Selection
-
- When you know which mode you want, use `select/1`:
-
- # Get the Raw input handler
- handler = TermUI.Input.Selector.select(:raw)
- # => TermUI.Input.Raw
-
- # Get the TTY input handler
- handler = TermUI.Input.Selector.select(:tty)
- # => TermUI.Input.TTY
-
- ### Auto-Detection
-
- When you want to match the current backend, use `select/0`:
-
- # Automatically select based on current backend
- handler = TermUI.Input.Selector.select()
- # => TermUI.Input.Raw or TermUI.Input.TTY
-
- ## State-Based Selection
-
- For runtime code that already has a `Backend.State` struct, you can
- extract the mode and pass it directly:
-
- backend_state = %TermUI.Backend.State{backend_mode: :tty, ...}
- handler = TermUI.Input.Selector.select(backend_state.backend_mode)
-
- ## Input Handler Contract
-
- Both `TermUI.Input.Raw` and `TermUI.Input.TTY` implement the `TermUI.Input`
- behaviour, providing a consistent interface:
-
- - `new/0` - Create initial handler state
- - `poll/2` - Poll for input with timeout
- - `mode/1` - Return the handler's mode (`:raw` or `:tty`)
-
- ## Example Integration
-
- # Typical usage in runtime initialization
- case TermUI.Backend.Selector.select() do
- {:raw, backend_state} ->
- input_handler = TermUI.Input.Selector.select(:raw)
- input_state = input_handler.new()
- # ...
-
- {:tty, capabilities} ->
- input_handler = TermUI.Input.Selector.select(:tty)
- input_state = input_handler.new()
- # ...
- end
-
- ## Note on LineReader
-
- `TermUI.Input.LineReader` is **not** included in the selector. LineReader
- is a specialized module for line-based input (used by `TextInput.Line`)
- and does not implement the `TermUI.Input` behaviour. Use LineReader
- directly when you need line-based input with shell editing.
- """
-
- @typedoc """
- Valid input mode atoms.
-
- - `:raw` - Select `TermUI.Input.Raw` for raw mode input
- - `:tty` - Select `TermUI.Input.TTY` for TTY mode input
- """
- @type mode :: :raw | :tty
-
- @typedoc """
- Input handler module that implements the `TermUI.Input` behaviour.
- """
- @type handler :: module()
-
- alias TermUI.Backend.Selector
- alias TermUI.Input.Raw
- alias TermUI.Input.TTY
-
- # Dialyzer: Functions return specific module types
- @dialyzer {:nowarn_function, select: 0, select: 1}
-
- @doc """
- Selects the appropriate input handler based on the current backend mode.
-
- This function auto-detects the current backend mode by attempting to
- determine whether raw mode is active. If detection cannot determine
- the mode, it defaults to TTY mode as the safer fallback.
-
- ## Returns
-
- - `TermUI.Input.Raw` if raw mode is active
- - `TermUI.Input.TTY` if TTY mode is active or mode cannot be determined
-
- ## Examples
-
- handler = TermUI.Input.Selector.select()
- state = handler.new()
- {result, state} = handler.poll(state, 100)
-
- ## Implementation Note
-
- This function uses `TermUI.Backend.Selector.select/0` to determine the
- current mode. This means it will attempt raw mode detection each time
- it's called. For performance, prefer using `select/1` with an explicit
- mode when the mode is already known from backend initialization.
- """
- @spec select() :: handler()
- def select do
- case Selector.select() do
- {:raw, _state} -> Raw
- {:tty, _capabilities} -> TTY
- end
- end
-
- @doc """
- Selects the input handler for the specified mode.
-
- This function provides explicit selection when the mode is already known,
- avoiding the overhead of backend detection.
-
- ## Arguments
-
- - `mode` - The input mode: `:raw` or `:tty`
-
- ## Returns
-
- - `TermUI.Input.Raw` for `:raw` mode
- - `TermUI.Input.TTY` for `:tty` mode
-
- ## Raises
-
- - `ArgumentError` if an invalid mode is provided
-
- ## Examples
-
- # Select Raw input handler
- handler = TermUI.Input.Selector.select(:raw)
- # => TermUI.Input.Raw
-
- # Select TTY input handler
- handler = TermUI.Input.Selector.select(:tty)
- # => TermUI.Input.TTY
-
- # Using with Backend.State
- backend_state = %TermUI.Backend.State{backend_mode: :tty, ...}
- handler = TermUI.Input.Selector.select(backend_state.backend_mode)
-
- # Invalid mode raises
- TermUI.Input.Selector.select(:invalid)
- # ** (ArgumentError) invalid input mode: :invalid, expected :raw or :tty
- """
- @spec select(mode()) :: handler()
- def select(:raw), do: Raw
- def select(:tty), do: TTY
-
- def select(mode) do
- raise ArgumentError, "invalid input mode: #{inspect(mode)}, expected :raw or :tty"
- end
-end
diff --git a/lib/term_ui/input/tty.ex b/lib/term_ui/input/tty.ex
deleted file mode 100644
index d016230c..00000000
--- a/lib/term_ui/input/tty.ex
+++ /dev/null
@@ -1,455 +0,0 @@
-defmodule TermUI.Input.TTY do
- @moduledoc """
- TTY mode input handler implementing the `TermUI.Input` behaviour.
-
- This module provides character-by-character input using `:io.get_chars/2`
- for IEx compatibility. The key to IEx compatibility is using Erlang's `:io`
- module directly instead of Elixir's `IO` module wrapper.
-
- ## Features
-
- - **IEx Compatible**: Uses `:io.get_chars/2` to bypass IEx's input interception
- - **Character-by-character input**: Single character reads work immediately
- - **Full keyboard support**: Arrow keys, Tab, Enter, function keys work normally
- - **Escape sequence parsing**: Handles arrow keys, function keys, mouse events,
- and other terminal escape sequences
- - **Buffer management**: Maintains partial escape sequences between poll calls
- - **Security**: Buffer and queue size limits prevent memory exhaustion
-
- ## IEx Compatibility
-
- The key to IEx compatibility is using `:io.get_chars/2` (Erlang) instead of
- `IO.getn/2` (Elixir). While both ultimately use the same IO server, the direct
- Erlang call behaves differently when running inside IEx, allowing TUI applications
- to receive keyboard input instead of having it stolen by IEx.
-
- This approach was verified in the `snake_test` project where TUI applications
- run correctly inside IEx using this method.
-
- ## How Arrow Keys and Special Keys Work
-
- A common misconception is that TTY mode requires Enter to submit input. This is
- only true for `IO.gets/1` (line-based input). Single character reads via
- `:io.get_chars/2` return immediately, so:
-
- - **Arrow keys**: Work normally (↑↓←→)
- - **Tab**: Works for field/button navigation
- - **Enter**: Detected immediately for selection
- - **Function keys**: F1-F12 work normally
- - **Ctrl combinations**: Ctrl+C, Ctrl+Z, etc. work
-
- This means most TUI widgets work **identically** in both Raw and TTY modes.
-
- ## Usage
-
- # Create initial state
- state = TermUI.Input.TTY.new()
-
- # Poll for input (timeout is noted but not honored - blocking I/O)
- case TermUI.Input.TTY.poll(state, 100) do
- {{:ok, event}, new_state} -> handle_event(event, new_state)
- {:eof, new_state} -> handle_shutdown(new_state)
- end
-
- ## Timeout Semantics
-
- **Important**: The timeout parameter is accepted for API compatibility but
- is **not honored** in TTY mode. `:io.get_chars/2` is blocking and will wait
- indefinitely for input. Design your application to handle this:
-
- - Don't rely on `:timeout` results for animations
- - Consider using a separate process for time-based updates
- - For timeout support, use the Raw backend instead
-
- ## Comparison with Raw Input Handler
-
- | Feature | TTY (`Input.TTY`) | Raw (`Input.Raw`) |
- |---------|-------------------|-------------------|
- | IEx Compatible | Yes | No |
- | Timeout support | No (blocking) | Yes (Task-based) |
- | Non-blocking poll | No | Yes |
- | Escape sequences | Yes | Yes |
- | Arrow/Tab/Enter | Yes | Yes |
- | Mouse events | Yes | Yes |
-
- ## When to Use TTY Mode
-
- TTY mode is appropriate when:
- - You want to run TUI applications inside IEx
- - You don't need timeout-based polling
- - You want simpler deployment (no raw mode setup)
- - Your application can block waiting for input
- - You're building simple interactive scripts
-
- For applications requiring animations, periodic updates, or non-blocking
- input checks, use the Raw backend with `Input.Raw` instead.
-
- ## Escape Sequence Handling
-
- When an escape sequence spans multiple reads (e.g., arrow keys send multiple
- bytes), the partial sequence is buffered and completed on subsequent polls.
-
- When a partial escape sequence is detected (e.g., lone ESC), the handler waits
- up to 100ms for completion using a blocking read. This matches standard terminal
- emulator behavior and distinguishes ESC key presses from escape sequences.
-
- ## Security
-
- This module implements several security measures to prevent resource exhaustion:
-
- - **Buffer size limit**: Input buffer is limited by `InputBuffer.apply_limit/2`
- (1KB max, truncates to 256 bytes when exceeded). This prevents memory
- exhaustion from malformed or malicious escape sequences.
-
- - **Event queue limit**: Maximum 1000 events can be queued. Excess events are
- dropped with a warning. This prevents memory exhaustion from rapid input.
-
- - **Rate-limited logging**: Buffer overflow warnings use rate-limited logging
- (via `InputBuffer`) to prevent log flooding attacks.
-
- - **Escape sequence timeout**: Partial sequences timeout after 100ms, preventing
- indefinite buffering of incomplete sequences.
-
- For concurrent usage, each handler instance maintains independent state, so
- memory usage scales linearly with the number of concurrent handlers.
- """
-
- @behaviour TermUI.Input
-
- require Logger
-
- alias TermUI.Backend.InputBuffer
- alias TermUI.Event
- alias TermUI.Terminal.EscapeParser
-
- # Dialyzer: Functions return specific struct types
- # Dialyzer: emit_partial_escape/1 calls Event.key with string args for partial escape chars
- # Key.new/2 spec says atom() but the function works with strings too
- @dialyzer {:nowarn_function,
- new: 0,
- stop: 1,
- emit_partial_escape: 1,
- restore_io_opts: 1,
- process_input: 2,
- poll: 2,
- setup_io_opts: 0,
- read_char: 0,
- handle_escape_timeout: 1}
-
- # Timeout for escape sequence completion (ms).
- # Matches snake_test's 100ms timeout for distinguishing ESC key presses.
- @escape_timeout 100
-
- # Maximum event queue size to prevent memory exhaustion.
- @max_queue_size 1000
-
- defstruct buffer: <<>>,
- event_queue: [],
- io_opts_restored: false,
- io_opts_set: false,
- original_opts: []
-
- @typedoc """
- State for the TTY input handler.
-
- - `:buffer` - Binary buffer for partial escape sequences
- - `:event_queue` - Queue of parsed events waiting to be returned
- - `:io_opts_restored` - Whether IO options have been restored
- - `:io_opts_set` - Whether IO options have been set
- """
- @type t :: %__MODULE__{
- buffer: binary(),
- event_queue: [Event.t()],
- io_opts_restored: boolean(),
- io_opts_set: boolean()
- }
-
- @doc """
- Creates a new TTY input handler state.
-
- Configures the IO server for TTY input (echo: false, binary: false).
-
- ## Examples
-
- state = TermUI.Input.TTY.new()
- """
- @spec new() :: t()
- def new do
- # Set IO options for IEx-compatible TTY input
- # We save the original options so we can restore them later
- original_opts = setup_io_opts()
-
- %__MODULE__{
- buffer: <<>>,
- event_queue: [],
- io_opts_set: true,
- io_opts_restored: false,
- original_opts: original_opts
- }
- end
-
- @doc """
- Polls for input.
-
- **Note**: The timeout parameter is accepted for API compatibility but is
- **not honored** in TTY mode. `:io.get_chars/2` is blocking and will wait
- indefinitely for input. This function will not return `:timeout` in normal operation.
-
- ## Parameters
-
- - `state` - Current handler state
- - `timeout` - Maximum wait time in milliseconds (ignored in TTY mode)
-
- ## Returns
-
- - `{{:ok, event}, new_state}` - An event was received
- - `{:eof, new_state}` - End of input stream
-
- ## Examples
-
- # Note: timeout is ignored, this will block until input
- {result, state} = TTY.poll(state, 100)
- """
- @impl TermUI.Input
- @spec poll(t(), non_neg_integer()) :: TermUI.Input.poll_result()
- def poll(%__MODULE__{} = state, timeout) when is_integer(timeout) and timeout >= 0 do
- # First, check if we have queued events from a previous parse
- case state.event_queue do
- [event | rest] ->
- {{:ok, event}, %{state | event_queue: rest}}
-
- [] ->
- # Try to get an event from the buffer
- case try_parse_buffer(state) do
- {:ok, event, new_state} ->
- {{:ok, event}, new_state}
-
- :need_more ->
- # Need to read more input (blocking)
- read_blocking(state)
- end
- end
- end
-
- @doc """
- Returns the input mode for this handler.
-
- Always returns `:tty` for the TTY input handler.
-
- ## Examples
-
- mode = TTY.mode(state)
- # => :tty
- """
- @impl TermUI.Input
- @spec mode(t()) :: :tty
- def mode(%__MODULE__{}), do: :tty
-
- @doc """
- Stops the TTY input handler and restores IO options.
-
- ## Examples
-
- :ok = TTY.stop(state)
- """
- @impl TermUI.Input
- @spec stop(t()) :: :ok
- def stop(%__MODULE__{original_opts: original_opts}) do
- restore_io_opts(original_opts)
- # Ensure echo is enabled after stopping (critical for IEx compatibility)
- # We do this unconditionally because IEx always needs echo on
- :io.setopts(echo: true)
- :ok
- end
-
- # Private Functions
-
- defp setup_io_opts do
- # Save original options
- original = :io.getopts() |> Keyword.take([:echo, :binary])
-
- # Set options for TTY input (like snake_test does)
- # binary: false means :io.get_chars returns charlists
- :io.setopts(echo: false, binary: false)
-
- original
- end
-
- defp restore_io_opts(original_opts) do
- # Restore echo and binary mode from original options
- # We use Keyword.get to safely extract values with defaults
- echo = Keyword.get(original_opts, :echo, true)
- binary = Keyword.get(original_opts, :binary, true)
- :io.setopts(echo: echo, binary: binary)
- end
-
- # Try to parse a complete event from the buffer
- @spec try_parse_buffer(t()) :: {:ok, Event.t(), t()} | :need_more
- defp try_parse_buffer(%__MODULE__{buffer: <<>>}), do: :need_more
-
- defp try_parse_buffer(%__MODULE__{buffer: buffer} = state) do
- case EscapeParser.parse(buffer) do
- {[event | rest_events], remaining} ->
- # Got at least one event
- # Queue any additional events for subsequent polls (with size limit)
- queued_events = limit_queue(rest_events)
- new_state = %{state | buffer: remaining, event_queue: queued_events}
- {:ok, event, new_state}
-
- {[], _remaining} ->
- # No complete events yet, need more input
- :need_more
- end
- end
-
- # Limit queue size to prevent memory exhaustion
- @spec limit_queue([Event.t()]) :: [Event.t()]
- defp limit_queue(events) when length(events) <= @max_queue_size, do: events
-
- defp limit_queue(events) do
- Logger.warning(
- "Input.TTY: Event queue overflow, dropping #{length(events) - @max_queue_size} events"
- )
-
- Enum.take(events, @max_queue_size)
- end
-
- # Read input with blocking I/O
- @spec read_blocking(t()) :: TermUI.Input.poll_result()
- defp read_blocking(%__MODULE__{} = state) do
- # Check if we have a partial escape sequence that needs timeout handling
- if EscapeParser.partial_sequence?(state.buffer) do
- # Wait a short time for escape sequence completion
- handle_escape_timeout(state)
- else
- # Normal blocking read
- do_read_blocking(state)
- end
- end
-
- # Handle the case where we have a partial escape sequence
- @spec handle_escape_timeout(t()) :: TermUI.Input.poll_result()
- defp handle_escape_timeout(%__MODULE__{} = state) do
- # For TTY mode, we use a Task with short timeout to check for sequence completion
- task = Task.async(fn -> read_char() end)
-
- case Task.yield(task, @escape_timeout) do
- {:ok, {:ok, data}} ->
- # Got more input, add to buffer and try to parse
- process_input(state, data)
-
- {:ok, :eof} ->
- {:eof, state}
-
- {:ok, {:error, reason}} ->
- Logger.debug("Input.TTY: IO read error: #{inspect(reason)}")
- {:eof, state}
-
- nil ->
- # Timeout - escape sequence didn't complete, emit partial
- Task.shutdown(task)
- emit_partial_escape(state)
- end
- end
-
- # Emit partial escape sequence as individual key events
- @spec emit_partial_escape(t()) :: TermUI.Input.poll_result()
- defp emit_partial_escape(%__MODULE__{buffer: buffer} = state) do
- events =
- cond do
- # Lone ESC
- buffer == <<27>> ->
- [Event.key(:escape)]
-
- # ESC[ without terminator
- buffer == <<27, ?[>> ->
- [Event.key(:escape), Event.key("[", char: "[")]
-
- # ESC O without terminator
- buffer == <<27, ?O>> ->
- [Event.key(:escape), Event.key("O", char: "O")]
-
- # Other partial sequences starting with ESC
- String.starts_with?(buffer, <<27>>) ->
- <<27, rest::binary>> = buffer
- {rest_events, _} = EscapeParser.parse(rest)
- [Event.key(:escape) | rest_events]
-
- true ->
- []
- end
-
- case events do
- [event | rest] ->
- # Return first event, queue rest, clear buffer
- {{:ok, event}, %{state | buffer: <<>>, event_queue: rest}}
-
- [] ->
- # No events to emit, continue with blocking read
- do_read_blocking(%{state | buffer: <<>>})
- end
- end
-
- # Perform the actual blocking read
- @spec do_read_blocking(t()) :: TermUI.Input.poll_result()
- defp do_read_blocking(%__MODULE__{} = state) do
- case read_char() do
- {:ok, data} ->
- process_input(state, data)
-
- :eof ->
- {:eof, state}
-
- {:error, reason} ->
- Logger.debug("Input.TTY: IO read error: #{inspect(reason)}")
- {:eof, state}
- end
- end
-
- # Process input data and try to parse
- @spec process_input(t(), binary()) :: TermUI.Input.poll_result()
- defp process_input(%__MODULE__{} = state, data) do
- new_buffer = state.buffer <> data
- # InputBuffer.apply_limit uses rate-limited logging via the :source option
- {limited_buffer, _truncated} = InputBuffer.apply_limit(new_buffer, source: :input_tty)
-
- new_state = %{state | buffer: limited_buffer}
-
- case try_parse_buffer(new_state) do
- {:ok, event, final_state} ->
- {{:ok, event}, final_state}
-
- :need_more ->
- # Still need more, continue reading
- read_blocking(new_state)
- end
- end
-
- # Read a single character from stdin using :io.get_chars/2
- # This is the key to IEx compatibility - using Erlang's :io module directly
- @spec read_char() :: {:ok, binary()} | :eof | {:error, term()}
- defp read_char do
- result = :io.get_chars(~c"", 1)
-
- case result do
- :eof ->
- :eof
-
- chars when is_list(chars) ->
- # Convert charlist to binary
- case :unicode.characters_to_binary(chars) do
- binary when is_binary(binary) ->
- {:ok, binary}
-
- :error ->
- {:error, :invalid_unicode}
- end
-
- {:error, reason} ->
- {:error, reason}
-
- other ->
- {:error, {:unexpected_io_return, other}}
- end
- end
-end
diff --git a/lib/term_ui/input/tty_server.ex b/lib/term_ui/input/tty_server.ex
deleted file mode 100644
index b32f6526..00000000
--- a/lib/term_ui/input/tty_server.ex
+++ /dev/null
@@ -1,339 +0,0 @@
-defmodule TermUI.Input.TTY.Server do
- @moduledoc """
- GenServer that manages IEx-compatible TTY input using a separate process.
-
- This server spawns a separate process that continuously polls for input using
- `:io.get_chars/2`. This approach allows TUI applications to work correctly
- inside IEx, bypassing IEx's input interception.
-
- ## Architecture
-
- The server manages a spawned process that:
- 1. Continuously polls with `receive after 0` for non-blocking behavior
- 2. Calls `:io.get_chars("", 1)` to read single characters
- 3. Parses escape sequences and converts charlists to binaries
- 4. Sends parsed key events as messages to the server
-
- The server maintains:
- - A queue of parsed events waiting to be delivered
- - The original IO options (for restoration on shutdown)
- - The spawned input process PID
-
- ## Usage
-
- {:ok, server} = TermUI.Input.TTY.Server.start_link(receiver: self())
- {:ok, event} = TermUI.Input.TTY.Server.poll(server, 100)
- :ok = TermUI.Input.TTY.Server.stop(server)
-
- ## IO Server Configuration
-
- The server configures the IO server on startup:
- - Saves original options via `:io.getopts/0`
- - Sets `echo: false` to disable character echo
- - Sets `binary: false` so `:io.get_chars/2` returns charlists
-
- On termination, it restores the original options.
- """
-
- use GenServer
- require Logger
-
- alias TermUI.Terminal.EscapeParser
-
- # Dialyzer: Complex guard patterns in handle_escape_timeout/3 and parse_buffer/1
- # Dialyzer: input_loop/4 is called in spawn, Dialyzer cannot prove safety
- @dialyzer {:nowarn_function,
- handle_escape_timeout: 3,
- parse_buffer: 1,
- input_loop: 4,
- handle_info: 2,
- terminate: 2,
- setup_io_opts: 0,
- restore_io_opts: 1}
-
- @escape_timeout 100
- @max_queue_size 1000
-
- defstruct event_queue: [],
- buffer: <<>>,
- original_opts: nil,
- input_pid: nil,
- receiver: nil
-
- # Client API
-
- @doc """
- Starts the TTY input server.
-
- ## Options
-
- - `:receiver` - PID to send key events to (defaults to `self()`)
- - `:name` - Name for GenServer registration (optional)
-
- ## Examples
-
- {:ok, server} = TermUI.Input.TTY.Server.start_link()
- {:ok, server} = TermUI.Input.TTY.Server.start_link(receiver: some_pid)
- """
- def start_link(opts \\ []) do
- {gen_opts, opts} = Keyword.split(opts, [:name])
- GenServer.start_link(__MODULE__, opts, gen_opts)
- end
-
- @doc """
- Stops the TTY input server.
- """
- def stop(server, reason \\ :normal, timeout \\ 5000) do
- GenServer.stop(server, reason, timeout)
- end
-
- @doc """
- Polls for a key event.
-
- Returns the next queued event, or waits for one if none is available.
- The timeout is in milliseconds.
-
- ## Returns
-
- - `{:ok, event}` - A key event was received
- - `{:error, :eof}` - End of input stream
- - `{:error, :timeout}` - No event within timeout (rare in TTY mode)
-
- ## Examples
-
- case TermUI.Input.TTY.Server.poll(server, 100) do
- {:ok, %Event.Key{} = event} -> handle_key(event)
- {:error, :eof} -> handle_shutdown()
- end
- """
- def poll(server, timeout \\ 100) do
- GenServer.call(server, {:poll, timeout}, timeout + 100)
- end
-
- @doc """
- Returns the current event queue size.
- """
- def queue_size(server) do
- GenServer.call(server, :queue_size)
- end
-
- # Server Callbacks
-
- @impl true
- def init(opts) do
- receiver = Keyword.get(opts, :receiver)
-
- # Save original IO options and configure for TTY input
- original_opts = setup_io_opts()
-
- # Spawn the input process
- input_pid = spawn_input_process(self(), receiver)
-
- state = %__MODULE__{
- original_opts: original_opts,
- input_pid: input_pid,
- receiver: receiver,
- buffer: <<>>,
- event_queue: []
- }
-
- {:ok, state}
- end
-
- @impl true
- def handle_call({:poll, _timeout}, _from, %__MODULE__{} = state) do
- case state.event_queue do
- [event | rest] ->
- {:reply, {:ok, event}, %{state | event_queue: rest}}
-
- [] ->
- # Check if input process is still alive
- if Process.alive?(state.input_pid) do
- # No events queued, wait a bit and check again
- # In TTY mode, we typically block, so we'll tell caller to try again
- # or wait for next message
- {:reply, {:error, :no_event}, state}
- else
- # Input process died, likely EOF
- {:reply, {:error, :eof}, state}
- end
- end
- end
-
- @impl true
- def handle_call(:queue_size, _from, state) do
- {:reply, length(state.event_queue), state}
- end
-
- @impl true
- def handle_cast({:input, data}, state) do
- # Process input data from the input process
- new_state = process_input_data(state, data)
- {:noreply, new_state}
- end
-
- @impl true
- def handle_cast(:eof, state) do
- # Input process reached EOF
- {:noreply, state}
- end
-
- @impl true
- def handle_info({:input_event, event}, state) do
- # Direct event from input process (for immediate delivery)
- new_queue = limit_queue(state.event_queue ++ [event])
- {:noreply, %{state | event_queue: new_queue}}
- end
-
- @impl true
- def handle_info({:DOWN, _ref, :process, pid, reason}, %{input_pid: pid} = state) do
- # Input process died
- Logger.debug("TTY.Server: Input process died: #{inspect(reason)}")
- {:noreply, state}
- end
-
- @impl true
- def terminate(_reason, state) do
- # Stop input process
- if state.input_pid && Process.alive?(state.input_pid) do
- Process.exit(state.input_pid, :stop)
- end
-
- # Restore original IO options
- if state.original_opts do
- restore_io_opts(state.original_opts)
- end
-
- :ok
- end
-
- # Private Functions
-
- defp setup_io_opts do
- # Save original options
- original = :io.getopts() |> Keyword.take([:echo, :binary])
-
- # Set options for TTY input
- :io.setopts(echo: false, binary: false)
-
- original
- end
-
- defp restore_io_opts(original) do
- :io.setopts(original)
- end
-
- defp spawn_input_process(server, receiver) do
- spawn(fn ->
- input_loop(server, receiver, <<>>, System.monotonic_time(:millisecond))
- end)
- end
-
- # Input loop - runs in separate process
- # Inspired by snake_test's TUI.loop/3
- defp input_loop(server, receiver, buffer, last_read_time) do
- receive do
- :stop ->
- :ok
- after
- 0 ->
- # Try to read input
- case :io.get_chars(~c"", 1) do
- :eof ->
- # End of input
- GenServer.cast(server, :eof)
- :ok
-
- chars when is_list(chars) ->
- now = System.monotonic_time(:millisecond)
- dt = now - last_read_time
-
- # Handle escape sequence timeout
- {new_buffer, events} = handle_escape_timeout(buffer, chars, dt)
-
- # Parse complete sequences
- {remaining_buffer, parsed_events} = parse_buffer(new_buffer)
-
- # Combine events from timeout handling and parsing
- all_events = events ++ parsed_events
-
- # Send events to receiver
- Enum.each(all_events, fn event ->
- send(receiver, {:input_event, event})
- end)
-
- # Also queue them in the server for poll/2
- if all_events != [] do
- GenServer.cast(server, {:input, all_events})
- end
-
- input_loop(server, receiver, remaining_buffer, now)
-
- other ->
- Logger.debug("TTY.Server: Unexpected input: #{inspect(other)}")
- input_loop(server, receiver, buffer, System.monotonic_time(:millisecond))
- end
- end
- end
-
- # Handle escape sequence timeout (similar to snake_test timeout/3)
- # If we get another ESC quickly (<100ms), it's an ESC key press
- # If we get other chars, accumulate them for parsing
- defp handle_escape_timeout(buffer, chars, dt) when dt < @escape_timeout do
- # Within timeout window, just accumulate
- {buffer ++ chars, []}
- end
-
- defp handle_escape_timeout(~c"\e", ~c"\e", _dt) do
- # Two ESC presses - emit ESC key
- {~c"\e", [:escape]}
- end
-
- defp handle_escape_timeout(~c"\e", chars, _dt) do
- # ESC followed by other chars - start of escape sequence
- {~c"\e" ++ chars, []}
- end
-
- defp handle_escape_timeout(buffer, chars, _dt) do
- # Normal case - just accumulate
- {buffer ++ chars, []}
- end
-
- # Parse buffer for complete sequences
- defp parse_buffer(charlist) do
- # Convert charlist to binary for parsing
- binary = :unicode.characters_to_binary(charlist)
-
- case EscapeParser.parse(binary) do
- {[event | rest_events], remaining} ->
- # Got at least one event
- # Convert remaining back to charlist
- remaining_charlist = :unicode.characters_to_list(remaining)
- {remaining_charlist, [event | rest_events]}
-
- {[], _remaining} ->
- # No complete events yet
- {charlist, []}
- end
- end
-
- defp limit_queue(events) when length(events) <= @max_queue_size, do: events
-
- defp limit_queue(events) do
- Logger.warning(
- "TTY.Server: Event queue overflow, dropping #{length(events) - @max_queue_size} events"
- )
-
- Enum.take(events, @max_queue_size)
- end
-
- defp process_input_data(state, events) when is_list(events) do
- new_queue = limit_queue(state.event_queue ++ events)
- %{state | event_queue: new_queue}
- end
-
- defp process_input_data(state, event) do
- new_queue = limit_queue(state.event_queue ++ [event])
- %{state | event_queue: new_queue}
- end
-end
diff --git a/lib/term_ui/layout.ex b/lib/term_ui/layout.ex
new file mode 100644
index 00000000..00eca1c1
--- /dev/null
+++ b/lib/term_ui/layout.ex
@@ -0,0 +1,490 @@
+defmodule TermUI.Layout do
+ @moduledoc """
+ Pure rectangle allocation and frame placement.
+
+ Layout rectangles use zero-based coordinates because mouse events use the
+ same coordinate system. `place/3` converts them to the one-based coordinates
+ used by `TermUI.Frame.overlay/4`.
+
+ A track can be fixed, fill, weighted, percentage-based, or bounded. Use
+ `content/2` when the application has measured a child's content size.
+ """
+
+ alias TermUI.{Frame, Mouse}
+ alias TermUI.Mouse.Region
+
+ @type rect ::
+ {x :: non_neg_integer(), y :: non_neg_integer(), width :: non_neg_integer(),
+ height :: non_neg_integer()}
+ @type maximum :: non_neg_integer() | :infinity
+ @type base_track ::
+ non_neg_integer()
+ | :fill
+ | {:weight, pos_integer() | float()}
+ | {:percentage, number()}
+ @type track :: base_track() | {:bounded, base_track(), non_neg_integer(), maximum()}
+ @type padding ::
+ non_neg_integer()
+ | {non_neg_integer(), non_neg_integer(), non_neg_integer(), non_neg_integer()}
+
+ defguardp is_rect(x, y, width, height)
+ when is_integer(x) and x >= 0 and is_integer(y) and y >= 0 and is_integer(width) and
+ width >= 0 and is_integer(height) and height >= 0
+
+ @doc "Creates a root rectangle from frame dimensions."
+ @spec new({non_neg_integer(), non_neg_integer()}) ::
+ {0, 0, non_neg_integer(), non_neg_integer()}
+ def new({width, height})
+ when is_integer(width) and width >= 0 and is_integer(height) and height >= 0,
+ do: {0, 0, width, height}
+
+ @doc "Returns a rectangle's frame dimensions."
+ @spec dimensions(rect()) :: {non_neg_integer(), non_neg_integer()}
+ def dimensions({_x, _y, width, height}), do: {width, height}
+
+ @doc "Creates a fixed-size track. A non-negative integer is the direct form."
+ @spec fixed(non_neg_integer()) :: non_neg_integer()
+ def fixed(size), do: non_negative_integer!(size, :size)
+
+ @doc "Creates a fill track. The direct form is `:fill`."
+ @spec fill() :: :fill
+ def fill, do: :fill
+
+ @doc "Creates a percentage track from 0 through 100 percent."
+ @spec percentage(number()) :: {:percentage, number()}
+ def percentage(value) when is_number(value) and value >= 0 and value <= 100,
+ do: {:percentage, value}
+
+ def percentage(value),
+ do:
+ raise(
+ ArgumentError,
+ "percentage must be a number from 0 through 100, got: #{inspect(value)}"
+ )
+
+ @doc "Creates a weighted track. This is the v2 replacement for a ratio constraint."
+ @spec ratio(pos_integer() | float()) :: {:weight, pos_integer() | float()}
+ def ratio(value) when is_number(value) and value > 0, do: {:weight, value}
+
+ def ratio(value),
+ do: raise(ArgumentError, "ratio must be a positive number, got: #{inspect(value)}")
+
+ @doc "Adds optional `:min` and `:max` cell bounds to a track."
+ @spec bounded(base_track(), keyword()) ::
+ {:bounded, base_track(), non_neg_integer(), maximum()}
+ def bounded(track, opts \\ []) when is_list(opts) do
+ validate_bound_options!(opts)
+ minimum = opts |> Keyword.get(:min, 0) |> non_negative_integer!(:min)
+ maximum = opts |> Keyword.get(:max, :infinity) |> maximum!(:max)
+
+ if maximum != :infinity and minimum > maximum do
+ raise ArgumentError, "minimum size cannot be greater than maximum size"
+ end
+
+ :ok = validate_track!(track)
+ {:bounded, track, minimum, maximum}
+ end
+
+ @doc "Creates a fixed track from measured content with optional cell bounds."
+ @spec content(non_neg_integer(), keyword()) ::
+ {:bounded, non_neg_integer(), non_neg_integer(), maximum()}
+ def content(measured_size, opts \\ []) do
+ measured_size
+ |> fixed()
+ |> bounded(opts)
+ end
+
+ @doc "Insets a rectangle by uniform or per-side padding."
+ @spec inset(rect(), padding()) :: rect()
+ def inset(rect, padding) when is_integer(padding) and padding >= 0,
+ do: inset(rect, {padding, padding, padding, padding})
+
+ def inset({x, y, width, height}, {top, right, bottom, left})
+ when is_rect(x, y, width, height) and is_integer(top) and top >= 0 and is_integer(right) and
+ right >= 0 and is_integer(bottom) and bottom >= 0 and is_integer(left) and left >= 0 do
+ inset_x = min(left, width)
+ inset_y = min(top, height)
+
+ {
+ x + inset_x,
+ y + inset_y,
+ max(width - left - right, 0),
+ max(height - top - bottom, 0)
+ }
+ end
+
+ @doc "Returns a child rectangle clipped to its parent."
+ @spec at(rect(), {non_neg_integer(), non_neg_integer()}, {non_neg_integer(), non_neg_integer()}) ::
+ rect()
+ def at({x, y, width, height}, {child_x, child_y}, {child_width, child_height})
+ when is_rect(x, y, width, height) and is_integer(child_x) and child_x >= 0 and
+ is_integer(child_y) and child_y >= 0 and is_integer(child_width) and child_width >= 0 and
+ is_integer(child_height) and child_height >= 0 do
+ offset_x = min(child_x, width)
+ offset_y = min(child_y, height)
+
+ {
+ x + offset_x,
+ y + offset_y,
+ min(child_width, max(width - offset_x, 0)),
+ min(child_height, max(height - offset_y, 0))
+ }
+ end
+
+ @doc "Allocates horizontal tracks inside a rectangle."
+ @spec row(rect(), [track()], keyword()) :: [rect()]
+ def row({x, y, width, height}, tracks, opts \\ [])
+ when is_rect(x, y, width, height) and is_list(tracks) do
+ gap = opts |> Keyword.get(:gap, 0) |> non_negative_integer!(:gap)
+ sizes = allocate(tracks, width, gap)
+ boundary = x + width
+
+ sizes
+ |> Enum.map_reduce(x, fn size, cursor ->
+ start = min(cursor, boundary)
+ size = min(size, boundary - start)
+ {{start, y, size, height}, min(start + size + gap, boundary)}
+ end)
+ |> elem(0)
+ end
+
+ @doc "Allocates vertical tracks inside a rectangle."
+ @spec column(rect(), [track()], keyword()) :: [rect()]
+ def column({x, y, width, height}, tracks, opts \\ [])
+ when is_rect(x, y, width, height) and is_list(tracks) do
+ gap = opts |> Keyword.get(:gap, 0) |> non_negative_integer!(:gap)
+ sizes = allocate(tracks, height, gap)
+ boundary = y + height
+
+ sizes
+ |> Enum.map_reduce(y, fn size, cursor ->
+ start = min(cursor, boundary)
+ size = min(size, boundary - start)
+ {{x, start, width, size}, min(start + size + gap, boundary)}
+ end)
+ |> elem(0)
+ end
+
+ @doc "Allocates a row-major grid with equal or explicit row and column tracks."
+ @spec grid(rect(), non_neg_integer(), keyword()) :: [rect()]
+ def grid(rect, item_count, opts \\ [])
+
+ def grid({x, y, width, height}, 0, _opts) when is_rect(x, y, width, height), do: []
+
+ def grid({x, y, width, height}, item_count, opts)
+ when is_rect(x, y, width, height) and is_integer(item_count) and item_count > 0 do
+ column_gap =
+ opts
+ |> Keyword.get(:column_gap, Keyword.get(opts, :gap, 0))
+ |> non_negative_integer!(:column_gap)
+
+ row_gap =
+ opts
+ |> Keyword.get(:row_gap, Keyword.get(opts, :gap, 0))
+ |> non_negative_integer!(:row_gap)
+
+ if Keyword.has_key?(opts, :column_tracks) or Keyword.has_key?(opts, :row_tracks) do
+ constrained_grid(
+ {x, y, width, height},
+ item_count,
+ opts,
+ column_gap,
+ row_gap
+ )
+ else
+ equal_grid({x, y, width, height}, item_count, opts, column_gap, row_gap)
+ end
+ end
+
+ @doc "Places and clips a child frame inside a rectangle."
+ @spec place(Frame.t(), Frame.t(), rect()) :: Frame.t()
+ def place(%Frame{} = base, %Frame{}, {x, y, 0, height}) when is_rect(x, y, 0, height),
+ do: base
+
+ def place(%Frame{} = base, %Frame{}, {x, y, width, 0}) when is_rect(x, y, width, 0),
+ do: base
+
+ def place(%Frame{} = base, %Frame{} = child, {x, y, width, height})
+ when is_rect(x, y, width, height) do
+ width = min(width, max(base.width - x, 0))
+ height = min(height, max(base.height - y, 0))
+
+ if width == 0 or height == 0 do
+ base
+ else
+ clipped = Frame.overlay(Frame.new(width, height), child, 1, 1)
+ Frame.overlay(base, clipped, x + 1, y + 1)
+ end
+ end
+
+ @doc "Creates a mouse region from a non-empty rectangle."
+ @spec region(term(), rect(), keyword()) :: Region.t() | nil
+ def region(id, rect, opts \\ [])
+
+ def region(_id, {x, y, 0, height}, _opts) when is_rect(x, y, 0, height), do: nil
+ def region(_id, {x, y, width, 0}, _opts) when is_rect(x, y, width, 0), do: nil
+
+ def region(id, {x, y, width, height}, opts) when is_rect(x, y, width, height),
+ do: Mouse.region(id, x, y, width, height, opts)
+
+ defp allocate([], _length, _gap), do: []
+
+ defp allocate(tracks, length, gap) do
+ if Enum.any?(tracks, &constrained_track?/1),
+ do: allocate_constrained(tracks, length, gap),
+ else: allocate_existing(tracks, length, gap)
+ end
+
+ defp allocate_existing(tracks, length, gap) do
+ gap_total = gap * max(length(tracks) - 1, 0)
+ available = max(length - gap_total, 0)
+ fixed_total = Enum.reduce(tracks, 0, fn track, total -> total + fixed_size(track) end)
+ flexible_space = max(available - fixed_total, 0)
+ flexible_sizes = weighted_sizes(tracks, flexible_space)
+
+ {sizes, _remaining, _flexible_sizes} =
+ Enum.reduce(tracks, {[], available, flexible_sizes}, fn track,
+ {sizes, remaining, flexible_sizes} ->
+ if flexible?(track) do
+ [flexible_size | rest] = flexible_sizes
+ size = min(flexible_size, remaining)
+ {[size | sizes], remaining - size, rest}
+ else
+ size = min(fixed_size(track), remaining)
+ {[size | sizes], remaining - size, flexible_sizes}
+ end
+ end)
+
+ Enum.reverse(sizes)
+ end
+
+ defp allocate_constrained(tracks, length, gap) do
+ available = max(length - gap * max(length(tracks) - 1, 0), 0)
+ specs = Enum.map(tracks, &normalize_track!/1)
+ minimums = Enum.map(specs, & &1.min)
+
+ if Enum.sum(minimums) > available do
+ largest_remainder(minimums, available, Enum.sum(minimums))
+ else
+ {sizes, remaining} = allocate_requested_sizes(specs, minimums, available)
+ {sizes, _remaining} = allocate_bounded_flexible(specs, sizes, remaining)
+ sizes
+ end
+ end
+
+ defp allocate_requested_sizes(specs, sizes, available) do
+ remaining = available - Enum.sum(sizes)
+
+ specs
+ |> Enum.with_index()
+ |> Enum.reject(fn {spec, _index} -> spec.kind == :flexible end)
+ |> Enum.sort_by(fn {spec, index} -> {request_priority(spec), index} end)
+ |> Enum.reduce({sizes, remaining}, fn {spec, index}, {sizes, remaining} ->
+ requested = spec |> requested_size(available) |> clamp(spec.min, spec.max)
+ addition = min(max(requested - Enum.at(sizes, index), 0), remaining)
+ {List.update_at(sizes, index, &(&1 + addition)), remaining - addition}
+ end)
+ end
+
+ defp allocate_bounded_flexible(_specs, sizes, 0), do: {sizes, 0}
+
+ defp allocate_bounded_flexible(specs, sizes, remaining) do
+ active =
+ specs
+ |> Enum.with_index()
+ |> Enum.filter(fn {spec, index} ->
+ spec.kind == :flexible and below_maximum?(Enum.at(sizes, index), spec.max)
+ end)
+
+ if active == [] do
+ {sizes, remaining}
+ else
+ weights = Enum.map(active, fn {spec, _index} -> spec.weight end)
+ additions = largest_remainder(weights, remaining, Enum.sum(weights))
+
+ {sizes, consumed} =
+ active
+ |> Enum.zip(additions)
+ |> Enum.reduce({sizes, 0}, fn {{spec, index}, addition}, {sizes, consumed} ->
+ current = Enum.at(sizes, index)
+ addition = cap_addition(addition, current, spec.max)
+ {List.update_at(sizes, index, &(&1 + addition)), consumed + addition}
+ end)
+
+ if consumed == 0,
+ do: {sizes, remaining},
+ else: allocate_bounded_flexible(specs, sizes, remaining - consumed)
+ end
+ end
+
+ defp weighted_sizes(tracks, available) do
+ weights = tracks |> Enum.filter(&flexible?/1) |> Enum.map(&weight/1)
+
+ case Enum.sum(weights) do
+ total when total > 0 -> largest_remainder(weights, available, total)
+ _total -> []
+ end
+ end
+
+ defp largest_remainder(weights, available, total) do
+ shares = Enum.map(weights, &(available * &1 / total))
+ base = Enum.map(shares, &floor/1)
+ remainder = available - Enum.sum(base)
+
+ winners =
+ shares
+ |> Enum.with_index()
+ |> Enum.sort_by(fn {share, index} -> {-(share - floor(share)), index} end)
+ |> Enum.take(remainder)
+ |> MapSet.new(fn {_share, index} -> index end)
+
+ base
+ |> Enum.with_index()
+ |> Enum.map(fn {size, index} -> size + if(MapSet.member?(winners, index), do: 1, else: 0) end)
+ end
+
+ defp fixed_size(track) when is_integer(track) and track >= 0, do: track
+ defp fixed_size(_track), do: 0
+ defp flexible?(:fill), do: true
+ defp flexible?({:weight, weight}) when is_number(weight) and weight > 0, do: true
+ defp flexible?(_track), do: false
+ defp weight(:fill), do: 1.0
+ defp weight({:weight, weight}), do: weight * 1.0
+ defp ceil_div(value, divisor), do: div(value + divisor - 1, divisor)
+
+ defp constrained_grid(rect, item_count, opts, column_gap, row_gap) do
+ column_tracks =
+ grid_tracks(opts, :column_tracks, :columns, 2)
+
+ row_tracks =
+ grid_tracks(opts, :row_tracks, :rows, ceil_div(item_count, length(column_tracks)))
+
+ columns = length(column_tracks)
+ rows = length(row_tracks)
+ cell_count = min(item_count, columns * rows)
+ column_rects = row(rect, column_tracks, gap: column_gap)
+ row_rects = column(rect, row_tracks, gap: row_gap)
+
+ Enum.map(0..(cell_count - 1), fn index ->
+ {cell_x, _y, cell_width, _height} = Enum.at(column_rects, rem(index, columns))
+ {_x, cell_y, _width, cell_height} = Enum.at(row_rects, div(index, columns))
+ {cell_x, cell_y, cell_width, cell_height}
+ end)
+ end
+
+ defp equal_grid({x, y, width, height}, item_count, opts, column_gap, row_gap) do
+ columns = opts |> Keyword.get(:columns, 2) |> positive_integer!(:columns)
+ rows = opts |> Keyword.get(:rows, ceil_div(item_count, columns)) |> positive_integer!(:rows)
+ cell_count = min(item_count, columns * rows)
+
+ Enum.map(0..(cell_count - 1), fn index ->
+ column_index = rem(index, columns)
+ row_index = div(index, columns)
+ {cell_x, cell_width} = equal_track(x, width, columns, column_gap, column_index)
+ {cell_y, cell_height} = equal_track(y, height, rows, row_gap, row_index)
+ {cell_x, cell_y, cell_width, cell_height}
+ end)
+ end
+
+ defp grid_tracks(opts, tracks_key, count_key, default_count) do
+ case Keyword.fetch(opts, tracks_key) do
+ {:ok, [_track | _rest] = tracks} ->
+ Enum.each(tracks, &validate_track!/1)
+ tracks
+
+ {:ok, invalid} ->
+ raise ArgumentError,
+ "#{tracks_key} must be a non-empty track list, got: #{inspect(invalid)}"
+
+ :error ->
+ count = opts |> Keyword.get(count_key, default_count) |> positive_integer!(count_key)
+ List.duplicate(:fill, count)
+ end
+ end
+
+ defp constrained_track?({:percentage, _value}), do: true
+ defp constrained_track?({:bounded, _track, _minimum, _maximum}), do: true
+ defp constrained_track?(_track), do: false
+
+ defp validate_track!(track) do
+ _spec = normalize_track!(track)
+ :ok
+ end
+
+ defp normalize_track!({:bounded, track, minimum, maximum}) do
+ minimum = non_negative_integer!(minimum, :min)
+ maximum = maximum!(maximum, :max)
+ spec = normalize_base_track!(track)
+ minimum = max(minimum, spec.min)
+
+ if maximum != :infinity and minimum > maximum do
+ raise ArgumentError, "minimum size cannot be greater than maximum size"
+ end
+
+ %{spec | min: minimum, max: maximum}
+ end
+
+ defp normalize_track!(track),
+ do: normalize_base_track!(track)
+
+ defp normalize_base_track!(size) when is_integer(size) and size >= 0,
+ do: %{kind: :fixed, value: size, weight: 0.0, min: 0, max: :infinity}
+
+ defp normalize_base_track!(:fill),
+ do: %{kind: :flexible, value: 0, weight: 1.0, min: 0, max: :infinity}
+
+ defp normalize_base_track!({:weight, weight}) when is_number(weight) and weight > 0,
+ do: %{kind: :flexible, value: 0, weight: weight * 1.0, min: 0, max: :infinity}
+
+ defp normalize_base_track!({:percentage, value})
+ when is_number(value) and value >= 0 and value <= 100,
+ do: %{kind: :percentage, value: value, weight: 0.0, min: 0, max: :infinity}
+
+ defp normalize_base_track!(track),
+ do: raise(ArgumentError, "invalid layout track: #{inspect(track)}")
+
+ defp request_priority(%{kind: :fixed}), do: 0
+ defp request_priority(%{kind: :percentage}), do: 1
+
+ defp requested_size(%{kind: :fixed, value: value}, _available), do: value
+
+ defp requested_size(%{kind: :percentage, value: value}, available),
+ do: round(available * value / 100)
+
+ defp clamp(value, minimum, :infinity), do: max(value, minimum)
+ defp clamp(value, minimum, maximum), do: value |> max(minimum) |> min(maximum)
+ defp below_maximum?(_value, :infinity), do: true
+ defp below_maximum?(value, maximum), do: value < maximum
+ defp cap_addition(addition, _current, :infinity), do: addition
+ defp cap_addition(addition, current, maximum), do: min(addition, maximum - current)
+
+ defp validate_bound_options!(opts) do
+ case Keyword.keys(opts) -- [:min, :max] do
+ [] -> :ok
+ unknown -> raise ArgumentError, "unknown bound options: #{inspect(unknown)}"
+ end
+ end
+
+ defp equal_track(origin, length, count, gap, index) do
+ available = max(length - gap * max(count - 1, 0), 0)
+ size = div(available, count)
+ remainder = rem(available, count)
+ offset = index * (size + gap) + min(index, remainder)
+ track_size = size + if(index < remainder, do: 1, else: 0)
+ start = min(origin + offset, origin + length)
+ {start, min(track_size, origin + length - start)}
+ end
+
+ defp non_negative_integer!(value, _name) when is_integer(value) and value >= 0, do: value
+
+ defp non_negative_integer!(value, name),
+ do: raise(ArgumentError, "#{name} must be a non-negative integer, got: #{inspect(value)}")
+
+ defp positive_integer!(value, _name) when is_integer(value) and value > 0, do: value
+
+ defp positive_integer!(value, name),
+ do: raise(ArgumentError, "#{name} must be a positive integer, got: #{inspect(value)}")
+
+ defp maximum!(:infinity, _name), do: :infinity
+ defp maximum!(value, name), do: non_negative_integer!(value, name)
+end
diff --git a/lib/term_ui/layout/alignment.ex b/lib/term_ui/layout/alignment.ex
deleted file mode 100644
index 9dbcf9fd..00000000
--- a/lib/term_ui/layout/alignment.ex
+++ /dev/null
@@ -1,339 +0,0 @@
-defmodule TermUI.Layout.Alignment do
- @moduledoc """
- Flexbox-inspired alignment for positioning components within allocated space.
-
- ## Alignment Model
-
- - **Main axis**: Direction of layout (X for horizontal, Y for vertical)
- - **Cross axis**: Perpendicular to main axis
-
- ## Justify Content (Main Axis)
-
- - `:start` - Pack at beginning
- - `:center` - Center in space
- - `:end` - Pack at end
- - `:space_between` - Equal space between components
- - `:space_around` - Equal space around each component
-
- ## Align Items (Cross Axis)
-
- - `:start` - Position at cross-axis start
- - `:center` - Center on cross-axis
- - `:end` - Position at cross-axis end
- - `:stretch` - Expand to fill cross-axis
-
- ## Examples
-
- # Apply alignment to solved rects
- rects = Solver.solve_to_rects(constraints, area)
- aligned = Alignment.apply(rects, area,
- direction: :horizontal,
- justify: :space_between,
- align: :center
- )
-
- # With margins
- aligned = Alignment.apply_with_spacing(rects, area,
- direction: :horizontal,
- margin: %{top: 5, right: 5, bottom: 5, left: 5}
- )
- """
-
- @type rect :: %{x: integer(), y: integer(), width: integer(), height: integer()}
- @type direction :: :horizontal | :vertical
- @type justify :: :start | :center | :end | :space_between | :space_around
- @type align :: :start | :center | :end | :stretch
- @type spacing :: %{top: integer(), right: integer(), bottom: integer(), left: integer()}
-
- @type opts :: [
- direction: direction(),
- justify: justify(),
- align: align(),
- align_self: [align() | nil]
- ]
-
- # Public API
-
- @doc """
- Applies alignment to a list of rectangles within a container area.
-
- ## Parameters
-
- - `rects` - list of rectangles from solver
- - `area` - container bounding rectangle
- - `opts` - alignment options
- - `:direction` - `:horizontal` (default) or `:vertical`
- - `:justify` - main axis alignment (default `:start`)
- - `:align` - cross axis alignment (default `:start`)
- - `:align_self` - per-component cross axis overrides
-
- ## Returns
-
- List of aligned rectangles.
- """
- @spec apply([rect()], rect(), opts()) :: [rect()]
- def apply(rects, area, opts \\ []) do
- direction = Keyword.get(opts, :direction, :horizontal)
- justify = Keyword.get(opts, :justify, :start)
- align = Keyword.get(opts, :align, :start)
- align_self = Keyword.get(opts, :align_self, [])
-
- rects
- |> apply_justify(area, direction, justify)
- |> apply_align(area, direction, align, align_self)
- end
-
- @doc """
- Applies margin to rectangles, shrinking them.
-
- ## Parameters
-
- - `rects` - list of rectangles
- - `margins` - list of margin maps (one per rect) or single margin for all
-
- ## Returns
-
- List of rectangles with margins applied.
- """
- @spec apply_margins([rect()], [spacing()] | spacing()) :: [rect()]
- def apply_margins(rects, margins) when is_map(margins) do
- Enum.map(rects, &apply_margin(&1, margins))
- end
-
- def apply_margins(rects, margins) when is_list(margins) do
- rects
- |> Enum.zip(margins ++ List.duplicate(%{top: 0, right: 0, bottom: 0, left: 0}, length(rects)))
- |> Enum.map(fn {rect, margin} -> apply_margin(rect, margin) end)
- end
-
- @doc """
- Applies padding to a rectangle, shrinking the content area.
-
- ## Parameters
-
- - `rect` - rectangle to pad
- - `padding` - padding map
-
- ## Returns
-
- Rectangle with padding applied (position adjusted, size reduced).
- """
- @spec apply_padding(rect(), spacing()) :: rect()
- def apply_padding(rect, padding) do
- %{
- x: rect.x + padding.left,
- y: rect.y + padding.top,
- width: max(0, rect.width - padding.left - padding.right),
- height: max(0, rect.height - padding.top - padding.bottom)
- }
- end
-
- @doc """
- Parses spacing shorthand into a spacing map.
-
- ## Examples
-
- iex> Alignment.parse_spacing(10)
- %{top: 10, right: 10, bottom: 10, left: 10}
-
- iex> Alignment.parse_spacing({5, 10})
- %{top: 5, right: 10, bottom: 5, left: 10}
-
- iex> Alignment.parse_spacing({1, 2, 3, 4})
- %{top: 1, right: 2, bottom: 3, left: 4}
- """
- @spec parse_spacing(
- integer()
- | {integer(), integer()}
- | {integer(), integer(), integer(), integer()}
- ) :: spacing()
- def parse_spacing(value) when is_integer(value) do
- %{top: value, right: value, bottom: value, left: value}
- end
-
- def parse_spacing({vertical, horizontal}) do
- %{top: vertical, right: horizontal, bottom: vertical, left: horizontal}
- end
-
- def parse_spacing({top, right, bottom, left}) do
- %{top: top, right: right, bottom: bottom, left: left}
- end
-
- def parse_spacing(%{} = map) do
- %{
- top: Map.get(map, :top, 0),
- right: Map.get(map, :right, 0),
- bottom: Map.get(map, :bottom, 0),
- left: Map.get(map, :left, 0)
- }
- end
-
- # Justify (main axis) implementation
-
- defp apply_justify(rects, area, direction, :start) do
- {main_start, _main_size} = get_main_axis(area, direction)
- shift_main_axis(rects, main_start, direction)
- end
-
- defp apply_justify(rects, area, direction, :center) do
- {main_start, main_size} = get_main_axis(area, direction)
- total_content = total_main_size(rects, direction)
- offset = div(main_size - total_content, 2)
-
- shift_main_axis(rects, main_start + offset, direction)
- end
-
- defp apply_justify(rects, area, direction, :end) do
- {main_start, main_size} = get_main_axis(area, direction)
- total_content = total_main_size(rects, direction)
- offset = main_size - total_content
-
- shift_main_axis(rects, main_start + offset, direction)
- end
-
- defp apply_justify(rects, area, direction, :space_between) do
- count = length(rects)
-
- if count <= 1 do
- rects
- else
- {main_start, main_size} = get_main_axis(area, direction)
- total_content = total_main_size(rects, direction)
- total_space = main_size - total_content
- space_between = div(total_space, count - 1)
-
- distribute_with_spacing(rects, main_start, space_between, direction)
- end
- end
-
- defp apply_justify(rects, area, direction, :space_around) do
- count = length(rects)
-
- if count == 0 do
- rects
- else
- {main_start, main_size} = get_main_axis(area, direction)
- total_content = total_main_size(rects, direction)
- total_space = main_size - total_content
- space_unit = div(total_space, count * 2)
-
- # Start with half space, then full space between each
- distribute_with_around(rects, main_start + space_unit, space_unit * 2, direction)
- end
- end
-
- # Align (cross axis) implementation
-
- defp apply_align(rects, area, direction, align, align_self) do
- {cross_start, cross_size} = get_cross_axis(area, direction)
-
- rects
- |> Enum.with_index()
- |> Enum.map(fn {rect, idx} ->
- effective_align = Enum.at(align_self, idx) || align
- apply_single_align(rect, cross_start, cross_size, direction, effective_align)
- end)
- end
-
- defp apply_single_align(rect, cross_start, _cross_size, direction, :start) do
- set_rect_cross_pos(rect, cross_start, direction)
- end
-
- defp apply_single_align(rect, cross_start, cross_size, direction, :center) do
- rect_cross_size = get_rect_cross_size(rect, direction)
- offset = div(cross_size - rect_cross_size, 2)
- set_rect_cross_pos(rect, cross_start + offset, direction)
- end
-
- defp apply_single_align(rect, cross_start, cross_size, direction, :end) do
- rect_cross_size = get_rect_cross_size(rect, direction)
- offset = cross_size - rect_cross_size
- set_rect_cross_pos(rect, cross_start + offset, direction)
- end
-
- defp apply_single_align(rect, cross_start, cross_size, direction, :stretch) do
- rect
- |> set_rect_cross_pos(cross_start, direction)
- |> set_rect_cross_size(cross_size, direction)
- end
-
- # Helper functions
-
- defp get_main_axis(area, :horizontal), do: {area.x, area.width}
- defp get_main_axis(area, :vertical), do: {area.y, area.height}
-
- defp get_cross_axis(area, :horizontal), do: {area.y, area.height}
- defp get_cross_axis(area, :vertical), do: {area.x, area.width}
-
- defp get_rect_main_size(rect, :horizontal), do: rect.width
- defp get_rect_main_size(rect, :vertical), do: rect.height
-
- defp get_rect_cross_size(rect, :horizontal), do: rect.height
- defp get_rect_cross_size(rect, :vertical), do: rect.width
-
- defp set_rect_cross_pos(rect, pos, :horizontal), do: %{rect | y: pos}
- defp set_rect_cross_pos(rect, pos, :vertical), do: %{rect | x: pos}
-
- defp set_rect_cross_size(rect, size, :horizontal), do: %{rect | height: size}
- defp set_rect_cross_size(rect, size, :vertical), do: %{rect | width: size}
-
- defp total_main_size(rects, direction) do
- Enum.reduce(rects, 0, fn rect, acc ->
- acc + get_rect_main_size(rect, direction)
- end)
- end
-
- defp shift_main_axis(rects, start_pos, direction) do
- {shifted, _pos} =
- Enum.map_reduce(rects, start_pos, fn rect, pos ->
- new_rect =
- case direction do
- :horizontal -> %{rect | x: pos}
- :vertical -> %{rect | y: pos}
- end
-
- {new_rect, pos + get_rect_main_size(rect, direction)}
- end)
-
- shifted
- end
-
- defp distribute_with_spacing(rects, start_pos, spacing, direction) do
- {distributed, _pos} =
- Enum.map_reduce(rects, start_pos, fn rect, pos ->
- new_rect =
- case direction do
- :horizontal -> %{rect | x: pos}
- :vertical -> %{rect | y: pos}
- end
-
- {new_rect, pos + get_rect_main_size(rect, direction) + spacing}
- end)
-
- distributed
- end
-
- defp distribute_with_around(rects, start_pos, spacing, direction) do
- {distributed, _pos} =
- Enum.map_reduce(rects, start_pos, fn rect, pos ->
- new_rect =
- case direction do
- :horizontal -> %{rect | x: pos}
- :vertical -> %{rect | y: pos}
- end
-
- {new_rect, pos + get_rect_main_size(rect, direction) + spacing}
- end)
-
- distributed
- end
-
- defp apply_margin(rect, margin) do
- %{
- x: rect.x + margin.left,
- y: rect.y + margin.top,
- width: max(0, rect.width - margin.left - margin.right),
- height: max(0, rect.height - margin.top - margin.bottom)
- }
- end
-end
diff --git a/lib/term_ui/layout/cache.ex b/lib/term_ui/layout/cache.ex
deleted file mode 100644
index f13ad9b2..00000000
--- a/lib/term_ui/layout/cache.ex
+++ /dev/null
@@ -1,341 +0,0 @@
-defmodule TermUI.Layout.Cache do
- @moduledoc """
- Layout cache with LRU eviction for caching constraint solver results.
-
- The cache stores solved layouts keyed by constraint hash and dimensions,
- providing O(1) lookup for unchanged layouts. LRU eviction keeps memory
- bounded while maintaining frequently-used layouts.
-
- ## Usage
-
- # Start cache (typically in supervision tree)
- Cache.start_link(max_size: 1000)
-
- # Cached solve
- rects = Cache.solve(constraints, area)
-
- # Statistics
- stats = Cache.stats()
- # => %{size: 150, hits: 1234, misses: 56, hit_rate: 0.956}
-
- # Clear on resize
- Cache.clear()
-
- ## Configuration
-
- - `:max_size` - Maximum entries before eviction (default 500)
- - `:eviction_count` - Entries to remove per eviction (default 50)
- """
-
- use GenServer
-
- alias TermUI.Layout.Solver
-
- @table :term_ui_layout_cache
- @stats_table :term_ui_layout_cache_stats
- @default_max_size 500
- @default_eviction_count 50
-
- # Dialyzer: Functions with unmatched return values
- @dialyzer {:nowarn_function, init: 1, increment_hits: 0, increment_misses: 0, solve: 3}
-
- # Client API
-
- @doc """
- Starts the layout cache.
-
- ## Options
-
- - `:max_size` - Maximum cache entries (default 500)
- - `:eviction_count` - Entries to remove per eviction (default 50)
- - `:name` - GenServer name (default __MODULE__)
- """
- def start_link(opts \\ []) do
- name = Keyword.get(opts, :name, __MODULE__)
- GenServer.start_link(__MODULE__, opts, name: name)
- end
-
- @doc """
- Solves constraints with automatic caching.
-
- Checks cache first, falls back to solver on miss.
-
- ## Parameters
-
- - `constraints` - list of constraints
- - `area` - bounding rectangle
- - `opts` - solver options (direction, gap, etc.)
-
- ## Returns
-
- List of positioned rectangles.
- """
- def solve(constraints, area, opts \\ []) do
- key = cache_key(constraints, area)
-
- case lookup(key) do
- {:ok, result} ->
- increment_hits()
- result
-
- :miss ->
- increment_misses()
- result = Solver.solve_to_rects(constraints, area, opts)
- insert(key, result)
- result
- end
- end
-
- @doc """
- Solves constraints without caching.
-
- Use for testing or when caching is not desired.
- """
- def solve_uncached(constraints, area, opts \\ []) do
- Solver.solve_to_rects(constraints, area, opts)
- end
-
- @doc """
- Looks up a cached result by key.
-
- Returns `{:ok, result}` if found, `:miss` otherwise.
- """
- def lookup(key) do
- case :ets.lookup(@table, key) do
- [{^key, result, _access_time}] ->
- # Update access time
- :ets.update_element(@table, key, {3, current_time()})
- {:ok, result}
-
- [] ->
- :miss
- end
- end
-
- @doc """
- Inserts a result into the cache.
-
- Triggers eviction if cache exceeds max size.
- """
- def insert(key, result) do
- now = current_time()
- :ets.insert(@table, {key, result, now})
- maybe_evict()
- :ok
- end
-
- @doc """
- Invalidates a specific cache entry.
- """
- def invalidate(key) do
- :ets.delete(@table, key)
- :ok
- end
-
- @doc """
- Invalidates cache entries matching constraints.
-
- Useful when a component's constraints change.
- """
- def invalidate_constraints(constraints) do
- hash = constraint_hash(constraints)
-
- # Find and delete all entries with this constraint hash
- :ets.select_delete(@table, [
- {{{hash, :_, :_}, :_, :_}, [], [true]}
- ])
-
- :ok
- end
-
- @doc """
- Clears all cache entries.
-
- Call this on terminal resize.
- """
- def clear do
- :ets.delete_all_objects(@table)
- :ok
- end
-
- @doc """
- Returns cache statistics.
-
- ## Returns
-
- Map with:
- - `:size` - current entry count
- - `:hits` - total cache hits
- - `:misses` - total cache misses
- - `:hit_rate` - hits / (hits + misses)
- """
- def stats do
- size = :ets.info(@table, :size)
-
- [{_, hits}] = :ets.lookup(@stats_table, :hits)
- [{_, misses}] = :ets.lookup(@stats_table, :misses)
-
- total = hits + misses
-
- hit_rate =
- if total > 0 do
- Float.round(hits / total, 3)
- else
- 0.0
- end
-
- %{
- size: size,
- hits: hits,
- misses: misses,
- hit_rate: hit_rate
- }
- end
-
- @doc """
- Resets cache statistics.
- """
- def reset_stats do
- :ets.insert(@stats_table, {:hits, 0})
- :ets.insert(@stats_table, {:misses, 0})
- :ok
- end
-
- @doc """
- Warms the cache with common layouts.
-
- ## Parameters
-
- - `layouts` - list of `{constraints, area, opts}` tuples
- """
- def warm(layouts) when is_list(layouts) do
- Enum.each(layouts, fn {constraints, area, opts} ->
- solve(constraints, area, opts)
- end)
-
- # Reset stats after warming so they reflect actual usage
- reset_stats()
- :ok
- end
-
- @doc """
- Returns the current cache size.
- """
- def size do
- :ets.info(@table, :size)
- end
-
- @doc """
- Forces eviction synchronously. Useful for testing.
- """
- def evict_now(name \\ __MODULE__) do
- GenServer.call(name, :evict_sync)
- end
-
- # GenServer callbacks
-
- @impl true
- def init(opts) do
- max_size = Keyword.get(opts, :max_size, @default_max_size)
- eviction_count = Keyword.get(opts, :eviction_count, @default_eviction_count)
-
- # Create ETS tables
- :ets.new(@table, [:set, :public, :named_table, read_concurrency: true])
- :ets.new(@stats_table, [:set, :public, :named_table])
-
- # Initialize stats
- :ets.insert(@stats_table, {:hits, 0})
- :ets.insert(@stats_table, {:misses, 0})
-
- state = %{
- max_size: max_size,
- eviction_count: eviction_count
- }
-
- {:ok, state}
- end
-
- @impl true
- def handle_call(:get_config, _from, state) do
- {:reply, state, state}
- end
-
- @impl true
- def handle_call(:evict_sync, _from, state) do
- do_eviction(state.max_size, state.eviction_count)
- {:reply, :ok, state}
- end
-
- @impl true
- def handle_cast(:evict, state) do
- do_eviction(state.max_size, state.eviction_count)
- {:noreply, state}
- end
-
- @impl true
- def terminate(_reason, _state) do
- # Clean up ETS tables
- if :ets.whereis(@table) != :undefined, do: :ets.delete(@table)
- if :ets.whereis(@stats_table) != :undefined, do: :ets.delete(@stats_table)
- :ok
- end
-
- # Private functions
-
- defp cache_key(constraints, area) do
- hash = constraint_hash(constraints)
- {hash, area.width, area.height}
- end
-
- defp constraint_hash(constraints) do
- :erlang.phash2(constraints)
- end
-
- defp current_time do
- :erlang.monotonic_time(:millisecond)
- end
-
- defp increment_hits do
- :ets.update_counter(@stats_table, :hits, 1)
- end
-
- defp increment_misses do
- :ets.update_counter(@stats_table, :misses, 1)
- end
-
- defp maybe_evict do
- current_size = :ets.info(@table, :size)
- config = get_config()
-
- if current_size > config.max_size do
- GenServer.cast(__MODULE__, :evict)
- end
- end
-
- defp get_config do
- GenServer.call(__MODULE__, :get_config, 100)
- catch
- :exit, _ ->
- %{max_size: @default_max_size, eviction_count: @default_eviction_count}
- end
-
- defp do_eviction(max_size, eviction_count) do
- current_size = :ets.info(@table, :size)
-
- if current_size > max_size do
- # Get all entries sorted by access time
- entries =
- :ets.tab2list(@table)
- |> Enum.sort_by(fn {_key, _result, access_time} -> access_time end)
-
- # Remove oldest entries
- to_remove = min(eviction_count, current_size - max_size + eviction_count)
-
- entries
- |> Enum.take(to_remove)
- |> Enum.each(fn {key, _result, _access_time} ->
- :ets.delete(@table, key)
- end)
- end
- end
-end
diff --git a/lib/term_ui/layout/constraint.ex b/lib/term_ui/layout/constraint.ex
deleted file mode 100644
index 4e3d6043..00000000
--- a/lib/term_ui/layout/constraint.ex
+++ /dev/null
@@ -1,518 +0,0 @@
-defmodule TermUI.Layout.Constraint do
- @moduledoc """
- Constraint types for the layout system.
-
- Constraints express how components request space from their parent container.
- They are declarative—describing desired outcome, not how to achieve it.
-
- ## Constraint Types
-
- - `length/1` - Exact size in terminal cells
- - `percentage/1` - Fraction of parent size (0-100)
- - `ratio/1` - Proportional share of remaining space
- - `min/1`, `max/1` - Bounds on size
- - `fill/0` - Take all remaining space
-
- ## Examples
-
- # Fixed 20 cells
- Constraint.length(20)
-
- # 50% of parent
- Constraint.percentage(50)
-
- # 50% but at least 10 cells
- Constraint.percentage(50) |> Constraint.with_min(10)
-
- # Fill remaining space
- Constraint.fill()
-
- # 2:1 ratio distribution
- [Constraint.ratio(2), Constraint.ratio(1)]
-
- ## Composition
-
- Constraints can be composed with bounds using `with_min/2` and `with_max/2`:
-
- Constraint.percentage(50) |> Constraint.with_min(10) |> Constraint.with_max(100)
-
- This creates a constraint that requests 50% of parent, but at least 10 and at most 100 cells.
- """
-
- require Logger
-
- # Constraint type structs
-
- defmodule Length do
- @moduledoc "Fixed size constraint in terminal cells."
- defstruct [:value]
-
- @type t :: %__MODULE__{value: non_neg_integer()}
- end
-
- defmodule Percentage do
- @moduledoc "Percentage of parent size constraint."
- defstruct [:value]
-
- @type t :: %__MODULE__{value: number()}
- end
-
- defmodule Ratio do
- @moduledoc "Proportional share of remaining space constraint."
- defstruct [:value]
-
- @type t :: %__MODULE__{value: number()}
- end
-
- defmodule Min do
- @moduledoc "Minimum size bound on another constraint."
- defstruct [:value, :constraint]
-
- @type t :: %__MODULE__{value: non_neg_integer(), constraint: TermUI.Layout.Constraint.t()}
- end
-
- defmodule Max do
- @moduledoc "Maximum size bound on another constraint."
- defstruct [:value, :constraint]
-
- @type t :: %__MODULE__{value: non_neg_integer(), constraint: TermUI.Layout.Constraint.t()}
- end
-
- defmodule Fill do
- @moduledoc "Fill remaining space constraint."
- defstruct []
-
- @type t :: %__MODULE__{}
- end
-
- @type t :: Length.t() | Percentage.t() | Ratio.t() | Min.t() | Max.t() | Fill.t()
-
- # Public API
-
- @doc """
- Creates a length constraint for exactly `n` cells.
-
- ## Parameters
-
- - `n` - Number of cells (non-negative integer)
-
- ## Returns
-
- A length constraint struct.
-
- ## Examples
-
- iex> Constraint.length(20)
- %TermUI.Layout.Constraint.Length{value: 20}
-
- iex> Constraint.length(0)
- %TermUI.Layout.Constraint.Length{value: 0}
-
- ## Errors
-
- Raises `ArgumentError` if `n` is negative or not an integer.
- """
- @spec length(non_neg_integer()) :: Length.t()
- def length(n) when is_integer(n) and n >= 0 do
- %Length{value: n}
- end
-
- def length(n) when is_integer(n) do
- raise ArgumentError, "length must be non-negative, got: #{n}"
- end
-
- def length(n) do
- raise ArgumentError, "length must be a non-negative integer, got: #{inspect(n)}"
- end
-
- @doc """
- Creates a percentage constraint for `p`% of parent size.
-
- ## Parameters
-
- - `p` - Percentage value (0 to 100, can be float)
-
- ## Returns
-
- A percentage constraint struct.
-
- ## Examples
-
- iex> Constraint.percentage(50)
- %TermUI.Layout.Constraint.Percentage{value: 50}
-
- iex> Constraint.percentage(33.33)
- %TermUI.Layout.Constraint.Percentage{value: 33.33}
-
- ## Errors
-
- Raises `ArgumentError` if `p` is outside 0-100 range.
- """
- @spec percentage(number()) :: Percentage.t()
- def percentage(p) when is_number(p) and p >= 0 and p <= 100 do
- %Percentage{value: p}
- end
-
- def percentage(p) when is_number(p) do
- raise ArgumentError, "percentage must be between 0 and 100, got: #{p}"
- end
-
- def percentage(p) do
- raise ArgumentError, "percentage must be a number between 0 and 100, got: #{inspect(p)}"
- end
-
- @doc """
- Creates a ratio constraint for proportional space distribution.
-
- Ratio constraints share remaining space (after fixed and percentage allocations)
- proportionally among siblings with ratio constraints.
-
- ## Parameters
-
- - `r` - Ratio value (positive number)
-
- ## Returns
-
- A ratio constraint struct.
-
- ## Examples
-
- # Two siblings with 2:1 ratio (first gets 2/3, second gets 1/3)
- [Constraint.ratio(2), Constraint.ratio(1)]
-
- # Three equal siblings
- [Constraint.ratio(1), Constraint.ratio(1), Constraint.ratio(1)]
-
- ## Errors
-
- Raises `ArgumentError` if `r` is not positive.
- """
- @spec ratio(number()) :: Ratio.t()
- def ratio(r) when is_number(r) and r > 0 do
- %Ratio{value: r}
- end
-
- def ratio(r) when is_number(r) do
- raise ArgumentError, "ratio must be positive, got: #{r}"
- end
-
- def ratio(r) do
- raise ArgumentError, "ratio must be a positive number, got: #{inspect(r)}"
- end
-
- @doc """
- Creates a minimum size constraint.
-
- When used alone, acts as a minimum size requirement.
- When composed with another constraint, acts as a lower bound.
-
- ## Parameters
-
- - `n` - Minimum size in cells (non-negative integer)
-
- ## Returns
-
- A min constraint struct with a fill constraint as default inner constraint.
-
- ## Examples
-
- # At least 10 cells
- Constraint.min(10)
-
- ## Errors
-
- Raises `ArgumentError` if `n` is negative or not an integer.
- """
- @spec min(non_neg_integer()) :: Min.t()
- def min(n) when is_integer(n) and n >= 0 do
- %Min{value: n, constraint: %Fill{}}
- end
-
- def min(n) when is_integer(n) do
- raise ArgumentError, "min must be non-negative, got: #{n}"
- end
-
- def min(n) do
- raise ArgumentError, "min must be a non-negative integer, got: #{inspect(n)}"
- end
-
- @doc """
- Creates a maximum size constraint.
-
- When used alone, acts as a maximum size requirement with fill behavior.
- When composed with another constraint, acts as an upper bound.
-
- ## Parameters
-
- - `n` - Maximum size in cells (non-negative integer)
-
- ## Returns
-
- A max constraint struct with a fill constraint as default inner constraint.
-
- ## Examples
-
- # At most 100 cells
- Constraint.max(100)
-
- ## Errors
-
- Raises `ArgumentError` if `n` is negative or not an integer.
- """
- @spec max(non_neg_integer()) :: Max.t()
- def max(n) when is_integer(n) and n >= 0 do
- %Max{value: n, constraint: %Fill{}}
- end
-
- def max(n) when is_integer(n) do
- raise ArgumentError, "max must be non-negative, got: #{n}"
- end
-
- def max(n) do
- raise ArgumentError, "max must be a non-negative integer, got: #{inspect(n)}"
- end
-
- @doc """
- Creates combined min/max bounds.
-
- ## Parameters
-
- - `min_val` - Minimum size in cells
- - `max_val` - Maximum size in cells
-
- ## Returns
-
- A min constraint wrapping a max constraint with fill behavior.
-
- ## Examples
-
- # Between 10 and 100 cells
- Constraint.min_max(10, 100)
-
- ## Errors
-
- Raises `ArgumentError` if min > max or values are invalid.
- """
- @spec min_max(non_neg_integer(), non_neg_integer()) :: Min.t()
- def min_max(min_val, max_val)
- when is_integer(min_val) and is_integer(max_val) and min_val >= 0 and max_val >= 0 do
- if min_val > max_val do
- raise ArgumentError, "min (#{min_val}) cannot be greater than max (#{max_val})"
- end
-
- %Min{value: min_val, constraint: %Max{value: max_val, constraint: %Fill{}}}
- end
-
- def min_max(min_val, max_val) do
- raise ArgumentError,
- "min_max requires non-negative integers, got: min=#{inspect(min_val)}, max=#{inspect(max_val)}"
- end
-
- @doc """
- Creates a fill constraint that takes all remaining space.
-
- Fill is equivalent to `ratio(1)` in calculation but semantically distinct—
- it means "take whatever is left" rather than "share proportionally".
-
- ## Returns
-
- A fill constraint struct.
-
- ## Examples
-
- # Main content area fills remaining space
- Constraint.fill()
-
- Multiple fills distribute space equally among them.
- """
- @spec fill() :: Fill.t()
- def fill do
- %Fill{}
- end
-
- @doc """
- Adds a minimum bound to a constraint.
-
- ## Parameters
-
- - `constraint` - The constraint to bound
- - `min_val` - Minimum size in cells
-
- ## Returns
-
- The constraint wrapped in a min bound.
-
- ## Examples
-
- # 50% but at least 10 cells
- Constraint.percentage(50) |> Constraint.with_min(10)
- """
- @spec with_min(t(), non_neg_integer()) :: Min.t()
- def with_min(constraint, min_val) when is_integer(min_val) and min_val >= 0 do
- %Min{value: min_val, constraint: constraint}
- end
-
- def with_min(_constraint, min_val) do
- raise ArgumentError, "with_min requires non-negative integer, got: #{inspect(min_val)}"
- end
-
- @doc """
- Adds a maximum bound to a constraint.
-
- ## Parameters
-
- - `constraint` - The constraint to bound
- - `max_val` - Maximum size in cells
-
- ## Returns
-
- The constraint wrapped in a max bound.
-
- ## Examples
-
- # 50% but at most 100 cells
- Constraint.percentage(50) |> Constraint.with_max(100)
- """
- @spec with_max(t(), non_neg_integer()) :: Max.t()
- def with_max(constraint, max_val) when is_integer(max_val) and max_val >= 0 do
- %Max{value: max_val, constraint: constraint}
- end
-
- def with_max(_constraint, max_val) do
- raise ArgumentError, "with_max requires non-negative integer, got: #{inspect(max_val)}"
- end
-
- @doc """
- Resolves a constraint to a concrete size given available space.
-
- This is used by the constraint solver to calculate final sizes.
-
- ## Parameters
-
- - `constraint` - The constraint to resolve
- - `available` - Available space in cells
- - `opts` - Options including `:remaining` for ratio calculations
-
- ## Returns
-
- The resolved size in cells (non-negative integer).
-
- ## Examples
-
- iex> Constraint.resolve(Constraint.length(20), 100)
- 20
-
- iex> Constraint.resolve(Constraint.percentage(50), 100)
- 50
-
- iex> Constraint.resolve(Constraint.fill(), 100, remaining: 30)
- 30
- """
- @spec resolve(t(), non_neg_integer(), keyword()) :: non_neg_integer()
- def resolve(constraint, available, opts \\ [])
-
- def resolve(%Length{value: n}, available, _opts) do
- if n > available do
- Logger.warning("Length constraint #{n} exceeds available space #{available}, truncating")
- available
- else
- n
- end
- end
-
- def resolve(%Percentage{value: p}, available, _opts) do
- result = available * p / 100
- round(result)
- end
-
- def resolve(%Ratio{value: r}, _available, opts) do
- remaining = Keyword.get(opts, :remaining, 0)
- total_ratio = Keyword.get(opts, :total_ratio, r)
-
- if total_ratio == 0 do
- 0
- else
- result = remaining * r / total_ratio
- round(result)
- end
- end
-
- def resolve(%Fill{}, _available, opts) do
- Keyword.get(opts, :remaining, 0)
- end
-
- def resolve(%Min{value: min_val, constraint: inner}, available, opts) do
- inner_size = resolve(inner, available, opts)
- max(min_val, inner_size)
- end
-
- def resolve(%Max{value: max_val, constraint: inner}, available, opts) do
- inner_size = resolve(inner, available, opts)
- min(max_val, inner_size)
- end
-
- @doc """
- Returns the constraint type as an atom.
-
- Useful for categorizing constraints during solving.
-
- ## Examples
-
- iex> Constraint.type(Constraint.length(20))
- :length
-
- iex> Constraint.type(Constraint.percentage(50))
- :percentage
- """
- @spec type(t()) :: atom()
- def type(%Length{}), do: :length
- def type(%Percentage{}), do: :percentage
- def type(%Ratio{}), do: :ratio
- def type(%Fill{}), do: :fill
- def type(%Min{constraint: inner}), do: {:min, type(inner)}
- def type(%Max{constraint: inner}), do: {:max, type(inner)}
-
- @doc """
- Checks if a constraint is fixed (length or bounded length).
-
- Fixed constraints are allocated first during solving.
- """
- @spec fixed?(t()) :: boolean()
- def fixed?(%Length{}), do: true
- def fixed?(%Min{constraint: %Length{}}), do: true
- def fixed?(%Max{constraint: %Length{}}), do: true
- def fixed?(_), do: false
-
- @doc """
- Checks if a constraint uses remaining space (ratio or fill).
- """
- @spec flexible?(t()) :: boolean()
- def flexible?(%Ratio{}), do: true
- def flexible?(%Fill{}), do: true
- def flexible?(%Min{constraint: inner}), do: flexible?(inner)
- def flexible?(%Max{constraint: inner}), do: flexible?(inner)
- def flexible?(_), do: false
-
- @doc """
- Gets the minimum value from a constraint, if bounded.
- """
- @spec get_min(t()) :: non_neg_integer() | nil
- def get_min(%Min{value: v}), do: v
- def get_min(_), do: nil
-
- @doc """
- Gets the maximum value from a constraint, if bounded.
- """
- @spec get_max(t()) :: non_neg_integer() | nil
- def get_max(%Max{value: v}), do: v
- def get_max(%Min{constraint: inner}), do: get_max(inner)
- def get_max(_), do: nil
-
- @doc """
- Gets the inner constraint, unwrapping bounds.
- """
- @spec unwrap(t()) :: t()
- def unwrap(%Min{constraint: inner}), do: unwrap(inner)
- def unwrap(%Max{constraint: inner}), do: unwrap(inner)
- def unwrap(constraint), do: constraint
-end
diff --git a/lib/term_ui/layout/solver.ex b/lib/term_ui/layout/solver.ex
deleted file mode 100644
index 7df4f90d..00000000
--- a/lib/term_ui/layout/solver.ex
+++ /dev/null
@@ -1,521 +0,0 @@
-defmodule TermUI.Layout.Solver do
- @moduledoc """
- Constraint solver for the layout system.
-
- Translates constraints into concrete cell positions and sizes using a
- Cassowary-inspired greedy multi-pass algorithm.
-
- ## Algorithm
-
- The solver processes constraints in priority order:
- 1. **Fixed pass** - allocate length constraints exactly
- 2. **Percentage pass** - calculate from total available space
- 3. **Ratio/Fill pass** - distribute remaining space proportionally
-
- ## Examples
-
- # Three-pane layout
- constraints = [
- Constraint.length(20),
- Constraint.ratio(1),
- Constraint.ratio(2)
- ]
-
- sizes = Solver.solve(constraints, 100)
- # => [20, 27, 53]
-
- # Get positioned rectangles
- rects = Solver.solve_to_rects(constraints, %{x: 0, y: 0, width: 100, height: 10})
- # => [
- # %{x: 0, y: 0, width: 20, height: 10},
- # %{x: 20, y: 0, width: 27, height: 10},
- # %{x: 47, y: 0, width: 53, height: 10}
- # ]
- """
-
- require Logger
-
- alias TermUI.Layout.Constraint
- alias TermUI.Layout.Constraint.Fill
- alias TermUI.Layout.Constraint.Length
- alias TermUI.Layout.Constraint.Percentage
- alias TermUI.Layout.Constraint.Ratio
-
- @type rect :: %{x: integer(), y: integer(), width: integer(), height: integer()}
- @type direction :: :horizontal | :vertical
- @type solve_opts :: [
- direction: direction(),
- gap: non_neg_integer(),
- cross_axis: non_neg_integer() | nil
- ]
-
- # Public API
-
- @doc """
- Solves constraints and returns a list of sizes.
-
- ## Parameters
-
- - `constraints` - list of constraints to solve
- - `available` - total available space in cells
-
- ## Returns
-
- List of solved sizes (non-negative integers) in same order as constraints.
-
- ## Examples
-
- iex> Solver.solve([Constraint.length(20), Constraint.fill()], 100)
- [20, 80]
-
- iex> Solver.solve([Constraint.percentage(50), Constraint.percentage(50)], 100)
- [50, 50]
-
- iex> Solver.solve([Constraint.ratio(1), Constraint.ratio(2)], 90)
- [30, 60]
- """
- @spec solve([Constraint.t()], non_neg_integer()) :: [non_neg_integer()]
- def solve(constraints, available) when is_list(constraints) and available >= 0 do
- # Try fast paths first
- case try_fast_path(constraints, available) do
- {:ok, sizes} ->
- sizes
-
- :general ->
- solve_general(constraints, available)
- end
- end
-
- @doc """
- Solves constraints and returns positioned rectangles.
-
- ## Parameters
-
- - `constraints` - list of constraints to solve
- - `area` - bounding rectangle with x, y, width, height
- - `opts` - solving options
- - `:direction` - `:horizontal` (default) or `:vertical`
- - `:gap` - spacing between elements (default 0)
- - `:cross_axis` - size on cross axis (default uses area dimension)
-
- ## Returns
-
- List of rectangles with x, y, width, height.
-
- ## Examples
-
- iex> Solver.solve_to_rects(
- ...> [Constraint.length(20), Constraint.fill()],
- ...> %{x: 0, y: 0, width: 100, height: 10}
- ...> )
- [
- %{x: 0, y: 0, width: 20, height: 10},
- %{x: 20, y: 0, width: 80, height: 10}
- ]
- """
- @spec solve_to_rects([Constraint.t()], rect(), solve_opts()) :: [rect()]
- def solve_to_rects(constraints, area, opts \\ []) do
- direction = Keyword.get(opts, :direction, :horizontal)
- gap = Keyword.get(opts, :gap, 0)
-
- {main_size, cross_size} =
- case direction do
- :horizontal -> {area.width, area.height}
- :vertical -> {area.height, area.width}
- end
-
- cross_size = Keyword.get(opts, :cross_axis, cross_size)
-
- # Account for gaps in available space
- total_gaps = max(0, length(constraints) - 1) * gap
- available = max(0, main_size - total_gaps)
-
- sizes = solve(constraints, available)
-
- # Convert sizes to rectangles
- sizes_to_rects(sizes, area, direction, gap, cross_size)
- end
-
- @doc """
- Solves horizontal layout (widths) with explicit cross-axis height.
-
- ## Parameters
-
- - `constraints` - width constraints
- - `area` - bounding rectangle
- - `opts` - options including `:gap`
-
- ## Returns
-
- List of rectangles positioned horizontally.
- """
- @spec solve_horizontal([Constraint.t()], rect(), keyword()) :: [rect()]
- def solve_horizontal(constraints, area, opts \\ []) do
- solve_to_rects(constraints, area, Keyword.put(opts, :direction, :horizontal))
- end
-
- @doc """
- Solves vertical layout (heights) with explicit cross-axis width.
-
- ## Parameters
-
- - `constraints` - height constraints
- - `area` - bounding rectangle
- - `opts` - options including `:gap`
-
- ## Returns
-
- List of rectangles positioned vertically.
- """
- @spec solve_vertical([Constraint.t()], rect(), keyword()) :: [rect()]
- def solve_vertical(constraints, area, opts \\ []) do
- solve_to_rects(constraints, area, Keyword.put(opts, :direction, :vertical))
- end
-
- # Fast paths for common cases
-
- defp try_fast_path([], _available), do: {:ok, []}
-
- defp try_fast_path(constraints, available) do
- cond do
- all_fixed?(constraints) ->
- {:ok, solve_all_fixed(constraints, available)}
-
- single_fill?(constraints) ->
- {:ok, solve_single_fill(constraints, available)}
-
- true ->
- :general
- end
- end
-
- defp all_fixed?(constraints) do
- Enum.all?(constraints, &Constraint.fixed?/1)
- end
-
- defp single_fill?(constraints) do
- fills =
- Enum.count(constraints, fn c ->
- case Constraint.unwrap(c) do
- %Fill{} -> true
- _ -> false
- end
- end)
-
- fills == 1 and
- Enum.all?(constraints, fn c ->
- inner = Constraint.unwrap(c)
- match?(%Length{}, inner) or match?(%Fill{}, inner)
- end)
- end
-
- defp solve_all_fixed(constraints, available) do
- sizes = Enum.map(constraints, fn c -> resolve_length(c) end)
- total = Enum.sum(sizes)
-
- if total > available do
- Logger.warning("Fixed constraints total #{total} exceeds available #{available}")
- scale_proportionally(sizes, available)
- else
- sizes
- end
- end
-
- defp solve_single_fill(constraints, available) do
- {sizes, fill_idx} =
- constraints
- |> Enum.with_index()
- |> Enum.map_reduce(nil, fn {c, idx}, fill_idx ->
- case Constraint.unwrap(c) do
- %Fill{} ->
- {{0, idx}, idx}
-
- %Length{value: v} ->
- {{v, idx}, fill_idx}
- end
- end)
-
- fixed_total = sizes |> Enum.map(&elem(&1, 0)) |> Enum.sum()
- fill_size = max(0, available - fixed_total)
-
- # Apply min/max bounds to fill
- fill_constraint = Enum.at(constraints, fill_idx)
- bounded_fill = apply_bounds(fill_constraint, fill_size)
-
- sizes
- |> Enum.map(fn {size, idx} ->
- if idx == fill_idx, do: bounded_fill, else: size
- end)
- end
-
- # General solving algorithm
-
- defp solve_general(constraints, available) do
- indexed = Enum.with_index(constraints)
-
- # Pass 1: Allocate fixed sizes
- {fixed_sizes, remaining1} = allocate_fixed(indexed, available)
-
- # Pass 2: Allocate percentages (from original available)
- {percent_sizes, remaining2} = allocate_percentages(indexed, available, remaining1)
-
- # Pass 3: Allocate ratios and fills
- {flex_sizes, _remaining3} = allocate_flexible(indexed, remaining2)
-
- # Merge results in original order
- merge_sizes(indexed, fixed_sizes, percent_sizes, flex_sizes)
- |> apply_all_bounds(constraints, available)
- |> handle_overflow(available)
- end
-
- defp allocate_fixed(indexed, available) do
- fixed =
- indexed
- |> Enum.filter(fn {c, _idx} -> length?(c) end)
- |> Enum.map(fn {c, idx} -> {idx, resolve_length(c)} end)
- |> Map.new()
-
- used = fixed |> Map.values() |> Enum.sum()
- {fixed, max(0, available - used)}
- end
-
- defp allocate_percentages(indexed, total_available, remaining) do
- percentages =
- indexed
- |> Enum.filter(fn {c, _idx} -> percentage?(c) end)
- |> Enum.map(fn {c, idx} ->
- inner = Constraint.unwrap(c)
- size = round(total_available * inner.value / 100)
- {idx, size}
- end)
- |> Map.new()
-
- used = percentages |> Map.values() |> Enum.sum()
- {percentages, max(0, remaining - used)}
- end
-
- defp allocate_flexible(indexed, remaining) do
- flex_constraints =
- indexed
- |> Enum.filter(fn {c, _idx} -> flexible?(c) end)
-
- if flex_constraints == [] do
- {%{}, remaining}
- else
- total_ratio =
- flex_constraints
- |> Enum.map(fn {c, _idx} -> get_ratio_value(c) end)
- |> Enum.sum()
-
- flex_sizes =
- flex_constraints
- |> Enum.map(fn {c, idx} ->
- ratio = get_ratio_value(c)
- size = calculate_flex_size(ratio, remaining, total_ratio)
- {idx, size}
- end)
- |> Map.new()
-
- used = flex_sizes |> Map.values() |> Enum.sum()
- {flex_sizes, max(0, remaining - used)}
- end
- end
-
- defp merge_sizes(indexed, fixed, percentages, flexible) do
- indexed
- |> Enum.map(fn {_c, idx} ->
- Map.get(fixed, idx) || Map.get(percentages, idx) || Map.get(flexible, idx) || 0
- end)
- end
-
- defp apply_all_bounds(sizes, constraints, available) do
- # First pass: apply bounds
- bounded =
- Enum.zip(sizes, constraints)
- |> Enum.map(fn {size, constraint} ->
- apply_bounds(constraint, size)
- end)
-
- # Check if bounds caused overflow
- total = Enum.sum(bounded)
-
- if total > available do
- # Reduce non-min-bounded items proportionally
- reduce_to_fit(bounded, constraints, available)
- else
- bounded
- end
- end
-
- defp reduce_to_fit(sizes, constraints, available) do
- total = Enum.sum(sizes)
- excess = total - available
-
- # Find reducible items (not at their min)
- reducible =
- Enum.zip(sizes, constraints)
- |> Enum.with_index()
- |> Enum.filter(fn {{size, constraint}, _idx} ->
- min_val = Constraint.get_min(constraint) || 0
- size > min_val
- end)
-
- do_reduce_to_fit(sizes, constraints, excess, total, available, reducible)
- end
-
- defp do_reduce_to_fit(sizes, _constraints, _excess, total, available, []) do
- # Nothing can be reduced, return as is with warning
- Logger.warning(
- "Cannot satisfy min constraints: total #{total} exceeds available #{available}"
- )
-
- sizes
- end
-
- defp do_reduce_to_fit(sizes, constraints, excess, _total, _available, reducible) do
- # Calculate how much each can be reduced
- reducible_total = calculate_reducible_total(reducible)
-
- apply_reductions(sizes, constraints, excess, reducible_total)
- end
-
- defp calculate_reducible_total(reducible) do
- reducible
- |> Enum.map(fn {{size, constraint}, _idx} ->
- min_val = Constraint.get_min(constraint) || 0
- size - min_val
- end)
- |> Enum.sum()
- end
-
- defp apply_reductions(sizes, _constraints, _excess, reducible_total)
- when reducible_total <= 0 do
- Logger.warning("Cannot reduce: all at minimum")
- sizes
- end
-
- defp apply_reductions(sizes, constraints, excess, reducible_total) do
- sizes
- |> Enum.with_index()
- |> Enum.map(fn {size, idx} ->
- constraint = Enum.at(constraints, idx)
- reduce_size(size, constraint, excess, reducible_total)
- end)
- end
-
- defp handle_overflow(sizes, available) do
- total = Enum.sum(sizes)
-
- if total > available do
- Logger.warning("Constraint overflow: total #{total} exceeds available #{available}")
- scale_proportionally(sizes, available)
- else
- sizes
- end
- end
-
- defp scale_proportionally(sizes, available) do
- total = Enum.sum(sizes)
-
- if total == 0 do
- sizes
- else
- Enum.map(sizes, fn size ->
- round(size * available / total)
- end)
- end
- end
-
- defp calculate_flex_size(_ratio, _remaining, 0), do: 0
-
- defp calculate_flex_size(ratio, remaining, total_ratio) do
- round(remaining * ratio / total_ratio)
- end
-
- defp reduce_size(size, constraint, excess, reducible_total) do
- min_val = Constraint.get_min(constraint) || 0
- reducible_amount = size - min_val
-
- if reducible_amount > 0 do
- reduction = round(excess * reducible_amount / reducible_total)
- max(min_val, size - reduction)
- else
- size
- end
- end
-
- # Helper functions
-
- defp length?(constraint) do
- case Constraint.unwrap(constraint) do
- %Length{} -> true
- _ -> false
- end
- end
-
- defp percentage?(constraint) do
- case Constraint.unwrap(constraint) do
- %Percentage{} -> true
- _ -> false
- end
- end
-
- defp flexible?(constraint) do
- case Constraint.unwrap(constraint) do
- %Ratio{} -> true
- %Fill{} -> true
- _ -> false
- end
- end
-
- defp resolve_length(constraint) do
- case Constraint.unwrap(constraint) do
- %Length{value: v} -> v
- _ -> 0
- end
- end
-
- defp get_ratio_value(constraint) do
- case Constraint.unwrap(constraint) do
- %Ratio{value: v} -> v
- %Fill{} -> 1
- _ -> 0
- end
- end
-
- defp apply_bounds(constraint, size) do
- min_val = Constraint.get_min(constraint)
- max_val = Constraint.get_max(constraint)
-
- size
- |> then(fn s -> if min_val, do: max(min_val, s), else: s end)
- |> then(fn s -> if max_val, do: min(max_val, s), else: s end)
- end
-
- # Position calculation
-
- defp sizes_to_rects(sizes, area, direction, gap, cross_size) do
- {start_main, start_cross} =
- case direction do
- :horizontal -> {area.x, area.y}
- :vertical -> {area.y, area.x}
- end
-
- {rects, _pos} =
- sizes
- |> Enum.map_reduce(start_main, fn size, pos ->
- rect =
- case direction do
- :horizontal ->
- %{x: pos, y: start_cross, width: size, height: cross_size}
-
- :vertical ->
- %{x: start_cross, y: pos, width: cross_size, height: size}
- end
-
- {rect, pos + size + gap}
- end)
-
- rects
- end
-end
diff --git a/lib/term_ui/logger_control.ex b/lib/term_ui/logger_control.ex
new file mode 100644
index 00000000..851b1bcb
--- /dev/null
+++ b/lib/term_ui/logger_control.ex
@@ -0,0 +1,131 @@
+defmodule TermUI.LoggerControl do
+ @moduledoc false
+
+ @filter_id :term_ui_full_screen
+ @state_key {__MODULE__, :state}
+
+ @type token :: {reference(), pid()}
+
+ @doc false
+ @spec suspend() :: token() | nil
+ def suspend do
+ owner = self()
+ token_ref = make_ref()
+ watcher = spawn(fn -> watch_owner(owner, token_ref) end)
+
+ result =
+ try do
+ transaction(fn -> add_token(token_ref) end)
+ rescue
+ _exception -> :error
+ catch
+ _kind, _reason -> :error
+ end
+
+ case result do
+ :ok ->
+ {token_ref, watcher}
+
+ _other ->
+ send(watcher, {:release, token_ref})
+ nil
+ end
+ end
+
+ @doc false
+ @spec resume(token() | nil) :: :ok
+ def resume(nil), do: :ok
+
+ def resume({token_ref, watcher}) when is_reference(token_ref) and is_pid(watcher) do
+ try do
+ transaction(fn -> release_token(token_ref) end)
+ rescue
+ _exception -> :ok
+ catch
+ _kind, _reason -> :ok
+ after
+ send(watcher, {:release, token_ref})
+ end
+
+ :ok
+ end
+
+ @doc false
+ @spec stop_event(term(), term()) :: :stop
+ def stop_event(_event, _extra), do: :stop
+
+ defp add_token(token_ref) do
+ state = :persistent_term.get(@state_key, %{tokens: MapSet.new(), owns_filter?: false})
+ state = if MapSet.size(state.tokens) == 0, do: install_filter(state), else: state
+ :persistent_term.put(@state_key, %{state | tokens: MapSet.put(state.tokens, token_ref)})
+ :ok
+ end
+
+ defp watch_owner(owner, token_ref) do
+ monitor_ref = Process.monitor(owner)
+
+ receive do
+ {:release, ^token_ref} ->
+ Process.demonitor(monitor_ref, [:flush])
+
+ {:DOWN, ^monitor_ref, :process, ^owner, _reason} ->
+ transaction(fn -> release_token(token_ref) end)
+ end
+ rescue
+ _exception -> :ok
+ catch
+ _kind, _reason -> :ok
+ end
+
+ defp install_filter(state) do
+ if filter_present?() do
+ %{state | owns_filter?: false}
+ else
+ case :logger.add_handler_filter(
+ :default,
+ @filter_id,
+ {&__MODULE__.stop_event/2, nil}
+ ) do
+ :ok -> %{state | owns_filter?: true}
+ {:error, _reason} -> %{state | owns_filter?: false}
+ end
+ end
+ end
+
+ defp release_token(token) do
+ case :persistent_term.get(@state_key, nil) do
+ %{tokens: tokens} = state ->
+ if MapSet.member?(tokens, token) do
+ finish_release(%{state | tokens: MapSet.delete(tokens, token)})
+ end
+
+ _other ->
+ :ok
+ end
+ end
+
+ defp finish_release(%{tokens: tokens, owns_filter?: owns_filter?} = state) do
+ if MapSet.size(tokens) == 0 do
+ _result = if owns_filter?, do: :logger.remove_handler_filter(:default, @filter_id)
+ :persistent_term.erase(@state_key)
+ else
+ :persistent_term.put(@state_key, state)
+ end
+
+ :ok
+ end
+
+ defp filter_present? do
+ case :logger.get_handler_config(:default) do
+ {:ok, %{filters: filters}} ->
+ Enum.any?(filters, fn {id, _filter} -> id == @filter_id end)
+
+ _other ->
+ false
+ end
+ end
+
+ defp transaction(function) do
+ :global.trans({@state_key, self()}, function)
+ end
+end
diff --git a/lib/term_ui/markdown.ex b/lib/term_ui/markdown.ex
index ed61f980..7457fca9 100644
--- a/lib/term_ui/markdown.ex
+++ b/lib/term_ui/markdown.ex
@@ -1,24 +1,37 @@
defmodule TermUI.Markdown do
@moduledoc """
- Markdown processor for rendering styled text in TermUI.
+ Converts MDEx Markdown documents to styled terminal rows.
- Converts markdown content to styled segments that can be rendered
- by TermUI components.
-
- ## Usage
-
- iex> lines = TermUI.Markdown.render("**bold** and *italic*", 80)
-
- iex> result = TermUI.Markdown.render_with_elements("```elixir\\ndef hello, do: :world\\n```", 80)
+ The renderer supports headings, emphasis, strong text, strike-through text,
+ inline code, links, images, quotes, lists, task lists, fenced code blocks,
+ rules, and tables. Raw HTML is shown as plain text and never becomes terminal
+ control data. Fenced blocks can use an optional syntax-highlighter adapter.
"""
- alias TermUI.Component.RenderNode
- alias TermUI.Renderer.Style
-
- @type styled_segment :: {String.t(), Style.t() | nil}
- @type styled_line :: [styled_segment]
-
- @type interactive_element :: %{
+ alias TermUI.{DisplayWidth, Frame, Style, SyntaxHighlighter}
+ alias TermUI.Markdown.Document
+ alias TermUI.Markdown.Parser
+
+ # Styles stored in MDEx node spans contain MapSet's opaque representation.
+ @dialyzer {:nowarn_function, inline_node: 2}
+
+ @plain Style.new()
+ @heading1 Style.new(fg: :cyan, attrs: [:bold, :underline])
+ @heading2 Style.new(fg: :cyan, attrs: [:bold])
+ @heading Style.new(attrs: [:bold])
+ @strong Style.new(attrs: [:bold])
+ @emphasis Style.new(attrs: [:italic])
+ @strike Style.new(attrs: [:strikethrough])
+ @code Style.new(fg: :yellow)
+ @code_border Style.new(fg: :bright_black)
+ @quote Style.new(fg: :bright_black)
+ @link Style.new(fg: :blue, attrs: [:underline])
+ @bullet Style.new(fg: :cyan)
+ @rule Style.new(fg: :bright_black)
+ @table_header Style.new(fg: :cyan, attrs: [:bold])
+
+ @type styled_line :: [Frame.span()]
+ @type element :: %{
id: String.t(),
type: :code_block,
content: String.t(),
@@ -26,699 +39,330 @@ defmodule TermUI.Markdown do
start_line: non_neg_integer(),
end_line: non_neg_integer()
}
-
- @type render_result :: %{
+ @type result :: %{
lines: [styled_line()],
- elements: [interactive_element()],
+ elements: [element()],
content_height: non_neg_integer()
}
- # Style definitions
- @header1_style Style.new(fg: :cyan, attrs: [:bold])
- @header2_style Style.new(fg: :cyan, attrs: [:bold])
- @header3_style Style.new(fg: :white, attrs: [:bold])
- @bold_style Style.new(attrs: [:bold])
- @italic_style Style.new(attrs: [:italic])
- @code_style Style.new(fg: :yellow)
- @code_block_style Style.new(fg: :yellow)
- @code_border_style Style.new(fg: :bright_black)
- @code_border_focused_style Style.new(fg: :cyan, attrs: [:bold])
- @blockquote_style Style.new(fg: :bright_black)
- @link_style Style.new(fg: :blue, attrs: [:underline])
- @list_bullet_style Style.new(fg: :cyan)
- @hr_style Style.new(fg: :bright_black)
-
- # Dialyzer: Pattern match coverage warnings
- @dialyzer {:nowarn_function,
- render: 2,
- render_with_elements: 3,
- render_line_to_node: 1,
- process_document: 1,
- process_document_with_elements: 2}
-
- # Syntax highlighting token styles
- @token_styles %{
- keyword: Style.new(fg: :magenta, attrs: [:bold]),
- keyword_namespace: Style.new(fg: :magenta, attrs: [:bold]),
- keyword_pseudo: Style.new(fg: :magenta, attrs: [:bold]),
- keyword_reserved: Style.new(fg: :magenta, attrs: [:bold]),
- keyword_constant: Style.new(fg: :magenta, attrs: [:bold]),
- keyword_declaration: Style.new(fg: :magenta, attrs: [:bold]),
- keyword_type: Style.new(fg: :magenta, attrs: [:bold]),
- string: Style.new(fg: :green),
- string_char: Style.new(fg: :green),
- string_doc: Style.new(fg: :green),
- string_double: Style.new(fg: :green),
- string_single: Style.new(fg: :green),
- string_sigil: Style.new(fg: :green),
- string_regex: Style.new(fg: :green),
- string_interpol: Style.new(fg: :red),
- string_escape: Style.new(fg: :cyan),
- string_symbol: Style.new(fg: :cyan),
- comment: Style.new(fg: :bright_black),
- comment_single: Style.new(fg: :bright_black),
- comment_multiline: Style.new(fg: :bright_black),
- comment_doc: Style.new(fg: :bright_black),
- atom: Style.new(fg: :cyan),
- number: Style.new(fg: :yellow),
- number_integer: Style.new(fg: :yellow),
- number_float: Style.new(fg: :yellow),
- number_bin: Style.new(fg: :yellow),
- number_oct: Style.new(fg: :yellow),
- number_hex: Style.new(fg: :yellow),
- operator: Style.new(fg: :yellow),
- operator_word: Style.new(fg: :magenta, attrs: [:bold]),
- name: Style.new(fg: :white),
- name_function: Style.new(fg: :blue),
- name_class: Style.new(fg: :yellow, attrs: [:bold]),
- name_builtin: Style.new(fg: :cyan),
- name_builtin_pseudo: Style.new(fg: :cyan),
- name_attribute: Style.new(fg: :cyan),
- name_label: Style.new(fg: :cyan),
- name_constant: Style.new(fg: :yellow, attrs: [:bold]),
- name_exception: Style.new(fg: :red),
- name_tag: Style.new(fg: :blue),
- name_decorator: Style.new(fg: :cyan),
- name_namespace: Style.new(fg: :yellow, attrs: [:bold]),
- punctuation: Style.new(fg: :white),
- whitespace: nil,
- text: nil
- }
-
- @supported_lexers %{
- "elixir" => Makeup.Lexers.ElixirLexer,
- "ex" => Makeup.Lexers.ElixirLexer,
- "exs" => Makeup.Lexers.ElixirLexer,
- "iex" => Makeup.Lexers.ElixirLexer,
- "erlang" => Makeup.Lexers.ErlangLexer,
- "erl" => Makeup.Lexers.ErlangLexer,
- "hrl" => Makeup.Lexers.ErlangLexer
- }
-
- @doc """
- Renders markdown content as a list of styled lines.
- """
- @spec render(String.t(), pos_integer()) :: [styled_line()]
- def render("", _max_width), do: [[{"", nil}]]
- def render(nil, _max_width), do: [[{"", nil}]]
+ @doc "Returns true because MDEx is a required dependency."
+ @spec available?() :: true
+ def available?, do: true
- def render(content, max_width) when is_binary(content) and max_width > 0 do
- case MDEx.parse_document(content) do
- {:ok, document} ->
- document
- |> process_document()
- |> wrap_styled_lines(max_width)
+ @doc "Parses Markdown with the supported CommonMark extensions."
+ @spec parse(String.t()) :: {:ok, MDEx.Document.t()} | {:error, term()}
+ defdelegate parse(markdown), to: Parser
- {:error, _reason} ->
- content
- |> String.split("\n")
- |> Enum.map(fn line -> [{line, nil}] end)
- |> wrap_styled_lines(max_width)
- end
+ @doc "Renders Markdown to styled terminal rows."
+ @spec render(String.t() | Document.t(), pos_integer(), keyword()) :: [styled_line()]
+ def render(markdown, width, opts \\ []) do
+ render_with_elements(markdown, width, opts).lines
end
- def render(content, _max_width) when is_binary(content), do: render(content, 80)
+ @doc "Renders Markdown and returns code-block metadata."
+ @spec render_with_elements(String.t() | Document.t(), pos_integer(), keyword()) :: result()
+ def render_with_elements(markdown, width, opts \\ [])
- @doc """
- Renders markdown content with interactive element tracking.
- """
- @spec render_with_elements(String.t(), pos_integer(), keyword()) :: render_result()
- def render_with_elements("", _max_width, _opts) do
- %{lines: [[{"", nil}]], elements: [], content_height: 1}
- end
+ def render_with_elements(%Document{} = document, width, opts) when width > 0 do
+ groups =
+ Enum.map(document.segments, &{:nodes, &1.nodes}) ++
+ if(document.pending == "", do: [], else: [pending_group(document.pending)])
- def render_with_elements(nil, _max_width, _opts) do
- %{lines: [[{"", nil}]], elements: [], content_height: 1}
+ {lines, elements} = render_groups(groups, width, opts)
+ lines = if lines == [], do: [[""]], else: trim_blank_tail(lines)
+ %{lines: lines, elements: elements, content_height: length(lines)}
end
- def render_with_elements(content, max_width, opts) when is_binary(content) and max_width > 0 do
- focused_id = Keyword.get(opts, :focused_element_id)
-
- case MDEx.parse_document(content) do
- {:ok, document} ->
- {raw_lines, elements} = process_document_with_elements(document, focused_id)
- wrapped_lines = wrap_styled_lines(raw_lines, max_width)
- %{lines: wrapped_lines, elements: elements, content_height: length(wrapped_lines)}
+ def render_with_elements(markdown, width, opts) when is_binary(markdown) and width > 0 do
+ case parse(markdown) do
+ {:ok, %MDEx.Document{nodes: nodes}} ->
+ {lines, elements} = render_nodes(nodes, width, opts)
+ lines = if lines == [], do: [[""]], else: trim_blank_tail(lines)
+ %{lines: lines, elements: elements, content_height: length(lines)}
{:error, _reason} ->
lines =
- content
- |> String.split("\n")
- |> Enum.map(fn line -> [{line, nil}] end)
- |> wrap_styled_lines(max_width)
+ markdown |> String.split("\n", trim: false) |> Enum.flat_map(&wrap_spans([&1], width))
%{lines: lines, elements: [], content_height: length(lines)}
end
end
- def render_with_elements(content, _max_width, opts) when is_binary(content) do
- render_with_elements(content, 80, opts)
- end
-
- @doc """
- Converts a styled line to a TermUI render node.
- """
- @spec render_line_to_node(styled_line()) :: RenderNode.t()
- def render_line_to_node([]), do: RenderNode.text("", nil)
-
- def render_line_to_node([{text, style}]) do
- RenderNode.text(text, style)
- end
-
- def render_line_to_node(segments) when is_list(segments) do
- nodes =
- Enum.map(segments, fn {text, style} ->
- RenderNode.text(text, style)
- end)
-
- RenderNode.stack(:horizontal, nodes)
- end
-
- # Document Processing
- defp process_document(%MDEx.Document{nodes: nodes}) do
- Enum.flat_map(nodes, &process_node/1)
+ @doc "Returns code blocks in source order without rendering the document."
+ @spec code_blocks(String.t() | Document.t()) :: [element()]
+ def code_blocks(markdown) do
+ render_with_elements(markdown, 80).elements
end
- defp process_document(_), do: [[{"", nil}]]
-
- defp process_document_with_elements(%MDEx.Document{nodes: nodes}, focused_id) do
- {lines, elements, _line_idx} =
- Enum.reduce(nodes, {[], [], 0}, fn node, {acc_lines, acc_elements, line_idx} ->
- {node_lines, node_elements} = process_node_with_elements(node, line_idx, focused_id)
- new_line_idx = line_idx + length(node_lines)
- {acc_lines ++ node_lines, acc_elements ++ node_elements, new_line_idx}
+ defp render_nodes(nodes, width, opts, start_index \\ 0) do
+ {lines, elements, _index} =
+ Enum.reduce(nodes, {[], [], start_index}, fn node, {lines, elements, line_index} ->
+ {node_lines, node_elements} = render_block(node, width, opts, line_index)
+ separator = if lines == [] or node_lines == [], do: [], else: [[""]]
+ start_shift = length(separator)
+ node_elements = Enum.map(node_elements, &shift_element(&1, start_shift))
+ next_lines = lines ++ separator ++ node_lines
+ {next_lines, elements ++ node_elements, start_index + length(next_lines)}
end)
{lines, elements}
end
- defp process_document_with_elements(_, _focused_id), do: {[[{"", nil}]], []}
-
- defp process_node_with_elements(
- %MDEx.CodeBlock{literal: code, info: info},
- line_idx,
- focused_id
- ) do
- lang = if info && info != "", do: String.downcase(String.trim(info)), else: nil
- element_id = generate_element_id(code, line_idx)
- is_focused = element_id == focused_id
- border_style = if is_focused, do: @code_border_focused_style, else: @code_border_style
-
- header =
- if lang do
- focus_hint = if is_focused, do: " [c]", else: ""
-
- [
- [
- {"┌─ " <> lang <> focus_hint <> " ", @code_block_style},
- {String.duplicate("─", 40 - String.length(focus_hint)), border_style}
- ]
- ]
- else
- focus_hint = if is_focused, do: " [c]", else: ""
-
- [
- [
- {"┌" <> focus_hint, @code_block_style},
- {String.duplicate("─", 44 - String.length(focus_hint)), border_style}
- ]
- ]
- end
-
- code_lines = render_code_block(code, lang)
- footer = [[{"└", @code_block_style}, {String.duplicate("─", 44), border_style}], [{"", nil}]]
-
- lines = header ++ code_lines ++ footer
-
- element = %{
- id: element_id,
- type: :code_block,
- content: String.trim_trailing(code),
- language: lang,
- start_line: line_idx,
- end_line: line_idx + length(lines) - 1
- }
-
- {lines, [element]}
- end
-
- defp process_node_with_elements(node, _line_idx, _focused_id) do
- lines = process_node(node)
- {lines, []}
- end
-
- defp generate_element_id(content, line_idx) do
- :crypto.hash(:md5, "#{line_idx}:#{content}")
- |> Base.encode16(case: :lower)
- |> String.slice(0, 16)
- end
-
- # Node Processing
- defp process_node(%MDEx.Heading{level: 1, nodes: children}) do
- content = extract_text(children)
- [[{content, @header1_style}], [{"", nil}]]
- end
-
- defp process_node(%MDEx.Heading{level: 2, nodes: children}) do
- content = extract_text(children)
- [[{content, @header2_style}], [{"", nil}]]
- end
-
- defp process_node(%MDEx.Heading{level: level, nodes: children}) when level >= 3 do
- content = extract_text(children)
- [[{content, @header3_style}], [{"", nil}]]
- end
-
- defp process_node(%MDEx.Paragraph{nodes: children}) do
- segments = process_inline_nodes(children)
- [segments, [{"", nil}]]
- end
-
- defp process_node(%MDEx.CodeBlock{literal: code, info: info}) do
- lang = if info && info != "", do: String.downcase(String.trim(info)), else: nil
-
- header =
- if lang do
- [
- [
- {"┌─ " <> lang <> " ", @code_block_style},
- {String.duplicate("─", 40), @code_border_style}
- ]
- ]
- else
- [[{"┌", @code_block_style}, {String.duplicate("─", 44), @code_border_style}]]
- end
-
- code_lines = render_code_block(code, lang)
-
- footer = [
- [{"└", @code_block_style}, {String.duplicate("─", 44), @code_border_style}],
- [{"", nil}]
- ]
-
- header ++ code_lines ++ footer
+ defp pending_group(pending) do
+ case parse(pending) do
+ {:ok, %MDEx.Document{nodes: nodes}} -> {:nodes, nodes}
+ {:error, _reason} -> {:source, pending}
+ end
end
- defp process_node(%MDEx.Code{literal: code}) do
- [[{"`" <> code <> "`", @code_style}]]
- end
+ defp render_groups(groups, width, opts) do
+ Enum.reduce(groups, {[], []}, fn group, {lines, elements} ->
+ separator = if lines == [], do: [], else: [[""]]
+ start_index = length(lines) + length(separator)
- defp process_node(%MDEx.BlockQuote{nodes: children}) do
- children
- |> Enum.flat_map(&process_node/1)
- |> Enum.map(fn segments ->
- case segments do
- [{text, _style} | rest] ->
- [{"│ " <> text, @blockquote_style} | rest]
+ {group_lines, group_elements} =
+ case group do
+ {:nodes, nodes} ->
+ render_nodes(nodes, width, opts, start_index)
- [] ->
- [{"│ ", @blockquote_style}]
- end
- end)
- end
+ {:source, source} ->
+ rendered =
+ source |> String.split("\n", trim: false) |> Enum.flat_map(&wrap_spans([&1], width))
- defp process_node(%MDEx.List{list_type: :bullet, nodes: items}) do
- items
- |> Enum.flat_map(fn item ->
- process_list_item(item, "• ")
- end)
- |> Kernel.++([[{"", nil}]])
- end
+ {rendered, []}
+ end
- defp process_node(%MDEx.List{list_type: :ordered, nodes: items, start: start}) do
- items
- |> Enum.with_index(start || 1)
- |> Enum.flat_map(fn {item, idx} ->
- process_list_item(item, "#{idx}. ")
+ {lines ++ separator ++ group_lines, elements ++ group_elements}
end)
- |> Kernel.++([[{"", nil}]])
end
- defp process_node(%MDEx.ListItem{nodes: children}) do
- Enum.flat_map(children, &process_node/1)
- end
+ defp render_block(%MDEx.Heading{nodes: nodes, level: level}, width, _focused, _index) do
+ style =
+ case level do
+ 1 -> @heading1
+ 2 -> @heading2
+ _ -> @heading
+ end
- defp process_node(%MDEx.ThematicBreak{}) do
- [[{"───────────────────────────────────────", @hr_style}], [{"", nil}]]
+ {wrap_spans(inline(nodes, style), width), []}
end
- defp process_node(%MDEx.SoftBreak{}), do: []
- defp process_node(%MDEx.LineBreak{}), do: [[{"", nil}]]
+ defp render_block(%MDEx.Paragraph{nodes: nodes}, width, _focused, _index),
+ do: {wrap_spans(inline(nodes, @plain), width), []}
- defp process_node(node) when is_map(node) do
- case Map.get(node, :nodes) do
- nil ->
- case Map.get(node, :literal) do
- nil -> []
- text -> [[{text, nil}]]
- end
+ defp render_block(%MDEx.BlockQuote{nodes: nodes}, width, opts, line_index) do
+ {lines, elements} =
+ render_nodes_without_spacing(nodes, max(width - 2, 1), opts, line_index)
- children ->
- Enum.flat_map(children, &process_node/1)
- end
+ quoted = Enum.map(lines, fn line -> [{"│ ", @quote} | line] end)
+ {quoted, elements}
end
- defp process_node(_), do: []
+ defp render_block(%MDEx.List{} = list, width, opts, line_index) do
+ {lines, elements, _number} =
+ Enum.reduce(list.nodes, {[], [], list.start || 1}, fn item, {lines, elements, number} ->
+ marker = list_marker(list, item, number)
- # Code Block Rendering
- defp render_code_block(code, lang) do
- case Map.get(@supported_lexers, lang) do
- nil ->
- plain_code_lines(code)
+ {item_lines, item_elements} =
+ render_list_item(
+ item,
+ max(width - DisplayWidth.width(marker), 1),
+ opts,
+ line_index + length(lines)
+ )
- lexer ->
- try do
- highlighted_code_lines(code, lexer)
- rescue
- _ -> plain_code_lines(code)
- end
- end
- end
-
- defp plain_code_lines(code) do
- code
- |> String.trim_trailing()
- |> String.split("\n")
- |> Enum.map(fn line -> [{"│ " <> line, @code_block_style}] end)
- end
+ item_lines = Enum.with_index(item_lines, &prefix_list_line(&1, &2, marker))
- defp highlighted_code_lines(code, lexer) do
- tokens = lexer.lex(code |> String.trim_trailing())
-
- {lines, current_line} =
- Enum.reduce(tokens, {[], []}, fn {type, _meta, text}, {lines, current} ->
- style = Map.get(@token_styles, type) || @code_block_style
- text_str = normalize_token_text(text)
- add_token_to_lines(text_str, style, lines, current)
+ {lines ++ item_lines, elements ++ item_elements, number + 1}
end)
- all_lines = finalize_code_lines(lines, current_line)
-
- Enum.map(all_lines, fn segments ->
- [{"│ ", @code_block_style} | segments]
- end)
- end
-
- defp add_token_to_lines(text, style, lines, current) do
- parts = String.split(text, "\n")
-
- case parts do
- [single] ->
- {lines, current ++ [{single, style}]}
-
- [first | rest] ->
- finished_line = current ++ [{first, style}]
- {middle_parts, [last]} = Enum.split(rest, -1)
- middle_lines = Enum.map(middle_parts, fn part -> [{part, style}] end)
- {lines ++ [finished_line] ++ middle_lines, [{last, style}]}
- end
+ {lines, elements}
end
- defp finalize_code_lines(lines, []), do: lines
- defp finalize_code_lines(lines, current), do: lines ++ [current]
-
- defp normalize_token_text(text) when is_binary(text), do: text
-
- defp normalize_token_text(text) when is_list(text) do
- text
- |> List.flatten()
- |> Enum.map_join(fn
- char when is_integer(char) -> <>
- str when is_binary(str) -> str
- end)
- end
+ defp render_block(%MDEx.CodeBlock{literal: code, info: info}, width, opts, line_index) do
+ language = info |> to_string() |> String.trim() |> empty_to_nil()
+ id = "code-" <> Integer.to_string(:erlang.phash2({code, line_index}))
+ focused_id = Keyword.get(opts, :focused_element_id)
+ focused? = id == focused_id
+ border_style = if focused?, do: Style.new(fg: :cyan, attrs: [:bold]), else: @code_border
- defp normalize_token_text(text), do: to_string(text)
+ label =
+ if language,
+ do: "─ " <> language <> if(focused?, do: " [selected] ", else: " "),
+ else: if(focused?, do: "─ [selected] ", else: "─")
- # Inline Node Processing
- defp process_inline_nodes(nodes) when is_list(nodes) do
- nodes
- |> Enum.flat_map(&process_inline_node/1)
- |> merge_adjacent_segments()
- end
+ top = [[{"┌" <> Frame.fit(label, max(width - 1, 0)), border_style}]]
- defp process_inline_node(%MDEx.Text{literal: text}), do: [{text, nil}]
+ body =
+ code
+ |> String.trim_trailing("\n")
+ |> SyntaxHighlighter.lines(language,
+ adapter: Keyword.get(opts, :highlighter),
+ max_bytes: Keyword.get(opts, :highlight_limit, SyntaxHighlighter.default_max_bytes())
+ )
+ |> Enum.flat_map(fn line -> wrap_spans([{"│ ", border_style} | line], width) end)
- defp process_inline_node(%MDEx.Strong{nodes: children}) do
- text = extract_text(children)
- [{text, @bold_style}]
- end
+ bottom = [[{"└" <> String.duplicate("─", max(width - 1, 0)), border_style}]]
+ lines = top ++ body ++ bottom
- defp process_inline_node(%MDEx.Emph{nodes: children}) do
- text = extract_text(children)
- [{text, @italic_style}]
- end
+ element = %{
+ id: id,
+ type: :code_block,
+ content: code,
+ language: language,
+ start_line: line_index,
+ end_line: line_index + length(lines) - 1
+ }
- defp process_inline_node(%MDEx.Code{literal: code}) do
- [{"`" <> code <> "`", @code_style}]
+ {lines, [element]}
end
- defp process_inline_node(%MDEx.Link{url: url, nodes: children}) do
- text = extract_text(children)
+ defp render_block(%MDEx.ThematicBreak{}, width, _focused, _index),
+ do: {[[{String.duplicate("─", width), @rule}]], []}
- if text == url do
- [{text, @link_style}]
- else
- [{text, @link_style}, {" (#{url})", Style.new(fg: :bright_black)}]
- end
- end
+ defp render_block(%MDEx.Table{nodes: rows, alignments: alignments}, width, _focused, _index) do
+ column_count = rows |> List.first(%{nodes: []}) |> Map.get(:nodes, []) |> length() |> max(1)
+ column_width = max(div(max(width - column_count - 1, column_count), column_count), 1)
- defp process_inline_node(%MDEx.SoftBreak{}), do: [{" ", nil}]
- defp process_inline_node(%MDEx.LineBreak{}), do: [{"\n", nil}]
+ rendered =
+ Enum.map(rows, fn %MDEx.TableRow{nodes: cells, header: header?} ->
+ style = if header?, do: @table_header, else: @plain
- defp process_inline_node(node) when is_map(node) do
- case Map.get(node, :literal) do
- nil ->
- case Map.get(node, :nodes) do
- nil -> []
- children -> process_inline_nodes(children)
- end
+ cells
+ |> Enum.with_index()
+ |> Enum.flat_map(fn {%MDEx.TableCell{nodes: nodes}, index} ->
+ alignment = Enum.at(alignments, index, :left) |> normalize_alignment()
+ text = nodes |> inline(@plain) |> plain_text()
+ [{"│", @rule}, {align(text, column_width, alignment), style}]
+ end)
+ |> Kernel.++([{"│", @rule}])
+ end)
- text ->
- [{text, nil}]
- end
+ {rendered, []}
end
- defp process_inline_node(_), do: []
-
- # List Processing
- defp process_list_item(%MDEx.ListItem{nodes: children}, prefix) do
- children
- |> Enum.flat_map(&process_node/1)
- |> Enum.with_index()
- |> Enum.map(fn {segments, idx} ->
- process_list_line(segments, idx, prefix)
- end)
- |> Enum.reject(fn segments ->
- segments == [{"", nil}]
- end)
+ defp render_block(%{literal: literal}, width, _focused, _index) when is_binary(literal) do
+ text = Regex.replace(~r/<[^>]*>/u, literal, "")
+ {text |> String.split("\n", trim: false) |> Enum.flat_map(&wrap_spans([&1], width)), []}
end
- defp process_list_line(segments, 0, prefix) do
- case segments do
- [{text, style} | rest] ->
- [{prefix, @list_bullet_style}, {text, style} | rest]
-
- [] ->
- [{prefix, @list_bullet_style}]
- end
- end
+ defp render_block(%{nodes: nodes}, width, opts, line_index) when is_list(nodes),
+ do: render_nodes_without_spacing(nodes, width, opts, line_index)
- defp process_list_line(segments, _idx, prefix) do
- indent = String.duplicate(" ", String.length(prefix))
+ defp render_block(_node, _width, _focused, _index), do: {[], []}
- case segments do
- [{text, style} | rest] ->
- [{indent <> text, style} | rest]
+ defp align(text, width, alignment) do
+ text = Frame.fit(text, width)
+ content = String.trim_trailing(text)
+ room = max(width - DisplayWidth.width(content), 0)
- [] ->
- segments
+ case alignment do
+ :right -> String.duplicate(" ", room) <> content
+ :center -> String.duplicate(" ", div(room, 2)) <> content
+ :left -> text
end
+ |> Frame.fit(width)
end
- # Text Extraction
- defp extract_text(nodes) when is_list(nodes) do
- Enum.map_join(nodes, &extract_text/1)
- end
-
- defp extract_text(%{literal: text}) when is_binary(text), do: text
- defp extract_text(%{nodes: children}), do: extract_text(children)
- defp extract_text(_), do: ""
-
- # Segment Merging
- defp merge_adjacent_segments([]), do: []
-
- defp merge_adjacent_segments(segments) do
- segments
- |> Enum.reduce([], fn {text, style}, acc ->
- case acc do
- [{prev_text, ^style} | rest] ->
- [{prev_text <> text, style} | rest]
-
- _ ->
- [{text, style} | acc]
- end
- end)
- |> Enum.reverse()
- end
-
- # Line Wrapping
- @spec wrap_styled_lines([styled_line()], pos_integer()) :: [styled_line()]
- def wrap_styled_lines(lines, max_width) do
- lines
- |> Enum.flat_map(fn line ->
- wrap_styled_line(line, max_width)
- end)
- end
-
- defp wrap_styled_line([], _max_width), do: [[]]
+ defp prefix_list_line(line, 0, marker), do: [{marker, @bullet} | line]
- defp wrap_styled_line(segments, max_width) do
- expanded_segments = expand_newlines_in_segments(segments)
+ defp prefix_list_line(line, _index, marker),
+ do: [{String.duplicate(" ", String.length(marker)), @bullet} | line]
- {current, wrapped} =
- Enum.reduce(expanded_segments, {[], []}, fn
- :newline, {current, acc} ->
- {[], acc ++ [Enum.reverse(current)]}
+ defp render_list_item(%{nodes: nodes}, width, opts, line_index),
+ do: render_nodes_without_spacing(nodes, width, opts, line_index)
- segment, {current, acc} ->
- {[segment | current], acc}
+ defp render_nodes_without_spacing(nodes, width, opts, line_index) do
+ {lines, elements, _index} =
+ Enum.reduce(nodes, {[], [], line_index}, fn node, {lines, elements, index} ->
+ {node_lines, node_elements} = render_block(node, width, opts, index)
+ {lines ++ node_lines, elements ++ node_elements, index + length(node_lines)}
end)
- lines_from_newlines = wrapped ++ [Enum.reverse(current)]
-
- lines_from_newlines
- |> Enum.flat_map(fn line_segments ->
- wrap_segments_for_width(line_segments, max_width)
- end)
- end
-
- defp expand_newlines_in_segments(segments) do
- Enum.flat_map(segments, fn {text, style} ->
- expand_segment_newlines(text, style)
- end)
- end
-
- defp expand_segment_newlines(text, style) do
- if String.contains?(text, "\n") do
- text
- |> String.split("\n")
- |> Enum.intersperse(:newline)
- |> Enum.map(fn
- :newline -> :newline
- t -> {t, style}
- end)
- else
- [{text, style}]
- end
+ {lines, elements}
end
- defp wrap_segments_for_width([], _max_width), do: [[]]
+ defp inline(nodes, style), do: Enum.flat_map(nodes, &inline_node(&1, style))
+ defp inline_node(%MDEx.Text{literal: literal}, style), do: [{literal, style}]
- defp wrap_segments_for_width(segments, max_width) do
- {lines, current_line, _current_width} =
- Enum.reduce(segments, {[], [], 0}, fn {text, style}, {lines, current, width} ->
- wrap_segment({text, style}, lines, current, width, max_width)
- end)
+ defp inline_node(%MDEx.Code{literal: literal}, style),
+ do: [{literal, Style.merge(style, @code)}]
- all_lines = lines ++ [current_line]
+ defp inline_node(%MDEx.Strong{nodes: nodes}, style),
+ do: inline(nodes, Style.merge(style, @strong))
- all_lines
- |> Enum.map(fn line ->
- case line do
- [] -> [{"", nil}]
- segments -> segments
- end
- end)
- end
+ defp inline_node(%MDEx.Emph{nodes: nodes}, style),
+ do: inline(nodes, Style.merge(style, @emphasis))
- defp wrap_segment({text, style}, lines, current, width, max_width) do
- text_len = String.length(text)
+ defp inline_node(%MDEx.Strikethrough{nodes: nodes}, style),
+ do: inline(nodes, Style.merge(style, @strike))
- cond do
- text == "" ->
- {lines, current ++ [{text, style}], width}
+ defp inline_node(%MDEx.Link{nodes: nodes}, style), do: inline(nodes, Style.merge(style, @link))
- width + text_len <= max_width ->
- {lines, current ++ [{text, style}], width + text_len}
+ defp inline_node(%MDEx.Image{nodes: nodes}, style),
+ do: [{"[image: " <> plain_text(inline(nodes, style)) <> "]", Style.merge(style, @link)}]
- true ->
- wrap_text_at_words(text, style, lines, current, width, max_width)
- end
- end
+ defp inline_node(%{nodes: nodes}, style) when is_list(nodes), do: inline(nodes, style)
+ defp inline_node(%{literal: literal}, style) when is_binary(literal), do: [{literal, style}]
+ defp inline_node(_node, _style), do: []
- defp wrap_text_at_words(text, style, lines, current, width, max_width) do
- words = String.split(text, ~r/(\s+)/, include_captures: true)
+ defp wrap_spans(spans, width) do
+ {lines, current, _used} =
+ Enum.reduce(spans, {[], [], 0}, fn span, acc -> add_span(span, acc, width) end)
- Enum.reduce(words, {lines, current, width}, fn word, acc ->
- handle_wrap_word(word, style, acc, max_width)
- end)
+ Enum.reverse([Enum.reverse(current) | lines])
end
- defp handle_wrap_word("", _style, acc, _max_width), do: acc
-
- defp handle_wrap_word(word, style, {ls, cur, w}, max_width) do
- word_len = String.length(word)
+ defp add_span({text, %Style{} = style}, acc, width),
+ do: add_graphemes(IO.iodata_to_binary(text), style, acc, width)
- cond do
- w + word_len <= max_width ->
- {ls, cur ++ [{word, style}], w + word_len}
+ defp add_span(text, acc, width),
+ do: add_graphemes(IO.iodata_to_binary(text), @plain, acc, width)
- word_len > max_width ->
- handle_long_word(word, style, ls, cur, w, max_width)
-
- String.trim(word) == "" ->
- {ls, cur, w}
-
- true ->
- {ls ++ [cur], [{word, style}], word_len}
- end
- end
+ defp add_graphemes(text, style, acc, width) do
+ text
+ |> String.graphemes()
+ |> Enum.reduce(acc, fn
+ "\n", {lines, current, _used} ->
+ {[Enum.reverse(current) | lines], [], 0}
- defp handle_long_word(word, style, ls, cur, w, max_width) do
- {new_lines, remainder} = break_long_word(word, style, max_width - w, max_width)
+ grapheme, {lines, current, used} ->
+ grapheme_width = max(DisplayWidth.width(grapheme), 0)
- if cur == [] do
- {ls ++ new_lines, [{remainder, style}], String.length(remainder)}
- else
- {ls ++ [cur] ++ new_lines, [{remainder, style}], String.length(remainder)}
- end
+ if current != [] and used + grapheme_width > width,
+ do: {[Enum.reverse(current) | lines], [{grapheme, style}], grapheme_width},
+ else: {lines, merge_grapheme(current, grapheme, style), used + grapheme_width}
+ end)
end
- defp break_long_word(word, style, first_chunk_size, max_width) do
- first_chunk_size = max(first_chunk_size, 1)
+ defp merge_grapheme([{text, style} | rest], grapheme, style),
+ do: [{text <> grapheme, style} | rest]
- chunks =
- word
- |> String.graphemes()
- |> Enum.chunk_every(max_width)
- |> Enum.map(&Enum.join/1)
+ defp merge_grapheme(current, grapheme, style), do: [{grapheme, style} | current]
- case chunks do
- [] ->
- {[], ""}
+ defp list_marker(%MDEx.List{list_type: :ordered}, _item, number), do: "#{number}. "
+ defp list_marker(_list, %MDEx.TaskItem{checked: true}, _number), do: "[x] "
+ defp list_marker(_list, %MDEx.TaskItem{}, _number), do: "[ ] "
+ defp list_marker(_list, _item, _number), do: "• "
- [only] ->
- {[], only}
+ defp plain_text(spans),
+ do:
+ Enum.map_join(spans, fn
+ {text, _style} -> IO.iodata_to_binary(text)
+ text -> IO.iodata_to_binary(text)
+ end)
- [first | rest] ->
- first_part = String.slice(first, 0, first_chunk_size)
- remainder_of_first = String.slice(first, first_chunk_size..-1//1)
+ defp normalize_alignment(:center), do: :center
+ defp normalize_alignment(:right), do: :right
+ defp normalize_alignment(_alignment), do: :left
+ defp empty_to_nil(""), do: nil
+ defp empty_to_nil(text), do: text
- all_parts = [remainder_of_first | rest]
+ defp trim_blank_tail(lines),
+ do: Enum.reverse(Enum.drop_while(Enum.reverse(lines), &(&1 in [[], [""]])))
- lines =
- all_parts
- |> Enum.slice(0..-2//1)
- |> Enum.map(fn part -> [{part, style}] end)
+ defp shift_element(element, 0), do: element
- last = List.last(all_parts) || ""
-
- if first_part == "" do
- {lines, last}
- else
- {[[{first_part, style}]] ++ lines, last}
- end
- end
- end
+ defp shift_element(element, shift),
+ do: %{element | start_line: element.start_line + shift, end_line: element.end_line + shift}
end
diff --git a/lib/term_ui/markdown/document.ex b/lib/term_ui/markdown/document.ex
new file mode 100644
index 00000000..af5daf14
--- /dev/null
+++ b/lib/term_ui/markdown/document.ex
@@ -0,0 +1,124 @@
+defmodule TermUI.Markdown.Document do
+ @moduledoc """
+ A bounded, incremental Markdown document.
+
+ Completed top-level blocks are parsed once and retained as MDEx nodes. The
+ final paragraph, list, or fenced block stays in `pending` because later text
+ can still extend it. Rendering reparses only that unfinished tail. If the
+ byte limit removes old source, the retained tail is rebuilt once.
+ """
+
+ alias TermUI.Markdown.Parser
+
+ @type segment :: %{source: String.t(), nodes: [struct()]}
+ @type t :: %__MODULE__{
+ content: String.t(),
+ segments: [segment()],
+ pending: String.t(),
+ content_limit: pos_integer(),
+ parsed_segments: non_neg_integer()
+ }
+
+ defstruct content: "",
+ segments: [],
+ pending: "",
+ content_limit: 2_000_000,
+ parsed_segments: 0
+
+ @doc "Creates a bounded incremental document from source text."
+ @spec new(String.t(), keyword()) :: t()
+ def new(content \\ "", opts \\ []) do
+ limit = opts |> Keyword.get(:content_limit, 2_000_000) |> max(1)
+ content = content |> to_string() |> retain_tail(limit)
+ {segments, pending} = parse_complete([], content)
+
+ %__MODULE__{
+ content: content,
+ segments: segments,
+ pending: pending,
+ content_limit: limit,
+ parsed_segments: length(segments)
+ }
+ end
+
+ @doc "Appends a fragment and parses only newly completed block groups."
+ @spec append(t(), String.t()) :: t()
+ def append(document, fragment) when is_binary(fragment) do
+ combined = document.content <> fragment
+ retained = retain_tail(combined, document.content_limit)
+
+ if byte_size(retained) < byte_size(combined) do
+ new(retained, content_limit: document.content_limit)
+ else
+ {segments, pending} = parse_complete(document.segments, document.pending <> fragment)
+
+ %{
+ document
+ | content: retained,
+ segments: segments,
+ pending: pending,
+ parsed_segments: document.parsed_segments + length(segments) - length(document.segments)
+ }
+ end
+ end
+
+ @doc "Replaces all source and resets the incremental parse state."
+ @spec replace(t(), String.t()) :: t()
+ def replace(document, content),
+ do: new(content, content_limit: document.content_limit)
+
+ @doc "Returns the count of source bytes that no longer need parsing."
+ @spec committed_bytes(t()) :: non_neg_integer()
+ def committed_bytes(document),
+ do: Enum.reduce(document.segments, 0, &(byte_size(&1.source) + &2))
+
+ defp parse_complete(existing, buffer) do
+ case Parser.parse(buffer) do
+ {:ok, %MDEx.Document{nodes: nodes}} when length(nodes) > 1 ->
+ pending_node = List.last(nodes)
+
+ case pending_start_line(pending_node) do
+ nil ->
+ {existing, buffer}
+
+ line ->
+ boundary = line_start_offset(buffer, line)
+ source = binary_part(buffer, 0, boundary)
+ pending = binary_part(buffer, boundary, byte_size(buffer) - boundary)
+ segment = %{source: source, nodes: Enum.drop(nodes, -1)}
+ {existing ++ [segment], pending}
+ end
+
+ _result ->
+ {existing, buffer}
+ end
+ end
+
+ defp pending_start_line(%{sourcepos: %{start: {line, _column}}}) when is_integer(line), do: line
+ defp pending_start_line(_node), do: nil
+
+ defp line_start_offset(_buffer, line) when line <= 1, do: 0
+
+ defp line_start_offset(buffer, line) do
+ case buffer |> :binary.matches("\n") |> Enum.at(line - 2) do
+ {position, length} -> position + length
+ nil -> 0
+ end
+ end
+
+ defp retain_tail(content, limit) when byte_size(content) <= limit, do: content
+
+ defp retain_tail(content, limit) do
+ content
+ |> binary_part(byte_size(content) - limit, limit)
+ |> valid_utf8_tail()
+ end
+
+ defp valid_utf8_tail(<<>>), do: ""
+
+ defp valid_utf8_tail(content) do
+ if String.valid?(content),
+ do: content,
+ else: content |> binary_part(1, byte_size(content) - 1) |> valid_utf8_tail()
+ end
+end
diff --git a/lib/term_ui/markdown/parser.ex b/lib/term_ui/markdown/parser.ex
new file mode 100644
index 00000000..d12c0033
--- /dev/null
+++ b/lib/term_ui/markdown/parser.ex
@@ -0,0 +1,10 @@
+defmodule TermUI.Markdown.Parser do
+ @moduledoc false
+
+ @extensions [table: true, strikethrough: true, tasklist: true, autolink: true]
+
+ @doc false
+ @spec parse(String.t()) :: {:ok, MDEx.Document.t()} | {:error, term()}
+ def parse(markdown) when is_binary(markdown),
+ do: MDEx.parse_document(markdown, extension: @extensions)
+end
diff --git a/lib/term_ui/message.ex b/lib/term_ui/message.ex
deleted file mode 100644
index b7460d1c..00000000
--- a/lib/term_ui/message.ex
+++ /dev/null
@@ -1,141 +0,0 @@
-defmodule TermUI.Message do
- @moduledoc """
- Message type conventions and helpers for component messages.
-
- Messages are component-specific types representing meaningful actions.
- They carry semantic meaning—`{:select_item, 3}` is clearer than the raw
- key event that triggered it.
-
- ## Message Conventions
-
- Components define their own message types using one of these patterns:
-
- ### Simple Atom Messages
-
- :increment
- :decrement
- :submit
- :cancel
-
- ### Tuple Messages with Data
-
- {:select_item, 3}
- {:update_text, "hello"}
- {:set_value, 42}
-
- ### Struct Messages (for complex data)
-
- defmodule MyComponent.Msg do
- defmodule SelectItem do
- defstruct [:index, :source]
- end
- end
-
- %MyComponent.Msg.SelectItem{index: 3, source: :keyboard}
-
- ## Event to Message Conversion
-
- Components implement `event_to_msg/2` to convert events to messages:
-
- def event_to_msg(%Event.Key{key: :enter}, _state) do
- {:msg, :submit}
- end
-
- def event_to_msg(%Event.Key{key: :up}, _state) do
- {:msg, {:move, :up}}
- end
-
- def event_to_msg(_event, _state) do
- :ignore
- end
-
- ## Message Routing
-
- Messages route to the component that should handle them. The runtime
- delivers messages and components update their state in response.
- """
-
- @type t :: atom() | tuple() | struct()
-
- @doc """
- Checks if a value is a valid message.
-
- Messages can be atoms, tuples, or structs.
- """
- @spec valid?(term()) :: boolean()
- def valid?(msg) when is_atom(msg) and not is_nil(msg), do: true
- def valid?(msg) when is_tuple(msg) and tuple_size(msg) >= 1, do: true
- def valid?(%{__struct__: _}), do: true
- def valid?(_), do: false
-
- @doc """
- Returns the message type/name.
-
- For atoms, returns the atom itself.
- For tuples, returns the first element.
- For structs, returns the struct module name.
- """
- @spec name(t()) :: atom()
- def name(msg) when is_atom(msg), do: msg
- def name(msg) when is_tuple(msg), do: elem(msg, 0)
- def name(%{__struct__: module}), do: module
-
- @doc """
- Returns the message payload.
-
- For atoms, returns nil.
- For tuples with 2 elements, returns the second element.
- For tuples with more elements, returns a list of remaining elements.
- For structs, returns the struct itself.
- """
- @spec payload(t()) :: term()
- def payload(msg) when is_atom(msg), do: nil
- def payload(msg) when is_tuple(msg) and tuple_size(msg) == 1, do: nil
- def payload(msg) when is_tuple(msg) and tuple_size(msg) == 2, do: elem(msg, 1)
- def payload(msg) when is_tuple(msg), do: Tuple.to_list(msg) |> tl()
- def payload(%{__struct__: _} = msg), do: msg
-
- @doc """
- Creates a wrapped message result from event_to_msg.
-
- Returns `{:msg, message}` to indicate the event was converted.
- """
- @spec wrap(t()) :: {:msg, t()}
- def wrap(msg), do: {:msg, msg}
-
- @doc """
- Checks if a value is an atom message.
- """
- @spec atom?(term()) :: boolean()
- def atom?(msg) when is_atom(msg) and not is_nil(msg), do: true
- def atom?(_), do: false
-
- @doc """
- Checks if a value is a tuple message.
- """
- @spec tuple?(term()) :: boolean()
- def tuple?(msg) when is_tuple(msg) and tuple_size(msg) >= 1, do: true
- def tuple?(_), do: false
-
- @doc """
- Checks if a value is a struct message.
- """
- @spec struct?(term()) :: boolean()
- def struct?(%{__struct__: _}), do: true
- def struct?(_), do: false
-
- @doc """
- Matches a message against a pattern.
-
- ## Examples
-
- Message.match?(:submit, :submit) # true
- Message.match?({:select, 3}, :select) # true
- Message.match?(%Msg.SelectItem{index: 3}, Msg.SelectItem) # true
- """
- @spec match?(t(), atom()) :: boolean()
- def match?(msg, pattern) when is_atom(msg), do: msg == pattern
- def match?(msg, pattern) when is_tuple(msg), do: elem(msg, 0) == pattern
- def match?(%{__struct__: module}, pattern), do: module == pattern
- def match?(_, _), do: false
-end
diff --git a/lib/term_ui/message_queue.ex b/lib/term_ui/message_queue.ex
deleted file mode 100644
index a60daa98..00000000
--- a/lib/term_ui/message_queue.ex
+++ /dev/null
@@ -1,189 +0,0 @@
-defmodule TermUI.MessageQueue do
- @moduledoc """
- Message queue for batching multiple messages before rendering.
-
- Multiple messages may arrive between renders. We batch messages, applying
- all updates before rendering once. This prevents redundant renders when
- multiple events arrive quickly. The batch preserves message order for
- deterministic updates.
-
- ## Usage
-
- # Create a queue
- queue = MessageQueue.new()
-
- # Enqueue messages
- queue = MessageQueue.enqueue(queue, :increment)
- queue = MessageQueue.enqueue(queue, {:set_value, 42})
-
- # Process all messages
- {messages, queue} = MessageQueue.flush(queue)
-
- # Apply messages to state
- state = Enum.reduce(messages, state, fn msg, state ->
- {new_state, _commands} = Component.update(msg, state)
- new_state
- end)
- """
-
- @default_max_size 1000
-
- # Dialyzer: Functions return specific tuple types
- @dialyzer {:nowarn_function, process: 3}
-
- @type message :: term()
- @type t :: %__MODULE__{
- messages: :queue.queue(message()),
- size: non_neg_integer(),
- max_size: pos_integer(),
- overflow_count: non_neg_integer()
- }
-
- defstruct messages: nil,
- size: 0,
- max_size: @default_max_size,
- overflow_count: 0
-
- @doc """
- Creates a new message queue.
-
- ## Options
-
- - `:max_size` - Maximum number of messages before dropping (default: #{@default_max_size})
- """
- @spec new(keyword()) :: t()
- def new(opts \\ []) do
- %__MODULE__{
- messages: :queue.new(),
- size: 0,
- max_size: Keyword.get(opts, :max_size, @default_max_size),
- overflow_count: 0
- }
- end
-
- @doc """
- Enqueues a message for processing.
-
- Messages are added to the back of the queue, preserving order.
- If the queue is at max capacity, the message is dropped and
- overflow count is incremented.
- """
- @spec enqueue(t(), message()) :: t()
- def enqueue(%__MODULE__{size: size, max_size: max_size} = queue, _message)
- when size >= max_size do
- %{queue | overflow_count: queue.overflow_count + 1}
- end
-
- def enqueue(%__MODULE__{} = queue, message) do
- %{
- queue
- | messages: :queue.in(message, queue.messages),
- size: queue.size + 1
- }
- end
-
- @doc """
- Enqueues multiple messages at once.
- """
- @spec enqueue_all(t(), [message()]) :: t()
- def enqueue_all(queue, messages) do
- Enum.reduce(messages, queue, &enqueue(&2, &1))
- end
-
- @doc """
- Removes and returns all messages from the queue.
-
- Returns `{messages, empty_queue}` where messages is a list
- in the order they were enqueued.
- """
- @spec flush(t()) :: {[message()], t()}
- def flush(%__MODULE__{} = queue) do
- messages = :queue.to_list(queue.messages)
-
- new_queue = %{
- queue
- | messages: :queue.new(),
- size: 0
- }
-
- {messages, new_queue}
- end
-
- @doc """
- Returns true if the queue is empty.
- """
- @spec empty?(t()) :: boolean()
- def empty?(%__MODULE__{size: 0}), do: true
- def empty?(_), do: false
-
- @doc """
- Returns the number of messages in the queue.
- """
- @spec size(t()) :: non_neg_integer()
- def size(%__MODULE__{size: size}), do: size
-
- @doc """
- Returns the number of dropped messages due to overflow.
- """
- @spec overflow_count(t()) :: non_neg_integer()
- def overflow_count(%__MODULE__{overflow_count: count}), do: count
-
- @doc """
- Peeks at the front message without removing it.
- """
- @spec peek(t()) :: {:value, message()} | :empty
- def peek(%__MODULE__{messages: messages}) do
- :queue.peek(messages)
- end
-
- @doc """
- Removes and returns the front message.
- """
- @spec dequeue(t()) :: {{:value, message()}, t()} | {:empty, t()}
- def dequeue(%__MODULE__{size: 0} = queue), do: {:empty, queue}
-
- def dequeue(%__MODULE__{} = queue) do
- {{:value, message}, new_messages} = :queue.out(queue.messages)
-
- new_queue = %{
- queue
- | messages: new_messages,
- size: queue.size - 1
- }
-
- {{:value, message}, new_queue}
- end
-
- @doc """
- Clears the queue and resets overflow count.
- """
- @spec clear(t()) :: t()
- def clear(%__MODULE__{} = queue) do
- %{
- queue
- | messages: :queue.new(),
- size: 0,
- overflow_count: 0
- }
- end
-
- @doc """
- Processes all queued messages with a function.
-
- Applies `fun` to each message and the accumulator, returning
- the final accumulator and empty queue.
-
- ## Example
-
- {final_state, commands, queue} = MessageQueue.process(queue, {state, []}, fn msg, {state, cmds} ->
- {new_state, new_cmds} = Component.update(msg, state)
- {new_state, cmds ++ new_cmds}
- end)
- """
- @spec process(t(), acc, (message(), acc -> acc)) :: {acc, t()} when acc: term()
- def process(%__MODULE__{} = queue, initial_acc, fun) do
- {messages, new_queue} = flush(queue)
- final_acc = Enum.reduce(messages, initial_acc, fun)
- {final_acc, new_queue}
- end
-end
diff --git a/lib/term_ui/mouse.ex b/lib/term_ui/mouse.ex
index 044a738c..b831716d 100644
--- a/lib/term_ui/mouse.ex
+++ b/lib/term_ui/mouse.ex
@@ -1,137 +1,278 @@
-defmodule TermUI.Mouse do
- @moduledoc """
- Mouse support utilities for terminal applications.
+defmodule TermUI.Mouse.Region do
+ @moduledoc "A zero-based screen region for pure mouse routing."
- Provides functions to enable/disable mouse tracking modes and
- utilities for working with mouse events.
+ @type t :: %__MODULE__{
+ id: term(),
+ x: non_neg_integer(),
+ y: non_neg_integer(),
+ width: pos_integer(),
+ height: pos_integer(),
+ z_index: integer(),
+ metadata: map()
+ }
- ## Mouse Tracking Modes
+ @schema Zoi.struct(__MODULE__, %{
+ id: Zoi.any(),
+ x: Zoi.integer() |> Zoi.non_negative(),
+ y: Zoi.integer() |> Zoi.non_negative(),
+ width: Zoi.integer() |> Zoi.positive(),
+ height: Zoi.integer() |> Zoi.positive(),
+ z_index: Zoi.integer() |> Zoi.default(0),
+ metadata: Zoi.map() |> Zoi.default(%{})
+ })
- - **Normal (1000)** - Report button press/release
- - **Button (1002)** - Report motion while button pressed
- - **Any (1003)** - Report all motion events
- - **SGR Extended (1006)** - Decimal coordinates, press/release distinction
+ @enforce_keys Zoi.Struct.enforce_keys(@schema)
+ defstruct Zoi.Struct.struct_fields(@schema)
- ## Usage
+ @doc "Returns the Zoi schema for mouse regions."
+ @spec schema() :: Zoi.schema()
+ def schema, do: @schema
+end
- # Enable mouse tracking
- sequences = Mouse.enable_mouse()
- IO.write(sequences)
+defmodule TermUI.Mouse.Tracker do
+ @moduledoc "Pure drag and hover state for one Elm application."
- # Enable motion tracking with SGR Extended
- sequences = Mouse.enable_mouse_motion()
- IO.write(sequences)
+ alias TermUI.Event
- # Disable mouse tracking
- sequences = Mouse.disable_mouse()
- IO.write(sequences)
- """
+ @type t :: %__MODULE__{
+ button_down: Event.Mouse.button(),
+ press_position: {integer(), integer()} | nil,
+ last_position: {integer(), integer()} | nil,
+ dragging: boolean(),
+ hovered: term(),
+ drag_threshold: non_neg_integer()
+ }
- # Mouse tracking mode escape sequences
- @mouse_normal_on "\e[?1000h"
- @mouse_normal_off "\e[?1000l"
- @mouse_button_on "\e[?1002h"
- @mouse_button_off "\e[?1002l"
- @mouse_any_on "\e[?1003h"
- @mouse_any_off "\e[?1003l"
- @mouse_sgr_on "\e[?1006h"
- @mouse_sgr_off "\e[?1006l"
-
- @doc """
- Returns escape sequences to enable normal mouse tracking.
-
- Normal mode reports button press and release events.
- Also enables SGR Extended mode for accurate coordinates.
- """
- @spec enable_mouse() :: String.t()
- def enable_mouse do
- @mouse_normal_on <> @mouse_sgr_on
+ defstruct button_down: nil,
+ press_position: nil,
+ last_position: nil,
+ dragging: false,
+ hovered: nil,
+ drag_threshold: 1
+
+ @doc "Creates tracker state. The drag threshold is measured in terminal cells."
+ @spec new(keyword()) :: t()
+ def new(opts \\ []) do
+ %__MODULE__{drag_threshold: max(Keyword.get(opts, :drag_threshold, 1), 0)}
+ end
+
+ @doc "Updates drag state and returns generated drag messages."
+ @spec update(t(), Event.Mouse.t()) :: {t(), [term()]}
+ def update(tracker, %Event.Mouse{action: :press, button: button, x: x, y: y}) do
+ {%{
+ tracker
+ | button_down: button,
+ press_position: {x, y},
+ last_position: {x, y},
+ dragging: false
+ }, []}
end
- @doc """
- Returns escape sequences to enable button motion tracking.
+ def update(tracker, %Event.Mouse{action: :release, button: button, x: x, y: y}) do
+ events = if tracker.dragging, do: [{:drag_end, button || tracker.button_down, x, y}], else: []
- Button mode reports motion events while a button is pressed.
- Also enables SGR Extended mode for accurate coordinates.
- """
- @spec enable_mouse_button() :: String.t()
- def enable_mouse_button do
- @mouse_button_on <> @mouse_sgr_on
+ {%{
+ tracker
+ | button_down: nil,
+ press_position: nil,
+ last_position: {x, y},
+ dragging: false
+ }, events}
end
- @doc """
- Returns escape sequences to enable all motion tracking.
+ def update(tracker, %Event.Mouse{action: action, button: button, x: x, y: y})
+ when action in [:move, :drag] do
+ update_motion(tracker, button || tracker.button_down, x, y)
+ end
- Any mode reports all mouse motion events.
- Also enables SGR Extended mode for accurate coordinates.
- """
- @spec enable_mouse_motion() :: String.t()
- def enable_mouse_motion do
- @mouse_any_on <> @mouse_sgr_on
+ def update(tracker, %Event.Mouse{}), do: {tracker, []}
+
+ @doc "Updates the hovered region identifier."
+ @spec hover(t(), term()) :: {t(), [term()]}
+ def hover(%__MODULE__{hovered: target} = tracker, target), do: {tracker, []}
+
+ def hover(%__MODULE__{hovered: nil} = tracker, target),
+ do: {%{tracker | hovered: target}, [{:hover_enter, target}]}
+
+ def hover(%__MODULE__{hovered: previous} = tracker, nil),
+ do: {%{tracker | hovered: nil}, [{:hover_leave, previous}]}
+
+ def hover(%__MODULE__{hovered: previous} = tracker, target),
+ do: {%{tracker | hovered: target}, [{:hover_leave, previous}, {:hover_enter, target}]}
+
+ @doc "Returns true during a drag."
+ @spec dragging?(t()) :: boolean()
+ def dragging?(%__MODULE__{dragging: dragging}), do: dragging
+
+ @doc "Returns the hovered region identifier."
+ @spec hovered(t()) :: term()
+ def hovered(%__MODULE__{hovered: hovered}), do: hovered
+
+ @doc "Clears drag state, such as after terminal focus is lost."
+ @spec reset_drag(t()) :: t()
+ def reset_drag(tracker),
+ do: %{tracker | button_down: nil, press_position: nil, last_position: nil, dragging: false}
+
+ defp update_motion(%{button_down: nil} = tracker, nil, x, y),
+ do: {%{tracker | last_position: {x, y}}, []}
+
+ defp update_motion(%{press_position: nil} = tracker, _button, x, y),
+ do: {%{tracker | last_position: {x, y}}, []}
+
+ defp update_motion(%{dragging: true} = tracker, button, x, y) do
+ {dx, dy} = delta(tracker.last_position, {x, y})
+ {%{tracker | last_position: {x, y}}, [{:drag, button, x, y, dx, dy}]}
end
- @doc """
- Returns escape sequences to disable all mouse tracking.
- """
- @spec disable_mouse() :: String.t()
- def disable_mouse do
- @mouse_sgr_off <> @mouse_any_off <> @mouse_button_off <> @mouse_normal_off
+ defp update_motion(tracker, button, x, y) do
+ {press_x, press_y} = tracker.press_position
+
+ if abs(x - press_x) >= tracker.drag_threshold or
+ abs(y - press_y) >= tracker.drag_threshold do
+ {%{tracker | dragging: true, last_position: {x, y}},
+ [
+ {:drag_start, button, press_x, press_y},
+ {:drag, button, x, y, x - press_x, y - press_y}
+ ]}
+ else
+ {%{tracker | last_position: {x, y}}, []}
+ end
end
- @doc """
- Returns the escape sequence for SGR Extended mode.
+ defp delta(nil, _position), do: {0, 0}
+ defp delta({old_x, old_y}, {x, y}), do: {x - old_x, y - old_y}
+end
- SGR Extended mode provides:
- - Decimal coordinate encoding (no 223 limit)
- - Press/release distinction via 'm' vs 'M' suffix
- """
- @spec sgr_extended_on() :: String.t()
- def sgr_extended_on, do: @mouse_sgr_on
+defmodule TermUI.Mouse do
+ @moduledoc """
+ Pure mouse hit testing and local-coordinate routing.
- @doc """
- Returns the escape sequence to disable SGR Extended mode.
+ The Elm application creates regions from its current layout and stores any
+ `TermUI.Mouse.Tracker` state. No registry, process, or global spatial index is
+ used. Coordinates are zero-based because terminal mouse events are zero-based.
"""
- @spec sgr_extended_off() :: String.t()
- def sgr_extended_off, do: @mouse_sgr_off
- # Scroll wheel directions
- @doc """
- Scroll up direction constant.
- """
- def scroll_up, do: :scroll_up
+ alias TermUI.Event
+ alias TermUI.Mouse.Region
- @doc """
- Scroll down direction constant.
- """
- def scroll_down, do: :scroll_down
+ @doc "Creates a routing region. Later equal-z regions are treated as topmost."
+ @spec region(
+ term(),
+ non_neg_integer(),
+ non_neg_integer(),
+ pos_integer(),
+ pos_integer(),
+ keyword()
+ ) ::
+ Region.t()
+ def region(id, x, y, width, height, opts \\ [])
+ when is_integer(x) and x >= 0 and is_integer(y) and y >= 0 and is_integer(width) and
+ width > 0 and is_integer(height) and height > 0 do
+ %Region{
+ id: id,
+ x: x,
+ y: y,
+ width: width,
+ height: height,
+ z_index: region_z_index!(opts),
+ metadata: region_metadata!(opts)
+ }
+ end
- @doc """
- Default number of lines to scroll per wheel tick.
- """
- def default_scroll_lines, do: 3
+ @doc "Returns the topmost region and local coordinates at a screen position."
+ @spec hit_test([Region.t()], integer(), integer()) ::
+ {:ok, Region.t(), {integer(), integer()}} | :none
+ def hit_test(regions, x, y) when is_list(regions) and is_integer(x) and is_integer(y) do
+ regions
+ |> topmost_region(x, y)
+ |> case do
+ nil -> :none
+ region -> {:ok, region, to_local(region, x, y)}
+ end
+ end
- @doc """
- Checks if a mouse action is a scroll action.
- """
- @spec scroll_action?(atom()) :: boolean()
- def scroll_action?(:scroll_up), do: true
- def scroll_action?(:scroll_down), do: true
- def scroll_action?(_), do: false
+ @doc "Routes a mouse event to the topmost region with local coordinates."
+ @spec route([Region.t()], Event.Mouse.t()) :: {:ok, term(), Event.Mouse.t()} | :none
+ def route(regions, %Event.Mouse{x: x, y: y} = event) do
+ case hit_test(regions, x, y) do
+ {:ok, region, {local_x, local_y}} ->
+ {:ok, region.id, %{event | x: local_x, y: local_y}}
- @doc """
- Checks if a mouse action is a click action.
- """
- @spec click_action?(atom()) :: boolean()
- def click_action?(:press), do: true
- def click_action?(:release), do: true
- def click_action?(:click), do: true
- def click_action?(_), do: false
-
- @doc """
- Checks if a mouse action is a motion action.
- """
- @spec motion_action?(atom()) :: boolean()
- def motion_action?(:move), do: true
- def motion_action?(:drag), do: true
- def motion_action?(_), do: false
+ :none ->
+ :none
+ end
+ end
+
+ @doc "Routes a mouse event to all matching regions in front-to-back order."
+ @spec route_all([Region.t()], Event.Mouse.t()) :: [{term(), Event.Mouse.t()}]
+ def route_all(regions, %Event.Mouse{x: x, y: y} = event) do
+ Enum.map(matching_regions(regions, x, y), fn region ->
+ {local_x, local_y} = to_local(region, x, y)
+ {region.id, %{event | x: local_x, y: local_y}}
+ end)
+ end
+
+ @doc "Transforms global coordinates to region-local coordinates."
+ @spec to_local(Region.t(), integer(), integer()) :: {integer(), integer()}
+ def to_local(%Region{} = region, x, y), do: {x - region.x, y - region.y}
+
+ @doc "Transforms region-local coordinates to global coordinates."
+ @spec to_global(Region.t(), integer(), integer()) :: {integer(), integer()}
+ def to_global(%Region{} = region, x, y), do: {x + region.x, y + region.y}
+
+ @doc "Returns true when a point is inside a region."
+ @spec contains?(Region.t(), integer(), integer()) :: boolean()
+ def contains?(%Region{} = region, x, y) do
+ x >= region.x and x < region.x + region.width and y >= region.y and
+ y < region.y + region.height
+ end
+
+ @doc "Returns true when two regions overlap."
+ @spec overlap?(Region.t(), Region.t()) :: boolean()
+ def overlap?(%Region{} = first, %Region{} = second) do
+ not (first.x + first.width <= second.x or second.x + second.width <= first.x or
+ first.y + first.height <= second.y or second.y + second.height <= first.y)
+ end
+
+ @doc "Clips global coordinates to a region."
+ @spec clip(Region.t(), integer(), integer()) :: {integer(), integer()}
+ def clip(%Region{} = region, x, y) do
+ {
+ x |> max(region.x) |> min(region.x + region.width - 1),
+ y |> max(region.y) |> min(region.y + region.height - 1)
+ }
+ end
+
+ defp matching_regions(regions, x, y) do
+ regions
+ |> Enum.with_index()
+ |> Enum.filter(fn {region, _index} -> contains?(region, x, y) end)
+ |> Enum.sort_by(fn {region, index} -> {region.z_index, index} end, :desc)
+ |> Enum.map(&elem(&1, 0))
+ end
+
+ defp topmost_region(regions, x, y) do
+ Enum.reduce(regions, nil, fn region, current ->
+ cond do
+ not contains?(region, x, y) -> current
+ is_nil(current) -> region
+ region.z_index >= current.z_index -> region
+ true -> current
+ end
+ end)
+ end
+
+ defp region_z_index!(opts) do
+ case Keyword.get(opts, :z_index, 0) do
+ z_index when is_integer(z_index) -> z_index
+ _other -> raise ArgumentError, "mouse region z_index must be an integer"
+ end
+ end
+
+ defp region_metadata!(opts) do
+ case Keyword.get(opts, :metadata, %{}) do
+ metadata when is_map(metadata) -> metadata
+ _other -> raise ArgumentError, "mouse region metadata must be a map"
+ end
+ end
end
diff --git a/lib/term_ui/mouse/router.ex b/lib/term_ui/mouse/router.ex
deleted file mode 100644
index 34c4f0a9..00000000
--- a/lib/term_ui/mouse/router.ex
+++ /dev/null
@@ -1,131 +0,0 @@
-defmodule TermUI.Mouse.Router do
- @moduledoc """
- Routes mouse events to components based on position.
-
- The router uses component bounds to determine which component
- should receive a mouse event, handles z-order for overlapping
- components, and transforms coordinates to component-local space.
-
- ## Usage
-
- # Find component at position
- {component_id, local_x, local_y} = Router.hit_test(components, x, y)
-
- # Route event to component
- {target_id, transformed_event} = Router.route(components, mouse_event)
- """
-
- alias TermUI.Event
-
- @type bounds :: %{x: integer(), y: integer(), width: integer(), height: integer()}
- @type component_entry :: %{bounds: bounds(), z_index: integer()}
- @type components :: %{atom() => component_entry()}
-
- @doc """
- Finds the component at the given position.
-
- Returns `{component_id, local_x, local_y}` or `nil` if no component at position.
-
- When multiple components overlap, returns the one with highest z_index.
- """
- @spec hit_test(components(), integer(), integer()) :: {atom(), integer(), integer()} | nil
- def hit_test(components, x, y) do
- components
- |> Enum.filter(fn {_id, entry} -> point_in_bounds?(x, y, entry.bounds) end)
- |> Enum.max_by(fn {_id, entry} -> Map.get(entry, :z_index, 0) end, fn -> nil end)
- |> case do
- nil ->
- nil
-
- {id, entry} ->
- local_x = x - entry.bounds.x
- local_y = y - entry.bounds.y
- {id, local_x, local_y}
- end
- end
-
- @doc """
- Routes a mouse event to the appropriate component.
-
- Returns `{component_id, transformed_event}` where the event has
- coordinates transformed to component-local space.
-
- Returns `nil` if no component at the event position.
- """
- @spec route(components(), Event.Mouse.t()) :: {atom(), Event.Mouse.t()} | nil
- def route(components, %Event.Mouse{x: x, y: y} = event) do
- case hit_test(components, x, y) do
- nil ->
- nil
-
- {id, local_x, local_y} ->
- transformed = %{event | x: local_x, y: local_y}
- {id, transformed}
- end
- end
-
- @doc """
- Finds all components at the given position, ordered by z-index (highest first).
-
- Useful for event bubbling through overlapping components.
- """
- @spec hit_test_all(components(), integer(), integer()) :: [{atom(), integer(), integer()}]
- def hit_test_all(components, x, y) do
- components
- |> Enum.filter(fn {_id, entry} -> point_in_bounds?(x, y, entry.bounds) end)
- |> Enum.sort_by(fn {_id, entry} -> Map.get(entry, :z_index, 0) end, :desc)
- |> Enum.map(fn {id, entry} ->
- local_x = x - entry.bounds.x
- local_y = y - entry.bounds.y
- {id, local_x, local_y}
- end)
- end
-
- @doc """
- Transforms global coordinates to component-local coordinates.
- """
- @spec to_local(bounds(), integer(), integer()) :: {integer(), integer()}
- def to_local(bounds, x, y) do
- {x - bounds.x, y - bounds.y}
- end
-
- @doc """
- Transforms component-local coordinates to global coordinates.
- """
- @spec to_global(bounds(), integer(), integer()) :: {integer(), integer()}
- def to_global(bounds, local_x, local_y) do
- {local_x + bounds.x, local_y + bounds.y}
- end
-
- @doc """
- Checks if a point is within bounds.
- """
- @spec point_in_bounds?(integer(), integer(), bounds()) :: boolean()
- def point_in_bounds?(x, y, bounds) do
- x >= bounds.x and
- x < bounds.x + bounds.width and
- y >= bounds.y and
- y < bounds.y + bounds.height
- end
-
- @doc """
- Checks if two bounds overlap.
- """
- @spec bounds_overlap?(bounds(), bounds()) :: boolean()
- def bounds_overlap?(a, b) do
- not (a.x + a.width <= b.x or
- b.x + b.width <= a.x or
- a.y + a.height <= b.y or
- b.y + b.height <= a.y)
- end
-
- @doc """
- Clips coordinates to be within bounds.
- """
- @spec clip_to_bounds(integer(), integer(), bounds()) :: {integer(), integer()}
- def clip_to_bounds(x, y, bounds) do
- clipped_x = x |> max(bounds.x) |> min(bounds.x + bounds.width - 1)
- clipped_y = y |> max(bounds.y) |> min(bounds.y + bounds.height - 1)
- {clipped_x, clipped_y}
- end
-end
diff --git a/lib/term_ui/mouse/tracker.ex b/lib/term_ui/mouse/tracker.ex
deleted file mode 100644
index 19dd2f15..00000000
--- a/lib/term_ui/mouse/tracker.ex
+++ /dev/null
@@ -1,209 +0,0 @@
-defmodule TermUI.Mouse.Tracker do
- @moduledoc """
- Tracks mouse state for drag and hover detection.
-
- The tracker maintains state for:
- - Drag operations (press → move → release)
- - Hover detection (enter/leave events)
- - Last known mouse position
-
- ## Usage
-
- # Create new tracker
- tracker = Tracker.new()
-
- # Process mouse events
- {tracker, events} = Tracker.process(tracker, mouse_event)
-
- # Events may include:
- # - {:drag_start, button, x, y}
- # - {:drag_move, button, x, y, dx, dy}
- # - {:drag_end, button, x, y}
- # - {:hover_enter, component_id}
- # - {:hover_leave, component_id}
- """
-
- alias TermUI.Event
-
- @type t :: %__MODULE__{
- button_down: atom() | nil,
- press_position: {integer(), integer()} | nil,
- last_position: {integer(), integer()} | nil,
- dragging: boolean(),
- hovered_component: atom() | nil,
- drag_threshold: integer()
- }
-
- defstruct [
- :button_down,
- :press_position,
- :last_position,
- :hovered_component,
- dragging: false,
- drag_threshold: 3
- ]
-
- @doc """
- Creates a new mouse tracker.
-
- ## Options
-
- - `:drag_threshold` - Pixels of movement before drag starts (default: 3)
- """
- @spec new(keyword()) :: t()
- def new(opts \\ []) do
- %__MODULE__{
- drag_threshold: Keyword.get(opts, :drag_threshold, 3)
- }
- end
-
- @doc """
- Processes a mouse event and returns updated tracker and generated events.
-
- Generated events:
- - `{:drag_start, button, x, y}` - Drag operation started
- - `{:drag_move, button, x, y, dx, dy}` - Mouse moved during drag
- - `{:drag_end, button, x, y}` - Drag operation ended
- """
- @spec process(t(), Event.Mouse.t()) :: {t(), list()}
- def process(tracker, %Event.Mouse{action: :press, button: button, x: x, y: y}) do
- tracker = %{
- tracker
- | button_down: button,
- press_position: {x, y},
- last_position: {x, y},
- dragging: false
- }
-
- {tracker, []}
- end
-
- def process(tracker, %Event.Mouse{action: :release, button: button, x: x, y: y}) do
- events =
- if tracker.dragging and tracker.button_down == button do
- [{:drag_end, button, x, y}]
- else
- []
- end
-
- tracker = %{
- tracker
- | button_down: nil,
- press_position: nil,
- dragging: false
- }
-
- {tracker, events}
- end
-
- def process(tracker, %Event.Mouse{action: :move, x: x, y: y}) do
- {tracker, events} = process_motion(tracker, x, y)
- tracker = %{tracker | last_position: {x, y}}
- {tracker, events}
- end
-
- def process(tracker, %Event.Mouse{action: :drag, button: button, x: x, y: y}) do
- # Drag events come with button info
- {tracker, events} = process_motion(tracker, x, y, button)
- tracker = %{tracker | last_position: {x, y}}
- {tracker, events}
- end
-
- def process(tracker, %Event.Mouse{}) do
- # Scroll or other events don't affect drag/hover state
- {tracker, []}
- end
-
- @doc """
- Updates hover state and returns enter/leave events.
- """
- @spec update_hover(t(), atom() | nil) :: {t(), list()}
- def update_hover(tracker, component_id) do
- cond do
- tracker.hovered_component == component_id ->
- {tracker, []}
-
- tracker.hovered_component == nil ->
- tracker = %{tracker | hovered_component: component_id}
- {tracker, [{:hover_enter, component_id}]}
-
- component_id == nil ->
- old = tracker.hovered_component
- tracker = %{tracker | hovered_component: nil}
- {tracker, [{:hover_leave, old}]}
-
- true ->
- old = tracker.hovered_component
- tracker = %{tracker | hovered_component: component_id}
- {tracker, [{:hover_leave, old}, {:hover_enter, component_id}]}
- end
- end
-
- @doc """
- Returns whether a drag operation is in progress.
- """
- @spec dragging?(t()) :: boolean()
- def dragging?(tracker), do: tracker.dragging
-
- @doc """
- Returns the currently hovered component.
- """
- @spec hovered_component(t()) :: atom() | nil
- def hovered_component(tracker), do: tracker.hovered_component
-
- @doc """
- Returns the button currently pressed.
- """
- @spec button_down(t()) :: atom() | nil
- def button_down(tracker), do: tracker.button_down
-
- @doc """
- Resets drag state (useful on focus loss).
- """
- @spec reset_drag(t()) :: t()
- def reset_drag(tracker) do
- %{tracker | button_down: nil, press_position: nil, dragging: false}
- end
-
- # --- Private Functions ---
-
- defp process_motion(tracker, x, y, button \\ nil) do
- button = button || tracker.button_down
-
- cond do
- # No button down, no drag events
- button == nil ->
- {tracker, []}
-
- # Already dragging, emit drag move
- tracker.dragging ->
- {dx, dy} = delta(tracker.last_position, {x, y})
- {tracker, [{:drag_move, button, x, y, dx, dy}]}
-
- # Check if we should start dragging
- should_start_drag?(tracker, x, y) ->
- tracker = %{tracker | dragging: true}
- {px, py} = tracker.press_position
- {tracker, [{:drag_start, button, px, py}, {:drag_move, button, x, y, x - px, y - py}]}
-
- # Not yet dragging
- true ->
- {tracker, []}
- end
- end
-
- defp should_start_drag?(tracker, x, y) do
- case tracker.press_position do
- nil ->
- false
-
- {px, py} ->
- dx = abs(x - px)
- dy = abs(y - py)
- dx >= tracker.drag_threshold or dy >= tracker.drag_threshold
- end
- end
-
- defp delta(nil, _), do: {0, 0}
- defp delta({x1, y1}, {x2, y2}), do: {x2 - x1, y2 - y1}
-end
diff --git a/lib/term_ui/parser.ex b/lib/term_ui/parser.ex
deleted file mode 100644
index 42ba6294..00000000
--- a/lib/term_ui/parser.ex
+++ /dev/null
@@ -1,492 +0,0 @@
-defmodule TermUI.Parser do
- @moduledoc """
- Escape sequence parser for terminal input.
-
- Transforms raw terminal input bytes into structured events (key presses,
- mouse actions, paste content, focus changes).
- """
-
- import Bitwise
-
- alias TermUI.Parser.Events.{FocusEvent, KeyEvent, MouseEvent, PasteEvent}
-
- # Dialyzer: Functions return specific map types
- @dialyzer {:nowarn_function, new: 0}
-
- @type event :: KeyEvent.t() | MouseEvent.t() | PasteEvent.t() | FocusEvent.t()
-
- # Map control characters to key events
- @control_chars %{
- 0x00 => {" ", [:ctrl]},
- 0x01 => {"a", [:ctrl]},
- 0x02 => {"b", [:ctrl]},
- 0x03 => {"c", [:ctrl]},
- 0x04 => {"d", [:ctrl]},
- 0x05 => {"e", [:ctrl]},
- 0x06 => {"f", [:ctrl]},
- 0x07 => {"g", [:ctrl]},
- 0x08 => {:backspace, []},
- 0x09 => {:tab, []},
- 0x0A => {:enter, []},
- 0x0B => {"k", [:ctrl]},
- 0x0C => {"l", [:ctrl]},
- 0x0D => {:enter, []},
- 0x0E => {"n", [:ctrl]},
- 0x0F => {"o", [:ctrl]},
- 0x10 => {"p", [:ctrl]},
- 0x11 => {"q", [:ctrl]},
- 0x12 => {"r", [:ctrl]},
- 0x13 => {"s", [:ctrl]},
- 0x14 => {"t", [:ctrl]},
- 0x15 => {"u", [:ctrl]},
- 0x16 => {"v", [:ctrl]},
- 0x17 => {"w", [:ctrl]},
- 0x18 => {"x", [:ctrl]},
- 0x19 => {"y", [:ctrl]},
- 0x1A => {"z", [:ctrl]}
- }
-
- # Map CSI tilde codes to keys
- @csi_tilde_keys %{
- 1 => :home,
- 2 => :insert,
- 3 => :delete,
- 4 => :end,
- 5 => :page_up,
- 6 => :page_down,
- 15 => :f5,
- 17 => :f6,
- 18 => :f7,
- 19 => :f8,
- 20 => :f9,
- 21 => :f10,
- 23 => :f11,
- 24 => :f12,
- 200 => :paste_start,
- 201 => :paste_end
- }
-
- # Map CSI letter codes to keys
- @csi_letter_keys %{
- ?A => :up,
- ?B => :down,
- ?C => :right,
- ?D => :left,
- ?H => :home,
- ?F => :end,
- ?I => :focus_in,
- ?O => :focus_out
- }
-
- # Map SS3 codes to keys
- @ss3_keys %{
- ?A => :up,
- ?B => :down,
- ?C => :right,
- ?D => :left,
- ?H => :home,
- ?F => :end,
- ?P => :f1,
- ?Q => :f2,
- ?R => :f3,
- ?S => :f4
- }
-
- @type state :: %{
- mode: atom(),
- buffer: binary(),
- params: [integer()],
- paste_buffer: binary()
- }
-
- @doc """
- Creates a new parser state.
- """
- @spec new() :: state()
- def new do
- %{
- mode: :ground,
- buffer: <<>>,
- params: [],
- paste_buffer: <<>>
- }
- end
-
- @doc """
- Parses input bytes into events.
-
- Returns `{events, remaining_bytes, new_state}` where:
- - `events` - List of parsed events
- - `remaining_bytes` - Bytes that couldn't be parsed yet (incomplete sequences)
- - `new_state` - Parser state for next call
-
- ## Examples
-
- iex> {events, "", _state} = TermUI.Parser.parse("a", TermUI.Parser.new())
- iex> [%TermUI.Parser.Events.KeyEvent{key: "a"}] = events
- """
- @spec parse(binary(), state()) :: {[event()], binary(), state()}
- def parse(input, state) do
- parse_bytes(input, state, [])
- end
-
- @doc """
- Resets parser state while preserving configuration.
- """
- @spec reset(state()) :: state()
- def reset(_state) do
- new()
- end
-
- @doc """
- Flushes any pending escape sequence as an ESC key event.
-
- Call this after a timeout when parser is in :escape state.
- """
- @spec flush_escape(state()) :: {[event()], state()}
- def flush_escape(%{mode: :escape} = state) do
- event = %KeyEvent{key: :escape, modifiers: []}
- {[event], %{state | mode: :ground, buffer: <<>>}}
- end
-
- def flush_escape(state), do: {[], state}
-
- # Main parsing loop
- defp parse_bytes(<<>>, state, events) do
- {Enum.reverse(events), <<>>, state}
- end
-
- defp parse_bytes(input, %{mode: :ground} = state, events) do
- <> = input
-
- case byte do
- 0x1B ->
- parse_bytes(rest, %{state | mode: :escape, buffer: <<0x1B>>}, events)
-
- b when b in 0x00..0x1F ->
- event = parse_control_char(b)
- parse_bytes(rest, state, [event | events])
-
- b when b in 0x20..0x7E ->
- event = %KeyEvent{key: <>, modifiers: []}
- parse_bytes(rest, state, [event | events])
-
- 0x7F ->
- event = %KeyEvent{key: :backspace, modifiers: []}
- parse_bytes(rest, state, [event | events])
-
- _ ->
- parse_utf8(input, state, events)
- end
- end
-
- defp parse_bytes(input, %{mode: :escape} = state, events) do
- case input do
- <<>> ->
- {Enum.reverse(events), state.buffer, state}
-
- <<"[", rest::binary>> ->
- parse_bytes(rest, %{state | mode: :csi, buffer: <<>>, params: []}, events)
-
- <<"O", rest::binary>> ->
- parse_bytes(rest, %{state | mode: :ss3, buffer: <<>>}, events)
-
- <> when b in ?a..?z or b in ?A..?Z ->
- event = %KeyEvent{key: <>, modifiers: [:alt]}
- parse_bytes(rest, %{state | mode: :ground, buffer: <<>>}, [event | events])
-
- <<_b, _rest::binary>> ->
- event = %KeyEvent{key: :escape, modifiers: []}
- parse_bytes(input, %{state | mode: :ground, buffer: <<>>}, [event | events])
- end
- end
-
- defp parse_bytes(input, %{mode: :csi} = state, events) do
- case input do
- <<>> ->
- {Enum.reverse(events), <<0x1B, ?[, state.buffer::binary>>, state}
-
- <<"<", rest::binary>> ->
- parse_bytes(rest, %{state | mode: :sgr_mouse, buffer: <<>>, params: []}, events)
-
- <<"M", rest::binary>> when state.buffer == <<>> and state.params == [] ->
- parse_x10_mouse(rest, state, events)
-
- <> when b in ?0..?9 ->
- parse_bytes(rest, %{state | buffer: <>}, events)
-
- <<";", rest::binary>> ->
- param = parse_param(state.buffer)
- parse_bytes(rest, %{state | buffer: <<>>, params: state.params ++ [param]}, events)
-
- <> ->
- parse_csi_terminator(b, rest, state, events)
- end
- end
-
- defp parse_bytes(input, %{mode: :ss3} = state, events) do
- case input do
- <<>> ->
- {Enum.reverse(events), <<0x1B, ?O>>, state}
-
- <> when b in ?A..?Z or b in ?a..?z ->
- event = handle_ss3_key(b)
- parse_bytes(rest, %{state | mode: :ground, buffer: <<>>}, [event | events])
-
- <<_b, rest::binary>> ->
- parse_bytes(rest, %{state | mode: :ground, buffer: <<>>}, events)
- end
- end
-
- defp parse_bytes(input, %{mode: :sgr_mouse} = state, events) do
- case input do
- <<>> ->
- {Enum.reverse(events), <<0x1B, ?[, ?<, state.buffer::binary>>, state}
-
- <> when b in ?0..?9 ->
- parse_bytes(rest, %{state | buffer: <>}, events)
-
- <<";", rest::binary>> ->
- param = parse_param(state.buffer)
- parse_bytes(rest, %{state | buffer: <<>>, params: state.params ++ [param]}, events)
-
- <> when term in [?M, ?m] ->
- param = parse_param(state.buffer)
- params = state.params ++ [param]
- event = parse_sgr_mouse_event(params, term)
- parse_bytes(rest, %{state | mode: :ground, buffer: <<>>, params: []}, [event | events])
-
- <<_b, rest::binary>> ->
- parse_bytes(rest, %{state | mode: :ground, buffer: <<>>, params: []}, events)
- end
- end
-
- defp parse_bytes(input, %{mode: :paste} = state, events) do
- case :binary.match(input, <<0x1B, ?[, ?2, ?0, ?1, ?~>>) do
- {pos, 6} ->
- content = binary_part(input, 0, pos)
- rest = binary_part(input, pos + 6, byte_size(input) - pos - 6)
- full_content = <>
- event = %PasteEvent{content: full_content}
-
- parse_bytes(rest, %{state | mode: :ground, paste_buffer: <<>>}, [event | events])
-
- :nomatch ->
- {Enum.reverse(events), <<>>,
- %{state | paste_buffer: <>}}
- end
- end
-
- defp parse_bytes(input, state, events) do
- <<_byte, rest::binary>> = input
- parse_bytes(rest, %{state | mode: :ground}, events)
- end
-
- # Handle CSI terminator characters
- defp parse_csi_terminator(?~, rest, state, events) do
- {event, new_state} = handle_csi_tilde(state)
-
- events =
- if event == nil do
- events
- else
- [event | events]
- end
-
- parse_bytes(rest, new_state, events)
- end
-
- defp parse_csi_terminator(b, rest, state, events) when b in ?A..?Z do
- {event, new_state} = handle_csi_letter(b, state)
- parse_bytes(rest, new_state, [event | events])
- end
-
- defp parse_csi_terminator(_b, rest, state, events) do
- parse_bytes(rest, %{state | mode: :ground, buffer: <<>>, params: []}, events)
- end
-
- # Parse UTF-8 characters
- defp parse_utf8(input, state, events) do
- case input do
- <> ->
- event = %KeyEvent{key: <>, modifiers: []}
- parse_bytes(rest, state, [event | events])
-
- _ ->
- <<_byte, rest::binary>> = input
- parse_bytes(rest, state, events)
- end
- end
-
- # Parse control characters (Ctrl+key)
- defp parse_control_char(byte) do
- case Map.get(@control_chars, byte) do
- {key, modifiers} -> %KeyEvent{key: key, modifiers: modifiers}
- nil -> %KeyEvent{key: :unknown, modifiers: []}
- end
- end
-
- # Handle CSI sequences ending with ~
- defp handle_csi_tilde(state) do
- param = parse_param(state.buffer)
- params = state.params ++ [param]
- key = Map.get(@csi_tilde_keys, hd(params), :unknown)
- modifiers = extract_modifiers(params)
-
- case key do
- :paste_start ->
- {nil, %{state | mode: :paste, buffer: <<>>, params: [], paste_buffer: <<>>}}
-
- :paste_end ->
- {nil, %{state | mode: :ground, buffer: <<>>, params: []}}
-
- _ ->
- event = %KeyEvent{key: key, modifiers: modifiers}
- {event, %{state | mode: :ground, buffer: <<>>, params: []}}
- end
- end
-
- # Handle CSI sequences ending with a letter
- defp handle_csi_letter(letter, state) do
- param = if state.buffer == <<>>, do: 0, else: parse_param(state.buffer)
- params = if param == 0 and state.params == [], do: [], else: state.params ++ [param]
- key = Map.get(@csi_letter_keys, letter, :unknown)
- modifiers = extract_modifiers(params)
-
- case key do
- :focus_in ->
- event = %FocusEvent{focused: true}
- {event, %{state | mode: :ground, buffer: <<>>, params: []}}
-
- :focus_out ->
- event = %FocusEvent{focused: false}
- {event, %{state | mode: :ground, buffer: <<>>, params: []}}
-
- _ ->
- event = %KeyEvent{key: key, modifiers: modifiers}
- {event, %{state | mode: :ground, buffer: <<>>, params: []}}
- end
- end
-
- # Handle SS3 key sequences (F1-F4, arrow keys in application mode)
- defp handle_ss3_key(byte) do
- key = Map.get(@ss3_keys, byte, :unknown)
- %KeyEvent{key: key, modifiers: []}
- end
-
- # Parse X10 mouse event
- defp parse_x10_mouse(input, state, events) do
- case input do
- <