Creating New Widgets
View SourceThis guide explains how to create new widgets for TermUI and contribute them to the project.
Widget Types
TermUI supports two types of widgets:
The default Elm runtime does not mount either type automatically. Applications call stateless render functions from the root view and keep stateful widget state inside the root Elm state.
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/:
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
endKey Points for Stateless Widgets
- Import RenderNode helpers:
import TermUI.Component.RenderNode - Use
Keyword.fetch!/2for required options - Use
Keyword.get/3for optional options with defaults - Return a RenderNode struct from
render/1 - Provide convenience functions like
simple/2for common use cases
Creating a Stateful Widget
Step 1: Create the Widget Module
defmodule TermUI.Widgets.MyStatefulWidget do
@moduledoc """
MyStatefulWidget provides [description].
## Usage
MyStatefulWidget.new(items: ["one", "two", "three"])
## Keyboard Controls
- Up/Down: Navigate items
- Enter: Select current item
"""
use TermUI.StatefulComponent
alias TermUI.Event
# Constructor for props
@doc """
Creates widget props.
## Options
- `:items` - List of items (required)
- `:style` - Style for normal items
- `:selected_style` - Style for selected item
"""
@spec new(keyword()) :: map()
def new(opts) do
%{
items: Keyword.fetch!(opts, :items),
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,
selected: nil,
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 = max(0, 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
{:ok, %{state | selected: Enum.at(state.items, state.cursor)}}
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
@doc "Returns the most recently selected item."
def get_selected(state), do: state.selected
endKey Points for Stateful Widgets
- Use the behaviour:
use TermUI.StatefulComponent - Provide
new/1to create props from options - Implement
init/1to initialize state from props - Implement
handle_event/2for user interactions - Implement
render/2to produce the render tree - Return
{:ok, state}or{:ok, state, commands}from event handlers
The normal Elm runtime embeds this state in the root. If an event handler
returns component commands, the root must translate them explicitly into root
messages or TermUI.Command values. Also avoid effectful widget callbacks when
calling handle_event/2 from the root's pure update/2; record an action in
widget state (as above) and let the root return an appropriate command instead.
Writing Tests
Tests are required for all new widgets. See Testing Framework for comprehensive testing documentation.
Create a test file in test/term_ui/widgets/:
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
endTest Categories to Cover
- Required options - Verify required params raise on missing
- Default values - Test behavior with minimal options
- All options - Test each option individually
- Edge cases - Empty data, zero values, extreme values
- Styling - Verify styles are applied correctly
- 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.exChecklist Before Submitting a PR
Code Quality
- [ ] Widget has comprehensive
@moduledocwith usage examples - [ ] All public functions have
@docand@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/1ornew/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
git checkout -b feature/my-widget
2. Implement and Test
# 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
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
- Tests must pass - CI will verify this
- Tests must be included - PRs without tests will not be merged
- Code must be formatted - Run
mix format - 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 - Comprehensive testing guide
- Architecture Overview - Understand the system
- Elm Implementation - Learn the component model
- Rendering Pipeline - How widgets become output