Skip to content

dengo07/strata-tui

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

22 Commits
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

Strata TUI Framework — Documentation

Strata is a retained-mode C++17 TUI (terminal UI) framework built on ncurses. Widgets own their state, declare a constraint-based layout, and re-render automatically whenever state changes. The ncurses backend is hidden behind a pure-virtual Backend interface, making it straightforward to swap for testing or for future backend implementations.


Table of Contents

  1. Getting Started
  2. Tutorial
  3. Core Concepts
  4. DSL Reference (ui.hpp)
  5. Widget Reference
  6. App API Reference
  7. Adding a Custom Widget
  8. Patterns & Recipes

1. Getting Started

Dependencies

Dependency Package (Debian/Ubuntu)
ncursesw (wide-char ncurses) libncursesw-dev
pkg-config pkg-config
CMake ≥ 3.16 cmake
C++17 compiler g++ or clang++
sudo apt install libncursesw-dev pkg-config cmake g++

Build

git clone <repo> cd strata mkdir build && cd build cmake .. make ./strata_test # run the built-in demo

CMake discovers all source files via GLOB_RECURSE. After adding a new .cpp file, re-run cmake .. from the build directory.

Install system-wide (like ncurses)

git clone https://github.com/dengo07/strata-tui cd strata-tui mkdir build && cd build cmake .. -DCMAKE_INSTALL_PREFIX=/usr/local make -j$(nproc) sudo make install

This installs:

  • libstrata.a/usr/local/lib/
  • Headers → /usr/local/include/Strata/
  • CMake package → /usr/local/lib/cmake/Strata/
  • pkg-config file → /usr/local/lib/pkgconfig/strata.pc

Linking your own project

Option A — CMake (recommended)

cmake_minimum_required(VERSION 3.16) project(MyApp LANGUAGES CXX) set(CMAKE_CXX_STANDARD 17) find_package(Strata REQUIRED) add_executable(myapp main.cpp) target_link_libraries(myapp PRIVATE Strata::strata)

Option B — pkg-config

g++ -std=c++17 main.cpp -o myapp $(pkg-config --cflags --libs strata)

Option C — embed as subdirectory (no install needed)

add_subdirectory(path/to/strata-tui) target_link_libraries(myapp PRIVATE strata)

Two include modes

// Low-level (full widget API, manual tree construction) #include <Strata/strata.hpp> // DSL layer (declarative, builder-pattern — recommended for most apps) #include <Strata/ui.hpp> using namespace strata::ui;

ui.hpp includes everything from strata.hpp plus the declarative descriptor types.


2. Tutorial

2a. Hello World

A centered greeting box — showcases Block, nested Col, and justify/cross-axis centering in one small example.

#include <Strata/ui.hpp> using namespace strata::ui; int main() { App app; populate(app, { Col({ Block(" Hello World ") .border(Style{}.with_fg(color::Cyan).with_bold()) .inner( Col({ Label("Hello, Strata!") .style(Style{}.with_fg(color::BrightWhite).with_bold()) .size(fixed(1)), Label("").size(fixed(1)), Label("press q to quit") .style(Style{}.with_fg(color::BrightBlack)) .size(fixed(1)), }).justify(Layout::Justify::Center) .cross_align(Layout::Align::Center) ) .size(fixed(7)) .cross(fixed(36)), }).justify(Layout::Justify::Center) .cross_align(Layout::Align::Center) }); app.on_event = [&](const Event& e) { if (is_char(e, 'q')) app.quit(); }; app.run(); }

What's happening:

  • App initialises the ncurses backend and creates a root Container.
  • populate() adds each top-level Node to that root container.
  • app.run() blocks in an event loop until app.quit() is called.
  • The outer Col with justify(Center) + cross_align(Center) centers the box vertically and horizontally on screen.
  • Block.size(fixed(7)).cross(fixed(36)) pins the box to 7 rows × 36 columns regardless of terminal size.
  • on_event is called for every event not consumed by a focused widget — use it for global hotkeys.

2b. Counter

Two bordered buttons that increment/decrement an integer, displayed in a styled panel — centered on screen.

#include <Strata/ui.hpp> using namespace strata::ui; int main() { App app; int count = 0; strata::Label* lbl = nullptr; populate(app, { Block("Counter App") .border(Style{}.with_bg(color::Black)) .inner( Col({ Block() .border(Style{}.with_fg(color::Red).with_bg(color::Black)) .inner( Col({ Label("0") .style(Style{}.with_fg(color::White).with_bold()) .size(fixed(1)) .cross(fixed(4)) .bind(lbl) }).justify(Layout::Justify::Center) .cross_align(Layout::Align::Center) ) .size(fixed(10)) .cross(fixed(50)), Row({ Button("-") .style(Style{}.with_bg(color::BrightRed).with_fg(color::White).with_bold()) .focused_style(Style{}.with_bg(color::BrightRed).with_fg(color::rgb(0,0,0)).with_bold()) .click([&]{ lbl->set_text(std::to_string(--count)); }) .size(fixed(10)) .cross(fixed(4)), Button("+") .style(Style{}.with_bg(color::BrightGreen).with_fg(color::Black).with_bold()) .focused_style(Style{}.with_bg(color::BrightGreen).with_fg(color::rgb(0,0,0)).with_bold()) .click([&]{ lbl->set_text(std::to_string(++count)); }) .size(fixed(10)) .cross(fixed(4)), }).justify(Layout::Justify::Center) .gap(1) .size(fixed(4)), }).justify(Layout::Justify::Center) .cross_align(Layout::Align::Center) ) }); app.on_event = [&](const Event& e) { if (is_char(e, 'q')) app.quit(); }; app.run(); }

Key ideas:

  • .bind(lbl) stores the raw pointer to the created widget so you can call setters later.
  • .click(fn) sets the on_click callback on the Button.
  • set_text() calls mark_dirty() internally, scheduling a redraw.
  • Row.size(fixed(4)) fixes the button row height so justify(Center) on the outer Col has no fill child and can distribute vertical space evenly.
  • Buttons use size(fixed(10)).cross(fixed(4)) — width 10, height 4. At body_h = 3 (unfocused) the bordered rendering tier activates automatically.
  • cross_align(Center) on the outer Col keeps the display block (50 wide) horizontally centered regardless of terminal width.

2c. To-Do List

An Input for adding items, ForEach for rendering the reactive list, a Select filter, and a modal confirmation for deletion — all in one panel. Mutating state automatically updates the list and stats.

#include <Strata/ui.hpp> using namespace strata::ui; #include <string> #include <algorithm> int main() { App app; struct Item { int id; std::string text; bool done = false; }; // ── Reactive state ───────────────────────────────────────────────────── auto items = make_state(std::vector<Item>{}); int next_id = 0; int filter_mode = 0; // 0=All 1=Active 2=Done int selected_id = -1; // id of the last focused checkbox strata::Label* stats_lbl = nullptr; strata::Input* input_widget = nullptr; int confirm_id = -1; std::string del_text; // text of item pending deletion // Stats label subscribes to items — updates automatically on every change items.on_change([&]{ if (!stats_lbl) return; int done = 0; for (const auto& it : items.get()) if (it.done) ++done; stats_lbl->set_text(std::to_string(done) + "/" + std::to_string(items->size()) + " done"); }); populate(app, { Block(" To-Do List ") .border(Style{}.with_fg(color::Cyan).with_bold()) .inner( Col({ Row({ Label(" Add: ").style(Style{}.with_fg(color::BrightBlack)).size(fixed(7)), Input() .placeholder("type and press Enter…") .group("input").size(fill()).bind(input_widget) .submit([&](const std::string& val) { if (val.empty()) return; int id = next_id++; items.mutate([&](auto& v){ v.push_back({id, val}); }); input_widget->set_value(""); }), }).size(fixed(1)), Row({ Label(" Filter: ").style(Style{}.with_fg(color::BrightBlack)).size(fixed(9)), Select() .items({"All", "Active", "Done"}) .group("filter").size(fill()) .change([&](int mode, const std::string&) { filter_mode = mode; // Empty mutate — triggers ForEach to re-filter items.mutate([](auto&){}); }), Label("0/0 done").style(Style{}.with_fg(color::BrightBlack)) .size(fixed(10)).bind(stats_lbl), }).size(fixed(1)), Label("").size(fixed(1)), // ForEach re-runs whenever items changes (including filter change above) ForEach(items, [&](const Item& item, int) -> std::optional<Node> { if (filter_mode == 1 && item.done) return std::nullopt; if (filter_mode == 2 && !item.done) return std::nullopt; int id = item.id; return Checkbox(item.text) .checked(item.done) .group("list") .style(Style{}.with_fg(color::White)) .focused_style(Style{}.with_fg(color::Black).with_bg(color::Cyan)) .focused([&selected_id, id]{ selected_id = id; }) .change([&items, id](bool checked) { items.mutate([id, checked](auto& v) { for (auto& it : v) if (it.id == id) { it.done = checked; break; } }); }) .size(fixed(1)); }), Label(" Tab: switch section · d: delete item") .style(Style{}.with_fg(color::BrightBlack)).size(fixed(1)), }) ) }); app.on_event = [&](const Event& e) { if (app.has_modal()) return; if (is_char(e, 'q')) app.quit(); if (is_char(e, 'd') && selected_id >= 0) { // Find text for the modal message del_text.clear(); for (const auto& it : items.get()) if (it.id == selected_id) { del_text = it.text; break; } if (del_text.empty()) return; confirm_id = app.open_modal( ModalDesc() .title(" Delete Item ") .size(44, 8) .border(Style{}.with_fg(color::Red)) .on_close([&]{ app.close_modal(confirm_id); }) .inner( Col({ Label(" Delete \"" + del_text + "\"?") .style(Style{}.with_fg(color::White)).size(fixed(1)), Label("").size(fixed(1)), Row({ Button("Yes") .style(Style{}.with_bg(color::Red).with_fg(color::White).with_bold()) .focused_style(Style{}.with_bg(color::BrightRed).with_fg(color::White).with_bold()) .click([&]{ app.close_modal(confirm_id); int sid = selected_id; items.mutate([sid](auto& v) { v.erase(std::remove_if(v.begin(), v.end(), [sid](const Item& it){ return it.id == sid; }), v.end()); }); selected_id = -1; }) .size(fixed(10)).cross(fixed(4)), Button("No") .style(Style{}.with_bg(color::BrightBlack).with_fg(color::White)) .focused_style(Style{}.with_bg(color::White).with_fg(color::Black)) .click([&]{ app.close_modal(confirm_id); }) .size(fixed(10)).cross(fixed(4)), }).gap(2).justify(Layout::Justify::Center).size(fixed(4)), }).justify(Layout::Justify::Center) ) .build_modal() ); } }; app.run(); }

Key ideas:

  • make_state replaces std::vector<Item> — every mutate() fires all listeners.
  • ForEach renders the list. The builder lambda captures filter_mode by reference; returning std::nullopt for non-matching items acts as a filter with no extra state.
  • Filter change: items.mutate([](auto&){}) is an empty (no-op) mutation that fires listeners, forcing ForEach to re-run the builder with the new filter_mode value.
  • .focused(fn) on each Checkbox sets selected_id when focus arrives — replaces the old Item::widget pointer and is_focused() scan.
  • .change(fn) on each Checkbox calls items.mutate(...) with the item's stable id. Mutating triggers the stats subscriber and the ForEach rebuild in one step.
  • Stats label subscribes once via items.on_change(...) — no explicit update_stats() call needed anywhere else.
  • Delete uses a modal for confirmation; the Yes button calls items.mutate(...) to erase by id — no index arithmetic needed.
  • app.has_modal() guard prevents global hotkeys from firing while the modal is open.

2d. Reactive Kanban Board

Three columns driven by Reactive<vector<Card>> and ForEach — a Flutter/SwiftUI-style showcase where mutating state automatically rebuilds only the affected widgets. No manual rebuild() function, no stored widget pointers per card.

#include <Strata/ui.hpp> using namespace strata::ui; #include <string> #include <algorithm> int main() { App app; struct Card { int id; std::string text; int col = 0; }; // ── Reactive state ───────────────────────────────────────────────────── // Mutate this anywhere; the three ForEach columns rebuild automatically. auto cards = make_state(std::vector<Card>{}); int next_id = 0; int selected_id = -1; // tracks which card last gained focus static const char* const kNames[3] = { "Backlog", "In Progress", "Done" }; static const char* const kGroups[3] = { "backlog", "progress", "done" }; auto card_style = [](int c) -> Style { if (c == 0) return Style{}.with_fg(color::BrightBlue); if (c == 1) return Style{}.with_fg(color::BrightYellow); return Style{}.with_fg(color::BrightGreen); }; auto card_focused_style = [](int c) -> Style { if (c == 0) return Style{}.with_fg(color::Black).with_bg(color::BrightBlue).with_bold(); if (c == 1) return Style{}.with_fg(color::Black).with_bg(color::BrightYellow).with_bold(); return Style{}.with_fg(color::Black).with_bg(color::BrightGreen).with_bold(); }; strata::Input* inp = nullptr; strata::Select* col_sel = nullptr; populate(app, { Col({ Label(" Kanban ─ m: move right d: delete c: clear column q: quit") .style(Style{}.with_fg(color::BrightBlack)).size(fixed(1)), Label("").size(fixed(1)), Row({ Label(" Add to: ").style(Style{}.with_fg(color::BrightBlack)) .size(fixed(9)).cross(fixed(1)), Select().items({"Backlog", "In Progress", "Done"}) .group("input").size(fixed(16)).cross(fixed(1)).bind(col_sel), Label(" ").size(fixed(2)).cross(fixed(1)), Block().inner( Input() .placeholder("card title, then Enter…") .group("input").bind(inp) .submit([&](const std::string& val) { if (val.empty()) return; int id = next_id++, c = col_sel ? col_sel->selected() : 0; cards.mutate([&](auto& v){ v.push_back({id, val, c}); }); inp->set_value(""); }) ).border(Style{}.with_fg(color::White)).size(fill()), }).size(fixed(3)).cross_align(Layout::Align::Center), Label("").size(fixed(1)), Row({ Block(" Backlog ") .focused_title(Style{}.with_bg(color::Blue)) .border(Style{}.with_fg(color::Blue).with_bold()) .inner( ForEach(cards, [&](const Card& card, int) -> std::optional<Node> { if (card.col != 0) return std::nullopt; int id = card.id; return Checkbox(card.text).group(kGroups[0]) .style(card_style(0)).focused_style(card_focused_style(0)) .focused([&selected_id, id]{ selected_id = id; }) .size(fixed(1)); }) ), Block(" In Progress ") .focused_title(Style{}.with_bg(color::Yellow)) .border(Style{}.with_fg(color::Yellow).with_bold()) .inner( ForEach(cards, [&](const Card& card, int) -> std::optional<Node> { if (card.col != 1) return std::nullopt; int id = card.id; return Checkbox(card.text).group(kGroups[1]) .style(card_style(1)).focused_style(card_focused_style(1)) .focused([&selected_id, id]{ selected_id = id; }) .size(fixed(1)); }) ), Block(" Done ") .focused_title(Style{}.with_bg(color::Green)) .border(Style{}.with_fg(color::Green).with_bold()) .inner( ForEach(cards, [&](const Card& card, int) -> std::optional<Node> { if (card.col != 2) return std::nullopt; int id = card.id; return Checkbox(card.text).group(kGroups[2]) .style(card_style(2)).focused_style(card_focused_style(2)) .focused([&selected_id, id]{ selected_id = id; }) .size(fixed(1)); }) ), }), }) }); app.on_event = [&](const Event& e) { if (is_char(e, 'q')) { app.quit(); return; } if (selected_id < 0) return; if (is_char(e, 'm')) { int to = -1; cards.mutate([&](auto& v) { for (auto& c : v) if (c.id == selected_id) { to = (c.col+1)%3; c.col = to; break; } }); if (to >= 0) app.notify("Moved to " + std::string(kNames[to]), "", strata::AlertManager::Level::Info, 1500); } if (is_char(e, 'd')) { std::string txt; for (const auto& c : cards.get()) if (c.id == selected_id) { txt = c.text; break; } app.notify("Deleted", txt, strata::AlertManager::Level::Warning, 1500); cards.mutate([&](auto& v){ v.erase(std::remove_if(v.begin(), v.end(), [&](const Card& c){ return c.id == selected_id; }), v.end()); }); selected_id = -1; } if (is_char(e, 'c')) { int col = -1; for (const auto& c : cards.get()) if (c.id == selected_id) { col = c.col; break; } if (col >= 0) { app.notify("Cleared " + std::string(kNames[col]), "", strata::AlertManager::Level::Warning, 1500); cards.mutate([col](auto& v){ v.erase(std::remove_if(v.begin(), v.end(), [col](const Card& c){ return c.col == col; }), v.end()); }); selected_id = -1; } } }; app.run(); }

Key ideas:

  • make_state() creates a Reactive<vector<Card>>. One mutate() call fires all subscribed ForEach columns, which each independently rebuild their widget list.
  • ForEach takes the reactive vector and a builder lambda. Return std::nullopt to filter out items (each column filters by card.col). Return a Node to render it.
  • selected_id tracks which card last gained focus via .focused(...) callback on each Checkbox. This replaces the old Card::widget pointer — no raw widget pointers in the data model.
  • No rebuild() function: adding, moving, and deleting cards is just cards.mutate(...) — the UI updates automatically.
  • card.id is a stable integer identity (independent of vector index) so captures in .focused() remain valid after mutations reorder the vector.
  • Toast notifications via app.notify() confirm each action.

2e. Dashboard with Async Updates

Three live metrics read from the Linux /proc filesystem — CPU usage, memory pressure, and network throughput — with color-coded status labels and a Spinner for visual feedback.

#include <Strata/ui.hpp> using namespace strata::ui; #include <memory> #include <array> #include <fstream> #include <sstream> #include <algorithm> int main() { App app; int tick = 0; strata::ProgressBar* cpu_bar = nullptr; strata::ProgressBar* mem_bar = nullptr; strata::ProgressBar* net_bar = nullptr; strata::Label* cpu_st = nullptr; strata::Label* mem_st = nullptr; strata::Label* net_st = nullptr; strata::Label* info_lbl = nullptr; auto threshold_style = [](float v) -> Style { if (v < 0.6f) return Style{}.with_fg(color::BrightGreen); if (v < 0.8f) return Style{}.with_fg(color::BrightYellow); return Style{}.with_fg(color::BrightRed).with_bold(); }; auto threshold_text = [](float v) -> std::string { if (v < 0.6f) return " OK "; if (v < 0.8f) return "WARN "; return "CRIT!"; }; populate(app, { Col({ Block(" System Monitor ") .border(Style{}.with_fg(color::Blue).with_bold()) .inner( Col({ Row({ Label("CPU ").style(Style{}.with_fg(color::BrightWhite).with_bold()).size(fixed(8)), ProgressBar().size(fill()).bind(cpu_bar), Label(" OK ").style(Style{}.with_fg(color::BrightGreen)).size(fixed(6)).bind(cpu_st), }).size(fixed(1)), Label("").size(fixed(1)), Row({ Label("Memory ").style(Style{}.with_fg(color::BrightWhite).with_bold()).size(fixed(8)), ProgressBar().size(fill()).bind(mem_bar), Label(" OK ").style(Style{}.with_fg(color::BrightGreen)).size(fixed(6)).bind(mem_st), }).size(fixed(1)), Label("").size(fixed(1)), Row({ Label("Network").style(Style{}.with_fg(color::BrightWhite).with_bold()).size(fixed(8)), ProgressBar().size(fill()).bind(net_bar), Label(" OK ").style(Style{}.with_fg(color::BrightGreen)).size(fixed(6)).bind(net_st), }).size(fixed(1)), Label("").size(fixed(1)), Row({ Spinner("").auto_animate(true, 4).size(fixed(3)), Label("Waiting for first sample…") .style(Style{}.with_fg(color::BrightBlack)) .size(fill()) .bind(info_lbl), }).size(fixed(1)), }) ) .size(fixed(13)) .cross(fixed(56)), }).justify(Layout::Justify::Center) .cross_align(Layout::Align::Center) }); // Delta state for CPU and network (only accessed from the bg thread) struct ProcState { long long cpu_idle = 0; long long cpu_total = 0; long long net_bytes = 0; }; auto state = std::make_shared<ProcState>(); struct Vals { float cpu = 0, mem = 0, net = 0; std::string info; }; app.set_interval(1000, [&, state]{ ++tick; auto vals = std::make_shared<Vals>(); app.run_async( // Background thread — reads /proc files; must NOT touch widgets [vals, state]{ // CPU: /proc/stat first line { std::ifstream f("/proc/stat"); std::string line; std::getline(f, line); // "cpu user nice system idle iowait irq softirq steal ..." std::istringstream ss(line.substr(5)); long long u, n, s, idle, iowait, irq, softirq, steal; ss >> u >> n >> s >> idle >> iowait >> irq >> softirq >> steal; long long total = u + n + s + idle + iowait + irq + softirq + steal; long long idle_all = idle + iowait; if (state->cpu_total > 0) { long long dt = total - state->cpu_total; long long di = idle_all - state->cpu_idle; vals->cpu = dt > 0 ? std::max(0.0f, (float)(dt - di) / dt) : 0.0f; } state->cpu_total = total; state->cpu_idle = idle_all; } // Memory: /proc/meminfo { std::ifstream f("/proc/meminfo"); std::string line; long long mem_total = 0, mem_avail = 0; while (std::getline(f, line)) { if (line.compare(0, 9, "MemTotal:") == 0) std::istringstream(line.substr(9)) >> mem_total; else if (line.compare(0, 13, "MemAvailable:") == 0) std::istringstream(line.substr(13)) >> mem_avail; if (mem_total && mem_avail) break; } vals->mem = mem_total > 0 ? std::min(1.0f, (float)(mem_total - mem_avail) / mem_total) : 0.0f; } // Network: /proc/net/dev, summed across interfaces, capped at 10 MB/s { std::ifstream f("/proc/net/dev"); std::string line; std::getline(f, line); // header 1 std::getline(f, line); // header 2 long long bytes = 0; while (std::getline(f, line)) { std::istringstream ns(line); std::string iface; ns >> iface; if (iface == "lo:") continue; long long rx; ns >> rx; long long dummy; for (int j = 0; j < 7; ++j) ns >> dummy; long long tx; ns >> tx; bytes += rx + tx; } if (state->net_bytes > 0) { long long db = std::max(0LL, bytes - state->net_bytes); vals->net = std::min(1.0f, (float)db / (10.0f * 1024 * 1024)); } state->net_bytes = bytes; } vals->info = " CPU " + std::to_string((int)(vals->cpu * 100)) + "%" + " Mem " + std::to_string((int)(vals->mem * 100)) + "%" + " Net " + std::to_string((int)(vals->net * 10)) + " MB/s"; }, // Main thread — safe to call widget setters [&, vals]{ if (cpu_bar) cpu_bar->set_value(vals->cpu); if (mem_bar) mem_bar->set_value(vals->mem); if (net_bar) net_bar->set_value(vals->net); if (cpu_st) { cpu_st->set_text(threshold_text(vals->cpu)); cpu_st->set_style(threshold_style(vals->cpu)); } if (mem_st) { mem_st->set_text(threshold_text(vals->mem)); mem_st->set_style(threshold_style(vals->mem)); } if (net_st) { net_st->set_text(threshold_text(vals->net)); net_st->set_style(threshold_style(vals->net)); } if (info_lbl) info_lbl->set_text(vals->info); } ); }); app.on_event = [&](const Event& e) { if (is_char(e, 'q')) app.quit(); }; app.run(); }

Thread safety rules:

  • bg_fn runs on a detached thread. Never touch any widget inside it.
  • on_done is queued and runs on the main thread ≤ 16 ms after bg_fn returns.
  • Pass data through a shared_ptr<T>: bg thread writes, on_done reads.

Key ideas:

  • ProcState for deltas: CPU usage and network throughput require two consecutive readings to compute a rate. ProcState is a shared_ptr captured in the interval lambda, written exclusively by the bg thread, so no concurrent access occurs.
  • CPU from /proc/stat: parse user nice system idle iowait irq softirq steal from the first line; usage = (Δtotal − Δidle) / Δtotal.
  • Memory from /proc/meminfo: (MemTotal − MemAvailable) / MemTotal; no delta needed.
  • Network from /proc/net/dev: sum rx_bytes + tx_bytes across all non-loopback interfaces; divide byte delta by 10 MB/s to normalize to 0–1.
  • set_style() for live color: each status label's color changes every update to reflect the current threshold — set_style() calls mark_dirty() internally.
  • Spinner with auto_animate(true): drives itself every N render calls — no extra timer needed for the animation.

3. Core Concepts

3a. The Widget Tree

App └── root Container (Vertical layout, fills terminal) ├── Widget A (Constraint::fill) ├── Widget B (Constraint::fixed(3)) └── Container C (Horizontal layout) ├── Widget D └── Widget E 
  • App owns the root Container and the Backend.
  • Widgets are added to a Container (or App) via add<W>(...) or populate().
  • Each widget knows its parent_; mark_dirty() propagates up to the root, signalling App::render_frame() to redraw.
  • Lifecycle: on_mount() fires after the widget is attached to the tree (before the first render). on_unmount() fires before removal. Both also fire for widgets added or removed dynamically at runtime.

3b. Retained-Mode Rendering

Strata is retained-mode: each widget stores its own state and renders itself into a Canvas on demand. You update state via setters (e.g. set_text()), which internally call mark_dirty(). The event loop redraws the whole frame only when the root is dirty.

The NcursesBackend is double-buffered: draw_cell() writes to a back buffer; flush() diffs the back buffer against the front buffer and only emits escape sequences for cells that changed. This keeps terminal output minimal even for large screens.

3c. Layout System

Constraint types

Each child carries two independent constraints: main-axis (.size()) and cross-axis (.cross()).

Col Row
Main axis height width
Cross axis width height
Constructor Meaning
fixed(n) Exactly n cells
min(n) At least n cells; grows into remaining space
max(n) At most n cells; fills available space up to cap
percentage(p) p% of the available space
fill(w=1) Proportional fill; weight w divides remaining space

Two-pass algorithm (main axis)

  1. Pass 1: resolve Fixed, Percentage, Min constraints and sum the allocated space. Min widgets also participate in fill distribution from their floor.
  2. Pass 2: distribute remaining space proportionally among Fill, Max, and Min widgets by weight.

Cross-axis sizing and alignment

By default every child stretches to fill the full cross-axis slot (fill()). Set .cross(constraint) to limit it:

// Row: a column that is exactly 5 rows tall, centered vertically Row{ Col{ ... }.size(fill()).cross(fixed(5)), }.cross_align(Layout::Align::Center)
.cross(constraint) Cross-axis behavior
fill() (default) Stretch to fill the full slot
fixed(n) Exactly n cells in the cross direction
max(n) At most n cells
percentage(p) p% of the cross-axis slot

When a child's cross size is less than the full slot, position it with cross_align on the container:

cross_align(value) Position
Align::Start (default) Top / left
Align::Center Centered
Align::End Bottom / right

Layout direction

Layout(Layout::Direction::Vertical) // children stacked top-to-bottom Layout(Layout::Direction::Horizontal) // children placed left-to-right

Col{...} in the DSL is Vertical; Row{...} is Horizontal.

Justify (main-axis alignment)

When no fill constraints are present, the leftover main-axis space is distributed according to Justify:

Value Effect
Start (default) All children packed at the start
Center Children centred, equal margins
End Children packed at the end
SpaceBetween Equal gaps between children, none at edges
SpaceAround Equal gaps around all children
Col({...}).justify(Layout::Justify::SpaceBetween)

Gap

Col({...}).gap(1) // 1-cell gap between every child

Canvas coordinates

  • Canvas::draw_* uses relative coordinates — (0,0) is the top-left of the current canvas.
  • Canvas::sub_canvas(Rect) takes an absolute Rect (as returned by Layout::split()).

3d. Event System

using Event = std::variant<KeyEvent, MouseEvent, ResizeEvent>;

Helper functions

is_key(e, 10) // true if key == 10 (Enter) is_char(e, 'q') // true if key == 'q' as_key(e) // const KeyEvent* or nullptr as_mouse(e) // const MouseEvent* or nullptr as_resize(e) // const ResizeEvent* or nullptr

Key code reference

Key Code
Enter 10
Backspace 263
Delete 330
Arrow Down 258
Arrow Up 259
Arrow Left 260
Arrow Right 261
Home 262
Page Down 338
Page Up 339
Tab 9
Escape 27

Event routing

  1. Delivered first to the focused widget via handle_event().
  2. If the widget returns false (not consumed), the event bubbles up through parent_ pointers.
  3. Unhandled Tab/Shift-Tab → focus group navigation.
  4. Unhandled ↓/j → focus_next_local(); ↑/k → focus_prev_local().
  5. Any remaining unhandled event reaches App::on_event.

When a modal is open, events are delivered only to modal widgets.

3e. Focus & Focus Groups

Tab order

Set tab_index on any focusable widget. FocusManager stable-sorts all focusable widgets by tab_index after a DFS collect; lower values receive focus earlier.

Button("First").tab_index(0) Button("Second").tab_index(1)

Focus groups

Group related widgets together so Tab jumps between groups rather than individual widgets:

Input().group("header") Button("OK").group("actions") Button("Cancel").group("actions")
  • Tab → move focus to the first widget in the next group.
  • Shift-Tab → move focus to the first widget in the previous group.
  • ↓ / j (unhandled) → next focusable widget within the current group.
  • ↑ / k (unhandled) → previous focusable widget within the current group.
  • Widgets with no group act as their own single-widget group for Tab purposes.
  • If all widgets share the same group, Tab does nothing (no other group to jump to).

3f. Styling

Style s = Style{} .with_fg(color::Green) .with_bg(color::Black) .with_bold() .with_italic() .with_underline() .with_dim() .with_blink() .with_reverse();

Named colors (strata::color::)

Black, Red, Green, Yellow, Blue, Magenta, Cyan, White, BrightBlack, BrightRed, BrightGreen, BrightYellow, BrightBlue, BrightMagenta, BrightCyan, BrightWhite, Default (terminal default)

RGB colors

color::rgb(255, 128, 0) // orange

On terminals with fewer than 256 colors, RGB is quantized to the nearest entry in the 6×6×6 color cube.


4. DSL Reference (ui.hpp)

Include and open the namespace:

#include <Strata/ui.hpp> using namespace strata::ui;

All descriptor types produce a Node that is consumed by populate() or nested inside Col/Row/Block/ScrollView.

populate()

void populate(App& app, std::initializer_list<Node> nodes);

Adds all top-level nodes to the App's root container.

Constraint helpers

fixed(int n) // Constraint::fixed(n) fill(int w = 1) // Constraint::fill(w) min(int n) // Constraint::min(n) max(int n) // Constraint::max(n) percentage(int p) // Constraint::percentage(p)

Col — Vertical container

Col({ child, child, ... }) .size(Constraint) // main-axis (height); default: fill() .cross(Constraint) // cross-axis (width); default: fill() = stretch .justify(Layout::Justify::Start) // main-axis child alignment when no fill .cross_align(Layout::Align::Start) // cross-axis child alignment when child < full .gap(int) // gap between children

Row — Horizontal container

Row({ child, child, ... }) .size(Constraint) // main-axis (width); default: fill() .cross(Constraint) // cross-axis (height); default: fill() = stretch .justify(Layout::Justify::Start) .cross_align(Layout::Align::Start) // Start | Center | End .gap(int)

Every leaf descriptor (Label, Button, Input, etc.) also accepts .cross(Constraint) to override its cross-axis size when placed inside a container.

Block — Bordered box

Block("Title") // optional title string .inner(Node) // single inner widget .size(Constraint) .border(Style) // border style (unfocused) .title_style(Style) // title style (unfocused) .focused_border(Style) // border style when inner has focus .focused_title(Style) // title style when inner has focus .styles(border, title, foc_border, foc_title) // set all four at once .bind(strata::Block*& ref)

Note: focused border highlighting works only when the direct inner widget is the focusable widget. If the inner widget is a Container with multiple focusable children, the border won't change on focus.

Label — Static text

Label("text") .style(Style) .wrap(bool) // word-wrap (default false) .size(Constraint) .bind(strata::Label*& ref)

Button — Clickable button

Button("label") .style(Style) .focused_style(Style) .shadow(Style) // explicit shadow style (optional) .size(Constraint) .click(std::function<void()>) .tab_index(int) .group(std::string) .bind(strata::Button*& ref)

Rendering tiers (chosen automatically based on available space):

Condition Style
body_h ≥ 3 and width ≥ 4 Rounded border (╭─╮ │ ╰─╯) with label centered inside
Otherwise Solid filled rectangle with label centered

body_h is height − 1 when unfocused (shadow row reserved) and height when focused. Use fixed(4) or taller for the bordered style when unfocused; fixed(3) is sufficient when focused (no shadow row).

The shadow row uses (half-block) characters — top half painted in the button's background color, bottom half in the shadow color — giving a seamless "raised button" effect. The shadow row disappears while the button has focus.

Checkbox — Toggle checkbox

Checkbox("label") .checked(bool) // initial state .size(Constraint) .change(std::function<void(bool)>) // called with new checked state .focused(std::function<void()>) // called when this widget gains focus .tab_index(int) .group(std::string) .bind(strata::Checkbox*& ref)

Switch — Toggle switch

Switch("label") .on(bool) // initial state .size(Constraint) .change(std::function<void(bool)>) .tab_index(int) .group(std::string) .bind(strata::Switch*& ref)

Input — Single-line text field

Input() .placeholder(std::string) .value(std::string) // initial value .size(Constraint) .submit(std::function<void(const std::string&)>) // on Enter .change(std::function<void(const std::string&)>) // on each keystroke .tab_index(int) .group(std::string) .bind(strata::Input*& ref)

Select — Cycling selector

Select() .items(std::vector<std::string>) .selected(int) // initial index .size(Constraint) .change(std::function<void(int, const std::string&)>) .tab_index(int) .group(std::string) .bind(strata::Select*& ref)

RadioGroup — Vertical radio list

RadioGroup() .items(std::vector<std::string>) .selected(int) .size(Constraint) .change(std::function<void(int, const std::string&)>) .tab_index(int) .group(std::string) .bind(strata::RadioGroup*& ref)

ProgressBar — Horizontal fill bar

ProgressBar() .value(float) // 0.0–1.0 .show_percent(bool) // default true .size(Constraint) .bind(strata::ProgressBar*& ref)

Spinner — Animated braille spinner

Spinner("label") .auto_animate(bool enable, int every = 6) // auto-advance every N frames .size(Constraint) .bind(strata::Spinner*& ref)

ScrollView — Scrollable container

ScrollView({ child, child, ... }) .size(Constraint) .tab_index(int) .group(std::string) .bind(strata::ScrollView*& ref)

ForEach — Reactive list renderer

Binds a Reactive<std::vector<T>> to a builder lambda. Whenever the state changes, the container clears and rebuilds its children list automatically — no manual refresh needed.

ForEach(reactive_vec, [](const T& item, int index) -> std::optional<Node> { // Return a Node to render this item, or std::nullopt to skip it. return Label(item.name).size(fixed(1)); }) .size(Constraint) // default: fill() .cross(Constraint)

The builder's int index is the item's position in the full vector (not filtered). Returning std::nullopt acts as a filter — useful for per-column views into a shared list:

// Only show cards that belong to column 0 ForEach(cards, [](const Card& c, int) -> std::optional<Node> { if (c.col != 0) return std::nullopt; return Checkbox(c.text).size(fixed(1)); })

Lifetime rule: the Reactive<T> object must outlive the ForEach widget. In practice this means declaring it in the same scope as App (typically main).

Grid — Two-dimensional layout container

Children are placed left-to-right, top-to-bottom (row-major order).

Grid({ child, child, ... }) .size(Constraint) // overall size; default: fill() .cross(Constraint) // overall cross-axis size; default: fill() .cols(int) // fixed column count (0 = auto, default) .min_col_width(int) // minimum column width for auto mode (default 10) .gap(int) // gap between both columns and rows .col_gap(int) // horizontal gap between columns only .row_gap(int) // vertical gap between rows only .col_constraints({Constraint, ...}) // per-column sizes, reused cyclically .row_constraints({Constraint, ...}) // per-row sizes, reused cyclically .bind(strata::Grid*& ref)

Column count modes:

Setting Behaviour
.cols(n) Fixed n columns regardless of terminal width
No .cols() (default) Auto: fits floor((W + col_gap) / (min_col_width + col_gap)) columns — re-evaluated each frame, so it responds to terminal resize automatically

Per-axis constraints — reused cyclically if the vector is shorter than the actual count:

// First column fixed at 20, rest fill equally Grid({...}).cols(3).col_constraints({ fixed(20), fill() }) // Alternate tall and short rows Grid({...}).row_constraints({ fixed(3), fixed(1) })

Examples:

// Fixed 3-column grid with a 1-cell gap Grid({ Label("A"), Label("B"), Label("C"), Label("D"), Label("E"), Label("F"), }).cols(3).gap(1) // Auto-fit columns, minimum 15 chars wide each Grid({ ... }).min_col_width(15) // First column twice as wide as the others Grid({ ... }).cols(4) .col_constraints({ fill(2), fill(1) })

Reactive state (make_state / Reactive<T>)

// Declare observable state auto count = make_state(0); auto items = make_state(std::vector<std::string>{}); // Read count.get() // const T& *count // same via operator* count->size() // operator-> for member access // Write — fires all listeners count.set(42); items.mutate([](auto& v){ v.push_back("hello"); }); // Subscribe / unsubscribe int id = count.on_change([&]{ lbl->set_text(std::to_string(*count)); }); count.off(id); // unsubscribe by id

ForEach subscribes and unsubscribes automatically via on_mount / on_unmount. For manual use (e.g. updating a label), subscribe in a set_timeout(0, ...) after populate or store the id to unsubscribe later.

ModalDesc — Modal dialog descriptor

Modals are opened imperatively; the descriptor builds the Modal object:

int modal_id = app.open_modal( ModalDesc() .title("Confirm") .size(40, 10) // width, height .border(Style) .title_style(Style) .overlay(Style) // dimming overlay style .on_close(std::function<void()>) // called on Escape .inner(Node) // inner content .bind(strata::Modal*& ref) .build_modal() ); // Close later: app.close_modal(modal_id);

5. Widget Reference

Label

Read-only text display. Non-focusable.

Label(std::string text = "", Style style = {})
Method Description
set_text(string) Update displayed text; calls mark_dirty()
set_style(Style) Update text style
set_wrap(bool) Enable/disable word-wrap
text() Returns current text
style() Returns current style

Block

Bordered box wrapping one inner widget.

Block()
Method Description
set_title(string) Set title text in the top border
set_border_style(Style) Unfocused border style
set_title_style(Style) Unfocused title style
set_focused_border_style(Style) Border style when inner widget has focus
set_focused_title_style(Style) Title style when inner widget has focus
set_inner<W>(args...) Construct inner widget in-place; returns W*
set_inner(unique_ptr<Widget>) Take ownership of existing widget

Button

Clickable, focusable widget. Automatically selects a rendering tier based on available height and width (see Button DSL for the tier thresholds).

Button(std::string label = "Button")
Public member Type Description
on_click function<void()> Called on Enter/Space when focused
Method Description
set_label(string) Update button text
set_style(Style) Unfocused style
set_focused_style(Style) Style when focused
set_shadow_style(Style) Override the auto-derived shadow color; sets the bg of the shadow row
Key Action
Enter / Space Fire on_click

ProgressBar

Horizontal fill bar. Non-focusable.

ProgressBar()
Method Description
set_value(float v) Set fill ratio in [0.0, 1.0]
set_value(int current, int total) Convenience: current / total
set_show_percent(bool) Show/hide percentage text
value() Returns current float value

Checkbox

Single toggle. Focusable.

Checkbox(std::string label = "", bool checked = false)
Public member Type Description
on_change function<void(bool)> Called with new checked state after toggle
on_focused function<void()> Called when this widget gains keyboard focus
Method Description
set_label(string) Update label text
set_checked(bool) Set state programmatically
is_checked() Returns current state
Key Action
Space / Enter Toggle

RadioGroup

Vertical list of radio buttons. Focusable.

RadioGroup()
Public member Type Description
on_change function<void(int, const string&)> Called with new index and item text
Method Description
add_item(string) Append an option
set_items(vector<string>) Replace all options
set_selected(int) Select by index
selected() Returns current index
selected_item() Returns current item text
Key Action
j / ↓ Move selection down
k / ↑ Move selection up
Space / Enter Confirm selection

TextBox

Read-only multiline text viewer. Focusable.

TextBox()
Method Description
set_text(string) Replace all content (splits on \n)
set_wrap(bool) Enable word-wrap
append(string) Append a line and scroll to bottom
clear() Remove all content
Key Action
j / ↓ Scroll down 1 line
k / ↑ Scroll up 1 line

Input

Single-line editable text field. Focusable.

Input()
Public member Type Description
on_submit function<void(const string&)> Called on Enter
on_change function<void(const string&)> Called on every character change
Method Description
set_placeholder(string) Dimmed hint shown when empty
set_value(string) Set content programmatically
value() Returns current text
Key Action
Printable chars Insert at cursor
← / → Move cursor
Home Jump to start
Backspace Delete before cursor
Delete Delete at cursor
Ctrl+U Clear entire field
Enter Fire on_submit

Select

Single-row cycling selector. Focusable. Renders as ◀ Item ▶ when focused.

Select()
Public member Type Description
on_change function<void(int, const string&)> Called on each item change
Method Description
set_items(vector<string>) Replace item list
set_selected(int) Select by index
selected() Returns current index
selected_item() Returns current item text
Key Action
← / h Previous item (wraps)
→ / l Next item (wraps)

Switch

Toggle with an ON/OFF indicator. Focusable. Renders as Label ─── [ ON ] or Label ─── [OFF ].

Switch(std::string label = "", bool on = false)
Public member Type Description
on_change function<void(bool)> Called with new state
Method Description
set_label(string) Update label text
set_on(bool) Set state programmatically
is_on() Returns current state
Key Action
Space / Enter Toggle

Spinner

Animated braille spinner. Non-focusable. Renders as ⠋ label, cycling through 10 frames.

Spinner(std::string label = "")
Method Description
set_label(string) Update accompanying text
tick() Advance one frame and mark dirty
set_auto_animate(bool, int every = 6) Auto-advance every N render calls

With auto_animate enabled the spinner drives itself; no timer needed.

ScrollView

Vertically scrollable container. Focusable.

ScrollView(Layout layout = Layout(Layout::Direction::Vertical))
Method Description
add(unique_ptr<Widget>, Constraint) Add a widget; calls on_mount() if already running
add<W>(Constraint, args...) Construct in-place with constraint
add<W>(args...) Construct in-place, constraint from flex
remove(Widget*) Remove and unmount a specific child; rebuilds focus list
clear() Remove and unmount all children; rebuilds focus list
scroll_to(int y) Set scroll offset absolutely
scroll_by(int delta) Adjust scroll offset by delta
scroll_y() Returns current scroll offset
Key Action
j / ↓ Scroll down 1 line
k / ↑ Scroll up 1 line
PgDn Scroll down (visible height − 1)
PgUp Scroll up (visible height − 1)

The ScrollView auto-scrolls to keep the currently focused descendant visible on every render pass.

Grid

Two-dimensional layout container. Non-focusable (children may be focusable). Children are placed left-to-right, top-to-bottom (row-major order).

Grid()
Method Description
set_cols(int) Fixed column count. 0 = auto (default).
set_min_col_width(int) Minimum column width used in auto mode (default 10).
set_gap(int) Set both col_gap and row_gap.
set_col_gap(int) Horizontal gap between columns.
set_row_gap(int) Vertical gap between rows.
set_col_constraints(vector<Constraint>) Per-column size constraints, reused cyclically.
set_row_constraints(vector<Constraint>) Per-row size constraints, reused cyclically.
add<W>(args...) Construct child in-place; appended to the next cell.
add(unique_ptr<Widget>) Transfer ownership; appended to the next cell.
remove(Widget*) Remove and unmount a specific child; rebuilds focus list.
clear() Remove and unmount all children; rebuilds focus list.
children() Returns const vector<unique_ptr<Widget>>&.

In auto mode (no set_cols()), the column count is recomputed each frame from the available canvas width and min_col_width, so it responds to terminal resize automatically.

Modal

A centered dialog with a full-screen dimming overlay. Not part of the widget tree; managed by App.

Modal()
Method Description
set_title(string) Dialog title
set_size(int w, int h) Preferred width and height
set_border_style(Style) Dialog border style
set_title_style(Style) Title text style
set_overlay_style(Style) Background overlay style
set_on_close(function<void()>) Called when Escape is pressed
set_inner(unique_ptr<Widget>) Content widget
set_inner<W>(args...) Construct content in-place
inner() Returns raw pointer to inner widget

Focus is trapped inside the modal while it is open. Press Escape to trigger on_close_.


6. App API Reference

#include <Strata/strata.hpp> // or #include <Strata/ui.hpp>

Constructor

explicit App(std::unique_ptr<Backend> backend = nullptr);

Pass nullptr (default) to use the built-in NcursesBackend. Pass a custom Backend subclass for testing or alternative outputs.

Adding widgets

// Construct in-place, Constraint::fill() by default template<typename W, typename... Args> W* add(Args&&... args); // Construct in-place with explicit constraint template<typename W, typename... Args> W* add(Constraint c, Args&&... args); // Transfer ownership of an existing widget Widget* add(std::unique_ptr<Widget> w, Constraint c = Constraint::fill());

Run / quit

void run(); // blocks until quit() is called void quit(); // signals the event loop to exit cleanly

Global event handler

std::function<void(const Event&)> on_event;

Called after the focused widget's bubble-up chain if no widget consumed the event. Use for global hotkeys.

Timer API

// Call fn on the main thread every ms milliseconds. Returns timer ID. int set_interval(int ms, std::function<void()> fn); // Call fn on the main thread once after ms milliseconds. Returns timer ID. int set_timeout(int ms, std::function<void()> fn); // Cancel a timer by ID (safe to call with an already-fired or invalid ID). void clear_timer(int id);

Timers fire on the main thread — widget setters are safe to call directly.

Async API

void run_async(std::function<void()> bg_fn, std::function<void()> on_done = {});
  • bg_fn runs on a detached background thread. Never access widgets or Strata state from it.
  • on_done is queued to the main thread and executes ≤ 16 ms after bg_fn returns.
  • Pass data between the two using shared_ptr<T>: bg writes, on_done reads.
  • The App and all captured widget pointers must remain alive until on_done executes.

Alert / toast notifications

// Show a toast in the bottom-right corner. Returns alert ID. // duration_ms = 0 → persistent until dismiss_alert() is called. int notify(std::string title, std::string message = "", AlertManager::Level level = AlertManager::Level::Info, int duration_ms = 4000); // Remove an alert by ID. void dismiss_alert(int id);

AlertManager::Level values: Info, Success, Warning, Error.

Modal system

// Open a modal overlay. Returns modal ID. int open_modal(std::unique_ptr<Modal> m); // Close a modal by ID, restoring background focus. void close_modal(int id); // Returns true if at least one modal is currently open. bool has_modal() const;

7. Adding a Custom Widget

Step 1 — Create the header

include/Strata/widgets/my_widget.hpp:

#pragma once #include <Strata/core/widget.hpp> #include <Strata/style/style.hpp> #include <string> namespace strata { class MyWidget : public Widget { std::string text_; Style style_; public: explicit MyWidget(std::string text = "", Style style = {}) : text_(std::move(text)), style_(style) {} MyWidget& set_text(std::string t) { text_ = std::move(t); mark_dirty(); return *this; } // Required — draw into the canvas using relative coordinates void render(Canvas& canvas) override { canvas.draw_text(0, 0, text_, style_); } // Optional — return true if this widget can receive Tab focus bool is_focusable() const override { return false; } // Optional — return true to consume the event (stop bubbling) bool handle_event(const Event& e) override { (void)e; return false; } // Required if this widget wraps children — iterate for DFS traversal // void for_each_child(const std::function<void(Widget&)>& v) override { v(*child_); } }; } // namespace strata

Step 2 — Create the implementation

src/widgets/my_widget.cpp:

#include <Strata/widgets/my_widget.hpp> // Additional implementation if needed

Even if the .cpp is empty it must exist so CMake's GLOB_RECURSE picks it up.

Step 3 — Register in the master include

Add to include/Strata/strata.hpp:

#include "widgets/my_widget.hpp"

Step 4 — Re-run CMake

cd build && cmake .. && make

GLOB_RECURSE does not auto-detect new .cpp files — cmake .. is required after every addition.

Canvas API quick reference

canvas.draw_text(x, y, "text", style); // relative coords canvas.draw_cell(x, y, cell); canvas.fill(U' ', style); // flood-fill entire area canvas.draw_border(style); // single-line Unicode border canvas.draw_title("Title", style); // title into top border canvas.width(); // canvas width canvas.height(); // canvas height // Create a sub-canvas for a child widget (takes ABSOLUTE rect) Canvas child_canvas = canvas.sub_canvas(absolute_rect);

8. Patterns & Recipes

Reactive state with ForEach

Declare state with make_state, render it with ForEach, mutate it anywhere — the UI rebuilds automatically:

struct Task { int id; std::string text; bool done = false; }; auto tasks = make_state(std::vector<Task>{}); int next_id = 0; strata::Input* inp = nullptr; populate(app, { Col({ Input().bind(inp) .submit([&](const std::string& val) { if (val.empty()) return; tasks.mutate([&](auto& v){ v.push_back({next_id++, val}); }); inp->set_value(""); }).size(fixed(1)), // Rebuilds whenever tasks changes ForEach(tasks, [](const Task& t, int) -> std::optional<Node> { return Checkbox(t.text).checked(t.done).size(fixed(1)); }), }) });

Filtering — each ForEach can show a different view of the same list:

// Active tasks only ForEach(tasks, [](const Task& t, int) -> std::optional<Node> { if (t.done) return std::nullopt; return Checkbox(t.text).size(fixed(1)); })

Triggering side-effects — subscribe manually to update labels or other non-ForEach widgets:

strata::Label* count_lbl = nullptr; // ...bind(count_lbl)... // After populate(): tasks.on_change([&]{ int n = (int)tasks->size(); count_lbl->set_text(std::to_string(n) + " tasks"); });

Custom reusable Nodes (React-style components)

Any type with build(), constraint(), and cross_constraint() is a valid Node. This lets you factor repeated widget subtrees into reusable components — exactly like React functional components or Flutter widgets.

Functional style (simplest)

A plain function returning a Node:

// Reusable component — just a function Node metric_row(const std::string& label, strata::ProgressBar*& bar, strata::Label*& status) { return Row({ Label(label).style(Style{}.with_fg(color::BrightWhite).with_bold()).size(fixed(8)), ProgressBar().size(fill()).bind(bar), Label(" OK ").style(Style{}.with_fg(color::BrightGreen)).size(fixed(6)).bind(status), }).size(fixed(1)); } // Usage — exactly like a built-in descriptor strata::ProgressBar* cpu_bar = nullptr; strata::Label* cpu_st = nullptr; strata::ProgressBar* mem_bar = nullptr; strata::Label* mem_st = nullptr; populate(app, { Col({ metric_row("CPU ", cpu_bar, cpu_st), metric_row("Memory ", mem_bar, mem_st), }) });

Class style (builder-pattern, nestable anywhere)

A struct that satisfies the Node concept — it can be used inside Col{}, Row{}, .inner(), or ForEach:

struct StatusCard { std::string title_; strata::ProgressBar** bar_ = nullptr; strata::Label** status_ = nullptr; strata::Constraint size_ = strata::Constraint::fill(); StatusCard(std::string title) : title_(std::move(title)) {} StatusCard& bar(strata::ProgressBar*& b) { bar_ = &b; return *this; } StatusCard& status(strata::Label*& s) { status_ = &s; return *this; } StatusCard& size(strata::Constraint c) { size_ = c; return *this; } std::unique_ptr<strata::Widget> build() const { // Compose from existing descriptors — no ncurses knowledge needed return Row({ Label(title_).style(Style{}.with_bold()).size(fixed(8)), ProgressBar().size(fill()).bind(*bar_), Label(" OK ").size(fixed(6)).bind(*status_), }).size(fixed(1)).build(); } strata::Constraint constraint() const { return size_; } strata::Constraint cross_constraint() const { return strata::Constraint::fill(); } }; // Usage — nests inside Col/Row just like Label or Button populate(app, { Block(" System Monitor ").inner( Col({ StatusCard("CPU ").bar(cpu_bar).status(cpu_st), StatusCard("Memory ").bar(mem_bar).status(mem_st), StatusCard("Network").bar(net_bar).status(net_st), }) ) });

The class's build() can delegate to any other descriptor's build(), so you compose reusable UI from existing pieces without any ncurses code.

Dynamic widget add/remove

Widgets can be added, removed, or cleared from any Container (or ScrollView) at runtime — even while app.run() is executing. on_mount() / on_unmount() are called automatically and the focus list is rebuilt so new widgets immediately participate in Tab cycling.

strata::Container* list = nullptr; populate(app, { Col({ ScrollView({}).bind(list) }) }); // From any callback or timer — safe to call while the app is running: list->add<Label>(fixed(1), "Item added at runtime"); // Remove a specific widget (unmount + focus rebuild): list->remove(some_label_ptr); // Clear all children (unmounts each, fixes the pre-existing constraints bug too): list->clear();

This works for App::add<W>(), Container::add<W>(), and ScrollView::add<W>() — any call to add() on an already-mounted container triggers the lifecycle automatically.

Dynamic list (retained-mode slot pattern)

When the set of widgets is fixed but their content changes frequently, the pre-allocated slot pattern avoids repeated add/remove overhead:

static const int MAX = 100; std::vector<strata::Label*> slots(MAX, nullptr); std::vector<int> view; // indices into your data model // Build slots once std::vector<Node> nodes; for (int i = 0; i < MAX; ++i) nodes.push_back(Label("").size(fixed(1)).bind(slots[i])); // Refresh after data changes auto refresh = [&]{ for (int i = 0; i < MAX; ++i) { if (!slots[i]) continue; if (i < (int)view.size()) slots[i]->set_text(data[view[i]].name); else slots[i]->set_text(""); } };

Guard all per-slot callbacks with if (s < view.size()) to avoid stale index access.

Modal with confirmation

int confirm_id = -1; auto open_confirm = [&]{ confirm_id = app.open_modal( ModalDesc() .title("Confirm Delete") .size(40, 8) .on_close([&]{ app.close_modal(confirm_id); }) .inner( Col({ Label("Are you sure?").size(fixed(1)), Row({ Button("Yes") .click([&]{ do_delete(); app.close_modal(confirm_id); }), Button("No") .click([&]{ app.close_modal(confirm_id); }), }).size(fixed(4)) // fixed(4) → bordered style (body_h=3 unfocused) }) ) .build_modal() ); };

Tab cycles between "Yes" and "No" while the modal is open. Escape fires on_close.

Background data fetch

app.set_interval(5000, [&]{ auto result = std::make_shared<std::string>(); app.run_async( // bg thread [result]{ *result = fetch_from_network(); }, // main thread [&, result]{ label->set_text(*result); } ); });

CPU/resource polling with async measurement

Combine set_interval for scheduling with run_async for non-blocking measurement:

app.set_interval(1000, [&]{ auto value = std::make_shared<float>(0.f); app.run_async( [value]{ *value = read_cpu_usage(); }, // blocks in bg [&, value]{ pb->set_value(*value); } // updates on main thread ); });

Alert / notification on completion

app.run_async( []{ do_long_work(); }, [&]{ app.notify("Done", "Task completed successfully", AlertManager::Level::Success, 3000); } );

Preventing double-handling with an open modal

Check app.has_modal() before acting on global events:

app.on_event = [&](const Event& e) { if (app.has_modal()) return; // let modal handle everything if (is_char(e, 'q')) app.quit(); if (is_char(e, 'd')) open_confirm(); };

Deferred close (dismiss after timeout)

int alert_id = app.notify("Saving…", "", AlertManager::Level::Info, 0); app.run_async( []{ save_file(); }, [&, alert_id]{ app.dismiss_alert(alert_id); app.notify("Saved", "", AlertManager::Level::Success, 2000); } );

About

Reactive,Retained-mode ,Declarative TUI framework for c++

Topics

Resources

Stars

Watchers

Forks

Releases

No releases published

Packages

 
 
 

Contributors