Skip to content

Commit 65f0fd8

Browse files
committed
Modularize native editor UI and fix diagnostics
1 parent 97842f8 commit 65f0fd8

165 files changed

Lines changed: 8591 additions & 6599 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

config/README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,8 @@ Manual Rust-Chonsole sessions use a stable history file at
2525
`~/.local/state/springboard/chonsole-history`). Set `SBC_CHONSOLE_HISTORY` to
2626
use a different file while testing. Smoke and E2E runs deliberately keep their
2727
history inside their disposable isolated write directory.
28+
29+
The native developer console retains every line by default. Set
30+
`SpringBoardDevConsoleMaxLines` in `springsettings.cfg` to impose a finite
31+
session limit; positive values are clamped to at least 2,000 lines. Zero means
32+
unlimited. The console header always reports how many lines it is showing.

dist_cfg/springsettings.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,6 @@
1515
"OverheadMaxHeightFactor": 1.39999998,
1616
"OverheadScrollSpeed": 50,
1717
"ScrollWheelSpeed": -35,
18-
"CamMode": 1
18+
"CamMode": 1,
19+
"SpringBoardDevConsoleMaxLines": 0
1920
}
20-

docs/porting/01-wip-refactor-plan.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ native UI, which is organized by rendering technology under `panels/editors`.
2929

3030
## Refactors required before stable transfers
3131

32+
Items 1–3 and 7 are specified in detail — target contracts, module layout, and
33+
migration order — in [ui-architecture.md](ui-architecture.md).
34+
3235
### 1. Make feature UI feature-owned
3336

3437
`panels/` must retain only reusable RmlUi infrastructure: host/document

docs/porting/ui-architecture.md

Lines changed: 316 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,316 @@
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/`.

docs/porting/verification.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,9 @@ python3 tools/e2e/ui_driver.py <target> --tag ui:rust
4242
| 3 | Objects → Properties | VERIFIED | Selection-driven fields, complete vector/table commits, map movement, and collision-field propagation. E2E `props_panel`. |
4343
| 4 | Objects → Collision | VERIFIED | Visibility, type/axis, scale/offset/center/aim, radius/height, linked cylinder scale, and blocking fields. E2E `props_panel` and `collision`. |
4444
| 5 | Map → Terrain | VERIFIED | Pattern, size, rotation, strength, height, direction, Add/Set/Smooth, textured preview, and a stationary held stroke. E2E `heightmap`, `map-editors`, `pattern-preview`, `terrain-stationary-hold`. |
45-
| 6 | Map → Texture | VERIFIED | Saved brushes, material dialog, Paint/Filter/DNTS/Void, and splat controls. Rust is the sole owner of texture paint, cache, stroke close, undo, and redo; native GL and Lua command-bridge tests cover that ownership contract. E2E `map-paint`, `map-editors`, `texture-panel`. |
46-
| 7 | Map → Metal | VERIFIED | Pattern, size, rotation, amount, and painting. E2E `map-editors`. |
47-
| 8 | Map → Grass | VERIFIED | Pattern, detail, size, rotation, and painting. E2E `map-editors`. |
45+
| 6 | Map → Texture | VERIFIED | Saved brushes, material dialog, Paint/Filter/DNTS/Void, and splat controls. Rust is the sole owner of texture paint, cache, stroke close, undo, and redo; native GL and Lua command-bridge tests cover that ownership contract. E2E `texture-paint`, `map-editors`. |
46+
| 7 | Map → Metal | VERIFIED | Pattern, size, rotation, amount, and painting. E2E `map-editors`, `metal-paint`. |
47+
| 8 | Map → Grass | VERIFIED | Pattern, detail, size, rotation, and painting. E2E `map-editors`, `grass-paint`. |
4848
| 9 | Map → Settings | VERIFIED | Rendering flags, splat fields, detail texture, and New/Existing texture paths. E2E `map-editors`, `settings-panel`. |
4949
| 10 | Env → Lighting | VERIFIED | Shadow mode, direction, six colours, and densities. E2E `lighting-panel`. |
5050
| 11 | Env → Sky | VERIFIED | Atmosphere colours, fog bounds, and skybox picker. E2E `sky-panel`. |

native/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ edition = "2021"
77
crate-type = ["cdylib"]
88

99
[dependencies]
10-
spring-native = { path = "/home/gajop/projects/spring-projects/spring-bar/rust/crates/spring-native" }
10+
spring-native = { path = "../../spring-bar/rust/crates/spring-native" }
1111
log = "0.4.30"
1212
log4rs = "1.4.0"
1313
chrono = { version = "0.4", default-features = false, features = ["clock"] }

native/src/sbc/actions/helpers.rs

Lines changed: 2 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -5,21 +5,10 @@ use spring_native::prelude::NativeInterfaceRef;
55
/// The `(name, version)` of the game the editor is running, for the reload
66
/// command's start script.
77
pub(super) fn game_id(interface: &NativeInterfaceRef) -> (String, String) {
8-
let Ok(info) = interface.game().get_game_mod_info() else {
8+
let Ok(info) = interface.game().get_game_mod_info_owned() else {
99
return (String::new(), String::new());
1010
};
11-
// SAFETY: the engine owns these strings for the lifetime of the call; we copy
12-
// them out immediately.
13-
unsafe { (cstr(info.gameName), cstr(info.gameVersion)) }
14-
}
15-
16-
/// Copy a C string the engine handed back, or empty if null.
17-
unsafe fn cstr(ptr: *const std::os::raw::c_char) -> String {
18-
if ptr.is_null() {
19-
String::new()
20-
} else {
21-
std::ffi::CStr::from_ptr(ptr).to_string_lossy().into_owned()
22-
}
11+
(info.game_name, info.game_version)
2312
}
2413

2514
/// The map's height extremes, as an import default when the user hasn't given

0 commit comments

Comments
 (0)