From c05023352965461ab49159587d8f582b326feb54 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Sat, 12 Sep 2026 22:18:59 +0200 Subject: [PATCH 1/6] feat(boards): add Cartesian and isometric paper backgrounds Integrate session appearance editing, recovery, configurator templates, previews, eraser-safe rendering, and PNG/PDF exports. Avoid redundant paper rendering on cache hits and preserve pending edits when using the visible palette. --- README.md | 1 + config.example.toml | 3 + configurator/README.md | 1 + configurator/src/app/pages/boards.rs | 5 + configurator/src/app/pages/boards/grid.rs | 56 +++ configurator/src/app/pages/boards/section.rs | 18 + configurator/src/app/search/summary.rs | 4 +- configurator/src/app/search/tests.rs | 9 +- configurator/src/app/update/boards.rs | 12 + configurator/src/app/update/mod.rs | 3 + configurator/src/messages.rs | 1 + configurator/src/models/config/boards.rs | 3 + .../src/models/config/boards/mapping.rs | 31 ++ configurator/src/models/config/tests.rs | 25 ++ docs/CONFIG.md | 32 ++ docs/board-paper-consumer-performance.csv | 97 ++++ docs/board-paper-source-performance.csv | 145 ++++++ docs/board-paper-validation.md | 159 +++++++ docs/codebase-overview.md | 10 + src/backend/wayland/session/tests.rs | 1 + src/backend/wayland/state/canvas_layer.rs | 29 +- src/backend/wayland/state/capture.rs | 5 +- .../wayland/state/clipboard/session_paste.rs | 1 + .../wayland/state/core/output/tests.rs | 2 + src/backend/wayland/state/pdf_export.rs | 10 +- src/backend/wayland/state/pdf_export/tests.rs | 24 +- .../wayland/state/render/canvas/background.rs | 70 ++- .../wayland/state/render/canvas/mod.rs | 34 +- .../state/render/canvas/resource_tests.rs | 116 +++-- .../canvas/resource_tests/grid_performance.rs | 169 +++++++ src/canvas_export/mod.rs | 3 + src/canvas_export/page.rs | 42 +- src/canvas_export/tests/board_grid.rs | 216 +++++++++ src/config/mod.rs | 10 +- src/config/tests/board_grid.rs | 72 +++ src/config/tests/document.rs | 35 ++ src/config/tests/mod.rs | 1 + src/config/tests/validate.rs | 4 + src/config/types/board_grid.rs | 71 +++ src/config/types/boards.rs | 11 + src/config/types/mod.rs | 2 + src/config/validate/boards.rs | 18 + src/domain/board.rs | 5 +- src/domain/board_grid.rs | 72 +++ src/domain/mod.rs | 5 + src/domain/tests.rs | 3 +- src/draw/mod.rs | 18 +- src/draw/render/board_grid.rs | 211 +++++++++ src/draw/render/board_grid/performance.rs | 152 +++++++ src/draw/render/board_grid/tests.rs | 182 ++++++++ src/draw/render/mod.rs | 2 + src/input/boards.rs | 8 + src/input/boards/appearance.rs | 59 +++ src/input/boards/mapping.rs | 8 + src/input/boards/naming.rs | 4 + src/input/state/actions/key_press/panels.rs | 4 + src/input/state/core/board.rs | 1 + src/input/state/core/board/appearance.rs | 43 ++ src/input/state/core/board/lifecycle.rs | 2 +- src/input/state/core/board/pages.rs | 24 +- src/input/state/core/board/switch.rs | 30 ++ .../state/core/board_picker/appearance.rs | 419 ++++++++++++++++++ .../core/board_picker/appearance/tests.rs | 306 +++++++++++++ .../state/core/board_picker/layout/cursor.rs | 3 + src/input/state/core/board_picker/mod.rs | 1 + src/input/state/core/board_picker/panel.rs | 4 + .../state/core/board_picker/state/edit.rs | 63 ++- .../core/board_picker/state/lifecycle.rs | 3 + .../state/core/session_preflight_exact.rs | 7 + src/input/state/mod.rs | 1 + src/input/state/mouse/press/panels.rs | 3 + src/input/state/mouse/release/panels.rs | 3 + src/input/state/tests/board_picker.rs | 6 +- src/session/catalog/tests.rs | 1 + src/session/mod.rs | 4 +- src/session/snapshot/appearance.rs | 88 ++++ src/session/snapshot/appearance/tests.rs | 112 +++++ src/session/snapshot/apply.rs | 13 + src/session/snapshot/capture.rs | 11 +- src/session/snapshot/load/payload.rs | 9 + src/session/snapshot/mod.rs | 2 + src/session/snapshot/save.rs | 6 +- src/session/snapshot/save/payload.rs | 16 +- src/session/snapshot/tests.rs | 7 + src/session/snapshot/types.rs | 21 +- src/session/storage/tests.rs | 2 + src/session/tests/board_appearance.rs | 155 +++++++ src/session/tests/limits.rs | 1 + src/session/tests/mod.rs | 1 + src/session/tests/roundtrip.rs | 2 + src/session/tests/snapshot.rs | 3 + src/ui/board_picker.rs | 2 + src/ui/board_picker/appearance.rs | 299 +++++++++++++ src/ui/board_picker/page_panel.rs | 2 + .../page_panel/thumbnail/cache.rs | 3 + .../thumbnail/cache/spotlight_tests.rs | 1 + .../page_panel/thumbnail/cache/tests.rs | 1 + .../page_panel/thumbnail/cards.rs | 4 + .../page_panel/thumbnail/content.rs | 24 +- .../page_panel/thumbnail/types.rs | 3 + src/ui/board_picker/palette.rs | 23 +- src/ui/board_picker/tests.rs | 44 ++ tests/cli.rs | 2 + 103 files changed, 3913 insertions(+), 162 deletions(-) create mode 100644 configurator/src/app/pages/boards/grid.rs create mode 100644 docs/board-paper-consumer-performance.csv create mode 100644 docs/board-paper-source-performance.csv create mode 100644 docs/board-paper-validation.md create mode 100644 src/backend/wayland/state/render/canvas/resource_tests/grid_performance.rs create mode 100644 src/canvas_export/tests/board_grid.rs create mode 100644 src/config/tests/board_grid.rs create mode 100644 src/config/types/board_grid.rs create mode 100644 src/domain/board_grid.rs create mode 100644 src/draw/render/board_grid.rs create mode 100644 src/draw/render/board_grid/performance.rs create mode 100644 src/draw/render/board_grid/tests.rs create mode 100644 src/input/boards/appearance.rs create mode 100644 src/input/state/core/board/appearance.rs create mode 100644 src/input/state/core/board_picker/appearance.rs create mode 100644 src/input/state/core/board_picker/appearance/tests.rs create mode 100644 src/session/snapshot/appearance.rs create mode 100644 src/session/snapshot/appearance/tests.rs create mode 100644 src/session/tests/board_appearance.rs create mode 100644 src/ui/board_picker/appearance.rs diff --git a/README.md b/README.md index a53e997ba..9063e1d8b 100644 --- a/README.md +++ b/README.md @@ -133,6 +133,7 @@ The v0.9.23+ prebuilt `wayscriber` packages require glibc 2.39 and GTK 4.12. See ### Boards - Named boards with transparent overlay or custom backgrounds +- Cartesian graph paper, isometric lines, and isometric dots with adjustable spacing - 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 diff --git a/config.example.toml b/config.example.toml index ac99c0b8b..f9a08b9be 100644 --- a/config.example.toml +++ b/config.example.toml @@ -923,6 +923,9 @@ persist = true [[boards.items]] id = "whiteboard" name = "Whiteboard" +# Paper: none, cartesian, isometric, or isometric-dots. Spacing: 8–200 logical px. +# Spacing is the square/triangle side length; 20 and 40 are convenient presets. +grid = { kind = "none", spacing = 40 } background = { rgb = [0.992, 0.992, 0.992] } # Tuned black #241F31; the built-in default bit-matches the "black" quick color default_pen_color = { rgb = [0.141, 0.122, 0.192] } diff --git a/configurator/README.md b/configurator/README.md index bc21255ac..d9fcfdac6 100644 --- a/configurator/README.md +++ b/configurator/README.md @@ -77,6 +77,7 @@ if its UI task is no longer observed. - **Drawing, Arrow, Performance, UI, Board, Capture** – numeric fields with inline validation, toggles, and color editors (RGBA/RGB components). - **Default color** – toggle between named colors and custom RGB triples. - **Keybindings** – a bulk shortcut manager over the same per-action chips, recorder, and conflict flow. Filter by All / Changed / Conflicts / Unbound / Device / Sequences, sort by category, name, or changed status, and reset visible or all keybindings with confirmation (draft-only until Save). Review Conflicts walks each collision without picking a winner. `--open keybindings/
?search=...` still opens that category and now selects the matching action. Press-to-bind recording covers keys, auxiliary mouse buttons, and stylus barrel buttons, plus **Record Sequence** for two- or three-chord keyboard sequences (`Ctrl+K then Ctrl+C`). Per-row reset and a raw comma-separated text editor remain available (`F5, Ctrl+K > Ctrl+C`). Super/Meta chords record when the desktop delivers them. Legacy `[tablet.stylus_button]` assignments can be moved into the keybinding list with an explicit confirmation. Source badges mark Default, Authored, Legacy Tablet, and Unavailable shortcuts. +- **Boards** – edit new-session templates with Cartesian, isometric line, or isometric dot paper and 8–200 logical-pixel spacing. Transparent boards disable paper controls. - **Session** – persistence settings plus named-session catalog management. Rename display labels, reveal files, and forget metadata without touching files. Clear Tool State preserves boards/history while removing persisted tool defaults. Duplicate, Move, Clear Tool State, and Clear are disabled while an overlay, manually started daemon, or background service is active. - Live dirty-state indicator plus status banner for success/error details. Editing is temporarily disabled during loading and saving; failed operations restore editing and retain the draft. - Non-fatal warnings list unrecognized config paths. Those values are preserved for forward compatibility instead of being deleted. diff --git a/configurator/src/app/pages/boards.rs b/configurator/src/app/pages/boards.rs index 0fc4a4efb..4d759e4da 100644 --- a/configurator/src/app/pages/boards.rs +++ b/configurator/src/app/pages/boards.rs @@ -23,6 +23,7 @@ //! components to whatever the 8-bit hex said. mod color; +mod grid; mod header; mod rows; mod section; @@ -207,6 +208,7 @@ fn add_board_list(page: &mut PageBuilder) { let values = BoardValues { id: &item.id, name: &item.name, + grid_spacing: &item.grid_spacing, background: ColorValues { hex: picker_hex(app, ColorPickerId::BoardBackground(index)), color: &item.background_color, @@ -234,6 +236,7 @@ struct SectionLayout { visible: bool, expanded: bool, background_kind: BoardBackgroundOption, + grid_kind: wayscriber::domain::BoardGridKind, pen_enabled: bool, auto_adjust: bool, persist: bool, @@ -256,6 +259,7 @@ fn section_layouts(app: &ConfiguratorApp, summary: &AppSearchSummary) -> Vec Vec { id: &'a str, name: &'a str, + grid_spacing: &'a str, background: ColorValues<'a>, pen: ColorValues<'a>, } diff --git a/configurator/src/app/pages/boards/grid.rs b/configurator/src/app/pages/boards/grid.rs new file mode 100644 index 000000000..877dd824e --- /dev/null +++ b/configurator/src/app/pages/boards/grid.rs @@ -0,0 +1,56 @@ +use super::{ + SectionLayout, + rows::{TextRow, build_text_row}, +}; +use crate::app::state::ConfiguratorApp; +use crate::messages::Message; +use crate::models::{BoardBackgroundOption, BoardItemTextField}; +use adw::prelude::*; +use relm4::{ComponentSender, adw, gtk}; +use wayscriber::domain::BoardGridKind; + +pub(super) fn build( + index: usize, + layout: SectionLayout, + sender: &ComponentSender, +) -> (adw::ComboRow, TextRow) { + let labels: Vec<_> = BoardGridKind::ALL.iter().map(|kind| kind.label()).collect(); + let grid = adw::ComboRow::builder() + .title("Paper pattern") + .model(>k::StringList::new(&labels)) + .visible(layout.expanded) + .build(); + grid.set_selected( + BoardGridKind::ALL + .iter() + .position(|kind| *kind == layout.grid_kind) + .unwrap_or(0) as u32, + ); + let solid = layout.background_kind != BoardBackgroundOption::Transparent; + grid.set_sensitive(solid); + if !solid { + grid.set_subtitle("Paper patterns require a solid board background"); + } + let grid_sender = sender.clone(); + grid.connect_selected_notify(move |row| { + if let Some(kind) = BoardGridKind::ALL.get(row.selected() as usize) { + grid_sender.input(Message::BoardsGridKindChanged(index, *kind)); + } + }); + let spacing_title = match layout.grid_kind { + BoardGridKind::None => "Grid spacing (8–200 logical pixels)", + BoardGridKind::Cartesian => "Square side (8–200 logical pixels)", + BoardGridKind::Isometric | BoardGridKind::IsometricDots => { + "Equilateral triangle side (8–200 logical pixels)" + } + }; + let spacing = build_text_row( + spacing_title, + index, + BoardItemTextField::GridSpacing, + sender, + ); + spacing.row.set_visible(layout.expanded); + spacing.row.set_sensitive(solid); + (grid, spacing) +} diff --git a/configurator/src/app/pages/boards/section.rs b/configurator/src/app/pages/boards/section.rs index 1fb8ad239..fb6a932d6 100644 --- a/configurator/src/app/pages/boards/section.rs +++ b/configurator/src/app/pages/boards/section.rs @@ -94,6 +94,10 @@ fn build_section( .set_visible(layout.expanded && layout.background_kind == BoardBackgroundOption::Color); section.append(&background.row); + let (grid_kind, grid_spacing) = super::grid::build(index, layout, sender); + section.append(&grid_kind); + section.append(&grid_spacing.row); + let pen_enabled = adw::SwitchRow::builder() .title("Override default pen color") .active(layout.pen_enabled) @@ -148,6 +152,20 @@ fn build_section( set_text_blocked(&id.row, &id.handler, values.id); set_text_blocked(&name.row, &name.handler, values.name); background.refresh(&values.background); + set_text_blocked( + &grid_spacing.row, + &grid_spacing.handler, + values.grid_spacing, + ); + let valid = values + .grid_spacing + .parse::() + .is_ok_and(|s| (8..=200).contains(&s)); + if valid { + grid_spacing.row.remove_css_class("error"); + } else { + grid_spacing.row.add_css_class("error"); + } pen.refresh(&values.pen); }); diff --git a/configurator/src/app/search/summary.rs b/configurator/src/app/search/summary.rs index 5692d92ca..6769861a3 100644 --- a/configurator/src/app/search/summary.rs +++ b/configurator/src/app/search/summary.rs @@ -230,11 +230,13 @@ fn board_matches(app: &ConfiguratorApp, query: &SearchQuery, summary: &mut TabSe ); for (index, item) in app.draft.boards.items.iter().enumerate() { let text = format!( - "board {} board id display name background background color override default pen color pen color auto-adjust pen auto adjust pen persist pinned duplicate remove up down collapse expand {} {} {} background pen persist pinned auto adjust", + "board {} board id display name background background color override default pen color pen color auto-adjust pen auto adjust pen persist pinned duplicate remove up down collapse expand {} {} {} background pen persist pinned auto adjust graph paper pattern grid spacing cartesian isometric dots spacing logical pixels {} {}", index + 1, item.id, item.name, item.background_kind.label(), + item.grid_kind.label(), + item.grid_spacing, ); if query.matches_text(&text) { summary.add_board_index(index); diff --git a/configurator/src/app/search/tests.rs b/configurator/src/app/search/tests.rs index 649b8e27a..8bd7887f2 100644 --- a/configurator/src/app/search/tests.rs +++ b/configurator/src/app/search/tests.rs @@ -509,7 +509,14 @@ fn startup_interaction_cancels_the_deferred_search_focus() { #[test] fn board_item_static_labels_match_board_rows() { - for query in ["display name", "board id", "override default pen color"] { + for query in [ + "display name", + "board id", + "override default pen color", + "graph paper", + "isometric", + "grid spacing", + ] { let (mut app, _effects) = ConfiguratorApp::new_app(); app.search_query = SearchQuery::new(query); diff --git a/configurator/src/app/update/boards.rs b/configurator/src/app/update/boards.rs index ef3f4dab5..016cf8e94 100644 --- a/configurator/src/app/update/boards.rs +++ b/configurator/src/app/update/boards.rs @@ -6,6 +6,17 @@ use super::super::effects::Effect; use super::super::state::{ConfiguratorApp, StatusMessage}; impl ConfiguratorApp { + pub(super) fn handle_boards_grid_kind_changed( + &mut self, + index: usize, + value: wayscriber::domain::BoardGridKind, + ) -> Vec { + if let Some(item) = self.draft.boards.items.get_mut(index) { + item.grid_kind = value; + } + self.refresh_dirty_flag(); + Vec::new() + } pub(super) fn handle_boards_add_item(&mut self) -> Vec { self.status = StatusMessage::idle(); let new_item = self.draft.boards.new_item(); @@ -99,6 +110,7 @@ impl ConfiguratorApp { let old_effective_id = self.draft.boards.effective_id_for_index(index); if let Some(item) = self.draft.boards.items.get_mut(index) { match field { + BoardItemTextField::GridSpacing => item.grid_spacing = value, BoardItemTextField::Id => { let trimmed = value.trim(); let new_effective_id = if trimmed.is_empty() { diff --git a/configurator/src/app/update/mod.rs b/configurator/src/app/update/mod.rs index d872382d9..28e1c188d 100644 --- a/configurator/src/app/update/mod.rs +++ b/configurator/src/app/update/mod.rs @@ -240,6 +240,9 @@ impl ConfiguratorApp { Message::BoardsItemTextChanged(index, field, value) => { self.handle_boards_item_text_changed(index, field, value) } + Message::BoardsGridKindChanged(index, value) => { + self.handle_boards_grid_kind_changed(index, value) + } Message::BoardsBackgroundKindChanged(index, value) => { self.handle_boards_background_kind_changed(index, value) } diff --git a/configurator/src/messages.rs b/configurator/src/messages.rs index e2e99f3e1..fc57ea247 100644 --- a/configurator/src/messages.rs +++ b/configurator/src/messages.rs @@ -147,6 +147,7 @@ pub enum Message { BoardsCollapseToggled(usize), BoardsDefaultChanged(String), BoardsItemTextChanged(usize, BoardItemTextField, String), + BoardsGridKindChanged(usize, wayscriber::domain::BoardGridKind), BoardsBackgroundKindChanged(usize, BoardBackgroundOption), BoardsBackgroundColorChanged(usize, usize, String), BoardsDefaultPenEnabledChanged(usize, bool), diff --git a/configurator/src/models/config/boards.rs b/configurator/src/models/config/boards.rs index 05692e333..abc7217d7 100644 --- a/configurator/src/models/config/boards.rs +++ b/configurator/src/models/config/boards.rs @@ -30,6 +30,7 @@ impl std::fmt::Display for BoardBackgroundOption { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum BoardItemTextField { + GridSpacing, Id, Name, } @@ -53,6 +54,8 @@ pub struct BoardItemDraft { pub name: String, pub background_kind: BoardBackgroundOption, pub background_color: ColorTripletInput, + pub grid_kind: wayscriber::domain::BoardGridKind, + pub grid_spacing: String, pub default_pen_color: OptionalTripletInput, pub auto_adjust_pen: bool, pub persist: bool, diff --git a/configurator/src/models/config/boards/mapping.rs b/configurator/src/models/config/boards/mapping.rs index 746a7ce8f..eb36f8ec2 100644 --- a/configurator/src/models/config/boards/mapping.rs +++ b/configurator/src/models/config/boards/mapping.rs @@ -54,6 +54,8 @@ impl BoardItemDraft { name: item.name.clone(), background_kind, background_color, + grid_kind: item.grid.kind.into(), + grid_spacing: item.grid.spacing.to_string(), default_pen_color: OptionalTripletInput::from_option( item.default_pen_color.as_ref(), fallback_pen, @@ -97,10 +99,37 @@ impl BoardItemDraft { .default_pen_color .to_option(&format!("boards.items[{index}].default_pen_color"), errors); + let spacing = match self.grid_spacing.trim().parse::() { + Ok(value) + if (i64::from(wayscriber::domain::BOARD_GRID_MIN_SPACING) + ..=i64::from(wayscriber::domain::BOARD_GRID_MAX_SPACING)) + .contains(&value) => + { + value + } + _ => { + errors.push(FormError::new( + format!("boards.items[{index}].grid.spacing"), + "Enter a whole number from 8 to 200.", + )); + return None; + } + }; + let grid = wayscriber::config::BoardGridConfig { + kind: if background.is_transparent() { + wayscriber::domain::BoardGridKind::None + } else { + self.grid_kind + } + .into(), + spacing, + }; + Some(BoardItemConfig { id, name, background, + grid, default_pen_color, auto_adjust_pen: self.auto_adjust_pen, persist: self.persist, @@ -196,6 +225,8 @@ impl BoardsDraft { name, background_kind: BoardBackgroundOption::Color, background_color: ColorTripletInput::from([0.992, 0.992, 0.992]), + grid_kind: wayscriber::domain::BoardGridKind::None, + grid_spacing: wayscriber::domain::BOARD_GRID_DEFAULT_SPACING.to_string(), default_pen_color: OptionalTripletInput::from_option( Some(&BoardColorConfig::Rgb([0.0, 0.0, 0.0])), [0.0, 0.0, 0.0], diff --git a/configurator/src/models/config/tests.rs b/configurator/src/models/config/tests.rs index 02e316bd6..dc2108ac6 100644 --- a/configurator/src/models/config/tests.rs +++ b/configurator/src/models/config/tests.rs @@ -1,4 +1,29 @@ use super::super::color::ColorInput; + +#[test] +fn board_grid_draft_preserves_patterns_and_rejects_invalid_spacing() { + let mut config = Config::default(); + let mut boards = wayscriber::config::BoardsConfig::default(); + boards.items[1].grid = wayscriber::config::BoardGridConfig { + kind: wayscriber::config::BoardGridKindConfig::IsometricDots, + spacing: 20, + }; + config.boards = Some(boards); + let mut draft = ConfigDraft::from_config(&config); + assert_eq!( + draft.boards.items[1].grid_kind, + wayscriber::domain::BoardGridKind::IsometricDots + ); + assert_eq!( + draft.to_config(&config).unwrap().boards.unwrap().items[1].grid, + config.boards.as_ref().unwrap().items[1].grid + ); + for invalid in ["", "-1", "7", "201", "20.5", "paper"] { + draft.boards.items[1].grid_spacing = invalid.to_string(); + assert!(draft.to_config(&config).is_err(), "{invalid}"); + assert_eq!(draft.boards.items[1].grid_spacing, invalid); + } +} use super::super::fields::{ ArrowStyleOption, DragMouseButton, DragToolField, DragToolOption, FontWeightOption, InputHudModeOption, InputHudPositionOption, OverrideOption, PdfFitModeOption, diff --git a/docs/CONFIG.md b/docs/CONFIG.md index 83808c9a2..836699498 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -1521,6 +1521,38 @@ wayscriber --active --mode blueprint wayscriber --daemon --mode transparent ``` +#### Board paper patterns + +Each solid board can use `grid = { kind = "cartesian", spacing = 40 }` in its +`[[boards.items]]` entry. Kinds are `none` (the default), `cartesian`, `isometric` +(lines), and `isometric-dots`. Spacing is an integer from 8 through 200 logical +pixels: a square edge for Cartesian paper or an equilateral triangle edge for +isometric paper. Isometric lines run vertically and at ±30°. Out-of-range config +values are clamped with a warning; invalid text or unknown kinds fail validation. +Transparent boards disable the pattern while retaining its spacing. + +Open the board picker (`Ctrl+Shift+B`), select a solid board, and click its color +swatch or press `Ctrl+C`. Choose a pattern and spacing, or use the 20/40 presets. +The preview is local to the editor; **Apply** changes the board and **Cancel** or +Escape discards the draft. Tab cycles color, pattern, and spacing; arrow keys +change the focused pattern and Enter applies. Switching to another board or +renaming cancels the draft. The configurator's Boards page edits the templates +used for new sessions; it does not replace an existing session's saved paper. + +Paper is anchored to board coordinates, follows pan and zoom, and appears behind +all annotations. It cannot be selected or erased and does not participate in +undo/redo. Clear Canvas keeps the paper. Thumbnails and canvas PNG/PDF exports use +the same pattern; PDF margins retain the plain board color. Native region capture +continues to use the frozen desktop. Lines and dots automatically contrast with +the board, and fade at very small preview scales to avoid dense visual noise. + +Session format 7 saves appearance with drawings and preserves explicitly edited +empty boards. Unchanged empty templates remain contentless for backup recovery. +Older sessions use configured/template appearance; opening another named session +resets missing appearance to those seeds. Clear Saved Data removes the saved +appearance override as well as drawing data. A grid-only edit preserves the pen +color; changing the background retains the board's existing auto-contrast policy. + ### `[board]` - Legacy Board Modes This section is still recognized for backward compatibility. If `[boards]` is missing, diff --git a/docs/board-paper-consumer-performance.csv b/docs/board-paper-consumer-performance.csv new file mode 100644 index 000000000..2e1bdd695 --- /dev/null +++ b/docs/board-paper-consumer-performance.csv @@ -0,0 +1,97 @@ +kind,spacing,width,height,origin,erasers,cold_pan_median_us,cold_pan_p95_us,warm_pan_median_us,warm_pan_p95_us,png_median_us,png_p95_us,png_bytes,surface_bytes_proxy +None,8,1920,1080,0,0,2964,3295,169,865,31442,31860,33424,32075776 +None,8,1920,1080,0,20,3408,3555,156,280,48536,48890,72712,32075776 +None,8,1920,1080,0,200,6632,6768,241,352,56368,57102,189182,32075776 +None,40,1920,1080,0,0,2609,2789,249,356,31385,31622,33424,32075776 +None,40,1920,1080,0,20,3053,3156,279,399,47974,48035,72712,32075776 +None,40,1920,1080,0,200,6481,6835,177,304,56138,56266,189182,32075776 +Cartesian,8,1920,1080,0,0,3526,3597,170,198,37480,37686,80088,32075776 +Cartesian,8,1920,1080,0,20,4363,4460,175,444,57646,58335,134441,32075776 +Cartesian,8,1920,1080,0,200,11369,11996,336,405,80644,80964,376164,32075776 +Cartesian,40,1920,1080,0,0,3447,3643,170,332,34651,34730,53224,32075776 +Cartesian,40,1920,1080,0,20,4227,4348,167,303,53276,56684,102884,32075776 +Cartesian,40,1920,1080,0,200,10951,11751,382,494,69904,70669,265083,32075776 +Isometric,8,1920,1080,0,0,7144,16695,703,841,134973,172560,1017818,32075776 +Isometric,8,1920,1080,0,20,8211,15019,340,759,200823,222206,1166903,32075776 +Isometric,8,1920,1080,0,200,24842,42471,673,756,248711,305444,1268264,32075776 +Isometric,40,1920,1080,0,0,7181,9508,571,597,74132,88345,672389,32075776 +Isometric,40,1920,1080,0,20,8527,8771,563,569,104521,105079,804536,32075776 +Isometric,40,1920,1080,0,200,19392,19791,351,467,123284,123768,932535,32075776 +IsometricDots,8,1920,1080,0,0,6195,6222,202,303,57517,57873,419974,32075776 +IsometricDots,8,1920,1080,0,20,8088,9074,371,832,88225,93938,557246,32075776 +IsometricDots,8,1920,1080,0,200,22666,25426,383,580,114643,119364,713126,32075776 +IsometricDots,40,1920,1080,0,0,6181,6336,353,436,36239,38479,85049,32075776 +IsometricDots,40,1920,1080,0,20,7502,7723,371,412,53787,64370,128204,32075776 +IsometricDots,40,1920,1080,0,200,31731,39567,787,1726,101258,106475,251278,32075776 +None,8,1920,1080,-1000021,0,2576,2664,248,263,31356,31542,33424,32075776 +None,8,1920,1080,-1000021,20,3120,3361,297,464,48592,49261,72712,32075776 +None,8,1920,1080,-1000021,200,6553,6720,314,413,57100,57878,189182,32075776 +None,40,1920,1080,-1000021,0,2648,3240,278,339,32083,32409,33424,32075776 +None,40,1920,1080,-1000021,20,3044,3054,244,268,48088,48185,72712,32075776 +None,40,1920,1080,-1000021,200,6495,6554,200,239,56255,56360,189182,32075776 +Cartesian,8,1920,1080,-1000021,0,3540,3729,185,191,37849,37998,83130,32075776 +Cartesian,8,1920,1080,-1000021,20,4481,4567,184,272,58529,58646,139657,32075776 +Cartesian,8,1920,1080,-1000021,200,11479,11559,201,211,81011,81423,382147,32075776 +Cartesian,40,1920,1080,-1000021,0,3617,3693,170,244,35263,35325,55388,32075776 +Cartesian,40,1920,1080,-1000021,20,4366,4432,188,313,54012,54224,105169,32075776 +Cartesian,40,1920,1080,-1000021,200,10689,11496,244,761,70457,73741,267355,32075776 +Isometric,8,1920,1080,-1000021,0,6328,6789,266,532,124400,125941,1006387,32075776 +Isometric,8,1920,1080,-1000021,20,7819,7833,186,239,182261,182654,1156608,32075776 +Isometric,8,1920,1080,-1000021,200,23758,24870,540,616,207378,209135,1252617,32075776 +Isometric,40,1920,1080,-1000021,0,6171,6292,207,473,72258,72362,674375,32075776 +Isometric,40,1920,1080,-1000021,20,7703,8597,401,540,104347,105351,807461,32075776 +Isometric,40,1920,1080,-1000021,200,20760,21508,507,539,127350,128127,935494,32075776 +IsometricDots,8,1920,1080,-1000021,0,6430,6530,455,466,59398,59747,424019,32075776 +IsometricDots,8,1920,1080,-1000021,20,8318,8847,466,527,90049,93251,558198,32075776 +IsometricDots,8,1920,1080,-1000021,200,24486,25307,571,584,119755,121443,721126,32075776 +IsometricDots,40,1920,1080,-1000021,0,6838,7115,568,575,36182,38004,84807,32075776 +IsometricDots,40,1920,1080,-1000021,20,8231,8424,520,566,55674,55729,128026,32075776 +IsometricDots,40,1920,1080,-1000021,200,21182,21842,526,564,72957,74122,250840,32075776 +None,8,3840,2160,0,0,6689,6830,2217,3196,129311,131052,82922,112869376 +None,8,3840,2160,0,20,7124,7454,2170,2293,185567,186637,140454,112869376 +None,8,3840,2160,0,200,11527,11617,2099,2119,196635,196751,299751,112869376 +None,40,3840,2160,0,0,6489,6579,2185,2242,127198,127256,82922,112869376 +None,40,3840,2160,0,20,6982,7576,2134,2216,183092,183391,140454,112869376 +None,40,3840,2160,0,200,10832,11267,2096,2190,195083,195972,299751,112869376 +Cartesian,8,3840,2160,0,0,8958,9098,1941,2026,145771,146452,214887,112869376 +Cartesian,8,3840,2160,0,20,9934,10202,1910,2015,207790,208632,310583,112869376 +Cartesian,8,3840,2160,0,200,17583,19033,1903,2021,238411,242324,617816,112869376 +Cartesian,40,3840,2160,0,0,8905,10164,2033,2618,138411,141335,145447,112869376 +Cartesian,40,3840,2160,0,20,9819,10340,1989,2035,201483,201693,290886,112869376 +Cartesian,40,3840,2160,0,200,16708,18042,2025,2068,223289,223984,503374,112869376 +Isometric,8,3840,2160,0,0,16684,16737,1923,1943,454268,455633,3170531,112869376 +Isometric,8,3840,2160,0,20,18898,19516,2167,2446,614747,624484,3525018,112869376 +Isometric,8,3840,2160,0,200,36990,39033,2046,3342,652967,660609,3761578,112869376 +Isometric,40,3840,2160,0,0,16803,17441,2040,2181,276753,277087,2357820,112869376 +Isometric,40,3840,2160,0,20,18396,18833,2064,2327,365316,367817,2674630,112869376 +Isometric,40,3840,2160,0,200,31600,31744,2065,2223,395045,397930,2872891,112869376 +IsometricDots,8,3840,2160,0,0,17341,17489,2029,2230,236783,237067,1536684,112869376 +IsometricDots,8,3840,2160,0,20,19257,20206,2134,2476,319396,320760,1784952,112869376 +IsometricDots,8,3840,2160,0,200,36696,36875,2043,2469,357748,358996,2034353,112869376 +IsometricDots,40,3840,2160,0,0,16937,17541,2068,2256,139286,139630,248864,112869376 +IsometricDots,40,3840,2160,0,20,18598,20155,2083,2591,199615,200309,335132,112869376 +IsometricDots,40,3840,2160,0,200,31477,33203,2066,2700,222001,224319,505456,112869376 +None,8,3840,2160,-1000021,0,6657,8292,2269,2394,128084,133287,82922,112869376 +None,8,3840,2160,-1000021,20,7158,7707,2195,2227,184799,195329,140454,112869376 +None,8,3840,2160,-1000021,200,11066,11552,2064,2588,195439,196309,299751,112869376 +None,40,3840,2160,-1000021,0,6481,6748,2209,2279,126051,126324,82922,112869376 +None,40,3840,2160,-1000021,20,7184,7452,2133,2747,182462,182986,140454,112869376 +None,40,3840,2160,-1000021,200,11670,12305,2236,2348,196137,198833,299751,112869376 +Cartesian,8,3840,2160,-1000021,0,9654,9989,2020,2297,147992,149402,222712,112869376 +Cartesian,8,3840,2160,-1000021,20,10795,10914,2031,2103,211975,213261,317630,112869376 +Cartesian,8,3840,2160,-1000021,200,18160,18188,1927,1958,239227,239664,623763,112869376 +Cartesian,40,3840,2160,-1000021,0,9293,9488,1973,2035,138388,138615,150181,112869376 +Cartesian,40,3840,2160,-1000021,20,10171,10319,1998,2011,201069,201697,298887,112869376 +Cartesian,40,3840,2160,-1000021,200,16940,17112,1936,1940,223481,223934,510941,112869376 +Isometric,8,3840,2160,-1000021,0,16649,16777,1942,1966,453213,454074,3173502,112869376 +Isometric,8,3840,2160,-1000021,20,18457,18870,1992,2165,604209,606566,3526205,112869376 +Isometric,8,3840,2160,-1000021,200,35370,35632,1921,1972,643836,645247,3759572,112869376 +Isometric,40,3840,2160,-1000021,0,16605,16787,1980,2068,276595,277171,2351763,112869376 +Isometric,40,3840,2160,-1000021,20,18173,18373,2011,2054,363726,372134,2675243,112869376 +Isometric,40,3840,2160,-1000021,200,30793,31725,1944,2037,391065,393237,2872401,112869376 +IsometricDots,8,3840,2160,-1000021,0,16880,17105,1940,2196,235193,236404,1532620,112869376 +IsometricDots,8,3840,2160,-1000021,20,18542,18702,2015,2052,316100,317114,1784251,112869376 +IsometricDots,8,3840,2160,-1000021,200,36018,36591,2005,2146,356921,362920,2034242,112869376 +IsometricDots,40,3840,2160,-1000021,0,16983,17460,2090,2121,139698,140506,247770,112869376 +IsometricDots,40,3840,2160,-1000021,20,18091,18267,1916,2201,198149,199032,335533,112869376 +IsometricDots,40,3840,2160,-1000021,200,31535,32155,1979,2067,221090,223278,505782,112869376 diff --git a/docs/board-paper-source-performance.csv b/docs/board-paper-source-performance.csv new file mode 100644 index 000000000..30e9a5205 --- /dev/null +++ b/docs/board-paper-source-performance.csv @@ -0,0 +1,145 @@ +source,kind,spacing,width,height,erasers,pdf,median_us,p95_us,target_or_pdf_bytes +Solid,Cartesian,8,1920,1080,0,false,1651,2597,8294400 +Tile,Cartesian,8,1920,1080,0,false,2109,2119,8294400 +Solid,Cartesian,8,1920,1080,0,true,112,130,1540 +Tile,Cartesian,8,1920,1080,0,true,136,143,2447 +Solid,Cartesian,8,1920,1080,20,false,1997,2036,8294400 +Tile,Cartesian,8,1920,1080,20,false,2875,2880,8294400 +Solid,Cartesian,8,1920,1080,20,true,54509,54588,157333 +Tile,Cartesian,8,1920,1080,20,true,72930,73621,297363 +Solid,Cartesian,8,1920,1080,200,false,5479,5510,8294400 +Tile,Cartesian,8,1920,1080,200,false,9851,9870,8294400 +Solid,Cartesian,8,1920,1080,200,true,570588,571785,2469987 +Tile,Cartesian,8,1920,1080,200,true,748253,753232,3642089 +Solid,Cartesian,40,1920,1080,0,false,1634,1636,8294400 +Tile,Cartesian,40,1920,1080,0,false,2029,2040,8294400 +Solid,Cartesian,40,1920,1080,0,true,107,127,1540 +Tile,Cartesian,40,1920,1080,0,true,135,150,2451 +Solid,Cartesian,40,1920,1080,20,false,2020,2043,8294400 +Tile,Cartesian,40,1920,1080,20,false,2704,2737,8294400 +Solid,Cartesian,40,1920,1080,20,true,54296,54365,157333 +Tile,Cartesian,40,1920,1080,20,true,65218,65378,227906 +Solid,Cartesian,40,1920,1080,200,false,5427,5434,8294400 +Tile,Cartesian,40,1920,1080,200,false,8778,8786,8294400 +Solid,Cartesian,40,1920,1080,200,true,567020,570245,2469987 +Tile,Cartesian,40,1920,1080,200,true,710492,716396,3066619 +Solid,Isometric,8,1920,1080,0,false,1632,1654,8294400 +Tile,Isometric,8,1920,1080,0,false,3293,3307,8294400 +Solid,Isometric,8,1920,1080,0,true,106,128,1540 +Tile,Isometric,8,1920,1080,0,true,136,153,2526 +Solid,Isometric,8,1920,1080,20,false,2021,2064,8294400 +Tile,Isometric,8,1920,1080,20,false,4830,4845,8294400 +Solid,Isometric,8,1920,1080,20,true,54191,54303,157333 +Tile,Isometric,8,1920,1080,20,true,208126,227192,1407502 +Solid,Isometric,8,1920,1080,200,false,5414,5486,8294400 +Tile,Isometric,8,1920,1080,200,false,18266,20113,8294400 +Solid,Isometric,8,1920,1080,200,true,567555,568208,2469987 +Tile,Isometric,8,1920,1080,200,true,1735954,1748975,12731843 +Solid,Isometric,40,1920,1080,0,false,1671,1695,8294400 +Tile,Isometric,40,1920,1080,0,false,3341,3358,8294400 +Solid,Isometric,40,1920,1080,0,true,115,131,1539 +Tile,Isometric,40,1920,1080,0,true,137,154,2531 +Solid,Isometric,40,1920,1080,20,false,2077,2087,8294400 +Tile,Isometric,40,1920,1080,20,false,4649,4730,8294400 +Solid,Isometric,40,1920,1080,20,true,54437,54815,157333 +Tile,Isometric,40,1920,1080,20,true,90099,90195,564291 +Solid,Isometric,40,1920,1080,200,false,5347,5388,8294400 +Tile,Isometric,40,1920,1080,200,false,15781,15797,8294400 +Solid,Isometric,40,1920,1080,200,true,567718,572051,2469987 +Tile,Isometric,40,1920,1080,200,true,1099744,1104767,6007667 +Solid,IsometricDots,8,1920,1080,0,false,1639,1659,8294400 +Tile,IsometricDots,8,1920,1080,0,false,3306,3320,8294400 +Solid,IsometricDots,8,1920,1080,0,true,111,130,1540 +Tile,IsometricDots,8,1920,1080,0,true,172,205,2678 +Solid,IsometricDots,8,1920,1080,20,false,2040,2048,8294400 +Tile,IsometricDots,8,1920,1080,20,false,4886,4895,8294400 +Solid,IsometricDots,8,1920,1080,20,true,54924,55018,157333 +Tile,IsometricDots,8,1920,1080,20,true,89683,90246,682528 +Solid,IsometricDots,8,1920,1080,200,false,5426,5445,8294400 +Tile,IsometricDots,8,1920,1080,200,false,18146,18235,8294400 +Solid,IsometricDots,8,1920,1080,200,true,564492,568582,2469987 +Tile,IsometricDots,8,1920,1080,200,true,906547,910484,6730998 +Solid,IsometricDots,40,1920,1080,0,false,1668,2550,8294400 +Tile,IsometricDots,40,1920,1080,0,false,3254,3277,8294400 +Solid,IsometricDots,40,1920,1080,0,true,111,128,1540 +Tile,IsometricDots,40,1920,1080,0,true,176,196,2679 +Solid,IsometricDots,40,1920,1080,20,false,2014,2031,8294400 +Tile,IsometricDots,40,1920,1080,20,false,4645,6641,8294400 +Solid,IsometricDots,40,1920,1080,20,true,54239,54546,157333 +Tile,IsometricDots,40,1920,1080,20,true,65836,65967,192174 +Solid,IsometricDots,40,1920,1080,200,false,5446,5575,8294400 +Tile,IsometricDots,40,1920,1080,200,false,16041,16175,8294400 +Solid,IsometricDots,40,1920,1080,200,true,565981,566682,2469987 +Tile,IsometricDots,40,1920,1080,200,true,730389,731736,2762761 +Solid,Cartesian,8,3840,2160,0,false,4852,5650,33177600 +Tile,Cartesian,8,3840,2160,0,false,6498,6690,33177600 +Solid,Cartesian,8,3840,2160,0,true,109,132,1543 +Tile,Cartesian,8,3840,2160,0,true,132,144,2449 +Solid,Cartesian,8,3840,2160,20,false,5742,5935,33177600 +Tile,Cartesian,8,3840,2160,20,false,7375,7578,33177600 +Solid,Cartesian,8,3840,2160,20,true,55091,55388,149953 +Tile,Cartesian,8,3840,2160,20,true,72774,72779,284050 +Solid,Cartesian,8,3840,2160,200,false,10297,10773,33177600 +Tile,Cartesian,8,3840,2160,200,false,16245,16666,33177600 +Solid,Cartesian,8,3840,2160,200,true,589838,593012,2941444 +Tile,Cartesian,8,3840,2160,200,true,799591,803500,3990879 +Solid,Cartesian,40,3840,2160,0,false,5187,5396,33177600 +Tile,Cartesian,40,3840,2160,0,false,6256,6456,33177600 +Solid,Cartesian,40,3840,2160,0,true,113,131,1542 +Tile,Cartesian,40,3840,2160,0,true,131,144,2453 +Solid,Cartesian,40,3840,2160,20,false,5339,5443,33177600 +Tile,Cartesian,40,3840,2160,20,false,6911,7028,33177600 +Solid,Cartesian,40,3840,2160,20,true,55440,55610,149953 +Tile,Cartesian,40,3840,2160,20,true,66254,66464,215815 +Solid,Cartesian,40,3840,2160,200,false,11128,11629,33177600 +Tile,Cartesian,40,3840,2160,200,false,14686,14778,33177600 +Solid,Cartesian,40,3840,2160,200,true,591393,598996,2941444 +Tile,Cartesian,40,3840,2160,200,true,767512,774257,3528120 +Solid,Isometric,8,3840,2160,0,false,5128,5391,33177600 +Tile,Isometric,8,3840,2160,0,false,11214,11397,33177600 +Solid,Isometric,8,3840,2160,0,true,108,129,1542 +Tile,Isometric,8,3840,2160,0,true,139,152,2530 +Solid,Isometric,8,3840,2160,20,false,5787,6034,33177600 +Tile,Isometric,8,3840,2160,20,false,12913,12996,33177600 +Solid,Isometric,8,3840,2160,20,true,55172,57034,149953 +Tile,Isometric,8,3840,2160,20,true,202756,204920,1386368 +Solid,Isometric,8,3840,2160,200,false,11581,11646,33177600 +Tile,Isometric,8,3840,2160,200,false,30583,30622,33177600 +Solid,Isometric,8,3840,2160,200,true,592189,604378,2941444 +Tile,Isometric,8,3840,2160,200,true,1824540,1842717,14531343 +Solid,Isometric,40,3840,2160,0,false,5841,5947,33177600 +Tile,Isometric,40,3840,2160,0,false,12047,12118,33177600 +Solid,Isometric,40,3840,2160,0,true,120,134,1542 +Tile,Isometric,40,3840,2160,0,true,145,159,2533 +Solid,Isometric,40,3840,2160,20,false,6492,6552,33177600 +Tile,Isometric,40,3840,2160,20,false,13579,13645,33177600 +Solid,Isometric,40,3840,2160,20,true,57160,57479,149953 +Tile,Isometric,40,3840,2160,20,true,94379,96976,551501 +Solid,Isometric,40,3840,2160,200,false,13841,17122,33177600 +Tile,Isometric,40,3840,2160,200,false,28812,29316,33177600 +Solid,Isometric,40,3840,2160,200,true,607961,615508,2941444 +Tile,Isometric,40,3840,2160,200,true,1156582,1163901,6642415 +Solid,IsometricDots,8,3840,2160,0,false,4885,4985,33177600 +Tile,IsometricDots,8,3840,2160,0,false,11344,11544,33177600 +Solid,IsometricDots,8,3840,2160,0,true,109,133,1542 +Tile,IsometricDots,8,3840,2160,0,true,172,188,2681 +Solid,IsometricDots,8,3840,2160,20,false,5319,5900,33177600 +Tile,IsometricDots,8,3840,2160,20,false,12856,13400,33177600 +Solid,IsometricDots,8,3840,2160,20,true,55338,55610,149954 +Tile,IsometricDots,8,3840,2160,20,true,90630,90963,670417 +Solid,IsometricDots,8,3840,2160,200,false,10771,11161,33177600 +Tile,IsometricDots,8,3840,2160,200,false,30072,30136,33177600 +Solid,IsometricDots,8,3840,2160,200,true,593308,594650,2941444 +Tile,IsometricDots,8,3840,2160,200,true,978665,1000595,7478069 +Solid,IsometricDots,40,3840,2160,0,false,5303,5343,33177600 +Tile,IsometricDots,40,3840,2160,0,false,11401,11435,33177600 +Solid,IsometricDots,40,3840,2160,0,true,112,132,1542 +Tile,IsometricDots,40,3840,2160,0,true,181,191,2682 +Solid,IsometricDots,40,3840,2160,20,false,5813,5929,33177600 +Tile,IsometricDots,40,3840,2160,20,false,12884,12937,33177600 +Solid,IsometricDots,40,3840,2160,20,true,56009,67806,149953 +Tile,IsometricDots,40,3840,2160,20,true,67464,74172,186626 +Solid,IsometricDots,40,3840,2160,200,false,11685,11798,33177600 +Tile,IsometricDots,40,3840,2160,200,false,27179,27269,33177600 +Solid,IsometricDots,40,3840,2160,200,true,610095,619162,2941444 +Tile,IsometricDots,40,3840,2160,200,true,919269,1180332,3241025 diff --git a/docs/board-paper-validation.md b/docs/board-paper-validation.md new file mode 100644 index 000000000..ee932bee7 --- /dev/null +++ b/docs/board-paper-validation.md @@ -0,0 +1,159 @@ +# Board paper implementation and validation + +Issue: [#389](https://github.com/devmobasa/wayscriber/issues/389). + +Cartesian, isometric lines, and isometric dots share a procedural paper source. +All shipped board templates still default to None. Background opacity, +transparent-board behavior, and screenshot source provenance keep their existing +owners. This document records implementation decisions and headless evidence; +it does not claim a live Wayland or GTK interaction test. + +## Final local checks + +`./tools/lint-and-test.sh` passed on 2026-09-12, including packaging and source +audits, 75 C# tooling tests, formatting, builds, and strict workspace Clippy in +both feature configurations. The all-feature suite passed 4,584 root and 491 +configurator tests; no-default-features passed 4,424 root and 490 configurator +tests. CLI, daemon fixture, UI integration, documentation, and the separately +invoked retained context-menu/board-picker render tests also passed. + +The headless appearance sheet was inspected at 420×300 and its bounds tested +at 420×300 and 900×700. Poppler PDF raster comparisons ran on this machine. +No live Wayland overlay or GTK configurator was launched, and no GitHub Actions +run was triggered by the feature-branch pushes. The performance measurements +below are separate opt-in runs, not timings collected during the CI suite. + +## State and recovery + +Appearance lives with the board, outside drawing history. The picker owns an +identity-bound draft and applies a field-level patch once. The small appearance +sheet has the same control geometry for painting and mouse input. Invalid raw +spacing remains editable, and Cancel or an invalidated identity cannot commit. +Draft differences are computed from final values, so changing a field and then +reverting it preserves later external changes. Preview updates and dismissal +damage the sheet's screen rectangle without publishing a board or session edit. +The visible outer palette edits that same draft; its clicks preserve pending +pattern and spacing changes, and both palette and sheet are marked for repaint. + +Each `BoardState` owns its immutable configured/template appearance seed. Keeping +this seed on the identity-bearing state, instead of a second manager map, lets +ordinary duplicate/delete/restore operations carry it with the board. Session +replacement resets retained states from those seeds before applying saved paper. +Saved tools restore last, including on a restore to the already-active board. + +Format 7 stores appearance with drawing data. Explicit paper edits also persist +on otherwise empty boards. Merely capturing an empty configured template cannot +claim recovery priority. History trimming drops non-explicit appearance when no +page data remains; explicit empty paper survives. Invalid appearance metadata +falls back to the seed without discarding valid drawings. Existing newer-format +preservation and clear-marker handling remain in use. + +## Renderer selection + +The full-viewport recording candidate was rejected before broad integration. +At 1920×1080, spacing 8 and 200 erasers, preliminary isometric-line medians were +about 405 ms for the viewport recording versus 26 ms for a compact vector tile. +Isometric dots similarly measured about 258 ms versus 34 ms. These preliminary +numbers came from the candidate comparison and are not the final raster timings. +The viewport recording walks visible grid geometry on each eraser replay, failing +the plan's adoption threshold (more than both 20% and 1 ms slower). + +The selected source repeats a compact Cartesian or isometric tile. Raster +consumers generate a small raster tile once per paper paint/replay pass; PDF +uses an integer-unit recording tile and remains vector without erasers. Source +phase is reduced by whole tile periods at far-away coordinates. Erasers reuse +the exact source and phase used to paint the background. Blur still uses the +existing captured-image rules; procedural paper does not pretend to be a capture. + +The raster tile caps density at 4×. At spacing 200 its maximum backing allocation +is 4,435,200 bytes (1386×800×4), independent of viewport and eraser count. The pan +cache retains its existing 256 MiB cap. Allocation counts were not instrumented; +reported surface byte counts are explicit proxies, not process peak RSS. + +## Source benchmark + +[Full measurements](board-paper-source-performance.csv), taken headlessly on an +AMD Ryzen 9 7950X, Cairo 1.18.4, Rust 1.98.1 test profile. One warmup followed by +five measurements; median is the middle sample and reported p95 is the maximum +of those five samples. No separate build or test was run concurrently by this +implementation during measurement. This is a local regression comparison, not +a hardware-independent frame-rate guarantee. + +Both viewport sizes use scale 1 (logical size equals device size), a far negative +origin, 80 fixed annotation lines, alternating circular/rectangular erasers with +three points and size 32, and 0/20/200 committed erasers. Raster cases add one +provisional eraser; PDF cases do not. Timing includes target/source construction, +annotation replay, erasers, and PDF finalization where applicable. + +| Pattern | Spacing | 1920×1080 median / p95 | 3840×2160 median / p95 | +|---|---:|---:|---:| +| Cartesian | 8 | 9.85 / 9.87 ms | 16.25 / 16.67 ms | +| Cartesian | 40 | 8.78 / 8.79 ms | 14.69 / 14.78 ms | +| Isometric lines | 8 | 18.27 / 20.11 ms | 30.58 / 30.62 ms | +| Isometric lines | 40 | 15.78 / 15.80 ms | 28.81 / 29.32 ms | +| Isometric dots | 8 | 18.15 / 18.24 ms | 30.07 / 30.14 ms | +| Isometric dots | 40 | 16.04 / 16.18 ms | 27.18 / 27.27 ms | + +The table shows 200 committed erasers plus one provisional stroke. Ordinary +panning uses the existing committed-layer cache, so it does not pay this full +replay cost on every frame. None retains the existing solid paint path. + +PDF files without erasers retain vector pattern resources (checked against +`/Subtype /Image`). Cairo's eraser compositing causes fallback raster work, +already present for plain solid backgrounds. Patterns increase fallback PDF +size and CPU cost; the CSV records both rather than presenting these pages as +wholly vector. + +## Regression coverage + +- Config defaults, enum/schema, spacing bounds, transparent normalization, + template inheritance, and comment/unknown-field preservation on guarded saves. +- Negative and far-negative coordinates, fractional/2× scale, tile boundaries, + Cairo path/state preservation, and circular/rectangular eraser replay. +- Direct versus baked pan rendering, pattern/spacing cache invalidation, PNG + eraser pixels, immutable exports, and plain PDF margins with vector paper. + Actual PDFs are also rasterized with Poppler when available to check paper + phase and eraser restoration against the raster renderer. +- Draft Cancel/no-op/invalid input, conflict and identity checks, field-level + merge, pen preservation on grid-only edits, and unchanged drawing history. +- Empty-board persistence, exact optional pen restoration, legacy appearance + seeds, named replacement, and backup recovery/history-trimming distinctions. + +## Integrated pan and PNG consumers + +[Initial consumer measurements](board-paper-consumer-performance.csv) use the actual +`CanvasLayerCache::ensure`/`blit` and PNG export entry points. The matrix includes +both zero and far-negative origins, both sizes, None plus all three patterns, +spacing 8/40, and 0/20/200 committed erasers. Warmup/sample counts, scale, shapes, +and brushes match the source benchmark. PNG includes encoding. A warm pan sample +checks cache validity and blits into an already allocated destination; it does +not construct the paper again. Those initial warm measurements covered only +`ensure` and `blit`, so they did not detect redundant painting in the live caller. +The benchmark now invokes the production committed-canvas dispatcher, including +backdrop preparation, and asserts that a cache hit constructs no paper source. +A 4K regression checks that path and paper restoration after a failed blit. +The historical CSV below has not been remeasured with this expanded harness. + +| Consumer (patterned boards) | 1920×1080 p95 range | 3840×2160 p95 range | +|---|---:|---:| +| Cold pan-cache bake | 3.60–42.47 ms | 9.10–39.03 ms | +| Warm pan-cache check and blit | 0.19–1.73 ms | 1.94–3.34 ms | +| PNG render and encoding | 34.73–305.44 ms | 138.62–660.61 ms | + +Ranges include all tested origins, spacings, patterns, and eraser counts, so +an occasional slower local sample is retained rather than removed. Live pan +reuse stays a blit. Export time is worker-side work, not event-loop rendering. +The simultaneous surface allocation proxy is approximately 30.6 MiB at 1080p +and 107.6 MiB at 4K for the benchmark's retained bake, blit destination, and PNG +target; it excludes codec scratch and the separately bounded paper tile. + +To reproduce the opt-in measurements: + +```sh +cargo test -p wayscriber --all-features --lib board_grid_backdrop_performance -- --ignored --nocapture --test-threads=1 +cargo test -p wayscriber --all-features --lib board_grid_consumers_performance -- --ignored --nocapture --test-threads=1 +``` + +Set `WAYSCRIBER_GRID_COMPARE_VIEWPORT=1` on the first command to include the +rejected viewport-recording candidate. These benchmarks are opt-in so ordinary +CI does not spend time measuring machine-dependent rendering latency. diff --git a/docs/codebase-overview.md b/docs/codebase-overview.md index 2a3fc624a..58370322f 100644 --- a/docs/codebase-overview.md +++ b/docs/codebase-overview.md @@ -654,3 +654,13 @@ Use `./tools/lint-and-test.sh` for package/source checks, formatting, linting, b and tests under both workspace feature configurations. Required GTK widget coverage is `./tools/test-gtk-widgets.sh`. Live Wayland focus, layer-shell, capture, and installed-binary checks remain separate from these automated checks. + +## Board paper + +Board pattern values live in `src/domain/board_grid.rs`; config adapters validate +and serialize them. `src/draw/render/board_grid.rs` builds the procedural source +shared by the canvas, erasers, pan cache, thumbnails, and canvas exports. +`src/input/state/core/board/appearance.rs` publishes appearance edits once; +the board picker owns only its draft. Session format 7 carries appearance and +its explicit-override provenance independently of page history. See +[board paper validation](board-paper-validation.md) for regression and performance evidence. diff --git a/src/backend/wayland/session/tests.rs b/src/backend/wayland/session/tests.rs index c98ce87b4..0f2107a05 100644 --- a/src/backend/wayland/session/tests.rs +++ b/src/backend/wayland/session/tests.rs @@ -168,6 +168,7 @@ fn board_snapshot(id: &str, x2: i32) -> stored_session::BoardSnapshot { thick: 2.0, }); stored_session::BoardSnapshot { + appearance: None, id: id.to_string(), pages: stored_session::BoardPagesSnapshot { pages: vec![frame], diff --git a/src/backend/wayland/state/canvas_layer.rs b/src/backend/wayland/state/canvas_layer.rs index c7a99a45a..04182a66b 100644 --- a/src/backend/wayland/state/canvas_layer.rs +++ b/src/backend/wayland/state/canvas_layer.rs @@ -36,6 +36,7 @@ pub(in crate::backend::wayland) struct CanvasLayerCache { shapes_len: usize, last_shape_id: Option, background: Option, + grid: crate::domain::BoardGrid, text_halo_enabled: bool, board_key: (usize, usize), valid: bool, @@ -54,6 +55,7 @@ impl CanvasLayerCache { shapes_len: 0, last_shape_id: None, background: None, + grid: Default::default(), text_halo_enabled: true, board_key: (0, 0), valid: false, @@ -180,6 +182,7 @@ impl WaylandState { scale, origin, background, + grid: self.input_state.boards.active_board().spec.grid, text_halo_enabled, board_key, generation, @@ -195,6 +198,7 @@ pub(super) struct CanvasLayerInputs { pub(super) scale: i32, pub(super) origin: (f64, f64), pub(super) background: Option, + pub(super) grid: crate::domain::BoardGrid, pub(super) text_halo_enabled: bool, pub(super) board_key: (usize, usize), pub(super) generation: u64, @@ -214,6 +218,7 @@ impl CanvasLayerCache { scale, origin, background, + grid, text_halo_enabled, board_key, generation, @@ -237,6 +242,7 @@ impl CanvasLayerCache { && cache.shapes_len == shapes_len && cache.last_shape_id == last_shape_id && cache.background == background + && cache.grid == grid && cache.text_halo_enabled == text_halo_enabled && cache.board_key == board_key; let covers_view = view_x >= cache.world_x @@ -297,10 +303,28 @@ impl CanvasLayerCache { bake_ctx.scale(scale as f64, scale as f64); bake_ctx.translate(-(world_x as f64), -(world_y as f64)); - // Erasers clear down to the baked solid background; blur rects have + let paper = match background.filter(|_| grid.kind != crate::domain::BoardGridKind::None) + { + Some(color) => match crate::draw::BoardPaper::for_context(color, grid, &bake_ctx) { + Ok(paper) => { + if paper.paint(&bake_ctx).is_err() { + cache.clear(); + return false; + } + Some(paper) + } + Err(_) => { + cache.clear(); + return false; + } + }, + None => None, + }; + + // Erasers clear down to the baked board paper; blur rects have // no backdrop image in this mode (same as the direct render path). let replay_ctx = crate::draw::EraserReplayContext { - pattern: None, + pattern: paper.as_ref().map(crate::draw::BoardPaper::pattern), surface: None, backdrop_cache_key: None, bg_color: background, @@ -347,6 +371,7 @@ impl CanvasLayerCache { cache.shapes_len = shapes_len; cache.last_shape_id = last_shape_id; cache.background = background; + cache.grid = grid; cache.text_halo_enabled = text_halo_enabled; cache.board_key = board_key; cache.valid = true; diff --git a/src/backend/wayland/state/capture.rs b/src/backend/wayland/state/capture.rs index 10ead0a94..d78bf3091 100644 --- a/src/backend/wayland/state/capture.rs +++ b/src/backend/wayland/state/capture.rs @@ -392,7 +392,10 @@ impl WaylandState { CanvasExportBackdropSnapshot::Transparent } crate::input::BoardBackground::Solid(color) => { - CanvasExportBackdropSnapshot::Solid(*color) + CanvasExportBackdropSnapshot::board_paper( + *color, + self.input_state.boards.active_board().spec.grid, + ) } }, board: BoardExportSnapshot { diff --git a/src/backend/wayland/state/clipboard/session_paste.rs b/src/backend/wayland/state/clipboard/session_paste.rs index e955da054..67ff67b48 100644 --- a/src/backend/wayland/state/clipboard/session_paste.rs +++ b/src/backend/wayland/state/clipboard/session_paste.rs @@ -174,6 +174,7 @@ fn snapshot_after_external_image_paste_from_input( return None; } snapshot.boards.push(session::BoardSnapshot { + appearance: Some(session::BoardAppearanceSnapshot::capture(target_board)), id: target_board.spec.id.clone(), pages: snapshot_pages_for_preflight(target_board, input, options), }); diff --git a/src/backend/wayland/state/core/output/tests.rs b/src/backend/wayland/state/core/output/tests.rs index 32416903d..d64380dd8 100644 --- a/src/backend/wayland/state/core/output/tests.rs +++ b/src/backend/wayland/state/core/output/tests.rs @@ -55,6 +55,7 @@ fn partial_output_load_clears_boards_omitted_from_snapshot() { let snapshot = SessionSnapshot { active_board_id: "whiteboard".to_string(), boards: vec![BoardSnapshot { + appearance: None, id: "whiteboard".to_string(), pages: BoardPagesSnapshot { pages: vec![Frame::new()], @@ -83,6 +84,7 @@ fn failed_output_replacement_preserves_source_board_contents() { add_test_line(&mut input); let boards = (0..=input.boards.max_count()) .map(|index| BoardSnapshot { + appearance: None, id: format!("replacement-{index}"), pages: BoardPagesSnapshot { pages: vec![Frame::new()], diff --git a/src/backend/wayland/state/pdf_export.rs b/src/backend/wayland/state/pdf_export.rs index 91ae7d067..5eae65327 100644 --- a/src/backend/wayland/state/pdf_export.rs +++ b/src/backend/wayland/state/pdf_export.rs @@ -114,8 +114,11 @@ fn build_board_pdf_export_snapshot( let board = &boards[app_board_index]; let board_page_count = board.pages.pages().len().max(1); for (board_page_index, frame) in board.pages.pages().iter().enumerate() { - let backdrop = - backdrop_from_background(&board.spec.background, desktop_backdrop.as_ref()); + let backdrop = backdrop_from_background( + &board.spec.background, + board.spec.grid, + desktop_backdrop.as_ref(), + ); let use_page_offsets = pan_enabled && !board.spec.background.is_transparent(); let (origin_x, origin_y) = if use_page_offsets { frame.view_offset() @@ -240,13 +243,14 @@ fn pdf_export_scope_has_transparent_pages( fn backdrop_from_background( background: &BoardBackground, + grid: crate::domain::BoardGrid, desktop_backdrop: Option<&CanvasExportBackdropSnapshot>, ) -> CanvasExportBackdropSnapshot { match background { BoardBackground::Transparent => desktop_backdrop .cloned() .unwrap_or(CanvasExportBackdropSnapshot::Transparent), - BoardBackground::Solid(color) => CanvasExportBackdropSnapshot::Solid(*color), + BoardBackground::Solid(color) => CanvasExportBackdropSnapshot::board_paper(*color, grid), } } diff --git a/src/backend/wayland/state/pdf_export/tests.rs b/src/backend/wayland/state/pdf_export/tests.rs index 44f83c3a6..776c730d7 100644 --- a/src/backend/wayland/state/pdf_export/tests.rs +++ b/src/backend/wayland/state/pdf_export/tests.rs @@ -5,18 +5,18 @@ use crate::input::BoardSpec; use std::sync::Arc; fn board(id: &str, name: &str, background: BoardBackground, pages: Vec) -> BoardState { - BoardState { - spec: BoardSpec { - id: id.to_string(), - name: name.to_string(), - background, - default_pen_color: None, - auto_adjust_pen: false, - persist: true, - pinned: false, - }, - pages: crate::draw::BoardPages::from_pages(pages, 0), - } + let mut board = BoardState::new(BoardSpec { + id: id.to_string(), + name: name.to_string(), + background, + grid: Default::default(), + default_pen_color: None, + auto_adjust_pen: false, + persist: true, + pinned: false, + }); + board.pages = crate::draw::BoardPages::from_pages(pages, 0); + board } fn snapshot_context<'a>( diff --git a/src/backend/wayland/state/render/canvas/background.rs b/src/backend/wayland/state/render/canvas/background.rs index 5d49b1542..13950ccd1 100644 --- a/src/backend/wayland/state/render/canvas/background.rs +++ b/src/backend/wayland/state/render/canvas/background.rs @@ -7,6 +7,7 @@ use crate::backend::wayland::state::screen_image::{ use crate::draw::Color; pub(super) struct CanvasEraserContext { + paper: Option, surface: Option, pattern: Option, backdrop_cache_key: Option, @@ -21,9 +22,46 @@ pub(super) struct CanvasEraserContext { } impl CanvasEraserContext { + pub(super) fn for_board(bg_color: Option) -> Self { + Self { + paper: None, + surface: None, + pattern: None, + backdrop_cache_key: None, + bg_color, + logical_to_image_scale_x: 1.0, + logical_to_image_scale_y: 1.0, + magnifier_source: crate::draw::SpotlightMagnifierSource::from_backdrop( + None, + bg_color.is_some(), + ), + } + } + + pub(super) fn prepare_paper( + &mut self, + ctx: &cairo::Context, + grid: crate::domain::BoardGrid, + ) -> Result<()> { + if let Some(color) = self.bg_color { + if grid.kind == crate::domain::BoardGridKind::None { + ctx.set_source_rgba(color.r, color.g, color.b, color.a); + ctx.paint()?; + } else { + let paper = crate::draw::BoardPaper::for_context(color, grid, ctx)?; + paper.paint(ctx)?; + self.paper = Some(paper); + } + } + Ok(()) + } pub(super) fn replay_context(&self) -> crate::draw::EraserReplayContext<'_> { crate::draw::EraserReplayContext { - pattern: self.pattern.as_ref().map(|p| p as &cairo::Pattern), + pattern: self + .paper + .as_ref() + .map(crate::draw::BoardPaper::pattern) + .or_else(|| self.pattern.as_ref().map(|p| p as &cairo::Pattern)), surface: self.surface.as_ref(), backdrop_cache_key: self.backdrop_cache_key, bg_color: self.bg_color, @@ -126,13 +164,13 @@ impl WaylandState { scale: i32, phys_width: u32, phys_height: u32, + paint_board: bool, ) -> Result { - let mut eraser_surface: Option = None; - let mut eraser_pattern: Option = None; - let mut backdrop_cache_key: Option = None; - let mut eraser_bg_color: Option = None; - let mut logical_to_image_scale_x = 1.0; - let mut logical_to_image_scale_y = 1.0; + let eraser_surface; + let eraser_pattern; + let backdrop_cache_key; + let logical_to_image_scale_x; + let logical_to_image_scale_y; // One provenance answer decides both what is painted and what the loupe // may sample, so the pixels on screen and the availability reported can @@ -215,21 +253,23 @@ impl WaylandState { eraser_pattern = Some(pattern); backdrop_cache_key = Some(cache_key); } else { - match self.input_state.boards.active_background() { - crate::input::BoardBackground::Solid(color) => { - ctx.set_source_rgba(color.r, color.g, color.b, color.a); - let _ = ctx.paint(); - eraser_bg_color = Some(*color); - } - crate::input::BoardBackground::Transparent => {} + let color = match self.input_state.boards.active_background() { + crate::input::BoardBackground::Solid(color) => Some(*color), + crate::input::BoardBackground::Transparent => None, + }; + let mut backdrop = CanvasEraserContext::for_board(color); + if paint_board { + backdrop.prepare_paper(ctx, crate::domain::BoardGrid::default())?; } + return Ok(backdrop); } Ok(CanvasEraserContext { + paper: None, surface: eraser_surface, pattern: eraser_pattern, backdrop_cache_key, - bg_color: eraser_bg_color, + bg_color: None, logical_to_image_scale_x, logical_to_image_scale_y, magnifier_source, diff --git a/src/backend/wayland/state/render/canvas/mod.rs b/src/backend/wayland/state/render/canvas/mod.rs index 6db651a40..597daed2d 100644 --- a/src/backend/wayland/state/render/canvas/mod.rs +++ b/src/backend/wayland/state/render/canvas/mod.rs @@ -110,7 +110,13 @@ impl WaylandState { } let background_start = perf.as_ref().map(|_| Instant::now()); - let eraser_ctx = self.render_canvas_background(ctx, scale, phys_width, phys_height)?; + let mut eraser_ctx = self.render_canvas_background( + ctx, + scale, + phys_width, + phys_height, + !canvas.canvas.draw_committed, + )?; if let (Some(perf), Some(background_start)) = (perf.as_mut(), background_start) { perf.stages.background = perf .stages @@ -150,8 +156,6 @@ impl WaylandState { ctx.translate(-canvas_origin_x, -canvas_origin_y); } - let replay_ctx = eraser_ctx.replay_context(); - let completed_shapes_start = perf.as_ref().map(|_| Instant::now()); let (layer_cache, draw_caches, measurer) = self.render.canvas_draw_parts_mut(); render_committed_canvas_shapes( @@ -161,9 +165,10 @@ impl WaylandState { draw_caches, canvas, layer_cache_ready, - &replay_ctx, + &mut eraser_ctx, + self.input_state.boards.active_board().spec.grid, perf.as_deref_mut(), - ); + )?; if let (Some(perf), Some(completed_shapes_start)) = (perf.as_mut(), completed_shapes_start) { perf.stages.completed_shapes = perf @@ -285,6 +290,7 @@ impl WaylandState { self.render_eraser_hover_halos(ctx, hover_mx, hover_my); + let replay_ctx = eraser_ctx.replay_context(); let provisional = self.input_state.provisional_tool_stroke(mx, my); let provisional_points = provisional_point_count(&provisional); let provisional_start = perf.as_ref().map(|_| Instant::now()); @@ -341,9 +347,10 @@ fn render_committed_canvas_shapes( draw_caches: &mut crate::draw::RenderCaches, canvas: &CanvasRenderCtx<'_>, layer_cache_ready: bool, - replay_ctx: &crate::draw::EraserReplayContext<'_>, + eraser_ctx: &mut background::CanvasEraserContext, + grid: crate::domain::BoardGrid, mut perf: Option<&mut PerfRenderBreakdown>, -) { +) -> Result<()> { let ctx = canvas.cairo; let width = canvas.geometry.width; let height = canvas.geometry.height; @@ -356,8 +363,12 @@ fn render_committed_canvas_shapes( perf.shapes_total = shapes.len(); perf.canvas_layer_cache_used = true; } - return; + return Ok(()); } + // A successful blit already includes the board paper. Build and paint its + // source only on the direct path, including a failed-cache fallback. + eraser_ctx.prepare_paper(ctx, grid)?; + let replay_ctx = eraser_ctx.replay_context(); debug!("Rendering {} completed shapes", shapes.len()); if let Some(perf) = perf.as_mut() { perf.shapes_total = shapes.len(); @@ -371,7 +382,7 @@ fn render_committed_canvas_shapes( measurer, &mut render, shape, - replay_ctx, + &replay_ctx, text_halo_enabled, ) }; @@ -383,12 +394,12 @@ fn render_committed_canvas_shapes( perf.shapes_tested = shapes.len(); perf.shapes_rendered = shapes.len(); } - return; + return Ok(()); }; let Some(safe_bounds) = safe_shape_damage_bounds(bounds, width, height, canvas_transform_active) else { - return; + return Ok(()); }; let mut shapes_rendered = 0usize; for shape in shapes { @@ -404,6 +415,7 @@ fn render_committed_canvas_shapes( perf.shapes_tested = shapes.len(); perf.shapes_rendered = shapes_rendered; } + Ok(()) } fn union_damage_bounds(regions: &[crate::util::Rect]) -> Option { diff --git a/src/backend/wayland/state/render/canvas/resource_tests.rs b/src/backend/wayland/state/render/canvas/resource_tests.rs index bdef6ff16..c7272214f 100644 --- a/src/backend/wayland/state/render/canvas/resource_tests.rs +++ b/src/backend/wayland/state/render/canvas/resource_tests.rs @@ -5,6 +5,7 @@ use crate::draw::{Color, DrawnShape, EmbeddedImage, EraserBrush, EraserKind, Sha fn inputs() -> CanvasLayerInputs { CanvasLayerInputs { + grid: Default::default(), width: 80, height: 64, scale: 1, @@ -87,10 +88,6 @@ fn paint( .unwrap(); { let cairo = cairo::Context::new(&surface).unwrap(); - if let Some(color) = inputs.background { - cairo.set_source_rgba(color.r, color.g, color.b, color.a); - cairo.paint().unwrap(); - } cairo.scale(inputs.scale as f64, inputs.scale as f64); cairo.translate(-inputs.origin.0, -inputs.origin.1); let frame = CanvasFrame { @@ -109,19 +106,26 @@ fn paint( damage_world: &[], now: Instant::now(), }; - let replay = crate::draw::EraserReplayContext { - pattern: None, - surface: None, - backdrop_cache_key: None, - bg_color: inputs.background, - logical_to_image_scale_x: 1.0, - logical_to_image_scale_y: 1.0, - logical_image_origin_x: 0.0, - logical_image_origin_y: 0.0, - }; + let mut backdrop = background::CanvasEraserContext::for_board(inputs.background); + let mut perf = PerfRenderBreakdown::default(); render_committed_canvas_shapes( - measurer, shapes, layer, caches, &canvas, cached, &replay, None, - ); + measurer, + shapes, + layer, + caches, + &canvas, + cached, + &mut backdrop, + inputs.grid, + Some(&mut perf), + ) + .unwrap(); + if perf.canvas_layer_cache_used { + assert!( + backdrop.replay_context().pattern.is_none(), + "cache hits must not construct or paint a paper source" + ); + } } surface.flush(); surface.data().unwrap().to_vec() @@ -368,16 +372,8 @@ fn measure_sparse_damage_scan() { damage_world: &damage, now: Instant::now(), }; - let replay = crate::draw::EraserReplayContext { - pattern: None, - surface: None, - backdrop_cache_key: None, - bg_color: None, - logical_to_image_scale_x: 1.0, - logical_to_image_scale_y: 1.0, - logical_image_origin_x: 0.0, - logical_image_origin_y: 0.0, - }; + let mut backdrop = background::CanvasEraserContext::for_board(None); + for count in [100, 1_000, 10_000] { let shapes: Vec<_> = (0..count) .map(|i| { @@ -413,9 +409,11 @@ fn measure_sparse_damage_scan() { &mut caches, &canvas, false, - &replay, + &mut backdrop, + Default::default(), Some(&mut perf), - ); + ) + .unwrap(); } eprintln!( "P03 shapes={count} tested={} rendered={} mean_us={:.2}", @@ -425,3 +423,65 @@ fn measure_sparse_damage_scan() { ); } } + +#[test] +fn board_grid_baked_pan_matches_direct_and_invalidates_on_pattern_and_spacing() { + use crate::domain::{BoardGrid, BoardGridKind}; + let measurer = crate::draw::TextMeasurer::default(); + let mut cache = CanvasLayerCache::new(); + let mut caches = crate::draw::RenderCaches::default(); + let shapes = shapes(); + for origin in [(0.0, 0.0), (-71.0, -53.0), (-1_000_021.0, -2_000_003.0)] { + for kind in BoardGridKind::ALL { + for spacing in [8, 40] { + let request = CanvasLayerInputs { + grid: BoardGrid::new(kind, spacing), + origin, + ..inputs() + }; + assert!(cache.ensure(&measurer, &mut caches, &shapes, request)); + let direct = paint(&measurer, &shapes, &cache, &mut caches, request, false); + let cached = paint(&measurer, &shapes, &cache, &mut caches, request, true); + let error: u64 = direct + .iter() + .zip(&cached) + .map(|(a, b)| u64::from(a.abs_diff(*b))) + .sum(); + assert!( + error as f64 / (direct.len() as f64) < 1.0, + "{kind:?} {spacing} {origin:?}: cached phase differs" + ); + } + } + } +} + +mod grid_performance; + +#[test] +fn warm_4k_paper_render_skips_source_and_failed_blit_restores_paper() { + let measurer = crate::draw::TextMeasurer::default(); + let request = CanvasLayerInputs { + width: 3840, + height: 2160, + grid: crate::domain::BoardGrid::new(crate::domain::BoardGridKind::Isometric, 40), + ..inputs() + }; + let mut cache = CanvasLayerCache::new(); + let mut caches = crate::draw::RenderCaches::default(); + assert!(cache.ensure(&measurer, &mut caches, &[], request)); + let warm = paint(&measurer, &[], &cache, &mut caches, request, true); + // Simulate an unavailable cache even though readiness was reported. The + // production dispatcher must paint the backdrop before replaying shapes. + cache.clear(); + let fallback = paint(&measurer, &[], &cache, &mut caches, request, true); + let direct = paint(&measurer, &[], &cache, &mut caches, request, false); + assert_eq!(fallback, direct); + let mean_error = warm + .iter() + .zip(&direct) + .map(|(a, b)| u64::from(a.abs_diff(*b))) + .sum::() as f64 + / warm.len() as f64; + assert!(mean_error < 1.0, "cached paper differs: {mean_error}"); +} diff --git a/src/backend/wayland/state/render/canvas/resource_tests/grid_performance.rs b/src/backend/wayland/state/render/canvas/resource_tests/grid_performance.rs new file mode 100644 index 000000000..12e189762 --- /dev/null +++ b/src/backend/wayland/state/render/canvas/resource_tests/grid_performance.rs @@ -0,0 +1,169 @@ +//! Opt-in end-to-end paper consumers. No compositor or native windows. +use super::*; +use crate::canvas_export::{ + BoardExportSnapshot, CanvasExportBackdropSnapshot, CanvasExportSnapshot, CanvasExportViewport, + render_canvas_png, +}; +use crate::domain::{BoardGrid, BoardGridKind}; +use std::time::Instant; + +fn workload(origin: (i32, i32), size: (u32, u32), count: usize) -> crate::draw::Frame { + let mut frame = crate::draw::Frame::new(); + for n in 0..80 { + frame.add_shape(Shape::Line { + x1: origin.0, + y1: origin.1 + n * 19, + x2: origin.0 + size.0 as i32, + y2: origin.1 + n * 19 + 60, + color: crate::draw::BLUE, + thick: 3.0, + }); + } + for n in 0..count { + let (x, y) = ( + origin.0 + (n as i32 * 73 % size.0 as i32), + origin.1 + (n as i32 * 47 % size.1 as i32), + ); + frame.add_shape(Shape::EraserStroke { + points: vec![(x, y), (x + 100, y + 24), (x + 180, y + 10)], + brush: EraserBrush { + kind: if n % 2 == 0 { + EraserKind::Circle + } else { + EraserKind::Rect + }, + size: 32.0, + }, + }); + } + frame +} + +#[test] +#[ignore = "opt-in cold/warm pan and PNG benchmark"] +fn board_grid_consumers_performance() { + println!( + "kind,spacing,width,height,origin,erasers,cold_pan_median_us,cold_pan_p95_us,warm_pan_median_us,warm_pan_p95_us,png_median_us,png_p95_us,png_bytes,surface_bytes_proxy" + ); + for size in [(1920, 1080), (3840, 2160)] { + for origin in [(0, 0), (-1_000_021, -2_000_003)] { + for kind in BoardGridKind::ALL { + for spacing in [8, 40] { + for count in [0, 20, 200] { + let grid = BoardGrid::new(kind, spacing); + let frame = workload(origin, size, count); + let request = CanvasLayerInputs { + width: size.0, + height: size.1, + origin: (f64::from(origin.0), f64::from(origin.1)), + background: Some(crate::draw::WHITE), + grid, + ..inputs() + }; + let snapshot = CanvasExportSnapshot { + viewport: CanvasExportViewport { + logical_width: size.0, + logical_height: size.1, + scale: 1, + origin_x: origin.0, + origin_y: origin.1, + }, + backdrop: CanvasExportBackdropSnapshot::board_paper( + crate::draw::WHITE, + grid, + ), + board: BoardExportSnapshot { + frame: frame.clone_without_history(), + }, + render_profile: None, + text_halo_enabled: true, + spotlight: Default::default(), + }; + let mut samples = [Vec::new(), Vec::new(), Vec::new()]; + let mut png_bytes = 0; + let measurer = crate::draw::TextMeasurer::default(); + for sample in 0..6 { + let mut layer = CanvasLayerCache::new(); + let mut caches = crate::draw::RenderCaches::default(); + let start = Instant::now(); + assert!(layer.ensure(&measurer, &mut caches, &frame.shapes, request)); + let cold = start.elapsed().as_micros(); + let target = cairo::ImageSurface::create( + cairo::Format::ARgb32, + size.0 as i32, + size.1 as i32, + ) + .unwrap(); + let ctx = cairo::Context::new(&target).unwrap(); + ctx.translate(-f64::from(origin.0), -f64::from(origin.1)); + let start = Instant::now(); + assert!(layer.ensure(&measurer, &mut caches, &frame.shapes, request)); + let geometry = FrameGeometry::new(size.0, size.1, 1); + let canvas_frame = CanvasFrame { + draw_committed: true, + render_transients: false, + transform_active: true, + origin: request.origin, + zoom_scale: None, + text_halo_enabled: request.text_halo_enabled, + layer_cache_eligible: true, + }; + let canvas = CanvasRenderCtx { + cairo: &ctx, + geometry: &geometry, + canvas: &canvas_frame, + damage_world: &[], + now: Instant::now(), + }; + let mut backdrop = + background::CanvasEraserContext::for_board(request.background); + let mut perf = PerfRenderBreakdown::default(); + render_committed_canvas_shapes( + &measurer, + &frame.shapes, + &layer, + &mut caches, + &canvas, + true, + &mut backdrop, + grid, + Some(&mut perf), + ) + .unwrap(); + assert!(perf.canvas_layer_cache_used); + assert!(backdrop.replay_context().pattern.is_none()); + let warm = start.elapsed().as_micros(); + let start = Instant::now(); + png_bytes = render_canvas_png(&snapshot).unwrap().bytes.len(); + let png = start.elapsed().as_micros(); + if sample > 0 { + for (values, value) in samples.iter_mut().zip([cold, warm, png]) { + values.push(value); + } + } + } + for values in &mut samples { + values.sort_unstable(); + } + // Simultaneous bake + blit target + PNG target, excluding bounded tile and codec scratch. + let bytes = (u64::from(size.0 + 512) * u64::from(size.1 + 512) + + 2 * u64::from(size.0) * u64::from(size.1)) + * 4; + println!( + "{kind:?},{spacing},{},{},{},{count},{},{},{},{},{},{},{png_bytes},{bytes}", + size.0, + size.1, + origin.0, + samples[0][2], + samples[0][4], + samples[1][2], + samples[1][4], + samples[2][2], + samples[2][4] + ); + } + } + } + } + } +} diff --git a/src/canvas_export/mod.rs b/src/canvas_export/mod.rs index bf8d27394..c83958426 100644 --- a/src/canvas_export/mod.rs +++ b/src/canvas_export/mod.rs @@ -1,4 +1,7 @@ #[cfg(test)] +#[path = "tests/board_grid.rs"] +mod board_grid_tests; +#[cfg(test)] #[path = "tests/cache_tests.rs"] mod cache_tests; mod page; diff --git a/src/canvas_export/page.rs b/src/canvas_export/page.rs index 025ed3c58..efed43d37 100644 --- a/src/canvas_export/page.rs +++ b/src/canvas_export/page.rs @@ -43,6 +43,10 @@ impl Default for SpotlightPassSnapshot { pub enum CanvasExportBackdropSnapshot { Transparent, Solid(Color), + BoardPaper { + color: Color, + grid: crate::domain::BoardGrid, + }, PersistedImage { data: Arc<[u8]>, width: i32, @@ -54,6 +58,13 @@ pub enum CanvasExportBackdropSnapshot { } impl CanvasExportBackdropSnapshot { + pub fn board_paper(color: Color, grid: crate::domain::BoardGrid) -> Self { + if grid.kind == crate::domain::BoardGridKind::None { + Self::Solid(color) + } else { + Self::BoardPaper { color, grid } + } + } /// Loupe availability for this backdrop, answered without decoding it. /// /// Mirrors what [`ExportBackdrop::new`] will produce for the same variant, @@ -62,7 +73,9 @@ impl CanvasExportBackdropSnapshot { pub(crate) fn magnifier_source(&self) -> SpotlightMagnifierSource { match self { Self::Transparent => SpotlightMagnifierSource::from_backdrop(None, false), - Self::Solid(_) => SpotlightMagnifierSource::from_backdrop(None, true), + Self::Solid(_) | Self::BoardPaper { .. } => { + SpotlightMagnifierSource::from_backdrop(None, true) + } Self::PersistedImage { .. } => SpotlightMagnifierSource::immutable_raster(), } } @@ -183,8 +196,10 @@ pub(crate) fn paint_pdf_page_background( width: f64, height: f64, ) { - let CanvasExportBackdropSnapshot::Solid(color) = page.backdrop else { - return; + let color = match page.backdrop { + CanvasExportBackdropSnapshot::Solid(color) + | CanvasExportBackdropSnapshot::BoardPaper { color, .. } => color, + _ => return, }; let _ = ctx.save(); ctx.set_source_rgba(color.r, color.g, color.b, color.a); @@ -229,7 +244,8 @@ impl ExportBackdrop { logical_image_origin_y: 0.0, _region_source: None, }), - CanvasExportBackdropSnapshot::Solid(color) => Ok(Self { + CanvasExportBackdropSnapshot::Solid(color) + | CanvasExportBackdropSnapshot::BoardPaper { color, .. } => Ok(Self { surface: None, pattern: None, bg_color: Some(*color), @@ -402,7 +418,23 @@ fn draw_canvas_page_contents( if paint_backdrop { backdrop.paint(ctx); } - let replay_ctx = backdrop.replay_context(); + let paper = match page.backdrop { + CanvasExportBackdropSnapshot::BoardPaper { color, grid } => Some( + crate::draw::BoardPaper::for_context(color, grid, ctx).map_err(|err| { + CaptureError::ImageError(format!("Failed to render board paper: {err}")) + })?, + ), + _ => None, + }; + if let Some(paper) = &paper { + paper.paint(ctx).map_err(|err| { + CaptureError::ImageError(format!("Failed to paint board paper: {err}")) + })?; + } + let mut replay_ctx = backdrop.replay_context(); + if let Some(paper) = &paper { + replay_ctx.pattern = Some(paper.pattern()); + } // What text should contrast with when the target cannot be read back. A PDF // page is a vector surface with no pixels to probe, so without this a board // exported to PDF would pick a different halo from the same board on screen. diff --git a/src/canvas_export/tests/board_grid.rs b/src/canvas_export/tests/board_grid.rs new file mode 100644 index 000000000..c4a3d0e81 --- /dev/null +++ b/src/canvas_export/tests/board_grid.rs @@ -0,0 +1,216 @@ +use super::*; +use crate::domain::{BoardGrid, BoardGridKind}; +use crate::draw::{EraserBrush, EraserKind, Frame, RED, Shape, WHITE}; + +fn page(kind: BoardGridKind) -> CanvasExportSnapshot { + CanvasExportSnapshot { + viewport: CanvasExportViewport { + logical_width: 160, + logical_height: 120, + scale: 1, + origin_x: -71, + origin_y: -53, + }, + backdrop: CanvasExportBackdropSnapshot::board_paper(WHITE, BoardGrid::new(kind, 20)), + board: BoardExportSnapshot { + frame: Frame::new(), + }, + render_profile: None, + text_halo_enabled: true, + spotlight: Default::default(), + } +} + +#[test] +fn board_grid_png_erasers_restore_pattern_and_snapshot_is_independent() { + for kind in BoardGridKind::ALL.into_iter().skip(1) { + let original = page(kind); + let baseline = render_canvas_png(&original).unwrap(); + for brush_kind in [EraserKind::Circle, EraserKind::Rect] { + let mut edited = original.clone(); + edited.board.frame.add_shape(Shape::Rect { + x: -40, + y: -20, + w: 20, + h: 20, + fill: true, + color: RED, + thick: 1.0, + }); + edited.board.frame.add_shape(Shape::EraserStroke { + points: vec![(-55, -10), (-5, -10)], + brush: EraserBrush { + kind: brush_kind, + size: 60.0, + }, + }); + let png = render_canvas_png(&edited).unwrap(); + let mut before = + cairo::ImageSurface::create_from_png(&mut std::io::Cursor::new(&baseline.bytes)) + .unwrap(); + let mut after = + cairo::ImageSurface::create_from_png(&mut std::io::Cursor::new(&png.bytes)) + .unwrap(); + let before = before.data().unwrap(); + let after = after.data().unwrap(); + for y in 33..53 { + for x in 31..51 { + let index = (y * 160 + x) * 4; + assert!( + before[index..index + 4] + .iter() + .zip(&after[index..index + 4]) + .all(|(a, b)| a.abs_diff(*b) <= 2) + ); + } + } + assert_eq!(render_canvas_png(&original).unwrap().bytes, baseline.bytes); + } + } +} + +#[test] +fn board_grid_pdf_stays_vector_without_erasers_and_leaves_margins_plain() { + use super::page::{ExportBackdrop, draw_canvas_page_region, paint_pdf_page_background}; + use crate::draw::{RenderCaches, RenderCtx, TextMeasurer}; + for kind in BoardGridKind::ALL.into_iter().skip(1) { + let snapshot = page(kind); + let page = CanvasPageExportSnapshot { + frame: Frame::new(), + backdrop: snapshot.backdrop, + viewport_width: 160, + viewport_height: 120, + origin_x: -71, + origin_y: -53, + text_halo_enabled: true, + spotlight: Default::default(), + }; + let source = CanvasExportRect::new(-71.0, -53.0, 160.0, 120.0).unwrap(); + let destination = CanvasExportRect::new(20.0, 20.0, 160.0, 120.0).unwrap(); + let mut surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 200, 160).unwrap(); + let ctx = cairo::Context::new(&surface).unwrap(); + paint_pdf_page_background(&ctx, &page, 200.0, 160.0); + let backdrop = ExportBackdrop::new(&page.backdrop).unwrap(); + draw_canvas_page_region( + &TextMeasurer::default(), + &mut RenderCtx::new(&ctx, &mut RenderCaches::default()), + &page, + &backdrop, + source, + destination, + false, + None, + ) + .unwrap(); + drop(ctx); + let pixels = surface.data().unwrap(); + for y in 0..160 { + for x in 0..200 { + if !(20..180).contains(&x) || !(20..140).contains(&y) { + assert_eq!(&pixels[(y * 200 + x) * 4..(y * 200 + x) * 4 + 4], &[255; 4]); + } + } + } + assert!(pixels.iter().any(|v| *v < 250)); + drop(pixels); + let document = BoardPdfExportSnapshot { + pages: vec![PdfPageExportSnapshot { + page, + layout: PdfPageLayout { + page_width: 200.0, + page_height: 160.0, + source_rect: source, + destination_rect: destination, + }, + metadata: PdfPageMetadata::new(0, 1, 0, 1, 0, 1, 0, 1, "Paper".into(), None), + }], + labels: Default::default(), + }; + let bytes = render_board_pdf(&document).unwrap(); + assert!( + !bytes + .windows(b"/Subtype /Image".len()) + .any(|v| v == b"/Subtype /Image"), + "{kind:?} paper should be a vector pattern" + ); + assert!(bytes.windows(b"/Pattern".len()).any(|v| v == b"/Pattern")); + check_pdf_pixels(&bytes, &surface); + let mut erased = document.clone(); + erased.pages[0].page.frame.add_shape(Shape::Rect { + x: -4, + y: -4, + w: 8, + h: 8, + fill: true, + color: RED, + thick: 1.0, + }); + erased.pages[0].page.frame.add_shape(Shape::EraserStroke { + points: vec![(-10, 0), (10, 0)], + brush: EraserBrush { + kind: EraserKind::Circle, + size: 32.0, + }, + }); + check_pdf_pixels(&render_board_pdf(&erased).unwrap(), &surface); + } +} + +fn check_pdf_pixels(pdf: &[u8], expected: &cairo::ImageSurface) { + use std::process::Command; + if Command::new("pdftoppm").arg("-v").output().is_err() { + return; + } + let folder = crate::test_temp::tempdir().unwrap(); + let path = folder.path().join("paper.pdf"); + let prefix = folder.path().join("paper"); + std::fs::write(&path, pdf).unwrap(); + let output = Command::new("pdftoppm") + .args(["-png", "-r", "72", "-singlefile"]) + .arg(&path) + .arg(&prefix) + .output() + .unwrap(); + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr) + ); + let mut image = cairo::ImageSurface::create_from_png( + &mut std::fs::File::open(prefix.with_extension("png")).unwrap(), + ) + .unwrap(); + assert_eq!( + (image.width(), image.height()), + (expected.width(), expected.height()) + ); + let actual = image.data().unwrap(); + // World origin is a vertex in every mode and maps to (91,73) here. + let darkest = (71..=75) + .flat_map(|y| (89..=93).map(move |x| (y * 200 + x) * 4)) + .map(|i| actual[i]) + .min() + .unwrap(); + assert!(darkest < 240, "PDF paper lost the origin vertex"); + expected + .with_data(|expected| { + let error: u64 = expected + .iter() + .zip(actual.iter()) + .map(|(a, b)| u64::from(a.abs_diff(*b))) + .sum(); + assert!( + error as f64 / (expected.len() as f64) < 4.0, + "PDF paper phase differs from raster: mean error {}", + error as f64 / expected.len() as f64 + ); + assert!( + actual + .as_chunks::<4>() + .0 + .iter() + .all(|pixel| pixel[3] == 255) + ); + }) + .unwrap(); +} diff --git a/src/config/mod.rs b/src/config/mod.rs index 5bdc89fb8..1e623f5cf 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -61,11 +61,11 @@ pub use migration::{MigrationChange, MigrationPreview}; #[allow(unused_imports)] pub use types::{ ARROW_ANGLE_MAX, ARROW_ANGLE_MIN, ARROW_LENGTH_MAX, ARROW_LENGTH_MIN, ArrowConfig, - BoardBackgroundConfig, BoardColorConfig, BoardConfig, BoardItemConfig, BoardsConfig, - CaptureConfig, ClickHighlightConfig, DEFAULT_OCR_LANGUAGES, DEFAULT_PEN_SMOOTHING, - DragButtonConfig, DrawingConfig, ExportConfig, HelpOverlayStyle, HistoryConfig, InputHudConfig, - InputHudMode, InputHudPosition, MouseDragToolsConfig, PDF_LABEL_APP_BOARD, - PDF_LABEL_APP_BOARDS, PDF_LABEL_BOARD_NAME, PDF_LABEL_DEFAULT_TEMPLATE, + BoardBackgroundConfig, BoardColorConfig, BoardConfig, BoardGridConfig, BoardGridKindConfig, + BoardItemConfig, BoardsConfig, CaptureConfig, ClickHighlightConfig, DEFAULT_OCR_LANGUAGES, + DEFAULT_PEN_SMOOTHING, DragButtonConfig, DrawingConfig, ExportConfig, HelpOverlayStyle, + HistoryConfig, InputHudConfig, InputHudMode, InputHudPosition, MouseDragToolsConfig, + PDF_LABEL_APP_BOARD, PDF_LABEL_APP_BOARDS, PDF_LABEL_BOARD_NAME, PDF_LABEL_DEFAULT_TEMPLATE, PDF_LABEL_DOCUMENT_PAGE, PDF_LABEL_DOCUMENT_PAGES, PDF_LABEL_EXPORT_BOARD, PDF_LABEL_EXPORT_BOARDS, PDF_LABEL_PAGE, PDF_LABEL_PAGE_NAME, PDF_LABEL_PAGES, PDF_LABEL_PLACEHOLDERS, PRESET_SLOTS_MAX, PRESET_SLOTS_MIN, PdfExportConfig, PdfFitMode, diff --git a/src/config/tests/board_grid.rs b/src/config/tests/board_grid.rs new file mode 100644 index 000000000..4eaaedf11 --- /dev/null +++ b/src/config/tests/board_grid.rs @@ -0,0 +1,72 @@ +use crate::config::{BoardGridConfig, BoardGridKindConfig, Config}; +use crate::domain::{BoardGrid, BoardGridKind}; + +#[test] +fn board_grid_config_defaults_and_modes_round_trip() { + assert_eq!( + toml::from_str::("").unwrap(), + BoardGridConfig::default() + ); + for (name, kind) in [ + ("none", BoardGridKind::None), + ("cartesian", BoardGridKind::Cartesian), + ("isometric", BoardGridKind::Isometric), + ("isometric-dots", BoardGridKind::IsometricDots), + ] { + let parsed: BoardGridConfig = + toml::from_str(&format!("kind = '{name}'\nspacing = 20")).unwrap(); + assert_eq!(BoardGrid::from(parsed), BoardGrid::new(kind, 20)); + let encoded = toml::to_string(&parsed).unwrap(); + assert_eq!(parsed, toml::from_str(&encoded).unwrap()); + } + for invalid in ["kind = 'diagonal'", "spacing = 2.5", "spacing = '40'"] { + assert!( + toml::from_str::(invalid).is_err(), + "{invalid}" + ); + } +} + +#[test] +fn board_grid_config_clamps_spacing_and_disables_transparent_patterns() { + let mut config: Config = toml::from_str("[[boards.items]]\nid = 'whiteboard'\nname = 'Paper'\nbackground = [1.0,1.0,1.0]\ngrid = { kind = 'cartesian', spacing = -20 }\n[[boards.items]]\nid = 'transparent'\nname = 'Overlay'\ngrid = {kind = 'isometric', spacing = 900}").unwrap(); + config.validate_and_clamp(); + let boards = config.boards.unwrap(); + assert_eq!(boards.items[0].grid.spacing, 8); + assert_eq!(boards.items[0].grid.kind, BoardGridKindConfig::Cartesian); + assert_eq!(boards.items[1].grid.spacing, 200); + assert_eq!(boards.items[1].grid.kind, BoardGridKindConfig::None); +} + +#[test] +fn board_grid_runtime_mapping_preserves_new_session_templates() { + let mut config = crate::config::BoardsConfig::default(); + config.items[1].grid = BoardGridConfig { + kind: BoardGridKindConfig::IsometricDots, + spacing: 20, + }; + let mut boards = crate::input::BoardManager::from_config(config); + assert_eq!( + boards.board_states()[1].spec.grid, + BoardGrid::new(BoardGridKind::IsometricDots, 20) + ); + assert!(boards.create_board()); + assert_eq!( + boards.active_board().spec.grid.kind, + BoardGridKind::IsometricDots + ); + assert_eq!(boards.to_config().items[1].grid.spacing, 20); +} + +#[test] +#[cfg(feature = "config-schema")] +fn board_grid_schema_exposes_supported_patterns_and_spacing_bounds() { + let schema = Config::json_schema(); + let grid = &schema["$defs"]["BoardGridConfig"]; + assert_eq!(grid["properties"]["spacing"]["minimum"], 8); + assert_eq!(grid["properties"]["spacing"]["maximum"], 200); + assert_eq!( + schema["$defs"]["BoardGridKindConfig"]["enum"], + serde_json::json!(["none", "cartesian", "isometric", "isometric-dots"]) + ); +} diff --git a/src/config/tests/document.rs b/src/config/tests/document.rs index 7a14f7555..5ae830d57 100644 --- a/src/config/tests/document.rs +++ b/src/config/tests/document.rs @@ -5,6 +5,41 @@ use std::sync::atomic::{AtomicU64, Ordering}; static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0); +#[test] +fn board_grid_guarded_save_preserves_inline_and_nested_unknown_settings() { + for grid in [ + "grid = { kind = 'cartesian', spacing = 20, future = 7 } # paper", + "[boards.items.grid] # paper\nkind = 'cartesian'\nspacing = 20\nfuture = 7", + ] { + let temp = TempConfig::new("board-grid"); + temp.write(&format!("# personal boards\n[[boards.items]]\nid = 'paper'\nname = 'Paper'\nbackground = [1.0,1.0,1.0]\n{grid}\n")); + let document = ConfigDocument::load_from_path(&temp.path).unwrap(); + let mut config = document.config().clone(); + let board = config + .boards + .as_mut() + .unwrap() + .items + .iter_mut() + .find(|b| b.id == "paper") + .unwrap(); + board.grid.kind = crate::config::BoardGridKindConfig::IsometricDots; + board.grid.spacing = 40; + document.save_with_backup(config).unwrap(); + let saved = fs::read_to_string(&temp.path).unwrap(); + assert!(saved.contains("# personal boards") && saved.contains("# paper")); + let value: toml::Value = toml::from_str(&saved).unwrap(); + let boards = value["boards"]["items"].as_array().unwrap(); + let paper = boards + .iter() + .find(|v| v["id"].as_str() == Some("paper")) + .unwrap(); + assert_eq!(paper["grid"]["future"].as_integer(), Some(7)); + assert_eq!(paper["grid"]["spacing"].as_integer(), Some(40)); + assert_eq!(paper["grid"]["kind"].as_str(), Some("isometric-dots")); + } +} + struct TempConfig { root: PathBuf, path: PathBuf, diff --git a/src/config/tests/mod.rs b/src/config/tests/mod.rs index c67986b85..1af6e12ef 100644 --- a/src/config/tests/mod.rs +++ b/src/config/tests/mod.rs @@ -1,3 +1,4 @@ +mod board_grid; mod document; mod file_io; mod immutability; diff --git a/src/config/tests/validate.rs b/src/config/tests/validate.rs index c874ca892..768c835c5 100644 --- a/src/config/tests/validate.rs +++ b/src/config/tests/validate.rs @@ -113,6 +113,7 @@ fn validate_boards_uses_boundary_id_normalization() { id: " Transparent ".to_string(), name: "Overlay".to_string(), background: BoardBackgroundConfig::Transparent("transparent".to_string()), + grid: Default::default(), default_pen_color: None, auto_adjust_pen: false, persist: true, @@ -124,6 +125,7 @@ fn validate_boards_uses_boundary_id_normalization() { background: BoardBackgroundConfig::Color(BoardColorConfig::Rgb([ 1.2, 0.5, -0.1, ])), + grid: Default::default(), default_pen_color: Some(BoardColorConfig::Rgb([0.2, 1.4, 0.6])), auto_adjust_pen: true, persist: true, @@ -135,6 +137,7 @@ fn validate_boards_uses_boundary_id_normalization() { background: BoardBackgroundConfig::Color(BoardColorConfig::Rgb([ 0.2, 0.3, 0.4, ])), + grid: Default::default(), default_pen_color: None, auto_adjust_pen: true, persist: true, @@ -146,6 +149,7 @@ fn validate_boards_uses_boundary_id_normalization() { background: BoardBackgroundConfig::Color(BoardColorConfig::Rgb([ 0.2, 0.3, 0.4, ])), + grid: Default::default(), default_pen_color: None, auto_adjust_pen: true, persist: true, diff --git a/src/config/types/board_grid.rs b/src/config/types/board_grid.rs new file mode 100644 index 000000000..e9d11fad7 --- /dev/null +++ b/src/config/types/board_grid.rs @@ -0,0 +1,71 @@ +use serde::{Deserialize, Serialize}; + +use crate::domain::{BOARD_GRID_DEFAULT_SPACING, BoardGrid, BoardGridKind}; + +#[cfg_attr(feature = "config-schema", derive(schemars::JsonSchema))] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum BoardGridKindConfig { + #[default] + None, + Cartesian, + Isometric, + IsometricDots, +} + +impl From for BoardGridKind { + fn from(value: BoardGridKindConfig) -> Self { + match value { + BoardGridKindConfig::None => Self::None, + BoardGridKindConfig::Cartesian => Self::Cartesian, + BoardGridKindConfig::Isometric => Self::Isometric, + BoardGridKindConfig::IsometricDots => Self::IsometricDots, + } + } +} + +impl From for BoardGridKindConfig { + fn from(value: BoardGridKind) -> Self { + match value { + BoardGridKind::None => Self::None, + BoardGridKind::Cartesian => Self::Cartesian, + BoardGridKind::Isometric => Self::Isometric, + BoardGridKind::IsometricDots => Self::IsometricDots, + } + } +} + +/// Paper decoration for a solid board. Config validation warns before clamping. +#[cfg_attr(feature = "config-schema", derive(schemars::JsonSchema))] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default)] +pub struct BoardGridConfig { + pub kind: BoardGridKindConfig, + /// Square/triangle side in logical board pixels, from 8 through 200. + #[cfg_attr(feature = "config-schema", schemars(range(min = 8, max = 200)))] + pub spacing: i64, +} + +impl Default for BoardGridConfig { + fn default() -> Self { + Self { + kind: BoardGridKindConfig::None, + spacing: i64::from(BOARD_GRID_DEFAULT_SPACING), + } + } +} + +impl From for BoardGrid { + fn from(value: BoardGridConfig) -> Self { + Self::new(value.kind.into(), value.spacing) + } +} + +impl From for BoardGridConfig { + fn from(value: BoardGrid) -> Self { + Self { + kind: value.kind.into(), + spacing: i64::from(value.spacing()), + } + } +} diff --git a/src/config/types/boards.rs b/src/config/types/boards.rs index 0d6c284fa..e251662b5 100644 --- a/src/config/types/boards.rs +++ b/src/config/types/boards.rs @@ -66,6 +66,7 @@ impl BoardsConfig { id: "transparent".to_string(), name: "Overlay".to_string(), background: BoardBackgroundConfig::Transparent("transparent".to_string()), + grid: Default::default(), default_pen_color: None, auto_adjust_pen: false, persist: true, @@ -83,6 +84,7 @@ impl BoardsConfig { background: BoardBackgroundConfig::Color(BoardColorConfig::Rgb( legacy.whiteboard_color, )), + grid: Default::default(), default_pen_color: Some(BoardColorConfig::Rgb(legacy.whiteboard_pen_color)), auto_adjust_pen: legacy.auto_adjust_pen, persist: true, @@ -94,6 +96,7 @@ impl BoardsConfig { background: BoardBackgroundConfig::Color(BoardColorConfig::Rgb( legacy.blackboard_color, )), + grid: Default::default(), default_pen_color: Some(BoardColorConfig::Rgb(legacy.blackboard_pen_color)), auto_adjust_pen: legacy.auto_adjust_pen, persist: true, @@ -128,6 +131,10 @@ pub struct BoardItemConfig { #[serde(default = "default_board_background")] pub background: BoardBackgroundConfig, + /// Non-selectable paper decoration on a solid background. + #[serde(default)] + pub grid: super::BoardGridConfig, + /// Default pen color when auto-adjust is enabled. #[serde(default)] pub default_pen_color: Option, @@ -229,6 +236,7 @@ fn default_board_items() -> Vec { id: "whiteboard".to_string(), name: "Whiteboard".to_string(), background: BoardBackgroundConfig::Color(BoardColorConfig::Rgb([0.992, 0.992, 0.992])), + grid: Default::default(), default_pen_color: Some(BoardColorConfig::Rgb([ PALETTE_BLACK.r, PALETTE_BLACK.g, @@ -242,6 +250,7 @@ fn default_board_items() -> Vec { id: "blackboard".to_string(), name: "Blackboard".to_string(), background: BoardBackgroundConfig::Color(BoardColorConfig::Rgb([0.067, 0.067, 0.067])), + grid: Default::default(), default_pen_color: Some(BoardColorConfig::Rgb([1.0, 1.0, 1.0])), auto_adjust_pen: true, persist: true, @@ -251,6 +260,7 @@ fn default_board_items() -> Vec { id: "blueprint".to_string(), name: "Blueprint".to_string(), background: BoardBackgroundConfig::Color(BoardColorConfig::Rgb([0.063, 0.125, 0.251])), + grid: Default::default(), default_pen_color: Some(BoardColorConfig::Rgb([0.902, 0.945, 1.0])), auto_adjust_pen: true, persist: true, @@ -260,6 +270,7 @@ fn default_board_items() -> Vec { id: "corkboard".to_string(), name: "Corkboard".to_string(), background: BoardBackgroundConfig::Color(BoardColorConfig::Rgb([0.420, 0.294, 0.165])), + grid: Default::default(), default_pen_color: Some(BoardColorConfig::Rgb([0.969, 0.890, 0.784])), auto_adjust_pen: true, persist: true, diff --git a/src/config/types/mod.rs b/src/config/types/mod.rs index 2a1cbe474..a3c3406d5 100644 --- a/src/config/types/mod.rs +++ b/src/config/types/mod.rs @@ -2,6 +2,7 @@ mod arrow; mod board; +mod board_grid; mod boards; mod capture; mod click_highlight; @@ -29,6 +30,7 @@ pub use arrow::{ ARROW_ANGLE_MAX, ARROW_ANGLE_MIN, ARROW_LENGTH_MAX, ARROW_LENGTH_MIN, ArrowConfig, }; pub use board::BoardConfig; +pub use board_grid::{BoardGridConfig, BoardGridKindConfig}; pub use boards::{BoardBackgroundConfig, BoardColorConfig, BoardItemConfig, BoardsConfig}; pub use capture::{ CaptureConfig, DEFAULT_OCR_LANGUAGES, RegionCaptureConfig, validate_capture_format, diff --git a/src/config/validate/boards.rs b/src/config/validate/boards.rs index 336050bee..c92dbdf5c 100644 --- a/src/config/validate/boards.rs +++ b/src/config/validate/boards.rs @@ -44,6 +44,7 @@ impl Config { } normalize_background(&mut item.background, &item.id); + normalize_grid(item); if let Some(color) = item.default_pen_color.as_mut() { clamp_color( color, @@ -84,6 +85,23 @@ impl Config { } } +fn normalize_grid(item: &mut crate::config::BoardItemConfig) { + let mut grid = crate::domain::BoardGrid::from(item.grid); + if i64::from(grid.spacing()) != item.grid.spacing { + warn!( + "Board '{}' grid spacing {} is outside 8–200; using {}", + item.id, + item.grid.spacing, + grid.spacing() + ); + } + if item.background.is_transparent() && grid.kind != crate::domain::BoardGridKind::None { + warn!("Board '{}' is transparent; disabling its grid", item.id); + grid = grid.disabled(); + } + item.grid = grid.into(); +} + fn ensure_transparent_board_in_range(boards: &mut BoardsConfig) { let transparent_in_range = boards .items diff --git a/src/domain/board.rs b/src/domain/board.rs index 857f83ff1..111609fb5 100644 --- a/src/domain/board.rs +++ b/src/domain/board.rs @@ -1,10 +1,10 @@ -use super::Color; +use super::{BoardGrid, Color}; pub const BOARD_ID_TRANSPARENT: &str = "transparent"; pub const BOARD_ID_WHITEBOARD: &str = "whiteboard"; pub const BOARD_ID_BLACKBOARD: &str = "blackboard"; -#[derive(Debug, Clone)] +#[derive(Debug, Clone, PartialEq)] pub enum BoardBackground { Transparent, Solid(Color), @@ -21,6 +21,7 @@ pub struct BoardSpec { pub id: String, pub name: String, pub background: BoardBackground, + pub grid: BoardGrid, pub default_pen_color: Option, pub auto_adjust_pen: bool, pub persist: bool, diff --git a/src/domain/board_grid.rs b/src/domain/board_grid.rs new file mode 100644 index 000000000..355b19a3c --- /dev/null +++ b/src/domain/board_grid.rs @@ -0,0 +1,72 @@ +//! Board-paper geometry, independent of rendering and serialization. + +pub const BOARD_GRID_MIN_SPACING: u16 = 8; +pub const BOARD_GRID_MAX_SPACING: u16 = 200; +pub const BOARD_GRID_DEFAULT_SPACING: u16 = 40; + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)] +pub enum BoardGridKind { + #[default] + None, + Cartesian, + Isometric, + IsometricDots, +} + +impl BoardGridKind { + pub const ALL: [Self; 4] = [ + Self::None, + Self::Cartesian, + Self::Isometric, + Self::IsometricDots, + ]; + + pub fn label(self) -> &'static str { + match self { + Self::None => "None", + Self::Cartesian => "Cartesian", + Self::Isometric => "Isometric lines", + Self::IsometricDots => "Isometric dots", + } + } +} + +/// A normalized grid. Spacing is a square/triangle side in logical board pixels. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct BoardGrid { + pub kind: BoardGridKind, + spacing: u16, +} + +impl Default for BoardGrid { + fn default() -> Self { + Self { + kind: BoardGridKind::None, + spacing: BOARD_GRID_DEFAULT_SPACING, + } + } +} + +impl BoardGrid { + pub fn new(kind: BoardGridKind, spacing: i64) -> Self { + Self { + kind, + spacing: spacing.clamp( + i64::from(BOARD_GRID_MIN_SPACING), + i64::from(BOARD_GRID_MAX_SPACING), + ) as u16, + } + } + + pub fn spacing(self) -> u16 { + self.spacing + } + + /// Disable decoration without discarding its spacing. + pub fn disabled(self) -> Self { + Self { + kind: BoardGridKind::None, + ..self + } + } +} diff --git a/src/domain/mod.rs b/src/domain/mod.rs index d7cae2860..ec1f07da8 100644 --- a/src/domain/mod.rs +++ b/src/domain/mod.rs @@ -6,6 +6,7 @@ mod action; mod board; +mod board_grid; mod board_validation; pub mod color; mod drawing; @@ -16,6 +17,10 @@ pub use action::Action; pub use board::{ BOARD_ID_BLACKBOARD, BOARD_ID_TRANSPARENT, BOARD_ID_WHITEBOARD, BoardBackground, BoardSpec, }; +pub use board_grid::{ + BOARD_GRID_DEFAULT_SPACING, BOARD_GRID_MAX_SPACING, BOARD_GRID_MIN_SPACING, BoardGrid, + BoardGridKind, +}; pub use board_validation::{ BoardIdChangeSet, BoundaryBoardId, BoundaryBoardIdSet, clamp_board_rgb, }; diff --git a/src/domain/tests.rs b/src/domain/tests.rs index 989789b28..6fb0340eb 100644 --- a/src/domain/tests.rs +++ b/src/domain/tests.rs @@ -365,6 +365,7 @@ fn established_public_paths_reexport_domain_types() { id: "board".to_string(), name: "Board".to_string(), background: BoardBackground::Transparent, + grid: Default::default(), default_pen_color: None, auto_adjust_pen: false, persist: true, @@ -396,7 +397,7 @@ fn production_domain_sources_have_no_upward_crate_dependencies() { } assert_eq!( - checked, 8, + checked, 9, "architecture test must cover every domain source" ); } diff --git a/src/draw/mod.rs b/src/draw/mod.rs index b7961730b..85a6fd864 100644 --- a/src/draw/mod.rs +++ b/src/draw/mod.rs @@ -35,16 +35,16 @@ pub(crate) use render::render_sticky_note_preview_with_measurer; pub(crate) use render::with_saved_state; #[allow(unused_imports)] pub use render::{ - BlurRectParams, EraserReplayContext, IMMUTABLE_RASTER_SOURCE_TOKEN, RenderCaches, RenderCtx, - SpotlightMagnifierMetrics, SpotlightMagnifierOutcome, SpotlightMagnifierScratch, + BlurRectParams, BoardPaper, EraserReplayContext, IMMUTABLE_RASTER_SOURCE_TOKEN, RenderCaches, + RenderCtx, SpotlightMagnifierMetrics, SpotlightMagnifierOutcome, SpotlightMagnifierScratch, SpotlightMagnifierSource, SpotlightPass, SpotlightRegion, SpotlightSnapshotStrategy, - caret_line_width, caret_outline_width, painted_background_luminance, perceived_luminance, - render_blur_rect, render_board_background, render_click_highlight, render_freehand_borrowed, - render_marker_stroke_borrowed, render_selection_halo, render_selection_halo_with_measurer, - render_selection_handles, render_shape, render_shape_over, render_shape_over_with_halo, - render_shape_with_halo, render_spotlight_magnification_pass, render_spotlight_pass, - render_sticky_note, render_sticky_note_with_measurer, render_text, render_text_over_with_halo, - render_text_over_with_halo_with_measurer, render_text_with_halo, + board_paper_device_scale, caret_line_width, caret_outline_width, painted_background_luminance, + perceived_luminance, render_blur_rect, render_board_background, render_click_highlight, + render_freehand_borrowed, render_marker_stroke_borrowed, render_selection_halo, + render_selection_halo_with_measurer, render_selection_handles, render_shape, render_shape_over, + render_shape_over_with_halo, render_shape_with_halo, render_spotlight_magnification_pass, + render_spotlight_pass, render_sticky_note, render_sticky_note_with_measurer, render_text, + render_text_over_with_halo, render_text_over_with_halo_with_measurer, render_text_with_halo, render_text_with_halo_with_measurer, render_text_with_measurer, selection_handle_rects, spotlight_regions_for_frame, sticky_note_foreground, text_outline_color, }; diff --git a/src/draw/render/board_grid.rs b/src/draw/render/board_grid.rs new file mode 100644 index 000000000..6a19f9635 --- /dev/null +++ b/src/draw/render/board_grid.rs @@ -0,0 +1,211 @@ +//! Procedural board paper shared by canvas paint, eraser replay, and exports. + +use cairo::{Context, RecordingSurface, Rectangle, SurfacePattern}; + +use crate::domain::{BoardGrid, BoardGridKind, Color}; + +use super::primitives::with_saved_state; + +/// A compact, world-anchored tile. Screen targets reuse a small raster source +/// during erasing; vector targets retain the recording. Neither resource +/// escapes into board state or persisted snapshots. +pub struct BoardPaper { + pattern: SurfacePattern, +} + +impl BoardPaper { + pub fn for_context( + color: Color, + grid: BoardGrid, + target: &Context, + ) -> Result { + let device_scale = board_paper_device_scale(target); + let origin = target.device_to_user(0.0, 0.0)?; + if !device_scale.is_finite() || device_scale <= 0.0 { + return Err(cairo::Error::InvalidMatrix); + } + let (width, height) = tile_size(grid); + let bounds = Rectangle::new(0.0, 0.0, width, height); + let raster = target.target().type_() == cairo::SurfaceType::Image; + // Cap a procedural tile at 4x density (under 4.5 MiB at max spacing). + // This also bounds allocations under unusually large zoom transforms. + let (source_width, source_height) = if raster { + ( + (width * device_scale.min(4.0)).ceil().max(1.0), + (height * device_scale.min(4.0)).ceil().max(1.0), + ) + } else { + (1.0, 1.0) + }; + let surface: cairo::Surface = if raster { + cairo::ImageSurface::create( + cairo::Format::ARgb32, + source_width as i32, + source_height as i32, + )? + .as_ref() + .clone() + } else { + // Fractional recording extents are rounded by Cairo when repeating + // and can leave translucent seams. Record in an integer unit cell. + RecordingSurface::create( + cairo::Content::ColorAlpha, + Some(Rectangle::new(0.0, 0.0, 1.0, 1.0)), + )? + .as_ref() + .clone() + }; + let ctx = Context::new(&surface)?; + ctx.scale(source_width / width, source_height / height); + paint_geometry(&ctx, color, grid, device_scale, bounds)?; + let pattern = SurfacePattern::create(&surface); + pattern.set_extend(cairo::Extend::Repeat); + let mut matrix = cairo::Matrix::identity(); + matrix.scale(source_width / width, source_height / height); + // Cairo recording replay has a finite coordinate range. Whole-cell + // translation preserves world phase while keeping its source near zero. + matrix.set_x0(-(origin.0 / width).floor() * source_width); + matrix.set_y0(-(origin.1 / height).floor() * source_height); + pattern.set_matrix(matrix); + Ok(Self { pattern }) + } + + /// The context must already map board-world coordinates to the target. + /// Paint does not touch its current path or leak source/operator state. + pub fn paint(&self, ctx: &Context) -> Result<(), cairo::Error> { + with_saved_state(ctx, || { + ctx.set_operator(cairo::Operator::Over); + ctx.set_source(&self.pattern)?; + ctx.paint() + }) + } + + pub fn pattern(&self) -> &cairo::Pattern { + self.pattern.as_ref() + } +} + +/// Smallest target-axis scale, including Cairo surface device scaling. +pub fn board_paper_device_scale(ctx: &Context) -> f64 { + let matrix = ctx.matrix(); + let (dx, dy) = ctx.target().device_scale(); + (matrix.xx() * dx) + .hypot(matrix.yx() * dy) + .min((matrix.xy() * dx).hypot(matrix.yy() * dy)) +} + +fn tile_size(grid: BoardGrid) -> (f64, f64) { + let s = f64::from(grid.spacing()); + match grid.kind { + BoardGridKind::None => (1.0, 1.0), + BoardGridKind::Cartesian => (s, s), + BoardGridKind::Isometric | BoardGridKind::IsometricDots => (3.0_f64.sqrt() * s, s), + } +} + +/// Also used by the full-viewport recording performance comparison. Work is +/// bounded by the requested region, never by distance from the world origin. +fn paint_geometry( + ctx: &Context, + color: Color, + grid: BoardGrid, + device_scale: f64, + bounds: Rectangle, +) -> Result<(), cairo::Error> { + let path = ctx.copy_path()?; + let result = with_saved_state(ctx, || { + ctx.new_path(); + ctx.rectangle(bounds.x(), bounds.y(), bounds.width(), bounds.height()); + ctx.clip(); + ctx.set_source_rgba(color.r, color.g, color.b, color.a); + ctx.paint()?; + if grid.kind == BoardGridKind::None { + return Ok(()); + } + let s = f64::from(grid.spacing()); + let fade = (s * device_scale / 3.0).clamp(0.0, 1.0); + let ink = if super::perceived_luminance(color.r, color.g, color.b) > 0.5 { + 0.0 + } else { + 1.0 + }; + let alpha = if grid.kind == BoardGridKind::IsometricDots { + 0.28 + } else { + 0.18 + }; + ctx.set_source_rgba(ink, ink, ink, alpha * fade); + ctx.set_line_width(1.0); + ctx.set_line_cap(cairo::LineCap::Butt); + ctx.set_line_join(cairo::LineJoin::Miter); + ctx.set_dash(&[], 0.0); + match grid.kind { + BoardGridKind::None => {} + BoardGridKind::Cartesian => cartesian_path(ctx, s, bounds), + BoardGridKind::Isometric => isometric_path(ctx, s, bounds), + BoardGridKind::IsometricDots => isometric_dots(ctx, s, bounds), + } + if grid.kind == BoardGridKind::IsometricDots { + ctx.fill() + } else { + ctx.stroke() + } + }); + ctx.new_path(); + ctx.append_path(&path); + result +} + +fn indices(min: f64, max: f64, spacing: f64) -> std::ops::RangeInclusive { + (min / spacing).floor() as i64..=(max / spacing).ceil() as i64 +} + +fn cartesian_path(ctx: &Context, s: f64, b: Rectangle) { + for n in indices(b.x() - 1.0, b.x() + b.width() + 1.0, s) { + let x = n as f64 * s; + ctx.move_to(x, b.y() - 1.0); + ctx.line_to(x, b.y() + b.height() + 1.0); + } + for n in indices(b.y() - 1.0, b.y() + b.height() + 1.0, s) { + let y = n as f64 * s; + ctx.move_to(b.x() - 1.0, y); + ctx.line_to(b.x() + b.width() + 1.0, y); + } +} + +fn isometric_path(ctx: &Context, s: f64, b: Rectangle) { + let column = 3.0_f64.sqrt() * s / 2.0; + for n in indices(b.x() - 1.0, b.x() + b.width() + 1.0, column) { + let x = n as f64 * column; + ctx.move_to(x, b.y() - 1.0); + ctx.line_to(x, b.y() + b.height() + 1.0); + } + let left = b.x() - 2.0; + let right = b.x() + b.width() + 2.0; + for slope in [-1.0 / 3.0_f64.sqrt(), 1.0 / 3.0_f64.sqrt()] { + let min = b.y() - (slope * left).max(slope * right) - 2.0; + let max = b.y() + b.height() - (slope * left).min(slope * right) + 2.0; + for n in indices(min, max, s) { + let intercept = n as f64 * s; + ctx.move_to(left, slope * left + intercept); + ctx.line_to(right, slope * right + intercept); + } + } +} + +fn isometric_dots(ctx: &Context, s: f64, b: Rectangle) { + let column = 3.0_f64.sqrt() * s / 2.0; + for i in indices(b.x() - 1.25, b.x() + b.width() + 1.25, column) { + let x = i as f64 * column; + let shift = i.rem_euclid(2) as f64 * s / 2.0; + for j in indices(b.y() - shift - 1.25, b.y() + b.height() - shift + 1.25, s) { + ctx.new_sub_path(); + ctx.arc(x, j as f64 * s + shift, 1.25, 0.0, std::f64::consts::TAU); + } + } +} + +#[cfg(test)] +mod performance; +#[cfg(test)] +mod tests; diff --git a/src/draw/render/board_grid/performance.rs b/src/draw/render/board_grid/performance.rs new file mode 100644 index 000000000..0424dd44e --- /dev/null +++ b/src/draw/render/board_grid/performance.rs @@ -0,0 +1,152 @@ +//! Opt-in source comparison; no compositor, font stack, or desktop windows. +use super::*; +use crate::draw::{EraserBrush, EraserKind, EraserReplayContext}; +use std::time::Instant; + +#[derive(Debug, Clone, Copy)] +enum Source { + Solid, + Viewport, + Tile, +} + +fn render_case( + source: Source, + kind: BoardGridKind, + spacing: i64, + size: (i32, i32), + erasers: usize, + pdf: bool, +) -> (u128, usize) { + let start = Instant::now(); + let target: cairo::Surface = if pdf { + cairo::PdfSurface::for_stream(size.0 as f64, size.1 as f64, Vec::::new()) + .unwrap() + .as_ref() + .clone() + } else { + cairo::ImageSurface::create(cairo::Format::ARgb32, size.0, size.1) + .unwrap() + .as_ref() + .clone() + }; + let ctx = Context::new(&target).unwrap(); + ctx.translate(1_000_021.0, 2_000_003.0); + let grid = BoardGrid::new(kind, spacing); + let pattern = match source { + Source::Solid => None, + Source::Tile => Some( + BoardPaper::for_context(crate::draw::WHITE, grid, &ctx) + .unwrap() + .pattern, + ), + Source::Viewport => { + let bounds = Rectangle::new(-1_000_021.0, -2_000_003.0, size.0 as f64, size.1 as f64); + let record = + RecordingSurface::create(cairo::Content::ColorAlpha, Some(bounds)).unwrap(); + paint_geometry( + &Context::new(&record).unwrap(), + crate::draw::WHITE, + grid, + 1.0, + bounds, + ) + .unwrap(); + Some(SurfacePattern::create(&record)) + } + }; + if let Some(p) = pattern.as_ref() { + ctx.set_source(p).unwrap(); + } else { + ctx.set_source_rgb(1.0, 1.0, 1.0); + } + ctx.paint().unwrap(); + // Stable annotation workload, including content under eraser strokes. + ctx.set_source_rgb(0.2, 0.3, 0.7); + ctx.set_line_width(3.0); + for n in 0..80 { + let y = -2_000_003.0 + f64::from(n * 19); + ctx.move_to(-1_000_021.0, y); + ctx.line_to(-1_000_021.0 + f64::from(size.0), y + 60.0); + } + ctx.stroke().unwrap(); + let replay = EraserReplayContext { + pattern: pattern.as_ref().map(|p| p.as_ref()), + surface: None, + backdrop_cache_key: None, + bg_color: Some(crate::draw::WHITE), + logical_to_image_scale_x: 1.0, + logical_to_image_scale_y: 1.0, + logical_image_origin_x: 0.0, + logical_image_origin_y: 0.0, + }; + // The direct raster path also replays one provisional eraser. Exports do not. + for n in 0..erasers + usize::from(!pdf) { + let x = -1_000_021 + (n as i32 * 73 % size.0); + let y = -2_000_003 + (n as i32 * 47 % size.1); + let brush = EraserBrush { + kind: if n % 2 == 0 { + EraserKind::Circle + } else { + EraserKind::Rect + }, + size: 32.0, + }; + super::super::strokes::render_eraser_stroke( + &ctx, + &[(x, y), (x + 100, y + 24), (x + 180, y + 10)], + &brush, + &replay, + ); + } + drop(ctx); + let bytes = if pdf { + target + .finish_output_stream() + .unwrap() + .downcast::>() + .unwrap() + .len() + } else { + target.flush(); + size.0 as usize * size.1 as usize * 4 + }; + (start.elapsed().as_micros(), bytes) +} + +#[test] +#[ignore = "opt-in board-paper CPU/PDF comparison; run with --nocapture --test-threads=1"] +fn board_grid_backdrop_performance() { + let sources: &[Source] = if std::env::var_os("WAYSCRIBER_GRID_COMPARE_VIEWPORT").is_some() { + &[Source::Solid, Source::Viewport, Source::Tile] + } else { + &[Source::Solid, Source::Tile] + }; + println!("source,kind,spacing,width,height,erasers,pdf,median_us,p95_us,target_or_pdf_bytes"); + for size in [(1920, 1080), (3840, 2160)] { + for kind in BoardGridKind::ALL.into_iter().skip(1) { + for spacing in [8, 40] { + for erasers in [0, 20, 200] { + for pdf in [false, true] { + for &source in sources { + render_case(source, kind, spacing, size, erasers, pdf); + let mut samples = Vec::new(); + let mut bytes = 0; + for _ in 0..5 { + let (time, n) = + render_case(source, kind, spacing, size, erasers, pdf); + samples.push(time); + bytes = n; + } + samples.sort_unstable(); + println!( + "{source:?},{kind:?},{spacing},{},{},{erasers},{pdf},{},{},{bytes}", + size.0, size.1, samples[2], samples[4] + ); + } + } + } + } + } + } +} diff --git a/src/draw/render/board_grid/tests.rs b/src/draw/render/board_grid/tests.rs new file mode 100644 index 000000000..0830e6169 --- /dev/null +++ b/src/draw/render/board_grid/tests.rs @@ -0,0 +1,182 @@ +use super::*; +use crate::draw::{EraserBrush, EraserKind, EraserReplayContext}; + +fn pixels(kind: BoardGridKind, scale: f64, origin: (f64, f64), tiled: bool) -> Vec { + let mut surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 180, 140).unwrap(); + let ctx = Context::new(&surface).unwrap(); + ctx.scale(scale, scale); + ctx.translate(-origin.0, -origin.1); + let grid = BoardGrid::new(kind, 20); + let color = Color::new(1.0, 1.0, 1.0, 1.0); + if tiled { + BoardPaper::for_context(color, grid, &ctx) + .unwrap() + .paint(&ctx) + .unwrap(); + } else { + paint_geometry( + &ctx, + color, + grid, + scale, + Rectangle::new(origin.0, origin.1, 180.0 / scale, 140.0 / scale), + ) + .unwrap(); + } + drop(ctx); + surface.data().unwrap().to_vec() +} + +#[test] +fn board_grid_tiles_match_world_geometry_at_negative_origins_and_scales() { + for kind in BoardGridKind::ALL { + for scale in [1.0, 1.25, 2.0] { + for origin in [(0.0, 0.0), (-71.0, -53.0), (-1_000_021.0, -2_000_003.0)] { + let a = pixels(kind, scale, origin, true); + let b = pixels(kind, scale, origin, false); + let error: u64 = a + .iter() + .zip(&b) + .map(|(x, y)| u64::from(x.abs_diff(*y))) + .sum(); + // Subpixel edges may rasterize differently in repeated recordings. + assert!( + error as f64 / (a.len() as f64) < 3.0, + "{kind:?} {scale} {origin:?}: mean channel error {} first tile {:?}, direct {:?}", + error as f64 / a.len() as f64, + &a[..4], + &b[..4] + ); + } + } + } +} + +#[test] +fn board_grid_paint_preserves_incoming_path_and_cairo_state() { + let surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 80, 80).unwrap(); + let ctx = Context::new(&surface).unwrap(); + ctx.move_to(3.0, 7.0); + ctx.line_to(55.0, 66.0); + let before = format!("{:?}", ctx.copy_path().unwrap().iter().collect::>()); + ctx.set_line_width(9.0); + let matrix = ctx.matrix(); + let grid = BoardGrid::new(BoardGridKind::Isometric, 40); + paint_geometry( + &ctx, + crate::draw::WHITE, + grid, + 1.0, + Rectangle::new(0.0, 0.0, 80.0, 80.0), + ) + .unwrap(); + BoardPaper::for_context(crate::draw::WHITE, grid, &ctx) + .unwrap() + .paint(&ctx) + .unwrap(); + assert_eq!( + before, + format!("{:?}", ctx.copy_path().unwrap().iter().collect::>()) + ); + assert_eq!(ctx.line_width(), 9.0); + assert_eq!(ctx.matrix(), matrix); +} + +#[test] +fn board_grid_eraser_replays_paper_instead_of_removing_lines_or_dots() { + for kind in BoardGridKind::ALL.into_iter().skip(1) { + for brush_kind in [EraserKind::Circle, EraserKind::Rect] { + let mut surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 100, 100).unwrap(); + let ctx = Context::new(&surface).unwrap(); + ctx.translate(9.0, 7.0); + let paper = BoardPaper::for_context(crate::draw::WHITE, BoardGrid::new(kind, 20), &ctx) + .unwrap(); + paper.paint(&ctx).unwrap(); + let original = { + let mut copy = + cairo::ImageSurface::create(cairo::Format::ARgb32, 100, 100).unwrap(); + let c = Context::new(©).unwrap(); + c.set_source_surface(&surface, 0.0, 0.0).unwrap(); + c.paint().unwrap(); + drop(c); + copy.data().unwrap().to_vec() + }; + ctx.set_source_rgb(1.0, 0.0, 0.0); + ctx.rectangle(20.0, 20.0, 20.0, 20.0); + ctx.fill().unwrap(); + let replay = EraserReplayContext { + pattern: Some(paper.pattern()), + surface: None, + backdrop_cache_key: None, + bg_color: Some(crate::draw::WHITE), + logical_to_image_scale_x: 1.0, + logical_to_image_scale_y: 1.0, + logical_image_origin_x: 0.0, + logical_image_origin_y: 0.0, + }; + super::super::strokes::render_eraser_stroke( + &ctx, + &[(10, 30), (50, 30)], + &EraserBrush { + kind: brush_kind, + size: 48.0, + }, + &replay, + ); + drop(ctx); + let stride = surface.stride() as usize; + let data = surface.data().unwrap(); + for y in 27..46 { + for x in 29..48 { + let i = y * stride + x * 4; + assert!( + data[i..i + 4] + .iter() + .zip(&original[i..i + 4]) + .all(|(a, b)| a.abs_diff(*b) <= 2), + "{kind:?} {brush_kind:?} at {x},{y}" + ); + } + } + } + } +} + +#[test] +fn board_grid_spacing_and_isometric_basis_are_stable() { + let g = BoardGrid::new(BoardGridKind::Isometric, i64::MIN); + assert_eq!(g.spacing(), 8); + assert_eq!(BoardGrid::new(g.kind, i64::MAX).spacing(), 200); + assert_eq!(g.disabled().spacing(), 8); + let (width, height) = tile_size(g); + assert!((width.hypot(height) / 2.0 - f64::from(g.spacing())).abs() < 1e-10); + assert_eq!(indices(-41.0, -1.0, 20.0), -3..=0); +} + +#[test] +fn board_grid_tiles_stay_opaque_and_show_expected_vertices() { + for kind in BoardGridKind::ALL.into_iter().skip(1) { + for scale in [1.0, 1.25, 2.0] { + let data = pixels(kind, scale, (0.0, 0.0), true); + assert!( + data.as_chunks::<4>().0.iter().all(|pixel| pixel[3] == 255), + "{kind:?} has a transparent tile seam" + ); + let (x, y) = if kind == BoardGridKind::Cartesian { + (20.0, 20.0) + } else { + (3.0_f64.sqrt() * 10.0, 10.0) + }; + let (x, y) = ((x * scale) as usize, (y * scale) as usize); + let darkest = (y - 1..=y + 1) + .flat_map(|y| (x - 1..=x + 1).map(move |x| (y * 180 + x) * 4)) + .map(|i| data[i]) + .min() + .unwrap(); + assert!( + darkest < 240, + "{kind:?} missing grid vertex at scale {scale}" + ); + } + } +} diff --git a/src/draw/render/mod.rs b/src/draw/render/mod.rs index abaff7bec..a950e6430 100644 --- a/src/draw/render/mod.rs +++ b/src/draw/render/mod.rs @@ -6,6 +6,7 @@ pub use backdrop_probe::painted_luminance as painted_background_luminance; pub use backdrop_probe::perceived_luminance; mod background; mod blur; +mod board_grid; mod context; mod highlight; mod image; @@ -20,6 +21,7 @@ mod types; pub use background::{fill_transparent, render_board_background}; pub use blur::{BlurRectParams, render_blur_rect}; +pub use board_grid::{BoardPaper, board_paper_device_scale}; pub use context::{RenderCaches, RenderCtx}; pub use highlight::render_click_highlight; #[allow(unused_imports)] diff --git a/src/input/boards.rs b/src/input/boards.rs index 85fe7a921..69af7df2a 100644 --- a/src/input/boards.rs +++ b/src/input/boards.rs @@ -1,4 +1,6 @@ +mod appearance; mod color; +pub use appearance::{BoardAppearance, BoardPenOrigin}; mod core; mod identity; mod mapping; @@ -36,11 +38,17 @@ pub use operations::{ pub struct BoardState { pub spec: BoardSpec, pub pages: BoardPages, + pub appearance_explicit: bool, + pub pen_origin: BoardPenOrigin, + configured_appearance: BoardAppearance, } impl BoardState { pub fn new(spec: BoardSpec) -> Self { Self { + configured_appearance: BoardAppearance::from_spec(&spec), + appearance_explicit: false, + pen_origin: BoardPenOrigin::Configured, spec, pages: BoardPages::new(), } diff --git a/src/input/boards/appearance.rs b/src/input/boards/appearance.rs new file mode 100644 index 000000000..4d1c96d38 --- /dev/null +++ b/src/input/boards/appearance.rs @@ -0,0 +1,59 @@ +use super::{BoardBackground, BoardManager, BoardSpec, BoardState}; +use crate::domain::{BoardGrid, Color}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum BoardPenOrigin { + #[default] + Configured, + RuntimeContrast, +} + +/// The reference paper and its board-entry pen policy, independent of drawing history. +#[derive(Debug, Clone, PartialEq)] +pub struct BoardAppearance { + pub background: BoardBackground, + pub grid: BoardGrid, + pub default_pen_color: Option, + pub auto_adjust_pen: bool, +} + +impl BoardAppearance { + pub fn from_spec(spec: &BoardSpec) -> Self { + Self { + background: spec.background.clone(), + grid: spec.grid, + default_pen_color: spec.default_pen_color, + auto_adjust_pen: spec.auto_adjust_pen, + } + } + + pub fn apply_to(&self, spec: &mut BoardSpec) { + spec.background = self.background.clone(); + spec.grid = if self.background.is_transparent() { + self.grid.disabled() + } else { + self.grid + }; + spec.default_pen_color = self.default_pen_color; + spec.auto_adjust_pen = self.auto_adjust_pen; + } +} + +impl BoardState { + /// Reset from the immutable configured/template seed, never from a prior session. + pub(crate) fn reset_appearance(&mut self) { + self.configured_appearance.apply_to(&mut self.spec); + self.appearance_explicit = false; + self.pen_origin = BoardPenOrigin::Configured; + } +} + +impl BoardManager { + pub(crate) fn reset_appearances(&mut self) { + for board in &mut self.boards { + board.reset_appearance(); + } + } +} diff --git a/src/input/boards/mapping.rs b/src/input/boards/mapping.rs index 9b0b9342a..5be88c2fd 100644 --- a/src/input/boards/mapping.rs +++ b/src/input/boards/mapping.rs @@ -32,6 +32,11 @@ impl BoardSpec { id: item.id.clone(), name: item.name.clone(), background: board_background_from_config(&item.background), + grid: if item.background.is_transparent() { + crate::domain::BoardGrid::from(item.grid).disabled() + } else { + item.grid.into() + }, default_pen_color: item.default_pen_color.as_ref().map(board_color_from_config), auto_adjust_pen: item.auto_adjust_pen, persist: item.persist, @@ -118,6 +123,7 @@ impl BoardManager { id: board.spec.id.clone(), name: board.spec.name.clone(), background: board_background_to_config(&board.spec.background), + grid: board.spec.grid.into(), default_pen_color: board.spec.default_pen_color.map(board_color_to_config), auto_adjust_pen: board.spec.auto_adjust_pen, persist: board.spec.persist, @@ -161,6 +167,7 @@ fn default_overlay_board() -> BoardState { id: BOARD_ID_TRANSPARENT.to_string(), name: "Overlay".to_string(), background: BoardBackground::Transparent, + grid: Default::default(), default_pen_color: None, auto_adjust_pen: false, persist: true, @@ -202,6 +209,7 @@ fn pick_template(boards: &[BoardState]) -> BoardSpec { b: 0.992, a: 1.0, }), + grid: Default::default(), default_pen_color: Some(PALETTE_BLACK), auto_adjust_pen: true, persist: true, diff --git a/src/input/boards/naming.rs b/src/input/boards/naming.rs index 708a9efda..92efc76d5 100644 --- a/src/input/boards/naming.rs +++ b/src/input/boards/naming.rs @@ -92,6 +92,7 @@ impl BoardManager { id: id.to_string(), name: "Overlay".to_string(), background: BoardBackground::Transparent, + grid: Default::default(), default_pen_color: None, auto_adjust_pen: false, persist: true, @@ -143,6 +144,9 @@ impl BoardManager { let mut new_board = BoardState::new(new_spec.clone()); // Clone pages from the active board new_board.pages = active.pages.clone(); + new_board.appearance_explicit = active.appearance_explicit; + new_board.pen_origin = active.pen_origin; + new_board.configured_appearance = active.configured_appearance.clone(); let insert_at = self.active_index + 1; self.pin_seeds.insert(new_spec.id.clone(), source_pin_seed); diff --git a/src/input/state/actions/key_press/panels.rs b/src/input/state/actions/key_press/panels.rs index 47e5a6dd5..9fbac15a9 100644 --- a/src/input/state/actions/key_press/panels.rs +++ b/src/input/state/actions/key_press/panels.rs @@ -73,6 +73,10 @@ impl InputState { return false; } + if self.board_appearance_key(key) { + return true; + } + if self.board_picker_page_edit_state().is_some() { match key { Key::Escape => { diff --git a/src/input/state/core/board.rs b/src/input/state/core/board.rs index a4d6a1cbd..2f7f5b239 100644 --- a/src/input/state/core/board.rs +++ b/src/input/state/core/board.rs @@ -1,3 +1,4 @@ +mod appearance; mod delete_restore; mod lifecycle; mod pages; diff --git a/src/input/state/core/board/appearance.rs b/src/input/state/core/board/appearance.rs new file mode 100644 index 000000000..59a140915 --- /dev/null +++ b/src/input/state/core/board/appearance.rs @@ -0,0 +1,43 @@ +use crate::domain::BoardBackground; +use crate::input::InputState; +use crate::input::boards::{BoardAppearance, BoardPenOrigin}; + +impl InputState { + /// Publish one appearance change without touching drawing history. + pub(in crate::input::state::core) fn apply_board_appearance_value( + &mut self, + index: usize, + appearance: BoardAppearance, + ) -> bool { + let active = index == self.boards.active_index(); + let Some(board) = self.boards.board_state_mut(index) else { + return false; + }; + let current = BoardAppearance::from_spec(&board.spec); + if current == appearance + || current.background.is_transparent() + || appearance.background.is_transparent() + { + return false; + } + let recolored = current.background != appearance.background; + appearance.apply_to(&mut board.spec); + let pen = if recolored && board.spec.auto_adjust_pen { + let BoardBackground::Solid(color) = board.spec.background else { + unreachable!() + }; + let pen = crate::input::runtime_contrast_pen_color(color); + board.spec.default_pen_color = Some(pen); + board.pen_origin = BoardPenOrigin::RuntimeContrast; + active.then_some(pen) + } else { + None + }; + board.appearance_explicit = true; + if let Some(pen) = pen { + self.set_pen_color_from_board(pen); + } + self.mark_board_surface_changed(); + true + } +} diff --git a/src/input/state/core/board/lifecycle.rs b/src/input/state/core/board/lifecycle.rs index c08f24f88..963bf3c4b 100644 --- a/src/input/state/core/board/lifecycle.rs +++ b/src/input/state/core/board/lifecycle.rs @@ -8,7 +8,7 @@ impl InputState { self.needs_redraw = true; } - pub(super) fn mark_board_surface_changed(&mut self) { + pub(in crate::input::state::core) fn mark_board_surface_changed(&mut self) { self.mark_board_surface_dirty(); self.mark_session_dirty(); } diff --git a/src/input/state/core/board/pages.rs b/src/input/state/core/board/pages.rs index e272d2852..baa559a5a 100644 --- a/src/input/state/core/board/pages.rs +++ b/src/input/state/core/board/pages.rs @@ -1,9 +1,9 @@ use super::super::base::InputState; use crate::draw::Color; use crate::draw::TextMeasurer; +use crate::input::BoardBackground; use crate::input::boards::PendingBoardRuntimeUiAction; use crate::input::state::{Toast, ToastPriority}; -use crate::input::{BoardBackground, runtime_contrast_pen_color}; impl InputState { pub(crate) fn reset_active_canvas_position(&mut self) -> bool { @@ -45,8 +45,7 @@ impl InputState { } pub(crate) fn set_board_background_color(&mut self, index: usize, color: Color) -> bool { - let is_active = self.boards.active_index() == index; - let Some(board) = self.boards.board_state_mut(index) else { + let Some(board) = self.boards.board_states().get(index) else { return false; }; if board.spec.background.is_transparent() { @@ -57,22 +56,9 @@ impl InputState { ); return false; } - if matches!(board.spec.background, BoardBackground::Solid(existing) if existing == color) { - return false; - } - - board.spec.background = BoardBackground::Solid(color); - let active_pen_color = if board.spec.auto_adjust_pen { - board.spec.default_pen_color = Some(runtime_contrast_pen_color(color)); - is_active.then(|| board.spec.effective_pen_color().unwrap_or(color)) - } else { - None - }; - if let Some(color) = active_pen_color { - self.set_pen_color_from_board(color); - } - self.mark_board_surface_dirty(); - true + let mut appearance = crate::input::boards::BoardAppearance::from_spec(&board.spec); + appearance.background = BoardBackground::Solid(color); + self.apply_board_appearance_value(index, appearance) } pub(crate) fn request_board_pin_toggle(&mut self, index: usize) -> bool { diff --git a/src/input/state/core/board/switch.rs b/src/input/state/core/board/switch.rs index 8c1fd8846..de43b2da4 100644 --- a/src/input/state/core/board/switch.rs +++ b/src/input/state/core/board/switch.rs @@ -21,6 +21,36 @@ impl InputState { self.board_transitions.replace_recent_for_test(recent); } + /// Resolve appearance before saved tools, including a restore to the same board ID. + pub(crate) fn restore_board_pen_after_snapshot( + &mut self, + previous_auto: bool, + previous_pen: crate::draw::Color, + previous_color: Option, + ) { + let spec = &self.boards.active_board().spec; + let target_auto = spec.auto_adjust_pen && !spec.background.is_transparent(); + let color = spec.effective_pen_color(); + if target_auto { + self.set_board_previous_color(if previous_auto { + previous_color + } else { + Some(previous_pen) + }); + if let Some(color) = color { + self.set_pen_color_from_board(color); + } + } else if previous_auto { + self.set_board_previous_color(None); + if let Some(color) = previous_color { + self.set_pen_color_from_board(color); + } + } else { + self.set_board_previous_color(previous_color); + self.set_pen_color_from_board(previous_pen); + } + } + /// Returns the active board id. pub fn board_id(&self) -> &str { self.boards.active_board_id() diff --git a/src/input/state/core/board_picker/appearance.rs b/src/input/state/core/board_picker/appearance.rs new file mode 100644 index 000000000..73a9b9c7a --- /dev/null +++ b/src/input/state/core/board_picker/appearance.rs @@ -0,0 +1,419 @@ +use super::{color_to_hex, parse_hex_color}; +use crate::domain::{BoardBackground, BoardGrid, BoardGridKind, Color}; +use crate::input::InputState; +use crate::input::boards::{BoardAppearance, BoardIdentityGeneration}; +use crate::input::events::Key; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum AppearanceField { + Color, + Pattern, + Spacing, +} + +const SHEET_PADDING: f64 = 12.0; +const SHEET_COLUMN_MARGIN: f64 = 8.0; +const SHEET_MIN_DOCKED_WIDTH: f64 = 240.0; +const SHEET_CLOSE_SIZE: f64 = 24.0; +const SHEET_COLOR_FIELD_WIDTH: f64 = 84.0; +const SHEET_HEADER_GAP: f64 = 8.0; + +/// The paper sheet's header controls as `(x, y, width, height)` rectangles. +#[derive(Debug, Clone, Copy)] +pub(crate) struct BoardAppearanceHeader { + /// The title is clipped before this x coordinate. + pub(crate) title_right: f64, + pub(crate) color_field: (f64, f64, f64, f64), + pub(crate) close: (f64, f64, f64, f64), +} + +fn rect_contains((x, y, width, height): (f64, f64, f64, f64), (px, py): (f64, f64)) -> bool { + px >= x && px <= x + width && py >= y && py <= y + height +} + +/// A board-identity-bound draft. Only Apply writes to the board or session. +#[derive(Debug)] +pub(crate) struct BoardAppearanceEdit { + id: String, + generation: BoardIdentityGeneration, + original: BoardAppearance, + pub(crate) color: String, + pub(crate) kind: BoardGridKind, + pub(crate) spacing: String, + pub(crate) focus: AppearanceField, + pub(crate) error: Option, +} + +impl BoardAppearanceEdit { + pub(super) fn set_color_text(&mut self, value: String) { + self.color = value; + } + + pub(crate) fn board_id(&self) -> &str { + &self.id + } + // Compare the displayed color with its original displayed value, preserving + // the exact configured float color when an edit is reverted. + fn color_is_dirty(&self) -> bool { + let BoardBackground::Solid(original) = self.original.background else { + return false; + }; + parse_hex_color(&self.color) != parse_hex_color(&color_to_hex(original)) + } + + pub(crate) fn validation_error(&self) -> Option<&'static str> { + if parse_hex_color(&self.color).is_none() { + return Some("Use a color in #RRGGBB format."); + } + if !self + .spacing + .parse::() + .is_ok_and(|s| (8..=200).contains(&s)) + { + return Some("Spacing must be a whole number from 8 to 200."); + } + None + } + + /// Whether the typed size equals a preset, so only that preset reads as selected. + pub(crate) fn spacing_matches(&self, preset: u16) -> bool { + self.spacing.trim().parse::().ok() == Some(preset) + } + + pub(crate) fn preview(&self) -> (Color, BoardGrid) { + let base = match self.original.background { + BoardBackground::Solid(color) => color, + _ => crate::draw::WHITE, + }; + ( + if self.color_is_dirty() { + parse_hex_color(&self.color).unwrap_or(base) + } else { + base + }, + BoardGrid::new( + self.kind, + self.spacing + .parse() + .unwrap_or(i64::from(self.original.grid.spacing())), + ), + ) + } + + fn desired(&self, current: &BoardAppearance) -> Result { + let color = parse_hex_color(&self.color).ok_or("Use a color in #RRGGBB format.")?; + let spacing = self + .spacing + .parse::() + .ok() + .filter(|s| (8..=200).contains(s)) + .ok_or("Spacing must be a whole number from 8 to 200.")?; + let kind_dirty = self.kind != self.original.grid.kind; + let spacing_dirty = spacing != i64::from(self.original.grid.spacing()); + let mut desired = current.clone(); + fn conflict(dirty: bool, current: &T, original: &T, desired: &T) -> bool { + dirty && current != original && current != desired + } + if conflict( + self.color_is_dirty(), + ¤t.background, + &self.original.background, + &BoardBackground::Solid(color), + ) || conflict( + kind_dirty, + ¤t.grid.kind, + &self.original.grid.kind, + &self.kind, + ) || conflict( + spacing_dirty, + ¤t.grid.spacing(), + &self.original.grid.spacing(), + &(spacing as u16), + ) { + return Err("Board appearance changed. Cancel and reopen to edit it."); + } + if self.color_is_dirty() { + desired.background = BoardBackground::Solid(color); + } + desired.grid = BoardGrid::new( + if kind_dirty { + self.kind + } else { + current.grid.kind + }, + if spacing_dirty { + spacing + } else { + i64::from(current.grid.spacing()) + }, + ); + Ok(desired) + } +} + +impl InputState { + pub(crate) fn board_appearance_edit(&self) -> Option<&BoardAppearanceEdit> { + self.board_picker.appearance.as_ref() + } + + pub(crate) fn begin_board_appearance(&mut self, index: usize) -> bool { + let Some(board) = self.boards.board_states().get(index) else { + return false; + }; + let BoardBackground::Solid(color) = board.spec.background else { + return false; + }; + self.board_picker.appearance = Some(BoardAppearanceEdit { + id: board.spec.id.clone(), + generation: self.boards.board_identity_generation(), + original: BoardAppearance::from_spec(&board.spec), + color: color_to_hex(color), + kind: board.spec.grid.kind, + spacing: board.spec.grid.spacing().to_string(), + focus: AppearanceField::Color, + error: None, + }); + // The sheet dims the whole surface behind it. + self.dirty_tracker.mark_full(); + self.needs_redraw = true; + true + } + + pub(crate) fn apply_board_appearance(&mut self) -> bool { + self.mark_board_appearance_region(); + let Some(edit) = &self.board_picker.appearance else { + return false; + }; + let index = self + .boards + .board_states() + .iter() + .position(|b| b.spec.id == edit.id); + if self.boards.board_identity_generation() != edit.generation || index.is_none() { + self.board_picker.appearance.as_mut().unwrap().error = + Some("Board identity changed. Cancel and reopen to edit it.".into()); + self.needs_redraw = true; + return false; + } + let index = index.unwrap(); + let current = BoardAppearance::from_spec(&self.boards.board_states()[index].spec); + let proposed = if current.background.is_transparent() { + Err("Paper patterns require a solid board.") + } else { + edit.desired(¤t) + }; + let desired = match proposed { + Ok(desired) => desired, + Err(error) => { + self.board_picker.appearance.as_mut().unwrap().error = Some(error.into()); + self.needs_redraw = true; + return false; + } + }; + self.apply_board_appearance_value(index, desired); + self.board_picker_clear_edit(); + self.needs_redraw = true; + true + } + + pub(crate) fn board_appearance_palette(&mut self, color: Color) -> bool { + self.mark_board_appearance_region(); + let Some(edit) = &mut self.board_picker.appearance else { + return false; + }; + edit.color = color_to_hex(color); + edit.error = None; + self.needs_redraw = true; + true + } + + pub(crate) fn board_appearance_key(&mut self, key: Key) -> bool { + self.mark_board_appearance_region(); + let Some(edit) = &mut self.board_picker.appearance else { + return false; + }; + match key { + Key::Escape => self.board_picker_cancel_edit(), + Key::Return => { + self.apply_board_appearance(); + } + Key::F2 => return false, + Key::Tab => { + edit.focus = match edit.focus { + AppearanceField::Color => AppearanceField::Pattern, + AppearanceField::Pattern => AppearanceField::Spacing, + AppearanceField::Spacing => AppearanceField::Color, + }; + } + Key::Left | Key::Right | Key::Up | Key::Down + if edit.focus == AppearanceField::Pattern => + { + let index = BoardGridKind::ALL + .iter() + .position(|kind| *kind == edit.kind) + .unwrap_or(0); + let step = if matches!(key, Key::Left | Key::Up) { + 3 + } else { + 1 + }; + edit.kind = BoardGridKind::ALL[(index + step) % 4]; + } + Key::Backspace | Key::Delete => match edit.focus { + AppearanceField::Color => { + edit.color.pop(); + } + AppearanceField::Spacing => { + edit.spacing.pop(); + } + _ => {} + }, + Key::Char(ch) => match edit.focus { + AppearanceField::Color + if (ch.is_ascii_hexdigit() || ch == '#') && edit.color.len() < 7 => + { + edit.color.push(ch); + } + AppearanceField::Spacing if !ch.is_control() && edit.spacing.len() < 8 => { + edit.spacing.push(ch); + } + _ => {} + }, + _ => {} + } + self.needs_redraw = true; + true + } + + /// Shared sheet geometry for painting and mouse input. The sheet floats over + /// the page column when it fits there, so it leaves the board list readable; + /// narrow pickers center it instead. + pub(crate) fn board_appearance_rect(&self) -> Option<(f64, f64, f64)> { + self.board_picker.appearance.as_ref()?; + let layout = self.board_picker.layout.as_ref()?; + let y = layout.origin_y + layout.height / 2.0 - 75.0; + + // Sheet padding plus a small margin on both sides of the page column. + let column_width = layout.origin_x + layout.width - layout.page_panel_x; + let docked_width = (column_width - 2.0 * (SHEET_PADDING + SHEET_COLUMN_MARGIN)).min(320.0); + // A picker wider than the output is clipped at both edges, so keep the + // sheet centered there to leave it fully on screen. + if layout.page_panel_enabled + && layout.origin_x >= 0.0 + && docked_width >= SHEET_MIN_DOCKED_WIDTH + { + let x = layout.page_panel_x + (column_width - docked_width) / 2.0; + return Some((x, y, docked_width)); + } + + let width = (layout.width - 32.0).clamp(140.0, 320.0); + Some((layout.origin_x + (layout.width - width) / 2.0, y, width)) + } + + /// Header controls in surface coordinates, shared by painting and clicks. + pub(crate) fn board_appearance_header(&self) -> Option { + let (x, y, width) = self.board_appearance_rect()?; + let close = ( + x + width + 4.0 - SHEET_CLOSE_SIZE, + y - 62.0, + SHEET_CLOSE_SIZE, + SHEET_CLOSE_SIZE, + ); + let field_x = close.0 - SHEET_HEADER_GAP - SHEET_COLOR_FIELD_WIDTH; + Some(BoardAppearanceHeader { + title_right: field_x - SHEET_HEADER_GAP, + color_field: (field_x, y - 61.0, SHEET_COLOR_FIELD_WIDTH, 22.0), + close, + }) + } + + pub(in crate::input::state) fn mark_board_appearance_region(&mut self) { + if self.board_picker.appearance.is_none() { + return; + } + // The outer palette also highlights the draft color. + if let Some(layout) = self.board_picker.layout + && layout.palette_rows > 0 + { + self.mark_board_picker_region(&layout); + } + // The frame plus its drop shadow below. + if let Some((x, y, width)) = self.board_appearance_rect() + && let Some(rect) = crate::util::Rect::new( + (x - 15.0).floor() as i32, + (y - 72.0).floor() as i32, + width.ceil() as i32 + 31, + 308, + ) + { + self.dirty_tracker.mark_rect(rect); + } else { + self.dirty_tracker.mark_full(); + } + self.needs_redraw = true; + } + + pub(crate) fn board_appearance_click(&mut self, x: i32, y: i32) -> bool { + let Some((left, top, width)) = self.board_appearance_rect() else { + return false; + }; + self.mark_board_appearance_region(); + let (screen_x, screen_y) = (x, y); + let x = f64::from(x) - left; + let y = f64::from(y) - top; + if !(-12.0..width + 12.0).contains(&x) || !(-70.0..222.0).contains(&y) { + if let Some(color) = self.board_picker_palette_color_at(screen_x, screen_y) { + return self.board_appearance_palette(color); + } + self.board_picker_cancel_edit(); + return false; + } + let header = self.board_appearance_header(); + let point = (f64::from(screen_x), f64::from(screen_y)); + if header.is_some_and(|header| rect_contains(header.close, point)) { + self.board_picker_cancel_edit(); + return true; + } + let in_color_field = header.is_some_and(|header| rect_contains(header.color_field, point)); + let edit = self.board_picker.appearance.as_mut().unwrap(); + match y as i32 { + -65..=-35 => { + if in_color_field { + edit.focus = AppearanceField::Color; + } + } + -30..=-5 => { + let index = (x / (width / 11.0)).floor() as usize; + if let Some(color) = super::board_palette_colors().get(index) { + edit.color = color_to_hex(*color); + } + } + 0..=55 => { + edit.kind = BoardGridKind::ALL + [((y / 28.0) as usize * 2 + (x / (width / 2.0)) as usize).min(3)]; + edit.focus = AppearanceField::Pattern; + } + 60..=87 => { + edit.focus = AppearanceField::Spacing; + if x > width - 40.0 { + edit.spacing = "40".into(); + } else if x > width - 80.0 { + edit.spacing = "20".into(); + } + } + 160..=187 => { + if x < width / 2.0 { + self.apply_board_appearance(); + } else { + self.board_picker_cancel_edit(); + } + } + _ => {} + } + self.needs_redraw = true; + true + } +} + +#[cfg(test)] +mod tests; diff --git a/src/input/state/core/board_picker/appearance/tests.rs b/src/input/state/core/board_picker/appearance/tests.rs new file mode 100644 index 000000000..5ea2f986d --- /dev/null +++ b/src/input/state/core/board_picker/appearance/tests.rs @@ -0,0 +1,306 @@ +use super::*; +use crate::draw::{BLUE, RED}; +use crate::input::state::test_support::TestInputStateBuilder; + +fn editing() -> InputState { + let mut input = TestInputStateBuilder::default().build(); + input.switch_board_force("whiteboard"); + input.open_board_picker_with_measurer(&crate::draw::TextMeasurer::default()); + input.board_picker_edit_color_selected_with_measurer(&crate::draw::TextMeasurer::default()); + input.clear_session_dirty(); + input +} + +#[test] +fn appearance_preview_cancel_and_noop_never_change_board_or_session() { + let mut input = editing(); + let original = BoardAppearance::from_spec(&input.boards.active_board().spec); + let pen = input.color_for_tool(crate::input::Tool::Pen); + input.board_appearance_palette(RED); + input.board_picker.appearance.as_mut().unwrap().preview(); + assert_eq!( + BoardAppearance::from_spec(&input.boards.active_board().spec), + original + ); + assert_eq!(input.color_for_tool(crate::input::Tool::Pen), pen); + assert!(!input.is_session_dirty()); + input.board_appearance_key(Key::Escape); + assert!(input.board_appearance_edit().is_none()); + assert!(!input.boards.active_board().appearance_explicit); + input.board_picker_edit_color_selected_with_measurer(&crate::draw::TextMeasurer::default()); + assert!(input.apply_board_appearance()); + assert!(!input.is_session_dirty()); +} + +#[test] +fn reverting_draft_fields_preserves_later_external_changes() { + let mut input = editing(); + let original_color = input.board_appearance_edit().unwrap().color.clone(); + input.board_appearance_palette(RED); + input.board_picker.appearance.as_mut().unwrap().color = original_color.to_lowercase(); + input.board_appearance_key(Key::Tab); + input.board_appearance_key(Key::Right); + input.board_appearance_key(Key::Left); + input.board_appearance_key(Key::Tab); + input.board_appearance_key(Key::Backspace); + input.board_appearance_key(Key::Char('0')); + + let board = input.boards.active_board_mut(); + board.spec.background = BoardBackground::Solid(BLUE); + board.spec.grid = BoardGrid::new(BoardGridKind::IsometricDots, 63); + let current = BoardAppearance::from_spec(&board.spec); + assert!(input.apply_board_appearance()); + assert_eq!( + BoardAppearance::from_spec(&input.boards.active_board().spec), + current + ); + assert!(!input.is_session_dirty()); + assert!(!input.boards.active_board().appearance_explicit); +} + +#[test] +fn appearance_apply_merges_untouched_fields_and_preserves_history_and_pen_for_grid_only() { + let mut input = editing(); + let pen = input.color_for_tool(crate::input::Tool::Pen); + let frame = input.boards.active_frame(); + let history = ( + frame.shapes.len(), + frame.undo_stack_len(), + frame.redo_stack_len(), + ); + let draft = input.board_picker.appearance.as_mut().unwrap(); + draft.kind = BoardGridKind::IsometricDots; + // Another operation changes an untouched field while the draft is open. + input.boards.active_board_mut().spec.grid = BoardGrid::new(BoardGridKind::None, 63); + assert!(input.apply_board_appearance()); + assert_eq!( + input.boards.active_board().spec.grid, + BoardGrid::new(BoardGridKind::IsometricDots, 63) + ); + assert_eq!(input.color_for_tool(crate::input::Tool::Pen), pen); + assert!(input.is_session_dirty()); + assert!(input.boards.active_board().appearance_explicit); + let frame = input.boards.active_frame(); + assert_eq!( + ( + frame.shapes.len(), + frame.undo_stack_len(), + frame.redo_stack_len() + ), + history + ); +} + +#[test] +fn appearance_invalid_spacing_conflict_and_identity_change_keep_draft_unapplied() { + let mut input = editing(); + let draft = input.board_picker.appearance.as_mut().unwrap(); + draft.spacing = "201".into(); + assert!(!input.apply_board_appearance()); + assert_eq!(input.board_appearance_edit().unwrap().spacing, "201"); + assert!(!input.is_session_dirty()); + input.board_picker.appearance.as_mut().unwrap().spacing = "20".into(); + input.board_appearance_palette(RED); + input.boards.active_board_mut().spec.background = BoardBackground::Solid(BLUE); + assert!(!input.apply_board_appearance()); + assert_eq!( + input.boards.active_board().spec.background, + BoardBackground::Solid(BLUE) + ); + input.boards.bump_board_identity_generation(); + assert!(!input.apply_board_appearance()); + assert!(!input.is_session_dirty()); +} + +#[test] +fn appearance_keyboard_edits_spacing_and_pattern_and_selection_cancels() { + let mut input = editing(); + input.board_appearance_key(Key::Tab); + input.board_appearance_key(Key::Right); + input.board_appearance_key(Key::Tab); + input.board_appearance_key(Key::Backspace); + input.board_appearance_key(Key::Backspace); + input.board_appearance_key(Key::Char('2')); + input.board_appearance_key(Key::Char('0')); + assert!(input.apply_board_appearance()); + assert_eq!( + input.boards.active_board().spec.grid, + BoardGrid::new(BoardGridKind::Cartesian, 20) + ); + input.board_picker_edit_color_selected_with_measurer(&crate::draw::TextMeasurer::default()); + input.board_picker_set_selected(0); + assert!(input.board_appearance_edit().is_none()); +} + +#[test] +fn appearance_preview_and_cancel_damage_the_sheet_without_changing_the_board() { + let mut input = editing(); + let surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 900, 700).unwrap(); + let ctx = cairo::Context::new(&surface).unwrap(); + input.update_board_picker_layout(&ctx, 900, 700); + let (x, y, width) = input.board_appearance_rect().unwrap(); + let original = BoardAppearance::from_spec(&input.boards.active_board().spec); + input.dirty_tracker.take_regions(900, 700); + for key in [Key::Tab, Key::Right, Key::Escape] { + // Unrelated UI damage must not suppress the sheet through a nonempty clip. + input + .dirty_tracker + .mark_rect(crate::util::Rect::new(0, 0, 10, 10).unwrap()); + assert!(input.board_appearance_key(key)); + let regions = input.dirty_tracker.take_regions(900, 700); + assert!(regions.iter().any(|rect| { + f64::from(rect.x) <= x - 12.0 + && f64::from(rect.y) <= y - 70.0 + && f64::from(rect.x + rect.width) >= x + width + 12.0 + && f64::from(rect.y + rect.height) >= y + 222.0 + })); + assert_eq!( + BoardAppearance::from_spec(&input.boards.active_board().spec), + original + ); + assert!(!input.is_session_dirty()); + } + assert!(input.board_appearance_edit().is_none()); +} + +#[test] +fn visible_palette_click_keeps_pattern_and_spacing_draft_until_apply() { + let mut input = editing(); + let surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 900, 700).unwrap(); + let ctx = cairo::Context::new(&surface).unwrap(); + input.update_board_picker_layout(&ctx, 900, 700); + input.board_picker.appearance.as_mut().unwrap().kind = BoardGridKind::IsometricDots; + input.board_picker.appearance.as_mut().unwrap().spacing = "63".into(); + let original = BoardAppearance::from_spec(&input.boards.active_board().spec); + let layout = input.board_picker_layout().unwrap(); + let x = (layout.origin_x + layout.padding_x + 1.0) as i32; + let y = (layout.palette_top + 1.0) as i32; + let expected_color = input.board_picker_palette_color_at(x, y).unwrap(); + let (left, top, width) = input.board_appearance_rect().unwrap(); + assert!( + f64::from(x) < left - 12.0 + || f64::from(x) >= left + width + 12.0 + || f64::from(y) < top - 70.0 + || f64::from(y) >= top + 222.0 + ); + assert!(input.board_appearance_click(x, y)); + let draft = input.board_appearance_edit().unwrap(); + assert_eq!(draft.kind, BoardGridKind::IsometricDots); + assert_eq!(draft.spacing, "63"); + assert_eq!(draft.preview().0, expected_color); + assert_eq!( + BoardAppearance::from_spec(&input.boards.active_board().spec), + original + ); + assert!(!input.is_session_dirty()); + assert!(input.apply_board_appearance()); + assert_eq!( + input.boards.active_board().spec.grid, + BoardGrid::new(BoardGridKind::IsometricDots, 63) + ); + assert_eq!( + input.boards.active_board().spec.background, + BoardBackground::Solid(expected_color) + ); +} + +#[test] +fn spacing_presets_select_only_the_matching_value() { + let mut input = editing(); + let surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 900, 700).unwrap(); + let ctx = cairo::Context::new(&surface).unwrap(); + input.update_board_picker_layout(&ctx, 900, 700); + let (left, top, width) = input.board_appearance_rect().unwrap(); + let row = (top + 72.0) as i32; + + assert!(input.board_appearance_click((left + width - 60.0) as i32, row)); + let draft = input.board_appearance_edit().unwrap(); + assert_eq!(draft.spacing, "20"); + assert!(draft.spacing_matches(20)); + assert!(!draft.spacing_matches(40)); + + assert!(input.board_appearance_click((left + width - 20.0) as i32, row)); + let draft = input.board_appearance_edit().unwrap(); + assert_eq!(draft.spacing, "40"); + assert!(draft.spacing_matches(40)); + assert!(!draft.spacing_matches(20)); + + input.board_picker.appearance.as_mut().unwrap().spacing = "63".into(); + let draft = input.board_appearance_edit().unwrap(); + assert!(!draft.spacing_matches(20) && !draft.spacing_matches(40)); +} + +fn laid_out(width: i32, height: i32) -> InputState { + let mut input = editing(); + let surface = cairo::ImageSurface::create(cairo::Format::ARgb32, width, height).unwrap(); + let ctx = cairo::Context::new(&surface).unwrap(); + input.update_board_picker_layout(&ctx, width as u32, height as u32); + input +} + +fn center((x, y, width, height): (f64, f64, f64, f64)) -> (i32, i32) { + ((x + width / 2.0) as i32, (y + height / 2.0) as i32) +} + +#[test] +fn sheet_floats_over_the_page_column_without_covering_the_board_list() { + for (width, height) in [(1920, 1080), (1280, 720), (900, 700)] { + let input = laid_out(width, height); + let layout = *input.board_picker_layout().unwrap(); + let (x, _, sheet_width) = input.board_appearance_rect().unwrap(); + + assert!( + x - 12.0 >= layout.origin_x + layout.list_width, + "{width}x{height}: the sheet covers the board list" + ); + assert!(x + sheet_width + 12.0 <= layout.origin_x + layout.width); + } +} + +#[test] +fn sheet_header_close_cancels_and_color_field_takes_focus() { + let mut input = laid_out(900, 700); + let original = BoardAppearance::from_spec(&input.boards.active_board().spec); + let header = input.board_appearance_header().unwrap(); + input.board_appearance_key(Key::Tab); + assert_ne!( + input.board_appearance_edit().unwrap().focus, + AppearanceField::Color + ); + + let (x, y) = center(header.color_field); + assert!(input.board_appearance_click(x, y)); + assert_eq!( + input.board_appearance_edit().unwrap().focus, + AppearanceField::Color + ); + + input.board_picker.appearance.as_mut().unwrap().kind = BoardGridKind::Cartesian; + let (x, y) = center(header.close); + assert!(input.board_appearance_click(x, y)); + assert!(input.board_appearance_edit().is_none()); + assert!(input.is_board_picker_open()); + assert_eq!( + BoardAppearance::from_spec(&input.boards.active_board().spec), + original + ); + assert!(!input.is_session_dirty()); +} + +#[test] +fn opening_and_closing_the_sheet_damage_the_whole_surface() { + let mut input = laid_out(900, 700); + let full = |regions: &[crate::util::Rect]| { + regions + .iter() + .any(|rect| rect.x <= 0 && rect.y <= 0 && rect.width >= 900 && rect.height >= 700) + }; + input.dirty_tracker.take_regions(900, 700); + + input.board_appearance_key(Key::Escape); + assert!(full(&input.dirty_tracker.take_regions(900, 700))); + + input.board_picker_edit_color_selected_with_measurer(&crate::draw::TextMeasurer::default()); + assert!(input.board_appearance_edit().is_some()); + assert!(full(&input.dirty_tracker.take_regions(900, 700))); +} diff --git a/src/input/state/core/board_picker/layout/cursor.rs b/src/input/state/core/board_picker/layout/cursor.rs index 5e544bf7c..dae2de1ab 100644 --- a/src/input/state/core/board_picker/layout/cursor.rs +++ b/src/input/state/core/board_picker/layout/cursor.rs @@ -30,6 +30,9 @@ impl InputState { return None; } let layout = self.board_picker.layout?; + if self.board_appearance_edit().is_some() { + return Some(BoardPickerCursorHint::Pointer); + } // Check if point is within the panel if !self.board_picker_contains_point(x, y) { diff --git a/src/input/state/core/board_picker/mod.rs b/src/input/state/core/board_picker/mod.rs index f471c07f7..381496857 100644 --- a/src/input/state/core/board_picker/mod.rs +++ b/src/input/state/core/board_picker/mod.rs @@ -1,5 +1,6 @@ #![allow(dead_code)] +pub(crate) mod appearance; mod layout; mod panel; mod search; diff --git a/src/input/state/core/board_picker/panel.rs b/src/input/state/core/board_picker/panel.rs index 6c5a68450..44397f08c 100644 --- a/src/input/state/core/board_picker/panel.rs +++ b/src/input/state/core/board_picker/panel.rs @@ -13,6 +13,7 @@ pub struct BoardPickerPanel { pub(in crate::input::state) state: BoardPickerState, pub(in crate::input::state) drag: Option, pub(in crate::input::state) page_drag: Option, + pub(in crate::input::state) appearance: Option, pub(in crate::input::state) page_edit: Option, pub(in crate::input::state) layout: Option, pub(in crate::input::state) search: String, @@ -47,6 +48,7 @@ impl BoardPickerPanel { self.drag = None; self.page_drag = None; self.page_edit = None; + self.appearance = None; self.state = BoardPickerState::Open { selected: selected_row.unwrap_or(active_index), hover_index: None, @@ -69,6 +71,7 @@ impl BoardPickerPanel { self.drag = None; self.page_drag = None; self.page_edit = None; + self.appearance = None; self.last_click = None; self.clear_search(); layout @@ -185,6 +188,7 @@ impl Default for BoardPickerPanel { drag: None, page_drag: None, page_edit: None, + appearance: None, layout: None, search: String::new(), search_last_input: None, diff --git a/src/input/state/core/board_picker/state/edit.rs b/src/input/state/core/board_picker/state/edit.rs index 1720151e5..ee62fcdaf 100644 --- a/src/input/state/core/board_picker/state/edit.rs +++ b/src/input/state/core/board_picker/state/edit.rs @@ -12,12 +12,37 @@ use super::super::{ impl InputState { pub(crate) fn board_picker_clear_edit(&mut self) { + if self.board_picker.appearance.take().is_some() { + // Closing the paper sheet removes the dim over the whole surface. + self.dirty_tracker.mark_full(); + self.needs_redraw = true; + } if let BoardPickerState::Open { edit, .. } = &mut self.board_picker.state { *edit = None; } } pub(crate) fn board_picker_start_edit(&mut self, mode: BoardPickerEditMode, buffer: String) { + self.board_picker_clear_edit(); + if mode == BoardPickerEditMode::Color { + let Some(index) = self + .board_picker_selected_index() + .and_then(|row| self.board_picker_board_index_for_row(row)) + else { + return; + }; + if !self.begin_board_appearance(index) { + return; + } + if let Some(draft) = &mut self.board_picker.appearance { + draft.set_color_text(buffer.clone()); + } + } + let buffer = if mode == BoardPickerEditMode::Color { + String::new() + } else { + buffer + }; if let BoardPickerState::Open { edit, .. } = &mut self.board_picker.state { *edit = Some(BoardPickerEdit { mode, buffer }); } @@ -29,7 +54,14 @@ impl InputState { return None; }; let edit = edit.as_ref()?; - Some((edit.mode, *selected, edit.buffer.as_str())) + Some(( + edit.mode, + *selected, + self.board_picker + .appearance + .as_ref() + .map_or(edit.buffer.as_str(), |draft| draft.color.as_str()), + )) } pub(crate) fn board_picker_edit_buffer_mut(&mut self) -> Option<&mut BoardPickerEdit> { @@ -320,6 +352,9 @@ impl InputState { } pub(crate) fn board_picker_commit_edit(&mut self) -> bool { + if self.board_picker.appearance.is_some() { + return self.apply_board_appearance(); + } let Some((mode, index, buffer)) = self.board_picker_edit_state() else { return false; }; @@ -365,6 +400,9 @@ impl InputState { } pub(crate) fn board_picker_edit_backspace(&mut self) { + if self.board_appearance_key(crate::input::events::Key::Backspace) { + return; + } if let Some(edit) = self.board_picker_edit_buffer_mut() { edit.buffer.pop(); self.needs_redraw = true; @@ -372,6 +410,9 @@ impl InputState { } pub(crate) fn board_picker_edit_append(&mut self, ch: char) { + if self.board_appearance_key(crate::input::events::Key::Char(ch)) { + return; + } let Some(edit) = self.board_picker_edit_buffer_mut() else { return; }; @@ -404,24 +445,6 @@ impl InputState { } pub(crate) fn board_picker_apply_palette_color(&mut self, color: Color) -> bool { - let Some(index) = self.board_picker_selected_index() else { - return false; - }; - if self.board_picker_is_new_row(index) { - return false; - } - let Some(board_index) = self.board_picker_board_index_for_row(index) else { - return false; - }; - if !self.set_board_background_color(board_index, color) { - return false; - } - if let Some(edit) = self.board_picker_edit_buffer_mut() - && edit.mode == BoardPickerEditMode::Color - { - edit.buffer = color_to_hex(color); - } - self.needs_redraw = true; - true + self.board_appearance_palette(color) } } diff --git a/src/input/state/core/board_picker/state/lifecycle.rs b/src/input/state/core/board_picker/state/lifecycle.rs index 95c58a3d7..f0bfa75c6 100644 --- a/src/input/state/core/board_picker/state/lifecycle.rs +++ b/src/input/state/core/board_picker/state/lifecycle.rs @@ -131,6 +131,9 @@ impl InputState { } pub(crate) fn board_picker_set_selected(&mut self, index: usize) { + if self.board_picker_selected_index() != Some(index) { + self.board_picker_clear_edit(); + } let row_count = self.board_picker_row_count().max(1); let next = index.min(row_count.saturating_sub(1)); let previous_board = self.board_picker_page_panel_board_index(); diff --git a/src/input/state/core/session_preflight_exact.rs b/src/input/state/core/session_preflight_exact.rs index a156e1885..9332db92b 100644 --- a/src/input/state/core/session_preflight_exact.rs +++ b/src/input/state/core/session_preflight_exact.rs @@ -111,6 +111,9 @@ fn duplicate_page_in_snapshot( let insert_at = (page_index + 1).min(pages.len()); pages.insert(insert_at, cloned_page); snapshot.boards.push(BoardSnapshot { + appearance: Some(crate::session::BoardAppearanceSnapshot::capture( + source_board, + )), id: source_board.spec.id.clone(), pages: BoardPagesSnapshot { active: insert_at, @@ -164,6 +167,9 @@ fn copy_page_between_boards_in_snapshot( let mut pages = pages_for_snapshot(&target_board.pages, history_limit); pages.push(cloned_page); snapshot.boards.push(BoardSnapshot { + appearance: Some(crate::session::BoardAppearanceSnapshot::capture( + target_board, + )), id: target_board.spec.id.clone(), pages: BoardPagesSnapshot { active: pages.len().saturating_sub(1), @@ -184,6 +190,7 @@ fn duplicate_active_board_in_snapshot(input: &InputState, snapshot: &mut Session }; let mut cloned = BoardSnapshot { + appearance: snapshot.boards[source_index].appearance.clone(), id: duplicate_board_id_for_preflight(input, &source_board.spec.id), pages: snapshot.boards[source_index].pages.clone(), }; diff --git a/src/input/state/mod.rs b/src/input/state/mod.rs index 6ddfb4f17..a690b5153 100644 --- a/src/input/state/mod.rs +++ b/src/input/state/mod.rs @@ -22,6 +22,7 @@ mod tests; pub(crate) use actions::key_press::bindings::key_to_action_label_for_test; pub(crate) use core::board_picker::BoardPickerEditMode; pub(crate) use core::board_picker::BoardPickerFocus; +pub(crate) use core::board_picker::appearance::AppearanceField; pub(crate) use core::board_picker::{ PAGE_DELETE_ICON_MARGIN, PAGE_DELETE_ICON_SIZE, PAGE_NAME_HEIGHT, PAGE_NAME_PADDING, }; diff --git a/src/input/state/mouse/press/panels.rs b/src/input/state/mouse/press/panels.rs index 2fd7d6215..889e900c9 100644 --- a/src/input/state/mouse/press/panels.rs +++ b/src/input/state/mouse/press/panels.rs @@ -136,6 +136,9 @@ impl InputState { if !self.is_board_picker_open() { return false; } + if self.board_appearance_edit().is_some() { + return true; + } self.update_pointer_position(x, y); match button { MouseButton::Left => { diff --git a/src/input/state/mouse/release/panels.rs b/src/input/state/mouse/release/panels.rs index 761119cf1..a74678874 100644 --- a/src/input/state/mouse/release/panels.rs +++ b/src/input/state/mouse/release/panels.rs @@ -113,6 +113,9 @@ pub(super) fn handle_board_picker_release( if !state.is_board_picker_open() { return false; } + if state.board_appearance_click(x, y) { + return true; + } if state.board_picker_is_page_dragging() { state.board_picker_finish_page_drag_with_measurer(resources.measurer); return true; diff --git a/src/input/state/tests/board_picker.rs b/src/input/state/tests/board_picker.rs index 1d23b4f02..1d53a5738 100644 --- a/src/input/state/tests/board_picker.rs +++ b/src/input/state/tests/board_picker.rs @@ -1461,8 +1461,10 @@ fn board_picker_commit_edit_rejects_invalid_colors_and_keeps_edit_open() { assert!(!input.board_picker_commit_edit()); assert_eq!( - input.active_toast().map(|toast| toast.message.as_str()), - Some("Invalid color. Use #RRGGBB or RRGGBB.") + input + .board_appearance_edit() + .and_then(|edit| edit.error.as_deref()), + Some("Use a color in #RRGGBB format.") ); assert_eq!( input.board_picker_edit_state(), diff --git a/src/session/catalog/tests.rs b/src/session/catalog/tests.rs index b19bd1b04..42b80cb47 100644 --- a/src/session/catalog/tests.rs +++ b/src/session/catalog/tests.rs @@ -61,6 +61,7 @@ fn sample_snapshot() -> SessionSnapshot { SessionSnapshot { active_board_id: "transparent".to_string(), boards: vec![BoardSnapshot { + appearance: None, id: "transparent".to_string(), pages: BoardPagesSnapshot { pages: vec![frame], diff --git a/src/session/mod.rs b/src/session/mod.rs index deca4a97d..fda6006cc 100644 --- a/src/session/mod.rs +++ b/src/session/mod.rs @@ -33,8 +33,8 @@ pub use options::{ }; #[allow(unused_imports)] pub use snapshot::{ - BoardPagesSnapshot, BoardSnapshot, SessionSnapshot, ToolStateSnapshot, apply_snapshot, - load_snapshot, save_snapshot, snapshot_from_input, + BoardAppearanceSnapshot, BoardPagesSnapshot, BoardSnapshot, SessionSnapshot, ToolStateSnapshot, + apply_snapshot, load_snapshot, save_snapshot, snapshot_from_input, }; #[allow(unused_imports)] pub(crate) use snapshot::{ diff --git a/src/session/snapshot/appearance.rs b/src/session/snapshot/appearance.rs new file mode 100644 index 000000000..e5911d25e --- /dev/null +++ b/src/session/snapshot/appearance.rs @@ -0,0 +1,88 @@ +use crate::config::BoardGridConfig; +use crate::domain::{BoardBackground, Color}; +use crate::input::boards::{BoardAppearance, BoardPenOrigin, BoardState}; +use serde::{Deserialize, Deserializer, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct BoardAppearanceSnapshot { + pub background: Option, + pub grid: BoardGridConfig, + pub default_pen_color: Option, + pub auto_adjust_pen: bool, + pub pen_origin: BoardPenOrigin, + pub explicit: bool, +} + +impl BoardAppearanceSnapshot { + pub(crate) fn capture(board: &BoardState) -> Self { + Self { + background: match board.spec.background { + BoardBackground::Transparent => None, + BoardBackground::Solid(color) => Some(color), + }, + grid: board.spec.grid.into(), + default_pen_color: board.spec.default_pen_color, + auto_adjust_pen: board.spec.auto_adjust_pen, + pen_origin: board.pen_origin, + explicit: board.appearance_explicit, + } + } + + pub(crate) fn is_valid(&self) -> bool { + fn valid(color: Color) -> bool { + [color.r, color.g, color.b, color.a] + .into_iter() + .all(|v| v.is_finite() && (0.0..=1.0).contains(&v)) + } + self.background.is_none_or(valid) + && self.default_pen_color.is_none_or(valid) + && (8..=200).contains(&self.grid.spacing) + } + + pub(crate) fn apply(&self, board: &mut BoardState) { + if !self.is_valid() { + return; + } + // The reserved overlay identity must stay transparent. + let background = if board.spec.id == crate::domain::BOARD_ID_TRANSPARENT { + BoardBackground::Transparent + } else { + self.background + .map_or(BoardBackground::Transparent, BoardBackground::Solid) + }; + BoardAppearance { + background, + grid: self.grid.into(), + default_pen_color: self.default_pen_color, + auto_adjust_pen: self.auto_adjust_pen, + } + .apply_to(&mut board.spec); + board.pen_origin = self.pen_origin; + board.appearance_explicit = self.explicit; + } +} + +pub(super) fn deserialize_appearance<'de, D: Deserializer<'de>>( + d: D, +) -> Result, D::Error> { + let value = Option::::deserialize(d)?; + let Some(value) = value else { + return Ok(None); + }; + if !value.as_object().is_some_and(|fields| { + fields.contains_key("background") && fields.contains_key("default_pen_color") + }) { + log::warn!("Ignoring incomplete saved board appearance; using configured paper"); + return Ok(None); + } + match serde_json::from_value::(value) { + Ok(appearance) if appearance.is_valid() => Ok(Some(appearance)), + _ => { + log::warn!("Ignoring invalid saved board appearance; using configured paper"); + Ok(None) + } + } +} + +#[cfg(test)] +mod tests; diff --git a/src/session/snapshot/appearance/tests.rs b/src/session/snapshot/appearance/tests.rs new file mode 100644 index 000000000..6a53ff7c5 --- /dev/null +++ b/src/session/snapshot/appearance/tests.rs @@ -0,0 +1,112 @@ +use super::*; +use crate::domain::{BoardGrid, BoardGridKind}; +use crate::draw::{Frame, RED}; +use crate::session::{ + BoardPagesSnapshot, BoardSnapshot, SessionOptions, SessionSnapshot, load_snapshot, + save_snapshot, +}; + +fn snapshot(explicit: bool) -> SessionSnapshot { + let mut boards = + crate::input::BoardManager::from_config(crate::config::BoardsConfig::default()); + boards.switch_to_id("whiteboard"); + let board = boards.active_board_mut(); + board.spec.grid = BoardGrid::new(BoardGridKind::IsometricDots, 20); + board.spec.default_pen_color = None; + board.appearance_explicit = explicit; + SessionSnapshot { + active_board_id: "whiteboard".into(), + tool_state: None, + boards: vec![BoardSnapshot { + id: "whiteboard".into(), + appearance: Some(BoardAppearanceSnapshot::capture(board)), + pages: BoardPagesSnapshot { + pages: vec![Frame::new()], + active: 0, + }, + }], + } +} + +#[test] +fn appearance_only_roundtrip_and_backup_recovery_preserve_exact_pen_none() { + let temp = crate::test_temp::tempdir().unwrap(); + let mut options = SessionOptions::new(temp.path().into(), "paper"); + options.persist_whiteboard = true; + let original = snapshot(true); + save_snapshot(&original, &options).unwrap(); + let loaded = load_snapshot(&options).unwrap().unwrap(); + assert!(loaded.has_board_data()); + let appearance = loaded.boards[0].appearance.as_ref().unwrap(); + assert!(appearance.explicit); + assert_eq!(appearance.default_pen_color, None); + assert_eq!( + appearance.grid.kind, + crate::config::BoardGridKindConfig::IsometricDots + ); + std::fs::rename(options.session_file_path(), options.backup_file_path()).unwrap(); + assert!(load_snapshot(&options).unwrap().unwrap().has_board_data()); +} + +#[test] +fn appearance_history_trimming_retains_only_explicit_empty_paper() { + use crate::session::snapshot::save::snapshot_without_history; + for explicit in [false, true] { + let mut original = snapshot(explicit); + let frame = &mut original.boards[0].pages.pages[0]; + let id = frame.add_shape(crate::draw::Shape::Line { + x1: 0, + y1: 0, + x2: 10, + y2: 10, + thick: 2.0, + color: RED, + }); + frame.push_undo_action( + crate::draw::frame::UndoAction::Create { + shapes: vec![(0, frame.shape(id).unwrap().clone())], + }, + 100, + ); + frame.undo_last(); + assert!(original.has_board_data()); + let trimmed = snapshot_without_history(&original); + assert_eq!(trimmed.has_board_data(), explicit); + assert_eq!(trimmed.boards.len(), usize::from(explicit)); + } +} + +#[test] +fn appearance_invalid_metadata_cannot_make_an_empty_board_recoverable() { + let mut original = snapshot(true); + original.boards[0].appearance.as_mut().unwrap().grid.spacing = -1; + assert!(!original.has_board_data()); + let value = serde_json::json!({"id":"whiteboard","pages":[],"active_page":0,"appearance":{"explicit":true,"grid":{"kind":"unknown"}}}); + let board: crate::session::snapshot::types::BoardFile = serde_json::from_value(value).unwrap(); + assert!(board.appearance.is_none()); +} + +#[test] +fn appearance_only_named_backup_survives_a_tool_only_primary() { + let temp = crate::test_temp::tempdir().unwrap(); + let mut options = SessionOptions::new(temp.path().into(), "paper"); + options.set_named_file_target(temp.path().join("paper.wayscriber")); + options.persist_whiteboard = true; + options.restore_tool_state = true; + save_snapshot(&snapshot(true), &options).unwrap(); + let tools_only = SessionSnapshot { + active_board_id: "whiteboard".into(), + boards: Vec::new(), + tool_state: Some(crate::session::ToolStateSnapshot::from_config( + &crate::config::Config::default(), + )), + }; + super::super::save::save_snapshot_with_report_and_clear_boundary(&tools_only, &options, false) + .unwrap(); + let outcome = super::super::load::load_named_session_candidate(&options).unwrap(); + assert!(outcome.has_board_data()); + assert!(matches!( + outcome, + super::super::load::LoadSnapshotOutcome::LoadedFromBackup(_) + )); +} diff --git a/src/session/snapshot/apply.rs b/src/session/snapshot/apply.rs index 30010fcc6..ce2b3942b 100644 --- a/src/session/snapshot/apply.rs +++ b/src/session/snapshot/apply.rs @@ -18,6 +18,13 @@ fn apply_snapshot_inner( options: &SessionOptions, replacement_board_ids: Option<&HashSet>, ) { + let previous_spec = &input.boards.active_board().spec; + let previous_auto = previous_spec.auto_adjust_pen && !previous_spec.background.is_transparent(); + let previous_pen = input.color_for_tool(crate::input::Tool::Pen); + let previous_color = input.board_previous_color(); + if replacement_board_ids.is_some() { + input.boards.reset_appearances(); + } let runtime_history_limit = options.effective_history_limit(input.history_limits.undo_stack_limit()); let board_generation_before = input.boards.board_identity_generation(); @@ -39,6 +46,10 @@ fn apply_snapshot_inner( .iter_mut() .find(|state| state.spec.id == board.id) { + board_state.reset_appearance(); + if let Some(appearance) = &board.appearance { + appearance.apply(board_state); + } clamp_runtime_history(&mut board_state.pages, runtime_history_limit); } } @@ -54,6 +65,8 @@ fn apply_snapshot_inner( ); } + input.restore_board_pen_after_snapshot(previous_auto, previous_pen, previous_color); + if options.restore_tool_state { if let Some(tool_state) = snapshot.tool_state { apply_tool_state_snapshot(input, measurer, tool_state); diff --git a/src/session/snapshot/capture.rs b/src/session/snapshot/capture.rs index 5a16de41d..c31c80968 100644 --- a/src/session/snapshot/capture.rs +++ b/src/session/snapshot/capture.rs @@ -19,17 +19,16 @@ pub fn snapshot_from_input( let history_limit = options.effective_history_limit(input.history_limits.undo_stack_limit()); - let capture_pages = |pages: &crate::draw::BoardPages| -> Option { + let capture_pages = |pages: &crate::draw::BoardPages| -> BoardPagesSnapshot { let cloned_pages = pages .pages() .iter() .map(|page| page.clone_with_history_limit(history_limit)) .collect(); - let snapshot = BoardPagesSnapshot { + BoardPagesSnapshot { pages: cloned_pages, active: pages.active_index(), - }; - snapshot.has_persistable_data().then_some(snapshot) + } }; let persist_non_transparent = options.persist_whiteboard || options.persist_blackboard; @@ -43,8 +42,10 @@ pub fn snapshot_from_input( if !should_persist { continue; } - if let Some(pages) = capture_pages(&board.pages) { + let pages = capture_pages(&board.pages); + if pages.has_persistable_data() || board.appearance_explicit { snapshot.boards.push(BoardSnapshot { + appearance: Some(super::BoardAppearanceSnapshot::capture(board)), id: board.spec.id.clone(), pages, }); diff --git a/src/session/snapshot/load/payload.rs b/src/session/snapshot/load/payload.rs index b6e4fe922..b5e35de1e 100644 --- a/src/session/snapshot/load/payload.rs +++ b/src/session/snapshot/load/payload.rs @@ -110,12 +110,14 @@ pub(super) fn load_snapshot_opened_with_expanded_limit( let mut snapshot = if !boards.is_empty() || active_board_id.is_some() { let mut board_snaps = Vec::new(); for BoardFile { + appearance, id, pages, active_page, } in boards { board_snaps.push(BoardSnapshot { + appearance, id, pages: normalized_board_pages_snapshot(pages, Some(active_page)), }); @@ -132,6 +134,7 @@ pub(super) fn load_snapshot_opened_with_expanded_limit( board_pages_from_file(transparent_pages, transparent_active_page, transparent) { board_snaps.push(BoardSnapshot { + appearance: None, id: "transparent".to_string(), pages, }); @@ -140,6 +143,7 @@ pub(super) fn load_snapshot_opened_with_expanded_limit( board_pages_from_file(whiteboard_pages, whiteboard_active_page, whiteboard) { board_snaps.push(BoardSnapshot { + appearance: None, id: "whiteboard".to_string(), pages, }); @@ -148,6 +152,7 @@ pub(super) fn load_snapshot_opened_with_expanded_limit( board_pages_from_file(blackboard_pages, blackboard_active_page, blackboard) { board_snaps.push(BoardSnapshot { + appearance: None, id: "blackboard".to_string(), pages, }); @@ -171,6 +176,10 @@ pub(super) fn load_snapshot_opened_with_expanded_limit( apply_history_policies(&mut board.pages, &board.id, disk_history_limit); } + snapshot + .boards + .retain(|board| board.appearance.is_none() || board.has_recoverable_user_data()); + if snapshot.is_empty() && snapshot.tool_state.is_none() { debug!( "Loaded session file at {} but it contained no data", diff --git a/src/session/snapshot/mod.rs b/src/session/snapshot/mod.rs index 1e9391357..33a66c927 100644 --- a/src/session/snapshot/mod.rs +++ b/src/session/snapshot/mod.rs @@ -1,4 +1,6 @@ +mod appearance; mod apply; +pub use appearance::BoardAppearanceSnapshot; mod capture; mod compression; mod history; diff --git a/src/session/snapshot/save.rs b/src/session/snapshot/save.rs index 1d2966316..26139aced 100644 --- a/src/session/snapshot/save.rs +++ b/src/session/snapshot/save.rs @@ -21,10 +21,8 @@ mod save_as; #[cfg(test)] mod tests; -use payload::{ - PayloadCandidate, estimate_from_candidate, payload_candidate, payload_within_limit, - snapshot_without_history, -}; +pub(super) use payload::snapshot_without_history; +use payload::{PayloadCandidate, estimate_from_candidate, payload_candidate, payload_within_limit}; use recovery::{ remove_backup_file, remove_backup_recovery_marker_file, remove_clear_marker_file, remove_recoverable_artifacts_suppressed_by_clear_marker, remove_recovery_file, diff --git a/src/session/snapshot/save/payload.rs b/src/session/snapshot/save/payload.rs index d64389f3f..7be60772c 100644 --- a/src/session/snapshot/save/payload.rs +++ b/src/session/snapshot/save/payload.rs @@ -350,6 +350,7 @@ fn serialize_payload(snapshot: &SessionSnapshot, last_modified: &str) -> Result< .boards .iter() .map(|board| BoardFile { + appearance: board.appearance.clone(), id: board.id.clone(), pages: board.pages.pages.clone(), active_page: board.pages.active, @@ -411,9 +412,14 @@ fn snapshot_with_history_depth(snapshot: &SessionSnapshot, depth: usize) -> Sess } } candidate + .boards + .retain(BoardSnapshot::has_recoverable_user_data); + candidate } -pub(super) fn snapshot_without_history(snapshot: &SessionSnapshot) -> SessionSnapshot { +pub(in crate::session::snapshot) fn snapshot_without_history( + snapshot: &SessionSnapshot, +) -> SessionSnapshot { let mut boards = Vec::with_capacity(snapshot.boards.len()); for board in &snapshot.boards { let pages = BoardPagesSnapshot { @@ -425,8 +431,14 @@ pub(super) fn snapshot_without_history(snapshot: &SessionSnapshot) -> SessionSna .collect(), active: board.pages.active, }; - if pages.has_persistable_data() { + if pages.has_persistable_data() + || board + .appearance + .as_ref() + .is_some_and(|a| a.explicit && a.is_valid()) + { boards.push(BoardSnapshot { + appearance: board.appearance.clone(), id: board.id.clone(), pages, }); diff --git a/src/session/snapshot/tests.rs b/src/session/snapshot/tests.rs index 1b5280832..a52ef7399 100644 --- a/src/session/snapshot/tests.rs +++ b/src/session/snapshot/tests.rs @@ -48,6 +48,7 @@ fn sample_snapshot() -> SessionSnapshot { SessionSnapshot { active_board_id: "transparent".to_string(), boards: vec![BoardSnapshot { + appearance: None, id: "transparent".to_string(), pages: BoardPagesSnapshot { pages: vec![sample_frame()], @@ -118,6 +119,7 @@ fn sample_session_file() -> SessionFile { active_board_id: Some("transparent".to_string()), active_mode: None, boards: vec![BoardFile { + appearance: None, id: "transparent".to_string(), pages: vec![sample_frame()], active_page: 0, @@ -1895,6 +1897,7 @@ fn save_snapshot_refuses_compressed_payload_over_expanded_limit() { let snapshot = SessionSnapshot { active_board_id: "transparent".to_string(), boards: vec![BoardSnapshot { + appearance: None, id: "transparent".to_string(), pages: BoardPagesSnapshot { pages: vec![frame], @@ -2239,6 +2242,7 @@ fn save_snapshot_preserves_multiple_pages() { let snapshot = SessionSnapshot { active_board_id: "transparent".to_string(), boards: vec![BoardSnapshot { + appearance: None, id: "transparent".to_string(), pages: BoardPagesSnapshot { pages: vec![first, second], @@ -2273,6 +2277,7 @@ fn save_snapshot_keeps_empty_pages() { let snapshot = SessionSnapshot { active_board_id: "transparent".to_string(), boards: vec![BoardSnapshot { + appearance: None, id: "transparent".to_string(), pages: BoardPagesSnapshot { pages: vec![Frame::new(), Frame::new(), Frame::new()], @@ -2358,6 +2363,7 @@ fn save_snapshot_serializes_compound_undo_history() { let snapshot = SessionSnapshot { active_board_id: "transparent".to_string(), boards: vec![BoardSnapshot { + appearance: None, id: "transparent".to_string(), pages: BoardPagesSnapshot { pages: vec![frame], @@ -2465,6 +2471,7 @@ fn load_snapshot_inner_falls_back_when_active_board_is_missing() { active_board_id: Some("missing".to_string()), active_mode: None, boards: vec![BoardFile { + appearance: None, id: "transparent".to_string(), pages: vec![sample_frame()], active_page: 0, diff --git a/src/session/snapshot/types.rs b/src/session/snapshot/types.rs index f52be641e..836ab73a6 100644 --- a/src/session/snapshot/types.rs +++ b/src/session/snapshot/types.rs @@ -5,7 +5,7 @@ use crate::draw::{ use crate::input::{DrawingStyle, EraserMode, InputState, PerToolDrawingSettings, Tool}; use serde::{Deserialize, Serialize}; -pub(super) const CURRENT_VERSION: u32 = 6; +pub(super) const CURRENT_VERSION: u32 = 7; /// Captured state suitable for serialisation or restoration. #[derive(Debug, Clone)] @@ -18,6 +18,7 @@ pub struct SessionSnapshot { #[derive(Debug, Clone)] pub struct BoardSnapshot { pub id: String, + pub appearance: Option, pub pages: BoardPagesSnapshot, } @@ -27,6 +28,16 @@ pub struct BoardPagesSnapshot { pub active: usize, } +impl BoardSnapshot { + pub(crate) fn has_recoverable_user_data(&self) -> bool { + self.pages.has_persistable_data() + || self + .appearance + .as_ref() + .is_some_and(|a| a.explicit && a.is_valid()) + } +} + impl BoardPagesSnapshot { pub(super) fn has_persistable_data(&self) -> bool { if self.pages.len() > 1 || self.active > 0 { @@ -40,7 +51,7 @@ impl SessionSnapshot { pub(crate) fn has_board_data(&self) -> bool { self.boards .iter() - .any(|board| board.pages.has_persistable_data()) + .any(BoardSnapshot::has_recoverable_user_data) } pub(super) fn is_empty(&self) -> bool { @@ -206,6 +217,12 @@ pub(super) struct SessionFile { #[derive(Debug, Clone, Serialize, Deserialize)] pub(super) struct BoardFile { pub id: String, + #[serde( + default, + skip_serializing_if = "Option::is_none", + deserialize_with = "super::appearance::deserialize_appearance" + )] + pub appearance: Option, pub pages: Vec, pub active_page: usize, } diff --git a/src/session/storage/tests.rs b/src/session/storage/tests.rs index 26e92ed36..7e6e214a2 100644 --- a/src/session/storage/tests.rs +++ b/src/session/storage/tests.rs @@ -29,6 +29,7 @@ fn transparent_line_snapshot() -> SessionSnapshot { SessionSnapshot { active_board_id: "transparent".to_string(), boards: vec![BoardSnapshot { + appearance: None, id: "transparent".to_string(), pages: BoardPagesSnapshot { pages: vec![frame], @@ -292,6 +293,7 @@ fn inspect_session_reports_counts_and_flags() { let snapshot = SessionSnapshot { active_board_id: "transparent".to_string(), boards: vec![BoardSnapshot { + appearance: None, id: "transparent".to_string(), pages: BoardPagesSnapshot { pages: vec![frame], diff --git a/src/session/tests/board_appearance.rs b/src/session/tests/board_appearance.rs new file mode 100644 index 000000000..07df34a2c --- /dev/null +++ b/src/session/tests/board_appearance.rs @@ -0,0 +1,155 @@ +use super::helpers::dummy_input_state; +use crate::domain::{BoardGrid, BoardGridKind}; +use crate::draw::{BLACK, RED}; +use crate::input::boards::{BoardAppearance, BoardPenOrigin}; +use crate::session::{SessionOptions, apply_snapshot, snapshot_from_input}; +use std::path::PathBuf; + +fn options() -> SessionOptions { + let mut options = SessionOptions::new(PathBuf::from("/tmp"), "appearance"); + options.persist_whiteboard = true; + options.restore_tool_state = false; + options +} + +#[test] +fn board_appearance_empty_templates_do_not_displace_recovery_but_explicit_paper_does() { + let mut input = dummy_input_state(); + let options = options(); + assert!(snapshot_from_input(&input, &options).is_none()); + input.switch_board_force("whiteboard"); + let board = input.boards.active_board_mut(); + board.spec.grid = BoardGrid::new(BoardGridKind::Isometric, 20); + board.appearance_explicit = true; + let snapshot = snapshot_from_input(&input, &options).unwrap(); + assert!(snapshot.has_board_data()); + assert_eq!(snapshot.boards.len(), 1); + assert!(snapshot.boards[0].appearance.as_ref().unwrap().explicit); + assert!(input.boards.active_board().appearance_explicit); + let mut restored = dummy_input_state(); + apply_snapshot(&mut restored, snapshot, &options); + assert_eq!( + restored.boards.active_board().spec.grid, + BoardGrid::new(BoardGridKind::Isometric, 20) + ); + assert!(restored.boards.active_board().appearance_explicit); +} + +#[test] +fn board_appearance_same_id_restore_resolves_pen_then_tool_state_wins() { + let mut source = dummy_input_state(); + source.switch_board_force("whiteboard"); + let board = source.boards.active_board_mut(); + board.spec.default_pen_color = Some(RED); + board.pen_origin = BoardPenOrigin::RuntimeContrast; + board.appearance_explicit = true; + let options = options(); + let snapshot = snapshot_from_input(&source, &options).unwrap(); + let mut target = dummy_input_state(); + target.switch_board_force("whiteboard"); + apply_snapshot(&mut target, snapshot, &options); + assert_eq!(target.color_for_tool(crate::input::Tool::Pen), RED); + assert_eq!( + target.boards.active_board().pen_origin, + BoardPenOrigin::RuntimeContrast + ); + + let mut options = options; + options.restore_tool_state = true; + let mut snapshot = snapshot_from_input(&source, &options).unwrap(); + let tools = snapshot.tool_state.as_mut().unwrap(); + tools.current_color = BLACK; + tools.tool_settings = None; + tools.board_previous_color = Some(RED); + apply_snapshot(&mut target, snapshot, &options); + assert_eq!(target.color_for_tool(crate::input::Tool::Pen), BLACK); + assert_eq!(target.board_previous_color(), Some(RED)); +} + +#[test] +fn board_appearance_legacy_restore_uses_immutable_seed() { + let mut input = dummy_input_state(); + input.switch_board_force("whiteboard"); + let seed = BoardAppearance::from_spec(&input.boards.active_board().spec); + input.set_board_background_color(input.boards.active_index(), RED); + let options = options(); + let mut snapshot = snapshot_from_input(&input, &options).unwrap(); + snapshot.boards[0].appearance = None; + apply_snapshot(&mut input, snapshot, &options); + assert_eq!( + BoardAppearance::from_spec(&input.boards.active_board().spec), + seed + ); + assert!(!input.boards.active_board().appearance_explicit); +} + +#[test] +fn board_appearance_named_replacement_clears_absent_overrides_and_failed_replace_keeps_draft() { + let mut input = dummy_input_state(); + input.switch_board_force("whiteboard"); + let seed = BoardAppearance::from_spec(&input.boards.active_board().spec); + input.set_board_background_color(input.boards.active_index(), RED); + input.open_board_picker_with_measurer(&crate::draw::TextMeasurer::default()); + input.board_picker_edit_color_selected_with_measurer(&crate::draw::TextMeasurer::default()); + let empty = crate::session::SessionSnapshot { + active_board_id: "whiteboard".into(), + boards: Vec::new(), + tool_state: None, + }; + let mut too_many = empty.clone(); + for index in 0..input.boards.max_count() + 1 { + too_many.boards.push(crate::session::BoardSnapshot { + id: format!("overflow-{index}"), + appearance: None, + pages: crate::session::BoardPagesSnapshot { + pages: vec![crate::draw::Frame::new()], + active: 0, + }, + }); + } + assert!( + crate::session::apply_snapshot_replacing_boards( + &mut input, + &crate::draw::TextMeasurer::default(), + too_many, + &options() + ) + .is_err() + ); + assert!(input.board_appearance_edit().is_some()); + crate::session::apply_snapshot_replacing_boards( + &mut input, + &crate::draw::TextMeasurer::default(), + empty, + &options(), + ) + .unwrap(); + assert!(input.board_appearance_edit().is_none()); + assert_eq!( + BoardAppearance::from_spec(&input.boards.active_board().spec), + seed + ); + assert!(!input.boards.active_board().appearance_explicit); +} + +#[test] +fn board_appearance_with_drawings_is_frozen_without_promoting_override_ownership() { + let mut source = dummy_input_state(); + source.switch_board_force("whiteboard"); + source.boards.active_board_mut().spec.grid = BoardGrid::new(BoardGridKind::Cartesian, 20); + source + .boards + .active_frame_mut() + .set_page_name(Some("Drawing reference".into())); + let snapshot = snapshot_from_input(&source, &options()).unwrap(); + assert!(!snapshot.boards[0].appearance.as_ref().unwrap().explicit); + let mut target = dummy_input_state(); + target.switch_board_force("whiteboard"); + target.boards.active_board_mut().spec.grid = BoardGrid::new(BoardGridKind::Isometric, 80); + apply_snapshot(&mut target, snapshot, &options()); + assert_eq!( + target.boards.active_board().spec.grid, + BoardGrid::new(BoardGridKind::Cartesian, 20) + ); + assert!(!target.boards.active_board().appearance_explicit); +} diff --git a/src/session/tests/limits.rs b/src/session/tests/limits.rs index e8aedb694..3711da006 100644 --- a/src/session/tests/limits.rs +++ b/src/session/tests/limits.rs @@ -205,6 +205,7 @@ fn single_page_snapshot(frame: crate::draw::Frame) -> SessionSnapshot { SessionSnapshot { active_board_id: "transparent".to_string(), boards: vec![BoardSnapshot { + appearance: None, id: "transparent".to_string(), pages: BoardPagesSnapshot { pages: vec![frame], diff --git a/src/session/tests/mod.rs b/src/session/tests/mod.rs index 81e51e080..5ffbccdf3 100644 --- a/src/session/tests/mod.rs +++ b/src/session/tests/mod.rs @@ -1,3 +1,4 @@ +mod board_appearance; mod helpers; mod history; mod limits; diff --git a/src/session/tests/roundtrip.rs b/src/session/tests/roundtrip.rs index 086fb59cc..1d068e3bb 100644 --- a/src/session/tests/roundtrip.rs +++ b/src/session/tests/roundtrip.rs @@ -166,6 +166,7 @@ fn save_snapshot_rotates_backup_when_enabled() { let snapshot = SessionSnapshot { active_board_id: BOARD_ID_TRANSPARENT.to_string(), boards: vec![BoardSnapshot { + appearance: None, id: BOARD_ID_TRANSPARENT.to_string(), pages: BoardPagesSnapshot { pages: vec![frame], @@ -213,6 +214,7 @@ fn save_snapshot_skips_backup_when_disabled() { let snapshot = SessionSnapshot { active_board_id: BOARD_ID_TRANSPARENT.to_string(), boards: vec![BoardSnapshot { + appearance: None, id: BOARD_ID_TRANSPARENT.to_string(), pages: BoardPagesSnapshot { pages: vec![frame], diff --git a/src/session/tests/snapshot.rs b/src/session/tests/snapshot.rs index a5c9cbd39..cb2c520c7 100644 --- a/src/session/tests/snapshot.rs +++ b/src/session/tests/snapshot.rs @@ -593,6 +593,7 @@ fn apply_snapshot_keeps_current_board_when_active_board_is_missing() { let snapshot = SessionSnapshot { active_board_id: "missing".to_string(), boards: vec![BoardSnapshot { + appearance: None, id: "transparent".to_string(), pages: BoardPagesSnapshot { pages: vec![Frame::new()], @@ -627,6 +628,7 @@ fn apply_snapshot_clears_pending_board_delete_confirmation() { let snapshot = SessionSnapshot { active_board_id: BOARD_ID_BLACKBOARD.to_string(), boards: vec![BoardSnapshot { + appearance: None, id: BOARD_ID_BLACKBOARD.to_string(), pages: BoardPagesSnapshot { pages: vec![Frame::new()], @@ -666,6 +668,7 @@ fn apply_snapshot_clears_pending_page_delete_confirmation() { let snapshot = SessionSnapshot { active_board_id: BOARD_ID_BLACKBOARD.to_string(), boards: vec![BoardSnapshot { + appearance: None, id: BOARD_ID_BLACKBOARD.to_string(), pages: BoardPagesSnapshot { pages: vec![Frame::new()], diff --git a/src/ui/board_picker.rs b/src/ui/board_picker.rs index aaff15562..d7445d85b 100644 --- a/src/ui/board_picker.rs +++ b/src/ui/board_picker.rs @@ -7,6 +7,7 @@ use super::constants::{ TEXT_PRIMARY, TEXT_TERTIARY, }; +mod appearance; mod helpers; mod page_panel; mod palette; @@ -186,6 +187,7 @@ pub(crate) fn render_board_picker_with_halo( text_halo_enabled, ); + appearance::render(engine, ctx, input_state, screen_width, screen_height); let _ = ctx.restore(); } diff --git a/src/ui/board_picker/appearance.rs b/src/ui/board_picker/appearance.rs new file mode 100644 index 000000000..b1c2f66a5 --- /dev/null +++ b/src/ui/board_picker/appearance.rs @@ -0,0 +1,299 @@ +use crate::domain::BoardGridKind; +use crate::input::InputState; +use crate::input::state::AppearanceField; +use crate::ui::constants::{self, INPUT_CARET, OVERLAY_DIM_MEDIUM, TEXT_HINT, TEXT_PRIMARY}; +use crate::ui::primitives::draw_rounded_rect; +use crate::ui::theme::{Rgba, popup}; +use crate::ui_text::{UiTextEngine, UiTextStyle}; + +use super::palette::{PALETTE_SWATCH_GAP, PALETTE_SWATCH_SIZE}; + +const SHEET_RADIUS: f64 = 10.0; +/// Two-layer drop shadow that lifts the sheet off the dimmed picker. +const SHEET_SHADOW_SOFT: Rgba = (0.0, 0.0, 0.0, 0.25); +const SHEET_SHADOW: Rgba = (0.0, 0.0, 0.0, 0.35); +const HEADER_RULE: Rgba = (1.0, 1.0, 1.0, 0.08); +const FIELD_BG: Rgba = (0.10, 0.10, 0.12, 1.0); +const FIELD_BORDER: Rgba = (1.0, 1.0, 1.0, 0.18); +const CHIP_EDGE: Rgba = (1.0, 1.0, 1.0, 0.25); + +pub(super) fn render( + engine: &UiTextEngine, + ctx: &cairo::Context, + input: &InputState, + screen_width: u32, + screen_height: u32, +) { + let Some(edit) = input.board_appearance_edit() else { + return; + }; + let (Some((x, y, width)), Some(header)) = ( + input.board_appearance_rect(), + input.board_appearance_header(), + ) else { + return; + }; + let style = UiTextStyle { + family: "Sans", + slant: cairo::FontSlant::Normal, + weight: cairo::FontWeight::Normal, + size: 12.0, + }; + let _ = ctx.save(); + draw_backdrop(ctx, input, screen_width, screen_height); + draw_frame(ctx, x, y, width); + + let board_name = input + .boards + .board_states() + .iter() + .find(|board| board.spec.id == edit.board_id()) + .map_or("", |board| board.spec.name.as_str()); + let _ = ctx.save(); + ctx.rectangle(x, y - 66.0, (header.title_right - x).max(0.0), 32.0); + ctx.clip(); + constants::set_color(ctx, TEXT_PRIMARY); + engine.draw_baseline( + ctx, + UiTextStyle { + weight: cairo::FontWeight::Bold, + ..style + }, + &format!("Paper — {board_name}"), + x + 4.0, + y - 45.0, + None, + ); + let _ = ctx.restore(); + + let (field_x, field_y, field_width, field_height) = header.color_field; + let color_focused = edit.focus == AppearanceField::Color; + draw_rounded_rect(ctx, field_x, field_y, field_width, field_height, 4.0); + constants::set_color(ctx, FIELD_BG); + let _ = ctx.fill_preserve(); + constants::set_color( + ctx, + if color_focused { + INPUT_CARET + } else { + FIELD_BORDER + }, + ); + ctx.set_line_width(if color_focused { 1.5 } else { 1.0 }); + let _ = ctx.stroke(); + let (draft_color, _) = edit.preview(); + ctx.set_source_rgb(draft_color.r, draft_color.g, draft_color.b); + draw_rounded_rect(ctx, field_x + 6.0, field_y + 6.0, 10.0, 10.0, 2.0); + let _ = ctx.fill_preserve(); + constants::set_color(ctx, CHIP_EDGE); + ctx.set_line_width(1.0); + let _ = ctx.stroke(); + let _ = ctx.save(); + ctx.rectangle(field_x, field_y, field_width - 4.0, field_height); + ctx.clip(); + constants::set_color(ctx, TEXT_PRIMARY); + engine.draw_baseline( + ctx, + UiTextStyle { + size: 11.0, + ..style + }, + &edit.color, + field_x + 22.0, + field_y + 15.0, + None, + ); + let _ = ctx.restore(); + + let (close_x, close_y, close_width, close_height) = header.close; + let (center_x, center_y) = (close_x + close_width / 2.0, close_y + close_height / 2.0); + constants::set_color(ctx, TEXT_HINT); + ctx.set_line_width(1.5); + ctx.move_to(center_x - 4.5, center_y - 4.5); + ctx.line_to(center_x + 4.5, center_y + 4.5); + ctx.move_to(center_x + 4.5, center_y - 4.5); + ctx.line_to(center_x - 4.5, center_y + 4.5); + let _ = ctx.stroke(); + + constants::set_color(ctx, HEADER_RULE); + ctx.set_line_width(1.0); + ctx.move_to(x - 12.0, y - 33.5); + ctx.line_to(x + width + 12.0, y - 33.5); + let _ = ctx.stroke(); + + for (index, color) in super::helpers::BOARD_PALETTE.iter().enumerate() { + ctx.set_source_rgb(color.r, color.g, color.b); + ctx.rectangle( + x + index as f64 * width / 11.0 + 1.0, + y - 28.0, + width / 11.0 - 3.0, + 21.0, + ); + let _ = ctx.fill(); + } + for (index, kind) in BoardGridKind::ALL.iter().enumerate() { + let left = x + (index % 2) as f64 * width / 2.0; + let top = y + (index / 2) as f64 * 28.0; + constants::set_color( + ctx, + if edit.kind == *kind { + INPUT_CARET + } else { + TEXT_HINT + }, + ); + ctx.rectangle(left + 1.0, top + 1.0, width / 2.0 - 5.0, 24.0); + let _ = ctx.stroke(); + engine.draw_baseline(ctx, style, kind.label(), left + 6.0, top + 17.0, None); + } + constants::set_color( + ctx, + if edit.focus == AppearanceField::Spacing { + INPUT_CARET + } else { + TEXT_PRIMARY + }, + ); + let spacing_label = match edit.kind { + BoardGridKind::None => "Spacing", + BoardGridKind::Cartesian => "Square side", + BoardGridKind::Isometric | BoardGridKind::IsometricDots => "Triangle side", + }; + engine.draw_baseline( + ctx, + style, + &format!("{spacing_label}: {} px", edit.spacing), + x + 4.0, + y + 79.0, + None, + ); + for (offset, preset) in [(80.0, 20), (40.0, 40)] { + // Selection follows the size value; field focus only colors the label. + constants::set_color( + ctx, + if edit.spacing_matches(preset) { + INPUT_CARET + } else { + TEXT_HINT + }, + ); + ctx.rectangle(x + width - offset, y + 60.0, 36.0, 25.0); + let _ = ctx.stroke(); + engine.draw_baseline( + ctx, + style, + &preset.to_string(), + x + width - offset + 9.0, + y + 78.0, + None, + ); + } + let _ = ctx.save(); + ctx.rectangle(x, y + 94.0, width, 56.0); + ctx.clip(); + ctx.translate(x, y + 94.0); + let (color, grid) = edit.preview(); + if let Ok(paper) = crate::draw::BoardPaper::for_context(color, grid, ctx) { + let _ = paper.paint(ctx); + } + let _ = ctx.restore(); + for (offset, label) in [(0.0, "Apply"), (width / 2.0, "Cancel")] { + constants::set_color( + ctx, + if label == "Apply" && edit.validation_error().is_some() { + TEXT_HINT + } else { + TEXT_PRIMARY + }, + ); + ctx.rectangle(x + offset + 1.0, y + 160.0, width / 2.0 - 5.0, 26.0); + let _ = ctx.stroke(); + engine.draw_baseline(ctx, style, label, x + offset + 8.0, y + 178.0, None); + } + constants::set_color(ctx, TEXT_HINT); + let message = edit + .error + .as_deref() + .or_else(|| edit.validation_error()) + .unwrap_or("Session only • Tab: field • arrows: pattern • Enter: Apply"); + engine.draw_baseline( + ctx, + UiTextStyle { + size: 10.0, + ..style + }, + message, + x, + y + 202.0, + Some(width), + ); + let _ = ctx.restore(); +} + +/// Dims everything behind the sheet so it reads as a dialog above the picker. +/// The picker's own swatches stay bright because they still edit the draft. +fn draw_backdrop(ctx: &cairo::Context, input: &InputState, screen_width: u32, screen_height: u32) { + ctx.new_path(); + ctx.rectangle(0.0, 0.0, f64::from(screen_width), f64::from(screen_height)); + if let Some(layout) = input.board_picker_layout() + && layout.palette_rows > 0 + && layout.palette_cols > 0 + { + let unit = PALETTE_SWATCH_SIZE + PALETTE_SWATCH_GAP; + let margin = 5.0; + rounded_sub_path( + ctx, + layout.origin_x + layout.padding_x - margin, + layout.palette_top - margin, + layout.palette_cols as f64 * unit - PALETTE_SWATCH_GAP + margin * 2.0, + layout.palette_rows as f64 * unit - PALETTE_SWATCH_GAP + margin * 2.0, + 6.0, + ); + } + ctx.set_fill_rule(cairo::FillRule::EvenOdd); + ctx.set_source_rgba(0.0, 0.0, 0.0, OVERLAY_DIM_MEDIUM); + let _ = ctx.fill(); + ctx.set_fill_rule(cairo::FillRule::Winding); +} + +/// Adds a rounded rectangle to the current path without clearing it. +fn rounded_sub_path(ctx: &cairo::Context, x: f64, y: f64, width: f64, height: f64, radius: f64) { + use std::f64::consts::{FRAC_PI_2, PI}; + + let radius = radius.min(width / 2.0).min(height / 2.0); + ctx.new_sub_path(); + ctx.arc(x + width - radius, y + radius, radius, -FRAC_PI_2, 0.0); + ctx.arc( + x + width - radius, + y + height - radius, + radius, + 0.0, + FRAC_PI_2, + ); + ctx.arc(x + radius, y + height - radius, radius, FRAC_PI_2, PI); + ctx.arc(x + radius, y + radius, radius, PI, PI + FRAC_PI_2); + ctx.close_path(); +} + +fn draw_frame(ctx: &cairo::Context, x: f64, y: f64, width: f64) { + let (left, top, frame_width, frame_height) = (x - 12.0, y - 70.0, width + 24.0, 292.0); + for (offset, shadow) in [(10.0, SHEET_SHADOW_SOFT), (4.0, SHEET_SHADOW)] { + constants::set_color(ctx, shadow); + draw_rounded_rect( + ctx, + left, + top + offset, + frame_width, + frame_height, + SHEET_RADIUS, + ); + let _ = ctx.fill(); + } + + draw_rounded_rect(ctx, left, top, frame_width, frame_height, SHEET_RADIUS); + constants::set_color(ctx, constants::with_alpha(popup::bg_modal(), 1.0)); + let _ = ctx.fill_preserve(); + constants::set_color(ctx, popup::border_modal()); + ctx.set_line_width(1.0); + let _ = ctx.stroke(); +} diff --git a/src/ui/board_picker/page_panel.rs b/src/ui/board_picker/page_panel.rs index ebaf7d79f..3b4f0143a 100644 --- a/src/ui/board_picker/page_panel.rs +++ b/src/ui/board_picker/page_panel.rs @@ -151,6 +151,7 @@ pub(super) fn render_page_panel( render, frame: page, background: &board.spec.background, + grid: board.spec.grid, x: thumb_x, y: thumb_y, width: layout.page_thumb_width, @@ -191,6 +192,7 @@ pub(super) fn render_page_panel( render, frame: page, background: &board.spec.background, + grid: board.spec.grid, thumb_x, thumb_y, thumb_w: layout.page_thumb_width, diff --git a/src/ui/board_picker/page_panel/thumbnail/cache.rs b/src/ui/board_picker/page_panel/thumbnail/cache.rs index abf657ea7..1ac4bd217 100644 --- a/src/ui/board_picker/page_panel/thumbnail/cache.rs +++ b/src/ui/board_picker/page_panel/thumbnail/cache.rs @@ -16,6 +16,7 @@ struct ThumbnailKey { content: u64, view: (i32, i32), background: Option<[u64; 3]>, + grid: crate::domain::BoardGrid, backdrop: Vec, geometry: [u64; 4], matrix: [u64; 6], @@ -182,6 +183,7 @@ fn try_render_cached( let key = ThumbnailKey { content: args.frame.content_revision(), view: args.frame.view_offset(), + grid: args.grid, background: match args.background { BoardBackground::Solid(color) => { Some([color.r.to_bits(), color.g.to_bits(), color.b.to_bits()]) @@ -258,6 +260,7 @@ fn try_render_cached( render: &mut RenderCtx::new(&target, args.render.caches), frame: args.frame, background: args.background, + grid: args.grid, x: args.x, y: args.y, width: args.width, diff --git a/src/ui/board_picker/page_panel/thumbnail/cache/spotlight_tests.rs b/src/ui/board_picker/page_panel/thumbnail/cache/spotlight_tests.rs index 974d6bc5b..0e8454e04 100644 --- a/src/ui/board_picker/page_panel/thumbnail/cache/spotlight_tests.rs +++ b/src/ui/board_picker/page_panel/thumbnail/cache/spotlight_tests.rs @@ -39,6 +39,7 @@ fn pixels( render: &mut render, frame, background, + grid: Default::default(), x: 35.5, y: 27.25, width: 128.0, diff --git a/src/ui/board_picker/page_panel/thumbnail/cache/tests.rs b/src/ui/board_picker/page_panel/thumbnail/cache/tests.rs index e2b75e0f7..7db5ed052 100644 --- a/src/ui/board_picker/page_panel/thumbnail/cache/tests.rs +++ b/src/ui/board_picker/page_panel/thumbnail/cache/tests.rs @@ -56,6 +56,7 @@ fn pixels( render: &mut render, frame, background, + grid: Default::default(), x: 8.25, y: 10.5, width: 128.0, diff --git a/src/ui/board_picker/page_panel/thumbnail/cards.rs b/src/ui/board_picker/page_panel/thumbnail/cards.rs index 795f33d46..00c5e0806 100644 --- a/src/ui/board_picker/page_panel/thumbnail/cards.rs +++ b/src/ui/board_picker/page_panel/thumbnail/cards.rs @@ -33,6 +33,7 @@ pub(in crate::ui::board_picker::page_panel) fn render_page_thumbnail( render, frame, background, + grid, x, y, width, @@ -72,6 +73,7 @@ pub(in crate::ui::board_picker::page_panel) fn render_page_thumbnail( render, frame, background, + grid, x, y, width, @@ -317,6 +319,7 @@ pub(in crate::ui::board_picker::page_panel) fn render_page_preview( render, frame, background, + grid, thumb_x, thumb_y, thumb_w, @@ -368,6 +371,7 @@ pub(in crate::ui::board_picker::page_panel) fn render_page_preview( render, frame, background, + grid, x: preview_x, y: preview_y, width: preview_w, diff --git a/src/ui/board_picker/page_panel/thumbnail/content.rs b/src/ui/board_picker/page_panel/thumbnail/content.rs index 8fd817567..685da1650 100644 --- a/src/ui/board_picker/page_panel/thumbnail/content.rs +++ b/src/ui/board_picker/page_panel/thumbnail/content.rs @@ -33,6 +33,7 @@ pub(super) fn render_page_content( render, frame, background, + grid, x, y, width, @@ -83,6 +84,7 @@ pub(super) fn render_page_content( render, frame, background, + grid, screen_width, screen_height, text_halo_enabled, @@ -98,13 +100,29 @@ fn render_frame_shapes( render: &mut crate::draw::RenderCtx<'_, '_>, frame: &crate::draw::Frame, background: &BoardBackground, + grid: crate::domain::BoardGrid, target_width: u32, target_height: u32, text_halo_enabled: bool, ) { let ctx = render.cairo; + let (view_x, view_y) = if background.is_transparent() { + (0, 0) + } else { + frame.view_offset() + }; + ctx.translate(-f64::from(view_x), -f64::from(view_y)); + let paper = match background { + BoardBackground::Solid(color) => { + crate::draw::BoardPaper::for_context(*color, grid, ctx).ok() + } + BoardBackground::Transparent => None, + }; + if let Some(paper) = &paper { + let _ = paper.paint(ctx); + } let eraser_ctx = EraserReplayContext { - pattern: None, + pattern: paper.as_ref().map(crate::draw::BoardPaper::pattern), surface: None, backdrop_cache_key: None, bg_color: match background { @@ -310,6 +328,7 @@ mod tests { ), frame: &frame, background, + grid: Default::default(), x: 0.0, y: 0.0, width: 120.0, @@ -351,6 +370,7 @@ mod tests { ), frame: &frame, background: &BoardBackground::Solid(Color::new(1.0, 1.0, 1.0, 1.0)), + grid: Default::default(), x: 0.0, y: 0.0, width: 120.0, @@ -451,6 +471,7 @@ mod tests { render: &mut RenderCtx::new(&ctx, caches), frame: &frame, background: &BoardBackground::Solid(crate::draw::WHITE), + grid: Default::default(), x: 0.0, y: 0.0, width: 124.0, @@ -560,6 +581,7 @@ mod measurement { render: &mut render, frame: &frame, background: &BoardBackground::Transparent, + grid: Default::default(), x: 0.0, y: 0.0, width: 240.0, diff --git a/src/ui/board_picker/page_panel/thumbnail/types.rs b/src/ui/board_picker/page_panel/thumbnail/types.rs index 01622574e..d2c28ca34 100644 --- a/src/ui/board_picker/page_panel/thumbnail/types.rs +++ b/src/ui/board_picker/page_panel/thumbnail/types.rs @@ -6,6 +6,7 @@ pub(in crate::ui::board_picker::page_panel) struct PageThumbnailArgs<'a, 'c, 'r> pub(in crate::ui::board_picker::page_panel) render: &'a mut crate::draw::RenderCtx<'c, 'r>, pub(in crate::ui::board_picker::page_panel) frame: &'a crate::draw::Frame, pub(in crate::ui::board_picker::page_panel) background: &'a BoardBackground, + pub(in crate::ui::board_picker::page_panel) grid: crate::domain::BoardGrid, pub(in crate::ui::board_picker::page_panel) x: f64, pub(in crate::ui::board_picker::page_panel) y: f64, pub(in crate::ui::board_picker::page_panel) width: f64, @@ -29,6 +30,7 @@ pub(in crate::ui::board_picker::page_panel) struct PagePreviewArgs<'a, 'c, 'r> { pub(in crate::ui::board_picker::page_panel) render: &'a mut crate::draw::RenderCtx<'c, 'r>, pub(in crate::ui::board_picker::page_panel) frame: &'a crate::draw::Frame, pub(in crate::ui::board_picker::page_panel) background: &'a BoardBackground, + pub(in crate::ui::board_picker::page_panel) grid: crate::domain::BoardGrid, pub(in crate::ui::board_picker::page_panel) thumb_x: f64, pub(in crate::ui::board_picker::page_panel) thumb_y: f64, pub(in crate::ui::board_picker::page_panel) thumb_w: f64, @@ -43,6 +45,7 @@ pub(in crate::ui::board_picker::page_panel) struct PageContentArgs<'a, 'c, 'r> { pub(in crate::ui::board_picker::page_panel) render: &'a mut crate::draw::RenderCtx<'c, 'r>, pub(in crate::ui::board_picker::page_panel) frame: &'a crate::draw::Frame, pub(in crate::ui::board_picker::page_panel) background: &'a BoardBackground, + pub(in crate::ui::board_picker::page_panel) grid: crate::domain::BoardGrid, pub(in crate::ui::board_picker::page_panel) x: f64, pub(in crate::ui::board_picker::page_panel) y: f64, pub(in crate::ui::board_picker::page_panel) width: f64, diff --git a/src/ui/board_picker/palette.rs b/src/ui/board_picker/palette.rs index 5d7676aa8..4c94cb6e7 100644 --- a/src/ui/board_picker/palette.rs +++ b/src/ui/board_picker/palette.rs @@ -4,8 +4,8 @@ use crate::ui::primitives::draw_rounded_rect; use super::constants::{self, INPUT_CARET, RADIUS_SM}; use super::helpers::{BOARD_PALETTE, SWATCH_EDGE}; -const PALETTE_SWATCH_SIZE: f64 = 18.0; -const PALETTE_SWATCH_GAP: f64 = 6.0; +pub(super) const PALETTE_SWATCH_SIZE: f64 = 18.0; +pub(super) const PALETTE_SWATCH_GAP: f64 = 6.0; pub(super) fn render_board_palette( ctx: &cairo::Context, @@ -19,12 +19,19 @@ pub(super) fn render_board_palette( let palette_x = layout.origin_x + layout.padding_x; let palette_y = layout.palette_top; let edit_state = input_state.board_picker_edit_state(); - let active_color = edit_state - .and_then(|(_, edit_index, _)| input_state.board_picker_board_index_for_row(edit_index)) - .and_then(|board_index| input_state.boards.board_states().get(board_index)) - .and_then(|board| match board.spec.background { - BoardBackground::Solid(color) => Some(color), - BoardBackground::Transparent => None, + let active_color = input_state + .board_appearance_edit() + .map(|edit| edit.preview().0) + .or_else(|| { + edit_state + .and_then(|(_, edit_index, _)| { + input_state.board_picker_board_index_for_row(edit_index) + }) + .and_then(|board_index| input_state.boards.board_states().get(board_index)) + .and_then(|board| match board.spec.background { + BoardBackground::Solid(color) => Some(color), + BoardBackground::Transparent => None, + }) }); let mut idx = 0usize; diff --git a/src/ui/board_picker/tests.rs b/src/ui/board_picker/tests.rs index 729836993..0aa9023b4 100644 --- a/src/ui/board_picker/tests.rs +++ b/src/ui/board_picker/tests.rs @@ -34,6 +34,7 @@ fn pixels( #[test] #[ignore = "isolated by tools/lint-and-test.sh; Cairo race: https://gitlab.freedesktop.org/cairo/cairo/-/merge_requests/81"] fn retained_board_text_owner_matches_fresh_during_unicode_rename_and_small_layouts() { + check_appearance_sheet_on_small_surfaces(); let engine = UiTextEngine::default(); let measurer = crate::draw::TextMeasurer::default(); let mut caches = crate::draw::RenderCaches::default(); @@ -95,3 +96,46 @@ fn retained_board_text_owner_matches_fresh_during_unicode_rename_and_small_layou ); } } + +fn check_appearance_sheet_on_small_surfaces() { + let engine = UiTextEngine::default(); + let measurer = crate::draw::TextMeasurer::default(); + let mut state = crate::input::state::test_support::make_test_input_state(); + state.switch_board_force("whiteboard"); + state.open_board_picker_with_measurer(&measurer); + state.board_picker_edit_color_selected_with_measurer(&measurer); + state.board_appearance_key(crate::input::events::Key::Tab); + state.board_appearance_key(crate::input::events::Key::Right); + state.board_appearance_key(crate::input::events::Key::Right); + for (width, height) in [(900, 700), (420, 300)] { + let surface = cairo::ImageSurface::create(cairo::Format::ARgb32, width, height).unwrap(); + let ctx = cairo::Context::new(&surface).unwrap(); + state.update_board_picker_layout(&ctx, width as u32, height as u32); + let (x, y, w) = state.board_appearance_rect().unwrap(); + assert!(x >= 12.0 && x + w + 12.0 <= f64::from(width)); + assert!(y >= 70.0 && y + 222.0 <= f64::from(height)); + let data = pixels( + &engine, + &measurer, + &mut crate::draw::RenderCaches::default(), + &state, + (width, height), + 1, + ); + if let Ok(folder) = std::env::var("WAYSCRIBER_GRID_UI_ARTIFACTS") { + let surface = cairo::ImageSurface::create_for_data( + data, + cairo::Format::ARgb32, + width, + height, + width * 4, + ) + .unwrap(); + let mut file = std::fs::File::create( + std::path::Path::new(&folder).join(format!("paper-editor-{width}x{height}.png")), + ) + .unwrap(); + surface.write_to_png(&mut file).unwrap(); + } + } +} diff --git a/tests/cli.rs b/tests/cli.rs index 0df9efbd3..524ecbc66 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -294,6 +294,7 @@ fn saved_line_snapshot(with_tool_state: bool) -> wayscriber::session::SessionSna wayscriber::session::SessionSnapshot { active_board_id: "transparent".to_string(), boards: vec![wayscriber::session::BoardSnapshot { + appearance: None, id: "transparent".to_string(), pages: wayscriber::session::BoardPagesSnapshot { pages: vec![frame], @@ -865,6 +866,7 @@ fn session_info_reports_saved_snapshot() { let snapshot = wayscriber::session::SessionSnapshot { active_board_id: "transparent".to_string(), boards: vec![wayscriber::session::BoardSnapshot { + appearance: None, id: "transparent".to_string(), pages: wayscriber::session::BoardPagesSnapshot { pages: vec![frame], From 719a0c886542e9b8dd40189114a922023178fa6a Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Sat, 12 Sep 2026 22:19:04 +0200 Subject: [PATCH 2/6] docs: clarify Rust readability and whitespace checks --- AGENTS.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index cc07a872a..dc5ef83a5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -21,6 +21,15 @@ - Preserve existing Rust module style. This repo intentionally mixes `foo.rs`, `foo/`, and `foo/mod.rs`. - For Rust split modules, an `AGENTS.md` in `foo/` does not apply to sibling `foo.rs`; put shared rules in the parent guide. +## Code Readability +- Let `rustfmt` own mechanical Rust layout. Use standard Rust spacing: `call(value)`, `name: Type`, `T: Trait`, and `left + right`; omit spaces inside parentheses and around `::`, `.`, `?`, and ranges, and omit unnecessary control-flow parentheses. +- Separate logical steps within functions with one blank line: validation and early exits, setup, execution or mutation, state and effect publication, and returning or mapping the result. Keep closely related statements together; add spacing when the purpose changes rather than after every statement. +- Keep a `let ... else`, `match`, or parsed value with its immediate validation and error path. Use `?` for ordinary error propagation instead of expanding it solely to imitate another language's guard style. +- Let `rustfmt` decide wrapping, same-line braces, `} else {`, indentation, and multiline collection or argument layout. Do not use formatter-skip attributes to force a competing layout. +- Apply this grouping consistently to production Rust and tests. Preserve intentional blank lines while editing and formatting. +- Review changed functions after `cargo fmt`. Formatter success verifies mechanical layout; also verify visually that logical chunks are clearly separated and easy to scan. +- Apply these rules to new and materially changed code. Do not reformat unrelated existing code solely to adopt them. + ## Coupled Changes - Config changes often require `config.example.toml`, `docs/CONFIG.md`, configurator models/views, schema behavior, and tests. - Tool/action/keybinding changes often require config defaults, action metadata, help/command UI, toolbar UI, configurator labels/search, docs, and tests. @@ -29,6 +38,7 @@ ## Validation - Full local CI is `./tools/lint-and-test.sh`. - That script runs version/package checks, `cargo fmt --all -- --check`, clippy with all targets/features, all-feature tests, and no-default-feature tests. +- Run `git diff --check` before handoff. When relevant files are untracked and the index must stay unchanged, check those files directly as well. - For docs-only `AGENTS.md` edits, make new files visible to Git before whitespace checks, for example `rg --files --hidden -g AGENTS.md -0 | xargs -0 git add -N --` followed by `git diff --check`. - On PowerShell, use `rg --files --hidden -g AGENTS.md | ForEach-Object { git add -N -- $_ }` followed by `git diff --check`. - If you do not want to alter the index, run an explicit trailing-whitespace check across the untracked `AGENTS.md` files instead of relying on plain `git diff --check`. From fe3732eebbb17b2f1efdd844ef33aa8fa460507b Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Sun, 13 Sep 2026 13:08:49 +0200 Subject: [PATCH 3/6] feat(boards): improve paper editing and picker menus Add a scalable paper editor with a size slider, typed spacing, wheel controls, and a configurable Edit Board Paper action. Expose paper, rename, and pin actions through board menus with matching rendering, cursor, hover, and click priority. Consume the release after outside menu dismissal so the picker stays open and underlying controls remain untouched. Cover board and page menu dismissal and subsequent clicks through the real pointer router. Validation: tools/lint-and-test.sh passed, including strict Clippy, all-feature and no-default-feature workspace tests, and isolated rendering checks. The dismissal regression failed before the fix and passes with it. --- config.example.toml | 2 + .../src/models/keybindings/field/list.rs | 1 + docs/CONFIG.md | 9 +- src/backend/wayland/handlers/pointer/axis.rs | 19 + .../wayland/handlers/pointer/cursor.rs | 9 +- src/backend/wayland/state/render/ui.rs | 5 +- src/config/action_meta/entries/board.rs | 20 + src/config/action_meta/tests.rs | 1 + src/config/keybindings/config/map/board.rs | 1 + src/config/keybindings/config/map/edit.rs | 1 + .../config/types/bindings/board.rs | 4 + src/config/keybindings/defaults/board.rs | 4 + src/config/keybindings/tests.rs | 1 + src/configurator_destination.rs | 1 + src/domain/action.rs | 1 + src/domain/tests.rs | 1 + src/input/state/actions/action_board_pages.rs | 5 + .../state/core/board_picker/appearance.rs | 475 +++++++++++++++--- .../core/board_picker/appearance/tests.rs | 234 +++++++-- .../state/core/board_picker/layout/compute.rs | 4 + .../layout/compute/layout_geometry.rs | 2 + src/input/state/core/board_picker/mod.rs | 3 + .../state/core/board_picker/state/edit.rs | 16 + src/input/state/core/menus/commands.rs | 50 ++ src/input/state/core/menus/context_menu.rs | 24 + src/input/state/core/menus/entries/boards.rs | 51 ++ src/input/state/core/menus/entries/mod.rs | 1 + src/input/state/core/menus/lifecycle.rs | 23 + src/input/state/core/menus/types.rs | 6 + src/input/state/interaction/actions.rs | 1 + .../state/interaction/adapters/pointer.rs | 18 + src/input/state/interaction/mod.rs | 184 +++++++ src/input/state/interaction/pointer.rs | 12 + src/input/state/mouse/press/panels.rs | 17 +- src/input/state/tests/menus/context_menu.rs | 106 ++++ src/ui/board_picker/appearance.rs | 321 +++++++++--- src/ui/board_picker/tests.rs | 12 +- 37 files changed, 1445 insertions(+), 200 deletions(-) diff --git a/config.example.toml b/config.example.toml index f9a08b9be..b8782dce5 100644 --- a/config.example.toml +++ b/config.example.toml @@ -179,6 +179,8 @@ board_new = ["Ctrl+Shift+N"] board_duplicate = ["Ctrl+Shift+D"] board_delete = ["Ctrl+Shift+Delete"] board_picker = ["Ctrl+Shift+B"] +# Edit the active board's paper (unbound by default; also in the command palette) +board_paper_edit = [] # Page navigation # Ubuntu/GNOME defaults avoid Ctrl+Alt workspace shortcuts (Ctrl+ArrowLeft/Right, Ctrl+PageUp/PageDown). diff --git a/configurator/src/models/keybindings/field/list.rs b/configurator/src/models/keybindings/field/list.rs index b51b27ee7..5a18491d7 100644 --- a/configurator/src/models/keybindings/field/list.rs +++ b/configurator/src/models/keybindings/field/list.rs @@ -86,6 +86,7 @@ const PREFERRED_ORDER: &[KeybindingField] = &[ KeybindingField::BoardDuplicate, KeybindingField::BoardDelete, KeybindingField::BoardPicker, + KeybindingField::BoardPaperEdit, KeybindingField::ToggleHelp, KeybindingField::ToggleQuickHelp, KeybindingField::ToggleStatusBar, diff --git a/docs/CONFIG.md b/docs/CONFIG.md index 836699498..46dcc64bf 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -1502,6 +1502,10 @@ default_pen_color = { rgb = [0.969, 0.890, 0.784] } **Board Picker:** - Modal list for switching, renaming, and recoloring boards. +- Right-click a board for **Edit Paper…**, **Rename Board**, and **Pin Board**. The canvas + context menu's **Boards** submenu also has **Edit Board Paper…** for the active board. +- The command palette's **Edit Board Paper** opens the same editor for the active board. It + is unbound by default; bind `board_paper_edit` to give it a shortcut. - Inline edits apply to the active session, not to the templates in `config.toml`. Edit the templates in the configurator's Boards screen. @@ -1532,7 +1536,8 @@ values are clamped with a warning; invalid text or unknown kinds fail validation Transparent boards disable the pattern while retaining its spacing. Open the board picker (`Ctrl+Shift+B`), select a solid board, and click its color -swatch or press `Ctrl+C`. Choose a pattern and spacing, or use the 20/40 presets. +swatch or press `Ctrl+C`, or run **Edit Board Paper** from the command palette. +Choose a pattern, then drag the size slider or type a size. The preview is local to the editor; **Apply** changes the board and **Cancel** or Escape discards the draft. Tab cycles color, pattern, and spacing; arrow keys change the focused pattern and Enter applies. Switching to another board or @@ -2207,6 +2212,8 @@ board_new = ["Ctrl+Shift+N"] board_duplicate = ["Ctrl+Shift+D"] board_delete = ["Ctrl+Shift+Delete"] board_picker = ["Ctrl+Shift+B"] +# Edit the active board's paper (unbound by default; also in the command palette) +board_paper_edit = [] # Page navigation # Ubuntu/GNOME defaults avoid Ctrl+Alt workspace shortcuts (Ctrl+ArrowLeft/Right, Ctrl+PageUp/PageDown). diff --git a/src/backend/wayland/handlers/pointer/axis.rs b/src/backend/wayland/handlers/pointer/axis.rs index 70c5fe052..95a422a8a 100644 --- a/src/backend/wayland/handlers/pointer/axis.rs +++ b/src/backend/wayland/handlers/pointer/axis.rs @@ -180,6 +180,10 @@ impl WaylandState { if self.try_handle_help_axis(scroll_direction) { return; } + if try_handle_board_appearance_axis(&mut self.input_state, event.position, scroll_direction) + { + return; + } if try_handle_board_picker_page_panel_axis( &mut self.input_state, event.position, @@ -330,6 +334,21 @@ impl WaylandState { } } +/// The paper sheet steps its size with the wheel and keeps the page panel +/// behind it from scrolling. +fn try_handle_board_appearance_axis( + input_state: &mut InputState, + position: (f64, f64), + scroll_direction: i32, +) -> bool { + if !input_state.is_board_picker_open() || input_state.is_context_menu_open() { + return false; + } + let x = position.0.round() as i32; + let y = position.1.round() as i32; + input_state.board_appearance_wheel(x, y, scroll_direction) +} + fn try_handle_board_picker_page_panel_axis( input_state: &mut InputState, position: (f64, f64), diff --git a/src/backend/wayland/handlers/pointer/cursor.rs b/src/backend/wayland/handlers/pointer/cursor.rs index 99cf22fb2..193734d01 100644 --- a/src/backend/wayland/handlers/pointer/cursor.rs +++ b/src/backend/wayland/handlers/pointer/cursor.rs @@ -269,13 +269,14 @@ impl WaylandState { .icon(), ); } - if self.input_state.is_board_picker_open() - && let Some(hint) = self.input_state.board_picker_cursor_hint_at(mx, my) + // Board and page menus open on top of the board picker. + if self.input_state.is_context_menu_open() + && let Some(hint) = self.input_state.context_menu_cursor_hint_at(mx, my) { return Some(hint.icon()); } - if self.input_state.is_context_menu_open() - && let Some(hint) = self.input_state.context_menu_cursor_hint_at(mx, my) + if self.input_state.is_board_picker_open() + && let Some(hint) = self.input_state.board_picker_cursor_hint_at(mx, my) { return Some(hint.icon()); } diff --git a/src/backend/wayland/state/render/ui.rs b/src/backend/wayland/state/render/ui.rs index 1c4a148cf..0b20e8ca1 100644 --- a/src/backend/wayland/state/render/ui.rs +++ b/src/backend/wayland/state/render/ui.rs @@ -357,7 +357,10 @@ impl WaylandState { height: u32, capture_picker: bool, ) { - if capture_picker || self.zoom.active || self.input_state.is_board_picker_open() { + // Board and page menus open on top of the board picker, so the picker + // must not hide this pass. It never shares the screen with the + // properties panel. + if capture_picker || self.zoom.active { self.input_state.clear_context_menu_layout(); self.input_state.clear_properties_panel_layout(); return; diff --git a/src/config/action_meta/entries/board.rs b/src/config/action_meta/entries/board.rs index f8daaea33..616b815fb 100644 --- a/src/config/action_meta/entries/board.rs +++ b/src/config/action_meta/entries/board.rs @@ -297,4 +297,24 @@ pub const ENTRIES: &[ActionMeta] = &[ true, false ), + meta!( + BoardPaperEdit, + "Edit Board Paper", + Some("Paper"), + "Choose the active board's paper color, pattern, and size", + Board, + true, + false, + false, + &[ + "grid", + "graph paper", + "cartesian", + "isometric", + "dots", + "pattern", + "background", + "board color", + ] + ), ]; diff --git a/src/config/action_meta/tests.rs b/src/config/action_meta/tests.rs index c0ad5ea86..3c2751c18 100644 --- a/src/config/action_meta/tests.rs +++ b/src/config/action_meta/tests.rs @@ -217,6 +217,7 @@ const EXPECTED_COMMAND_PALETTE_ACTIONS: &[Action] = &[ Action::BoardNew, Action::BoardDelete, Action::BoardPicker, + Action::BoardPaperEdit, Action::BoardRestoreDeleted, Action::BoardDuplicate, Action::BoardSwitchRecent, diff --git a/src/config/keybindings/config/map/board.rs b/src/config/keybindings/config/map/board.rs index c5501c912..0f3fd5135 100644 --- a/src/config/keybindings/config/map/board.rs +++ b/src/config/keybindings/config/map/board.rs @@ -35,6 +35,7 @@ impl KeybindingsConfig { inserter.insert_all(&self.board.board_duplicate, Action::BoardDuplicate)?; inserter.insert_all(&self.board.board_delete, Action::BoardDelete)?; inserter.insert_all(&self.board.board_picker, Action::BoardPicker)?; + inserter.insert_all(&self.board.board_paper_edit, Action::BoardPaperEdit)?; Ok(()) } } diff --git a/src/config/keybindings/config/map/edit.rs b/src/config/keybindings/config/map/edit.rs index 3b9b95c31..e1b436607 100644 --- a/src/config/keybindings/config/map/edit.rs +++ b/src/config/keybindings/config/map/edit.rs @@ -203,6 +203,7 @@ define_action_binding_accessors! { BoardDelete => board.board_delete, BoardPicker => board.board_picker, BoardDuplicate => board.board_duplicate, + BoardPaperEdit => board.board_paper_edit, FocusNextOutput => board.focus_next_output, FocusPrevOutput => board.focus_prev_output, PagePrev => board.page_prev, diff --git a/src/config/keybindings/config/types/bindings/board.rs b/src/config/keybindings/config/types/bindings/board.rs index a336097e6..12a0d389d 100644 --- a/src/config/keybindings/config/types/bindings/board.rs +++ b/src/config/keybindings/config/types/bindings/board.rs @@ -79,6 +79,9 @@ pub struct BoardKeybindingsConfig { #[serde(default = "default_board_picker")] pub board_picker: Vec, + + #[serde(default = "default_board_paper_edit")] + pub board_paper_edit: Vec, } impl Default for BoardKeybindingsConfig { @@ -109,6 +112,7 @@ impl Default for BoardKeybindingsConfig { board_duplicate: default_board_duplicate(), board_delete: default_board_delete(), board_picker: default_board_picker(), + board_paper_edit: default_board_paper_edit(), } } } diff --git a/src/config/keybindings/defaults/board.rs b/src/config/keybindings/defaults/board.rs index b5d86db8b..d8841476d 100644 --- a/src/config/keybindings/defaults/board.rs +++ b/src/config/keybindings/defaults/board.rs @@ -128,3 +128,7 @@ pub(crate) fn default_board_delete() -> Vec { pub(crate) fn default_board_picker() -> Vec { vec!["Ctrl+Shift+B".to_string()] } + +pub(crate) fn default_board_paper_edit() -> Vec { + Vec::new() +} diff --git a/src/config/keybindings/tests.rs b/src/config/keybindings/tests.rs index ffb320b92..fa979ced1 100644 --- a/src/config/keybindings/tests.rs +++ b/src/config/keybindings/tests.rs @@ -783,6 +783,7 @@ const DEFAULT_BINDING_SNAPSHOT: &[(&str, &[&str])] = &[ ("board_delete", &["Ctrl+Shift+Delete"]), ("board_picker", &["Ctrl+Shift+B"]), ("board_duplicate", &["Ctrl+Shift+D"]), + ("board_paper_edit", &[]), ("focus_next_output", &["Ctrl+Alt+Shift+ArrowRight"]), ("focus_prev_output", &["Ctrl+Alt+Shift+ArrowLeft"]), ("page_prev", &["Ctrl+Alt+ArrowLeft", "Ctrl+Alt+PageUp"]), diff --git a/src/configurator_destination.rs b/src/configurator_destination.rs index a29543808..3c0f26651 100644 --- a/src/configurator_destination.rs +++ b/src/configurator_destination.rs @@ -179,6 +179,7 @@ pub fn keybindings_section_for_action(action: Action) -> Option KeybindingsSection::Boards, Action::ToggleHelp diff --git a/src/domain/action.rs b/src/domain/action.rs index 2d454e7d5..dcd139b43 100644 --- a/src/domain/action.rs +++ b/src/domain/action.rs @@ -102,6 +102,7 @@ pub enum Action { BoardRestoreDeleted, BoardDuplicate, BoardSwitchRecent, + BoardPaperEdit, FocusNextOutput, FocusPrevOutput, diff --git a/src/domain/tests.rs b/src/domain/tests.rs index 6fb0340eb..c09f8050b 100644 --- a/src/domain/tests.rs +++ b/src/domain/tests.rs @@ -134,6 +134,7 @@ fn action_serialization_matches_established_contract() { (Action::BoardRestoreDeleted, "board_restore_deleted"), (Action::BoardDuplicate, "board_duplicate"), (Action::BoardSwitchRecent, "board_switch_recent"), + (Action::BoardPaperEdit, "board_paper_edit"), (Action::FocusNextOutput, "focus_next_output"), (Action::FocusPrevOutput, "focus_prev_output"), (Action::PagePrev, "page_prev"), diff --git a/src/input/state/actions/action_board_pages.rs b/src/input/state/actions/action_board_pages.rs index d82c21a83..cc5eec4e3 100644 --- a/src/input/state/actions/action_board_pages.rs +++ b/src/input/state/actions/action_board_pages.rs @@ -153,6 +153,11 @@ impl InputState { self.switch_board_recent_with_measurer(measurer); true } + Action::BoardPaperEdit => { + let board_index = self.boards.active_index(); + self.board_picker_edit_board_paper_with_measurer(measurer, board_index); + true + } _ => false, } } diff --git a/src/input/state/core/board_picker/appearance.rs b/src/input/state/core/board_picker/appearance.rs index 73a9b9c7a..6f44412c8 100644 --- a/src/input/state/core/board_picker/appearance.rs +++ b/src/input/state/core/board_picker/appearance.rs @@ -1,5 +1,8 @@ use super::{color_to_hex, parse_hex_color}; -use crate::domain::{BoardBackground, BoardGrid, BoardGridKind, Color}; +use crate::domain::{ + BOARD_GRID_MAX_SPACING, BOARD_GRID_MIN_SPACING, BoardBackground, BoardGrid, BoardGridKind, + Color, +}; use crate::input::InputState; use crate::input::boards::{BoardAppearance, BoardIdentityGeneration}; use crate::input::events::Key; @@ -11,14 +14,81 @@ pub(crate) enum AppearanceField { Spacing, } +/// Content width and the frame around the content origin, in sheet units. +const SHEET_WIDTH: f64 = 320.0; +const SHEET_MIN_WIDTH: f64 = 140.0; const SHEET_PADDING: f64 = 12.0; -const SHEET_COLUMN_MARGIN: f64 = 8.0; -const SHEET_MIN_DOCKED_WIDTH: f64 = 240.0; +const SHEET_TOP: f64 = 70.0; +const SHEET_HEIGHT: f64 = 292.0; +const SHEET_SHADOW: f64 = 10.0; +/// The sheet is magnified up to this scale while it fits on the output. +const SHEET_MAX_SCALE: f64 = 2.0; +const SHEET_SCREEN_MARGIN: f64 = 24.0; const SHEET_CLOSE_SIZE: f64 = 24.0; const SHEET_COLOR_FIELD_WIDTH: f64 = 84.0; const SHEET_HEADER_GAP: f64 = 8.0; +const SIZE_ROW_TOP: f64 = 60.0; +const SIZE_ROW_HEIGHT: f64 = 25.0; +const SIZE_LABEL_WIDTH: f64 = 92.0; +const SIZE_FIELD_WIDTH: f64 = 64.0; +const SIZE_CONTROL_GAP: f64 = 10.0; +const SLIDER_MIN_WIDTH: f64 = 60.0; +const SLIDER_THUMB_RADIUS: f64 = 7.0; +const BUTTON_TOP: f64 = 158.0; +const BUTTON_HEIGHT: f64 = 30.0; +const BUTTON_GAP: f64 = 8.0; + +/// Where the paper sheet sits on the surface. Its controls are laid out in +/// sheet units from the content origin and magnified by `scale`. +#[derive(Debug, Clone, Copy, PartialEq)] +pub(crate) struct BoardAppearanceFrame { + /// Content origin on the surface. + pub(crate) x: f64, + pub(crate) y: f64, + /// Content width in sheet units. + pub(crate) width: f64, + pub(crate) scale: f64, +} + +impl BoardAppearanceFrame { + /// The frame around the content, in sheet units. + pub(crate) fn outline(self) -> (f64, f64, f64, f64) { + ( + -SHEET_PADDING, + -SHEET_TOP, + self.width + 2.0 * SHEET_PADDING, + SHEET_HEIGHT, + ) + } + + /// The frame on the surface, without its shadow. + pub(crate) fn bounds(self) -> (f64, f64, f64, f64) { + self.to_surface(self.outline()) + } + + pub(crate) fn to_surface( + self, + (x, y, width, height): (f64, f64, f64, f64), + ) -> (f64, f64, f64, f64) { + ( + self.x + x * self.scale, + self.y + y * self.scale, + width * self.scale, + height * self.scale, + ) + } + + /// A pointer position in sheet units. + fn to_sheet(self, x: i32, y: i32) -> (f64, f64) { + ( + (f64::from(x) - self.x) / self.scale, + (f64::from(y) - self.y) / self.scale, + ) + } +} -/// The paper sheet's header controls as `(x, y, width, height)` rectangles. +/// The paper sheet's header controls as `(x, y, width, height)` rectangles in +/// sheet units. #[derive(Debug, Clone, Copy)] pub(crate) struct BoardAppearanceHeader { /// The title is clipped before this x coordinate. @@ -27,10 +97,60 @@ pub(crate) struct BoardAppearanceHeader { pub(crate) close: (f64, f64, f64, f64), } +/// Size controls in sheet units, shared by painting and pointer input. +#[derive(Debug, Clone, Copy)] +pub(crate) struct BoardAppearanceSizeRow { + /// The label is clipped before this x coordinate. + pub(crate) label_right: f64, + /// Slider hit area; zero width when the sheet is too narrow for a slider. + pub(crate) track: (f64, f64, f64, f64), + /// Painted rail as `(start_x, end_x, center_y)`. + pub(crate) rail: (f64, f64, f64), + pub(crate) thumb_x: f64, + pub(crate) thumb_radius: f64, + pub(crate) field: (f64, f64, f64, f64), +} + +/// Dialog buttons, with Cancel before the affirmative Apply. +#[derive(Debug, Clone, Copy)] +pub(crate) struct BoardAppearanceButtons { + pub(crate) cancel: (f64, f64, f64, f64), + pub(crate) apply: (f64, f64, f64, f64), +} + fn rect_contains((x, y, width, height): (f64, f64, f64, f64), (px, py): (f64, f64)) -> bool { px >= x && px <= x + width && py >= y && py <= y + height } +/// Start of a span of `length` kept within `low..high`, centered there when it +/// cannot fit. +fn fit_span(start: f64, length: f64, (low, high): (f64, f64)) -> f64 { + if high - low < length { + low + (high - low - length) / 2.0 + } else { + start.clamp(low, high - length) + } +} + +/// Slider position in `0..=1` for a size. Sizes use a logarithmic scale so +/// small grids get as much travel as large ones. +fn size_to_slider(size: u16) -> f64 { + let min = f64::from(BOARD_GRID_MIN_SPACING); + let max = f64::from(BOARD_GRID_MAX_SPACING); + (f64::from(size).clamp(min, max) / min).ln() / (max / min).ln() +} + +fn slider_to_size(position: f64) -> u16 { + let min = f64::from(BOARD_GRID_MIN_SPACING); + let max = f64::from(BOARD_GRID_MAX_SPACING); + (min * (max / min).powf(position.clamp(0.0, 1.0))).round() as u16 +} + +fn slider_size_at(track: (f64, f64, f64, f64), x: f64) -> u16 { + let travel = (track.2 - 2.0 * SLIDER_THUMB_RADIUS).max(1.0); + slider_to_size((x - track.0 - SLIDER_THUMB_RADIUS) / travel) +} + /// A board-identity-bound draft. Only Apply writes to the board or session. #[derive(Debug)] pub(crate) struct BoardAppearanceEdit { @@ -41,6 +161,9 @@ pub(crate) struct BoardAppearanceEdit { pub(crate) kind: BoardGridKind, pub(crate) spacing: String, pub(crate) focus: AppearanceField, + /// The size text is selected, so the next digit replaces it. + pub(crate) spacing_armed: bool, + pub(crate) size_dragging: bool, pub(crate) error: Option, } @@ -52,6 +175,7 @@ impl BoardAppearanceEdit { pub(crate) fn board_id(&self) -> &str { &self.id } + // Compare the displayed color with its original displayed value, preserving // the exact configured float color when an edit is reverted. fn color_is_dirty(&self) -> bool { @@ -65,19 +189,43 @@ impl BoardAppearanceEdit { if parse_hex_color(&self.color).is_none() { return Some("Use a color in #RRGGBB format."); } - if !self - .spacing - .parse::() - .is_ok_and(|s| (8..=200).contains(&s)) - { + if !self.size_is_valid() { return Some("Spacing must be a whole number from 8 to 200."); } None } - /// Whether the typed size equals a preset, so only that preset reads as selected. - pub(crate) fn spacing_matches(&self, preset: u16) -> bool { - self.spacing.trim().parse::().ok() == Some(preset) + fn spacing_value(&self) -> Option { + self.spacing + .parse::() + .ok() + .filter(|spacing| (BOARD_GRID_MIN_SPACING..=BOARD_GRID_MAX_SPACING).contains(spacing)) + } + + pub(crate) fn size_is_valid(&self) -> bool { + self.spacing_value().is_some() + } + + /// The size the slider and preview show: the typed value when valid, + /// otherwise the original size while the field is mid-edit. + pub(crate) fn size_value(&self) -> u16 { + self.spacing_value().unwrap_or(self.original.grid.spacing()) + } + + fn set_size(&mut self, size: u16) { + self.spacing = size + .clamp(BOARD_GRID_MIN_SPACING, BOARD_GRID_MAX_SPACING) + .to_string(); + self.error = None; + } + + fn step_size(&mut self, delta: i32) { + let next = (i32::from(self.size_value()) + delta).clamp( + i32::from(BOARD_GRID_MIN_SPACING), + i32::from(BOARD_GRID_MAX_SPACING), + ); + self.set_size(next as u16); + self.spacing_armed = true; } pub(crate) fn preview(&self) -> (Color, BoardGrid) { @@ -91,12 +239,7 @@ impl BoardAppearanceEdit { } else { base }, - BoardGrid::new( - self.kind, - self.spacing - .parse() - .unwrap_or(i64::from(self.original.grid.spacing())), - ), + BoardGrid::new(self.kind, i64::from(self.size_value())), ) } @@ -171,6 +314,8 @@ impl InputState { kind: board.spec.grid.kind, spacing: board.spec.grid.spacing().to_string(), focus: AppearanceField::Color, + spacing_armed: false, + size_dragging: false, error: None, }); // The sheet dims the whole surface behind it. @@ -229,6 +374,7 @@ impl InputState { pub(crate) fn board_appearance_key(&mut self, key: Key) -> bool { self.mark_board_appearance_region(); + let shift = self.modifiers.shift; let Some(edit) = &mut self.board_picker.appearance else { return false; }; @@ -244,6 +390,7 @@ impl InputState { AppearanceField::Pattern => AppearanceField::Spacing, AppearanceField::Spacing => AppearanceField::Color, }; + edit.spacing_armed = edit.focus == AppearanceField::Spacing; } Key::Left | Key::Right | Key::Up | Key::Down if edit.focus == AppearanceField::Pattern => @@ -259,12 +406,27 @@ impl InputState { }; edit.kind = BoardGridKind::ALL[(index + step) % 4]; } + Key::Left | Key::Right | Key::Up | Key::Down + if edit.focus == AppearanceField::Spacing => + { + let step = if shift { 10 } else { 1 }; + edit.step_size(if matches!(key, Key::Right | Key::Up) { + step + } else { + -step + }); + } Key::Backspace | Key::Delete => match edit.focus { AppearanceField::Color => { edit.color.pop(); } AppearanceField::Spacing => { - edit.spacing.pop(); + if edit.spacing_armed { + edit.spacing.clear(); + edit.spacing_armed = false; + } else { + edit.spacing.pop(); + } } _ => {} }, @@ -274,8 +436,14 @@ impl InputState { { edit.color.push(ch); } - AppearanceField::Spacing if !ch.is_control() && edit.spacing.len() < 8 => { - edit.spacing.push(ch); + AppearanceField::Spacing if ch.is_ascii_digit() => { + if edit.spacing_armed { + edit.spacing.clear(); + edit.spacing_armed = false; + } + if edit.spacing.len() < 3 { + edit.spacing.push(ch); + } } _ => {} }, @@ -285,48 +453,125 @@ impl InputState { true } - /// Shared sheet geometry for painting and mouse input. The sheet floats over - /// the page column when it fits there, so it leaves the board list readable; - /// narrow pickers center it instead. - pub(crate) fn board_appearance_rect(&self) -> Option<(f64, f64, f64)> { + /// Where the sheet sits and how much it is magnified, shared by painting and + /// pointer input. It is twice its base size when that fits on the output, + /// stepping down to base size on small outputs. It leans toward the page + /// column so the start of the board list stays visible. + pub(crate) fn board_appearance_frame(&self) -> Option { self.board_picker.appearance.as_ref()?; let layout = self.board_picker.layout.as_ref()?; - let y = layout.origin_y + layout.height / 2.0 - 75.0; - - // Sheet padding plus a small margin on both sides of the page column. - let column_width = layout.origin_x + layout.width - layout.page_panel_x; - let docked_width = (column_width - 2.0 * (SHEET_PADDING + SHEET_COLUMN_MARGIN)).min(320.0); - // A picker wider than the output is clipped at both edges, so keep the - // sheet centered there to leave it fully on screen. - if layout.page_panel_enabled - && layout.origin_x >= 0.0 - && docked_width >= SHEET_MIN_DOCKED_WIDTH - { - let x = layout.page_panel_x + (column_width - docked_width) / 2.0; - return Some((x, y, docked_width)); - } + let room_width = layout.screen_width - 2.0 * SHEET_SCREEN_MARGIN; + let room_height = layout.screen_height - 2.0 * SHEET_SCREEN_MARGIN; + let fit = (room_width / (SHEET_WIDTH + 2.0 * SHEET_PADDING)) + .min(room_height / (SHEET_HEIGHT + SHEET_SHADOW)); + // Quarter steps keep strokes close to whole pixels. + let scale = ((fit * 4.0).floor() / 4.0).clamp(1.0, SHEET_MAX_SCALE); + // Outputs too narrow even at base size narrow the content instead. + let width = (room_width / scale - 2.0 * SHEET_PADDING).clamp(SHEET_MIN_WIDTH, SHEET_WIDTH); + let frame_width = (width + 2.0 * SHEET_PADDING) * scale; + let frame_height = SHEET_HEIGHT * scale; - let width = (layout.width - 32.0).clamp(140.0, 320.0); - Some((layout.origin_x + (layout.width - width) / 2.0, y, width)) + let picker_right = layout.origin_x + layout.width; + let center_x = if layout.page_panel_enabled { + (layout.page_panel_x + picker_right) / 2.0 + } else { + layout.origin_x + layout.width / 2.0 + }; + let left = fit_span( + center_x - frame_width / 2.0, + frame_width, + (layout.origin_x, picker_right), + ); + let left = fit_span( + left, + frame_width, + ( + SHEET_SCREEN_MARGIN, + layout.screen_width - SHEET_SCREEN_MARGIN, + ), + ); + let top = fit_span( + layout.origin_y + (layout.height - frame_height) / 2.0, + frame_height, + ( + SHEET_SCREEN_MARGIN, + layout.screen_height - SHEET_SCREEN_MARGIN, + ), + ); + Some(BoardAppearanceFrame { + x: left.round() + SHEET_PADDING * scale, + y: top.round() + SHEET_TOP * scale, + width, + scale, + }) } - /// Header controls in surface coordinates, shared by painting and clicks. + /// Header controls in sheet units, shared by painting and clicks. pub(crate) fn board_appearance_header(&self) -> Option { - let (x, y, width) = self.board_appearance_rect()?; + let width = self.board_appearance_frame()?.width; let close = ( - x + width + 4.0 - SHEET_CLOSE_SIZE, - y - 62.0, + width + 4.0 - SHEET_CLOSE_SIZE, + -62.0, SHEET_CLOSE_SIZE, SHEET_CLOSE_SIZE, ); let field_x = close.0 - SHEET_HEADER_GAP - SHEET_COLOR_FIELD_WIDTH; Some(BoardAppearanceHeader { title_right: field_x - SHEET_HEADER_GAP, - color_field: (field_x, y - 61.0, SHEET_COLOR_FIELD_WIDTH, 22.0), + color_field: (field_x, -61.0, SHEET_COLOR_FIELD_WIDTH, 22.0), close, }) } + /// Size label, slider, and field, placed from the draft's current size. + pub(crate) fn board_appearance_size_row(&self) -> Option { + let edit = self.board_picker.appearance.as_ref()?; + let width = self.board_appearance_frame()?.width; + let top = SIZE_ROW_TOP; + let field = ( + width - SIZE_FIELD_WIDTH, + top, + SIZE_FIELD_WIDTH, + SIZE_ROW_HEIGHT, + ); + + let track_x = SIZE_LABEL_WIDTH; + let track_width = field.0 - SIZE_CONTROL_GAP - track_x; + let (track, label_right) = if track_width >= SLIDER_MIN_WIDTH { + ((track_x, top, track_width, SIZE_ROW_HEIGHT), track_x - 6.0) + } else { + ( + (track_x, top, 0.0, SIZE_ROW_HEIGHT), + field.0 - SIZE_CONTROL_GAP, + ) + }; + let rail_start = track.0 + SLIDER_THUMB_RADIUS; + let rail_end = (track.0 + track.2 - SLIDER_THUMB_RADIUS).max(rail_start); + + Some(BoardAppearanceSizeRow { + label_right, + track, + rail: (rail_start, rail_end, top + SIZE_ROW_HEIGHT / 2.0), + thumb_x: rail_start + size_to_slider(edit.size_value()) * (rail_end - rail_start), + thumb_radius: SLIDER_THUMB_RADIUS, + field, + }) + } + + pub(crate) fn board_appearance_buttons(&self) -> Option { + let width = self.board_appearance_frame()?.width; + let button_width = (width - BUTTON_GAP) / 2.0; + Some(BoardAppearanceButtons { + cancel: (0.0, BUTTON_TOP, button_width, BUTTON_HEIGHT), + apply: ( + button_width + BUTTON_GAP, + BUTTON_TOP, + button_width, + BUTTON_HEIGHT, + ), + }) + } + pub(in crate::input::state) fn mark_board_appearance_region(&mut self) { if self.board_picker.appearance.is_none() { return; @@ -337,15 +582,19 @@ impl InputState { { self.mark_board_picker_region(&layout); } - // The frame plus its drop shadow below. - if let Some((x, y, width)) = self.board_appearance_rect() - && let Some(rect) = crate::util::Rect::new( - (x - 15.0).floor() as i32, - (y - 72.0).floor() as i32, - width.ceil() as i32 + 31, - 308, + // The frame, its drop shadow below, and antialiasing on every side. + let damage = self.board_appearance_frame().and_then(|frame| { + let (left, top, width, height) = frame.bounds(); + let bottom = top + height + SHEET_SHADOW * frame.scale; + let (x, y) = ((left - 4.0).floor(), (top - 4.0).floor()); + crate::util::Rect::new( + x as i32, + y as i32, + ((left + width + 4.0).ceil() - x) as i32, + ((bottom + 4.0).ceil() - y) as i32, ) - { + }); + if let Some(rect) = damage { self.dirty_tracker.mark_rect(rect); } else { self.dirty_tracker.mark_full(); @@ -353,30 +602,115 @@ impl InputState { self.needs_redraw = true; } + /// A press on the size slider jumps the thumb there and starts a drag. + pub(crate) fn board_appearance_press(&mut self, x: i32, y: i32) -> bool { + let (Some(frame), Some(row)) = ( + self.board_appearance_frame(), + self.board_appearance_size_row(), + ) else { + return false; + }; + let point = frame.to_sheet(x, y); + if row.track.2 <= 0.0 || !rect_contains(row.track, point) { + return false; + } + + let size = slider_size_at(row.track, point.0); + if let Some(edit) = self.board_picker.appearance.as_mut() { + edit.set_size(size); + edit.focus = AppearanceField::Spacing; + edit.spacing_armed = true; + edit.size_dragging = true; + } + self.mark_board_appearance_region(); + true + } + + pub(crate) fn board_appearance_drag_to(&mut self, x: i32, y: i32) -> bool { + let dragging = self + .board_picker + .appearance + .as_ref() + .is_some_and(|edit| edit.size_dragging); + if !dragging { + return false; + } + let (Some(frame), Some(row)) = ( + self.board_appearance_frame(), + self.board_appearance_size_row(), + ) else { + return false; + }; + + if let Some(edit) = self.board_picker.appearance.as_mut() { + edit.set_size(slider_size_at(row.track, frame.to_sheet(x, y).0)); + } + self.mark_board_appearance_region(); + true + } + + /// The wheel steps the size over its row. While the sheet is open it also + /// consumes wheel events over the picker so nothing behind it scrolls. + pub(crate) fn board_appearance_wheel(&mut self, x: i32, y: i32, direction: i32) -> bool { + let Some(frame) = self.board_appearance_frame() else { + return false; + }; + let point = frame.to_sheet(x, y); + if !rect_contains(frame.outline(), point) && !self.board_picker_contains_point(x, y) { + return false; + } + + let size_row = (0.0, SIZE_ROW_TOP, frame.width, SIZE_ROW_HEIGHT); + if direction != 0 && rect_contains(size_row, point) { + let step = if self.modifiers.shift { 10 } else { 1 }; + if let Some(edit) = self.board_picker.appearance.as_mut() { + edit.step_size(if direction > 0 { -step } else { step }); + edit.focus = AppearanceField::Spacing; + } + self.mark_board_appearance_region(); + } + true + } + pub(crate) fn board_appearance_click(&mut self, x: i32, y: i32) -> bool { - let Some((left, top, width)) = self.board_appearance_rect() else { + // A slider drag ends on release wherever the pointer is. + if let Some(edit) = self.board_picker.appearance.as_mut() + && edit.size_dragging + { + edit.size_dragging = false; + self.mark_board_appearance_region(); + return true; + } + let Some(frame) = self.board_appearance_frame() else { return false; }; self.mark_board_appearance_region(); - let (screen_x, screen_y) = (x, y); - let x = f64::from(x) - left; - let y = f64::from(y) - top; - if !(-12.0..width + 12.0).contains(&x) || !(-70.0..222.0).contains(&y) { - if let Some(color) = self.board_picker_palette_color_at(screen_x, screen_y) { + let point = frame.to_sheet(x, y); + if !rect_contains(frame.outline(), point) { + if let Some(color) = self.board_picker_palette_color_at(x, y) { return self.board_appearance_palette(color); } self.board_picker_cancel_edit(); return false; } + let header = self.board_appearance_header(); - let point = (f64::from(screen_x), f64::from(screen_y)); - if header.is_some_and(|header| rect_contains(header.close, point)) { + let buttons = self.board_appearance_buttons(); + if header.is_some_and(|header| rect_contains(header.close, point)) + || buttons.is_some_and(|buttons| rect_contains(buttons.cancel, point)) + { self.board_picker_cancel_edit(); return true; } + if buttons.is_some_and(|buttons| rect_contains(buttons.apply, point)) { + self.apply_board_appearance(); + return true; + } + let in_color_field = header.is_some_and(|header| rect_contains(header.color_field, point)); + let ((x, y), width) = (point, frame.width); let edit = self.board_picker.appearance.as_mut().unwrap(); - match y as i32 { + match y.floor() as i32 { -65..=-35 => { if in_color_field { edit.focus = AppearanceField::Color; @@ -393,20 +727,11 @@ impl InputState { [((y / 28.0) as usize * 2 + (x / (width / 2.0)) as usize).min(3)]; edit.focus = AppearanceField::Pattern; } + // Slider presses start drags in `board_appearance_press`; the rest + // of the row selects the size field for typing. 60..=87 => { edit.focus = AppearanceField::Spacing; - if x > width - 40.0 { - edit.spacing = "40".into(); - } else if x > width - 80.0 { - edit.spacing = "20".into(); - } - } - 160..=187 => { - if x < width / 2.0 { - self.apply_board_appearance(); - } else { - self.board_picker_cancel_edit(); - } + edit.spacing_armed = true; } _ => {} } diff --git a/src/input/state/core/board_picker/appearance/tests.rs b/src/input/state/core/board_picker/appearance/tests.rs index 5ea2f986d..daa745cdc 100644 --- a/src/input/state/core/board_picker/appearance/tests.rs +++ b/src/input/state/core/board_picker/appearance/tests.rs @@ -41,8 +41,9 @@ fn reverting_draft_fields_preserves_later_external_changes() { input.board_appearance_key(Key::Tab); input.board_appearance_key(Key::Right); input.board_appearance_key(Key::Left); + // Tab selects the size, so typing the original value replaces it. input.board_appearance_key(Key::Tab); - input.board_appearance_key(Key::Backspace); + input.board_appearance_key(Key::Char('4')); input.board_appearance_key(Key::Char('0')); let board = input.boards.active_board_mut(); @@ -138,7 +139,7 @@ fn appearance_preview_and_cancel_damage_the_sheet_without_changing_the_board() { let surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 900, 700).unwrap(); let ctx = cairo::Context::new(&surface).unwrap(); input.update_board_picker_layout(&ctx, 900, 700); - let (x, y, width) = input.board_appearance_rect().unwrap(); + let (x, y, width, height) = input.board_appearance_frame().unwrap().bounds(); let original = BoardAppearance::from_spec(&input.boards.active_board().spec); input.dirty_tracker.take_regions(900, 700); for key in [Key::Tab, Key::Right, Key::Escape] { @@ -149,10 +150,10 @@ fn appearance_preview_and_cancel_damage_the_sheet_without_changing_the_board() { assert!(input.board_appearance_key(key)); let regions = input.dirty_tracker.take_regions(900, 700); assert!(regions.iter().any(|rect| { - f64::from(rect.x) <= x - 12.0 - && f64::from(rect.y) <= y - 70.0 - && f64::from(rect.x + rect.width) >= x + width + 12.0 - && f64::from(rect.y + rect.height) >= y + 222.0 + f64::from(rect.x) <= x + && f64::from(rect.y) <= y + && f64::from(rect.x + rect.width) >= x + width + && f64::from(rect.y + rect.height) >= y + height })); assert_eq!( BoardAppearance::from_spec(&input.boards.active_board().spec), @@ -165,10 +166,7 @@ fn appearance_preview_and_cancel_damage_the_sheet_without_changing_the_board() { #[test] fn visible_palette_click_keeps_pattern_and_spacing_draft_until_apply() { - let mut input = editing(); - let surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 900, 700).unwrap(); - let ctx = cairo::Context::new(&surface).unwrap(); - input.update_board_picker_layout(&ctx, 900, 700); + let mut input = laid_out(1920, 1080); input.board_picker.appearance.as_mut().unwrap().kind = BoardGridKind::IsometricDots; input.board_picker.appearance.as_mut().unwrap().spacing = "63".into(); let original = BoardAppearance::from_spec(&input.boards.active_board().spec); @@ -176,12 +174,12 @@ fn visible_palette_click_keeps_pattern_and_spacing_draft_until_apply() { let x = (layout.origin_x + layout.padding_x + 1.0) as i32; let y = (layout.palette_top + 1.0) as i32; let expected_color = input.board_picker_palette_color_at(x, y).unwrap(); - let (left, top, width) = input.board_appearance_rect().unwrap(); + let (left, top, width, height) = input.board_appearance_frame().unwrap().bounds(); assert!( - f64::from(x) < left - 12.0 - || f64::from(x) >= left + width + 12.0 - || f64::from(y) < top - 70.0 - || f64::from(y) >= top + 222.0 + f64::from(x) < left + || f64::from(x) > left + width + || f64::from(y) < top + || f64::from(y) > top + height ); assert!(input.board_appearance_click(x, y)); let draft = input.board_appearance_edit().unwrap(); @@ -205,29 +203,107 @@ fn visible_palette_click_keeps_pattern_and_spacing_draft_until_apply() { } #[test] -fn spacing_presets_select_only_the_matching_value() { - let mut input = editing(); - let surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 900, 700).unwrap(); - let ctx = cairo::Context::new(&surface).unwrap(); - input.update_board_picker_layout(&ctx, 900, 700); - let (left, top, width) = input.board_appearance_rect().unwrap(); - let row = (top + 72.0) as i32; +fn size_slider_uses_a_log_scale_that_round_trips_every_size() { + assert_eq!(slider_to_size(0.0), 8); + assert_eq!(slider_to_size(1.0), 200); + assert!((size_to_slider(40) - 0.5).abs() < 1e-9); + for size in 8..=200 { + assert_eq!(slider_to_size(size_to_slider(size)), size); + } +} - assert!(input.board_appearance_click((left + width - 60.0) as i32, row)); - let draft = input.board_appearance_edit().unwrap(); - assert_eq!(draft.spacing, "20"); - assert!(draft.spacing_matches(20)); - assert!(!draft.spacing_matches(40)); +#[test] +fn dragging_the_size_slider_updates_the_draft_until_release() { + let mut input = laid_out(900, 700); + let row = input.board_appearance_size_row().unwrap(); + let track = input + .board_appearance_frame() + .unwrap() + .to_surface(row.track); + let (track_x, track_y, track_width, track_height) = track; + let middle_y = (track_y + track_height / 2.0) as i32; - assert!(input.board_appearance_click((left + width - 20.0) as i32, row)); - let draft = input.board_appearance_edit().unwrap(); - assert_eq!(draft.spacing, "40"); - assert!(draft.spacing_matches(40)); - assert!(!draft.spacing_matches(20)); + assert!(input.board_appearance_press(track_x as i32 + 1, middle_y)); + assert_eq!(input.board_appearance_edit().unwrap().spacing, "8"); + assert!(input.board_appearance_drag_to((track_x + track_width) as i32, middle_y)); + assert_eq!(input.board_appearance_edit().unwrap().spacing, "200"); - input.board_picker.appearance.as_mut().unwrap().spacing = "63".into(); + // Releasing outside the sheet only ends the drag. + assert!(input.board_appearance_click(1, 1)); let draft = input.board_appearance_edit().unwrap(); - assert!(!draft.spacing_matches(20) && !draft.spacing_matches(40)); + assert!(!draft.size_dragging); + assert_eq!(draft.spacing, "200"); + assert!(!input.board_appearance_drag_to(track_x as i32, middle_y)); + assert!(!input.is_session_dirty()); +} + +#[test] +fn size_field_replaces_typed_values_and_arrows_step_the_size() { + let mut input = laid_out(900, 700); + let row = input.board_appearance_size_row().unwrap(); + let (x, y) = center(&input, row.field); + assert!(input.board_appearance_click(x, y)); + assert_eq!( + input.board_appearance_edit().unwrap().focus, + AppearanceField::Spacing + ); + + for ch in ['2', '4', 'x'] { + input.board_appearance_key(Key::Char(ch)); + } + assert_eq!(input.board_appearance_edit().unwrap().spacing, "24"); + + input.board_appearance_key(Key::Up); + assert_eq!(input.board_appearance_edit().unwrap().spacing, "25"); + input.modifiers.shift = true; + input.board_appearance_key(Key::Down); + input.modifiers.shift = false; + assert_eq!(input.board_appearance_edit().unwrap().spacing, "15"); +} + +#[test] +fn wheel_steps_the_size_row_and_the_sheet_swallows_other_scrolling() { + let mut input = laid_out(900, 700); + let row = input.board_appearance_size_row().unwrap(); + let (x, y) = center(&input, row.field); + + assert!(input.board_appearance_wheel(x, y, -1)); + assert_eq!(input.board_appearance_edit().unwrap().spacing, "41"); + assert!(input.board_appearance_wheel(x, y, 1)); + assert_eq!(input.board_appearance_edit().unwrap().spacing, "40"); + + // Over the preview: inside the sheet but outside the size row. + let width = input.board_appearance_frame().unwrap().width; + let (x, y) = center(&input, (0.0, 120.0, width, 0.0)); + assert!(input.board_appearance_wheel(x, y, 1)); + assert_eq!(input.board_appearance_edit().unwrap().spacing, "40"); + assert!(!input.board_appearance_wheel(1, 1, 1)); +} + +#[test] +fn cancel_sits_before_apply_and_both_buttons_work() { + let mut input = laid_out(900, 700); + let buttons = input.board_appearance_buttons().unwrap(); + assert!(buttons.cancel.0 + buttons.cancel.2 <= buttons.apply.0); + + input.board_picker.appearance.as_mut().unwrap().kind = BoardGridKind::Cartesian; + let (x, y) = center(&input, buttons.cancel); + assert!(input.board_appearance_click(x, y)); + assert!(input.board_appearance_edit().is_none()); + assert_eq!( + input.boards.active_board().spec.grid.kind, + BoardGridKind::None + ); + + input.board_picker_edit_color_selected_with_measurer(&crate::draw::TextMeasurer::default()); + input.board_picker.appearance.as_mut().unwrap().kind = BoardGridKind::Cartesian; + let (x, y) = center(&input, buttons.apply); + assert!(input.board_appearance_click(x, y)); + assert!(input.board_appearance_edit().is_none()); + assert_eq!( + input.boards.active_board().spec.grid.kind, + BoardGridKind::Cartesian + ); } fn laid_out(width: i32, height: i32) -> InputState { @@ -238,25 +314,52 @@ fn laid_out(width: i32, height: i32) -> InputState { input } -fn center((x, y, width, height): (f64, f64, f64, f64)) -> (i32, i32) { +/// The surface pixel at the center of a rectangle in sheet units. +fn center(input: &InputState, rect: (f64, f64, f64, f64)) -> (i32, i32) { + let (x, y, width, height) = input.board_appearance_frame().unwrap().to_surface(rect); ((x + width / 2.0) as i32, (y + height / 2.0) as i32) } #[test] -fn sheet_floats_over_the_page_column_without_covering_the_board_list() { - for (width, height) in [(1920, 1080), (1280, 720), (900, 700)] { +fn sheet_doubles_where_the_output_has_room_and_shrinks_on_small_outputs() { + for (width, height, scale) in [ + (1920, 1080, 2.0), + (1280, 720, 2.0), + (900, 700, 2.0), + (640, 480, 1.25), + (420, 300, 1.0), + ] { let input = laid_out(width, height); - let layout = *input.board_picker_layout().unwrap(); - let (x, _, sheet_width) = input.board_appearance_rect().unwrap(); + let frame = input.board_appearance_frame().unwrap(); + let (left, top, sheet_width, sheet_height) = frame.bounds(); + assert_eq!(frame.scale, scale, "{width}x{height}"); + assert!( + left >= 0.0 && left + sheet_width <= f64::from(width), + "{width}x{height}: {frame:?}" + ); assert!( - x - 12.0 >= layout.origin_x + layout.list_width, - "{width}x{height}: the sheet covers the board list" + top >= 0.0 && top + sheet_height <= f64::from(height), + "{width}x{height}: {frame:?}" ); - assert!(x + sheet_width + 12.0 <= layout.origin_x + layout.width); } } +#[test] +fn doubled_sheet_leans_toward_the_page_column_inside_the_picker() { + let input = laid_out(1920, 1080); + let layout = *input.board_picker_layout().unwrap(); + let (left, _, width, _) = input.board_appearance_frame().unwrap().bounds(); + let picker_right = layout.origin_x + layout.width; + + assert!( + left >= layout.origin_x - 0.5 && left + width <= picker_right + 0.5, + "sheet {left}+{width} outside picker {}..{picker_right}", + layout.origin_x + ); + assert!(left + width / 2.0 > layout.origin_x + layout.width / 2.0); +} + #[test] fn sheet_header_close_cancels_and_color_field_takes_focus() { let mut input = laid_out(900, 700); @@ -268,7 +371,7 @@ fn sheet_header_close_cancels_and_color_field_takes_focus() { AppearanceField::Color ); - let (x, y) = center(header.color_field); + let (x, y) = center(&input, header.color_field); assert!(input.board_appearance_click(x, y)); assert_eq!( input.board_appearance_edit().unwrap().focus, @@ -276,7 +379,7 @@ fn sheet_header_close_cancels_and_color_field_takes_focus() { ); input.board_picker.appearance.as_mut().unwrap().kind = BoardGridKind::Cartesian; - let (x, y) = center(header.close); + let (x, y) = center(&input, header.close); assert!(input.board_appearance_click(x, y)); assert!(input.board_appearance_edit().is_none()); assert!(input.is_board_picker_open()); @@ -304,3 +407,46 @@ fn opening_and_closing_the_sheet_damage_the_whole_surface() { assert!(input.board_appearance_edit().is_some()); assert!(full(&input.dirty_tracker.take_regions(900, 700))); } + +#[test] +fn command_palette_edit_board_paper_opens_the_sheet_on_the_active_board() { + use crate::domain::Action; + + let measurer = crate::draw::TextMeasurer::default(); + let ui_engine = crate::ui_text::UiTextEngine::default(); + let resources = crate::input::state::InputTextResources { + measurer: &measurer, + ui_engine: &ui_engine, + }; + let mut input = TestInputStateBuilder::default().build(); + input.switch_board_force("whiteboard"); + input.update_screen_dimensions(1280, 720); + input.toggle_command_palette(); + + input.command_palette.set_query("grid"); + assert!( + input + .filtered_commands() + .iter() + .any(|command| command.action == Action::BoardPaperEdit) + ); + input.command_palette.set_query("paper"); + assert_eq!( + input + .filtered_commands() + .first() + .map(|command| command.action), + Some(Action::BoardPaperEdit) + ); + + assert!(input.handle_command_palette_key_with_resources(resources, Key::Return)); + assert!(!input.command_palette.is_open()); + assert!(input.is_board_picker_open()); + assert!(!input.board_picker_is_quick()); + assert_eq!( + input + .board_appearance_edit() + .map(BoardAppearanceEdit::board_id), + Some("whiteboard") + ); +} diff --git a/src/input/state/core/board_picker/layout/compute.rs b/src/input/state/core/board_picker/layout/compute.rs index 64c54fabe..55e51e9f8 100644 --- a/src/input/state/core/board_picker/layout/compute.rs +++ b/src/input/state/core/board_picker/layout/compute.rs @@ -125,6 +125,8 @@ struct BoardPickerLayoutGeometry { list_width: f64, page_panel_x: f64, page_panel_y: f64, + screen_width: f64, + screen_height: f64, } impl InputState { @@ -281,6 +283,8 @@ impl InputState { page_count: page_panel.count, page_visible_count: page_panel.visible_count, page_board_index: page_panel.board_index, + screen_width: geometry.screen_width, + screen_height: geometry.screen_height, } } } diff --git a/src/input/state/core/board_picker/layout/compute/layout_geometry.rs b/src/input/state/core/board_picker/layout/compute/layout_geometry.rs index 537726b41..d3f0e68f7 100644 --- a/src/input/state/core/board_picker/layout/compute/layout_geometry.rs +++ b/src/input/state/core/board_picker/layout/compute/layout_geometry.rs @@ -49,6 +49,8 @@ impl InputState { list_width: final_list_width, page_panel_x, page_panel_y: origin_y, + screen_width: screen_width as f64, + screen_height: screen_height as f64, } } } diff --git a/src/input/state/core/board_picker/mod.rs b/src/input/state/core/board_picker/mod.rs index 381496857..b074e10a4 100644 --- a/src/input/state/core/board_picker/mod.rs +++ b/src/input/state/core/board_picker/mod.rs @@ -203,6 +203,9 @@ pub struct BoardPickerLayout { pub page_count: usize, pub page_visible_count: usize, pub page_board_index: Option, + /// Output size the picker was laid out for. + pub screen_width: f64, + pub screen_height: f64, } fn truncate_search_label(value: &str, max_chars: usize) -> String { diff --git a/src/input/state/core/board_picker/state/edit.rs b/src/input/state/core/board_picker/state/edit.rs index ee62fcdaf..4e89702f5 100644 --- a/src/input/state/core/board_picker/state/edit.rs +++ b/src/input/state/core/board_picker/state/edit.rs @@ -315,6 +315,22 @@ impl InputState { } } + /// Opens the paper sheet for a board, opening the full picker when needed. + pub(crate) fn board_picker_edit_board_paper_with_measurer( + &mut self, + measurer: &crate::draw::TextMeasurer, + board_index: usize, + ) { + if !self.is_board_picker_open() || self.board_picker_is_quick() { + self.open_board_picker_with_measurer(measurer); + } + if let Some(row) = self.board_picker_row_for_board(board_index) { + self.board_picker_set_selected(row); + } + + self.board_picker_edit_color_selected_with_measurer(measurer); + } + pub(crate) fn board_picker_edit_color_selected_with_measurer( &mut self, measurer: &crate::draw::TextMeasurer, diff --git a/src/input/state/core/menus/commands.rs b/src/input/state/core/menus/commands.rs index 8fb102649..c4abbff93 100644 --- a/src/input/state/core/menus/commands.rs +++ b/src/input/state/core/menus/commands.rs @@ -32,6 +32,23 @@ impl InputState { self.board_picker_page_panel_board_index() == Some(board_index) } + fn context_menu_board_target_index(&self) -> Option { + let id = self.context_menu.board_target.as_deref()?; + self.boards + .board_states() + .iter() + .position(|board| board.spec.id == id) + } + + /// Picker row actions act on the selected row, so select the target first. + fn select_board_picker_row_for(&mut self, board_index: usize) -> bool { + let Some(row) = self.board_picker_row_for_board(board_index) else { + return false; + }; + self.board_picker_set_selected(row); + true + } + fn context_submenu_anchor(&self) -> (i32, i32) { if let Some(layout) = self.context_menu.layout { ( @@ -332,6 +349,39 @@ impl InputState { self.delete_active_board_with_measurer(resources.measurer); self.close_context_menu(); } + MenuCommand::BoardEditPaper => { + self.close_context_menu(); + let active = self.boards.active_index(); + self.board_picker_edit_board_paper_with_measurer(resources.measurer, active); + } + MenuCommand::BoardEditPaperFromContext => { + let target = self.context_menu_board_target_index(); + self.close_context_menu(); + if let Some(board_index) = target { + self.board_picker_edit_board_paper_with_measurer( + resources.measurer, + board_index, + ); + } + } + MenuCommand::BoardRenameFromContext => { + let target = self.context_menu_board_target_index(); + self.close_context_menu(); + if let Some(board_index) = target + && self.select_board_picker_row_for(board_index) + { + self.board_picker_rename_selected_with_measurer(resources.measurer); + } + } + MenuCommand::BoardTogglePinFromContext => { + let target = self.context_menu_board_target_index(); + self.close_context_menu(); + if let Some(board_index) = target + && self.select_board_picker_row_for(board_index) + { + self.board_picker_toggle_pin_selected(); + } + } MenuCommand::SwitchToBoard { id } => { self.switch_board_with_measurer(resources.measurer, &id); self.close_context_menu(); diff --git a/src/input/state/core/menus/context_menu.rs b/src/input/state/core/menus/context_menu.rs index b6ff9a432..1f8c8643b 100644 --- a/src/input/state/core/menus/context_menu.rs +++ b/src/input/state/core/menus/context_menu.rs @@ -7,8 +7,12 @@ use crate::draw::ShapeId; pub struct ContextMenuPanel { pub(in crate::input::state) state: ContextMenuState, pub(in crate::input::state) page_target: Option, + /// Board id for a picker row menu; ids survive row reordering. + pub(in crate::input::state) board_target: Option, pub(in crate::input::state) enabled: bool, pub(in crate::input::state) layout: Option, + /// An outside left press dismissed the menu but still owns its release. + dismissal_release_pending: bool, } impl ContextMenuPanel { @@ -32,10 +36,19 @@ impl ContextMenuPanel { self.layout = None; } + pub(in crate::input::state) fn set_dismissal_release_pending(&mut self) { + self.dismissal_release_pending = true; + } + + pub(in crate::input::state) fn take_dismissal_release_pending(&mut self) -> bool { + std::mem::take(&mut self.dismissal_release_pending) + } + pub(crate) fn close(&mut self) -> Option { let layout = self.layout.take(); self.state = ContextMenuState::Hidden; self.page_target = None; + self.board_target = None; layout } @@ -48,6 +61,7 @@ impl ContextMenuPanel { ) -> Option { let layout = self.layout.take(); self.page_target = None; + self.board_target = None; self.state = ContextMenuState::Open { anchor, shape_ids, @@ -66,6 +80,10 @@ impl ContextMenuPanel { }); } + pub(crate) fn set_board_target(&mut self, board_id: String) { + self.board_target = Some(board_id); + } + pub(crate) fn set_enabled(&mut self, enabled: bool) -> bool { self.enabled = enabled; !enabled && self.is_open() @@ -77,8 +95,10 @@ impl Default for ContextMenuPanel { Self { state: ContextMenuState::Hidden, page_target: None, + board_target: None, enabled: true, layout: None, + dismissal_release_pending: false, } } } @@ -91,6 +111,7 @@ mod tests { fn opening_replaces_transient_target_and_closing_clears_it() { let mut panel = ContextMenuPanel::default(); panel.set_page_target(2, 3); + panel.set_board_target("whiteboard".to_string()); assert!( panel @@ -99,11 +120,14 @@ mod tests { ); assert!(panel.is_open()); assert!(panel.page_target.is_none()); + assert!(panel.board_target.is_none()); panel.set_page_target(4, 5); + panel.set_board_target("blackboard".to_string()); assert!(panel.close().is_none()); assert!(!panel.is_open()); assert!(panel.page_target.is_none()); + assert!(panel.board_target.is_none()); } #[test] diff --git a/src/input/state/core/menus/entries/boards.rs b/src/input/state/core/menus/entries/boards.rs index e0cec01e8..c197a8ef1 100644 --- a/src/input/state/core/menus/entries/boards.rs +++ b/src/input/state/core/menus/entries/boards.rs @@ -106,6 +106,14 @@ impl InputState { false, Some(MenuCommand::BoardDuplicate), )); + // The overlay has no paper to edit. + entries.push(ContextMenuEntry::new( + "Edit Board Paper…", + self.shortcut_for_action(Action::BoardPaperEdit), + false, + self.board_is_transparent(), + Some(MenuCommand::BoardEditPaper), + )); // Can't delete the transparent board or if only one board left let can_delete = !self.board_is_transparent() && board_count > 1; @@ -119,4 +127,47 @@ impl InputState { entries } + + /// Actions for the board row right-clicked in the board picker. Shortcut + /// hints name the picker's own keys for the same actions. + pub(super) fn board_context_menu_entries(&self) -> Vec { + let Some(board) = self.context_menu.board_target.as_deref().and_then(|id| { + self.boards + .board_states() + .iter() + .find(|board| board.spec.id == id) + }) else { + return Vec::new(); + }; + let pin_label = if board.spec.pinned { + "Unpin Board" + } else { + "Pin Board" + }; + + vec![ + ContextMenuEntry::new(board.spec.name.clone(), None::, false, true, None), + ContextMenuEntry::new( + "Edit Paper…", + Some("Ctrl+C"), + false, + board.spec.background.is_transparent(), + Some(MenuCommand::BoardEditPaperFromContext), + ), + ContextMenuEntry::new( + "Rename Board", + Some("F2"), + false, + false, + Some(MenuCommand::BoardRenameFromContext), + ), + ContextMenuEntry::new( + pin_label, + Some("Ctrl+P"), + false, + false, + Some(MenuCommand::BoardTogglePinFromContext), + ), + ] + } } diff --git a/src/input/state/core/menus/entries/mod.rs b/src/input/state/core/menus/entries/mod.rs index dc12de544..1762064d0 100644 --- a/src/input/state/core/menus/entries/mod.rs +++ b/src/input/state/core/menus/entries/mod.rs @@ -56,6 +56,7 @@ impl InputState { ContextMenuKind::Boards => self.boards_menu_entries(), ContextMenuKind::Page => self.page_context_menu_entries(), ContextMenuKind::PageMove => self.page_move_menu_entries(), + ContextMenuKind::Board => self.board_context_menu_entries(), }, } } diff --git a/src/input/state/core/menus/lifecycle.rs b/src/input/state/core/menus/lifecycle.rs index dab451f31..be5726c66 100644 --- a/src/input/state/core/menus/lifecycle.rs +++ b/src/input/state/core/menus/lifecycle.rs @@ -57,6 +57,29 @@ impl InputState { self.needs_redraw = true; } + /// Opens actions for one board row, keeping the board picker underneath. + pub fn open_board_context_menu(&mut self, anchor: (i32, i32), board_index: usize) { + if !self.context_menu.enabled { + return; + } + let Some(board_id) = self + .boards + .board_states() + .get(board_index) + .map(|board| board.spec.id.clone()) + else { + return; + }; + + self.open_context_menu(anchor, Vec::new(), ContextMenuKind::Board, None); + self.context_menu.set_board_target(board_id); + self.pointer.clear_menu_hover_recalc(); + self.set_context_menu_focus(None); + self.focus_first_context_menu_entry(); + self.dirty_tracker.mark_full(); + self.needs_redraw = true; + } + pub fn toggle_context_menu_via_keyboard(&mut self) { let measurer = TextMeasurer::default(); self.toggle_context_menu_via_keyboard_with(&measurer); diff --git a/src/input/state/core/menus/types.rs b/src/input/state/core/menus/types.rs index 59743eac7..7d5caa349 100644 --- a/src/input/state/core/menus/types.rs +++ b/src/input/state/core/menus/types.rs @@ -10,6 +10,8 @@ pub enum ContextMenuKind { Boards, Page, PageMove, + /// Actions for one board row in the board picker. + Board, } /// Tracks the context menu lifecycle. @@ -66,6 +68,10 @@ pub enum MenuCommand { BoardNew, BoardDuplicate, BoardDelete, + BoardEditPaper, + BoardEditPaperFromContext, + BoardRenameFromContext, + BoardTogglePinFromContext, SwitchToBoard { id: String }, SwitchToWhiteboard, SwitchToBlackboard, diff --git a/src/input/state/interaction/actions.rs b/src/input/state/interaction/actions.rs index 4a68f65dc..45feb5740 100644 --- a/src/input/state/interaction/actions.rs +++ b/src/input/state/interaction/actions.rs @@ -86,6 +86,7 @@ pub(crate) fn classify_action(action: Action) -> ActionRoute { | Action::BoardRestoreDeleted | Action::BoardDuplicate | Action::BoardSwitchRecent + | Action::BoardPaperEdit | Action::PagePrev | Action::PageNext | Action::PageNew diff --git a/src/input/state/interaction/adapters/pointer.rs b/src/input/state/interaction/adapters/pointer.rs index 24c9385da..9be77c06a 100644 --- a/src/input/state/interaction/adapters/pointer.rs +++ b/src/input/state/interaction/adapters/pointer.rs @@ -76,6 +76,14 @@ pub(crate) fn handle_board_picker_press( button: MouseButton, points: PointerPoints, ) -> Option { + // Board and page menus open above the picker, so they take its left + // presses; a press away from the menu only dismisses the menu. + if button == MouseButton::Left + && state.is_board_picker_open() + && let Some(outcome) = handle_left_context_menu_press(state, points) + { + return Some(outcome); + } let screen = points.screen(); state .handle_board_picker_press(button, screen.x(), screen.y()) @@ -105,6 +113,9 @@ pub(crate) fn handle_left_context_menu_press( state.update_pointer_positions(screen.x(), screen.y(), canvas.x(), canvas.y()); state.trigger_click_highlight(canvas.x(), canvas.y()); state.handle_context_menu_press(screen.x(), screen.y()); + if !state.is_context_menu_open() { + state.context_menu.set_dismissal_release_pending(); + } Some(RoutingOutcome::Consumed(ConsumedBy::ContextMenu)) } @@ -426,7 +437,14 @@ pub(crate) fn handle_board_picker_motion( if !state.is_board_picker_open() { return None; } + // Board and page menus open above the picker, so they take its hover. + if let Some(outcome) = handle_context_menu_motion(state, points) { + return Some(outcome); + } let screen = points.screen(); + if state.board_appearance_drag_to(screen.x(), screen.y()) { + return Some(RoutingOutcome::Consumed(ConsumedBy::BoardPicker)); + } if state.board_picker_is_page_dragging() { state.board_picker_update_page_drag_from_pointer(screen.x(), screen.y()); } else if state.board_picker_is_dragging() { diff --git a/src/input/state/interaction/mod.rs b/src/input/state/interaction/mod.rs index ebc9dab23..47247e3cd 100644 --- a/src/input/state/interaction/mod.rs +++ b/src/input/state/interaction/mod.rs @@ -289,4 +289,188 @@ mod tests { assert_eq!(classify_action(Action::ApplyPreset1), ActionRoute::Preset); assert_eq!(classify_action(Action::PickScreenColor), ActionRoute::Color); } + + #[test] + fn dismissing_picker_menus_consumes_release_without_activating_the_picker() { + let measurer = crate::draw::TextMeasurer::default(); + let ui_engine = crate::ui_text::UiTextEngine::default(); + let resources = crate::input::state::InputTextResources { + measurer: &measurer, + ui_engine: &ui_engine, + }; + let at = |x, y| PointerPoints::new(ScreenPoint::new(x, y), CanvasPoint::new(x, y)); + let surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 1280, 720).unwrap(); + let ctx = cairo::Context::new(&surface).unwrap(); + + for page_menu in [false, true] { + for over_swatch in [false, true] { + let mut state = make_test_input_state(); + state.open_board_picker_with_measurer(&measurer); + state.update_board_picker_layout(&ctx, 1280, 720); + let layout = *state.board_picker_layout().unwrap(); + let blackboard = state + .boards + .board_states() + .iter() + .position(|board| board.spec.id == BOARD_ID_BLACKBOARD) + .unwrap(); + let row = state.board_picker_row_for_board(blackboard).unwrap(); + let swatch_x = + (layout.origin_x + layout.padding_x + layout.swatch_size / 2.0) as i32; + let swatch_y = (layout.origin_y + + layout.padding_y + + layout.header_height + + layout.row_height * (row as f64 + 0.5)) as i32; + assert_eq!( + state.board_picker_swatch_index_at(swatch_x, swatch_y), + Some(row) + ); + + if page_menu { + state.open_page_context_menu((1000, 50), blackboard, 0); + } else { + state.open_board_context_menu((1000, 50), blackboard); + } + state.update_context_menu_layout(&ctx, 1280, 720); + let target = if over_swatch { + at(swatch_x, swatch_y) + } else { + at(0, 0) + }; + + assert_eq!( + route_pointer_press( + &mut state, + resources, + PointerPress::new(MouseButton::Left, target) + ), + RoutingOutcome::Consumed(ConsumedBy::ContextMenu) + ); + assert!(!state.is_context_menu_open()); + assert!(state.is_board_picker_open()); + assert_eq!( + route_pointer_release( + &mut state, + resources, + PointerRelease::new(MouseButton::Left, target) + ), + RoutingOutcome::Consumed(ConsumedBy::ContextMenu) + ); + assert!(state.is_board_picker_open()); + assert!(state.board_appearance_edit().is_none()); + + // Only the dismissal click is swallowed; the next click works normally. + route_pointer_press( + &mut state, + resources, + PointerPress::new(MouseButton::Left, target), + ); + route_pointer_release( + &mut state, + resources, + PointerRelease::new(MouseButton::Left, target), + ); + if over_swatch { + assert!(state.board_appearance_edit().is_some()); + } else { + assert!(!state.is_board_picker_open()); + } + } + } + } + + /// Board-row menus open above the board picker, so pointer hover and clicks + /// must reach the menu before the picker underneath it. + #[test] + fn board_picker_row_menu_gets_hover_and_clicks_before_the_picker() { + use crate::input::state::core::ContextMenuState; + + let measurer = crate::draw::TextMeasurer::default(); + let ui_engine = crate::ui_text::UiTextEngine::default(); + let resources = crate::input::state::InputTextResources { + measurer: &measurer, + ui_engine: &ui_engine, + }; + let at = |x, y| PointerPoints::new(ScreenPoint::new(x, y), CanvasPoint::new(x, y)); + let surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 1280, 720).unwrap(); + let ctx = cairo::Context::new(&surface).unwrap(); + + let mut state = make_test_input_state(); + state.open_board_picker_with_measurer(&measurer); + state.update_board_picker_layout(&ctx, 1280, 720); + let layout = *state.board_picker_layout().unwrap(); + let blackboard = state + .boards + .board_states() + .iter() + .position(|board| board.spec.id == BOARD_ID_BLACKBOARD) + .unwrap(); + let row = state.board_picker_row_for_board(blackboard).unwrap(); + let row_y = layout.origin_y + + layout.padding_y + + layout.header_height + + layout.row_height * (row as f64 + 0.5); + let row_x = layout.origin_x + layout.padding_x + 60.0; + route_pointer_press( + &mut state, + resources, + PointerPress::new(MouseButton::Right, at(row_x as i32, row_y as i32)), + ); + assert!(state.is_board_picker_open() && state.is_context_menu_open()); + + state.update_context_menu_layout(&ctx, 1280, 720); + let menu = state.context_menu_layout().unwrap(); + let (menu_x, menu_y, menu_bottom) = ( + (menu.origin_x + menu.width / 2.0) as i32, + menu.origin_y as i32, + (menu.origin_y + menu.height) as i32, + ); + // Entries: the board name, Edit Paper, Rename Board, then Pin Board. + let entry_at = |state: &crate::input::state::InputState, index: usize| { + let y = (menu_y..menu_bottom) + .find(|&y| state.context_menu_index_at(menu_x, y) == Some(index)) + .unwrap(); + at(menu_x, y) + }; + + let rename = entry_at(&state, 2); + assert_eq!( + route_pointer_motion(&mut state, &measurer, PointerMotion::new(rename)), + RoutingOutcome::Consumed(ConsumedBy::ContextMenu) + ); + assert!(matches!( + state.context_menu.state, + ContextMenuState::Open { + hover_index: Some(2), + .. + } + )); + + // The backend applies pins, so the click only queues the request. + let _ = state.take_pending_board_runtime_ui_actions(); + let pin = entry_at(&state, 3); + assert_eq!( + route_pointer_press( + &mut state, + resources, + PointerPress::new(MouseButton::Left, pin) + ), + RoutingOutcome::Consumed(ConsumedBy::ContextMenu) + ); + assert!(!state.board_picker_is_dragging()); + assert_eq!( + route_pointer_release( + &mut state, + resources, + PointerRelease::new(MouseButton::Left, pin) + ), + RoutingOutcome::Consumed(ConsumedBy::ContextMenu) + ); + assert!(state.is_board_picker_open() && !state.is_context_menu_open()); + assert!(matches!( + state.take_pending_board_runtime_ui_actions().as_slice(), + [crate::input::boards::PendingBoardRuntimeUiAction::TogglePin { board_id, .. }] + if board_id == BOARD_ID_BLACKBOARD + )); + } } diff --git a/src/input/state/interaction/pointer.rs b/src/input/state/interaction/pointer.rs index 930d77e08..cb257a2b7 100644 --- a/src/input/state/interaction/pointer.rs +++ b/src/input/state/interaction/pointer.rs @@ -16,6 +16,12 @@ pub(crate) fn route_pointer_press( // interaction. state.clear_status_hud_press_pending(); state.clear_zoom_chip_press_pending(); + // Recover if the previous dismissal's release never reached this router. + // Other buttons must not relinquish ownership of the pending left release. + if event.button() == MouseButton::Left { + state.context_menu.take_dismissal_release_pending(); + } + if let Some(outcome) = adapters::handle_building_polygon_non_left_press( state, resources.measurer, @@ -123,6 +129,12 @@ pub(crate) fn route_pointer_release( let points = event.points(); adapters::update_pointer_positions(state, points); + // The menu has already closed on press. Consume its release before any + // newly exposed control can act on it. + if event.button() == MouseButton::Left && state.context_menu.take_dismissal_release_pending() { + return RoutingOutcome::Consumed(ConsumedBy::ContextMenu); + } + // Status HUD press→release contract for paths that route presses through // this chain (tablet, touch fallbacks): a HUD press consumed by // `handle_status_hud_press` activates its chip on release-inside. The diff --git a/src/input/state/mouse/press/panels.rs b/src/input/state/mouse/press/panels.rs index 889e900c9..77128f564 100644 --- a/src/input/state/mouse/press/panels.rs +++ b/src/input/state/mouse/press/panels.rs @@ -137,6 +137,10 @@ impl InputState { return false; } if self.board_appearance_edit().is_some() { + // The size slider acts on press so it can be dragged. + if matches!(button, MouseButton::Left) { + self.board_appearance_press(x, y); + } return true; } self.update_pointer_position(x, y); @@ -159,8 +163,17 @@ impl InputState { } } MouseButton::Right => { - if self.board_picker_contains_point(x, y) - && let Some(page_index) = self.board_picker_page_index_at(x, y) + if !self.board_picker_contains_point(x, y) { + self.close_board_picker(); + } else if let Some(row) = self.board_picker_index_at(x, y) + && !self.board_picker_is_new_row(row) + && let Some(board_index) = self.board_picker_board_index_for_row(row) + { + // Like a left click, a right click selects the row it acts on. + self.board_picker_set_selected(row); + self.update_pointer_position_synthetic(x, y); + self.open_board_context_menu((x, y), board_index); + } else if let Some(page_index) = self.board_picker_page_index_at(x, y) && let Some(board_index) = self.board_picker_page_panel_board_index() { self.update_pointer_position_synthetic(x, y); diff --git a/src/input/state/tests/menus/context_menu.rs b/src/input/state/tests/menus/context_menu.rs index c6e05f221..d5dc5d4a7 100644 --- a/src/input/state/tests/menus/context_menu.rs +++ b/src/input/state/tests/menus/context_menu.rs @@ -516,6 +516,112 @@ fn boards_menu_disables_delete_for_transparent_board_and_shows_overflow_entry() assert!(delete_entry.disabled); } +fn menu_entry(state: &InputState, command: MenuCommand) -> ContextMenuEntry { + state + .context_menu_entries() + .into_iter() + .find(|entry| entry.command == Some(command.clone())) + .unwrap_or_else(|| panic!("missing {command:?} entry")) +} + +#[test] +fn boards_menu_edits_active_board_paper_and_skips_the_overlay() { + let mut state = create_test_input_state(); + state.switch_board(BOARD_ID_WHITEBOARD); + + state.open_context_menu((0, 0), Vec::new(), ContextMenuKind::Boards, None); + assert!(!menu_entry(&state, MenuCommand::BoardEditPaper).disabled); + state.execute_menu_command(MenuCommand::BoardEditPaper); + + assert!(!state.is_context_menu_open()); + assert!(state.is_board_picker_open()); + assert_eq!( + state.board_appearance_edit().map(|edit| edit.board_id()), + Some(BOARD_ID_WHITEBOARD) + ); + + state.close_board_picker(); + state.switch_board(BOARD_ID_TRANSPARENT); + state.open_context_menu((0, 0), Vec::new(), ContextMenuKind::Boards, None); + assert!(menu_entry(&state, MenuCommand::BoardEditPaper).disabled); +} + +#[test] +fn board_row_menu_edits_renames_and_pins_its_own_board() { + let mut state = create_test_input_state(); + let blackboard = board_index(&state, BOARD_ID_BLACKBOARD); + let overlay = board_index(&state, BOARD_ID_TRANSPARENT); + state.open_board_picker_with_measurer(&crate::draw::TextMeasurer::default()); + + state.open_board_context_menu((5, 5), overlay); + assert_eq!(state.context_menu_entries()[0].label, "Overlay"); + assert!(menu_entry(&state, MenuCommand::BoardEditPaperFromContext).disabled); + + state.open_board_context_menu((5, 5), blackboard); + assert!( + state.is_board_picker_open(), + "row menus keep the picker open" + ); + assert_eq!(state.context_menu_entries()[0].label, "Blackboard"); + state.execute_menu_command(MenuCommand::BoardEditPaperFromContext); + assert!(!state.is_context_menu_open()); + assert_eq!( + state.board_appearance_edit().map(|edit| edit.board_id()), + Some(BOARD_ID_BLACKBOARD) + ); + + state.board_picker_cancel_edit(); + state.open_board_context_menu((5, 5), blackboard); + state.execute_menu_command(MenuCommand::BoardRenameFromContext); + let row = state.board_picker_row_for_board(blackboard).unwrap(); + assert_eq!( + state + .board_picker_edit_state() + .map(|(mode, index, _)| (mode, index)), + Some((crate::input::state::BoardPickerEditMode::Name, row)) + ); + + state.board_picker_cancel_edit(); + let _ = state.take_pending_board_runtime_ui_actions(); + state.open_board_context_menu((5, 5), blackboard); + state.execute_menu_command(MenuCommand::BoardTogglePinFromContext); + assert!(matches!( + state.take_pending_board_runtime_ui_actions().as_slice(), + [crate::input::boards::PendingBoardRuntimeUiAction::TogglePin { board_id, .. }] + if board_id == BOARD_ID_BLACKBOARD + )); +} + +#[test] +fn right_clicking_a_board_row_selects_it_and_opens_its_menu() { + let mut state = create_test_input_state(); + let blackboard = board_index(&state, BOARD_ID_BLACKBOARD); + state.open_board_picker_with_measurer(&crate::draw::TextMeasurer::default()); + let surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 1280, 720).unwrap(); + let ctx = cairo::Context::new(&surface).unwrap(); + state.update_board_picker_layout(&ctx, 1280, 720); + let layout = *state.board_picker_layout().unwrap(); + let row = state.board_picker_row_for_board(blackboard).unwrap(); + let x = (layout.origin_x + layout.padding_x + 60.0) as i32; + let y = (layout.origin_y + + layout.padding_y + + layout.header_height + + layout.row_height * (row as f64 + 0.5)) as i32; + + assert!(state.handle_board_picker_press(crate::input::MouseButton::Right, x, y)); + + assert!(state.is_board_picker_open()); + assert_eq!(state.board_picker_selected_index(), Some(row)); + assert!(matches!( + state.context_menu.state, + ContextMenuState::Open { + kind: ContextMenuKind::Board, + .. + } + )); + assert_eq!(state.context_menu_entries()[0].label, "Blackboard"); +} + #[test] fn open_pages_menu_command_switches_to_pages_submenu_with_actionable_focus() { let mut state = create_test_input_state(); diff --git a/src/ui/board_picker/appearance.rs b/src/ui/board_picker/appearance.rs index b1c2f66a5..4565766ae 100644 --- a/src/ui/board_picker/appearance.rs +++ b/src/ui/board_picker/appearance.rs @@ -1,13 +1,23 @@ use crate::domain::BoardGridKind; use crate::input::InputState; use crate::input::state::AppearanceField; -use crate::ui::constants::{self, INPUT_CARET, OVERLAY_DIM_MEDIUM, TEXT_HINT, TEXT_PRIMARY}; -use crate::ui::primitives::draw_rounded_rect; +use crate::ui::constants::{ + self, ACCENT_PRIMARY, BG_INPUT_SELECTION, INPUT_CARET, OVERLAY_DIM_MEDIUM, TEXT_HINT, + TEXT_PRIMARY, TEXT_WHITE, +}; +use crate::ui::primitives::{draw_rounded_rect, text_extents_for_with_engine}; use crate::ui::theme::{Rgba, popup}; use crate::ui_text::{UiTextEngine, UiTextStyle}; use super::palette::{PALETTE_SWATCH_GAP, PALETTE_SWATCH_SIZE}; +const SLIDER_RAIL: Rgba = (1.0, 1.0, 1.0, 0.18); +const SLIDER_THUMB_EDGE: Rgba = (0.0, 0.0, 0.0, 0.35); +const FIELD_INVALID: Rgba = (0.90, 0.35, 0.30, 0.9); +/// Secondary button: a quiet fill and border beside the accent-filled Apply. +const BUTTON_SECONDARY_BG: Rgba = (0.25, 0.25, 0.30, 0.95); +const BUTTON_SECONDARY_BORDER: Rgba = (0.40, 0.40, 0.45, 0.8); + const SHEET_RADIUS: f64 = 10.0; /// Two-layer drop shadow that lifts the sheet off the dimmed picker. const SHEET_SHADOW_SOFT: Rgba = (0.0, 0.0, 0.0, 0.25); @@ -27,12 +37,13 @@ pub(super) fn render( let Some(edit) = input.board_appearance_edit() else { return; }; - let (Some((x, y, width)), Some(header)) = ( - input.board_appearance_rect(), + let (Some(frame), Some(header)) = ( + input.board_appearance_frame(), input.board_appearance_header(), ) else { return; }; + let width = frame.width; let style = UiTextStyle { family: "Sans", slant: cairo::FontSlant::Normal, @@ -41,7 +52,12 @@ pub(super) fn render( }; let _ = ctx.save(); draw_backdrop(ctx, input, screen_width, screen_height); - draw_frame(ctx, x, y, width); + // The sheet is laid out in sheet units from its content origin and drawn + // magnified, so text, controls, and strokes scale together. + ctx.translate(frame.x, frame.y); + ctx.scale(frame.scale, frame.scale); + let outline = frame.outline(); + draw_frame(ctx, outline); let board_name = input .boards @@ -50,7 +66,7 @@ pub(super) fn render( .find(|board| board.spec.id == edit.board_id()) .map_or("", |board| board.spec.name.as_str()); let _ = ctx.save(); - ctx.rectangle(x, y - 66.0, (header.title_right - x).max(0.0), 32.0); + ctx.rectangle(0.0, -66.0, header.title_right.max(0.0), 32.0); ctx.clip(); constants::set_color(ctx, TEXT_PRIMARY); engine.draw_baseline( @@ -60,8 +76,8 @@ pub(super) fn render( ..style }, &format!("Paper — {board_name}"), - x + 4.0, - y - 45.0, + 4.0, + -45.0, None, ); let _ = ctx.restore(); @@ -117,23 +133,23 @@ pub(super) fn render( constants::set_color(ctx, HEADER_RULE); ctx.set_line_width(1.0); - ctx.move_to(x - 12.0, y - 33.5); - ctx.line_to(x + width + 12.0, y - 33.5); + ctx.move_to(outline.0, -33.5); + ctx.line_to(outline.0 + outline.2, -33.5); let _ = ctx.stroke(); for (index, color) in super::helpers::BOARD_PALETTE.iter().enumerate() { ctx.set_source_rgb(color.r, color.g, color.b); ctx.rectangle( - x + index as f64 * width / 11.0 + 1.0, - y - 28.0, + index as f64 * width / 11.0 + 1.0, + -28.0, width / 11.0 - 3.0, 21.0, ); let _ = ctx.fill(); } for (index, kind) in BoardGridKind::ALL.iter().enumerate() { - let left = x + (index % 2) as f64 * width / 2.0; - let top = y + (index / 2) as f64 * 28.0; + let left = (index % 2) as f64 * width / 2.0; + let top = (index / 2) as f64 * 28.0; constants::set_color( ctx, if edit.kind == *kind { @@ -146,69 +162,55 @@ pub(super) fn render( let _ = ctx.stroke(); engine.draw_baseline(ctx, style, kind.label(), left + 6.0, top + 17.0, None); } - constants::set_color( - ctx, - if edit.focus == AppearanceField::Spacing { - INPUT_CARET - } else { - TEXT_PRIMARY - }, - ); - let spacing_label = match edit.kind { - BoardGridKind::None => "Spacing", - BoardGridKind::Cartesian => "Square side", - BoardGridKind::Isometric | BoardGridKind::IsometricDots => "Triangle side", - }; - engine.draw_baseline( - ctx, - style, - &format!("{spacing_label}: {} px", edit.spacing), - x + 4.0, - y + 79.0, - None, - ); - for (offset, preset) in [(80.0, 20), (40.0, 40)] { - // Selection follows the size value; field focus only colors the label. - constants::set_color( + if let Some(row) = input.board_appearance_size_row() { + draw_size_row( + engine, ctx, - if edit.spacing_matches(preset) { - INPUT_CARET - } else { - TEXT_HINT + SizeRowPaint { + label_x: 4.0, + label_right: row.label_right, + label: match edit.kind { + BoardGridKind::None => "Spacing", + BoardGridKind::Cartesian => "Square side", + BoardGridKind::Isometric | BoardGridKind::IsometricDots => "Triangle side", + }, + slider: (row.track.2 > 0.0).then_some((row.rail, row.thumb_x, row.thumb_radius)), + field: row.field, + text: &edit.spacing, + focused: edit.focus == AppearanceField::Spacing, + armed: edit.spacing_armed, + dragging: edit.size_dragging, + valid: edit.size_is_valid(), }, - ); - ctx.rectangle(x + width - offset, y + 60.0, 36.0, 25.0); - let _ = ctx.stroke(); - engine.draw_baseline( - ctx, style, - &preset.to_string(), - x + width - offset + 9.0, - y + 78.0, - None, ); } let _ = ctx.save(); - ctx.rectangle(x, y + 94.0, width, 56.0); + ctx.rectangle(0.0, 94.0, width, 56.0); ctx.clip(); - ctx.translate(x, y + 94.0); + ctx.translate(0.0, 94.0); + // The pattern stays at board size so the preview shows the real spacing. + ctx.scale(1.0 / frame.scale, 1.0 / frame.scale); let (color, grid) = edit.preview(); if let Ok(paper) = crate::draw::BoardPaper::for_context(color, grid, ctx) { let _ = paper.paint(ctx); } let _ = ctx.restore(); - for (offset, label) in [(0.0, "Apply"), (width / 2.0, "Cancel")] { - constants::set_color( + if let Some(buttons) = input.board_appearance_buttons() { + draw_button( + engine, ctx, - if label == "Apply" && edit.validation_error().is_some() { - TEXT_HINT - } else { - TEXT_PRIMARY - }, + buttons.cancel, + "Cancel", + ButtonStyle::Secondary, + style, ); - ctx.rectangle(x + offset + 1.0, y + 160.0, width / 2.0 - 5.0, 26.0); - let _ = ctx.stroke(); - engine.draw_baseline(ctx, style, label, x + offset + 8.0, y + 178.0, None); + let apply_style = if edit.validation_error().is_none() { + ButtonStyle::Primary + } else { + ButtonStyle::Disabled + }; + draw_button(engine, ctx, buttons.apply, "Apply", apply_style, style); } constants::set_color(ctx, TEXT_HINT); let message = edit @@ -223,8 +225,8 @@ pub(super) fn render( ..style }, message, - x, - y + 202.0, + 0.0, + 202.0, Some(width), ); let _ = ctx.restore(); @@ -275,8 +277,7 @@ fn rounded_sub_path(ctx: &cairo::Context, x: f64, y: f64, width: f64, height: f6 ctx.close_path(); } -fn draw_frame(ctx: &cairo::Context, x: f64, y: f64, width: f64) { - let (left, top, frame_width, frame_height) = (x - 12.0, y - 70.0, width + 24.0, 292.0); +fn draw_frame(ctx: &cairo::Context, (left, top, frame_width, frame_height): (f64, f64, f64, f64)) { for (offset, shadow) in [(10.0, SHEET_SHADOW_SOFT), (4.0, SHEET_SHADOW)] { constants::set_color(ctx, shadow); draw_rounded_rect( @@ -297,3 +298,189 @@ fn draw_frame(ctx: &cairo::Context, x: f64, y: f64, width: f64) { ctx.set_line_width(1.0); let _ = ctx.stroke(); } + +struct SizeRowPaint<'a> { + label_x: f64, + label_right: f64, + label: &'a str, + /// Rail `(start_x, end_x, center_y)`, thumb x, and thumb radius. + slider: Option<((f64, f64, f64), f64, f64)>, + field: (f64, f64, f64, f64), + text: &'a str, + focused: bool, + /// Typing replaces the size, shown as a selection. + armed: bool, + dragging: bool, + valid: bool, +} + +/// Label, logarithmic size slider, and editable pixel field. +fn draw_size_row( + engine: &UiTextEngine, + ctx: &cairo::Context, + row: SizeRowPaint<'_>, + style: UiTextStyle<'_>, +) { + let (field_x, field_y, field_width, field_height) = row.field; + let baseline = field_y + field_height / 2.0 + 4.5; + + let _ = ctx.save(); + ctx.rectangle( + row.label_x, + field_y - 4.0, + (row.label_right - row.label_x).max(0.0), + field_height + 8.0, + ); + ctx.clip(); + constants::set_color( + ctx, + if row.focused { + INPUT_CARET + } else { + TEXT_PRIMARY + }, + ); + engine.draw_baseline(ctx, style, row.label, row.label_x, baseline, None); + let _ = ctx.restore(); + + if let Some(((start, end, center_y), thumb_x, radius)) = row.slider { + draw_rounded_rect(ctx, start, center_y - 2.0, end - start, 4.0, 2.0); + constants::set_color(ctx, SLIDER_RAIL); + let _ = ctx.fill(); + if thumb_x > start { + draw_rounded_rect(ctx, start, center_y - 2.0, thumb_x - start, 4.0, 2.0); + constants::set_color(ctx, ACCENT_PRIMARY); + let _ = ctx.fill(); + } + + ctx.new_sub_path(); + ctx.arc(thumb_x, center_y, radius, 0.0, std::f64::consts::TAU); + constants::set_color(ctx, TEXT_PRIMARY); + let _ = ctx.fill_preserve(); + constants::set_color( + ctx, + if row.focused || row.dragging { + INPUT_CARET + } else { + SLIDER_THUMB_EDGE + }, + ); + ctx.set_line_width(if row.dragging { 2.5 } else { 1.5 }); + let _ = ctx.stroke(); + } + + draw_rounded_rect(ctx, field_x, field_y, field_width, field_height, 4.0); + constants::set_color(ctx, FIELD_BG); + let _ = ctx.fill_preserve(); + let (border, border_width) = if !row.valid { + (FIELD_INVALID, 1.5) + } else if row.focused { + (INPUT_CARET, 1.5) + } else { + (FIELD_BORDER, 1.0) + }; + constants::set_color(ctx, border); + ctx.set_line_width(border_width); + let _ = ctx.stroke(); + + let _ = ctx.save(); + ctx.rectangle(field_x + 2.0, field_y, field_width - 4.0, field_height); + ctx.clip(); + let text_x = field_x + 8.0; + let advance = text_extents_for_with_engine( + engine, + ctx, + style.family, + style.slant, + style.weight, + style.size, + row.text, + ) + .x_advance(); + if row.focused && row.armed && !row.text.is_empty() { + constants::set_color(ctx, BG_INPUT_SELECTION); + ctx.rectangle( + text_x - 2.0, + field_y + 5.0, + advance + 4.0, + field_height - 10.0, + ); + let _ = ctx.fill(); + } + constants::set_color(ctx, TEXT_PRIMARY); + engine.draw_baseline(ctx, style, row.text, text_x, baseline, None); + constants::set_color(ctx, TEXT_HINT); + engine.draw_baseline( + ctx, + UiTextStyle { + size: 11.0, + ..style + }, + "px", + text_x + advance + 4.0, + baseline, + None, + ); + let _ = ctx.restore(); +} + +#[derive(Clone, Copy)] +enum ButtonStyle { + Primary, + Disabled, + Secondary, +} + +fn draw_button( + engine: &UiTextEngine, + ctx: &cairo::Context, + (x, y, width, height): (f64, f64, f64, f64), + label: &str, + button: ButtonStyle, + style: UiTextStyle<'_>, +) { + draw_rounded_rect(ctx, x, y, width, height, 6.0); + let text = match button { + ButtonStyle::Primary => { + constants::set_color(ctx, ACCENT_PRIMARY); + let _ = ctx.fill(); + TEXT_WHITE + } + ButtonStyle::Disabled => { + constants::set_color(ctx, constants::with_alpha(ACCENT_PRIMARY, 0.3)); + let _ = ctx.fill(); + constants::with_alpha(TEXT_WHITE, 0.5) + } + ButtonStyle::Secondary => { + constants::set_color(ctx, BUTTON_SECONDARY_BG); + let _ = ctx.fill_preserve(); + constants::set_color(ctx, BUTTON_SECONDARY_BORDER); + ctx.set_line_width(1.0); + let _ = ctx.stroke(); + TEXT_PRIMARY + } + }; + + let bold = UiTextStyle { + weight: cairo::FontWeight::Bold, + ..style + }; + let extents = text_extents_for_with_engine( + engine, + ctx, + bold.family, + bold.slant, + bold.weight, + bold.size, + label, + ); + constants::set_color(ctx, text); + engine.draw_baseline( + ctx, + bold, + label, + x + (width - extents.width()) / 2.0, + y + height / 2.0 + 4.5, + None, + ); +} diff --git a/src/ui/board_picker/tests.rs b/src/ui/board_picker/tests.rs index 0aa9023b4..5f49faeea 100644 --- a/src/ui/board_picker/tests.rs +++ b/src/ui/board_picker/tests.rs @@ -34,7 +34,7 @@ fn pixels( #[test] #[ignore = "isolated by tools/lint-and-test.sh; Cairo race: https://gitlab.freedesktop.org/cairo/cairo/-/merge_requests/81"] fn retained_board_text_owner_matches_fresh_during_unicode_rename_and_small_layouts() { - check_appearance_sheet_on_small_surfaces(); + check_appearance_sheet_fits_each_surface(); let engine = UiTextEngine::default(); let measurer = crate::draw::TextMeasurer::default(); let mut caches = crate::draw::RenderCaches::default(); @@ -97,7 +97,7 @@ fn retained_board_text_owner_matches_fresh_during_unicode_rename_and_small_layou } } -fn check_appearance_sheet_on_small_surfaces() { +fn check_appearance_sheet_fits_each_surface() { let engine = UiTextEngine::default(); let measurer = crate::draw::TextMeasurer::default(); let mut state = crate::input::state::test_support::make_test_input_state(); @@ -107,13 +107,13 @@ fn check_appearance_sheet_on_small_surfaces() { state.board_appearance_key(crate::input::events::Key::Tab); state.board_appearance_key(crate::input::events::Key::Right); state.board_appearance_key(crate::input::events::Key::Right); - for (width, height) in [(900, 700), (420, 300)] { + for (width, height) in [(1920, 1080), (1280, 720), (900, 700), (420, 300)] { let surface = cairo::ImageSurface::create(cairo::Format::ARgb32, width, height).unwrap(); let ctx = cairo::Context::new(&surface).unwrap(); state.update_board_picker_layout(&ctx, width as u32, height as u32); - let (x, y, w) = state.board_appearance_rect().unwrap(); - assert!(x >= 12.0 && x + w + 12.0 <= f64::from(width)); - assert!(y >= 70.0 && y + 222.0 <= f64::from(height)); + let (x, y, w, h) = state.board_appearance_frame().unwrap().bounds(); + assert!(x >= 0.0 && x + w <= f64::from(width)); + assert!(y >= 0.0 && y + h <= f64::from(height)); let data = pixels( &engine, &measurer, From 84e543645788576c710a895ff4e1a077aa7fbe72 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Sun, 13 Sep 2026 14:55:20 +0200 Subject: [PATCH 4/6] feat(menus): add cascading submenus with delayed hover navigation --- docs/CONFIG.md | 4 + src/backend/wayland/backend/event_loop/mod.rs | 2 + src/backend/wayland/state/render/prepare.rs | 4 + src/backend/wayland/state/render/runtime.rs | 6 +- src/backend/wayland/state/render/ui.rs | 18 +- .../wayland/state/render/ui_effect_damage.rs | 22 + src/input/state/actions/key_press/panels.rs | 15 +- src/input/state/core/menus/commands.rs | 66 +-- src/input/state/core/menus/context_menu.rs | 73 ++- src/input/state/core/menus/entries/boards.rs | 42 +- src/input/state/core/menus/entries/canvas.rs | 49 +- src/input/state/core/menus/entries/mod.rs | 58 +- src/input/state/core/menus/entries/page.rs | 27 +- src/input/state/core/menus/entries/pages.rs | 60 +- src/input/state/core/menus/entries/shape.rs | 25 +- src/input/state/core/menus/entries/zoom.rs | 31 +- src/input/state/core/menus/focus.rs | 128 +++-- src/input/state/core/menus/hover.rs | 253 ++++++++- src/input/state/core/menus/layout.rs | 324 ++++++----- src/input/state/core/menus/lifecycle.rs | 13 +- src/input/state/core/menus/mod.rs | 4 +- src/input/state/core/menus/submenu.rs | 149 +++++ src/input/state/core/menus/types.rs | 41 +- src/input/state/core/mod.rs | 4 +- src/input/state/interaction/mod.rs | 4 +- src/input/state/mod.rs | 27 +- src/input/state/mouse/press/panels.rs | 11 +- src/input/state/mouse/release/panels.rs | 16 +- src/input/state/tests/menus/context_menu.rs | 105 ++-- src/input/state/tests/menus/mod.rs | 1 + src/input/state/tests/menus/submenu.rs | 512 ++++++++++++++++++ src/ui.rs | 4 +- src/ui/board_picker/appearance.rs | 2 +- src/ui/context_menu.rs | 286 ++++++++-- src/ui/theme.rs | 18 + 35 files changed, 1798 insertions(+), 606 deletions(-) create mode 100644 src/input/state/core/menus/submenu.rs create mode 100644 src/input/state/tests/menus/submenu.rs diff --git a/docs/CONFIG.md b/docs/CONFIG.md index 46dcc64bf..e121fe12d 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -1514,6 +1514,10 @@ default_pen_color = { rgb = [0.969, 0.890, 0.784] } - 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**. +- Submenus such as **Zoom**, **Boards**, and **Pages** open beside their row once the pointer + rests on it, or on click, and the menu stays open. Clicking the row again collapses it. From + the keyboard, → opens a submenu and ← or Esc returns to its row. The parent row shows the + submenu's current state, such as the zoom level or the active page. - Right-click menus expose **Paste**; shape menus also expose **Copy** for the selected annotations. - Pan offsets are stored per page, so each page keeps its own position. diff --git a/src/backend/wayland/backend/event_loop/mod.rs b/src/backend/wayland/backend/event_loop/mod.rs index 8acb4a6bb..220a6cf42 100644 --- a/src/backend/wayland/backend/event_loop/mod.rs +++ b/src/backend/wayland/backend/event_loop/mod.rs @@ -226,6 +226,7 @@ fn advance_post_dispatch_state( state.input_state.needs_redraw = true; } state.input_state.tick_radial_menu_paint(Instant::now()); + state.input_state.tick_context_menu_hover(Instant::now()); capture::handle_pending_actions(state, qh); if break_on_requested_exit(state) { return true; @@ -311,6 +312,7 @@ fn event_loop_timeout( durable_action_retry_timeout(state, now), pending_backend_action_timeout, state.input_state.radial_menu_paint_timeout(now), + state.input_state.context_menu_hover_timeout(now), state.key_repeat_timeout(now), state.input_state.sequence_timeout(now), ] diff --git a/src/backend/wayland/state/render/prepare.rs b/src/backend/wayland/state/render/prepare.rs index cfe1d612a..2d6b33987 100644 --- a/src/backend/wayland/state/render/prepare.rs +++ b/src/backend/wayland/state/render/prepare.rs @@ -159,6 +159,10 @@ impl WaylandState { UiEffect::ColorPicker, render_ui && self.input_state.is_color_picker_popup_open(), ) + .with( + UiEffect::ContextMenu, + render_ui && self.input_state.is_context_menu_open(), + ) .with( UiEffect::ToolPreview, render_ui && self.mouse_tool_preview_eligible(), diff --git a/src/backend/wayland/state/render/runtime.rs b/src/backend/wayland/state/render/runtime.rs index 6516b1e09..b0146fb2c 100644 --- a/src/backend/wayland/state/render/runtime.rs +++ b/src/backend/wayland/state/render/runtime.rs @@ -15,10 +15,12 @@ pub(super) enum UiEffect { ToolPreview, ShapeMeasureBadge, OcrScan, + ContextMenu, + ContextSubmenu, } impl UiEffect { - const COUNT: usize = 11; + const COUNT: usize = 13; const fn index(self) -> usize { self as usize @@ -284,6 +286,8 @@ mod tests { UiEffect::ToolPreview, UiEffect::ShapeMeasureBadge, UiEffect::OcrScan, + UiEffect::ContextMenu, + UiEffect::ContextSubmenu, ]; let mut history = UiDamageHistory::default(); diff --git a/src/backend/wayland/state/render/ui.rs b/src/backend/wayland/state/render/ui.rs index 0b20e8ca1..22f2fd2a3 100644 --- a/src/backend/wayland/state/render/ui.rs +++ b/src/backend/wayland/state/render/ui.rs @@ -384,23 +384,11 @@ impl WaylandState { width, height, ); - if self.input_state.is_context_menu_open() { - self.input_state.update_context_menu_layout_with_engine( - self.render.ui_text(), - ctx, - width, - height, - ); - } else { + // An open menu was already laid out along with this frame's damage. + if !self.input_state.is_context_menu_open() { self.input_state.clear_context_menu_layout(); } - crate::ui::render_context_menu_with_engine( - self.render.ui_text(), - ctx, - &self.input_state, - width, - height, - ); + crate::ui::render_context_menu_with_engine(self.render.ui_text(), ctx, &self.input_state); } fn render_inline_and_modal_ui( diff --git a/src/backend/wayland/state/render/ui_effect_damage.rs b/src/backend/wayland/state/render/ui_effect_damage.rs index a85a127ef..2ec38d837 100644 --- a/src/backend/wayland/state/render/ui_effect_damage.rs +++ b/src/backend/wayland/state/render/ui_effect_damage.rs @@ -253,6 +253,28 @@ impl WaylandState { .ui_damage_mut() .roll(UiEffect::ColorPicker, color_picker_rect, &mut regions); + // Context menus lay out here, once per frame before painting, so damage, + // painting, and pointer hit-testing share one layout. The submenu has its + // own slot, so opening, switching, or closing it repaints only its pane. + let (menu_rect, submenu_rect) = if flags.active(UiEffect::ContextMenu) { + self.input_state.update_context_menu_layout_with_engine( + self.render.ui_text(), + width, + height, + ); + ( + crate::ui::context_menu_visual_geometry(&self.input_state) + .and_then(|bounds| effect_rect(bounds, width, height)), + crate::ui::context_submenu_visual_geometry(&self.input_state) + .and_then(|bounds| effect_rect(bounds, width, height)), + ) + } else { + (None, None) + }; + let history = self.render.ui_damage_mut(); + history.roll(UiEffect::ContextMenu, menu_rect, &mut regions); + history.roll(UiEffect::ContextSubmenu, submenu_rect, &mut regions); + let preview_position = self.stylus_hover_cursor_position().unwrap_or_else(|| { let (x, y) = self.pointer.position(); (x as f64, y as f64) diff --git a/src/input/state/actions/key_press/panels.rs b/src/input/state/actions/key_press/panels.rs index 9fbac15a9..4b5133f4a 100644 --- a/src/input/state/actions/key_press/panels.rs +++ b/src/input/state/actions/key_press/panels.rs @@ -446,8 +446,21 @@ impl InputState { key: Key, ) -> bool { match key { + // Escape and Left step back out of a submenu; Escape then closes. Key::Escape => { - self.close_context_menu(); + if !self.close_context_submenu(true) { + self.close_context_menu(); + } + true + } + // The menu owns every arrow key while open, so a Left with no + // submenu or a Right on a plain row cannot reach the canvas. + Key::Left => { + self.close_context_submenu(true); + true + } + Key::Right => { + self.open_focused_context_submenu(); true } Key::Up => self.focus_previous_context_menu_entry(), diff --git a/src/input/state/core/menus/commands.rs b/src/input/state/core/menus/commands.rs index c4abbff93..2f45ed0ce 100644 --- a/src/input/state/core/menus/commands.rs +++ b/src/input/state/core/menus/commands.rs @@ -1,5 +1,5 @@ use super::super::base::{InputState, PasteAnchor}; -use super::types::{ContextMenuKind, ContextMenuState, MenuCommand}; +use super::types::{ContextMenuState, MenuCommand}; use crate::domain::Action; use crate::draw::ShapeId; use crate::input::state::{Toast, ToastPriority}; @@ -49,19 +49,6 @@ impl InputState { true } - fn context_submenu_anchor(&self) -> (i32, i32) { - 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.pointer.screen() - } - } - fn select_hovered_context_menu_shape_with(&mut self, measurer: &crate::draw::TextMeasurer) { if let Some(hovered_shape) = self.hovered_context_menu_shape() { let previous_ids = self.selected_shape_ids().to_vec(); @@ -169,14 +156,13 @@ impl InputState { self.reset_active_canvas_position(); self.close_context_menu(); } - MenuCommand::OpenZoomMenu => { - let anchor = self.context_submenu_anchor(); - self.open_context_menu(anchor, Vec::new(), ContextMenuKind::Zoom, None); - self.pointer.clear_menu_hover_recalc(); - self.set_context_menu_focus(None); - self.focus_first_context_menu_entry(); - self.dirty_tracker.mark_full(); - self.needs_redraw = true; + MenuCommand::OpenZoomMenu + | MenuCommand::OpenPagesMenu + | MenuCommand::OpenBoardsMenu + | MenuCommand::OpenPageMoveMenu => { + // Beside the parent row that holds it, keeping this menu open; + // on its own when no such row is open. + self.open_menu_for_command(&command); } MenuCommand::ZoomIn => { self.request_zoom_action(crate::input::ZoomAction::In); @@ -197,37 +183,6 @@ impl InputState { self.handle_action_with_resources(resources, Action::ToggleHighlightTool); self.close_context_menu(); } - MenuCommand::OpenPagesMenu => { - let anchor = self.context_submenu_anchor(); - self.open_context_menu(anchor, Vec::new(), ContextMenuKind::Pages, None); - self.pointer.clear_menu_hover_recalc(); - self.set_context_menu_focus(None); - self.focus_first_context_menu_entry(); - // Mark full screen dirty to ensure submenu renders completely - self.dirty_tracker.mark_full(); - self.needs_redraw = true; - } - MenuCommand::OpenBoardsMenu => { - let anchor = self.context_submenu_anchor(); - self.open_context_menu(anchor, Vec::new(), ContextMenuKind::Boards, None); - self.pointer.clear_menu_hover_recalc(); - self.set_context_menu_focus(None); - self.focus_first_context_menu_entry(); - // Mark full screen dirty to ensure submenu renders completely - self.dirty_tracker.mark_full(); - self.needs_redraw = true; - } - MenuCommand::OpenPageMoveMenu => { - let anchor = self.context_submenu_anchor(); - let target = self.context_menu.page_target; - self.open_context_menu(anchor, Vec::new(), ContextMenuKind::PageMove, None); - self.context_menu.page_target = target; - self.pointer.clear_menu_hover_recalc(); - self.set_context_menu_focus(None); - self.focus_first_context_menu_entry(); - self.dirty_tracker.mark_full(); - self.needs_redraw = true; - } MenuCommand::PagePrev => { self.page_prev_with_measurer(resources.measurer); self.close_context_menu(); @@ -327,7 +282,10 @@ impl InputState { } MenuCommand::OpenBoardPicker => { self.close_context_menu(); - self.toggle_board_picker_with_measurer(resources.measurer); + // A menu opened from the picker itself leaves it open. + if !self.is_board_picker_open() { + self.open_board_picker_with_measurer(resources.measurer); + } } MenuCommand::BoardPrev => { self.switch_board_prev_with_measurer(resources.measurer); diff --git a/src/input/state/core/menus/context_menu.rs b/src/input/state/core/menus/context_menu.rs index 1f8c8643b..3978346f0 100644 --- a/src/input/state/core/menus/context_menu.rs +++ b/src/input/state/core/menus/context_menu.rs @@ -1,7 +1,25 @@ +use std::time::Instant; + use super::super::board_picker::BoardPickerPageTarget; -use super::{ContextMenuKind, ContextMenuLayout, ContextMenuState}; +use super::{ContextMenuKind, ContextMenuLayout, ContextMenuState, SubmenuSide}; use crate::draw::ShapeId; +/// Where the pointer was at the previous hover update, and when. A move from +/// here toward an open submenu keeps it open while the pointer crosses other +/// rows, for as long as the sample is fresh. +#[derive(Debug, Clone, Copy)] +pub(in crate::input::state) struct AimSample { + pub(in crate::input::state) point: (f64, f64), + pub(in crate::input::state) at: Instant, +} + +/// A hover change waiting for the pointer to rest. At `due` the row under the +/// pointer decides which submenu is open. +#[derive(Debug, Clone, Copy)] +pub(in crate::input::state) struct PendingHover { + pub(in crate::input::state) due: Instant, +} + /// Lifecycle, target, and cached layout for the context menu. #[derive(Debug)] pub struct ContextMenuPanel { @@ -11,6 +29,14 @@ pub struct ContextMenuPanel { pub(in crate::input::state) board_target: Option, pub(in crate::input::state) enabled: bool, pub(in crate::input::state) layout: Option, + pub(in crate::input::state) submenu_layout: Option, + /// Which side of the menu submenus open on for this layout. + pub(in crate::input::state) submenu_side: SubmenuSide, + pub(in crate::input::state) aim: Option, + pub(in crate::input::state) pending_hover: Option, + /// A parent row collapsed by a click keeps its submenu shut while the + /// pointer stays on it. + pub(in crate::input::state) hover_open_suppressed: Option, /// An outside left press dismissed the menu but still owns its release. dismissal_release_pending: bool, } @@ -32,8 +58,17 @@ impl ContextMenuPanel { self.layout.as_ref() } + pub fn submenu_layout(&self) -> Option<&ContextMenuLayout> { + self.submenu_layout.as_ref() + } + + pub fn submenu_side(&self) -> SubmenuSide { + self.submenu_side + } + pub(crate) fn clear_layout(&mut self) { self.layout = None; + self.submenu_layout = None; } pub(in crate::input::state) fn set_dismissal_release_pending(&mut self) { @@ -44,12 +79,14 @@ impl ContextMenuPanel { std::mem::take(&mut self.dismissal_release_pending) } - pub(crate) fn close(&mut self) -> Option { - let layout = self.layout.take(); + /// Closes the menu and any submenu. The frame damage history repaints the + /// area they covered. + pub(crate) fn close(&mut self) { + self.clear_layout(); self.state = ContextMenuState::Hidden; self.page_target = None; self.board_target = None; - layout + self.reset_hover_timing(); } pub(crate) fn open( @@ -58,10 +95,11 @@ impl ContextMenuPanel { shape_ids: Vec, kind: ContextMenuKind, hovered_shape_id: Option, - ) -> Option { - let layout = self.layout.take(); + ) { + self.clear_layout(); self.page_target = None; self.board_target = None; + self.reset_hover_timing(); self.state = ContextMenuState::Open { anchor, shape_ids, @@ -69,8 +107,14 @@ impl ContextMenuPanel { hover_index: None, keyboard_focus: None, hovered_shape_id, + submenu: None, }; - layout + } + + fn reset_hover_timing(&mut self) { + self.aim = None; + self.pending_hover = None; + self.hover_open_suppressed = None; } pub(crate) fn set_page_target(&mut self, board_index: usize, page_index: usize) { @@ -98,6 +142,11 @@ impl Default for ContextMenuPanel { board_target: None, enabled: true, layout: None, + submenu_layout: None, + submenu_side: SubmenuSide::Right, + aim: None, + pending_hover: None, + hover_open_suppressed: None, dismissal_release_pending: false, } } @@ -113,21 +162,19 @@ mod tests { panel.set_page_target(2, 3); panel.set_board_target("whiteboard".to_string()); - assert!( - panel - .open((10, 20), Vec::new(), ContextMenuKind::Canvas, None) - .is_none() - ); + panel.open((10, 20), Vec::new(), ContextMenuKind::Canvas, None); assert!(panel.is_open()); assert!(panel.page_target.is_none()); assert!(panel.board_target.is_none()); panel.set_page_target(4, 5); panel.set_board_target("blackboard".to_string()); - assert!(panel.close().is_none()); + panel.hover_open_suppressed = Some(1); + panel.close(); assert!(!panel.is_open()); assert!(panel.page_target.is_none()); assert!(panel.board_target.is_none()); + assert!(panel.hover_open_suppressed.is_none()); } #[test] diff --git a/src/input/state/core/menus/entries/boards.rs b/src/input/state/core/menus/entries/boards.rs index c197a8ef1..156c29c89 100644 --- a/src/input/state/core/menus/entries/boards.rs +++ b/src/input/state/core/menus/entries/boards.rs @@ -6,7 +6,17 @@ use crate::domain::Action; const MAX_VISIBLE_BOARDS: usize = 8; impl InputState { - pub(super) fn boards_menu_entries(&self) -> Vec { + /// The active board, shown on the parent row of the boards submenu. + pub(super) fn boards_summary(&self) -> String { + format!( + "{} ({}/{})", + self.boards.active_board_name(), + self.boards.active_index() + 1, + self.boards.board_count() + ) + } + + pub(super) fn boards_menu_entries(&self, with_header: bool) -> Vec { let board_count = self.boards.board_count(); let board_index = self.boards.active_index(); let can_prev = board_count > 1; @@ -15,14 +25,14 @@ impl InputState { let mut entries = Vec::new(); // Current board indicator - let current_name = self.boards.active_board_name(); - entries.push(ContextMenuEntry::new( - format!("{} ({}/{})", current_name, board_index + 1, board_count), - None::, - false, - true, - None, - )); + if with_header { + entries.push(ContextMenuEntry::new( + self.boards_summary(), + None::, + true, + None, + )); + } // List boards for quick switching (limited to MAX_VISIBLE_BOARDS) let boards = self.boards.board_states(); @@ -42,7 +52,6 @@ impl InputState { format!(" ... {} above (open picker)", start), self.shortcut_for_action(Action::BoardPicker), false, - false, Some(MenuCommand::OpenBoardPicker), )); } @@ -57,7 +66,6 @@ impl InputState { entries.push(ContextMenuEntry::new( label, None::, - false, is_active, Some(MenuCommand::SwitchToBoard { id: board.spec.id.clone(), @@ -70,7 +78,6 @@ impl InputState { format!(" ... {} below (open picker)", board_count - end), self.shortcut_for_action(Action::BoardPicker), false, - false, Some(MenuCommand::OpenBoardPicker), )); } @@ -79,14 +86,12 @@ impl InputState { entries.push(ContextMenuEntry::new( "Previous Board", self.shortcut_for_action(Action::BoardPrev), - false, !can_prev, Some(MenuCommand::BoardPrev), )); entries.push(ContextMenuEntry::new( "Next Board", self.shortcut_for_action(Action::BoardNext), - false, !can_next, Some(MenuCommand::BoardNext), )); @@ -96,21 +101,18 @@ impl InputState { "New Board", self.shortcut_for_action(Action::BoardNew), false, - false, Some(MenuCommand::BoardNew), )); entries.push(ContextMenuEntry::new( "Duplicate Board", self.shortcut_for_action(Action::BoardDuplicate), false, - false, Some(MenuCommand::BoardDuplicate), )); // The overlay has no paper to edit. entries.push(ContextMenuEntry::new( "Edit Board Paper…", self.shortcut_for_action(Action::BoardPaperEdit), - false, self.board_is_transparent(), Some(MenuCommand::BoardEditPaper), )); @@ -120,7 +122,6 @@ impl InputState { entries.push(ContextMenuEntry::new( "Delete Board", self.shortcut_for_action(Action::BoardDelete), - false, !can_delete, Some(MenuCommand::BoardDelete), )); @@ -146,11 +147,10 @@ impl InputState { }; vec![ - ContextMenuEntry::new(board.spec.name.clone(), None::, false, true, None), + ContextMenuEntry::new(board.spec.name.clone(), None::, true, None), ContextMenuEntry::new( "Edit Paper…", Some("Ctrl+C"), - false, board.spec.background.is_transparent(), Some(MenuCommand::BoardEditPaperFromContext), ), @@ -158,14 +158,12 @@ impl InputState { "Rename Board", Some("F2"), false, - false, Some(MenuCommand::BoardRenameFromContext), ), ContextMenuEntry::new( pin_label, Some("Ctrl+P"), false, - false, Some(MenuCommand::BoardTogglePinFromContext), ), ] diff --git a/src/input/state/core/menus/entries/canvas.rs b/src/input/state/core/menus/entries/canvas.rs index afb1ba174..df3de2bf7 100644 --- a/src/input/state/core/menus/entries/canvas.rs +++ b/src/input/state/core/menus/entries/canvas.rs @@ -1,5 +1,5 @@ use super::super::super::base::InputState; -use super::super::types::{ContextMenuEntry, MenuCommand}; +use super::super::types::{ContextMenuEntry, ContextMenuKind, MenuCommand}; use crate::domain::Action; use crate::input::{BOARD_ID_BLACKBOARD, BOARD_ID_TRANSPARENT, BOARD_ID_WHITEBOARD}; @@ -29,13 +29,11 @@ impl InputState { "Paste", self.shortcut_for_action(Action::PasteSelection), false, - false, Some(MenuCommand::Paste), )); entries.push(ContextMenuEntry::new( clear_label, self.shortcut_for_action(Action::ClearCanvas), - false, clear_disabled, Some(MenuCommand::ClearAll), )); @@ -44,39 +42,29 @@ impl InputState { 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), - )); + // Parent rows show their submenu's current state in the shortcut column. + entries.push( + ContextMenuEntry::new("Zoom", Some(self.zoom_summary()), false, None) + .with_submenu(ContextMenuKind::Zoom), + ); entries.push(ContextMenuEntry::new( "Toggle Highlight (tool + click)", self.shortcut_for_action(Action::ToggleHighlightTool), false, - false, Some(MenuCommand::ToggleHighlightTool), )); - entries.push(ContextMenuEntry::new( - "Boards", - None::, - true, - false, - Some(MenuCommand::OpenBoardsMenu), - )); - entries.push(ContextMenuEntry::new( - "Pages", - None::, - true, - false, - Some(MenuCommand::OpenPagesMenu), - )); + entries.push( + ContextMenuEntry::new("Boards", Some(self.boards_summary()), false, None) + .with_submenu(ContextMenuKind::Boards), + ); + entries.push( + ContextMenuEntry::new("Pages", Some(self.pages_summary()), false, None) + .with_submenu(ContextMenuKind::Pages), + ); // Quick board switching options let current_id = self.board_id(); @@ -89,7 +77,6 @@ impl InputState { "Switch to Whiteboard", self.shortcut_for_action(Action::ToggleWhiteboard), false, - false, Some(MenuCommand::SwitchToWhiteboard), )); } @@ -98,7 +85,6 @@ impl InputState { "Switch to Blackboard", self.shortcut_for_action(Action::ToggleBlackboard), false, - false, Some(MenuCommand::SwitchToBlackboard), )); } @@ -107,7 +93,6 @@ impl InputState { "Return to Transparent", self.shortcut_for_action(Action::ReturnToTransparent), false, - false, Some(MenuCommand::ReturnToTransparent), )); if current_id == BOARD_ID_WHITEBOARD && has_blackboard { @@ -115,7 +100,6 @@ impl InputState { "Switch to Blackboard", self.shortcut_for_action(Action::ToggleBlackboard), false, - false, Some(MenuCommand::SwitchToBlackboard), )); } else if current_id == BOARD_ID_BLACKBOARD && has_whiteboard { @@ -123,7 +107,6 @@ impl InputState { "Switch to Whiteboard", self.shortcut_for_action(Action::ToggleWhiteboard), false, - false, Some(MenuCommand::SwitchToWhiteboard), )); } @@ -133,14 +116,12 @@ impl InputState { "Command Palette", self.shortcut_for_action(Action::ToggleCommandPalette), false, - false, Some(MenuCommand::OpenCommandPalette), )); entries.push(ContextMenuEntry::new( "Radial Menu", self.shortcut_for_action(Action::ToggleRadialMenu), false, - false, Some(MenuCommand::OpenRadialMenu), )); self.push_chrome_recovery_entries(&mut entries); @@ -148,14 +129,12 @@ impl InputState { "Help", self.shortcut_for_action(Action::ToggleHelp), false, - false, Some(MenuCommand::ToggleHelp), )); entries.push(ContextMenuEntry::new( "Open Config File", None::, false, - false, Some(MenuCommand::OpenConfigFile), )); entries diff --git a/src/input/state/core/menus/entries/mod.rs b/src/input/state/core/menus/entries/mod.rs index 1762064d0..dd4540ac9 100644 --- a/src/input/state/core/menus/entries/mod.rs +++ b/src/input/state/core/menus/entries/mod.rs @@ -6,8 +6,11 @@ mod shape; mod zoom; use super::super::base::InputState; -use super::types::{ContextMenuEntry, ContextMenuKind, ContextMenuState, MenuCommand}; +use super::types::{ + ContextMenuEntry, ContextMenuKind, ContextMenuLevel, ContextMenuState, MenuCommand, +}; use crate::domain::Action; +use crate::draw::ShapeId; impl InputState { /// Append the chrome recovery entries ("Show Toolbar"/"Show Status Bar") @@ -22,7 +25,6 @@ impl InputState { "Show Toolbar", self.shortcut_for_action(Action::ToggleToolbar), false, - false, Some(MenuCommand::ShowToolbar), )); } @@ -33,7 +35,6 @@ impl InputState { "Show Status Bar", self.shortcut_for_action(Action::ToggleStatusBar), false, - false, Some(MenuCommand::ShowStatusBar), )); } @@ -48,16 +49,47 @@ impl InputState { shape_ids, hovered_shape_id, .. - } => 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(), - ContextMenuKind::PageMove => self.page_move_menu_entries(), - ContextMenuKind::Board => self.board_context_menu_entries(), - }, + } => self.menu_entries(*kind, shape_ids, *hovered_shape_id, ContextMenuLevel::Root), + } + } + + /// Returns the entries of the open submenu, if any. + pub fn context_submenu_entries(&self) -> Vec { + self.context_submenu().map_or_else(Vec::new, |submenu| { + self.menu_entries(submenu.kind, &[], None, ContextMenuLevel::Submenu) + }) + } + + pub(super) fn context_menu_level_entries( + &self, + level: ContextMenuLevel, + ) -> Vec { + match level { + ContextMenuLevel::Root => self.context_menu_entries(), + ContextMenuLevel::Submenu => self.context_submenu_entries(), + } + } + + /// Builds one menu's rows. A menu that stands on its own starts with a + /// header naming its current state; as a submenu that state sits in the + /// parent row instead, so the first row lines up with the parent. + fn menu_entries( + &self, + kind: ContextMenuKind, + shape_ids: &[ShapeId], + hovered_shape_id: Option, + level: ContextMenuLevel, + ) -> Vec { + let with_header = level == ContextMenuLevel::Root; + 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(with_header), + ContextMenuKind::Pages => self.pages_menu_entries(with_header), + ContextMenuKind::Boards => self.boards_menu_entries(with_header), + ContextMenuKind::Page => self.page_context_menu_entries(), + ContextMenuKind::PageMove => self.page_move_menu_entries(), + ContextMenuKind::Board => self.board_context_menu_entries(), } } } diff --git a/src/input/state/core/menus/entries/page.rs b/src/input/state/core/menus/entries/page.rs index 0f7e436ae..a20e0d0b0 100644 --- a/src/input/state/core/menus/entries/page.rs +++ b/src/input/state/core/menus/entries/page.rs @@ -1,5 +1,5 @@ use super::super::super::base::InputState; -use super::super::types::{ContextMenuEntry, MenuCommand}; +use super::super::types::{ContextMenuEntry, ContextMenuKind, MenuCommand}; use crate::domain::Action; impl InputState { @@ -23,43 +23,31 @@ impl InputState { } else { format!("Page {} ({}/{})", page_number, page_number, page_count) }; - entries.push(ContextMenuEntry::new( - header, - None::, - false, - true, - None, - )); + entries.push(ContextMenuEntry::new(header, None::, true, None)); entries.push(ContextMenuEntry::new( "Rename Page", None::, false, - false, Some(MenuCommand::PageRename), )); entries.push(ContextMenuEntry::new( "Duplicate Page", self.shortcut_for_action(Action::PageDuplicate), false, - false, Some(MenuCommand::PageDuplicateFromContext), )); entries.push(ContextMenuEntry::new( "Delete Page", self.shortcut_for_action(Action::PageDelete), false, - false, Some(MenuCommand::PageDeleteFromContext), )); let can_move = self.boards.board_count() > 1; - entries.push(ContextMenuEntry::new( - "Move to Board", - None::, - true, - !can_move, - Some(MenuCommand::OpenPageMoveMenu), - )); + entries.push( + ContextMenuEntry::new("Move to Board", None::, !can_move, None) + .with_submenu(ContextMenuKind::PageMove), + ); entries } @@ -72,7 +60,6 @@ impl InputState { return vec![ContextMenuEntry::new( "No other boards", None::, - false, true, None, )]; @@ -87,7 +74,6 @@ impl InputState { board.spec.name.clone(), None::, false, - false, Some(MenuCommand::PageMoveToBoard { id: board.spec.id.clone(), }), @@ -97,7 +83,6 @@ impl InputState { entries.push(ContextMenuEntry::new( "No other boards", None::, - false, true, None, )); diff --git a/src/input/state/core/menus/entries/pages.rs b/src/input/state/core/menus/entries/pages.rs index a9e6257b6..e6c2df0df 100644 --- a/src/input/state/core/menus/entries/pages.rs +++ b/src/input/state/core/menus/entries/pages.rs @@ -6,7 +6,16 @@ use crate::domain::Action; const MAX_VISIBLE_PAGES: usize = 8; impl InputState { - pub(super) fn pages_menu_entries(&self) -> Vec { + /// The active page position, shown on the parent row of the pages submenu. + pub(super) fn pages_summary(&self) -> String { + format!( + "Page {}/{}", + self.boards.active_page_index() + 1, + self.boards.page_count().max(1) + ) + } + + pub(super) fn pages_menu_entries(&self, with_header: bool) -> Vec { let page_count = self.boards.page_count(); let page_index = self.boards.active_page_index(); let can_prev = page_index > 0; @@ -15,19 +24,18 @@ impl InputState { let mut entries = Vec::new(); // Current page indicator - let board_name = self.boards.active_board_name(); - entries.push(ContextMenuEntry::new( - format!( - "{} - Page {}/{}", - board_name, - page_index + 1, - page_count.max(1) - ), - None::, - false, - true, - None, - )); + if with_header { + entries.push(ContextMenuEntry::new( + format!( + "{} - {}", + self.boards.active_board_name(), + self.pages_summary() + ), + None::, + true, + None, + )); + } // List pages for quick switching (limited to MAX_VISIBLE_PAGES) // Window around the active page index @@ -40,14 +48,14 @@ impl InputState { }; let end = start + show_count; - // Show "above" indicator if there are pages before the window + // Pages outside the window are reachable through the board picker's + // page panel, like the boards submenu's overflow rows. if start > 0 { entries.push(ContextMenuEntry::new( - format!(" ... {} above", start), - None::, + format!(" ... {} above (open picker)", start), + self.shortcut_for_action(Action::BoardPicker), false, - true, - None, + Some(MenuCommand::OpenBoardPicker), )); } @@ -61,20 +69,17 @@ impl InputState { entries.push(ContextMenuEntry::new( label, None::, - false, is_active, Some(MenuCommand::SwitchToPage(i)), )); } - // Show "below" indicator if there are pages after the window if end < page_count { entries.push(ContextMenuEntry::new( - format!(" ... {} below", page_count - end), - None::, + format!(" ... {} below (open picker)", page_count - end), + self.shortcut_for_action(Action::BoardPicker), false, - true, - None, + Some(MenuCommand::OpenBoardPicker), )); } @@ -82,14 +87,12 @@ impl InputState { entries.push(ContextMenuEntry::new( "Previous Page", self.shortcut_for_action(Action::PagePrev), - false, !can_prev, Some(MenuCommand::PagePrev), )); entries.push(ContextMenuEntry::new( "Next Page", self.shortcut_for_action(Action::PageNext), - false, !can_next, Some(MenuCommand::PageNext), )); @@ -99,21 +102,18 @@ impl InputState { "New Page", self.shortcut_for_action(Action::PageNew), false, - false, Some(MenuCommand::PageNew), )); entries.push(ContextMenuEntry::new( "Duplicate Page", self.shortcut_for_action(Action::PageDuplicate), false, - false, Some(MenuCommand::PageDuplicate), )); entries.push(ContextMenuEntry::new( "Delete Page", self.shortcut_for_action(Action::PageDelete), false, - false, Some(MenuCommand::PageDelete), )); diff --git a/src/input/state/core/menus/entries/shape.rs b/src/input/state/core/menus/entries/shape.rs index a0a5f9ffd..4fadd8a5d 100644 --- a/src/input/state/core/menus/entries/shape.rs +++ b/src/input/state/core/menus/entries/shape.rs @@ -1,5 +1,5 @@ use super::super::super::base::InputState; -use super::super::types::{ContextMenuEntry, MenuCommand}; +use super::super::types::{ContextMenuEntry, ContextMenuKind, MenuCommand}; use crate::domain::Action; use crate::draw::{Shape, ShapeId}; @@ -24,7 +24,6 @@ impl InputState { "Select This Shape", Some("Alt+Click"), // Mouse action, not configurable false, - false, Some(MenuCommand::SelectHoveredShape), )); } @@ -32,14 +31,12 @@ impl InputState { entries.push(ContextMenuEntry::new( "Delete", self.shortcut_for_action(Action::DeleteSelection), - false, all_locked, Some(MenuCommand::Delete), )); entries.push(ContextMenuEntry::new( "Copy", self.shortcut_for_action(Action::CopySelection), - false, all_locked, Some(MenuCommand::Copy), )); @@ -47,35 +44,30 @@ impl InputState { "Paste", self.shortcut_for_action(Action::PasteSelection), false, - false, Some(MenuCommand::Paste), )); entries.push(ContextMenuEntry::new( "Duplicate", self.shortcut_for_action(Action::DuplicateSelection), false, - false, Some(MenuCommand::Duplicate), )); entries.push(ContextMenuEntry::new( "Move to Front", self.shortcut_for_action(Action::MoveSelectionToFront), false, - false, Some(MenuCommand::MoveToFront), )); entries.push(ContextMenuEntry::new( "Move to Back", self.shortcut_for_action(Action::MoveSelectionToBack), false, - false, Some(MenuCommand::MoveToBack), )); entries.push(ContextMenuEntry::new( if locked { "Unlock" } else { "Lock" }, None::, // Lock/unlock not a configurable keybinding false, - false, Some(if locked { MenuCommand::Unlock } else { @@ -86,7 +78,6 @@ impl InputState { "Properties", self.shortcut_for_action(Action::ToggleSelectionProperties), false, - false, Some(MenuCommand::Properties), )); if self.boards.pan_enabled() && !self.board_is_transparent() { @@ -94,23 +85,18 @@ impl InputState { 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("Zoom", Some(self.zoom_summary()), false, None) + .with_submenu(ContextMenuKind::Zoom), + ); entries.push(ContextMenuEntry::new( "Radial Menu", self.shortcut_for_action(Action::ToggleRadialMenu), false, - false, Some(MenuCommand::OpenRadialMenu), )); @@ -126,7 +112,6 @@ impl InputState { entries.push(ContextMenuEntry::new( label, None::, // Edit text not a configurable keybinding - false, drawn.locked, Some(MenuCommand::EditText), )); diff --git a/src/input/state/core/menus/entries/zoom.rs b/src/input/state/core/menus/entries/zoom.rs index c889c4ce9..59169df01 100644 --- a/src/input/state/core/menus/entries/zoom.rs +++ b/src/input/state/core/menus/entries/zoom.rs @@ -3,40 +3,43 @@ use super::super::types::{ContextMenuEntry, MenuCommand}; use crate::domain::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 { + /// The current zoom level, shown on the parent row of the zoom submenu. + pub(super) fn zoom_summary(&self) -> String { + let zoom_percent = if self.zoom_active() { (self.zoom_scale() * 100.0).round() as i32 } else { 100 }; + format!("{zoom_percent}%") + } - entries.push(ContextMenuEntry::new( - format!("Zoom {}%", zoom_percent), - None::, - false, - true, - None, - )); + pub(super) fn zoom_menu_entries(&self, with_header: bool) -> Vec { + let mut entries = Vec::new(); + let zoom_active = self.zoom_active(); + + if with_header { + entries.push(ContextMenuEntry::new( + format!("Zoom {}", self.zoom_summary()), + None::, + 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), )); diff --git a/src/input/state/core/menus/focus.rs b/src/input/state/core/menus/focus.rs index 026ea64d9..91027026f 100644 --- a/src/input/state/core/menus/focus.rs +++ b/src/input/state/core/menus/focus.rs @@ -1,37 +1,44 @@ use super::super::base::InputState; -use super::types::{ContextMenuEntry, ContextMenuState}; +use super::types::ContextMenuLevel; impl InputState { - fn current_menu_focus_or_hover(&self) -> Option { - if let ContextMenuState::Open { - hover_index, - keyboard_focus, - .. - } = &self.context_menu.state - { - hover_index.or(*keyboard_focus) - } else { - None + /// The menu keyboard navigation acts on: an open submenu once the pointer + /// or keyboard focus is inside it, otherwise the parent menu. + pub(crate) fn active_context_menu_level(&self) -> ContextMenuLevel { + match self.context_submenu() { + Some(submenu) if submenu.hover_index.is_some() || submenu.keyboard_focus.is_some() => { + ContextMenuLevel::Submenu + } + _ => ContextMenuLevel::Root, } } + /// Whether the open submenu holds the selection, so keys act on it. + pub fn context_submenu_is_active(&self) -> bool { + self.active_context_menu_level() == ContextMenuLevel::Submenu + } + + pub(super) fn current_menu_focus_or_hover(&self, level: ContextMenuLevel) -> Option { + let (hover, focus) = self.context_menu_level_selection(level)?; + hover.or(focus) + } + fn select_edge_context_menu_entry(&mut self, start_front: bool) -> bool { if !self.is_context_menu_open() { return false; } - let entries = self.context_menu_entries(); - let iter: Box> = if start_front { - Box::new(entries.iter().enumerate()) + let level = self.active_context_menu_level(); + let entries = self.context_menu_level_entries(level); + let index = if start_front { + entries.iter().position(|entry| !entry.disabled) } else { - Box::new(entries.iter().enumerate().rev()) + entries.iter().rposition(|entry| !entry.disabled) }; - for (index, entry) in iter { - if !entry.disabled { - self.set_context_menu_focus(Some(index)); - return true; - } - } - false + let Some(index) = index else { + return false; + }; + self.set_context_menu_level_focus(level, Some(index)); + true } pub(crate) fn focus_next_context_menu_entry(&mut self) -> bool { @@ -46,14 +53,15 @@ impl InputState { if !self.is_context_menu_open() { return false; } - let entries = self.context_menu_entries(); + let level = self.active_context_menu_level(); + let entries = self.context_menu_level_entries(level); if entries.is_empty() { return false; } let len = entries.len(); let mut index = self - .current_menu_focus_or_hover() + .current_menu_focus_or_hover(level) .unwrap_or_else(|| if forward { len - 1 } else { 0 }); for _ in 0..len { @@ -63,7 +71,15 @@ impl InputState { (index + len - 1) % len }; if !entries[index].disabled { - self.set_context_menu_focus(Some(index)); + // Moving off a parent row collapses its submenu. + if level == ContextMenuLevel::Root + && self + .context_submenu() + .is_some_and(|submenu| submenu.parent_index != index) + { + self.close_context_submenu(false); + } + self.set_context_menu_level_focus(level, Some(index)); return true; } } @@ -85,26 +101,54 @@ impl InputState { if !self.is_context_menu_open() { return false; } - let entries = self.context_menu_entries(); - if entries.is_empty() { + let level = self.active_context_menu_level(); + let Some(index) = self.current_menu_focus_or_hover(level) else { return false; - } - let index = match self.current_menu_focus_or_hover() { - Some(idx) => idx, - None => return false, }; - if let Some(entry) = entries.get(index) { - if entry.disabled { - return false; - } - if let Some(command) = entry.command.clone() { - self.execute_menu_command_with_resources(resources, command); + self.activate_context_menu_row_with_resources(resources, level, index, true) + } + + /// Clicks the menu row under the pointer. Returns false when no enabled row + /// is there. + pub(crate) fn activate_context_menu_row_at_with_resources( + &mut self, + resources: crate::input::state::InputTextResources<'_>, + x: i32, + y: i32, + ) -> bool { + let Some(level) = self.context_menu_level_at(x, y) else { + return false; + }; + let Some(index) = self.context_menu_row_at(level, x, y) else { + return false; + }; + self.activate_context_menu_row_with_resources(resources, level, index, false) + } + + /// Runs one enabled row. A parent row with a submenu opens it instead: from + /// the keyboard with focus inside it, from a click as a toggle. + fn activate_context_menu_row_with_resources( + &mut self, + resources: crate::input::state::InputTextResources<'_>, + level: ContextMenuLevel, + index: usize, + focus_submenu: bool, + ) -> bool { + let entries = self.context_menu_level_entries(level); + let Some(entry) = entries.get(index).filter(|entry| !entry.disabled) else { + return false; + }; + if level == ContextMenuLevel::Root && entry.submenu.is_some() { + return if focus_submenu { + self.open_context_submenu(index, true) } else { - self.close_context_menu(); - } - true - } else { - false + self.toggle_context_submenu(index) + }; + } + match entry.command.clone() { + Some(command) => self.execute_menu_command_with_resources(resources, command), + None => self.close_context_menu(), } + true } } diff --git a/src/input/state/core/menus/hover.rs b/src/input/state/core/menus/hover.rs index 999fa968d..9040c026c 100644 --- a/src/input/state/core/menus/hover.rs +++ b/src/input/state/core/menus/hover.rs @@ -1,55 +1,224 @@ +use std::time::{Duration, Instant}; + use super::super::base::InputState; -use super::types::{ContextMenuState, MenuCommand}; +use super::context_menu::{AimSample, PendingHover}; +use super::types::{ContextMenuLevel, ContextMenuState, MenuCommand, SubmenuSide}; + +/// How long the pointer rests on a parent row before its submenu opens, and +/// on another row before the open submenu closes or switches. Sweeping the +/// pointer down the menu then passes parent rows without flashing their panes. +pub const SUBMENU_HOVER_DELAY: Duration = Duration::from_millis(120); +/// How long a move toward the open submenu keeps it open while the pointer +/// crosses other rows. After this the row under the resting pointer wins. +pub const SUBMENU_AIM_GRACE: Duration = Duration::from_millis(300); impl InputState { pub(super) fn update_context_menu_hover_from_pointer_internal( &mut self, x: i32, y: i32, + now: Instant, trigger_redraw: bool, ) { if !self.is_context_menu_open() { return; } - let new_hover = self.context_menu_index_at(x, y); - if let ContextMenuState::Open { - ref mut hover_index, - ref mut keyboard_focus, - .. - } = self.context_menu.state - && *hover_index != new_hover + let point = (f64::from(x), f64::from(y)); + let changed = if self.context_menu_level_at(x, y) == Some(ContextMenuLevel::Submenu) { + let row = self.context_submenu_index_at(x, y); + self.context_menu.pending_hover = None; + let cleared = self.set_context_menu_hover(ContextMenuLevel::Root, None); + self.set_context_menu_hover(ContextMenuLevel::Submenu, row) || cleared + } else { + let row = self.context_menu_index_at(x, y); + self.update_parent_menu_hover(point, row, Some(now)) + }; + self.context_menu.aim = Some(AimSample { point, at: now }); + if changed && trigger_redraw { + self.needs_redraw = true; + } + } + + /// Hover over the parent menu. Resting on a row with a submenu opens it and + /// resting on another row closes it, except while the pointer crosses rows + /// on its way into the open submenu. With `now` the open or close waits + /// for the pointer to rest; without it the row under the pointer decides + /// at once. + fn update_parent_menu_hover( + &mut self, + point: (f64, f64), + row: Option, + now: Option, + ) -> bool { + let mut changed = self.set_context_menu_hover(ContextMenuLevel::Submenu, None); + let open_parent = self.context_submenu().map(|submenu| submenu.parent_index); + if let Some(now) = now + && row.is_some() + && open_parent.is_some() + && row != open_parent + && self.pointer_is_heading_into_submenu(point, now) { - *hover_index = new_hover; - if new_hover.is_some() { - *keyboard_focus = None; + // Bound the grace so a pointer that stops here still settles. + self.context_menu.pending_hover = Some(PendingHover { + due: now + SUBMENU_AIM_GRACE, + }); + return changed; + } + + changed |= self.set_context_menu_hover(ContextMenuLevel::Root, row); + let Some(row) = row else { + // Off every row: leave the submenu as it is. A collapsed parent + // row opens again once the pointer has been away from it. + self.context_menu.pending_hover = None; + self.context_menu.hover_open_suppressed = None; + return changed; + }; + // The pointer decides the target now; keyboard focus inside the + // submenu would otherwise compete with it for Enter. + self.set_context_menu_level_focus(ContextMenuLevel::Submenu, None); + if self.context_menu.hover_open_suppressed != Some(row) { + self.context_menu.hover_open_suppressed = None; + } + if open_parent == Some(row) || self.context_menu.hover_open_suppressed == Some(row) { + self.context_menu.pending_hover = None; + return changed; + } + match now { + Some(now) => { + self.context_menu.pending_hover = Some(PendingHover { + due: now + SUBMENU_HOVER_DELAY, + }); } - if trigger_redraw { - self.needs_redraw = true; + None => { + changed |= match self.context_menu_row_submenu(row) { + Some(kind) => self.open_context_submenu_kind(row, kind, false), + None => self.close_context_submenu(false), + }; } } + changed + } + + /// When the next hover deadline needs the event loop awake. + pub fn context_menu_hover_timeout(&self, now: Instant) -> Option { + self.context_menu + .pending_hover + .map(|pending| pending.due.saturating_duration_since(now)) + } + + /// Event-loop pump: once a hover deadline passes, the row under the resting + /// pointer decides which submenu is open. Returns whether it fired. + pub fn tick_context_menu_hover(&mut self, now: Instant) -> bool { + let due = self + .context_menu + .pending_hover + .is_some_and(|pending| now >= pending.due); + if !due || !self.is_context_menu_open() { + return false; + } + self.context_menu.pending_hover = None; + let (x, y) = self.pointer.screen(); + if self.context_menu_level_at(x, y) != Some(ContextMenuLevel::Root) { + return true; + } + let point = (f64::from(x), f64::from(y)); + let row = self.context_menu_index_at(x, y); + if self.update_parent_menu_hover(point, row, None) { + self.needs_redraw = true; + } + true + } + + /// Whether the latest pointer move stays inside the triangle between its + /// fresh previous position and the near edge of the open submenu. + fn pointer_is_heading_into_submenu(&self, point: (f64, f64), now: Instant) -> bool { + let (Some(aim), Some(pane)) = (self.context_menu.aim, self.context_menu.submenu_layout) + else { + return false; + }; + if now.saturating_duration_since(aim.at) > SUBMENU_AIM_GRACE { + return false; + } + let edge_x = match self.context_menu.submenu_side { + SubmenuSide::Right => pane.origin_x, + SubmenuSide::Left => pane.origin_x + pane.width, + }; + point_in_triangle( + point, + aim.point, + (edge_x, pane.origin_y), + (edge_x, pane.origin_y + pane.height), + ) + } + + /// Moves one menu's hover. Landing on a row clears that menu's keyboard + /// focus. Returns whether the hover changed. + fn set_context_menu_hover(&mut self, level: ContextMenuLevel, row: Option) -> bool { + let Some((hover_index, keyboard_focus)) = + level_selection_mut(&mut self.context_menu.state, level) + else { + return false; + }; + if *hover_index == row { + return false; + } + *hover_index = row; + if row.is_some() { + *keyboard_focus = None; + } + true } /// Updates hover state based on the provided pointer position. pub fn update_context_menu_hover_from_pointer(&mut self, x: i32, y: i32) { - self.update_context_menu_hover_from_pointer_internal(x, y, true); + self.update_context_menu_hover_from_pointer_internal(x, y, Instant::now(), true); } - /// Updates cached hover information without forcing a redraw. /// Updates the keyboard focus entry for the context menu. pub fn set_context_menu_focus(&mut self, focus: Option) { - if let ContextMenuState::Open { - ref mut keyboard_focus, - ref mut hover_index, + self.set_context_menu_level_focus(ContextMenuLevel::Root, focus); + } + + pub(super) fn set_context_menu_level_focus( + &mut self, + level: ContextMenuLevel, + focus: Option, + ) { + let Some((hover_index, keyboard_focus)) = + level_selection_mut(&mut self.context_menu.state, level) + else { + return; + }; + let changed = *keyboard_focus != focus; + *keyboard_focus = focus; + if focus.is_some() { + *hover_index = None; + // The keyboard took over; a resting pointer must not undo it. + self.context_menu.pending_hover = None; + } + if changed { + self.needs_redraw = true; + } + } + + /// One menu's hover and keyboard focus, if that menu is open. + pub(super) fn context_menu_level_selection( + &self, + level: ContextMenuLevel, + ) -> Option<(Option, Option)> { + let ContextMenuState::Open { + hover_index, + keyboard_focus, + submenu, .. - } = self.context_menu.state - { - let changed = *keyboard_focus != focus; - *keyboard_focus = focus; - if focus.is_some() { - *hover_index = None; - } - if changed { - self.needs_redraw = true; + } = &self.context_menu.state + else { + return None; + }; + match level { + ContextMenuLevel::Root => Some((*hover_index, *keyboard_focus)), + ContextMenuLevel::Submenu => { + submenu.map(|submenu| (submenu.hover_index, submenu.keyboard_focus)) } } } @@ -71,3 +240,33 @@ impl InputState { false } } + +fn level_selection_mut( + state: &mut ContextMenuState, + level: ContextMenuLevel, +) -> Option<(&mut Option, &mut Option)> { + let ContextMenuState::Open { + hover_index, + keyboard_focus, + submenu, + .. + } = state + else { + return None; + }; + match level { + ContextMenuLevel::Root => Some((hover_index, keyboard_focus)), + ContextMenuLevel::Submenu => submenu + .as_mut() + .map(|submenu| (&mut submenu.hover_index, &mut submenu.keyboard_focus)), + } +} + +/// Whether `p` lies strictly inside the triangle `a`, `b`, `c`. +fn point_in_triangle(p: (f64, f64), a: (f64, f64), b: (f64, f64), c: (f64, f64)) -> bool { + let side = |(x0, y0): (f64, f64), (x1, y1): (f64, f64)| { + (x1 - x0) * (p.1 - y0) - (y1 - y0) * (p.0 - x0) + }; + let sides = [side(a, b), side(b, c), side(c, a)]; + sides.iter().all(|value| *value > 0.0) || sides.iter().all(|value| *value < 0.0) +} diff --git a/src/input/state/core/menus/layout.rs b/src/input/state/core/menus/layout.rs index 1e7ec5a81..73c038bd8 100644 --- a/src/input/state/core/menus/layout.rs +++ b/src/input/state/core/menus/layout.rs @@ -1,8 +1,22 @@ +use std::time::Instant; + use super::super::base::InputState; -use super::types::{ContextMenuCursorHint, ContextMenuLayout, ContextMenuState}; +use super::types::{ + ContextMenuCursorHint, ContextMenuEntry, ContextMenuLayout, ContextMenuLevel, ContextMenuState, + SubmenuSide, +}; use crate::ui_text::{UiTextEngine, UiTextStyle}; -use crate::util::Rect; -use cairo::Context as CairoContext; + +const FONT_SIZE: f64 = 14.0; +const ROW_HEIGHT: f64 = 24.0; +const PADDING_X: f64 = 12.0; +const PADDING_Y: f64 = 8.0; +const GAP_BETWEEN_COLUMNS: f64 = 20.0; +const ARROW_WIDTH: f64 = 10.0; +/// Space between a menu and the submenu beside it. +const SUBMENU_GAP: f64 = 4.0; +/// Menus keep this distance from the output edges. +const SCREEN_MARGIN: f64 = 6.0; impl InputState { /// Returns cached context menu layout, if available. @@ -10,6 +24,17 @@ impl InputState { self.context_menu.layout() } + /// Returns the cached layout of the open submenu, if any. + pub fn context_submenu_layout(&self) -> Option<&ContextMenuLayout> { + self.context_menu.submenu_layout() + } + + /// The side submenus open on for the current layout. Arrows on parent + /// rows point this way. + pub fn context_submenu_side(&self) -> SubmenuSide { + self.context_menu.submenu_side() + } + /// Clears cached layout data (used when menu closes). pub fn clear_context_menu_layout(&mut self) { self.context_menu.clear_layout(); @@ -17,106 +42,37 @@ impl InputState { } /// Recomputes context menu layout for rendering and hit-testing. - pub fn update_context_menu_layout( - &mut self, - ctx: &CairoContext, - screen_width: u32, - screen_height: u32, - ) { + pub fn update_context_menu_layout(&mut self, screen_width: u32, screen_height: u32) { self.update_context_menu_layout_with_engine( &UiTextEngine::default(), - ctx, screen_width, screen_height, ); } + /// Lays out the menu and any open submenu. The backend calls this once per + /// frame before painting, so damage, painting, and hit-testing share it. pub(crate) fn update_context_menu_layout_with_engine( &mut self, engine: &UiTextEngine, - ctx: &CairoContext, screen_width: u32, screen_height: u32, ) { - if !self.is_context_menu_open() { - self.context_menu.layout = None; + let screen = (f64::from(screen_width), f64::from(screen_height)); + let ContextMenuState::Open { anchor, .. } = &self.context_menu.state else { + self.context_menu.clear_layout(); return; - } - - let entries = self.context_menu_entries(); - if entries.is_empty() { - self.context_menu.layout = None; - return; - } - - const FONT_SIZE: f64 = 14.0; - const ROW_HEIGHT: f64 = 24.0; - const PADDING_X: f64 = 12.0; - const PADDING_Y: f64 = 8.0; - const GAP_BETWEEN_COLUMNS: f64 = 20.0; - const ARROW_WIDTH: f64 = 10.0; - - let _ = ctx.save(); - let text_style = UiTextStyle { - family: "Sans", - slant: cairo::FontSlant::Normal, - weight: cairo::FontWeight::Normal, - size: FONT_SIZE, - }; - - let mut max_label_width: f64 = 0.0; - let mut max_shortcut_width: f64 = 0.0; - for entry in &entries { - let extents = engine - .layout(ctx, text_style, &entry.label, None) - .ink_extents(); - max_label_width = max_label_width.max(extents.width()); - if let Some(shortcut) = &entry.shortcut { - let extents = engine.layout(ctx, text_style, shortcut, None).ink_extents(); - max_shortcut_width = max_shortcut_width.max(extents.width()); - } - } - - let _ = ctx.restore(); - - let menu_width = PADDING_X * 2.0 - + max_label_width - + GAP_BETWEEN_COLUMNS - + max_shortcut_width - + ARROW_WIDTH; - let menu_height = PADDING_Y * 2.0 + ROW_HEIGHT * entries.len() as f64; - - let mut origin_x = match &self.context_menu.state { - ContextMenuState::Open { anchor, .. } => anchor.0 as f64, - ContextMenuState::Hidden => 0.0, }; - let mut origin_y = match &self.context_menu.state { - ContextMenuState::Open { anchor, .. } => anchor.1 as f64, - ContextMenuState::Hidden => 0.0, + let anchor = *anchor; + let Some(mut root) = measure_menu(engine, &self.context_menu_entries()) else { + self.context_menu.clear_layout(); + return; }; + root.origin_x = fit_within(f64::from(anchor.0), root.width, screen.0); + root.origin_y = fit_within(f64::from(anchor.1), root.height, screen.1); + self.context_menu.layout = Some(root); - let screen_w = screen_width as f64; - let screen_h = screen_height as f64; - if origin_x + menu_width > screen_w - 6.0 { - origin_x = (screen_w - menu_width - 6.0).max(6.0); - } - if origin_y + menu_height > screen_h - 6.0 { - origin_y = (screen_h - menu_height - 6.0).max(6.0); - } - - self.context_menu.layout = Some(ContextMenuLayout { - origin_x, - origin_y, - width: menu_width, - height: menu_height, - row_height: ROW_HEIGHT, - font_size: FONT_SIZE, - padding_x: PADDING_X, - padding_y: PADDING_Y, - shortcut_width: max_shortcut_width, - arrow_width: ARROW_WIDTH, - }); - + // Hover can open a submenu, so settle it before laying one out. if self.pointer.take_menu_hover_recalc() { let focus_set = matches!( self.context_menu.state, @@ -127,41 +83,82 @@ impl InputState { ); if !focus_set { let (px, py) = self.pointer.screen(); - self.update_context_menu_hover_from_pointer_internal(px, py, false); + self.update_context_menu_hover_from_pointer_internal(px, py, Instant::now(), false); } } - if let Some(layout) = self.context_menu.layout { - self.mark_context_menu_region(layout); - } + let pane = self.context_submenu().and_then(|submenu| { + Some(( + submenu, + measure_menu(engine, &self.context_submenu_entries())?, + )) + }); + // Without an open pane, predict the side from one as wide as the menu + // so parent-row arrows point where a pane would open. + let pane_width = pane.map_or(root.width, |(_, pane)| pane.width); + let side = submenu_side(&root, pane_width, screen.0); + self.context_menu.submenu_side = side; + self.context_menu.submenu_layout = pane.map(|(submenu, mut pane)| { + pane.origin_x = match side { + SubmenuSide::Right => fit_within( + root.origin_x + root.width + SUBMENU_GAP, + pane.width, + screen.0, + ), + SubmenuSide::Left => root.origin_x - SUBMENU_GAP - pane.width, + }; + // Line the first entry up with the parent row. + let row_top = + root.origin_y + root.padding_y + root.row_height * submenu.parent_index as f64; + pane.origin_y = fit_within(row_top - pane.padding_y, pane.height, screen.1); + pane + }); } /// Maps pointer coordinates to a context menu entry index, if applicable. pub fn context_menu_index_at(&self, x: i32, y: i32) -> Option { - let layout = self.context_menu_layout()?; - let entries = self.context_menu_entries(); - if entries.is_empty() { - return None; - } + self.context_menu_row_at(ContextMenuLevel::Root, x, y) + } - let local_x = x as f64 - layout.origin_x; - let local_y = y as f64 - layout.origin_y; + /// Maps pointer coordinates to an entry of the open submenu. + pub fn context_submenu_index_at(&self, x: i32, y: i32) -> Option { + self.context_menu_row_at(ContextMenuLevel::Submenu, x, y) + } - if local_x < 0.0 || local_y < 0.0 || local_x > layout.width || local_y > layout.height { + /// The row of one menu under a point. The layout already encodes the row + /// count, so this needs no entry list. + pub(super) fn context_menu_row_at( + &self, + level: ContextMenuLevel, + x: i32, + y: i32, + ) -> Option { + let layout = self.context_menu_level_layout(level)?; + let (x, y) = (f64::from(x), f64::from(y)); + if !layout_contains(layout, x, y) { return None; } + let row = ((y - layout.origin_y - layout.padding_y) / layout.row_height).floor(); + (row >= 0.0 && row < row_count(layout)).then_some(row as usize) + } - let row = ((local_y - layout.padding_y) / layout.row_height).floor() as isize; - if row < 0 { - return None; + fn context_menu_level_layout(&self, level: ContextMenuLevel) -> Option<&ContextMenuLayout> { + match level { + ContextMenuLevel::Root => self.context_menu.layout(), + ContextMenuLevel::Submenu => self.context_menu.submenu_layout(), } + } - let index = row as usize; - if index >= entries.len() { - None - } else { - Some(index) - } + /// The menu under the pointer. The submenu is checked first because it + /// paints above its parent. + pub(crate) fn context_menu_level_at(&self, x: i32, y: i32) -> Option { + let (x, y) = (f64::from(x), f64::from(y)); + [ContextMenuLevel::Submenu, ContextMenuLevel::Root] + .into_iter() + .find(|level| { + self.context_menu_level_layout(*level) + .is_some_and(|layout| layout_contains(layout, x, y)) + }) } /// Determine the cursor type for a given point within the context menu. @@ -170,46 +167,87 @@ impl InputState { if !self.is_context_menu_open() { return None; } - let layout = self.context_menu_layout()?; - - let local_x = x as f64 - layout.origin_x; - let local_y = y as f64 - layout.origin_y; + let level = self.context_menu_level_at(x, y)?; + let on_enabled_row = self.context_menu_row_at(level, x, y).is_some_and(|index| { + self.context_menu_level_entries(level) + .get(index) + .is_some_and(|entry| !entry.disabled) + }); + Some(if on_enabled_row { + ContextMenuCursorHint::Pointer + } else { + ContextMenuCursorHint::Default + }) + } +} - // Check if within panel bounds - if local_x < 0.0 || local_y < 0.0 || local_x > layout.width || local_y > layout.height { - return None; - } +/// Sizes a menu for its entries, placed at the origin. +fn measure_menu(engine: &UiTextEngine, entries: &[ContextMenuEntry]) -> Option { + if entries.is_empty() { + return None; + } + let text_style = UiTextStyle { + family: "Sans", + slant: cairo::FontSlant::Normal, + weight: cairo::FontWeight::Normal, + size: FONT_SIZE, + }; + let text_width = |text: &str| { + engine + .measure(text_style, text, None) + .map_or(0.0, |extents| extents.width()) + }; + let label_width = entries + .iter() + .map(|entry| text_width(&entry.label)) + .fold(0.0, f64::max); + let shortcut_width = entries + .iter() + .filter_map(|entry| entry.shortcut.as_deref()) + .map(text_width) + .fold(0.0, f64::max); + + Some(ContextMenuLayout { + origin_x: 0.0, + origin_y: 0.0, + width: PADDING_X * 2.0 + label_width + GAP_BETWEEN_COLUMNS + shortcut_width + ARROW_WIDTH, + height: PADDING_Y * 2.0 + ROW_HEIGHT * entries.len() as f64, + row_height: ROW_HEIGHT, + font_size: FONT_SIZE, + padding_x: PADDING_X, + padding_y: PADDING_Y, + shortcut_width, + arrow_width: ARROW_WIDTH, + }) +} - // Check if hovering over a menu item (not disabled) - if let Some(index) = self.context_menu_index_at(x, y) { - let entries = self.context_menu_entries(); - if let Some(entry) = entries.get(index) - && !entry.disabled - { - return Some(ContextMenuCursorHint::Pointer); - } - } +/// The number of rows a menu was measured for. +fn row_count(layout: &ContextMenuLayout) -> f64 { + ((layout.height - layout.padding_y * 2.0) / layout.row_height).round() +} - Some(ContextMenuCursorHint::Default) +/// Submenus open to the right, or to the left when the output edge is in the +/// way and there is room on the left. +fn submenu_side(root: &ContextMenuLayout, pane_width: f64, screen_width: f64) -> SubmenuSide { + let right = root.origin_x + root.width + SUBMENU_GAP; + let left = root.origin_x - SUBMENU_GAP - pane_width; + if right + pane_width <= screen_width - SCREEN_MARGIN || left < SCREEN_MARGIN { + SubmenuSide::Right + } else { + SubmenuSide::Left } +} - pub(super) fn mark_context_menu_region(&mut self, layout: ContextMenuLayout) { - // Add margin for border stroke and anti-aliasing - let margin = 4; - let x = layout.origin_x.floor() as i32 - margin; - let y = layout.origin_y.floor() as i32 - margin; - let width = layout.width.ceil() as i32 + margin * 2; - // Include navigation hint area below the menu: - // hint_y = layout.height + 4.0, hint_height = font_size * 0.8 + 6.0 * 2.0 - let hint_extra = 4.0 + layout.font_size * 0.8 + 12.0 + 4.0; // gap + hint + padding - let height = (layout.height + hint_extra).ceil() as i32 + margin * 2; - let width = width.max(1); - let height = height.max(1); - - if let Some(rect) = Rect::new(x, y, width, height) { - self.dirty_tracker.mark_rect(rect); - } else { - self.dirty_tracker.mark_full(); - } +/// Pulls a span that would cross the far output edge back inside the margin. +fn fit_within(start: f64, length: f64, limit: f64) -> f64 { + if start + length > limit - SCREEN_MARGIN { + (limit - length - SCREEN_MARGIN).max(SCREEN_MARGIN) + } else { + start } } + +fn layout_contains(layout: &ContextMenuLayout, x: f64, y: f64) -> bool { + let (local_x, local_y) = (x - layout.origin_x, y - layout.origin_y); + (0.0..=layout.width).contains(&local_x) && (0.0..=layout.height).contains(&local_y) +} diff --git a/src/input/state/core/menus/lifecycle.rs b/src/input/state/core/menus/lifecycle.rs index be5726c66..fad17d99e 100644 --- a/src/input/state/core/menus/lifecycle.rs +++ b/src/input/state/core/menus/lifecycle.rs @@ -6,9 +6,7 @@ use crate::draw::TextMeasurer; impl InputState { /// Closes the currently open context menu. pub fn close_context_menu(&mut self) { - if let Some(layout) = self.context_menu.close() { - self.mark_context_menu_region(layout); - } + self.context_menu.close(); self.pointer.clear_menu_hover_recalc(); self.needs_redraw = true; } @@ -30,13 +28,10 @@ impl InputState { return; } self.close_modals_for_open(crate::input::state::core::modal::ModalSurface::ContextMenu); - if let Some(layout) = self - .context_menu - .open(anchor, shape_ids, kind, hovered_shape_id) - { - self.mark_context_menu_region(layout); - } + self.context_menu + .open(anchor, shape_ids, kind, hovered_shape_id); self.pointer.request_menu_hover_recalc(); + self.needs_redraw = true; } pub fn open_page_context_menu( diff --git a/src/input/state/core/menus/mod.rs b/src/input/state/core/menus/mod.rs index 0ae6ae89c..9cd45dd46 100644 --- a/src/input/state/core/menus/mod.rs +++ b/src/input/state/core/menus/mod.rs @@ -6,10 +6,12 @@ mod hover; mod layout; mod lifecycle; mod shortcuts; +mod submenu; mod types; pub use context_menu::ContextMenuPanel; +pub use hover::{SUBMENU_AIM_GRACE, SUBMENU_HOVER_DELAY}; pub use types::{ ContextMenuCursorHint, ContextMenuEntry, ContextMenuKind, ContextMenuLayout, ContextMenuState, - MenuCommand, + ContextSubmenu, MenuCommand, SubmenuSide, }; diff --git a/src/input/state/core/menus/submenu.rs b/src/input/state/core/menus/submenu.rs new file mode 100644 index 000000000..437dc6dd7 --- /dev/null +++ b/src/input/state/core/menus/submenu.rs @@ -0,0 +1,149 @@ +//! Submenus open beside the parent row that holds them, and the parent menu +//! stays open underneath. + +use super::super::base::InputState; +use super::types::{ + ContextMenuKind, ContextMenuLevel, ContextMenuState, ContextSubmenu, MenuCommand, +}; + +/// The menu a command asks for. Opened as a submenu when the open menu has a +/// row for it, otherwise on its own at the pointer. +pub(super) fn menu_kind_for_command(command: &MenuCommand) -> Option { + match command { + MenuCommand::OpenZoomMenu => Some(ContextMenuKind::Zoom), + MenuCommand::OpenPagesMenu => Some(ContextMenuKind::Pages), + MenuCommand::OpenBoardsMenu => Some(ContextMenuKind::Boards), + MenuCommand::OpenPageMoveMenu => Some(ContextMenuKind::PageMove), + _ => None, + } +} + +impl InputState { + /// The open submenu, if any. + pub fn context_submenu(&self) -> Option { + match &self.context_menu.state { + ContextMenuState::Open { submenu, .. } => *submenu, + ContextMenuState::Hidden => None, + } + } + + /// The submenu an enabled row of the parent menu opens. + pub(super) fn context_menu_row_submenu(&self, index: usize) -> Option { + self.context_menu_entries() + .get(index) + .filter(|entry| !entry.disabled)? + .submenu + } + + /// Opens the menu `command` asks for: beside the parent row that holds it + /// when the open menu has one, otherwise as its own menu at the pointer. + pub(super) fn open_menu_for_command(&mut self, command: &MenuCommand) { + let Some(kind) = menu_kind_for_command(command) else { + return; + }; + let row = self + .context_menu_entries() + .iter() + .position(|entry| entry.submenu == Some(kind) && !entry.disabled); + if let Some(row) = row { + self.open_context_submenu_kind(row, kind, true); + return; + } + // The board picker's page overflow link opens the pages menu with no + // parent menu open. + let page_target = self.context_menu.page_target; + self.open_context_menu(self.pointer.screen(), Vec::new(), kind, None); + self.context_menu.page_target = page_target; + self.needs_redraw = true; + } + + /// Opens the submenu of a parent row beside it. `focus` moves keyboard focus + /// to its first enabled entry. Returns whether the row opens a submenu. + pub(crate) fn open_context_submenu(&mut self, parent_index: usize, focus: bool) -> bool { + match self.context_menu_row_submenu(parent_index) { + Some(kind) => self.open_context_submenu_kind(parent_index, kind, focus), + None => false, + } + } + + pub(super) fn open_context_submenu_kind( + &mut self, + parent_index: usize, + kind: ContextMenuKind, + focus: bool, + ) -> bool { + let ContextMenuState::Open { submenu, .. } = &mut self.context_menu.state else { + return false; + }; + if submenu.is_none_or(|open| open.parent_index != parent_index) { + *submenu = Some(ContextSubmenu { + kind, + parent_index, + hover_index: None, + keyboard_focus: None, + }); + // The replaced pane is laid out again before the next paint. + self.context_menu.submenu_layout = None; + } + self.context_menu.pending_hover = None; + if focus { + let first = self + .context_submenu_entries() + .iter() + .position(|entry| !entry.disabled); + self.set_context_menu_level_focus(ContextMenuLevel::Submenu, first); + } + self.needs_redraw = true; + true + } + + /// Closes the open submenu. `focus_parent` moves keyboard focus back to the + /// row that opened it. Returns whether a submenu was open. + pub(crate) fn close_context_submenu(&mut self, focus_parent: bool) -> bool { + let ContextMenuState::Open { + submenu, + hover_index, + keyboard_focus, + .. + } = &mut self.context_menu.state + else { + return false; + }; + let Some(closed) = submenu.take() else { + return false; + }; + if focus_parent { + *keyboard_focus = Some(closed.parent_index); + *hover_index = None; + } + self.context_menu.submenu_layout = None; + self.context_menu.pending_hover = None; + self.needs_redraw = true; + true + } + + /// A click on a parent row: collapses its open submenu, otherwise opens + /// it. A collapsed submenu stays shut until the pointer leaves the row. + pub(super) fn toggle_context_submenu(&mut self, parent_index: usize) -> bool { + let expanded = self + .context_submenu() + .is_some_and(|submenu| submenu.parent_index == parent_index); + if expanded { + self.context_menu.hover_open_suppressed = Some(parent_index); + self.close_context_submenu(false) + } else { + self.context_menu.hover_open_suppressed = None; + self.open_context_submenu(parent_index, false) + } + } + + /// Opens the submenu of the focused or hovered parent row, with keyboard + /// focus inside it. + pub(crate) fn open_focused_context_submenu(&mut self) -> bool { + if self.active_context_menu_level() != ContextMenuLevel::Root { + return false; + } + self.current_menu_focus_or_hover(ContextMenuLevel::Root) + .is_some_and(|row| self.open_context_submenu(row, true)) + } +} diff --git a/src/input/state/core/menus/types.rs b/src/input/state/core/menus/types.rs index 7d5caa349..09203a350 100644 --- a/src/input/state/core/menus/types.rs +++ b/src/input/state/core/menus/types.rs @@ -25,9 +25,36 @@ pub enum ContextMenuState { hover_index: Option, keyboard_focus: Option, hovered_shape_id: Option, + /// A submenu cascading from one of this menu's rows. + submenu: Option, }, } +/// A submenu open beside the parent row that opened it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ContextSubmenu { + pub(crate) kind: ContextMenuKind, + /// The parent menu row this submenu belongs to. + pub(crate) parent_index: usize, + pub(crate) hover_index: Option, + pub(crate) keyboard_focus: Option, +} + +/// Which open menu an operation targets. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum ContextMenuLevel { + Root, + Submenu, +} + +/// The side of the parent menu a submenu opens on. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum SubmenuSide { + #[default] + Right, + Left, +} + /// Commands triggered by context menu selection. #[derive(Debug, Clone, PartialEq, Eq)] pub enum MenuCommand { @@ -88,8 +115,11 @@ pub enum MenuCommand { #[derive(Debug, Clone)] pub struct ContextMenuEntry { pub label: String, + /// Text in the right-hand column: a shortcut, or a parent row's summary + /// of its submenu's current state. pub shortcut: Option, - pub has_submenu: bool, + /// The menu this row opens beside itself instead of running a command. + pub submenu: Option, pub disabled: bool, pub command: Option, } @@ -98,18 +128,23 @@ impl ContextMenuEntry { pub fn new( label: impl Into, shortcut: Option>, - has_submenu: bool, disabled: bool, command: Option, ) -> Self { Self { label: label.into(), shortcut: shortcut.map(|s| s.into()), - has_submenu, + submenu: None, disabled, command, } } + + /// Makes this a parent row that opens `kind` as a submenu. + pub fn with_submenu(mut self, kind: ContextMenuKind) -> Self { + self.submenu = Some(kind); + self + } } /// Layout metadata for rendering and hit-testing the context menu. diff --git a/src/input/state/core/mod.rs b/src/input/state/core/mod.rs index 2b06b7909..f2c0727e0 100644 --- a/src/input/state/core/mod.rs +++ b/src/input/state/core/mod.rs @@ -116,8 +116,10 @@ pub use ime::ImePreedit; #[cfg(test)] pub(crate) use ime::build_text_input_preview; pub use menus::{ - ContextMenuCursorHint, ContextMenuEntry, ContextMenuKind, ContextMenuState, MenuCommand, + ContextMenuCursorHint, ContextMenuEntry, ContextMenuKind, ContextMenuLayout, ContextMenuState, + ContextSubmenu, MenuCommand, SubmenuSide, }; +pub use menus::{SUBMENU_AIM_GRACE, SUBMENU_HOVER_DELAY}; pub use properties::{SelectionPropertyEntry, SelectionPropertyKind}; pub use radial_menu::{ COMPASS_SLICES as RADIAL_COMPASS_SLICES, CompassDir, RADIAL_PAINT_DELAY, RadialMenuLayout, diff --git a/src/input/state/interaction/mod.rs b/src/input/state/interaction/mod.rs index 47247e3cd..f439a601e 100644 --- a/src/input/state/interaction/mod.rs +++ b/src/input/state/interaction/mod.rs @@ -331,7 +331,7 @@ mod tests { } else { state.open_board_context_menu((1000, 50), blackboard); } - state.update_context_menu_layout(&ctx, 1280, 720); + state.update_context_menu_layout(1280, 720); let target = if over_swatch { at(swatch_x, swatch_y) } else { @@ -418,7 +418,7 @@ mod tests { ); assert!(state.is_board_picker_open() && state.is_context_menu_open()); - state.update_context_menu_layout(&ctx, 1280, 720); + state.update_context_menu_layout(1280, 720); let menu = state.context_menu_layout().unwrap(); let (menu_x, menu_y, menu_bottom) = ( (menu.origin_x + menu.width / 2.0) as i32, diff --git a/src/input/state/mod.rs b/src/input/state/mod.rs index a690b5153..2499fa040 100644 --- a/src/input/state/mod.rs +++ b/src/input/state/mod.rs @@ -37,19 +37,20 @@ pub use core::{ COLOR_PICKER_RECENT_SWATCH_COUNT, COLOR_PICKER_RECENT_SWATCH_SIZE, COMMAND_PALETTE_MAX_VISIBLE, ColorPickerCursorHint, ColorPickerPopupLayout, ColorPickerPopupState, CommandPaletteCursorHint, CommandPaletteListRow, CommandPaletteState, CompassDir, CompositorCapabilities, - ContextMenuCursorHint, ContextMenuEntry, ContextMenuKind, ContextMenuState, DesktopEnvironment, - DrawingState, EyedropperCaptureSource, EyedropperUiState, FontPickerFilter, FontPickerLayout, - FontPickerResults, FontPickerRow, FontPickerTarget, HelpOverlayClick, HelpOverlayCursorHint, - HelpOverlayReleaseOutcome, ImePreedit, InputState, MAX_STROKE_THICKNESS, MIN_STROKE_THICKNESS, - OutputFocusAction, PRESET_FEEDBACK_DURATION_MS, PRESET_TOAST_DURATION_MS, PickerDrag, - PrecisionEntryState, PresetAction, PresetFeedbackKind, PressureThicknessEditMode, - PressureThicknessEntryMode, QuickColorEdit, RADIAL_COMPASS_SLICES, RADIAL_PAINT_DELAY, - RADIAL_TOOL_SEGMENT_COUNT, RadialMenuLayout, RadialMenuState, RadialParent, RadialRingSwatch, - RadialSegmentId, RadialSlice, RadialSliceKind, RegionInputSource, RegionPurposeTag, - RegionSelectUiState, RegionSelection, SIZE_RING_ARC_SPAN, SIZE_RING_ARC_START, - ScreenCaptureSource, SelectionAxis, SelectionHandle, SelectionPolicy, SelectionPropertyEntry, - SelectionPropertyKind, ShellMode, TextInputMode, Toast, ToastPriority, ToastPushOutcome, - TourStep, UI_TOAST_DURATION_MS, UiToastKind, UiVisibility, ZoomAction, color_picker_rgb_to_hsv, + ContextMenuCursorHint, ContextMenuEntry, ContextMenuKind, ContextMenuLayout, ContextMenuState, + ContextSubmenu, DesktopEnvironment, DrawingState, EyedropperCaptureSource, EyedropperUiState, + FontPickerFilter, FontPickerLayout, FontPickerResults, FontPickerRow, FontPickerTarget, + HelpOverlayClick, HelpOverlayCursorHint, HelpOverlayReleaseOutcome, ImePreedit, InputState, + MAX_STROKE_THICKNESS, MIN_STROKE_THICKNESS, OutputFocusAction, PRESET_FEEDBACK_DURATION_MS, + PRESET_TOAST_DURATION_MS, PickerDrag, PrecisionEntryState, PresetAction, PresetFeedbackKind, + PressureThicknessEditMode, PressureThicknessEntryMode, QuickColorEdit, RADIAL_COMPASS_SLICES, + RADIAL_PAINT_DELAY, RADIAL_TOOL_SEGMENT_COUNT, RadialMenuLayout, RadialMenuState, RadialParent, + RadialRingSwatch, RadialSegmentId, RadialSlice, RadialSliceKind, RegionInputSource, + RegionPurposeTag, RegionSelectUiState, RegionSelection, SIZE_RING_ARC_SPAN, + SIZE_RING_ARC_START, SUBMENU_AIM_GRACE, SUBMENU_HOVER_DELAY, ScreenCaptureSource, + SelectionAxis, SelectionHandle, SelectionPolicy, SelectionPropertyEntry, SelectionPropertyKind, + ShellMode, SubmenuSide, TextInputMode, Toast, ToastPriority, ToastPushOutcome, TourStep, + UI_TOAST_DURATION_MS, UiToastKind, UiVisibility, ZoomAction, color_picker_rgb_to_hsv, compass_slice, font_picker_layout, font_picker_rows, size_ring_angle_for_value, size_ring_value_for_angle, slice_parent, sub_ring_child_count, sub_ring_children, }; diff --git a/src/input/state/mouse/press/panels.rs b/src/input/state/mouse/press/panels.rs index 77128f564..cad9e1c6b 100644 --- a/src/input/state/mouse/press/panels.rs +++ b/src/input/state/mouse/press/panels.rs @@ -5,16 +5,7 @@ use super::super::super::InputState; impl InputState { fn is_point_in_context_menu(&self, x: i32, y: i32) -> bool { - if let Some(layout) = self.context_menu_layout() { - let xf = x as f64; - let yf = y as f64; - xf >= layout.origin_x - && xf <= layout.origin_x + layout.width - && yf >= layout.origin_y - && yf <= layout.origin_y + layout.height - } else { - false - } + self.context_menu_level_at(x, y).is_some() } pub(in crate::input::state) fn handle_context_menu_press( diff --git a/src/input/state/mouse/release/panels.rs b/src/input/state/mouse/release/panels.rs index a74678874..cd8bd7592 100644 --- a/src/input/state/mouse/release/panels.rs +++ b/src/input/state/mouse/release/panels.rs @@ -254,20 +254,8 @@ pub(super) fn handle_context_menu_release( if !state.is_context_menu_open() { return false; } - if let Some(index) = state.context_menu_index_at(x, y) { - let entries = state.context_menu_entries(); - if let Some(entry) = entries.get(index) { - if !entry.disabled { - if let Some(command) = entry.command.clone() { - state.execute_menu_command_with_resources(resources, command); - } else { - state.close_context_menu(); - } - } else { - state.close_context_menu(); - } - } - } else { + // Rows run their command or open their submenu; anywhere else dismisses. + if !state.activate_context_menu_row_at_with_resources(resources, x, y) { state.close_context_menu(); } state.needs_redraw = true; diff --git a/src/input/state/tests/menus/context_menu.rs b/src/input/state/tests/menus/context_menu.rs index d5dc5d4a7..a3c8c7a9c 100644 --- a/src/input/state/tests/menus/context_menu.rs +++ b/src/input/state/tests/menus/context_menu.rs @@ -266,8 +266,8 @@ fn context_menu_includes_zoom_submenu_entry() { .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); + assert_eq!(zoom_entry.submenu, Some(ContextMenuKind::Zoom)); + assert_eq!(zoom_entry.shortcut.as_deref(), Some("100%")); } #[test] @@ -331,24 +331,32 @@ fn context_menu_radial_entry_shows_keyboard_shortcut_when_mouse_binding_disabled assert_eq!(radial_entry.shortcut.as_deref(), Some("Ctrl+R")); } +/// The canvas menu stays open under the submenu, whose keyboard focus sits on +/// an entry it can run. +fn assert_submenu_has_actionable_focus(state: &InputState, kind: ContextMenuKind) { + assert!(matches!( + state.context_menu.state, + ContextMenuState::Open { + kind: ContextMenuKind::Canvas, + .. + } + )); + let submenu = state.context_submenu().expect("submenu open"); + assert_eq!(submenu.kind, kind); + let focus = submenu.keyboard_focus.expect("submenu focus"); + let entries = state.context_submenu_entries(); + assert!(!entries[focus].disabled); + assert!(entries[focus].command.is_some()); +} + #[test] -fn open_zoom_menu_command_switches_to_zoom_submenu_with_actionable_focus() { +fn open_zoom_menu_command_opens_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()); + assert_submenu_has_actionable_focus(&state, ContextMenuKind::Zoom); } #[test] @@ -446,24 +454,42 @@ fn page_context_menu_header_uses_page_name_and_enables_move_submenu() { assert_eq!(entries[0].label, "Agenda — Page 2 (2/2)"); let move_entry = entries .iter() - .find(|entry| entry.command == Some(MenuCommand::OpenPageMoveMenu)) + .find(|entry| entry.submenu == Some(ContextMenuKind::PageMove)) .expect("move entry"); - assert!(move_entry.has_submenu); assert!(!move_entry.disabled); } #[test] -fn page_move_menu_excludes_source_board_and_lists_other_boards() { +fn page_move_submenu_excludes_source_board_and_moves_the_menu_page() { let mut state = create_test_input_state(); let blackboard = board_index(&state, BOARD_ID_BLACKBOARD); - state.open_page_context_menu((5, 5), blackboard, 0); + let whiteboard = board_index(&state, BOARD_ID_WHITEBOARD); + set_named_pages(&mut state, blackboard, &[Some("Keep"), Some("Move me")], 1); + state.open_page_context_menu((5, 5), blackboard, 1); state.execute_menu_command(MenuCommand::OpenPageMoveMenu); - let entries = state.context_menu_entries(); + assert!(matches!( + state.context_menu.state, + ContextMenuState::Open { + kind: ContextMenuKind::Page, + .. + } + )); + let entries = state.context_submenu_entries(); assert!(entries.iter().any(|entry| entry.label == "Overlay")); assert!(entries.iter().any(|entry| entry.label == "Whiteboard")); assert!(!entries.iter().any(|entry| entry.label == "Blackboard")); + + // The submenu keeps the page its parent menu was opened for. + let pages = state.boards.board_states()[whiteboard].pages.page_count(); + state.execute_menu_command(MenuCommand::PageMoveToBoard { + id: BOARD_ID_WHITEBOARD.to_string(), + }); + assert_eq!( + state.boards.board_states()[whiteboard].pages.page_count(), + pages + 1 + ); } #[test] @@ -482,8 +508,17 @@ fn pages_menu_shows_window_indicators_around_active_page() { state.open_context_menu((0, 0), Vec::new(), ContextMenuKind::Pages, None); let entries = state.context_menu_entries(); - assert!(entries.iter().any(|entry| entry.label == " ... 1 above")); - assert!(entries.iter().any(|entry| entry.label == " ... 1 below")); + // Overflow rows lead to the board picker's page panel. + assert!(entries.iter().any(|entry| { + entry.label == " ... 1 above (open picker)" + && entry.command == Some(MenuCommand::OpenBoardPicker) + && !entry.disabled + })); + assert!( + entries + .iter() + .any(|entry| entry.label == " ... 1 below (open picker)") + ); assert!( entries .iter() @@ -623,43 +658,23 @@ fn right_clicking_a_board_row_selects_it_and_opens_its_menu() { } #[test] -fn open_pages_menu_command_switches_to_pages_submenu_with_actionable_focus() { +fn open_pages_menu_command_opens_pages_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::OpenPagesMenu); - let focus_index = match &state.context_menu.state { - ContextMenuState::Open { - kind: ContextMenuKind::Pages, - keyboard_focus, - .. - } => keyboard_focus.expect("pages submenu focus"), - _ => panic!("expected pages submenu to be open"), - }; - let entries = state.context_menu_entries(); - assert!(!entries[focus_index].disabled); - assert!(entries[focus_index].command.is_some()); + assert_submenu_has_actionable_focus(&state, ContextMenuKind::Pages); } #[test] -fn open_boards_menu_command_switches_to_boards_submenu_with_actionable_focus() { +fn open_boards_menu_command_opens_boards_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::OpenBoardsMenu); - let focus_index = match &state.context_menu.state { - ContextMenuState::Open { - kind: ContextMenuKind::Boards, - keyboard_focus, - .. - } => keyboard_focus.expect("boards submenu focus"), - _ => panic!("expected boards submenu to be open"), - }; - let entries = state.context_menu_entries(); - assert!(!entries[focus_index].disabled); - assert!(entries[focus_index].command.is_some()); + assert_submenu_has_actionable_focus(&state, ContextMenuKind::Boards); } #[test] diff --git a/src/input/state/tests/menus/mod.rs b/src/input/state/tests/menus/mod.rs index 53276ee48..f9d831f15 100644 --- a/src/input/state/tests/menus/mod.rs +++ b/src/input/state/tests/menus/mod.rs @@ -4,3 +4,4 @@ mod clipboard; mod context_menu; mod history; mod locks; +mod submenu; diff --git a/src/input/state/tests/menus/submenu.rs b/src/input/state/tests/menus/submenu.rs new file mode 100644 index 000000000..63e97ec4a --- /dev/null +++ b/src/input/state/tests/menus/submenu.rs @@ -0,0 +1,512 @@ +use std::time::{Duration, Instant}; + +use super::*; +use crate::input::BOARD_ID_WHITEBOARD; +use crate::input::events::Key; +use crate::input::state::{SUBMENU_AIM_GRACE, SUBMENU_HOVER_DELAY, SubmenuSide}; + +const SCREEN: (u32, u32) = (1280, 720); + +fn open_canvas_menu(state: &mut InputState, anchor: (i32, i32)) { + state.open_context_menu(anchor, Vec::new(), ContextMenuKind::Canvas, None); + state.update_context_menu_layout(SCREEN.0, SCREEN.1); +} + +fn row_of(state: &InputState, command: MenuCommand) -> usize { + state + .context_menu_entries() + .iter() + .position(|entry| entry.command.as_ref() == Some(&command)) + .expect("menu row") +} + +fn parent_row_of(state: &InputState, kind: ContextMenuKind) -> usize { + state + .context_menu_entries() + .iter() + .position(|entry| entry.submenu == Some(kind)) + .expect("parent row") +} + +/// A point inside a row of the menu, near its right side. +fn menu_row(state: &InputState, row: usize) -> (i32, i32) { + let layout = state.context_menu_layout().expect("menu layout"); + ( + (layout.origin_x + layout.width - 30.0) as i32, + (layout.origin_y + layout.padding_y + layout.row_height * (row as f64 + 0.5)) as i32, + ) +} + +fn submenu_row(state: &InputState, row: usize) -> (i32, i32) { + let layout = state.context_submenu_layout().expect("submenu layout"); + ( + (layout.origin_x + layout.padding_x) as i32, + (layout.origin_y + layout.padding_y + layout.row_height * (row as f64 + 0.5)) as i32, + ) +} + +fn root_kind(state: &InputState) -> Option { + match &state.context_menu.state { + ContextMenuState::Open { kind, .. } => Some(*kind), + ContextMenuState::Hidden => None, + } +} + +fn submenu_kind(state: &InputState) -> Option { + state.context_submenu().map(|submenu| submenu.kind) +} + +fn root_hover(state: &InputState) -> Option { + match &state.context_menu.state { + ContextMenuState::Open { hover_index, .. } => *hover_index, + ContextMenuState::Hidden => None, + } +} + +/// Moves the pointer over the menu and lets the hover delay elapse, as the +/// event loop does once the pointer rests. +fn hover_and_settle(state: &mut InputState, x: i32, y: i32) { + state.update_pointer_position_synthetic(x, y); + state.update_context_menu_hover_from_pointer(x, y); + settle(state); +} + +fn settle(state: &mut InputState) { + state.tick_context_menu_hover(Instant::now() + SUBMENU_AIM_GRACE + Duration::from_secs(1)); + state.update_context_menu_layout(SCREEN.0, SCREEN.1); +} + +fn text_resources() -> (crate::draw::TextMeasurer, crate::ui_text::UiTextEngine) { + ( + crate::draw::TextMeasurer::default(), + crate::ui_text::UiTextEngine::default(), + ) +} + +#[test] +fn hovering_a_parent_row_opens_its_submenu_beside_it_after_a_pause() { + let mut state = create_test_input_state(); + open_canvas_menu(&mut state, (100, 100)); + let boards = parent_row_of(&state, ContextMenuKind::Boards); + let (x, y) = menu_row(&state, boards); + + state.update_pointer_position_synthetic(x, y); + state.update_context_menu_hover_from_pointer(x, y); + assert_eq!(root_hover(&state), Some(boards), "hover follows at once"); + assert_eq!( + submenu_kind(&state), + None, + "the pane waits for the pointer to rest" + ); + assert!(state.context_menu_hover_timeout(Instant::now()) <= Some(SUBMENU_HOVER_DELAY)); + + settle(&mut state); + + assert_eq!(root_kind(&state), Some(ContextMenuKind::Canvas)); + let submenu = state.context_submenu().expect("boards submenu"); + assert_eq!( + (submenu.kind, submenu.parent_index, submenu.keyboard_focus), + (ContextMenuKind::Boards, boards, None) + ); + let menu = *state.context_menu_layout().unwrap(); + let pane = *state.context_submenu_layout().unwrap(); + assert!(pane.origin_x >= menu.origin_x + menu.width); + assert_eq!( + pane.origin_y + pane.padding_y, + menu.origin_y + menu.padding_y + menu.row_height * boards as f64, + "the first submenu entry lines up with its row" + ); + assert_eq!(state.context_submenu_side(), SubmenuSide::Right); +} + +#[test] +fn a_submenu_opens_on_the_left_when_the_right_edge_is_too_close() { + let mut state = create_test_input_state(); + open_canvas_menu(&mut state, (SCREEN.0 as i32 - 10, 100)); + assert_eq!( + state.context_submenu_side(), + SubmenuSide::Left, + "arrows point left before any pane opens" + ); + + state.execute_menu_command(MenuCommand::OpenBoardsMenu); + state.update_context_menu_layout(SCREEN.0, SCREEN.1); + + let menu = *state.context_menu_layout().unwrap(); + let pane = *state.context_submenu_layout().unwrap(); + assert!(pane.origin_x + pane.width <= menu.origin_x); + assert_eq!(state.context_submenu_side(), SubmenuSide::Left); +} + +#[test] +fn heading_into_a_submenu_keeps_it_while_other_rows_switch_or_close_it() { + let mut state = create_test_input_state(); + open_canvas_menu(&mut state, (100, 100)); + let boards = parent_row_of(&state, ContextMenuKind::Boards); + let pages = parent_row_of(&state, ContextMenuKind::Pages); + let help = row_of(&state, MenuCommand::ToggleHelp); + assert_eq!(pages, boards + 1, "Pages sits right below Boards"); + let (x, boards_y) = menu_row(&state, boards); + let (_, pages_y) = menu_row(&state, pages); + let (_, help_y) = menu_row(&state, help); + + hover_and_settle(&mut state, x, boards_y); + assert_eq!(submenu_kind(&state), Some(ContextMenuKind::Boards)); + + // A diagonal step toward the Boards submenu crosses the Pages row without + // moving the highlight off Boards. + state.update_pointer_position_synthetic(x + 20, pages_y); + state.update_context_menu_hover_from_pointer(x + 20, pages_y); + assert_eq!(submenu_kind(&state), Some(ContextMenuKind::Boards)); + assert_eq!(root_hover(&state), Some(boards)); + + // Moving within the Pages row without heading into the submenu switches + // once the pointer rests. + hover_and_settle(&mut state, x + 20, pages_y + 4); + assert_eq!(submenu_kind(&state), Some(ContextMenuKind::Pages)); + + // A row without a submenu closes it and leaves the menu open. + hover_and_settle(&mut state, x + 20, help_y); + assert_eq!(submenu_kind(&state), None); + assert!(state.is_context_menu_open()); +} + +#[test] +fn a_pointer_that_stops_inside_the_aim_triangle_settles_on_its_row() { + let mut state = create_test_input_state(); + open_canvas_menu(&mut state, (100, 100)); + let boards = parent_row_of(&state, ContextMenuKind::Boards); + let pages = parent_row_of(&state, ContextMenuKind::Pages); + let (x, boards_y) = menu_row(&state, boards); + let (_, pages_y) = menu_row(&state, pages); + + hover_and_settle(&mut state, x, boards_y); + state.update_pointer_position_synthetic(x + 20, pages_y); + state.update_context_menu_hover_from_pointer(x + 20, pages_y); + assert_eq!(submenu_kind(&state), Some(ContextMenuKind::Boards)); + let timeout = state + .context_menu_hover_timeout(Instant::now()) + .expect("the aim grace is bounded"); + assert!(timeout <= SUBMENU_AIM_GRACE); + + // Nothing moves. Before the grace ends the pane stays; after it the row + // under the resting pointer wins. + assert!(!state.tick_context_menu_hover(Instant::now())); + assert_eq!(submenu_kind(&state), Some(ContextMenuKind::Boards)); + assert!(state.tick_context_menu_hover(Instant::now() + SUBMENU_AIM_GRACE * 2)); + assert_eq!(submenu_kind(&state), Some(ContextMenuKind::Pages)); + assert_eq!(root_hover(&state), Some(pages)); + assert!(state.needs_redraw); + assert_eq!(state.context_menu_hover_timeout(Instant::now()), None); +} + +#[test] +fn sweeping_across_a_parent_row_does_not_open_its_submenu() { + let mut state = create_test_input_state(); + open_canvas_menu(&mut state, (100, 100)); + let boards = parent_row_of(&state, ContextMenuKind::Boards); + let help = row_of(&state, MenuCommand::ToggleHelp); + let (x, boards_y) = menu_row(&state, boards); + let (_, help_y) = menu_row(&state, help); + + state.update_context_menu_hover_from_pointer(x, boards_y); + state.update_context_menu_hover_from_pointer(x, help_y); + settle(&mut state); + + assert_eq!(submenu_kind(&state), None); + assert_eq!(root_hover(&state), Some(help)); +} + +#[test] +fn right_opens_a_submenu_and_left_or_escape_return_to_its_row() { + let (measurer, ui_engine) = text_resources(); + let resources = crate::input::state::InputTextResources { + measurer: &measurer, + ui_engine: &ui_engine, + }; + let press = |state: &mut InputState, key: Key| { + state.handle_context_menu_key_with_resources(resources, key) + }; + let mut state = create_test_input_state(); + state.toggle_context_menu_via_keyboard(); + let boards = parent_row_of(&state, ContextMenuKind::Boards); + state.set_context_menu_focus(Some(boards)); + + assert!(press(&mut state, Key::Right)); + let first = state + .context_submenu() + .and_then(|submenu| submenu.keyboard_focus) + .expect("focus moves into the submenu"); + assert!(!state.context_submenu_entries()[first].disabled); + assert!(state.context_submenu_is_active()); + assert!(press(&mut state, Key::Down)); + assert_ne!( + state + .context_submenu() + .and_then(|submenu| submenu.keyboard_focus), + Some(first) + ); + + assert!(press(&mut state, Key::Left)); + assert_eq!(submenu_kind(&state), None); + assert!(matches!( + state.context_menu.state, + ContextMenuState::Open { + keyboard_focus: Some(row), + .. + } if row == boards + )); + + assert!(press(&mut state, Key::Right)); + assert!(press(&mut state, Key::Escape)); + assert_eq!(submenu_kind(&state), None); + assert!(state.is_context_menu_open()); + assert!(press(&mut state, Key::Escape)); + assert!(!state.is_context_menu_open()); +} + +#[test] +fn the_menu_swallows_arrow_keys_that_have_nothing_to_do() { + let (measurer, ui_engine) = text_resources(); + let resources = crate::input::state::InputTextResources { + measurer: &measurer, + ui_engine: &ui_engine, + }; + let mut state = create_test_input_state(); + state.toggle_context_menu_via_keyboard(); + let help = row_of(&state, MenuCommand::ToggleHelp); + state.set_context_menu_focus(Some(help)); + + assert!(state.handle_context_menu_key_with_resources(resources, Key::Right)); + assert_eq!(submenu_kind(&state), None); + assert!(state.handle_context_menu_key_with_resources(resources, Key::Left)); + assert!(state.is_context_menu_open()); +} + +#[test] +fn hovering_the_parent_row_hands_the_selection_back_from_the_submenu() { + let (measurer, ui_engine) = text_resources(); + let resources = crate::input::state::InputTextResources { + measurer: &measurer, + ui_engine: &ui_engine, + }; + let mut state = create_test_input_state(); + open_canvas_menu(&mut state, (100, 100)); + let boards = parent_row_of(&state, ContextMenuKind::Boards); + state.set_context_menu_focus(Some(boards)); + assert!(state.handle_context_menu_key_with_resources(resources, Key::Right)); + assert!(state.context_submenu_is_active()); + + // The mouse lands on the parent row: the pane stays, its keyboard focus + // goes, and Enter acts on the hovered row. + let (x, y) = menu_row(&state, boards); + hover_and_settle(&mut state, x, y); + assert_eq!(submenu_kind(&state), Some(ContextMenuKind::Boards)); + assert_eq!( + state + .context_submenu() + .and_then(|submenu| submenu.keyboard_focus), + None + ); + assert!(!state.context_submenu_is_active()); +} + +#[test] +fn clicking_a_parent_row_opens_it_and_clicking_an_entry_runs_it() { + let (measurer, ui_engine) = text_resources(); + let resources = crate::input::state::InputTextResources { + measurer: &measurer, + ui_engine: &ui_engine, + }; + let mut state = create_test_input_state(); + state.switch_board(BOARD_ID_WHITEBOARD); + open_canvas_menu(&mut state, (100, 100)); + let pages = parent_row_of(&state, ContextMenuKind::Pages); + let page_count = state.boards.page_count(); + + let (x, y) = menu_row(&state, pages); + assert!(state.handle_context_menu_release_at_with_resources(resources, x, y)); + assert_eq!(submenu_kind(&state), Some(ContextMenuKind::Pages)); + state.update_context_menu_layout(SCREEN.0, SCREEN.1); + + let new_page = state + .context_submenu_entries() + .iter() + .position(|entry| entry.command == Some(MenuCommand::PageNew)) + .expect("New Page entry"); + let (x, y) = submenu_row(&state, new_page); + assert!(state.handle_context_menu_release_at_with_resources(resources, x, y)); + assert_eq!(state.boards.page_count(), page_count + 1); + assert!(!state.is_context_menu_open()); +} + +#[test] +fn clicking_an_expanded_parent_row_collapses_it_until_the_pointer_leaves() { + let (measurer, ui_engine) = text_resources(); + let resources = crate::input::state::InputTextResources { + measurer: &measurer, + ui_engine: &ui_engine, + }; + let mut state = create_test_input_state(); + open_canvas_menu(&mut state, (100, 100)); + let boards = parent_row_of(&state, ContextMenuKind::Boards); + let pages = parent_row_of(&state, ContextMenuKind::Pages); + let (x, boards_y) = menu_row(&state, boards); + let (_, pages_y) = menu_row(&state, pages); + hover_and_settle(&mut state, x, boards_y); + assert_eq!(submenu_kind(&state), Some(ContextMenuKind::Boards)); + + assert!(state.handle_context_menu_release_at_with_resources(resources, x, boards_y)); + assert_eq!(submenu_kind(&state), None); + assert!(state.is_context_menu_open()); + + // A jiggle on the same row does not reopen it. + hover_and_settle(&mut state, x + 1, boards_y + 1); + assert_eq!(submenu_kind(&state), None); + + // Leaving and returning does. + hover_and_settle(&mut state, x, pages_y); + assert_eq!(submenu_kind(&state), Some(ContextMenuKind::Pages)); + hover_and_settle(&mut state, x, boards_y); + assert_eq!(submenu_kind(&state), Some(ContextMenuKind::Boards)); +} + +#[test] +fn keyboard_navigation_cancels_a_pending_hover_open() { + let (measurer, ui_engine) = text_resources(); + let resources = crate::input::state::InputTextResources { + measurer: &measurer, + ui_engine: &ui_engine, + }; + let mut state = create_test_input_state(); + open_canvas_menu(&mut state, (100, 100)); + let boards = parent_row_of(&state, ContextMenuKind::Boards); + let pages = parent_row_of(&state, ContextMenuKind::Pages); + let (x, y) = menu_row(&state, boards); + state.update_pointer_position_synthetic(x, y); + state.update_context_menu_hover_from_pointer(x, y); + + // Down before the delay elapses moves the selection to Pages. + assert!(state.handle_context_menu_key_with_resources(resources, Key::Down)); + assert_eq!(state.context_menu_hover_timeout(Instant::now()), None); + settle(&mut state); + + assert_eq!( + submenu_kind(&state), + None, + "the resting pointer must not undo the keyboard" + ); + assert!(state.handle_context_menu_key_with_resources(resources, Key::Right)); + assert_eq!(submenu_kind(&state), Some(ContextMenuKind::Pages)); + assert_eq!( + state.context_submenu().map(|submenu| submenu.parent_index), + Some(pages) + ); +} + +#[test] +fn leaving_the_menu_lifts_a_collapsed_parent_rows_suppression() { + let (measurer, ui_engine) = text_resources(); + let resources = crate::input::state::InputTextResources { + measurer: &measurer, + ui_engine: &ui_engine, + }; + let mut state = create_test_input_state(); + open_canvas_menu(&mut state, (100, 100)); + let boards = parent_row_of(&state, ContextMenuKind::Boards); + let (x, y) = menu_row(&state, boards); + hover_and_settle(&mut state, x, y); + assert!(state.handle_context_menu_release_at_with_resources(resources, x, y)); + assert_eq!(submenu_kind(&state), None); + + // Out of the menu and straight back onto the same row. + let menu = *state.context_menu_layout().unwrap(); + let outside_x = (menu.origin_x + menu.width + 200.0) as i32; + hover_and_settle(&mut state, outside_x, y); + assert!(state.is_context_menu_open()); + hover_and_settle(&mut state, x, y); + + assert_eq!(submenu_kind(&state), Some(ContextMenuKind::Boards)); +} + +#[test] +fn page_overflow_from_the_picker_keeps_the_picker_open() { + let mut state = create_test_input_state(); + state.switch_board(BOARD_ID_WHITEBOARD); + state.open_board_picker_with_measurer(&crate::draw::TextMeasurer::default()); + state.execute_menu_command(MenuCommand::OpenPagesMenu); + assert!(state.is_board_picker_open() && state.is_context_menu_open()); + + state.execute_menu_command(MenuCommand::OpenBoardPicker); + + assert!(!state.is_context_menu_open()); + assert!( + state.is_board_picker_open(), + "the picker underneath stays open" + ); +} + +#[test] +fn a_menu_command_with_no_parent_row_opens_that_menu_on_its_own() { + let mut state = create_test_input_state(); + state.switch_board(BOARD_ID_WHITEBOARD); + state.open_board_picker_with_measurer(&crate::draw::TextMeasurer::default()); + state.update_pointer_position_synthetic(300, 200); + + // The board picker's page overflow link runs this with no menu open. + state.execute_menu_command(MenuCommand::OpenPagesMenu); + + assert_eq!(root_kind(&state), Some(ContextMenuKind::Pages)); + assert_eq!(submenu_kind(&state), None); + assert!(matches!( + state.context_menu.state, + ContextMenuState::Open { + anchor: (300, 200), + .. + } + )); + let entries = state.context_menu_entries(); + assert!( + entries[0].disabled && entries[0].label.ends_with("Page 1/1"), + "a standalone menu keeps its header: {}", + entries[0].label + ); +} + +#[test] +fn a_submenu_drops_the_header_its_parent_row_already_shows() { + let mut state = create_test_input_state(); + open_canvas_menu(&mut state, (100, 100)); + let boards = parent_row_of(&state, ContextMenuKind::Boards); + let summary = state.context_menu_entries()[boards].shortcut.clone(); + state.execute_menu_command(MenuCommand::OpenBoardsMenu); + + let entries = state.context_submenu_entries(); + let summary = summary.expect("the parent row summarises the submenu"); + assert!(summary.starts_with("Overlay (1/"), "{summary}"); + assert!( + entries[0].command.is_some(), + "the row beside the parent is a board, not a header: {}", + entries[0].label + ); + assert!(entries.iter().all(|entry| entry.label != summary)); +} + +#[test] +fn opening_a_submenu_keeps_the_menu_without_repainting_the_whole_surface() { + let mut state = create_test_input_state(); + open_canvas_menu(&mut state, (100, 100)); + let _ = state.dirty_tracker.take_regions(1280, 720); + + state.execute_menu_command(MenuCommand::OpenBoardsMenu); + + assert_eq!(root_kind(&state), Some(ContextMenuKind::Canvas)); + assert_eq!(submenu_kind(&state), Some(ContextMenuKind::Boards)); + assert!(state.needs_redraw); + let regions = state.dirty_tracker.take_regions(1280, 720); + assert!( + !regions + .iter() + .any(|rect| rect.width >= 1280 && rect.height >= 720) + ); +} diff --git a/src/ui.rs b/src/ui.rs index b02c69e9f..3dc53dce2 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -42,7 +42,9 @@ pub(crate) use command_palette::{ }; pub use command_palette::{command_palette_visual_geometry, render_command_palette}; pub use context_menu::render_context_menu; -pub(crate) use context_menu::render_context_menu_with_engine; +pub(crate) use context_menu::{ + context_menu_visual_geometry, context_submenu_visual_geometry, render_context_menu_with_engine, +}; pub(crate) use eyedropper_loupe::{compute_eyedropper_loupe_layout, render_eyedropper_loupe}; pub use font_picker::render_font_picker; #[allow(unused_imports)] diff --git a/src/ui/board_picker/appearance.rs b/src/ui/board_picker/appearance.rs index 4565766ae..ead04caeb 100644 --- a/src/ui/board_picker/appearance.rs +++ b/src/ui/board_picker/appearance.rs @@ -75,7 +75,7 @@ pub(super) fn render( weight: cairo::FontWeight::Bold, ..style }, - &format!("Paper — {board_name}"), + &format!("Paper: {board_name}"), 4.0, -45.0, None, diff --git a/src/ui/context_menu.rs b/src/ui/context_menu.rs index f8370f149..8ca1e1f8b 100644 --- a/src/ui/context_menu.rs +++ b/src/ui/context_menu.rs @@ -1,12 +1,13 @@ use crate::input::InputState; -use crate::input::state::ContextMenuState; +use crate::input::state::{ContextMenuEntry, ContextMenuLayout, ContextMenuState, SubmenuSide}; use crate::ui::primitives::draw_rounded_rect; use crate::ui::theme::Rgba; use crate::ui_text::{UiTextEngine, UiTextStyle}; use super::constants::{ - self, BG_HOVER, BORDER_FOCUS, FOCUS_RING_WIDTH, ICON_SUBMENU_ARROW, NAV_HINT_MENU, - RADIUS_PANEL, RADIUS_SM, RADIUS_STD, TEXT_DISABLED, TEXT_HINT, TEXT_PRIMARY, + self, BG_EXPANDED, BG_HOVER, BORDER_FOCUS, FOCUS_RING_WIDTH, ICON_SUBMENU_ARROW, NAV_HINT_MENU, + NAV_HINT_MENU_SUBMENUS, NAV_HINT_SUBMENU, RADIUS_PANEL, RADIUS_SM, RADIUS_STD, SHADOW, + TEXT_DISABLED, TEXT_HINT, TEXT_PRIMARY, }; /// Footer strip below the menu: darker than the menu surface so the hint reads @@ -15,37 +16,36 @@ const HINT_FOOTER_BG: Rgba = (0.08, 0.10, 0.14, 0.9); /// Footer hint text: slightly brighter than TEXT_TERTIARY for legibility on /// the darker strip (kept from pre-theme literals). const HINT_FOOTER_TEXT: Rgba = (0.65, 0.68, 0.75, 1.0); +/// Gap between the menu and its hint footer. +const HINT_GAP: f64 = 4.0; +const HINT_PADDING: f64 = 6.0; +/// Accent bar on the parent row of an open submenu. +const EXPANDED_BAR_WIDTH: f64 = 3.0; +/// The submenu's shadow: a few layers stepping outward stand in for a blur. +const SHADOW_LAYERS: u32 = 3; +const SHADOW_SPREAD: f64 = 2.0; +const SHADOW_OFFSET_Y: f64 = 2.0; +/// How far the submenu's shadow reaches past its pane. +const SHADOW_EXTENT: f64 = SHADOW_LAYERS as f64 * SHADOW_SPREAD + SHADOW_OFFSET_Y; /// Renders a floating context menu for shape or canvas actions. -pub fn render_context_menu( - ctx: &cairo::Context, - input_state: &InputState, - _screen_width: u32, - _screen_height: u32, -) { - render_context_menu_with_engine( - &UiTextEngine::default(), - ctx, - input_state, - _screen_width, - _screen_height, - ); +pub fn render_context_menu(ctx: &cairo::Context, input_state: &InputState) { + render_context_menu_with_engine(&UiTextEngine::default(), ctx, input_state); } pub(crate) fn render_context_menu_with_engine( engine: &UiTextEngine, ctx: &cairo::Context, input_state: &InputState, - _screen_width: u32, - _screen_height: u32, ) { - let (hover_index, focus_index) = match input_state.context_menu.state() { - ContextMenuState::Open { - hover_index, - keyboard_focus, - .. - } => (*hover_index, *keyboard_focus), - ContextMenuState::Hidden => return, + let ContextMenuState::Open { + hover_index, + keyboard_focus, + submenu, + .. + } = input_state.context_menu.state() + else { + return; }; let entries = input_state.context_menu_entries(); @@ -53,12 +53,116 @@ pub(crate) fn render_context_menu_with_engine( return; } - let layout = match input_state.context_menu_layout() { - Some(layout) => *layout, - None => return, + let Some(layout) = input_state.context_menu_layout().copied() else { + return; }; let _ = ctx.save(); + let side = input_state.context_submenu_side(); + draw_menu( + engine, + ctx, + &layout, + &entries, + MenuStyle { + surface: crate::ui::theme::popup::bg_context_menu(), + arrow_side: side, + shadow: false, + }, + RowHighlight { + hover: *hover_index, + focus: *keyboard_focus, + expanded: submenu.map(|submenu| submenu.parent_index), + }, + ); + let hint = if input_state.context_submenu_is_active() { + NAV_HINT_SUBMENU + } else if entries.iter().any(|entry| entry.submenu.is_some()) { + NAV_HINT_MENU_SUBMENUS + } else { + NAV_HINT_MENU + }; + draw_hint_footer(engine, ctx, &layout, hint); + + // An open submenu paints above the menu it opens from. + let submenu_entries = input_state.context_submenu_entries(); + if let (Some(submenu), Some(pane)) = (submenu, input_state.context_submenu_layout()) + && !submenu_entries.is_empty() + { + draw_menu( + engine, + ctx, + pane, + &submenu_entries, + MenuStyle { + surface: crate::ui::theme::popup::bg_context_submenu(), + arrow_side: side, + shadow: true, + }, + RowHighlight { + hover: submenu.hover_index, + focus: submenu.keyboard_focus, + expanded: None, + }, + ); + } + + let _ = ctx.restore(); +} + +/// Bounds of the open menu and its hint footer, as laid out for this frame. +pub(crate) fn context_menu_visual_geometry( + input_state: &InputState, +) -> Option<(f64, f64, f64, f64)> { + let layout = input_state.context_menu_layout()?; + Some(( + layout.origin_x, + layout.origin_y, + layout.width, + layout.height + HINT_GAP + hint_footer_height(layout), + )) +} + +/// Bounds of the open submenu and its shadow, as laid out for this frame. +pub(crate) fn context_submenu_visual_geometry( + input_state: &InputState, +) -> Option<(f64, f64, f64, f64)> { + let pane = input_state.context_submenu_layout()?; + Some(( + pane.origin_x - SHADOW_EXTENT, + pane.origin_y - SHADOW_EXTENT, + pane.width + SHADOW_EXTENT * 2.0, + pane.height + SHADOW_EXTENT * 2.0, + )) +} + +/// The highlighted rows of one menu. +#[derive(Clone, Copy)] +struct RowHighlight { + hover: Option, + focus: Option, + /// The row whose submenu is open. + expanded: Option, +} + +/// How one menu pane is dressed. +#[derive(Clone, Copy)] +struct MenuStyle { + surface: Rgba, + /// Where submenu arrows point. + arrow_side: SubmenuSide, + /// A drop shadow lifts a pane above the menu it opens from. + shadow: bool, +} + +fn draw_menu( + engine: &UiTextEngine, + ctx: &cairo::Context, + layout: &ContextMenuLayout, + entries: &[ContextMenuEntry], + style: MenuStyle, + highlight: RowHighlight, +) { let text_style = UiTextStyle { family: "Sans", slant: cairo::FontSlant::Normal, @@ -66,6 +170,10 @@ pub(crate) fn render_context_menu_with_engine( size: layout.font_size, }; + if style.shadow { + draw_shadow(ctx, layout); + } + // Background and hairline border (popover radius, matching the other // overlay popups) draw_rounded_rect( @@ -76,7 +184,7 @@ pub(crate) fn render_context_menu_with_engine( layout.height, RADIUS_PANEL, ); - constants::set_color(ctx, crate::ui::theme::popup::bg_context_menu()); + constants::set_color(ctx, style.surface); let _ = ctx.fill_preserve(); constants::set_color(ctx, crate::ui::theme::popup::border_context_menu()); ctx.set_line_width(1.0); @@ -86,12 +194,14 @@ pub(crate) fn render_context_menu_with_engine( let row_top = layout.origin_y + layout.padding_y + layout.row_height * index as f64; let row_center = row_top + layout.row_height * 0.5; - // Distinguish hover (filled background) from keyboard focus (border ring) - let is_hovered = hover_index == Some(index) && !entry.disabled; - let is_focused = focus_index == Some(index) && !entry.disabled; + // Hover fills the row, keyboard focus rings it, and the parent of an + // open submenu keeps a quieter fill with an accent bar. + let is_hovered = highlight.hover == Some(index) && !entry.disabled; + let is_expanded = highlight.expanded == Some(index) && !entry.disabled; + let is_focused = highlight.focus == Some(index) && !entry.disabled; - if is_hovered { - constants::set_color(ctx, BG_HOVER); + if is_hovered || is_expanded { + constants::set_color(ctx, if is_hovered { BG_HOVER } else { BG_EXPANDED }); draw_rounded_rect( ctx, layout.origin_x + 4.0, @@ -102,6 +212,20 @@ pub(crate) fn render_context_menu_with_engine( ); let _ = ctx.fill(); } + if is_expanded { + constants::set_color(ctx, BORDER_FOCUS); + let bar_x = match style.arrow_side { + SubmenuSide::Right => layout.origin_x + layout.width - 4.0 - EXPANDED_BAR_WIDTH, + SubmenuSide::Left => layout.origin_x + 4.0, + }; + ctx.rectangle( + bar_x, + row_top + 3.0, + EXPANDED_BAR_WIDTH, + layout.row_height - 6.0, + ); + let _ = ctx.fill(); + } if is_focused && !is_hovered { // Draw focus ring (outline) when keyboard navigating @@ -152,53 +276,86 @@ pub(crate) fn render_context_menu_with_engine( ); } - if entry.has_submenu { + if entry.submenu.is_some() { let arrow_x = layout.origin_x + layout.width - layout.padding_x - layout.arrow_width * 0.6; let arrow_y = row_center; - constants::set_color(ctx, constants::with_alpha(ICON_SUBMENU_ARROW, text_a)); - ctx.move_to(arrow_x, arrow_y - 5.0); - ctx.line_to(arrow_x + 6.0, arrow_y); - ctx.line_to(arrow_x, arrow_y + 5.0); + let arrow_color = if is_expanded { + BORDER_FOCUS + } else { + ICON_SUBMENU_ARROW + }; + constants::set_color(ctx, constants::with_alpha(arrow_color, text_a)); + // The arrow points to the side the pane opens on. + let (base_x, tip_x) = match style.arrow_side { + SubmenuSide::Right => (arrow_x, arrow_x + 6.0), + SubmenuSide::Left => (arrow_x + 6.0, arrow_x), + }; + ctx.move_to(base_x, arrow_y - 5.0); + ctx.line_to(tip_x, arrow_y); + ctx.line_to(base_x, arrow_y + 5.0); let _ = ctx.fill(); } } +} + +/// A soft shadow under a pane: stacked translucent rects widening outward. +fn draw_shadow(ctx: &cairo::Context, layout: &ContextMenuLayout) { + let alpha = SHADOW.3 / SHADOW_LAYERS as f64; + for layer in 1..=SHADOW_LAYERS { + let spread = SHADOW_SPREAD * layer as f64; + constants::set_color(ctx, constants::with_alpha(SHADOW, alpha)); + draw_rounded_rect( + ctx, + layout.origin_x - spread, + layout.origin_y - spread + SHADOW_OFFSET_Y, + layout.width + spread * 2.0, + layout.height + spread * 2.0, + RADIUS_PANEL + spread, + ); + let _ = ctx.fill(); + } +} - // Navigation hint footer with background for visibility +fn hint_footer_height(layout: &ContextMenuLayout) -> f64 { + layout.font_size * 0.8 + HINT_PADDING * 2.0 +} + +/// Navigation hint footer with background for visibility. +fn draw_hint_footer( + engine: &UiTextEngine, + ctx: &cairo::Context, + layout: &ContextMenuLayout, + hint: &str, +) { let hint_style = UiTextStyle { family: "Sans", slant: cairo::FontSlant::Normal, weight: cairo::FontWeight::Normal, size: layout.font_size * 0.8, }; - let hint_padding = 6.0; - let hint_height = layout.font_size * 0.8 + hint_padding * 2.0; - let hint_y = layout.origin_y + layout.height + 4.0; + let hint_y = layout.origin_y + layout.height + HINT_GAP; - // Draw hint background constants::set_color(ctx, HINT_FOOTER_BG); draw_rounded_rect( ctx, layout.origin_x, hint_y, layout.width, - hint_height, + hint_footer_height(layout), RADIUS_STD, ); let _ = ctx.fill(); - // Draw hint text constants::set_color(ctx, HINT_FOOTER_TEXT); engine.draw_baseline( ctx, hint_style, - NAV_HINT_MENU, + hint, layout.origin_x + layout.padding_x, - hint_y + hint_padding + layout.font_size * 0.65, + hint_y + HINT_PADDING + layout.font_size * 0.65, None, ); - - let _ = ctx.restore(); } #[cfg(test)] @@ -213,7 +370,7 @@ mod engine_tests { { let ctx = cairo::Context::new(&surface).unwrap(); ctx.scale(f64::from(density), f64::from(density)); - render_context_menu_with_engine(engine, &ctx, state, 640, 480); + render_context_menu_with_engine(engine, &ctx, state); } surface.data().unwrap().to_vec() } @@ -233,13 +390,11 @@ mod engine_tests { (ContextMenuKind::Canvas, 1), ] { state.open_context_menu((620, 460), Vec::new(), kind, None); - let surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 640, 480).unwrap(); - let ctx = cairo::Context::new(&surface).unwrap(); - state.update_context_menu_layout_with_engine(&engine, &ctx, 640, 480); + state.update_context_menu_layout_with_engine(&engine, 640, 480); let layout = *state.context_menu_layout().unwrap(); let actual = paint(&engine, &state, density); assert!(actual.iter().any(|&byte| byte != 0)); - state.update_context_menu_layout_with_engine(&UiTextEngine::default(), &ctx, 640, 480); + state.update_context_menu_layout_with_engine(&UiTextEngine::default(), 640, 480); let fresh = state.context_menu_layout().unwrap(); assert_eq!( (fresh.origin_x, fresh.origin_y, fresh.width, fresh.height), @@ -263,5 +418,26 @@ mod engine_tests { assert_eq!(state.context_menu_index_at(x, y), Some(index)); } } + + // A submenu paints with its menu and hit-tests its own entries. It sits + // beside the menu, or over it when 640px leaves no room on either side. + state.open_context_menu((20, 20), Vec::new(), ContextMenuKind::Canvas, None); + let boards = state + .context_menu_entries() + .iter() + .position(|entry| entry.label == "Boards") + .unwrap(); + assert!(state.open_context_submenu(boards, false)); + state.update_context_menu_layout_with_engine(&engine, 640, 480); + let pane = *state.context_submenu_layout().unwrap(); + assert!(pane.origin_x >= 6.0 && pane.origin_x + pane.width <= 634.0); + let actual = paint(&engine, &state, 1); + assert!(actual == paint(&UiTextEngine::default(), &state, 1)); + for index in 0..state.context_submenu_entries().len() { + let x = (pane.origin_x + pane.padding_x) as i32; + let y = + (pane.origin_y + pane.padding_y + pane.row_height * (index as f64 + 0.5)) as i32; + assert_eq!(state.context_submenu_index_at(x, y), Some(index)); + } } } diff --git a/src/ui/theme.rs b/src/ui/theme.rs index 2a1d72423..c1e411143 100644 --- a/src/ui/theme.rs +++ b/src/ui/theme.rs @@ -76,6 +76,9 @@ pub mod overlay { // Theme's surface tokens as surfaces migrate. /// Context menu background pub const PANEL_BG_CONTEXT_MENU: Rgba = (0.10, 0.13, 0.17, 0.95); + /// Submenu surface: one shade lighter than the menu it opens from, so + /// the stacking reads even where the panes overlap. + pub const PANEL_BG_CONTEXT_SUBMENU: Rgba = (0.13, 0.16, 0.21, 0.96); /// Board picker panel background pub const PANEL_BG_BOARD_PICKER: Rgba = (0.09, 0.11, 0.15, 0.96); /// Properties panel background @@ -125,6 +128,9 @@ pub mod overlay { // ---- Interactive states ---- /// Hover state background (mouse hover) pub const BG_HOVER: Rgba = (0.25, 0.32, 0.45, 0.9); + /// A parent row whose submenu is open: a quieter fill than hover, so the + /// pointer's row and the pane's owner stay distinguishable. + pub const BG_EXPANDED: Rgba = (0.25, 0.32, 0.45, 0.5); /// State-ladder hover wash: white at 8% painted over the resting /// surface. Sits below the accent-filled selected state. pub const BG_HOVER_WASH: Rgba = (1.0, 1.0, 1.0, 0.08); @@ -298,6 +304,10 @@ pub mod overlay { // ---- Keyboard navigation hint text ---- /// Context menu navigation hint pub const NAV_HINT_MENU: &str = "↑↓ to navigate • Enter to select • Esc to close"; + /// Context menu navigation hint when a row opens a submenu + pub const NAV_HINT_MENU_SUBMENUS: &str = "↑↓ navigate • → open • Enter select • Esc close"; + /// Context menu navigation hint while a submenu holds the selection + pub const NAV_HINT_SUBMENU: &str = "↑↓ navigate • ← back • Enter select • Esc close"; /// Board picker navigation hint pub const NAV_HINT_BOARD_PICKER: &str = "↑↓ Navigate • Type to search"; /// Modal close hint @@ -789,6 +799,10 @@ pub mod popup { overlay::PANEL_BG_CONTEXT_MENU } + pub fn bg_context_submenu() -> Rgba { + overlay::PANEL_BG_CONTEXT_SUBMENU + } + pub fn bg_board_picker() -> Rgba { overlay::PANEL_BG_BOARD_PICKER } @@ -1055,6 +1069,10 @@ mod popup_theme_tests { fn popup_accessors_keep_the_complete_legacy_palette() { for (actual, expected) in [ (popup::bg_context_menu(), overlay::PANEL_BG_CONTEXT_MENU), + ( + popup::bg_context_submenu(), + overlay::PANEL_BG_CONTEXT_SUBMENU, + ), (popup::bg_board_picker(), overlay::PANEL_BG_BOARD_PICKER), (popup::bg_properties(), overlay::PANEL_BG_PROPERTIES), ( From 8b1108b4effdea74202c5c404f42e6c4a583c513 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Sun, 13 Sep 2026 15:51:58 +0200 Subject: [PATCH 5/6] feat(shortcuts): unify key glyph labels across the overlay Use shared glyphs and short key names in shortcut labels and the input HUD while preserving canonical config and terminal spellings. Keep help and command-palette search matching canonical key names, and give narrow keycaps a consistent minimum width. Validation: formatting and whitespace checks passed. The preceding review reported 4,623 passing all-feature library tests with 10 ignored; full CI and manual UI validation were not run for this patch. --- src/app/usage.rs | 11 +- src/config/keybindings.rs | 5 +- src/config/keybindings/binding.rs | 102 ++++++++++++++++++ src/config/keybindings/shortcut.rs | 24 ++++- src/config/keybindings/tests.rs | 63 +++++++++++ .../state/core/command_palette/search.rs | 49 ++++++++- src/input/state/input_hud/label.rs | 46 +++++--- src/ui/help_overlay/search.rs | 19 ++++ src/ui/input_hud/tests.rs | 3 +- src/ui/primitives.rs | 82 +++++++++++--- 10 files changed, 373 insertions(+), 31 deletions(-) diff --git a/src/app/usage.rs b/src/app/usage.rs index 6f34e6134..1a0813165 100644 --- a/src/app/usage.rs +++ b/src/app/usage.rs @@ -13,10 +13,17 @@ fn default_action_bindings() -> HashMap> { } } +/// Bindings as the config file spells them. +/// +/// The terminal control listing is what a user copies into `config.toml`, so it +/// stays on the canonical [`std::fmt::Display`] form: raw key names +/// (`ArrowLeft`), and `>` rather than `then` between sequence steps. The glyph +/// labels ([`Shortcut::display_label`]) belong to the on-screen surfaces, which +/// can render a glyph the terminal has no font for. fn action_binding_labels(bindings: &HashMap>, action: Action) -> Vec { bindings .get(&action) - .map(|list| list.iter().map(Shortcut::display_label).collect()) + .map(|list| list.iter().map(Shortcut::to_string).collect()) .unwrap_or_default() } @@ -31,7 +38,7 @@ fn action_primary_binding_label( bindings .get(&action) .and_then(|list| list.first()) - .map(Shortcut::display_label) + .map(Shortcut::to_string) } fn color_binding_labels(bindings: &HashMap>) -> String { diff --git a/src/config/keybindings.rs b/src/config/keybindings.rs index ca8b0549d..cd8e0cd54 100644 --- a/src/config/keybindings.rs +++ b/src/config/keybindings.rs @@ -11,7 +11,10 @@ mod shortcut; pub use crate::domain::Action; pub use authorship::KeybindingAuthorship; -pub use binding::{KeyBinding, NAMED_KEYS, is_deliverable_key_name, suggest_key_name}; +pub use binding::{ + KeyBinding, NAMED_KEYS, canonical_key_names, is_deliverable_key_name, key_display_name, + suggest_key_name, +}; pub use config::{ConfigurableAction, KeybindingConflict, KeybindingsConfig}; pub use shortcut::{ MAX_POINTER_EXTRA, MAX_SEQUENCE_STEPS, PointerButton, PointerTrigger, Shortcut, diff --git a/src/config/keybindings/binding.rs b/src/config/keybindings/binding.rs index 4169ce564..2812bc744 100644 --- a/src/config/keybindings/binding.rs +++ b/src/config/keybindings/binding.rs @@ -73,6 +73,95 @@ pub const NAMED_KEYS: &[&str] = &[ "F12", ]; +/// Named keys whose config spelling is replaced by a glyph or a short name in +/// every user-facing label, paired as (config name, display form). +/// +/// Only the display side changes: config files, [`fmt::Display`], and anything +/// persisted keep the left column. Names absent here (`Space`, `Home`, `End`, +/// the function keys) already read well and stay as they are. +const KEY_DISPLAY_NAMES: &[(&str, &str)] = &[ + ("ArrowLeft", "←"), + ("ArrowRight", "→"), + ("ArrowUp", "↑"), + ("ArrowDown", "↓"), + ("Return", "Enter"), + ("Escape", "Esc"), + ("Backspace", "⌫"), + ("Delete", "Del"), + ("PageUp", "PgUp"), + ("PageDown", "PgDn"), +]; + +/// How a key name is shown to the user. +/// +/// Case-insensitive like every other key-name comparison, so a config that +/// spells `arrowleft` still displays `←`. Anything without a display form — +/// single characters, `+`, `Space`, the function keys — comes back unchanged. +pub fn key_display_name(key: &str) -> &str { + KEY_DISPLAY_NAMES + .iter() + .find(|(name, _)| name.eq_ignore_ascii_case(key)) + .map_or(key, |(_, display)| *display) +} + +/// The config key names behind the display forms in `label`, or `None` when it +/// has none. +/// +/// Search boxes see the label a surface renders, not the binding behind it, so +/// a query still has to reach the name the config file spells: typing +/// `arrowleft` (or `left`) must find the action shown as `←`. Rewriting the +/// rendered label is what lets pre-formatted label text — joined alternatives, +/// compacted ranges, `then` sequences — stay searchable without every surface +/// carrying a second string. +pub fn canonical_key_names(label: &str) -> Option { + let mut restored: Option = None; + for (name, display) in KEY_DISPLAY_NAMES { + let current = restored.as_deref().unwrap_or(label); + if let Some(next) = replace_key_token(current, display, name) { + restored = Some(next); + } + } + restored +} + +/// Swap every whole-word `display` in `label` for `name`, or `None` when there +/// is none. +/// +/// Whole-word because a short display form can sit inside a longer word the +/// label wrote itself: hand-written help text spells `Backspace/Delete`, and a +/// blind substring swap would turn the `Del` in it into `Deleteete`. +fn replace_key_token(label: &str, display: &str, name: &str) -> Option { + let mut restored = String::new(); + let mut rest = label; + let mut replaced = false; + while let Some(index) = rest.find(display) { + let (before, at_match) = rest.split_at(index); + let after = &at_match[display.len()..]; + let stands_alone = !ends_in_word_char(before) && !starts_with_word_char(after); + restored.push_str(before); + if stands_alone { + restored.push_str(name); + replaced = true; + } else { + restored.push_str(display); + } + rest = after; + } + if !replaced { + return None; + } + restored.push_str(rest); + Some(restored) +} + +fn ends_in_word_char(text: &str) -> bool { + text.chars().next_back().is_some_and(char::is_alphanumeric) +} + +fn starts_with_word_char(text: &str) -> bool { + text.chars().next().is_some_and(char::is_alphanumeric) +} + /// Whether a key event can ever carry this name. /// /// Single characters come through as themselves, so any one-character key is @@ -235,6 +324,19 @@ impl KeyBinding { }) } + /// Label for chips, keycaps, menus, and help: modifiers as words, the key + /// as its glyph or short name (`Ctrl+Alt+←`). + /// + /// [`fmt::Display`] stays the canonical config spelling, because that is + /// what gets written back to `config.toml`. + pub fn display_label(&self) -> String { + format_modifiers(self.ctrl, self.shift, self.alt, self.logo) + .into_iter() + .chain(std::iter::once(key_display_name(&self.key))) + .collect::>() + .join("+") + } + /// Check if this keybinding matches the current input state. pub fn matches(&self, key: &str, ctrl: bool, shift: bool, alt: bool, logo: bool) -> bool { self.key.eq_ignore_ascii_case(key) diff --git a/src/config/keybindings/shortcut.rs b/src/config/keybindings/shortcut.rs index 954cf87f3..a7ea64e08 100644 --- a/src/config/keybindings/shortcut.rs +++ b/src/config/keybindings/shortcut.rs @@ -375,12 +375,17 @@ impl Shortcut { } /// Label for chips, help, and the command palette (`Ctrl+K then Ctrl+C`). + /// + /// Keyboard chords render their key as a glyph or short name + /// ([`KeyBinding::display_label`]); device buttons keep their reserved + /// name, which is already the readable form. pub fn display_label(&self) -> String { match self { + Self::Single(ShortcutTrigger::Keyboard(binding)) => binding.display_label(), Self::Single(trigger) => trigger.to_string(), Self::Sequence(steps) => steps .iter() - .map(ToString::to_string) + .map(KeyBinding::display_label) .collect::>() .join(" then "), } @@ -696,6 +701,23 @@ mod tests { assert_eq!(three.display_label(), "Ctrl+K then Ctrl+C then Ctrl+V"); } + #[test] + fn display_labels_use_glyphs_while_storage_keeps_the_config_spelling() { + let single = Shortcut::parse("Ctrl+Alt+ArrowLeft").unwrap(); + assert_eq!(single.display_label(), "Ctrl+Alt+←"); + assert_eq!(single.to_string(), "Ctrl+Alt+ArrowLeft"); + + let sequence = Shortcut::parse("Ctrl+K > Shift+PageUp").unwrap(); + assert_eq!(sequence.display_label(), "Ctrl+K then Shift+PgUp"); + assert_eq!(sequence.to_string(), "Ctrl+K > Shift+PageUp"); + + // Device buttons already read as names, so they show as stored. + assert_eq!( + Shortcut::parse("Ctrl+MouseBack").unwrap().display_label(), + "Ctrl+MouseBack" + ); + } + #[test] fn single_chords_remain_byte_compatible() { let shortcut = Shortcut::parse("Ctrl+Shift+X").unwrap(); diff --git a/src/config/keybindings/tests.rs b/src/config/keybindings/tests.rs index fa979ced1..939f00c51 100644 --- a/src/config/keybindings/tests.rs +++ b/src/config/keybindings/tests.rs @@ -1055,3 +1055,66 @@ fn ordinary_bindings_are_not_flagged() { ); } } + +#[test] +fn key_display_name_shows_glyphs_for_named_keys_and_leaves_the_rest_alone() { + assert_eq!(key_display_name("ArrowLeft"), "←"); + assert_eq!(key_display_name("ArrowRight"), "→"); + assert_eq!(key_display_name("ArrowUp"), "↑"); + assert_eq!(key_display_name("ArrowDown"), "↓"); + assert_eq!(key_display_name("Return"), "Enter"); + assert_eq!(key_display_name("Escape"), "Esc"); + assert_eq!(key_display_name("Backspace"), "⌫"); + assert_eq!(key_display_name("Delete"), "Del"); + assert_eq!(key_display_name("PageUp"), "PgUp"); + assert_eq!(key_display_name("PageDown"), "PgDn"); + // Case-insensitive, like every other key-name comparison. + assert_eq!(key_display_name("arrowleft"), "←"); + // Names that already read well, and ordinary keys, pass through. + for key in ["Space", "Home", "End", "Menu", "F5", "K", "+"] { + assert_eq!(key_display_name(key), key); + } +} + +#[test] +fn display_label_renders_glyphs_while_display_stays_the_config_spelling() { + let arrow = KeyBinding::parse("Ctrl+Alt+ArrowLeft").unwrap(); + assert_eq!(arrow.display_label(), "Ctrl+Alt+←"); + assert_eq!(arrow.to_string(), "Ctrl+Alt+ArrowLeft"); + + let page_up = KeyBinding::parse("Shift+PageUp").unwrap(); + assert_eq!(page_up.display_label(), "Shift+PgUp"); + assert_eq!(page_up.to_string(), "Shift+PageUp"); + + // Modifiers stay words, and keys without a display form are untouched. + assert_eq!( + KeyBinding::parse("Ctrl+Shift+Alt+Super+K") + .unwrap() + .display_label(), + "Ctrl+Shift+Alt+Super+K" + ); + assert_eq!(KeyBinding::parse("F5").unwrap().display_label(), "F5"); + assert_eq!( + KeyBinding::parse("Ctrl++").unwrap().display_label(), + "Ctrl++" + ); +} + +#[test] +fn canonical_key_names_restores_the_config_spelling_behind_a_glyph_label() { + // Search boxes see the rendered label, so they need the name back. + let arrow = KeyBinding::parse("Ctrl+Alt+ArrowLeft").unwrap(); + assert_eq!( + canonical_key_names(&arrow.display_label()).as_deref(), + Some(arrow.to_string().as_str()) + ); + assert_eq!( + canonical_key_names("Shift+PgUp / Esc").as_deref(), + Some("Shift+PageUp / Escape") + ); + // A label with nothing substituted has nothing to restore. + assert_eq!(canonical_key_names("Ctrl+Shift+K"), None); + // A short display form inside a longer word is left alone, so hand-written + // help text does not come back as "Deleteete". + assert_eq!(canonical_key_names("Backspace/Delete, +Ctrl"), None); +} diff --git a/src/input/state/core/command_palette/search.rs b/src/input/state/core/command_palette/search.rs index d414424b4..f2355d789 100644 --- a/src/input/state/core/command_palette/search.rs +++ b/src/input/state/core/command_palette/search.rs @@ -1,6 +1,7 @@ use super::super::base::InputState; use super::{CommandEntry, CommandPaletteState, command_palette_entries}; use crate::config::action_meta::{ActionCategory, ActionMeta}; +use crate::config::keybindings::canonical_key_names; use crate::domain::Action; use crate::input::state::core::search::fuzzy_score; /// Group label shown above recent commands when the query is empty. @@ -133,7 +134,7 @@ impl CommandPaletteState { return Some(recent_bonus); } - let shortcuts = labels(command.action).join(" "); + let shortcuts = shortcut_search_text(&labels(command.action)); let mut score = 0; // Require all tokens to match somewhere for cleaner result sets. The @@ -330,6 +331,18 @@ fn action_meta_query_bonus(meta: &ActionMeta, query: &str) -> i32 { bonus } +/// The shortcut haystack a query is scored against: the labels exactly as the +/// palette shows them, plus the config key names behind any glyph they use. +/// Without the second half, a binding rendered `Ctrl+Alt+←` would stop +/// answering to "arrow" or "left", which is how it is spelled in `config.toml`. +fn shortcut_search_text(labels: &[String]) -> String { + let shown = labels.join(" "); + match canonical_key_names(&shown) { + Some(canonical) => format!("{shown} {canonical}"), + None => shown, + } +} + fn normalize_query(query: &str) -> String { query.trim().to_lowercase() } @@ -410,6 +423,40 @@ mod tests { assert_eq!(action_meta_token_score(radial, "zznomatch"), 0); } + #[test] + fn shortcut_search_still_reaches_the_config_name_behind_a_glyph() { + use crate::config::{KeybindingsConfig, Shortcut}; + use crate::input::state::test_support::make_test_input_state; + + let mut bindings = KeybindingsConfig::default() + .build_action_bindings() + .expect("default bindings"); + bindings.insert( + Action::ClearCanvas, + vec![Shortcut::parse("Ctrl+Alt+ArrowLeft").expect("binding")], + ); + let mut state = make_test_input_state(); + state.set_action_bindings(bindings); + + // The palette shows the glyph ... + assert_eq!( + state.action_binding_labels(Action::ClearCanvas), + vec!["Ctrl+Alt+←".to_string()] + ); + + // ... and the name the config file spells still finds the command. + for query in ["arrow", "left", "arrowleft"] { + state.command_palette.query = query.to_string(); + assert!( + state + .filtered_commands() + .iter() + .any(|entry| entry.action == Action::ClearCanvas), + "query {query:?} did not reach the ArrowLeft binding" + ); + } + } + #[test] fn category_runs_are_unique_detects_split_runs() { fn entry(category: ActionCategory) -> &'static CommandEntry { diff --git a/src/input/state/input_hud/label.rs b/src/input/state/input_hud/label.rs index c0f3372f7..ece6097b6 100644 --- a/src/input/state/input_hud/label.rs +++ b/src/input/state/input_hud/label.rs @@ -1,9 +1,9 @@ //! Display labels for input HUD chips. //! -//! Key names follow the vocabulary `docs/CONFIG.md` and the help overlay -//! already print, so a chip always reads like the binding it would match. -//! Arrows are the only deliberate divergence: the HUD is a visual surface, so -//! `ArrowUp` renders as the glyph. +//! A chip reads like the binding it would match: the key's config name run +//! through the same [`crate::config::keybindings::key_display_name`] every +//! other surface uses, so `ArrowUp` is the glyph on the HUD exactly as it is +//! in the help overlay. use crate::input::events::Key; use crate::input::modifiers::Modifiers; @@ -36,6 +36,11 @@ pub fn is_bare_modifier(key: Key) -> bool { /// Display name of a single key without modifiers, or `None` for keys the HUD /// deliberately skips (unmapped keysyms and control characters that would /// render as an empty or invisible chip). +/// +/// The match names each key the way `config.toml` spells it; the shared +/// display mapping then turns the named keys into their glyph or short name, +/// so this surface can never drift from the rest of the app. `Tab` and the +/// bare modifiers are HUD-only chips no binding can carry, and pass through. pub(crate) fn key_display_name(key: Key) -> Option { let name = match key { Key::Char(c) => { @@ -44,15 +49,15 @@ pub(crate) fn key_display_name(key: Key) -> Option { } return Some(c.to_uppercase().to_string()); } - Key::Escape => "Esc", - Key::Return => "Enter", + Key::Escape => "Escape", + Key::Return => "Return", Key::Backspace => "Backspace", Key::Tab => "Tab", Key::Space => "Space", - Key::Up => "\u{2191}", - Key::Down => "\u{2193}", - Key::Left => "\u{2190}", - Key::Right => "\u{2192}", + Key::Up => "ArrowUp", + Key::Down => "ArrowDown", + Key::Left => "ArrowLeft", + Key::Right => "ArrowRight", Key::Delete => "Delete", Key::Home => "Home", Key::End => "End", @@ -77,7 +82,7 @@ pub(crate) fn key_display_name(key: Key) -> Option { Key::F12 => "F12", Key::Unknown => return None, }; - Some(name.to_string()) + Some(crate::config::keybindings::key_display_name(name).to_string()) } /// Chord label for a key press: the held modifiers in canonical order plus the @@ -141,7 +146,7 @@ mod tests { } #[test] - fn special_keys_use_the_help_overlay_names_and_arrow_glyphs() { + fn special_keys_use_the_shared_display_names_and_arrow_glyphs() { assert_eq!( input_hud_key_label(Key::Space, mods(false, false, false, false)).as_deref(), Some("Space") @@ -158,6 +163,23 @@ mod tests { input_hud_key_label(Key::Left, mods(false, false, false, false)).as_deref(), Some("\u{2190}") ); + // The chip agrees with what a binding on the same key would show. + for (key, name) in [ + (Key::PageUp, "PageUp"), + (Key::Delete, "Delete"), + (Key::Backspace, "Backspace"), + (Key::Return, "Return"), + ] { + assert_eq!( + input_hud_key_label(key, mods(false, false, false, false)).as_deref(), + Some(crate::config::keybindings::key_display_name(name)) + ); + } + // Tab is a HUD-only chip: no binding can carry it, so it stays a word. + assert_eq!( + input_hud_key_label(Key::Tab, mods(false, false, false, false)).as_deref(), + Some("Tab") + ); } #[test] diff --git a/src/ui/help_overlay/search.rs b/src/ui/help_overlay/search.rs index e6aa64696..6a6206579 100644 --- a/src/ui/help_overlay/search.rs +++ b/src/ui/help_overlay/search.rs @@ -2,6 +2,7 @@ use super::types::Row; // The palette's fuzzy scorer/tokenizer and its shared static search-model // scorer, reused directly so help search and the command palette rank // identically (no per-surface reimplementation). +use crate::config::keybindings::canonical_key_names; use crate::input::state::{action_meta_token_score, fuzzy_score, query_tokens}; use crate::ui_text::UiTextStyle; @@ -20,14 +21,22 @@ pub(crate) use crate::ui::primitives::ellipsize_to_fit_with_engine; /// description + category + aliases). Reusing that model is what lets an alias /// query like "pie menu" resolve the radial-menu row, whose shortcut and label /// never spell "pie". Mirrors the palette's all-tokens rule. +/// +/// The shortcut string is the rendered one, so a key shown as a glyph is also +/// matched against the config name behind it ("arrowleft" finds a row drawn +/// `Ctrl+Alt+←`). pub(crate) fn row_matches(row: &Row, needle_lower: &str) -> bool { let tokens = query_tokens(needle_lower); if tokens.is_empty() { return false; } let meta = row.action_id.and_then(crate::config::action_meta); + let key_names = canonical_key_names(&row.key); tokens.iter().all(|token| { fuzzy_score(token, &row.key) > 0 + || key_names + .as_deref() + .is_some_and(|names| fuzzy_score(token, names) > 0) || fuzzy_score(token, row.action) > 0 || meta.is_some_and(|meta| action_meta_token_score(meta, token) > 0) }) @@ -91,6 +100,16 @@ mod tests { assert!(row_matches(&r, "pie menu")); } + #[test] + fn row_matches_the_config_key_name_behind_a_glyph() { + // The row draws the glyph, but users search for what their config + // file spells. Neither query is reachable through the rendered + // shortcut ("Ctrl+Alt+←") or the description. + let r = row("Ctrl+Alt+←", "Previous Page"); + assert!(row_matches(&r, "arrowleft")); + assert!(row_matches(&r, "left")); + } + #[test] fn row_without_action_id_cannot_borrow_aliases() { // A gesture-only row carries no action id, so it matches on its own diff --git a/src/ui/input_hud/tests.rs b/src/ui/input_hud/tests.rs index b8b6a4e8b..00ddd32ab 100644 --- a/src/ui/input_hud/tests.rs +++ b/src/ui/input_hud/tests.rs @@ -123,7 +123,8 @@ fn repeat_counter_is_appended_to_the_chip_text() { let layout = compute_input_hud_layout(&UiTextEngine::default(), &state, 1920, 1080).expect("layout"); assert_eq!(layout.chips.len(), 1); - assert_eq!(layout.chips[0].text, "Backspace \u{00d7}7"); + // Backspace shows as the shared display glyph; the counter is appended to it. + assert_eq!(layout.chips[0].text, "\u{232b} \u{00d7}7"); } #[test] diff --git a/src/ui/primitives.rs b/src/ui/primitives.rs index 2d23d6611..a39f6d516 100644 --- a/src/ui/primitives.rs +++ b/src/ui/primitives.rs @@ -246,6 +246,19 @@ pub(crate) fn draw_pill( const KEYCAP_PAD_X_FACTOR: f64 = 0.5; const KEYCAP_PAD_Y_FACTOR: f64 = 0.3; +/// Chip footprint for a label's ink extents: padding on each side, then +/// widened to at least the chip's own height. +/// +/// The minimum keeps a one-glyph cap square rather than a sliver, so `←`, `1`, +/// and `W` sit in caps of the same shape instead of caps as narrow as their +/// ink. Every keycap sizer goes through here, so measuring and drawing cannot +/// disagree about the footprint. +fn keycap_box_for_ink(ink_width: f64, ink_height: f64, font_size: f64) -> (f64, f64) { + let height = ink_height + font_size * KEYCAP_PAD_Y_FACTOR * 2.0; + let width = (ink_width + font_size * KEYCAP_PAD_X_FACTOR * 2.0).max(height); + (width, height) +} + /// Measured (width, height) the [`draw_keycap_with_engine`] chip occupies for `label` at /// `font_size`, for callers that need to center the chip before drawing it. pub(crate) fn keycap_size_with_engine( @@ -266,10 +279,7 @@ pub(crate) fn keycap_size_with_engine( None, ); let extents = layout.ink_extents(); - ( - extents.width() + font_size * KEYCAP_PAD_X_FACTOR * 2.0, - extents.height() + font_size * KEYCAP_PAD_Y_FACTOR * 2.0, - ) + keycap_box_for_ink(extents.width(), extents.height(), font_size) } /// Draw a flat keycap chip (rounded rect + centered label) and return its @@ -298,20 +308,19 @@ pub(crate) fn draw_keycap_with_engine( None, ); let extents = layout.ink_extents(); - let pad_x = font_size * KEYCAP_PAD_X_FACTOR; - let pad_y = font_size * KEYCAP_PAD_Y_FACTOR; - let width = extents.width() + pad_x * 2.0; - let height = extents.height() + pad_y * 2.0; + let (width, height) = keycap_box_for_ink(extents.width(), extents.height(), font_size); theme::set_color(ctx, fill); draw_rounded_rect(ctx, x, y, width, height, theme::overlay::RADIUS_SM); let _ = ctx.fill(); + // Centred rather than padded from the left edge, because the box can be + // wider than ink plus padding once the square minimum applies. theme::set_color(ctx, text_color); layout.show_at_baseline( ctx, - x + pad_x - extents.x_bearing(), - y + pad_y - extents.y_bearing(), + x + (width - extents.width()) / 2.0 - extents.x_bearing(), + y + (height - extents.height()) / 2.0 - extents.y_bearing(), ); (width, height) } @@ -336,9 +345,10 @@ pub(crate) fn keycap_box_size( font_size: f64, ) -> Option<(f64, f64)> { let extents = engine.measure(keycap_text_style(font_size), label, None)?; - Some(( - extents.width() + font_size * KEYCAP_PAD_X_FACTOR * 2.0, - extents.height() + font_size * KEYCAP_PAD_Y_FACTOR * 2.0, + Some(keycap_box_for_ink( + extents.width(), + extents.height(), + font_size, )) } @@ -570,6 +580,52 @@ mod tests { (pixel_at(&mut surface, 32, 32), pixel_at(&mut surface, 1, 1)) } + /// A cap holding one narrow glyph would otherwise be a sliver next to the + /// caps around it, so the box is widened to at least its own height. + #[test] + fn a_narrow_keycap_is_widened_to_a_square_and_a_wide_one_is_not() { + let engine = UiTextEngine::default(); + let font_size = 14.0; + + let (width, height) = keycap_box_size(&engine, "←", font_size).expect("arrow measurement"); + assert!( + width + 1e-6 >= height, + "narrow glyph cap stayed narrower than it is tall: {width} x {height}" + ); + + let (wide_width, wide_height) = + keycap_box_size(&engine, "Ctrl", font_size).expect("word measurement"); + assert!( + wide_width > wide_height, + "a wide label must still size from its ink: {wide_width} x {wide_height}" + ); + } + + /// The pre-measured footprint is what callers center and lay out with, so + /// it has to be the footprint the chip actually draws. + #[test] + fn keycap_measurement_matches_the_drawn_chip() { + let engine = UiTextEngine::default(); + let surface = ImageSurface::create(Format::Rgb24, 64, 64).expect("surface"); + let ctx = Context::new(&surface).expect("context"); + let fill = (0.0, 0.0, 0.0, 1.0); + let text = (1.0, 1.0, 1.0, 1.0); + + for label in ["←", "W", "Ctrl"] { + let measured = keycap_size_with_engine(&engine, &ctx, label, 14.0); + let drawn = draw_keycap_with_engine(&engine, &ctx, 0.0, 0.0, label, 14.0, fill, text); + assert_eq!( + measured, drawn, + "measuring and drawing disagreed on {label:?}" + ); + assert_eq!( + keycap_box_size(&engine, label, 14.0), + Some(measured), + "headless measurement disagreed on {label:?}" + ); + } + } + #[test] fn checkerboard_behind_fills_only_inside_the_path() { let (inside, outside) = inside_and_outside(0.5); From 2e46936a3a753f16e10da65e7f1e5b8bc50047c2 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Sun, 13 Sep 2026 17:47:53 +0200 Subject: [PATCH 6/6] feat(boards): edit paper drafts with the color picker Open the color popup on the paper draft while preserving Apply and Cancel. Keep colors opaque, hide unsupported controls, and prevent wheel input from reaching the underlying sheet or page grid. Always restore the accepted target color when typed shorthand returns to the opening value. Remove the board-paper validation artifacts and update the architecture reference. Validation: the full tools/lint-and-test.sh gate passed, including packaging and repository tools, strict Clippy, all-feature and no-default-feature workspace tests, and isolated rendering tests. --- docs/board-paper-consumer-performance.csv | 97 -------- docs/board-paper-source-performance.csv | 145 ------------ docs/board-paper-validation.md | 159 ------------- docs/codebase-overview.md | 5 +- src/backend/wayland/handlers/pointer/axis.rs | 78 ++++++- .../state/core/board_picker/appearance.rs | 26 +++ .../core/board_picker/appearance/tests.rs | 208 ++++++++++++++++++ .../state/core/board_picker/state/edit.rs | 5 + .../core/board_picker/state/lifecycle.rs | 3 + .../state/core/color_picker_popup/mod.rs | 103 +++++++-- .../state/core/color_picker_popup/panel.rs | 30 ++- .../state/core/color_picker_popup/state.rs | 163 +++++++++++--- src/input/state/core/mod.rs | 6 +- src/input/state/core/modal.rs | 22 ++ src/input/state/mod.rs | 37 ++-- src/input/state/mouse/release/panels.rs | 2 +- src/input/state/tests/tool_controls.rs | 32 +++ src/ui/color_picker_popup.rs | 53 +++-- 18 files changed, 665 insertions(+), 509 deletions(-) delete mode 100644 docs/board-paper-consumer-performance.csv delete mode 100644 docs/board-paper-source-performance.csv delete mode 100644 docs/board-paper-validation.md diff --git a/docs/board-paper-consumer-performance.csv b/docs/board-paper-consumer-performance.csv deleted file mode 100644 index 2e1bdd695..000000000 --- a/docs/board-paper-consumer-performance.csv +++ /dev/null @@ -1,97 +0,0 @@ -kind,spacing,width,height,origin,erasers,cold_pan_median_us,cold_pan_p95_us,warm_pan_median_us,warm_pan_p95_us,png_median_us,png_p95_us,png_bytes,surface_bytes_proxy -None,8,1920,1080,0,0,2964,3295,169,865,31442,31860,33424,32075776 -None,8,1920,1080,0,20,3408,3555,156,280,48536,48890,72712,32075776 -None,8,1920,1080,0,200,6632,6768,241,352,56368,57102,189182,32075776 -None,40,1920,1080,0,0,2609,2789,249,356,31385,31622,33424,32075776 -None,40,1920,1080,0,20,3053,3156,279,399,47974,48035,72712,32075776 -None,40,1920,1080,0,200,6481,6835,177,304,56138,56266,189182,32075776 -Cartesian,8,1920,1080,0,0,3526,3597,170,198,37480,37686,80088,32075776 -Cartesian,8,1920,1080,0,20,4363,4460,175,444,57646,58335,134441,32075776 -Cartesian,8,1920,1080,0,200,11369,11996,336,405,80644,80964,376164,32075776 -Cartesian,40,1920,1080,0,0,3447,3643,170,332,34651,34730,53224,32075776 -Cartesian,40,1920,1080,0,20,4227,4348,167,303,53276,56684,102884,32075776 -Cartesian,40,1920,1080,0,200,10951,11751,382,494,69904,70669,265083,32075776 -Isometric,8,1920,1080,0,0,7144,16695,703,841,134973,172560,1017818,32075776 -Isometric,8,1920,1080,0,20,8211,15019,340,759,200823,222206,1166903,32075776 -Isometric,8,1920,1080,0,200,24842,42471,673,756,248711,305444,1268264,32075776 -Isometric,40,1920,1080,0,0,7181,9508,571,597,74132,88345,672389,32075776 -Isometric,40,1920,1080,0,20,8527,8771,563,569,104521,105079,804536,32075776 -Isometric,40,1920,1080,0,200,19392,19791,351,467,123284,123768,932535,32075776 -IsometricDots,8,1920,1080,0,0,6195,6222,202,303,57517,57873,419974,32075776 -IsometricDots,8,1920,1080,0,20,8088,9074,371,832,88225,93938,557246,32075776 -IsometricDots,8,1920,1080,0,200,22666,25426,383,580,114643,119364,713126,32075776 -IsometricDots,40,1920,1080,0,0,6181,6336,353,436,36239,38479,85049,32075776 -IsometricDots,40,1920,1080,0,20,7502,7723,371,412,53787,64370,128204,32075776 -IsometricDots,40,1920,1080,0,200,31731,39567,787,1726,101258,106475,251278,32075776 -None,8,1920,1080,-1000021,0,2576,2664,248,263,31356,31542,33424,32075776 -None,8,1920,1080,-1000021,20,3120,3361,297,464,48592,49261,72712,32075776 -None,8,1920,1080,-1000021,200,6553,6720,314,413,57100,57878,189182,32075776 -None,40,1920,1080,-1000021,0,2648,3240,278,339,32083,32409,33424,32075776 -None,40,1920,1080,-1000021,20,3044,3054,244,268,48088,48185,72712,32075776 -None,40,1920,1080,-1000021,200,6495,6554,200,239,56255,56360,189182,32075776 -Cartesian,8,1920,1080,-1000021,0,3540,3729,185,191,37849,37998,83130,32075776 -Cartesian,8,1920,1080,-1000021,20,4481,4567,184,272,58529,58646,139657,32075776 -Cartesian,8,1920,1080,-1000021,200,11479,11559,201,211,81011,81423,382147,32075776 -Cartesian,40,1920,1080,-1000021,0,3617,3693,170,244,35263,35325,55388,32075776 -Cartesian,40,1920,1080,-1000021,20,4366,4432,188,313,54012,54224,105169,32075776 -Cartesian,40,1920,1080,-1000021,200,10689,11496,244,761,70457,73741,267355,32075776 -Isometric,8,1920,1080,-1000021,0,6328,6789,266,532,124400,125941,1006387,32075776 -Isometric,8,1920,1080,-1000021,20,7819,7833,186,239,182261,182654,1156608,32075776 -Isometric,8,1920,1080,-1000021,200,23758,24870,540,616,207378,209135,1252617,32075776 -Isometric,40,1920,1080,-1000021,0,6171,6292,207,473,72258,72362,674375,32075776 -Isometric,40,1920,1080,-1000021,20,7703,8597,401,540,104347,105351,807461,32075776 -Isometric,40,1920,1080,-1000021,200,20760,21508,507,539,127350,128127,935494,32075776 -IsometricDots,8,1920,1080,-1000021,0,6430,6530,455,466,59398,59747,424019,32075776 -IsometricDots,8,1920,1080,-1000021,20,8318,8847,466,527,90049,93251,558198,32075776 -IsometricDots,8,1920,1080,-1000021,200,24486,25307,571,584,119755,121443,721126,32075776 -IsometricDots,40,1920,1080,-1000021,0,6838,7115,568,575,36182,38004,84807,32075776 -IsometricDots,40,1920,1080,-1000021,20,8231,8424,520,566,55674,55729,128026,32075776 -IsometricDots,40,1920,1080,-1000021,200,21182,21842,526,564,72957,74122,250840,32075776 -None,8,3840,2160,0,0,6689,6830,2217,3196,129311,131052,82922,112869376 -None,8,3840,2160,0,20,7124,7454,2170,2293,185567,186637,140454,112869376 -None,8,3840,2160,0,200,11527,11617,2099,2119,196635,196751,299751,112869376 -None,40,3840,2160,0,0,6489,6579,2185,2242,127198,127256,82922,112869376 -None,40,3840,2160,0,20,6982,7576,2134,2216,183092,183391,140454,112869376 -None,40,3840,2160,0,200,10832,11267,2096,2190,195083,195972,299751,112869376 -Cartesian,8,3840,2160,0,0,8958,9098,1941,2026,145771,146452,214887,112869376 -Cartesian,8,3840,2160,0,20,9934,10202,1910,2015,207790,208632,310583,112869376 -Cartesian,8,3840,2160,0,200,17583,19033,1903,2021,238411,242324,617816,112869376 -Cartesian,40,3840,2160,0,0,8905,10164,2033,2618,138411,141335,145447,112869376 -Cartesian,40,3840,2160,0,20,9819,10340,1989,2035,201483,201693,290886,112869376 -Cartesian,40,3840,2160,0,200,16708,18042,2025,2068,223289,223984,503374,112869376 -Isometric,8,3840,2160,0,0,16684,16737,1923,1943,454268,455633,3170531,112869376 -Isometric,8,3840,2160,0,20,18898,19516,2167,2446,614747,624484,3525018,112869376 -Isometric,8,3840,2160,0,200,36990,39033,2046,3342,652967,660609,3761578,112869376 -Isometric,40,3840,2160,0,0,16803,17441,2040,2181,276753,277087,2357820,112869376 -Isometric,40,3840,2160,0,20,18396,18833,2064,2327,365316,367817,2674630,112869376 -Isometric,40,3840,2160,0,200,31600,31744,2065,2223,395045,397930,2872891,112869376 -IsometricDots,8,3840,2160,0,0,17341,17489,2029,2230,236783,237067,1536684,112869376 -IsometricDots,8,3840,2160,0,20,19257,20206,2134,2476,319396,320760,1784952,112869376 -IsometricDots,8,3840,2160,0,200,36696,36875,2043,2469,357748,358996,2034353,112869376 -IsometricDots,40,3840,2160,0,0,16937,17541,2068,2256,139286,139630,248864,112869376 -IsometricDots,40,3840,2160,0,20,18598,20155,2083,2591,199615,200309,335132,112869376 -IsometricDots,40,3840,2160,0,200,31477,33203,2066,2700,222001,224319,505456,112869376 -None,8,3840,2160,-1000021,0,6657,8292,2269,2394,128084,133287,82922,112869376 -None,8,3840,2160,-1000021,20,7158,7707,2195,2227,184799,195329,140454,112869376 -None,8,3840,2160,-1000021,200,11066,11552,2064,2588,195439,196309,299751,112869376 -None,40,3840,2160,-1000021,0,6481,6748,2209,2279,126051,126324,82922,112869376 -None,40,3840,2160,-1000021,20,7184,7452,2133,2747,182462,182986,140454,112869376 -None,40,3840,2160,-1000021,200,11670,12305,2236,2348,196137,198833,299751,112869376 -Cartesian,8,3840,2160,-1000021,0,9654,9989,2020,2297,147992,149402,222712,112869376 -Cartesian,8,3840,2160,-1000021,20,10795,10914,2031,2103,211975,213261,317630,112869376 -Cartesian,8,3840,2160,-1000021,200,18160,18188,1927,1958,239227,239664,623763,112869376 -Cartesian,40,3840,2160,-1000021,0,9293,9488,1973,2035,138388,138615,150181,112869376 -Cartesian,40,3840,2160,-1000021,20,10171,10319,1998,2011,201069,201697,298887,112869376 -Cartesian,40,3840,2160,-1000021,200,16940,17112,1936,1940,223481,223934,510941,112869376 -Isometric,8,3840,2160,-1000021,0,16649,16777,1942,1966,453213,454074,3173502,112869376 -Isometric,8,3840,2160,-1000021,20,18457,18870,1992,2165,604209,606566,3526205,112869376 -Isometric,8,3840,2160,-1000021,200,35370,35632,1921,1972,643836,645247,3759572,112869376 -Isometric,40,3840,2160,-1000021,0,16605,16787,1980,2068,276595,277171,2351763,112869376 -Isometric,40,3840,2160,-1000021,20,18173,18373,2011,2054,363726,372134,2675243,112869376 -Isometric,40,3840,2160,-1000021,200,30793,31725,1944,2037,391065,393237,2872401,112869376 -IsometricDots,8,3840,2160,-1000021,0,16880,17105,1940,2196,235193,236404,1532620,112869376 -IsometricDots,8,3840,2160,-1000021,20,18542,18702,2015,2052,316100,317114,1784251,112869376 -IsometricDots,8,3840,2160,-1000021,200,36018,36591,2005,2146,356921,362920,2034242,112869376 -IsometricDots,40,3840,2160,-1000021,0,16983,17460,2090,2121,139698,140506,247770,112869376 -IsometricDots,40,3840,2160,-1000021,20,18091,18267,1916,2201,198149,199032,335533,112869376 -IsometricDots,40,3840,2160,-1000021,200,31535,32155,1979,2067,221090,223278,505782,112869376 diff --git a/docs/board-paper-source-performance.csv b/docs/board-paper-source-performance.csv deleted file mode 100644 index 30e9a5205..000000000 --- a/docs/board-paper-source-performance.csv +++ /dev/null @@ -1,145 +0,0 @@ -source,kind,spacing,width,height,erasers,pdf,median_us,p95_us,target_or_pdf_bytes -Solid,Cartesian,8,1920,1080,0,false,1651,2597,8294400 -Tile,Cartesian,8,1920,1080,0,false,2109,2119,8294400 -Solid,Cartesian,8,1920,1080,0,true,112,130,1540 -Tile,Cartesian,8,1920,1080,0,true,136,143,2447 -Solid,Cartesian,8,1920,1080,20,false,1997,2036,8294400 -Tile,Cartesian,8,1920,1080,20,false,2875,2880,8294400 -Solid,Cartesian,8,1920,1080,20,true,54509,54588,157333 -Tile,Cartesian,8,1920,1080,20,true,72930,73621,297363 -Solid,Cartesian,8,1920,1080,200,false,5479,5510,8294400 -Tile,Cartesian,8,1920,1080,200,false,9851,9870,8294400 -Solid,Cartesian,8,1920,1080,200,true,570588,571785,2469987 -Tile,Cartesian,8,1920,1080,200,true,748253,753232,3642089 -Solid,Cartesian,40,1920,1080,0,false,1634,1636,8294400 -Tile,Cartesian,40,1920,1080,0,false,2029,2040,8294400 -Solid,Cartesian,40,1920,1080,0,true,107,127,1540 -Tile,Cartesian,40,1920,1080,0,true,135,150,2451 -Solid,Cartesian,40,1920,1080,20,false,2020,2043,8294400 -Tile,Cartesian,40,1920,1080,20,false,2704,2737,8294400 -Solid,Cartesian,40,1920,1080,20,true,54296,54365,157333 -Tile,Cartesian,40,1920,1080,20,true,65218,65378,227906 -Solid,Cartesian,40,1920,1080,200,false,5427,5434,8294400 -Tile,Cartesian,40,1920,1080,200,false,8778,8786,8294400 -Solid,Cartesian,40,1920,1080,200,true,567020,570245,2469987 -Tile,Cartesian,40,1920,1080,200,true,710492,716396,3066619 -Solid,Isometric,8,1920,1080,0,false,1632,1654,8294400 -Tile,Isometric,8,1920,1080,0,false,3293,3307,8294400 -Solid,Isometric,8,1920,1080,0,true,106,128,1540 -Tile,Isometric,8,1920,1080,0,true,136,153,2526 -Solid,Isometric,8,1920,1080,20,false,2021,2064,8294400 -Tile,Isometric,8,1920,1080,20,false,4830,4845,8294400 -Solid,Isometric,8,1920,1080,20,true,54191,54303,157333 -Tile,Isometric,8,1920,1080,20,true,208126,227192,1407502 -Solid,Isometric,8,1920,1080,200,false,5414,5486,8294400 -Tile,Isometric,8,1920,1080,200,false,18266,20113,8294400 -Solid,Isometric,8,1920,1080,200,true,567555,568208,2469987 -Tile,Isometric,8,1920,1080,200,true,1735954,1748975,12731843 -Solid,Isometric,40,1920,1080,0,false,1671,1695,8294400 -Tile,Isometric,40,1920,1080,0,false,3341,3358,8294400 -Solid,Isometric,40,1920,1080,0,true,115,131,1539 -Tile,Isometric,40,1920,1080,0,true,137,154,2531 -Solid,Isometric,40,1920,1080,20,false,2077,2087,8294400 -Tile,Isometric,40,1920,1080,20,false,4649,4730,8294400 -Solid,Isometric,40,1920,1080,20,true,54437,54815,157333 -Tile,Isometric,40,1920,1080,20,true,90099,90195,564291 -Solid,Isometric,40,1920,1080,200,false,5347,5388,8294400 -Tile,Isometric,40,1920,1080,200,false,15781,15797,8294400 -Solid,Isometric,40,1920,1080,200,true,567718,572051,2469987 -Tile,Isometric,40,1920,1080,200,true,1099744,1104767,6007667 -Solid,IsometricDots,8,1920,1080,0,false,1639,1659,8294400 -Tile,IsometricDots,8,1920,1080,0,false,3306,3320,8294400 -Solid,IsometricDots,8,1920,1080,0,true,111,130,1540 -Tile,IsometricDots,8,1920,1080,0,true,172,205,2678 -Solid,IsometricDots,8,1920,1080,20,false,2040,2048,8294400 -Tile,IsometricDots,8,1920,1080,20,false,4886,4895,8294400 -Solid,IsometricDots,8,1920,1080,20,true,54924,55018,157333 -Tile,IsometricDots,8,1920,1080,20,true,89683,90246,682528 -Solid,IsometricDots,8,1920,1080,200,false,5426,5445,8294400 -Tile,IsometricDots,8,1920,1080,200,false,18146,18235,8294400 -Solid,IsometricDots,8,1920,1080,200,true,564492,568582,2469987 -Tile,IsometricDots,8,1920,1080,200,true,906547,910484,6730998 -Solid,IsometricDots,40,1920,1080,0,false,1668,2550,8294400 -Tile,IsometricDots,40,1920,1080,0,false,3254,3277,8294400 -Solid,IsometricDots,40,1920,1080,0,true,111,128,1540 -Tile,IsometricDots,40,1920,1080,0,true,176,196,2679 -Solid,IsometricDots,40,1920,1080,20,false,2014,2031,8294400 -Tile,IsometricDots,40,1920,1080,20,false,4645,6641,8294400 -Solid,IsometricDots,40,1920,1080,20,true,54239,54546,157333 -Tile,IsometricDots,40,1920,1080,20,true,65836,65967,192174 -Solid,IsometricDots,40,1920,1080,200,false,5446,5575,8294400 -Tile,IsometricDots,40,1920,1080,200,false,16041,16175,8294400 -Solid,IsometricDots,40,1920,1080,200,true,565981,566682,2469987 -Tile,IsometricDots,40,1920,1080,200,true,730389,731736,2762761 -Solid,Cartesian,8,3840,2160,0,false,4852,5650,33177600 -Tile,Cartesian,8,3840,2160,0,false,6498,6690,33177600 -Solid,Cartesian,8,3840,2160,0,true,109,132,1543 -Tile,Cartesian,8,3840,2160,0,true,132,144,2449 -Solid,Cartesian,8,3840,2160,20,false,5742,5935,33177600 -Tile,Cartesian,8,3840,2160,20,false,7375,7578,33177600 -Solid,Cartesian,8,3840,2160,20,true,55091,55388,149953 -Tile,Cartesian,8,3840,2160,20,true,72774,72779,284050 -Solid,Cartesian,8,3840,2160,200,false,10297,10773,33177600 -Tile,Cartesian,8,3840,2160,200,false,16245,16666,33177600 -Solid,Cartesian,8,3840,2160,200,true,589838,593012,2941444 -Tile,Cartesian,8,3840,2160,200,true,799591,803500,3990879 -Solid,Cartesian,40,3840,2160,0,false,5187,5396,33177600 -Tile,Cartesian,40,3840,2160,0,false,6256,6456,33177600 -Solid,Cartesian,40,3840,2160,0,true,113,131,1542 -Tile,Cartesian,40,3840,2160,0,true,131,144,2453 -Solid,Cartesian,40,3840,2160,20,false,5339,5443,33177600 -Tile,Cartesian,40,3840,2160,20,false,6911,7028,33177600 -Solid,Cartesian,40,3840,2160,20,true,55440,55610,149953 -Tile,Cartesian,40,3840,2160,20,true,66254,66464,215815 -Solid,Cartesian,40,3840,2160,200,false,11128,11629,33177600 -Tile,Cartesian,40,3840,2160,200,false,14686,14778,33177600 -Solid,Cartesian,40,3840,2160,200,true,591393,598996,2941444 -Tile,Cartesian,40,3840,2160,200,true,767512,774257,3528120 -Solid,Isometric,8,3840,2160,0,false,5128,5391,33177600 -Tile,Isometric,8,3840,2160,0,false,11214,11397,33177600 -Solid,Isometric,8,3840,2160,0,true,108,129,1542 -Tile,Isometric,8,3840,2160,0,true,139,152,2530 -Solid,Isometric,8,3840,2160,20,false,5787,6034,33177600 -Tile,Isometric,8,3840,2160,20,false,12913,12996,33177600 -Solid,Isometric,8,3840,2160,20,true,55172,57034,149953 -Tile,Isometric,8,3840,2160,20,true,202756,204920,1386368 -Solid,Isometric,8,3840,2160,200,false,11581,11646,33177600 -Tile,Isometric,8,3840,2160,200,false,30583,30622,33177600 -Solid,Isometric,8,3840,2160,200,true,592189,604378,2941444 -Tile,Isometric,8,3840,2160,200,true,1824540,1842717,14531343 -Solid,Isometric,40,3840,2160,0,false,5841,5947,33177600 -Tile,Isometric,40,3840,2160,0,false,12047,12118,33177600 -Solid,Isometric,40,3840,2160,0,true,120,134,1542 -Tile,Isometric,40,3840,2160,0,true,145,159,2533 -Solid,Isometric,40,3840,2160,20,false,6492,6552,33177600 -Tile,Isometric,40,3840,2160,20,false,13579,13645,33177600 -Solid,Isometric,40,3840,2160,20,true,57160,57479,149953 -Tile,Isometric,40,3840,2160,20,true,94379,96976,551501 -Solid,Isometric,40,3840,2160,200,false,13841,17122,33177600 -Tile,Isometric,40,3840,2160,200,false,28812,29316,33177600 -Solid,Isometric,40,3840,2160,200,true,607961,615508,2941444 -Tile,Isometric,40,3840,2160,200,true,1156582,1163901,6642415 -Solid,IsometricDots,8,3840,2160,0,false,4885,4985,33177600 -Tile,IsometricDots,8,3840,2160,0,false,11344,11544,33177600 -Solid,IsometricDots,8,3840,2160,0,true,109,133,1542 -Tile,IsometricDots,8,3840,2160,0,true,172,188,2681 -Solid,IsometricDots,8,3840,2160,20,false,5319,5900,33177600 -Tile,IsometricDots,8,3840,2160,20,false,12856,13400,33177600 -Solid,IsometricDots,8,3840,2160,20,true,55338,55610,149954 -Tile,IsometricDots,8,3840,2160,20,true,90630,90963,670417 -Solid,IsometricDots,8,3840,2160,200,false,10771,11161,33177600 -Tile,IsometricDots,8,3840,2160,200,false,30072,30136,33177600 -Solid,IsometricDots,8,3840,2160,200,true,593308,594650,2941444 -Tile,IsometricDots,8,3840,2160,200,true,978665,1000595,7478069 -Solid,IsometricDots,40,3840,2160,0,false,5303,5343,33177600 -Tile,IsometricDots,40,3840,2160,0,false,11401,11435,33177600 -Solid,IsometricDots,40,3840,2160,0,true,112,132,1542 -Tile,IsometricDots,40,3840,2160,0,true,181,191,2682 -Solid,IsometricDots,40,3840,2160,20,false,5813,5929,33177600 -Tile,IsometricDots,40,3840,2160,20,false,12884,12937,33177600 -Solid,IsometricDots,40,3840,2160,20,true,56009,67806,149953 -Tile,IsometricDots,40,3840,2160,20,true,67464,74172,186626 -Solid,IsometricDots,40,3840,2160,200,false,11685,11798,33177600 -Tile,IsometricDots,40,3840,2160,200,false,27179,27269,33177600 -Solid,IsometricDots,40,3840,2160,200,true,610095,619162,2941444 -Tile,IsometricDots,40,3840,2160,200,true,919269,1180332,3241025 diff --git a/docs/board-paper-validation.md b/docs/board-paper-validation.md deleted file mode 100644 index ee932bee7..000000000 --- a/docs/board-paper-validation.md +++ /dev/null @@ -1,159 +0,0 @@ -# Board paper implementation and validation - -Issue: [#389](https://github.com/devmobasa/wayscriber/issues/389). - -Cartesian, isometric lines, and isometric dots share a procedural paper source. -All shipped board templates still default to None. Background opacity, -transparent-board behavior, and screenshot source provenance keep their existing -owners. This document records implementation decisions and headless evidence; -it does not claim a live Wayland or GTK interaction test. - -## Final local checks - -`./tools/lint-and-test.sh` passed on 2026-09-12, including packaging and source -audits, 75 C# tooling tests, formatting, builds, and strict workspace Clippy in -both feature configurations. The all-feature suite passed 4,584 root and 491 -configurator tests; no-default-features passed 4,424 root and 490 configurator -tests. CLI, daemon fixture, UI integration, documentation, and the separately -invoked retained context-menu/board-picker render tests also passed. - -The headless appearance sheet was inspected at 420×300 and its bounds tested -at 420×300 and 900×700. Poppler PDF raster comparisons ran on this machine. -No live Wayland overlay or GTK configurator was launched, and no GitHub Actions -run was triggered by the feature-branch pushes. The performance measurements -below are separate opt-in runs, not timings collected during the CI suite. - -## State and recovery - -Appearance lives with the board, outside drawing history. The picker owns an -identity-bound draft and applies a field-level patch once. The small appearance -sheet has the same control geometry for painting and mouse input. Invalid raw -spacing remains editable, and Cancel or an invalidated identity cannot commit. -Draft differences are computed from final values, so changing a field and then -reverting it preserves later external changes. Preview updates and dismissal -damage the sheet's screen rectangle without publishing a board or session edit. -The visible outer palette edits that same draft; its clicks preserve pending -pattern and spacing changes, and both palette and sheet are marked for repaint. - -Each `BoardState` owns its immutable configured/template appearance seed. Keeping -this seed on the identity-bearing state, instead of a second manager map, lets -ordinary duplicate/delete/restore operations carry it with the board. Session -replacement resets retained states from those seeds before applying saved paper. -Saved tools restore last, including on a restore to the already-active board. - -Format 7 stores appearance with drawing data. Explicit paper edits also persist -on otherwise empty boards. Merely capturing an empty configured template cannot -claim recovery priority. History trimming drops non-explicit appearance when no -page data remains; explicit empty paper survives. Invalid appearance metadata -falls back to the seed without discarding valid drawings. Existing newer-format -preservation and clear-marker handling remain in use. - -## Renderer selection - -The full-viewport recording candidate was rejected before broad integration. -At 1920×1080, spacing 8 and 200 erasers, preliminary isometric-line medians were -about 405 ms for the viewport recording versus 26 ms for a compact vector tile. -Isometric dots similarly measured about 258 ms versus 34 ms. These preliminary -numbers came from the candidate comparison and are not the final raster timings. -The viewport recording walks visible grid geometry on each eraser replay, failing -the plan's adoption threshold (more than both 20% and 1 ms slower). - -The selected source repeats a compact Cartesian or isometric tile. Raster -consumers generate a small raster tile once per paper paint/replay pass; PDF -uses an integer-unit recording tile and remains vector without erasers. Source -phase is reduced by whole tile periods at far-away coordinates. Erasers reuse -the exact source and phase used to paint the background. Blur still uses the -existing captured-image rules; procedural paper does not pretend to be a capture. - -The raster tile caps density at 4×. At spacing 200 its maximum backing allocation -is 4,435,200 bytes (1386×800×4), independent of viewport and eraser count. The pan -cache retains its existing 256 MiB cap. Allocation counts were not instrumented; -reported surface byte counts are explicit proxies, not process peak RSS. - -## Source benchmark - -[Full measurements](board-paper-source-performance.csv), taken headlessly on an -AMD Ryzen 9 7950X, Cairo 1.18.4, Rust 1.98.1 test profile. One warmup followed by -five measurements; median is the middle sample and reported p95 is the maximum -of those five samples. No separate build or test was run concurrently by this -implementation during measurement. This is a local regression comparison, not -a hardware-independent frame-rate guarantee. - -Both viewport sizes use scale 1 (logical size equals device size), a far negative -origin, 80 fixed annotation lines, alternating circular/rectangular erasers with -three points and size 32, and 0/20/200 committed erasers. Raster cases add one -provisional eraser; PDF cases do not. Timing includes target/source construction, -annotation replay, erasers, and PDF finalization where applicable. - -| Pattern | Spacing | 1920×1080 median / p95 | 3840×2160 median / p95 | -|---|---:|---:|---:| -| Cartesian | 8 | 9.85 / 9.87 ms | 16.25 / 16.67 ms | -| Cartesian | 40 | 8.78 / 8.79 ms | 14.69 / 14.78 ms | -| Isometric lines | 8 | 18.27 / 20.11 ms | 30.58 / 30.62 ms | -| Isometric lines | 40 | 15.78 / 15.80 ms | 28.81 / 29.32 ms | -| Isometric dots | 8 | 18.15 / 18.24 ms | 30.07 / 30.14 ms | -| Isometric dots | 40 | 16.04 / 16.18 ms | 27.18 / 27.27 ms | - -The table shows 200 committed erasers plus one provisional stroke. Ordinary -panning uses the existing committed-layer cache, so it does not pay this full -replay cost on every frame. None retains the existing solid paint path. - -PDF files without erasers retain vector pattern resources (checked against -`/Subtype /Image`). Cairo's eraser compositing causes fallback raster work, -already present for plain solid backgrounds. Patterns increase fallback PDF -size and CPU cost; the CSV records both rather than presenting these pages as -wholly vector. - -## Regression coverage - -- Config defaults, enum/schema, spacing bounds, transparent normalization, - template inheritance, and comment/unknown-field preservation on guarded saves. -- Negative and far-negative coordinates, fractional/2× scale, tile boundaries, - Cairo path/state preservation, and circular/rectangular eraser replay. -- Direct versus baked pan rendering, pattern/spacing cache invalidation, PNG - eraser pixels, immutable exports, and plain PDF margins with vector paper. - Actual PDFs are also rasterized with Poppler when available to check paper - phase and eraser restoration against the raster renderer. -- Draft Cancel/no-op/invalid input, conflict and identity checks, field-level - merge, pen preservation on grid-only edits, and unchanged drawing history. -- Empty-board persistence, exact optional pen restoration, legacy appearance - seeds, named replacement, and backup recovery/history-trimming distinctions. - -## Integrated pan and PNG consumers - -[Initial consumer measurements](board-paper-consumer-performance.csv) use the actual -`CanvasLayerCache::ensure`/`blit` and PNG export entry points. The matrix includes -both zero and far-negative origins, both sizes, None plus all three patterns, -spacing 8/40, and 0/20/200 committed erasers. Warmup/sample counts, scale, shapes, -and brushes match the source benchmark. PNG includes encoding. A warm pan sample -checks cache validity and blits into an already allocated destination; it does -not construct the paper again. Those initial warm measurements covered only -`ensure` and `blit`, so they did not detect redundant painting in the live caller. -The benchmark now invokes the production committed-canvas dispatcher, including -backdrop preparation, and asserts that a cache hit constructs no paper source. -A 4K regression checks that path and paper restoration after a failed blit. -The historical CSV below has not been remeasured with this expanded harness. - -| Consumer (patterned boards) | 1920×1080 p95 range | 3840×2160 p95 range | -|---|---:|---:| -| Cold pan-cache bake | 3.60–42.47 ms | 9.10–39.03 ms | -| Warm pan-cache check and blit | 0.19–1.73 ms | 1.94–3.34 ms | -| PNG render and encoding | 34.73–305.44 ms | 138.62–660.61 ms | - -Ranges include all tested origins, spacings, patterns, and eraser counts, so -an occasional slower local sample is retained rather than removed. Live pan -reuse stays a blit. Export time is worker-side work, not event-loop rendering. -The simultaneous surface allocation proxy is approximately 30.6 MiB at 1080p -and 107.6 MiB at 4K for the benchmark's retained bake, blit destination, and PNG -target; it excludes codec scratch and the separately bounded paper tile. - -To reproduce the opt-in measurements: - -```sh -cargo test -p wayscriber --all-features --lib board_grid_backdrop_performance -- --ignored --nocapture --test-threads=1 -cargo test -p wayscriber --all-features --lib board_grid_consumers_performance -- --ignored --nocapture --test-threads=1 -``` - -Set `WAYSCRIBER_GRID_COMPARE_VIEWPORT=1` on the first command to include the -rejected viewport-recording candidate. These benchmarks are opt-in so ordinary -CI does not spend time measuring machine-dependent rendering latency. diff --git a/docs/codebase-overview.md b/docs/codebase-overview.md index 58370322f..950e7d504 100644 --- a/docs/codebase-overview.md +++ b/docs/codebase-overview.md @@ -662,5 +662,6 @@ and serialize them. `src/draw/render/board_grid.rs` builds the procedural source shared by the canvas, erasers, pan cache, thumbnails, and canvas exports. `src/input/state/core/board/appearance.rs` publishes appearance edits once; the board picker owns only its draft. Session format 7 carries appearance and -its explicit-override provenance independently of page history. See -[board paper validation](board-paper-validation.md) for regression and performance evidence. +its explicit-override provenance independently of page history. The regression +and performance evidence for this work is kept with the internal documentation +rather than in this repository. diff --git a/src/backend/wayland/handlers/pointer/axis.rs b/src/backend/wayland/handlers/pointer/axis.rs index 95a422a8a..868f24b33 100644 --- a/src/backend/wayland/handlers/pointer/axis.rs +++ b/src/backend/wayland/handlers/pointer/axis.rs @@ -334,6 +334,14 @@ impl WaylandState { } } +/// Whether a surface that deliberately stays open over the board picker is +/// covering it: a page row's context menu, or the color picker on the paper +/// sheet's draft. Either owns the wheel, so nothing in the picker may scroll +/// or step out from under it. The modal registry swallows the tick afterwards. +fn board_picker_wheel_is_covered(input_state: &InputState) -> bool { + input_state.is_context_menu_open() || input_state.is_color_picker_popup_open() +} + /// The paper sheet steps its size with the wheel and keeps the page panel /// behind it from scrolling. fn try_handle_board_appearance_axis( @@ -341,7 +349,7 @@ fn try_handle_board_appearance_axis( position: (f64, f64), scroll_direction: i32, ) -> bool { - if !input_state.is_board_picker_open() || input_state.is_context_menu_open() { + if !input_state.is_board_picker_open() || board_picker_wheel_is_covered(input_state) { return false; } let x = position.0.round() as i32; @@ -357,9 +365,7 @@ fn try_handle_board_picker_page_panel_axis( if !input_state.is_board_picker_open() || scroll_direction == 0 { return false; } - // A page context menu is the one surface that deliberately stays open over - // the picker, so it is also the one that can be scrolled out from under. - if input_state.is_context_menu_open() { + if board_picker_wheel_is_covered(input_state) { return false; } let x = position.0.round() as i32; @@ -422,6 +428,70 @@ mod tests { assert_eq!(layout.page_scroll_row, 1); } + #[test] + fn the_paper_color_picker_takes_the_wheel_from_the_picker_under_it() { + // The color picker opened from the paper sheet is the other surface + // that stays open over the board picker, so the same rule applies to + // both board-picker wheel routes before the registry swallows the tick. + let mut input_state = make_test_input_state(); + input_state.switch_board_force("whiteboard"); + input_state.open_board_picker_with_measurer(&crate::draw::TextMeasurer::default()); + let board_index = input_state + .board_picker_page_panel_board_index() + .expect("page panel board index"); + set_board_page_count(&mut input_state, board_index, 80); + update_picker_layout(&mut input_state); + let layout = *input_state.board_picker_layout().expect("layout"); + let page_panel = (layout.page_viewport_x + 1.0, layout.page_viewport_y + 1.0); + input_state.board_picker_set_focus(BoardPickerFocus::PagePanel); + + input_state + .board_picker_edit_color_selected_with_measurer(&crate::draw::TextMeasurer::default()); + assert!( + input_state.open_color_picker_popup_for_board_paper_with_measurer( + &crate::draw::TextMeasurer::default() + ) + ); + assert!(input_state.is_board_picker_open()); + let frame = input_state.board_appearance_frame().expect("sheet frame"); + let row = input_state.board_appearance_size_row().expect("size row"); + let (rx, ry, rw, rh) = frame.to_surface(row.track); + let size_row = (rx + rw / 2.0, ry + rh / 2.0); + let spacing = input_state.board_appearance_edit().unwrap().spacing.clone(); + + assert!( + !try_handle_board_appearance_axis(&mut input_state, size_row, 1), + "the sheet's size control is under the popup" + ); + assert!( + !try_handle_board_picker_page_panel_axis(&mut input_state, page_panel, 1), + "the page panel is under the popup" + ); + assert!( + input_state.modal_owns_wheel(), + "the registry swallows the tick after both routes decline it" + ); + update_picker_layout(&mut input_state); + let layout = *input_state.board_picker_layout().expect("layout"); + assert_eq!(layout.page_scroll_row, 0, "the list behind must not move"); + assert_eq!( + input_state.board_appearance_edit().unwrap().spacing, + spacing + ); + + // Cancelling the popup hands the wheel back to the sheet. + input_state.close_color_picker_popup(true); + assert!(try_handle_board_appearance_axis( + &mut input_state, + size_row, + 1 + )); + assert_ne!( + input_state.board_appearance_edit().unwrap().spacing, + spacing + ); + } + #[test] fn an_active_screen_modal_prevents_the_toolbar_scroll_route() { let mut input_state = make_test_input_state(); diff --git a/src/input/state/core/board_picker/appearance.rs b/src/input/state/core/board_picker/appearance.rs index 6f44412c8..8287f68a9 100644 --- a/src/input/state/core/board_picker/appearance.rs +++ b/src/input/state/core/board_picker/appearance.rs @@ -3,6 +3,7 @@ use crate::domain::{ BOARD_GRID_MAX_SPACING, BOARD_GRID_MIN_SPACING, BoardBackground, BoardGrid, BoardGridKind, Color, }; +use crate::draw::TextMeasurer; use crate::input::InputState; use crate::input::boards::{BoardAppearance, BoardIdentityGeneration}; use crate::input::events::Key; @@ -384,6 +385,12 @@ impl InputState { self.apply_board_appearance(); } Key::F2 => return false, + // Space on the color field opens the full picker on the draft. + Key::Space if edit.focus == AppearanceField::Color => { + let measurer = TextMeasurer::default(); + self.open_color_picker_popup_for_board_paper_with_measurer(&measurer); + return true; + } Key::Tab => { edit.focus = match edit.focus { AppearanceField::Color => AppearanceField::Pattern, @@ -652,6 +659,11 @@ impl InputState { /// The wheel steps the size over its row. While the sheet is open it also /// consumes wheel events over the picker so nothing behind it scrolls. pub(crate) fn board_appearance_wheel(&mut self, x: i32, y: i32, direction: i32) -> bool { + // A color picker open on the draft owns the wheel; the modal registry + // swallows it after this returns. + if self.is_color_picker_popup_open() { + return false; + } let Some(frame) = self.board_appearance_frame() else { return false; }; @@ -673,6 +685,16 @@ impl InputState { } pub(crate) fn board_appearance_click(&mut self, x: i32, y: i32) -> bool { + let measurer = TextMeasurer::default(); + self.board_appearance_click_with_measurer(&measurer, x, y) + } + + pub(crate) fn board_appearance_click_with_measurer( + &mut self, + measurer: &TextMeasurer, + x: i32, + y: i32, + ) -> bool { // A slider drag ends on release wherever the pointer is. if let Some(edit) = self.board_picker.appearance.as_mut() && edit.size_dragging @@ -713,7 +735,11 @@ impl InputState { match y.floor() as i32 { -65..=-35 => { if in_color_field { + // The field is the picker's trigger, like a toolbar swatch; + // typing a hex still works while the field has focus. edit.focus = AppearanceField::Color; + self.open_color_picker_popup_for_board_paper_with_measurer(measurer); + return true; } } -30..=-5 => { diff --git a/src/input/state/core/board_picker/appearance/tests.rs b/src/input/state/core/board_picker/appearance/tests.rs index daa745cdc..b96791cf7 100644 --- a/src/input/state/core/board_picker/appearance/tests.rs +++ b/src/input/state/core/board_picker/appearance/tests.rs @@ -450,3 +450,211 @@ fn command_palette_edit_board_paper_opens_the_sheet_on_the_active_board() { Some("whiteboard") ); } + +/// The sheet's hex field opens the full color picker on the draft, with the +/// picker and its sheet left open underneath. +fn open_paper_picker(input: &mut InputState) { + let surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 1280, 720).unwrap(); + let ctx = cairo::Context::new(&surface).unwrap(); + input.update_board_picker_layout(&ctx, 1280, 720); + let frame = input.board_appearance_frame().expect("sheet frame"); + let header = input.board_appearance_header().expect("sheet header"); + let (fx, fy, fw, fh) = frame.to_surface(header.color_field); + let (x, y) = ((fx + fw / 2.0) as i32, (fy + fh / 2.0) as i32); + assert!(input.board_appearance_click(x, y)); +} + +#[test] +fn clicking_the_color_field_opens_the_picker_on_the_draft_and_keeps_the_sheet() { + let mut input = editing(); + let draft = input.board_appearance_edit().unwrap().color.clone(); + + open_paper_picker(&mut input); + + assert!(input.is_color_picker_popup_open()); + assert!(input.color_picker_popup_edits_board_paper()); + assert!(input.is_board_picker_open()); + assert!(input.board_appearance_edit().is_some()); + assert_eq!( + input.color_picker_popup_current_color().map(color_to_hex), + Some(draft), + "the popup starts on the draft color" + ); + assert_eq!(input.color_picker_popup_title(), "Paper Color"); + assert!(!input.color_picker_popup_shows_default_button()); + input.update_color_picker_popup_layout(1280, 720); + let layout = input.color_picker_popup_layout().unwrap(); + assert_eq!(layout.alpha_h, 0.0, "paper has no alpha bar"); + // Sampling from the screen would close the sheet and land on the pen, so + // the paper popup has no eyedropper. + assert!(!layout.eyedropper_enabled); + let (bx, by) = ( + layout.eyedropper_btn_x + layout.action_btn_size / 2.0, + layout.eyedropper_btn_y + layout.action_btn_size / 2.0, + ); + assert_eq!(layout.action_at(bx, by), None); + assert_eq!(layout.action_tooltip_at(bx, by), None); +} + +#[test] +fn the_wheel_does_not_reach_the_size_control_under_the_paper_picker() { + let mut input = editing(); + open_paper_picker(&mut input); + let frame = input.board_appearance_frame().unwrap(); + let row = input.board_appearance_size_row().unwrap(); + let (rx, ry, rw, rh) = frame.to_surface(row.track); + let spacing = input.board_appearance_edit().unwrap().spacing.clone(); + + assert!(!input.board_appearance_wheel((rx + rw / 2.0) as i32, (ry + rh / 2.0) as i32, 1)); + assert!(input.modal_owns_wheel(), "the registry swallows the tick"); + assert_eq!(input.board_appearance_edit().unwrap().spacing, spacing); +} + +#[test] +fn picker_edits_preview_on_the_draft_and_cancel_restores_it() { + let mut input = editing(); + let original = input.board_appearance_edit().unwrap().color.clone(); + let pen = input.color_for_tool(crate::input::Tool::Pen); + open_paper_picker(&mut input); + + input.color_picker_popup_set_color(RED); + assert_eq!( + input.board_appearance_edit().unwrap().color, + color_to_hex(RED) + ); + assert_eq!(input.board_appearance_edit().unwrap().preview().0, RED); + // Nothing behind the sheet moves until the sheet's own Apply. + assert_ne!( + input.boards.active_board().spec.background, + BoardBackground::Solid(RED) + ); + assert_eq!(input.color_for_tool(crate::input::Tool::Pen), pen); + assert!(!input.is_session_dirty()); + + input.close_color_picker_popup(true); + assert!(!input.is_color_picker_popup_open()); + assert!(input.board_appearance_edit().is_some(), "the sheet stays"); + assert_eq!(input.board_appearance_edit().unwrap().color, original); +} + +#[test] +fn picker_ok_keeps_the_draft_color_and_only_apply_writes_the_board() { + let mut input = editing(); + let pen = input.color_for_tool(crate::input::Tool::Pen); + let recents = input.recent_colors().len(); + open_paper_picker(&mut input); + + input.color_picker_popup_set_color(BLUE); + input.apply_color_picker_popup(); + + assert!(!input.is_color_picker_popup_open()); + assert_eq!( + input.board_appearance_edit().unwrap().color, + color_to_hex(BLUE) + ); + assert_eq!(input.color_for_tool(crate::input::Tool::Pen), pen); + assert_eq!( + input.recent_colors().len(), + recents, + "paper is not a pen color" + ); + assert!(!input.is_session_dirty()); + + assert!(input.apply_board_appearance()); + assert_eq!( + input.boards.active_board().spec.background, + BoardBackground::Solid(BLUE) + ); + assert!(input.is_session_dirty()); +} + +#[test] +fn ok_with_the_opening_color_typed_back_undoes_an_earlier_preview() { + let mut input = editing(); + // A color with a three-digit spelling, so it is only parsed on OK. + assert!(input.board_appearance_palette(crate::draw::WHITE)); + open_paper_picker(&mut input); + input.color_picker_popup_set_color(RED); + assert_eq!( + input.board_appearance_edit().unwrap().color, + color_to_hex(RED) + ); + + input.color_picker_popup_set_hex_editing(true); + for ch in "#FFF".chars() { + input.color_picker_popup_hex_append(ch); + } + input.apply_color_picker_popup(); + + assert!(!input.is_color_picker_popup_open()); + assert_eq!(input.board_appearance_edit().unwrap().color, "#FFFFFF"); + assert!(!input.is_session_dirty()); +} + +#[test] +fn picker_colors_for_paper_are_always_opaque() { + let mut input = editing(); + open_paper_picker(&mut input); + + input.color_picker_popup_set_alpha(0.25); + assert_eq!(input.color_picker_popup_alpha(), Some(1.0)); + input.color_picker_popup_set_color(Color { a: 0.5, ..RED }); + assert_eq!(input.color_picker_popup_current_color(), Some(RED)); + assert_eq!(input.board_appearance_edit().unwrap().color, "#FF0000"); + + // Typed hex is another way in: an alpha pair previews, commits, and + // copies as opaque, so the popup never shows a color the paper cannot be. + input.color_picker_popup_set_hex_editing(true); + for ch in "#0000FF80".chars() { + input.color_picker_popup_hex_append(ch); + } + assert_eq!(input.color_picker_popup_current_color(), Some(BLUE)); + assert!(input.color_picker_popup_commit_hex()); + assert_eq!(input.color_picker_popup_current_color(), Some(BLUE)); + assert_eq!(input.color_picker_popup_hex_buffer(), Some("#0000FF")); + assert_eq!(input.board_appearance_edit().unwrap().color, "#0000FF"); + + // And through OK with an uncommitted buffer. + input.color_picker_popup_set_hex_editing(true); + for ch in "#00FF0080".chars() { + input.color_picker_popup_hex_append(ch); + } + input.apply_color_picker_popup(); + assert_eq!(input.board_appearance_edit().unwrap().color, "#00FF00"); +} + +#[test] +fn closing_the_sheet_or_picker_takes_the_paper_picker_with_it() { + let mut input = editing(); + open_paper_picker(&mut input); + input.board_picker_cancel_edit(); + assert!(!input.is_color_picker_popup_open()); + assert!(input.is_board_picker_open()); + + input.board_picker_edit_color_selected_with_measurer(&crate::draw::TextMeasurer::default()); + open_paper_picker(&mut input); + input.close_board_picker(); + assert!(!input.is_color_picker_popup_open()); + assert!(input.board_appearance_edit().is_none()); +} + +#[test] +fn space_on_the_color_field_opens_the_picker_and_escape_closes_only_the_picker() { + let mut input = editing(); + assert_eq!( + input.board_appearance_edit().unwrap().focus, + AppearanceField::Color + ); + assert!(input.board_appearance_key(Key::Space)); + assert!(input.color_picker_popup_edits_board_paper()); + // A typed space is not the shortcut; it reaches the field like other text. + input.close_color_picker_popup(true); + assert!(input.board_appearance_key(Key::Char(' '))); + assert!(!input.is_color_picker_popup_open()); + assert!(input.board_appearance_key(Key::Space)); + + // The picker has key precedence over the board picker. + assert!(input.handle_color_picker_popup_key(Key::Escape)); + assert!(!input.is_color_picker_popup_open()); + assert!(input.board_appearance_edit().is_some()); +} diff --git a/src/input/state/core/board_picker/state/edit.rs b/src/input/state/core/board_picker/state/edit.rs index 4e89702f5..9ae1162fb 100644 --- a/src/input/state/core/board_picker/state/edit.rs +++ b/src/input/state/core/board_picker/state/edit.rs @@ -13,6 +13,11 @@ use super::super::{ impl InputState { pub(crate) fn board_picker_clear_edit(&mut self) { if self.board_picker.appearance.take().is_some() { + // A picker open on the draft has nothing left to edit. The draft is + // gone, so there is nothing to restore either. + if self.color_picker_popup_edits_board_paper() { + self.close_color_picker_popup(false); + } // Closing the paper sheet removes the dim over the whole surface. self.dirty_tracker.mark_full(); self.needs_redraw = true; diff --git a/src/input/state/core/board_picker/state/lifecycle.rs b/src/input/state/core/board_picker/state/lifecycle.rs index f0bfa75c6..8d8374a59 100644 --- a/src/input/state/core/board_picker/state/lifecycle.rs +++ b/src/input/state/core/board_picker/state/lifecycle.rs @@ -38,6 +38,9 @@ impl InputState { } pub(crate) fn close_board_picker(&mut self) { + if self.color_picker_popup_edits_board_paper() { + self.close_color_picker_popup(false); + } if let Some(layout) = self.board_picker.close() { self.mark_board_picker_region(&layout); } diff --git a/src/input/state/core/color_picker_popup/mod.rs b/src/input/state/core/color_picker_popup/mod.rs index d9e88e087..d5746b5c9 100644 --- a/src/input/state/core/color_picker_popup/mod.rs +++ b/src/input/state/core/color_picker_popup/mod.rs @@ -86,6 +86,33 @@ pub(crate) enum HexPasteTarget { ColorPickerPopup { generation: u64 }, } +/// What the popup edits: live preview and OK both write here, and Cancel +/// restores it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ColorPickerTarget { + /// The color the opening tool paints with. + Tool, + /// A quick-color palette slot, recolored in place. + QuickColor(usize), + /// The paper color draft of the board picker's appearance sheet. + BoardPaper, +} + +impl ColorPickerTarget { + /// The quick-color slot this target recolors, if any. + pub fn slot(self) -> Option { + match self { + ColorPickerTarget::QuickColor(index) => Some(index), + ColorPickerTarget::Tool | ColorPickerTarget::BoardPaper => None, + } + } + + /// Paper is opaque, so the popup neither shows nor edits alpha for it. + pub fn edits_alpha(self) -> bool { + self != ColorPickerTarget::BoardPaper + } +} + /// State of the color picker popup. #[derive(Debug, Clone, Default)] pub enum ColorPickerPopupState { @@ -94,16 +121,13 @@ pub enum ColorPickerPopupState { Hidden, /// Popup is open with current editing state. Open { - /// Tool whose color is being edited. + /// Tool that was active when the popup opened; the edit target when + /// `target` is [`ColorPickerTarget::Tool`]. tool: Tool, - /// Quick-color slot being recolored, when the popup was opened by - /// secondary-clicking a swatch. `None` edits the tool's own color. - /// The slot is the edit target for both live preview and accept, so a - /// recolor never hijacks what the tool is currently painting with. - slot: Option, - /// Original color when popup was opened (for cancel restoration). This - /// is the edit target's color: the tool's, or the slot's when - /// recoloring. + /// What live preview and accept write to. A slot or paper target never + /// hijacks what the tool is currently painting with. + target: ColorPickerTarget, + /// The target's color when the popup opened, for cancel restoration. original_color: Color, /// Currently selected color (live updates). current_color: Color, @@ -126,6 +150,35 @@ pub enum ColorPickerPopupState { }, } +/// Which optional controls a layout includes; the edit target decides. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ColorPickerPopupLayoutOptions { + /// The "Default" button, offered while recoloring a shipped palette slot. + pub show_default_button: bool, + /// The alpha bar; paper has no alpha. + pub show_alpha: bool, + /// The screen eyedropper. Sampling closes every popup and lands on the + /// drawing color, which would discard a paper draft, so paper hides it. + pub show_eyedropper: bool, +} + +impl ColorPickerPopupLayoutOptions { + /// Every control, as the tool-color popup shows them. + pub const ALL: Self = Self { + show_default_button: true, + show_alpha: true, + show_eyedropper: true, + }; + + pub fn for_target(target: ColorPickerTarget, show_default_button: bool) -> Self { + Self { + show_default_button, + show_alpha: target.edits_alpha(), + show_eyedropper: target != ColorPickerTarget::BoardPaper, + } + } +} + /// Cached layout metrics for the color picker popup. #[derive(Debug, Clone, Copy)] pub struct ColorPickerPopupLayout { @@ -191,6 +244,8 @@ pub struct ColorPickerPopupLayout { pub eyedropper_btn_y: f64, /// Size of the square action buttons (copy / paste / eyedropper). pub action_btn_size: f64, + /// Whether the eyedropper button is present. + pub eyedropper_enabled: bool, /// Top-left of the "Default" button, present only while recoloring a /// quick-color slot that the shipped palette defines. It shares the /// button row with OK/Cancel, which is why its absence has to change the @@ -214,9 +269,24 @@ impl ColorPickerPopupLayout { /// Compute the layout for given screen dimensions. `show_default_button` /// comes from the popup's target: recoloring a slot with a built-in color /// adds a third button to the bottom row. - pub fn compute(screen_width: u32, screen_height: u32, show_default_button: bool) -> Self { + pub fn compute( + screen_width: u32, + screen_height: u32, + options: ColorPickerPopupLayoutOptions, + ) -> Self { + let ColorPickerPopupLayoutOptions { + show_default_button, + show_alpha, + show_eyedropper, + } = options; let width = POPUP_WIDTH; - let height = POPUP_HEIGHT; + // Without an alpha bar the rows below it move up and the panel shrinks. + let (alpha_h, alpha_room) = if show_alpha { + (ALPHA_HEIGHT, ALPHA_HEIGHT + SLIDER_GAP) + } else { + (0.0, 0.0) + }; + let height = POPUP_HEIGHT - (ALPHA_HEIGHT + SLIDER_GAP - alpha_room); // Center the popup on screen let origin_x = (screen_width as f64 - width) / 2.0; @@ -236,7 +306,7 @@ impl ColorPickerPopupLayout { let alpha_y = hue_y + HUE_HEIGHT + SLIDER_GAP; // Preview row (preview swatch + hex input) - let preview_row_y = alpha_y + ALPHA_HEIGHT + ELEMENT_GAP; + let preview_row_y = hue_y + HUE_HEIGHT + alpha_room + ELEMENT_GAP; let preview_x = content_x; let preview_y = preview_row_y; @@ -290,7 +360,7 @@ impl ColorPickerPopupLayout { alpha_x, alpha_y, alpha_w: GRADIENT_WIDTH, - alpha_h: ALPHA_HEIGHT, + alpha_h, recents_y, recents_x, hue_x, @@ -309,6 +379,7 @@ impl ColorPickerPopupLayout { paste_btn_y, eyedropper_btn_x, eyedropper_btn_y, + eyedropper_enabled: show_eyedropper, action_btn_size, default_btn, ok_btn_x, @@ -347,7 +418,8 @@ impl ColorPickerPopupLayout { /// Check if a point is within the alpha bar. pub fn point_in_alpha(&self, x: f64, y: f64) -> bool { - x >= self.alpha_x + self.alpha_h > 0.0 + && x >= self.alpha_x && x <= self.alpha_x + self.alpha_w && y >= self.alpha_y && y <= self.alpha_y + self.alpha_h @@ -410,7 +482,8 @@ impl ColorPickerPopupLayout { /// Check if a point is within the screen eyedropper button. pub fn point_in_eyedropper_button(&self, x: f64, y: f64) -> bool { - x >= self.eyedropper_btn_x + self.eyedropper_enabled + && x >= self.eyedropper_btn_x && x <= self.eyedropper_btn_x + self.action_btn_size && y >= self.eyedropper_btn_y && y <= self.eyedropper_btn_y + self.action_btn_size diff --git a/src/input/state/core/color_picker_popup/panel.rs b/src/input/state/core/color_picker_popup/panel.rs index d5ed9e2af..332d18881 100644 --- a/src/input/state/core/color_picker_popup/panel.rs +++ b/src/input/state/core/color_picker_popup/panel.rs @@ -1,6 +1,6 @@ use super::{ - ColorPickerPopupAction, ColorPickerPopupLayout, ColorPickerPopupState, PickerDrag, - color_to_hex, rgb_to_hsv, + ColorPickerPopupAction, ColorPickerPopupLayout, ColorPickerPopupLayoutOptions, + ColorPickerPopupState, ColorPickerTarget, PickerDrag, color_to_hex, rgb_to_hsv, }; use crate::draw::Color; use crate::input::Tool; @@ -19,12 +19,12 @@ impl ColorPickerPopupPanel { matches!(self.state, ColorPickerPopupState::Open { .. }) } - pub(crate) fn open(&mut self, tool: Tool, slot: Option, color: Color) { + pub(crate) fn open(&mut self, tool: Tool, target: ColorPickerTarget, color: Color) { self.generation = self.generation.wrapping_add(1); self.pressed_action = None; self.state = ColorPickerPopupState::Open { tool, - slot, + target, original_color: color, current_color: color, hex_editing: false, @@ -42,13 +42,17 @@ impl ColorPickerPopupPanel { self.pressed_action = None; } - pub fn slot(&self) -> Option { + pub fn target(&self) -> Option { match &self.state { - ColorPickerPopupState::Open { slot, .. } => *slot, + ColorPickerPopupState::Open { target, .. } => Some(*target), ColorPickerPopupState::Hidden => None, } } + pub fn slot(&self) -> Option { + self.target().and_then(ColorPickerTarget::slot) + } + pub fn current_color(&self) -> Option { match &self.state { ColorPickerPopupState::Open { current_color, .. } => Some(*current_color), @@ -83,8 +87,12 @@ impl ColorPickerPopupPanel { screen_height: u32, show_default_button: bool, ) { - self.layout = self.is_open().then(|| { - ColorPickerPopupLayout::compute(screen_width, screen_height, show_default_button) + self.layout = self.target().map(|target| { + ColorPickerPopupLayout::compute( + screen_width, + screen_height, + ColorPickerPopupLayoutOptions::for_target(target, show_default_button), + ) }); } @@ -123,11 +131,11 @@ mod tests { #[test] fn reopening_advances_generation_and_resets_transient_state() { let mut panel = ColorPickerPopupPanel::default(); - panel.open(Tool::Pen, Some(2), RED); + panel.open(Tool::Pen, ColorPickerTarget::QuickColor(2), RED); let first = panel.current_generation().expect("open generation"); panel.set_dragging(Some(PickerDrag::Hue)); panel.hide(); - panel.open(Tool::Marker, None, RED); + panel.open(Tool::Marker, ColorPickerTarget::Tool, RED); assert!(panel.current_generation().expect("reopened generation") > first); assert_eq!(panel.slot(), None); @@ -138,7 +146,7 @@ mod tests { #[test] fn taking_a_drag_target_ends_the_drag() { let mut panel = ColorPickerPopupPanel::default(); - panel.open(Tool::Pen, None, RED); + panel.open(Tool::Pen, ColorPickerTarget::Tool, RED); panel.set_dragging(Some(PickerDrag::SatVal)); assert_eq!(panel.take_drag_target(), Some(PickerDrag::SatVal)); diff --git a/src/input/state/core/color_picker_popup/state.rs b/src/input/state/core/color_picker_popup/state.rs index d2693647f..43756ac5a 100644 --- a/src/input/state/core/color_picker_popup/state.rs +++ b/src/input/state/core/color_picker_popup/state.rs @@ -8,9 +8,25 @@ use crate::input::state::InputState; use crate::input::state::QuickColorEdit; use super::{ - ColorPickerPopupAction, ColorPickerPopupLayout, ColorPickerPopupState, PickerDrag, - color_to_hex, hsv_to_rgb, parse_hex_color, rgb_to_hsv, + ColorPickerPopupAction, ColorPickerPopupLayout, ColorPickerPopupState, ColorPickerTarget, + PickerDrag, color_to_hex, hsv_to_rgb, parse_hex_color, rgb_to_hsv, }; +use crate::input::state::core::modal::ModalSurface; + +/// Paper has no alpha, so every color the paper target takes is opaque. +fn opaque(color: Color) -> Color { + Color { a: 1.0, ..color } +} + +/// A color as the target can hold it: typed and pasted hex can carry an +/// alpha pair the paper target must drop, like every other way in. +fn constrain_for_target(target: ColorPickerTarget, color: Color) -> Color { + if target.edits_alpha() { + color + } else { + opaque(color) + } +} fn hex_is_complete_for_live_preview(value: &str) -> bool { // Six digits is a complete opaque color and eight a complete translucent @@ -54,7 +70,39 @@ impl InputState { pub fn open_color_picker_popup_with_measurer(&mut self, measurer: &TextMeasurer) { self.discard_open_color_picker_recolor(); let color = self.color_for_tool(self.active_tool()); - self.open_color_picker_popup_for(measurer, None, color); + self.open_color_picker_popup_for(measurer, ColorPickerTarget::Tool, color); + } + + /// Opens the popup on the paper sheet's color draft. The board picker and + /// its sheet stay open underneath: the popup edits the draft, and only the + /// sheet's Apply writes the board. Returns false when no sheet is open. + pub fn open_color_picker_popup_for_board_paper_with_measurer( + &mut self, + measurer: &TextMeasurer, + ) -> bool { + let Some(edit) = self.board_appearance_edit() else { + return false; + }; + let (color, _) = edit.preview(); + self.discard_open_color_picker_recolor(); + self.open_color_picker_popup_for(measurer, ColorPickerTarget::BoardPaper, opaque(color)); + true + } + + /// Whether the open popup edits the paper sheet's color draft. + pub fn color_picker_popup_edits_board_paper(&self) -> bool { + self.color_picker_popup.target() == Some(ColorPickerTarget::BoardPaper) + } + + /// The optional controls the open popup's layout includes. + pub fn color_picker_popup_layout_options( + &self, + ) -> Option { + let target = self.color_picker_popup.target()?; + Some(super::ColorPickerPopupLayoutOptions::for_target( + target, + self.color_picker_popup_shows_default_button(), + )) } /// Opens the color picker popup bound to a quick-color slot, so editing it @@ -80,7 +128,7 @@ impl InputState { let Some(color) = self.style.quick_colors.color_for_index(index) else { return false; }; - self.open_color_picker_popup_for(measurer, Some(index), color); + self.open_color_picker_popup_for(measurer, ColorPickerTarget::QuickColor(index), color); true } @@ -89,7 +137,11 @@ impl InputState { /// dropping the state and leaving the swatch changed but unsaved. A /// tool-color preview is left in place, as reopening has always done. fn discard_open_color_picker_recolor(&mut self) { - if self.color_picker_popup_slot().is_some() { + if self + .color_picker_popup + .target() + .is_some_and(|target| target != ColorPickerTarget::Tool) + { self.close_color_picker_popup(true); } } @@ -97,15 +149,24 @@ impl InputState { fn open_color_picker_popup_for( &mut self, measurer: &TextMeasurer, - slot: Option, + target: ColorPickerTarget, color: Color, ) { self.cancel_pending_color_picker_paste(); - self.close_modals_for_open(crate::input::state::core::modal::ModalSurface::ColorPicker); + if target == ColorPickerTarget::BoardPaper { + // The paper draft lives in the board picker, so the picker is the + // one surface this popup must not close. + self.close_modals_for_open_keeping( + ModalSurface::ColorPicker, + ModalSurface::BoardPicker, + ); + } else { + self.close_modals_for_open(ModalSurface::ColorPicker); + } self.cancel_active_interaction_with(measurer); let tool = self.active_tool(); - self.color_picker_popup.open(tool, slot, color); + self.color_picker_popup.open(tool, target, color); self.dirty_tracker.mark_full(); self.needs_redraw = true; @@ -115,15 +176,17 @@ impl InputState { /// target is never ambiguous. This is the semantic title; the renderer /// trims the shaped text to the panel it draws into. pub fn color_picker_popup_title(&self) -> Cow<'static, str> { - let ColorPickerPopupState::Open { - slot: Some(index), .. - } = &self.color_picker_popup.state - else { - return Cow::Borrowed("Select Color"); - }; - match self.style.quick_colors.entry(*index) { - Some(entry) => Cow::Owned(format!("Recolor {}", single_line_slot_label(&entry.label))), - None => Cow::Borrowed("Recolor swatch"), + match self.color_picker_popup.target() { + Some(ColorPickerTarget::QuickColor(index)) => { + match self.style.quick_colors.entry(index) { + Some(entry) => { + Cow::Owned(format!("Recolor {}", single_line_slot_label(&entry.label))) + } + None => Cow::Borrowed("Recolor swatch"), + } + } + Some(ColorPickerTarget::BoardPaper) => Cow::Borrowed("Paper Color"), + Some(ColorPickerTarget::Tool) | None => Cow::Borrowed("Select Color"), } } @@ -152,7 +215,8 @@ impl InputState { fn color_picker_popup_preview(&mut self, color: Color) { match self.color_picker_popup.state { ColorPickerPopupState::Open { - slot: Some(index), .. + target: ColorPickerTarget::QuickColor(index), + .. } => { if self.style.quick_colors.set_color_for_index(index, color) { self.dirty_tracker.mark_full(); @@ -160,10 +224,19 @@ impl InputState { } } ColorPickerPopupState::Open { - tool, slot: None, .. + tool, + target: ColorPickerTarget::Tool, + .. } => { let _ = self.preview_color_for_tool(tool, color); } + ColorPickerPopupState::Open { + target: ColorPickerTarget::BoardPaper, + .. + } => { + // The sheet's draft is the target; its own Apply commits. + let _ = self.board_appearance_palette(opaque(color)); + } ColorPickerPopupState::Hidden => {} } } @@ -173,14 +246,14 @@ impl InputState { self.cancel_pending_color_picker_paste(); let mut restored_color = None; if let ColorPickerPopupState::Open { - slot, + target, original_color, .. } = &self.color_picker_popup.state // A recolor edits durable config, so even an implicit close (light // mode, session restore) must not leave the palette changed and // unsaved. A tool-color preview stays put, as callers expect. - && (restore_original || slot.is_some()) + && (restore_original || *target != ColorPickerTarget::Tool) { restored_color = Some(*original_color); } @@ -200,7 +273,7 @@ impl InputState { let mut applied_color = None; if let ColorPickerPopupState::Open { tool, - slot, + target, original_color, current_color, hex_buffer, @@ -218,18 +291,24 @@ impl InputState { if !buffered_digits.eq_ignore_ascii_case(current_digits) && let Some(color) = parse_hex_color(hex_buffer) { - *current_color = color; + *current_color = constrain_for_target(*target, color); } - applied_color = Some((*tool, *slot, *original_color, *current_color)); + applied_color = Some((*tool, *target, *original_color, *current_color)); + } + if let Some((_, _, _, color)) = applied_color { + // The target always ends on the accepted color, which also catches + // a three-digit hex first parsed just above. This cannot be gated + // on the color having changed: a preview may have moved the target + // away from the opening color, and typing that color back then + // has to put it back. + self.color_picker_popup_preview(color); } - if let Some((tool, slot, original_color, color)) = applied_color + if let Some((tool, target, original_color, color)) = applied_color && original_color != color { - // Commit on the popup's own target, which also catches a - // three-digit hex first parsed just above. - self.color_picker_popup_preview(color); - match slot { - Some(index) => { + // Persistence and history only when something actually changed. + match target { + ColorPickerTarget::QuickColor(index) => { self.request_quick_color_edit(index, color); // The swatch the tool was already painting with follows its // own recolor, so the palette's selection ring and the live @@ -242,7 +321,7 @@ impl InputState { self.mark_session_dirty(); } } - None => { + ColorPickerTarget::Tool => { self.preset_slots.clear_active(); // Accepting is where a mixed color becomes the color in // use, so it belongs in recents. This commits on the @@ -252,6 +331,9 @@ impl InputState { self.note_recent_color(color); self.mark_session_dirty(); } + // The draft already holds the color; the sheet's Apply decides + // whether the board changes, so nothing is dirty yet. + ColorPickerTarget::BoardPaper => {} } } self.color_picker_popup.hide(); @@ -369,10 +451,17 @@ impl InputState { /// Sets the color's alpha from a position on the alpha bar. pub fn color_picker_popup_set_alpha(&mut self, norm_x: f64) { let alpha = norm_x.clamp(0.0, 1.0); - let ColorPickerPopupState::Open { current_color, .. } = &self.color_picker_popup.state + let ColorPickerPopupState::Open { + current_color, + target, + .. + } = &self.color_picker_popup.state else { return; }; + if !target.edits_alpha() { + return; + } let color = Color { a: alpha, ..*current_color @@ -420,9 +509,11 @@ impl InputState { hex_buffer, hex_editing, hex_selected, + target, .. } = &mut self.color_picker_popup.state { + let color = constrain_for_target(*target, color); *current_color = color; *hex_buffer = color_to_hex(color); *hex_editing = false; @@ -529,6 +620,7 @@ impl InputState { hex_editing, hex_selected, current_color, + target, .. } = &mut self.color_picker_popup.state else { @@ -569,6 +661,7 @@ impl InputState { if hex_is_complete_for_live_preview(hex_buffer) && let Some(color) = parse_hex_color(hex_buffer) { + let color = constrain_for_target(*target, color); *current_color = color; live_color = Some(color); } @@ -588,6 +681,7 @@ impl InputState { hex_editing, hex_selected, current_color, + target, .. } = &mut self.color_picker_popup.state && *hex_editing @@ -606,6 +700,7 @@ impl InputState { if hex_is_complete_for_live_preview(hex_buffer) && let Some(color) = parse_hex_color(hex_buffer) { + let color = constrain_for_target(*target, color); *current_color = color; live_color = Some(color); } @@ -623,6 +718,7 @@ impl InputState { hex_buffer, hex_editing, current_color, + target, .. } = &mut self.color_picker_popup.state else { @@ -634,6 +730,9 @@ impl InputState { } if let Some(color) = parse_hex_color(hex_buffer) { + // The buffer is rewritten from the constrained color, so a + // typed alpha pair disappears on commit for paper. + let color = constrain_for_target(*target, color); *current_color = color; *hex_buffer = color_to_hex(color); *hex_editing = false; diff --git a/src/input/state/core/mod.rs b/src/input/state/core/mod.rs index f2c0727e0..e1e5c0d28 100644 --- a/src/input/state/core/mod.rs +++ b/src/input/state/core/mod.rs @@ -88,9 +88,9 @@ pub(crate) use captured_image::BoardPasteTarget; pub(crate) use color_picker_popup::HexPasteTarget; pub use color_picker_popup::PickerDrag; pub use color_picker_popup::{ - ColorPickerCursorHint, ColorPickerPopupLayout, ColorPickerPopupPanel, ColorPickerPopupState, - POPUP_HEIGHT as COLOR_PICKER_POPUP_HEIGHT, POPUP_WIDTH as COLOR_PICKER_POPUP_WIDTH, - PREVIEW_SIZE as COLOR_PICKER_PREVIEW_SIZE, + ColorPickerCursorHint, ColorPickerPopupLayout, ColorPickerPopupLayoutOptions, + ColorPickerPopupPanel, ColorPickerPopupState, POPUP_HEIGHT as COLOR_PICKER_POPUP_HEIGHT, + POPUP_WIDTH as COLOR_PICKER_POPUP_WIDTH, PREVIEW_SIZE as COLOR_PICKER_PREVIEW_SIZE, RECENT_SWATCH_COUNT as COLOR_PICKER_RECENT_SWATCH_COUNT, RECENT_SWATCH_SIZE as COLOR_PICKER_RECENT_SWATCH_SIZE, rgb_to_hsv as color_picker_rgb_to_hsv, }; diff --git a/src/input/state/core/modal.rs b/src/input/state/core/modal.rs index 9936d66df..7010bebfd 100644 --- a/src/input/state/core/modal.rs +++ b/src/input/state/core/modal.rs @@ -163,6 +163,28 @@ impl InputState { } } + /// [`Self::close_modals_for_open`] for a surface opened *from* `kept`, + /// which stays open underneath it. The pairing is per opening rather than + /// a registry rule because the same surface opened from anywhere else + /// still excludes `kept`: the color picker keeps the board picker only + /// while it edits the picker's paper sheet. + pub(crate) fn close_modals_for_open_keeping( + &mut self, + opening: ModalSurface, + kept: ModalSurface, + ) { + self.clear_pending_sequence(); + for other in ModalSurface::ALL { + if other != opening + && other != kept + && !opening.keeps_open(other) + && self.modal_is_open(other) + { + self.close_modal(other); + } + } + } + /// Close everything a screen-region modal must not compete with, and /// cancel any unfinished gesture. Shared by the eyedropper and OCR: both /// take over pointer input entirely while they are up. diff --git a/src/input/state/mod.rs b/src/input/state/mod.rs index 2499fa040..1461aa501 100644 --- a/src/input/state/mod.rs +++ b/src/input/state/mod.rs @@ -35,24 +35,25 @@ pub use core::{ BLOCKED_ACTION_DURATION_MS, BoardPickerCursorHint, BoardPickerLayout, COLOR_PICKER_POPUP_HEIGHT, COLOR_PICKER_POPUP_WIDTH, COLOR_PICKER_PREVIEW_SIZE, COLOR_PICKER_RECENT_SWATCH_COUNT, COLOR_PICKER_RECENT_SWATCH_SIZE, COMMAND_PALETTE_MAX_VISIBLE, - ColorPickerCursorHint, ColorPickerPopupLayout, ColorPickerPopupState, CommandPaletteCursorHint, - CommandPaletteListRow, CommandPaletteState, CompassDir, CompositorCapabilities, - ContextMenuCursorHint, ContextMenuEntry, ContextMenuKind, ContextMenuLayout, ContextMenuState, - ContextSubmenu, DesktopEnvironment, DrawingState, EyedropperCaptureSource, EyedropperUiState, - FontPickerFilter, FontPickerLayout, FontPickerResults, FontPickerRow, FontPickerTarget, - HelpOverlayClick, HelpOverlayCursorHint, HelpOverlayReleaseOutcome, ImePreedit, InputState, - MAX_STROKE_THICKNESS, MIN_STROKE_THICKNESS, OutputFocusAction, PRESET_FEEDBACK_DURATION_MS, - PRESET_TOAST_DURATION_MS, PickerDrag, PrecisionEntryState, PresetAction, PresetFeedbackKind, - PressureThicknessEditMode, PressureThicknessEntryMode, QuickColorEdit, RADIAL_COMPASS_SLICES, - RADIAL_PAINT_DELAY, RADIAL_TOOL_SEGMENT_COUNT, RadialMenuLayout, RadialMenuState, RadialParent, - RadialRingSwatch, RadialSegmentId, RadialSlice, RadialSliceKind, RegionInputSource, - RegionPurposeTag, RegionSelectUiState, RegionSelection, SIZE_RING_ARC_SPAN, - SIZE_RING_ARC_START, SUBMENU_AIM_GRACE, SUBMENU_HOVER_DELAY, ScreenCaptureSource, - SelectionAxis, SelectionHandle, SelectionPolicy, SelectionPropertyEntry, SelectionPropertyKind, - ShellMode, SubmenuSide, TextInputMode, Toast, ToastPriority, ToastPushOutcome, TourStep, - UI_TOAST_DURATION_MS, UiToastKind, UiVisibility, ZoomAction, color_picker_rgb_to_hsv, - compass_slice, font_picker_layout, font_picker_rows, size_ring_angle_for_value, - size_ring_value_for_angle, slice_parent, sub_ring_child_count, sub_ring_children, + ColorPickerCursorHint, ColorPickerPopupLayout, ColorPickerPopupLayoutOptions, + ColorPickerPopupState, CommandPaletteCursorHint, CommandPaletteListRow, CommandPaletteState, + CompassDir, CompositorCapabilities, ContextMenuCursorHint, ContextMenuEntry, ContextMenuKind, + ContextMenuLayout, ContextMenuState, ContextSubmenu, DesktopEnvironment, DrawingState, + EyedropperCaptureSource, EyedropperUiState, FontPickerFilter, FontPickerLayout, + FontPickerResults, FontPickerRow, FontPickerTarget, HelpOverlayClick, HelpOverlayCursorHint, + HelpOverlayReleaseOutcome, ImePreedit, InputState, MAX_STROKE_THICKNESS, MIN_STROKE_THICKNESS, + OutputFocusAction, PRESET_FEEDBACK_DURATION_MS, PRESET_TOAST_DURATION_MS, PickerDrag, + PrecisionEntryState, PresetAction, PresetFeedbackKind, PressureThicknessEditMode, + PressureThicknessEntryMode, QuickColorEdit, RADIAL_COMPASS_SLICES, RADIAL_PAINT_DELAY, + RADIAL_TOOL_SEGMENT_COUNT, RadialMenuLayout, RadialMenuState, RadialParent, RadialRingSwatch, + RadialSegmentId, RadialSlice, RadialSliceKind, RegionInputSource, RegionPurposeTag, + RegionSelectUiState, RegionSelection, SIZE_RING_ARC_SPAN, SIZE_RING_ARC_START, + SUBMENU_AIM_GRACE, SUBMENU_HOVER_DELAY, ScreenCaptureSource, SelectionAxis, SelectionHandle, + SelectionPolicy, SelectionPropertyEntry, SelectionPropertyKind, ShellMode, SubmenuSide, + TextInputMode, Toast, ToastPriority, ToastPushOutcome, TourStep, UI_TOAST_DURATION_MS, + UiToastKind, UiVisibility, ZoomAction, color_picker_rgb_to_hsv, compass_slice, + font_picker_layout, font_picker_rows, size_ring_angle_for_value, size_ring_value_for_angle, + slice_parent, sub_ring_child_count, sub_ring_children, }; #[allow(unused_imports)] pub(crate) use core::{ diff --git a/src/input/state/mouse/release/panels.rs b/src/input/state/mouse/release/panels.rs index cd8bd7592..9a49b7ea3 100644 --- a/src/input/state/mouse/release/panels.rs +++ b/src/input/state/mouse/release/panels.rs @@ -113,7 +113,7 @@ pub(super) fn handle_board_picker_release( if !state.is_board_picker_open() { return false; } - if state.board_appearance_click(x, y) { + if state.board_appearance_click_with_measurer(resources.measurer, x, y) { return true; } if state.board_picker_is_page_dragging() { diff --git a/src/input/state/tests/tool_controls.rs b/src/input/state/tests/tool_controls.rs index 3efe34f53..77c82d2e6 100644 --- a/src/input/state/tests/tool_controls.rs +++ b/src/input/state/tests/tool_controls.rs @@ -586,6 +586,38 @@ fn sync_modifiers_resyncs_current_settings_to_compositor_tool() { assert_eq!(state.style.current_thickness, pen_thickness); } +#[test] +fn color_picker_ok_with_the_opening_color_typed_back_undoes_the_preview() { + let mut state = create_test_input_state(); + let white = Color { + r: 1.0, + g: 1.0, + b: 1.0, + a: 1.0, + }; + assert!(state.set_color(white)); + state.clear_session_dirty(); + + state.open_color_picker_popup(); + state.color_picker_popup_set_from_gradient(0.6, 0.1); + assert_ne!( + state.color_for_tool(Tool::Pen), + white, + "the preview moved the pen" + ); + + // The opening color spelled with three digits is parsed only on OK, so + // OK must sync the pen back even though nothing changed on paper. + state.color_picker_popup_set_hex_editing(true); + for ch in "#FFF".chars() { + state.color_picker_popup_hex_append(ch); + } + state.apply_color_picker_popup(); + + assert_eq!(state.color_for_tool(Tool::Pen), white); + assert!(!state.is_session_dirty(), "no change, nothing to persist"); +} + #[test] fn canceling_color_picker_restores_color_without_dirtying_session_or_preset() { let mut state = create_test_input_state(); diff --git a/src/ui/color_picker_popup.rs b/src/ui/color_picker_popup.rs index 08c002e42..07ff98626 100644 --- a/src/ui/color_picker_popup.rs +++ b/src/ui/color_picker_popup.rs @@ -95,7 +95,7 @@ pub(crate) fn color_picker_popup_visual_geometry_with_engine( let layout = ColorPickerPopupLayout::compute( screen_width, screen_height, - input_state.color_picker_popup_shows_default_button(), + input_state.color_picker_popup_layout_options()?, ); let mut bounds = ( layout.origin_x, @@ -257,17 +257,20 @@ pub(crate) fn render_color_picker_popup_with_engine( // Alpha bar: the current colour ramped from transparent to opaque over a // checkerboard, so the swatch under the pointer previews the result. - let alpha = input_state.color_picker_popup_alpha().unwrap_or(1.0); - draw_alpha_bar( - ctx, - layout.alpha_x, - layout.alpha_y, - layout.alpha_w, - layout.alpha_h, - current_color, - ); - let alpha_marker_x = layout.alpha_x + alpha * layout.alpha_w; - draw_bar_marker(ctx, alpha_marker_x, layout.alpha_y, layout.alpha_h); + // Hidden entirely for a target without alpha, such as paper. + if layout.alpha_h > 0.0 { + let alpha = input_state.color_picker_popup_alpha().unwrap_or(1.0); + draw_alpha_bar( + ctx, + layout.alpha_x, + layout.alpha_y, + layout.alpha_w, + layout.alpha_h, + current_color, + ); + let alpha_marker_x = layout.alpha_x + alpha * layout.alpha_w; + draw_bar_marker(ctx, alpha_marker_x, layout.alpha_y, layout.alpha_h); + } // Recent colors, most-recent-first. Empty until something has been // applied, so a fresh session shows no strip rather than dead slots. @@ -326,15 +329,17 @@ pub(crate) fn render_color_picker_popup_with_engine( crate::toolbar_icons::draw_icon_paste, 16.0, ); - draw_action_button( - ctx, - layout.eyedropper_btn_x, - layout.eyedropper_btn_y, - size, - eyedropper_hover, - crate::toolbar_icons::draw_icon_eyedropper, - 18.0, - ); + if layout.eyedropper_enabled { + draw_action_button( + ctx, + layout.eyedropper_btn_x, + layout.eyedropper_btn_y, + size, + eyedropper_hover, + crate::toolbar_icons::draw_icon_eyedropper, + 18.0, + ); + } // Determine button hover states let ok_hover = hover_pos @@ -489,7 +494,11 @@ mod tests { fn wide_titles_are_trimmed_to_the_panel_not_a_character_budget() { let engine = &UiTextEngine::default(); let ctx = test_context(); - let layout = ColorPickerPopupLayout::compute(1920, 1080, true); + let layout = ColorPickerPopupLayout::compute( + 1920, + 1080, + crate::input::state::ColorPickerPopupLayoutOptions::ALL, + ); let content_width = layout.width - TITLE_INSET * 2.0; // Wide glyphs: few characters, far more pixels than a Latin label of