Building Apps
View SourceYou've built a counter. Now let's look at patterns you'll reach for in real apps.
All code below assumes
use Raxol.Core.Runtime.Application, which aliasesRaxol.Core.Events.EventasEvent. You can write%Event{...}instead of the full module path.
State Design
Flat state with derived values
Keep your model flat. Derive display values in view/1, not update/2:
# Model -- just raw data
%{
items: ["milk", "eggs", "bread"],
cursor: 0,
filter: "",
editing: false
}
# Derive in view
def view(model) do
filtered = Enum.filter(model.items, &String.contains?(&1, model.filter))
visible_count = length(filtered)
# ...render filtered, visible_count...
endState machines via pattern matching
Use atoms for mode, pattern match in both update/2 and view/1:
def init(_ctx), do: %{mode: :browsing, items: [], selected: nil}
def update(msg, %{mode: :browsing} = model) do
case msg do
%Raxol.Core.Events.Event{type: :key, data: %{key: :enter}} ->
{%{model | mode: :editing}, []}
%Raxol.Core.Events.Event{type: :key, data: %{key: :char, char: "/"}} ->
{%{model | mode: :searching, filter: ""}, []}
_ -> {model, []}
end
end
def update(msg, %{mode: :searching} = model) do
case msg do
%Raxol.Core.Events.Event{type: :key, data: %{key: :escape}} ->
{%{model | mode: :browsing, filter: ""}, []}
%Raxol.Core.Events.Event{type: :key, data: %{key: :char, char: c}} ->
{%{model | filter: model.filter <> c}, []}
_ -> {model, []}
end
end
def view(%{mode: :browsing} = model), do: render_browser(model)
def view(%{mode: :searching} = model), do: render_search(model)
def view(%{mode: :editing} = model), do: render_editor(model)Common Recipes
These patterns solve real problems. Use state machines when your app has distinct modes (browsing vs editing vs searching). Use scrollable lists when you have more items than fit on screen. Use keychord sequences for Vim-style multi-key commands.
Scrollable list
def init(_ctx) do
%{
items: Enum.map(1..100, &"Item #{&1}"),
cursor: 0,
scroll_offset: 0,
visible_rows: 20
}
end
def update(msg, model) do
case msg do
%Raxol.Core.Events.Event{type: :key, data: %{key: :down}} ->
new_cursor = min(model.cursor + 1, length(model.items) - 1)
scroll = adjust_scroll(new_cursor, model.scroll_offset, model.visible_rows)
{%{model | cursor: new_cursor, scroll_offset: scroll}, []}
%Raxol.Core.Events.Event{type: :key, data: %{key: :up}} ->
new_cursor = max(model.cursor - 1, 0)
scroll = adjust_scroll(new_cursor, model.scroll_offset, model.visible_rows)
{%{model | cursor: new_cursor, scroll_offset: scroll}, []}
_ -> {model, []}
end
end
defp adjust_scroll(cursor, offset, visible) do
cond do
cursor < offset -> cursor
cursor >= offset + visible -> cursor - visible + 1
true -> offset
end
end
def view(model) do
visible_items =
model.items
|> Enum.slice(model.scroll_offset, model.visible_rows)
|> Enum.with_index(model.scroll_offset)
column do
Enum.map(visible_items, fn {item, idx} ->
if idx == model.cursor do
text("> #{item}", fg: :cyan, style: [:bold])
else
text(" #{item}")
end
end)
end
endSee examples/apps/todo_app.ex for a working scrollable list.
Periodic data refresh
def subscribe(_model) do
[subscribe_interval(1000, :refresh)]
end
def update(:refresh, model) do
stats = %{
memory: :erlang.memory(:total) |> div(1024 * 1024),
processes: :erlang.system_info(:process_count),
uptime: :erlang.statistics(:wall_clock) |> elem(0) |> div(1000)
}
{%{model | stats: stats}, []}
endConfirmation dialog
def update(:delete_pressed, model) do
{%{model | confirm: "Delete #{model.selected}?"}, []}
end
def update(:confirm_yes, model) do
items = List.delete(model.items, model.selected)
{%{model | items: items, confirm: nil, selected: nil}, []}
end
def update(:confirm_no, model) do
{%{model | confirm: nil}, []}
end
def view(%{confirm: msg} = model) when is_binary(msg) do
column do
[
render_main(model),
box style: %{border: :double, padding: 1, width: 40} do
column style: %{gap: 1} do
[
text(msg, fg: :yellow, style: [:bold]),
row style: %{gap: 2} do
[
button("Yes", on_click: :confirm_yes),
button("No", on_click: :confirm_no)
]
end
]
end
end
]
end
endMulti-panel layout with Tab switching
@panels [:files, :preview, :log]
def update(%Raxol.Core.Events.Event{type: :key, data: %{key: :tab}}, model) do
current = Enum.find_index(@panels, &(&1 == model.panel))
next = Enum.at(@panels, rem(current + 1, length(@panels)))
{%{model | panel: next}, []}
end
def view(model) do
column do
[
# Tab bar
row style: %{height: 1} do
Enum.map(@panels, fn panel ->
if panel == model.panel do
text(" #{panel} ", fg: :black, bg: :cyan, style: [:bold])
else
text(" #{panel} ", fg: :white)
end
end)
end,
# Active panel content
box style: %{border: :single, flex: 1} do
render_panel(model.panel, model)
end
]
end
endSparkline helper
Render inline charts from a list of values:
@spark_chars ~w(▁ ▂ ▃ ▄ ▅ ▆ ▇ █)
defp sparkline(values) when values == [], do: ""
defp sparkline(values) do
max_val = Enum.max(values)
if max_val == 0 do
String.duplicate(hd(@spark_chars), length(values))
else
values
|> Enum.map(fn v ->
idx = trunc(v / max_val * 7)
Enum.at(@spark_chars, min(idx, 7))
end)
|> Enum.join()
end
end
# Usage in view:
text("Memory: #{sparkline(model.memory_history)}", fg: :green)See examples/demo.exs for sparklines in action.
Progress bar helper
defp progress_bar(value, max, width) do
pct = if max > 0, do: value / max, else: 0
filled = trunc(pct * width)
empty = width - filled
String.duplicate("█", filled) <> String.duplicate("░", empty)
end
# Usage:
text("[#{progress_bar(75, 100, 30)}] 75%", fg: :cyan)Keyboard Patterns
Vim-style keybindings
def update(%Raxol.Core.Events.Event{type: :key, data: %{key: :char, char: "j"}}, model) do
# Down
{%{model | cursor: min(model.cursor + 1, length(model.items) - 1)}, []}
end
def update(%Raxol.Core.Events.Event{type: :key, data: %{key: :char, char: "k"}}, model) do
# Up
{%{model | cursor: max(model.cursor - 1, 0)}, []}
end
def update(%Raxol.Core.Events.Event{type: :key, data: %{key: :char, char: "g", ctrl: false}}, model) do
# Go to top
{%{model | cursor: 0}, []}
end
def update(%Raxol.Core.Events.Event{type: :key, data: %{key: :char, char: "G"}}, model) do
# Go to bottom
{%{model | cursor: length(model.items) - 1}, []}
endKey chord sequences
Track a key buffer for multi-key commands:
def update(%Raxol.Core.Events.Event{type: :key, data: %{key: :char, char: c}}, model) do
chord = model.key_buffer <> c
case chord do
"dd" -> {%{model | items: delete_current(model), key_buffer: ""}, []}
"gg" -> {%{model | cursor: 0, key_buffer: ""}, []}
_ when byte_size(chord) >= 2 -> {%{model | key_buffer: ""}, []}
_ -> {%{model | key_buffer: chord}, []}
end
endStyling Patterns
Color by value
defp status_color(:ok), do: :green
defp status_color(:warning), do: :yellow
defp status_color(:error), do: :red
defp status_color(_), do: :white
defp cpu_color(pct) when pct > 90, do: :red
defp cpu_color(pct) when pct > 70, do: :yellow
defp cpu_color(_), do: :greenBordered panels with titles
defp panel(title, opts \\ [], do: content) do
fg = Keyword.get(opts, :fg, :cyan)
box style: %{border: :single, flex: 1, padding: 0} do
column do
[
text(" #{title} ", fg: fg, style: [:bold]),
text(String.duplicate("─", 40), fg: :magenta),
content
]
end
end
endConditional content
def view(model) do
column do
[
text("Status: #{model.status}"),
if model.loading do
text("Loading...", fg: :yellow)
else
text("Ready", fg: :green)
end,
if model.error do
text("Error: #{model.error}", fg: :red)
end
]
|> List.flatten()
|> Enum.reject(&is_nil/1)
end
endTesting
Test your update function
update/2 is a pure function -- test it directly:
test "increment increases count" do
model = %{count: 0}
{new_model, cmds} = MyApp.update(:increment, model)
assert new_model.count == 1
assert cmds == []
end
test "quit sends command" do
model = %{count: 0}
quit_event = %Raxol.Core.Events.Event{
type: :key, data: %{key: :char, char: "q"}
}
{_model, cmds} = MyApp.update(quit_event, model)
assert [{:quit, _}] = cmds
endTest state transitions
test "search mode filters items" do
model = %{mode: :browsing, items: ["apple", "banana", "avocado"], filter: ""}
# Enter search mode
{model, _} = MyApp.update(key("/"), model)
assert model.mode == :searching
# Type filter
{model, _} = MyApp.update(key("a"), model)
assert model.filter == "a"
# Escape returns to browsing
{model, _} = MyApp.update(key(:escape), model)
assert model.mode == :browsing
assert model.filter == ""
endNext Steps
- SSH Deployment -- Serve apps over SSH
- Theming -- Custom color schemes
- Performance -- 60fps techniques
- Widget Gallery -- All widgets with examples