- C++ 98%
- CMake 2%
| Filename | Latest commit message | Latest commit date |
|---|---|---|
TECH-31: word-wrapping ui.text (honours newlines), Text::mono() with mono glyph path through software, D3D11 and GDI renderers. TECH-32: Button colour/selected/align overrides, ui.list_item, Container::border, ui.divider. TECH-33: ui.selectable_text (drag select, Ctrl+C/A) and ui::pick_folder Task wrapper (IFileOpenDialog), with headless tests and docs. Co-Authored-By: Claude Sonnet 5.5 <[email protected]> |
||
| .forgejo/workflows | ||
| docs | ||
| examples | ||
| include/ui | ||
| src/ui | ||
| tests | ||
| .clang-format | ||
| .gitignore | ||
| AGENTS.md | ||
| CMakeLists.txt | ||
| compile_flags.txt | ||
| README.md | ||
| ui-implementation-plan.md | ||
modern::ui
C++20 Elm-style GUI library. Your app is a struct with a Message variant, update() overloads and a view(); the library does layout, rendering and event dispatch. Windows only (Win32 window, D3D11 or software rendering); everything except the window is testable headless.
For AI agents: this file is the complete usage guide. Deeper references:
docs/decisions.md(behavior and limits, per phase),examples/(runnable apps),tests/ui/(usage of every widget under the headless runtime),include/ui/(public headers, no platform includes).
1. Add it to a CMake project
include(FetchContent)
FetchContent_Declare(modern GIT_REPOSITORY <repo-url> GIT_TAG main)
FetchContent_MakeAvailable(modern)
add_executable(app main.cpp)
target_link_libraries(app PRIVATE modern::ui)
- Needs C++20 (set automatically via
cxx_std_20), MSVC or clang on Windows. Linksuser32 gdi32 d3d11 dxgi d3dcompiler ole32 uuid shell32itself. - Tests/examples are only built when this repo is the top-level project. Options:
UI_BUILD_TESTS,UI_BUILD_EXAMPLES,UI_ENABLE_SANITIZERS. add_subdirectory(path/to/modern-ui)andfind_package(modern)(aftercmake --install) also work; the target ismodern::ui.- Include one header:
#include "ui/ui.hpp". The C++ namespace isui(notmodern). - For a GUI-subsystem exe with a normal
main:add_executable(app WIN32 ...)and, on MSVC,target_link_options(app PRIVATE /ENTRY:mainCRTStartup).
2. Minimal app
#include "ui/ui.hpp"
#include <chrono>
using namespace std::chrono_literals;
using namespace ui;
struct Counter {
struct Inc {};
struct Dec {};
struct Tick {};
struct SetTo { float v; }; // slider publishes M{float}
using Message = std::variant<Inc, Dec, Tick, SetTo>;
using Cmd = Cmd<Message>; // every app declares this
int value = 0;
auto update(Inc) -> Cmd { ++value; return {}; } // {} == Cmd::none
auto update(Dec) -> Cmd { --value; return {}; }
auto update(Tick) -> Cmd { ++value; return {}; }
auto update(SetTo s) -> Cmd { value = static_cast<int>(s.v); return {}; }
auto view(Ctx<Message>& ui) const -> Element<Message> {
return ui.column({
ui.row({ ui.button("+", Inc{}), ui.text("{}", value).size(32), ui.button("-", Dec{}) })
.spacing(8).padding(2).align_y(Align::Center),
ui.slider<SetTo>(0.f, 100.f, static_cast<float>(value)),
}).spacing(8).padding(16).align_x(ui::center);
}
auto subscribe() const { return every(1s, Tick{}); } // optional
};
int main() { return run<Counter>({.title = "counter", .size = {400, 300}, .theme = dark}); }
Rules the compiler enforces or checks:
Messageis astd::variantof small structs. You need exactly oneupdate(T)overload per alternative; a missing one is astatic_assertnaming the first missing alternative. Never writestd::visit.updatereturnsCmd(return {};for none).viewandsubscribeareconst.viewis a pure function of state. It is called again after any message; do not keepElements across calls (they are arena handles, valid only within oneview()).- Button messages must be trivially destructible when active (don't put a
std::stringin a button's message).text_inputmessages may holdstd::string. - Redraws happen only after a message, an event that changed something, or
Shell::request_redraw. Idle CPU is ~0.
3. Widgets (all via Ctx<Message>& ui)
| Call | Notes |
|---|---|
ui.text("fmt {}", args...) |
std::format syntax. .size(px), .color(Color), .mono(). Wraps at the available width and honours \n |
ui.button(label, Msg{}) |
.padding(Insets), .size(px), .colors(normal, hover, pressed), .text_color, .corner_radius, .selected(bool), .align_x(Align); Tab focus, Enter/Space activates |
ui.list_item(label, Msg{}, selected) |
full-width, left-aligned, transparent-until-hovered row; highlighted when selected |
ui.column({...}) / ui.row({...}) |
.spacing(px), .padding(Insets), .align_x(Align) / .align_y(Align) |
ui.column(ui.collect(range)) |
dynamic lists: collect copies any range of elements into the arena (initializer lists can't be built at runtime) |
ui.container(child) |
.padding, .align_x, .align_y, .background(Color, radius), .border(Color, px) |
ui.divider() |
1px horizontal rule across the available width |
ui.selectable_text(str) |
read-only wrapped multi-line text; drag to select, Ctrl+C copies, Ctrl+A selects all; .mono() |
ui.slider<SetTo>(lo, hi, value) |
publishes SetTo{float}; arrows step 5% |
ui.text_input<Edited>(text, "placeholder") |
publishes Edited{std::string} with the full new text on every edit; the app owns the text. Single-line, caret, shift-selection, clipboard |
ui.scrollable(child) |
vertical only; wheel scrolls |
ui.space() |
filler; give it .width/.height(Length::fill()) |
Common to every widget: .width(Length), .height(Length), .id("name").
Length:Length::fill(),Length::fill_portion(n),Length::shrink()(default),Length::fixed(px)or a plainint.Insets(all)orInsets(top, right, bottom, left).Align::{Start, Center, End}(alsoui::start,ui::center,ui::align_end)..id("x")gives a widget a stable identity so its internal state (caret, scroll offset, focus) survives reordering. Unkeyed widgets keep state by position. Duplicate ids assert in debug builds.- Themes:
Settings{.theme = dark}; colors come fromui.theme().palette.
4. Side effects: Cmd, Task, Subscription
auto update(Fetch) -> Cmd {
return spawn(load()); // Cmd from a coroutine
}
Task<Message> load() {
co_await delay(500ms); // virtual-clock friendly sleep
int v = co_await offload([] { return heavy(); }); // runs on a worker, resumes on UI thread
co_return Loaded{v}; // a Task yields exactly one message
}
Cmd:none,spawn(Task),batch(a, b, ...),map<Parent>(cmd, wrap). Move-only.Task<Msg>: lazy coroutine, oneco_return. Exceptions terminate.offloadneedsSettings::worker_threads > 0, otherwise it runs inline.subscribe()returnsevery(period, Msg{})(combine withSubscription::batch). Identity is (period, message type); remove it fromsubscribe()and it stops.- Coroutines run only on the UI thread; shutdown destroys outstanding frames before state.
5. Composition
Sub-components with their own Message:
static Message to_parent(Sub::Message m) { return Wrapped{m}; } // plain function, not a capturing lambda
ui.scope<Sub::Message>(&to_parent, [&](Ctx<Sub::Message>& c) { return sub.view(c); });
// or: ui.map(element_of_child_msg, &to_parent)
// commands: map<Message>(sub_cmd, +[](Sub::Message m) { return Message{Wrapped{m}}; })
wrap must be a function pointer (nodes are trivially destructible), so captureless lambdas need a unary +.
6. Custom widgets
struct Swatch { // must be trivially destructible
Color c;
Size layout(const Limits& l) const { return l.clamp({40, 40}); } // or layout(l, const TextMeasurer&)
void draw(Renderer& r, Rect b) const { r.fill_quad(Quad{b, c, 4}); }
void on_event(const Event& e, Shell<Message>& s) {} // or on_event(e, s, Rect bounds)
};
ui.add(Swatch{red}).focusable();
The widget is copied into the frame arena each frame and holds no persistent state; keep state in the app. Check include/ui/core/renderer.hpp for the Renderer interface (fill_quad, draw_text, clip stack).
Native folder dialog: auto dir = co_await ui::pick_folder("Title"); inside a Task yields std::optional<std::string> (nullopt = cancelled). Runs on a worker when Settings::worker_threads > 0; tests can stub it with ui::set_folder_picker.
7. Testing without a window
ui::HeadlessRuntime<Counter> rt({.size = {400, 300}});
rt.inject(Mouse{Mouse::Kind::Press, {x, y}}); rt.inject(Mouse{Mouse::Kind::Release, {x, y}});
rt.step(); // process events, render a frame
rt.advance_clock(3s); // virtual time: fires subscriptions and delays, no sleeping
assert(rt.app().value == 1);
assert(rt.contains_text("1")); // also rt.find_text(str, Point&), rt.primitives()
Events: Mouse, Key{.code = Key::Tab, ...}, TextInput{U'a'}, Window{Resize, size}. Text uses fixed test metrics. Find clickable widgets via find_text. See tests/ui/test_input.cpp for typing, selection, slider drag, focus cycling.
8. Build and test this repo
cmake -S . -B build
cmake --build build --config Release
ctest --test-dir build -C Release --output-on-failure
zero_alloc_frames runs in Release only. win32_window_input* tests need an interactive desktop. Golden images: UPDATE_SNAPSHOTS=1 build/Release/ui_tests.
9. Limits to know before using
Windows only. Left-to-right text (one proportional face plus a monospace face), word wrapping only in text/selectable_text, no kerning, no complex scripts. Vertical scrolling only. No image drawing. Text fields don't scroll horizontally. See docs/decisions.md for the full list and rationale.