|
| 1 | +--- |
| 2 | +name: Native UI architecture |
| 3 | +description: Editor = typed model (data) + layout (data) + behavior; one runtime owns all UI mechanics |
| 4 | +--- |
| 5 | + |
| 6 | +# Native UI architecture |
| 7 | + |
| 8 | +Deepens items 1–3 and 7 of [01-wip-refactor-plan.md](01-wip-refactor-plan.md). |
| 9 | + |
| 10 | +## The idea |
| 11 | + |
| 12 | +An editor is three parts, each with one owner: |
| 13 | + |
| 14 | +1. **Model** — the fields as a typed struct: definitions (label, range, step, |
| 15 | + choices, defaults) *and* current values, plus the state of its widgets |
| 16 | + (grid items/selection, modal open, row visibility) and its action set. |
| 17 | + First-class data. This is what behavior — and, where relevant, editing |
| 18 | + states — interact with. |
| 19 | +2. **Layout** — how the model is arranged on screen: sections, rows, grids, |
| 20 | + action strips, modals. Data over typed field IDs, authored per editor — |
| 21 | + layout quality is per-editor work, never delegated to defaults. |
| 22 | +3. **Behavior** — what it means: `refresh` (world → model) and `apply` |
| 23 | + (event → commands / state request / model updates). |
| 24 | + |
| 25 | +The **runtime** (in `panels/`, written once) is everything mechanical: render |
| 26 | +layout to RML, bind, keep model ↔ DOM in sync, and run every protocol — |
| 27 | +drag/preview/commit, edit/blur/echo, modals, brush sync, thumbnail scheduling. |
| 28 | +Editors never see the DOM. |
| 29 | + |
| 30 | +## The model is typed |
| 31 | + |
| 32 | +Fields are struct members, not string lookups. Each field type carries its |
| 33 | +definition and value and exposes typed access — no casts, no `FieldValue` |
| 34 | +matching in editor code: |
| 35 | + |
| 36 | +```rust |
| 37 | +pub(crate) struct TerrainModel { |
| 38 | + pub pattern: AssetGrid, // "brush_patterns/terrain/", IMAGE_EXTS |
| 39 | + pub size: Num, // label "Size", 100.0, range 10..5000 |
| 40 | + pub rotation: Num, |
| 41 | + pub strength: Num, // step 0.1, 1 decimal |
| 42 | + pub height: Num, |
| 43 | + pub apply_dir: Choice<ApplyDir>, // a real enum, not "Only Raise" strings |
| 44 | +} |
| 45 | +``` |
| 46 | + |
| 47 | +`model.size.get() -> f32`, `model.apply_dir.get() -> ApplyDir`, |
| 48 | +`model.height.visible = false`. `Choice<E>` maps DOM strings to the enum at |
| 49 | +the boundary once; `ApplyDir::from_caption` string-matching in editor code |
| 50 | +disappears. |
| 51 | + |
| 52 | +Every model has a **field ID enum** (`TerrainField::{Pattern, Size, …}`) used |
| 53 | +by layout and events. IDs pair with fields through one boring hand-written |
| 54 | +table per model: |
| 55 | + |
| 56 | +```rust |
| 57 | +fn fields(&mut self) -> Vec<(TerrainField, &mut dyn Field)> { … } |
| 58 | +``` |
| 59 | + |
| 60 | +No proc macro to start — the table is ten explicit lines. If it grates after |
| 61 | +fifteen models, a `derive(EditorModel)` generating enum + table is a purely |
| 62 | +mechanical addition; the design doesn't depend on it. |
| 63 | + |
| 64 | +Per-field data also carries the brush binding where one exists |
| 65 | +(`size: Num { brush: Some(Brush::Size), … }`), so both directions of brush |
| 66 | +sync are one loop in the runtime and `write_brush`/`read_brush` disappear. |
| 67 | + |
| 68 | +**Actions are model, placement is layout.** The action set (Add/Set/Smooth, |
| 69 | +their icons, the brush kind each enters) is part of what the editor *is* — |
| 70 | +declared const data in the model half. Where the strip renders is layout. |
| 71 | + |
| 72 | +**Dynamic editors** (Properties, Teams — fields generated from the selection or |
| 73 | +the teams list at runtime) cannot be a static struct; they use a dynamic model |
| 74 | +(`DynModel`, runtime keys) behind the same field-access trait. Compile-time |
| 75 | +field typing covers the ~12 static editors; the dynamic two keep runtime keys |
| 76 | +because that is what they actually are. |
| 77 | + |
| 78 | +## The contract |
| 79 | + |
| 80 | +```rust |
| 81 | +pub(crate) trait EditorBehavior { |
| 82 | + type Model: EditorModel; // provides the ID enum + field table |
| 83 | + |
| 84 | + /// Layout as data over the model's IDs. Re-called when the model's |
| 85 | + /// structure changed (Outcome::rebuild). |
| 86 | + fn layout(&self, model: &Self::Model) -> Vec<Item<FieldId<Self::Model>>>; |
| 87 | + |
| 88 | + /// World → model. Runs on open, undo/redo, watch hit. |
| 89 | + fn refresh(&mut self, model: &mut Self::Model, |
| 90 | + engine: &NativeInterfaceRef, models: &mut Models); |
| 91 | + |
| 92 | + /// Event → outcome. One channel for every event kind. |
| 93 | + fn apply(&mut self, event: Event<FieldId<Self::Model>>, model: &mut Self::Model, |
| 94 | + engine: &NativeInterfaceRef, models: &mut Models) -> Outcome; |
| 95 | + |
| 96 | + /// Optional cheap per-tick check for views following external state. |
| 97 | + fn watch(&mut self, models: &mut Models) -> Watch { Watch::Unchanged } |
| 98 | +} |
| 99 | + |
| 100 | +pub(crate) enum Event<F> { |
| 101 | + Changed(F, Phase), // Phase: Live | Preview | Commit |
| 102 | + Action(ActionId), // action-strip / button press |
| 103 | + GridPick(F, GridItemId), |
| 104 | +} |
| 105 | + |
| 106 | +pub(crate) struct Outcome { |
| 107 | + pub commands: Vec<Box<dyn Command>>, // Preview-phase → wrapped off-history |
| 108 | + pub state: Option<StateRequest>, |
| 109 | + pub rebuild: bool, // layout() will be re-run |
| 110 | +} |
| 111 | +``` |
| 112 | + |
| 113 | +Events carry the field ID enum — no string comparison on the hot path, no |
| 114 | +misspellable names. `Phase::Commit` (Enter/blur/select/drag-end/picker-accept) |
| 115 | +produces the undoable command; `Preview` fires per drag step and the runtime |
| 116 | +handles off-history wrapping and restore-original-before-final; `Live` fires on |
| 117 | +keystrokes/toggles for the editors that react before commit (search-as-you-type, |
| 118 | +linked scales) — most ignore it. |
| 119 | + |
| 120 | +Layout items (the current `Layout::` vocabulary survives, typed): |
| 121 | + |
| 122 | +```rust |
| 123 | +pub(crate) enum Item<F> { |
| 124 | + Field(F), |
| 125 | + Row(Vec<F>), // side-by-side group |
| 126 | + Section(&'static str), |
| 127 | + Grid(F), // a grid field placed here |
| 128 | + Actions(&'static [BrushAction]), |
| 129 | + Modal(F, Vec<Item<F>>), // hidden until model opens it; joins ModalStack |
| 130 | + Raw(String), // audited escape hatch |
| 131 | +} |
| 132 | +``` |
| 133 | + |
| 134 | +## The hard cases |
| 135 | + |
| 136 | +**Terrain** — model above; behavior is nearly empty (`refresh` no-op, `apply` |
| 137 | +returns default `Outcome`) because pattern-grid selection, brush sync, and the |
| 138 | +action strip are all model+runtime work. Today: 206 lines + macro. After: |
| 139 | +a model literal, a layout list, ~10 lines of behavior. |
| 140 | + |
| 141 | +**Texture** (836 lines — the stress test). Model: two grids (saved brushes, |
| 142 | +pattern), ~20 typed fields incl. `Choice<PaintMode>` / `Choice<Kernel>`, four |
| 143 | +channel toggles, the material dialog as `ModalState` + a materials grid. |
| 144 | +Layout: exactly today's list — sections, three-field rows, identified rows — |
| 145 | +but over `TextureField::` IDs. Behavior keeps only what is genuinely texture |
| 146 | +logic, each piece now a short typed match arm: |
| 147 | + |
| 148 | +- `Changed(Mode, _)` → set `visible` on the mode-dependent fields (today's |
| 149 | + 65-line `apply_visibility` DOM walk becomes model writes; runtime syncs). |
| 150 | +- `GridPick(SavedBrushes, id)` → load the saved brush into the model, or open |
| 151 | + the material dialog for the `+` tile. |
| 152 | +- `GridPick(Materials, id)` → create the saved brush, close the modal. |
| 153 | +- Material discovery/parsing (`list_materials`, `material_of`) moves to the |
| 154 | + `textures` feature proper (plan item 6); the editor consumes it. |
| 155 | + |
| 156 | +Estimated split: `textures/ui/{model,layout,behavior,materials}.rs`, each well |
| 157 | +under 300 lines, none containing DOM or RML. |
| 158 | + |
| 159 | +**ObjectDefs** (893). Model: search `Text` (live), filter `Choice`s, team |
| 160 | +`Choice` (items filled in `refresh`), defs grid with a feature-provided |
| 161 | +`ThumbSource` (model thumbnails rendered by the runtime on the draw thread). |
| 162 | +Behavior: `Changed(Search, Live)` → filter + `model.defs.set_items(…)`; |
| 163 | +`GridPick(Defs, id)` → `Outcome::state(StateRequest::AddObject…)`; placement |
| 164 | +mode change → `rebuild`. |
| 165 | + |
| 166 | +**Properties / Collision**. Dynamic model from selection descriptors; `watch` |
| 167 | +returns `Rebuild` on selection change. Composite commits (position xyz → one |
| 168 | +command, collision multi-field commands) read sibling fields through the model |
| 169 | +— that code is domain logic and stays. Both project the selection through a |
| 170 | +shared `objects/ui/selection.rs` instead of duplicating conversion (plan item 4). |
| 171 | +Collision's linked-scale sync is `Changed(_, Live)` + model writes. |
| 172 | + |
| 173 | +**Teams / MapSettings**. Their hand-rolled dialogs (`element_by_id("team-edit-modal")`, |
| 174 | +button-draining `tick`s) become `Item::Modal` + `Event::Action` arms; the |
| 175 | +shared `ModalStack` gives Escape ordering and input routing for free. |
| 176 | + |
| 177 | +## Known limits, accepted |
| 178 | + |
| 179 | +- The two dynamic editors keep runtime field keys; typing them would mean |
| 180 | + lying about their nature. |
| 181 | +- The model field table is per-editor boilerplate (~10 lines) until/unless a |
| 182 | + derive replaces it. Chosen over a proc macro up front. |
| 183 | +- Debugging gains one hop (runtime decides, behavior reacts). The runtime must |
| 184 | + debug-log its protocol decisions (dropped echo, suppressed blur, preview vs |
| 185 | + commit) or authors re-derive the rules from source. |
| 186 | +- The color-picker restore-then-commit dance moves into the runtime unchanged — |
| 187 | + inherent to "preview on engine, one undoable command". |
| 188 | +- During migration old and new editors coexist behind the registry; migrate |
| 189 | + tab-by-tab so a tab is never mixed. |
| 190 | + |
| 191 | +Falsifiers to watch on the first ports (Terrain, then ObjectDefs): a layout |
| 192 | +that can't be expressed as `Item`s, an interaction that doesn't fit `Event`, |
| 193 | +or model state two components need to own at once. Any of these → revise the |
| 194 | +contract before the mass migration. |
| 195 | + |
| 196 | +## Decomposing `PanelManager` |
| 197 | + |
| 198 | +`PanelManager` stays the composition root with the explicit update order — no |
| 199 | +event bus — but each concern gets its own file: |
| 200 | + |
| 201 | +| Component | Takes from manager | |
| 202 | +| --- | --- | |
| 203 | +| `EditorSlot` | open/rebuild/refresh lifecycle, state-request drain | |
| 204 | +| `FieldSession` | `editing`/`just_committed`/`drag_original`, commit protocol (absorbed by the runtime later) | |
| 205 | +| `ModalStack` | the four pickers/dialogs + declared editor modals, one `close_top` | |
| 206 | +| `ActionDispatcher` | hotkeys, pending actions, dialog-accept callbacks | |
| 207 | +| `BrushSync` | revision tracking, both directions via field bindings | |
| 208 | + |
| 209 | +Target: manager ~200 lines of composition and input delegation. `ModelShader` |
| 210 | +moves out of `panels/` to a neutral world-render module so `states` stops |
| 211 | +importing panel code (plan item 2). |
| 212 | + |
| 213 | +## Layout on disk |
| 214 | + |
| 215 | +`panels/` becomes a toolkit with zero feature knowledge: |
| 216 | + |
| 217 | +```text |
| 218 | +panels/ |
| 219 | + shell/ view, tabs, registry, manager + components above |
| 220 | + runtime/ model↔DOM sync, Item/Event/Outcome, protocols, brush sync |
| 221 | + fields/ Num, Text, Toggle, Choice<E>, Color, Asset + the field trait |
| 222 | + widgets/ grid, action strip, thumbnail scheduling |
| 223 | + modals/ color picker, asset picker, file dialog, new-project |
| 224 | + theme/ rcss parts |
| 225 | +``` |
| 226 | + |
| 227 | +Features own their editors — typically `model.rs`, `layout.rs`, `behavior.rs` |
| 228 | +plus the `inventory` registration, everything under 300 lines: |
| 229 | + |
| 230 | +```text |
| 231 | +objects/ui/{definitions,properties,collision}/ + selection.rs |
| 232 | +heightmap/ui/ textures/ui/ grass/ui/ metal/ui/ |
| 233 | +map_settings/ui/ teams/ui/ project/ui/ |
| 234 | +``` |
| 235 | + |
| 236 | +**States** get the same split: `states/` keeps infrastructure (`StateManager`, |
| 237 | +state trait, `BrushSettings`, tracing, shapes, rectangle-select, highlight); |
| 238 | +feature states move home (`add_object` → `objects/states/`). |
| 239 | + |
| 240 | +## Stylesheet |
| 241 | + |
| 242 | +Split `ui.rcss` by component into `panels/theme/` — `tokens.rcss` (the only |
| 243 | +place colors/spacing/sizes live), `base`, `fields`, `buttons`, `grid`, |
| 244 | +`modals`, `tooltip` — concatenated at build into the single sheet RmlUi loads. |
| 245 | +Chonsole and the dev console keep their layout files but import the same |
| 246 | +tokens/base. Editors emit only `Item`s, items emit only toolkit markup, markup |
| 247 | +uses only theme classes — new editors are styled right by construction. |
| 248 | + |
| 249 | +## Migration order |
| 250 | + |
| 251 | +Golden screenshots verify every step. |
| 252 | + |
| 253 | +1. Carve `PanelManager` into the components above (no behavior change). |
| 254 | +2. Extract `ModelShader` from `panels/`. |
| 255 | +3. Build the runtime + field types; port **Terrain** as proof (simplest, yet |
| 256 | + exercises grid, brush bindings, choice enum). Old and new coexist. |
| 257 | +4. Port **ObjectDefs** — hardest widget case; contract flaws surface at a cost |
| 258 | + of two editors, not fifteen. |
| 259 | +5. Port the rest tab-by-tab (Texture brings the materials extraction with it); |
| 260 | + delete the old `Editor` trait and both macros with the last user. |
| 261 | +6. Move files into feature `ui/` dirs + the states move (mechanical, last, so |
| 262 | + files move once in final shape). |
| 263 | +7. Split the rcss (independent; any time after step 1). |
| 264 | + |
| 265 | +## Completion criteria |
| 266 | + |
| 267 | +1. No editor file contains DOM access, RML strings, or listener binding. |
| 268 | +2. Editor code reads/writes fields through typed model members; `FieldValue` |
| 269 | + and field-name strings appear only in the runtime and the two dynamic editors. |
| 270 | +3. Old trait and macros gone; `panels/runtime` is the only control plumbing. |
| 271 | +4. `panels/` imports no feature modules; features import the toolkit only. |
| 272 | +5. `states/` holds only infrastructure; `PanelManager` ≤ ~250 lines. |
| 273 | +6. All theme values live in `tokens.rcss`. |
| 274 | +7. A new editor = model + layout + behavior + one `inventory` line; `panels/` |
| 275 | + unchanged. |
| 276 | + |
| 277 | +## As built (2026-07-18) |
| 278 | + |
| 279 | +Everything above landed in wip; deviations found during the port: |
| 280 | + |
| 281 | +- **Contract** (`panels/runtime/contract.rs`): `EditorModel` (fields tables, |
| 282 | + `id_of`/`name_of` — instance methods so dynamic models can map) + |
| 283 | + `Behavior` with `layout`/`refresh`/`apply(Event, Phase)` and optional hooks |
| 284 | + that real editors forced: `watch` (takes the model), `bind`/`tick` (custom |
| 285 | + widgets), `state_request`/`state_cleared` (action strips), `brush_read`/ |
| 286 | + `brush_write` (texture's material brush), `dragged` (collision's linked |
| 287 | + scales), `draw` (def thumbnails), `modal_open`, `actions`. |
| 288 | +- **`TableModel<Id>`** joined the named-member model: uniform editors (Water's |
| 289 | + 25 command-forwarding fields) declare a typed-ID entry table instead of 25 |
| 290 | + struct members; `EditorModel` is implemented once for it. Named members |
| 291 | + remain the pattern for editors with real per-field logic (Terrain, Grass, |
| 292 | + Metal, ObjectDefs). |
| 293 | +- **Properties stayed dynamic** as designed: `Id = usize` into runtime-built |
| 294 | + fields, `Item::OwnedRow`/`OwnedSection` for runtime layout rows. |
| 295 | +- **`Phase` reaches behaviors correctly** because the old `Editor` trait's |
| 296 | + `process_drag_end` grew a `preview: bool` — drag steps arrive as `Preview`, |
| 297 | + releases/picker-accepts as `Commit`. The runtime also suppresses echo |
| 298 | + commits (value-unchanged `change` events) for every editor, and commits |
| 299 | + colour sub-channels through `read_sub_field` universally (previously only |
| 300 | + Lighting did this correctly). |
| 301 | +- **The old wide `Editor` trait survives as the manager-facing interface**, |
| 302 | + implemented once by `Runtime<B>`. `sb_field_editor_methods!`, |
| 303 | + `sb_delegate_editor_methods!`, `FieldSet`, and the old `Layout` enum are |
| 304 | + deleted. |
| 305 | +- **rcss**: split byte-identically into `panels/theme/{base,shell,fields, |
| 306 | + scrollbars,buttons,modals,grid}.rcss`, concatenated with `concat!` into the |
| 307 | + one sheet RmlUi loads. `tokens.rcss` is **not** done: RmlUi's RCSS has no |
| 308 | + variables, so a single source of values needs build-time substitution — |
| 309 | + deliberate follow-up, not an oversight. |
| 310 | +- **Layout on disk**: `heightmap/ui`, `grass/ui`, `metal/ui`, `textures/ui`, |
| 311 | + `map_settings/ui/{lighting,sky,water,settings}`, `teams/ui`, `project/ui`, |
| 312 | + `objects/ui/{definitions,units,features,properties,collision,filters}`; |
| 313 | + `add_object` → `objects/states/`; the dev gallery and brush-action strip |
| 314 | + stayed in `panels/` as toolkit. `PanelManager` is a composition root with |
| 315 | + `EditorSlot`/`FieldSession`/`ModalStack`/`ActionDispatcher`/`BrushSync` |
| 316 | + beside it; `ModelShader` lives in `sbc/render/`. |
0 commit comments