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`. 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..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). @@ -923,6 +925,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/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 83808c9a2..e121fe12d 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. @@ -1510,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. @@ -1521,6 +1529,39 @@ 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`, 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 +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, @@ -2175,6 +2216,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/docs/codebase-overview.md b/docs/codebase-overview.md index 2a3fc624a..950e7d504 100644 --- a/docs/codebase-overview.md +++ b/docs/codebase-overview.md @@ -654,3 +654,14 @@ 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. The regression +and performance evidence for this work is kept with the internal documentation +rather than in this repository. 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/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/handlers/pointer/axis.rs b/src/backend/wayland/handlers/pointer/axis.rs index 70c5fe052..868f24b33 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,29 @@ 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( + input_state: &mut InputState, + position: (f64, f64), + scroll_direction: i32, +) -> bool { + if !input_state.is_board_picker_open() || board_picker_wheel_is_covered(input_state) { + 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), @@ -338,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; @@ -403,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/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/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/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 1c4a148cf..22f2fd2a3 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; @@ -381,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/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/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.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/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/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 ffb320b92..939f00c51 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"]), @@ -1054,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/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/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/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..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"), @@ -365,6 +366,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 +398,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/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/actions/key_press/panels.rs b/src/input/state/actions/key_press/panels.rs index 47e5a6dd5..4b5133f4a 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 => { @@ -442,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/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..8287f68a9 --- /dev/null +++ b/src/input/state/core/board_picker/appearance.rs @@ -0,0 +1,770 @@ +use super::{color_to_hex, parse_hex_color}; +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; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum AppearanceField { + Color, + Pattern, + 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_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 in +/// sheet units. +#[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), +} + +/// 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 { + id: String, + generation: BoardIdentityGeneration, + original: BoardAppearance, + pub(crate) color: String, + 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, +} + +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.size_is_valid() { + return Some("Spacing must be a whole number from 8 to 200."); + } + None + } + + 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) { + 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, i64::from(self.size_value())), + ) + } + + 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, + spacing_armed: false, + size_dragging: false, + 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 shift = self.modifiers.shift; + 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, + // 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, + 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 => + { + 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::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 => { + if edit.spacing_armed { + edit.spacing.clear(); + edit.spacing_armed = false; + } else { + 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_ascii_digit() => { + if edit.spacing_armed { + edit.spacing.clear(); + edit.spacing_armed = false; + } + if edit.spacing.len() < 3 { + edit.spacing.push(ch); + } + } + _ => {} + }, + _ => {} + } + self.needs_redraw = true; + true + } + + /// 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 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 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 sheet units, shared by painting and clicks. + pub(crate) fn board_appearance_header(&self) -> Option { + let width = self.board_appearance_frame()?.width; + let close = ( + 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, -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; + } + // 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, 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(); + } + 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 { + // 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; + }; + 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 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 + { + 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 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 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.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 => { + 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; + } + // 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; + edit.spacing_armed = true; + } + _ => {} + } + 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..b96791cf7 --- /dev/null +++ b/src/input/state/core/board_picker/appearance/tests.rs @@ -0,0 +1,660 @@ +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); + // Tab selects the size, so typing the original value replaces it. + input.board_appearance_key(Key::Tab); + input.board_appearance_key(Key::Char('4')); + 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, 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] { + // 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 + && 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), + 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 = 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); + 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, height) = input.board_appearance_frame().unwrap().bounds(); + assert!( + 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(); + 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 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); + } +} + +#[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_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"); + + // Releasing outside the sheet only ends the drag. + assert!(input.board_appearance_click(1, 1)); + let draft = input.board_appearance_edit().unwrap(); + 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 { + 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 +} + +/// 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_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 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!( + top >= 0.0 && top + sheet_height <= f64::from(height), + "{width}x{height}: {frame:?}" + ); + } +} + +#[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); + 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(&input, 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(&input, 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))); +} + +#[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") + ); +} + +/// 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/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/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..b074e10a4 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; @@ -202,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/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..9ae1162fb 100644 --- a/src/input/state/core/board_picker/state/edit.rs +++ b/src/input/state/core/board_picker/state/edit.rs @@ -12,12 +12,42 @@ 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; + } 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 +59,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> { @@ -283,6 +320,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, @@ -320,6 +373,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 +421,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 +431,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 +466,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..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); } @@ -131,6 +134,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/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/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/core/menus/commands.rs b/src/input/state/core/menus/commands.rs index 8fb102649..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}; @@ -32,17 +32,21 @@ impl InputState { self.board_picker_page_panel_board_index() == Some(board_index) } - 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 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 select_hovered_context_menu_shape_with(&mut self, measurer: &crate::draw::TextMeasurer) { @@ -152,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); @@ -180,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(); @@ -310,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); @@ -332,6 +307,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..3978346f0 100644 --- a/src/input/state/core/menus/context_menu.rs +++ b/src/input/state/core/menus/context_menu.rs @@ -1,14 +1,44 @@ +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 { 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, + 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, } impl ContextMenuPanel { @@ -28,15 +58,35 @@ 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(crate) fn close(&mut self) -> Option { - let layout = self.layout.take(); + 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) + } + + /// 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; - layout + self.board_target = None; + self.reset_hover_timing(); } pub(crate) fn open( @@ -45,9 +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, @@ -55,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) { @@ -66,6 +124,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 +139,15 @@ impl Default for ContextMenuPanel { Self { state: ContextMenuState::Hidden, page_target: None, + 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, } } } @@ -91,19 +160,21 @@ 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 - .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); - assert!(panel.close().is_none()); + panel.set_board_target("blackboard".to_string()); + 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 e0cec01e8..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,27 +101,71 @@ 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), + 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; entries.push(ContextMenuEntry::new( "Delete Board", self.shortcut_for_action(Action::BoardDelete), - false, !can_delete, Some(MenuCommand::BoardDelete), )); 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::, true, None), + ContextMenuEntry::new( + "Edit Paper…", + Some("Ctrl+C"), + board.spec.background.is_transparent(), + Some(MenuCommand::BoardEditPaperFromContext), + ), + ContextMenuEntry::new( + "Rename Board", + Some("F2"), + false, + Some(MenuCommand::BoardRenameFromContext), + ), + ContextMenuEntry::new( + pin_label, + Some("Ctrl+P"), + 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 dc12de544..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,15 +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(), - }, + } => 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 dab451f31..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( @@ -57,6 +52,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/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 59743eac7..09203a350 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. @@ -23,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 { @@ -66,6 +95,10 @@ pub enum MenuCommand { BoardNew, BoardDuplicate, BoardDelete, + BoardEditPaper, + BoardEditPaperFromContext, + BoardRenameFromContext, + BoardTogglePinFromContext, SwitchToBoard { id: String }, SwitchToWhiteboard, SwitchToBlackboard, @@ -82,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, } @@ -92,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..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, }; @@ -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/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/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/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/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..f439a601e 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(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(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/mod.rs b/src/input/state/mod.rs index 6ddfb4f17..1461aa501 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, }; @@ -34,10 +35,11 @@ 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, ContextMenuState, DesktopEnvironment, - DrawingState, EyedropperCaptureSource, EyedropperUiState, FontPickerFilter, FontPickerLayout, + 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, @@ -46,11 +48,12 @@ pub use core::{ 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, - 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, + 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/press/panels.rs b/src/input/state/mouse/press/panels.rs index 2fd7d6215..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( @@ -136,6 +127,13 @@ impl InputState { if !self.is_board_picker_open() { 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); match button { MouseButton::Left => { @@ -156,8 +154,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/mouse/release/panels.rs b/src/input/state/mouse/release/panels.rs index 761119cf1..9a49b7ea3 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_with_measurer(resources.measurer, x, y) { + return true; + } if state.board_picker_is_page_dragging() { state.board_picker_finish_page_drag_with_measurer(resources.measurer); return true; @@ -251,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/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/input/state/tests/menus/context_menu.rs b/src/input/state/tests/menus/context_menu.rs index c6e05f221..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() @@ -516,44 +551,130 @@ 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 open_pages_menu_command_switches_to_pages_submenu_with_actionable_focus() { +fn boards_menu_edits_active_board_paper_and_skips_the_overlay() { let mut state = create_test_input_state(); - state.open_context_menu((12, 34), Vec::new(), ContextMenuKind::Canvas, None); + state.switch_board(BOARD_ID_WHITEBOARD); - state.execute_menu_command(MenuCommand::OpenPagesMenu); + state.open_context_menu((0, 0), Vec::new(), ContextMenuKind::Boards, None); + assert!(!menu_entry(&state, MenuCommand::BoardEditPaper).disabled); + state.execute_menu_command(MenuCommand::BoardEditPaper); - let focus_index = match &state.context_menu.state { + 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::Pages, - keyboard_focus, + kind: ContextMenuKind::Board, .. - } => 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_eq!(state.context_menu_entries()[0].label, "Blackboard"); } #[test] -fn open_boards_menu_command_switches_to_boards_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); + + assert_submenu_has_actionable_focus(&state, ContextMenuKind::Pages); +} + +#[test] +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/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/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.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.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..ead04caeb --- /dev/null +++ b/src/ui/board_picker/appearance.rs @@ -0,0 +1,486 @@ +use crate::domain::BoardGridKind; +use crate::input::InputState; +use crate::input::state::AppearanceField; +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); +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(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, + weight: cairo::FontWeight::Normal, + size: 12.0, + }; + let _ = ctx.save(); + draw_backdrop(ctx, input, screen_width, screen_height); + // 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 + .board_states() + .iter() + .find(|board| board.spec.id == edit.board_id()) + .map_or("", |board| board.spec.name.as_str()); + let _ = ctx.save(); + 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( + ctx, + UiTextStyle { + weight: cairo::FontWeight::Bold, + ..style + }, + &format!("Paper: {board_name}"), + 4.0, + -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(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( + 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 = (index % 2) as f64 * width / 2.0; + let top = (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); + } + if let Some(row) = input.board_appearance_size_row() { + draw_size_row( + engine, + ctx, + 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(), + }, + style, + ); + } + let _ = ctx.save(); + ctx.rectangle(0.0, 94.0, width, 56.0); + ctx.clip(); + 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(); + if let Some(buttons) = input.board_appearance_buttons() { + draw_button( + engine, + ctx, + buttons.cancel, + "Cancel", + ButtonStyle::Secondary, + style, + ); + 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 + .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, + 0.0, + 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, (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( + 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(); +} + +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/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..5f49faeea 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_fits_each_surface(); 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_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(); + 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 [(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, 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, + &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/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 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/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); 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), ( 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],