From c90070c4972b76fdd39437bb1041e4afe703e60d Mon Sep 17 00:00:00 2001 From: bherbruck Date: Sat, 10 Oct 2026 16:54:54 +0000 Subject: [PATCH] =?UTF-8?q?Agent=20cursor=20speed:=20a=20slider=20from=201?= =?UTF-8?q?=C3=97=20to=205=C3=97=20with=20Instant=20at=20the=20far=20right?= =?UTF-8?q?.=20Instant=20no=20longer=20breaks:=20the=20command=20runs=20at?= =?UTF-8?q?=20once=20(as=20with=20the=20cursor=20off)=20and=20the=20pointe?= =?UTF-8?q?r=20jumps=20to=20where=20it=20acted,=20with=20a=20click=20rippl?= =?UTF-8?q?e.=20ui.agent=5Fcursor=20speed=20takes=201=E2=80=935=20or=20"in?= =?UTF-8?q?stant";=20saved=20"slow"=20and=20"normal"=20read=20as=201=C3=97?= =?UTF-8?q?.=20Scenario=20covers=201=C3=97,=203=C3=97=20and=20Instant=20ag?= =?UTF-8?q?ainst=20the=20cursor-off=20result?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes #79 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01RjGw4gA5ZvKrU8MQP6sBgT --- crates/ui-egui/src/agent_cursor.rs | 110 +++++++++++++++--- crates/ui-egui/src/control.rs | 8 +- crates/ui-egui/src/prefs.rs | 6 +- crates/ui-egui/src/scenario.rs | 7 +- crates/ui-egui/src/viewport.rs | 4 +- .../ui-egui/tests/scenarios/agent_cursor.json | 25 +++- docs/control-protocol.md | 2 +- 7 files changed, 128 insertions(+), 34 deletions(-) diff --git a/crates/ui-egui/src/agent_cursor.rs b/crates/ui-egui/src/agent_cursor.rs index d2d35b8e..6988be14 100644 --- a/crates/ui-egui/src/agent_cursor.rs +++ b/crates/ui-egui/src/agent_cursor.rs @@ -21,36 +21,87 @@ use solvecraft_engine::geom::{Vec2, Vec3}; use crate::SolveApp; use crate::control::ControlResponse; -/// How fast the pointer moves. -#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] -#[serde(rename_all = "lowercase")] +/// How fast the pointer moves: 1× to 5×, or Instant (no animation: the command runs at once +/// and the pointer jumps to where it acted, with a click ripple). Saved and sent as a number, +/// or "instant"; the old "slow" and "normal" read as 1×. +#[derive(Clone, Copy, Debug, PartialEq)] pub enum Speed { - /// No animation: the command runs at once. + Times(f64), Instant, - #[default] - Normal, - Slow, +} + +impl Default for Speed { + fn default() -> Self { + Speed::Times(1.0) + } } impl Speed { - pub const ALL: [Speed; 3] = [Speed::Instant, Speed::Normal, Speed::Slow]; - pub fn label(self) -> &'static str { + /// From a number (1–5) or a name ("instant"; "slow" and "normal" are 1×). + pub fn from_value(v: &Value) -> Option { + match v { + Value::Number(n) => n.as_f64().filter(|x| x.is_finite() && (1.0..=5.0).contains(x)).map(Speed::Times), + Value::String(s) => match s.to_ascii_lowercase().as_str() { + "instant" => Some(Speed::Instant), + "slow" | "normal" => Some(Speed::Times(1.0)), + "fast" => Some(Speed::Times(2.0)), + _ => None, + }, + _ => None, + } + } + pub fn label(self) -> String { match self { - Speed::Instant => "Instant", - Speed::Normal => "Normal", - Speed::Slow => "Slow", + Speed::Instant => "Instant".into(), + Speed::Times(x) => { + let s = format!("{x:.1}"); + format!("{}×", s.trim_end_matches(".0")) + } } } /// Seconds to glide to a stop, and to click there. fn times(self) -> (f64, f64) { match self { Speed::Instant => (0.0, 0.0), - Speed::Normal => (0.45, 0.25), - Speed::Slow => (0.9, 0.45), + Speed::Times(x) => { + let x = x.clamp(1.0, 5.0); + (0.45 / x, 0.25 / x) + } } } } +impl Serialize for Speed { + fn serialize(&self, s: S) -> Result { + match self { + Speed::Instant => s.serialize_str("instant"), + Speed::Times(x) => s.serialize_f64(*x), + } + } +} + +impl<'de> Deserialize<'de> for Speed { + fn deserialize>(d: D) -> Result { + let v = Value::deserialize(d)?; + Ok(Speed::from_value(&v).unwrap_or_default()) + } +} + +/// The speed as a slider: 1× to 5× in half steps, and Instant at the far right. +pub fn speed_slider(ui: &mut egui::Ui, speed: &mut Speed) -> egui::Response { + let mut v = match *speed { + Speed::Times(x) => x.clamp(1.0, 5.0), + Speed::Instant => 5.5, + }; + let r = ui.add( + egui::Slider::new(&mut v, 1.0..=5.5).step_by(0.5).custom_formatter(|v, _| if v > 5.25 { "Instant".into() } else { Speed::Times(v).label() }), + ); + if r.changed() { + *speed = if v > 5.25 { Speed::Instant } else { Speed::Times(v) }; + } + r +} + /// The settings (kept with the other view settings). #[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] #[serde(default)] @@ -176,7 +227,7 @@ fn walk<'a>(v: &'a Value, key: Option<&'a str>, f: &mut dyn FnMut(Option<&'a str /// back when it should simply run. pub fn intercept(app: &mut SolveApp, method: &str, params: &Value, reply: &Sender) -> bool { let s = app.ui.agent_cursor; - if !s.show || s.speed == Speed::Instant || !matches!(method, "engine.execute" | "command") { + if !s.show || !matches!(method, "engine.execute" | "command") { return false; } if params.get("animate").and_then(Value::as_bool) == Some(false) { @@ -185,6 +236,14 @@ pub fn intercept(app: &mut SolveApp, method: &str, params: &Value, reply: &Sende let Some(cmd) = params.get("command").or(params.get("id")).and_then(Value::as_str) else { return false }; let inner = params.get("params").cloned().unwrap_or(json!({})); let stops: VecDeque = stops(app, cmd, &inner).into(); + if s.speed == Speed::Instant { + // No animation: the command runs now; the pointer jumps to where it acted. + if let Some(last) = stops.back() { + app.agent.pos = Some(*last); + app.agent.ripples.push((*last, app.now)); + } + return false; + } if stops.is_empty() { return false; } @@ -336,4 +395,25 @@ mod tests { // Off-screen places are skipped rather than pointed at outside the view. assert!(stops(&a, "solid.hole", &json!({"position": [1e6, 0, 0]})).iter().all(|p| rect.contains(*p))); } + + #[test] + fn speeds_read_old_and_new_settings() { + // Saved settings from before #79: slow and normal are 1×, instant stays. + for (v, want) in [ + (json!("slow"), Speed::Times(1.0)), + (json!("normal"), Speed::Times(1.0)), + (json!("instant"), Speed::Instant), + (json!(3), Speed::Times(3.0)), + ] { + assert_eq!(Speed::from_value(&v), Some(want), "{v}"); + let s: Settings = serde_json::from_value(json!({"show": true, "speed": v})).unwrap(); + assert_eq!(s.speed, want); + } + assert_eq!(Speed::from_value(&json!(9)), None); + assert_eq!(serde_json::to_value(Speed::Instant).unwrap(), json!("instant")); + assert_eq!(serde_json::to_value(Speed::Times(2.5)).unwrap(), json!(2.5)); + assert_eq!((Speed::Times(1.0).label(), Speed::Times(2.5).label(), Speed::Instant.label()), ("1×".into(), "2.5×".into(), "Instant".into())); + // Faster glides take less time. + assert!(Speed::Times(5.0).times().0 < Speed::Times(1.0).times().0); + } } diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 4b03fe61..2080aedd 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -27,7 +27,7 @@ //! - `ui.prefs {...}`: the preferences (default_units, nav, zoom_reverse, orbit_cursor, msaa, //! length_decimals, angle_decimals); returns them all //! - `ui.capture {action?: capture|revert|cancel}`: the Capture Position question -//! - `ui.agent_cursor {show?, speed?: instant|normal|slow, follow_camera?}`: the agent cursor +//! - `ui.agent_cursor {show?, speed?: 1–5 (times) | "instant", follow_camera?}`: the agent cursor //! (#35); while it is shown, `engine.execute` first glides to the command's button and picks //! and replies once the command has run (`"animate": false` skips that one call) //! - `ui.resize {width, height}`, `app.quit` @@ -107,9 +107,9 @@ pub fn handle(app: &mut SolveApp, ctx: &egui::Context, req: &ControlRequest) -> s.follow_camera = v; } if let Some(v) = p.get("speed") { - match serde_json::from_value(v.clone()) { - Ok(sp) => s.speed = sp, - Err(_) => return err("speed must be instant, normal or slow"), + match crate::agent_cursor::Speed::from_value(v) { + Some(sp) => s.speed = sp, + None => return err("speed must be a number from 1 to 5, or \"instant\""), } } ok(json!(app.ui.agent_cursor)) diff --git a/crates/ui-egui/src/prefs.rs b/crates/ui-egui/src/prefs.rs index 779d260b..4320ae44 100644 --- a/crates/ui-egui/src/prefs.rs +++ b/crates/ui-egui/src/prefs.rs @@ -507,11 +507,7 @@ fn page(app: &mut SolveApp, ui: &mut egui::Ui, t: &Tokens) { ui.end_row(); ui.label("Agent cursor speed"); ui.add_enabled_ui(s.show, |ui| { - ui.horizontal(|ui| { - for sp in crate::agent_cursor::Speed::ALL { - ui.selectable_value(&mut s.speed, sp, sp.label()); - } - }); + crate::agent_cursor::speed_slider(ui, &mut s.speed); }); ui.end_row(); ui.label(""); diff --git a/crates/ui-egui/src/scenario.rs b/crates/ui-egui/src/scenario.rs index 183c1c8a..fb267f6e 100644 --- a/crates/ui-egui/src/scenario.rs +++ b/crates/ui-egui/src/scenario.rs @@ -9,7 +9,7 @@ //! - `{"click": AT, "double"?, "shift"?, "ctrl"?, "button"?}`, `{"move": AT}`, //! `{"drag": [AT, AT], "shift"?, "ctrl"?, "steps"?}` where AT is `[x, y]` (screen), `{"world": [x,y,z]}` //! or `{"sketch": [x,y]}` (the sketch being edited) -//! - `{"queued": "engine.execute", "params": {…}, "min_frames"?: n}`: a request sent the way an +//! - `{"queued": "engine.execute", "params": {…}, "min_frames"?: n, "max_frames"?: n}`: a request sent the way an //! agent's arrive (through the app's request queue, answered between frames, so the agent //! cursor can act it out first); fails if the answer came in fewer than `min_frames` frames //! - `{"call": "ui.view", "params": {…}}`: any other control request (`fail: true` expects an @@ -375,6 +375,11 @@ impl Harness { if (frames as u64) < min { return Err(format!("{m} was answered after {frames} frames: the agent cursor did not act it out ({min})")); } + if let Some(max) = s.get("max_frames").and_then(Value::as_u64) + && frames as u64 > max + { + return Err(format!("{m} was answered after {frames} frames, more than {max}")); + } v } else if let Some(f) = s.get("script").and_then(Value::as_str) { // A command script (`{"commands": [{command, params}]}`, path from the repository diff --git a/crates/ui-egui/src/viewport.rs b/crates/ui-egui/src/viewport.rs index c9e0e892..e9eff948 100644 --- a/crates/ui-egui/src/viewport.rs +++ b/crates/ui-egui/src/viewport.rs @@ -2275,9 +2275,7 @@ fn view_menu(app: &mut SolveApp, ui: &mut egui::Ui, button: Rect) -> bool { ui.add_enabled_ui(s.show, |ui| { ui.horizontal(|ui| { ui.label("Speed"); - for sp in crate::agent_cursor::Speed::ALL { - ui.selectable_value(&mut s.speed, sp, sp.label()); - } + crate::agent_cursor::speed_slider(ui, &mut s.speed); }); ui.checkbox(&mut s.follow_camera, "Follow camera (Look At sketches)"); }); diff --git a/crates/ui-egui/tests/scenarios/agent_cursor.json b/crates/ui-egui/tests/scenarios/agent_cursor.json index 2d1380be..848fd953 100644 --- a/crates/ui-egui/tests/scenarios/agent_cursor.json +++ b/crates/ui-egui/tests/scenarios/agent_cursor.json @@ -1,17 +1,32 @@ [ - {"note": "#35: with the agent cursor on, an agent's commands are acted out (the reply waits for the pointer) and give the same result as with it off"}, - {"call": "ui.agent_cursor", "params": {"show": true, "speed": "normal", "follow_camera": true}}, + {"note": "#35, #79: with the agent cursor on, an agent's commands are acted out (the reply waits for the pointer) at 1x and 3x; at Instant they run at once. Each speed builds the same body as the cursor being off."}, + {"call": "ui.agent_cursor", "params": {"show": true, "speed": 1, "follow_camera": true}}, {"queued": "engine.execute", "params": {"command": "sketch.create", "params": {"plane": "XY"}}, "min_frames": 30}, {"queued": "engine.execute", "params": {"command": "sketch.rectangle.two_point", "params": {"p0": [0, 0], "p1": [40, 25]}}, "min_frames": 45}, {"queued": "engine.execute", "params": {"command": "sketch.finish", "params": {}}, "min_frames": 30}, {"queued": "engine.execute", "params": {"command": "solid.extrude", "params": {"distance": 10, "operation": "new"}}, "min_frames": 30}, {"expect": {"errors": 0, "bodies": 1, "volume": [10000, 1e-6]}}, + {"note": "3x: still acted out, about three times as fast"}, + {"call": "ui.agent_cursor", "params": {"speed": 3}}, + {"queued": "engine.execute", "params": {"command": "sketch.create", "params": {"plane": "XY"}}, "min_frames": 8, "max_frames": 45}, + {"queued": "engine.execute", "params": {"command": "sketch.rectangle.two_point", "params": {"p0": [0, 0], "p1": [40, 25]}}, "min_frames": 8}, + {"queued": "engine.execute", "params": {"command": "sketch.finish", "params": {}}, "min_frames": 8}, + {"queued": "engine.execute", "params": {"command": "solid.extrude", "params": {"distance": 10, "operation": "new"}}, "min_frames": 8}, + {"expect": {"errors": 0, "bodies": 2, "volume": [20000, 1e-6]}}, + {"note": "Instant: answered on the next frame, no animation"}, + {"call": "ui.agent_cursor", "params": {"speed": "instant"}}, + {"queued": "engine.execute", "params": {"command": "sketch.create", "params": {"plane": "XY"}}, "max_frames": 2}, + {"queued": "engine.execute", "params": {"command": "sketch.rectangle.two_point", "params": {"p0": [0, 0], "p1": [40, 25]}}, "max_frames": 2}, + {"queued": "engine.execute", "params": {"command": "sketch.finish", "params": {}}, "max_frames": 2}, + {"queued": "engine.execute", "params": {"command": "solid.extrude", "params": {"distance": 10, "operation": "new"}}, "max_frames": 2}, + {"expect": {"errors": 0, "bodies": 3, "volume": [30000, 1e-6]}}, {"note": "A call with animate: false runs at once"}, - {"queued": "engine.execute", "params": {"command": "sketch.create", "params": {"plane": "XY"}, "animate": false}}, + {"call": "ui.agent_cursor", "params": {"speed": 1}}, + {"queued": "engine.execute", "params": {"command": "sketch.create", "params": {"plane": "XY"}, "animate": false}, "max_frames": 2}, {"call": "ui.agent_cursor", "params": {"show": false}}, - {"note": "The same steps with the cursor off: a second, identical body"}, + {"note": "The same steps with the cursor off: a fourth, identical body"}, {"queued": "engine.execute", "params": {"command": "sketch.rectangle.two_point", "params": {"p0": [0, 0], "p1": [40, 25]}}}, {"queued": "engine.execute", "params": {"command": "sketch.finish", "params": {}}}, {"queued": "engine.execute", "params": {"command": "solid.extrude", "params": {"distance": 10, "operation": "new"}}}, - {"expect": {"errors": 0, "bodies": 2, "volume": [20000, 1e-6]}} + {"expect": {"errors": 0, "bodies": 4, "volume": [40000, 1e-6]}} ] diff --git a/docs/control-protocol.md b/docs/control-protocol.md index 32eef94b..d83556b9 100644 --- a/docs/control-protocol.md +++ b/docs/control-protocol.md @@ -52,7 +52,7 @@ Commands never open dialogs when run this way. `solvecraft-cli commands` prints | `ui.prefs` | any of `default_units`, `nav` (`fusion`, `solidworks`, `inventor`, `alias`, `tinkercad`), `zoom_reverse`, `zoom_to_cursor`, `orbit_cursor`, `look_at_anim`, `look_at_speed`, `msaa`, `length_decimals`, `angle_decimals`, `theme` (`dark`, `light`, `system`), `ui_scale`, `up_axis` (`z`, `y`), `offer_stand_up`; `open` (a page: General, Navigation, Keyboard Shortcuts, Design, Graphics, Sketch, Selection) opens the Preferences dialog there; `button`: `ok`, `cancel`, `apply`, `defaults`; `stand_up: true` stands up a just-opened Y-up file | sets preferences (kept between runs), returns them all plus `dialog` (the open page or null), `dark` and `stand_up_offer` | | `ui.capture` | `action?`: `capture`, `revert`, `cancel` | answers the Capture Position question (asked when a command starts or the design is saved while components were moved without capturing); without `action`, what waits | | `ui.window` | `action`: minimize, maximize, restore, toggle, close | what the title bar's caption buttons do (on Windows and Linux the application bar is the title bar); close asks about unsaved designs first | -| `ui.agent_cursor` | `show` (bool), `speed`: `instant`, `normal`, `slow`, `follow_camera` (bool); all optional | the settings. While the cursor is shown, `engine.execute` first glides to the command's toolbar button and to each pick, then runs and replies; later requests wait their turn. `"animate": false` on a call skips it | +| `ui.agent_cursor` | `show` (bool), `speed`: a number from 1 to 5 (times the normal speed) or `"instant"` (no animation: the command runs at once and the pointer jumps to where it acted), `follow_camera` (bool); all optional | the settings. While the cursor is shown, `engine.execute` first glides to the command's toolbar button and to each pick, then runs and replies; later requests wait their turn. `"animate": false` on a call skips it | | `ui.resize` | `width`, `height` | | | `app.quit` | `force?` (quit without asking about unsaved designs) | `quitting` (the window closes), `asking` (a "save changes?" prompt is open) |