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;