Skip to content

Commit d20fdfa

Browse files
gajopclaude
andcommitted
Fields: let RmlUi own the drag; Objects: let the user leave Add mode
The drag was hand-rolled on the engine's mouse callbacks, and it could not work: the engine hands mouse input to its RmlUi contexts *before* its event clients, so a press on the panel never reaches the plugin at all. We never become the engine's mouse owner, and no mouse_release is ever delivered for a field -- which is why a drag released outside the panel stayed latched to the mouse no matter how many times it was "fixed" (logging the callbacks showed the panel presses simply never arrive). RmlUi has drag support with pointer capture: `dragstart`/`dragend` arrive wherever the button comes up, on or off the element. The field opts in with `drag: drag` and is bracketed by those events. mousedown/mouseup now only decide whether a press was a *click* (which opens the inline editor). That deletes the guesswork: no drag threshold of our own, no force_drag_release, no reading the button state the engine does not have. Objects: - The Add/Brush buttons toggle, as Lua's action buttons do. There was no way out of Add mode: every click on the map kept placing, so a placed feature could never be selected or dragged. - A field change only re-arms placement if the value actually moved. RmlUi fires `change` for the values the editor writes back, and re-arming on those threw the user out of what they were doing -- a refresh mid-drag re-entered placement and previewed ghosts over the object being moved. Verified: a drag released out over the map commits and stops (asserted: nothing more is sent afterwards); toggling Add off makes the next map click select the feature rather than place a second one (one AddObjectCommand, not two). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent b17a03f commit d20fdfa

7 files changed

Lines changed: 166 additions & 108 deletions

File tree

native/src/sbc/panels/editors/object_defs.rs

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,9 @@ pub(crate) struct ObjectDefsView {
6969

7070
fields: FieldSet,
7171
mode: PlaceMode,
72+
/// Whether the map click places an object. The Add/Brush buttons toggle it;
73+
/// with it off the map behaves normally and objects can be selected.
74+
placing: bool,
7275
mode_clicks: Rc<RefCell<Vec<PlaceMode>>>,
7376
/// Team captions in the choice, paired with their ids.
7477
teams: Vec<(i32, String)>,
@@ -98,6 +101,8 @@ impl ObjectDefsView {
98101
fields
99102
}),
100103
mode: PlaceMode::Set,
104+
// Add is the mode the view opens in, and the button renders pressed.
105+
placing: true,
101106
mode_clicks: Rc::new(RefCell::new(Vec::new())),
102107
teams: Vec::new(),
103108
teams_revision: usize::MAX,
@@ -117,9 +122,18 @@ impl ObjectDefsView {
117122

118123
/// A field committed. A filter re-filters the grid; anything else is a
119124
/// placement setting, so re-arm placement with it.
125+
///
126+
/// Only if the value actually moved. RmlUi fires `change` for the values the
127+
/// editor writes back into the DOM, and re-arming on those dropped the user
128+
/// out of whatever they were doing -- a refresh mid-drag re-entered placement
129+
/// and started previewing ghosts over the object being moved.
120130
pub(crate) fn note_field_change(&mut self, name: &str, interface: &NativeInterfaceRef) {
121131
let base = resolve_base(name);
132+
let before = self.fields.value(base);
122133
self.fields.read(base, interface);
134+
if self.fields.value(base) == before {
135+
return;
136+
}
123137
if FILTER_FIELDS.contains(&base) {
124138
self.search_dirty = true;
125139
} else {
@@ -141,7 +155,13 @@ impl ObjectDefsView {
141155
"LuaUI/images/scenedit/object-brush-add.png",
142156
),
143157
] {
144-
let pressed = if mode == self.mode { " pressed" } else { "" };
158+
// Pressed only while placement is actually armed: toggling the mode
159+
// off must not leave the button looking active.
160+
let pressed = if self.placing && mode == self.mode {
161+
" pressed"
162+
} else {
163+
""
164+
};
145165
let id = if mode == PlaceMode::Set {
146166
"objectdef-mode-add"
147167
} else {
@@ -411,6 +431,8 @@ impl ObjectDefsView {
411431

412432
for id in self.grid.drain_clicks() {
413433
self.grid.set_selected(Some(&id));
434+
// Picking a definition is a request to place it.
435+
self.placing = true;
414436
self.request_dirty = true;
415437
self.grid.render(interface, document)?;
416438
}
@@ -425,6 +447,14 @@ impl ObjectDefsView {
425447
if mode != self.mode {
426448
self.mode = mode;
427449
self.needs_rebuild = true;
450+
self.placing = true;
451+
} else {
452+
// Clicking the active button leaves placement, as Lua's action
453+
// buttons toggle. Without this there is no way out of Add mode:
454+
// every click on the map keeps placing, and objects can never be
455+
// selected or dragged.
456+
self.placing = !self.placing;
457+
self.needs_rebuild = true;
428458
}
429459
self.request_dirty = true;
430460
}
@@ -477,6 +507,9 @@ impl ObjectDefsView {
477507
if !std::mem::take(&mut self.request_dirty) {
478508
return None;
479509
}
510+
if !self.placing {
511+
return Some(StateRequest::Default);
512+
}
480513
let Some(def) = self.grid.selected().map(str::to_string) else {
481514
return Some(StateRequest::Default);
482515
};

native/src/sbc/panels/field.rs

Lines changed: 36 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ pub(crate) fn new_interaction_queue() -> InteractionQueue {
2828
pub enum InteractionEvent {
2929
PointerDown { field: String },
3030
PointerUp { field: String },
31+
/// RmlUi's drag, which captures the pointer: `DragEnd` arrives wherever the
32+
/// button comes up, including outside the panel.
33+
DragStart { field: String },
34+
DragEnd { field: String },
3135
}
3236

3337
#[derive(Debug, Clone, PartialEq)]
@@ -119,30 +123,44 @@ pub(crate) fn on_enter(
119123
Ok(())
120124
}
121125

122-
/// Register mousedown + mouseup listeners for drag support. Pushes
123-
/// `InteractionEvent`s into the interaction queue.
126+
/// Listen for the events a field drag is made of.
127+
///
128+
/// The drag is bracketed by RmlUi's own `dragstart`/`dragend`, not by the
129+
/// engine's mouse callbacks. The engine hands mouse input to its RmlUi contexts
130+
/// *before* its event clients, and RmlUi consumes a press over the panel -- so
131+
/// the plugin's `mouse_press` is never called for a click on a field, never
132+
/// becomes the engine's mouse owner, and never receives `mouse_release`. A drag
133+
/// released outside the panel could not be ended at all.
134+
///
135+
/// RmlUi captures the pointer for a drag, so `dragend` arrives wherever the
136+
/// button comes up, on or off the element. That is the signal to use. (The
137+
/// element must opt in with `drag: drag` in RCSS.)
138+
///
139+
/// `mousedown`/`mouseup` still bracket a *click*, which is what opens the inline
140+
/// editor when the pointer never moved far enough to become a drag.
124141
pub(crate) fn on_pointer(
125142
interface: &NativeInterfaceRef,
126143
element: u64,
127144
name: String,
128145
interactions: &InteractionQueue,
129146
) -> Result<(), Error> {
130-
let iq = interactions.clone();
131-
let n = name.clone();
132-
interface
133-
.rml_ui()
134-
.element_add_event_listener(element, "mousedown", false, move || {
135-
iq.borrow_mut()
136-
.push(InteractionEvent::PointerDown { field: n.clone() });
137-
})?;
138-
let iq2 = interactions.clone();
139-
let n2 = name.clone();
140-
interface
141-
.rml_ui()
142-
.element_add_event_listener(element, "mouseup", false, move || {
143-
iq2.borrow_mut()
144-
.push(InteractionEvent::PointerUp { field: n2.clone() });
145-
})?;
147+
for (event, make) in [
148+
(
149+
"mousedown",
150+
(|field| InteractionEvent::PointerDown { field }) as fn(String) -> InteractionEvent,
151+
),
152+
("mouseup", |field| InteractionEvent::PointerUp { field }),
153+
("dragstart", |field| InteractionEvent::DragStart { field }),
154+
("dragend", |field| InteractionEvent::DragEnd { field }),
155+
] {
156+
let queue = interactions.clone();
157+
let field = name.clone();
158+
interface
159+
.rml_ui()
160+
.element_add_event_listener(element, event, false, move || {
161+
queue.borrow_mut().push(make(field.clone()));
162+
})?;
163+
}
146164
Ok(())
147165
}
148166

native/src/sbc/panels/input.rs

Lines changed: 34 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -4,40 +4,31 @@ use crate::sbc::panels::editor::Editor;
44
use crate::sbc::panels::field::{ChangeQueue, CommitRequest, InteractionEvent, InteractionQueue};
55
use crate::sbc::panels::view::PanelView;
66

7-
const DRAG_THRESHOLD: f32 = 3.0;
87
const FINE_DRAG_MULT: f32 = 0.1;
98
/// Mask bit for Shift in `get_mod_key_state`.
109
const SHIFT_BIT: u32 = 1;
1110

12-
/// Three-state drag tracker.
11+
/// Which field the pointer is on, and whether RmlUi has called it a drag yet.
1312
enum DragState {
1413
Idle,
15-
/// Mouse pressed but threshold not yet exceeded.
16-
Pending {
17-
field: String,
18-
start_x: f32,
19-
},
20-
/// Dragging is active; value updates every mouse-move.
21-
Dragging {
22-
field: String,
23-
},
14+
/// Pressed. Becomes a drag if RmlUi says so, a click if it does not.
15+
Pending { field: String },
16+
/// RmlUi is dragging: the value follows the cursor until `dragend`.
17+
Dragging { field: String },
2418
}
2519

2620
/// Action to perform after processing interaction events.
2721
pub(crate) enum PendingAction {
22+
DragStart(String),
2823
DragEnd(String),
2924
ClickEdit(String),
3025
}
3126

3227
/// What one `tick_drag` did.
3328
pub(crate) enum DragTick {
3429
Idle,
35-
/// A drag just crossed the threshold; the field still holds its old value.
36-
Started(String),
3730
/// The field's value moved and should be previewed.
3831
Moved(String),
39-
/// The button came up while the drag was live; finish and commit it.
40-
Released(String),
4132
}
4233

4334
/// Handles all engine input callbacks (mouse, keyboard, text) for the panel.
@@ -103,27 +94,8 @@ impl PanelInput {
10394
};
10495
let x = mouse.x;
10596

106-
// A drag is *not* ended from `mouse.left`. The panel consumes the press,
107-
// so the engine never registers the button as held and `left` reads
108-
// false for the whole drag -- ending it here killed the drag on the tick
109-
// it started, before it could move.
110-
//
111-
// The release arrives either as RmlUi's `mouseup` on the field, or (when
112-
// the cursor left the field) as the engine's mouse-release callback,
113-
// which the manager turns into `force_drag_release`.
114-
if let DragState::Pending { field, start_x } = &self.drag {
115-
if (x - *start_x).abs() > DRAG_THRESHOLD {
116-
let field = field.clone();
117-
self.last_mouse_x = *start_x;
118-
self.drag = DragState::Dragging {
119-
field: field.clone(),
120-
};
121-
// Report the start before moving: the manager has to record the
122-
// value the drag began from, so undo can return to it.
123-
return DragTick::Started(field);
124-
}
125-
}
126-
97+
// Starting and ending a drag is RmlUi's business (`dragstart`/`dragend`,
98+
// see `on_pointer`); this only moves the value while one is live.
12799
let DragState::Dragging { field } = &self.drag else {
128100
return DragTick::Idle;
129101
};
@@ -157,35 +129,43 @@ impl PanelInput {
157129
}
158130
}
159131

132+
/// Turn the field's pointer events into drag/click actions.
133+
///
134+
/// RmlUi decides what is a drag: it captures the pointer on `dragstart` and
135+
/// delivers `dragend` wherever the button is released, even off the panel.
136+
/// A press that never became a drag is a click, and opens the inline editor.
160137
pub(crate) fn process_interactions(&mut self) -> Vec<PendingAction> {
161138
let events: Vec<InteractionEvent> = self.interactions.borrow_mut().drain(..).collect();
162139
let mut actions = Vec::new();
163140
for event in events {
164141
match event {
165142
InteractionEvent::PointerDown { field } => {
166-
self.drag = DragState::Pending {
167-
field,
168-
start_x: self.cursor_x,
169-
};
143+
self.drag = DragState::Pending { field };
144+
self.last_mouse_x = self.cursor_x;
145+
}
146+
InteractionEvent::DragStart { field } => {
170147
self.last_mouse_x = self.cursor_x;
148+
self.drag = DragState::Dragging {
149+
field: field.clone(),
150+
};
151+
// The manager records the value the drag began from, so undo
152+
// can return to it.
153+
actions.push(PendingAction::DragStart(field));
154+
}
155+
InteractionEvent::DragEnd { field } => {
156+
self.drag = DragState::Idle;
157+
actions.push(PendingAction::DragEnd(field));
171158
}
172159
InteractionEvent::PointerUp { field } => {
173-
// A drag ends wherever the button comes up. The cursor has
174-
// usually left the field by then, so the `mouseup` RmlUi
175-
// delivers names a *different* element -- requiring it to
176-
// match the dragged field left the drag stuck, and never
177-
// committed.
178-
let dragged = match std::mem::replace(&mut self.drag, DragState::Idle) {
179-
DragState::Dragging { field } => Some(field),
180-
DragState::Pending { field: pending, .. } if pending == field => {
181-
// Never crossed the threshold: it was a click.
160+
// Only a press that never became a drag is a click. RmlUi
161+
// sends `mouseup` after `dragend` too, and that must not
162+
// reopen the editor on the field just dragged.
163+
if let DragState::Pending { field: pending } =
164+
std::mem::replace(&mut self.drag, DragState::Idle)
165+
{
166+
if pending == field {
182167
actions.push(PendingAction::ClickEdit(pending));
183-
None
184168
}
185-
_ => None,
186-
};
187-
if let Some(dragged) = dragged {
188-
actions.push(PendingAction::DragEnd(dragged));
189169
}
190170
}
191171
}
@@ -198,18 +178,6 @@ impl PanelInput {
198178
self.changes.borrow_mut().drain(..).collect()
199179
}
200180

201-
/// End an active drag from the engine mouse-release callback. RmlUi only
202-
/// sends the field's `mouseup` when the pointer is still over that element;
203-
/// releasing outside must still finish the drag.
204-
pub(crate) fn force_drag_release(&mut self) -> Option<String> {
205-
let field = match &self.drag {
206-
DragState::Dragging { field } => Some(field.clone()),
207-
DragState::Pending { .. } | DragState::Idle => None,
208-
};
209-
self.drag = DragState::Idle;
210-
field
211-
}
212-
213181
fn drag_field(&self) -> Option<&str> {
214182
match &self.drag {
215183
DragState::Pending { field, .. } | DragState::Dragging { field } => Some(field),

native/src/sbc/panels/manager.rs

Lines changed: 13 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -151,9 +151,15 @@ impl PanelManager {
151151

152152
self.input.set_cursor(&self.interface);
153153

154-
// Pointer interactions (pointer down/up → drag or click-to-edit)
154+
// Pointer interactions: RmlUi's drag, or a click that opens the editor.
155155
for action in self.input.process_interactions() {
156156
match action {
157+
PendingAction::DragStart(field) => {
158+
// Remember what the drag began from, so undo returns to it.
159+
if let Some(ed) = self.editor.as_deref() {
160+
self.drag_original = Some((field.clone(), ed.field_value(&field)));
161+
}
162+
}
157163
PendingAction::DragEnd(field) => {
158164
if let Some(ed) = self.editor.as_deref_mut() {
159165
ed.drag_end_field(&field, &self.interface);
@@ -189,23 +195,12 @@ impl PanelManager {
189195

190196
// Advance an in-progress drag from the polled cursor. Each step previews
191197
// on the engine, so a dragged number is visible before the mouse is
192-
// released; the undoable command lands on release.
198+
// released; the undoable command lands on `dragend`.
193199
match self
194200
.input
195201
.tick_drag(&self.interface, self.editor.as_deref_mut())
196202
{
197-
DragTick::Started(field) => {
198-
if let Some(ed) = self.editor.as_deref() {
199-
self.drag_original = Some((field.clone(), ed.field_value(&field)));
200-
}
201-
}
202203
DragTick::Moved(field) => self.preview_field(&field),
203-
DragTick::Released(field) => {
204-
if let Some(ed) = self.editor.as_deref_mut() {
205-
ed.drag_end_field(&field, &self.interface);
206-
}
207-
self.commit_drag(&field);
208-
}
209204
DragTick::Idle => {}
210205
}
211206

@@ -722,19 +717,16 @@ impl PanelManager {
722717
.mouse_press(&self.interface, &self.view, x, y, button)
723718
}
724719

720+
/// The engine hands mouse input to its RmlUi contexts before its event
721+
/// clients, so a press over the panel never reaches here -- the panel never
722+
/// becomes the engine's mouse owner and no release is delivered for it. A
723+
/// field drag is therefore ended by RmlUi's own `dragend`, not from here.
725724
pub fn mouse_release(&mut self, x: i32, y: i32, button: i32) -> Result<(), Error> {
726725
if !self.enabled {
727726
return Ok(());
728727
}
729728
self.input
730-
.mouse_release(&self.interface, &self.view, x, y, button)?;
731-
if let Some(field) = self.input.force_drag_release() {
732-
if let Some(ed) = self.editor.as_deref_mut() {
733-
ed.drag_end_field(&field, &self.interface);
734-
}
735-
self.commit_drag(&field);
736-
}
737-
Ok(())
729+
.mouse_release(&self.interface, &self.view, x, y, button)
738730
}
739731

740732
pub fn mouse_wheel(&mut self, up: bool, value: f32) -> Result<bool, Error> {

native/src/sbc/panels/ui.rcss

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -350,6 +350,11 @@ body {
350350
.field-numeric-button {
351351
height: 31px;
352352
padding: 0px 8px;
353+
/* Opt into RmlUi's drag: it captures the pointer, so `dragend` arrives
354+
wherever the button is released -- including outside the panel, where the
355+
engine never tells us anything (its RmlUi consumes the press before the
356+
event clients, so no mouse_release is ever delivered for a field). */
357+
drag: drag;
353358
}
354359

355360
.field-numeric-button:hover {

0 commit comments

Comments
 (0)