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