Skip to content

Commit 6a03e87

Browse files
gajopclaude
andcommitted
Numeric drag: pin and hide the pointer, as the Chili version does
Dragging a value now behaves like a content-creation tool: the pointer is swapped for the empty cursor and warped back to where the drag began after every move, so the value can be pushed as far as the user likes and the cursor never runs off the field, the panel, or the screen. It is restored to the anchor when the drag ends. This is what NumericField:__StartDragging/__StopDragging already did in Lua (SB.SetMouseCursor("empty") + Spring.WarpMouse), and the bindings for it were already there -- so no engine change was needed after all. The cursor has to be re-asserted every tick: the engine syncs the cursor to whatever RmlUi is hovering on each frame, so setting it once at dragstart is undone immediately. The harness grew `move_relative`, because a pinned pointer consumes *relative* motion; absolute `mousemove` fights the warp (the pointer is put back on its anchor, and the next absolute move re-applies the whole offset from it). Objects -> Collision gets its own scenario, split out of props so both stay quick and focused. It checks the thing the editor is for: with the debug volume shown, scaling an axis and changing the volume type must visibly redraw the shape on the object -- asserted by pixel diff, not just by the command. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent d20fdfa commit 6a03e87

7 files changed

Lines changed: 197 additions & 36 deletions

File tree

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
//! The pointer while a numeric field is being dragged.
2+
//!
3+
//! A port of what `NumericField:__StartDragging` / `__StopDragging` do in Lua:
4+
//! swap the cursor for an empty one and pin the pointer to where the drag began,
5+
//! warping it back after every move. The value then follows the mouse for as far
6+
//! as the user cares to push it, and the pointer never runs off the field, off
7+
//! the panel, or off the screen -- the way every content-creation tool behaves.
8+
//!
9+
//! The pointer is restored to the anchor when the drag ends, so the cursor is
10+
//! exactly where the user left it.
11+
12+
use spring_native::prelude::NativeInterfaceRef;
13+
14+
/// Lua assigns a cursor named "empty" and selects it; the name is the asset.
15+
const EMPTY_CURSOR: &str = "empty";
16+
17+
#[derive(Default)]
18+
pub(crate) struct DragCursor {
19+
/// Where the drag began, in engine mouse coordinates. The pointer is warped
20+
/// back here every tick, so it never actually moves.
21+
anchor: Option<(i32, i32)>,
22+
assigned: bool,
23+
}
24+
25+
impl DragCursor {
26+
/// Where the pointer is pinned. The drag's delta is measured from here,
27+
/// because the pointer is put back on it after every move.
28+
pub(crate) fn anchor_x(&self) -> Option<f32> {
29+
self.anchor.map(|(x, _)| x as f32)
30+
}
31+
32+
pub(crate) fn begin(&mut self, interface: &NativeInterfaceRef) {
33+
let Ok(mouse) = interface.input().get_mouse_state() else {
34+
return;
35+
};
36+
self.anchor = Some((mouse.x as i32, mouse.y as i32));
37+
38+
let ctrl = interface.unsynced_ctrl();
39+
if !self.assigned {
40+
// Lua does the same on first use: the cursor has to exist before it
41+
// can be selected.
42+
let _ = ctrl.assign_mouse_cursor(EMPTY_CURSOR, EMPTY_CURSOR, true, true);
43+
self.assigned = true;
44+
}
45+
let _ = ctrl.set_mouse_cursor(EMPTY_CURSOR, 1.0);
46+
}
47+
48+
/// Pin the pointer back to the anchor. Called after the value has taken the
49+
/// movement, so the motion is consumed rather than lost.
50+
pub(crate) fn hold(&self, interface: &NativeInterfaceRef) {
51+
let Some((x, y)) = self.anchor else {
52+
return;
53+
};
54+
let ctrl = interface.unsynced_ctrl();
55+
let _ = ctrl.warp_mouse(x, y);
56+
// Re-assert it every tick: the engine syncs the cursor to whatever RmlUi
57+
// is hovering on each update, so setting it once at dragstart is undone
58+
// on the very next frame.
59+
let _ = ctrl.set_mouse_cursor(EMPTY_CURSOR, 1.0);
60+
}
61+
62+
pub(crate) fn end(&mut self, interface: &NativeInterfaceRef) {
63+
let ctrl = interface.unsynced_ctrl();
64+
if let Some((x, y)) = self.anchor.take() {
65+
let _ = ctrl.warp_mouse(x, y);
66+
}
67+
// An empty name restores the engine's own cursor, as Lua's bare
68+
// `SB.SetMouseCursor()` does.
69+
let _ = ctrl.set_mouse_cursor("", 1.0);
70+
}
71+
}

native/src/sbc/panels/input.rs

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
use spring_native::prelude::{Error, NativeInterfaceRef};
22

3+
use crate::sbc::panels::drag_cursor::DragCursor;
34
use crate::sbc::panels::editor::Editor;
45
use crate::sbc::panels::field::{ChangeQueue, CommitRequest, InteractionEvent, InteractionQueue};
56
use crate::sbc::panels::view::PanelView;
@@ -41,6 +42,8 @@ pub(crate) struct PanelInput {
4142
cursor_x: f32,
4243
changes: ChangeQueue,
4344
interactions: InteractionQueue,
45+
/// Pins and hides the pointer while a field is being dragged.
46+
cursor: DragCursor,
4447
}
4548

4649
impl PanelInput {
@@ -53,6 +56,7 @@ impl PanelInput {
5356
cursor_x: 0.0,
5457
changes,
5558
interactions,
59+
cursor: DragCursor::default(),
5660
}
5761
}
5862

@@ -99,21 +103,30 @@ impl PanelInput {
99103
let DragState::Dragging { field } = &self.drag else {
100104
return DragTick::Idle;
101105
};
102-
let dx = x - self.last_mouse_x;
103-
if dx == 0.0 {
106+
// The pointer is pinned to where the drag began and warped back after
107+
// every move, so the movement to consume is its distance from that
108+
// anchor -- not from wherever it was last tick.
109+
let Some(anchor_x) = self.cursor.anchor_x() else {
104110
return DragTick::Idle;
105-
}
106-
self.last_mouse_x = x;
111+
};
112+
let dx = x - anchor_x;
113+
let field = field.clone();
107114
let mult = if self.fine_drag_multiplier(interface) {
108115
FINE_DRAG_MULT
109116
} else {
110117
1.0
111118
};
112-
let field = field.clone();
113-
let Some(ed) = editor else {
114-
return DragTick::Idle;
119+
120+
let moved = match editor {
121+
Some(ed) if dx != 0.0 => ed.drag_field(&field, dx * mult, interface),
122+
_ => false,
115123
};
116-
if ed.drag_field(&field, dx * mult, interface) {
124+
// Hold the pointer every tick of the drag, not only when it moved: the
125+
// engine re-asserts its own cursor each frame, so a still mouse would
126+
// get the arrow back.
127+
self.cursor.hold(interface);
128+
129+
if moved {
117130
DragTick::Moved(field)
118131
} else {
119132
DragTick::Idle
@@ -134,7 +147,10 @@ impl PanelInput {
134147
/// RmlUi decides what is a drag: it captures the pointer on `dragstart` and
135148
/// delivers `dragend` wherever the button is released, even off the panel.
136149
/// A press that never became a drag is a click, and opens the inline editor.
137-
pub(crate) fn process_interactions(&mut self) -> Vec<PendingAction> {
150+
pub(crate) fn process_interactions(
151+
&mut self,
152+
interface: &NativeInterfaceRef,
153+
) -> Vec<PendingAction> {
138154
let events: Vec<InteractionEvent> = self.interactions.borrow_mut().drain(..).collect();
139155
let mut actions = Vec::new();
140156
for event in events {
@@ -144,7 +160,8 @@ impl PanelInput {
144160
self.last_mouse_x = self.cursor_x;
145161
}
146162
InteractionEvent::DragStart { field } => {
147-
self.last_mouse_x = self.cursor_x;
163+
// Pin and hide the pointer for the length of the drag.
164+
self.cursor.begin(interface);
148165
self.drag = DragState::Dragging {
149166
field: field.clone(),
150167
};
@@ -153,6 +170,7 @@ impl PanelInput {
153170
actions.push(PendingAction::DragStart(field));
154171
}
155172
InteractionEvent::DragEnd { field } => {
173+
self.cursor.end(interface);
156174
self.drag = DragState::Idle;
157175
actions.push(PendingAction::DragEnd(field));
158176
}

native/src/sbc/panels/manager.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -152,7 +152,7 @@ impl PanelManager {
152152
self.input.set_cursor(&self.interface);
153153

154154
// Pointer interactions: RmlUi's drag, or a click that opens the editor.
155-
for action in self.input.process_interactions() {
155+
for action in self.input.process_interactions(&self.interface) {
156156
match action {
157157
PendingAction::DragStart(field) => {
158158
// Remember what the drag began from, so undo returns to it.

native/src/sbc/panels/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
mod asset_picker;
22
mod color_picker;
3+
mod drag_cursor;
34
mod editor;
45
mod editor_base;
56
mod editors;

tools/e2e/cases.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
"info-panel",
1616
"settings-panel",
1717
"props-panel",
18+
"collision",
1819
"selection",
1920
"cursortip",
2021
"notifications",
@@ -211,6 +212,14 @@ def target_cases(target: str, case: str) -> list[Case]:
211212
),
212213
]
213214
return cases
215+
if target == "collision":
216+
return [
217+
Case(
218+
name="collision-rust",
219+
flags={"chonsole": "rust", "ui": "rust"},
220+
scenario="collision",
221+
),
222+
]
214223
if target == "selection":
215224
return [
216225
Case(

tools/e2e/runner.py

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,27 @@ def move(self, x: int, y: int, delay: float = 0.08) -> None:
275275
run("xdotool", "mousemove", "--window", self.window, str(x), str(y))
276276
time.sleep(delay)
277277

278+
def move_relative(self, dx: int, dy: int = 0, steps: int = 6, delay: float = 0.06) -> None:
279+
"""Move the mouse *by* an offset, the way a real mouse reports motion.
280+
281+
A field drag pins the pointer and warps it back after every move, so what
282+
it consumes is relative motion. Absolute `mousemove` fights that: the
283+
pointer is put back on its anchor and the next absolute move re-applies
284+
the whole offset from it.
285+
"""
286+
self.require_window()
287+
self.event("move_relative", dx=dx, dy=dy, steps=steps)
288+
for _ in range(steps):
289+
run(
290+
"xdotool",
291+
"mousemove_relative",
292+
"--sync",
293+
"--",
294+
str(round(dx / steps)),
295+
str(round(dy / steps)),
296+
)
297+
time.sleep(delay)
298+
278299
def wheel(self, x: int, y: int, clicks: int = 1, up: bool = True, delay: float = 0.25) -> None:
279300
"""Scroll the wheel over a point. Over the map this zooms the camera,
280301
which is the only way to get close enough to *see* what a scenario placed

tools/e2e/scenarios/objects.py

Lines changed: 66 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -157,29 +157,33 @@ def props_panel(run_state: E2ERun) -> None:
157157
run_state.assert_screenshot_pixels(before, after, min_changed=400)
158158

159159
# Dragging a numeric field changes it without ever entering text mode, and
160-
# commits once on release.
161-
run_state.drag(left + 58, 223, left + 178, 223, steps=8)
160+
# commits once on release. The drag pins the pointer and warps it back after
161+
# every move (as content-creation tools do), so the motion has to be
162+
# relative -- absolute moves would fight the warp.
163+
run_state.press(left + 58, 223)
164+
run_state.move_relative(120)
165+
# Mid-drag: the pointer is pinned to where the drag began and drawn as the
166+
# empty cursor, so nothing follows the mouse across the panel.
167+
run_state.screenshot("props-pos-dragging")
168+
run_state.release(left + 58, 223)
162169
run_state.screenshot("props-pos-dragged")
163170
run_state.assert_any_command(
164171
"SetObjectParamCommand",
165172
key="pos",
166173
value=lambda v: isinstance(v, dict) and v.get("x", 0) > 1500.5,
167174
)
168175

169-
# Releasing a drag *outside* the panel must still end it. RmlUi never sees
170-
# that mouseup, so the drag can only be ended by the engine's release
171-
# callback -- and if that never arrives the field stays latched to the mouse
172-
# forever, which is the bug this pins down.
173-
# This drag ends up left of where it started, so the value comes down; the
174-
# previous one left it at ~1620, so only this drag's commit can be below.
176+
# Releasing a drag with the pointer far outside the panel must still end it:
177+
# RmlUi's drag capture delivers `dragend` wherever the button comes up, and
178+
# nothing else can (the engine never hands the plugin a release for a press
179+
# the panel's RmlUi consumed).
175180
run_state.press(left + 58, 223)
176-
run_state.move(left + 150, 223, delay=0.15)
177-
run_state.move(left - 300, 500, delay=0.2) # out over the map
181+
run_state.move_relative(-400, 260) # out over the map
178182
run_state.release(left - 300, 500)
179183
dragged = run_state.assert_any_command(
180184
"SetObjectParamCommand",
181185
key="pos",
182-
value=lambda v: isinstance(v, dict) and v.get("x", 9999) < 1400.0,
186+
value=lambda v: isinstance(v, dict) and v.get("x", 9999) < 1500.0,
183187
)
184188
# Now moving the mouse must not keep changing it: the drag is over.
185189
run_state.move(left - 600, 500, delay=0.4)
@@ -196,29 +200,65 @@ def props_panel(run_state: E2ERun) -> None:
196200
value=lambda v: isinstance(v, dict),
197201
)
198202

203+
# Collision has its own scenario (`collision`), where the volume it edits can
204+
# actually be seen.
205+
#
206+
# Last, because it deselects: clicking empty ground with an object editor
207+
# open used to abort the engine -- the panel wrote through element handles
208+
# RmlUi had already destroyed. It must survive, and clear the selection.
209+
for dx, dy in ((-300, -150), (250, 120), (-120, 260), (380, -220)):
210+
run_state.click(spot_x + dx, spot_y + dy, delay=0.4)
211+
run_state.screenshot("props-after-map-clicks")
212+
run_state.assert_running()
213+
214+
215+
def collision(run_state: E2ERun) -> None:
216+
"""Objects -> Collision, on its own so it is quick and focused.
217+
218+
The point of the editor is the *volume*, so the volume is what gets checked:
219+
turn on the debug rendering and prove that each edit visibly changes the
220+
shape drawn on the object. A command reaching the bridge would not tell us
221+
the volume actually moved.
222+
"""
223+
run_state.focus()
224+
left = _open(run_state, 110) # Features
225+
_arm_tree(run_state, left)
226+
227+
width, height = window_size(run_state)
228+
spot_x, spot_y = width // 3, height // 2
229+
run_state.wheel(spot_x, spot_y, clicks=8, up=True)
230+
run_state.click(spot_x, spot_y, delay=0.8) # place
231+
run_state.click(left + 54, ACTION_Y, delay=0.6) # leave Add mode
232+
run_state.click(spot_x, spot_y, delay=0.8) # select it
233+
199234
run_state.click(left + 270, EDITOR_BUTTON_Y, delay=0.9) # Collision
200-
run_state.screenshot("collision-open")
235+
hidden = run_state.screenshot_root("volume-hidden")
236+
237+
# Show volume: the collision shape is drawn over the object.
238+
run_state.click(left + 110, 190, delay=0.8)
239+
shown = run_state.screenshot_root("volume-shown")
240+
run_state.assert_screenshot_pixels(hidden, shown, min_changed=300)
201241

202-
# The collision volume is its own sub-object: editing a scale axis must send
203-
# the whole volume table, and the field must hold the new value after.
204-
run_state.click(left + 60, 339, delay=0.4) # Scale X
242+
# Scaling the volume must redraw it bigger, not merely emit a command.
243+
run_state.click(left + 60, 339, delay=0.4) # Scale X
205244
run_state.key("ctrl+a", delay=0.15)
206-
run_state.type_text("45")
207-
run_state.key("Return", delay=0.8)
208-
run_state.screenshot("collision-scale-edited")
245+
run_state.type_text("120")
246+
run_state.key("Return", delay=0.9)
247+
scaled = run_state.screenshot_root("volume-scaled")
209248
run_state.assert_any_command(
210249
"SetObjectParamCommand",
211250
key="collision",
212251
value=lambda v: isinstance(v, dict),
213252
)
253+
run_state.assert_screenshot_pixels(shown, scaled, min_changed=300)
214254

215-
# Last, because it deselects: clicking empty ground with an object editor
216-
# open used to abort the engine -- the panel wrote through element handles
217-
# RmlUi had already destroyed. It must survive, and clear the selection.
218-
for dx, dy in ((-300, -150), (250, 120), (-120, 260), (380, -220)):
219-
run_state.click(spot_x + dx, spot_y + dy, delay=0.4)
220-
run_state.screenshot("props-after-map-clicks")
221-
run_state.assert_running()
255+
# A different volume type is a different shape on screen.
256+
run_state.click(left + 200, 231, delay=0.4) # Type
257+
run_state.key("Down", delay=0.2)
258+
run_state.key("Return", delay=0.9)
259+
typed = run_state.screenshot_root("volume-type-changed")
260+
run_state.assert_screenshot_pixels(scaled, typed, min_changed=200)
261+
run_state.screenshot("collision-fields")
222262

223263

224264
def cursortip(run_state: E2ERun) -> None:
@@ -289,6 +329,7 @@ def selection(run_state: E2ERun) -> None:
289329
SCENARIOS = {
290330
"units_panel": units_panel,
291331
"props_panel": props_panel,
332+
"collision": collision,
292333
"selection": selection,
293334
"cursortip": cursortip,
294335
}

0 commit comments

Comments
 (0)