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.
- Getting Started
- Tutorial
- Core Concepts
- DSL Reference (ui.hpp)
- Widget Reference
- App API Reference
- Adding a Custom Widget
- Patterns & Recipes
| 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++git clone <repo> cd strata mkdir build && cd build cmake .. make ./strata_test # run the built-in demoCMake discovers all source files via GLOB_RECURSE. After adding a new .cpp file, re-run cmake .. from the build directory.
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 installThis 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
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)// 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.
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:
Appinitialises the ncurses backend and creates a rootContainer.populate()adds each top-levelNodeto that root container.app.run()blocks in an event loop untilapp.quit()is called.- The outer
Colwithjustify(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_eventis called for every event not consumed by a focused widget — use it for global hotkeys.
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 theon_clickcallback on theButton.set_text()callsmark_dirty()internally, scheduling a redraw.Row.size(fixed(4))fixes the button row height sojustify(Center)on the outerColhas no fill child and can distribute vertical space evenly.- Buttons use
size(fixed(10)).cross(fixed(4))— width 10, height 4. Atbody_h = 3(unfocused) the bordered rendering tier activates automatically. cross_align(Center)on the outerColkeeps the display block (50 wide) horizontally centered regardless of terminal width.
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_statereplacesstd::vector<Item>— everymutate()fires all listeners.ForEachrenders the list. The builder lambda capturesfilter_modeby reference; returningstd::nulloptfor 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, forcingForEachto re-run the builder with the newfilter_modevalue. .focused(fn)on eachCheckboxsetsselected_idwhen focus arrives — replaces the oldItem::widgetpointer andis_focused()scan..change(fn)on eachCheckboxcallsitems.mutate(...)with the item's stableid. Mutating triggers the stats subscriber and theForEachrebuild in one step.- Stats label subscribes once via
items.on_change(...)— no explicitupdate_stats()call needed anywhere else. - Delete uses a modal for confirmation; the
Yesbutton callsitems.mutate(...)to erase byid— no index arithmetic needed. app.has_modal()guard prevents global hotkeys from firing while the modal is open.
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 aReactive<vector<Card>>. Onemutate()call fires all subscribedForEachcolumns, which each independently rebuild their widget list.ForEachtakes the reactive vector and a builder lambda. Returnstd::nulloptto filter out items (each column filters bycard.col). Return aNodeto render it.selected_idtracks which card last gained focus via.focused(...)callback on eachCheckbox. This replaces the oldCard::widgetpointer — no raw widget pointers in the data model.- No
rebuild()function: adding, moving, and deleting cards is justcards.mutate(...)— the UI updates automatically. card.idis 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.
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_fnruns on a detached thread. Never touch any widget inside it.on_doneis queued and runs on the main thread ≤ 16 ms afterbg_fnreturns.- Pass data through a
shared_ptr<T>: bg thread writes, on_done reads.
Key ideas:
ProcStatefor deltas: CPU usage and network throughput require two consecutive readings to compute a rate.ProcStateis ashared_ptrcaptured in the interval lambda, written exclusively by the bg thread, so no concurrent access occurs.- CPU from
/proc/stat: parseuser nice system idle iowait irq softirq stealfrom the first line; usage =(Δtotal − Δidle) / Δtotal. - Memory from
/proc/meminfo:(MemTotal − MemAvailable) / MemTotal; no delta needed. - Network from
/proc/net/dev: sumrx_bytes + tx_bytesacross 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()callsmark_dirty()internally.- Spinner with
auto_animate(true): drives itself every N render calls — no extra timer needed for the animation.
App └── root Container (Vertical layout, fills terminal) ├── Widget A (Constraint::fill) ├── Widget B (Constraint::fixed(3)) └── Container C (Horizontal layout) ├── Widget D └── Widget E Appowns the rootContainerand theBackend.- Widgets are added to a
Container(orApp) viaadd<W>(...)orpopulate(). - Each widget knows its
parent_;mark_dirty()propagates up to the root, signallingApp::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.
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.
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 |
- Pass 1: resolve
Fixed,Percentage,Minconstraints and sum the allocated space.Minwidgets also participate in fill distribution from their floor. - Pass 2: distribute remaining space proportionally among
Fill,Max, andMinwidgets by weight.
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(Layout::Direction::Vertical) // children stacked top-to-bottom Layout(Layout::Direction::Horizontal) // children placed left-to-rightCol{...} in the DSL is Vertical; Row{...} is Horizontal.
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)Col({...}).gap(1) // 1-cell gap between every childCanvas::draw_*uses relative coordinates — (0,0) is the top-left of the current canvas.Canvas::sub_canvas(Rect)takes an absoluteRect(as returned byLayout::split()).
using Event = std::variant<KeyEvent, MouseEvent, ResizeEvent>;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 |
|---|---|
| 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 |
- Delivered first to the focused widget via
handle_event(). - If the widget returns
false(not consumed), the event bubbles up throughparent_pointers. - Unhandled Tab/Shift-Tab → focus group navigation.
- Unhandled ↓/j →
focus_next_local(); ↑/k →focus_prev_local(). - Any remaining unhandled event reaches
App::on_event.
When a modal is open, events are delivered only to modal widgets.
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)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).
Style s = Style{} .with_fg(color::Green) .with_bg(color::Black) .with_bold() .with_italic() .with_underline() .with_dim() .with_blink() .with_reverse();Black, Red, Green, Yellow, Blue, Magenta, Cyan, White, BrightBlack, BrightRed, BrightGreen, BrightYellow, BrightBlue, BrightMagenta, BrightCyan, BrightWhite, Default (terminal default)
color::rgb(255, 128, 0) // orangeOn terminals with fewer than 256 colors, RGB is quantized to the nearest entry in the 6×6×6 color cube.
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.
void populate(App& app, std::initializer_list<Node> nodes);Adds all top-level nodes to the App's root container.
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({ 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 childrenRow({ 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("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("text") .style(Style) .wrap(bool) // word-wrap (default false) .size(Constraint) .bind(strata::Label*& ref)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("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("label") .on(bool) // initial state .size(Constraint) .change(std::function<void(bool)>) .tab_index(int) .group(std::string) .bind(strata::Switch*& ref)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() .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() .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() .value(float) // 0.0–1.0 .show_percent(bool) // default true .size(Constraint) .bind(strata::ProgressBar*& ref)Spinner("label") .auto_animate(bool enable, int every = 6) // auto-advance every N frames .size(Constraint) .bind(strata::Spinner*& ref)ScrollView({ child, child, ... }) .size(Constraint) .tab_index(int) .group(std::string) .bind(strata::ScrollView*& ref)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 theForEachwidget. In practice this means declaring it in the same scope asApp(typicallymain).
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) })// 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 idForEach 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.
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);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 |
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 |
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 |
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 |
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 |
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 |
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 |
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 |
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) |
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 |
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.
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.
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.
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_.
#include <Strata/strata.hpp> // or #include <Strata/ui.hpp>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.
// 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());void run(); // blocks until quit() is called void quit(); // signals the event loop to exit cleanlystd::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.
// 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.
void run_async(std::function<void()> bg_fn, std::function<void()> on_done = {});bg_fnruns on a detached background thread. Never access widgets or Strata state from it.on_doneis queued to the main thread and executes ≤ 16 ms afterbg_fnreturns.- Pass data between the two using
shared_ptr<T>: bg writes, on_done reads. - The
Appand all captured widget pointers must remain alive untilon_doneexecutes.
// 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.
// 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;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 stratasrc/widgets/my_widget.cpp:
#include <Strata/widgets/my_widget.hpp> // Additional implementation if neededEven if the .cpp is empty it must exist so CMake's GLOB_RECURSE picks it up.
Add to include/Strata/strata.hpp:
#include "widgets/my_widget.hpp"cd build && cmake .. && makeGLOB_RECURSE does not auto-detect new .cpp files — cmake .. is required after every addition.
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);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"); });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.
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), }) });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.
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.
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.
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.
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); } ); });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 ); });app.run_async( []{ do_long_work(); }, [&]{ app.notify("Done", "Task completed successfully", AlertManager::Level::Success, 3000); } );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(); };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); } );