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 <noreply@anthropic.com>
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,
)
};
+16 -1
View File
@@ -129,6 +129,7 @@ pub fn selection_overlay<'a>(
cursor_screen: Point,
show_viewcube: bool,
tile_edges: Vec<crate::scene::TileEdge>,
pan_mode: bool,
) -> Element<'a, Message> {
canvas(SelectionCanvas {
selection,
@@ -140,6 +141,7 @@ pub fn selection_overlay<'a>(
cursor_screen,
show_viewcube,
tile_edges,
pan_mode,
})
.width(Length::Fill)
.height(Length::Fill)
@@ -156,6 +158,9 @@ struct SelectionCanvas {
cursor_screen: Point,
show_viewcube: bool,
tile_edges: Vec<crate::scene::TileEdge>,
/// Interactive PAN mode: the crosshair is hidden and the cursor becomes a
/// hand so the viewport reads as a draggable surface.
pan_mode: bool,
}
impl SelectionCanvas {
@@ -207,6 +212,15 @@ impl canvas::Program<Message> for SelectionCanvas {
bounds: iced::Rectangle,
cursor: mouse::Cursor,
) -> mouse::Interaction {
// PAN mode owns the whole viewport: an open hand when hovering, a
// closed hand while dragging.
if self.pan_mode && cursor.is_over(bounds) {
return if self.selection.middle_down {
mouse::Interaction::Grabbing
} else {
mouse::Interaction::Grab
};
}
if self.show_viewcube {
if let Some(pos) = cursor.position_in(bounds) {
use crate::scene::{VIEWCUBE_DRAW_PX, VIEWCUBE_PAD};
@@ -757,7 +771,8 @@ impl canvas::Program<Message> for SelectionCanvas {
// arrow (see `mouse_interaction`); drawing the CAD crosshair on
// top of it would double up the visual feedback.
let over_divider = self.tile_edge_under(cursor, bounds).is_some();
if !over_viewcube && !over_divider {
// PAN mode replaces the crosshair with a hand cursor.
if !over_viewcube && !over_divider && !self.pan_mode {
if let Some(cp) = self.selection.last_move_pos {
let color = Color {
r: 0.85,