From 5f59ffa4d394283f94b7fcb94f050c7c4c87a2dc Mon Sep 17 00:00:00 2001 From: Baptiste Parmantier Date: Wed, 12 Aug 2026 14:22:40 +0200 Subject: [PATCH] feat(cursor): add a step path easing so a caret jumps instead of gliding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cursor draws a caret — a rounded vertical bar — and cursor_style is metadata today: "default" and "pointer" render identically. But every path_easing it offered interpolated, so a caret walking between two text fields slid across the gap and spent most of its time outside any of them. A text caret never does that: it holds a position and jumps. `step` holds the departure waypoint for the whole segment; the move happens when time reaches the next waypoint and the segment changes. The interpolating easings are unchanged and stay the default — this is additive, for the case the component's own shape implies. SKILL.md documents the new value, and says plainly that the component draws a caret and that cursor_style does not change the drawing. --- .claude/skills/rustmotion/SKILL.md | 9 +++- crates/rustmotion-components/src/cursor.rs | 59 ++++++++++++++++++++++ 2 files changed, 66 insertions(+), 2 deletions(-) diff --git a/.claude/skills/rustmotion/SKILL.md b/.claude/skills/rustmotion/SKILL.md index d38149c7..0de2b219 100644 --- a/.claude/skills/rustmotion/SKILL.md +++ b/.claude/skills/rustmotion/SKILL.md @@ -1693,8 +1693,13 @@ Animated cursor with click effects, blinking, and path animation between waypoin | `click_at` | array | `[]` | Times to trigger click animation (seconds) | | `auto_path` | array | `[]` | Waypoints: `[{ "time", "x", "y" }]` | | `click_duration`| f32 | `0.3` | Click animation duration | -| `cursor_style` | string | `"default"` | Cursor appearance style | -| `path_easing` | string | `"ease_in_out"` | Path interpolation: `"linear"`, `"ease_out"`, `"ease_in_out"` | +| `cursor_style` | string | `"default"` | `"default"` or `"pointer"` — metadata only: both draw the same bar | +| `path_easing` | string | `"ease_in_out"` | Path interpolation: `"linear"`, `"ease_out"`, `"ease_in_out"`, `"step"` | + +> The component draws a **caret** (a rounded vertical bar), not an arrow. Staged as a +> text caret it should use `"path_easing": "step"`, which holds each waypoint and jumps +> to the next — a caret never slides between two fields. The interpolating easings are +> for a pointer travelling over a surface. **Notes:** When `auto_path` is set, click animations trigger automatically at each waypoint time. Cursor movement uses Catmull-Rom spline interpolation for smooth curves. diff --git a/crates/rustmotion-components/src/cursor.rs b/crates/rustmotion-components/src/cursor.rs index f97ec8cc..49681c4e 100644 --- a/crates/rustmotion-components/src/cursor.rs +++ b/crates/rustmotion-components/src/cursor.rs @@ -104,6 +104,10 @@ pub enum CursorPathEasing { EaseOut, #[default] EaseInOut, + /// No interpolation: hold each waypoint until the next one's time, then + /// jump. What a text caret does — it never slides between two positions — + /// and the only way to express that, since every other easing glides. + Step, } rustmotion_core::impl_traits!(Cursor { @@ -174,6 +178,9 @@ impl Cursor { // Apply easing let t = match self.path_easing { + // Hold the departure point for the whole segment; the jump happens + // when `time` reaches the next waypoint and the segment changes. + CursorPathEasing::Step => 0.0, CursorPathEasing::Linear => raw_t, CursorPathEasing::EaseOut => 1.0 - (1.0 - raw_t).powi(3), CursorPathEasing::EaseInOut => { @@ -287,3 +294,55 @@ impl Painter for Cursor { } } } + +#[cfg(test)] +mod tests { + use super::*; + + fn caret(easing: CursorPathEasing) -> Cursor { + serde_json::from_value(serde_json::json!({ + "path_easing": easing, + "click_duration": 0.0, + "auto_path": [ + {"time": 1.0, "x": 100.0, "y": 0.0}, + {"time": 2.0, "x": 500.0, "y": 0.0}, + ], + })) + .expect("cursor fixture") + } + + /// A caret jumps between positions; it must never be caught between two + /// fields. Every other easing interpolates, so `step` is the only way to + /// say that. + #[test] + fn step_easing_holds_the_departure_point_until_the_next_waypoint() { + let c = caret(CursorPathEasing::Step); + for t in [1.0, 1.25, 1.5, 1.75, 1.99] { + assert_eq!( + c.auto_path_offset(t), + (100.0, 0.0), + "step must hold the first waypoint at t={t}" + ); + } + assert_eq!(c.auto_path_offset(2.0), (500.0, 0.0)); + assert_eq!(c.auto_path_offset(9.0), (500.0, 0.0)); + } + + /// The other easings keep gliding — `step` is additive, not a change of + /// default behaviour. + #[test] + fn linear_easing_still_interpolates() { + let (x, _) = caret(CursorPathEasing::Linear).auto_path_offset(1.5); + assert!( + (x - 300.0).abs() < 0.5, + "linear should be halfway at t=1.5, got {x}" + ); + } + + #[test] + fn step_is_spelled_snake_case_in_json() { + let c: Cursor = serde_json::from_value(serde_json::json!({"path_easing": "step"})) + .expect("`step` must parse"); + assert_eq!(c.path_easing, CursorPathEasing::Step); + } +}