diff --git a/Widgets/CMakeLists.txt b/Widgets/CMakeLists.txt index 80d060bb..74b62b37 100644 --- a/Widgets/CMakeLists.txt +++ b/Widgets/CMakeLists.txt @@ -1,17 +1,67 @@ project(Widgets) -### ---------------------- Static Library --------------------- ### -file(GLOB SOURCES "./private/*.cpp" "./private/*/*.cpp") -file(GLOB HEADERS "./public/*.hpp") +function(widgets_library TARGET_NAME LIBRARY_DIR) + set(DIR ${CMAKE_CURRENT_SOURCE_DIR}/src/${LIBRARY_DIR}) + file(GLOB_RECURSE SOURCES CONFIGURE_DEPENDS ${DIR}/private/*.cpp ${DIR}/private/*.hpp ${DIR}/public/*.hpp) + add_library(${TARGET_NAME} STATIC ${SOURCES}) + target_include_directories(${TARGET_NAME} PUBLIC ${DIR}/public) + target_include_directories(${TARGET_NAME} PRIVATE ${DIR}/private) +endfunction() -add_library(${PROJECT_NAME} STATIC ${SOURCES} ${HEADERS}) -target_include_directories(${PROJECT_NAME} PUBLIC ./public/ ./public/layouts ./public/mangers ./public/widgets) -target_link_libraries(${PROJECT_NAME} PUBLIC Math GraphicApplication Imgui) +### ---------------------- Core --------------------- ### +widgets_library(${PROJECT_NAME}Core core) +target_link_libraries(${PROJECT_NAME}Core PUBLIC Math GraphicApplication) -### -------------------------- Applications -------------------------- ### +### ---------------------- Layouts --------------------- ### +widgets_library(${PROJECT_NAME}Layouts layouts) +target_link_libraries(${PROJECT_NAME}Layouts PUBLIC ${PROJECT_NAME}Core) -add_executable(WidgetsExample examples/Example.cpp) -target_link_libraries(WidgetsExample ${PROJECT_NAME} ${GLEW_LIB}) -graphic_application_resources(WidgetsExample) -target_include_directories(WidgetsExample PUBLIC ../Externals/glfw/include ${GLEW_INCLUDE_DIR}) +### ---------------------- Components (all widgets + nodes, by category) --------------------- ### +file(GLOB_RECURSE COMPONENT_SOURCES CONFIGURE_DEPENDS + ${CMAKE_CURRENT_SOURCE_DIR}/src/components/*/private/*.cpp + ${CMAKE_CURRENT_SOURCE_DIR}/src/components/*/private/*.hpp + ${CMAKE_CURRENT_SOURCE_DIR}/src/components/*/public/*.hpp) +file(GLOB COMPONENT_PUBLIC_DIRS LIST_DIRECTORIES true ${CMAKE_CURRENT_SOURCE_DIR}/src/components/*/public) +file(GLOB COMPONENT_PRIVATE_DIRS LIST_DIRECTORIES true ${CMAKE_CURRENT_SOURCE_DIR}/src/components/*/private) +add_library(${PROJECT_NAME}Components STATIC ${COMPONENT_SOURCES}) +target_include_directories(${PROJECT_NAME}Components PUBLIC ${COMPONENT_PUBLIC_DIRS}) +target_include_directories(${PROJECT_NAME}Components PRIVATE ${COMPONENT_PRIVATE_DIRS}) +target_link_libraries(${PROJECT_NAME}Components PUBLIC ${PROJECT_NAME}Core ${PROJECT_NAME}Layouts Imgui PRIVATE Md4c) +### ---------------------- Window application adapter --------------------- ### +widgets_library(${PROJECT_NAME}App app) +target_link_libraries(${PROJECT_NAME}App PUBLIC ${PROJECT_NAME}Components GraphicApplication Imgui) + +### ---------------------- Umbrella --------------------- ### +add_library(${PROJECT_NAME} INTERFACE) +target_link_libraries(${PROJECT_NAME} INTERFACE + ${PROJECT_NAME}Core ${PROJECT_NAME}Layouts ${PROJECT_NAME}App ${PROJECT_NAME}Components) + +### -------------------------- Gallery -------------------------- ### +add_library(${PROJECT_NAME}Gallery STATIC ./examples/Gallery.cpp ./examples/Gallery.hpp ./examples/ThemeEditor.cpp ./examples/ThemeEditor.hpp) +target_include_directories(${PROJECT_NAME}Gallery PUBLIC ./examples) +target_link_libraries(${PROJECT_NAME}Gallery PUBLIC ${PROJECT_NAME}Components) + +if (NOT ANDROID) + add_executable(${PROJECT_NAME}Example ./examples/GalleryApplication.cpp) + target_link_libraries(${PROJECT_NAME}Example ${PROJECT_NAME}Gallery ${PROJECT_NAME}App) +endif () + +### -------------------------- Tests -------------------------- ### +file(GLOB TEST_SOURCES CONFIGURE_DEPENDS "./tests/*.cpp") +add_executable(Test${PROJECT_NAME} ${TEST_SOURCES}) +target_link_libraries(Test${PROJECT_NAME} ${PROJECT_NAME}Core ${PROJECT_NAME}Layouts UnitTest++) +add_test(NAME Test${PROJECT_NAME} COMMAND Test${PROJECT_NAME}) + +if (NOT ANDROID) + # Component tests live with the components (src/components/tests). + file(GLOB COMPONENT_TEST_SOURCES CONFIGURE_DEPENDS "./src/components/tests/*.cpp") + file(GLOB TEST_SUPPORT_SOURCES CONFIGURE_DEPENDS "./src/components/tests/support/*.cpp") + # Note: these two need a live Application (the SessionRecorder is Application-coupled); re-enable once that is testable headless. + list(FILTER COMPONENT_TEST_SOURCES EXCLUDE REGEX "SessionRecordingControlTests.cpp") + list(FILTER COMPONENT_TEST_SOURCES EXCLUDE REGEX "DebugToolsTests.cpp") + add_executable(Test${PROJECT_NAME}Components ${COMPONENT_TEST_SOURCES} ${TEST_SUPPORT_SOURCES} ./tests/Main.cpp) + target_include_directories(Test${PROJECT_NAME}Components PRIVATE ./src/components/tests ./src/components/tests/support) + target_link_libraries(Test${PROJECT_NAME}Components ${PROJECT_NAME}Components ${PROJECT_NAME}Gallery ${PROJECT_NAME}App UnitTest++) + add_test(NAME Test${PROJECT_NAME}Components COMMAND Test${PROJECT_NAME}Components) +endif () diff --git a/Widgets/docs/ARCHITECTURE.md b/Widgets/docs/ARCHITECTURE.md new file mode 100644 index 00000000..fc26dd9a --- /dev/null +++ b/Widgets/docs/ARCHITECTURE.md @@ -0,0 +1,453 @@ +# Widgets + +A small retained-mode UI library: minimal, flexible, headless-testable. + +- `Widgets` — the library. Depends on `Math` only (plus the `InputCodes` / + `Rect` / `Color` headers). No window, no GPU. +- `WidgetsApp` — the adapter that runs a `Ui` inside a `Graphics` window: + `CanvasPainter`, `InputTranslator`, `WidgetApplication`. +- `WidgetsGallery` / `WidgetsExample` — a gallery of every widget, used both + as a demo application and as an end-to-end test subject. +- `TestWidgets` — headless tests. + +## Five concepts + +| Concept | Responsibility | +|---|---| +| `Widget` | A node: children, sizing request, assigned rect, state, hooks | +| `Layout` | Optional strategy on a container: measure children, then arrange them | +| `Event` | A value delivered to widgets in their local coordinates; bubbles to ancestors until accepted | +| `Popup` | A widget shown above the tree, anchored to a rect, with placement and dismissal rules | +| `Ui` | The context: owns layout, input routing, popups, ticking, tooltips, stats | + +## Frame + +``` +ui.dispatch(event) input: hit test, hover diff, capture, click synthesis, bubbling, popup dismissal +ui.advance(deltaMs) ticks: widget animations, geometry springs, tooltip dwell +ui.updateLayout() only if something requested layout: measure (cached) + arrange (skips clean subtrees) +ui.paint(painter) tree, then popups, each subtree translated and clipped +ui.needsFrame() true while layout/paint is pending, something animates, or a tooltip is pending +``` + +Layout always runs before an event is routed and after it is handled, so +handlers and hit testing never see stale geometry. + +## Writing a widget + +A custom widget overrides only what it needs: + +```cpp +class Swatch : public ui::Widget { +public: + explicit Swatch(RGBA color) : mColor(color) {} + +protected: + ui::Extent measureContent(ui::Axis, halnf) const override { return ui::Extent::flexible(24); } + void paint(ui::Painter& painter) override { painter.fillRect(localRect(), mColor, 4); } + void onEvent(ui::Event& event) override { + if (event.type() == ui::Event::Type::Click) event.accept(); + } + +private: + RGBA mColor; +}; +``` + +Hooks: `measureContent`, `onEvent`, `onTick`, `paint`, `paintOverlay`, +`hitTest`, `acceptsKeyboardFocus`, `onHoverChanged`, `onAttached`, +`onDetached`. Requests: `requestLayout`, `requestPaint`, `startTicking`, +`stopTicking`. Every built-in widget is written against exactly this API. + +## Sizing + +Each axis has one `AxisSizing`: + +| Mode | Meaning | +|---|---| +| `fit()` | Preferred size comes from content (text, icon, children) | +| `fixed(length)` | Exactly this length | +| `fill(weight)` | Content size, then grows with `weight` share of leftover space | + +Each can be bounded with `.atLeast(min)` and `.atMost(max)`. Resolution +produces an `Extent { minimum, preferred, maximum, grow }` that layouts work +with. + +`measureContent` declares how compressible the content is: + +| Extent | Use | +|---|---| +| `Extent::natural(length)` | Never smaller than its content: text, buttons, toggles, menu items | +| `Extent::shrinkable(preferred, minimum)` | Can give up space down to `minimum`: slider tracks, separators, generic boxes | +| `Extent::rigid(length)` | Exactly this length | + +When a container overflows, space is taken back only from items with slack +above their minimum. A `ScrollView` reports a zero minimum along its scroll +axis, so a long list scrolls instead of squashing the controls around it. + +## Algorithms + +**Measure** (bottom-up, cached per axis and cross length): a leaf reports +`measureContent`; a container asks its `Layout`; the widget's `AxisSizing` +then resolves the result. The cache is invalidated up the ancestor chain by +`requestLayout`. + +**Distribute** (`distributeSpace`): start each item at its preferred length, +then repeatedly give out free space by `grow` weight (or take it back +proportionally to each item's slack above its minimum), clamping to +`[minimum, maximum]`. Free space is recomputed from scratch every round, so +the loop converges in at most `n + 1` rounds without epsilon tricks. + +**Snap** (`placeSnapped`): edges are rounded, not lengths, so adjacent +children always tile without gaps. + +**Arrange** (top-down): a parent assigns each child a rect. A child whose +rect is unchanged and which has no pending layout is skipped entirely. + +**Popup placement** (`placePopup`): place on the requested side, flip to the +opposite side if it doesn't fit, then slide inside the viewport. + +**Hover**: diff the old and new root-to-target paths and notify only the +widgets that entered or left. + +## Layouts + +| Layout | Use | +|---|---| +| `RowLayout` / `ColumnLayout` (`BoxLayout`) | Flex-like stacking with gap, padding, main/cross alignment | +| `StackLayout` | Every child fills the whole area (pages, overlays) | +| `FreeLayout` | Children at `requestPosition()` with their preferred size (floating panels, desks) | +| `GridLayout` | Equal columns, rows as tall as their tallest cell | + +`SplitView` and `ScrollView` own private layouts with typed slots instead of +"child number N means X". + +## Docking + +`DockArea` is a CLion-style tool-window frame around a center widget, built +from `DockPanel`s: + +- **Stripes.** A thin rail on the left and right lists every panel. Left and + bottom panels share the left rail (bottom panels at its lower end); right + panels use the right rail. Clicking a stripe button toggles its panel. +- **One panel per side.** Opening a panel closes the other one docked on the + same side, as in CLion. +- **Header.** Title, a "⋮" options button and a "−" hide button, as in + CLion. The options menu has *View Mode* (Dock Pinned, Dock Unpinned, + Float), *Move To* (Left, Right, Bottom) and *Hide*. +- **View modes:** + - *Docked / pinned*: the panel takes a zone and pushes the center aside. + - *Undocked / unpinned*: the panel overlays the center and hides as soon as + the user activates something outside it (pressing the center, another + panel, a toolbar). Its stripe button can still toggle it. + - *Float*: a movable window inside the dock area, resizable from its + corner grip, independent of side zones. +- **Resize.** Drag a docked panel's inner edge; length is clamped so the + center always stays visible. +- **Drag to dock.** Dragging a header beyond a small threshold detaches the + panel, which follows the pointer as a raised window. While any panel is + dragged, docked or floating, a hot target square is shown for every side. + Moving the pointer onto one grows an animated preview of the zone the + panel will take; releasing there docks it on that side and moves its + stripe button. Releasing anywhere else floats the panel at the drop + position; nothing docks without a hot target. + +"Activates something outside" comes from `Ui::activeWidget()`, the widget +last pressed. The active path follows popup owners, so using a panel's +options menu counts as activity inside the panel. Every ancestor reports `containsActiveWidget()` and gets +`onActiveWithinChanged()`; that is how unpinned panels auto-hide without +polling. + +## Kanban + +`KanbanBoard` shows the items of a `KanbanModel` in columns and lets the user +drag them between and within columns. + +- **Columns** are configured in code with `setColumns({ KanbanColumn... })`: + title, accent, optional card limit (WIP), whether drops are accepted, and + ordering. `Manual` columns place a dropped card where the placeholder opens; + `ModelDefined` columns highlight as a whole and leave the order to the model. +- **Data** comes from the model: `items(column)` lists ids in display order and + `canMove(move)` may veto a move. The board never edits the model; it reports + `onMoved(KanbanMove)` and re-reads the model, so the model is the single source + of truth. Call `modelChanged()` after external edits; cards are kept per id and + animate when they change column. +- **Cards** are `KanbanCard` widgets (title, detail, accent strip, badge) made by + an optional factory and refreshed by a card updater, as in `TreeView`. + `setCardHeight(h)` gives every card the same height: the title keeps one line + and the detail as many lines as fit, ending in an ellipsis + (`Label::setMaximumLines`). Without it cards grow with their text. +- **Drag** starts after a small pointer movement (a long press with touch). The + card is lifted above the board and follows the pointer, edges auto-scroll, a + refused target is outlined in red, Esc cancels. Click selects, double-click + activates. +- **Column visibility**: every column header has an options ("...") menu with a + toggle per column, so any column can be shown or hidden from any other one. + `KanbanColumn::visible` sets the initial state, `setColumnVisible` changes it and + `setOnColumnVisibilityChanged` reports it so the host can persist it. The last + visible column cannot be hidden. Hidden columns take no space and receive no drops. +- **Width**: visible columns share the width equally, never narrower than the + minimum of `setColumnWidthRange`; when they don't fit, the strip scrolls + horizontally (scroll bar, trackpad swipe or horizontal wheel). + +## Tab views + +`TabView` shows one page at a time under a row of tab buttons. The style is +fixed at construction: + +- `TabView::Style::Tabs` (default) switches pages instantly. +- `TabView::Style::Carousel` adds Previous/Next arrow buttons around the tabs and + slides pages horizontally: the current page leaves to one side while the next + comes in from the other. Navigation is cyclic, so Next on the last page shows + the first. A horizontal wheel (or Shift+wheel) switches pages only when nothing + inside the page accepted it, because wheel events bubble from the hovered + widget up. Mouse notches switch at once; trackpad deltas pull the pages with + the same magnetic step as `TreeView` (see below). + +## Magnetic step + +`MagneticStep` (in `ScrollPhysics.hpp`, next to `FlingSpline` and +`StretchEffect`) is the one implementation of a discrete step reached by wheel +or pinch input. Widgets feed it input in step units with `push()` and advance it +every tick; it owns the accumulated pull, the resistance curve +`give * (1 - exp(-|pull| / give))`, the commit threshold, the release countdown +and a critically damped spring without overshoot. The visible `offset()` moves +from the first delta. Crossing `commitFraction` commits a step and starts a new +pull, so one gesture can pass several steps; `moveBy()` steps directly (mouse +notches, buttons). Tuning comes from one `MagneticStepTuning` that the step +reads live; `MagneticStepTuning::standard()` is the shared default and +`TreeViewStyle::step` lets a tree view adjust its own. + +Users: `TreeView` level pull, camera rest between compact and detailed cards, +and the pull into the full view; `TabView::Style::Carousel` pages. + +Each carousel page sits in its own slot with geometry animation off; the carousel +layout places the current slot and, while sliding, its neighbour, and the page +area clips them. + +## Application storage + +Every application keeps its own data in one folder, `~//`. A +`WidgetApplication` subclass names it by implementing +`persistentAppStorage()` (part of `WindowHost`), for example +`AppStorage::inHome("taskboard")`. Widgets reach it through +`ui()->windowHost()->persistentAppStorage()`: + +- `images/` holds images pasted into a `MarkdownEditor`. Without an explicit + `setImageStore`, a pasted image is saved there and linked by absolute path. +- `sessions/` is the default folder for session recordings + (`$MODULES_SESSION_DIR` overrides it). +- `hangs/` holds hang reports (`$MODULES_HANG_DIR` overrides it). +- Other files are the application's own, such as taskboard's + `session-presets.json` or sketch2d's `sketches/` library. + +Tests and benchmarks that create a whole application give it a temporary +storage folder (for example `Sketch2DOptions::forTesting`) so they never +write into the user's real data. + +## Popups + +One mechanism serves menus, submenus, dropdowns, popup buttons and tooltips: + +```cpp +ui::PopupRequest request; +request.content = &menu; // any widget; attached while open +request.owner = &button; // pressing the owner doesn't dismiss; owner removal closes it +request.anchor = button.windowRect(); +request.options.placement = ui::Placement::Below; +ui->openPopup(request); +``` + +The stack tracks chains: a popup opened from inside another popup is its +child. Opening from the main tree replaces unrelated popups. Pressing outside +closes popups above the pressed one. Escape closes the topmost. `modal` +blocks input below. `interactive = false` (tooltips) makes it ignore the +pointer. + +## Input + +- Pointer events go to the deepest visible widget under the pointer (popups + first), then bubble to ancestors until one calls `accept()`. +- A widget that calls `ui()->capturePointer(*this)` receives moves and the + release even outside its bounds. Only the capturing widget can release. +- `Click` is synthesized when press and release land on the same widget. +- Keyboard events go to the focused widget (set when a focusable widget is + pressed), else the topmost popup, else the root. +- Disabled widgets receive nothing; their ancestors still do. +- `PointerDown` and `Click` carry `clickCount()`: presses of the same button + within 400 ms and 4 px count up, so double-click is `clickCount() == 2`. + The clock comes from `Ui::advance`, which keeps it deterministic in tests. +- `setCursor(shape)` gives a widget a pointer cursor; the hovered widget or its + nearest ancestor with a cursor wins and is sent to the window host. + +## Window + +The window has no system decorations. `Ui::setWindowHost` connects the tree to +the window (`WidgetApplication` is the host in apps; tests use +`FakeWindowHost`). Without a host the window controls stay hidden. + +- `MenuBar` ends with `WindowControls`: monochrome minimize, maximize/restore + and close buttons (close hovers red, the one non-gray color). +- Pressing empty menu-bar space starts a compositor window move, double-click + toggles maximize, and secondary press opens the system window menu. +- The frame itself (rounded corners, outline, shadow, edge resizing) belongs to + `Graphics`: `WindowFrame` owns the geometry and edge hit-testing, the Wayland + backend reserves a shadow margin, sets the window geometry and input region, + and the canvas cuts the corners and paints the shadow last. Maximized and + tiled windows drop the margin and corners. Without compositor alpha support + the frame falls back to an opaque square window that still resizes from its + edges. + +## Animation + +Geometry is animated by default, as in the original library. Every widget +has two rects: + +- `targetRect()`: where layout wants the widget. +- `rect()`: where the widget is now. Painting, hit testing, `windowRect()` + and the arrangement of its children all use this one. + +When layout assigns a new target, a spring (`Motion::fluid()`, the same +stiffness 0.0041 and damping 0.25 per millisecond as the original +`SpringRect`) moves `rect()` towards it. Each step re-arranges the widget's +children inside the current rect, so a panel that is opening pushes the +main view and lays out its own content as it grows; nothing is faked at +paint time. Children of a moving widget follow it directly instead of +running their own springs, so motion never compounds. The curve has no +overshoot: 90% of the travel takes about 135 ms, and it settles to within +a quarter pixel in about 300 ms. + +Appearing is animated too: + +- Popups grow out of the edge of their anchor. +- Dock panels slide out of their side. +- A widget that becomes visible grows from its center. + +`setGeometryAnimated(false)` opts a widget out; scroll view content and +dock panels being resized by the pointer use it so they track input +exactly. `finishGeometryAnimation()` jumps to the target. + +`Math/SpringAnimations.hpp` steps springs in substeps of at most 1 ms with +an explicit delta, so the curve is the same at any frame rate and tests are +deterministic. Controls animate hover, press and toggle visuals with +`AnimatedFloat` / `AnimatedColor` and the `Motion::quick()` / +`Motion::smooth()` presets. + +## Text styles + +`Painter::styledText` / `TextMetrics::styledTextWidth` take a `TextStyle` +(`fontSize` + `FontFace`: Regular, Bold, Italic, BoldItalic, Monospace). +`text()` / `textWidth()` are Regular shorthands. `Canvas` loads +`rsc/FontBold.ttf` (Cantarell Bold) and `rsc/FontMono.ttf` (JetBrains Mono), +both OFL (`Graphics/rsc/licenses`); italic is a synthetic skew. A missing +face falls back to the regular font. + +## Markdown + +Parsing is md4c (`Externals/md4c`, MIT), linked privately into `Widgets`. + +| Piece | Responsibility | +|---|---| +| `markdown::MarkdownDocument` | md4c callbacks → `Block` tree (kind, children, styled `TextRun`s, byte `SourceRange` into the source), outline, task toggling | +| `RichText` | One paragraph of `TextSpan`s: per-span face, colour, background chip, link, strike/underline; word wrap, alignment, link hover/click, click → source offset | +| `MarkdownView` | Builds widgets from the document (structure ported from lv_markdown, tables/links/images from imgui_md): headings, lists, task checkboxes, quotes, admonitions, code, tables, rules | +| `MarkdownImage` | An image paragraph: loaded through `Ui::imageLoader()`, shrinks to the column width, alt-text placeholder when missing | +| `MarkdownEditor` | CLion-like Editor / Split / Preview: monospace `TextArea` with markdown highlighting, debounced re-render, scroll-synced panes, Ctrl+wheel zoom of both panes, clicking preview text moves the caret, task checkboxes rewrite the source | + +Rebuilding happens on `setMarkdown`; widgets replaced while one of them is +handling an event are retired and destroyed on the next tick. Task toggles +are applied on the next tick for the same reason. + +Scroll sync: whichever pane scrolls first leads until the next tick; the +other jumps to the block or source line at the leader's top edge. +`ScrollView::setOnScrolled` reports every applied offset change. + +Images: `ImageLoader` is an optional `Ui` service (like `TextMetrics`). +`WidgetsApp` installs `CanvasImageLoader`, which caches nanovg images by +path. Relative image paths resolve against `MarkdownView::setBaseDirectory`. + +Pasting images: `WindowHost::clipboardImage()` returns the clipboard's +image bytes and MIME type (Wayland: PNG preferred, any `image/*` otherwise; +Android: none yet). Ctrl+V in the editor asks `MarkdownEditor`'s +`ImageStore` callback to save the bytes and return a path, then inserts +`![](path)` as its own paragraph. Without an image on the clipboard, Ctrl+V +pastes text as before. The gallery stores pasted images in `images/` next +to the open file (or in `rsc` for the sample) and copies them along on +Save as. + +Opening links: clicking a link in the preview, or Ctrl+clicking it in the +source, calls `MarkdownEditor::openLink`. An optional `LinkHandler` may +claim the link first. `#anchor` links scroll to the heading with that +GitHub-style anchor (`markdown::headingAnchor`, duplicates get `-1`, `-2`). +Everything else goes to `WindowHost::openUrl`: links with a scheme as they +are, relative paths resolved against the base directory into absolute file +paths. Wayland spawns `xdg-open` with an `xdg-activation` token so the +compositor lets the opened app take focus; Android starts an +`ACTION_VIEW` intent. + +## Debugging + +- `ui.dumpTree()` prints every widget's name, rect and state. +- `ui.stats()` counts layout passes and measured, arranged, skipped, + delivered and painted widgets. +- `WidgetApplication` toggles the widget debugger with D. It takes its own column on the right; the Ui viewport shrinks to the area left of it, and pointer input over the panel goes to ImGui only, so the panel never covers or steals input from the UI. +- `setDebugName` names widgets in dumps and the inspector. + +### Debug menu and session recording + +Every `WidgetApplication` owns `DebugTools` and attaches a **Debug** menu +to its menu bar by default: *Debug UI* (the inspector), *Record session* +and *Stop recording and save*. It is a trailing menu, so it stays last +when the app adds its own menus later. Apps with their own menu bar call +`debugTools().attachTo(theirBar)` (and `detach()` before the bar is +destroyed); `setDebugToolsEnabled(false)` removes it. `DebugTools::addMenuItems` +adds the same entries to any other menu. + +Recording (`SessionRecorder`, GraphicsInput) taps every raw window event +before coalescing and turns the profiler on. While it runs, a `● REC m:ss` +badge (`SessionRecordingControl::badge`) shows next to the window +controls; clicking it stops. Stopping asks where to save, defaulting to +`~//sessions/` (or `$MODULES_SESSION_DIR`); cancelling asks +before discarding. A session folder holds: + +| file | contents | +|---|---| +| `environment.txt` | application, revision, GPU, date, window size and scale | +| `input.txt` | every raw input event with its time (`InputRecording`) | +| `recorded-frames.tsv` | per frame wall and CPU time, profiled sections, GPU times, counters | + +Apps add their own state with `SessionRecorder::setSnapshot`: it is +captured when recording starts and written into the same folder. +Sketch2D uses it for `session.txt` and layer tiles, which its bench +replays. + +## Testing + +`tests/support` provides everything needed to test any widget tree headlessly: + +- `UiDriver` owns a `Ui` and drives it like a user would: `moveTo`, `press`, + `release`, `click`, `drag`, `wheel`, `key`, `advance`, `settle`, + `hoverFor`, `paint`. +- `RecordingPainter` records paint commands with their window rects and + clips, so tests can assert what text is visible and that clip/translation + stacks are balanced. +- `MonospaceTextMetrics` makes text measurement deterministic. +- `Box` / `EventProbe` are minimal widgets for exact layout and event checks. + +Suites: algorithms (sizing, distribution, snapping, placement, springs), +layouts, input routing, popups and tooltips, every widget, menus, window +chrome, and the gallery application end to end. `Sketch2D/tests` reuses the same support code to +test the Sketch2D UI with a fake renderer. + +## What changed from the old library + +| Old | New | +|---|---| +| Four copies of a rect (`mArea` spring target/current, `mAreaCache`, layout area) copied back and forth each frame | A target rect from layout and a sprung current rect that children are laid out in | +| Size policy and min/max stored on the child's own layout object; every leaf allocated a layout | `AxisSizing` on the widget; only containers have a layout | +| Child picks its own rect from parent, children and itself; dependency graph, topological sort, dead policy table | Measure up, arrange down; no graph | +| `UpdateManager` mixed hover, event delivery, layout scoping, animation and redraw; `process()` polled every frame | Separate `InputRouter`, `TickScheduler`, layout dirty flags, paint request; widgets only run on events, ticks and paint | +| Popups re-parented under a shared layer plus a focus lock to fake modality | `PopupStack` with owners, chains and dismissal rules | +| Four friend managers and many `dynamic_cast`s | A narrow public API; one internal access class for the engine | +| Layouts keyed on child count (`ToolBarLayout`, `FixedSizeBarsLayout`, `ScrollableLayout`) | Generic `Row`/`Column`/`Stack`/`Free`/`Grid` plus typed slots | +| `std::to_string` debug reasons built on every `setArea` | No hot-path strings; `dumpTree` and stats on demand | diff --git a/Widgets/examples/Example.cpp b/Widgets/examples/Example.cpp deleted file mode 100644 index d0e7b318..00000000 --- a/Widgets/examples/Example.cpp +++ /dev/null @@ -1,227 +0,0 @@ - -#include "WidgetApplication.hpp" - -#include "RootWidget.hpp" -#include "FloatingWidget.hpp" -#include "DockWidget.hpp" -#include "ColorPickerWidget.hpp" - -#include "ScrollableLayout.hpp" - -using namespace tp; - - -class Example : public WidgetApplication { -public: - Example() { - exampleColorPicker(); - } - - void exampleAll() { - static DockWidget dock; - static LabelWidget centralWidget; - static FloatingMenu menus[4]; - static FloatingMenu nestedMenus[4]; - static FloatingMenu buttons[10]; - - dock.setCenterWidget(¢ralWidget); - - for (auto& menu : menus) { - dock.addChild(&menu); - } - - for (auto& menu : nestedMenus) { - menus[0].addToMenu(&menu); - } - - setRoot(&dock); - } - - void exampleColorPicker() { - static DockWidget dock; - static RGBPickerWidget colorPicker; - - dock.dockWidget(&colorPicker, DockLayout::RIGHT); - - setRoot(&dock); - } - - void exampleBasic() { - static Widget widget; - static LabelWidget label; - static SliderWidget slider; - static ButtonWidget button; - - widget.addChild(&label); - widget.addChild(&button); - widget.addChild(&slider); - - setRoot(&widget); - } - - void exampleScrolling() { - static ScrollableWidget widget; - static ButtonWidget buttons[10]; - - widget.setDirection(false); - - setRoot(&widget); - - for (auto& button : buttons) { - widget.getContainer()->addChild(&button); - } - - RootWidget::setWidgetArea(*widget.getContainer(), { 333, 333, 522, 522 }); - } - - void examplePopup() { - static Widget root; - static Widget popup; - - static ButtonWidget closeButton; - static ButtonWidget openButton; - static ButtonWidget buttons[3]; - - for (auto& button : buttons) { - popup.addChild(&button); - } - - popup.addChild(&closeButton); - - root.addChild(&openButton); - ((BasicLayout*)root.getLayout())->setLayoutPolicy(LayoutPolicy::Passive); - - openButton.setAction([&](){ - popup.setArea({ 50, 50, 300, 300 }); - openButton.openPopup(&popup); - }); - - closeButton.setAction([&](){ - closeButton.closePopup(&popup); - }); - - openButton.setText("open"); - closeButton.setText("close"); - - RootWidget::setWidgetArea(openButton, { 333, 333, 222, 222 }); - - setRoot(&root); - } - - void exampleNestedMenus() { - static DockWidget dock; - static FloatingMenu menu1; - static FloatingMenu menu2; - static ButtonWidget buttons[15]; - - setRoot(&dock); - - dock.addChild(&menu1); - dock.addChild(&menu2); - - dock.setCenterWidget(&buttons[5]); - dock.dockWidget(&buttons[6], DockLayout::RIGHT); - - menu1.addToMenu(&buttons[0]); - - menu2.addToMenu(&buttons[2]); - menu2.addToMenu(&buttons[3]); - menu2.addToMenu(&buttons[4]); - - menu2.addToMenu(&menu1); - - // popup - { - buttons[0].setAction([&](){ - buttons[10].setArea({ 30, 30, 100, 100 }); - buttons[0].openPopup(&buttons[10]); - }); - - buttons[0].setText("open popup"); - - buttons[10].setAction([&](){ - buttons[10].closePopup(&buttons[10]); - }); - - buttons[10].setText("close popup"); - } - - RootWidget::setWidgetArea(menu1, { 300, 100, 150, 500 }); - RootWidget::setWidgetArea(menu2, { 100, 100, 150, 300 }); - } - - void exampleLayouts() { - static Widget widgets[15]; - static ButtonWidget buttons[15]; - - setRoot(&widgets[0]); - - widgets[0].addChild(&widgets[1]); - - widgets[1].addChild(&buttons[1]); - widgets[1].addChild(&buttons[2]); - widgets[1].addChild(&widgets[2]); - - RootWidget::setWidgetArea(buttons[1], { 300, 100, 350, 800 }); - RootWidget::setWidgetArea(buttons[2], { 100, 100, 150, 300 }); - - widgets[2].addChild(&buttons[3]); - widgets[2].addChild(&buttons[4]); - widgets[2].addChild(&buttons[5]); - widgets[2].addChild(&buttons[6]); - - buttons[1].setSizePolicy(SizePolicy::Expanding, SizePolicy::Expanding); - buttons[2].setSizePolicy(SizePolicy::Expanding, SizePolicy::Expanding); - buttons[3].setSizePolicy(SizePolicy::Expanding, SizePolicy::Expanding); - buttons[4].setSizePolicy(SizePolicy::Expanding, SizePolicy::Expanding); - buttons[5].setSizePolicy(SizePolicy::Expanding, SizePolicy::Expanding); - buttons[6].setSizePolicy(SizePolicy::Expanding, SizePolicy::Expanding); - - widgets[2].setSizePolicy(SizePolicy::Expanding, SizePolicy::Expanding); - ((BasicLayout*)widgets[1].getLayout())->setLayoutPolicy(LayoutPolicy::Horizontal); - } - - void exampleDock() { - static DockWidget dock; - static FloatingMenu menu1; - static FloatingMenu menu2; - static ButtonWidget buttons[15]; - - setRoot(&dock); - - dock.addChild(&menu2); - dock.addChild(&menu1); - - dock.setCenterWidget(&buttons[5]); - dock.dockWidget(&buttons[6], DockLayout::RIGHT); - - menu1.addToMenu(&buttons[0]); - menu2.addToMenu(&buttons[2]); - //menu2.addToMenu(&menu1); - menu2.addToMenu(&buttons[3]); - menu2.addToMenu(&buttons[4]); - - buttons[0].setAction([&]() { buttons[2].setColor(RGBA::random()); }); - buttons[2].setAction([&]() { buttons[0].setColor(RGBA::random()); }); - - buttons[5].setAction([&]() { dock.dockWidget(&menu1, DockLayout::LEFT); }); - buttons[6].setAction([&]() { dock.undockWidget(DockLayout::LEFT); }); - - buttons[3].setAction([&]() { dock.toggleWidgetVisibility(DockLayout::LEFT); }); - // buttons[4].setAction([this]() { mDockLayout.undockWidget(DockLayout::LEFT); }); - - buttons[3].setText("toggle"); - buttons[5].setText("dock"); - buttons[6].setText("undock"); - - RootWidget::setWidgetArea(menu2, { 300, 100, 150, 500 }); - RootWidget::setWidgetArea(menu1, { 100, 100, 150, 300 }); - } -}; - -int main() { - { - Example gui; - gui.run(); - } -} diff --git a/Widgets/examples/Gallery.cpp b/Widgets/examples/Gallery.cpp new file mode 100644 index 00000000..55aad527 --- /dev/null +++ b/Widgets/examples/Gallery.cpp @@ -0,0 +1,1012 @@ +#include "Gallery.hpp" + +#include "Theme.hpp" +#include "Ui.hpp" + +#include "BoxLayout.hpp" +#include "StackLayout.hpp" + +#include +#include +#include +#include +#include +#include + +using namespace tp; +using namespace tp::ui; +using namespace tp::ui::gallery; + +namespace { + constexpr halnf kPageSpacing = 12; + + std::string percent(halnf fraction) { return std::to_string((int) std::round(fraction * 100)) + "%"; } + + std::string colorText(const RGBA& color) { + char buffer[48]; + std::snprintf(buffer, sizeof(buffer), "rgb %.2f %.2f %.2f", color.r, color.g, color.b); + return buffer; + } +} + +ControlsPage::ControlsPage(GalleryState& state) : + mState(state), + mSecondSeparator(Axis::Horizontal) { + setDebugName("controls page"); + ColumnLayout& layout = emplaceLayout(kPageSpacing); + layout.setPadding(Insets::uniform(16)); + layout.setCrossAlign(CrossAlign::Start); + + mButtonRow.emplaceLayout(8).setCrossAlign(CrossAlign::Center); + clickMe.setOnActivate([this]() { + mState.buttonClicks++; + refreshClickCount(); + }); + clickMe.setTooltip("Counts how many times it was clicked"); + accent.setStyle(Button::Style::Accent); + disabled.setEnabled(false); + mButtonRow.addChild(clickMe); + mButtonRow.addChild(accent); + mButtonRow.addChild(disabled); + mButtonRow.addChild(clickCount); + refreshClickCount(); + + notifications.setChecked(mState.notificationsEnabled); + notifications.setOnChanged([this](bool checked) { mState.notificationsEnabled = checked; }); + darkMode.setChecked(mState.darkMode); + darkMode.setOnChanged([this](bool checked) { mState.darkMode = checked; }); + + mRadioRow.emplaceLayout(16); + for (RadioButton* option : { &low, &medium, &high }) { + quality.add(*option); + mRadioRow.addChild(*option); + } + quality.selectIndex((size_t) mState.qualityIndex); + quality.setOnChanged([this](size_t index) { mState.qualityIndex = (long) index; }); + + volume.setSizing(AxisSizing::fixed(260), AxisSizing::fit()); + volume.setValueFormatter(percent); + volume.setValue(mState.volume); + volume.setOnChanged([this](halnf value) { + mState.volume = value; + progress.setFraction(value); + }); + + brushSize.setSizing(AxisSizing::fixed(260), AxisSizing::fit()); + brushSize.setScale(Slider::Scale::Logarithmic); + brushSize.setValueFormatter([](halnf value) { return std::to_string((int) std::lround(value)) + " px"; }); + + progress.setSizing(AxisSizing::fixed(260), AxisSizing::fit()); + progress.setFraction(mState.volume); + blendMode.setTooltip("Layer blend mode"); + + accentColor.setTooltip("Accent color"); + accentColor.setOnSelected([this](long index) { + const RGBA choices[] = { accents::blue, accents::purple, accents::green, accents::orange, accents::pink }; + if (index >= 0 && index < 5 && ui()) ui()->setTheme(Theme::standard().withAccent(choices[index])); + }); + + addChild(mButtonRow); + addChild(mFirstSeparator); + addChild(notifications); + addChild(darkMode); + addChild(mRadioRow); + addChild(mSecondSeparator); + addChild(volume); + addChild(brushSize); + addChild(progress); + addChild(blendMode); + addChild(accentColor); +} + +void ControlsPage::refreshClickCount() { + clickCount.setText("clicked " + std::to_string(mState.buttonClicks) + " times"); +} + +InputPage::InputPage(GalleryState& state) : + mState(state) { + setDebugName("input page"); + ColumnLayout& layout = emplaceLayout(kPageSpacing); + layout.setPadding(Insets::uniform(16)); + layout.setCrossAlign(CrossAlign::Start); + + name.setText(mState.userName); + name.setPlaceholder("Type a name"); + name.setSizing(AxisSizing::fixed(260), AxisSizing::fit()); + name.setOnEdited([this](const std::string& text) { + mState.userName = text; + refresh(); + }); + + size.setSuffix(" px"); + size.setValue(mState.brushSize); + size.setSizing(AxisSizing::fixed(260), AxisSizing::fit()); + size.setOnChanged([this](halnf value) { + mState.brushSize = value; + refresh(); + }); + + dialog.setMessage("Widgets gallery: text, number fields and dialogs."); + dialog.addButton("Close", {}, true); + openDialog.setOnActivate([this]() { + if (ui()) dialog.open(*ui()); + }); + askConfirm.setOnActivate([this]() { + if (!ui()) return; + confirm.ask(*ui(), "Clear history", "Are you sure you want to clear history?", "Clear history", [this]() { + mState.confirmations++; + refresh(); + }); + }); + + showNotice.setOnActivate([this]() { notice.show(*this, "Saved " + mState.userName + "'s settings"); }); + + notes.setPlaceholder("Multiline text: Enter adds a line, Ctrl+Enter commits"); + notes.setSizing(Axis::Horizontal, AxisSizing::fixed(360)); + notes.setBoxHeight(110); + color.setPalette({ accents::blue, accents::purple, accents::green, accents::orange, accents::pink }); + color.setColor(accents::blue); + + mDialogRow.emplaceLayout(8).setCrossAlign(CrossAlign::Center); + mDialogRow.addChild(openDialog); + mDialogRow.addChild(askConfirm); + mDialogRow.addChild(showNotice); + mDialogRow.addChild(confirmations); + + addChild(nameLabel); + addChild(name); + addChild(sizeLabel); + addChild(size); + addChild(greeting); + addChild(notesLabel); + addChild(notes); + addChild(colorLabel); + addChild(color); + addChild(mDialogRow); + refresh(); +} + +void InputPage::refresh() { + char size[32]; + std::snprintf(size, sizeof(size), "%.0f", (double) mState.brushSize); + greeting.setText( + "Hello, " + (mState.userName.empty() ? std::string("stranger") : mState.userName) + " - brush " + size + " px" + ); + confirmations.setText("confirmed " + std::to_string(mState.confirmations) + " times"); +} + +FilesPage::FilesPage(FileSystem& fileSystem) : + browser(fileSystem), + dialog(fileSystem) { + setDebugName("files page"); + ColumnLayout& layout = emplaceLayout(kPageSpacing); + layout.setPadding(Insets::uniform(16)); + + hint.setTone(Label::Tone::Muted); + status.setTone(Label::Tone::Muted); + browser.setSizing(AxisSizing::fill(), AxisSizing::fill()); + browser.setDirectory(fileSystem.home()); + browser.onSelected = [this](const FileEntry& entry) { + status.setText("Selected " + joinPath(browser.directory(), entry.name)); + }; + browser.onFileOpened = [this](const std::string& path) { report("Opened", path); }; + + actions.setSizing(AxisSizing::fill(), AxisSizing::fit()); + actions.emplaceLayout(8); + openFile.setOnActivate([this]() { + if (!ui()) return; + dialog.show(*ui(), FileDialog::Mode::Open, "Open file", browser.directory(), {}, [this](const std::string& path) { + report("Opened", path); + }); + }); + saveFile.setOnActivate([this]() { + if (!ui()) return; + dialog.show( + *ui(), FileDialog::Mode::Save, "Save as", browser.directory(), "untitled.txt", [this](const std::string& path) { + report("Would save to", path); + } + ); + }); + chooseFolder.setOnActivate([this]() { + if (!ui()) return; + dialog.show( + *ui(), + FileDialog::Mode::ChooseDirectory, + "Choose folder", + browser.directory(), + {}, + [this](const std::string& path) { + report("Chose", path); + browser.setDirectory(path); + } + ); + }); + actions.addChild(openFile); + actions.addChild(saveFile); + actions.addChild(chooseFolder); + + addChild(hint); + addChild(browser); + addChild(actions); + addChild(status); +} + +void FilesPage::report(const std::string& action, const std::string& path) { status.setText(action + " " + path); } + +const char* const MarkdownPage::kSampleDirectory = "rsc"; + +const char* const MarkdownPage::kSampleDocument = R"md(# Markdown editor + +Write on the **left**, read on the *right*. Scrolling either side keeps the other on the +same section, and clicking rendered text puts the caret on its source line. Use the buttons above to switch between +**Editor**, **Split** and **Preview**, like in an IDE. + +## Inline styles + +Plain text with **bold**, *italic*, ***both***, `inline code`, ~~strikethrough~~, +_underline_ and ==highlight==. Links look like [the md4c parser](https://github.com/mity/md4c) +and autolinks like https://example.com.\ +A backslash at the end of a line forces a hard break. + +Clicking a link in the preview, or Ctrl+clicking it in the source, opens it in the default desktop app: +web pages in the browser, [local files](markdown/pipeline.png) in their viewer, and +[headings in this document](#images) scroll into view. + +## Lists + +1. Ordered lists keep their numbers +2. Even when nested: + - bullets change shape + - at every level +3. And they wrap long lines neatly under the text instead of under the number marker. + +### Tasks + +- [x] Parse markdown with md4c +- [x] Render it with our own widgets +- [ ] Click a checkbox: the source changes too + +## Quotes and callouts + +> Simplicity is prerequisite for reliability. +> — Edsger W. Dijkstra + +> [!TIP] +> GitHub style admonitions: NOTE, TIP, IMPORTANT, WARNING and CAUTION. + +> [!WARNING] +> Saving overwrites the chosen file. + +## Code + +```cpp +#include "MarkdownEditor.hpp" + +tp::ui::MarkdownEditor editor; +editor.setMarkdown("# Hello"); +editor.setMode(tp::ui::MarkdownEditor::Mode::Split); +``` + +## Tables + +| Feature | Status | Notes | +|:---------------|:------:|----------------------------:| +| Headings | yes | six levels | +| Tables | yes | left, center, right columns | +| Images | yes | scaled to the column width | + +## Images + +Image paths are resolved against the open file's folder, or `rsc` for this sample. +Large images shrink to the column width; missing ones show their alt text. + +![Rendering pipeline](markdown/pipeline.png) + +![A missing picture](markdown/missing.png) + +--- + +Hold **Ctrl** and scroll to zoom both panes together. +)md"; + +MarkdownPage::MarkdownPage(FileSystem& fileSystem) : + dialog(fileSystem), + mFileSystem(fileSystem), + mDirectory(fileSystem.home()) { + setDebugName("markdown page"); + ColumnLayout& layout = emplaceLayout(8); + layout.setPadding(Insets::uniform(12)); + + actions.setSizing(AxisSizing::fill(), AxisSizing::fit()); + actions.emplaceLayout(8).setCrossAlign(CrossAlign::Center); + status.setTone(Label::Tone::Muted); + status.setText("Sample document"); + + openFile.setOnActivate([this]() { + if (!ui()) return; + dialog.show(*ui(), FileDialog::Mode::Open, "Open markdown", mDirectory, {}, [this](const std::string& path) { + open(path); + }); + }); + saveFile.setOnActivate([this]() { + if (!ui()) return; + dialog.show( + *ui(), FileDialog::Mode::Save, "Save markdown", mDirectory, "notes.md", [this](const std::string& path) { + save(path); + } + ); + }); + reset.setOnActivate([this]() { showSample(); }); + editor.setImageStore([this](const ClipboardImage& image) { return storePastedImage(image); }); + showSample(); + + actions.addChild(openFile); + actions.addChild(saveFile); + actions.addChild(reset); + actions.addChild(status); + addChild(actions); + addChild(editor); +} + +void MarkdownPage::showSample() { + mOpenedFile = false; + editor.setBaseDirectory(kSampleDirectory); + editor.setMarkdown(kSampleDocument); + status.setText("Sample document"); +} + +std::optional MarkdownPage::storePastedImage(const ClipboardImage& image) { + const std::string folder = mOpenedFile ? mDirectory : std::string(kSampleDirectory); + const std::string relativeFolder = "images"; + const std::optional saved = saveClipboardImage(image, joinPath(folder, relativeFolder)); + if (!saved) return std::nullopt; + return relativeFolder + "/" + std::filesystem::path(*saved).filename().string(); +} + +void MarkdownPage::open(const std::string& path) { + std::ifstream file(path, std::ios::binary); + if (!file) { + status.setText("Cannot open " + path); + return; + } + + std::ostringstream content; + content << file.rdbuf(); + mDirectory = parentPath(path); + mOpenedFile = true; + editor.setBaseDirectory(mDirectory); + editor.setMarkdown(content.str()); + status.setText("Opened " + path); +} + +void MarkdownPage::save(const std::string& path) { + std::ofstream file(path, std::ios::binary | std::ios::trunc); + if (!file || !(file << editor.markdown())) { + status.setText("Cannot save " + path); + return; + } + + const std::string previousFolder = mOpenedFile ? mDirectory : std::string(kSampleDirectory); + mDirectory = parentPath(path); + mOpenedFile = true; + copyPastedImages(previousFolder, mDirectory); + editor.setBaseDirectory(mDirectory); + status.setText("Saved " + path); +} + +void MarkdownPage::copyPastedImages(const std::string& fromFolder, const std::string& toFolder) { + if (fromFolder == toFolder) return; + + const std::string& source = editor.markdown(); + for (size_t at = source.find("](images/pasted-"); at != std::string::npos; + at = source.find("](images/pasted-", at + 2)) { + const size_t end = source.find(')', at); + if (end == std::string::npos) break; + + const std::string relative = source.substr(at + 2, end - at - 2); + const std::string target = joinPath(toFolder, relative); + if (mFileSystem.exists(target)) continue; + if (!mFileSystem.isDirectory(joinPath(toFolder, "images"))) + mFileSystem.createDirectory(joinPath(toFolder, "images")); + + std::ifstream in(joinPath(fromFolder, relative), std::ios::binary); + std::ofstream out(target, std::ios::binary | std::ios::trunc); + if (in && out) out << in.rdbuf(); + } +} + +MediaPage::MediaPage() : + playlist( + { { "Jumpsuit", "twenty one pilots", "Trench", 238.5f, true }, + { "Mystery Lady", "Masego & Don Toliver", "Studying Abroad", 205, false }, + { "No Idea", "Don Toliver", "Heaven or Hell", 154, true } } + ) { + setDebugName("media page"); + ColumnLayout& layout = emplaceLayout(kPageSpacing); + layout.setPadding(Insets::uniform(16)); + + hint.setTone(Label::Tone::Muted); + status.setTone(Label::Tone::Muted); + player.onPlayPause = [this]() { togglePlaying(); }; + player.onPrevious = [this]() { load((current + playlist.size() - 1) % playlist.size()); }; + player.onNext = [this]() { load((current + 1) % playlist.size()); }; + player.seek.setOnSeek([this](halnf position) { + status.setText("Seeked to " + formatDuration(position * playlist[current].durationSeconds)); + }); + player.volume.setOnChanged([this](halnf volume) { + status.setText("Volume " + std::to_string((int) std::lround(volume * 100)) + "%"); + }); + + addChild(hint); + addChild(player); + addChild(status); + load(0); +} + +void MediaPage::load(size_t index) { + current = index; + player.setTrack(&playlist[current]); + player.setPosition(0); + player.setBuffered(1); + status.setText("Loaded \"" + playlist[current].title + "\""); +} + +void MediaPage::togglePlaying() { + player.setPlaying(!player.isPlaying()); + if (player.isPlaying()) startTicking(); + else stopTicking(); + status.setText(player.isPlaying() ? "Playing" : "Paused"); +} + +void MediaPage::onTick(halnf deltaMs) { + const halnf duration = playlist[current].durationSeconds; + if (!player.isPlaying() || duration <= 0 || player.seek.isScrubbing()) return; + + const halnf position = player.seek.position() + deltaMs / 1000.0f / duration; + if (position < 1) { + player.setPosition(position); + return; + } + load((current + 1) % playlist.size()); + player.setPlaying(true); +} + +ReorderPage::ReorderPage() { + setDebugName("reorder page"); + ColumnLayout& layout = emplaceLayout(kPageSpacing); + layout.setPadding(Insets::uniform(16)); + layout.setCrossAlign(CrossAlign::Start); + + hint.setTone(Label::Tone::Muted); + list.setSizing(AxisSizing::fixed(320), AxisSizing::fit()); + for (const char* task : { "Sketch", "Line art", "Flat colors", "Shading", "Highlights", "Background" }) { + tasks.push_back(std::make_unique(task)); + tasks.back()->setSizing(AxisSizing::fill(), AxisSizing::fit()); + list.addItem(*tasks.back()); + } + list.setOnMoved([this](size_t from, size_t to) { + status.setText( + "Moved \"" + static_cast(list.item(to)).text() + "\" from " + std::to_string(from + 1) + " to " + + std::to_string(to + 1) + ); + }); + status.setTone(Label::Tone::Muted); + + addChild(hint); + addChild(list); + addChild(status); + refreshStatus(); +} + +void ReorderPage::refreshStatus() { status.setText("Nothing moved yet"); } + +ListPage::ListPage() { + setDebugName("list page"); + emplaceLayout(); + + content.emplaceLayout(0).setPadding(Insets::uniform(8)); + content.setSizing(AxisSizing::fill(), AxisSizing::fit()); + + const char* const groupNames[] = { "Brushes", "Layers", "Documents" }; + const char* const subgroupNames[] = { "Recent", "Favorites", "Archived" }; + for (const char* groupName : groupNames) { + groups.push_back(std::make_unique(groupName)); + Collapsible& group = *groups.back(); + group.header().setSummary(std::to_string(std::size(subgroupNames)) + " folders"); + for (const char* subgroupName : subgroupNames) { + subgroups.push_back(std::make_unique(subgroupName)); + Collapsible& subgroup = *subgroups.back(); + constexpr int kItems = 5; + const bool reorderable = std::string(subgroupName) == "Favorites"; + subgroup.header().setSummary(std::to_string(kItems) + (reorderable ? " · drag to sort" : "")); + ReorderableList* sortable = nullptr; + if (reorderable) { + favoriteLists.push_back(std::make_unique()); + sortable = favoriteLists.back().get(); + subgroup.body().addChild(*sortable); + } + for (int item = 0; item < kItems; item++) { + groupRows.push_back( + std::make_unique