diff --git a/config.example.toml b/config.example.toml index c821b589..e2429ee6 100644 --- a/config.example.toml +++ b/config.example.toml @@ -565,6 +565,13 @@ toggle_whiteboard = ["Ctrl+W"] toggle_blackboard = ["Ctrl+B"] return_to_transparent = ["Ctrl+Shift+T"] +# Page navigation +page_prev = [] +page_next = [] +page_new = ["Ctrl+Alt+N"] +page_duplicate = ["Ctrl+Alt+D"] +page_delete = ["Ctrl+Alt+Delete"] + # Toggle help overlay toggle_help = ["F10", "F1"] diff --git a/configurator/src/models/keybindings.rs b/configurator/src/models/keybindings.rs index 6f96ca32..27ff955f 100644 --- a/configurator/src/models/keybindings.rs +++ b/configurator/src/models/keybindings.rs @@ -61,6 +61,11 @@ pub enum KeybindingField { ToggleWhiteboard, ToggleBlackboard, ReturnToTransparent, + PagePrev, + PageNext, + PageNew, + PageDuplicate, + PageDelete, ToggleHelp, ToggleStatusBar, ToggleClickHighlight, @@ -205,6 +210,11 @@ impl KeybindingField { Self::ToggleWhiteboard, Self::ToggleBlackboard, Self::ReturnToTransparent, + Self::PagePrev, + Self::PageNext, + Self::PageNew, + Self::PageDuplicate, + Self::PageDelete, Self::ToggleHelp, Self::ToggleStatusBar, Self::ToggleClickHighlight, @@ -302,6 +312,11 @@ impl KeybindingField { Self::ToggleWhiteboard => "Toggle whiteboard", Self::ToggleBlackboard => "Toggle blackboard", Self::ReturnToTransparent => "Return to transparent", + Self::PagePrev => "Page: previous", + Self::PageNext => "Page: next", + Self::PageNew => "Page: new", + Self::PageDuplicate => "Page: duplicate", + Self::PageDelete => "Page: delete", Self::ToggleHelp => "Toggle help", Self::ToggleStatusBar => "Toggle status bar", Self::ToggleClickHighlight => "Toggle click highlight", @@ -399,6 +414,11 @@ impl KeybindingField { Self::ToggleWhiteboard => "toggle_whiteboard", Self::ToggleBlackboard => "toggle_blackboard", Self::ReturnToTransparent => "return_to_transparent", + Self::PagePrev => "page_prev", + Self::PageNext => "page_next", + Self::PageNew => "page_new", + Self::PageDuplicate => "page_duplicate", + Self::PageDelete => "page_delete", Self::ToggleHelp => "toggle_help", Self::ToggleStatusBar => "toggle_status_bar", Self::ToggleClickHighlight => "toggle_click_highlight", @@ -506,6 +526,11 @@ impl KeybindingField { Self::ToggleWhiteboard | Self::ToggleBlackboard | Self::ReturnToTransparent + | Self::PagePrev + | Self::PageNext + | Self::PageNew + | Self::PageDuplicate + | Self::PageDelete | Self::ToggleHelp | Self::ToggleStatusBar | Self::ToggleClickHighlight @@ -592,6 +617,11 @@ impl KeybindingField { Self::ToggleWhiteboard => &config.toggle_whiteboard, Self::ToggleBlackboard => &config.toggle_blackboard, Self::ReturnToTransparent => &config.return_to_transparent, + Self::PagePrev => &config.page_prev, + Self::PageNext => &config.page_next, + Self::PageNew => &config.page_new, + Self::PageDuplicate => &config.page_duplicate, + Self::PageDelete => &config.page_delete, Self::ToggleHelp => &config.toggle_help, Self::ToggleStatusBar => &config.toggle_status_bar, Self::ToggleClickHighlight => &config.toggle_click_highlight, @@ -689,6 +719,11 @@ impl KeybindingField { Self::ToggleWhiteboard => config.toggle_whiteboard = value, Self::ToggleBlackboard => config.toggle_blackboard = value, Self::ReturnToTransparent => config.return_to_transparent = value, + Self::PagePrev => config.page_prev = value, + Self::PageNext => config.page_next = value, + Self::PageNew => config.page_new = value, + Self::PageDuplicate => config.page_duplicate = value, + Self::PageDelete => config.page_delete = value, Self::ToggleHelp => config.toggle_help = value, Self::ToggleStatusBar => config.toggle_status_bar = value, Self::ToggleClickHighlight => config.toggle_click_highlight = value, diff --git a/docs/CONFIG.md b/docs/CONFIG.md index ead825b7..2c603e32 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -550,6 +550,13 @@ toggle_whiteboard = ["Ctrl+W"] toggle_blackboard = ["Ctrl+B"] return_to_transparent = ["Ctrl+Shift+T"] +# Page navigation +page_prev = [] +page_next = [] +page_new = ["Ctrl+Alt+N"] +page_duplicate = ["Ctrl+Alt+D"] +page_delete = ["Ctrl+Alt+Delete"] + # Toggle help overlay toggle_help = ["F10"] diff --git a/src/backend/wayland/state/render.rs b/src/backend/wayland/state/render.rs index c9f18455..82548235 100644 --- a/src/backend/wayland/state/render.rs +++ b/src/backend/wayland/state/render.rs @@ -397,6 +397,14 @@ impl WaylandState { self.input_state.zoom_locked(), ); } + if !self.input_state.show_status_bar { + let mode = self.input_state.board_mode(); + let page_count = self.input_state.canvas_set.page_count(mode); + if page_count > 1 { + let page_index = self.input_state.canvas_set.active_page_index(mode); + crate::ui::render_page_badge(&ctx, width, height, page_index, page_count); + } + } // Render status bar if enabled if self.input_state.show_status_bar { diff --git a/src/backend/wayland/toolbar/layout.rs b/src/backend/wayland/toolbar/layout.rs index e88d8fe2..3550a728 100644 --- a/src/backend/wayland/toolbar/layout.rs +++ b/src/backend/wayland/toolbar/layout.rs @@ -174,6 +174,7 @@ impl ToolbarLayoutSpec { let show_text_controls = snapshot.text_active || snapshot.note_active || snapshot.show_text_controls; let show_actions = snapshot.show_actions_section || snapshot.show_actions_advanced; + let show_pages = snapshot.show_actions_advanced; let show_presets = snapshot.show_presets && snapshot.preset_slot_count.min(snapshot.presets.len()) > 0; let show_step_section = snapshot.show_step_section; @@ -207,6 +208,11 @@ impl ToolbarLayoutSpec { add_section(actions_card_h, &mut height); } + if show_pages { + let pages_h = self.side_pages_height(snapshot); + add_section(pages_h, &mut height); + } + if show_step_section { let step_h = self.side_step_height(snapshot); add_section(step_h, &mut height); @@ -351,6 +357,18 @@ impl ToolbarLayoutSpec { } } + pub(super) fn side_pages_height(&self, snapshot: &ToolbarSnapshot) -> f64 { + if !snapshot.show_actions_advanced { + return 0.0; + } + let btn_h = if self.use_icons { + Self::SIDE_ACTION_BUTTON_HEIGHT_ICON + } else { + Self::SIDE_ACTION_BUTTON_HEIGHT_TEXT + }; + Self::SIDE_SECTION_TOGGLE_OFFSET_Y + btn_h + Self::SIDE_ACTION_BUTTON_GAP + } + pub(super) fn side_step_height(&self, snapshot: &ToolbarSnapshot) -> f64 { let delay_h = if snapshot.show_delay_sliders { Self::SIDE_DELAY_SECTION_HEIGHT @@ -1067,6 +1085,38 @@ pub fn build_side_hits( y += actions_card_h + section_gap; } + if snapshot.show_actions_advanced { + let pages_card_h = spec.side_pages_height(snapshot); + let pages_y = y + ToolbarLayoutSpec::SIDE_SECTION_TOGGLE_OFFSET_Y; + let btn_h = if use_icons { + ToolbarLayoutSpec::SIDE_ACTION_BUTTON_HEIGHT_ICON + } else { + ToolbarLayoutSpec::SIDE_ACTION_BUTTON_HEIGHT_TEXT + }; + let btn_gap = ToolbarLayoutSpec::SIDE_ACTION_BUTTON_GAP; + let btn_w = (content_width - btn_gap * 4.0) / 5.0; + let buttons = [ + (ToolbarEvent::PagePrev, "Prev"), + (ToolbarEvent::PageNext, "Next"), + (ToolbarEvent::PageNew, "New"), + (ToolbarEvent::PageDuplicate, "Dup"), + (ToolbarEvent::PageDelete, "Del"), + ]; + for (idx, (evt, label)) in buttons.iter().enumerate() { + let bx = x + (btn_w + btn_gap) * idx as f64; + hits.push(HitRegion { + rect: (bx, pages_y, btn_w, btn_h), + event: evt.clone(), + kind: HitKind::Click, + tooltip: Some(super::format_binding_label( + label, + snapshot.binding_hints.binding_for_event(evt), + )), + }); + } + y += pages_card_h + section_gap; + } + // Delay sliders if snapshot.show_step_section && snapshot.show_delay_sliders { let undo_t = delay_t_from_ms(snapshot.undo_all_delay_ms); diff --git a/src/backend/wayland/toolbar/render.rs b/src/backend/wayland/toolbar/render.rs index 25fdec94..6bbd6a25 100644 --- a/src/backend/wayland/toolbar/render.rs +++ b/src/backend/wayland/toolbar/render.rs @@ -1968,6 +1968,94 @@ pub fn render_side_palette( y += actions_card_h + section_gap; } + if snapshot.show_actions_advanced { + let pages_card_h = spec.side_pages_height(snapshot); + draw_group_card(ctx, card_x, y, card_w, pages_card_h); + draw_section_label( + ctx, + x, + y + ToolbarLayoutSpec::SIDE_SECTION_LABEL_OFFSET_TALL, + "Pages", + ); + + let pages_y = y + ToolbarLayoutSpec::SIDE_SECTION_TOGGLE_OFFSET_Y; + let btn_h = if use_icons { + ToolbarLayoutSpec::SIDE_ACTION_BUTTON_HEIGHT_ICON + } else { + ToolbarLayoutSpec::SIDE_ACTION_BUTTON_HEIGHT_TEXT + }; + let btn_gap = ToolbarLayoutSpec::SIDE_ACTION_BUTTON_GAP; + let btn_w = (content_width - btn_gap * 4.0) / 5.0; + let can_prev = snapshot.page_index > 0; + let can_next = snapshot.page_index + 1 < snapshot.page_count; + let buttons = [ + ( + ToolbarEvent::PagePrev, + "Prev", + toolbar_icons::draw_icon_undo as fn(&cairo::Context, f64, f64, f64), + can_prev, + ), + ( + ToolbarEvent::PageNext, + "Next", + toolbar_icons::draw_icon_redo as fn(&cairo::Context, f64, f64, f64), + can_next, + ), + ( + ToolbarEvent::PageNew, + "New", + toolbar_icons::draw_icon_plus as fn(&cairo::Context, f64, f64, f64), + true, + ), + ( + ToolbarEvent::PageDuplicate, + "Dup", + toolbar_icons::draw_icon_save as fn(&cairo::Context, f64, f64, f64), + true, + ), + ( + ToolbarEvent::PageDelete, + "Del", + toolbar_icons::draw_icon_clear as fn(&cairo::Context, f64, f64, f64), + true, + ), + ]; + + for (idx, (evt, label, icon_fn, enabled)) in buttons.iter().enumerate() { + let bx = x + (btn_w + btn_gap) * idx as f64; + let is_hover = hover + .map(|(hx, hy)| point_in_rect(hx, hy, bx, pages_y, btn_w, btn_h)) + .unwrap_or(false); + draw_button(ctx, bx, pages_y, btn_w, btn_h, *enabled, is_hover); + if use_icons { + if *enabled { + set_icon_color(ctx, is_hover); + } else { + ctx.set_source_rgba(0.5, 0.5, 0.55, 0.5); + } + let icon_size = ToolbarLayoutSpec::SIDE_ACTION_ICON_SIZE; + let icon_x = bx + (btn_w - icon_size) / 2.0; + let icon_y = pages_y + (btn_h - icon_size) / 2.0; + icon_fn(ctx, icon_x, icon_y, icon_size); + } else { + draw_label_center(ctx, bx, pages_y, btn_w, btn_h, label); + } + if *enabled { + hits.push(HitRegion { + rect: (bx, pages_y, btn_w, btn_h), + event: evt.clone(), + kind: HitKind::Click, + tooltip: Some(format_binding_label( + label, + snapshot.binding_hints.binding_for_event(evt), + )), + }); + } + } + + y += pages_card_h + section_gap; + } + if snapshot.show_step_section { let custom_toggle_h = ToolbarLayoutSpec::SIDE_TOGGLE_HEIGHT; let toggle_gap = ToolbarLayoutSpec::SIDE_TOGGLE_GAP; diff --git a/src/config/keybindings.rs b/src/config/keybindings.rs index 695e0cba..e9ce9dbc 100644 --- a/src/config/keybindings.rs +++ b/src/config/keybindings.rs @@ -64,6 +64,13 @@ pub enum Action { ToggleBlackboard, ReturnToTransparent, + // Page navigation + PagePrev, + PageNext, + PageNew, + PageDuplicate, + PageDelete, + // UI toggles ToggleHelp, ToggleStatusBar, @@ -353,6 +360,21 @@ pub struct KeybindingsConfig { #[serde(default = "default_return_to_transparent")] pub return_to_transparent: Vec, + #[serde(default = "default_page_prev")] + pub page_prev: Vec, + + #[serde(default = "default_page_next")] + pub page_next: Vec, + + #[serde(default = "default_page_new")] + pub page_new: Vec, + + #[serde(default = "default_page_duplicate")] + pub page_duplicate: Vec, + + #[serde(default = "default_page_delete")] + pub page_delete: Vec, + #[serde(default = "default_toggle_help")] pub toggle_help: Vec, #[serde(default = "default_toggle_status_bar")] @@ -528,6 +550,11 @@ impl Default for KeybindingsConfig { toggle_whiteboard: default_toggle_whiteboard(), toggle_blackboard: default_toggle_blackboard(), return_to_transparent: default_return_to_transparent(), + page_prev: default_page_prev(), + page_next: default_page_next(), + page_new: default_page_new(), + page_duplicate: default_page_duplicate(), + page_delete: default_page_delete(), toggle_help: default_toggle_help(), toggle_status_bar: default_toggle_status_bar(), toggle_click_highlight: default_toggle_click_highlight(), @@ -777,6 +804,26 @@ impl KeybindingsConfig { insert_binding(binding_str, Action::ReturnToTransparent)?; } + for binding_str in &self.page_prev { + insert_binding(binding_str, Action::PagePrev)?; + } + + for binding_str in &self.page_next { + insert_binding(binding_str, Action::PageNext)?; + } + + for binding_str in &self.page_new { + insert_binding(binding_str, Action::PageNew)?; + } + + for binding_str in &self.page_duplicate { + insert_binding(binding_str, Action::PageDuplicate)?; + } + + for binding_str in &self.page_delete { + insert_binding(binding_str, Action::PageDelete)?; + } + // Ensure help is reachable via F1 even if older configs only include F10. let mut help_bindings = if self.toggle_help.is_empty() { default_toggle_help() @@ -1149,6 +1196,26 @@ fn default_return_to_transparent() -> Vec { vec!["Ctrl+Shift+T".to_string()] } +fn default_page_prev() -> Vec { + Vec::new() +} + +fn default_page_next() -> Vec { + Vec::new() +} + +fn default_page_new() -> Vec { + vec!["Ctrl+Alt+N".to_string()] +} + +fn default_page_duplicate() -> Vec { + vec!["Ctrl+Alt+D".to_string()] +} + +fn default_page_delete() -> Vec { + vec!["Ctrl+Alt+Delete".to_string()] +} + fn default_toggle_help() -> Vec { vec!["F10".to_string(), "F1".to_string()] } diff --git a/src/draw/canvas_set.rs b/src/draw/canvas_set.rs index 7470b9f4..3c02a301 100644 --- a/src/draw/canvas_set.rs +++ b/src/draw/canvas_set.rs @@ -4,45 +4,175 @@ use super::Frame; use crate::input::BoardMode; use std::sync::LazyLock; +/// Collection of pages for a single board mode. +#[derive(Debug, Clone)] +pub struct BoardPages { + pages: Vec, + active: usize, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum PageDeleteOutcome { + Removed, + Cleared, +} + +impl Default for BoardPages { + fn default() -> Self { + Self::new() + } +} + +impl BoardPages { + pub fn new() -> Self { + Self { + pages: vec![Frame::new()], + active: 0, + } + } + + pub fn from_pages(mut pages: Vec, active: usize) -> Self { + if pages.is_empty() { + pages.push(Frame::new()); + } + let active = active.min(pages.len() - 1); + Self { pages, active } + } + + pub fn page_count(&self) -> usize { + self.pages.len() + } + + pub fn active_index(&self) -> usize { + self.active + } + + pub fn active_frame(&self) -> &Frame { + &self.pages[self.active] + } + + pub fn active_frame_mut(&mut self) -> &mut Frame { + &mut self.pages[self.active] + } + + pub fn next_page(&mut self) -> bool { + if self.active + 1 < self.pages.len() { + self.active += 1; + true + } else { + false + } + } + + pub fn prev_page(&mut self) -> bool { + if self.active > 0 { + self.active -= 1; + true + } else { + false + } + } + + pub fn new_page(&mut self) { + self.pages.push(Frame::new()); + self.active = self.pages.len() - 1; + } + + pub fn duplicate_page(&mut self) { + let cloned = self.active_frame().clone_without_history(); + self.pages.push(cloned); + self.active = self.pages.len() - 1; + } + + pub fn delete_page(&mut self) -> PageDeleteOutcome { + if self.pages.len() == 1 { + self.pages[0].clear(); + PageDeleteOutcome::Cleared + } else { + self.pages.remove(self.active); + if self.active >= self.pages.len() { + self.active = self.pages.len() - 1; + } + PageDeleteOutcome::Removed + } + } + + #[allow(dead_code)] + pub fn trim_trailing_empty_pages(&mut self) { + while self.pages.len() > 1 + && self + .pages + .last() + .is_some_and(|frame| !frame.has_persistable_data()) + { + self.pages.pop(); + if self.active >= self.pages.len() { + self.active = self.pages.len() - 1; + } + } + } + + pub fn pages(&self) -> &[Frame] { + &self.pages + } + + pub fn pages_mut(&mut self) -> &mut Vec { + &mut self.pages + } +} + /// Manages multiple frames, one per board mode (with lazy initialization). /// /// This structure maintains separate drawing frames for each board mode: -/// - Transparent mode always has a frame (used for screen annotation) -/// - Whiteboard and Blackboard frames are lazily created on first use +/// - Transparent mode always has pages (used for screen annotation) +/// - Whiteboard and Blackboard pages are lazily created on first use /// /// This design allows seamless mode switching while preserving work, /// and saves memory when board modes are never activated. pub struct CanvasSet { - /// Frame for transparent overlay mode (always exists) - transparent: Frame, - /// Frame for whiteboard mode (lazy: created on first use) - whiteboard: Option, - /// Frame for blackboard mode (lazy: created on first use) - blackboard: Option, + /// Pages for transparent overlay mode (always exists) + transparent: BoardPages, + /// Pages for whiteboard mode (lazy: created on first use) + whiteboard: Option, + /// Pages for blackboard mode (lazy: created on first use) + blackboard: Option, /// Currently active mode active_mode: BoardMode, } impl CanvasSet { - /// Creates a new canvas set with only the transparent frame initialized. + /// Creates a new canvas set with only the transparent pages initialized. pub fn new() -> Self { Self { - transparent: Frame::new(), + transparent: BoardPages::new(), whiteboard: None, blackboard: None, active_mode: BoardMode::Transparent, } } + fn ensure_pages_mut(&mut self, mode: BoardMode) -> &mut BoardPages { + match mode { + BoardMode::Transparent => &mut self.transparent, + BoardMode::Whiteboard => self.whiteboard.get_or_insert_with(BoardPages::new), + BoardMode::Blackboard => self.blackboard.get_or_insert_with(BoardPages::new), + } + } + + fn pages_or_empty(&self, mode: BoardMode) -> &BoardPages { + static EMPTY_PAGES: LazyLock = LazyLock::new(BoardPages::new); + match mode { + BoardMode::Transparent => &self.transparent, + BoardMode::Whiteboard => self.whiteboard.as_ref().unwrap_or(&EMPTY_PAGES), + BoardMode::Blackboard => self.blackboard.as_ref().unwrap_or(&EMPTY_PAGES), + } + } + /// Gets the currently active frame (mutable). /// - /// Lazily creates whiteboard/blackboard frames if they don't exist yet. + /// Lazily creates whiteboard/blackboard pages if they don't exist yet. pub fn active_frame_mut(&mut self) -> &mut Frame { - match self.active_mode { - BoardMode::Transparent => &mut self.transparent, - BoardMode::Whiteboard => self.whiteboard.get_or_insert_with(Frame::new), - BoardMode::Blackboard => self.blackboard.get_or_insert_with(Frame::new), - } + self.ensure_pages_mut(self.active_mode).active_frame_mut() } /// Gets the currently active frame (immutable). @@ -50,13 +180,7 @@ impl CanvasSet { /// For board modes that don't exist yet, returns a reference to a static empty frame /// instead of creating one (since we can't mutate in an immutable method). pub fn active_frame(&self) -> &Frame { - static EMPTY_FRAME: LazyLock = LazyLock::new(Frame::new); - - match self.active_mode { - BoardMode::Transparent => &self.transparent, - BoardMode::Whiteboard => self.whiteboard.as_ref().unwrap_or(&EMPTY_FRAME), - BoardMode::Blackboard => self.blackboard.as_ref().unwrap_or(&EMPTY_FRAME), - } + self.pages_or_empty(self.active_mode).active_frame() } /// Returns the current active board mode. @@ -66,7 +190,7 @@ impl CanvasSet { /// Switches to a different board mode. /// - /// This does not create frames lazily - they are created when first accessed + /// This does not create pages lazily - they are created when first accessed /// via `active_frame_mut()`. pub fn switch_mode(&mut self, new_mode: BoardMode) { self.active_mode = new_mode; @@ -78,8 +202,8 @@ impl CanvasSet { self.active_frame_mut().clear(); } - /// Returns an immutable reference to the frame for the requested mode, if it exists. - pub fn frame(&self, mode: BoardMode) -> Option<&Frame> { + /// Returns an immutable reference to the pages for the requested mode, if it exists. + pub fn pages(&self, mode: BoardMode) -> Option<&BoardPages> { match mode { BoardMode::Transparent => Some(&self.transparent), BoardMode::Whiteboard => self.whiteboard.as_ref(), @@ -87,8 +211,8 @@ impl CanvasSet { } } - /// Returns a mutable reference to the frame for the requested mode, if it exists. - pub fn frame_mut(&mut self, mode: BoardMode) -> Option<&mut Frame> { + /// Returns a mutable reference to the pages for the requested mode, if it exists. + pub fn pages_mut(&mut self, mode: BoardMode) -> Option<&mut BoardPages> { match mode { BoardMode::Transparent => Some(&mut self.transparent), BoardMode::Whiteboard => self.whiteboard.as_mut(), @@ -96,20 +220,64 @@ impl CanvasSet { } } - /// Replaces the frame for the requested mode with the provided data. - pub fn set_frame(&mut self, mode: BoardMode, frame: Option) { + /// Returns the active frame for the requested mode, if it exists. + #[allow(dead_code)] + pub fn frame(&self, mode: BoardMode) -> Option<&Frame> { + self.pages(mode).map(|pages| pages.active_frame()) + } + + /// Returns the active frame for the requested mode, if it exists. + #[allow(dead_code)] + pub fn frame_mut(&mut self, mode: BoardMode) -> Option<&mut Frame> { + self.pages_mut(mode).map(|pages| pages.active_frame_mut()) + } + + /// Replaces the pages for the requested mode with the provided data. + pub fn set_pages(&mut self, mode: BoardMode, pages: Option) { match mode { BoardMode::Transparent => { - self.transparent = frame.unwrap_or_default(); + self.transparent = pages.unwrap_or_default(); } BoardMode::Whiteboard => { - self.whiteboard = frame; + self.whiteboard = pages; } BoardMode::Blackboard => { - self.blackboard = frame; + self.blackboard = pages; } } } + + pub fn page_count(&self, mode: BoardMode) -> usize { + self.pages(mode) + .map(|pages| pages.page_count()) + .unwrap_or(1) + } + + pub fn active_page_index(&self, mode: BoardMode) -> usize { + self.pages(mode) + .map(|pages| pages.active_index()) + .unwrap_or(0) + } + + pub fn next_page(&mut self, mode: BoardMode) -> bool { + self.ensure_pages_mut(mode).next_page() + } + + pub fn prev_page(&mut self, mode: BoardMode) -> bool { + self.ensure_pages_mut(mode).prev_page() + } + + pub fn new_page(&mut self, mode: BoardMode) { + self.ensure_pages_mut(mode).new_page(); + } + + pub fn duplicate_page(&mut self, mode: BoardMode) { + self.ensure_pages_mut(mode).duplicate_page(); + } + + pub fn delete_page(&mut self, mode: BoardMode) -> PageDeleteOutcome { + self.ensure_pages_mut(mode).delete_page() + } } impl Default for CanvasSet { @@ -293,7 +461,46 @@ mod tests { // Accessing a non-existent board frame immutably should work // (returns empty frame reference, doesn't create it) - // This test demonstrates the static EMPTY_FRAME pattern + // This test demonstrates the static EMPTY_PAGES pattern + assert_eq!(canvas_set.active_frame().shapes.len(), 0); + } + + #[test] + fn test_page_navigation_and_delete() { + let mut canvas_set = CanvasSet::new(); + assert_eq!(canvas_set.page_count(BoardMode::Transparent), 1); + assert_eq!(canvas_set.active_page_index(BoardMode::Transparent), 0); + assert!(!canvas_set.next_page(BoardMode::Transparent)); + + canvas_set.new_page(BoardMode::Transparent); + assert_eq!(canvas_set.page_count(BoardMode::Transparent), 2); + assert_eq!(canvas_set.active_page_index(BoardMode::Transparent), 1); + assert!(canvas_set.prev_page(BoardMode::Transparent)); + assert_eq!(canvas_set.active_page_index(BoardMode::Transparent), 0); + + canvas_set.duplicate_page(BoardMode::Transparent); + assert_eq!(canvas_set.page_count(BoardMode::Transparent), 3); + assert_eq!(canvas_set.active_page_index(BoardMode::Transparent), 2); + + let outcome = canvas_set.delete_page(BoardMode::Transparent); + assert_eq!(outcome, PageDeleteOutcome::Removed); + assert_eq!(canvas_set.page_count(BoardMode::Transparent), 2); + } + + #[test] + fn test_delete_last_page_clears() { + let mut canvas_set = CanvasSet::new(); + canvas_set.active_frame_mut().add_shape(Shape::Line { + x1: 0, + y1: 0, + x2: 10, + y2: 10, + color: RED, + thick: 2.0, + }); + + let outcome = canvas_set.delete_page(BoardMode::Transparent); + assert_eq!(outcome, PageDeleteOutcome::Cleared); assert_eq!(canvas_set.active_frame().shapes.len(), 0); } } diff --git a/src/draw/frame.rs b/src/draw/frame.rs index 2f1e011a..1bdee1fd 100644 --- a/src/draw/frame.rs +++ b/src/draw/frame.rs @@ -174,6 +174,14 @@ impl Frame { self.next_shape_id = 1; } + /// Clone the frame's shapes while dropping history. + pub fn clone_without_history(&self) -> Self { + let mut frame = Frame::new(); + frame.shapes = self.shapes.clone(); + frame.rebuild_next_id(); + frame + } + #[allow(dead_code)] /// Returns the number of shapes in the frame. pub fn len(&self) -> usize { diff --git a/src/draw/mod.rs b/src/draw/mod.rs index 00a88162..7e4fc4cd 100644 --- a/src/draw/mod.rs +++ b/src/draw/mod.rs @@ -16,7 +16,7 @@ pub mod shape; // Re-export commonly used types at module level #[allow(unused_imports)] -pub use canvas_set::CanvasSet; +pub use canvas_set::{BoardPages, CanvasSet, PageDeleteOutcome}; pub use color::Color; pub use dirty::DirtyTracker; pub use font::FontDescriptor; diff --git a/src/input/state/actions.rs b/src/input/state/actions.rs index 1e5594f7..abaf6d75 100644 --- a/src/input/state/actions.rs +++ b/src/input/state/actions.rs @@ -1,5 +1,5 @@ use crate::config::Action; -use crate::draw::Shape; +use crate::draw::{PageDeleteOutcome, Shape}; use crate::input::{ZoomAction, board_mode::BoardMode, events::Key, tool::Tool}; use crate::util; use log::{info, warn}; @@ -733,6 +733,36 @@ impl InputState { self.switch_board_mode(BoardMode::Transparent); } } + Action::PagePrev => { + if self.page_prev() { + info!("Switched to previous page"); + } else { + self.set_ui_toast(UiToastKind::Info, "Already on the first page."); + } + } + Action::PageNext => { + if self.page_next() { + info!("Switched to next page"); + } else { + self.set_ui_toast(UiToastKind::Info, "Already on the last page."); + } + } + Action::PageNew => { + self.page_new(); + info!("Created new page"); + } + Action::PageDuplicate => { + self.page_duplicate(); + info!("Duplicated page"); + } + Action::PageDelete => match self.page_delete() { + PageDeleteOutcome::Removed => { + info!("Deleted page"); + } + PageDeleteOutcome::Cleared => { + self.set_ui_toast(UiToastKind::Info, "Cleared the last page."); + } + }, Action::ToggleHelp => { self.show_help = !self.show_help; self.dirty_tracker.mark_full(); diff --git a/src/input/state/core/board.rs b/src/input/state/core/board.rs index 41ec07eb..dd227485 100644 --- a/src/input/state/core/board.rs +++ b/src/input/state/core/board.rs @@ -77,4 +77,52 @@ impl InputState { log::info!("Switched from {:?} to {:?} mode", current_mode, target_mode); } + + fn prepare_page_switch(&mut self) { + self.cancel_active_interaction(); + self.clear_selection(); + self.close_context_menu(); + self.invalidate_hit_cache(); + self.dirty_tracker.mark_full(); + self.needs_redraw = true; + } + + pub fn page_prev(&mut self) -> bool { + let mode = self.canvas_set.active_mode(); + if self.canvas_set.prev_page(mode) { + self.prepare_page_switch(); + true + } else { + false + } + } + + pub fn page_next(&mut self) -> bool { + let mode = self.canvas_set.active_mode(); + if self.canvas_set.next_page(mode) { + self.prepare_page_switch(); + true + } else { + false + } + } + + pub fn page_new(&mut self) { + let mode = self.canvas_set.active_mode(); + self.canvas_set.new_page(mode); + self.prepare_page_switch(); + } + + pub fn page_duplicate(&mut self) { + let mode = self.canvas_set.active_mode(); + self.canvas_set.duplicate_page(mode); + self.prepare_page_switch(); + } + + pub fn page_delete(&mut self) -> crate::draw::PageDeleteOutcome { + let mode = self.canvas_set.active_mode(); + let outcome = self.canvas_set.delete_page(mode); + self.prepare_page_switch(); + outcome + } } diff --git a/src/input/state/core/menus.rs b/src/input/state/core/menus.rs index 119654da..82689271 100644 --- a/src/input/state/core/menus.rs +++ b/src/input/state/core/menus.rs @@ -1,4 +1,4 @@ -use super::base::InputState; +use super::base::{InputState, UiToastKind}; use crate::draw::ShapeId; use crate::input::board_mode::BoardMode; use crate::util::Rect; @@ -9,6 +9,7 @@ use cairo::Context as CairoContext; pub enum ContextMenuKind { Shape, Canvas, + Pages, } /// Tracks the context menu lifecycle. @@ -39,6 +40,12 @@ pub enum MenuCommand { EditText, ClearAll, ToggleHighlightTool, + OpenPagesMenu, + PagePrev, + PageNext, + PageNew, + PageDuplicate, + PageDelete, SwitchToWhiteboard, SwitchToBlackboard, ReturnToTransparent, @@ -102,6 +109,7 @@ impl InputState { } => match kind { ContextMenuKind::Canvas => self.canvas_menu_entries(), ContextMenuKind::Shape => self.shape_menu_entries(shape_ids, *hovered_shape_id), + ContextMenuKind::Pages => self.pages_menu_entries(), }, } } @@ -271,6 +279,13 @@ impl InputState { false, Some(MenuCommand::ToggleHighlightTool), )); + entries.push(ContextMenuEntry::new( + "Pages", + None::, + true, + false, + Some(MenuCommand::OpenPagesMenu), + )); match self.canvas_set.active_mode() { BoardMode::Transparent => { @@ -340,6 +355,59 @@ impl InputState { entries } + fn pages_menu_entries(&self) -> Vec { + let mode = self.canvas_set.active_mode(); + let page_count = self.canvas_set.page_count(mode); + let page_index = self.canvas_set.active_page_index(mode); + let can_prev = page_index > 0; + let can_next = page_index + 1 < page_count; + + vec![ + ContextMenuEntry::new( + format!("Page {}/{}", page_index + 1, page_count.max(1)), + None::, + false, + true, + None, + ), + ContextMenuEntry::new( + "Previous Page", + None::, + false, + !can_prev, + Some(MenuCommand::PagePrev), + ), + ContextMenuEntry::new( + "Next Page", + None::, + false, + !can_next, + Some(MenuCommand::PageNext), + ), + ContextMenuEntry::new( + "New Page", + Some("Ctrl+Alt+N"), + false, + false, + Some(MenuCommand::PageNew), + ), + ContextMenuEntry::new( + "Duplicate Page", + Some("Ctrl+Alt+D"), + false, + false, + Some(MenuCommand::PageDuplicate), + ), + ContextMenuEntry::new( + "Delete Page", + Some("Ctrl+Alt+Delete"), + false, + false, + Some(MenuCommand::PageDelete), + ), + ] + } + fn shape_menu_entries( &self, ids: &[ShapeId], @@ -826,6 +894,45 @@ impl InputState { self.toggle_all_highlights(); self.close_context_menu(); } + MenuCommand::OpenPagesMenu => { + 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::Pages, None); + self.pending_menu_hover_recalc = false; + self.set_context_menu_focus(None); + self.focus_first_context_menu_entry(); + self.needs_redraw = true; + } + MenuCommand::PagePrev => { + self.page_prev(); + self.close_context_menu(); + } + MenuCommand::PageNext => { + self.page_next(); + self.close_context_menu(); + } + MenuCommand::PageNew => { + self.page_new(); + self.close_context_menu(); + } + MenuCommand::PageDuplicate => { + self.page_duplicate(); + self.close_context_menu(); + } + MenuCommand::PageDelete => { + if matches!(self.page_delete(), crate::draw::PageDeleteOutcome::Cleared) { + self.set_ui_toast(UiToastKind::Info, "Cleared the last page."); + } + self.close_context_menu(); + } MenuCommand::SwitchToWhiteboard => { self.switch_board_mode(BoardMode::Whiteboard); self.close_context_menu(); diff --git a/src/input/state/core/utility.rs b/src/input/state/core/utility.rs index f71a6e36..cdc9e0e5 100644 --- a/src/input/state/core/utility.rs +++ b/src/input/state/core/utility.rs @@ -40,6 +40,41 @@ impl InputState { self.needs_redraw = true; } + /// Cancels any in-progress interaction without exiting the application. + pub(crate) fn cancel_active_interaction(&mut self) { + match &self.state { + DrawingState::TextInput { .. } => { + self.cancel_text_input(); + } + DrawingState::PendingTextClick { .. } => { + self.state = DrawingState::Idle; + } + DrawingState::Drawing { .. } => { + self.clear_provisional_dirty(); + self.last_provisional_bounds = None; + self.state = DrawingState::Idle; + self.needs_redraw = true; + } + DrawingState::MovingSelection { snapshots, .. } => { + self.restore_selection_from_snapshots(snapshots.clone()); + self.state = DrawingState::Idle; + } + DrawingState::Selecting { .. } => { + self.clear_provisional_dirty(); + self.last_provisional_bounds = None; + self.state = DrawingState::Idle; + self.needs_redraw = true; + } + DrawingState::ResizingText { + shape_id, snapshot, .. + } => { + self.restore_selection_from_snapshots(vec![(*shape_id, snapshot.clone())]); + self.state = DrawingState::Idle; + } + DrawingState::Idle => {} + } + } + /// Drains pending dirty rectangles for the current surface size. #[allow(dead_code)] pub fn take_dirty_regions(&mut self) -> Vec { diff --git a/src/session/mod.rs b/src/session/mod.rs index 11246cd4..f655a98b 100644 --- a/src/session/mod.rs +++ b/src/session/mod.rs @@ -16,8 +16,8 @@ pub use options::{ }; #[allow(unused_imports)] pub use snapshot::{ - SessionSnapshot, ToolStateSnapshot, apply_snapshot, load_snapshot, save_snapshot, - snapshot_from_input, + BoardPagesSnapshot, SessionSnapshot, ToolStateSnapshot, apply_snapshot, load_snapshot, + save_snapshot, snapshot_from_input, }; #[allow(unused_imports)] pub use storage::{ClearOutcome, FrameCounts, SessionInspection, clear_session, inspect_session}; diff --git a/src/session/snapshot.rs b/src/session/snapshot.rs index 2ad0666e..c345172c 100644 --- a/src/session/snapshot.rs +++ b/src/session/snapshot.rs @@ -1,6 +1,6 @@ use super::options::{CompressionMode, SessionOptions}; use crate::draw::frame::{MAX_COMPOUND_DEPTH, ShapeId}; -use crate::draw::{Color, EraserKind, Frame}; +use crate::draw::{BoardPages, Color, EraserKind, Frame}; use crate::input::{ EraserMode, InputState, Tool, board_mode::BoardMode, @@ -19,28 +19,43 @@ use std::io::{Read, Write}; use std::path::{Path, PathBuf}; use std::str::FromStr; -const CURRENT_VERSION: u32 = 3; +const CURRENT_VERSION: u32 = 4; /// Captured state suitable for serialisation or restoration. #[derive(Debug, Clone)] pub struct SessionSnapshot { pub active_mode: BoardMode, - pub transparent: Option, - pub whiteboard: Option, - pub blackboard: Option, + pub transparent: Option, + pub whiteboard: Option, + pub blackboard: Option, pub tool_state: Option, } +#[derive(Debug, Clone)] +pub struct BoardPagesSnapshot { + pub pages: Vec, + pub active: usize, +} + +impl BoardPagesSnapshot { + fn has_persistable_data(&self) -> bool { + if self.pages.len() > 1 || self.active > 0 { + return true; + } + self.pages.iter().any(|page| page.has_persistable_data()) + } +} + impl SessionSnapshot { fn is_empty(&self) -> bool { - let empty_frame = |frame: &Option| { - frame + let empty_pages = |pages: &Option| { + pages .as_ref() .is_none_or(|data| !data.has_persistable_data()) }; - empty_frame(&self.transparent) - && empty_frame(&self.whiteboard) - && empty_frame(&self.blackboard) + empty_pages(&self.transparent) + && empty_pages(&self.whiteboard) + && empty_pages(&self.blackboard) } } @@ -111,12 +126,24 @@ struct SessionFile { version: u32, last_modified: String, active_mode: String, - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] transparent: Option, - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] whiteboard: Option, - #[serde(default)] + #[serde(default, skip_serializing_if = "Option::is_none")] blackboard: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + transparent_pages: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + whiteboard_pages: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + blackboard_pages: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + transparent_active_page: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + whiteboard_active_page: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + blackboard_active_page: Option, #[serde(default)] tool_state: Option, } @@ -146,31 +173,33 @@ pub fn snapshot_from_input( let history_limit = options.effective_history_limit(input.undo_stack_limit); - let capture_frame = |mode: BoardMode| -> Option { - let frame = input.canvas_set.frame(mode)?; - let mut cloned = frame.clone(); - if history_limit == 0 { - cloned.clamp_history_depth(0); - } else if history_limit < usize::MAX { - cloned.clamp_history_depth(history_limit); - } - if cloned.has_persistable_data() { - Some(cloned) - } else { - None + let capture_pages = |mode: BoardMode| -> Option { + let pages = input.canvas_set.pages(mode)?; + let mut cloned_pages: Vec = pages.pages().to_vec(); + for page in &mut cloned_pages { + if history_limit == 0 { + page.clamp_history_depth(0); + } else if history_limit < usize::MAX { + page.clamp_history_depth(history_limit); + } } + let snapshot = BoardPagesSnapshot { + pages: cloned_pages, + active: pages.active_index(), + }; + snapshot.has_persistable_data().then_some(snapshot) }; if options.persist_transparent { - snapshot.transparent = capture_frame(BoardMode::Transparent); + snapshot.transparent = capture_pages(BoardMode::Transparent); } if options.persist_whiteboard { - snapshot.whiteboard = capture_frame(BoardMode::Whiteboard); + snapshot.whiteboard = capture_pages(BoardMode::Whiteboard); } if options.persist_blackboard { - snapshot.blackboard = capture_frame(BoardMode::Blackboard); + snapshot.blackboard = capture_pages(BoardMode::Blackboard); } if options.restore_tool_state { @@ -242,13 +271,23 @@ fn save_snapshot_inner(snapshot: &SessionSnapshot, options: &SessionOptions) -> return Ok(()); } + let transparent = snapshot.transparent.clone(); + let whiteboard = snapshot.whiteboard.clone(); + let blackboard = snapshot.blackboard.clone(); + let file_payload = SessionFile { version: CURRENT_VERSION, last_modified: now_rfc3339(), active_mode: board_mode_to_str(snapshot.active_mode).to_string(), - transparent: snapshot.transparent.clone(), - whiteboard: snapshot.whiteboard.clone(), - blackboard: snapshot.blackboard.clone(), + transparent: None, + whiteboard: None, + blackboard: None, + transparent_pages: transparent.as_ref().map(|pages| pages.pages.clone()), + whiteboard_pages: whiteboard.as_ref().map(|pages| pages.pages.clone()), + blackboard_pages: blackboard.as_ref().map(|pages| pages.pages.clone()), + transparent_active_page: transparent.as_ref().map(|pages| pages.active), + whiteboard_active_page: whiteboard.as_ref().map(|pages| pages.active), + blackboard_active_page: blackboard.as_ref().map(|pages| pages.active), tool_state: snapshot.tool_state.clone(), }; @@ -496,12 +535,43 @@ pub(crate) fn load_snapshot_inner( let active_mode = BoardMode::from_str(&session_file.active_mode).unwrap_or(BoardMode::Transparent); + let SessionFile { + transparent, + whiteboard, + blackboard, + transparent_pages, + whiteboard_pages, + blackboard_pages, + transparent_active_page, + whiteboard_active_page, + blackboard_active_page, + tool_state, + .. + } = session_file; + + let pages_from_file = |pages: Option>, + active: Option, + legacy: Option| + -> Option { + if let Some(mut pages) = pages { + if pages.is_empty() { + pages.push(Frame::new()); + } + let active = active.unwrap_or(0).min(pages.len() - 1); + return Some(BoardPagesSnapshot { pages, active }); + } + legacy.map(|frame| BoardPagesSnapshot { + pages: vec![frame], + active: 0, + }) + }; + let mut snapshot = SessionSnapshot { active_mode, - transparent: session_file.transparent, - whiteboard: session_file.whiteboard, - blackboard: session_file.blackboard, - tool_state: session_file.tool_state, + transparent: pages_from_file(transparent_pages, transparent_active_page, transparent), + whiteboard: pages_from_file(whiteboard_pages, whiteboard_active_page, whiteboard), + blackboard: pages_from_file(blackboard_pages, blackboard_active_page, blackboard), + tool_state, }; enforce_shape_limits(&mut snapshot, options.max_shapes_per_frame); @@ -549,9 +619,10 @@ pub fn apply_snapshot(input: &mut InputState, snapshot: SessionSnapshot, options let runtime_history_limit = options.effective_history_limit(input.undo_stack_limit); if options.persist_transparent { - input - .canvas_set - .set_frame(BoardMode::Transparent, snapshot.transparent); + input.canvas_set.set_pages( + BoardMode::Transparent, + snapshot.transparent.map(snapshot_to_board_pages), + ); clamp_runtime_history( &mut input.canvas_set, BoardMode::Transparent, @@ -559,9 +630,10 @@ pub fn apply_snapshot(input: &mut InputState, snapshot: SessionSnapshot, options ); } if options.persist_whiteboard { - input - .canvas_set - .set_frame(BoardMode::Whiteboard, snapshot.whiteboard); + input.canvas_set.set_pages( + BoardMode::Whiteboard, + snapshot.whiteboard.map(snapshot_to_board_pages), + ); clamp_runtime_history( &mut input.canvas_set, BoardMode::Whiteboard, @@ -569,9 +641,10 @@ pub fn apply_snapshot(input: &mut InputState, snapshot: SessionSnapshot, options ); } if options.persist_blackboard { - input - .canvas_set - .set_frame(BoardMode::Blackboard, snapshot.blackboard); + input.canvas_set.set_pages( + BoardMode::Blackboard, + snapshot.blackboard.map(snapshot_to_board_pages), + ); clamp_runtime_history( &mut input.canvas_set, BoardMode::Blackboard, @@ -639,9 +712,15 @@ pub fn apply_snapshot(input: &mut InputState, snapshot: SessionSnapshot, options input.needs_redraw = true; } +fn snapshot_to_board_pages(pages: BoardPagesSnapshot) -> BoardPages { + BoardPages::from_pages(pages.pages, pages.active) +} + fn clamp_runtime_history(canvas: &mut crate::draw::CanvasSet, mode: BoardMode, limit: usize) { - if let Some(frame) = canvas.frame_mut(mode) { - frame.clamp_history_depth(limit); + if let Some(pages) = canvas.pages_mut(mode) { + for page in pages.pages_mut() { + page.clamp_history_depth(limit); + } } } @@ -650,25 +729,33 @@ fn enforce_shape_limits(snapshot: &mut SessionSnapshot, max_shapes: usize) { return; } - let truncate = |frame: &mut Option, mode: &str| { - if let Some(frame_data) = frame - && frame_data.shapes.len() > max_shapes - { - let removed: Vec<_> = frame_data.shapes.drain(max_shapes..).collect(); - warn!( - "Session frame '{}' contains {} shapes which exceeds the limit of {}; truncating", - mode, - frame_data.shapes.len() + removed.len(), - max_shapes - ); - let removed_ids: HashSet = removed.into_iter().map(|shape| shape.id).collect(); - if !removed_ids.is_empty() { - let stats = frame_data.prune_history_for_removed_ids(&removed_ids); - if !stats.is_empty() { - warn!( - "Dropped {} undo and {} redo actions referencing trimmed shapes in '{}' history", - stats.undo_removed, stats.redo_removed, mode - ); + let truncate = |pages: &mut Option, mode: &str| { + if let Some(pages) = pages { + for (idx, frame_data) in pages.pages.iter_mut().enumerate() { + if frame_data.shapes.len() <= max_shapes { + continue; + } + let removed: Vec<_> = frame_data.shapes.drain(max_shapes..).collect(); + warn!( + "Session page '{}' (#{}) contains {} shapes which exceeds the limit of {}; truncating", + mode, + idx + 1, + frame_data.shapes.len() + removed.len(), + max_shapes + ); + let removed_ids: HashSet = + removed.into_iter().map(|shape| shape.id).collect(); + if !removed_ids.is_empty() { + let stats = frame_data.prune_history_for_removed_ids(&removed_ids); + if !stats.is_empty() { + warn!( + "Dropped {} undo and {} redo actions referencing trimmed shapes in '{}' page #{} history", + stats.undo_removed, + stats.redo_removed, + mode, + idx + 1 + ); + } } } } @@ -679,45 +766,76 @@ fn enforce_shape_limits(snapshot: &mut SessionSnapshot, max_shapes: usize) { truncate(&mut snapshot.blackboard, "blackboard"); } -fn apply_history_policies(frame: &mut Option, mode: &str, depth_limit: Option) { - if let Some(frame_data) = frame { - let depth_trim = frame_data.validate_history(MAX_COMPOUND_DEPTH); - if !depth_trim.is_empty() { - warn!( - "Removed {} undo and {} redo actions with invalid structure in '{}' history", - depth_trim.undo_removed, depth_trim.redo_removed, mode - ); - } - let shape_trim = frame_data.prune_history_against_shapes(); - if !shape_trim.is_empty() { - warn!( - "Removed {} undo and {} redo actions referencing missing shapes in '{}' history", - shape_trim.undo_removed, shape_trim.redo_removed, mode - ); - } - if let Some(limit) = depth_limit { - let trimmed = frame_data.clamp_history_depth(limit); - if !trimmed.is_empty() { - debug!( - "Clamped '{}' history to {} entries (dropped {} undo / {} redo)", - mode, limit, trimmed.undo_removed, trimmed.redo_removed +fn apply_history_policies( + pages: &mut Option, + mode: &str, + depth_limit: Option, +) { + if let Some(pages) = pages { + for (idx, frame_data) in pages.pages.iter_mut().enumerate() { + let depth_trim = frame_data.validate_history(MAX_COMPOUND_DEPTH); + if !depth_trim.is_empty() { + warn!( + "Removed {} undo and {} redo actions with invalid structure in '{}' page #{} history", + depth_trim.undo_removed, + depth_trim.redo_removed, + mode, + idx + 1 + ); + } + let shape_trim = frame_data.prune_history_against_shapes(); + if !shape_trim.is_empty() { + warn!( + "Removed {} undo and {} redo actions referencing missing shapes in '{}' page #{} history", + shape_trim.undo_removed, + shape_trim.redo_removed, + mode, + idx + 1 ); } + if let Some(limit) = depth_limit { + let trimmed = frame_data.clamp_history_depth(limit); + if !trimmed.is_empty() { + debug!( + "Clamped '{}' page #{} history to {} entries (dropped {} undo / {} redo)", + mode, + idx + 1, + limit, + trimmed.undo_removed, + trimmed.redo_removed + ); + } + } } } } fn max_history_depth(doc: &Value) -> usize { let mut max_depth = 0; - for key in ["transparent", "whiteboard", "blackboard"] { - if let Some(frame) = doc.get(key) - && let Some(obj) = frame.as_object() - { + for key in [ + "transparent", + "whiteboard", + "blackboard", + "transparent_pages", + "whiteboard_pages", + "blackboard_pages", + ] { + if let Some(Value::Object(obj)) = doc.get(key) { for stack_key in ["undo_stack", "redo_stack"] { if let Some(Value::Array(arr)) = obj.get(stack_key) { max_depth = max_depth.max(depth_array(arr)); } } + } else if let Some(Value::Array(pages)) = doc.get(key) { + for page in pages { + if let Some(obj) = page.as_object() { + for stack_key in ["undo_stack", "redo_stack"] { + if let Some(Value::Array(arr)) = obj.get(stack_key) { + max_depth = max_depth.max(depth_array(arr)); + } + } + } + } } } max_depth @@ -745,10 +863,24 @@ fn depth_action(action: &Value) -> usize { fn strip_history_fields(doc: &mut Value) { if let Some(obj) = doc.as_object_mut() { - for key in ["transparent", "whiteboard", "blackboard"] { + for key in [ + "transparent", + "whiteboard", + "blackboard", + "transparent_pages", + "whiteboard_pages", + "blackboard_pages", + ] { if let Some(Value::Object(frame)) = obj.get_mut(key) { frame.remove("undo_stack"); frame.remove("redo_stack"); + } else if let Some(Value::Array(pages)) = obj.get_mut(key) { + for page in pages { + if let Some(frame) = page.as_object_mut() { + frame.remove("undo_stack"); + frame.remove("redo_stack"); + } + } } } } @@ -814,7 +946,10 @@ mod tests { SessionSnapshot { active_mode: BoardMode::Transparent, - transparent: Some(frame), + transparent: Some(BoardPagesSnapshot { + pages: vec![frame], + active: 0, + }), whiteboard: None, blackboard: None, tool_state: None, @@ -876,6 +1011,12 @@ mod tests { transparent: None, whiteboard: None, blackboard: None, + transparent_pages: None, + whiteboard_pages: None, + blackboard_pages: None, + transparent_active_page: None, + whiteboard_active_page: None, + blackboard_active_page: None, tool_state: None, }; let bytes = serde_json::to_vec_pretty(&file).unwrap(); @@ -886,4 +1027,146 @@ mod tests { load_snapshot_inner(&session_path, &options).expect("load_snapshot_inner should work"); assert!(loaded.is_none()); } + + #[test] + fn save_snapshot_preserves_multiple_pages() { + let temp = tempdir().unwrap(); + let mut options = SessionOptions::new(temp.path().to_path_buf(), "multi"); + options.persist_transparent = true; + + let mut first = Frame::new(); + first.add_shape(Shape::Line { + x1: 0, + y1: 0, + x2: 10, + y2: 10, + color: Color { + r: 1.0, + g: 0.0, + b: 0.0, + a: 1.0, + }, + thick: 2.0, + }); + + let mut second = Frame::new(); + second.add_shape(Shape::Rect { + x: 5, + y: 5, + w: 8, + h: 8, + fill: false, + color: Color { + r: 0.0, + g: 0.0, + b: 0.0, + a: 1.0, + }, + thick: 1.0, + }); + + let snapshot = SessionSnapshot { + active_mode: BoardMode::Transparent, + transparent: Some(BoardPagesSnapshot { + pages: vec![first, second], + active: 1, + }), + whiteboard: None, + blackboard: None, + tool_state: None, + }; + + save_snapshot(&snapshot, &options).expect("save_snapshot should succeed"); + + let loaded = load_snapshot(&options) + .expect("load_snapshot should succeed") + .expect("snapshot should be present"); + let pages = loaded + .transparent + .expect("transparent pages should be present"); + assert_eq!(pages.pages.len(), 2); + assert_eq!(pages.active, 1); + assert_eq!(pages.pages[0].shapes.len(), 1); + assert_eq!(pages.pages[1].shapes.len(), 1); + } + + #[test] + fn save_snapshot_keeps_empty_pages() { + let temp = tempdir().unwrap(); + let mut options = SessionOptions::new(temp.path().to_path_buf(), "empty-pages"); + options.persist_transparent = true; + + let snapshot = SessionSnapshot { + active_mode: BoardMode::Transparent, + transparent: Some(BoardPagesSnapshot { + pages: vec![Frame::new(), Frame::new(), Frame::new()], + active: 2, + }), + whiteboard: None, + blackboard: None, + tool_state: None, + }; + + save_snapshot(&snapshot, &options).expect("save_snapshot should succeed"); + + let loaded = load_snapshot(&options) + .expect("load_snapshot should succeed") + .expect("snapshot should be present"); + let pages = loaded + .transparent + .expect("transparent pages should be present"); + assert_eq!(pages.pages.len(), 3); + assert_eq!(pages.active, 2); + } + + #[test] + fn load_snapshot_inner_migrates_legacy_frame_to_pages() { + let temp = tempdir().unwrap(); + let session_path = temp.path().join("session.json"); + + let mut frame = Frame::new(); + frame.add_shape(Shape::Line { + x1: 1, + y1: 2, + x2: 3, + y2: 4, + color: Color { + r: 0.2, + g: 0.3, + b: 0.4, + a: 1.0, + }, + thick: 1.0, + }); + + let file = SessionFile { + version: CURRENT_VERSION - 1, + last_modified: now_rfc3339(), + active_mode: "transparent".to_string(), + transparent: Some(frame), + whiteboard: None, + blackboard: None, + transparent_pages: None, + whiteboard_pages: None, + blackboard_pages: None, + transparent_active_page: None, + whiteboard_active_page: None, + blackboard_active_page: None, + tool_state: None, + }; + let bytes = serde_json::to_vec_pretty(&file).unwrap(); + std::fs::write(&session_path, bytes).unwrap(); + + let options = SessionOptions::new(temp.path().to_path_buf(), "legacy"); + let loaded = load_snapshot_inner(&session_path, &options) + .expect("load_snapshot_inner should succeed") + .expect("snapshot should be present"); + let pages = loaded + .snapshot + .transparent + .expect("transparent pages should be present"); + assert_eq!(pages.pages.len(), 1); + assert_eq!(pages.active, 0); + assert_eq!(pages.pages[0].shapes.len(), 1); + } } diff --git a/src/session/storage.rs b/src/session/storage.rs index 2bc2d2c0..fb223525 100644 --- a/src/session/storage.rs +++ b/src/session/storage.rs @@ -1,6 +1,6 @@ use super::options::SessionOptions; use super::snapshot; -use crate::draw::Frame; +use super::snapshot::BoardPagesSnapshot; use crate::session::lock::{lock_shared, unlock}; use anyhow::{Context, Result}; use log::warn; @@ -180,14 +180,14 @@ pub fn inspect_session(options: &SessionOptions) -> Result { if let Some(loaded) = loaded? { let snapshot = loaded.snapshot; frame_counts = Some(FrameCounts { - transparent: snapshot.transparent.as_ref().map_or(0, |f| f.shapes.len()), - whiteboard: snapshot.whiteboard.as_ref().map_or(0, |f| f.shapes.len()), - blackboard: snapshot.blackboard.as_ref().map_or(0, |f| f.shapes.len()), + transparent: page_shape_count(snapshot.transparent.as_ref()), + whiteboard: page_shape_count(snapshot.whiteboard.as_ref()), + blackboard: page_shape_count(snapshot.blackboard.as_ref()), }); let counts = HistoryCounts { - transparent: history_depth_from_frame(snapshot.transparent.as_ref()), - whiteboard: history_depth_from_frame(snapshot.whiteboard.as_ref()), - blackboard: history_depth_from_frame(snapshot.blackboard.as_ref()), + transparent: history_depth_from_pages(snapshot.transparent.as_ref()), + whiteboard: history_depth_from_pages(snapshot.whiteboard.as_ref()), + blackboard: history_depth_from_pages(snapshot.blackboard.as_ref()), }; history_present = counts.has_history(); history_counts = Some(counts); @@ -256,17 +256,23 @@ fn remove_matching_files(dir: &Path, prefix: &str, suffix: &str) -> Result Ok(removed) } -fn history_depth_from_frame(frame: Option<&Frame>) -> HistoryDepth { - if let Some(frame) = frame { +fn history_depth_from_pages(pages: Option<&BoardPagesSnapshot>) -> HistoryDepth { + if let Some(pages) = pages { HistoryDepth { - undo: frame.undo_stack_len(), - redo: frame.redo_stack_len(), + undo: pages.pages.iter().map(|page| page.undo_stack_len()).sum(), + redo: pages.pages.iter().map(|page| page.redo_stack_len()).sum(), } } else { HistoryDepth::default() } } +fn page_shape_count(pages: Option<&BoardPagesSnapshot>) -> usize { + pages + .map(|pages| pages.pages.iter().map(|page| page.shapes.len()).sum()) + .unwrap_or(0) +} + fn find_existing_variant( dir: &Path, prefix: &str, @@ -405,7 +411,10 @@ mod tests { let snapshot = SessionSnapshot { active_mode: BoardMode::Transparent, - transparent: Some(frame), + transparent: Some(BoardPagesSnapshot { + pages: vec![frame], + active: 0, + }), whiteboard: None, blackboard: None, tool_state: Some(ToolStateSnapshot { diff --git a/src/session/tests.rs b/src/session/tests.rs index 78d856d8..f0eed9f2 100644 --- a/src/session/tests.rs +++ b/src/session/tests.rs @@ -1,3 +1,4 @@ +use super::snapshot::BoardPagesSnapshot; use super::*; use crate::config::{Action, BoardConfig, SessionConfig, SessionStorageMode}; use crate::draw::FontDescriptor; @@ -649,7 +650,10 @@ fn save_snapshot_rotates_backup_when_enabled() { let snapshot = SessionSnapshot { active_mode: BoardMode::Transparent, - transparent: Some(frame), + transparent: Some(BoardPagesSnapshot { + pages: vec![frame], + active: 0, + }), whiteboard: None, blackboard: None, tool_state: None, @@ -692,7 +696,10 @@ fn save_snapshot_skips_backup_when_disabled() { let snapshot = SessionSnapshot { active_mode: BoardMode::Transparent, - transparent: Some(frame), + transparent: Some(BoardPagesSnapshot { + pages: vec![frame], + active: 0, + }), whiteboard: None, blackboard: None, tool_state: None, @@ -768,7 +775,7 @@ fn load_snapshot_truncates_shapes_when_exceeding_max_shapes_per_frame() { .transparent .expect("transparent frame should be present"); assert_eq!( - transparent.shapes.len(), + transparent.pages[0].shapes.len(), 2, "frame should be truncated to max_shapes_per_frame" ); diff --git a/src/ui.rs b/src/ui.rs index 1a403e10..76d5ede5 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -129,6 +129,14 @@ pub fn render_status_bar( BoardMode::Whiteboard => "[WHITEBOARD] ", BoardMode::Blackboard => "[BLACKBOARD] ", }; + let page_count = input_state + .canvas_set + .page_count(input_state.board_mode()) + .max(1); + let page_index = input_state + .canvas_set + .active_page_index(input_state.board_mode()); + let page_badge = format!("[Page {}/{}] ", page_index + 1, page_count); // Build status text with mode badge and font size let font_size = input_state.current_font_size; @@ -160,10 +168,11 @@ pub fn render_status_bar( }; let status_text = format!( - "{}{}{}[{}] [{}px] [{}] [Text {}px]{}{} F1=Help", + "{}{}{}{}[{}] [{}px] [{}] [Text {}px]{}{} F1=Help", frozen_badge, zoom_badge, mode_badge, + page_badge, color_name, thickness as i32, tool_name, @@ -320,6 +329,43 @@ pub fn render_zoom_badge( let _ = ctx.show_text(&label); } +/// Render a small badge indicating the current page (visible even when status bar is hidden). +pub fn render_page_badge( + ctx: &cairo::Context, + _screen_width: u32, + _screen_height: u32, + page_index: usize, + page_count: usize, +) { + let label = format!("Page {}/{}", page_index + 1, page_count.max(1)); + let padding = 12.0; + let radius = 8.0; + let font_size = 15.0; + + ctx.select_font_face("Sans", cairo::FontSlant::Normal, cairo::FontWeight::Bold); + ctx.set_font_size(font_size); + + let extents = ctx + .text_extents(&label) + .unwrap_or_else(|_| fallback_text_extents(font_size, &label)); + + let width = extents.width() + padding * 1.4; + let height = extents.height() + padding; + + let x = padding; + let y = padding + height; + + // Background with a neutral cool tone. + ctx.set_source_rgba(0.2, 0.32, 0.45, 0.92); + draw_rounded_rect(ctx, x, y - height, width, height, radius); + let _ = ctx.fill(); + + // Text + ctx.set_source_rgba(1.0, 1.0, 1.0, 1.0); + ctx.move_to(x + (padding * 0.7), y - (padding * 0.35)); + let _ = ctx.show_text(&label); +} + /// Render a transient toast for preset actions (apply/save/clear). pub fn render_preset_toast( ctx: &cairo::Context, diff --git a/src/ui/toolbar.rs b/src/ui/toolbar.rs index 9414898d..e9b59249 100644 --- a/src/ui/toolbar.rs +++ b/src/ui/toolbar.rs @@ -1,7 +1,7 @@ use crate::config::ToolbarLayoutMode; use crate::config::{KeybindingsConfig, PRESET_SLOTS_MAX}; use crate::draw::{Color, EraserKind, FontDescriptor}; -use crate::input::state::{PRESET_FEEDBACK_DURATION_MS, PresetFeedbackKind}; +use crate::input::state::{PRESET_FEEDBACK_DURATION_MS, PresetFeedbackKind, UiToastKind}; use crate::input::{EraserMode, InputState, Tool}; use std::time::Instant; @@ -27,6 +27,11 @@ pub enum ToolbarEvent { Undo, Redo, ClearCanvas, + PagePrev, + PageNext, + PageNew, + PageDuplicate, + PageDelete, EnterTextMode, EnterStickyNoteMode, /// Toggle both highlight tool and click highlight together @@ -142,6 +147,8 @@ pub struct ToolbarSnapshot { pub fill_enabled: bool, pub undo_available: bool, pub redo_available: bool, + pub page_index: usize, + pub page_count: usize, pub click_highlight_enabled: bool, pub highlight_tool_active: bool, /// Whether any highlight feature is active (tool or click) @@ -207,6 +214,9 @@ impl ToolbarSnapshot { ) -> Self { let frame = state.canvas_set.active_frame(); let active_tool = state.active_tool(); + let active_mode = state.board_mode(); + let page_count = state.canvas_set.page_count(active_mode); + let page_index = state.canvas_set.active_page_index(active_mode); let text_active = matches!(state.state, crate::input::DrawingState::TextInput { .. }) && state.text_input_mode == crate::input::TextInputMode::Plain; let note_active = matches!(state.state, crate::input::DrawingState::TextInput { .. }) @@ -285,6 +295,8 @@ impl ToolbarSnapshot { fill_enabled: state.fill_enabled, undo_available: frame.undo_stack_len() > 0, redo_available: frame.redo_stack_len() > 0, + page_index, + page_count, click_highlight_enabled: state.click_highlight_enabled(), highlight_tool_active: state.highlight_tool_active(), any_highlight_active: state.click_highlight_enabled() || state.highlight_tool_active(), @@ -347,6 +359,11 @@ pub struct ToolbarBindingHints { pub zoom_out: Option, pub reset_zoom: Option, pub toggle_zoom_lock: Option, + pub page_prev: Option, + pub page_next: Option, + pub page_new: Option, + pub page_duplicate: Option, + pub page_delete: Option, pub open_configurator: Option, pub apply_presets: Vec>, pub save_presets: Vec>, @@ -424,6 +441,11 @@ impl ToolbarBindingHints { zoom_out: first(&kb.zoom_out), reset_zoom: first(&kb.reset_zoom), toggle_zoom_lock: first(&kb.toggle_zoom_lock), + page_prev: first(&kb.page_prev), + page_next: first(&kb.page_next), + page_new: first(&kb.page_new), + page_duplicate: first(&kb.page_duplicate), + page_delete: first(&kb.page_delete), open_configurator: first(&kb.open_configurator), apply_presets, save_presets, @@ -463,6 +485,11 @@ impl ToolbarBindingHints { ToolbarEvent::ZoomOut => self.zoom_out.as_deref(), ToolbarEvent::ResetZoom => self.reset_zoom.as_deref(), ToolbarEvent::ToggleZoomLock => self.toggle_zoom_lock.as_deref(), + ToolbarEvent::PagePrev => self.page_prev.as_deref(), + ToolbarEvent::PageNext => self.page_next.as_deref(), + ToolbarEvent::PageNew => self.page_new.as_deref(), + ToolbarEvent::PageDuplicate => self.page_duplicate.as_deref(), + ToolbarEvent::PageDelete => self.page_delete.as_deref(), ToolbarEvent::OpenConfigurator => self.open_configurator.as_deref(), ToolbarEvent::ClearCanvas => self.clear.as_deref(), ToolbarEvent::ToggleAllHighlight(_) => self.toggle_highlight.as_deref(), @@ -579,6 +606,36 @@ impl InputState { self.toolbar_clear(); true } + ToolbarEvent::PagePrev => { + if self.page_prev() { + true + } else { + self.set_ui_toast(UiToastKind::Info, "Already on the first page."); + false + } + } + ToolbarEvent::PageNext => { + if self.page_next() { + true + } else { + self.set_ui_toast(UiToastKind::Info, "Already on the last page."); + false + } + } + ToolbarEvent::PageNew => { + self.page_new(); + true + } + ToolbarEvent::PageDuplicate => { + self.page_duplicate(); + true + } + ToolbarEvent::PageDelete => { + if matches!(self.page_delete(), crate::draw::PageDeleteOutcome::Cleared) { + self.set_ui_toast(UiToastKind::Info, "Cleared the last page."); + } + true + } ToolbarEvent::EnterTextMode => { let _ = self.set_tool_override(None); self.toolbar_enter_text_mode(); diff --git a/tests/cli.rs b/tests/cli.rs index 665e78e8..e2afa5d7 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -121,7 +121,10 @@ fn session_info_reports_saved_snapshot() { let snapshot = wayscriber::session::SessionSnapshot { active_mode: wayscriber::input::BoardMode::Transparent, - transparent: Some(frame), + transparent: Some(wayscriber::session::BoardPagesSnapshot { + pages: vec![frame], + active: 0, + }), whiteboard: None, blackboard: None, tool_state: None,