Merge PR #1577 from mechpanos/fix/zoom-cursor-and-extents: zoom cursor per navigation tool, and ZOOM EXTENTS frames everything

This commit is contained in:
Hakan Seven committed 2026-10-02 10:06:43 +03:00
commit e8a4f1316f
8 files changed
+514 -341

No files matched your search

+105 -1
View File
@@ -48,6 +48,11 @@ enum ScrollIntent {
Pan { dx: f32, dy: f32 },
}
/// How far a ZOOM Dynamic drag must travel vertically before the magnifier
/// cursor flips between "+" and "-". Without the dead zone a hand holding
/// still at the top of a drag strobes the glyph on every jittered pixel.
const ZOOM_DIR_FLIP_PX: f32 = 2.0;
/// A wheel notch zooms; a trackpad's two fingers pan.
///
/// A wheel reports notches, a precise-scrolling device reports pixels, and that
@@ -1238,7 +1243,14 @@ impl OpenCADStudio {
self.tabs[i]
.scene
.record_nav_perf(crate::scene::NavPerfOp::Zoom, move_started);
self.tabs[i].scene.selection.borrow_mut().middle_last_pos = Some(p);
let mut sel = self.tabs[i].scene.selection.borrow_mut();
// Latch the direction for the magnifier cursor. A dead
// zone keeps the +/- glyph from strobing when a slow drag
// wobbles a pixel either side of where it started.
if dy.abs() >= ZOOM_DIR_FLIP_PX {
sel.zoom_dir_out = dy > 0.0;
}
sel.middle_last_pos = Some(p);
return Task::none();
}
// Shift+MMB drag orbits the model view instead of panning
@@ -3473,6 +3485,9 @@ impl OpenCADStudio {
sel.box_current = Some(p);
sel.box_crossing = false;
sel.box_crossing_locked = true;
// A fresh drag starts neutral: the magnifier shows "+" until
// the pointer has actually moved far enough to mean zoom out.
sel.zoom_dir_out = false;
}
return Task::none();
}
@@ -3742,6 +3757,7 @@ impl OpenCADStudio {
sel.box_anchor_world = None;
sel.box_current = None;
sel.box_crossing_locked = false;
sel.zoom_dir_out = false;
drop(sel);
self.arm_hover_after_navigation(i);
return Task::none();
@@ -6729,6 +6745,94 @@ mod scroll_intent_tests {
}
}
#[cfg(test)]
mod zoom_dynamic_cursor_tests {
use super::*;
use crate::app::OpenCADStudio;
use crate::ui::overlay::NavCursor;
fn zoom_armed_app() -> OpenCADStudio {
let mut app = OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
app.tabs[0].scene.selection.borrow_mut().vp_size = (800.0, 600.0);
let _ = app.dispatch_command("ZOOM DYNAMIC");
app
}
fn drag_to(app: &mut OpenCADStudio, y: f32) {
let _ = app.update(Message::ViewportMove(Point::new(400.0, y)));
}
fn zoom_dir_out(app: &OpenCADStudio) -> bool {
app.tabs[0].scene.selection.borrow().zoom_dir_out
}
/// The magnifier has to carry the sign of the zoom actually under way:
/// dragging up zooms in, dragging back down zooms out.
#[test]
fn the_magnifier_follows_the_drag_direction() {
let mut app = zoom_armed_app();
assert!(app.tabs[0].zoom_dynamic_mode);
drag_to(&mut app, 300.0);
let _ = app.update(Message::ViewportLeftPress);
// A fresh drag starts neutral — "+" until told otherwise.
assert!(!zoom_dir_out(&app));
drag_to(&mut app, 200.0);
assert!(!zoom_dir_out(&app), "dragging up zooms in");
drag_to(&mut app, 400.0);
assert!(zoom_dir_out(&app), "dragging down zooms out");
drag_to(&mut app, 250.0);
assert!(!zoom_dir_out(&app), "dragging up again zooms in");
// Releasing disarms the direction, so the next hover shows "+".
let _ = app.update(Message::ViewportLeftRelease);
assert!(!zoom_dir_out(&app));
}
/// Sub-pixel wobble while a hand holds still must not strobe the glyph.
#[test]
fn jitter_below_the_dead_zone_does_not_flip_the_glyph() {
let mut app = zoom_armed_app();
drag_to(&mut app, 300.0);
let _ = app.update(Message::ViewportLeftPress);
drag_to(&mut app, 400.0);
assert!(zoom_dir_out(&app));
// Back up by less than ZOOM_DIR_FLIP_PX: still zooming out.
drag_to(&mut app, 399.0);
assert!(zoom_dir_out(&app), "1 px of wobble must not flip the sign");
}
/// Each navigation tool owns its own cursor. They used to share the pan
/// hand, which read as "drag the sheet" during a zoom and an orbit.
#[test]
fn each_navigation_tool_reports_its_own_mode() {
let mut app = OpenCADStudio::new_for_test();
app.automation_op(r#"{"op":"new"}"#);
for (command, want) in [
("PAN", NavCursor::Pan),
("ORBIT", NavCursor::Orbit),
("ZOOM DYNAMIC", NavCursor::Zoom),
] {
let _ = app.dispatch_command(command);
assert_eq!(
crate::app::view::nav_cursor(&app.tabs[0]),
want,
"{command} must have its own cursor"
);
let _ = app.update(Message::CommandEscape);
assert_eq!(
crate::app::view::nav_cursor(&app.tabs[0]),
NavCursor::None,
"Esc must disarm {command}"
);
}
}
}
#[cfg(test)]
mod snap_elevation_tests {
use super::snap_keeps_elevation;
+18 -1
View File
@@ -45,6 +45,23 @@ fn crosshair_background(tab: &DocumentTab, is_paper: bool) -> [f32; 4] {
tab.scene.paper_bg_color
}
/// Which navigation tool the viewport cursor should advertise. The three flags
/// are mutually exclusive — every command entry point clears all of them before
/// arming one (`crate::app::commands`) — so the order here only settles a state
/// that cannot occur.
pub(in crate::app) fn nav_cursor(tab: &DocumentTab) -> crate::ui::overlay::NavCursor {
use crate::ui::overlay::NavCursor;
if tab.pan_mode {
NavCursor::Pan
} else if tab.orbit_mode {
NavCursor::Orbit
} else if tab.zoom_dynamic_mode {
NavCursor::Zoom
} else {
NavCursor::None
}
}
/// Clear gap (px) kept between the render-mode bar (top-left) and the ViewCube
/// (top-right) before the cube is judged to collide and hides.
const VIEWCUBE_GAP: f32 = 12.0;
@@ -894,7 +911,7 @@ bg={bg_ms:.1}ms n={view_count}"
dividers,
pane_move_rect,
pane_drop_rect,
tab.pan_mode || tab.orbit_mode || tab.zoom_dynamic_mode,
nav_cursor(tab),
self.ribbon.open_dropdown.is_some(),
hover_locked,
crosshair_background(tab, is_paper),