This is an R&D experiment — exploring what a 2D UI toolkit could look like if built from scratch in modern C++ with no legacy constraints. Nothing here is production-ready.
Take Qt's persistent widget tree, strip away moc and QObject, replace signal/slot strings with C++26 senders, and let model structs generate the UI — via P2996 reflection on C++26, or manual
view()on C++23.
Every major UI toolkit — Qt, GTK, wxWidgets — couples the application and the renderer on a single thread. Rendering competes with business logic for CPU time, and if any step exceeds the frame budget, the UI freezes.
graph TD
subgraph "Traditional single-thread event loop"
A[Process input events] --> B[Fire signal/slot callbacks<br/><i>your model logic runs here</i>]
B --> C[Execute layout pass<br/><i>sequential, blocks frame</i>]
C --> D["Call paintEvent() on each widget<br/><i>rendering happens here</i>"]
D --> E[Present frame]
E -->|"If ANY step > 16ms"| F["Frame drop / frozen UI"]
end
This is an architectural problem, not a tuning problem.
PRISM follows a Model-View-Behavior pattern:
- Model — plain structs with
Field<T>members. Data + change notification. Knows nothing about rendering or input. - View —
Widget<T>specializations. Per-type rendering and widget-level input mechanics, automatic via P2996 reflection. PRISM-internal. - Behavior — user-written
on_change()/observe()chains. Business logic that reacts to field mutations.
The application and renderer are decoupled through a versioned, immutable scene snapshot exchanged via atomic pointer swap. Both threads sleep at OS level when idle — zero CPU when nothing changes.
graph TB
subgraph app["Application Thread"]
Model["Model structs<br/>(Field<T> observables)"]
WT["Persistent WidgetTree<br/>(dirty tracking)"]
Model -->|"Field::set() triggers sender"| WT
end
subgraph snap["Scene Description"]
SS["SceneSnapshot<br/>(immutable, versioned, plain data)<br/>{widget_id → DrawList, rect, z_order, ...}"]
end
subgraph render["Backend Thread"]
SDL["SDL event wait"]
Raster["Rasterise + present"]
SDL --> Raster
end
WT -->|"atomic pointer swap"| SS
SS -->|"load latest"| Raster
SDL -->|"InputEvent callback"| Model
The frame contract: the renderer guarantees frame delivery independent of application state. The application never blocks the renderer. The renderer never calls into application code.
Screenshots are auto-generated: each model is compiled, rendered headlessly, and exported as SVG. Run scripts/update_screenshots.sh to regenerate.
Hello World — define a struct, get a UI. With C++26 reflection, there is no boilerplate:
struct Counter {
prism::Field<int> count{42};
prism::Field<std::string> label{"Hello, PRISM!"};
};
Counter counter;
prism::model_app("My App", counter); // reflection generates the UIOn C++23 (no reflection), add a one-liner view() method — same result:
void view(prism::WidgetTree::ViewBuilder& vb) { vb.vstack(count, label); }Widgets from types — the type inside Field<T> determines the widget. No registration:
struct AudioMixer {
prism::Field<prism::Slider<>> volume{{.value = 0.75, .min = 0.0, .max = 1.0}};
prism::Field<prism::Slider<int>> quality{{.value = 3, .min = 1, .max = 5, .step = 1}};
prism::Field<prism::Checkbox> mute{{.checked = false, .label = "Mute"}};
};Composition — nest structs. Reflection walks them recursively:
struct Settings {
prism::Field<std::string> username{"jeandet"};
prism::Field<bool> dark_mode{true};
};
struct Dashboard {
Settings settings; // nested group — recurses automatically
prism::Field<prism::Slider<>> brightness{{.value = 0.6, .min = 0.0, .max = 1.0}};
prism::Field<prism::Button> apply{{"Apply"}};
};For custom layouts (e.g. hstack), add a view() method:
void view(prism::WidgetTree::ViewBuilder& vb) {
vb.vstack([&] {
vb.component(settings);
vb.hstack(brightness, apply);
});
}Resizable split panes — drop a handle() between two children of any hstack/vstack to let
the user drag the divider; sizes stay content-based until the first drag, then persist and rescale
proportionally on resize:
void view(prism::WidgetTree::ViewBuilder& vb) {
vb.hstack([&] {
vb.component(settings);
vb.handle();
vb.widget(brightness);
});
}Canvas escape hatch — drop to raw drawing when you need it:
struct Waveform {
prism::Field<prism::Slider<>> frequency{{.value = 3.0, .min = 0.5, .max = 8.0}};
void canvas(prism::DrawList& dl, prism::Rect bounds, const prism::WidgetNode& node) {
// Custom drawing: polyline, shapes, theme colors...
}
void view(prism::WidgetTree::ViewBuilder& vb) {
vb.canvas(*this).depends_on(frequency);
vb.widget(frequency);
}
};Plot widget — scientific data visualization with zoom, pan, and crosshair:
struct PlotShowcase {
prism::plot::PlotModel plot;
// ... populate with XYData series ...
void view(prism::WidgetTree::ViewBuilder& vb) {
vb.canvas(plot)
.depends_on(plot.x_range).depends_on(plot.y_range)
.depends_on(plot.view).depends_on(plot.cursor)
.depends_on(plot.revision);
}
};Live inspector — point PRISM at any plain struct behind a Shared<T> and get a live,
per-field editable panel, no mirror struct to hand-write. Built for cross-thread state:
device drivers, background workers, plugin config living on another thread:
struct DeviceState {
float voltage = 3.3f;
int mode = 2;
bool enabled = true;
};
prism::Shared<DeviceState> device_state{DeviceState{}}; // owned/updated elsewhere
prism::inspector::Inspector<DeviceState> inspector(device_state);Inspector<T> is an ordinary component — embed it in a larger UI via vb.component(), or
run it standalone like any model:
prism::model_app("Device Control", inspector);Tree widget — lazy, virtualized tree view over any hierarchical data source, with keyboard
nav (arrow keys expand/collapse/move) and a synced detail panel. On C++26, wrap_struct_tree()
turns any nested struct into a browsable tree via reflection, no adapter code:
struct Sensors { float battery_v = 3.7f; float bus_v = 12.1f; };
struct Device { std::string name = "Controller"; Sensors sensors; int firmware = 12; };
struct TreeShowcase {
Device device;
prism::TreeController ctrl{prism::wrap_struct_tree(device)};
void view(prism::WidgetTree::ViewBuilder& vb) { vb.tree(ctrl); }
};For data that isn't a plain struct — a filesystem, a database, a custom model — implement
TreeSource by hand (Tier 1), or satisfy the TreeStorage concept and adapt it with
wrap_tree_storage() (Tier 2, see examples/model_tree_browser/model_tree_browser.cpp for a filesystem browser).
wrap_struct_tree() (Tier 3) is the reflection-only fast path shown above.
Table widget — virtualized rows with headers and single-row selection. wrap_row_storage()
turns a List<T> of Field<T>-bearing rows into a table via reflection; wrap_column_storage()
adapts any type satisfying ColumnStorage without reflection:
struct Reading {
prism::Field<std::string> sensor{""};
prism::Field<double> value{0.0};
};
struct TableShowcase {
prism::List<Reading> readings;
void view(prism::WidgetTree::ViewBuilder& vb) {
vb.table(readings).headers({"Sensor", "Value"});
}
};Tabs — group content behind a tab bar. Each tab body is an independent ViewBuilder closure,
built lazily:
struct TabsShowcase {
prism::Field<prism::TextField<>> username{{.value = "jeandet"}};
prism::Field<bool> dark_mode{true};
prism::Field<prism::Slider<>> volume{{.value = 0.6}};
prism::Field<prism::TabBar<>> tabs;
void view(prism::WidgetTree::ViewBuilder& vb) {
vb.tabs(tabs, [&] {
vb.tab("Account", [&](prism::WidgetTree::ViewBuilder& tvb) {
tvb.vstack(username, dark_mode);
});
vb.tab("Audio", [&](prism::WidgetTree::ViewBuilder& tvb) {
tvb.widget(volume);
});
});
}
};Inspector annotations — customize how individual fields render in the inspector via C++26 attributes, without changing the field's declared type:
struct Settings {
[[=prism::inspector::skip]] int internal_version;
[[=prism::inspector::readonly]] std::string device_id;
[[=prism::inspector::label<"Sample Rate">]] int sample_rate;
[[=prism::inspector::section<"Audio">]] float volume;
};skip excludes a field entirely (still round-trips safely under the hood); readonly renders
it without input wiring; label overrides the caption; section inserts a header row above
the field.
Same MVB architecture, fully multi-threaded from Python — any thread may mutate the model.
import prism
from typing import Annotated
from pydantic import Field as PField
Vol = Annotated[float, PField(ge=0, le=1)]
class Mixer(prism.Model):
volume: Vol = 0.75 # Annotated → validated via TypeAdapter
mute = prism.checkbox(False, label="Mute")
count = prism.field(42)
volume_slider = prism.slider(0.75, min=0.0, max=1.0)
total = prism.derived(lambda self: self.count.value * 2, "count")
def view(self, vb): # optional — else auto-stacked
vb.hstack(self.volume_slider, self.mute)
vb.widget(self.count)
m = Mixer()
prism.run(m, title="Mixer") # blocks, releases GIL around SDL pump
# from any thread:
m.count.value = 43
conn = Mixer.count.observe(m, lambda v: print(v)) # type-safe, no string name
with prism.transaction():
m.count.value = 1
m.volume = 0.9 # coalesced into one publish| Python | C++ equivalent | Notes |
|---|---|---|
prism.field(default) |
Field<T> |
.value get/set, .observe() |
prism.slider / prism.checkbox |
Field<Slider<>> / Field<Checkbox> |
sentinel widgets |
prism.shared(default) |
Shared<T> |
latest-value, cross-thread |
prism.channel(type_hint) |
Channel<T> |
lossless ordered |
prism.derived(fn, *deps) |
Derived<T> |
recomputed on dep change |
prism.list_field([...]) |
List<T> |
push/erase/observe_* |
prism.transaction() |
prism::transaction([]{...}) |
coalesced publish |
prism.validator_for(Annotated) |
— | Pydantic validation |
prism.run(model) |
model_app(title, model) |
vb trampoline via ViewBuilder |
Also: prism.headless() (display-less run for tests/CI) and prism.run(model, headless_seconds=seconds, until=predicate)
(one-liner CLI form — prism.run(m, headless_seconds=1.0 if "--headless" in sys.argv else None)),
any-thread field.add(n) (atomic increment, avoids read-modify-write),
plot.replace_series()/set_labels() (canvas plot updates), and the @prism.on_change(*deps)
method decorator — see Threading guarantees in
the examples README for the full picture.
Install (editable, requires Meson ≥1.5):
pip install -e . --no-build-isolation
meson test -C builddir # 73 C++ + Python bindings via meson test
pytest python/tests -v # direct pytest alternativePython examples (pure prism.Model, no C++ needed) live in python/examples/: 01_counter (field), 02_mixer (slider/checkbox + view), 03_validation_and_transaction (Annotated/pydantic), 04_background_shared_channel (Shared/Channel + threads), 05_lists_and_derived (List + Derived).
PYTHONPATH=build/python python python/examples/01_counter.pySee Python SDK design for threading (SenderHub snapshot + coalescing queue), GIL handling, and lifecycle details. python/tests/test_prism_python.py is the API reference by example.
Two observable types share the same core (.get(), .set(), .on_change()):
Field<T>— data + observable + widget (reflection generates UI for it)State<T>— data + observable + no widget (backend state, synchronisation)
struct Settings {
prism::Field<std::string> username{"jeandet"}; // → text input
prism::Field<bool> dark_mode{true}; // → checkbox
prism::State<std::string> session_token{""}; // → no widget, still observable
};Field<T> holds only the value — no display label. The member name via P2996 reflection provides identity. Display labels are a form-layout concern.
Both support equality-guarded set() (no spurious notifications) and RAII Connection lifetime on on_change().
Two more observable types round out the reactive core, both usable outside any widget tree:
Derived<T>— read-only value recomputed from other observables, recalculated (and only notifies on actual change) whenever a source fires:prism::Derived<double> total{[&]{ return price.get() * quantity.get(); }, price, quantity};
Shared<T>— atomic cross-thread cell (readers and writers take a brief internal lock (std::atomic<std::shared_ptr>); no lock is held while user code runs) (get()/set()from any thread); the owning thread callsdrain_notifications()to fireon_change()on its own turn. This is what backs theShared<DeviceState>in the live inspector example above — devices/background workers publish into it from another thread.
Wrap a batch of field.set() calls in prism::transaction([&]{ ... }) to coalesce their notifications into one flush at the end of the block instead of firing per-call (see Transaction API).
The type inside Field<T> determines which widget (View layer) renders it. Sentinel types are templated wrappers that encode presentation semantics:
enum class Theme { Light, Dark, System };
struct Editor {
prism::Field<std::string> title{""}; // → read-only text (default for StringLike)
prism::Field<prism::Label<>> status{{"OK"}}; // → read-only label
prism::Field<prism::TextField<>> search{{.placeholder = "Search..."}}; // → editable text field
prism::Field<prism::Password<>> secret{{.placeholder = "API key"}}; // → masked input
prism::Field<prism::Slider<>> volume{{.value = 0.8}}; // → continuous slider
prism::Field<prism::Button> save{{"Save"}}; // → clickable button
prism::Field<bool> dark_mode{true}; // → checkbox
prism::Field<prism::Checkbox> notify{{.checked = true, .label = "Enable"}}; // → checkbox with label
prism::Field<Theme> theme{Theme::Dark}; // → auto-dropdown via reflection
prism::Field<prism::Slider<int>> quality{{.value = 3, .min = 1,
.max = 5, .step = 1}}; // → discrete slider
};Widgets are resolved at compile time via concepts, not concrete types. A widget matches on traits (StringLike, Numeric, ScopedEnum), so custom types work automatically if they satisfy the right concept:
// Your own string type works in Label<> if it satisfies StringLike
prism::Field<prism::Label<MyString>> info{{my_string}};
// Any scoped enum gets an auto-dropdown via P2996 enumerators_of
prism::Field<MyEnum> mode{MyEnum::Default};Models are plain structs. Compose by nesting — no inheritance, no macros:
struct Settings {
prism::Field<std::string> username{"jeandet"};
prism::Field<bool> dark_mode{true};
};
struct Dashboard {
Settings settings; // nested group
prism::Field<int> counter{0};
prism::State<int> request_count{0}; // observable, no widget
};graph TD
D["Dashboard"] --> S["Settings"]
D --> C["counter : Field<int>"]
D --> R["request_count : State<int><br/><i>(no widget)</i>"]
S --> U["username : Field<string>"]
S --> DM["dark_mode : Field<bool>"]
On C++26, P2996 reflection walks the struct members automatically — Field<T> gets a widget, State<T> is skipped, nested structs recurse. On C++23, each model provides a view() method using vstack/hstack:
struct Dashboard {
Settings settings;
prism::Field<int> counter{0};
void view(prism::WidgetTree::ViewBuilder& vb) {
vb.vstack(settings, counter); // Fields and nested components in one call
}
};Both paths produce identical output through an intermediate Node layer. No registration, no moc, no string-based identity.
graph LR
subgraph "Entry Points"
MA["model_app(title, model)<br/><b>Model-driven</b><br/>Reflection generates UI"]
MVU["app<State>(title, view, update)<br/><b>Retained layout</b><br/>Manual composition"]
RAW["App + Frame<br/><b>Raw DrawList</b><br/>No state management"]
end
MA -->|primary| W[WidgetTree + SceneSnapshot]
MVU --> W
RAW --> W
1. Model-driven (primary API) — define model structs, reflection does the rest:
Dashboard dashboard;
prism::model_app("My App", dashboard);2. Retained layout — manual row()/column()/spacer() composition:
prism::app<State>("App", State{},
[](auto& ui) { ui.column([&] { /* ... */ }); },
[](State& s, const prism::InputEvent& ev) { /* ... */ }
);3. Raw DrawList — direct rendering, no state management:
using namespace prism::literals;
prism::App app({.title = "Hello", .width = 800, .height = 600});
app.run([](prism::Frame& frame) {
frame.filled_rect({prism::Point{10_x, 10_y}, prism::Size{200_w, 100_h}},
prism::Color::rgba(0, 120, 215));
});All three entry points take a WindowConfig — model_app() accepts one directly in place of a
bare title string:
prism::model_app({.title = "My App", .width = 900, .height = 600,
.resizable = true, .decoration = prism::DecorationMode::Custom},
dashboard);decoration selects the window chrome:
| Mode | Behavior |
|---|---|
DecorationMode::Custom (default) |
PRISM draws its own title bar, close/min/max buttons, and manual resize edges — works identically across platforms, including Wayland where server-side decoration is often unavailable |
DecorationMode::Native |
Defers to the OS window manager's decoration |
DecorationMode::None |
Borderless, undecorated window |
sequenceDiagram
participant App as Application Thread
participant Snap as SceneSnapshot (atomic)
participant Back as Backend Thread (SDL)
Note over App: Sleeps on stdexec run_loop
Note over Back: Blocks on SDL_WaitEvent
Back->>App: InputEvent via run_loop scheduler
App->>App: Widget handle_input (View)
App->>App: field.set() → on_change (Behavior)
App->>App: Rebuild dirty DrawLists only
App->>Snap: Publish new snapshot (atomic swap)
App->>Back: Wake (SDL_PushEvent)
Back->>Snap: Load latest snapshot
Back->>Back: Rasterise + present
Both threads sleep at OS level when idle (futex / SDL event wait). Zero CPU when nothing changes.
One logic thread owns the widget tree and every Field<T>; Field<T> is not thread-safe. Any thread may call Shared<T>::set(), Channel<T>::send(), or post a closure; these are safe under arbitrary concurrency. Shared<T> publishes only the latest value — intermediate values are dropped by design. Channel<T> is lossless and per-producer FIFO. A posted closure wakes an idle logic thread exactly once per burst. Exceptions thrown by posted or observed callbacks are routed to prism::core::set_unhandled_error_handler (default: printed to stderr) and never stop the drain. transaction() batches and coalesces notifications on the calling thread; it does not roll back on exception.
| Feature | Used for | Required |
|---|---|---|
| Static Reflection (P2996) | Auto-generate UI from model structs | Optional — view() method is the fallback |
std::execution (P2300) |
run_loop event loop, prism::then / prism::on pipe adaptors |
Yes (via stdexec) |
| Senders/receivers | Observer pattern — Field<T>::on_change() + SenderHub |
Yes |
| Concepts & Constraints | Widget resolution (StringLike, Numeric, SliderRenderable), strong type algebra |
Yes (C++20) |
std::expected |
Fallible API operations — no exceptions at API boundary | Yes (C++23) |
| Designated initialisers | Named-parameter widget construction | Yes (C++20) |
| magic_enum | Enum introspection fallback when P2996 is unavailable | Auto — only on pre-C++26 |
Minimum: C++23 compiler (GCC 14+, Clang 17+) and Meson >= 1.5.
With reflection: GCC 16+ with -freflection (auto-detected by the build system). When available, model structs don't need a view() method — P2996 generates the UI automatically.
Without reflection: All model structs must provide a view() method that describes the widget tree via ViewBuilder. Enum dropdowns use magic_enum (fetched automatically as a Meson wrap).
meson setup builddir
ninja -C builddir
meson test -C builddirThe build auto-detects -freflection support and conditionally enables P2996. Dependencies (SDL3, stdexec, doctest, magic_enum) are fetched via Meson wraps.
graph LR
P1["Phase 1<br/>Core Infrastructure<br/><b>DONE</b>"]
P2["Phase 2<br/>Layout + Observers<br/><b>DONE</b>"]
P3["Phase 3<br/>Widgets + Rendering<br/><b>DONE</b>"]
P35["Phase 3.5<br/>stdexec<br/><b>DONE</b>"]
P36["Phase 3.6<br/>Node Tree<br/><b>DONE</b>"]
P4["Phase 4<br/>Advanced Features<br/><b>DONE</b>"]
P45["Phase 4.5<br/>Strategic Priorities<br/><b>IN PROGRESS</b>"]
P5["Phase 5<br/>GPU Backend"]
P1 --> P2 --> P3 --> P35 --> P36 --> P4 --> P45 --> P5
- Phase 1 (done) — DrawList, SceneSnapshot, SDL3 backend, event-driven loop
- Phase 2 (done) — Layout engine, hit testing,
Connection/SenderHub,Field<T>,List<T>, P2996 reflection,WidgetTree,model_app() - Phase 3 (done) — Widget dispatch, SDL_Renderer + SDL3_ttf, all built-in widgets (Label, TextField, Password, TextArea, Slider, Button, Checkbox, Dropdown), overlay/popup system, keyboard focus (Tab/Shift+Tab), custom
view()override,canvas()escape hatch, strong coordinate types,clip_pushlocal coordinate system - Phase 3.5 (done) — stdexec
run_loopevent loops,prism::then/prism::onpipe adaptors,AppContext - Phase 3.6 (done) —
Nodeintermediate layer (type-erasedField<T>+ children), pre-C++26 support viaview()methods, magic_enum enum fallback,#if __cpp_impl_reflectionguards (only 2 locations) - Phase 4 (done) — Scroll areas, virtual lists, animation system (easing/spring,
Animation<T>,TransitionGuard<T>), mouse capture & drag, table widget (virtual scroll, headers, row selection), tab bar (TabBar<>, lazy per-tabViewBuilder), plot widget (zoom/pan/crosshair, auto-fit), SVG export, window abstraction + custom chrome, theme palette (runtime-switchable), transaction API (deferred/coalesced callbacks),Derived<T>/Shared<T>state taxonomy, codebase reorganization into 8 modules - Phase 4.5 (in progress) — Custom widget from type (
Widget<T>,is_widget_v,EditState), live object inspector (Inspector<T>/FieldMirror<T>— edit aShared<T>cross-thread with zero boilerplate), inspector field annotations (skip/readonly/label/sectionvia C++26[[=...]]attributes),Tree<T>widget (TreeSource/TreeController,wrap_tree_storage()/wrap_struct_tree(), virtualized rows, keyboard nav), live tree inspector (browse a running app's ownWidgetNodetree, synced detail panel with auto-scroll), Python SDK (nanobind, fully multi-threaded,Field/Shared/Channel/Derived/List,ViewBuildertrampoline,Annotatedvalidation,transaction); broader debugging/profiling story (dirty-region viewer) continues - Phase 5 — Vulkan/WebGPU backend, SDF text, tile compositing
Detailed design rationale for each subsystem lives in doc/design/:
- Threading Model — atomic snapshot handoff (readers never block writers; implementation is
std::atomic<std::shared_ptr>, not guaranteed lock-free), thread roles, input flow - Scene Snapshot — structure, versioning, dirty repaint model
- Draw List — command set, strong coordinate types, local coordinate system, serialisation
- Render Backend — BackendBase vtable, software vs GPU path
- Input Events — input queue, event forwarding, hit testing
- Layout Engine — row/column/spacer, two-pass solver, hit testing
- Field/Sender/Widget Spec — Field, observer pattern, persistent widget tree
- Widgets & Sentinels — concept-driven widgets, all sentinel types (incl. TextArea), overlay system, focus policy
- Input Routing — hit_test → dispatch → delegate handle_input → field mutation
- stdexec Integration — run_loop event loops, prism::then/on pipe adaptors, AppContext
- SDL_Renderer Migration — SDL_Renderer + SDL3_ttf replaces PixelBuffer surface-blit
- Dynamic Node Tree —
Nodeintermediate layer, pre-C++26 support, ViewBuilder→Node→WidgetNode pipeline - Styling — theme as data, context propagation (draft)
- Components —
prism::Componentbase class, self-wiring reusable UI + logic bundles (design only) - Table Widget —
ColumnStorage/RowStorageadapters, virtual scroll, headers, row selection - Tab Widget — tabbed container, active-tab state
- Plot Widget —
PlotModel, per-axis zoom/pan, crosshair, auto-fit - SVG Export — draw commands, viewport-aware
to_svg() - Theme Palette — flat
Themestruct, runtime-switchable palette - Window Abstraction —
Window/WindowConfig, custom chrome, manual resize tracking - Transaction API —
prism::transaction()/TransactionGuard, coalesced notifications - State Taxonomy —
Derived<T>signal graph,Shared<T>cross-thread cell - Custom Widget From Type —
Widget<T>,is_widget_v,EditState, user extension story - Live Object Inspector —
Inspector<T>/FieldMirror<T>reflection synthesis - Inspector Field Annotations —
skip/readonly/label/sectionC++26 attributes - Tree Widget —
TreeSource, 3-tier adapter API, virtualized rows, keyboard nav - Live Tree Inspector — live
WidgetNodetree browser, dual-window event loop, detail panel auto-scroll - Python SDK — nanobind, fully multi-threaded, GIL-free 3.14+, posted-mutation queue, ViewBuilder trampoline
MIT
