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.
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.
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.
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.
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}]}
]}.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}.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.
-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}).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.
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
% 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)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)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.
Cellium uses a flexible layout engine that calculates absolute coordinates and dimensions based on container constraints and widget properties.
- 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}.
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 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}}.
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.
- 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.
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.
- Erlang/OTP 26 or later.
- rebar3.
rebar3 compileUse the provided Makefile to execute example applications:
make run example=counter
make run example=widgets_gallery- Testing:
rebar3 eunit - Logging: Logs are written to
./logs/cellium-debugby default. Logging configuration is managed insrc/logging.erl.
- API docs https://wmealing.github.io/cellium/api-reference.html
- Occasional discussions https://wmealing.github.io/
