README
View Source
Cellium is a Terminal User Interface (TUI) framework for Erlang/OTP. It provides a declarative way to build interactive terminal applications using an architecture inspired by the Elm Architecture.
Core Architecture
Applications built with Cellium implement the cellium behaviour, which follows a strictly decoupled pattern:
- Model: The application state.
- Update: A function that transforms the model in response to messages (keyboard input, resize events, or internal timers).
- View: A function that transforms the model into a UI representation using a tuple-based DSL.
The cellium Behaviour
To create an application, implement the following callbacks:
init(Args): Initializes the application model.update(Model, Msg): Processes events and returns the updated model.render(Model): Returns the UI structure as a DSL tree.
Automatic Component State
Cellium simplifies interactive UIs by automatically managing the state of complex widgets (like text_input, list, checkbox, and gauge). This "Component Pattern" is inspired by modern frameworks like React (Controlled Components) and Phoenix LiveView (LiveComponents).
Instead of manually threading every keypress and update:
- Event Routing: When a widget has focus, the framework automatically routes keyboard events to that widget's internal handler.
- State Storage: The framework stores the internal state of widgets in a
widget_statesmap within your application's Model, keyed by the widget'sid. - Automatic Injection: During rendering, the DSL lookups the stored state by ID and merges it into the widget properties before drawing.
Example: Stateless Render
Notice how text_input and list are defined with only an id. The framework "fills in" the text and scroll position automatically from the Model.
render(Model) ->
{vbox, [], [
{text_input, [{id, my_search_box}, {expand, true}]},
{list, [{id, results_list}, {expand, true}]}
]}.Initializing State
You can provide initial values for components in your init/1 function:
init(_) ->
Model = #{
widget_states => #{
my_search_box => #{text => "Initial search..."},
results_list => #{items => ["Apple", "Banana", "Cherry"]}
}
},
{ok, Model}.Handling Custom Events
Interactive widgets emit high-level events to your update/2 function for application logic:
-
button: Emits{button_clicked, Id} -
radio: Emits{radio_selected, Id, Group} -
checkbox/text_input/list: Their internal state (checked, text, selection) is updated automatically in the Model.
Example
-module(counter).
-behaviour(cellium).
-export([init/1, update/2, render/1, start/0]).
init(_Args) ->
InitialCount = 0,
{ok, #{
count => InitialCount,
widget_states => #{
display => #{text => io_lib:format("Count: ~p", [InitialCount])}
}
}}.
update(Model = #{count := Count, widget_states := States}, Msg) ->
case Msg of
{button_clicked, plus_btn} ->
NewCount = Count + 1,
Model#{
count => NewCount,
widget_states => States#{display => #{text => io_lib:format("Count: ~p", [NewCount])}}
};
{button_clicked, minus_btn} ->
NewCount = Count - 1,
Model#{
count => NewCount,
widget_states => States#{display => #{text => io_lib:format("Count: ~p", [NewCount])}}
};
{key, _, _, _, _, <<"q">>} -> cellium:stop(), Model;
_ -> Model
end.
render(_Model) ->
{vbox, [{padding, 1}], [
{header, [], "Counter Example"},
{text, [{id, display}]},
{hbox, [{size, 1}], [
{button, [{id, plus_btn}, {size, 5}], "+"},
{spacer, [{size, 2}]},
{button, [{id, minus_btn}, {size, 5}], "-"}
]},
{text, [], "Press Tab to focus, Space/Enter to click, 'q' to quit"}
]}.
start() ->
cellium:start(#{module => ?MODULE}).Screen Management
Cellium provides a screen management system for applications with multiple views (e.g., search screen, customer form, settings dialog). The screen module handles screen lifecycle, transitions, and automatic focus cleanup.
Screen Lifecycle
Screens have four states:
- Created: Widget tree built but not registered
- Shown: Active, widgets registered with focus manager
- Hidden: Inactive, widgets unregistered but preserved
- Destroyed: Permanently removed
Basic Screen Transitions
% Create screens
SearchScreen = screen:new(search_screen,
cellium_dsl:from_dsl({vbox, [], [
{text_input, [{id, search_box}, {focusable, true}]},
{list, [{id, results_list}, {focusable, true}]}
]})),
CustomerScreen = screen:new(customer_form,
cellium_dsl:from_dsl({vbox, [], [
{text_input, [{id, name_field}, {focusable, true}]},
{button, [{id, save_btn}, {focusable, true}], "Save"}
]})),
% Transition between screens (automatic cleanup)
NewScreen = screen:transition(SearchScreen, CustomerScreen)Screen Stack
For modal dialogs or nested navigation, use the screen stack:
% Push a dialog (current screen hidden but preserved)
screen:push(ConfirmDialog),
% Pop back to previous screen
screen:pop(),
% Replace current screen entirely
screen:replace(NewScreen)Dynamic Screens with Builders
For screens that need fresh data on each display:
screen:new(
customer_form,
fun() ->
Data = fetch_customer_data(),
build_customer_form(Data)
end,
empty_widget()
)The builder function is called each time the screen is shown, ensuring data is current.
Layout System
Cellium uses a flexible layout engine that calculates absolute coordinates and dimensions based on container constraints and widget properties.
Positioning Modes
- Relative (Default): Widgets are positioned by their parent container (
vbox,hbox,grid, etc.) according to the container's orientation and expansion rules. - Absolute: By setting
{position, absolute}, a widget bypasses the layout engine. You must manually provide{x, X},{y, Y},{width, W}, and{height, H}.
Space Distribution
In relative layout, space is distributed along the container's primary axis (vertical for vbox, horizontal for hbox):
- Fixed Size: Use
{size, N}to request a fixed number of characters in the primary axis. - Expansion: Use
{expand, true}to request that the widget fill the remaining available space. - Automatic Splitting: If multiple widgets have
{expand, true}, they split the remaining space equally. - Default Behavior: If neither
sizenorexpandis specified, a default{size, 1}is applied.
Padding
Padding can be applied to any container or widget to create space between the border and the content:
- Uniform:
{padding, 1}(1 character on all four sides). - Specific:
{padding, #{top => 1, bottom => 1, left => 2, right => 2}}.
Width and Height
While size controls the dimension along the container's primary axis, width and height can be used to explicitly set the dimension along the cross-axis or for absolutely positioned widgets.
UI Pipeline
- DSL: The
render/1function returns a high-level DSL (e.g.,{vbox, Props, Children}). - Processing:
cellium_dslconverts the DSL into a tree of internal widget maps. - Layout: The
layoutengine calculates absolute coordinates (x, y) and final dimensions for every widget based on constraints and expansion rules. - Styling: A CSS-like engine (
css) applies visual properties (colors, borders) from cached stylesheets. - Rendering: The
viewprocess utilizes thenative_terminaldriver to draw the final representation to the terminal screen.
Project Structure
src/: Core framework source code, including the layout engine and terminal drivers.examples/: Sample applications demonstrating widgets and architectural patterns.include/: Common header files and macro definitions.priv/: Default stylesheets and theme configuration.
Build and Run
Prerequisites
- Erlang/OTP 26 or later.
- rebar3.
Compilation
rebar3 compile
Running Examples
Use the provided Makefile to execute example applications:
make run example=counter
make run example=widgets_gallery
Development
- Testing:
rebar3 eunit - Logging: Logs are written to
./logs/cellium-debugby default. Logging configuration is managed insrc/logging.erl.
Documentation
- API docs https://wmealing.github.io/cellium/api-reference.html
- Occasional discussions https://wmealing.github.io/