quadraui 0.6.1

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
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
1140
1141
1142
1143
1144
1145
1146
1147
1148
1149
1150
1151
1152
1153
1154
1155
1156
1157
1158
1159
1160
1161
1162
1163
1164
1165
1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
1295
1296
1297
1298
1299
1300
1301
1302
1303
1304
1305
1306
1307
1308
1309
1310
1311
1312
1313
1314
1315
1316
1317
1318
1319
1320
1321
1322
1323
1324
1325
1326
1327
1328
1329
1330
1331
1332
1333
1334
1335
1336
1337
1338
1339
1340
1341
1342
1343
1344
1345
1346
1347
1348
1349
1350
1351
1352
1353
1354
1355
1356
1357
1358
1359
1360
1361
1362
1363
1364
1365
1366
1367
1368
1369
1370
1371
1372
1373
1374
1375
1376
1377
1378
1379
1380
1381
1382
1383
1384
1385
1386
1387
1388
1389
1390
1391
1392
1393
1394
1395
1396
1397
1398
1399
1400
1401
1402
1403
1404
1405
1406
1407
1408
1409
1410
1411
1412
1413
1414
1415
1416
1417
1418
1419
1420
1421
1422
1423
1424
1425
1426
1427
1428
1429
1430
1431
1432
1433
1434
1435
1436
1437
1438
1439
1440
1441
1442
1443
1444
1445
1446
1447
1448
1449
1450
1451
1452
1453
1454
1455
1456
1457
1458
1459
1460
1461
1462
1463
1464
1465
1466
1467
1468
1469
1470
1471
1472
1473
1474
1475
1476
1477
1478
1479
1480
1481
1482
1483
1484
1485
1486
# `Backend` trait + `UiEvent` dispatch — proposal

**Status:** Draft. Blocks #169 (Postman-class validation app).
**Date:** 2026-04-21.
**Audience:** Decision-makers evaluating whether the design is right, and
implementers if it is.

---

## TL;DR

Phase A shipped a declarative **primitive catalog** (Tree, Form, List,
Palette, StatusBar, TabBar, ActivityBar, Terminal, TextDisplay). It
works. But every non-trivial feature (terminal maximize, VSCode mode,
find/replace, multi-cursor) ends up duplicating **event routing**,
**keybinding**, and **resize handling** across three backends because
the crate never owned those — each backend reads its native event
stream and dispatches directly into the app's `Engine`.

For vimcode this is tolerable (Claude can re-grep every backend on
every feature). For the Postman-class app (#169) and every future
consumer, it's a wall: a dev would need to understand GTK key
controllers AND Win32 message pumps AND crossterm event polling AND
Cocoa responder chain before shipping anything.

This proposal introduces two abstractions:

1. **`UiEvent`** — a backend-neutral event enum. Everything a user
   can do (press a key, click a widget, resize the window, drop a
   file) surfaces as one variant. Primitives emit their own events
   as sub-variants (`UiEvent::Tree(id, TreeEvent::RowClicked { … })`).

2. **`Backend` trait** — one method to poll events, one to draw, one
   to expose platform services. Each backend (`quadraui_tui`,
   `quadraui_gtk`, `quadraui_win`, future `quadraui_macos`) becomes an
   impl of this trait. The app's main loop is `for ev in backend.poll_events()
   { engine.handle(ev); }` — platform-independent.

The value is **quadratic**: today every (feature × backend) pair is
its own bespoke wiring. With the trait, every feature is wired once
against `UiEvent` and every backend translates its native events to
`UiEvent` once. New features add linearly; new backends add linearly;
the cross-product stops.

Migration is **gradual and coexists with the current dispatch** — no
big-bang rewrite. Details in §5.

---

## 1. Why now

Three concrete pieces of evidence:

**1a. The terminal-maximize wave (#34).** A single feature took 11
commits, spread across 10 files, introduced 61 references to helper
functions, and revealed two bug classes:

- Per-backend hit-test parity (fixed in `1d7141a`, `507d63a`)
- Per-backend resize handling differences (fixed in `187a7c6`)

Both bugs were structural: the app owns state, but *every backend
re-implements the plumbing to route events into that state*. See
`quadraui/docs/APP_ARCHITECTURE.md` §"Worked example — terminal
maximize" for the trace.

**1b. The #169 Postman app can't ship without it.** A typical
Postman-class feature (say, "Ctrl+Enter sends the current request")
would today require: define a `PanelKeys` field, add a matcher in
TUI's event loop, add a GTK key-controller check, add a Win-GUI
`on_key_down` arm, handle the action in three action-dispatch
branches. Five files per feature. Hundred-feature apps need a better
abstraction.

**1c. macOS is coming.** Phase C (issue #47) will add a fourth
backend. Without `Backend` trait + `UiEvent`, the macOS impl
duplicates all the dispatch code from the other three — a known
pain point per `docs/NATIVE_GUI_LESSONS.md` and PLAN.md lessons.

---

## 2. `UiEvent` — what a user did

```rust
pub enum UiEvent {
    // ── Input ───────────────────────────────────────────────────
    /// A declared accelerator fired. See §3 for Accelerator.
    Accelerator(AcceleratorId, Modifiers),

    /// A raw key was pressed (for text input primitives that want
    /// every keystroke, not just accelerators).
    KeyPressed { key: Key, modifiers: Modifiers, repeat: bool },

    /// A character was typed, ready for insertion. No backend runs an
    /// IME composition pipeline yet (epic #481) — this is a direct
    /// keystroke-to-char translation, not IME output.
    CharTyped(char),

    // ── Mouse ───────────────────────────────────────────────────
    /// Mouse button pressed over a widget (or the background if none).
    MouseDown {
        widget: Option<WidgetId>,
        button: MouseButton,
        position: Point,
        modifiers: Modifiers,
    },
    MouseUp { widget: Option<WidgetId>, button: MouseButton, position: Point },
    MouseMoved { position: Point, buttons: ButtonMask },
    MouseEntered { widget: WidgetId },
    MouseLeft { widget: WidgetId },
    DoubleClick { widget: Option<WidgetId>, position: Point },
    Scroll { widget: Option<WidgetId>, delta: ScrollDelta, position: Point },

    // ── Window ──────────────────────────────────────────────────
    WindowResized { viewport: Viewport },
    WindowClose,
    WindowFocused(bool),
    DpiChanged(f32),

    // ── Files (drop / paste / etc.) ─────────────────────────────
    FilesDropped { paths: Vec<PathBuf>, position: Point },
    ClipboardPaste(String),

    // ── Primitive-specific events bubble up by WidgetId ─────────
    Tree(WidgetId, TreeEvent),
    List(WidgetId, ListEvent),
    Form(WidgetId, FormEvent),
    Palette(WidgetId, PaletteEvent),
    TabBar(WidgetId, TabBarEvent),
    StatusBar(WidgetId, StatusBarEvent),
    ActivityBar(WidgetId, ActivityBarEvent),
    Terminal(WidgetId, TerminalEvent),
    TextDisplay(WidgetId, TextDisplayEvent),

    // ── Escape hatches ──────────────────────────────────────────
    /// Backend-specific event the crate couldn't normalise. Apps
    /// ignore unless they want to special-case a platform.
    BackendNative(BackendNativeEvent),
}
```

**Invariants:**

**Trait bounds** — `UiEvent` must implement:

- `Debug` — dev-facing formatting for panic messages and log output
- `Clone` — explicit copy when an app wants to preserve the event
  beyond its single-handler lifetime (e.g. recording for replay)
- `PartialEq` — equality checks in tests (`assert_eq!(got, expected)`)
- `Serialize + Deserialize` — JSON / IPC / replay-file round-trip,
  required for the Lua plugin boundary

`Send` is implied by the "no lifetimes beyond `'static`" rule below, so
events can be moved to another thread. `Sync` is **not** required —
events flow one-way through the dispatch loop; there's no cross-thread
sharing of a single event. `Eq` is also **not** required because
nested value types may include floats (which don't implement `Eq`
because of NaN); `PartialEq` is strictly weaker and sufficient for
the testing use case.

**Known deviation (#831):** `UiEvent::User(UserPayload)` — added for
wake-from-a-background-thread support — does not honestly implement
`Serialize`/`Deserialize`. `UserPayload` wraps an opaque
`Arc<dyn Any + Send + Sync>` with no wire representation; its impls of
those two traits always return `Err` with a descriptive message rather
than failing to compile, and its `PartialEq` is `Arc::ptr_eq` (pointer
identity) rather than structural equality. See `UserPayload`'s doc in
`quadraui/src/event.rs` for the full rationale. No in-tree code path
serialises a live `Vec<UiEvent>` today, so this doesn't break anything
current — but it's a standing trap for any future record/replay-all-events
feature, which would need to special-case (or filter out) `UiEvent::User`
rather than assuming every variant round-trips through JSON like this
section promises.

**Data shape:**

- **No closures, no lifetimes beyond `'static`** — identical discipline
  as primitive data (§10 of `UI_CRATE_DESIGN.md`). A `UiEvent` is
  inert, owned data; it never borrows from its producer, never carries
  a callback, never depends on the backend's internal state. This is
  what makes it serialisable, thread-transferable, and plugin-friendly.
- Mouse events carry `Option<WidgetId>` because the backend does
  hit-testing **before** emitting — apps dispatch on widget identity,
  not screen coordinates. `None` means the click landed outside any
  declared widget (e.g. on the editor content area or background).
- `Modifiers` is the existing `quadraui::types::Modifiers` — no
  per-event-type fork.

**Lifecycle — events are discarded by default:**

The trait bounds above enable *optional* preservation; they do not
mandate it. The normal path is:

```rust
for ev in backend.poll_events() {
    handle(&mut engine, ev);  // ev is consumed, then dropped
}
// at end of loop every event is gone — Rust's deterministic drop
```

Events are moved into `handle`; when `handle` returns they fall out
of scope and their memory is freed immediately. No GC, no log write,
no clone, no allocation beyond the `Vec` returned by `poll_events`.

An app that wants to preserve events (recording for replay, logging
for debugging, forwarding to another thread) must **explicitly** do
so — typically via `.clone()` before dispatch, or by pushing a
serialised copy onto a recorder. The invariants guarantee that every
such preservation path *can* work; they do not automatically do it.
This matters for performance (hot-path event dispatch allocates
nothing by default) and for reasoning about side effects (no hidden
retention or accidental aliasing).

**Event routing — hit-test vs focus:**

A clean boundary apps rely on: **mouse events route by hit-test at
cursor position; keyboard events route by focus.** The dispatcher
never conflates them.

| Event class | Routed by | Why |
|---|---|---|
| `MouseDown` / `MouseUp` / `MouseMoved` / `MouseEntered` / `MouseLeft` / `DoubleClick` / **`Scroll`** | Hit-test at cursor position | The user is *pointing at* something — they mean that thing, regardless of where the keyboard focus happens to be. |
| `KeyPressed` / `CharTyped` | Focus | The user has declared "this is where I'm typing." |
| `Accelerator` | `AcceleratorScope` field (see §3) | Keybindings can be scoped narrower than focus (per-mode, per-widget) or broader (global). |
| `WindowResized` / `WindowClose` / `WindowFocused` / `DpiChanged` | Application-global | Platform-level, not user-targeted. |
| `FilesDropped` | Hit-test at drop position | Same rationale as mouse — user dropped there. |
| `ClipboardPaste` | Focus | Paste goes to whichever text-input has focus. |

The practical consequence — and the one users notice first if we get
it wrong: **scroll-wheel events dispatch to the widget under the
cursor, regardless of keyboard focus.** Hover over a sidebar list,
spin the wheel, list scrolls — even if the editor has keyboard focus.
This matches native behaviour on Win32, Cocoa, and GTK. Getting this
wrong produces "the scroll wheel only works on the focused widget"
bugs that are instantly recognisable as non-native.

Backends do the hit-test **before emitting** and set `widget:
Option<WidgetId>` in every mouse variant. Apps dispatch on the widget
ID without consulting their own focus state. `None` widget means the
cursor was over non-widget area (bare editor content, empty
background); apps handle those via `position` if meaningful.

---

## 3. `Accelerator` — declarative cross-platform keybindings

```rust
/// A named keybinding. Apps register these; backends translate
/// platform-native key events to `UiEvent::Accelerator`.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Accelerator {
    pub id: AcceleratorId,  // String — the name the app will match on
    pub binding: KeyBinding, // Vim-style: `<C-S-t>`, `<A-f>`, etc.
    pub scope: AcceleratorScope,
    pub label: Option<String>, // For menu rendering and help text
}

pub enum AcceleratorScope {
    /// Fires regardless of what's focused. "Ctrl+P" for the palette.
    Global,
    /// Fires only when a specific widget or widget family is focused.
    Widget(WidgetId),
    /// Fires only when a specific mode is active (Normal / Insert / Visual for vim).
    Mode(String),
}

pub enum KeyBinding {
    /// Platform-appropriate rendering: `⌘S` on macOS, `Ctrl+S` elsewhere.
    Save,
    Copy,
    Cut,
    Paste,
    Undo,
    Redo,
    SelectAll,
    Find,
    Quit,
    // ...the universal ones — renders natively per platform

    /// Literal, no platform substitution. Used for app-specific bindings.
    /// Parser accepts two formats — see "Input formats for Literal" below.
    Literal(String),
}
```

**Why an `Accelerator` type, not raw key-binding strings?**

- **Platform idiom parity.** `⌘S` on macOS renders as `Ctrl+S` on
  Win/Linux **without** the app caring. Menus, tooltips, palette
  entries can all show the correct modifier glyph.
- **Scope enforcement.** `Ctrl+P` in a text input shouldn't open the
  palette; backends respect the scope.
- **Mode dispatch.** Vim modes (Normal/Insert/Visual) are just
  `AcceleratorScope::Mode`. No more `handle_vscode_key` vs
  `handle_normal_key` branching — the backend picks the right scope.

### Input formats for `Literal` (Decision 5 = C)

The parser accepts **both** vim-style and plus-style strings. Which
one to parse is detected from the first character — `<` means
vim-style, anything else means plus-style. Internal representation
after parse is the same `Modifiers + Key` tuple; apps never care
which format the string came in as.

| Example | Style | Notes |
|---|---|---|
| `<C-S-t>` | Vim | `C` = Ctrl, `S` = Shift, `A` = Alt, `D`/`M` = Cmd/Super |
| `<C-A-Left>` | Vim | Named keys inside brackets |
| `Ctrl+Shift+T` | Plus | Case-insensitive modifiers |
| `Cmd+Shift+K` | Plus | `Cmd` renders as `⌘` on macOS, `Ctrl` elsewhere |
| `Alt+F4` | Plus | Named keys work unadorned |

**Recommended convention for new quadraui apps:** plus-style
(`Ctrl+Shift+T`). Matches what OS-native menus, tooltips, and docs
show users; what Postman / k8s / SQL-client audiences expect.

**Vim-native apps** (vimcode, potential vim-workflow extensions) may
stay on vim-style; zero migration cost and the convention is internal
to those codebases. The parser supports both indefinitely.

**Canonical rendering** (what `render_accelerator(acc)` returns for
display in menus, tooltips, palette entries, help overlays) is
**always** platform-appropriate: `⌘⇧T` on macOS, `Ctrl+Shift+T` on
Win/Linux/TUI — regardless of which input format the app used.
Input format ≠ render format.

**Case sensitivity:**

- Modifier names (`Ctrl`/`ctrl`/`CTRL`, `C`, `cmd`/`Cmd`/`CMD`) are
  case-insensitive on input.
- Key character is lowercased internally (`T` → `t`) so `Ctrl+T` and
  `Ctrl+t` parse to the same binding. Shift-T on a keyboard requires
  writing `Shift+T` explicitly (or `<S-t>`).

### Registration API (draft)

```rust
impl<B: Backend> App<B> {
    pub fn register_accelerator(&mut self, acc: Accelerator);
    pub fn unregister_accelerator(&mut self, id: &AcceleratorId);
}
```

---

## 4. `Backend` trait — one impl per platform

```rust
pub trait Backend {
    // ── Frame + viewport ────────────────────────────────────────
    /// Viewport geometry in native units. Caller converts to rows
    /// via `viewport.rows_at(line_height)` etc.
    fn viewport(&self) -> Viewport;

    /// Begin a frame. Backends may set up render target, clear, etc.
    fn begin_frame(&mut self, viewport: Viewport);

    /// Flush to screen.
    fn end_frame(&mut self);

    // ── Events + keybindings ────────────────────────────────────
    /// Poll all queued native events. Returns a fully-translated
    /// `Vec<UiEvent>` ready for app dispatch. Never blocks.
    fn poll_events(&mut self) -> Vec<UiEvent>;

    /// Block for up to `timeout` waiting for at least one event.
    /// Returns empty vec on timeout. Used by apps that don't want
    /// to busy-poll.
    fn wait_events(&mut self, timeout: Duration) -> Vec<UiEvent>;

    /// Register an accelerator with the backend's native keybinding
    /// system. Backend stores it and emits `UiEvent::Accelerator`
    /// when matched.
    fn register_accelerator(&mut self, acc: &Accelerator);

    // ── Platform services ───────────────────────────────────────
    /// Clipboard, file dialogs, notifications, open-url, platform name.
    fn services(&self) -> &dyn PlatformServices;

    // ── Drawing — one method per primitive (Decision 2 = B) ────
    // Implementations are thin wrappers around the backend crate's
    // internal draw functions, e.g.:
    //
    //   impl Backend for WinBackend {
    //       fn draw_tree(&mut self, rect: Rect, tree: &TreeView) {
    //           quadraui_win::draw_tree(self.ctx(), tree,
    //                                   self.theme(), rect);
    //       }
    //       // ... same pattern for the other primitives
    //   }
    //
    // Adding a primitive is a breaking change to this trait. That's
    // intentional: the "which backends need this?" conversation
    // happens at trait-update time, not as a runtime panic from a
    // defaulted method.
    fn draw_tree(&mut self, rect: Rect, tree: &TreeView);
    fn draw_list(&mut self, rect: Rect, list: &ListView);
    fn draw_form(&mut self, rect: Rect, form: &Form);
    fn draw_palette(&mut self, rect: Rect, palette: &Palette);
    fn draw_status_bar(&mut self, rect: Rect, bar: &StatusBar);
    fn draw_tab_bar(&mut self, rect: Rect, bar: &TabBar);
    fn draw_activity_bar(&mut self, rect: Rect, bar: &ActivityBar);
    fn draw_terminal(&mut self, rect: Rect, term: &Terminal);
    fn draw_text_display(&mut self, rect: Rect, td: &TextDisplay);
}

pub trait PlatformServices {
    fn clipboard(&self) -> &dyn Clipboard;
    fn show_file_open_dialog(&self, opts: FileDialogOptions) -> Option<PathBuf>;
    fn show_file_save_dialog(&self, opts: FileDialogOptions) -> Option<PathBuf>;
    fn send_notification(&self, n: Notification);
    fn open_url(&self, url: &str);
    fn platform_name(&self) -> &'static str;
}
```

### How apps use this

The per-method shape lets app render code be **fully backend-generic**:

```rust
fn render_sidebar<B: Backend>(backend: &mut B, tree: &TreeView, rect: Rect) {
    backend.draw_tree(rect, tree);
}
```

One codebase, runs on every backend. No `#[cfg(...)]` gates sprinkled through app code selecting between `quadraui_gtk::draw_tree` vs `quadraui_tui::draw_tree` vs `quadraui_win::draw_tree`.

Plugins and apps that want **backend-specific** rendering (e.g. a bespoke primitive defined outside quadraui) still call the free function directly — the quadraui backend crates expose both the trait impl and the underlying `pub fn draw_tree(...)`, so neither path is walled off.

### Why not `AnyPrimitive` enum dispatch

An alternative design — a single `fn draw_primitive(&mut self, rect: Rect, p: &AnyPrimitive)` with `AnyPrimitive` being an enum of every primitive variant — was considered and rejected. Reasons:

1. Adding a primitive still requires work in every backend (a new match arm, same as a new trait method), but changes become **runtime panics via unhandled arms**, not compile errors.
2. The `AnyPrimitive` enum ossifies every primitive's public shape into one bulky type and brings `'a` lifetime parameters everywhere the enum flows.
3. Violates `quadraui/docs/decisions/DECISIONS.md` D-001 principle: "one primitive per UX concept, not per algebraic reduction." `AnyPrimitive` is the reduction.
4. The speculative benefit — "plugins can pass heterogeneous primitive lists through one call" — has no concrete use case today and can be added later as `AnyPrimitive` + a single `draw_primitive` method alongside the per-method ones if a real need appears.

---

## 5. Migration — backend-by-backend rewrite

**Strategy (revised 2026-04-23):** The earlier "coexistence, not
switch" rule has been replaced by a **backend-by-backend rewrite**
strategy. vimcode has no external users yet, so extended per-backend
breakage during the rewrite is acceptable. D6 (primitives return
`Layout`, backends rasterise verbatim) makes half-migration loud at
the type level rather than silent — weakening the original reason
for coexistence. See `PLAN.md` §"Architectural focus" for the
north-star statement.

### Phase B.1 ✅ — `UiEvent` + `Accelerator` + `Backend` trait scaffolding

Shipped 2026-04-22, PR #170 at `06dec4a`. Pure additive types in
`quadraui/src/{event,accelerator,backend}.rs`. No vimcode runtime
change.

### Phase B.2 ✅ — terminal-maximize accelerator pilot

Shipped 2026-04-22, Session 321. Engine-owned accelerator registry.
Six call sites migrated across TUI/GTK/Win-GUI. §11 of this doc
captures the spike findings and why the final shape is engine-owned
rather than backend-owned.

### Phase B.3 — Ready-state quadraui (all primitives, all with `layout()`)

Build every primitive vimcode needs, each with a D6-shape `layout()`
method. This is the "quadraui readiness gate" in `PLAN.md`. End of
B.3: quadraui has a stable contract; **nothing in vimcode has been
rewritten yet** (the old code continues to work, or not, depending
on which pre-existing bugs show up — no effort spent fixing them
mid-B.3).

Work items (in rough order):
- `TabBar::layout()` + `TabBarLayout::hit_test()` first — reference
  implementation for the D6 shape, closes #179.
- Existing primitives gain `layout()`: `StatusBar`, `TreeView`,
  `ListView`, `ActivityBar`, `Form`, `Palette`, `TextDisplay`.
- New layout primitives: `Panel`, `Split`, `Tabs`, `Stack`,
  `MenuBar`, `Modal`, `Dialog` (per D7 focus model — #6.4).
- New primitives vimcode's surface needs: `ContextMenu`,
  `Completions`, `Tooltip`, `Toast` (#141), `Spinner` +
  `ProgressBar` (#142), form field primitives (#143).
- `Backend` trait reshaped to `draw_*(&Layout)` throughout.
- `TextEditor` / `BufferView` — see PLAN.md TBD note (may stay
  engine-owned for TUI; revisit at GTK rewrite).

### Phase B.4 — TUI rewrite (first backend)

First backend rewrite. TUI is the smallest surface, no native
deps, fastest iteration cycle. vimcode-tui is rewritten from scratch
on top of the finalized quadraui. Discovers any remaining quadraui
abstraction gaps at the lowest cost — gaps feed back into quadraui
and forward to the GTK / Win-GUI / macOS rewrites.

**During B.4, vimcode-gtk and vimcode-win are broken or
inconsistent.** That's fine — no external users. The old `src/gtk/`
and `src/win_gui/` trees stay in place, untouched, while B.4 proceeds.

### Phase B.5 — GTK rewrite (second backend)

Second backend rewrite. Primary Linux backend; the TUI-proven
contract should transfer cleanly. Any gaps surfaced here feed back
into quadraui and forward to Win-GUI and macOS.

### Phase B.6 — Win-GUI rewrite (third backend)

Third backend rewrite. Third opinion on the contract. Most
abstraction gaps should already be fixed by this stage.

### Phase B.7 — macOS native backend (fourth backend)

Fourth backend. Core Graphics + Core Text. Intentionally last: by
this point the contract is tight and lessons are encoded, so a
fresh native backend drops in clean instead of adding the fifth
simultaneous complexity cost.

### Phase B.8 — Postman-class validation app (#169)

With four vimcode backends proving quadraui, scaffold the
Postman-class app as a separate workspace member. This is the "does
quadraui serve an app other than vimcode?" test. Issues #145 (k8s
dashboard) and #46 (SQL client) follow.

---

## 6. Open questions

### 6.1 `AnyPrimitive` vs per-primitive trait methods

✅ **RESOLVED 2026-04-22.** Per-primitive methods, explicit impls
required. Backend implementations are thin wrappers around each
backend crate's internal `pub fn draw_*` free functions — apps
benefit from `<B: Backend>` generics without app-side `cfg` gates.
See §4 for the updated trait shape and rejection rationale for
`AnyPrimitive` enum dispatch.

### 6.2 Where does layout computation live?

✅ **RESOLVED 2026-04-22: A (primitives return fully-resolved
`Layout`; backends rasterise verbatim).** Each primitive grows a
`layout(viewport, measure) -> Layout` method that resolves
positions, hit regions, scroll state, overflow chevrons. The
`Backend` trait's `draw_*` methods take `&Layout`, not `&Primitive`.
Wrong consumption produces visibly broken output (no tabs drawn at
all), not silent platform-specific divergence. Closes the structural
gap that produced #178 / #179. See §9 D6 for the three options
considered, why A won, and what lands when applied. The earlier
proposal here (`quadraui::layout::compute(root: &Layout, viewport)
-> LayoutResult` as a single global pass) is superseded — layout
is per-primitive, not a single tree-walk.

### 6.3 What about multi-window?

§7 decision #5 says v1 supports multiple windows. The trait draft
above is single-window. Extension: `Backend` becomes `BackendWindow`
and there's an outer `BackendManager` that owns N windows. Not
blocking for Phase B.1-B.3 — vimcode and Postman clone are
single-window.

### 6.4 How do we handle focus?

Focus is a **keyboard-only** concern — the event-routing table in §2
makes this explicit. Mouse events (including scroll) route by
hit-test at cursor position, not by focus. Focus only determines who
receives `KeyPressed` / `CharTyped`, and factors into `Accelerator`
when scope is `Widget` or `Mode`.

✅ **RESOLVED 2026-04-23 — see §9 D7 for the five sub-decisions
D7a–D7e, all accepted as recommended.** In short: click + Tab +
programmatic transitions; destruction falls back to app-designated
default else null; focusability is a property of the primitive
type; modal interactions use a backend-owned focus stack; native
focus stays at the top-level with in-app simulation below.

The focus *model* resolved five things:

- **D7a Transitions** — does focus move on click, on Tab, on
  app-directed `set_focus(id)`, or all three?
- **D7b Destruction** — what happens when the focused widget is removed?
  Fall back to parent? To an app-designated default?
- **D7c Declaration** — do primitives explicitly opt in to being
  focusable, or is it implicit?
- **D7d Modal interaction** — a `Dialog` opens while a text input has
  focus; what happens on close? Stack-like focus history?
- **D7e Native focus bridging** — on GTK each `DrawingArea` has its own
  native focus; on Win32 the top-level HWND has focus and we
  simulate per-widget focus in-app. The quadraui focus model needs
  to abstract over this without leaking.

None of these block Phase B.1 (types + `poll_events` alongside native
dispatch). The terminal-maximize pilot in B.2 uses
`Accelerator::Global` scope only, so focus isn't required for that
migration. The focus model will want its own proposal doc before
Phase B.3, since `Panel` / `Tabs` / `Dialog` need focus transition
rules to be useful.

### 6.5 Text input / IME

§7 decision #7 parks IME for v1.1. Text input primitives need to
plumb composition events through `UiEvent` — probably
`UiEvent::TextComposing { candidate, position }` plus the existing
`CharTyped`. Defer until first non-Latin user or the text editor
primitive lands.

### 6.6 Performance

Vimcode currently rebuilds primitives every frame. Most primitive
data is cheap (indices + small strings); the expensive one is
`Terminal` (3600+ cells). Already measured as OK. Don't
pre-optimise; profile after B.5 if necessary.

---

## 7. Risks

| Risk | Severity | Mitigation |
|---|---|---|
| `UiEvent` enum bloats with one variant per primitive-event-type | Medium | Accept. The alternative (trait objects, closures) violates plugin invariants. |
| Native event translation is lossy — some app wants a platform-specific gesture | Low | `UiEvent::BackendNative(…)` escape hatch. Apps opt in to platform branches when needed. |
| Migrations get stuck half-done; both dispatch paths linger forever | Medium | Single-session-per-subsystem rule. Full migration or revert. PLAN.md tracks in-flight. |
| The trait is wrong and Phase B.5 discovers it mid-Postman-build | Medium-High | Phase B.2's pilot feature (terminal maximize migration) is the early-warning system. If the collapsed LOC isn't compelling, iterate on the trait before further migration. |
| Multiple consumers (vimcode + Postman) force conflicting API shapes | Low | Both apps are ours; we control the iteration. Community consumers come post-Phase-C. |
| Backend trait ossifies too early (pre-macOS) | Medium | Phase C (macOS) lands last and may force changes. That's OK — nothing is stabilised for crates.io until Phase D. |

---

## 8. What this proposal is NOT

- **Not a full framework.** quadraui remains a primitive catalog + a
  thin event/layout abstraction. Not Electron, not Qt, not egui.
- **Not a replacement for the render adapter.** Apps still have
  their own `render_adapter(engine, theme, viewport) -> ScreenLayout`.
  The trait adds an event side, not a state side.
- **Not a virtual DOM.** Primitives are still built every frame.
  Diffing is not on the table.
- **Not retained widget state.** Scroll offsets and text-input
  state remain the only primitive-owned state; everything else
  stays on the app (see `docs/decisions/DECISIONS.md` D-001 principle).

---

## 9. Decisions needed before code starts

1. **Confirm the overall shape** — does this capture what we want?
   ✅ **RESOLVED 2026-04-22: A (ship all three abstractions together,
   phased migration per §5).** `UiEvent` + `Accelerator` + `Backend`
   are interlocking; splitting them would produce half-abstractions.
   Phase-by-phase risk control lives in §5, not in the decision count.

2. **`AnyPrimitive` vs per-method draw?** (§6.1)
   ✅ **RESOLVED 2026-04-22: B (per-method, explicit impls required).**
   Trait impls are thin wrappers around each backend crate's internal
   `pub fn draw_*` free functions. App code uses `<B: Backend>`
   generics without app-side `cfg` gates. See §4 for the trait shape.

3. **Scope of Phase B.1** — is "ship UiEvent + poll_events alongside
   existing code" the smallest viable first PR? Or should we pair
   it with B.2's pilot feature?
   ✅ **RESOLVED 2026-04-22: A (B.1 ships scaffolding alone).** Pure
   additive types + trait skeleton + empty impls. Reviewer focus on
   the shape of the trait/enum, undistracted by a feature migration.
   B.2 lands as a separate PR that uses the scaffolding for real.

4. **Pilot feature for B.2** — terminal maximize is the current
   candidate (painful, contained, measurable). Any reason to pick a
   different one?
   ✅ **RESOLVED 2026-04-22: A (terminal maximize).** Fresh pain =
   clear win narrative; moderate surface exercises the key
   `Accelerator::Global` + `UiEvent::Accelerator` + `register_accelerator`
   path without prematurely forcing the §6.4 focus design. Target:
   ~-60 LOC net after deleting duplicate key dispatch in three
   backends.

5. **`Accelerator::Literal(String)` format** — stick with Vim-style
   `<C-S-t>` or switch to a platform-agnostic `"Ctrl+Shift+T"`? Vim
   style is already used throughout vimcode; carries over cleanly.
   ✅ **RESOLVED 2026-04-22: C (accept both, parser auto-detects).**
   First character determines dispatch (`<` = vim, otherwise plus).
   Zero migration cost for vimcode; Postman/k8s/SQL-client audiences
   get the format they expect. See §3 "Input formats for Literal"
   for the full convention.

6. **Where does layout computation live? (§6.2)** What can a
   primitive's contract *require* of its rasteriser, vs. what does
   it merely *offer*? Today, primitives expose helpers
   (`fit_active_scroll_offset`, `fit_right_start_chars`) that
   backends may or may not call — producing silent divergence
   (#179 tab overflow, #178 font fallback) where the same bug
   recurs in N-1 backends after being fixed in 1.

   ✅ **RESOLVED 2026-04-22: A (primitives return fully-resolved
   `Layout`; backends rasterise verbatim).**

   **The three options considered:**
   - **A.** Each primitive grows `Primitive::layout(viewport, measure)
     -> Layout` returning positions, hit regions, scroll state,
     overflow chevrons. Backend trait shape moves from
     `draw_tab_bar(&TabBar)` to `draw_tab_bar(&TabBarLayout)`.
     Backend can't deviate — wrong consumption produces visibly
     broken output, not a silent slip on the next platform.
   - **B.** Behavioural primitives own state — introduce dedicated
     primitives for cross-backend guarantees (`ScrollableTabBar`
     owning offset + scroll buttons, alongside the existing
     `TabBar`). New behavioural guarantee → new primitive type.
     Backend trait shape unchanged.
   - **C.** Augment existing primitives with required fields
     (`overflow_indicator`, `glyph_fallback`). Backends MUST honour —
     but enforcement is by convention, not by the type system.

   **Why A:**
   - D-001 ("one primitive per UX concept") cuts against B —
     overflow is a *layout-policy* property of one UX concept, not
     a separate concept. B inflates the catalog
     (`ScrollableTabBar`, `ScrollableTextDisplay`, `ScrollableList`,
     …) for what is structurally one question.
   - C doesn't actually force the backend — it can still ignore
     the new field. Same failure mode as today.
   - A makes wrong rendering loud: a backend that fails to iterate
     `layout.visible_tabs` draws no tabs. Failure is detectable in
     a single-backend smoke test; it can't slip to platform N.
   - Operationalises §6's existing sentence ("backend never makes
     layout decisions beyond intrinsic sizing") — today aspirational
     discipline; under A it's a type-level contract.
   - Generic-over-measurer pattern (`fit_*`) extends naturally —
     `layout()` takes the measure closure each backend already
     knows how to supply.

   **What lands when applied:**
   - `TabBar::layout()` + `TabBarLayout::hit_test()` (closes #179).
   - `StatusBar::layout()` (existing `fit_right_start_chars`
     migrates into it).
   - `Backend` trait `draw_*` methods take `&Layout` not
     `&Primitive`; §4 / §6 trait sketch updated accordingly.
   - Existing backend `draw_tab_bar` / `draw_status_bar` collapse
     to "iterate layout, rasterise" (~−100 LOC per backend).
   - All future primitives ship with `layout()` from day one.
   - §6.4 (focus) becomes adjacent — focus regions probably live
     on the layout struct too.

   **Scope.** Phase B.3 (layout primitives — `Panel`, `Split`,
   `Tabs`, `Stack`, `MenuBar`, `Modal`, `Dialog`) needs this
   resolved before it can land. Without it, B.3 ships container
   primitives without knowing whether *they* also expose
   `layout()`. By symmetry with A, they do.

   **What this does NOT decide.** §6.4 (focus model) and §6.5
   (IME) remain open. §6.3 (multi-window) is a v1.x concern, not
   blocking. §6.6 (performance) is profile-after-B.5.

7. **Focus model (§6.4)** — five interlocking sub-questions. The
   focus model is the last design blocker before Phase B.3 layout
   primitives can land, because `Panel` / `Tabs` / `Dialog` /
   `MenuBar` / `Modal` all depend on focus semantics for their
   keyboard behaviour. Presented as five separate sub-decisions
   (D7a–D7e) so each can be picked independently.

   ✅ **RESOLVED 2026-04-23 — all five sub-decisions accepted as
   recommended.** Picks: D7a = A, D7b = B+C hybrid, D7c = C,
   D7d = A, D7e = C. Rationale under each sub-decision below is
   authoritative; may iterate once implementation reveals edge
   cases (noted by user at resolution time — edge-case iteration
   is expected, not a sign the top-level shape is wrong).

   ---

   #### D7a — Transitions: how does focus move?

   - **A.** Click, Tab, and programmatic `set_focus(id)` — all three.
   - **B.** Click only; Tab is app-implemented.
   - **C.** Programmatic only; app owns all focus state.

   **Recommendation: A.** All three funnel through a single
   `set_focus(id)` path — primitives don't need to know how focus
   got here, only that they received or lost it. Click is
   universal; Tab is the accessibility baseline; programmatic is
   needed for dialog-open / palette-first-result / after-action
   focus moves. Backend handles click-to-focus by emitting
   `set_focus` on hit-test; Tab walks a focus-order registry;
   apps call `set_focus` directly.

   ---

   #### D7b — Destruction: what happens when the focused widget disappears?

   - **A.** Fall back to parent widget (walk up the tree).
   - **B.** Fall back to an app-designated default (app registers
     "when nothing else is focused, focus this").
   - **C.** Focus becomes `None`; next Tab/click re-establishes.

   **Recommendation: B + C hybrid.** If the app registered a
   default (`backend.set_default_focus(id)`), restore to it; else
   focus becomes `None` and re-establishes on the next input.
   A alone (parent walk) requires a parent-pointer tree quadraui
   primitives don't maintain — primitives are declarative trees
   rebuilt per frame, not retained widgets. B alone is fragile
   (app forgets to register → permanent null focus). C alone
   breaks the keyboard flow when a dialog closes.

   Implementation: `Backend` owns `focused_id: Option<WidgetId>`
   and `default_focus_id: Option<WidgetId>`. Each frame, if
   `focused_id` isn't in the current primitive tree, fall back to
   `default_focus_id` or `None`.

   ---

   #### D7c — Declaration: opt-in or opt-out focusable?

   - **A.** All primitives focusable by default; opt out via field.
   - **B.** Each primitive carries a `focusable: bool` field; apps
     set it per-instance.
   - **C.** Focusability is a property of the primitive *type* —
     `TextInput` / `ListView` / `TreeView` / `Palette` / `Form` /
     `Editor` are always focusable; `Toast` / `Tooltip` /
     `Separator` / `StatusBar` / `ActivityBar` never are. `Dialog`
     is focusable when open (modal semantics override).

   **Recommendation: C.** Aligns with plugin-invariant hygiene
   (§10) — smaller serde payload, no "what happens if a Toast
   has `focusable=true`?" foot-gun. A requires every primitive
   author to remember opt-out. B adds a field that's the same
   value for 95% of primitives (noise). C encodes knowledge
   once, in the type.

   Open sub-case: `ListView` *rows* — focusable as a whole, not
   per-row. Row selection is separate from focus. Same for
   `TreeView`.

   ---

   #### D7d — Modal interaction: Dialog opens, what happens on close?

   - **A.** Stack-based focus history: backend pushes `focused_id`
     on modal open, pops on close.
   - **B.** Apps are responsible for restoring focus manually on
     dialog close.
   - **C.** No modal-specific behaviour — `Dialog` is just another
     focusable primitive.

   **Recommendation: A.** This is the obvious-correct UX (every
   OS does this). B puts the burden on every app author who opens
   a dialog to remember restoration — high defect rate, and
   indistinguishable from a forgotten case until a user notices.
   C is wrong: modal is load-bearing UX, not cosmetic.

   Implementation: `Backend` owns a focus stack `Vec<WidgetId>`.
   `push_modal_focus()` / `pop_modal_focus()` are backend-owned
   methods primitives can trigger; apps don't touch the stack.
   Close via Esc or Enter pops automatically. Nested modals work
   by depth of stack.

   ---

   #### D7e — Native focus bridging: how does quadraui focus relate to OS focus?

   - **A.** Simulate in-app focus entirely; ignore native focus
     system. Backend gives its own top-level widget native focus
     once at startup; after that, nothing talks to the native
     focus API.
   - **B.** Map quadraui focus 1:1 to native focus. GTK: focus
     the parent `DrawingArea`. Win32: mostly no-op (single HWND).
     macOS: drive NSResponder chain. TUI: no-op (no native focus).
   - **C.** Hybrid: native focus for the top-level window;
     simulate within. TUI no-op inside; GTK/Win32/macOS focus
     the app's single drawing surface, then simulate widget
     focus inside it.

   **Recommendation: C.** This is implicitly what today's code
   does; formalising it makes the contract explicit. A loses
   native conventions (e.g. Alt+Tab on Windows returning focus
   to the app needs something to focus; losing OS-level focus
   means the app stops receiving keyboard input). B is
   impossible for TUI (no native focus model) and awkward for
   GTK (one `DrawingArea` per quadraui widget would require
   rebuilding the existing scene-as-custom-paint model).

   C works everywhere: native focus lives at the top-level; the
   `Backend::focus_surface()` is what the OS considers "has
   keyboard focus." Inside that surface, `focused_id` routes
   which widget gets `KeyPressed` / `CharTyped`.

   ---

   #### What lands when D7a–D7e are resolved

   - `Backend::focused_id()` / `set_focus(id)` / `set_default_focus(id)`
     methods.
   - `UiEvent::FocusGained(WidgetId)` / `UiEvent::FocusLost(WidgetId)`.
   - Backend-owned focus stack for modal push/pop.
   - Each primitive's type documented as focusable (always / never /
     modal-conditional) in its crate docstring.
   - Tab-order registry on `Backend` — apps register focusable
     widgets; backend walks on Tab / Shift+Tab.
   - `Accelerator` scope `Widget(id)` and `Mode(mode_id)` now
     dispatchable (previously stub).

   #### What this unblocks

   Phase B.3 can start once D7a–D7e are resolved (or at minimum
   D7a + D7c + D7d, since D7b and D7e are implementation details
   that don't affect primitive shape). `Panel` / `Tabs` / `Modal`
   / `Dialog` / `MenuBar` all need to know focusability rules to
   define their keyboard behaviour coherently.

---

### All decisions resolved — next step is code

With D1–D7 all resolved, there are no remaining design-axis
blockers for Phase B.3 (ready-state quadraui). Code work proceeds:
- `TabBar::layout()` + `TabBarLayout::hit_test()` lands first
  (reference implementation for D6, closes #179).
- `Backend` trait reshape to `draw_*(&Layout)` follows mechanically
  as each primitive gains `layout()`.
- Focus-model surface (`Backend::focused_id`, `set_focus`,
  `set_default_focus`, focus-stack push/pop, `UiEvent::FocusGained`
  / `FocusLost`) lands alongside the first primitive that needs it
  (likely `Panel` or `Modal`).
- New layout primitives (`Panel`, `Split`, `Tabs`, `Stack`,
  `MenuBar`, `Modal`, `Dialog`) follow, all built on Layout + focus.
- Remaining primitives (`StatusBar`, `TreeView`, `ListView`, `Form`,
  `Palette`, `ActivityBar`, `TextDisplay`, `ContextMenu`,
  `Completions`, `Tooltip`, `Toast`, `Spinner`, `ProgressBar`, form
  fields) gain `layout()` incrementally.
- Once the readiness gate (PLAN.md) is clear, **Phase B.4 (TUI
  rewrite)** starts. Backend-by-backend rewrite sequence lives in §5.

Iteration expected on D7 sub-decisions as implementation hits edge
cases — this is the accepted risk of picking focus semantics without
having built a primitive that exercises them yet.

---

## 10. References

- `quadraui/docs/UI_CRATE_DESIGN.md` §6 ("Backend responsibilities")
  — the original sketch this proposal expands
- `quadraui/docs/APP_ARCHITECTURE.md` — layer cake + worked example
  that motivated this
- `docs/NATIVE_GUI_LESSONS.md` — cross-backend pitfalls the trait
  should absorb
- `PLAN.md` §"Lessons learned" — maximize-era lessons
- Issue #169 — Postman-class validation app (depends on this)
- Issues #47 (macOS backend), #139 (TreeTable), #143 (Form fields),
  #146 (plugin UI API), #147 (bundled Postman extension — likely
  subsumed by #169)

---

## 11. Phase B.2 implementation notes — terminal-maximize pilot

**Status:** Sketched 2026-04-22 (pre-code), grounded in a read of the
three current backend dispatch paths. Five questions called out by
`PLAN.md` §"Phase B.2 starting notes". Answers below; each cites
specific file:line references where the existing code is the
load-bearing constraint.

The plan: the terminal-maximize migration is a **near-total
recreation** of the existing `pk.toggle_terminal_maximize` keybinding
plumbing in B.1's `Backend` trait shape — same behaviour, new path.
It is intentionally narrow (one accelerator) so the trait shape is
stress-tested without the noise of a multi-key feature.

### Existing dispatch path (the thing being replaced)

For reference — this is what each backend does today for `Ctrl+Shift+T`:

| Backend | Native event arrives | Translation | Match site | Engine call |
|---|---|---|---|---|
| TUI | `crossterm::event::read` at `src/tui_main/mod.rs:1608` (poll at `:1429`) | `translate_key` at `:4131` (KeyCode → key_name + modifiers); `matches_tui_key` at `:143` | **Two sites**: early intercept at `:2888` (terminal-panel context); `EngineAction::ToggleTerminalMaximize` arm at `:3586` (editor context, via `engine.handle_key` return) | `engine.toggle_terminal_maximize()` (`:2892` and `:3590`); `terminal_target_maximize_rows_tui()` at `:116`; `terminal_resize`/`terminal_new_tab` |
| GTK | GTK4 `EventControllerKey` (Capture phase) `connect_key_pressed` closure at `src/gtk/mod.rs:1213` | `matches_gtk_key` at `:1386` (key + GDK modifier mask vs `pk.toggle_terminal_maximize`) | One funnel for the editor DA: closure at `:1386` calls `sender.input(Msg::ToggleTerminalMaximize)` (variant decl at `:629`) | `App::update` arm at `:7219` calls `App::terminal_target_maximize_rows()` then `engine.toggle_terminal_maximize()` then `terminal_resize`/`terminal_new_tab` |
| Win-GUI | `WM_KEYDOWN` in `wnd_proc_inner` at `src/win_gui/mod.rs:868` (delegates to `on_key_down` at `:1791`) | `translate_vk` at `:1797` produces `Key { key_name }`; modifiers from three `GetKeyState` calls at `:1793-1795` | Inline cascade in `on_key_down`: `if ctrl && shift && !alt && (key.key_name == "t" || "T")` at `:1832` | `state.engine.toggle_terminal_maximize()` at `:1843`; `win_gui_terminal_target_maximize_rows()` at `:1640`; `terminal_resize`/`terminal_new_tab`; `InvalidateRect` |

Total duplication: 3 backends × (key match + size compute + engine
call + repaint trigger) ≈ ~80 LOC of native-event plumbing for one
keybinding. Plus the TUI's *internal* duplication (sites `:2888` and
`:3586` both compute `terminal_target_maximize_rows_tui` + dispatch).

That cluster is what B.2 deletes (and replaces with one
`register_accelerator` call per backend + one `Engine::handle_ui_event`
arm matching `Accelerator("terminal.toggle_maximize", _)`).

---

### Q1 — `TuiBackend` struct shape

**Recommendation:** the backend owns the ratatui `Terminal` end-to-end.
Apps never see a raw `Frame<'_>`; they call `backend.begin_frame()` /
`backend.draw_*(...)` / `backend.end_frame()` in Backend-trait order.

The constraint: ratatui's `Frame<'_>` is only obtainable inside the
closure passed to `terminal.draw(|frame| ...)`, and its lifetime is
tied to that closure. There is no way to store a `Frame` on the
backend struct across method calls. So the backend must either:

- **(A) Defer draws** — `draw_*` methods paint into a frame-scoped
  `ratatui::buffer::Buffer` owned by the backend; `end_frame` runs
  `terminal.draw(|f| f.buffer_mut().merge(taken_buffer))`.
- **(B) Closure-pattern API** — extra trait method
  `with_frame(&mut self, f: impl FnOnce(&mut Self))` that opens a
  ratatui draw context. App code becomes
  `backend.with_frame(|b| { b.draw_tree(...); ... })` instead of
  `begin_frame` / draw / `end_frame`. Violates the §4 trait shape.

Pick **(A)**. Trait stays clean; deferred buffer is cheap (one
`Buffer::empty(viewport)` per frame, all painting is mutation). Same
pattern works for GTK (Cairo `Context` from `connect_draw` is
similarly closure-scoped) and Win-GUI (Direct2D render target is
already long-lived, no constraint).

Concrete struct:

```rust
pub struct TuiBackend {
    terminal: ratatui::Terminal<ratatui::backend::CrosstermBackend<std::io::Stdout>>,
    accelerators: Vec<Accelerator>,    // small N; linear scan is fine
    event_queue: VecDeque<UiEvent>,
    current_buffer: Option<ratatui::buffer::Buffer>,  // Some between begin/end_frame
    services: TuiServices,             // clipboard, etc.
    // Modifier-aware key naming (parity with src/tui_main/mod.rs:4131).
    keyboard_enhanced: bool,
}
```

`begin_frame(viewport)`: allocate `current_buffer` sized to viewport.
`draw_tree(rect, tree)` etc.: paint into `self.current_buffer.as_mut().unwrap()` via the existing `quadraui_tui::draw_tree` free functions (which already accept a `&mut Buffer` after the A.1a pattern; verify this when implementing).
`end_frame()`: take the buffer, run `self.terminal.draw(|f| { let b = f.buffer_mut(); /* merge taken into b */ })`.

### Q2 — Native event → `UiEvent` translation algorithm

**TUI** — `TuiBackend::poll_events`:

1. While `crossterm::event::poll(Duration::ZERO).unwrap_or(false)`:
2. Drain one `crossterm::event::Event` via `event::read()`.
3. Translate by variant:
   - `Event::Key(k)` → reuse the existing `translate_key` algorithm
     (`src/tui_main/mod.rs:4131`), which produces
     `(key_name: String, unicode: Option<char>, ctrl: bool)`. Build a
     `Key` enum (per §2 type) + `Modifiers` from this.
   - `Event::Mouse(m)` → `UiEvent::MouseDown` / `MouseUp` / `MouseMoved`
     / `Scroll` with `widget: None` for B.2 (hit-testing comes in B.3
     when `Panel` exists). Position via `Point { x: m.column, y: m.row }`.
   - `Event::Resize(c, r)` → `UiEvent::WindowResized { viewport: ... }`.
   - `Event::Paste(s)` → `UiEvent::ClipboardPaste(s)`.
   - `Event::FocusGained`/`FocusLost` → `UiEvent::WindowFocused(_)`.
4. For key events, **before** emitting `UiEvent::KeyPressed`, check
   the accelerator registry. First-match-wins:
   - For each `Accelerator { binding, scope: Global, id }` in
     `self.accelerators`, ask `binding.matches(&key_name, modifiers)`
     (helper exists in B.1's `Accelerator` impl, line refs in
     `quadraui/src/accelerator.rs`). If true → emit
     `UiEvent::Accelerator(id.clone(), modifiers)` and skip the
     `UiEvent::KeyPressed` for this event.
   - For `Widget` and `Mode` scopes: defer to B.3 / B.4. In B.2 the
     registry only ever holds `Global` accelerators, so the algorithm
     above is exhaustive.
5. Push translated `UiEvent` onto `self.event_queue`.
6. After the drain loop, return `self.event_queue.drain(..).collect()`.

**Algorithm rationale:** "first-match-wins, scope-filtered" mirrors
how editors universally resolve keymap conflicts. Walking the
registry every key press is O(N) where N is the registered count
(maximize alone = 1; full vimcode under B.4 = ~30); a Vec scan
beats a HashMap for that range. If N grows past ~50, swap to a
two-level dispatch (modifier → Vec) — but premature for B.2.

**GTK** — `GtkBackend::poll_events` is a queue-drain only (events
arrive via `EventControllerKey` callbacks, not by polling — see Q4).
Translation happens *inside* the callback before push. Same accelerator
match logic as TUI; same fall-through to `UiEvent::KeyPressed` if
unmatched.

**Win-GUI** — same shape as GTK: translation happens inside
`wnd_proc_inner` (`src/win_gui/mod.rs:832`) before push to the
`WinBackend.event_queue`. See Q5.

### Q3 — Main-loop integration

**Recommendation:** keep the existing main-loop structure in each
backend; replace the inner native-event dispatch with
`backend.poll_events()` → `engine.handle_ui_event(ev)`. Minimum-diff,
preserves all the surrounding machinery (LSP polling, frame-rate
limiting, terminal-panel polling, idle hooks).

**TUI** (`src/tui_main/mod.rs:1022` `event_loop`):

- Currently: `event::poll` at `:1429`, then `event::read` at `:1608`,
  then dispatch through `matches_tui_key` (early-intercepts) or
  `engine.handle_key` at `:3564`.
- After B.2: keep the structure. **Add** `let ui_events =
  backend.poll_events();` near the top of each loop iteration. **For
  each ui_event**, call `engine.handle_ui_event(ev)` — `Engine`
  dispatches `Accelerator("terminal.toggle_maximize", _)` to
  `engine.toggle_terminal_maximize()` etc. **Delete** the early
  intercept at `:2888-2899` (the `matches_tui_key(&pk.toggle_terminal_maximize, …)`
  block) and the `EngineAction::ToggleTerminalMaximize` arm at
  `:3586-3596`. Other keys still flow through the legacy
  `event::read` → `translate_key` → `engine.handle_key` path —
  coexistence per §5 Phase B.1's contract.
- The viewport-rows computation (`terminal_target_maximize_rows_tui`
  at `:116`) moves into the `Engine::handle_ui_event` arm itself —
  it's pure, takes no terminal-specific args other than the screen
  height which is on `Viewport`. Engine grows a small wrapper that
  reads `viewport.height_rows` from cached state, builds the
  `PanelChromeDesc`, and calls the existing
  `toggle_terminal_maximize` + `terminal_resize` chain.

**GTK** (`src/gtk/mod.rs` Relm4 main loop):

- Relm4 owns the GLib loop; we don't touch it directly. App.update
  drains the backend on every Msg cycle (see Q4 for the wake-up
  path). The `Msg::ToggleTerminalMaximize` arm at `:7219` stays
  initially as the dispatch target of `Engine::handle_ui_event`; the
  caller chain becomes:
  - `EventControllerKey` closure at `:1386` translates +
    accelerator-matches → pushes `UiEvent::Accelerator(...)` to
    `backend.event_queue` → fires `sender.input(Msg::PollUiEvents)`.
  - `Msg::PollUiEvents` arm: `for ev in backend.poll_events() {
    engine.handle_ui_event(ev); }`. (This Msg variant is added in
    B.2; in B.4 it absorbs all the per-widget `Msg::*Key` variants.)
  - `engine.handle_ui_event` for the maximize accelerator computes
    target rows via the engine wrapper (same as TUI) and calls
    `toggle_terminal_maximize` etc. directly — bypassing the
    `Msg::ToggleTerminalMaximize` arm. We can then delete that arm
    and `Msg::ToggleTerminalMaximize` in the same PR.
- The `App::terminal_target_maximize_rows()` GTK helper currently
  reads DA pixel height + `cached_line_height` (line refs in
  `src/gtk/mod.rs` per the existing helper). Under B.2, the engine
  needs that info — so the GTK Relm4 wrapper passes
  `Viewport { width_px, height_px, line_height_px, char_width_px }`
  on `WindowResized` events, and `Engine` caches the latest viewport
  to use inside `handle_ui_event`. This is a small change with
  reusable value (other accelerators in B.4 will need viewport too).

**Win-GUI** (`src/win_gui/mod.rs:726-732` message pump):

- Pump unchanged. **After** `DispatchMessageW` returns at `:731`,
  drain: `for ev in backend.poll_events() { engine.handle_ui_event(ev); }`.
  WndProc's job (Q5) is to push, not dispatch. This keeps event
  handling on the main thread between message-pump iterations and
  matches the natural Win32 pattern.
- The maximize arm at `:1832-1853` collapses to: `if ctrl && shift &&
  !alt && key.key_name == "t" { backend.push_accelerator_match("terminal.toggle_maximize", mods); return true; }`,
  which is what `WinBackend::translate_and_push` does for any
  registered accelerator. Specific `if ctrl && shift && key == "t"`
  string disappears.

### Q4 — GTK event ownership: where does the queue live?

**Recommendation: side-channel queue + Relm4 wake-up Msg.** Concretely:

- `GtkBackend` owns `Rc<RefCell<VecDeque<UiEvent>>>` plus
  `Rc<RefCell<Vec<Accelerator>>>` (the registry).
- Each `EventControllerKey` closure (the editor DA at
  `src/gtk/mod.rs:1213` is the primary one; see also six per-widget
  controllers at `:2075` Settings, `:2145` Ex, `:2759` Debug, `:2818`
  Sc, `:2874` Ext, `:2921` ExtPanel, `:3155` AI) clones both `Rc`s at
  `init_widgets` time, alongside the existing `sender` clone.
- When a key arrives: closure runs `backend_match(key, modifier,
  &accelerators)`; on match, pushes
  `UiEvent::Accelerator(id, mods)` to `event_queue` and calls
  `sender.input(Msg::PollUiEvents)`.
- On no match: pushes `UiEvent::KeyPressed { key, modifiers, repeat:
  false }` and same `sender.input(Msg::PollUiEvents)`.
- `App::update` arm `Msg::PollUiEvents`:
  `for ev in self.backend.poll_events() { self.engine.borrow_mut().handle_ui_event(ev); }`.

**Why side-channel + sender-wake, not `Msg::UiEvent(UiEvent)` directly:**
direct `Msg::UiEvent` looked attractive but breaks ordering when two
keys arrive in close succession — Relm4 may interleave Msg arrivals
with other events, and we want strict FIFO across all sources (key,
mouse, paste). A single queue + wake-up Msg gives strict FIFO; the
Msg payload doesn't matter (carries nothing).

**Why not a Relm4 sub-component:** Relm4 components have their own
update + view; `GtkBackend` has no view (it draws via the existing
DA `connect_draw` callbacks) and no Msg-cycle of its own. Wrapping
it as a sub-component is overhead without benefit. Side-channel is
the right primitive — it just lives next to App, not inside Relm4.

**`backend.poll_events()` impl on GTK:** drains the `Rc<RefCell<VecDeque>>`
into a `Vec`. Trivial.

**Edge case — drop ordering at App teardown:** the queue's Rc clones
in closures must drop before App. Relm4 takes care of this naturally
when the App component is dropped (controllers are removed first).
Verify when implementing.

### Q5 — Win-GUI WndProc → `WinBackend` queue hookup

**Recommendation: WndProc translates inline (option (b) from PLAN.md
§"Phase B.2 starting notes" Q5).** WndProc pushes a fully-translated
`UiEvent` to `WinBackend.event_queue`; `poll_events` just drains.

**Why inline beats deferred:**

- Modifier state must be read at the moment of the key event via
  `GetKeyState` (`src/win_gui/mod.rs:1793-1795`). Deferring to
  `poll_events` means `GetKeyState` returns the *current* modifier
  state, not the state at the moment of `WM_KEYDOWN`. Possible to
  capture-and-store in a "raw event" struct, but that's just inline
  translation with extra boxing.
- The existing `translate_vk` at `:1797` already produces the
  canonical `Key { key_name }` we need. Reusing it in WndProc costs
  nothing.
- `state.line_height` (`:307`) and `state.engine` are accessible via
  `APP.with(|app| ...)` inside WndProc (line refs across the file).
  A future `WinBackend` lives on `state` next to `engine`; same
  access.
- Inline is symmetric with how GTK handles it (Q4) — both push-based
  GUI backends do translation in the native callback; only the pull-
  based TUI does it in `poll_events`.

**Concrete WndProc rewrite for the maximize key (replaces
`:1832-1853`):**

```rust
if let Some((id, mods)) = state.backend.match_key_to_accelerator(&key) {
    state.backend.push(UiEvent::Accelerator(id, mods));
    InvalidateRect(state.hwnd, None, false);
    return LRESULT(0);
}
// fall through to UiEvent::KeyPressed push, plus legacy on_key_down
// dispatch for keys not yet migrated to UiEvent
state.backend.push(UiEvent::KeyPressed { key, modifiers: mods, repeat: false });
```

**Drain site:** in the message pump at `:726-732`, after
`DispatchMessageW(&msg)`:

```rust
APP.with(|app| {
    let mut state = app.borrow_mut();
    let events = state.backend.poll_events();
    for ev in events {
        state.engine.handle_ui_event(ev);
    }
});
```

This adds one `APP.with` per pump iteration. Cheap; `GetMessageW`
blocks in normal paint cycles anyway.

**Why not keep WndProc dispatching directly to engine (skip the
queue):** breaks the `Backend::poll_events` trait contract. Apps that
want to inspect or filter events before dispatch (e.g. recording for
replay per §2 invariants, or a future reduced-input mode) need the
queue as a chokepoint. Cost of one VecDeque push per event is
negligible.

---

### Recommended next step after §11 lands — TUI-only spike

Worth considering **before** going broad on B.2: implement
`impl Backend for TuiBackend` end-to-end for the maximize accelerator
*alone*, and verify it runs in-tree without integrating with vimcode's
main `event_loop` yet (e.g. via a tiny standalone example in
`quadraui/examples/maximize_pilot.rs`). Goals:

1. Validate Q1 (the deferred-buffer pattern actually works with
   ratatui's `Frame` API).
2. Validate Q2's accelerator-match algorithm against real crossterm
   key events including the `keyboard_enhanced` path.
3. Iterate §11 if any of the above answers turn out wrong, *before*
   replicating the pattern to GTK and Win-GUI.

Cost: ~half-day. Reduces "big bang" risk for the GTK + Win-GUI parts,
which are harder to iterate on (both require running GUI builds).

The pattern would be: tiny binary that opens a TUI surface, registers
one accelerator (`"toggle_maximize"` → `<C-S-t>`), runs the event
loop, prints "MAXIMIZED" on each `Accelerator` event. ~50-80 LOC,
disposable after B.2 ships. Optional but recommended.

### Spike findings (2026-04-22) — TUI accelerator caveats

The recommended TUI spike was implemented at
`quadraui/examples/maximize_pilot_tui.rs` (commits `ed2e8a9` +
`f775d06` on branch `quadraui-spike-tui-maximize`). The spike is a
single-file `impl Backend for TuiBackend` that registers exactly the
maximize accelerator, paints a status bar via the deferred-buffer
pattern, and toggles state on each fire.

**What it validated:**

1. **Q1 deferred-buffer pattern works.** Owned `Buffer` in
   `current_buffer: Option<Buffer>`, allocated in `begin_frame`,
   manual cell-copy into `f.buffer_mut()` inside `terminal.draw` in
   `end_frame`. Renders cleanly. Note that `Buffer::merge` was
   sidestepped in favour of an explicit cell loop — equal-area
   constraints on the merge API made the manual copy more robust for
   the spike. B.2 can revisit if there's a perf reason.
2. **Q2 algorithm is sound.** First-match-wins against the registered
   accelerators with `parse_key_binding` cached at registration time
   matches correctly *when the input arrives correctly*. The match
   logic itself is not the bottleneck.
3. **Trait shape is implementable end-to-end** without extension
   traits. All 9 `draw_*` methods + `register_accelerator` +
   `poll_events` + `wait_events` + `services` + lifecycle methods
   compile and behave as expected.

**What it surfaced — and §11 did not predict:**

TUI accelerator dispatch is at the mercy of a three-layer stack
*outside* the Backend trait's control:

| Layer | Behaviour | Outcome |
|---|---|---|
| **Terminal emulator** | gnome-terminal, iTerm intercept `Ctrl+Shift+T` for "new tab" | App never sees the keystroke |
| **Multiplexer (tmux, screen)** | tmux strips Shift bit unless `extended-keys on` is configured | Spike reported `Last key: Ctrl+t` (Shift dropped) inside tmux on alacritty |
| **Kitty enhancement protocol** | Only kitty/foot/recent-alacritty/wezterm/ghostty/recent-iTerm support `DISAMBIGUATE_ESCAPE_CODES` | Older terminals silently ignore the push |

Vimcode TUI today already pushes the same enhancement flags (see
`src/tui_main/mod.rs:931-938`), so this is a **pre-existing
limitation** of the TUI binding `pk.toggle_terminal_maximize`, not a
B.2 regression. Users who run vimcode TUI inside tmux on alacritty
have *never* been able to use `Ctrl+Shift+T` for terminal maximize;
the spike just made the failure mode visible.

**Implications for B.2:**

- **Algorithm is fine — proceed with B.2.** The accelerator-match
  code in vimcode's `TuiBackend` will work correctly for any input
  the terminal stack delivers intact.
- **Default bindings should be chosen for lowest-common-denominator
  TUI environments.** Function keys (`F11`), single-modifier chords
  (`Ctrl+Space`), or leader-key sequences (vim-style `<leader>tm`)
  survive the most stack configurations. `Ctrl+Shift+<letter>` is
  fragile.
- **Settings.toml should remain authoritative.** Apps must let users
  rebind any accelerator that doesn't survive their specific
  terminal stack. The B.2 trait API already supports this — users
  just override the `KeyBinding` of any registered `Accelerator`.
- **Document the failure modes for users.** TUI section of the
  vimcode README should call out tmux's `extended-keys on` config
  and gnome-terminal's preferences → shortcuts override pattern.
  This is product-doc work, not architecture work.
- **B.2 does NOT need a "fallback binding" mechanism in quadraui
  itself.** Letting apps register multiple accelerators with the
  same `id` is a thinkable feature (try `<C-S-t>`, fall back to
  `<F11>`), but it's solving a layer-cake problem at the wrong
  layer. The right layer is app config + user-facing docs.

**The spike branch.** `quadraui-spike-tui-maximize` carries the
working TuiBackend impl + diagnostic harness (commits `ed2e8a9` +
`f775d06`). Three reasonable dispositions, choose at B.2 kickoff:

- (a) **Discard.** Lessons captured in this section; commits stay in
  reflog if needed. Cleanest.
- (b) **Squash + keep as reference example.** Single commit landing
  `quadraui/examples/maximize_pilot_tui.rs` to develop. Small
  ongoing maintenance cost (must compile against future Backend
  trait changes); high pedagogical value for the next backend
  implementer (e.g. macOS Phase C).
- (c) **Promote.** Move `TuiBackend` from the example into
  `quadraui::tui::TuiBackend`, drop the diagnostic harness, use it
  as B.2's starting code. Skips re-writing what already works;
  forces an early decision on whether quadraui ships per-backend
  modules vs separate sub-crates (currently neither; just one
  crate).

Recommendation: (c) is the most diff-efficient path to B.2 if there's
no architectural objection to a `quadraui::tui` module. (b) is a
lower-commitment alternative. (a) is fine if the spike code is
considered too entangled with the diagnostic harness to extract
cleanly.

### B.2 implementation notes (2026-04-22) — engine-owned registry

When B.2 was implemented, the "backend owns the event loop" shape
from §11 Q3 proved more invasive than the maximize pilot justified.
The final shape is **engine-owned registry, backend lookup-on-demand**.
Same user-visible B.1 types (`Accelerator`, `UiEvent`,
`KeyBinding`, `parse_key_binding`); same deletion of per-backend
`matches_*_key` checks for the migrated keybinding; smaller blast
radius to vimcode's three event loops.

**What changed from §11 Q3:**

§11 described a full event-loop swap: backend owns crossterm /
GTK signals / WndProc, drains via `poll_events()`, emits typed
`UiEvent`s for everything, apps consume in a `for ev in
backend.poll_events()` loop. For B.2 with exactly one accelerator
to migrate, that meant translating every crossterm mouse + paste +
focus event into a faithful `UiEvent` variant and back-translating
`UiEvent::KeyPressed` to vimcode's existing `(key_name, unicode,
ctrl)` shape for all the keys *not* yet migrated — ~400 lines of
back-translation code to light up one accelerator.

The simpler shape implemented in B.2:

```rust
// Engine (src/core/engine/mod.rs)
pub struct RegisteredAccelerator { acc, parsed }
pub struct UiEventContext { terminal_cols, terminal_max_rows }
impl Engine {
    pub fn register_accelerator(&mut self, acc: Accelerator);
    pub fn unregister_accelerator(&mut self, id: &AcceleratorId);
    pub fn match_accelerator(&self, ctrl, shift, alt, key_char, is_tab, is_space, is_escape) -> Option<AcceleratorId>;
    pub fn handle_ui_event(&mut self, ev: UiEvent, ctx: UiEventContext);
    pub fn register_default_accelerators(&mut self);  // called from Engine::new()
}
```

At each backend's existing key-handler site, the `matches_*_key`
check becomes an `engine.match_accelerator(...)` lookup; on match,
the backend fills a `UiEventContext` (terminal cols + max-rows in
native units) and calls `engine.handle_ui_event(...)`. The engine
owns the flip-and-resize sequence; backends keep their native
viewport math because it genuinely differs (TUI: cells; GTK/Win-GUI:
`floor(px/lh)`).

**What this buys:**

- **One registry, three backends.** Adding the second accelerator
  in B.4 costs exactly one line (`register_accelerator(...)` in
  `register_default_accelerators`) + one arm in `handle_ui_event`.
  No new `matches_*_key` literal in any backend.
- **Zero event-loop disruption.** TUI's `event_loop`, GTK's Relm4
  dispatcher, and Win-GUI's WndProc all stay exactly as they are.
  The migration sits inside existing key-handler blocks.
- **No back-translation cost.** Keys that aren't registered
  accelerators flow through legacy paths unchanged. No
  `UiEvent::KeyPressed → (key_name, unicode, ctrl)` shim.

**What it defers:**

- **Full backend-owned event loops** (§11 Q3's recommendation).
  Real payoff is in B.4 when accelerator count grows; B.2's one
  accelerator doesn't justify the rewrite cost. Each stack of
  `engine.match_accelerator(...)` calls across the three backends
  could still migrate to `backend.poll_events()` later, and the
  engine-owned registry is the natural home for that logic.
- **`Backend` trait impls for vimcode's own backends.** There are
  no `TuiBackend` / `GtkBackend` / `WinBackend` structs in vimcode
  after B.2. The trait from B.1 is exercised by the spike (and
  available to Postman / future consumers); vimcode uses the
  engine-owned helpers directly. When B.5 kicks off the Postman
  validation app it'll write its own `PostmanTuiBackend` etc.,
  which is when the trait's value proposition lands.
- **Live rebinding.** `register_accelerator` replaces prior
  entries by id (tested), but there's no hook on settings reload
  yet. Rebinding requires restart. B.4+ can add a
  `Engine::rebuild_accelerators()` hook alongside
  `rebuild_user_keymaps()`.

**Five sites migrated:**

| File | Before | After |
|---|---|---|
| `src/tui_main/mod.rs:2888` (terminal-panel key intercept) | `matches_tui_key(&pk.toggle_terminal_maximize, ...)` + 8-line flip+resize | `engine.match_accelerator(...)` + `engine.handle_ui_event(...)` |
| `src/tui_main/mod.rs:3586` (EngineAction dispatch arm) | 9-line flip+resize sequence | `engine.handle_ui_event(...)` |
| `src/gtk/mod.rs:1386` (EventControllerKey closure) | `matches_gtk_key(&pk.toggle_terminal_maximize, ...)` | `engine.match_accelerator(...)` + `sender.input(Msg::ToggleTerminalMaximize)` |
| `src/gtk/mod.rs:7219` (`Msg::ToggleTerminalMaximize` handler) | 14-line flip+resize sequence | `engine.handle_ui_event(...)` |
| `src/win_gui/mod.rs:1832` (WndProc cascade) | `if ctrl && shift && !alt && key.key_name == "t"` + inline flip+resize | `engine.match_accelerator(...)` + `engine.handle_ui_event(...)` |
| `src/win_gui/mod.rs:4586` + `:6178` (EngineAction handlers) | 15 + 10 line flip+resize sequences | `engine.handle_ui_event(...)` at both |

**LOC delta:** +339 / -74 across 6 files. Net +265 for one
accelerator. The payoff shows up at accelerator #2: each new
registered binding adds ~1 line per backend (just the `match` arm on
`AcceleratorId` if backends dispatch per-id, or zero lines if the
backend routes all matched accelerators through a single
`Msg::DispatchAccelerator`).

**Integration tests:** `tests/accelerator_registry.rs` — 10 tests
covering default registration, match positive/negative, case
insensitivity, idempotent toggle, unknown-accelerator no-op,
re-register-same-id replaces, unregister removes, scope filtering
(non-Global rejected).

### What §11 explicitly does NOT decide

- **Focus model.** Per §6.4, deferred. B.2 only uses `Accelerator::Global`,
  which doesn't need focus. Widget/Mode scope arrive in B.3+ with
  the focus design.
- **`Backend` trait method for hit-testing.** Mouse events for B.2
  carry `widget: None`. Real hit-testing arrives with `Panel` in B.3.
- **`Engine::handle_ui_event` dispatch shape.** B.2 has exactly one
  arm (`Accelerator("terminal.toggle_maximize", _)`). The
  match-statement-vs-HashMap question is premature with N=1.
- **What happens to `EngineAction::ToggleTerminalMaximize`.** Likely
  deleted after B.2 since the new path doesn't need it; verify
  during implementation that no other code references it.
- **Lua plugin API shape for accelerator registration.** Out of scope;
  separate proposal before plugin authors get access.