Elm-architecture C++20 GUI library
  • C++ 98%
  • CMake 2%
Find a file
Repository files (latest commit first)
Filename Latest commit message Latest commit date
techwritescode 835192c4dd
All checks were successful
ci / windows-msvc (pull_request) Successful in 6m47s
ci / windows-msvc (push) Successful in 6m58s
Add wrapping/monospace text, selectable rows, selectable text, folder picker
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]>
2026-10-14 16:12:30 -05:00
.forgejo/workflows Move CI to Forgejo Actions, Windows only 2026-10-13 23:12:32 -05:00
docs Add wrapping/monospace text, selectable rows, selectable text, folder picker 2026-10-14 16:12:30 -05:00
examples Implement modern-ui library (phases 0-6) 2026-10-13 23:10:40 -05:00
include/ui Add wrapping/monospace text, selectable rows, selectable text, folder picker 2026-10-14 16:12:30 -05:00
src/ui Add wrapping/monospace text, selectable rows, selectable text, folder picker 2026-10-14 16:12:30 -05:00
tests Add wrapping/monospace text, selectable rows, selectable text, folder picker 2026-10-14 16:12:30 -05:00
.clang-format Implement modern-ui library (phases 0-6) 2026-10-13 23:10:40 -05:00
.gitignore Implement modern-ui library (phases 0-6) 2026-10-13 23:10:40 -05:00
AGENTS.md Make library consumable via FetchContent as modern::ui; add README 2026-10-14 15:41:59 -05:00
CMakeLists.txt Add wrapping/monospace text, selectable rows, selectable text, folder picker 2026-10-14 16:12:30 -05:00
compile_flags.txt Implement modern-ui library (phases 0-6) 2026-10-13 23:10:40 -05:00
README.md Add wrapping/monospace text, selectable rows, selectable text, folder picker 2026-10-14 16:12:30 -05:00
ui-implementation-plan.md Implement modern-ui library (phases 0-6) 2026-10-13 23:10:40 -05:00

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. Links user32 gdi32 d3d11 dxgi d3dcompiler ole32 uuid shell32 itself.
  • 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) and find_package(modern) (after cmake --install) also work; the target is modern::ui.
  • Include one header: #include "ui/ui.hpp". The C++ namespace is ui (not modern).
  • 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:

  • Message is a std::variant of small structs. You need exactly one update(T) overload per alternative; a missing one is a static_assert naming the first missing alternative. Never write std::visit.
  • update returns Cmd (return {}; for none). view and subscribe are const.
  • view is a pure function of state. It is called again after any message; do not keep Elements across calls (they are arena handles, valid only within one view()).
  • Button messages must be trivially destructible when active (don't put a std::string in a button's message). text_input messages may hold std::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 plain int.
  • Insets(all) or Insets(top, right, bottom, left). Align::{Start, Center, End} (also ui::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 from ui.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, one co_return. Exceptions terminate. offload needs Settings::worker_threads > 0, otherwise it runs inline.
  • subscribe() returns every(period, Msg{}) (combine with Subscription::batch). Identity is (period, message type); remove it from subscribe() 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.