Skip to content

Commit 429ecde

Browse files
committed
feat: expand typed control-driven editor workflows
1 parent 0724de6 commit 429ecde

81 files changed

Lines changed: 986 additions & 260 deletions

Some content is hidden

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

docs/design/programmatic-control.md

Lines changed: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,8 @@ A control channel lets a script drive a running SpringBoard the way a user
99
drives it — open an editor, set its fields, run a command, place the camera,
1010
capture the result — without synthesising X11 input.
1111

12-
Status: **implemented for editor fields, registered commands, camera state, and
13-
ordered capture; domain handles are the next layer.**
12+
Status: **implemented for editor fields, domain dialogs, registered commands,
13+
camera state, and ordered capture.**
1414

1515
## Why
1616

@@ -47,7 +47,7 @@ The Python client detects that replacement, waits for the new `instance_id`,
4747
refreshes the schema, and reconnects subsequent calls; a caller does not need
4848
to rebuild its editor or camera handles.
4949

50-
## Three surfaces, all first-class
50+
## First-class surfaces
5151

5252
**Editors**`ui.open(tab, editor)`, `ui.set(editor, field, value)`,
5353
`ui.get(editor, field)`. This is the user path: `Editor::set_field_value`
@@ -78,6 +78,22 @@ controller distance/height), `camera.trace_screen_ray(x, y)`,
7878
reload deliberately preserves engine world/project state; it is not a project
7979
reset.
8080

81+
**Dialogs**`dialog.open/get/set/select/accept/cancel` controls typed
82+
project/file dialogs through their existing action and modal paths. A domain
83+
workflow can create a project, choose a VFS project, or save/export by name
84+
without synthesising a click, text entry, or Enter key:
85+
86+
```python
87+
project = sb.dialog("new_project").open()
88+
project.set("name", "Example")
89+
project.set("size_x", 32)
90+
project.accept()
91+
92+
load = sb.dialog("load_project").open()
93+
load.select("springboard/projects/Example.sdd")
94+
load.accept()
95+
```
96+
8197
## The Python client
8298

8399
`tools/control/`, a `control` package alongside `e2e`/`smoke`/`lint`, depending
@@ -124,7 +140,7 @@ a step at a time while the rest of it still clicks.
124140
`native/src/sbc/control/` separates the two halves so neither drifts into the
125141
other:
126142

127-
- `api/` — the surface, one file per method group (`editors`, `commands`,
143+
- `api/` — the surface, one file per method group (`editors`, `dialogs`, `commands`,
128144
`camera`, `capture`, `schema`). Each goes through the seam its user action
129145
goes through, and knows nothing about sockets.
130146
- `channel/` — the plumbing: `server` (socket and connections), `discovery`
@@ -136,8 +152,9 @@ other:
136152
## When a call is done
137153

138154
A reply is sent once its effect has landed, not once the request was accepted:
139-
`ui.open` answers when the editor is on screen, `capture` when the image is on
140-
disk, and `runtime.barrier` after two input-idle native updates. Requests on a
155+
`ui.open` answers when the editor is on screen, `dialog.open`/`dialog.accept`
156+
answer when the modal opens/closes, `capture` when the image is on disk, and
157+
`runtime.barrier` after two input-idle native updates. Requests on a
141158
connection apply and answer in order, and `capture` is queued at
142159
`draw_screen_post`, so an image already contains every call before it. No
143160
scenario needs a sleep to make a screenshot honest.
@@ -148,7 +165,7 @@ Nothing in this channel may fail by doing nothing.
148165

149166
## Scope
150167

151-
**Built**: `describe`, `ui.open/set/get`, `command.execute`,
168+
**Built**: `describe`, `ui.open/set/get`, `dialog.open/get/set/select/accept/cancel`, `command.execute`,
152169
`camera.set/get/trace_screen_ray/zoom`,
153170
`capture`, `runtime.barrier`, `runtime.reload_native_modules`. `SBC_CONTROL_FILE` names the discovery file and turns the channel on;
154171
the E2E harness sets it per run and exposes the connection as

justfile

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,7 @@ run config="config/ui-rust.json": build-native
148148
# invocations store all scenario artifacts and the aggregate suite report under
149149
# one timestamped suite folder.
150150
[group('test')]
151-
test-e2e target *args: build-native
151+
test-e2e target="all" *args: build-native
152152
PYTHONPATH="{{tool_pythonpath}}" uv run --locked sbc-e2e run "{{target}}" {{args}}
153153

154154
# `just test-e2e all` shares compatible launch environments by default and

native/src/sbc/actions/action.rs

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,30 @@ pub enum Action {
2323
}
2424

2525
impl Action {
26+
/// The stable name used by the programmatic dialog API for actions that
27+
/// collect file/project input.
28+
pub fn dialog_name(self) -> Option<&'static str> {
29+
match self {
30+
Action::NewProject => Some("new_project"),
31+
Action::Load => Some("load_project"),
32+
Action::Import => Some("import"),
33+
Action::SaveAs => Some("save_project_as"),
34+
Action::Export => Some("export"),
35+
_ => None,
36+
}
37+
}
38+
39+
pub fn from_dialog_name(name: &str) -> Option<Self> {
40+
match name {
41+
"new_project" => Some(Action::NewProject),
42+
"load_project" => Some(Action::Load),
43+
"import" => Some(Action::Import),
44+
"save_project_as" => Some(Action::SaveAs),
45+
"export" => Some(Action::Export),
46+
_ => None,
47+
}
48+
}
49+
2650
/// All actions, in a stable order.
2751
pub const ALL: [Action; 15] = [
2852
Action::NewProject,

native/src/sbc/actions/dialog.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,8 @@ pub enum ActionResult {
2525
/// Configuration for the file browser dialog.
2626
#[derive(Debug, Clone)]
2727
pub struct FileDialogConfig {
28+
/// Stable programmatic name for this action's file dialog.
29+
pub control_name: &'static str,
2830
pub title: String,
2931
pub root_dir: String,
3032
/// File extensions to show (e.g. `[".png", ".jpg"]`). Empty = show all.
@@ -40,6 +42,7 @@ pub struct FileDialogConfig {
4042
impl Default for FileDialogConfig {
4143
fn default() -> Self {
4244
FileDialogConfig {
45+
control_name: "file",
4346
title: "File".to_string(),
4447
root_dir: PROJECTS_DIR.to_string(),
4548
extensions: Vec::new(),

native/src/sbc/actions/run.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ pub fn execute(
6565

6666
Action::Load => {
6767
let config = FileDialogConfig {
68+
control_name: "load_project",
6869
title: "Open project".to_string(),
6970
root_dir: PROJECTS_DIR.to_string(),
7071
dirs_as_items: true,
@@ -86,6 +87,7 @@ pub fn execute(
8687

8788
Action::Import => {
8889
let config = FileDialogConfig {
90+
control_name: "import",
8991
title: "Import".to_string(),
9092
root_dir: PROJECTS_DIR.to_string(),
9193
extensions: [".png", ".jpg", ".bmp", ".tga", ".tif"]
@@ -126,6 +128,7 @@ pub fn execute(
126128
return ActionResult::None;
127129
}
128130
let config = FileDialogConfig {
131+
control_name: "export",
129132
title: "Export".to_string(),
130133
root_dir: EXPORTS_DIR.to_string(),
131134
file_types: [
@@ -221,6 +224,7 @@ pub fn execute_paste(
221224

222225
fn open_save_as() -> ActionResult {
223226
let config = FileDialogConfig {
227+
control_name: "save_project_as",
224228
title: "Save project as...".to_string(),
225229
root_dir: PROJECTS_DIR.to_string(),
226230
show_name_input: true,

native/src/sbc/control/api/camera.rs

Lines changed: 49 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
33
use serde::Deserialize;
44
use serde_json::json;
5+
use spring_native::prelude::NativeInterfaceRef;
56

67
use crate::sbc::sbc::SBC;
78

@@ -28,6 +29,11 @@ pub(crate) struct Set {
2829
#[derive(Deserialize)]
2930
pub(crate) struct Zoom {
3031
pub factor: f32,
32+
/// Keep this ground point under a screen-space cursor while zooming.
33+
/// This is the semantic equivalent of the engine's mouse-wheel zoom; the
34+
/// client should pass the screen point, not reproduce camera geometry.
35+
#[serde(default)]
36+
pub screen: Option<[f32; 2]>,
3137
}
3238

3339
#[derive(Deserialize)]
@@ -168,7 +174,18 @@ pub(crate) fn zoom(sbc: &mut SBC, params: Zoom) -> Handled {
168174
"camera.zoom factor must be positive and finite",
169175
));
170176
}
171-
let camera = sbc.interface().camera();
177+
let interface = sbc.interface();
178+
let target = if let Some(screen) = params.screen {
179+
if !screen[0].is_finite() || !screen[1].is_finite() {
180+
return Err(ControlError::invalid(
181+
"camera.zoom screen coordinates must be finite",
182+
));
183+
}
184+
trace_ground(interface, screen)?
185+
} else {
186+
None
187+
};
188+
let camera = interface.camera();
172189
let mut state = camera
173190
.get_camera_state(false)
174191
.map_err(|err| ControlError::failed(format!("get_camera_state: {err:?}")))?;
@@ -187,6 +204,26 @@ pub(crate) fn zoom(sbc: &mut SBC, params: Zoom) -> Handled {
187204
.map_err(|err| ControlError::failed(format!("set_camera_state: {err:?}")))?
188205
.then_some(())
189206
.ok_or_else(|| ControlError::failed("engine rejected camera zoom"))?;
207+
208+
if let (Some(screen), Some(target)) = (params.screen, target) {
209+
// The generic camera.zoom operation scales around the controller
210+
// position. The mouse-wheel operation instead keeps the traced ground
211+
// point beneath the cursor. Re-trace after scaling and apply the
212+
// horizontal ground delta in the controller, where the engine owns the
213+
// camera projection and terrain height.
214+
if let Some(current) = trace_ground(interface, screen)? {
215+
let mut state = camera
216+
.get_camera_state(false)
217+
.map_err(|err| ControlError::failed(format!("get_camera_state: {err:?}")))?;
218+
state.pos.x += target[0] - current[0];
219+
state.pos.z += target[2] - current[2];
220+
camera
221+
.set_camera_state(state, 0.0, 1.0, 1.0)
222+
.map_err(|err| ControlError::failed(format!("set_camera_state: {err:?}")))?
223+
.then_some(())
224+
.ok_or_else(|| ControlError::failed("engine rejected camera focus"))?;
225+
}
226+
}
190227
get(sbc)
191228
}
192229

@@ -208,3 +245,14 @@ pub(crate) fn trace(sbc: &mut SBC, params: Trace) -> Handled {
208245
"position": [position.x, position.y, position.z],
209246
})))
210247
}
248+
249+
fn trace_ground(
250+
interface: &NativeInterfaceRef,
251+
screen: [f32; 2],
252+
) -> Result<Option<[f32; 3]>, ControlError> {
253+
let (hit_type, _, position) = interface
254+
.camera()
255+
.trace_screen_ray(screen[0], screen[1], true, false, false, true, 0.0)
256+
.map_err(|err| ControlError::failed(format!("trace_screen_ray: {err:?}")))?;
257+
Ok((hit_type == 3).then_some([position.x, position.y, position.z]))
258+
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
//! Typed control of dialogs that collect domain input.
2+
//!
3+
//! This is deliberately separate from editor UI control. Domain workflows can
4+
//! open and submit a project/file dialog without synthesising mouse or key
5+
//! events; tests that specifically exercise the dialog's visible controls can
6+
//! continue to use input.
7+
8+
use serde::Deserialize;
9+
use serde_json::{json, Value};
10+
11+
use crate::sbc::actions::Action;
12+
use crate::sbc::panels::PanelManager;
13+
use crate::sbc::sbc::SBC;
14+
15+
use super::super::channel::Effect;
16+
use super::super::{ControlError, Handled, Reply};
17+
use super::schema;
18+
19+
#[derive(Deserialize)]
20+
pub(crate) struct Open {
21+
pub dialog: String,
22+
}
23+
24+
#[derive(Deserialize)]
25+
pub(crate) struct Field {
26+
pub dialog: String,
27+
pub field: String,
28+
}
29+
30+
#[derive(Deserialize)]
31+
pub(crate) struct Dialog {
32+
pub dialog: String,
33+
}
34+
35+
#[derive(Deserialize)]
36+
pub(crate) struct Set {
37+
pub dialog: String,
38+
pub field: String,
39+
pub value: Value,
40+
}
41+
42+
#[derive(Deserialize)]
43+
pub(crate) struct Select {
44+
pub dialog: String,
45+
pub path: String,
46+
}
47+
48+
pub(crate) fn open(sbc: &mut SBC, params: Open) -> Handled {
49+
let canonical = sbc.models_mut().with::<PanelManager, _>(|panels, models| {
50+
panels.control_open_dialog(&params.dialog, models)
51+
})?;
52+
Ok(Reply::When(Effect::DialogOpen(canonical)))
53+
}
54+
55+
pub(crate) fn get(sbc: &mut SBC, params: Field) -> Handled {
56+
let (spec, _) = sbc
57+
.model::<PanelManager>()
58+
.control_dialog_field_value(&params.dialog, &params.field)?;
59+
Ok(Reply::now(json!({
60+
"value": schema::value_json(&spec.value),
61+
"kind": schema::kind_of(&spec.value),
62+
})))
63+
}
64+
65+
pub(crate) fn set(sbc: &mut SBC, params: Set) -> Handled {
66+
let (spec, _) = sbc
67+
.model::<PanelManager>()
68+
.control_dialog_field_value(&params.dialog, &params.field)?;
69+
let value = schema::parse_value(&spec.value, &params.value)
70+
.map_err(|message| ControlError::invalid(format!("{}: {message}", params.field)))?;
71+
let applied = sbc.model::<PanelManager>().control_set_dialog_field(
72+
&params.dialog,
73+
&params.field,
74+
value,
75+
)?;
76+
Ok(Reply::now(json!({ "value": schema::value_json(&applied) })))
77+
}
78+
79+
pub(crate) fn select(sbc: &mut SBC, params: Select) -> Handled {
80+
sbc.model::<PanelManager>()
81+
.control_select_dialog(&params.dialog, &params.path)?;
82+
Ok(Reply::now(json!({ "path": params.path })))
83+
}
84+
85+
pub(crate) fn accept(sbc: &mut SBC, params: Dialog) -> Handled {
86+
let canonical = canonical_name(&params.dialog)?;
87+
sbc.model::<PanelManager>()
88+
.control_accept_dialog(&params.dialog)?;
89+
Ok(Reply::When(Effect::DialogClosed(canonical)))
90+
}
91+
92+
pub(crate) fn cancel(sbc: &mut SBC, params: Dialog) -> Handled {
93+
let canonical = canonical_name(&params.dialog)?;
94+
sbc.model::<PanelManager>()
95+
.control_cancel_dialog(&params.dialog)?;
96+
Ok(Reply::When(Effect::DialogClosed(canonical)))
97+
}
98+
99+
fn canonical_name(name: &str) -> Result<&'static str, ControlError> {
100+
Action::from_dialog_name(name)
101+
.and_then(Action::dialog_name)
102+
.ok_or_else(|| ControlError::unknown(format!("no such dialog: {name}")))
103+
}

native/src/sbc/control/api/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
pub(crate) mod camera;
44
pub(crate) mod capture;
55
pub(crate) mod commands;
6+
pub(crate) mod dialogs;
67
pub(crate) mod editors;
78
pub(crate) mod runtime;
89
pub(crate) mod schema;

native/src/sbc/control/api/schema.rs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ pub(crate) fn describe() -> Value {
3131

3232
json!({
3333
"tabs": tabs,
34+
"dialogs": dialog_descriptors(),
3435
"commands": registered_class_names(),
3536
})
3637
}
@@ -105,3 +106,33 @@ fn field_json(spec: FieldSpec) -> Value {
105106
"options": spec.options,
106107
})
107108
}
109+
110+
fn dialog_descriptors() -> Vec<Value> {
111+
vec![
112+
json!({
113+
"name": "new_project",
114+
"fields": [
115+
{ "name": "name", "kind": "text", "value": "" },
116+
{ "name": "map", "kind": "text", "value": "SB_Blank_Map" },
117+
{ "name": "size_x", "kind": "number", "value": 10.0 },
118+
{ "name": "size_y", "kind": "number", "value": 10.0 },
119+
],
120+
}),
121+
json!({ "name": "load_project", "fields": [] }),
122+
json!({
123+
"name": "save_project_as",
124+
"fields": [{ "name": "name", "kind": "text", "value": "" }],
125+
}),
126+
json!({
127+
"name": "import",
128+
"fields": [{ "name": "file_type", "kind": "text", "value": "" }],
129+
}),
130+
json!({
131+
"name": "export",
132+
"fields": [
133+
{ "name": "name", "kind": "text", "value": "" },
134+
{ "name": "file_type", "kind": "text", "value": "" },
135+
],
136+
}),
137+
]
138+
}

0 commit comments

Comments
 (0)