feat(pan): interactive PAN mode with left-drag (no middle button needed)

The PAN command/tool dispatched a command nobody handled, so it did
nothing. Combined with no middle mouse button on a trackpad/web client,
there was no way to pan at all (issue 164).

PAN (and the "P" alias) now enters a pan mode: a left-button drag pans the
view, reusing the existing middle-button pan path. Exit with Esc or by
starting another command. While active the crosshair is hidden and the
cursor becomes a hand (open when hovering, closed while dragging). PAN/P
are also added to command-line autocomplete.

Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
Hakan SevenandClaude Opus 4.8 committed 2026-06-25 10:03:54 +03:00
1 parent efade01896
commit 69ea050124
5 files changed
+67 -2

No files matched your search

+13 -1
View File
@@ -29,6 +29,9 @@ impl OpenCADStudio {
self.tabs[i].scene.clear_preview_wire();
self.tabs[i].active_cmd = None;
}
// Starting any command leaves interactive PAN mode (the PAN arm below
// re-enables it).
self.tabs[i].pan_mode = false;
// Reset the last committed point so the first click of the new command
// is not constrained by ortho/polar relative to a previous command's endpoint.
self.last_point = None;
@@ -5148,6 +5151,14 @@ impl OpenCADStudio {
self.command_line.push_output("Display regenerated.");
}
// Interactive pan: left-drag pans the view until Esc. The only pan
// path when there is no middle mouse button (trackpad / web).
"PAN" | "P" => {
self.tabs[i].pan_mode = true;
self.command_line
.push_output("PAN: drag with the left mouse button. Press Esc to exit.");
}
// ── TABLE cell editing ─────────────────────────────────────────────
// TABLE CELL <row> <col> <text> — set text for a cell in the selected Table
cmd if cmd.starts_with("TABLE ") => {
@@ -5972,7 +5983,8 @@ inventory::submit!(crate::command::CommandRegistration {
"LAYER", "LAYERS", "LAYISO", "LAYON", "LAYOUTMANAGER", "LAYOUTPANEL", "LAYOUTTAB", "LAYTHW",
"LAYUNISO", "LI", "LINETYPE", "LIST", "LTSCALE", "LWDISPLAY", "MASSPROP", "MLEADERSTYLE",
"MLSTYLE", "MS", "MSPACE", "NAVVCUBE", "NEW", "OBJIMPORT", "OPEN", "ORTHO",
"PAGESETUP", "PERF", "PERSP", "PLOT", "PLOTSTYLE", "PLOTSTYLEEDITOR", "PLOTSTYLEPANEL", "PR",
"P", "PAN", "PAGESETUP", "PERF", "PERSP", "PLOT", "PLOTSTYLE", "PLOTSTYLEEDITOR",
"PLOTSTYLEPANEL", "PR",
"PRINT", "PROPERTIES", "PROPS", "PSPACE", "PURGE", "QS", "QSAVE", "QSELECT",
"QUIT", "REDO", "REDRAW", "REDRWALL", "REGEN", "REGENALL", "RENAME", "REPORT",
"SA", "SAVE", "SAVEAS", "SCALETEXT", "SELECTALL", "SELECTSIMILAR", "SELSIM", "SHEETSET",
+6
View File
@@ -158,6 +158,11 @@ pub(super) struct DocumentTab {
/// of the model-space shader. The scene is still constructed so the
/// rest of the code can treat it as a normal tab when reading.
pub(super) is_start: bool,
/// Interactive PAN mode (the PAN command / tool). While active, a left-
/// button drag pans the view instead of selecting — the only pan path on a
/// device with no middle mouse button (a trackpad / web client). Exited
/// with Esc or by starting another command.
pub(super) pan_mode: bool,
/// Per-plugin document state (`plugin::BuiltinPlugin` manifest id → state).
#[cfg_attr(target_arch = "wasm32", allow(dead_code))]
pub(super) plugin_state: HashMap<&'static str, Box<dyn Any + Send + Sync>>,
@@ -377,6 +382,7 @@ impl DocumentTab {
active_mleader_style: "Standard".to_string(),
last_synced_camera_gen: 0,
is_start: false,
pan_mode: false,
plugin_state: HashMap::new(),
suspended_cmd: None,
}
+31
View File
@@ -1835,6 +1835,18 @@ impl OpenCADStudio {
self.text_inline_cancel();
return self.post_editor_closed(false);
}
// Leave interactive PAN mode (and end any in-flight pan drag).
if self.tabs[self.active_tab].pan_mode {
let i = self.active_tab;
self.tabs[i].pan_mode = false;
{
let mut sel = self.tabs[i].scene.selection.borrow_mut();
sel.middle_down = false;
sel.middle_last_pos = None;
}
self.command_line.push_output("PAN ended.");
return Task::none();
}
// Grip popup intercepts Escape — dismisses the menu
// without doing anything else.
if self.grip_popup.take().is_some() {
@@ -3217,6 +3229,16 @@ impl OpenCADStudio {
};
let (vw, vh) = vp_size;
// PAN mode: a left press begins a pan drag. Reuse the middle-
// button pan path (the move handler pans whenever `middle_down`),
// so no selection/pick logic runs while panning.
if self.tabs[i].pan_mode {
let mut sel = self.tabs[i].scene.selection.borrow_mut();
sel.middle_down = true;
sel.middle_last_pos = Some(p);
return Task::none();
}
if vw > 1.0 && vh > 1.0 {
let rot = self.tabs[i].scene.active_view_rotation_mat();
// Map the cursor into whichever area owns the cube (active
@@ -3382,6 +3404,15 @@ impl OpenCADStudio {
Message::ViewportLeftRelease => {
let i = self.active_tab;
// PAN mode: end the pan drag but stay in pan mode for the next
// drag (exit is Esc / another command). Mirror of the press.
if self.tabs[i].pan_mode {
let mut sel = self.tabs[i].scene.selection.borrow_mut();
sel.middle_down = false;
sel.middle_last_pos = None;
return Task::none();
}
// End an in-flight tile-divider drag. Any tile that fell
// below the minimum (viewcube fits comfortably) gets
// absorbed into its longest-contact neighbour, so the
+1
View File
@@ -263,6 +263,7 @@ impl OpenCADStudio {
tab.last_cursor_screen,
!is_paper && self.show_viewcube,
tile_edges,
tab.pan_mode,
)
};