diff --git a/README.md b/README.md index af97f713..3a253082 100644 --- a/README.md +++ b/README.md @@ -94,6 +94,7 @@ https://github.com/user-attachments/assets/4b5ed159-8d1c-44cb-8fe4-e0f2ea41d818 ### Boards - Named boards with transparent overlay or custom backgrounds - Isolated pages per board with auto-contrast pens +- Pan solid boards with Space + left-drag; reset from the context menu - Jump slots: Ctrl+Shift+1..9 - Toggle whiteboard/blackboard - Board picker: Ctrl+Shift+B @@ -142,6 +143,7 @@ https://github.com/user-attachments/assets/4b5ed159-8d1c-44cb-8fe4-e0f2ea41d818 - Reset: Ctrl+Alt+0 - Lock view: Ctrl+Alt+L - Pan: middle drag or arrow keys + - Right-click menu: Zoom → Zoom In / Zoom Out / Reset Zoom --- @@ -524,6 +526,8 @@ Drag modifier mappings are configurable in `config.toml` via `[drawing]` (`drag_ | New board | Ctrl+Shift+N | | Delete board | Ctrl+Shift+Delete | | Board picker | Ctrl+Shift+B | +| Pan solid boards | Hold Space + left-drag | +| Reset solid-board pan | Right-click → Reset Canvas Position | diff --git a/config.example.toml b/config.example.toml index 152809ca..aad4b18d 100644 --- a/config.example.toml +++ b/config.example.toml @@ -519,6 +519,12 @@ auto_create = true # Show board name/slot in the status bar show_board_badge = true +# Allow panning on solid-color boards with Space + left-drag +pan_enabled = true + +# Show the pan hint in the status bar or as a floating badge +show_pan_badge = true + # Persist runtime edits (rename/background) back to config persist_customizations = true diff --git a/docs/CONFIG.md b/docs/CONFIG.md index 972d628e..1531c5a2 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -509,6 +509,8 @@ Configure multiple boards (each with its own pages) plus the special transparent max_count = 9 auto_create = true show_board_badge = true +pan_enabled = true +show_pan_badge = true persist_customizations = true default_board = "transparent" @@ -549,6 +551,8 @@ default_pen_color = { rgb = [0.969, 0.890, 0.784] } - `max_count` — hard cap on total boards. - `auto_create` — create a board when switching to an empty slot. - `show_board_badge` — show board name/slot in the status bar. +- `pan_enabled` — allow panning on solid-color boards with Space + left-drag. +- `show_pan_badge` — show the pan hint in the status bar or as a floating badge. - `persist_customizations` — runtime edits (rename/background) are written back to config. - `default_board` — board id to activate on startup. - `items` — ordered list of boards; each board has: @@ -571,6 +575,13 @@ default_pen_color = { rgb = [0.969, 0.890, 0.784] } - Modal list for switching, renaming, and recoloring boards. - Inline edits persist immediately when `persist_customizations = true`. +**Solid-board pan:** +- Hold Space and drag with the left mouse button to pan whiteboards and other solid-color boards. +- Transparent overlay does not pan; it stays anchored to the live screen. +- The canvas context menu includes **Reset Canvas Position** when board panning is enabled. +- The same right-click menu exposes **Zoom** → **Zoom In**, **Zoom Out**, and **Reset Zoom**. +- Pan offsets are stored per page, so each page keeps its own position. + **CLI Override:** Use a board id with `--mode`: ```bash diff --git a/src/backend/wayland/backend/state_init/input_state.rs b/src/backend/wayland/backend/state_init/input_state.rs index 9a03dc4f..9d12f68f 100644 --- a/src/backend/wayland/backend/state_init/input_state.rs +++ b/src/backend/wayland/backend/state_init/input_state.rs @@ -180,6 +180,9 @@ mod tests { config.ui.show_floating_badge_always = true; config.ui.active_output_badge = true; config.ui.command_palette_toast_duration_ms = 1234; + let boards = config.boards.as_mut().expect("boards config"); + boards.pan_enabled = false; + boards.show_pan_badge = false; let input = build_input_state(&config); @@ -189,6 +192,8 @@ mod tests { assert!(input.show_floating_badge_always); assert!(input.show_active_output_badge); assert_eq!(input.command_palette_toast_duration_ms, 1234); + assert!(!input.boards.pan_enabled()); + assert!(!input.boards.show_pan_badge()); } #[test] diff --git a/src/backend/wayland/handlers/keyboard/mod.rs b/src/backend/wayland/handlers/keyboard/mod.rs index 7481977f..1f53fb0d 100644 --- a/src/backend/wayland/handlers/keyboard/mod.rs +++ b/src/backend/wayland/handlers/keyboard/mod.rs @@ -62,6 +62,8 @@ impl KeyboardHandler for WaylandState { // and breaking shortcuts/tools, aggressively reset our modifier state on // focus loss. self.input_state.reset_modifiers(); + self.set_board_pan_key_held(false); + self.stop_board_pan(); if self.surface.is_xdg_window() && self.focus_exit_suppressed() { warn!("Keyboard focus lost in xdg fallback; suppressing exit after clipboard action"); @@ -112,6 +114,11 @@ impl KeyboardHandler for WaylandState { if self.try_handle_first_run_background_mode_choice(key) { return; } + if matches!(key, Key::Space) && self.should_capture_space_for_board_pan() { + self.set_board_pan_key_held(true); + self.input_state.needs_redraw = true; + return; + } if self.zoom.is_engaged() { match key { Key::Escape => { @@ -143,6 +150,7 @@ impl KeyboardHandler for WaylandState { self.surface.width(), self.surface.height(), ); + self.sync_input_zoom_state(); self.input_state.dirty_tracker.mark_full(); self.input_state.needs_redraw = true; return; @@ -180,6 +188,11 @@ impl KeyboardHandler for WaylandState { ) { let key = keysym_to_key(event.keysym); debug!("Key released: {:?}", key); + if matches!(key, Key::Space) && self.board_pan_key_held() { + self.set_board_pan_key_held(false); + self.input_state.needs_redraw = true; + return; + } self.input_state.on_key_release(key); } @@ -216,6 +229,9 @@ impl KeyboardHandler for WaylandState { return; } let key = keysym_to_key(event.keysym); + if matches!(key, Key::Space) && self.board_pan_key_held() { + return; + } if self.zoom.active { match key { Key::Up | Key::Down | Key::Left | Key::Right => { @@ -240,6 +256,7 @@ impl KeyboardHandler for WaylandState { self.surface.width(), self.surface.height(), ); + self.sync_input_zoom_state(); self.input_state.dirty_tracker.mark_full(); self.input_state.needs_redraw = true; return; diff --git a/src/backend/wayland/handlers/pointer/cursor.rs b/src/backend/wayland/handlers/pointer/cursor.rs index 0da13a37..bf5316ff 100644 --- a/src/backend/wayland/handlers/pointer/cursor.rs +++ b/src/backend/wayland/handlers/pointer/cursor.rs @@ -112,6 +112,12 @@ impl WaylandState { if self.toolbar_dragging() { return CursorIcon::Grabbing; } + if self.board_panning_active() { + return CursorIcon::Grabbing; + } + if self.board_pan_key_held() && self.can_start_board_pan() { + return CursorIcon::Grab; + } // Inline toolbar cursor hints (when using inline mode) if self.inline_toolbars_active() @@ -183,8 +189,8 @@ impl WaylandState { } // Check if hovering over selection handles - let (mx, my) = self.current_mouse(); - if let Some(handle) = self.input_state.hit_selection_handle(mx, my) { + let (canvas_x, canvas_y) = self.input_state.canvas_pointer_position(); + if let Some(handle) = self.input_state.hit_selection_handle(canvas_x, canvas_y) { return match handle { SelectionHandle::TopLeft | SelectionHandle::BottomRight => CursorIcon::NwseResize, SelectionHandle::TopRight | SelectionHandle::BottomLeft => CursorIcon::NeswResize, @@ -194,12 +200,16 @@ impl WaylandState { } // Check if hovering over text resize handle - if self.input_state.hit_text_resize_handle(mx, my).is_some() { + if self + .input_state + .hit_text_resize_handle(canvas_x, canvas_y) + .is_some() + { return CursorIcon::SeResize; } // Check if hovering over a selected shape (for move) - if let Some(hit_id) = self.input_state.hit_test_at(mx, my) + if let Some(hit_id) = self.input_state.hit_test_at(canvas_x, canvas_y) && self .input_state .selected_shape_ids_set() diff --git a/src/backend/wayland/handlers/pointer/enter_leave.rs b/src/backend/wayland/handlers/pointer/enter_leave.rs index 1b5b5ef5..05befc1b 100644 --- a/src/backend/wayland/handlers/pointer/enter_leave.rs +++ b/src/backend/wayland/handlers/pointer/enter_leave.rs @@ -29,7 +29,8 @@ impl WaylandState { { self.set_current_mouse(sx as i32, sy as i32); let (wx, wy) = self.zoomed_world_coords(sx, sy); - self.input_state.update_pointer_position(wx, wy); + self.input_state + .update_pointer_positions(sx as i32, sy as i32, wx, wy); } else { self.set_current_mouse(event.position.0 as i32, event.position.1 as i32); } @@ -39,7 +40,12 @@ impl WaylandState { if !on_toolbar { self.set_current_mouse(event.position.0 as i32, event.position.1 as i32); let (wx, wy) = self.zoomed_world_coords(event.position.0, event.position.1); - self.input_state.update_pointer_position(wx, wy); + self.input_state.update_pointer_positions( + event.position.0.round() as i32, + event.position.1.round() as i32, + wx, + wy, + ); if self.input_state.eraser_mode == EraserMode::Stroke && self.input_state.active_tool() == Tool::Eraser { diff --git a/src/backend/wayland/handlers/pointer/motion.rs b/src/backend/wayland/handlers/pointer/motion.rs index d46684b4..acb3b056 100644 --- a/src/backend/wayland/handlers/pointer/motion.rs +++ b/src/backend/wayland/handlers/pointer/motion.rs @@ -51,7 +51,8 @@ impl WaylandState { { self.set_current_mouse(sx as i32, sy as i32); let (wx, wy) = self.zoomed_world_coords(sx, sy); - self.input_state.update_pointer_position(wx, wy); + self.input_state + .update_pointer_positions(sx as i32, sy as i32, wx, wy); } let evt = self.toolbar.pointer_motion(&event.surface, event.position); if self.toolbar_dragging() { @@ -77,7 +78,12 @@ impl WaylandState { if self.pointer_over_toolbar() { self.set_current_mouse(event.position.0 as i32, event.position.1 as i32); let (wx, wy) = self.zoomed_world_coords(event.position.0, event.position.1); - self.input_state.update_pointer_position(wx, wy); + self.input_state.update_pointer_positions( + event.position.0.round() as i32, + event.position.1.round() as i32, + wx, + wy, + ); let evt = self.toolbar.pointer_motion(&event.surface, event.position); if self.toolbar_dragging() { // Use move_drag_intent if pointer_motion didn't return an intent @@ -118,17 +124,48 @@ impl WaylandState { .update_pan_position(event.position.0, event.position.1); self.zoom .pan_by_screen_delta(dx, dy, self.surface.width(), self.surface.height()); + self.sync_input_zoom_state(); + let (wx, wy) = self.zoomed_world_coords(event.position.0, event.position.1); + self.input_state.update_pointer_positions( + event.position.0.round() as i32, + event.position.1.round() as i32, + wx, + wy, + ); self.input_state.dirty_tracker.mark_full(); self.input_state.needs_redraw = true; return; } + if self.board_panning_active() { + self.set_current_mouse(event.position.0 as i32, event.position.1 as i32); + let (dx, dy) = self.update_board_pan_position(event.position.0, event.position.1); + let _ = self.pan_board_by_screen_delta(dx, dy); + let (wx, wy) = self.zoomed_world_coords(event.position.0, event.position.1); + self.input_state.update_pointer_positions( + event.position.0.round() as i32, + event.position.1.round() as i32, + wx, + wy, + ); + return; + } self.set_current_mouse(event.position.0 as i32, event.position.1 as i32); // Block pointer motion when modal overlays are active if self.input_state.command_palette_open || self.input_state.tour_active { return; } let (wx, wy) = self.zoomed_world_coords(event.position.0, event.position.1); - self.input_state.update_pointer_position(wx, wy); - self.input_state.on_mouse_motion(wx, wy); + self.input_state.update_pointer_positions( + event.position.0.round() as i32, + event.position.1.round() as i32, + wx, + wy, + ); + self.input_state.on_mouse_motion_with_canvas( + event.position.0.round() as i32, + event.position.1.round() as i32, + wx, + wy, + ); } } diff --git a/src/backend/wayland/handlers/pointer/press.rs b/src/backend/wayland/handlers/pointer/press.rs index caf1ae7f..9cbc0fc7 100644 --- a/src/backend/wayland/handlers/pointer/press.rs +++ b/src/backend/wayland/handlers/pointer/press.rs @@ -109,6 +109,11 @@ impl WaylandState { self.input_state.needs_redraw = true; return; } + if button == BTN_LEFT && self.board_pan_key_held() && self.can_start_board_pan() { + self.start_board_pan(event.position.0, event.position.1); + self.input_state.needs_redraw = true; + return; + } let mb = match button { BTN_LEFT => MouseButton::Left, @@ -117,8 +122,11 @@ impl WaylandState { _ => return, }; + let screen_x = event.position.0.round() as i32; + let screen_y = event.position.1.round() as i32; let (wx, wy) = self.zoomed_world_coords(event.position.0, event.position.1); - self.input_state.on_mouse_press(mb, wx, wy); + self.input_state + .on_mouse_press_with_canvas(mb, screen_x, screen_y, wx, wy); self.input_state.needs_redraw = true; } } diff --git a/src/backend/wayland/handlers/pointer/release.rs b/src/backend/wayland/handlers/pointer/release.rs index 133606e5..c032efa4 100644 --- a/src/backend/wayland/handlers/pointer/release.rs +++ b/src/backend/wayland/handlers/pointer/release.rs @@ -96,6 +96,11 @@ impl WaylandState { } return; } + if button == BTN_LEFT && self.board_panning_active() { + self.stop_board_pan(); + self.input_state.needs_redraw = true; + return; + } let mb = match button { BTN_LEFT => MouseButton::Left, @@ -117,8 +122,11 @@ impl WaylandState { } } + let screen_x = event.position.0.round() as i32; + let screen_y = event.position.1.round() as i32; let (wx, wy) = self.zoomed_world_coords(event.position.0, event.position.1); - self.input_state.on_mouse_release(mb, wx, wy); + self.input_state + .on_mouse_release_with_canvas(mb, screen_x, screen_y, wx, wy); self.input_state.needs_redraw = true; } } diff --git a/src/backend/wayland/handlers/tablet/tool.rs b/src/backend/wayland/handlers/tablet/tool.rs index b732f00e..6bfbd71d 100644 --- a/src/backend/wayland/handlers/tablet/tool.rs +++ b/src/backend/wayland/handlers/tablet/tool.rs @@ -144,11 +144,16 @@ impl Dispatch for WaylandState { state.current_mouse().0, state.current_mouse().1 ); - let (wx, wy) = state.zoomed_world_coords( - state.current_mouse().0 as f64, - state.current_mouse().1 as f64, + let screen_x = state.current_mouse().0; + let screen_y = state.current_mouse().1; + let (wx, wy) = state.zoomed_world_coords(screen_x as f64, screen_y as f64); + state.input_state.on_mouse_press_with_canvas( + MouseButton::Left, + screen_x, + screen_y, + wx, + wy, ); - state.input_state.on_mouse_press(MouseButton::Left, wx, wy); state.input_state.needs_redraw = true; } Event::Up => { @@ -186,13 +191,16 @@ impl Dispatch for WaylandState { state.current_mouse().0, state.current_mouse().1 ); - let (wx, wy) = state.zoomed_world_coords( - state.current_mouse().0 as f64, - state.current_mouse().1 as f64, + let screen_x = state.current_mouse().0; + let screen_y = state.current_mouse().1; + let (wx, wy) = state.zoomed_world_coords(screen_x as f64, screen_y as f64); + state.input_state.on_mouse_release_with_canvas( + MouseButton::Left, + screen_x, + screen_y, + wx, + wy, ); - state - .input_state - .on_mouse_release(MouseButton::Left, wx, wy); state.input_state.needs_redraw = true; } Event::Motion { x, y } => { @@ -255,7 +263,12 @@ impl Dispatch for WaylandState { state.current_mouse().0 as f64, state.current_mouse().1 as f64, ); - state.input_state.on_mouse_motion(wx, wy); + state.input_state.on_mouse_motion_with_canvas( + x.round() as i32, + y.round() as i32, + wx, + wy, + ); if state.stylus_tip_down { state.stylus_pressure_thickness = Some(state.input_state.current_thickness); state.record_stylus_peak(state.input_state.current_thickness); diff --git a/src/backend/wayland/state/boards.rs b/src/backend/wayland/state/boards.rs index a432c3c5..0a98b664 100644 --- a/src/backend/wayland/state/boards.rs +++ b/src/backend/wayland/state/boards.rs @@ -1,4 +1,5 @@ use crate::config::BoardsConfig; +use crate::input::DrawingState; use super::WaylandState; @@ -9,4 +10,140 @@ impl WaylandState { log::warn!("Failed to save board config: {}", err); } } + + pub(in crate::backend::wayland) fn board_view_offset(&self) -> (f64, f64) { + if self.input_state.board_is_transparent() || !self.input_state.boards.pan_enabled() { + (0.0, 0.0) + } else { + let (x, y) = self.input_state.boards.active_frame().view_offset(); + (x as f64, y as f64) + } + } + + pub(in crate::backend::wayland) fn canvas_view_origin(&self) -> (f64, f64) { + let (board_x, board_y) = self.board_view_offset(); + if self.zoom.active { + ( + board_x + self.zoom.view_offset.0, + board_y + self.zoom.view_offset.1, + ) + } else { + (board_x, board_y) + } + } + + pub(in crate::backend::wayland) fn canvas_transform_active(&self) -> bool { + self.zoom.active + || (self.input_state.boards.pan_enabled() + && !self.input_state.board_is_transparent() + && self.input_state.boards.active_frame().view_offset() != (0, 0)) + } + + pub(in crate::backend::wayland) fn canvas_world_coords( + &self, + screen_x: f64, + screen_y: f64, + ) -> (i32, i32) { + let (board_x, board_y) = self.board_view_offset(); + if self.zoom.active { + let (zoom_x, zoom_y) = self.zoom.screen_to_world(screen_x, screen_y); + ( + (board_x + zoom_x).round() as i32, + (board_y + zoom_y).round() as i32, + ) + } else { + ( + (board_x + screen_x).round() as i32, + (board_y + screen_y).round() as i32, + ) + } + } + + pub(in crate::backend::wayland) fn can_start_board_pan(&self) -> bool { + self.input_state.boards.pan_enabled() + && !self.input_state.board_is_transparent() + && !self.zoom.active + && !self.input_state.tour_active + && !self.input_state.command_palette_open + && !self.input_state.is_board_picker_open() + && !self.input_state.is_color_picker_popup_open() + && !self.input_state.is_context_menu_open() + && !self.input_state.is_properties_panel_open() + && !self.input_state.is_radial_menu_open() + && matches!(self.input_state.state, DrawingState::Idle) + } + + pub(in crate::backend::wayland) fn start_board_pan(&mut self, screen_x: f64, screen_y: f64) { + self.data.board_panning = true; + self.data.board_pan_last_pos = (screen_x, screen_y); + } + + pub(in crate::backend::wayland) fn stop_board_pan(&mut self) { + self.data.board_panning = false; + } + + pub(in crate::backend::wayland) fn board_panning_active(&self) -> bool { + self.data.board_panning + } + + pub(in crate::backend::wayland) fn board_pan_key_held(&self) -> bool { + self.data.board_pan_key_held + } + + pub(in crate::backend::wayland) fn set_board_pan_key_held(&mut self, held: bool) { + self.data.board_pan_key_held = held; + } + + pub(in crate::backend::wayland) fn pan_board_by_screen_delta( + &mut self, + dx: f64, + dy: f64, + ) -> bool { + if self.input_state.board_is_transparent() || !self.input_state.boards.pan_enabled() { + return false; + } + let dx = dx.round() as i32; + let dy = dy.round() as i32; + if dx == 0 && dy == 0 { + return false; + } + let changed = self + .input_state + .boards + .active_frame_mut() + .pan_view_by(-dx, -dy); + if changed { + self.input_state.dirty_tracker.mark_full(); + self.input_state.needs_redraw = true; + self.input_state.mark_session_dirty(); + } + changed + } + + pub(in crate::backend::wayland) fn update_board_pan_position( + &mut self, + screen_x: f64, + screen_y: f64, + ) -> (f64, f64) { + let (last_x, last_y) = self.data.board_pan_last_pos; + self.data.board_pan_last_pos = (screen_x, screen_y); + (screen_x - last_x, screen_y - last_y) + } + + pub(in crate::backend::wayland) fn should_capture_space_for_board_pan(&self) -> bool { + self.input_state.boards.pan_enabled() + && !self.input_state.board_is_transparent() + && !self.zoom.active + && !self.input_state.tour_active + && !self.input_state.show_help + && !self.input_state.command_palette_open + && !self.input_state.is_board_picker_open() + && !self.input_state.is_color_picker_popup_open() + && !self.input_state.is_context_menu_open() + && !self.input_state.is_properties_panel_open() + && !self.input_state.is_radial_menu_open() + && !self.pointer_over_toolbar() + && self.toolbar_focus_target().is_none() + && matches!(self.input_state.state, DrawingState::Idle) + } } diff --git a/src/backend/wayland/state/data.rs b/src/backend/wayland/state/data.rs index a33b72c6..1e1983b1 100644 --- a/src/backend/wayland/state/data.rs +++ b/src/backend/wayland/state/data.rs @@ -33,6 +33,9 @@ pub struct StateData { pub(super) has_pointer_focus: bool, pub(super) current_mouse_x: i32, pub(super) current_mouse_y: i32, + pub(super) board_panning: bool, + pub(super) board_pan_last_pos: (f64, f64), + pub(super) board_pan_key_held: bool, pub(super) current_seat: Option, pub(super) last_activation_serial: Option, pub(super) pointer_over_toolbar: bool, @@ -98,6 +101,9 @@ impl StateData { has_pointer_focus: false, current_mouse_x: 0, current_mouse_y: 0, + board_panning: false, + board_pan_last_pos: (0.0, 0.0), + board_pan_key_held: false, current_seat: None, last_activation_serial: None, pointer_over_toolbar: false, diff --git a/src/backend/wayland/state/render/canvas/mod.rs b/src/backend/wayland/state/render/canvas/mod.rs index e3cbd427..ed3c395d 100644 --- a/src/backend/wayland/state/render/canvas/mod.rs +++ b/src/backend/wayland/state/render/canvas/mod.rs @@ -18,7 +18,8 @@ impl WaylandState { now: Instant, damage_world: &[crate::util::Rect], ) -> Result<()> { - let zoom_transform_active = self.zoom.active; + let canvas_transform_active = self.canvas_transform_active(); + let (canvas_origin_x, canvas_origin_y) = self.canvas_view_origin(); let eraser_ctx = self.render_canvas_background(ctx, scale, phys_width, phys_height)?; // Scale subsequent drawing to logical coordinates @@ -27,10 +28,12 @@ impl WaylandState { ctx.scale(scale as f64, scale as f64); } - if zoom_transform_active { + if canvas_transform_active { let _ = ctx.save(); - ctx.scale(self.zoom.scale, self.zoom.scale); - ctx.translate(-self.zoom.view_offset.0, -self.zoom.view_offset.1); + if self.zoom.active { + ctx.scale(self.zoom.scale, self.zoom.scale); + } + ctx.translate(-canvas_origin_x, -canvas_origin_y); } // Render all completed shapes from active frame @@ -86,7 +89,7 @@ impl WaylandState { let safe_y = bounds.y.saturating_sub(margin); let safe_width = bounds.width.saturating_add(margin * 2); let safe_height = bounds.height.saturating_add(margin * 2); - let safe_bounds = if zoom_transform_active { + let safe_bounds = if canvas_transform_active { crate::util::Rect::new(safe_x, safe_y, safe_width, safe_height) } else { // Clamp to logical surface bounds to avoid negative coords or overflow. @@ -136,11 +139,8 @@ impl WaylandState { self.render_selection_overlays(ctx); - let (mx, my) = if zoom_transform_active { - self.zoomed_world_coords(self.current_mouse().0 as f64, self.current_mouse().1 as f64) - } else { - self.current_mouse() - }; + let (mx, my) = + self.canvas_world_coords(self.current_mouse().0 as f64, self.current_mouse().1 as f64); self.render_eraser_hover_halos(ctx, mx, my); @@ -158,7 +158,7 @@ impl WaylandState { // Render click highlight overlays before UI so status/help remain legible self.input_state.render_click_highlights(ctx, now); - if zoom_transform_active { + if canvas_transform_active { let _ = ctx.restore(); } diff --git a/src/backend/wayland/state/render/mod.rs b/src/backend/wayland/state/render/mod.rs index bb4b007f..1406afa3 100644 --- a/src/backend/wayland/state/render/mod.rs +++ b/src/backend/wayland/state/render/mod.rs @@ -47,7 +47,7 @@ impl WaylandState { let input_damage = self.input_state.take_dirty_regions(); let logical_width = width.min(i32::MAX as u32) as i32; let logical_height = height.min(i32::MAX as u32) as i32; - let force_full_damage = self.zoom.active + let force_full_damage = self.canvas_transform_active() || ui_toast_active || preset_feedback_active || blocked_feedback_active @@ -108,15 +108,23 @@ impl WaylandState { self.buffer_damage.mark_all_full(); } let damage_screen = logical_damage; - let damage_world = if self.zoom.active { - let scale = self.zoom.scale.max(f64::MIN_POSITIVE); + let damage_world = if self.canvas_transform_active() { + let scale = if self.zoom.active { + self.zoom.scale.max(f64::MIN_POSITIVE) + } else { + 1.0 + }; let view_width = ((width as f64) / scale).ceil() as i32; let view_height = ((height as f64) / scale).ceil() as i32; - let view_x = self.zoom.view_offset.0.floor() as i32; - let view_y = self.zoom.view_offset.1.floor() as i32; - crate::util::Rect::new(view_x, view_y, view_width, view_height) - .map(|rect| vec![rect]) - .unwrap_or_default() + let (view_x, view_y) = self.canvas_view_origin(); + crate::util::Rect::new( + view_x.floor() as i32, + view_y.floor() as i32, + view_width, + view_height, + ) + .map(|rect| vec![rect]) + .unwrap_or_default() } else { damage_screen.clone() }; diff --git a/src/backend/wayland/state/render/ui.rs b/src/backend/wayland/state/render/ui.rs index 7428a987..dac49104 100644 --- a/src/backend/wayland/state/render/ui.rs +++ b/src/backend/wayland/state/render/ui.rs @@ -50,6 +50,20 @@ impl WaylandState { ); top_badge_offset += 42.0; // Space below zoom badge } + if self.input_state.boards.pan_enabled() + && self.input_state.boards.show_pan_badge() + && !self.input_state.board_is_transparent() + && !self.input_state.show_status_bar + { + crate::ui::render_pan_badge( + ctx, + width, + height, + self.input_state.boards.active_frame().view_offset() != (0, 0), + top_badge_offset, + ); + top_badge_offset += 42.0; + } // Render editing badge when in text edit mode if matches!(self.input_state.state, DrawingState::TextInput { .. }) && self.input_state.text_edit_target.is_some() diff --git a/src/backend/wayland/state/zoom.rs b/src/backend/wayland/state/zoom.rs index cf882361..486f60af 100644 --- a/src/backend/wayland/state/zoom.rs +++ b/src/backend/wayland/state/zoom.rs @@ -1,6 +1,15 @@ use super::*; impl WaylandState { + pub(in crate::backend::wayland) fn sync_input_zoom_state(&mut self) { + self.input_state.set_zoom_status( + self.zoom.active, + self.zoom.locked, + self.zoom.scale, + self.zoom.view_offset, + ); + } + pub(in crate::backend::wayland) fn sync_zoom_board_mode(&mut self) { let board_is_transparent = self.input_state.board_is_transparent(); if !board_is_transparent { @@ -13,8 +22,7 @@ impl WaylandState { } if self.zoom.is_engaged() && !self.zoom.active { self.zoom.activate_without_capture(); - self.input_state - .set_zoom_status(true, self.zoom.locked, self.zoom.scale); + self.sync_input_zoom_state(); } if self.zoom.clear_image() { self.input_state.dirty_tracker.mark_full(); @@ -39,12 +47,7 @@ impl WaylandState { screen_x: f64, screen_y: f64, ) -> (i32, i32) { - if self.zoom.active { - let (wx, wy) = self.zoom.screen_to_world(screen_x, screen_y); - (wx.round() as i32, wy.round() as i32) - } else { - (screen_x.round() as i32, screen_y.round() as i32) - } + self.canvas_world_coords(screen_x, screen_y) } pub(in crate::backend::wayland) fn handle_zoom_action(&mut self, action: ZoomAction) { @@ -67,11 +70,7 @@ impl WaylandState { if self.zoom.locked && self.zoom.panning { self.zoom.stop_pan(); } - self.input_state.set_zoom_status( - self.zoom.active, - self.zoom.locked, - self.zoom.scale, - ); + self.sync_input_zoom_state(); } } ZoomAction::RefreshCapture => { @@ -153,23 +152,20 @@ impl WaylandState { self.input_state.close_properties_panel(); if board_zoom { self.zoom.activate_without_capture(); - self.input_state - .set_zoom_status(true, self.zoom.locked, self.zoom.scale); + self.sync_input_zoom_state(); } else { self.zoom.request_activation(); } } else if board_zoom && !self.zoom.active { self.zoom.activate_without_capture(); - self.input_state - .set_zoom_status(true, self.zoom.locked, self.zoom.scale); + self.sync_input_zoom_state(); } let changed = self .zoom .zoom_at_screen_point(factor, screen_x, screen_y, screen_w, screen_h); if self.zoom.active && changed { - self.input_state - .set_zoom_status(true, self.zoom.locked, self.zoom.scale); + self.sync_input_zoom_state(); } if self.zoom.is_engaged() diff --git a/src/backend/wayland/zoom/capture.rs b/src/backend/wayland/zoom/capture.rs index 23250ae4..7d4f320b 100644 --- a/src/backend/wayland/zoom/capture.rs +++ b/src/backend/wayland/zoom/capture.rs @@ -164,7 +164,7 @@ impl ZoomState { self.active = true; self.pending_activation = false; } - input_state.set_zoom_status(self.active, self.locked, self.scale); + input_state.set_zoom_status(self.active, self.locked, self.scale, self.view_offset); input_state.dirty_tracker.mark_full(); input_state.needs_redraw = true; self.capture_done = true; diff --git a/src/backend/wayland/zoom/portal.rs b/src/backend/wayland/zoom/portal.rs index eaeb59b8..3f7cef58 100644 --- a/src/backend/wayland/zoom/portal.rs +++ b/src/backend/wayland/zoom/portal.rs @@ -102,7 +102,7 @@ impl ZoomState { self.active = false; } self.pending_activation = false; - input_state.set_zoom_status(self.active, self.locked, self.scale); + input_state.set_zoom_status(self.active, self.locked, self.scale, self.view_offset); input_state.needs_redraw = true; self.capture_done = true; return; @@ -132,7 +132,12 @@ impl ZoomState { self.active = false; } self.pending_activation = false; - input_state.set_zoom_status(self.active, self.locked, self.scale); + input_state.set_zoom_status( + self.active, + self.locked, + self.scale, + self.view_offset, + ); input_state.dirty_tracker.mark_full(); input_state.needs_redraw = true; self.capture_done = true; @@ -147,7 +152,12 @@ impl ZoomState { self.active = false; } self.pending_activation = false; - input_state.set_zoom_status(self.active, self.locked, self.scale); + input_state.set_zoom_status( + self.active, + self.locked, + self.scale, + self.view_offset, + ); input_state.needs_redraw = true; self.capture_done = true; } @@ -162,7 +172,12 @@ impl ZoomState { self.active = false; } self.pending_activation = false; - input_state.set_zoom_status(self.active, self.locked, self.scale); + input_state.set_zoom_status( + self.active, + self.locked, + self.scale, + self.view_offset, + ); input_state.needs_redraw = true; self.capture_done = true; } diff --git a/src/backend/wayland/zoom/state.rs b/src/backend/wayland/zoom/state.rs index 0f7332ad..1db90671 100644 --- a/src/backend/wayland/zoom/state.rs +++ b/src/backend/wayland/zoom/state.rs @@ -169,7 +169,7 @@ impl ZoomState { self.image = None; } - input_state.set_zoom_status(self.active, self.locked, self.scale); + input_state.set_zoom_status(self.active, self.locked, self.scale, self.view_offset); input_state.dirty_tracker.mark_full(); input_state.needs_redraw = true; } diff --git a/src/config/core.rs b/src/config/core.rs index 1666f5ba..997dfdcb 100644 --- a/src/config/core.rs +++ b/src/config/core.rs @@ -123,6 +123,8 @@ impl Config { max_count: boards.max_count, auto_create: boards.auto_create, show_board_badge: boards.show_board_badge, + pan_enabled: boards.pan_enabled, + show_pan_badge: boards.show_pan_badge, persist_customizations: boards.persist_customizations, default_board: boards.default_board.clone(), ..BoardsConfig::default() diff --git a/src/config/types/boards.rs b/src/config/types/boards.rs index 87c94ca6..6e3d8997 100644 --- a/src/config/types/boards.rs +++ b/src/config/types/boards.rs @@ -18,6 +18,14 @@ pub struct BoardsConfig { #[serde(default = "default_boards_show_badge")] pub show_board_badge: bool, + /// Enable panning on solid-color boards. + #[serde(default = "default_boards_pan_enabled")] + pub pan_enabled: bool, + + /// Show a pan hint badge for solid-color boards. + #[serde(default = "default_boards_show_pan_badge")] + pub show_pan_badge: bool, + /// Persist runtime edits (name/color) back to config. #[serde(default = "default_boards_persist_customizations")] pub persist_customizations: bool, @@ -37,6 +45,8 @@ impl Default for BoardsConfig { max_count: default_boards_max_count(), auto_create: default_boards_auto_create(), show_board_badge: default_boards_show_badge(), + pan_enabled: default_boards_pan_enabled(), + show_pan_badge: default_boards_show_pan_badge(), persist_customizations: default_boards_persist_customizations(), default_board: default_boards_default_board(), items: Self::default_items(), @@ -89,6 +99,8 @@ impl BoardsConfig { max_count: default_boards_max_count(), auto_create: default_boards_auto_create(), show_board_badge: default_boards_show_badge(), + pan_enabled: default_boards_pan_enabled(), + show_pan_badge: default_boards_show_pan_badge(), persist_customizations: default_boards_persist_customizations(), default_board: legacy.default_mode.clone(), items, @@ -173,6 +185,14 @@ fn default_boards_persist_customizations() -> bool { true } +fn default_boards_pan_enabled() -> bool { + true +} + +fn default_boards_show_pan_badge() -> bool { + true +} + fn default_boards_default_board() -> String { "transparent".to_string() } diff --git a/src/draw/frame/core.rs b/src/draw/frame/core.rs index 236bad33..e0f5226e 100644 --- a/src/draw/frame/core.rs +++ b/src/draw/frame/core.rs @@ -9,6 +9,8 @@ pub struct Frame { pub shapes: Vec, #[serde(default, skip_serializing_if = "Option::is_none")] pub page_name: Option, + #[serde(default, skip_serializing_if = "is_origin_offset")] + pub view_offset: (i32, i32), #[serde(default, skip_serializing_if = "Vec::is_empty")] pub(super) undo_stack: Vec, #[serde(default, skip_serializing_if = "Vec::is_empty")] @@ -29,6 +31,7 @@ impl Frame { Self { shapes: Vec::new(), page_name: None, + view_offset: (0, 0), undo_stack: Vec::new(), redo_stack: Vec::new(), next_shape_id: 1, @@ -41,6 +44,7 @@ impl Frame { self.shapes.clear(); self.undo_stack.clear(); self.redo_stack.clear(); + self.view_offset = (0, 0); self.next_shape_id = 1; } @@ -49,6 +53,7 @@ impl Frame { let mut frame = Frame::new(); frame.shapes = self.shapes.clone(); frame.page_name = self.page_name.clone(); + frame.view_offset = self.view_offset; frame.rebuild_next_id(); frame } @@ -85,10 +90,30 @@ impl Frame { pub fn has_persistable_data(&self) -> bool { !self.shapes.is_empty() || self.page_name.is_some() + || self.view_offset != (0, 0) || !self.undo_stack.is_empty() || !self.redo_stack.is_empty() } + pub fn view_offset(&self) -> (i32, i32) { + self.view_offset + } + + pub fn set_view_offset(&mut self, x: i32, y: i32) -> bool { + let next = (x, y); + if self.view_offset == next { + return false; + } + self.view_offset = next; + true + } + + pub fn pan_view_by(&mut self, dx: i32, dy: i32) -> bool { + let next_x = self.view_offset.0.saturating_add(dx); + let next_y = self.view_offset.1.saturating_add(dy); + self.set_view_offset(next_x, next_y) + } + /// Adds a shape at the end of the stack and returns its identifier. pub fn add_shape(&mut self, shape: Shape) -> ShapeId { let index = self.shapes.len(); @@ -196,3 +221,7 @@ impl Frame { self.next_shape_id = shapes_max.max(history_max).saturating_add(1); } } + +fn is_origin_offset(offset: &(i32, i32)) -> bool { + *offset == (0, 0) +} diff --git a/src/draw/frame/serde.rs b/src/draw/frame/serde.rs index e0863dae..303612ff 100644 --- a/src/draw/frame/serde.rs +++ b/src/draw/frame/serde.rs @@ -60,6 +60,8 @@ impl<'de> Deserialize<'de> for Frame { #[serde(default)] page_name: Option, #[serde(default)] + view_offset: (i32, i32), + #[serde(default)] undo_stack: Vec, #[serde(default)] redo_stack: Vec, @@ -69,6 +71,7 @@ impl<'de> Deserialize<'de> for Frame { let mut frame = Frame { shapes: helper.shapes, page_name: helper.page_name, + view_offset: helper.view_offset, undo_stack: helper.undo_stack, redo_stack: helper.redo_stack, next_shape_id: 1, diff --git a/src/draw/frame/tests/serialization.rs b/src/draw/frame/tests/serialization.rs index 3070b6bb..3ce5d941 100644 --- a/src/draw/frame/tests/serialization.rs +++ b/src/draw/frame/tests/serialization.rs @@ -83,6 +83,18 @@ fn frame_with_history_is_persistable_even_without_shapes() { assert!(frame.has_persistable_data()); } +#[test] +fn frame_with_view_offset_is_persistable_even_without_shapes() { + let mut frame = Frame::new(); + + assert!(frame.set_view_offset(240, -180)); + assert!(frame.has_persistable_data()); + + let json = serde_json::to_string(&frame).expect("serialize frame"); + let restored: Frame = serde_json::from_str(&json).expect("deserialize frame"); + assert_eq!(restored.view_offset(), (240, -180)); +} + #[test] fn try_add_shape_respects_limit() { let mut frame = Frame::new(); diff --git a/src/input/boards.rs b/src/input/boards.rs index fdee73a3..47f0acdf 100644 --- a/src/input/boards.rs +++ b/src/input/boards.rs @@ -53,6 +53,8 @@ pub struct BoardManager { max_count: usize, auto_create: bool, show_badge: bool, + pan_enabled: bool, + show_pan_badge: bool, persist_customizations: bool, default_board_id: String, template: BoardSpec, diff --git a/src/input/boards/core.rs b/src/input/boards/core.rs index cdf5573d..267c13a9 100644 --- a/src/input/boards/core.rs +++ b/src/input/boards/core.rs @@ -54,6 +54,14 @@ impl BoardManager { self.show_badge } + pub fn pan_enabled(&self) -> bool { + self.pan_enabled + } + + pub fn show_pan_badge(&self) -> bool { + self.show_pan_badge + } + pub fn max_count(&self) -> usize { self.max_count } diff --git a/src/input/boards/mapping.rs b/src/input/boards/mapping.rs index 8fcb87d7..44bbc3ea 100644 --- a/src/input/boards/mapping.rs +++ b/src/input/boards/mapping.rs @@ -59,6 +59,8 @@ impl BoardManager { max_count: config.max_count, auto_create: config.auto_create, show_badge: config.show_board_badge, + pan_enabled: config.pan_enabled, + show_pan_badge: config.show_pan_badge, persist_customizations: config.persist_customizations, default_board_id: config.default_board, template, @@ -70,6 +72,8 @@ impl BoardManager { max_count: self.max_count, auto_create: self.auto_create, show_board_badge: self.show_badge, + pan_enabled: self.pan_enabled, + show_pan_badge: self.show_pan_badge, persist_customizations: self.persist_customizations, default_board: self.default_board_id.clone(), items: self diff --git a/src/input/state/core/base/state/init.rs b/src/input/state/core/base/state/init.rs index 9465047f..c258b4cb 100644 --- a/src/input/state/core/base/state/init.rs +++ b/src/input/state/core/base/state/init.rs @@ -196,6 +196,7 @@ impl InputState { board_picker_layout: None, spatial_index: None, last_pointer_position: (0, 0), + last_canvas_pointer_position: (0, 0), pending_menu_hover_recalc: false, shape_properties_panel: None, properties_panel_layout: None, @@ -206,6 +207,7 @@ impl InputState { zoom_active: false, zoom_locked: false, zoom_scale: 1.0, + zoom_view_offset: (0.0, 0.0), show_more_colors: false, show_actions_section: true, // Show by default show_actions_advanced: false, diff --git a/src/input/state/core/base/state/structs.rs b/src/input/state/core/base/state/structs.rs index b9ec7083..0b94d88d 100644 --- a/src/input/state/core/base/state/structs.rs +++ b/src/input/state/core/base/state/structs.rs @@ -299,8 +299,10 @@ pub struct InputState { pub board_picker_layout: Option, /// Optional spatial index for accelerating hit-testing when many shapes are present pub(in crate::input::state::core) spatial_index: Option, - /// Last known pointer position (for keyboard anchors and hover refresh) + /// Last known pointer position in screen coordinates (for overlays and hover refresh) pub(in crate::input::state::core) last_pointer_position: (i32, i32), + /// Last known pointer position in canvas/world coordinates + pub(in crate::input::state::core) last_canvas_pointer_position: (i32, i32), /// Recompute hover next time layout is available pub(in crate::input::state::core) pending_menu_hover_recalc: bool, /// Optional properties panel describing the current selection @@ -321,6 +323,8 @@ pub struct InputState { pub(in crate::input::state::core) zoom_locked: bool, /// Current zoom scale (1.0 = no zoom) pub(in crate::input::state::core) zoom_scale: f64, + /// Current zoom view offset in canvas/world space + pub(in crate::input::state::core) zoom_view_offset: (f64, f64), /// Whether to show extended color palette pub show_more_colors: bool, /// Whether to show the Actions section (undo all, redo all, etc.) diff --git a/src/input/state/core/board/pages.rs b/src/input/state/core/board/pages.rs index e1ea7b97..1c88d853 100644 --- a/src/input/state/core/board/pages.rs +++ b/src/input/state/core/board/pages.rs @@ -3,6 +3,21 @@ use crate::draw::Color; use crate::input::BoardBackground; impl InputState { + pub(crate) fn reset_active_canvas_position(&mut self) -> bool { + if self.board_is_transparent() || !self.boards.pan_enabled() { + return false; + } + if !self.boards.active_frame_mut().set_view_offset(0, 0) { + return false; + } + self.sync_canvas_pointer_to_current_transform(); + self.dirty_tracker.mark_full(); + self.needs_redraw = true; + self.mark_session_dirty(); + self.set_ui_toast(UiToastKind::Info, "Canvas position reset."); + true + } + pub(crate) fn set_board_name(&mut self, index: usize, name: String) -> bool { let Some(board) = self.boards.board_state_mut(index) else { return false; diff --git a/src/input/state/core/board/switch.rs b/src/input/state/core/board/switch.rs index d007074d..bd1b9064 100644 --- a/src/input/state/core/board/switch.rs +++ b/src/input/state/core/board/switch.rs @@ -190,6 +190,7 @@ impl InputState { // Reset drawing state to prevent partial shapes crossing modes self.state = super::super::base::DrawingState::Idle; + self.sync_canvas_pointer_to_current_transform(); // Trigger redraw self.dirty_tracker.mark_full(); @@ -228,6 +229,7 @@ impl InputState { self.clear_selection(); self.close_context_menu(); self.invalidate_hit_cache(); + self.sync_canvas_pointer_to_current_transform(); self.dirty_tracker.mark_full(); self.needs_redraw = true; self.mark_session_dirty(); diff --git a/src/input/state/core/highlight_controls.rs b/src/input/state/core/highlight_controls.rs index 3db55e56..d84c1055 100644 --- a/src/input/state/core/highlight_controls.rs +++ b/src/input/state/core/highlight_controls.rs @@ -17,7 +17,7 @@ impl InputState { /// Enables or disables the persistent highlight ring. pub fn set_highlight_tool_ring_enabled(&mut self, enabled: bool) -> bool { - let (x, y) = self.last_pointer_position; + let (x, y) = self.last_canvas_pointer_position; if self.click_highlight.set_show_on_highlight_tool( enabled, self.highlight_tool_active(), diff --git a/src/input/state/core/menus/commands.rs b/src/input/state/core/menus/commands.rs index d32664a9..78d50947 100644 --- a/src/input/state/core/menus/commands.rs +++ b/src/input/state/core/menus/commands.rs @@ -89,6 +89,40 @@ impl InputState { self.clear_all(); self.close_context_menu(); } + MenuCommand::ResetCanvasPosition => { + self.reset_active_canvas_position(); + self.close_context_menu(); + } + MenuCommand::OpenZoomMenu => { + let anchor = if let Some(layout) = self.context_menu_layout { + ( + (layout.origin_x + layout.width + 8.0).round() as i32, + layout.origin_y.round() as i32, + ) + } else if let ContextMenuState::Open { anchor, .. } = &self.context_menu_state { + *anchor + } else { + self.last_pointer_position + }; + self.open_context_menu(anchor, Vec::new(), ContextMenuKind::Zoom, None); + self.pending_menu_hover_recalc = false; + self.set_context_menu_focus(None); + self.focus_first_context_menu_entry(); + self.dirty_tracker.mark_full(); + self.needs_redraw = true; + } + MenuCommand::ZoomIn => { + self.request_zoom_action(crate::input::ZoomAction::In); + self.close_context_menu(); + } + MenuCommand::ZoomOut => { + self.request_zoom_action(crate::input::ZoomAction::Out); + self.close_context_menu(); + } + MenuCommand::ResetZoom => { + self.request_zoom_action(crate::input::ZoomAction::Reset); + self.close_context_menu(); + } MenuCommand::ToggleHighlightTool => { self.toggle_all_highlights(); self.close_context_menu(); diff --git a/src/input/state/core/menus/entries/canvas.rs b/src/input/state/core/menus/entries/canvas.rs index 8e580d45..a92f4787 100644 --- a/src/input/state/core/menus/entries/canvas.rs +++ b/src/input/state/core/menus/entries/canvas.rs @@ -32,6 +32,23 @@ impl InputState { clear_disabled, Some(MenuCommand::ClearAll), )); + if self.boards.pan_enabled() && !self.board_is_transparent() { + let reset_disabled = self.boards.active_frame().view_offset() == (0, 0); + entries.push(ContextMenuEntry::new( + "Reset Canvas Position", + Some("Space+Drag"), + false, + reset_disabled, + Some(MenuCommand::ResetCanvasPosition), + )); + } + entries.push(ContextMenuEntry::new( + "Zoom", + None::, + true, + false, + Some(MenuCommand::OpenZoomMenu), + )); entries.push(ContextMenuEntry::new( "Toggle Highlight (tool + click)", self.shortcut_for_action(Action::ToggleHighlightTool), diff --git a/src/input/state/core/menus/entries/mod.rs b/src/input/state/core/menus/entries/mod.rs index b3aedd59..c65ecaac 100644 --- a/src/input/state/core/menus/entries/mod.rs +++ b/src/input/state/core/menus/entries/mod.rs @@ -3,6 +3,7 @@ mod canvas; mod page; mod pages; mod shape; +mod zoom; use super::super::base::InputState; use super::types::{ContextMenuEntry, ContextMenuKind, ContextMenuState}; @@ -20,6 +21,7 @@ impl InputState { } => match kind { ContextMenuKind::Canvas => self.canvas_menu_entries(), ContextMenuKind::Shape => self.shape_menu_entries(shape_ids, *hovered_shape_id), + ContextMenuKind::Zoom => self.zoom_menu_entries(), ContextMenuKind::Pages => self.pages_menu_entries(), ContextMenuKind::Boards => self.boards_menu_entries(), ContextMenuKind::Page => self.page_context_menu_entries(), diff --git a/src/input/state/core/menus/entries/shape.rs b/src/input/state/core/menus/entries/shape.rs index b163ffab..faecb335 100644 --- a/src/input/state/core/menus/entries/shape.rs +++ b/src/input/state/core/menus/entries/shape.rs @@ -75,6 +75,23 @@ impl InputState { false, Some(MenuCommand::Properties), )); + if self.boards.pan_enabled() && !self.board_is_transparent() { + let reset_disabled = self.boards.active_frame().view_offset() == (0, 0); + entries.push(ContextMenuEntry::new( + "Reset Canvas Position", + Some("Space+Drag"), + false, + reset_disabled, + Some(MenuCommand::ResetCanvasPosition), + )); + } + entries.push(ContextMenuEntry::new( + "Zoom", + None::, + true, + false, + Some(MenuCommand::OpenZoomMenu), + )); entries.push(ContextMenuEntry::new( "Radial Menu", self.shortcut_for_action(Action::ToggleRadialMenu), diff --git a/src/input/state/core/menus/entries/zoom.rs b/src/input/state/core/menus/entries/zoom.rs new file mode 100644 index 00000000..0cc6e094 --- /dev/null +++ b/src/input/state/core/menus/entries/zoom.rs @@ -0,0 +1,46 @@ +use super::super::super::base::InputState; +use super::super::types::{ContextMenuEntry, MenuCommand}; +use crate::config::Action; + +impl InputState { + pub(super) fn zoom_menu_entries(&self) -> Vec { + let mut entries = Vec::new(); + let zoom_active = self.zoom_active(); + let zoom_percent = if zoom_active { + (self.zoom_scale() * 100.0).round() as i32 + } else { + 100 + }; + + entries.push(ContextMenuEntry::new( + format!("Zoom {}%", zoom_percent), + None::, + false, + true, + None, + )); + entries.push(ContextMenuEntry::new( + "Zoom In", + self.shortcut_for_action(Action::ZoomIn), + false, + false, + Some(MenuCommand::ZoomIn), + )); + entries.push(ContextMenuEntry::new( + "Zoom Out", + self.shortcut_for_action(Action::ZoomOut), + false, + !zoom_active, + Some(MenuCommand::ZoomOut), + )); + entries.push(ContextMenuEntry::new( + "Reset Zoom", + self.shortcut_for_action(Action::ResetZoom), + false, + !zoom_active, + Some(MenuCommand::ResetZoom), + )); + + entries + } +} diff --git a/src/input/state/core/menus/lifecycle.rs b/src/input/state/core/menus/lifecycle.rs index fb9dc1e3..1a6e08b9 100644 --- a/src/input/state/core/menus/lifecycle.rs +++ b/src/input/state/core/menus/lifecycle.rs @@ -122,7 +122,7 @@ impl InputState { } fn keyboard_shape_menu_anchor(&self, ids: &[ShapeId]) -> (i32, i32) { - if let Some(bounds) = self.selection_bounding_box(ids) { + if let Some(bounds) = self.selection_screen_bounding_box(ids) { (bounds.x + bounds.width / 2, bounds.y + bounds.height / 2) } else { let (px, py) = self.last_pointer_position; diff --git a/src/input/state/core/menus/types.rs b/src/input/state/core/menus/types.rs index d97b0439..1336197c 100644 --- a/src/input/state/core/menus/types.rs +++ b/src/input/state/core/menus/types.rs @@ -5,6 +5,7 @@ use crate::draw::ShapeId; pub enum ContextMenuKind { Shape, Canvas, + Zoom, Pages, Boards, Page, @@ -38,6 +39,11 @@ pub enum MenuCommand { Properties, EditText, ClearAll, + ResetCanvasPosition, + OpenZoomMenu, + ZoomIn, + ZoomOut, + ResetZoom, ToggleHighlightTool, OpenPagesMenu, OpenPageMoveMenu, diff --git a/src/input/state/core/properties/panel.rs b/src/input/state/core/properties/panel.rs index 686ee115..7e23a22f 100644 --- a/src/input/state/core/properties/panel.rs +++ b/src/input/state/core/properties/panel.rs @@ -42,7 +42,8 @@ impl InputState { let panel = (|| { let ids = self.selected_shape_ids(); let frame = self.boards.active_frame(); - let anchor_rect = self.selection_bounding_box(ids); + let canvas_bounds = self.selection_bounding_box(ids); + let anchor_rect = self.selection_screen_bounding_box(ids); let anchor = selection_panel_anchor(anchor_rect, self.last_pointer_position); let entries = self.build_selection_property_entries(ids); @@ -57,7 +58,7 @@ impl InputState { if locked > 0 { lines.push(format!("Locked: {locked}/{total}")); } - if let Some(bounds) = anchor_rect { + if let Some(bounds) = canvas_bounds { lines.push(format!( "Bounds: {}×{} px", bounds.width.max(0), @@ -124,7 +125,8 @@ impl InputState { let entries = self.build_selection_property_entries(ids); let frame = self.boards.active_frame(); - let anchor_rect = self.selection_bounding_box(ids); + let canvas_bounds = self.selection_bounding_box(ids); + let anchor_rect = self.selection_screen_bounding_box(ids); let anchor = selection_panel_anchor(anchor_rect, self.last_pointer_position); let (title, lines, multiple_selection) = if ids.len() > 1 { @@ -138,7 +140,7 @@ impl InputState { if locked > 0 { lines.push(format!("Locked: {locked}/{total}")); } - if let Some(bounds) = anchor_rect { + if let Some(bounds) = canvas_bounds { lines.push(format!( "Bounds: {}×{} px", bounds.width.max(0), diff --git a/src/input/state/core/selection.rs b/src/input/state/core/selection.rs index c4c52f1a..b9fddb3c 100644 --- a/src/input/state/core/selection.rs +++ b/src/input/state/core/selection.rs @@ -113,4 +113,9 @@ impl InputState { None } } + + pub(crate) fn selection_screen_bounding_box(&self, ids: &[ShapeId]) -> Option { + self.selection_bounding_box(ids) + .and_then(|bounds| self.screen_rect_for_canvas(bounds)) + } } diff --git a/src/input/state/core/utility/frozen_zoom.rs b/src/input/state/core/utility/frozen_zoom.rs index 5a229f95..e4b7ad70 100644 --- a/src/input/state/core/utility/frozen_zoom.rs +++ b/src/input/state/core/utility/frozen_zoom.rs @@ -28,14 +28,24 @@ impl InputState { } /// Updates cached zoom status and triggers a redraw when it changes. - pub fn set_zoom_status(&mut self, active: bool, locked: bool, scale: f64) { + pub fn set_zoom_status( + &mut self, + active: bool, + locked: bool, + scale: f64, + view_offset: (f64, f64), + ) { let changed = self.zoom_active != active || self.zoom_locked != locked - || (self.zoom_scale - scale).abs() > f64::EPSILON; + || (self.zoom_scale - scale).abs() > f64::EPSILON + || (self.zoom_view_offset.0 - view_offset.0).abs() > f64::EPSILON + || (self.zoom_view_offset.1 - view_offset.1).abs() > f64::EPSILON; if changed { self.zoom_active = active; self.zoom_locked = locked; self.zoom_scale = scale; + self.zoom_view_offset = view_offset; + self.sync_canvas_pointer_to_current_transform(); self.dirty_tracker.mark_full(); self.needs_redraw = true; } @@ -132,14 +142,14 @@ mod tests { let mut state = make_state(); state.needs_redraw = false; - state.set_zoom_status(true, true, 2.0); + state.set_zoom_status(true, true, 2.0, (40.0, 60.0)); assert!(state.zoom_active()); assert!(state.zoom_locked()); assert_eq!(state.zoom_scale(), 2.0); assert!(state.needs_redraw); state.needs_redraw = false; - state.set_zoom_status(true, true, 2.0); + state.set_zoom_status(true, true, 2.0, (40.0, 60.0)); assert!(!state.needs_redraw); } } diff --git a/src/input/state/core/utility/interaction.rs b/src/input/state/core/utility/interaction.rs index 4517af91..b9070df6 100644 --- a/src/input/state/core/utility/interaction.rs +++ b/src/input/state/core/utility/interaction.rs @@ -2,17 +2,100 @@ use super::super::base::{DrawingState, InputState}; use crate::util::Rect; impl InputState { + fn board_view_offset(&self) -> (f64, f64) { + if self.board_is_transparent() || !self.boards.pan_enabled() { + (0.0, 0.0) + } else { + let (x, y) = self.boards.active_frame().view_offset(); + (x as f64, y as f64) + } + } + + fn current_canvas_scale(&self) -> f64 { + if self.zoom_active { + self.zoom_scale.max(f64::MIN_POSITIVE) + } else { + 1.0 + } + } + + fn current_canvas_origin(&self) -> (f64, f64) { + let (board_x, board_y) = self.board_view_offset(); + if self.zoom_active { + ( + board_x + self.zoom_view_offset.0, + board_y + self.zoom_view_offset.1, + ) + } else { + (board_x, board_y) + } + } + + fn canvas_coords_for_screen(&self, screen_x: i32, screen_y: i32) -> (i32, i32) { + let scale = self.current_canvas_scale(); + let (origin_x, origin_y) = self.current_canvas_origin(); + ( + (origin_x + screen_x as f64 / scale).round() as i32, + (origin_y + screen_y as f64 / scale).round() as i32, + ) + } + + pub(crate) fn sync_canvas_pointer_to_current_transform(&mut self) { + let (screen_x, screen_y) = self.last_pointer_position; + self.last_canvas_pointer_position = self.canvas_coords_for_screen(screen_x, screen_y); + } + + #[allow(dead_code)] // Kept for legacy input wrappers that translate canvas coords back to UI space. + pub(crate) fn screen_coords_for_canvas(&self, canvas_x: i32, canvas_y: i32) -> (i32, i32) { + let scale = self.current_canvas_scale(); + let (origin_x, origin_y) = self.current_canvas_origin(); + ( + ((canvas_x as f64 - origin_x) * scale).round() as i32, + ((canvas_y as f64 - origin_y) * scale).round() as i32, + ) + } + + pub(crate) fn screen_rect_for_canvas(&self, rect: Rect) -> Option { + let scale = self.current_canvas_scale(); + let (origin_x, origin_y) = self.current_canvas_origin(); + let min_x = ((rect.x as f64 - origin_x) * scale).floor() as i32; + let min_y = ((rect.y as f64 - origin_y) * scale).floor() as i32; + let max_x = (((rect.x + rect.width) as f64 - origin_x) * scale).ceil() as i32; + let max_y = (((rect.y + rect.height) as f64 - origin_y) * scale).ceil() as i32; + Rect::from_min_max(min_x, min_y, max_x, max_y) + } + /// Returns the last known pointer position. pub(crate) fn pointer_position(&self) -> (i32, i32) { self.last_pointer_position } + + /// Returns the last known pointer position in canvas/world coordinates. + #[allow(dead_code)] + pub(crate) fn canvas_pointer_position(&self) -> (i32, i32) { + self.last_canvas_pointer_position + } + /// Updates the cached pointer location. pub fn update_pointer_position(&mut self, x: i32, y: i32) { - self.last_pointer_position = (x, y); + let (canvas_x, canvas_y) = self.canvas_coords_for_screen(x, y); + self.update_pointer_positions(x, y, canvas_x, canvas_y); + } + + /// Updates cached screen and canvas pointer locations together. + pub fn update_pointer_positions( + &mut self, + screen_x: i32, + screen_y: i32, + canvas_x: i32, + canvas_y: i32, + ) { + self.last_pointer_position = (screen_x, screen_y); + self.last_canvas_pointer_position = (canvas_x, canvas_y); if self.click_highlight.update_tool_ring( self.highlight_tool_active(), - x, - y, + canvas_x, + canvas_y, &mut self.dirty_tracker, ) { self.needs_redraw = true; @@ -22,6 +105,7 @@ impl InputState { /// Updates the cached pointer location without triggering pointer-driven visuals. pub fn update_pointer_position_synthetic(&mut self, x: i32, y: i32) { self.last_pointer_position = (x, y); + self.last_canvas_pointer_position = self.canvas_coords_for_screen(x, y); } /// Updates the undo stack limit for subsequent actions. @@ -100,6 +184,7 @@ impl InputState { #[cfg(test)] mod tests { use super::*; + use crate::input::BOARD_ID_WHITEBOARD; use crate::input::state::test_support::make_test_input_state; #[test] @@ -110,9 +195,53 @@ mod tests { state.update_pointer_position_synthetic(12, 34); assert_eq!(state.pointer_position(), (12, 34)); + assert_eq!(state.canvas_pointer_position(), (12, 34)); assert!(!state.needs_redraw); } + #[test] + fn update_pointer_position_synthetic_preserves_canvas_transform() { + let mut state = make_test_input_state(); + state.set_zoom_status(true, false, 2.0, (100.0, 200.0)); + + state.update_pointer_position_synthetic(30, 40); + + assert_eq!(state.pointer_position(), (30, 40)); + assert_eq!(state.canvas_pointer_position(), (115, 220)); + } + + #[test] + fn update_pointer_position_uses_canvas_transform_for_screen_space_updates() { + let mut state = make_test_input_state(); + state.switch_board(BOARD_ID_WHITEBOARD); + assert!(state.boards.active_frame_mut().set_view_offset(100, 50)); + + state.update_pointer_position(30, 40); + + assert_eq!(state.pointer_position(), (30, 40)); + assert_eq!(state.canvas_pointer_position(), (130, 90)); + } + + #[test] + fn screen_rect_for_canvas_tracks_board_offset_after_pointer_cache_changes() { + let mut state = make_test_input_state(); + state.switch_board(BOARD_ID_WHITEBOARD); + assert!(state.boards.active_frame_mut().set_view_offset(100, 50)); + state.update_pointer_position(400, 300); + let rect = Rect::new(138, 88, 24, 24).expect("valid rect"); + + assert_eq!( + state.screen_rect_for_canvas(rect), + Rect::new(38, 38, 24, 24) + ); + + assert!(state.reset_active_canvas_position()); + assert_eq!( + state.screen_rect_for_canvas(rect), + Rect::new(138, 88, 24, 24) + ); + } + #[test] fn set_undo_stack_limit_clamps_to_at_least_one() { let mut state = make_test_input_state(); diff --git a/src/input/state/mouse/motion.rs b/src/input/state/mouse/motion.rs index 1d6d8410..d436f4e4 100644 --- a/src/input/state/mouse/motion.rs +++ b/src/input/state/mouse/motion.rs @@ -14,11 +14,23 @@ impl InputState { /// # Behavior /// - When drawing with Pen tool: Adds points to the freehand stroke /// - When drawing with other tools: Triggers redraw for live preview + #[allow(dead_code)] // Retained for older callers that only have canvas coordinates. pub fn on_mouse_motion(&mut self, x: i32, y: i32) { - self.update_pointer_position(x, y); + let (screen_x, screen_y) = self.screen_coords_for_canvas(x, y); + self.on_mouse_motion_with_canvas(screen_x, screen_y, x, y); + } + + pub fn on_mouse_motion_with_canvas( + &mut self, + screen_x: i32, + screen_y: i32, + canvas_x: i32, + canvas_y: i32, + ) { + self.update_pointer_positions(screen_x, screen_y, canvas_x, canvas_y); if self.is_radial_menu_open() { - self.update_radial_menu_hover(x as f64, y as f64); + self.update_radial_menu_hover(screen_x as f64, screen_y as f64); return; } @@ -26,8 +38,8 @@ impl InputState { if self.color_picker_popup_is_dragging() && let Some(layout) = self.color_picker_popup_layout() { - let fx = x as f64; - let fy = y as f64; + let fx = screen_x as f64; + let fy = screen_y as f64; let norm_x = ((fx - layout.gradient_x) / layout.gradient_w).clamp(0.0, 1.0); let norm_y = ((fy - layout.gradient_y) / layout.gradient_h).clamp(0.0, 1.0); self.color_picker_popup_set_from_gradient(norm_x, norm_y); @@ -37,11 +49,11 @@ impl InputState { if self.is_board_picker_open() { if self.board_picker_is_page_dragging() { - self.board_picker_update_page_drag_from_pointer(x, y); + self.board_picker_update_page_drag_from_pointer(screen_x, screen_y); } else if self.board_picker_is_dragging() { - self.board_picker_update_drag_from_pointer(x, y); + self.board_picker_update_drag_from_pointer(screen_x, screen_y); } else { - self.update_board_picker_hover_from_pointer(x, y); + self.update_board_picker_hover_from_pointer(screen_x, screen_y); } return; } @@ -50,7 +62,7 @@ impl InputState { if self.properties_panel_layout().is_none() { return; } - self.update_properties_panel_hover_from_pointer(x, y); + self.update_properties_panel_hover_from_pointer(screen_x, screen_y); return; } @@ -61,7 +73,7 @@ impl InputState { .. } = &self.state { - let new_width = self.clamp_text_wrap_width(*base_x, x, *size); + let new_width = self.clamp_text_wrap_width(*base_x, canvas_x, *size); let _ = self.update_text_wrap_width(*shape_id, new_width); return; } @@ -73,15 +85,15 @@ impl InputState { .. } = &self.state { - let dx = x - *start_x; - let dy = y - *start_y; + let dx = canvas_x - *start_x; + let dy = canvas_y - *start_y; if dx.abs() >= TEXT_CLICK_DRAG_THRESHOLD || dy.abs() >= TEXT_CLICK_DRAG_THRESHOLD { let tool = *tool; if tool != Tool::Highlight && tool != Tool::Select { let mut points = vec![(*start_x, *start_y)]; let mut point_thicknesses = vec![self.current_thickness as f32]; if tool == Tool::Pen || tool == Tool::Marker || tool == Tool::Eraser { - points.push((x, y)); + points.push((canvas_x, canvas_y)); point_thicknesses.push(self.current_thickness as f32); } self.state = DrawingState::Drawing { @@ -93,7 +105,7 @@ impl InputState { }; self.last_text_click = None; self.last_provisional_bounds = None; - self.update_provisional_dirty(x, y); + self.update_provisional_dirty(canvas_x, canvas_y); self.needs_redraw = true; } } @@ -101,8 +113,8 @@ impl InputState { } if let DrawingState::MovingSelection { last_x, last_y, .. } = &self.state { - let dx = x - *last_x; - let dy = y - *last_y; + let dx = canvas_x - *last_x; + let dy = canvas_y - *last_y; if (dx != 0 || dy != 0) && self.apply_translation_to_selection(dx, dy) && let DrawingState::MovingSelection { @@ -112,8 +124,8 @@ impl InputState { .. } = &mut self.state { - *last_x = x; - *last_y = y; + *last_x = canvas_x; + *last_y = canvas_y; *moved = true; } return; @@ -127,8 +139,8 @@ impl InputState { snapshots, } = &self.state { - let dx = x - *start_x; - let dy = y - *start_y; + let dx = canvas_x - *start_x; + let dy = canvas_y - *start_y; let handle = *handle; let original_bounds = *original_bounds; let snapshots = Arc::clone(snapshots); @@ -138,13 +150,13 @@ impl InputState { } if matches!(self.state, DrawingState::Selecting { .. }) { - self.update_provisional_dirty(x, y); + self.update_provisional_dirty(canvas_x, canvas_y); self.needs_redraw = true; return; } if self.is_context_menu_open() { - self.update_context_menu_hover_from_pointer(x, y); + self.update_context_menu_hover_from_pointer(screen_x, screen_y); return; } @@ -157,14 +169,14 @@ impl InputState { } = &mut self.state { if *tool == Tool::Pen || *tool == Tool::Marker || *tool == Tool::Eraser { - points.push((x, y)); + points.push((canvas_x, canvas_y)); point_thicknesses.push(self.current_thickness as f32); } drawing = true; } if drawing { - self.update_provisional_dirty(x, y); + self.update_provisional_dirty(canvas_x, canvas_y); self.needs_redraw = true; } else if self.eraser_mode == EraserMode::Stroke && self.active_tool() == Tool::Eraser @@ -174,3 +186,21 @@ impl InputState { } } } + +#[cfg(test)] +mod tests { + use crate::input::BOARD_ID_WHITEBOARD; + use crate::input::state::test_support::make_test_input_state; + + #[test] + fn on_mouse_motion_wrapper_preserves_screen_coords_from_canvas_transform() { + let mut state = make_test_input_state(); + state.switch_board(BOARD_ID_WHITEBOARD); + assert!(state.boards.active_frame_mut().set_view_offset(100, 200)); + + state.on_mouse_motion(130, 240); + + assert_eq!(state.pointer_position(), (30, 40)); + assert_eq!(state.canvas_pointer_position(), (130, 240)); + } +} diff --git a/src/input/state/mouse/press.rs b/src/input/state/mouse/press.rs index 92bfa678..b5b4fdff 100644 --- a/src/input/state/mouse/press.rs +++ b/src/input/state/mouse/press.rs @@ -22,8 +22,8 @@ impl InputState { && self.is_radial_menu_toggle_button(button) } - fn handle_right_click(&mut self, x: i32, y: i32) { - self.update_pointer_position(x, y); + fn handle_right_click(&mut self, screen_x: i32, screen_y: i32, canvas_x: i32, canvas_y: i32) { + self.update_pointer_positions(screen_x, screen_y, canvas_x, canvas_y); self.last_text_click = None; if !matches!(self.state, DrawingState::Idle) { match &self.state { @@ -70,7 +70,7 @@ impl InputState { return; } - let hit_shape = self.hit_test_at(x, y); + let hit_shape = self.hit_test_at(canvas_x, canvas_y); let mut focus_edit = false; if let Some(id) = hit_shape { if self.modifiers.shift { @@ -88,13 +88,23 @@ impl InputState { matches!(shape.shape, Shape::Text { .. } | Shape::StickyNote { .. }) }) .unwrap_or(false); - self.open_context_menu((x, y), selection, ContextMenuKind::Shape, hit_shape); + self.open_context_menu( + (screen_x, screen_y), + selection, + ContextMenuKind::Shape, + hit_shape, + ); } else { self.clear_selection(); - self.open_context_menu((x, y), Vec::new(), ContextMenuKind::Canvas, None); + self.open_context_menu( + (screen_x, screen_y), + Vec::new(), + ContextMenuKind::Canvas, + None, + ); } - self.update_context_menu_hover_from_pointer(x, y); + self.update_context_menu_hover_from_pointer(screen_x, screen_y); if focus_edit { self.focus_context_menu_command(MenuCommand::EditText); } @@ -128,20 +138,33 @@ impl InputState { /// - Left click while Idle: Starts drawing with the current tool (based on modifiers) /// - Left click during TextInput: Updates text position /// - Right click: Cancels current action + #[allow(dead_code)] // Retained for older callers that only have canvas coordinates. pub fn on_mouse_press(&mut self, button: MouseButton, x: i32, y: i32) { - if self.handle_radial_menu_press(button, x, y) { + let (screen_x, screen_y) = self.screen_coords_for_canvas(x, y); + self.on_mouse_press_with_canvas(button, screen_x, screen_y, x, y); + } + + pub fn on_mouse_press_with_canvas( + &mut self, + button: MouseButton, + screen_x: i32, + screen_y: i32, + canvas_x: i32, + canvas_y: i32, + ) { + if self.handle_radial_menu_press(button, screen_x, screen_y, canvas_x, canvas_y) { return; } - if self.handle_color_picker_press(button, x, y) { + if self.handle_color_picker_press(button, screen_x, screen_y) { return; } - if self.handle_board_picker_press(button, x, y) { + if self.handle_board_picker_press(button, screen_x, screen_y) { return; } - if self.handle_properties_panel_press(button, x, y) { + if self.handle_properties_panel_press(button, screen_x, screen_y) { return; } @@ -149,19 +172,19 @@ impl InputState { match button { MouseButton::Right => { if self.should_toggle_radial_menu_from_mouse(MouseButton::Right) { - self.toggle_radial_menu(x as f64, y as f64); + self.toggle_radial_menu(screen_x as f64, screen_y as f64); } else { - self.handle_right_click(x, y); + self.handle_right_click(screen_x, screen_y, canvas_x, canvas_y); } } MouseButton::Left => { - self.update_pointer_position(x, y); - self.trigger_click_highlight(x, y); + self.update_pointer_positions(screen_x, screen_y, canvas_x, canvas_y); + self.trigger_click_highlight(canvas_x, canvas_y); if self.is_context_menu_open() { self.last_text_click = None; - if self.is_point_in_context_menu(x, y) { - self.update_context_menu_hover_from_pointer(x, y); + if self.is_point_in_context_menu(screen_x, screen_y) { + self.update_context_menu_hover_from_pointer(screen_x, screen_y); } else { self.close_context_menu(); self.needs_redraw = true; @@ -170,10 +193,10 @@ impl InputState { } match &mut self.state { - DrawingState::Idle => self.handle_idle_left_click(x, y), + DrawingState::Idle => self.handle_idle_left_click(canvas_x, canvas_y), DrawingState::TextInput { x: tx, y: ty, .. } => { - *tx = x; - *ty = y; + *tx = canvas_x; + *ty = canvas_y; self.update_text_preview_dirty(); self.needs_redraw = true; } @@ -187,7 +210,7 @@ impl InputState { } MouseButton::Middle => { if self.should_toggle_radial_menu_from_mouse(MouseButton::Middle) { - self.toggle_radial_menu(x as f64, y as f64); + self.toggle_radial_menu(screen_x as f64, screen_y as f64); } } } @@ -309,15 +332,22 @@ impl InputState { } } - fn handle_radial_menu_press(&mut self, button: MouseButton, x: i32, y: i32) -> bool { + fn handle_radial_menu_press( + &mut self, + button: MouseButton, + screen_x: i32, + screen_y: i32, + canvas_x: i32, + canvas_y: i32, + ) -> bool { if !self.is_radial_menu_open() { return false; } - self.update_pointer_position(x, y); + self.update_pointer_positions(screen_x, screen_y, canvas_x, canvas_y); match button { MouseButton::Left => { // Update hover at exact click position before selecting - self.update_radial_menu_hover(x as f64, y as f64); + self.update_radial_menu_hover(screen_x as f64, screen_y as f64); self.radial_menu_select_hovered(); } MouseButton::Right => { @@ -325,7 +355,7 @@ impl InputState { if !self.is_radial_menu_toggle_button(MouseButton::Right) { // Keep right-click context-menu flow when right button is not the // configured radial-menu trigger. - self.handle_right_click(x, y); + self.handle_right_click(screen_x, screen_y, canvas_x, canvas_y); } } MouseButton::Middle => { diff --git a/src/input/state/mouse/release/mod.rs b/src/input/state/mouse/release/mod.rs index c9e5368d..3e4b46b9 100644 --- a/src/input/state/mouse/release/mod.rs +++ b/src/input/state/mouse/release/mod.rs @@ -20,8 +20,21 @@ impl InputState { /// - Finalizes the shape using start position and current position /// - Adds the completed shape to the frame /// - Returns to Idle state + #[allow(dead_code)] // Retained for older callers that only have canvas coordinates. pub fn on_mouse_release(&mut self, button: MouseButton, x: i32, y: i32) { - self.update_pointer_position(x, y); + let (screen_x, screen_y) = self.screen_coords_for_canvas(x, y); + self.on_mouse_release_with_canvas(button, screen_x, screen_y, x, y); + } + + pub fn on_mouse_release_with_canvas( + &mut self, + button: MouseButton, + screen_x: i32, + screen_y: i32, + canvas_x: i32, + canvas_y: i32, + ) { + self.update_pointer_positions(screen_x, screen_y, canvas_x, canvas_y); // Radial menu uses press for selection, ignore release if self.is_radial_menu_open() { @@ -29,16 +42,16 @@ impl InputState { } if button == MouseButton::Left { - if panels::handle_color_picker_popup_release(self, x, y) { + if panels::handle_color_picker_popup_release(self, screen_x, screen_y) { return; } - if panels::handle_context_menu_release(self, x, y) { + if panels::handle_context_menu_release(self, screen_x, screen_y) { return; } - if panels::handle_board_picker_release(self, x, y) { + if panels::handle_board_picker_release(self, screen_x, screen_y) { return; } - if panels::handle_properties_panel_release(self, x, y) { + if panels::handle_properties_panel_release(self, screen_x, screen_y) { return; } } @@ -59,7 +72,9 @@ impl InputState { start_y, additive, } => { - selection::finish_selection_drag(self, start_x, start_y, x, y, additive); + selection::finish_selection_drag( + self, start_x, start_y, canvas_x, canvas_y, additive, + ); } DrawingState::ResizingText { shape_id, snapshot, .. @@ -81,7 +96,7 @@ impl InputState { tool, drawing::DrawingRelease { start: (start_x, start_y), - end: (x, y), + end: (canvas_x, canvas_y), points, point_thicknesses, }, diff --git a/src/input/state/tests/menus/context_menu.rs b/src/input/state/tests/menus/context_menu.rs index 1c70255c..6972b847 100644 --- a/src/input/state/tests/menus/context_menu.rs +++ b/src/input/state/tests/menus/context_menu.rs @@ -1,6 +1,6 @@ use super::*; use crate::draw::{BoardPages, Frame}; -use crate::input::{BOARD_ID_BLACKBOARD, BOARD_ID_TRANSPARENT}; +use crate::input::{BOARD_ID_BLACKBOARD, BOARD_ID_TRANSPARENT, BOARD_ID_WHITEBOARD}; fn board_index(state: &InputState, id: &str) -> usize { state @@ -92,6 +92,37 @@ fn shape_menu_includes_select_this_entry_whenever_hovered() { ); } +#[test] +fn shape_menu_includes_reset_canvas_position_on_solid_boards() { + let mut state = create_test_input_state(); + state.switch_board(BOARD_ID_BLACKBOARD); + let shape_id = state.boards.active_frame_mut().add_shape(Shape::Rect { + x: 10, + y: 10, + w: 20, + h: 20, + fill: false, + color: state.current_color, + thick: state.current_thickness, + }); + + state.set_selection(vec![shape_id]); + state.open_context_menu( + (0, 0), + vec![shape_id], + ContextMenuKind::Shape, + Some(shape_id), + ); + + let entries = state.context_menu_entries(); + let entry = entries + .iter() + .find(|entry| entry.label == "Reset Canvas Position") + .expect("reset canvas position entry should exist on solid-board shape menus"); + assert_eq!(entry.shortcut.as_deref(), Some("Space+Drag")); + assert!(entry.disabled); +} + #[test] fn select_this_shape_command_focuses_single_shape() { let mut state = create_test_input_state(); @@ -224,6 +255,20 @@ fn context_menu_includes_radial_menu_entry() { assert_eq!(radial_entry.command, Some(MenuCommand::OpenRadialMenu)); } +#[test] +fn context_menu_includes_zoom_submenu_entry() { + let mut state = create_test_input_state(); + state.toggle_context_menu_via_keyboard(); + + let zoom_entry = state + .context_menu_entries() + .into_iter() + .find(|entry| entry.label == "Zoom") + .expect("zoom submenu entry should exist in context menu"); + assert_eq!(zoom_entry.command, Some(MenuCommand::OpenZoomMenu)); + assert!(zoom_entry.has_submenu); +} + #[test] fn context_menu_radial_entry_shows_default_mouse_shortcut() { let mut state = create_test_input_state(); @@ -285,6 +330,56 @@ fn context_menu_radial_entry_shows_keyboard_shortcut_when_mouse_binding_disabled assert_eq!(radial_entry.shortcut.as_deref(), Some("Ctrl+R")); } +#[test] +fn open_zoom_menu_command_switches_to_zoom_submenu_with_actionable_focus() { + let mut state = create_test_input_state(); + state.open_context_menu((12, 34), Vec::new(), ContextMenuKind::Canvas, None); + + state.execute_menu_command(MenuCommand::OpenZoomMenu); + + let focus_index = match &state.context_menu_state { + ContextMenuState::Open { + kind: ContextMenuKind::Zoom, + keyboard_focus, + .. + } => keyboard_focus.expect("zoom submenu focus"), + _ => panic!("expected zoom submenu to be open"), + }; + let entries = state.context_menu_entries(); + assert!(!entries[focus_index].disabled); + assert!(entries[focus_index].command.is_some()); +} + +#[test] +fn zoom_menu_disables_out_and_reset_when_zoom_is_inactive() { + let mut state = create_test_input_state(); + state.open_context_menu((12, 34), Vec::new(), ContextMenuKind::Zoom, None); + + let entries = state.context_menu_entries(); + let zoom_out = entries + .iter() + .find(|entry| entry.command == Some(MenuCommand::ZoomOut)) + .expect("zoom out entry"); + let reset_zoom = entries + .iter() + .find(|entry| entry.command == Some(MenuCommand::ResetZoom)) + .expect("reset zoom entry"); + + assert!(zoom_out.disabled); + assert!(reset_zoom.disabled); +} + +#[test] +fn zoom_in_command_queues_zoom_action_and_closes_menu() { + let mut state = create_test_input_state(); + state.open_context_menu((12, 34), Vec::new(), ContextMenuKind::Zoom, None); + + state.execute_menu_command(MenuCommand::ZoomIn); + + assert_eq!(state.take_pending_zoom_action(), Some(ZoomAction::In)); + assert!(!state.is_context_menu_open()); +} + #[test] fn context_menu_open_radial_command_opens_radial_and_closes_context_menu() { let mut state = create_test_input_state(); @@ -458,6 +553,35 @@ fn open_boards_menu_command_switches_to_boards_submenu_with_actionable_focus() { assert!(entries[focus_index].command.is_some()); } +#[test] +fn keyboard_shape_menu_anchor_tracks_panned_board_view_offset() { + let mut state = create_test_input_state(); + state.switch_board(BOARD_ID_WHITEBOARD); + assert!(state.boards.active_frame_mut().set_view_offset(100, 50)); + state.update_pointer_position(400, 300); + let shape_id = state.boards.active_frame_mut().add_shape(Shape::Rect { + x: 140, + y: 90, + w: 20, + h: 20, + fill: false, + color: state.current_color, + thick: state.current_thickness, + }); + state.set_selection(vec![shape_id]); + + state.toggle_context_menu_via_keyboard(); + + match state.context_menu_state { + ContextMenuState::Open { + kind: ContextMenuKind::Shape, + anchor, + .. + } => assert_eq!(anchor, (50, 50)), + _ => panic!("expected keyboard shape context menu"), + } +} + #[test] fn page_duplicate_from_context_duplicates_target_page_and_closes_menu() { let mut state = create_test_input_state(); diff --git a/src/input/state/tests/pages.rs b/src/input/state/tests/pages.rs index 6a6ce03a..99b8ea16 100644 --- a/src/input/state/tests/pages.rs +++ b/src/input/state/tests/pages.rs @@ -120,6 +120,20 @@ fn move_page_between_boards_copy_preserves_source_and_adds_page_to_target() { ); } +#[test] +fn reset_active_canvas_position_clears_view_offset_on_solid_board() { + let mut state = create_test_input_state(); + state.switch_board(BOARD_ID_WHITEBOARD); + assert!(state.boards.active_frame_mut().set_view_offset(180, -90)); + + assert!(state.reset_active_canvas_position()); + assert_eq!(state.boards.active_frame().view_offset(), (0, 0)); + assert_eq!( + state.ui_toast.as_ref().map(|toast| toast.message.as_str()), + Some("Canvas position reset.") + ); +} + #[test] fn move_page_between_boards_move_removes_source_page_and_activates_target_copy() { let mut state = create_test_input_state(); diff --git a/src/input/state/tests/properties_panel.rs b/src/input/state/tests/properties_panel.rs index 92f9e27b..1d0177e0 100644 --- a/src/input/state/tests/properties_panel.rs +++ b/src/input/state/tests/properties_panel.rs @@ -1,4 +1,6 @@ use super::*; +use crate::input::BOARD_ID_WHITEBOARD; +use crate::util::Rect; fn add_rect(state: &mut InputState, x: i32, y: i32, w: i32, h: i32) -> crate::draw::ShapeId { state.boards.active_frame_mut().add_shape(Shape::Rect { @@ -83,6 +85,21 @@ fn close_properties_panel_clears_panel_and_requests_redraw() { assert!(state.needs_redraw); } +#[test] +fn show_properties_panel_anchors_to_screen_space_on_panned_boards() { + let mut state = create_test_input_state(); + state.switch_board(BOARD_ID_WHITEBOARD); + assert!(state.boards.active_frame_mut().set_view_offset(100, 50)); + state.update_pointer_position(400, 300); + let shape_id = add_rect(&mut state, 140, 90, 20, 20); + state.set_selection(vec![shape_id]); + + assert!(state.show_properties_panel()); + + let panel = state.properties_panel().expect("properties panel"); + assert_eq!(panel.anchor_rect, Rect::new(38, 38, 24, 24)); +} + #[test] fn activate_fill_entry_toggles_rectangle_fill_and_refreshes_panel_value() { let mut state = create_test_input_state(); diff --git a/src/input/state/tests/radial_menu.rs b/src/input/state/tests/radial_menu.rs index 2ef82217..64cb583b 100644 --- a/src/input/state/tests/radial_menu.rs +++ b/src/input/state/tests/radial_menu.rs @@ -1,5 +1,6 @@ use super::helpers::create_test_input_state_with_keybindings; use super::*; +use crate::input::BOARD_ID_WHITEBOARD; use std::f64::consts::PI; fn point_at(cx: f64, cy: f64, radius: f64, degrees: f64) -> (f64, f64) { @@ -170,6 +171,40 @@ fn right_click_when_radial_open_opens_context_menu() { assert!(state.is_context_menu_open()); } +#[test] +fn right_click_when_radial_open_on_panned_board_uses_canvas_hit_testing() { + let mut state = create_test_input_state(); + state.switch_board(BOARD_ID_WHITEBOARD); + assert!(state.boards.active_frame_mut().set_view_offset(100, 50)); + let shape_id = state.boards.active_frame_mut().add_shape(Shape::Line { + x1: 140, + y1: 90, + x2: 180, + y2: 90, + color: state.current_color, + thick: state.current_thickness, + }); + state.open_radial_menu(50.0, 40.0); + assert!(state.is_radial_menu_open()); + + state.on_mouse_press_with_canvas(MouseButton::Right, 50, 40, 150, 90); + + assert!(!state.is_radial_menu_open()); + match &state.context_menu_state { + ContextMenuState::Open { + kind, + shape_ids, + hovered_shape_id, + .. + } => { + assert_eq!(*kind, ContextMenuKind::Shape); + assert_eq!(shape_ids.as_slice(), &[shape_id]); + assert_eq!(*hovered_shape_id, Some(shape_id)); + } + ContextMenuState::Hidden => panic!("context menu should be open"), + } +} + #[test] fn selecting_clear_tool_segment_clears_canvas() { let mut state = create_test_input_state(); diff --git a/src/ui.rs b/src/ui.rs index fc7503f5..68c2361f 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -26,8 +26,8 @@ pub use onboarding_card::{OnboardingCard, OnboardingChecklistItem, render_onboar pub use properties_panel::render_properties_panel; pub use radial_menu::render_radial_menu; pub use status::{ - render_editing_badge, render_frozen_badge, render_page_badge, render_status_bar, - render_zoom_badge, + render_editing_badge, render_frozen_badge, render_page_badge, render_pan_badge, + render_status_bar, render_zoom_badge, }; pub use toasts::{render_blocked_feedback, render_preset_toast, render_ui_toast}; pub use tour::render_tour; diff --git a/src/ui/status/badges.rs b/src/ui/status/badges.rs index 19e1a45c..25022bc6 100644 --- a/src/ui/status/badges.rs +++ b/src/ui/status/badges.rs @@ -82,6 +82,49 @@ pub fn render_zoom_badge( layout.show_at_baseline(ctx, x + (padding * 0.7), y - (padding * 0.35)); } +/// Render a small badge indicating canvas pan is available on the active solid board. +pub fn render_pan_badge( + ctx: &cairo::Context, + screen_width: u32, + _screen_height: u32, + panned: bool, + offset_y: f64, +) { + let label = if panned { + "PANNED SPACE+DRAG" + } else { + "PAN SPACE+DRAG" + }; + let padding = 12.0; + let radius = 8.0; + let font_size = 14.0; + let layout = text_layout( + ctx, + UiTextStyle { + family: "Sans", + slant: cairo::FontSlant::Normal, + weight: cairo::FontWeight::Bold, + size: font_size, + }, + label, + None, + ); + let extents = layout.ink_extents(); + + let width = extents.width() + padding * 1.4; + let height = extents.height() + padding; + + let x = screen_width as f64 - width - padding; + let y = padding + offset_y + height; + + ctx.set_source_rgba(0.33, 0.44, 0.24, 0.92); + draw_rounded_rect(ctx, x, y - height, width, height, radius); + let _ = ctx.fill(); + + ctx.set_source_rgba(1.0, 1.0, 1.0, 1.0); + layout.show_at_baseline(ctx, x + (padding * 0.7), y - (padding * 0.35)); +} + /// Render a small badge indicating text edit mode is active. pub fn render_editing_badge( ctx: &cairo::Context, diff --git a/src/ui/status/bar.rs b/src/ui/status/bar.rs index e642fa79..51d61fa2 100644 --- a/src/ui/status/bar.rs +++ b/src/ui/status/bar.rs @@ -103,6 +103,18 @@ pub fn render_status_bar( } else { String::new() }; + let pan_badge = if input_state.boards.pan_enabled() + && input_state.boards.show_pan_badge() + && !input_state.board_is_transparent() + { + if input_state.boards.active_frame().view_offset() == (0, 0) { + "[PAN Space+Drag] ".to_string() + } else { + "[PANNED Space+Drag] ".to_string() + } + } else { + String::new() + }; let selection_badge = if let Some(bounds) = input_state.selection_bounds() { let count = input_state.selected_shape_ids().len(); @@ -116,9 +128,10 @@ pub fn render_status_bar( }; let status_text = format!( - "{}{}{}{}{}{}[{}] [{}px] [{}] [Text {}px]{}{} {}={}", + "{}{}{}{}{}{}{}[{}] [{}px] [{}] [Text {}px]{}{} {}={}", frozen_badge, zoom_badge, + pan_badge, selection_badge, output_badge, board_badge, diff --git a/src/ui/status/mod.rs b/src/ui/status/mod.rs index 7f99dfee..1087a5a6 100644 --- a/src/ui/status/mod.rs +++ b/src/ui/status/mod.rs @@ -1,5 +1,6 @@ mod badges; mod bar; +pub use badges::render_pan_badge; pub use badges::{render_editing_badge, render_frozen_badge, render_page_badge, render_zoom_badge}; pub use bar::render_status_bar;