quadraui 0.6.0

Cross-platform Rust UI toolkit: write one AppLogic and run it unmodified on TUI, GTK4 (Linux), macOS, and Windows backends
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
# Implementing a quadraui backend

**`Backend` is sealed — see [`quadraui::Backend`]'s rustdoc.** It cannot
be implemented outside this crate; that isn't a style preference, it's
enforced by a private supertrait, plus a `compile_fail` doctest proving
an out-of-crate `impl Backend` is rejected (quadraui#800). If your target
isn't one of the four in-tree backends (TUI, GTK, Win-GUI, macOS —
`quadraui/src/{tui,gtk,win,macos}/backend.rs`), the supported path is
**contributing a fifth in-tree backend to this repo**, not writing an
out-of-tree crate against `Backend`. `docs/PRIMITIVE_RULES.md` rule 8
explains why: a new primitive adds a required `Backend` method in an
ordinary PR, with no default and no version-bump ceremony, and an
out-of-tree implementor would have no way to keep up with that.

Audience: you're opening a PR against this repo to add a new render
backend — a new GPU surface, a webview, a different terminal library, an
embedded display, the next major desktop toolkit — or you want to
understand why the existing backends look the way they do.

This guide covers the patterns and pitfalls. The per-primitive contracts
themselves live in each primitive's rustdoc; treat this doc as the
"how to think about backends" complement to those contracts.

## 1. Mental model

quadraui has three rules — they're worth internalising before reading
any primitive code:

1. **Apps build primitives from state, every frame, by value.** No
   retained widget tree. No `widget.set_text(...)` between frames. The
   app holds canonical state; primitives are throwaway snapshots.
2. **Backends rasterise primitives.** A backend is a set of `draw_<P>`
   functions, one per primitive it renders. Each takes a primitive plus
   theme/layout and writes to its target (Cairo context, GPU command
   buffer, ratatui buffer, …).
3. **Events flow back as data.** When the user clicks/types/scrolls,
   the backend matches the input against the rendered primitive's
   geometry, emits a `*Event` enum referencing the primitive's
   `WidgetId`, and the app handles it. **No closures cross the
   primitive boundary.**

```
   app state ─build──▶ primitive ─paint──▶ pixels
                                              │
                                              │ user input
                                              ▼
   app state ◀─update── *Event {id, kind} ◀── backend
```

This is unusual if you've used GTK/Qt/SwiftUI (retained-mode) or
React/Dioxus (virtual DOM diffing). It's closer to immediate-mode
GUIs (egui, imgui) but with serializable primitives instead of
opaque widget calls.

The wins:

- **Same primitive, three or more rendering targets.** No
  per-backend coordination.
- **Plugin-safe.** Primitives are `Serialize + Deserialize`, so a Lua
  script can describe UI as a JSON-ish table and the backend renders
  it without trusting Lua to manage Rust object lifetimes.
- **Trivial undo, time-travel, snapshot-testing.** Primitives are
  plain data. Diff them, log them, replay them.

The losses:

- **Per-frame allocation.** You build new primitive structs every
  paint. In practice this is cheap (vimcode does it at 60 fps without
  measurable cost), but if you target ≥10k items you'll want to use
  partial-rebuild patterns (see `TextDisplay`).
- **No retained focus/selection state in the primitive.** The app
  has to remember focus across frames (`focused_field` on
  [`Form`], `selected_path` on [`TreeView`]).

## 2. The three contracts every backend must honour

### Contract A: owned data, no closures, serde-friendly

This is the [§10 plugin invariants](docs/UI_CRATE_DESIGN.md#10-plugin-invariants)
made operational:

| Rule | What it looks like in practice |
|------|--------------------------------|
| `WidgetId` is `String` | Backends can store `WidgetId` in events, send it across threads, hand it to plugins. Never take `&'static str`. |
| Events are plain enums | `TabBarEvent::TabActivated { index }`, `StatusBarEvent::SegmentClicked { id }`. No `Box<dyn Fn>`. |
| Primitives implement `Serialize + Deserialize` | Plugin can declare a panel as JSON. App deserializes → primitive → backend renders. |
| WidgetIds are namespaced | `"plugin:my-ext:send"`, not `"send"`. Prevents collisions when plugins bring their own UI. |
| No global event handlers | Every event references a `WidgetId`. The app's dispatch table is `HashMap<WidgetId, fn>` or similar. |
| Primitives don't borrow app state | Owned data or explicit `'a` lifetimes. No `Arc<Mutex<AppState>>` smuggled through `WidgetId`. |

If your backend respects these, plugins (current and future) can
declare UI without app code changes. If you violate one, you'll find
out the day the first plugin crashes.

### Contract B: measurer-parameterised algorithms

**The most important pattern in quadraui**, and the source of the
hardest-to-debug class of bug.

When a rendering algorithm needs a "width" or per-element measurement
(`fit X within Y` / `where does Z scroll to` / `which slice fits in N
units`), it lives in quadraui *parameterised over a measurement
closure*. Two examples shipped:

```rust
// In quadraui:
StatusBar::fit_right_start<F: Fn(&StatusBarSegment) -> usize>(
    &self, bar_width: usize, min_gap: usize, measure: F,
) -> usize;

TabBar::fit_active_scroll_offset<F: Fn(usize) -> usize>(
    active: usize, tab_count: usize, available_width: usize, measure: F,
) -> usize;
```

Each backend supplies its native measurer:

| Backend | Measurer for `&str` length | Unit |
|---------|----------------------------|------|
| TUI (ratatui) | `s.chars().count()` | cells |
| GTK (Cairo + Pango) | `layout.set_text(s); layout.pixel_size().0 as usize` | pixels |
| Win-GUI (Direct2D + DirectWrite) | DirectWrite `IDWriteTextLayout::GetMetrics().widthIncludingTrailingWhitespace as usize` | pixels |
| macOS (Core Text) | `CTLineGetTypographicBounds(line, ...).width as usize` | pixels |

The shared algorithm doesn't care about units — it just compares
`measure(i) + measure(j) ≤ width` in whatever unit the closure
returns. Each backend uses its own.

**When you add a new "fit/scroll/elide" primitive method, follow the
same pattern. Do NOT bake a unit into shared code.** The cost of
getting this wrong was 5 commits and 3 layered band-aids before the
right architecture surfaced (vimcode issue #158). Symptom: one
backend's tabs / segments / list items appear off-screen or clipped
in ways that look like timing bugs but aren't.

**Detection signal for new contributors:** if you find yourself
wanting to write `let w = name.len() * 8` or `let cell_width = 1` in
shared code, stop. That constant is a unit assumption that will
silently break some backend. Take a measurer instead.

### Contract C: per-primitive backend contract

Some primitives are purely declarative — give the backend the data,
it paints. Others have measurement-dependent state that the backend
MUST maintain each frame, otherwise the primitive renders fine but
interactions break in subtle ways.

Two primitives ship with non-trivial contracts:

- **[`TabBar`]** — measure each tab → call `fit_active_scroll_offset`
  → write the result back to wherever the app stores `scroll_offset`
  → repaint if it changed. Skipping any step makes the active tab land
  off-screen after layout changes. Read [`TabBar`]'s "Backend contract"
  rustdoc section.
- **[`StatusBar`]** — call `fit_right_start` → render only the visible
  slice → click handlers must skip dropped segments. Skipping makes
  narrow bars overlap/touch, and ghost-clicks fire on hidden segments.
  Read [`StatusBar`]'s "Backend contract" rustdoc section.

The other seven primitives ([`TreeView`], [`ListView`], [`Form`],
[`Palette`], [`ActivityBar`], [`Terminal`], [`TextDisplay`]) are
mostly declarative — render the data, route events. Their rustdoc
calls out any per-primitive convention worth knowing (e.g.
`Palette` MUST intercept all clicks when open).

**Always read a primitive's "Backend contract" section before
implementing it.** It's two paragraphs and saves a debugging
session.

## 3. The two-pass paint pattern

Backends without **mid-draw mutability** (you can't change app state
while a paint is in progress) need a way to react to measurements
they take during a paint. The pattern:

```text
              ┌──────────────────────┐
              │  Pass 1: paint with  │
              │  current app state   │
              └─────────┬────────────┘
                        │ (measure tabs / fit segments / …)
                        ▼
              ┌──────────────────────┐
              │  Apply measurements  │
              │  → mutate app state  │
              └─────────┬────────────┘
                        │
                if state changed?
                        │
                ┌───────┴───────┐
                │ no            │ yes
                ▼               ▼
            we're done     ┌──────────────────────┐
                           │  Pass 2: paint again │
                           │  with corrected state│
                           └──────────────────────┘
```

Pass 2 measures the same widths and reaches the same corrected
state, so its apply step changes nothing — converges in 2 frames.

**Per-backend wiring:**

- **TUI (ratatui)**: pass 2 is just calling `terminal.draw(...)` again
  in the same iteration of the main loop. Cheap (≤1ms for a typical
  terminal). See `examples/tui_demo.rs::run` for the pattern.
- **GTK (Cairo)**: pass 2 happens **inside the same `set_draw_func`
  callback** — drop the immutable engine borrow, mutate state, then
  re-enter `draw_editor` overdrawing the same Cairo context. Critical:
  do NOT defer pass 2 via `glib::idle_add_local_once`. During
  continuous events (window drag-resize), GTK's idle queue is
  starved and the deferred callback never fires until the events
  stop — the user sees a stale frame the entire time.
- **Win-GUI (Direct2D)**: every `WM_PAINT` message produces a paint;
  invalidate the area at the end of pass 1 if state changed, the
  next `WM_PAINT` is pass 2. Reliable because Windows posts
  `WM_PAINT` whenever the paint area is invalidated, regardless of
  what other messages are pending.
- **GPU / web / custom backends**: whatever your "request another
  frame" mechanism is, make sure it fires reliably during continuous
  input. If your engine has the equivalent of `requestAnimationFrame`
  scheduling, prefer that over an idle queue.

The two-pass pattern only applies to primitives with the contract-C
"measure-then-correct" requirement. For purely declarative primitives,
a single paint per state change is enough.

## 4. Backend operation reference

Side-by-side mapping of the operations every backend needs. If you
already know your target API, this table is enough to translate
patterns directly. If you don't, find your target's column and treat
it as a checklist of "things to learn first."

The TUI and GTK columns mirror what you'll find in `examples/tui_demo.rs`
and `examples/gtk_demo.rs`. The Win-GUI column reflects the production
Win-GUI backend in vimcode (`src/win_gui/quadraui_win.rs`); macOS is
the planned shape based on Core Graphics + Core Text idioms.

### Measure text width (the core of contract B)

| Backend | Code | Returns |
|---------|------|---------|
| **TUI** (ratatui) | `text.chars().count()` | cells (`usize`) |
| **GTK** (Pango) | `layout.set_text(text); layout.pixel_size().0 as usize` | pixels (`usize`) |
| **Win-GUI** (DirectWrite) | `dw.create_text_layout(text, format, w, h)?.GetMetrics()?.widthIncludingTrailingWhitespace as usize` | DIPs (`usize`) |
| **macOS** (Core Text) | `let line = CTLineCreateWithAttributedString(astr); CTLineGetTypographicBounds(line, ...).width as usize` | points (`usize`) |

The unit doesn't matter to quadraui — pass the same unit to
`available_width` and the closure return type matches. Mix-ups
between backends are impossible because each backend supplies its own
measurer; mix-ups *within* one backend (using cells in one place and
pixels in another) are the bug class to watch for.

### Fill rectangle in a colour

| Backend | Code |
|---------|------|
| **TUI** | `for x in area.x..area.x + area.width { buf.cell_mut(...).set_style(Style::default().bg(rat_color(c))); }` |
| **GTK** | `cr.set_source_rgb(r, g, b); cr.rectangle(x, y, w, h); cr.fill().ok();` |
| **Win-GUI** | `let brush = rt.CreateSolidColorBrush(&color, None)?; rt.FillRectangle(&D2D_RECT_F { left: x, top: y, right: x+w, bottom: y+h }, &brush);` |
| **macOS** | `CGContextSetRGBFillColor(ctx, r, g, b, 1.0); CGContextFillRect(ctx, CGRectMake(x, y, w, h));` |

### Draw text at a position

| Backend | Code |
|---------|------|
| **TUI** | `for ch in text.chars() { buf.cell_mut(pos).set_char(ch); pos.x += 1; }` |
| **GTK** | `layout.set_text(text); cr.move_to(x, y); pangocairo::functions::show_layout(cr, &layout);` |
| **Win-GUI** | `rt.DrawTextLayout(D2D_POINT_2F { x, y }, &text_layout, &brush, D2D1_DRAW_TEXT_OPTIONS_NONE);` |
| **macOS** | `let line = CTLineCreateWithAttributedString(astr); CGContextSetTextPosition(ctx, x, y); CTLineDraw(line, ctx);` |

### Apply bold to text

| Backend | Code |
|---------|------|
| **TUI** | `style.add_modifier(Modifier::BOLD)` |
| **GTK** | `let attrs = pango::AttrList::new(); attrs.insert(pango::AttrInt::new_weight(Weight::Bold)); layout.set_attributes(Some(&attrs));` |
| **Win-GUI** | Configure a separate `IDWriteTextFormat` with `DWRITE_FONT_WEIGHT_BOLD`; use that format for the bold span. |
| **macOS** | Use a separate `CTFont` with `kCTFontBoldTrait` set; reference it in the `NSAttributedString`'s font attribute. |

### Clip to rectangle (popups, dialogs)

| Backend | Code |
|---------|------|
| **TUI** | Implicit — write only inside the rect's `(x, y)` range. No clip API. |
| **GTK** | `cr.save().ok(); cr.rectangle(x, y, w, h); cr.clip(); /* draw */ cr.restore().ok();` |
| **Win-GUI** | `rt.PushAxisAlignedClip(&rect, D2D1_ANTIALIAS_MODE_PER_PRIMITIVE); /* draw */ rt.PopAxisAlignedClip();` |
| **macOS** | `CGContextSaveGState(ctx); CGContextClipToRect(ctx, rect); /* draw */ CGContextRestoreGState(ctx);` |

Clipping is **mandatory** for popup primitives (`Palette`, dialog,
context menu) — without it, long text bleeds past popup borders.

### Schedule another paint (the two-pass trigger)

| Backend | Code |
|---------|------|
| **TUI** | Just call `terminal.draw(...)` again in the same loop iteration. The next event-loop tick redraws unconditionally. |
| **GTK** | Inline within the same `set_draw_func` callback — `do_paint(); apply_state(); if changed { do_paint(); }`. Do **not** use `glib::idle_add_local_once` (starves under continuous events). |
| **Win-GUI** | `InvalidateRect(hwnd, None, false);` — schedules a `WM_PAINT` for the next message-pump iteration. Reliable because Win32 always processes pending paints before idling. |
| **macOS** | `view.setNeedsDisplay(true);` (AppKit) or `view.setNeedsDisplay()` (UIKit) — schedules a redraw for the next run-loop pass. |

### Receive a key event

| Backend | Code |
|---------|------|
| **TUI** | `match event::read()? { Event::Key(k) => match k.code { KeyCode::Char('q') => ..., KeyCode::Left => ..., } }` |
| **GTK** | `let kc = EventControllerKey::new(); kc.connect_key_pressed(\|_, key, _, _\| match key { gdk::Key::Left => ..., gdk::Key::q => ..., });` |
| **Win-GUI** | `WM_KEYDOWN` with `wparam` = virtual key code (`VK_LEFT`, etc.); `WM_CHAR` with character. Translate via your own dispatch table. |
| **macOS** | `keyDown(_:)` on `NSResponder` (AppKit) or `pressesBegan(_:with:)` (UIKit). |

### Receive a resize event (and read viewport size)

| Backend | Code |
|---------|------|
| **TUI** | `Event::Resize(w, h)` from crossterm; or read `terminal.size()?` at the start of each frame. |
| **GTK** | The `width`/`height` parameters passed to `set_draw_func` reflect the current size. |
| **Win-GUI** | `WM_SIZE` with `lparam`'s low/high words = new client width/height. Read via `LOWORD(lparam)` / `HIWORD(lparam)`. |
| **macOS** | `viewDidLayoutSubviews()` (UIKit) or override `setFrame(_:)` (AppKit); read `bounds`. |

### Receive a mouse click (with coordinates)

| Backend | Code |
|---------|------|
| **TUI** | `Event::Mouse(MouseEvent { kind: MouseEventKind::Down(MouseButton::Left), column, row, .. })` |
| **GTK** | `GestureClick::new()` with `connect_pressed(\|gesture, n_press, x, y\| ...)` |
| **Win-GUI** | `WM_LBUTTONDOWN` with `LOWORD(lparam)` / `HIWORD(lparam)` for client-area pixel coords. |
| **macOS** | `mouseDown(with event:)`; coords via `event.locationInWindow` then `convert(_:from:)`. |

### Notes on the table

- **The patterns themselves don't change between rows.** Only the API
  surface does. Once you've internalised "measure → fit → write back
  → repaint if changed" from one backend's example, you can transcribe
  it to any other column.
- **All four backends fundamentally do the same thing** — measure
  geometry, fill rectangles, draw text, route events. Differences are
  cosmetic (call shapes, type names, retained-vs-immediate tradeoffs).
- **The TUI column is the easy case** — char cells, no font metrics
  to wrangle, integer arithmetic. Read the TUI demo first to fix the
  pattern in your head, then translate to your target.
- **For Win-GUI specifically**: `windows-rs` is verbose but
  mechanical. The `unsafe` blocks are unavoidable for Win32 / D2D
  calls; isolate them in narrow helpers and the surface that touches
  quadraui primitives stays safe.

### Don't re-type `UiEvent` construction — call the shared constructors

Every backend's translator (`gtk::events`, `macos::events`, `tui::events`)
used to hand-write the same `UiEvent::MouseDown { widget: None, button,
position, modifiers }`-shaped struct literal, with only the button-number
and modifier-mask *values* differing per platform (issue #495). That
duplication is gone: `crate::event` exports free-function constructors —
[`mouse_down`], [`mouse_up`], [`mouse_moved`], [`scroll`], and
[`window_resized`] — that own the event shape, including the mouse-wheel
sign flip (`scroll`'s `dy_native_down_positive` parameter negates once,
here, instead of every backend re-deriving and re-commenting the same
negation).

A new backend's event translator is genuinely small once you use these.
It needs exactly three backend-native pieces:

1. A keysym/keycode → [`Key`] table (see `gdk_key_to_quadraui_key`,
   `ns_keycode_to_named_key`, `vk_to_named_key` for the existing shapes).
2. A button-number → [`MouseButton`] map (`gdk_button_to_quadraui`,
   `ns_button_number_to_quadraui`).
3. A modifier-bitmask → [`Modifiers`] map (`gdk_modifiers_to_quadraui`,
   `ns_modifier_flags_to_quadraui`).

...plus one-line wrappers that call the shared constructors with those
three tables' output. `gtk::events`'s and `macos::events`'s `MouseDown`/
`MouseUp`/`MouseMoved`/`Scroll`/`WindowResized` translators are ~40 lines
combined post-#495 — down from each backend independently re-typing the
five `UiEvent` variant shapes. Grep either module for `UiEvent::Mouse` or
`UiEvent::Scroll`: there should be none outside `crate::event` itself.

## 5. Click intercept hierarchy

When the user clicks anywhere, your backend must check potential
targets **in z-order, top-to-bottom**, and route to the first match.
A common bug class: missing one layer makes clicks fall through to
hidden widgets, triggering actions the user can't see.

Recommended dispatch order:

1. **Modal overlays first** — open `Palette`, dialog, context menu.
   If any is visible, **all clicks** go through it. Don't even
   compute editor / panel hit-tests.
2. **Floating popups** — tooltips, hover popups, autocomplete.
   These have priority over the underlying editor.
3. **Chrome** — tab bar (segments + buttons), status bar (segments),
   activity bar, sidebars. Each has its own `*Event` channel.
4. **Editor / main content area** — the catch-all when nothing else
   matched.

For each chrome layer, the backend stores per-frame hit zones (e.g.
`Vec<(Rect, WidgetId)>` populated during paint, consulted during
click handling). The pattern in vimcode's GTK backend is `Rc<RefCell<HashMap<...>>>`
filled inside `set_draw_func` and read inside the click controller.

**Modal click intercept is mandatory** for `Palette` per its
rustdoc — skipping it is a backend bug class documented in
`NATIVE_GUI_LESSONS.md` §10.

## 6. Minimal backend walkthroughs

The best way to grok the patterns is to read a working backend that
exercises the contracts. Two runnable examples ship with the crate,
demonstrating the **same demo app** in two different rendering
backends — proving the app side is fully backend-agnostic.

**Shared structure:** the two demos share their app code via
`examples/common/mod.rs` (~240 lines: `AppState`, `build_tab_bar`,
`build_status_bar`, event-dispatch helpers). Each demo is then ~350-450
lines of pure backend code. Side-by-side `diff tui_demo.rs gtk_demo.rs`
shows exactly what differs between two backends rendering the same
declarative UI — that's the abstraction quadraui exists to provide.

### `examples/tui_demo.rs` — TUI / ratatui (~350 lines)

```bash
cargo run --example tui_demo
```

Cell-unit measurement (TUI is the easy case — units match the
engine's defaults). Demonstrates:

- **Building primitives from app state** — `build_tab_bar(state)` /
  `build_status_bar(state, focused_id)`.
- **TabBar contract** — `draw_tab_bar` measures each tab in cells,
  calls `fit_active_scroll_offset`, returns the corrected offset.
  Main loop compares to app state, runs pass 2 if it changed.
- **StatusBar contract** — `draw_status_bar` calls
  `fit_right_start_chars(width, 2-cell gap)` and renders only the
  visible slice.
- **Event flow** — Tab cycles `focused_status_idx`, Enter dispatches
  via `handle_status_action(&id)`, state mutation reflected on next
  frame.
- **No hidden state** — every paint rebuilds primitives from
  `AppState`. No retained widget references.

### `examples/gtk_demo.rs` — GTK4 / Cairo + Pango (~430 lines)

```bash
cargo run --example gtk_demo --features gtk
```

(Requires GTK4 development libraries — `libgtk-4-dev` on Debian /
Ubuntu, `gtk4-devel` on Fedora, `gtk4` on Homebrew. Off by default
so `cargo build` works on platforms without them.)

Pixel-unit measurement (the harder case — every backend other than
TUI needs this pattern). Same demo app as `tui_demo.rs`, with the
**identical AppState struct, identical primitive builders**, and a
GTK-flavoured backend layer. Demonstrates everything the TUI demo
does, plus:

- **Pango pixel measurer** — each tab's full slot width is
  `tab_pad + label_pixels + close_pixels + tab_pad + outer_gap`
  (~6 chars-equivalent of overhead per tab; this is the under-
  estimation that motivated lesson §12 in NATIVE_GUI_LESSONS.md).
- **Two-pass paint inline within `set_draw_func`** — pass 1 paints,
  the post-paint apply mutates app state via `RefCell::borrow_mut`
  after dropping the immutable borrow, pass 2 overdraws the same
  Cairo context. **No `idle_add`** — the deferred-callback approach
  is unreliable during continuous resize (drag the window narrow
  while many tabs are open to see this matter).
- **Per-frame interaction state passed alongside the primitive** —
  the focused status segment's bold styling is computed from a
  separate `focused_id` parameter, not stored on the primitive
  struct.

The hint row at the bottom shows `[pass-2 fired]` when the demo's
last paint triggered a second pass — flip the flag on/off by
resizing the window or opening/closing tabs.

### Read both before implementing a new backend

Comparing the two demos side-by-side is the fastest way to see
which parts are app code (identical) and which are backend code
(different). The structure transfers near-verbatim to Direct2D /
Core Graphics / wgpu / browser canvas — only the `draw_*`
internals change.

## 7. Backend-implementer checklist

Run through this when you stand up a new backend. Each item is
"implement and test it works"; missing one is a bug class waiting
for a smoke test.

### Per-primitive

- [ ] **Every primitive your app uses has a `draw_<primitive>`
      function.**
- [ ] **For [`TabBar`]**: implement the measure → fit → correct →
      repaint contract. See its rustdoc.
- [ ] **For [`StatusBar`]**: implement the fit → render-slice →
      click-skip contract. See its rustdoc.
- [ ] **For [`Palette`]**: when one is open, intercept ALL mouse
      events (clicks AND scrolls AND drags) and ALL keys.
- [ ] **For [`Form`]**: route Tab / Shift-Tab to focus-change
      events; route printable keys to the focused field's mutation.
- [ ] **For [`Terminal`]**: per-cell `bg` + `fg` + `bold/italic/underline`
      attrs; cursor cell inverts fg/bg; selection / find overlays use
      theme accent colours.
- [ ] **For [`TextDisplay`]**: when `auto_scroll == true`, pin to
      bottom; otherwise respect `scroll_offset`. User scroll-up sets
      `auto_scroll = false`.

### Cross-cutting

- [ ] **Owned `WidgetId` everywhere** — never `&'static str` in event
      types or hit-region tables.
- [ ] **No closures in events** — only plain data with `WidgetId`
      references.
- [ ] **Modal overlays intercept first** in all click handlers.
- [ ] **Two-pass paint** wired for any primitive with a contract-C
      requirement.
- [ ] **Measurer closures use the backend's native units** —
      no shared constants leaking between backends.
- [ ] **Every per-frame interaction state** (hover, drag, focus-within)
      lives **outside** the primitive — passed as a parameter to your
      `draw_*` function.
- [ ] **Click hit-tests use the SAME geometry as draw** — extract a
      shared measurement function so they can't drift.
- [ ] **All popup/dialog content is clipped to the popup bounds** —
      otherwise text bleeds past borders.
- [ ] **All popup positions are clamped to screen bounds** —
      especially right and bottom edges.
- [ ] **Focus flags cleared on every competing click** — clicking
      the editor must clear sidebar/terminal focus, etc. (See
      vimcode's `NATIVE_GUI_LESSONS.md` §7 for the patterns.)
- [ ] **Every new toolkit-crate import has its Cargo feature enabled.**
      Some bindings gate *per class*: `objc2-app-kit` ships every class,
      enum and constant behind a feature named after its originating
      header, so `use objc2_app_kit::NSCursor` without
      `features = ["NSCursor"]` is an `unresolved import`, not a warning
      — and the feature is not reliably the symbol's own name
      (`NSApplicationActivationPolicy` is in `NSRunningApplication`,
      `NSBackingStoreType` in `NSGraphics`). Because `src/macos` only
      compiles on `target_os = "macos"`, nothing on a Linux dev box or
      the default CI legs catches this; `quadraui/tests/macos_appkit_features.rs`
      is the portable guard, and adding an import means registering it
      there too (#498).
- [ ] **Type-check your backend's target from wherever you are, before
      pushing.** `cargo check --features <backend>` on the *host* triple
      compiles none of a `target_os`-gated module (`lib.rs` gates
      `mod macos` on `target_os = "macos"`), so it exits 0 having proved
      nothing. `cargo check` never links, so it does not need that
      platform's SDK — only its `rust-std`. From a Linux box:

      ```bash
      rustup target add aarch64-apple-darwin   # once
      RUSTFLAGS="-D warnings" cargo check -p quadraui --features macos \
          --target aarch64-apple-darwin --all-targets
      ```

      `--all-targets` matters: it compiles the `#[cfg(test)]` blocks too,
      which is where the `-D warnings` `dead_code` failures from a
      too-narrow `crate::desktop` gate show up. `--target
      aarch64-apple-darwin` matters because `macos-latest` is Apple
      silicon. This turns the macOS workflow's slow
      once-per-macOS-touching-PR compile signal into a ~15s local one
      (#498 burned two merge-gate round trips before anyone tried it).
      **It still does not run anything** — see the AppKit-at-runtime
      caveat under *Recommended testing* below.
- [ ] **Don't call the native toolkit from a plain `#[test]`.** libtest
      runs every test on a spawned worker thread, in a process that never
      created an `NSApplication` / GTK `Application` / message loop.
      Vending an AppKit object there (`+[NSCursor arrowCursor]`,
      `NSWindow`, `NSMenu`) is a hazard no Linux leg can see, and it fails
      only on the one runner that matters. Two ways out, and prefer the
      first: **split the mapping decision out as a pure value** (see
      `macos::backend`'s `MacCursorKind` / `mac_cursor_kind`, which made
      the `PointerShape` → cursor table assertable on any thread and any
      OS), or **gate on `MainThreadMarker::new()`** and accept that the
      test no-ops in CI (see `macos::menu_bar_install`'s tests). A test
      that asserts the toolkit's own invariant ("these factory methods
      return a shared instance") buys nothing either way — assert *your*
      mapping, not Apple's.

### Recommended testing

- [ ] **Snapshot test** the primitives your app builds at known
      states. Plain-data primitives are trivial to compare with
      `assert_eq!` or `insta`.
- [ ] **Manual resize test** — drag your window narrower and wider
      while many tabs / many right-segments are open. Active tab
      stays visible? Status bar drops segments cleanly? Both
      symptoms of contract-C correctness.
- [ ] **Multi-pane test** — if your app has split layouts, test that
      each pane's chrome is independent (one tab bar's scroll doesn't
      affect another's).
- [ ] **Modal overlay test** — open a `Palette`, click "below" it on
      what would normally be a button — the click must be eaten by
      the palette, not the button.

## 8. When to extend quadraui itself

You'll inevitably want a primitive that doesn't exist yet. Options
in increasing scope:

1. **Use existing primitives creatively.** A simple "Notification
   list" can be a `ListView` with custom decoration. A "log viewer"
   is a `TextDisplay`. A "config screen" is a `Form`. Try this
   first — you'll find ~80% of "I need a new primitive" goes away.

2. **Pass extra per-frame state alongside the primitive** to your
   `draw_*` function. Hover indices, focus rings, drag overlays,
   highlight ranges — these are typically per-frame interaction
   state, not primitive state. Don't bloat the primitive.

3. **Extend an existing primitive.** Add a field, add a `*Event`
   variant. Make sure it satisfies all six §10 invariants. Prefer
   `Option<T>` + `#[serde(default)]` for backward compatibility.

4. **Add a new primitive.** Mirror the existing module structure
   (`src/primitives/your_primitive.rs`), add `pub use` to `lib.rs`,
   write rustdoc with the "Backend contract" section, add a
   `*_roundtrip_serde` test in `lib.rs`. If it has fit/scroll/elide
   logic, parameterise over a measurer (Contract B above).

5. **Ask whether it's app-specific.** Highly app-specific UI (a
   markdown editor, a kubernetes resource graph) is probably
   better as app code that consumes the existing primitives, not
   a new primitive. The bar for "this is reusable across multiple
   apps" should be high.

When in doubt, look at `quadraui/docs/decisions/DECISIONS.md` for the
existing rationale on which primitives shipped, which were deferred,
and why. Add to that log when adding new ones.

## 9. Reference implementations

The four in-tree `impl Backend` blocks are the reference implementations
— read the one closest to your target before writing a fifth:

- `src/tui/backend.rs` — `TuiBackend`, TUI (ratatui + crossterm). Full
  feature parity; the easiest one to read first (cell units, no font
  metrics to wrangle).
- `src/gtk/backend.rs` — `GtkBackend`, GTK4 (Cairo + Pango).
- `src/win/backend.rs` — `WinBackend`, Windows (Direct2D + DirectWrite).
  Window/event/platform-services infrastructure is real and CI-blocking;
  most per-primitive rasterisers are still `todo!()` stubs (see the
  crate root README's *Status* section).
- `src/macos/backend.rs` — `MacBackend`, macOS (Core Graphics + Core
  Text). Every in-window rasteriser shipped; built/tested for real on
  `macos-latest` CI.

These were originally prototyped as per-backend code in [vimcode] — still
quadraui's primary consumer and R&D lab for new patterns — then extracted
in-tree behind the `Backend` trait. A new backend belongs in this repo
the same way: add `src/<platform>/backend.rs`, `impl Backend for
<Platform>Backend`, and `impl crate::backend::sealed::Sealed for
<Platform>Backend` (required — `Backend` is sealed, see the top of this
file) alongside it.

[vimcode]: https://github.com/JDonaghy/vimcode

## 10. Desktop-interaction plumbing you get for free (`crate::desktop`)

A handful of interaction patterns are genuinely windowing-generic — the
same state machine or hazard shows up on every real desktop toolkit,
not just the one that first implemented it. quadraui factors those into
`src/desktop.rs` (`pub(crate)`, in-tree only) so a new backend doesn't
have to reinvent them from a GTK or AppKit source read. None of it
depends on `gtk4`, `objc2`, `windows`, or any other toolkit crate —
it's plain Rust, generic over whatever payload your backend's native
events carry.

Each item is nonetheless `#[cfg]`-gated — **on the feature(s) of the
backends that actually call it today, nothing wider** (see the module doc
for why the `cfg`s exist at all despite there being no toolkit dependency
to gate). So the gates are not uniform: `WindowDragArm` is
`any(feature = "gtk", all(feature = "macos", target_os = "macos"))`
because both backends use it, while `ModalPumpDepth`/`ModalPumpGuard` are
plain `feature = "gtk"` — GTK's file-dialog pump is their only adopter so
far — and `WindowDragArm::{discard, commit_if_past_threshold}` are
`any(feature = "gtk", test)`, since macOS's `.take()`-only usage never
calls them.

Adopting a piece therefore means **widening that piece's own gate with
your backend's feature, in the same commit as the call** — not
introducing a standalone `your-backend` feature gate, and not leaving the
gate alone. Skipping the widen is not a warning: with `ci.yml`'s
workspace-wide `RUSTFLAGS: "-D warnings"`, a `pub(crate)` item with no
caller under some compiled feature set is a hard `dead_code` **build
failure** — and one that only appears on a build with *your* feature and
not `gtk`, which is exactly the combination your own backend's CI job
runs and the default Linux legs never do (#498 shipped this bug to
`macos-latest`).

| What | Type | Extracted from | The hazard it solves |
|---|---|---|---|
| CSD titlebar drag-to-move (and, by the same shape, edge-resize) arm/threshold/commit state machine | `desktop::WindowDragArm<P>` | `gtk::backend::GtkBackend::armed_window_drag` (#400) | Calling a native "begin move" synchronously on the raw press starts an interactive grab that swallows a following double-click. `WindowDragArm` owns the arm/discard/threshold-check/commit *sequencing*; your backend still makes the actual native call (`gdk4::Toplevel::begin_move`, `NSWindow::performWindowDragWithEvent:`, …) once `commit_if_past_threshold` hands the press back — or, if your platform's own API already disambiguates drag-vs-click internally (AppKit's does), just call `.take()` unconditionally instead and skip the threshold step entirely. `macos::backend::MacBackend::begin_window_drag` (#498) is the worked example of that second shape. |
| Nested-modal-loop re-entrancy guard | `desktop::ModalPumpDepth` + `desktop::ModalPumpGuard` | `gtk::services::GtkPlatformServices`'s `pumping` field + `PumpGuard` (#427) | A synchronous file-dialog/message-box call that internally pumps the native event loop (GTK's async `FileDialog` awaited via `MainContext::iteration`, AppKit's `runModal`, Win32's `IFileOpenDialog::Show`) lets *every other* pending event-loop source run too — including your own idle-drain timer and input controllers, which may try to mutably borrow the same `Rc<RefCell<Backend>>` the dialog call is already holding borrowed. Unguarded, that second borrow panics inside a non-unwindable native callback frame and aborts the process. Clone a `ModalPumpDepth` into every runner closure that touches the backend; each checks `is_pumping()` and no-ops instead. Wrap the nested-loop call itself in a `ModalPumpGuard` (a depth counter, not a bool, so a dialog opened re-entrantly from inside another dialog's pump stays guarded until the *outermost* one unwinds). |
| Headless smoke-mode config + pass/fail predicates | `desktop::SmokeConfig`, `desktop::smoke_size_ok`, `desktop::smoke_clipboard_round_trip_ok` | `gtk::run` (#450, GD-5) | The "does the live window actually look sane" class of bug (quadraui#437: a tiny/garbled window) only reproduces against a real, running window — a headless in-process driver structurally can't catch it. `SmokeConfig::from_env(ms_var, paste_var)` reads your backend's own env-var names (so `QUADRAUI_GTK_SMOKE_MS` and a future `QUADRAUI_WIN_SMOKE_MS` don't collide) and the two predicates are pure size/round-trip comparisons your runner's one-shot timer calls after presenting the window — see `gtk::run::schedule_smoke_check` for the full wiring (present → wait `after_ms` → assert → close, so an unattended `xvfb-run`-style invocation exits deterministically instead of hanging). |
| `PointerShape`/`ResizeEdge` enum-walk scaffold | `desktop::ALL_RESIZE_EDGES`, `desktop::all_pointer_shapes()` | `gtk::backend`'s cursor-name match (#406) | Every backend still owns its own `PointerShape` → native-cursor table — cursor names/objects are inherently native, and some platforms (AppKit) don't even have a public cursor for every `ResizeEdge`. What was missing was a canonical list of every variant to build and *test* that table against, instead of each backend hand-listing all 9 combinations (and silently under-covering a future 10th). Iterate `all_pointer_shapes()` in your own mapping test the way `gtk::backend::pointer_shape_cursor_name_maps_every_variant` and `macos::backend`'s equivalent do. |

None of this is optional plumbing you can skip past — `Backend::begin_window_drag` / `toggle_window_maximize` / `begin_window_resize` / `set_cursor` all default to a `false`-returning no-op (§7's checklist covers declaring `BackendCaps::window_chrome`/`pointer_cursor` honestly once you override any of them), so a backend that never touches `crate::desktop` still compiles and runs — it just doesn't support a client-side-decoration titlebar. Wire it in once your backend has a real window handle to drive; `gtk::backend::GtkBackend` and `macos::backend::MacBackend` are both worked examples to copy from, and between them cover both the "needs a threshold" and "native API already disambiguates" shapes of `WindowDragArm`.

## See also

- [`docs/UI_CRATE_DESIGN.md`](docs/UI_CRATE_DESIGN.md) — full design
  rationale, the 13 §7 decisions, the §10 plugin invariants.
- [`docs/decisions/DECISIONS.md`](docs/decisions/DECISIONS.md) — running log of
  primitive decisions (which shipped, which deferred, why).
- vimcode's `docs/NATIVE_GUI_LESSONS.md` — production lessons from
  shipping three backends. §12-14 cover the unit-mismatch / idle-add
  / debugging-instinct lessons that motivated this guide; §1-11
  cover backend bugs we hit in Win-GUI specifically.