From c05023352965461ab49159587d8f582b326feb54 Mon Sep 17 00:00:00 2001
From: devmobasa <4170275+devmobasa@users.noreply.github.com>
Date: Sat, 12 Sep 2026 22:18:59 +0200
Subject: [PATCH 1/6] feat(boards): add Cartesian and isometric paper
backgrounds
Integrate session appearance editing, recovery, configurator templates, previews, eraser-safe rendering, and PNG/PDF exports. Avoid redundant paper rendering on cache hits and preserve pending edits when using the visible palette.
---
README.md | 1 +
config.example.toml | 3 +
configurator/README.md | 1 +
configurator/src/app/pages/boards.rs | 5 +
configurator/src/app/pages/boards/grid.rs | 56 +++
configurator/src/app/pages/boards/section.rs | 18 +
configurator/src/app/search/summary.rs | 4 +-
configurator/src/app/search/tests.rs | 9 +-
configurator/src/app/update/boards.rs | 12 +
configurator/src/app/update/mod.rs | 3 +
configurator/src/messages.rs | 1 +
configurator/src/models/config/boards.rs | 3 +
.../src/models/config/boards/mapping.rs | 31 ++
configurator/src/models/config/tests.rs | 25 ++
docs/CONFIG.md | 32 ++
docs/board-paper-consumer-performance.csv | 97 ++++
docs/board-paper-source-performance.csv | 145 ++++++
docs/board-paper-validation.md | 159 +++++++
docs/codebase-overview.md | 10 +
src/backend/wayland/session/tests.rs | 1 +
src/backend/wayland/state/canvas_layer.rs | 29 +-
src/backend/wayland/state/capture.rs | 5 +-
.../wayland/state/clipboard/session_paste.rs | 1 +
.../wayland/state/core/output/tests.rs | 2 +
src/backend/wayland/state/pdf_export.rs | 10 +-
src/backend/wayland/state/pdf_export/tests.rs | 24 +-
.../wayland/state/render/canvas/background.rs | 70 ++-
.../wayland/state/render/canvas/mod.rs | 34 +-
.../state/render/canvas/resource_tests.rs | 116 +++--
.../canvas/resource_tests/grid_performance.rs | 169 +++++++
src/canvas_export/mod.rs | 3 +
src/canvas_export/page.rs | 42 +-
src/canvas_export/tests/board_grid.rs | 216 +++++++++
src/config/mod.rs | 10 +-
src/config/tests/board_grid.rs | 72 +++
src/config/tests/document.rs | 35 ++
src/config/tests/mod.rs | 1 +
src/config/tests/validate.rs | 4 +
src/config/types/board_grid.rs | 71 +++
src/config/types/boards.rs | 11 +
src/config/types/mod.rs | 2 +
src/config/validate/boards.rs | 18 +
src/domain/board.rs | 5 +-
src/domain/board_grid.rs | 72 +++
src/domain/mod.rs | 5 +
src/domain/tests.rs | 3 +-
src/draw/mod.rs | 18 +-
src/draw/render/board_grid.rs | 211 +++++++++
src/draw/render/board_grid/performance.rs | 152 +++++++
src/draw/render/board_grid/tests.rs | 182 ++++++++
src/draw/render/mod.rs | 2 +
src/input/boards.rs | 8 +
src/input/boards/appearance.rs | 59 +++
src/input/boards/mapping.rs | 8 +
src/input/boards/naming.rs | 4 +
src/input/state/actions/key_press/panels.rs | 4 +
src/input/state/core/board.rs | 1 +
src/input/state/core/board/appearance.rs | 43 ++
src/input/state/core/board/lifecycle.rs | 2 +-
src/input/state/core/board/pages.rs | 24 +-
src/input/state/core/board/switch.rs | 30 ++
.../state/core/board_picker/appearance.rs | 419 ++++++++++++++++++
.../core/board_picker/appearance/tests.rs | 306 +++++++++++++
.../state/core/board_picker/layout/cursor.rs | 3 +
src/input/state/core/board_picker/mod.rs | 1 +
src/input/state/core/board_picker/panel.rs | 4 +
.../state/core/board_picker/state/edit.rs | 63 ++-
.../core/board_picker/state/lifecycle.rs | 3 +
.../state/core/session_preflight_exact.rs | 7 +
src/input/state/mod.rs | 1 +
src/input/state/mouse/press/panels.rs | 3 +
src/input/state/mouse/release/panels.rs | 3 +
src/input/state/tests/board_picker.rs | 6 +-
src/session/catalog/tests.rs | 1 +
src/session/mod.rs | 4 +-
src/session/snapshot/appearance.rs | 88 ++++
src/session/snapshot/appearance/tests.rs | 112 +++++
src/session/snapshot/apply.rs | 13 +
src/session/snapshot/capture.rs | 11 +-
src/session/snapshot/load/payload.rs | 9 +
src/session/snapshot/mod.rs | 2 +
src/session/snapshot/save.rs | 6 +-
src/session/snapshot/save/payload.rs | 16 +-
src/session/snapshot/tests.rs | 7 +
src/session/snapshot/types.rs | 21 +-
src/session/storage/tests.rs | 2 +
src/session/tests/board_appearance.rs | 155 +++++++
src/session/tests/limits.rs | 1 +
src/session/tests/mod.rs | 1 +
src/session/tests/roundtrip.rs | 2 +
src/session/tests/snapshot.rs | 3 +
src/ui/board_picker.rs | 2 +
src/ui/board_picker/appearance.rs | 299 +++++++++++++
src/ui/board_picker/page_panel.rs | 2 +
.../page_panel/thumbnail/cache.rs | 3 +
.../thumbnail/cache/spotlight_tests.rs | 1 +
.../page_panel/thumbnail/cache/tests.rs | 1 +
.../page_panel/thumbnail/cards.rs | 4 +
.../page_panel/thumbnail/content.rs | 24 +-
.../page_panel/thumbnail/types.rs | 3 +
src/ui/board_picker/palette.rs | 23 +-
src/ui/board_picker/tests.rs | 44 ++
tests/cli.rs | 2 +
103 files changed, 3913 insertions(+), 162 deletions(-)
create mode 100644 configurator/src/app/pages/boards/grid.rs
create mode 100644 docs/board-paper-consumer-performance.csv
create mode 100644 docs/board-paper-source-performance.csv
create mode 100644 docs/board-paper-validation.md
create mode 100644 src/backend/wayland/state/render/canvas/resource_tests/grid_performance.rs
create mode 100644 src/canvas_export/tests/board_grid.rs
create mode 100644 src/config/tests/board_grid.rs
create mode 100644 src/config/types/board_grid.rs
create mode 100644 src/domain/board_grid.rs
create mode 100644 src/draw/render/board_grid.rs
create mode 100644 src/draw/render/board_grid/performance.rs
create mode 100644 src/draw/render/board_grid/tests.rs
create mode 100644 src/input/boards/appearance.rs
create mode 100644 src/input/state/core/board/appearance.rs
create mode 100644 src/input/state/core/board_picker/appearance.rs
create mode 100644 src/input/state/core/board_picker/appearance/tests.rs
create mode 100644 src/session/snapshot/appearance.rs
create mode 100644 src/session/snapshot/appearance/tests.rs
create mode 100644 src/session/tests/board_appearance.rs
create mode 100644 src/ui/board_picker/appearance.rs
diff --git a/README.md b/README.md
index a53e997ba..9063e1d8b 100644
--- a/README.md
+++ b/README.md
@@ -133,6 +133,7 @@ The v0.9.23+ prebuilt `wayscriber` packages require glibc 2.39 and GTK 4.12. See
### Boards
- Named boards with transparent overlay or custom backgrounds
+- Cartesian graph paper, isometric lines, and isometric dots with adjustable spacing
- Isolated pages per board with auto-contrast pens
- Pan solid boards with Space + left-drag; reset from the context menu
- Jump slots: Ctrl+Shift+1..9
diff --git a/config.example.toml b/config.example.toml
index ac99c0b8b..f9a08b9be 100644
--- a/config.example.toml
+++ b/config.example.toml
@@ -923,6 +923,9 @@ persist = true
[[boards.items]]
id = "whiteboard"
name = "Whiteboard"
+# Paper: none, cartesian, isometric, or isometric-dots. Spacing: 8–200 logical px.
+# Spacing is the square/triangle side length; 20 and 40 are convenient presets.
+grid = { kind = "none", spacing = 40 }
background = { rgb = [0.992, 0.992, 0.992] }
# Tuned black #241F31; the built-in default bit-matches the "black" quick color
default_pen_color = { rgb = [0.141, 0.122, 0.192] }
diff --git a/configurator/README.md b/configurator/README.md
index bc21255ac..d9fcfdac6 100644
--- a/configurator/README.md
+++ b/configurator/README.md
@@ -77,6 +77,7 @@ if its UI task is no longer observed.
- **Drawing, Arrow, Performance, UI, Board, Capture** – numeric fields with inline validation, toggles, and color editors (RGBA/RGB components).
- **Default color** – toggle between named colors and custom RGB triples.
- **Keybindings** – a bulk shortcut manager over the same per-action chips, recorder, and conflict flow. Filter by All / Changed / Conflicts / Unbound / Device / Sequences, sort by category, name, or changed status, and reset visible or all keybindings with confirmation (draft-only until Save). Review Conflicts walks each collision without picking a winner. `--open keybindings/?search=...` still opens that category and now selects the matching action. Press-to-bind recording covers keys, auxiliary mouse buttons, and stylus barrel buttons, plus **Record Sequence** for two- or three-chord keyboard sequences (`Ctrl+K then Ctrl+C`). Per-row reset and a raw comma-separated text editor remain available (`F5, Ctrl+K > Ctrl+C`). Super/Meta chords record when the desktop delivers them. Legacy `[tablet.stylus_button]` assignments can be moved into the keybinding list with an explicit confirmation. Source badges mark Default, Authored, Legacy Tablet, and Unavailable shortcuts.
+- **Boards** – edit new-session templates with Cartesian, isometric line, or isometric dot paper and 8–200 logical-pixel spacing. Transparent boards disable paper controls.
- **Session** – persistence settings plus named-session catalog management. Rename display labels, reveal files, and forget metadata without touching files. Clear Tool State preserves boards/history while removing persisted tool defaults. Duplicate, Move, Clear Tool State, and Clear are disabled while an overlay, manually started daemon, or background service is active.
- Live dirty-state indicator plus status banner for success/error details. Editing is temporarily disabled during loading and saving; failed operations restore editing and retain the draft.
- Non-fatal warnings list unrecognized config paths. Those values are preserved for forward compatibility instead of being deleted.
diff --git a/configurator/src/app/pages/boards.rs b/configurator/src/app/pages/boards.rs
index 0fc4a4efb..4d759e4da 100644
--- a/configurator/src/app/pages/boards.rs
+++ b/configurator/src/app/pages/boards.rs
@@ -23,6 +23,7 @@
//! components to whatever the 8-bit hex said.
mod color;
+mod grid;
mod header;
mod rows;
mod section;
@@ -207,6 +208,7 @@ fn add_board_list(page: &mut PageBuilder) {
let values = BoardValues {
id: &item.id,
name: &item.name,
+ grid_spacing: &item.grid_spacing,
background: ColorValues {
hex: picker_hex(app, ColorPickerId::BoardBackground(index)),
color: &item.background_color,
@@ -234,6 +236,7 @@ struct SectionLayout {
visible: bool,
expanded: bool,
background_kind: BoardBackgroundOption,
+ grid_kind: wayscriber::domain::BoardGridKind,
pen_enabled: bool,
auto_adjust: bool,
persist: bool,
@@ -256,6 +259,7 @@ fn section_layouts(app: &ConfiguratorApp, summary: &AppSearchSummary) -> Vec Vec {
id: &'a str,
name: &'a str,
+ grid_spacing: &'a str,
background: ColorValues<'a>,
pen: ColorValues<'a>,
}
diff --git a/configurator/src/app/pages/boards/grid.rs b/configurator/src/app/pages/boards/grid.rs
new file mode 100644
index 000000000..877dd824e
--- /dev/null
+++ b/configurator/src/app/pages/boards/grid.rs
@@ -0,0 +1,56 @@
+use super::{
+ SectionLayout,
+ rows::{TextRow, build_text_row},
+};
+use crate::app::state::ConfiguratorApp;
+use crate::messages::Message;
+use crate::models::{BoardBackgroundOption, BoardItemTextField};
+use adw::prelude::*;
+use relm4::{ComponentSender, adw, gtk};
+use wayscriber::domain::BoardGridKind;
+
+pub(super) fn build(
+ index: usize,
+ layout: SectionLayout,
+ sender: &ComponentSender,
+) -> (adw::ComboRow, TextRow) {
+ let labels: Vec<_> = BoardGridKind::ALL.iter().map(|kind| kind.label()).collect();
+ let grid = adw::ComboRow::builder()
+ .title("Paper pattern")
+ .model(>k::StringList::new(&labels))
+ .visible(layout.expanded)
+ .build();
+ grid.set_selected(
+ BoardGridKind::ALL
+ .iter()
+ .position(|kind| *kind == layout.grid_kind)
+ .unwrap_or(0) as u32,
+ );
+ let solid = layout.background_kind != BoardBackgroundOption::Transparent;
+ grid.set_sensitive(solid);
+ if !solid {
+ grid.set_subtitle("Paper patterns require a solid board background");
+ }
+ let grid_sender = sender.clone();
+ grid.connect_selected_notify(move |row| {
+ if let Some(kind) = BoardGridKind::ALL.get(row.selected() as usize) {
+ grid_sender.input(Message::BoardsGridKindChanged(index, *kind));
+ }
+ });
+ let spacing_title = match layout.grid_kind {
+ BoardGridKind::None => "Grid spacing (8–200 logical pixels)",
+ BoardGridKind::Cartesian => "Square side (8–200 logical pixels)",
+ BoardGridKind::Isometric | BoardGridKind::IsometricDots => {
+ "Equilateral triangle side (8–200 logical pixels)"
+ }
+ };
+ let spacing = build_text_row(
+ spacing_title,
+ index,
+ BoardItemTextField::GridSpacing,
+ sender,
+ );
+ spacing.row.set_visible(layout.expanded);
+ spacing.row.set_sensitive(solid);
+ (grid, spacing)
+}
diff --git a/configurator/src/app/pages/boards/section.rs b/configurator/src/app/pages/boards/section.rs
index 1fb8ad239..fb6a932d6 100644
--- a/configurator/src/app/pages/boards/section.rs
+++ b/configurator/src/app/pages/boards/section.rs
@@ -94,6 +94,10 @@ fn build_section(
.set_visible(layout.expanded && layout.background_kind == BoardBackgroundOption::Color);
section.append(&background.row);
+ let (grid_kind, grid_spacing) = super::grid::build(index, layout, sender);
+ section.append(&grid_kind);
+ section.append(&grid_spacing.row);
+
let pen_enabled = adw::SwitchRow::builder()
.title("Override default pen color")
.active(layout.pen_enabled)
@@ -148,6 +152,20 @@ fn build_section(
set_text_blocked(&id.row, &id.handler, values.id);
set_text_blocked(&name.row, &name.handler, values.name);
background.refresh(&values.background);
+ set_text_blocked(
+ &grid_spacing.row,
+ &grid_spacing.handler,
+ values.grid_spacing,
+ );
+ let valid = values
+ .grid_spacing
+ .parse::()
+ .is_ok_and(|s| (8..=200).contains(&s));
+ if valid {
+ grid_spacing.row.remove_css_class("error");
+ } else {
+ grid_spacing.row.add_css_class("error");
+ }
pen.refresh(&values.pen);
});
diff --git a/configurator/src/app/search/summary.rs b/configurator/src/app/search/summary.rs
index 5692d92ca..6769861a3 100644
--- a/configurator/src/app/search/summary.rs
+++ b/configurator/src/app/search/summary.rs
@@ -230,11 +230,13 @@ fn board_matches(app: &ConfiguratorApp, query: &SearchQuery, summary: &mut TabSe
);
for (index, item) in app.draft.boards.items.iter().enumerate() {
let text = format!(
- "board {} board id display name background background color override default pen color pen color auto-adjust pen auto adjust pen persist pinned duplicate remove up down collapse expand {} {} {} background pen persist pinned auto adjust",
+ "board {} board id display name background background color override default pen color pen color auto-adjust pen auto adjust pen persist pinned duplicate remove up down collapse expand {} {} {} background pen persist pinned auto adjust graph paper pattern grid spacing cartesian isometric dots spacing logical pixels {} {}",
index + 1,
item.id,
item.name,
item.background_kind.label(),
+ item.grid_kind.label(),
+ item.grid_spacing,
);
if query.matches_text(&text) {
summary.add_board_index(index);
diff --git a/configurator/src/app/search/tests.rs b/configurator/src/app/search/tests.rs
index 649b8e27a..8bd7887f2 100644
--- a/configurator/src/app/search/tests.rs
+++ b/configurator/src/app/search/tests.rs
@@ -509,7 +509,14 @@ fn startup_interaction_cancels_the_deferred_search_focus() {
#[test]
fn board_item_static_labels_match_board_rows() {
- for query in ["display name", "board id", "override default pen color"] {
+ for query in [
+ "display name",
+ "board id",
+ "override default pen color",
+ "graph paper",
+ "isometric",
+ "grid spacing",
+ ] {
let (mut app, _effects) = ConfiguratorApp::new_app();
app.search_query = SearchQuery::new(query);
diff --git a/configurator/src/app/update/boards.rs b/configurator/src/app/update/boards.rs
index ef3f4dab5..016cf8e94 100644
--- a/configurator/src/app/update/boards.rs
+++ b/configurator/src/app/update/boards.rs
@@ -6,6 +6,17 @@ use super::super::effects::Effect;
use super::super::state::{ConfiguratorApp, StatusMessage};
impl ConfiguratorApp {
+ pub(super) fn handle_boards_grid_kind_changed(
+ &mut self,
+ index: usize,
+ value: wayscriber::domain::BoardGridKind,
+ ) -> Vec {
+ if let Some(item) = self.draft.boards.items.get_mut(index) {
+ item.grid_kind = value;
+ }
+ self.refresh_dirty_flag();
+ Vec::new()
+ }
pub(super) fn handle_boards_add_item(&mut self) -> Vec {
self.status = StatusMessage::idle();
let new_item = self.draft.boards.new_item();
@@ -99,6 +110,7 @@ impl ConfiguratorApp {
let old_effective_id = self.draft.boards.effective_id_for_index(index);
if let Some(item) = self.draft.boards.items.get_mut(index) {
match field {
+ BoardItemTextField::GridSpacing => item.grid_spacing = value,
BoardItemTextField::Id => {
let trimmed = value.trim();
let new_effective_id = if trimmed.is_empty() {
diff --git a/configurator/src/app/update/mod.rs b/configurator/src/app/update/mod.rs
index d872382d9..28e1c188d 100644
--- a/configurator/src/app/update/mod.rs
+++ b/configurator/src/app/update/mod.rs
@@ -240,6 +240,9 @@ impl ConfiguratorApp {
Message::BoardsItemTextChanged(index, field, value) => {
self.handle_boards_item_text_changed(index, field, value)
}
+ Message::BoardsGridKindChanged(index, value) => {
+ self.handle_boards_grid_kind_changed(index, value)
+ }
Message::BoardsBackgroundKindChanged(index, value) => {
self.handle_boards_background_kind_changed(index, value)
}
diff --git a/configurator/src/messages.rs b/configurator/src/messages.rs
index e2e99f3e1..fc57ea247 100644
--- a/configurator/src/messages.rs
+++ b/configurator/src/messages.rs
@@ -147,6 +147,7 @@ pub enum Message {
BoardsCollapseToggled(usize),
BoardsDefaultChanged(String),
BoardsItemTextChanged(usize, BoardItemTextField, String),
+ BoardsGridKindChanged(usize, wayscriber::domain::BoardGridKind),
BoardsBackgroundKindChanged(usize, BoardBackgroundOption),
BoardsBackgroundColorChanged(usize, usize, String),
BoardsDefaultPenEnabledChanged(usize, bool),
diff --git a/configurator/src/models/config/boards.rs b/configurator/src/models/config/boards.rs
index 05692e333..abc7217d7 100644
--- a/configurator/src/models/config/boards.rs
+++ b/configurator/src/models/config/boards.rs
@@ -30,6 +30,7 @@ impl std::fmt::Display for BoardBackgroundOption {
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BoardItemTextField {
+ GridSpacing,
Id,
Name,
}
@@ -53,6 +54,8 @@ pub struct BoardItemDraft {
pub name: String,
pub background_kind: BoardBackgroundOption,
pub background_color: ColorTripletInput,
+ pub grid_kind: wayscriber::domain::BoardGridKind,
+ pub grid_spacing: String,
pub default_pen_color: OptionalTripletInput,
pub auto_adjust_pen: bool,
pub persist: bool,
diff --git a/configurator/src/models/config/boards/mapping.rs b/configurator/src/models/config/boards/mapping.rs
index 746a7ce8f..eb36f8ec2 100644
--- a/configurator/src/models/config/boards/mapping.rs
+++ b/configurator/src/models/config/boards/mapping.rs
@@ -54,6 +54,8 @@ impl BoardItemDraft {
name: item.name.clone(),
background_kind,
background_color,
+ grid_kind: item.grid.kind.into(),
+ grid_spacing: item.grid.spacing.to_string(),
default_pen_color: OptionalTripletInput::from_option(
item.default_pen_color.as_ref(),
fallback_pen,
@@ -97,10 +99,37 @@ impl BoardItemDraft {
.default_pen_color
.to_option(&format!("boards.items[{index}].default_pen_color"), errors);
+ let spacing = match self.grid_spacing.trim().parse::() {
+ Ok(value)
+ if (i64::from(wayscriber::domain::BOARD_GRID_MIN_SPACING)
+ ..=i64::from(wayscriber::domain::BOARD_GRID_MAX_SPACING))
+ .contains(&value) =>
+ {
+ value
+ }
+ _ => {
+ errors.push(FormError::new(
+ format!("boards.items[{index}].grid.spacing"),
+ "Enter a whole number from 8 to 200.",
+ ));
+ return None;
+ }
+ };
+ let grid = wayscriber::config::BoardGridConfig {
+ kind: if background.is_transparent() {
+ wayscriber::domain::BoardGridKind::None
+ } else {
+ self.grid_kind
+ }
+ .into(),
+ spacing,
+ };
+
Some(BoardItemConfig {
id,
name,
background,
+ grid,
default_pen_color,
auto_adjust_pen: self.auto_adjust_pen,
persist: self.persist,
@@ -196,6 +225,8 @@ impl BoardsDraft {
name,
background_kind: BoardBackgroundOption::Color,
background_color: ColorTripletInput::from([0.992, 0.992, 0.992]),
+ grid_kind: wayscriber::domain::BoardGridKind::None,
+ grid_spacing: wayscriber::domain::BOARD_GRID_DEFAULT_SPACING.to_string(),
default_pen_color: OptionalTripletInput::from_option(
Some(&BoardColorConfig::Rgb([0.0, 0.0, 0.0])),
[0.0, 0.0, 0.0],
diff --git a/configurator/src/models/config/tests.rs b/configurator/src/models/config/tests.rs
index 02e316bd6..dc2108ac6 100644
--- a/configurator/src/models/config/tests.rs
+++ b/configurator/src/models/config/tests.rs
@@ -1,4 +1,29 @@
use super::super::color::ColorInput;
+
+#[test]
+fn board_grid_draft_preserves_patterns_and_rejects_invalid_spacing() {
+ let mut config = Config::default();
+ let mut boards = wayscriber::config::BoardsConfig::default();
+ boards.items[1].grid = wayscriber::config::BoardGridConfig {
+ kind: wayscriber::config::BoardGridKindConfig::IsometricDots,
+ spacing: 20,
+ };
+ config.boards = Some(boards);
+ let mut draft = ConfigDraft::from_config(&config);
+ assert_eq!(
+ draft.boards.items[1].grid_kind,
+ wayscriber::domain::BoardGridKind::IsometricDots
+ );
+ assert_eq!(
+ draft.to_config(&config).unwrap().boards.unwrap().items[1].grid,
+ config.boards.as_ref().unwrap().items[1].grid
+ );
+ for invalid in ["", "-1", "7", "201", "20.5", "paper"] {
+ draft.boards.items[1].grid_spacing = invalid.to_string();
+ assert!(draft.to_config(&config).is_err(), "{invalid}");
+ assert_eq!(draft.boards.items[1].grid_spacing, invalid);
+ }
+}
use super::super::fields::{
ArrowStyleOption, DragMouseButton, DragToolField, DragToolOption, FontWeightOption,
InputHudModeOption, InputHudPositionOption, OverrideOption, PdfFitModeOption,
diff --git a/docs/CONFIG.md b/docs/CONFIG.md
index 83808c9a2..836699498 100644
--- a/docs/CONFIG.md
+++ b/docs/CONFIG.md
@@ -1521,6 +1521,38 @@ wayscriber --active --mode blueprint
wayscriber --daemon --mode transparent
```
+#### Board paper patterns
+
+Each solid board can use `grid = { kind = "cartesian", spacing = 40 }` in its
+`[[boards.items]]` entry. Kinds are `none` (the default), `cartesian`, `isometric`
+(lines), and `isometric-dots`. Spacing is an integer from 8 through 200 logical
+pixels: a square edge for Cartesian paper or an equilateral triangle edge for
+isometric paper. Isometric lines run vertically and at ±30°. Out-of-range config
+values are clamped with a warning; invalid text or unknown kinds fail validation.
+Transparent boards disable the pattern while retaining its spacing.
+
+Open the board picker (`Ctrl+Shift+B`), select a solid board, and click its color
+swatch or press `Ctrl+C`. Choose a pattern and spacing, or use the 20/40 presets.
+The preview is local to the editor; **Apply** changes the board and **Cancel** or
+Escape discards the draft. Tab cycles color, pattern, and spacing; arrow keys
+change the focused pattern and Enter applies. Switching to another board or
+renaming cancels the draft. The configurator's Boards page edits the templates
+used for new sessions; it does not replace an existing session's saved paper.
+
+Paper is anchored to board coordinates, follows pan and zoom, and appears behind
+all annotations. It cannot be selected or erased and does not participate in
+undo/redo. Clear Canvas keeps the paper. Thumbnails and canvas PNG/PDF exports use
+the same pattern; PDF margins retain the plain board color. Native region capture
+continues to use the frozen desktop. Lines and dots automatically contrast with
+the board, and fade at very small preview scales to avoid dense visual noise.
+
+Session format 7 saves appearance with drawings and preserves explicitly edited
+empty boards. Unchanged empty templates remain contentless for backup recovery.
+Older sessions use configured/template appearance; opening another named session
+resets missing appearance to those seeds. Clear Saved Data removes the saved
+appearance override as well as drawing data. A grid-only edit preserves the pen
+color; changing the background retains the board's existing auto-contrast policy.
+
### `[board]` - Legacy Board Modes
This section is still recognized for backward compatibility. If `[boards]` is missing,
diff --git a/docs/board-paper-consumer-performance.csv b/docs/board-paper-consumer-performance.csv
new file mode 100644
index 000000000..2e1bdd695
--- /dev/null
+++ b/docs/board-paper-consumer-performance.csv
@@ -0,0 +1,97 @@
+kind,spacing,width,height,origin,erasers,cold_pan_median_us,cold_pan_p95_us,warm_pan_median_us,warm_pan_p95_us,png_median_us,png_p95_us,png_bytes,surface_bytes_proxy
+None,8,1920,1080,0,0,2964,3295,169,865,31442,31860,33424,32075776
+None,8,1920,1080,0,20,3408,3555,156,280,48536,48890,72712,32075776
+None,8,1920,1080,0,200,6632,6768,241,352,56368,57102,189182,32075776
+None,40,1920,1080,0,0,2609,2789,249,356,31385,31622,33424,32075776
+None,40,1920,1080,0,20,3053,3156,279,399,47974,48035,72712,32075776
+None,40,1920,1080,0,200,6481,6835,177,304,56138,56266,189182,32075776
+Cartesian,8,1920,1080,0,0,3526,3597,170,198,37480,37686,80088,32075776
+Cartesian,8,1920,1080,0,20,4363,4460,175,444,57646,58335,134441,32075776
+Cartesian,8,1920,1080,0,200,11369,11996,336,405,80644,80964,376164,32075776
+Cartesian,40,1920,1080,0,0,3447,3643,170,332,34651,34730,53224,32075776
+Cartesian,40,1920,1080,0,20,4227,4348,167,303,53276,56684,102884,32075776
+Cartesian,40,1920,1080,0,200,10951,11751,382,494,69904,70669,265083,32075776
+Isometric,8,1920,1080,0,0,7144,16695,703,841,134973,172560,1017818,32075776
+Isometric,8,1920,1080,0,20,8211,15019,340,759,200823,222206,1166903,32075776
+Isometric,8,1920,1080,0,200,24842,42471,673,756,248711,305444,1268264,32075776
+Isometric,40,1920,1080,0,0,7181,9508,571,597,74132,88345,672389,32075776
+Isometric,40,1920,1080,0,20,8527,8771,563,569,104521,105079,804536,32075776
+Isometric,40,1920,1080,0,200,19392,19791,351,467,123284,123768,932535,32075776
+IsometricDots,8,1920,1080,0,0,6195,6222,202,303,57517,57873,419974,32075776
+IsometricDots,8,1920,1080,0,20,8088,9074,371,832,88225,93938,557246,32075776
+IsometricDots,8,1920,1080,0,200,22666,25426,383,580,114643,119364,713126,32075776
+IsometricDots,40,1920,1080,0,0,6181,6336,353,436,36239,38479,85049,32075776
+IsometricDots,40,1920,1080,0,20,7502,7723,371,412,53787,64370,128204,32075776
+IsometricDots,40,1920,1080,0,200,31731,39567,787,1726,101258,106475,251278,32075776
+None,8,1920,1080,-1000021,0,2576,2664,248,263,31356,31542,33424,32075776
+None,8,1920,1080,-1000021,20,3120,3361,297,464,48592,49261,72712,32075776
+None,8,1920,1080,-1000021,200,6553,6720,314,413,57100,57878,189182,32075776
+None,40,1920,1080,-1000021,0,2648,3240,278,339,32083,32409,33424,32075776
+None,40,1920,1080,-1000021,20,3044,3054,244,268,48088,48185,72712,32075776
+None,40,1920,1080,-1000021,200,6495,6554,200,239,56255,56360,189182,32075776
+Cartesian,8,1920,1080,-1000021,0,3540,3729,185,191,37849,37998,83130,32075776
+Cartesian,8,1920,1080,-1000021,20,4481,4567,184,272,58529,58646,139657,32075776
+Cartesian,8,1920,1080,-1000021,200,11479,11559,201,211,81011,81423,382147,32075776
+Cartesian,40,1920,1080,-1000021,0,3617,3693,170,244,35263,35325,55388,32075776
+Cartesian,40,1920,1080,-1000021,20,4366,4432,188,313,54012,54224,105169,32075776
+Cartesian,40,1920,1080,-1000021,200,10689,11496,244,761,70457,73741,267355,32075776
+Isometric,8,1920,1080,-1000021,0,6328,6789,266,532,124400,125941,1006387,32075776
+Isometric,8,1920,1080,-1000021,20,7819,7833,186,239,182261,182654,1156608,32075776
+Isometric,8,1920,1080,-1000021,200,23758,24870,540,616,207378,209135,1252617,32075776
+Isometric,40,1920,1080,-1000021,0,6171,6292,207,473,72258,72362,674375,32075776
+Isometric,40,1920,1080,-1000021,20,7703,8597,401,540,104347,105351,807461,32075776
+Isometric,40,1920,1080,-1000021,200,20760,21508,507,539,127350,128127,935494,32075776
+IsometricDots,8,1920,1080,-1000021,0,6430,6530,455,466,59398,59747,424019,32075776
+IsometricDots,8,1920,1080,-1000021,20,8318,8847,466,527,90049,93251,558198,32075776
+IsometricDots,8,1920,1080,-1000021,200,24486,25307,571,584,119755,121443,721126,32075776
+IsometricDots,40,1920,1080,-1000021,0,6838,7115,568,575,36182,38004,84807,32075776
+IsometricDots,40,1920,1080,-1000021,20,8231,8424,520,566,55674,55729,128026,32075776
+IsometricDots,40,1920,1080,-1000021,200,21182,21842,526,564,72957,74122,250840,32075776
+None,8,3840,2160,0,0,6689,6830,2217,3196,129311,131052,82922,112869376
+None,8,3840,2160,0,20,7124,7454,2170,2293,185567,186637,140454,112869376
+None,8,3840,2160,0,200,11527,11617,2099,2119,196635,196751,299751,112869376
+None,40,3840,2160,0,0,6489,6579,2185,2242,127198,127256,82922,112869376
+None,40,3840,2160,0,20,6982,7576,2134,2216,183092,183391,140454,112869376
+None,40,3840,2160,0,200,10832,11267,2096,2190,195083,195972,299751,112869376
+Cartesian,8,3840,2160,0,0,8958,9098,1941,2026,145771,146452,214887,112869376
+Cartesian,8,3840,2160,0,20,9934,10202,1910,2015,207790,208632,310583,112869376
+Cartesian,8,3840,2160,0,200,17583,19033,1903,2021,238411,242324,617816,112869376
+Cartesian,40,3840,2160,0,0,8905,10164,2033,2618,138411,141335,145447,112869376
+Cartesian,40,3840,2160,0,20,9819,10340,1989,2035,201483,201693,290886,112869376
+Cartesian,40,3840,2160,0,200,16708,18042,2025,2068,223289,223984,503374,112869376
+Isometric,8,3840,2160,0,0,16684,16737,1923,1943,454268,455633,3170531,112869376
+Isometric,8,3840,2160,0,20,18898,19516,2167,2446,614747,624484,3525018,112869376
+Isometric,8,3840,2160,0,200,36990,39033,2046,3342,652967,660609,3761578,112869376
+Isometric,40,3840,2160,0,0,16803,17441,2040,2181,276753,277087,2357820,112869376
+Isometric,40,3840,2160,0,20,18396,18833,2064,2327,365316,367817,2674630,112869376
+Isometric,40,3840,2160,0,200,31600,31744,2065,2223,395045,397930,2872891,112869376
+IsometricDots,8,3840,2160,0,0,17341,17489,2029,2230,236783,237067,1536684,112869376
+IsometricDots,8,3840,2160,0,20,19257,20206,2134,2476,319396,320760,1784952,112869376
+IsometricDots,8,3840,2160,0,200,36696,36875,2043,2469,357748,358996,2034353,112869376
+IsometricDots,40,3840,2160,0,0,16937,17541,2068,2256,139286,139630,248864,112869376
+IsometricDots,40,3840,2160,0,20,18598,20155,2083,2591,199615,200309,335132,112869376
+IsometricDots,40,3840,2160,0,200,31477,33203,2066,2700,222001,224319,505456,112869376
+None,8,3840,2160,-1000021,0,6657,8292,2269,2394,128084,133287,82922,112869376
+None,8,3840,2160,-1000021,20,7158,7707,2195,2227,184799,195329,140454,112869376
+None,8,3840,2160,-1000021,200,11066,11552,2064,2588,195439,196309,299751,112869376
+None,40,3840,2160,-1000021,0,6481,6748,2209,2279,126051,126324,82922,112869376
+None,40,3840,2160,-1000021,20,7184,7452,2133,2747,182462,182986,140454,112869376
+None,40,3840,2160,-1000021,200,11670,12305,2236,2348,196137,198833,299751,112869376
+Cartesian,8,3840,2160,-1000021,0,9654,9989,2020,2297,147992,149402,222712,112869376
+Cartesian,8,3840,2160,-1000021,20,10795,10914,2031,2103,211975,213261,317630,112869376
+Cartesian,8,3840,2160,-1000021,200,18160,18188,1927,1958,239227,239664,623763,112869376
+Cartesian,40,3840,2160,-1000021,0,9293,9488,1973,2035,138388,138615,150181,112869376
+Cartesian,40,3840,2160,-1000021,20,10171,10319,1998,2011,201069,201697,298887,112869376
+Cartesian,40,3840,2160,-1000021,200,16940,17112,1936,1940,223481,223934,510941,112869376
+Isometric,8,3840,2160,-1000021,0,16649,16777,1942,1966,453213,454074,3173502,112869376
+Isometric,8,3840,2160,-1000021,20,18457,18870,1992,2165,604209,606566,3526205,112869376
+Isometric,8,3840,2160,-1000021,200,35370,35632,1921,1972,643836,645247,3759572,112869376
+Isometric,40,3840,2160,-1000021,0,16605,16787,1980,2068,276595,277171,2351763,112869376
+Isometric,40,3840,2160,-1000021,20,18173,18373,2011,2054,363726,372134,2675243,112869376
+Isometric,40,3840,2160,-1000021,200,30793,31725,1944,2037,391065,393237,2872401,112869376
+IsometricDots,8,3840,2160,-1000021,0,16880,17105,1940,2196,235193,236404,1532620,112869376
+IsometricDots,8,3840,2160,-1000021,20,18542,18702,2015,2052,316100,317114,1784251,112869376
+IsometricDots,8,3840,2160,-1000021,200,36018,36591,2005,2146,356921,362920,2034242,112869376
+IsometricDots,40,3840,2160,-1000021,0,16983,17460,2090,2121,139698,140506,247770,112869376
+IsometricDots,40,3840,2160,-1000021,20,18091,18267,1916,2201,198149,199032,335533,112869376
+IsometricDots,40,3840,2160,-1000021,200,31535,32155,1979,2067,221090,223278,505782,112869376
diff --git a/docs/board-paper-source-performance.csv b/docs/board-paper-source-performance.csv
new file mode 100644
index 000000000..30e9a5205
--- /dev/null
+++ b/docs/board-paper-source-performance.csv
@@ -0,0 +1,145 @@
+source,kind,spacing,width,height,erasers,pdf,median_us,p95_us,target_or_pdf_bytes
+Solid,Cartesian,8,1920,1080,0,false,1651,2597,8294400
+Tile,Cartesian,8,1920,1080,0,false,2109,2119,8294400
+Solid,Cartesian,8,1920,1080,0,true,112,130,1540
+Tile,Cartesian,8,1920,1080,0,true,136,143,2447
+Solid,Cartesian,8,1920,1080,20,false,1997,2036,8294400
+Tile,Cartesian,8,1920,1080,20,false,2875,2880,8294400
+Solid,Cartesian,8,1920,1080,20,true,54509,54588,157333
+Tile,Cartesian,8,1920,1080,20,true,72930,73621,297363
+Solid,Cartesian,8,1920,1080,200,false,5479,5510,8294400
+Tile,Cartesian,8,1920,1080,200,false,9851,9870,8294400
+Solid,Cartesian,8,1920,1080,200,true,570588,571785,2469987
+Tile,Cartesian,8,1920,1080,200,true,748253,753232,3642089
+Solid,Cartesian,40,1920,1080,0,false,1634,1636,8294400
+Tile,Cartesian,40,1920,1080,0,false,2029,2040,8294400
+Solid,Cartesian,40,1920,1080,0,true,107,127,1540
+Tile,Cartesian,40,1920,1080,0,true,135,150,2451
+Solid,Cartesian,40,1920,1080,20,false,2020,2043,8294400
+Tile,Cartesian,40,1920,1080,20,false,2704,2737,8294400
+Solid,Cartesian,40,1920,1080,20,true,54296,54365,157333
+Tile,Cartesian,40,1920,1080,20,true,65218,65378,227906
+Solid,Cartesian,40,1920,1080,200,false,5427,5434,8294400
+Tile,Cartesian,40,1920,1080,200,false,8778,8786,8294400
+Solid,Cartesian,40,1920,1080,200,true,567020,570245,2469987
+Tile,Cartesian,40,1920,1080,200,true,710492,716396,3066619
+Solid,Isometric,8,1920,1080,0,false,1632,1654,8294400
+Tile,Isometric,8,1920,1080,0,false,3293,3307,8294400
+Solid,Isometric,8,1920,1080,0,true,106,128,1540
+Tile,Isometric,8,1920,1080,0,true,136,153,2526
+Solid,Isometric,8,1920,1080,20,false,2021,2064,8294400
+Tile,Isometric,8,1920,1080,20,false,4830,4845,8294400
+Solid,Isometric,8,1920,1080,20,true,54191,54303,157333
+Tile,Isometric,8,1920,1080,20,true,208126,227192,1407502
+Solid,Isometric,8,1920,1080,200,false,5414,5486,8294400
+Tile,Isometric,8,1920,1080,200,false,18266,20113,8294400
+Solid,Isometric,8,1920,1080,200,true,567555,568208,2469987
+Tile,Isometric,8,1920,1080,200,true,1735954,1748975,12731843
+Solid,Isometric,40,1920,1080,0,false,1671,1695,8294400
+Tile,Isometric,40,1920,1080,0,false,3341,3358,8294400
+Solid,Isometric,40,1920,1080,0,true,115,131,1539
+Tile,Isometric,40,1920,1080,0,true,137,154,2531
+Solid,Isometric,40,1920,1080,20,false,2077,2087,8294400
+Tile,Isometric,40,1920,1080,20,false,4649,4730,8294400
+Solid,Isometric,40,1920,1080,20,true,54437,54815,157333
+Tile,Isometric,40,1920,1080,20,true,90099,90195,564291
+Solid,Isometric,40,1920,1080,200,false,5347,5388,8294400
+Tile,Isometric,40,1920,1080,200,false,15781,15797,8294400
+Solid,Isometric,40,1920,1080,200,true,567718,572051,2469987
+Tile,Isometric,40,1920,1080,200,true,1099744,1104767,6007667
+Solid,IsometricDots,8,1920,1080,0,false,1639,1659,8294400
+Tile,IsometricDots,8,1920,1080,0,false,3306,3320,8294400
+Solid,IsometricDots,8,1920,1080,0,true,111,130,1540
+Tile,IsometricDots,8,1920,1080,0,true,172,205,2678
+Solid,IsometricDots,8,1920,1080,20,false,2040,2048,8294400
+Tile,IsometricDots,8,1920,1080,20,false,4886,4895,8294400
+Solid,IsometricDots,8,1920,1080,20,true,54924,55018,157333
+Tile,IsometricDots,8,1920,1080,20,true,89683,90246,682528
+Solid,IsometricDots,8,1920,1080,200,false,5426,5445,8294400
+Tile,IsometricDots,8,1920,1080,200,false,18146,18235,8294400
+Solid,IsometricDots,8,1920,1080,200,true,564492,568582,2469987
+Tile,IsometricDots,8,1920,1080,200,true,906547,910484,6730998
+Solid,IsometricDots,40,1920,1080,0,false,1668,2550,8294400
+Tile,IsometricDots,40,1920,1080,0,false,3254,3277,8294400
+Solid,IsometricDots,40,1920,1080,0,true,111,128,1540
+Tile,IsometricDots,40,1920,1080,0,true,176,196,2679
+Solid,IsometricDots,40,1920,1080,20,false,2014,2031,8294400
+Tile,IsometricDots,40,1920,1080,20,false,4645,6641,8294400
+Solid,IsometricDots,40,1920,1080,20,true,54239,54546,157333
+Tile,IsometricDots,40,1920,1080,20,true,65836,65967,192174
+Solid,IsometricDots,40,1920,1080,200,false,5446,5575,8294400
+Tile,IsometricDots,40,1920,1080,200,false,16041,16175,8294400
+Solid,IsometricDots,40,1920,1080,200,true,565981,566682,2469987
+Tile,IsometricDots,40,1920,1080,200,true,730389,731736,2762761
+Solid,Cartesian,8,3840,2160,0,false,4852,5650,33177600
+Tile,Cartesian,8,3840,2160,0,false,6498,6690,33177600
+Solid,Cartesian,8,3840,2160,0,true,109,132,1543
+Tile,Cartesian,8,3840,2160,0,true,132,144,2449
+Solid,Cartesian,8,3840,2160,20,false,5742,5935,33177600
+Tile,Cartesian,8,3840,2160,20,false,7375,7578,33177600
+Solid,Cartesian,8,3840,2160,20,true,55091,55388,149953
+Tile,Cartesian,8,3840,2160,20,true,72774,72779,284050
+Solid,Cartesian,8,3840,2160,200,false,10297,10773,33177600
+Tile,Cartesian,8,3840,2160,200,false,16245,16666,33177600
+Solid,Cartesian,8,3840,2160,200,true,589838,593012,2941444
+Tile,Cartesian,8,3840,2160,200,true,799591,803500,3990879
+Solid,Cartesian,40,3840,2160,0,false,5187,5396,33177600
+Tile,Cartesian,40,3840,2160,0,false,6256,6456,33177600
+Solid,Cartesian,40,3840,2160,0,true,113,131,1542
+Tile,Cartesian,40,3840,2160,0,true,131,144,2453
+Solid,Cartesian,40,3840,2160,20,false,5339,5443,33177600
+Tile,Cartesian,40,3840,2160,20,false,6911,7028,33177600
+Solid,Cartesian,40,3840,2160,20,true,55440,55610,149953
+Tile,Cartesian,40,3840,2160,20,true,66254,66464,215815
+Solid,Cartesian,40,3840,2160,200,false,11128,11629,33177600
+Tile,Cartesian,40,3840,2160,200,false,14686,14778,33177600
+Solid,Cartesian,40,3840,2160,200,true,591393,598996,2941444
+Tile,Cartesian,40,3840,2160,200,true,767512,774257,3528120
+Solid,Isometric,8,3840,2160,0,false,5128,5391,33177600
+Tile,Isometric,8,3840,2160,0,false,11214,11397,33177600
+Solid,Isometric,8,3840,2160,0,true,108,129,1542
+Tile,Isometric,8,3840,2160,0,true,139,152,2530
+Solid,Isometric,8,3840,2160,20,false,5787,6034,33177600
+Tile,Isometric,8,3840,2160,20,false,12913,12996,33177600
+Solid,Isometric,8,3840,2160,20,true,55172,57034,149953
+Tile,Isometric,8,3840,2160,20,true,202756,204920,1386368
+Solid,Isometric,8,3840,2160,200,false,11581,11646,33177600
+Tile,Isometric,8,3840,2160,200,false,30583,30622,33177600
+Solid,Isometric,8,3840,2160,200,true,592189,604378,2941444
+Tile,Isometric,8,3840,2160,200,true,1824540,1842717,14531343
+Solid,Isometric,40,3840,2160,0,false,5841,5947,33177600
+Tile,Isometric,40,3840,2160,0,false,12047,12118,33177600
+Solid,Isometric,40,3840,2160,0,true,120,134,1542
+Tile,Isometric,40,3840,2160,0,true,145,159,2533
+Solid,Isometric,40,3840,2160,20,false,6492,6552,33177600
+Tile,Isometric,40,3840,2160,20,false,13579,13645,33177600
+Solid,Isometric,40,3840,2160,20,true,57160,57479,149953
+Tile,Isometric,40,3840,2160,20,true,94379,96976,551501
+Solid,Isometric,40,3840,2160,200,false,13841,17122,33177600
+Tile,Isometric,40,3840,2160,200,false,28812,29316,33177600
+Solid,Isometric,40,3840,2160,200,true,607961,615508,2941444
+Tile,Isometric,40,3840,2160,200,true,1156582,1163901,6642415
+Solid,IsometricDots,8,3840,2160,0,false,4885,4985,33177600
+Tile,IsometricDots,8,3840,2160,0,false,11344,11544,33177600
+Solid,IsometricDots,8,3840,2160,0,true,109,133,1542
+Tile,IsometricDots,8,3840,2160,0,true,172,188,2681
+Solid,IsometricDots,8,3840,2160,20,false,5319,5900,33177600
+Tile,IsometricDots,8,3840,2160,20,false,12856,13400,33177600
+Solid,IsometricDots,8,3840,2160,20,true,55338,55610,149954
+Tile,IsometricDots,8,3840,2160,20,true,90630,90963,670417
+Solid,IsometricDots,8,3840,2160,200,false,10771,11161,33177600
+Tile,IsometricDots,8,3840,2160,200,false,30072,30136,33177600
+Solid,IsometricDots,8,3840,2160,200,true,593308,594650,2941444
+Tile,IsometricDots,8,3840,2160,200,true,978665,1000595,7478069
+Solid,IsometricDots,40,3840,2160,0,false,5303,5343,33177600
+Tile,IsometricDots,40,3840,2160,0,false,11401,11435,33177600
+Solid,IsometricDots,40,3840,2160,0,true,112,132,1542
+Tile,IsometricDots,40,3840,2160,0,true,181,191,2682
+Solid,IsometricDots,40,3840,2160,20,false,5813,5929,33177600
+Tile,IsometricDots,40,3840,2160,20,false,12884,12937,33177600
+Solid,IsometricDots,40,3840,2160,20,true,56009,67806,149953
+Tile,IsometricDots,40,3840,2160,20,true,67464,74172,186626
+Solid,IsometricDots,40,3840,2160,200,false,11685,11798,33177600
+Tile,IsometricDots,40,3840,2160,200,false,27179,27269,33177600
+Solid,IsometricDots,40,3840,2160,200,true,610095,619162,2941444
+Tile,IsometricDots,40,3840,2160,200,true,919269,1180332,3241025
diff --git a/docs/board-paper-validation.md b/docs/board-paper-validation.md
new file mode 100644
index 000000000..ee932bee7
--- /dev/null
+++ b/docs/board-paper-validation.md
@@ -0,0 +1,159 @@
+# Board paper implementation and validation
+
+Issue: [#389](https://github.com/devmobasa/wayscriber/issues/389).
+
+Cartesian, isometric lines, and isometric dots share a procedural paper source.
+All shipped board templates still default to None. Background opacity,
+transparent-board behavior, and screenshot source provenance keep their existing
+owners. This document records implementation decisions and headless evidence;
+it does not claim a live Wayland or GTK interaction test.
+
+## Final local checks
+
+`./tools/lint-and-test.sh` passed on 2026-09-12, including packaging and source
+audits, 75 C# tooling tests, formatting, builds, and strict workspace Clippy in
+both feature configurations. The all-feature suite passed 4,584 root and 491
+configurator tests; no-default-features passed 4,424 root and 490 configurator
+tests. CLI, daemon fixture, UI integration, documentation, and the separately
+invoked retained context-menu/board-picker render tests also passed.
+
+The headless appearance sheet was inspected at 420×300 and its bounds tested
+at 420×300 and 900×700. Poppler PDF raster comparisons ran on this machine.
+No live Wayland overlay or GTK configurator was launched, and no GitHub Actions
+run was triggered by the feature-branch pushes. The performance measurements
+below are separate opt-in runs, not timings collected during the CI suite.
+
+## State and recovery
+
+Appearance lives with the board, outside drawing history. The picker owns an
+identity-bound draft and applies a field-level patch once. The small appearance
+sheet has the same control geometry for painting and mouse input. Invalid raw
+spacing remains editable, and Cancel or an invalidated identity cannot commit.
+Draft differences are computed from final values, so changing a field and then
+reverting it preserves later external changes. Preview updates and dismissal
+damage the sheet's screen rectangle without publishing a board or session edit.
+The visible outer palette edits that same draft; its clicks preserve pending
+pattern and spacing changes, and both palette and sheet are marked for repaint.
+
+Each `BoardState` owns its immutable configured/template appearance seed. Keeping
+this seed on the identity-bearing state, instead of a second manager map, lets
+ordinary duplicate/delete/restore operations carry it with the board. Session
+replacement resets retained states from those seeds before applying saved paper.
+Saved tools restore last, including on a restore to the already-active board.
+
+Format 7 stores appearance with drawing data. Explicit paper edits also persist
+on otherwise empty boards. Merely capturing an empty configured template cannot
+claim recovery priority. History trimming drops non-explicit appearance when no
+page data remains; explicit empty paper survives. Invalid appearance metadata
+falls back to the seed without discarding valid drawings. Existing newer-format
+preservation and clear-marker handling remain in use.
+
+## Renderer selection
+
+The full-viewport recording candidate was rejected before broad integration.
+At 1920×1080, spacing 8 and 200 erasers, preliminary isometric-line medians were
+about 405 ms for the viewport recording versus 26 ms for a compact vector tile.
+Isometric dots similarly measured about 258 ms versus 34 ms. These preliminary
+numbers came from the candidate comparison and are not the final raster timings.
+The viewport recording walks visible grid geometry on each eraser replay, failing
+the plan's adoption threshold (more than both 20% and 1 ms slower).
+
+The selected source repeats a compact Cartesian or isometric tile. Raster
+consumers generate a small raster tile once per paper paint/replay pass; PDF
+uses an integer-unit recording tile and remains vector without erasers. Source
+phase is reduced by whole tile periods at far-away coordinates. Erasers reuse
+the exact source and phase used to paint the background. Blur still uses the
+existing captured-image rules; procedural paper does not pretend to be a capture.
+
+The raster tile caps density at 4×. At spacing 200 its maximum backing allocation
+is 4,435,200 bytes (1386×800×4), independent of viewport and eraser count. The pan
+cache retains its existing 256 MiB cap. Allocation counts were not instrumented;
+reported surface byte counts are explicit proxies, not process peak RSS.
+
+## Source benchmark
+
+[Full measurements](board-paper-source-performance.csv), taken headlessly on an
+AMD Ryzen 9 7950X, Cairo 1.18.4, Rust 1.98.1 test profile. One warmup followed by
+five measurements; median is the middle sample and reported p95 is the maximum
+of those five samples. No separate build or test was run concurrently by this
+implementation during measurement. This is a local regression comparison, not
+a hardware-independent frame-rate guarantee.
+
+Both viewport sizes use scale 1 (logical size equals device size), a far negative
+origin, 80 fixed annotation lines, alternating circular/rectangular erasers with
+three points and size 32, and 0/20/200 committed erasers. Raster cases add one
+provisional eraser; PDF cases do not. Timing includes target/source construction,
+annotation replay, erasers, and PDF finalization where applicable.
+
+| Pattern | Spacing | 1920×1080 median / p95 | 3840×2160 median / p95 |
+|---|---:|---:|---:|
+| Cartesian | 8 | 9.85 / 9.87 ms | 16.25 / 16.67 ms |
+| Cartesian | 40 | 8.78 / 8.79 ms | 14.69 / 14.78 ms |
+| Isometric lines | 8 | 18.27 / 20.11 ms | 30.58 / 30.62 ms |
+| Isometric lines | 40 | 15.78 / 15.80 ms | 28.81 / 29.32 ms |
+| Isometric dots | 8 | 18.15 / 18.24 ms | 30.07 / 30.14 ms |
+| Isometric dots | 40 | 16.04 / 16.18 ms | 27.18 / 27.27 ms |
+
+The table shows 200 committed erasers plus one provisional stroke. Ordinary
+panning uses the existing committed-layer cache, so it does not pay this full
+replay cost on every frame. None retains the existing solid paint path.
+
+PDF files without erasers retain vector pattern resources (checked against
+`/Subtype /Image`). Cairo's eraser compositing causes fallback raster work,
+already present for plain solid backgrounds. Patterns increase fallback PDF
+size and CPU cost; the CSV records both rather than presenting these pages as
+wholly vector.
+
+## Regression coverage
+
+- Config defaults, enum/schema, spacing bounds, transparent normalization,
+ template inheritance, and comment/unknown-field preservation on guarded saves.
+- Negative and far-negative coordinates, fractional/2× scale, tile boundaries,
+ Cairo path/state preservation, and circular/rectangular eraser replay.
+- Direct versus baked pan rendering, pattern/spacing cache invalidation, PNG
+ eraser pixels, immutable exports, and plain PDF margins with vector paper.
+ Actual PDFs are also rasterized with Poppler when available to check paper
+ phase and eraser restoration against the raster renderer.
+- Draft Cancel/no-op/invalid input, conflict and identity checks, field-level
+ merge, pen preservation on grid-only edits, and unchanged drawing history.
+- Empty-board persistence, exact optional pen restoration, legacy appearance
+ seeds, named replacement, and backup recovery/history-trimming distinctions.
+
+## Integrated pan and PNG consumers
+
+[Initial consumer measurements](board-paper-consumer-performance.csv) use the actual
+`CanvasLayerCache::ensure`/`blit` and PNG export entry points. The matrix includes
+both zero and far-negative origins, both sizes, None plus all three patterns,
+spacing 8/40, and 0/20/200 committed erasers. Warmup/sample counts, scale, shapes,
+and brushes match the source benchmark. PNG includes encoding. A warm pan sample
+checks cache validity and blits into an already allocated destination; it does
+not construct the paper again. Those initial warm measurements covered only
+`ensure` and `blit`, so they did not detect redundant painting in the live caller.
+The benchmark now invokes the production committed-canvas dispatcher, including
+backdrop preparation, and asserts that a cache hit constructs no paper source.
+A 4K regression checks that path and paper restoration after a failed blit.
+The historical CSV below has not been remeasured with this expanded harness.
+
+| Consumer (patterned boards) | 1920×1080 p95 range | 3840×2160 p95 range |
+|---|---:|---:|
+| Cold pan-cache bake | 3.60–42.47 ms | 9.10–39.03 ms |
+| Warm pan-cache check and blit | 0.19–1.73 ms | 1.94–3.34 ms |
+| PNG render and encoding | 34.73–305.44 ms | 138.62–660.61 ms |
+
+Ranges include all tested origins, spacings, patterns, and eraser counts, so
+an occasional slower local sample is retained rather than removed. Live pan
+reuse stays a blit. Export time is worker-side work, not event-loop rendering.
+The simultaneous surface allocation proxy is approximately 30.6 MiB at 1080p
+and 107.6 MiB at 4K for the benchmark's retained bake, blit destination, and PNG
+target; it excludes codec scratch and the separately bounded paper tile.
+
+To reproduce the opt-in measurements:
+
+```sh
+cargo test -p wayscriber --all-features --lib board_grid_backdrop_performance -- --ignored --nocapture --test-threads=1
+cargo test -p wayscriber --all-features --lib board_grid_consumers_performance -- --ignored --nocapture --test-threads=1
+```
+
+Set `WAYSCRIBER_GRID_COMPARE_VIEWPORT=1` on the first command to include the
+rejected viewport-recording candidate. These benchmarks are opt-in so ordinary
+CI does not spend time measuring machine-dependent rendering latency.
diff --git a/docs/codebase-overview.md b/docs/codebase-overview.md
index 2a3fc624a..58370322f 100644
--- a/docs/codebase-overview.md
+++ b/docs/codebase-overview.md
@@ -654,3 +654,13 @@ Use `./tools/lint-and-test.sh` for package/source checks, formatting, linting, b
and tests under both workspace feature configurations. Required GTK widget coverage is
`./tools/test-gtk-widgets.sh`. Live Wayland focus, layer-shell, capture, and installed-binary
checks remain separate from these automated checks.
+
+## Board paper
+
+Board pattern values live in `src/domain/board_grid.rs`; config adapters validate
+and serialize them. `src/draw/render/board_grid.rs` builds the procedural source
+shared by the canvas, erasers, pan cache, thumbnails, and canvas exports.
+`src/input/state/core/board/appearance.rs` publishes appearance edits once;
+the board picker owns only its draft. Session format 7 carries appearance and
+its explicit-override provenance independently of page history. See
+[board paper validation](board-paper-validation.md) for regression and performance evidence.
diff --git a/src/backend/wayland/session/tests.rs b/src/backend/wayland/session/tests.rs
index c98ce87b4..0f2107a05 100644
--- a/src/backend/wayland/session/tests.rs
+++ b/src/backend/wayland/session/tests.rs
@@ -168,6 +168,7 @@ fn board_snapshot(id: &str, x2: i32) -> stored_session::BoardSnapshot {
thick: 2.0,
});
stored_session::BoardSnapshot {
+ appearance: None,
id: id.to_string(),
pages: stored_session::BoardPagesSnapshot {
pages: vec![frame],
diff --git a/src/backend/wayland/state/canvas_layer.rs b/src/backend/wayland/state/canvas_layer.rs
index c7a99a45a..04182a66b 100644
--- a/src/backend/wayland/state/canvas_layer.rs
+++ b/src/backend/wayland/state/canvas_layer.rs
@@ -36,6 +36,7 @@ pub(in crate::backend::wayland) struct CanvasLayerCache {
shapes_len: usize,
last_shape_id: Option,
background: Option,
+ grid: crate::domain::BoardGrid,
text_halo_enabled: bool,
board_key: (usize, usize),
valid: bool,
@@ -54,6 +55,7 @@ impl CanvasLayerCache {
shapes_len: 0,
last_shape_id: None,
background: None,
+ grid: Default::default(),
text_halo_enabled: true,
board_key: (0, 0),
valid: false,
@@ -180,6 +182,7 @@ impl WaylandState {
scale,
origin,
background,
+ grid: self.input_state.boards.active_board().spec.grid,
text_halo_enabled,
board_key,
generation,
@@ -195,6 +198,7 @@ pub(super) struct CanvasLayerInputs {
pub(super) scale: i32,
pub(super) origin: (f64, f64),
pub(super) background: Option,
+ pub(super) grid: crate::domain::BoardGrid,
pub(super) text_halo_enabled: bool,
pub(super) board_key: (usize, usize),
pub(super) generation: u64,
@@ -214,6 +218,7 @@ impl CanvasLayerCache {
scale,
origin,
background,
+ grid,
text_halo_enabled,
board_key,
generation,
@@ -237,6 +242,7 @@ impl CanvasLayerCache {
&& cache.shapes_len == shapes_len
&& cache.last_shape_id == last_shape_id
&& cache.background == background
+ && cache.grid == grid
&& cache.text_halo_enabled == text_halo_enabled
&& cache.board_key == board_key;
let covers_view = view_x >= cache.world_x
@@ -297,10 +303,28 @@ impl CanvasLayerCache {
bake_ctx.scale(scale as f64, scale as f64);
bake_ctx.translate(-(world_x as f64), -(world_y as f64));
- // Erasers clear down to the baked solid background; blur rects have
+ let paper = match background.filter(|_| grid.kind != crate::domain::BoardGridKind::None)
+ {
+ Some(color) => match crate::draw::BoardPaper::for_context(color, grid, &bake_ctx) {
+ Ok(paper) => {
+ if paper.paint(&bake_ctx).is_err() {
+ cache.clear();
+ return false;
+ }
+ Some(paper)
+ }
+ Err(_) => {
+ cache.clear();
+ return false;
+ }
+ },
+ None => None,
+ };
+
+ // Erasers clear down to the baked board paper; blur rects have
// no backdrop image in this mode (same as the direct render path).
let replay_ctx = crate::draw::EraserReplayContext {
- pattern: None,
+ pattern: paper.as_ref().map(crate::draw::BoardPaper::pattern),
surface: None,
backdrop_cache_key: None,
bg_color: background,
@@ -347,6 +371,7 @@ impl CanvasLayerCache {
cache.shapes_len = shapes_len;
cache.last_shape_id = last_shape_id;
cache.background = background;
+ cache.grid = grid;
cache.text_halo_enabled = text_halo_enabled;
cache.board_key = board_key;
cache.valid = true;
diff --git a/src/backend/wayland/state/capture.rs b/src/backend/wayland/state/capture.rs
index 10ead0a94..d78bf3091 100644
--- a/src/backend/wayland/state/capture.rs
+++ b/src/backend/wayland/state/capture.rs
@@ -392,7 +392,10 @@ impl WaylandState {
CanvasExportBackdropSnapshot::Transparent
}
crate::input::BoardBackground::Solid(color) => {
- CanvasExportBackdropSnapshot::Solid(*color)
+ CanvasExportBackdropSnapshot::board_paper(
+ *color,
+ self.input_state.boards.active_board().spec.grid,
+ )
}
},
board: BoardExportSnapshot {
diff --git a/src/backend/wayland/state/clipboard/session_paste.rs b/src/backend/wayland/state/clipboard/session_paste.rs
index e955da054..67ff67b48 100644
--- a/src/backend/wayland/state/clipboard/session_paste.rs
+++ b/src/backend/wayland/state/clipboard/session_paste.rs
@@ -174,6 +174,7 @@ fn snapshot_after_external_image_paste_from_input(
return None;
}
snapshot.boards.push(session::BoardSnapshot {
+ appearance: Some(session::BoardAppearanceSnapshot::capture(target_board)),
id: target_board.spec.id.clone(),
pages: snapshot_pages_for_preflight(target_board, input, options),
});
diff --git a/src/backend/wayland/state/core/output/tests.rs b/src/backend/wayland/state/core/output/tests.rs
index 32416903d..d64380dd8 100644
--- a/src/backend/wayland/state/core/output/tests.rs
+++ b/src/backend/wayland/state/core/output/tests.rs
@@ -55,6 +55,7 @@ fn partial_output_load_clears_boards_omitted_from_snapshot() {
let snapshot = SessionSnapshot {
active_board_id: "whiteboard".to_string(),
boards: vec![BoardSnapshot {
+ appearance: None,
id: "whiteboard".to_string(),
pages: BoardPagesSnapshot {
pages: vec![Frame::new()],
@@ -83,6 +84,7 @@ fn failed_output_replacement_preserves_source_board_contents() {
add_test_line(&mut input);
let boards = (0..=input.boards.max_count())
.map(|index| BoardSnapshot {
+ appearance: None,
id: format!("replacement-{index}"),
pages: BoardPagesSnapshot {
pages: vec![Frame::new()],
diff --git a/src/backend/wayland/state/pdf_export.rs b/src/backend/wayland/state/pdf_export.rs
index 91ae7d067..5eae65327 100644
--- a/src/backend/wayland/state/pdf_export.rs
+++ b/src/backend/wayland/state/pdf_export.rs
@@ -114,8 +114,11 @@ fn build_board_pdf_export_snapshot(
let board = &boards[app_board_index];
let board_page_count = board.pages.pages().len().max(1);
for (board_page_index, frame) in board.pages.pages().iter().enumerate() {
- let backdrop =
- backdrop_from_background(&board.spec.background, desktop_backdrop.as_ref());
+ let backdrop = backdrop_from_background(
+ &board.spec.background,
+ board.spec.grid,
+ desktop_backdrop.as_ref(),
+ );
let use_page_offsets = pan_enabled && !board.spec.background.is_transparent();
let (origin_x, origin_y) = if use_page_offsets {
frame.view_offset()
@@ -240,13 +243,14 @@ fn pdf_export_scope_has_transparent_pages(
fn backdrop_from_background(
background: &BoardBackground,
+ grid: crate::domain::BoardGrid,
desktop_backdrop: Option<&CanvasExportBackdropSnapshot>,
) -> CanvasExportBackdropSnapshot {
match background {
BoardBackground::Transparent => desktop_backdrop
.cloned()
.unwrap_or(CanvasExportBackdropSnapshot::Transparent),
- BoardBackground::Solid(color) => CanvasExportBackdropSnapshot::Solid(*color),
+ BoardBackground::Solid(color) => CanvasExportBackdropSnapshot::board_paper(*color, grid),
}
}
diff --git a/src/backend/wayland/state/pdf_export/tests.rs b/src/backend/wayland/state/pdf_export/tests.rs
index 44f83c3a6..776c730d7 100644
--- a/src/backend/wayland/state/pdf_export/tests.rs
+++ b/src/backend/wayland/state/pdf_export/tests.rs
@@ -5,18 +5,18 @@ use crate::input::BoardSpec;
use std::sync::Arc;
fn board(id: &str, name: &str, background: BoardBackground, pages: Vec) -> BoardState {
- BoardState {
- spec: BoardSpec {
- id: id.to_string(),
- name: name.to_string(),
- background,
- default_pen_color: None,
- auto_adjust_pen: false,
- persist: true,
- pinned: false,
- },
- pages: crate::draw::BoardPages::from_pages(pages, 0),
- }
+ let mut board = BoardState::new(BoardSpec {
+ id: id.to_string(),
+ name: name.to_string(),
+ background,
+ grid: Default::default(),
+ default_pen_color: None,
+ auto_adjust_pen: false,
+ persist: true,
+ pinned: false,
+ });
+ board.pages = crate::draw::BoardPages::from_pages(pages, 0);
+ board
}
fn snapshot_context<'a>(
diff --git a/src/backend/wayland/state/render/canvas/background.rs b/src/backend/wayland/state/render/canvas/background.rs
index 5d49b1542..13950ccd1 100644
--- a/src/backend/wayland/state/render/canvas/background.rs
+++ b/src/backend/wayland/state/render/canvas/background.rs
@@ -7,6 +7,7 @@ use crate::backend::wayland::state::screen_image::{
use crate::draw::Color;
pub(super) struct CanvasEraserContext {
+ paper: Option,
surface: Option,
pattern: Option,
backdrop_cache_key: Option,
@@ -21,9 +22,46 @@ pub(super) struct CanvasEraserContext {
}
impl CanvasEraserContext {
+ pub(super) fn for_board(bg_color: Option) -> Self {
+ Self {
+ paper: None,
+ surface: None,
+ pattern: None,
+ backdrop_cache_key: None,
+ bg_color,
+ logical_to_image_scale_x: 1.0,
+ logical_to_image_scale_y: 1.0,
+ magnifier_source: crate::draw::SpotlightMagnifierSource::from_backdrop(
+ None,
+ bg_color.is_some(),
+ ),
+ }
+ }
+
+ pub(super) fn prepare_paper(
+ &mut self,
+ ctx: &cairo::Context,
+ grid: crate::domain::BoardGrid,
+ ) -> Result<()> {
+ if let Some(color) = self.bg_color {
+ if grid.kind == crate::domain::BoardGridKind::None {
+ ctx.set_source_rgba(color.r, color.g, color.b, color.a);
+ ctx.paint()?;
+ } else {
+ let paper = crate::draw::BoardPaper::for_context(color, grid, ctx)?;
+ paper.paint(ctx)?;
+ self.paper = Some(paper);
+ }
+ }
+ Ok(())
+ }
pub(super) fn replay_context(&self) -> crate::draw::EraserReplayContext<'_> {
crate::draw::EraserReplayContext {
- pattern: self.pattern.as_ref().map(|p| p as &cairo::Pattern),
+ pattern: self
+ .paper
+ .as_ref()
+ .map(crate::draw::BoardPaper::pattern)
+ .or_else(|| self.pattern.as_ref().map(|p| p as &cairo::Pattern)),
surface: self.surface.as_ref(),
backdrop_cache_key: self.backdrop_cache_key,
bg_color: self.bg_color,
@@ -126,13 +164,13 @@ impl WaylandState {
scale: i32,
phys_width: u32,
phys_height: u32,
+ paint_board: bool,
) -> Result {
- let mut eraser_surface: Option = None;
- let mut eraser_pattern: Option = None;
- let mut backdrop_cache_key: Option = None;
- let mut eraser_bg_color: Option = None;
- let mut logical_to_image_scale_x = 1.0;
- let mut logical_to_image_scale_y = 1.0;
+ let eraser_surface;
+ let eraser_pattern;
+ let backdrop_cache_key;
+ let logical_to_image_scale_x;
+ let logical_to_image_scale_y;
// One provenance answer decides both what is painted and what the loupe
// may sample, so the pixels on screen and the availability reported can
@@ -215,21 +253,23 @@ impl WaylandState {
eraser_pattern = Some(pattern);
backdrop_cache_key = Some(cache_key);
} else {
- match self.input_state.boards.active_background() {
- crate::input::BoardBackground::Solid(color) => {
- ctx.set_source_rgba(color.r, color.g, color.b, color.a);
- let _ = ctx.paint();
- eraser_bg_color = Some(*color);
- }
- crate::input::BoardBackground::Transparent => {}
+ let color = match self.input_state.boards.active_background() {
+ crate::input::BoardBackground::Solid(color) => Some(*color),
+ crate::input::BoardBackground::Transparent => None,
+ };
+ let mut backdrop = CanvasEraserContext::for_board(color);
+ if paint_board {
+ backdrop.prepare_paper(ctx, crate::domain::BoardGrid::default())?;
}
+ return Ok(backdrop);
}
Ok(CanvasEraserContext {
+ paper: None,
surface: eraser_surface,
pattern: eraser_pattern,
backdrop_cache_key,
- bg_color: eraser_bg_color,
+ bg_color: None,
logical_to_image_scale_x,
logical_to_image_scale_y,
magnifier_source,
diff --git a/src/backend/wayland/state/render/canvas/mod.rs b/src/backend/wayland/state/render/canvas/mod.rs
index 6db651a40..597daed2d 100644
--- a/src/backend/wayland/state/render/canvas/mod.rs
+++ b/src/backend/wayland/state/render/canvas/mod.rs
@@ -110,7 +110,13 @@ impl WaylandState {
}
let background_start = perf.as_ref().map(|_| Instant::now());
- let eraser_ctx = self.render_canvas_background(ctx, scale, phys_width, phys_height)?;
+ let mut eraser_ctx = self.render_canvas_background(
+ ctx,
+ scale,
+ phys_width,
+ phys_height,
+ !canvas.canvas.draw_committed,
+ )?;
if let (Some(perf), Some(background_start)) = (perf.as_mut(), background_start) {
perf.stages.background = perf
.stages
@@ -150,8 +156,6 @@ impl WaylandState {
ctx.translate(-canvas_origin_x, -canvas_origin_y);
}
- let replay_ctx = eraser_ctx.replay_context();
-
let completed_shapes_start = perf.as_ref().map(|_| Instant::now());
let (layer_cache, draw_caches, measurer) = self.render.canvas_draw_parts_mut();
render_committed_canvas_shapes(
@@ -161,9 +165,10 @@ impl WaylandState {
draw_caches,
canvas,
layer_cache_ready,
- &replay_ctx,
+ &mut eraser_ctx,
+ self.input_state.boards.active_board().spec.grid,
perf.as_deref_mut(),
- );
+ )?;
if let (Some(perf), Some(completed_shapes_start)) = (perf.as_mut(), completed_shapes_start)
{
perf.stages.completed_shapes = perf
@@ -285,6 +290,7 @@ impl WaylandState {
self.render_eraser_hover_halos(ctx, hover_mx, hover_my);
+ let replay_ctx = eraser_ctx.replay_context();
let provisional = self.input_state.provisional_tool_stroke(mx, my);
let provisional_points = provisional_point_count(&provisional);
let provisional_start = perf.as_ref().map(|_| Instant::now());
@@ -341,9 +347,10 @@ fn render_committed_canvas_shapes(
draw_caches: &mut crate::draw::RenderCaches,
canvas: &CanvasRenderCtx<'_>,
layer_cache_ready: bool,
- replay_ctx: &crate::draw::EraserReplayContext<'_>,
+ eraser_ctx: &mut background::CanvasEraserContext,
+ grid: crate::domain::BoardGrid,
mut perf: Option<&mut PerfRenderBreakdown>,
-) {
+) -> Result<()> {
let ctx = canvas.cairo;
let width = canvas.geometry.width;
let height = canvas.geometry.height;
@@ -356,8 +363,12 @@ fn render_committed_canvas_shapes(
perf.shapes_total = shapes.len();
perf.canvas_layer_cache_used = true;
}
- return;
+ return Ok(());
}
+ // A successful blit already includes the board paper. Build and paint its
+ // source only on the direct path, including a failed-cache fallback.
+ eraser_ctx.prepare_paper(ctx, grid)?;
+ let replay_ctx = eraser_ctx.replay_context();
debug!("Rendering {} completed shapes", shapes.len());
if let Some(perf) = perf.as_mut() {
perf.shapes_total = shapes.len();
@@ -371,7 +382,7 @@ fn render_committed_canvas_shapes(
measurer,
&mut render,
shape,
- replay_ctx,
+ &replay_ctx,
text_halo_enabled,
)
};
@@ -383,12 +394,12 @@ fn render_committed_canvas_shapes(
perf.shapes_tested = shapes.len();
perf.shapes_rendered = shapes.len();
}
- return;
+ return Ok(());
};
let Some(safe_bounds) =
safe_shape_damage_bounds(bounds, width, height, canvas_transform_active)
else {
- return;
+ return Ok(());
};
let mut shapes_rendered = 0usize;
for shape in shapes {
@@ -404,6 +415,7 @@ fn render_committed_canvas_shapes(
perf.shapes_tested = shapes.len();
perf.shapes_rendered = shapes_rendered;
}
+ Ok(())
}
fn union_damage_bounds(regions: &[crate::util::Rect]) -> Option {
diff --git a/src/backend/wayland/state/render/canvas/resource_tests.rs b/src/backend/wayland/state/render/canvas/resource_tests.rs
index bdef6ff16..c7272214f 100644
--- a/src/backend/wayland/state/render/canvas/resource_tests.rs
+++ b/src/backend/wayland/state/render/canvas/resource_tests.rs
@@ -5,6 +5,7 @@ use crate::draw::{Color, DrawnShape, EmbeddedImage, EraserBrush, EraserKind, Sha
fn inputs() -> CanvasLayerInputs {
CanvasLayerInputs {
+ grid: Default::default(),
width: 80,
height: 64,
scale: 1,
@@ -87,10 +88,6 @@ fn paint(
.unwrap();
{
let cairo = cairo::Context::new(&surface).unwrap();
- if let Some(color) = inputs.background {
- cairo.set_source_rgba(color.r, color.g, color.b, color.a);
- cairo.paint().unwrap();
- }
cairo.scale(inputs.scale as f64, inputs.scale as f64);
cairo.translate(-inputs.origin.0, -inputs.origin.1);
let frame = CanvasFrame {
@@ -109,19 +106,26 @@ fn paint(
damage_world: &[],
now: Instant::now(),
};
- let replay = crate::draw::EraserReplayContext {
- pattern: None,
- surface: None,
- backdrop_cache_key: None,
- bg_color: inputs.background,
- logical_to_image_scale_x: 1.0,
- logical_to_image_scale_y: 1.0,
- logical_image_origin_x: 0.0,
- logical_image_origin_y: 0.0,
- };
+ let mut backdrop = background::CanvasEraserContext::for_board(inputs.background);
+ let mut perf = PerfRenderBreakdown::default();
render_committed_canvas_shapes(
- measurer, shapes, layer, caches, &canvas, cached, &replay, None,
- );
+ measurer,
+ shapes,
+ layer,
+ caches,
+ &canvas,
+ cached,
+ &mut backdrop,
+ inputs.grid,
+ Some(&mut perf),
+ )
+ .unwrap();
+ if perf.canvas_layer_cache_used {
+ assert!(
+ backdrop.replay_context().pattern.is_none(),
+ "cache hits must not construct or paint a paper source"
+ );
+ }
}
surface.flush();
surface.data().unwrap().to_vec()
@@ -368,16 +372,8 @@ fn measure_sparse_damage_scan() {
damage_world: &damage,
now: Instant::now(),
};
- let replay = crate::draw::EraserReplayContext {
- pattern: None,
- surface: None,
- backdrop_cache_key: None,
- bg_color: None,
- logical_to_image_scale_x: 1.0,
- logical_to_image_scale_y: 1.0,
- logical_image_origin_x: 0.0,
- logical_image_origin_y: 0.0,
- };
+ let mut backdrop = background::CanvasEraserContext::for_board(None);
+
for count in [100, 1_000, 10_000] {
let shapes: Vec<_> = (0..count)
.map(|i| {
@@ -413,9 +409,11 @@ fn measure_sparse_damage_scan() {
&mut caches,
&canvas,
false,
- &replay,
+ &mut backdrop,
+ Default::default(),
Some(&mut perf),
- );
+ )
+ .unwrap();
}
eprintln!(
"P03 shapes={count} tested={} rendered={} mean_us={:.2}",
@@ -425,3 +423,65 @@ fn measure_sparse_damage_scan() {
);
}
}
+
+#[test]
+fn board_grid_baked_pan_matches_direct_and_invalidates_on_pattern_and_spacing() {
+ use crate::domain::{BoardGrid, BoardGridKind};
+ let measurer = crate::draw::TextMeasurer::default();
+ let mut cache = CanvasLayerCache::new();
+ let mut caches = crate::draw::RenderCaches::default();
+ let shapes = shapes();
+ for origin in [(0.0, 0.0), (-71.0, -53.0), (-1_000_021.0, -2_000_003.0)] {
+ for kind in BoardGridKind::ALL {
+ for spacing in [8, 40] {
+ let request = CanvasLayerInputs {
+ grid: BoardGrid::new(kind, spacing),
+ origin,
+ ..inputs()
+ };
+ assert!(cache.ensure(&measurer, &mut caches, &shapes, request));
+ let direct = paint(&measurer, &shapes, &cache, &mut caches, request, false);
+ let cached = paint(&measurer, &shapes, &cache, &mut caches, request, true);
+ let error: u64 = direct
+ .iter()
+ .zip(&cached)
+ .map(|(a, b)| u64::from(a.abs_diff(*b)))
+ .sum();
+ assert!(
+ error as f64 / (direct.len() as f64) < 1.0,
+ "{kind:?} {spacing} {origin:?}: cached phase differs"
+ );
+ }
+ }
+ }
+}
+
+mod grid_performance;
+
+#[test]
+fn warm_4k_paper_render_skips_source_and_failed_blit_restores_paper() {
+ let measurer = crate::draw::TextMeasurer::default();
+ let request = CanvasLayerInputs {
+ width: 3840,
+ height: 2160,
+ grid: crate::domain::BoardGrid::new(crate::domain::BoardGridKind::Isometric, 40),
+ ..inputs()
+ };
+ let mut cache = CanvasLayerCache::new();
+ let mut caches = crate::draw::RenderCaches::default();
+ assert!(cache.ensure(&measurer, &mut caches, &[], request));
+ let warm = paint(&measurer, &[], &cache, &mut caches, request, true);
+ // Simulate an unavailable cache even though readiness was reported. The
+ // production dispatcher must paint the backdrop before replaying shapes.
+ cache.clear();
+ let fallback = paint(&measurer, &[], &cache, &mut caches, request, true);
+ let direct = paint(&measurer, &[], &cache, &mut caches, request, false);
+ assert_eq!(fallback, direct);
+ let mean_error = warm
+ .iter()
+ .zip(&direct)
+ .map(|(a, b)| u64::from(a.abs_diff(*b)))
+ .sum::() as f64
+ / warm.len() as f64;
+ assert!(mean_error < 1.0, "cached paper differs: {mean_error}");
+}
diff --git a/src/backend/wayland/state/render/canvas/resource_tests/grid_performance.rs b/src/backend/wayland/state/render/canvas/resource_tests/grid_performance.rs
new file mode 100644
index 000000000..12e189762
--- /dev/null
+++ b/src/backend/wayland/state/render/canvas/resource_tests/grid_performance.rs
@@ -0,0 +1,169 @@
+//! Opt-in end-to-end paper consumers. No compositor or native windows.
+use super::*;
+use crate::canvas_export::{
+ BoardExportSnapshot, CanvasExportBackdropSnapshot, CanvasExportSnapshot, CanvasExportViewport,
+ render_canvas_png,
+};
+use crate::domain::{BoardGrid, BoardGridKind};
+use std::time::Instant;
+
+fn workload(origin: (i32, i32), size: (u32, u32), count: usize) -> crate::draw::Frame {
+ let mut frame = crate::draw::Frame::new();
+ for n in 0..80 {
+ frame.add_shape(Shape::Line {
+ x1: origin.0,
+ y1: origin.1 + n * 19,
+ x2: origin.0 + size.0 as i32,
+ y2: origin.1 + n * 19 + 60,
+ color: crate::draw::BLUE,
+ thick: 3.0,
+ });
+ }
+ for n in 0..count {
+ let (x, y) = (
+ origin.0 + (n as i32 * 73 % size.0 as i32),
+ origin.1 + (n as i32 * 47 % size.1 as i32),
+ );
+ frame.add_shape(Shape::EraserStroke {
+ points: vec![(x, y), (x + 100, y + 24), (x + 180, y + 10)],
+ brush: EraserBrush {
+ kind: if n % 2 == 0 {
+ EraserKind::Circle
+ } else {
+ EraserKind::Rect
+ },
+ size: 32.0,
+ },
+ });
+ }
+ frame
+}
+
+#[test]
+#[ignore = "opt-in cold/warm pan and PNG benchmark"]
+fn board_grid_consumers_performance() {
+ println!(
+ "kind,spacing,width,height,origin,erasers,cold_pan_median_us,cold_pan_p95_us,warm_pan_median_us,warm_pan_p95_us,png_median_us,png_p95_us,png_bytes,surface_bytes_proxy"
+ );
+ for size in [(1920, 1080), (3840, 2160)] {
+ for origin in [(0, 0), (-1_000_021, -2_000_003)] {
+ for kind in BoardGridKind::ALL {
+ for spacing in [8, 40] {
+ for count in [0, 20, 200] {
+ let grid = BoardGrid::new(kind, spacing);
+ let frame = workload(origin, size, count);
+ let request = CanvasLayerInputs {
+ width: size.0,
+ height: size.1,
+ origin: (f64::from(origin.0), f64::from(origin.1)),
+ background: Some(crate::draw::WHITE),
+ grid,
+ ..inputs()
+ };
+ let snapshot = CanvasExportSnapshot {
+ viewport: CanvasExportViewport {
+ logical_width: size.0,
+ logical_height: size.1,
+ scale: 1,
+ origin_x: origin.0,
+ origin_y: origin.1,
+ },
+ backdrop: CanvasExportBackdropSnapshot::board_paper(
+ crate::draw::WHITE,
+ grid,
+ ),
+ board: BoardExportSnapshot {
+ frame: frame.clone_without_history(),
+ },
+ render_profile: None,
+ text_halo_enabled: true,
+ spotlight: Default::default(),
+ };
+ let mut samples = [Vec::new(), Vec::new(), Vec::new()];
+ let mut png_bytes = 0;
+ let measurer = crate::draw::TextMeasurer::default();
+ for sample in 0..6 {
+ let mut layer = CanvasLayerCache::new();
+ let mut caches = crate::draw::RenderCaches::default();
+ let start = Instant::now();
+ assert!(layer.ensure(&measurer, &mut caches, &frame.shapes, request));
+ let cold = start.elapsed().as_micros();
+ let target = cairo::ImageSurface::create(
+ cairo::Format::ARgb32,
+ size.0 as i32,
+ size.1 as i32,
+ )
+ .unwrap();
+ let ctx = cairo::Context::new(&target).unwrap();
+ ctx.translate(-f64::from(origin.0), -f64::from(origin.1));
+ let start = Instant::now();
+ assert!(layer.ensure(&measurer, &mut caches, &frame.shapes, request));
+ let geometry = FrameGeometry::new(size.0, size.1, 1);
+ let canvas_frame = CanvasFrame {
+ draw_committed: true,
+ render_transients: false,
+ transform_active: true,
+ origin: request.origin,
+ zoom_scale: None,
+ text_halo_enabled: request.text_halo_enabled,
+ layer_cache_eligible: true,
+ };
+ let canvas = CanvasRenderCtx {
+ cairo: &ctx,
+ geometry: &geometry,
+ canvas: &canvas_frame,
+ damage_world: &[],
+ now: Instant::now(),
+ };
+ let mut backdrop =
+ background::CanvasEraserContext::for_board(request.background);
+ let mut perf = PerfRenderBreakdown::default();
+ render_committed_canvas_shapes(
+ &measurer,
+ &frame.shapes,
+ &layer,
+ &mut caches,
+ &canvas,
+ true,
+ &mut backdrop,
+ grid,
+ Some(&mut perf),
+ )
+ .unwrap();
+ assert!(perf.canvas_layer_cache_used);
+ assert!(backdrop.replay_context().pattern.is_none());
+ let warm = start.elapsed().as_micros();
+ let start = Instant::now();
+ png_bytes = render_canvas_png(&snapshot).unwrap().bytes.len();
+ let png = start.elapsed().as_micros();
+ if sample > 0 {
+ for (values, value) in samples.iter_mut().zip([cold, warm, png]) {
+ values.push(value);
+ }
+ }
+ }
+ for values in &mut samples {
+ values.sort_unstable();
+ }
+ // Simultaneous bake + blit target + PNG target, excluding bounded tile and codec scratch.
+ let bytes = (u64::from(size.0 + 512) * u64::from(size.1 + 512)
+ + 2 * u64::from(size.0) * u64::from(size.1))
+ * 4;
+ println!(
+ "{kind:?},{spacing},{},{},{},{count},{},{},{},{},{},{},{png_bytes},{bytes}",
+ size.0,
+ size.1,
+ origin.0,
+ samples[0][2],
+ samples[0][4],
+ samples[1][2],
+ samples[1][4],
+ samples[2][2],
+ samples[2][4]
+ );
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/src/canvas_export/mod.rs b/src/canvas_export/mod.rs
index bf8d27394..c83958426 100644
--- a/src/canvas_export/mod.rs
+++ b/src/canvas_export/mod.rs
@@ -1,4 +1,7 @@
#[cfg(test)]
+#[path = "tests/board_grid.rs"]
+mod board_grid_tests;
+#[cfg(test)]
#[path = "tests/cache_tests.rs"]
mod cache_tests;
mod page;
diff --git a/src/canvas_export/page.rs b/src/canvas_export/page.rs
index 025ed3c58..efed43d37 100644
--- a/src/canvas_export/page.rs
+++ b/src/canvas_export/page.rs
@@ -43,6 +43,10 @@ impl Default for SpotlightPassSnapshot {
pub enum CanvasExportBackdropSnapshot {
Transparent,
Solid(Color),
+ BoardPaper {
+ color: Color,
+ grid: crate::domain::BoardGrid,
+ },
PersistedImage {
data: Arc<[u8]>,
width: i32,
@@ -54,6 +58,13 @@ pub enum CanvasExportBackdropSnapshot {
}
impl CanvasExportBackdropSnapshot {
+ pub fn board_paper(color: Color, grid: crate::domain::BoardGrid) -> Self {
+ if grid.kind == crate::domain::BoardGridKind::None {
+ Self::Solid(color)
+ } else {
+ Self::BoardPaper { color, grid }
+ }
+ }
/// Loupe availability for this backdrop, answered without decoding it.
///
/// Mirrors what [`ExportBackdrop::new`] will produce for the same variant,
@@ -62,7 +73,9 @@ impl CanvasExportBackdropSnapshot {
pub(crate) fn magnifier_source(&self) -> SpotlightMagnifierSource {
match self {
Self::Transparent => SpotlightMagnifierSource::from_backdrop(None, false),
- Self::Solid(_) => SpotlightMagnifierSource::from_backdrop(None, true),
+ Self::Solid(_) | Self::BoardPaper { .. } => {
+ SpotlightMagnifierSource::from_backdrop(None, true)
+ }
Self::PersistedImage { .. } => SpotlightMagnifierSource::immutable_raster(),
}
}
@@ -183,8 +196,10 @@ pub(crate) fn paint_pdf_page_background(
width: f64,
height: f64,
) {
- let CanvasExportBackdropSnapshot::Solid(color) = page.backdrop else {
- return;
+ let color = match page.backdrop {
+ CanvasExportBackdropSnapshot::Solid(color)
+ | CanvasExportBackdropSnapshot::BoardPaper { color, .. } => color,
+ _ => return,
};
let _ = ctx.save();
ctx.set_source_rgba(color.r, color.g, color.b, color.a);
@@ -229,7 +244,8 @@ impl ExportBackdrop {
logical_image_origin_y: 0.0,
_region_source: None,
}),
- CanvasExportBackdropSnapshot::Solid(color) => Ok(Self {
+ CanvasExportBackdropSnapshot::Solid(color)
+ | CanvasExportBackdropSnapshot::BoardPaper { color, .. } => Ok(Self {
surface: None,
pattern: None,
bg_color: Some(*color),
@@ -402,7 +418,23 @@ fn draw_canvas_page_contents(
if paint_backdrop {
backdrop.paint(ctx);
}
- let replay_ctx = backdrop.replay_context();
+ let paper = match page.backdrop {
+ CanvasExportBackdropSnapshot::BoardPaper { color, grid } => Some(
+ crate::draw::BoardPaper::for_context(color, grid, ctx).map_err(|err| {
+ CaptureError::ImageError(format!("Failed to render board paper: {err}"))
+ })?,
+ ),
+ _ => None,
+ };
+ if let Some(paper) = &paper {
+ paper.paint(ctx).map_err(|err| {
+ CaptureError::ImageError(format!("Failed to paint board paper: {err}"))
+ })?;
+ }
+ let mut replay_ctx = backdrop.replay_context();
+ if let Some(paper) = &paper {
+ replay_ctx.pattern = Some(paper.pattern());
+ }
// What text should contrast with when the target cannot be read back. A PDF
// page is a vector surface with no pixels to probe, so without this a board
// exported to PDF would pick a different halo from the same board on screen.
diff --git a/src/canvas_export/tests/board_grid.rs b/src/canvas_export/tests/board_grid.rs
new file mode 100644
index 000000000..c4a3d0e81
--- /dev/null
+++ b/src/canvas_export/tests/board_grid.rs
@@ -0,0 +1,216 @@
+use super::*;
+use crate::domain::{BoardGrid, BoardGridKind};
+use crate::draw::{EraserBrush, EraserKind, Frame, RED, Shape, WHITE};
+
+fn page(kind: BoardGridKind) -> CanvasExportSnapshot {
+ CanvasExportSnapshot {
+ viewport: CanvasExportViewport {
+ logical_width: 160,
+ logical_height: 120,
+ scale: 1,
+ origin_x: -71,
+ origin_y: -53,
+ },
+ backdrop: CanvasExportBackdropSnapshot::board_paper(WHITE, BoardGrid::new(kind, 20)),
+ board: BoardExportSnapshot {
+ frame: Frame::new(),
+ },
+ render_profile: None,
+ text_halo_enabled: true,
+ spotlight: Default::default(),
+ }
+}
+
+#[test]
+fn board_grid_png_erasers_restore_pattern_and_snapshot_is_independent() {
+ for kind in BoardGridKind::ALL.into_iter().skip(1) {
+ let original = page(kind);
+ let baseline = render_canvas_png(&original).unwrap();
+ for brush_kind in [EraserKind::Circle, EraserKind::Rect] {
+ let mut edited = original.clone();
+ edited.board.frame.add_shape(Shape::Rect {
+ x: -40,
+ y: -20,
+ w: 20,
+ h: 20,
+ fill: true,
+ color: RED,
+ thick: 1.0,
+ });
+ edited.board.frame.add_shape(Shape::EraserStroke {
+ points: vec![(-55, -10), (-5, -10)],
+ brush: EraserBrush {
+ kind: brush_kind,
+ size: 60.0,
+ },
+ });
+ let png = render_canvas_png(&edited).unwrap();
+ let mut before =
+ cairo::ImageSurface::create_from_png(&mut std::io::Cursor::new(&baseline.bytes))
+ .unwrap();
+ let mut after =
+ cairo::ImageSurface::create_from_png(&mut std::io::Cursor::new(&png.bytes))
+ .unwrap();
+ let before = before.data().unwrap();
+ let after = after.data().unwrap();
+ for y in 33..53 {
+ for x in 31..51 {
+ let index = (y * 160 + x) * 4;
+ assert!(
+ before[index..index + 4]
+ .iter()
+ .zip(&after[index..index + 4])
+ .all(|(a, b)| a.abs_diff(*b) <= 2)
+ );
+ }
+ }
+ assert_eq!(render_canvas_png(&original).unwrap().bytes, baseline.bytes);
+ }
+ }
+}
+
+#[test]
+fn board_grid_pdf_stays_vector_without_erasers_and_leaves_margins_plain() {
+ use super::page::{ExportBackdrop, draw_canvas_page_region, paint_pdf_page_background};
+ use crate::draw::{RenderCaches, RenderCtx, TextMeasurer};
+ for kind in BoardGridKind::ALL.into_iter().skip(1) {
+ let snapshot = page(kind);
+ let page = CanvasPageExportSnapshot {
+ frame: Frame::new(),
+ backdrop: snapshot.backdrop,
+ viewport_width: 160,
+ viewport_height: 120,
+ origin_x: -71,
+ origin_y: -53,
+ text_halo_enabled: true,
+ spotlight: Default::default(),
+ };
+ let source = CanvasExportRect::new(-71.0, -53.0, 160.0, 120.0).unwrap();
+ let destination = CanvasExportRect::new(20.0, 20.0, 160.0, 120.0).unwrap();
+ let mut surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 200, 160).unwrap();
+ let ctx = cairo::Context::new(&surface).unwrap();
+ paint_pdf_page_background(&ctx, &page, 200.0, 160.0);
+ let backdrop = ExportBackdrop::new(&page.backdrop).unwrap();
+ draw_canvas_page_region(
+ &TextMeasurer::default(),
+ &mut RenderCtx::new(&ctx, &mut RenderCaches::default()),
+ &page,
+ &backdrop,
+ source,
+ destination,
+ false,
+ None,
+ )
+ .unwrap();
+ drop(ctx);
+ let pixels = surface.data().unwrap();
+ for y in 0..160 {
+ for x in 0..200 {
+ if !(20..180).contains(&x) || !(20..140).contains(&y) {
+ assert_eq!(&pixels[(y * 200 + x) * 4..(y * 200 + x) * 4 + 4], &[255; 4]);
+ }
+ }
+ }
+ assert!(pixels.iter().any(|v| *v < 250));
+ drop(pixels);
+ let document = BoardPdfExportSnapshot {
+ pages: vec![PdfPageExportSnapshot {
+ page,
+ layout: PdfPageLayout {
+ page_width: 200.0,
+ page_height: 160.0,
+ source_rect: source,
+ destination_rect: destination,
+ },
+ metadata: PdfPageMetadata::new(0, 1, 0, 1, 0, 1, 0, 1, "Paper".into(), None),
+ }],
+ labels: Default::default(),
+ };
+ let bytes = render_board_pdf(&document).unwrap();
+ assert!(
+ !bytes
+ .windows(b"/Subtype /Image".len())
+ .any(|v| v == b"/Subtype /Image"),
+ "{kind:?} paper should be a vector pattern"
+ );
+ assert!(bytes.windows(b"/Pattern".len()).any(|v| v == b"/Pattern"));
+ check_pdf_pixels(&bytes, &surface);
+ let mut erased = document.clone();
+ erased.pages[0].page.frame.add_shape(Shape::Rect {
+ x: -4,
+ y: -4,
+ w: 8,
+ h: 8,
+ fill: true,
+ color: RED,
+ thick: 1.0,
+ });
+ erased.pages[0].page.frame.add_shape(Shape::EraserStroke {
+ points: vec![(-10, 0), (10, 0)],
+ brush: EraserBrush {
+ kind: EraserKind::Circle,
+ size: 32.0,
+ },
+ });
+ check_pdf_pixels(&render_board_pdf(&erased).unwrap(), &surface);
+ }
+}
+
+fn check_pdf_pixels(pdf: &[u8], expected: &cairo::ImageSurface) {
+ use std::process::Command;
+ if Command::new("pdftoppm").arg("-v").output().is_err() {
+ return;
+ }
+ let folder = crate::test_temp::tempdir().unwrap();
+ let path = folder.path().join("paper.pdf");
+ let prefix = folder.path().join("paper");
+ std::fs::write(&path, pdf).unwrap();
+ let output = Command::new("pdftoppm")
+ .args(["-png", "-r", "72", "-singlefile"])
+ .arg(&path)
+ .arg(&prefix)
+ .output()
+ .unwrap();
+ assert!(
+ output.status.success(),
+ "{}",
+ String::from_utf8_lossy(&output.stderr)
+ );
+ let mut image = cairo::ImageSurface::create_from_png(
+ &mut std::fs::File::open(prefix.with_extension("png")).unwrap(),
+ )
+ .unwrap();
+ assert_eq!(
+ (image.width(), image.height()),
+ (expected.width(), expected.height())
+ );
+ let actual = image.data().unwrap();
+ // World origin is a vertex in every mode and maps to (91,73) here.
+ let darkest = (71..=75)
+ .flat_map(|y| (89..=93).map(move |x| (y * 200 + x) * 4))
+ .map(|i| actual[i])
+ .min()
+ .unwrap();
+ assert!(darkest < 240, "PDF paper lost the origin vertex");
+ expected
+ .with_data(|expected| {
+ let error: u64 = expected
+ .iter()
+ .zip(actual.iter())
+ .map(|(a, b)| u64::from(a.abs_diff(*b)))
+ .sum();
+ assert!(
+ error as f64 / (expected.len() as f64) < 4.0,
+ "PDF paper phase differs from raster: mean error {}",
+ error as f64 / expected.len() as f64
+ );
+ assert!(
+ actual
+ .as_chunks::<4>()
+ .0
+ .iter()
+ .all(|pixel| pixel[3] == 255)
+ );
+ })
+ .unwrap();
+}
diff --git a/src/config/mod.rs b/src/config/mod.rs
index 5bdc89fb8..1e623f5cf 100644
--- a/src/config/mod.rs
+++ b/src/config/mod.rs
@@ -61,11 +61,11 @@ pub use migration::{MigrationChange, MigrationPreview};
#[allow(unused_imports)]
pub use types::{
ARROW_ANGLE_MAX, ARROW_ANGLE_MIN, ARROW_LENGTH_MAX, ARROW_LENGTH_MIN, ArrowConfig,
- BoardBackgroundConfig, BoardColorConfig, BoardConfig, BoardItemConfig, BoardsConfig,
- CaptureConfig, ClickHighlightConfig, DEFAULT_OCR_LANGUAGES, DEFAULT_PEN_SMOOTHING,
- DragButtonConfig, DrawingConfig, ExportConfig, HelpOverlayStyle, HistoryConfig, InputHudConfig,
- InputHudMode, InputHudPosition, MouseDragToolsConfig, PDF_LABEL_APP_BOARD,
- PDF_LABEL_APP_BOARDS, PDF_LABEL_BOARD_NAME, PDF_LABEL_DEFAULT_TEMPLATE,
+ BoardBackgroundConfig, BoardColorConfig, BoardConfig, BoardGridConfig, BoardGridKindConfig,
+ BoardItemConfig, BoardsConfig, CaptureConfig, ClickHighlightConfig, DEFAULT_OCR_LANGUAGES,
+ DEFAULT_PEN_SMOOTHING, DragButtonConfig, DrawingConfig, ExportConfig, HelpOverlayStyle,
+ HistoryConfig, InputHudConfig, InputHudMode, InputHudPosition, MouseDragToolsConfig,
+ PDF_LABEL_APP_BOARD, PDF_LABEL_APP_BOARDS, PDF_LABEL_BOARD_NAME, PDF_LABEL_DEFAULT_TEMPLATE,
PDF_LABEL_DOCUMENT_PAGE, PDF_LABEL_DOCUMENT_PAGES, PDF_LABEL_EXPORT_BOARD,
PDF_LABEL_EXPORT_BOARDS, PDF_LABEL_PAGE, PDF_LABEL_PAGE_NAME, PDF_LABEL_PAGES,
PDF_LABEL_PLACEHOLDERS, PRESET_SLOTS_MAX, PRESET_SLOTS_MIN, PdfExportConfig, PdfFitMode,
diff --git a/src/config/tests/board_grid.rs b/src/config/tests/board_grid.rs
new file mode 100644
index 000000000..4eaaedf11
--- /dev/null
+++ b/src/config/tests/board_grid.rs
@@ -0,0 +1,72 @@
+use crate::config::{BoardGridConfig, BoardGridKindConfig, Config};
+use crate::domain::{BoardGrid, BoardGridKind};
+
+#[test]
+fn board_grid_config_defaults_and_modes_round_trip() {
+ assert_eq!(
+ toml::from_str::("").unwrap(),
+ BoardGridConfig::default()
+ );
+ for (name, kind) in [
+ ("none", BoardGridKind::None),
+ ("cartesian", BoardGridKind::Cartesian),
+ ("isometric", BoardGridKind::Isometric),
+ ("isometric-dots", BoardGridKind::IsometricDots),
+ ] {
+ let parsed: BoardGridConfig =
+ toml::from_str(&format!("kind = '{name}'\nspacing = 20")).unwrap();
+ assert_eq!(BoardGrid::from(parsed), BoardGrid::new(kind, 20));
+ let encoded = toml::to_string(&parsed).unwrap();
+ assert_eq!(parsed, toml::from_str(&encoded).unwrap());
+ }
+ for invalid in ["kind = 'diagonal'", "spacing = 2.5", "spacing = '40'"] {
+ assert!(
+ toml::from_str::(invalid).is_err(),
+ "{invalid}"
+ );
+ }
+}
+
+#[test]
+fn board_grid_config_clamps_spacing_and_disables_transparent_patterns() {
+ let mut config: Config = toml::from_str("[[boards.items]]\nid = 'whiteboard'\nname = 'Paper'\nbackground = [1.0,1.0,1.0]\ngrid = { kind = 'cartesian', spacing = -20 }\n[[boards.items]]\nid = 'transparent'\nname = 'Overlay'\ngrid = {kind = 'isometric', spacing = 900}").unwrap();
+ config.validate_and_clamp();
+ let boards = config.boards.unwrap();
+ assert_eq!(boards.items[0].grid.spacing, 8);
+ assert_eq!(boards.items[0].grid.kind, BoardGridKindConfig::Cartesian);
+ assert_eq!(boards.items[1].grid.spacing, 200);
+ assert_eq!(boards.items[1].grid.kind, BoardGridKindConfig::None);
+}
+
+#[test]
+fn board_grid_runtime_mapping_preserves_new_session_templates() {
+ let mut config = crate::config::BoardsConfig::default();
+ config.items[1].grid = BoardGridConfig {
+ kind: BoardGridKindConfig::IsometricDots,
+ spacing: 20,
+ };
+ let mut boards = crate::input::BoardManager::from_config(config);
+ assert_eq!(
+ boards.board_states()[1].spec.grid,
+ BoardGrid::new(BoardGridKind::IsometricDots, 20)
+ );
+ assert!(boards.create_board());
+ assert_eq!(
+ boards.active_board().spec.grid.kind,
+ BoardGridKind::IsometricDots
+ );
+ assert_eq!(boards.to_config().items[1].grid.spacing, 20);
+}
+
+#[test]
+#[cfg(feature = "config-schema")]
+fn board_grid_schema_exposes_supported_patterns_and_spacing_bounds() {
+ let schema = Config::json_schema();
+ let grid = &schema["$defs"]["BoardGridConfig"];
+ assert_eq!(grid["properties"]["spacing"]["minimum"], 8);
+ assert_eq!(grid["properties"]["spacing"]["maximum"], 200);
+ assert_eq!(
+ schema["$defs"]["BoardGridKindConfig"]["enum"],
+ serde_json::json!(["none", "cartesian", "isometric", "isometric-dots"])
+ );
+}
diff --git a/src/config/tests/document.rs b/src/config/tests/document.rs
index 7a14f7555..5ae830d57 100644
--- a/src/config/tests/document.rs
+++ b/src/config/tests/document.rs
@@ -5,6 +5,41 @@ use std::sync::atomic::{AtomicU64, Ordering};
static TEMP_SEQUENCE: AtomicU64 = AtomicU64::new(0);
+#[test]
+fn board_grid_guarded_save_preserves_inline_and_nested_unknown_settings() {
+ for grid in [
+ "grid = { kind = 'cartesian', spacing = 20, future = 7 } # paper",
+ "[boards.items.grid] # paper\nkind = 'cartesian'\nspacing = 20\nfuture = 7",
+ ] {
+ let temp = TempConfig::new("board-grid");
+ temp.write(&format!("# personal boards\n[[boards.items]]\nid = 'paper'\nname = 'Paper'\nbackground = [1.0,1.0,1.0]\n{grid}\n"));
+ let document = ConfigDocument::load_from_path(&temp.path).unwrap();
+ let mut config = document.config().clone();
+ let board = config
+ .boards
+ .as_mut()
+ .unwrap()
+ .items
+ .iter_mut()
+ .find(|b| b.id == "paper")
+ .unwrap();
+ board.grid.kind = crate::config::BoardGridKindConfig::IsometricDots;
+ board.grid.spacing = 40;
+ document.save_with_backup(config).unwrap();
+ let saved = fs::read_to_string(&temp.path).unwrap();
+ assert!(saved.contains("# personal boards") && saved.contains("# paper"));
+ let value: toml::Value = toml::from_str(&saved).unwrap();
+ let boards = value["boards"]["items"].as_array().unwrap();
+ let paper = boards
+ .iter()
+ .find(|v| v["id"].as_str() == Some("paper"))
+ .unwrap();
+ assert_eq!(paper["grid"]["future"].as_integer(), Some(7));
+ assert_eq!(paper["grid"]["spacing"].as_integer(), Some(40));
+ assert_eq!(paper["grid"]["kind"].as_str(), Some("isometric-dots"));
+ }
+}
+
struct TempConfig {
root: PathBuf,
path: PathBuf,
diff --git a/src/config/tests/mod.rs b/src/config/tests/mod.rs
index c67986b85..1af6e12ef 100644
--- a/src/config/tests/mod.rs
+++ b/src/config/tests/mod.rs
@@ -1,3 +1,4 @@
+mod board_grid;
mod document;
mod file_io;
mod immutability;
diff --git a/src/config/tests/validate.rs b/src/config/tests/validate.rs
index c874ca892..768c835c5 100644
--- a/src/config/tests/validate.rs
+++ b/src/config/tests/validate.rs
@@ -113,6 +113,7 @@ fn validate_boards_uses_boundary_id_normalization() {
id: " Transparent ".to_string(),
name: "Overlay".to_string(),
background: BoardBackgroundConfig::Transparent("transparent".to_string()),
+ grid: Default::default(),
default_pen_color: None,
auto_adjust_pen: false,
persist: true,
@@ -124,6 +125,7 @@ fn validate_boards_uses_boundary_id_normalization() {
background: BoardBackgroundConfig::Color(BoardColorConfig::Rgb([
1.2, 0.5, -0.1,
])),
+ grid: Default::default(),
default_pen_color: Some(BoardColorConfig::Rgb([0.2, 1.4, 0.6])),
auto_adjust_pen: true,
persist: true,
@@ -135,6 +137,7 @@ fn validate_boards_uses_boundary_id_normalization() {
background: BoardBackgroundConfig::Color(BoardColorConfig::Rgb([
0.2, 0.3, 0.4,
])),
+ grid: Default::default(),
default_pen_color: None,
auto_adjust_pen: true,
persist: true,
@@ -146,6 +149,7 @@ fn validate_boards_uses_boundary_id_normalization() {
background: BoardBackgroundConfig::Color(BoardColorConfig::Rgb([
0.2, 0.3, 0.4,
])),
+ grid: Default::default(),
default_pen_color: None,
auto_adjust_pen: true,
persist: true,
diff --git a/src/config/types/board_grid.rs b/src/config/types/board_grid.rs
new file mode 100644
index 000000000..e9d11fad7
--- /dev/null
+++ b/src/config/types/board_grid.rs
@@ -0,0 +1,71 @@
+use serde::{Deserialize, Serialize};
+
+use crate::domain::{BOARD_GRID_DEFAULT_SPACING, BoardGrid, BoardGridKind};
+
+#[cfg_attr(feature = "config-schema", derive(schemars::JsonSchema))]
+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "kebab-case")]
+pub enum BoardGridKindConfig {
+ #[default]
+ None,
+ Cartesian,
+ Isometric,
+ IsometricDots,
+}
+
+impl From for BoardGridKind {
+ fn from(value: BoardGridKindConfig) -> Self {
+ match value {
+ BoardGridKindConfig::None => Self::None,
+ BoardGridKindConfig::Cartesian => Self::Cartesian,
+ BoardGridKindConfig::Isometric => Self::Isometric,
+ BoardGridKindConfig::IsometricDots => Self::IsometricDots,
+ }
+ }
+}
+
+impl From for BoardGridKindConfig {
+ fn from(value: BoardGridKind) -> Self {
+ match value {
+ BoardGridKind::None => Self::None,
+ BoardGridKind::Cartesian => Self::Cartesian,
+ BoardGridKind::Isometric => Self::Isometric,
+ BoardGridKind::IsometricDots => Self::IsometricDots,
+ }
+ }
+}
+
+/// Paper decoration for a solid board. Config validation warns before clamping.
+#[cfg_attr(feature = "config-schema", derive(schemars::JsonSchema))]
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(default)]
+pub struct BoardGridConfig {
+ pub kind: BoardGridKindConfig,
+ /// Square/triangle side in logical board pixels, from 8 through 200.
+ #[cfg_attr(feature = "config-schema", schemars(range(min = 8, max = 200)))]
+ pub spacing: i64,
+}
+
+impl Default for BoardGridConfig {
+ fn default() -> Self {
+ Self {
+ kind: BoardGridKindConfig::None,
+ spacing: i64::from(BOARD_GRID_DEFAULT_SPACING),
+ }
+ }
+}
+
+impl From for BoardGrid {
+ fn from(value: BoardGridConfig) -> Self {
+ Self::new(value.kind.into(), value.spacing)
+ }
+}
+
+impl From for BoardGridConfig {
+ fn from(value: BoardGrid) -> Self {
+ Self {
+ kind: value.kind.into(),
+ spacing: i64::from(value.spacing()),
+ }
+ }
+}
diff --git a/src/config/types/boards.rs b/src/config/types/boards.rs
index 0d6c284fa..e251662b5 100644
--- a/src/config/types/boards.rs
+++ b/src/config/types/boards.rs
@@ -66,6 +66,7 @@ impl BoardsConfig {
id: "transparent".to_string(),
name: "Overlay".to_string(),
background: BoardBackgroundConfig::Transparent("transparent".to_string()),
+ grid: Default::default(),
default_pen_color: None,
auto_adjust_pen: false,
persist: true,
@@ -83,6 +84,7 @@ impl BoardsConfig {
background: BoardBackgroundConfig::Color(BoardColorConfig::Rgb(
legacy.whiteboard_color,
)),
+ grid: Default::default(),
default_pen_color: Some(BoardColorConfig::Rgb(legacy.whiteboard_pen_color)),
auto_adjust_pen: legacy.auto_adjust_pen,
persist: true,
@@ -94,6 +96,7 @@ impl BoardsConfig {
background: BoardBackgroundConfig::Color(BoardColorConfig::Rgb(
legacy.blackboard_color,
)),
+ grid: Default::default(),
default_pen_color: Some(BoardColorConfig::Rgb(legacy.blackboard_pen_color)),
auto_adjust_pen: legacy.auto_adjust_pen,
persist: true,
@@ -128,6 +131,10 @@ pub struct BoardItemConfig {
#[serde(default = "default_board_background")]
pub background: BoardBackgroundConfig,
+ /// Non-selectable paper decoration on a solid background.
+ #[serde(default)]
+ pub grid: super::BoardGridConfig,
+
/// Default pen color when auto-adjust is enabled.
#[serde(default)]
pub default_pen_color: Option,
@@ -229,6 +236,7 @@ fn default_board_items() -> Vec {
id: "whiteboard".to_string(),
name: "Whiteboard".to_string(),
background: BoardBackgroundConfig::Color(BoardColorConfig::Rgb([0.992, 0.992, 0.992])),
+ grid: Default::default(),
default_pen_color: Some(BoardColorConfig::Rgb([
PALETTE_BLACK.r,
PALETTE_BLACK.g,
@@ -242,6 +250,7 @@ fn default_board_items() -> Vec {
id: "blackboard".to_string(),
name: "Blackboard".to_string(),
background: BoardBackgroundConfig::Color(BoardColorConfig::Rgb([0.067, 0.067, 0.067])),
+ grid: Default::default(),
default_pen_color: Some(BoardColorConfig::Rgb([1.0, 1.0, 1.0])),
auto_adjust_pen: true,
persist: true,
@@ -251,6 +260,7 @@ fn default_board_items() -> Vec {
id: "blueprint".to_string(),
name: "Blueprint".to_string(),
background: BoardBackgroundConfig::Color(BoardColorConfig::Rgb([0.063, 0.125, 0.251])),
+ grid: Default::default(),
default_pen_color: Some(BoardColorConfig::Rgb([0.902, 0.945, 1.0])),
auto_adjust_pen: true,
persist: true,
@@ -260,6 +270,7 @@ fn default_board_items() -> Vec {
id: "corkboard".to_string(),
name: "Corkboard".to_string(),
background: BoardBackgroundConfig::Color(BoardColorConfig::Rgb([0.420, 0.294, 0.165])),
+ grid: Default::default(),
default_pen_color: Some(BoardColorConfig::Rgb([0.969, 0.890, 0.784])),
auto_adjust_pen: true,
persist: true,
diff --git a/src/config/types/mod.rs b/src/config/types/mod.rs
index 2a1cbe474..a3c3406d5 100644
--- a/src/config/types/mod.rs
+++ b/src/config/types/mod.rs
@@ -2,6 +2,7 @@
mod arrow;
mod board;
+mod board_grid;
mod boards;
mod capture;
mod click_highlight;
@@ -29,6 +30,7 @@ pub use arrow::{
ARROW_ANGLE_MAX, ARROW_ANGLE_MIN, ARROW_LENGTH_MAX, ARROW_LENGTH_MIN, ArrowConfig,
};
pub use board::BoardConfig;
+pub use board_grid::{BoardGridConfig, BoardGridKindConfig};
pub use boards::{BoardBackgroundConfig, BoardColorConfig, BoardItemConfig, BoardsConfig};
pub use capture::{
CaptureConfig, DEFAULT_OCR_LANGUAGES, RegionCaptureConfig, validate_capture_format,
diff --git a/src/config/validate/boards.rs b/src/config/validate/boards.rs
index 336050bee..c92dbdf5c 100644
--- a/src/config/validate/boards.rs
+++ b/src/config/validate/boards.rs
@@ -44,6 +44,7 @@ impl Config {
}
normalize_background(&mut item.background, &item.id);
+ normalize_grid(item);
if let Some(color) = item.default_pen_color.as_mut() {
clamp_color(
color,
@@ -84,6 +85,23 @@ impl Config {
}
}
+fn normalize_grid(item: &mut crate::config::BoardItemConfig) {
+ let mut grid = crate::domain::BoardGrid::from(item.grid);
+ if i64::from(grid.spacing()) != item.grid.spacing {
+ warn!(
+ "Board '{}' grid spacing {} is outside 8–200; using {}",
+ item.id,
+ item.grid.spacing,
+ grid.spacing()
+ );
+ }
+ if item.background.is_transparent() && grid.kind != crate::domain::BoardGridKind::None {
+ warn!("Board '{}' is transparent; disabling its grid", item.id);
+ grid = grid.disabled();
+ }
+ item.grid = grid.into();
+}
+
fn ensure_transparent_board_in_range(boards: &mut BoardsConfig) {
let transparent_in_range = boards
.items
diff --git a/src/domain/board.rs b/src/domain/board.rs
index 857f83ff1..111609fb5 100644
--- a/src/domain/board.rs
+++ b/src/domain/board.rs
@@ -1,10 +1,10 @@
-use super::Color;
+use super::{BoardGrid, Color};
pub const BOARD_ID_TRANSPARENT: &str = "transparent";
pub const BOARD_ID_WHITEBOARD: &str = "whiteboard";
pub const BOARD_ID_BLACKBOARD: &str = "blackboard";
-#[derive(Debug, Clone)]
+#[derive(Debug, Clone, PartialEq)]
pub enum BoardBackground {
Transparent,
Solid(Color),
@@ -21,6 +21,7 @@ pub struct BoardSpec {
pub id: String,
pub name: String,
pub background: BoardBackground,
+ pub grid: BoardGrid,
pub default_pen_color: Option,
pub auto_adjust_pen: bool,
pub persist: bool,
diff --git a/src/domain/board_grid.rs b/src/domain/board_grid.rs
new file mode 100644
index 000000000..355b19a3c
--- /dev/null
+++ b/src/domain/board_grid.rs
@@ -0,0 +1,72 @@
+//! Board-paper geometry, independent of rendering and serialization.
+
+pub const BOARD_GRID_MIN_SPACING: u16 = 8;
+pub const BOARD_GRID_MAX_SPACING: u16 = 200;
+pub const BOARD_GRID_DEFAULT_SPACING: u16 = 40;
+
+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Hash)]
+pub enum BoardGridKind {
+ #[default]
+ None,
+ Cartesian,
+ Isometric,
+ IsometricDots,
+}
+
+impl BoardGridKind {
+ pub const ALL: [Self; 4] = [
+ Self::None,
+ Self::Cartesian,
+ Self::Isometric,
+ Self::IsometricDots,
+ ];
+
+ pub fn label(self) -> &'static str {
+ match self {
+ Self::None => "None",
+ Self::Cartesian => "Cartesian",
+ Self::Isometric => "Isometric lines",
+ Self::IsometricDots => "Isometric dots",
+ }
+ }
+}
+
+/// A normalized grid. Spacing is a square/triangle side in logical board pixels.
+#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
+pub struct BoardGrid {
+ pub kind: BoardGridKind,
+ spacing: u16,
+}
+
+impl Default for BoardGrid {
+ fn default() -> Self {
+ Self {
+ kind: BoardGridKind::None,
+ spacing: BOARD_GRID_DEFAULT_SPACING,
+ }
+ }
+}
+
+impl BoardGrid {
+ pub fn new(kind: BoardGridKind, spacing: i64) -> Self {
+ Self {
+ kind,
+ spacing: spacing.clamp(
+ i64::from(BOARD_GRID_MIN_SPACING),
+ i64::from(BOARD_GRID_MAX_SPACING),
+ ) as u16,
+ }
+ }
+
+ pub fn spacing(self) -> u16 {
+ self.spacing
+ }
+
+ /// Disable decoration without discarding its spacing.
+ pub fn disabled(self) -> Self {
+ Self {
+ kind: BoardGridKind::None,
+ ..self
+ }
+ }
+}
diff --git a/src/domain/mod.rs b/src/domain/mod.rs
index d7cae2860..ec1f07da8 100644
--- a/src/domain/mod.rs
+++ b/src/domain/mod.rs
@@ -6,6 +6,7 @@
mod action;
mod board;
+mod board_grid;
mod board_validation;
pub mod color;
mod drawing;
@@ -16,6 +17,10 @@ pub use action::Action;
pub use board::{
BOARD_ID_BLACKBOARD, BOARD_ID_TRANSPARENT, BOARD_ID_WHITEBOARD, BoardBackground, BoardSpec,
};
+pub use board_grid::{
+ BOARD_GRID_DEFAULT_SPACING, BOARD_GRID_MAX_SPACING, BOARD_GRID_MIN_SPACING, BoardGrid,
+ BoardGridKind,
+};
pub use board_validation::{
BoardIdChangeSet, BoundaryBoardId, BoundaryBoardIdSet, clamp_board_rgb,
};
diff --git a/src/domain/tests.rs b/src/domain/tests.rs
index 989789b28..6fb0340eb 100644
--- a/src/domain/tests.rs
+++ b/src/domain/tests.rs
@@ -365,6 +365,7 @@ fn established_public_paths_reexport_domain_types() {
id: "board".to_string(),
name: "Board".to_string(),
background: BoardBackground::Transparent,
+ grid: Default::default(),
default_pen_color: None,
auto_adjust_pen: false,
persist: true,
@@ -396,7 +397,7 @@ fn production_domain_sources_have_no_upward_crate_dependencies() {
}
assert_eq!(
- checked, 8,
+ checked, 9,
"architecture test must cover every domain source"
);
}
diff --git a/src/draw/mod.rs b/src/draw/mod.rs
index b7961730b..85a6fd864 100644
--- a/src/draw/mod.rs
+++ b/src/draw/mod.rs
@@ -35,16 +35,16 @@ pub(crate) use render::render_sticky_note_preview_with_measurer;
pub(crate) use render::with_saved_state;
#[allow(unused_imports)]
pub use render::{
- BlurRectParams, EraserReplayContext, IMMUTABLE_RASTER_SOURCE_TOKEN, RenderCaches, RenderCtx,
- SpotlightMagnifierMetrics, SpotlightMagnifierOutcome, SpotlightMagnifierScratch,
+ BlurRectParams, BoardPaper, EraserReplayContext, IMMUTABLE_RASTER_SOURCE_TOKEN, RenderCaches,
+ RenderCtx, SpotlightMagnifierMetrics, SpotlightMagnifierOutcome, SpotlightMagnifierScratch,
SpotlightMagnifierSource, SpotlightPass, SpotlightRegion, SpotlightSnapshotStrategy,
- caret_line_width, caret_outline_width, painted_background_luminance, perceived_luminance,
- render_blur_rect, render_board_background, render_click_highlight, render_freehand_borrowed,
- render_marker_stroke_borrowed, render_selection_halo, render_selection_halo_with_measurer,
- render_selection_handles, render_shape, render_shape_over, render_shape_over_with_halo,
- render_shape_with_halo, render_spotlight_magnification_pass, render_spotlight_pass,
- render_sticky_note, render_sticky_note_with_measurer, render_text, render_text_over_with_halo,
- render_text_over_with_halo_with_measurer, render_text_with_halo,
+ board_paper_device_scale, caret_line_width, caret_outline_width, painted_background_luminance,
+ perceived_luminance, render_blur_rect, render_board_background, render_click_highlight,
+ render_freehand_borrowed, render_marker_stroke_borrowed, render_selection_halo,
+ render_selection_halo_with_measurer, render_selection_handles, render_shape, render_shape_over,
+ render_shape_over_with_halo, render_shape_with_halo, render_spotlight_magnification_pass,
+ render_spotlight_pass, render_sticky_note, render_sticky_note_with_measurer, render_text,
+ render_text_over_with_halo, render_text_over_with_halo_with_measurer, render_text_with_halo,
render_text_with_halo_with_measurer, render_text_with_measurer, selection_handle_rects,
spotlight_regions_for_frame, sticky_note_foreground, text_outline_color,
};
diff --git a/src/draw/render/board_grid.rs b/src/draw/render/board_grid.rs
new file mode 100644
index 000000000..6a19f9635
--- /dev/null
+++ b/src/draw/render/board_grid.rs
@@ -0,0 +1,211 @@
+//! Procedural board paper shared by canvas paint, eraser replay, and exports.
+
+use cairo::{Context, RecordingSurface, Rectangle, SurfacePattern};
+
+use crate::domain::{BoardGrid, BoardGridKind, Color};
+
+use super::primitives::with_saved_state;
+
+/// A compact, world-anchored tile. Screen targets reuse a small raster source
+/// during erasing; vector targets retain the recording. Neither resource
+/// escapes into board state or persisted snapshots.
+pub struct BoardPaper {
+ pattern: SurfacePattern,
+}
+
+impl BoardPaper {
+ pub fn for_context(
+ color: Color,
+ grid: BoardGrid,
+ target: &Context,
+ ) -> Result {
+ let device_scale = board_paper_device_scale(target);
+ let origin = target.device_to_user(0.0, 0.0)?;
+ if !device_scale.is_finite() || device_scale <= 0.0 {
+ return Err(cairo::Error::InvalidMatrix);
+ }
+ let (width, height) = tile_size(grid);
+ let bounds = Rectangle::new(0.0, 0.0, width, height);
+ let raster = target.target().type_() == cairo::SurfaceType::Image;
+ // Cap a procedural tile at 4x density (under 4.5 MiB at max spacing).
+ // This also bounds allocations under unusually large zoom transforms.
+ let (source_width, source_height) = if raster {
+ (
+ (width * device_scale.min(4.0)).ceil().max(1.0),
+ (height * device_scale.min(4.0)).ceil().max(1.0),
+ )
+ } else {
+ (1.0, 1.0)
+ };
+ let surface: cairo::Surface = if raster {
+ cairo::ImageSurface::create(
+ cairo::Format::ARgb32,
+ source_width as i32,
+ source_height as i32,
+ )?
+ .as_ref()
+ .clone()
+ } else {
+ // Fractional recording extents are rounded by Cairo when repeating
+ // and can leave translucent seams. Record in an integer unit cell.
+ RecordingSurface::create(
+ cairo::Content::ColorAlpha,
+ Some(Rectangle::new(0.0, 0.0, 1.0, 1.0)),
+ )?
+ .as_ref()
+ .clone()
+ };
+ let ctx = Context::new(&surface)?;
+ ctx.scale(source_width / width, source_height / height);
+ paint_geometry(&ctx, color, grid, device_scale, bounds)?;
+ let pattern = SurfacePattern::create(&surface);
+ pattern.set_extend(cairo::Extend::Repeat);
+ let mut matrix = cairo::Matrix::identity();
+ matrix.scale(source_width / width, source_height / height);
+ // Cairo recording replay has a finite coordinate range. Whole-cell
+ // translation preserves world phase while keeping its source near zero.
+ matrix.set_x0(-(origin.0 / width).floor() * source_width);
+ matrix.set_y0(-(origin.1 / height).floor() * source_height);
+ pattern.set_matrix(matrix);
+ Ok(Self { pattern })
+ }
+
+ /// The context must already map board-world coordinates to the target.
+ /// Paint does not touch its current path or leak source/operator state.
+ pub fn paint(&self, ctx: &Context) -> Result<(), cairo::Error> {
+ with_saved_state(ctx, || {
+ ctx.set_operator(cairo::Operator::Over);
+ ctx.set_source(&self.pattern)?;
+ ctx.paint()
+ })
+ }
+
+ pub fn pattern(&self) -> &cairo::Pattern {
+ self.pattern.as_ref()
+ }
+}
+
+/// Smallest target-axis scale, including Cairo surface device scaling.
+pub fn board_paper_device_scale(ctx: &Context) -> f64 {
+ let matrix = ctx.matrix();
+ let (dx, dy) = ctx.target().device_scale();
+ (matrix.xx() * dx)
+ .hypot(matrix.yx() * dy)
+ .min((matrix.xy() * dx).hypot(matrix.yy() * dy))
+}
+
+fn tile_size(grid: BoardGrid) -> (f64, f64) {
+ let s = f64::from(grid.spacing());
+ match grid.kind {
+ BoardGridKind::None => (1.0, 1.0),
+ BoardGridKind::Cartesian => (s, s),
+ BoardGridKind::Isometric | BoardGridKind::IsometricDots => (3.0_f64.sqrt() * s, s),
+ }
+}
+
+/// Also used by the full-viewport recording performance comparison. Work is
+/// bounded by the requested region, never by distance from the world origin.
+fn paint_geometry(
+ ctx: &Context,
+ color: Color,
+ grid: BoardGrid,
+ device_scale: f64,
+ bounds: Rectangle,
+) -> Result<(), cairo::Error> {
+ let path = ctx.copy_path()?;
+ let result = with_saved_state(ctx, || {
+ ctx.new_path();
+ ctx.rectangle(bounds.x(), bounds.y(), bounds.width(), bounds.height());
+ ctx.clip();
+ ctx.set_source_rgba(color.r, color.g, color.b, color.a);
+ ctx.paint()?;
+ if grid.kind == BoardGridKind::None {
+ return Ok(());
+ }
+ let s = f64::from(grid.spacing());
+ let fade = (s * device_scale / 3.0).clamp(0.0, 1.0);
+ let ink = if super::perceived_luminance(color.r, color.g, color.b) > 0.5 {
+ 0.0
+ } else {
+ 1.0
+ };
+ let alpha = if grid.kind == BoardGridKind::IsometricDots {
+ 0.28
+ } else {
+ 0.18
+ };
+ ctx.set_source_rgba(ink, ink, ink, alpha * fade);
+ ctx.set_line_width(1.0);
+ ctx.set_line_cap(cairo::LineCap::Butt);
+ ctx.set_line_join(cairo::LineJoin::Miter);
+ ctx.set_dash(&[], 0.0);
+ match grid.kind {
+ BoardGridKind::None => {}
+ BoardGridKind::Cartesian => cartesian_path(ctx, s, bounds),
+ BoardGridKind::Isometric => isometric_path(ctx, s, bounds),
+ BoardGridKind::IsometricDots => isometric_dots(ctx, s, bounds),
+ }
+ if grid.kind == BoardGridKind::IsometricDots {
+ ctx.fill()
+ } else {
+ ctx.stroke()
+ }
+ });
+ ctx.new_path();
+ ctx.append_path(&path);
+ result
+}
+
+fn indices(min: f64, max: f64, spacing: f64) -> std::ops::RangeInclusive {
+ (min / spacing).floor() as i64..=(max / spacing).ceil() as i64
+}
+
+fn cartesian_path(ctx: &Context, s: f64, b: Rectangle) {
+ for n in indices(b.x() - 1.0, b.x() + b.width() + 1.0, s) {
+ let x = n as f64 * s;
+ ctx.move_to(x, b.y() - 1.0);
+ ctx.line_to(x, b.y() + b.height() + 1.0);
+ }
+ for n in indices(b.y() - 1.0, b.y() + b.height() + 1.0, s) {
+ let y = n as f64 * s;
+ ctx.move_to(b.x() - 1.0, y);
+ ctx.line_to(b.x() + b.width() + 1.0, y);
+ }
+}
+
+fn isometric_path(ctx: &Context, s: f64, b: Rectangle) {
+ let column = 3.0_f64.sqrt() * s / 2.0;
+ for n in indices(b.x() - 1.0, b.x() + b.width() + 1.0, column) {
+ let x = n as f64 * column;
+ ctx.move_to(x, b.y() - 1.0);
+ ctx.line_to(x, b.y() + b.height() + 1.0);
+ }
+ let left = b.x() - 2.0;
+ let right = b.x() + b.width() + 2.0;
+ for slope in [-1.0 / 3.0_f64.sqrt(), 1.0 / 3.0_f64.sqrt()] {
+ let min = b.y() - (slope * left).max(slope * right) - 2.0;
+ let max = b.y() + b.height() - (slope * left).min(slope * right) + 2.0;
+ for n in indices(min, max, s) {
+ let intercept = n as f64 * s;
+ ctx.move_to(left, slope * left + intercept);
+ ctx.line_to(right, slope * right + intercept);
+ }
+ }
+}
+
+fn isometric_dots(ctx: &Context, s: f64, b: Rectangle) {
+ let column = 3.0_f64.sqrt() * s / 2.0;
+ for i in indices(b.x() - 1.25, b.x() + b.width() + 1.25, column) {
+ let x = i as f64 * column;
+ let shift = i.rem_euclid(2) as f64 * s / 2.0;
+ for j in indices(b.y() - shift - 1.25, b.y() + b.height() - shift + 1.25, s) {
+ ctx.new_sub_path();
+ ctx.arc(x, j as f64 * s + shift, 1.25, 0.0, std::f64::consts::TAU);
+ }
+ }
+}
+
+#[cfg(test)]
+mod performance;
+#[cfg(test)]
+mod tests;
diff --git a/src/draw/render/board_grid/performance.rs b/src/draw/render/board_grid/performance.rs
new file mode 100644
index 000000000..0424dd44e
--- /dev/null
+++ b/src/draw/render/board_grid/performance.rs
@@ -0,0 +1,152 @@
+//! Opt-in source comparison; no compositor, font stack, or desktop windows.
+use super::*;
+use crate::draw::{EraserBrush, EraserKind, EraserReplayContext};
+use std::time::Instant;
+
+#[derive(Debug, Clone, Copy)]
+enum Source {
+ Solid,
+ Viewport,
+ Tile,
+}
+
+fn render_case(
+ source: Source,
+ kind: BoardGridKind,
+ spacing: i64,
+ size: (i32, i32),
+ erasers: usize,
+ pdf: bool,
+) -> (u128, usize) {
+ let start = Instant::now();
+ let target: cairo::Surface = if pdf {
+ cairo::PdfSurface::for_stream(size.0 as f64, size.1 as f64, Vec::::new())
+ .unwrap()
+ .as_ref()
+ .clone()
+ } else {
+ cairo::ImageSurface::create(cairo::Format::ARgb32, size.0, size.1)
+ .unwrap()
+ .as_ref()
+ .clone()
+ };
+ let ctx = Context::new(&target).unwrap();
+ ctx.translate(1_000_021.0, 2_000_003.0);
+ let grid = BoardGrid::new(kind, spacing);
+ let pattern = match source {
+ Source::Solid => None,
+ Source::Tile => Some(
+ BoardPaper::for_context(crate::draw::WHITE, grid, &ctx)
+ .unwrap()
+ .pattern,
+ ),
+ Source::Viewport => {
+ let bounds = Rectangle::new(-1_000_021.0, -2_000_003.0, size.0 as f64, size.1 as f64);
+ let record =
+ RecordingSurface::create(cairo::Content::ColorAlpha, Some(bounds)).unwrap();
+ paint_geometry(
+ &Context::new(&record).unwrap(),
+ crate::draw::WHITE,
+ grid,
+ 1.0,
+ bounds,
+ )
+ .unwrap();
+ Some(SurfacePattern::create(&record))
+ }
+ };
+ if let Some(p) = pattern.as_ref() {
+ ctx.set_source(p).unwrap();
+ } else {
+ ctx.set_source_rgb(1.0, 1.0, 1.0);
+ }
+ ctx.paint().unwrap();
+ // Stable annotation workload, including content under eraser strokes.
+ ctx.set_source_rgb(0.2, 0.3, 0.7);
+ ctx.set_line_width(3.0);
+ for n in 0..80 {
+ let y = -2_000_003.0 + f64::from(n * 19);
+ ctx.move_to(-1_000_021.0, y);
+ ctx.line_to(-1_000_021.0 + f64::from(size.0), y + 60.0);
+ }
+ ctx.stroke().unwrap();
+ let replay = EraserReplayContext {
+ pattern: pattern.as_ref().map(|p| p.as_ref()),
+ surface: None,
+ backdrop_cache_key: None,
+ bg_color: Some(crate::draw::WHITE),
+ logical_to_image_scale_x: 1.0,
+ logical_to_image_scale_y: 1.0,
+ logical_image_origin_x: 0.0,
+ logical_image_origin_y: 0.0,
+ };
+ // The direct raster path also replays one provisional eraser. Exports do not.
+ for n in 0..erasers + usize::from(!pdf) {
+ let x = -1_000_021 + (n as i32 * 73 % size.0);
+ let y = -2_000_003 + (n as i32 * 47 % size.1);
+ let brush = EraserBrush {
+ kind: if n % 2 == 0 {
+ EraserKind::Circle
+ } else {
+ EraserKind::Rect
+ },
+ size: 32.0,
+ };
+ super::super::strokes::render_eraser_stroke(
+ &ctx,
+ &[(x, y), (x + 100, y + 24), (x + 180, y + 10)],
+ &brush,
+ &replay,
+ );
+ }
+ drop(ctx);
+ let bytes = if pdf {
+ target
+ .finish_output_stream()
+ .unwrap()
+ .downcast::>()
+ .unwrap()
+ .len()
+ } else {
+ target.flush();
+ size.0 as usize * size.1 as usize * 4
+ };
+ (start.elapsed().as_micros(), bytes)
+}
+
+#[test]
+#[ignore = "opt-in board-paper CPU/PDF comparison; run with --nocapture --test-threads=1"]
+fn board_grid_backdrop_performance() {
+ let sources: &[Source] = if std::env::var_os("WAYSCRIBER_GRID_COMPARE_VIEWPORT").is_some() {
+ &[Source::Solid, Source::Viewport, Source::Tile]
+ } else {
+ &[Source::Solid, Source::Tile]
+ };
+ println!("source,kind,spacing,width,height,erasers,pdf,median_us,p95_us,target_or_pdf_bytes");
+ for size in [(1920, 1080), (3840, 2160)] {
+ for kind in BoardGridKind::ALL.into_iter().skip(1) {
+ for spacing in [8, 40] {
+ for erasers in [0, 20, 200] {
+ for pdf in [false, true] {
+ for &source in sources {
+ render_case(source, kind, spacing, size, erasers, pdf);
+ let mut samples = Vec::new();
+ let mut bytes = 0;
+ for _ in 0..5 {
+ let (time, n) =
+ render_case(source, kind, spacing, size, erasers, pdf);
+ samples.push(time);
+ bytes = n;
+ }
+ samples.sort_unstable();
+ println!(
+ "{source:?},{kind:?},{spacing},{},{},{erasers},{pdf},{},{},{bytes}",
+ size.0, size.1, samples[2], samples[4]
+ );
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/src/draw/render/board_grid/tests.rs b/src/draw/render/board_grid/tests.rs
new file mode 100644
index 000000000..0830e6169
--- /dev/null
+++ b/src/draw/render/board_grid/tests.rs
@@ -0,0 +1,182 @@
+use super::*;
+use crate::draw::{EraserBrush, EraserKind, EraserReplayContext};
+
+fn pixels(kind: BoardGridKind, scale: f64, origin: (f64, f64), tiled: bool) -> Vec {
+ let mut surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 180, 140).unwrap();
+ let ctx = Context::new(&surface).unwrap();
+ ctx.scale(scale, scale);
+ ctx.translate(-origin.0, -origin.1);
+ let grid = BoardGrid::new(kind, 20);
+ let color = Color::new(1.0, 1.0, 1.0, 1.0);
+ if tiled {
+ BoardPaper::for_context(color, grid, &ctx)
+ .unwrap()
+ .paint(&ctx)
+ .unwrap();
+ } else {
+ paint_geometry(
+ &ctx,
+ color,
+ grid,
+ scale,
+ Rectangle::new(origin.0, origin.1, 180.0 / scale, 140.0 / scale),
+ )
+ .unwrap();
+ }
+ drop(ctx);
+ surface.data().unwrap().to_vec()
+}
+
+#[test]
+fn board_grid_tiles_match_world_geometry_at_negative_origins_and_scales() {
+ for kind in BoardGridKind::ALL {
+ for scale in [1.0, 1.25, 2.0] {
+ for origin in [(0.0, 0.0), (-71.0, -53.0), (-1_000_021.0, -2_000_003.0)] {
+ let a = pixels(kind, scale, origin, true);
+ let b = pixels(kind, scale, origin, false);
+ let error: u64 = a
+ .iter()
+ .zip(&b)
+ .map(|(x, y)| u64::from(x.abs_diff(*y)))
+ .sum();
+ // Subpixel edges may rasterize differently in repeated recordings.
+ assert!(
+ error as f64 / (a.len() as f64) < 3.0,
+ "{kind:?} {scale} {origin:?}: mean channel error {} first tile {:?}, direct {:?}",
+ error as f64 / a.len() as f64,
+ &a[..4],
+ &b[..4]
+ );
+ }
+ }
+ }
+}
+
+#[test]
+fn board_grid_paint_preserves_incoming_path_and_cairo_state() {
+ let surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 80, 80).unwrap();
+ let ctx = Context::new(&surface).unwrap();
+ ctx.move_to(3.0, 7.0);
+ ctx.line_to(55.0, 66.0);
+ let before = format!("{:?}", ctx.copy_path().unwrap().iter().collect::>());
+ ctx.set_line_width(9.0);
+ let matrix = ctx.matrix();
+ let grid = BoardGrid::new(BoardGridKind::Isometric, 40);
+ paint_geometry(
+ &ctx,
+ crate::draw::WHITE,
+ grid,
+ 1.0,
+ Rectangle::new(0.0, 0.0, 80.0, 80.0),
+ )
+ .unwrap();
+ BoardPaper::for_context(crate::draw::WHITE, grid, &ctx)
+ .unwrap()
+ .paint(&ctx)
+ .unwrap();
+ assert_eq!(
+ before,
+ format!("{:?}", ctx.copy_path().unwrap().iter().collect::>())
+ );
+ assert_eq!(ctx.line_width(), 9.0);
+ assert_eq!(ctx.matrix(), matrix);
+}
+
+#[test]
+fn board_grid_eraser_replays_paper_instead_of_removing_lines_or_dots() {
+ for kind in BoardGridKind::ALL.into_iter().skip(1) {
+ for brush_kind in [EraserKind::Circle, EraserKind::Rect] {
+ let mut surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 100, 100).unwrap();
+ let ctx = Context::new(&surface).unwrap();
+ ctx.translate(9.0, 7.0);
+ let paper = BoardPaper::for_context(crate::draw::WHITE, BoardGrid::new(kind, 20), &ctx)
+ .unwrap();
+ paper.paint(&ctx).unwrap();
+ let original = {
+ let mut copy =
+ cairo::ImageSurface::create(cairo::Format::ARgb32, 100, 100).unwrap();
+ let c = Context::new(©).unwrap();
+ c.set_source_surface(&surface, 0.0, 0.0).unwrap();
+ c.paint().unwrap();
+ drop(c);
+ copy.data().unwrap().to_vec()
+ };
+ ctx.set_source_rgb(1.0, 0.0, 0.0);
+ ctx.rectangle(20.0, 20.0, 20.0, 20.0);
+ ctx.fill().unwrap();
+ let replay = EraserReplayContext {
+ pattern: Some(paper.pattern()),
+ surface: None,
+ backdrop_cache_key: None,
+ bg_color: Some(crate::draw::WHITE),
+ logical_to_image_scale_x: 1.0,
+ logical_to_image_scale_y: 1.0,
+ logical_image_origin_x: 0.0,
+ logical_image_origin_y: 0.0,
+ };
+ super::super::strokes::render_eraser_stroke(
+ &ctx,
+ &[(10, 30), (50, 30)],
+ &EraserBrush {
+ kind: brush_kind,
+ size: 48.0,
+ },
+ &replay,
+ );
+ drop(ctx);
+ let stride = surface.stride() as usize;
+ let data = surface.data().unwrap();
+ for y in 27..46 {
+ for x in 29..48 {
+ let i = y * stride + x * 4;
+ assert!(
+ data[i..i + 4]
+ .iter()
+ .zip(&original[i..i + 4])
+ .all(|(a, b)| a.abs_diff(*b) <= 2),
+ "{kind:?} {brush_kind:?} at {x},{y}"
+ );
+ }
+ }
+ }
+ }
+}
+
+#[test]
+fn board_grid_spacing_and_isometric_basis_are_stable() {
+ let g = BoardGrid::new(BoardGridKind::Isometric, i64::MIN);
+ assert_eq!(g.spacing(), 8);
+ assert_eq!(BoardGrid::new(g.kind, i64::MAX).spacing(), 200);
+ assert_eq!(g.disabled().spacing(), 8);
+ let (width, height) = tile_size(g);
+ assert!((width.hypot(height) / 2.0 - f64::from(g.spacing())).abs() < 1e-10);
+ assert_eq!(indices(-41.0, -1.0, 20.0), -3..=0);
+}
+
+#[test]
+fn board_grid_tiles_stay_opaque_and_show_expected_vertices() {
+ for kind in BoardGridKind::ALL.into_iter().skip(1) {
+ for scale in [1.0, 1.25, 2.0] {
+ let data = pixels(kind, scale, (0.0, 0.0), true);
+ assert!(
+ data.as_chunks::<4>().0.iter().all(|pixel| pixel[3] == 255),
+ "{kind:?} has a transparent tile seam"
+ );
+ let (x, y) = if kind == BoardGridKind::Cartesian {
+ (20.0, 20.0)
+ } else {
+ (3.0_f64.sqrt() * 10.0, 10.0)
+ };
+ let (x, y) = ((x * scale) as usize, (y * scale) as usize);
+ let darkest = (y - 1..=y + 1)
+ .flat_map(|y| (x - 1..=x + 1).map(move |x| (y * 180 + x) * 4))
+ .map(|i| data[i])
+ .min()
+ .unwrap();
+ assert!(
+ darkest < 240,
+ "{kind:?} missing grid vertex at scale {scale}"
+ );
+ }
+ }
+}
diff --git a/src/draw/render/mod.rs b/src/draw/render/mod.rs
index abaff7bec..a950e6430 100644
--- a/src/draw/render/mod.rs
+++ b/src/draw/render/mod.rs
@@ -6,6 +6,7 @@ pub use backdrop_probe::painted_luminance as painted_background_luminance;
pub use backdrop_probe::perceived_luminance;
mod background;
mod blur;
+mod board_grid;
mod context;
mod highlight;
mod image;
@@ -20,6 +21,7 @@ mod types;
pub use background::{fill_transparent, render_board_background};
pub use blur::{BlurRectParams, render_blur_rect};
+pub use board_grid::{BoardPaper, board_paper_device_scale};
pub use context::{RenderCaches, RenderCtx};
pub use highlight::render_click_highlight;
#[allow(unused_imports)]
diff --git a/src/input/boards.rs b/src/input/boards.rs
index 85fe7a921..69af7df2a 100644
--- a/src/input/boards.rs
+++ b/src/input/boards.rs
@@ -1,4 +1,6 @@
+mod appearance;
mod color;
+pub use appearance::{BoardAppearance, BoardPenOrigin};
mod core;
mod identity;
mod mapping;
@@ -36,11 +38,17 @@ pub use operations::{
pub struct BoardState {
pub spec: BoardSpec,
pub pages: BoardPages,
+ pub appearance_explicit: bool,
+ pub pen_origin: BoardPenOrigin,
+ configured_appearance: BoardAppearance,
}
impl BoardState {
pub fn new(spec: BoardSpec) -> Self {
Self {
+ configured_appearance: BoardAppearance::from_spec(&spec),
+ appearance_explicit: false,
+ pen_origin: BoardPenOrigin::Configured,
spec,
pages: BoardPages::new(),
}
diff --git a/src/input/boards/appearance.rs b/src/input/boards/appearance.rs
new file mode 100644
index 000000000..4d1c96d38
--- /dev/null
+++ b/src/input/boards/appearance.rs
@@ -0,0 +1,59 @@
+use super::{BoardBackground, BoardManager, BoardSpec, BoardState};
+use crate::domain::{BoardGrid, Color};
+use serde::{Deserialize, Serialize};
+
+#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
+#[serde(rename_all = "kebab-case")]
+pub enum BoardPenOrigin {
+ #[default]
+ Configured,
+ RuntimeContrast,
+}
+
+/// The reference paper and its board-entry pen policy, independent of drawing history.
+#[derive(Debug, Clone, PartialEq)]
+pub struct BoardAppearance {
+ pub background: BoardBackground,
+ pub grid: BoardGrid,
+ pub default_pen_color: Option,
+ pub auto_adjust_pen: bool,
+}
+
+impl BoardAppearance {
+ pub fn from_spec(spec: &BoardSpec) -> Self {
+ Self {
+ background: spec.background.clone(),
+ grid: spec.grid,
+ default_pen_color: spec.default_pen_color,
+ auto_adjust_pen: spec.auto_adjust_pen,
+ }
+ }
+
+ pub fn apply_to(&self, spec: &mut BoardSpec) {
+ spec.background = self.background.clone();
+ spec.grid = if self.background.is_transparent() {
+ self.grid.disabled()
+ } else {
+ self.grid
+ };
+ spec.default_pen_color = self.default_pen_color;
+ spec.auto_adjust_pen = self.auto_adjust_pen;
+ }
+}
+
+impl BoardState {
+ /// Reset from the immutable configured/template seed, never from a prior session.
+ pub(crate) fn reset_appearance(&mut self) {
+ self.configured_appearance.apply_to(&mut self.spec);
+ self.appearance_explicit = false;
+ self.pen_origin = BoardPenOrigin::Configured;
+ }
+}
+
+impl BoardManager {
+ pub(crate) fn reset_appearances(&mut self) {
+ for board in &mut self.boards {
+ board.reset_appearance();
+ }
+ }
+}
diff --git a/src/input/boards/mapping.rs b/src/input/boards/mapping.rs
index 9b0b9342a..5be88c2fd 100644
--- a/src/input/boards/mapping.rs
+++ b/src/input/boards/mapping.rs
@@ -32,6 +32,11 @@ impl BoardSpec {
id: item.id.clone(),
name: item.name.clone(),
background: board_background_from_config(&item.background),
+ grid: if item.background.is_transparent() {
+ crate::domain::BoardGrid::from(item.grid).disabled()
+ } else {
+ item.grid.into()
+ },
default_pen_color: item.default_pen_color.as_ref().map(board_color_from_config),
auto_adjust_pen: item.auto_adjust_pen,
persist: item.persist,
@@ -118,6 +123,7 @@ impl BoardManager {
id: board.spec.id.clone(),
name: board.spec.name.clone(),
background: board_background_to_config(&board.spec.background),
+ grid: board.spec.grid.into(),
default_pen_color: board.spec.default_pen_color.map(board_color_to_config),
auto_adjust_pen: board.spec.auto_adjust_pen,
persist: board.spec.persist,
@@ -161,6 +167,7 @@ fn default_overlay_board() -> BoardState {
id: BOARD_ID_TRANSPARENT.to_string(),
name: "Overlay".to_string(),
background: BoardBackground::Transparent,
+ grid: Default::default(),
default_pen_color: None,
auto_adjust_pen: false,
persist: true,
@@ -202,6 +209,7 @@ fn pick_template(boards: &[BoardState]) -> BoardSpec {
b: 0.992,
a: 1.0,
}),
+ grid: Default::default(),
default_pen_color: Some(PALETTE_BLACK),
auto_adjust_pen: true,
persist: true,
diff --git a/src/input/boards/naming.rs b/src/input/boards/naming.rs
index 708a9efda..92efc76d5 100644
--- a/src/input/boards/naming.rs
+++ b/src/input/boards/naming.rs
@@ -92,6 +92,7 @@ impl BoardManager {
id: id.to_string(),
name: "Overlay".to_string(),
background: BoardBackground::Transparent,
+ grid: Default::default(),
default_pen_color: None,
auto_adjust_pen: false,
persist: true,
@@ -143,6 +144,9 @@ impl BoardManager {
let mut new_board = BoardState::new(new_spec.clone());
// Clone pages from the active board
new_board.pages = active.pages.clone();
+ new_board.appearance_explicit = active.appearance_explicit;
+ new_board.pen_origin = active.pen_origin;
+ new_board.configured_appearance = active.configured_appearance.clone();
let insert_at = self.active_index + 1;
self.pin_seeds.insert(new_spec.id.clone(), source_pin_seed);
diff --git a/src/input/state/actions/key_press/panels.rs b/src/input/state/actions/key_press/panels.rs
index 47e5a6dd5..9fbac15a9 100644
--- a/src/input/state/actions/key_press/panels.rs
+++ b/src/input/state/actions/key_press/panels.rs
@@ -73,6 +73,10 @@ impl InputState {
return false;
}
+ if self.board_appearance_key(key) {
+ return true;
+ }
+
if self.board_picker_page_edit_state().is_some() {
match key {
Key::Escape => {
diff --git a/src/input/state/core/board.rs b/src/input/state/core/board.rs
index a4d6a1cbd..2f7f5b239 100644
--- a/src/input/state/core/board.rs
+++ b/src/input/state/core/board.rs
@@ -1,3 +1,4 @@
+mod appearance;
mod delete_restore;
mod lifecycle;
mod pages;
diff --git a/src/input/state/core/board/appearance.rs b/src/input/state/core/board/appearance.rs
new file mode 100644
index 000000000..59a140915
--- /dev/null
+++ b/src/input/state/core/board/appearance.rs
@@ -0,0 +1,43 @@
+use crate::domain::BoardBackground;
+use crate::input::InputState;
+use crate::input::boards::{BoardAppearance, BoardPenOrigin};
+
+impl InputState {
+ /// Publish one appearance change without touching drawing history.
+ pub(in crate::input::state::core) fn apply_board_appearance_value(
+ &mut self,
+ index: usize,
+ appearance: BoardAppearance,
+ ) -> bool {
+ let active = index == self.boards.active_index();
+ let Some(board) = self.boards.board_state_mut(index) else {
+ return false;
+ };
+ let current = BoardAppearance::from_spec(&board.spec);
+ if current == appearance
+ || current.background.is_transparent()
+ || appearance.background.is_transparent()
+ {
+ return false;
+ }
+ let recolored = current.background != appearance.background;
+ appearance.apply_to(&mut board.spec);
+ let pen = if recolored && board.spec.auto_adjust_pen {
+ let BoardBackground::Solid(color) = board.spec.background else {
+ unreachable!()
+ };
+ let pen = crate::input::runtime_contrast_pen_color(color);
+ board.spec.default_pen_color = Some(pen);
+ board.pen_origin = BoardPenOrigin::RuntimeContrast;
+ active.then_some(pen)
+ } else {
+ None
+ };
+ board.appearance_explicit = true;
+ if let Some(pen) = pen {
+ self.set_pen_color_from_board(pen);
+ }
+ self.mark_board_surface_changed();
+ true
+ }
+}
diff --git a/src/input/state/core/board/lifecycle.rs b/src/input/state/core/board/lifecycle.rs
index c08f24f88..963bf3c4b 100644
--- a/src/input/state/core/board/lifecycle.rs
+++ b/src/input/state/core/board/lifecycle.rs
@@ -8,7 +8,7 @@ impl InputState {
self.needs_redraw = true;
}
- pub(super) fn mark_board_surface_changed(&mut self) {
+ pub(in crate::input::state::core) fn mark_board_surface_changed(&mut self) {
self.mark_board_surface_dirty();
self.mark_session_dirty();
}
diff --git a/src/input/state/core/board/pages.rs b/src/input/state/core/board/pages.rs
index e272d2852..baa559a5a 100644
--- a/src/input/state/core/board/pages.rs
+++ b/src/input/state/core/board/pages.rs
@@ -1,9 +1,9 @@
use super::super::base::InputState;
use crate::draw::Color;
use crate::draw::TextMeasurer;
+use crate::input::BoardBackground;
use crate::input::boards::PendingBoardRuntimeUiAction;
use crate::input::state::{Toast, ToastPriority};
-use crate::input::{BoardBackground, runtime_contrast_pen_color};
impl InputState {
pub(crate) fn reset_active_canvas_position(&mut self) -> bool {
@@ -45,8 +45,7 @@ impl InputState {
}
pub(crate) fn set_board_background_color(&mut self, index: usize, color: Color) -> bool {
- let is_active = self.boards.active_index() == index;
- let Some(board) = self.boards.board_state_mut(index) else {
+ let Some(board) = self.boards.board_states().get(index) else {
return false;
};
if board.spec.background.is_transparent() {
@@ -57,22 +56,9 @@ impl InputState {
);
return false;
}
- if matches!(board.spec.background, BoardBackground::Solid(existing) if existing == color) {
- return false;
- }
-
- board.spec.background = BoardBackground::Solid(color);
- let active_pen_color = if board.spec.auto_adjust_pen {
- board.spec.default_pen_color = Some(runtime_contrast_pen_color(color));
- is_active.then(|| board.spec.effective_pen_color().unwrap_or(color))
- } else {
- None
- };
- if let Some(color) = active_pen_color {
- self.set_pen_color_from_board(color);
- }
- self.mark_board_surface_dirty();
- true
+ let mut appearance = crate::input::boards::BoardAppearance::from_spec(&board.spec);
+ appearance.background = BoardBackground::Solid(color);
+ self.apply_board_appearance_value(index, appearance)
}
pub(crate) fn request_board_pin_toggle(&mut self, index: usize) -> bool {
diff --git a/src/input/state/core/board/switch.rs b/src/input/state/core/board/switch.rs
index 8c1fd8846..de43b2da4 100644
--- a/src/input/state/core/board/switch.rs
+++ b/src/input/state/core/board/switch.rs
@@ -21,6 +21,36 @@ impl InputState {
self.board_transitions.replace_recent_for_test(recent);
}
+ /// Resolve appearance before saved tools, including a restore to the same board ID.
+ pub(crate) fn restore_board_pen_after_snapshot(
+ &mut self,
+ previous_auto: bool,
+ previous_pen: crate::draw::Color,
+ previous_color: Option,
+ ) {
+ let spec = &self.boards.active_board().spec;
+ let target_auto = spec.auto_adjust_pen && !spec.background.is_transparent();
+ let color = spec.effective_pen_color();
+ if target_auto {
+ self.set_board_previous_color(if previous_auto {
+ previous_color
+ } else {
+ Some(previous_pen)
+ });
+ if let Some(color) = color {
+ self.set_pen_color_from_board(color);
+ }
+ } else if previous_auto {
+ self.set_board_previous_color(None);
+ if let Some(color) = previous_color {
+ self.set_pen_color_from_board(color);
+ }
+ } else {
+ self.set_board_previous_color(previous_color);
+ self.set_pen_color_from_board(previous_pen);
+ }
+ }
+
/// Returns the active board id.
pub fn board_id(&self) -> &str {
self.boards.active_board_id()
diff --git a/src/input/state/core/board_picker/appearance.rs b/src/input/state/core/board_picker/appearance.rs
new file mode 100644
index 000000000..73a9b9c7a
--- /dev/null
+++ b/src/input/state/core/board_picker/appearance.rs
@@ -0,0 +1,419 @@
+use super::{color_to_hex, parse_hex_color};
+use crate::domain::{BoardBackground, BoardGrid, BoardGridKind, Color};
+use crate::input::InputState;
+use crate::input::boards::{BoardAppearance, BoardIdentityGeneration};
+use crate::input::events::Key;
+
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub(crate) enum AppearanceField {
+ Color,
+ Pattern,
+ Spacing,
+}
+
+const SHEET_PADDING: f64 = 12.0;
+const SHEET_COLUMN_MARGIN: f64 = 8.0;
+const SHEET_MIN_DOCKED_WIDTH: f64 = 240.0;
+const SHEET_CLOSE_SIZE: f64 = 24.0;
+const SHEET_COLOR_FIELD_WIDTH: f64 = 84.0;
+const SHEET_HEADER_GAP: f64 = 8.0;
+
+/// The paper sheet's header controls as `(x, y, width, height)` rectangles.
+#[derive(Debug, Clone, Copy)]
+pub(crate) struct BoardAppearanceHeader {
+ /// The title is clipped before this x coordinate.
+ pub(crate) title_right: f64,
+ pub(crate) color_field: (f64, f64, f64, f64),
+ pub(crate) close: (f64, f64, f64, f64),
+}
+
+fn rect_contains((x, y, width, height): (f64, f64, f64, f64), (px, py): (f64, f64)) -> bool {
+ px >= x && px <= x + width && py >= y && py <= y + height
+}
+
+/// A board-identity-bound draft. Only Apply writes to the board or session.
+#[derive(Debug)]
+pub(crate) struct BoardAppearanceEdit {
+ id: String,
+ generation: BoardIdentityGeneration,
+ original: BoardAppearance,
+ pub(crate) color: String,
+ pub(crate) kind: BoardGridKind,
+ pub(crate) spacing: String,
+ pub(crate) focus: AppearanceField,
+ pub(crate) error: Option,
+}
+
+impl BoardAppearanceEdit {
+ pub(super) fn set_color_text(&mut self, value: String) {
+ self.color = value;
+ }
+
+ pub(crate) fn board_id(&self) -> &str {
+ &self.id
+ }
+ // Compare the displayed color with its original displayed value, preserving
+ // the exact configured float color when an edit is reverted.
+ fn color_is_dirty(&self) -> bool {
+ let BoardBackground::Solid(original) = self.original.background else {
+ return false;
+ };
+ parse_hex_color(&self.color) != parse_hex_color(&color_to_hex(original))
+ }
+
+ pub(crate) fn validation_error(&self) -> Option<&'static str> {
+ if parse_hex_color(&self.color).is_none() {
+ return Some("Use a color in #RRGGBB format.");
+ }
+ if !self
+ .spacing
+ .parse::()
+ .is_ok_and(|s| (8..=200).contains(&s))
+ {
+ return Some("Spacing must be a whole number from 8 to 200.");
+ }
+ None
+ }
+
+ /// Whether the typed size equals a preset, so only that preset reads as selected.
+ pub(crate) fn spacing_matches(&self, preset: u16) -> bool {
+ self.spacing.trim().parse::().ok() == Some(preset)
+ }
+
+ pub(crate) fn preview(&self) -> (Color, BoardGrid) {
+ let base = match self.original.background {
+ BoardBackground::Solid(color) => color,
+ _ => crate::draw::WHITE,
+ };
+ (
+ if self.color_is_dirty() {
+ parse_hex_color(&self.color).unwrap_or(base)
+ } else {
+ base
+ },
+ BoardGrid::new(
+ self.kind,
+ self.spacing
+ .parse()
+ .unwrap_or(i64::from(self.original.grid.spacing())),
+ ),
+ )
+ }
+
+ fn desired(&self, current: &BoardAppearance) -> Result {
+ let color = parse_hex_color(&self.color).ok_or("Use a color in #RRGGBB format.")?;
+ let spacing = self
+ .spacing
+ .parse::()
+ .ok()
+ .filter(|s| (8..=200).contains(s))
+ .ok_or("Spacing must be a whole number from 8 to 200.")?;
+ let kind_dirty = self.kind != self.original.grid.kind;
+ let spacing_dirty = spacing != i64::from(self.original.grid.spacing());
+ let mut desired = current.clone();
+ fn conflict(dirty: bool, current: &T, original: &T, desired: &T) -> bool {
+ dirty && current != original && current != desired
+ }
+ if conflict(
+ self.color_is_dirty(),
+ ¤t.background,
+ &self.original.background,
+ &BoardBackground::Solid(color),
+ ) || conflict(
+ kind_dirty,
+ ¤t.grid.kind,
+ &self.original.grid.kind,
+ &self.kind,
+ ) || conflict(
+ spacing_dirty,
+ ¤t.grid.spacing(),
+ &self.original.grid.spacing(),
+ &(spacing as u16),
+ ) {
+ return Err("Board appearance changed. Cancel and reopen to edit it.");
+ }
+ if self.color_is_dirty() {
+ desired.background = BoardBackground::Solid(color);
+ }
+ desired.grid = BoardGrid::new(
+ if kind_dirty {
+ self.kind
+ } else {
+ current.grid.kind
+ },
+ if spacing_dirty {
+ spacing
+ } else {
+ i64::from(current.grid.spacing())
+ },
+ );
+ Ok(desired)
+ }
+}
+
+impl InputState {
+ pub(crate) fn board_appearance_edit(&self) -> Option<&BoardAppearanceEdit> {
+ self.board_picker.appearance.as_ref()
+ }
+
+ pub(crate) fn begin_board_appearance(&mut self, index: usize) -> bool {
+ let Some(board) = self.boards.board_states().get(index) else {
+ return false;
+ };
+ let BoardBackground::Solid(color) = board.spec.background else {
+ return false;
+ };
+ self.board_picker.appearance = Some(BoardAppearanceEdit {
+ id: board.spec.id.clone(),
+ generation: self.boards.board_identity_generation(),
+ original: BoardAppearance::from_spec(&board.spec),
+ color: color_to_hex(color),
+ kind: board.spec.grid.kind,
+ spacing: board.spec.grid.spacing().to_string(),
+ focus: AppearanceField::Color,
+ error: None,
+ });
+ // The sheet dims the whole surface behind it.
+ self.dirty_tracker.mark_full();
+ self.needs_redraw = true;
+ true
+ }
+
+ pub(crate) fn apply_board_appearance(&mut self) -> bool {
+ self.mark_board_appearance_region();
+ let Some(edit) = &self.board_picker.appearance else {
+ return false;
+ };
+ let index = self
+ .boards
+ .board_states()
+ .iter()
+ .position(|b| b.spec.id == edit.id);
+ if self.boards.board_identity_generation() != edit.generation || index.is_none() {
+ self.board_picker.appearance.as_mut().unwrap().error =
+ Some("Board identity changed. Cancel and reopen to edit it.".into());
+ self.needs_redraw = true;
+ return false;
+ }
+ let index = index.unwrap();
+ let current = BoardAppearance::from_spec(&self.boards.board_states()[index].spec);
+ let proposed = if current.background.is_transparent() {
+ Err("Paper patterns require a solid board.")
+ } else {
+ edit.desired(¤t)
+ };
+ let desired = match proposed {
+ Ok(desired) => desired,
+ Err(error) => {
+ self.board_picker.appearance.as_mut().unwrap().error = Some(error.into());
+ self.needs_redraw = true;
+ return false;
+ }
+ };
+ self.apply_board_appearance_value(index, desired);
+ self.board_picker_clear_edit();
+ self.needs_redraw = true;
+ true
+ }
+
+ pub(crate) fn board_appearance_palette(&mut self, color: Color) -> bool {
+ self.mark_board_appearance_region();
+ let Some(edit) = &mut self.board_picker.appearance else {
+ return false;
+ };
+ edit.color = color_to_hex(color);
+ edit.error = None;
+ self.needs_redraw = true;
+ true
+ }
+
+ pub(crate) fn board_appearance_key(&mut self, key: Key) -> bool {
+ self.mark_board_appearance_region();
+ let Some(edit) = &mut self.board_picker.appearance else {
+ return false;
+ };
+ match key {
+ Key::Escape => self.board_picker_cancel_edit(),
+ Key::Return => {
+ self.apply_board_appearance();
+ }
+ Key::F2 => return false,
+ Key::Tab => {
+ edit.focus = match edit.focus {
+ AppearanceField::Color => AppearanceField::Pattern,
+ AppearanceField::Pattern => AppearanceField::Spacing,
+ AppearanceField::Spacing => AppearanceField::Color,
+ };
+ }
+ Key::Left | Key::Right | Key::Up | Key::Down
+ if edit.focus == AppearanceField::Pattern =>
+ {
+ let index = BoardGridKind::ALL
+ .iter()
+ .position(|kind| *kind == edit.kind)
+ .unwrap_or(0);
+ let step = if matches!(key, Key::Left | Key::Up) {
+ 3
+ } else {
+ 1
+ };
+ edit.kind = BoardGridKind::ALL[(index + step) % 4];
+ }
+ Key::Backspace | Key::Delete => match edit.focus {
+ AppearanceField::Color => {
+ edit.color.pop();
+ }
+ AppearanceField::Spacing => {
+ edit.spacing.pop();
+ }
+ _ => {}
+ },
+ Key::Char(ch) => match edit.focus {
+ AppearanceField::Color
+ if (ch.is_ascii_hexdigit() || ch == '#') && edit.color.len() < 7 =>
+ {
+ edit.color.push(ch);
+ }
+ AppearanceField::Spacing if !ch.is_control() && edit.spacing.len() < 8 => {
+ edit.spacing.push(ch);
+ }
+ _ => {}
+ },
+ _ => {}
+ }
+ self.needs_redraw = true;
+ true
+ }
+
+ /// Shared sheet geometry for painting and mouse input. The sheet floats over
+ /// the page column when it fits there, so it leaves the board list readable;
+ /// narrow pickers center it instead.
+ pub(crate) fn board_appearance_rect(&self) -> Option<(f64, f64, f64)> {
+ self.board_picker.appearance.as_ref()?;
+ let layout = self.board_picker.layout.as_ref()?;
+ let y = layout.origin_y + layout.height / 2.0 - 75.0;
+
+ // Sheet padding plus a small margin on both sides of the page column.
+ let column_width = layout.origin_x + layout.width - layout.page_panel_x;
+ let docked_width = (column_width - 2.0 * (SHEET_PADDING + SHEET_COLUMN_MARGIN)).min(320.0);
+ // A picker wider than the output is clipped at both edges, so keep the
+ // sheet centered there to leave it fully on screen.
+ if layout.page_panel_enabled
+ && layout.origin_x >= 0.0
+ && docked_width >= SHEET_MIN_DOCKED_WIDTH
+ {
+ let x = layout.page_panel_x + (column_width - docked_width) / 2.0;
+ return Some((x, y, docked_width));
+ }
+
+ let width = (layout.width - 32.0).clamp(140.0, 320.0);
+ Some((layout.origin_x + (layout.width - width) / 2.0, y, width))
+ }
+
+ /// Header controls in surface coordinates, shared by painting and clicks.
+ pub(crate) fn board_appearance_header(&self) -> Option {
+ let (x, y, width) = self.board_appearance_rect()?;
+ let close = (
+ x + width + 4.0 - SHEET_CLOSE_SIZE,
+ y - 62.0,
+ SHEET_CLOSE_SIZE,
+ SHEET_CLOSE_SIZE,
+ );
+ let field_x = close.0 - SHEET_HEADER_GAP - SHEET_COLOR_FIELD_WIDTH;
+ Some(BoardAppearanceHeader {
+ title_right: field_x - SHEET_HEADER_GAP,
+ color_field: (field_x, y - 61.0, SHEET_COLOR_FIELD_WIDTH, 22.0),
+ close,
+ })
+ }
+
+ pub(in crate::input::state) fn mark_board_appearance_region(&mut self) {
+ if self.board_picker.appearance.is_none() {
+ return;
+ }
+ // The outer palette also highlights the draft color.
+ if let Some(layout) = self.board_picker.layout
+ && layout.palette_rows > 0
+ {
+ self.mark_board_picker_region(&layout);
+ }
+ // The frame plus its drop shadow below.
+ if let Some((x, y, width)) = self.board_appearance_rect()
+ && let Some(rect) = crate::util::Rect::new(
+ (x - 15.0).floor() as i32,
+ (y - 72.0).floor() as i32,
+ width.ceil() as i32 + 31,
+ 308,
+ )
+ {
+ self.dirty_tracker.mark_rect(rect);
+ } else {
+ self.dirty_tracker.mark_full();
+ }
+ self.needs_redraw = true;
+ }
+
+ pub(crate) fn board_appearance_click(&mut self, x: i32, y: i32) -> bool {
+ let Some((left, top, width)) = self.board_appearance_rect() else {
+ return false;
+ };
+ self.mark_board_appearance_region();
+ let (screen_x, screen_y) = (x, y);
+ let x = f64::from(x) - left;
+ let y = f64::from(y) - top;
+ if !(-12.0..width + 12.0).contains(&x) || !(-70.0..222.0).contains(&y) {
+ if let Some(color) = self.board_picker_palette_color_at(screen_x, screen_y) {
+ return self.board_appearance_palette(color);
+ }
+ self.board_picker_cancel_edit();
+ return false;
+ }
+ let header = self.board_appearance_header();
+ let point = (f64::from(screen_x), f64::from(screen_y));
+ if header.is_some_and(|header| rect_contains(header.close, point)) {
+ self.board_picker_cancel_edit();
+ return true;
+ }
+ let in_color_field = header.is_some_and(|header| rect_contains(header.color_field, point));
+ let edit = self.board_picker.appearance.as_mut().unwrap();
+ match y as i32 {
+ -65..=-35 => {
+ if in_color_field {
+ edit.focus = AppearanceField::Color;
+ }
+ }
+ -30..=-5 => {
+ let index = (x / (width / 11.0)).floor() as usize;
+ if let Some(color) = super::board_palette_colors().get(index) {
+ edit.color = color_to_hex(*color);
+ }
+ }
+ 0..=55 => {
+ edit.kind = BoardGridKind::ALL
+ [((y / 28.0) as usize * 2 + (x / (width / 2.0)) as usize).min(3)];
+ edit.focus = AppearanceField::Pattern;
+ }
+ 60..=87 => {
+ edit.focus = AppearanceField::Spacing;
+ if x > width - 40.0 {
+ edit.spacing = "40".into();
+ } else if x > width - 80.0 {
+ edit.spacing = "20".into();
+ }
+ }
+ 160..=187 => {
+ if x < width / 2.0 {
+ self.apply_board_appearance();
+ } else {
+ self.board_picker_cancel_edit();
+ }
+ }
+ _ => {}
+ }
+ self.needs_redraw = true;
+ true
+ }
+}
+
+#[cfg(test)]
+mod tests;
diff --git a/src/input/state/core/board_picker/appearance/tests.rs b/src/input/state/core/board_picker/appearance/tests.rs
new file mode 100644
index 000000000..5ea2f986d
--- /dev/null
+++ b/src/input/state/core/board_picker/appearance/tests.rs
@@ -0,0 +1,306 @@
+use super::*;
+use crate::draw::{BLUE, RED};
+use crate::input::state::test_support::TestInputStateBuilder;
+
+fn editing() -> InputState {
+ let mut input = TestInputStateBuilder::default().build();
+ input.switch_board_force("whiteboard");
+ input.open_board_picker_with_measurer(&crate::draw::TextMeasurer::default());
+ input.board_picker_edit_color_selected_with_measurer(&crate::draw::TextMeasurer::default());
+ input.clear_session_dirty();
+ input
+}
+
+#[test]
+fn appearance_preview_cancel_and_noop_never_change_board_or_session() {
+ let mut input = editing();
+ let original = BoardAppearance::from_spec(&input.boards.active_board().spec);
+ let pen = input.color_for_tool(crate::input::Tool::Pen);
+ input.board_appearance_palette(RED);
+ input.board_picker.appearance.as_mut().unwrap().preview();
+ assert_eq!(
+ BoardAppearance::from_spec(&input.boards.active_board().spec),
+ original
+ );
+ assert_eq!(input.color_for_tool(crate::input::Tool::Pen), pen);
+ assert!(!input.is_session_dirty());
+ input.board_appearance_key(Key::Escape);
+ assert!(input.board_appearance_edit().is_none());
+ assert!(!input.boards.active_board().appearance_explicit);
+ input.board_picker_edit_color_selected_with_measurer(&crate::draw::TextMeasurer::default());
+ assert!(input.apply_board_appearance());
+ assert!(!input.is_session_dirty());
+}
+
+#[test]
+fn reverting_draft_fields_preserves_later_external_changes() {
+ let mut input = editing();
+ let original_color = input.board_appearance_edit().unwrap().color.clone();
+ input.board_appearance_palette(RED);
+ input.board_picker.appearance.as_mut().unwrap().color = original_color.to_lowercase();
+ input.board_appearance_key(Key::Tab);
+ input.board_appearance_key(Key::Right);
+ input.board_appearance_key(Key::Left);
+ input.board_appearance_key(Key::Tab);
+ input.board_appearance_key(Key::Backspace);
+ input.board_appearance_key(Key::Char('0'));
+
+ let board = input.boards.active_board_mut();
+ board.spec.background = BoardBackground::Solid(BLUE);
+ board.spec.grid = BoardGrid::new(BoardGridKind::IsometricDots, 63);
+ let current = BoardAppearance::from_spec(&board.spec);
+ assert!(input.apply_board_appearance());
+ assert_eq!(
+ BoardAppearance::from_spec(&input.boards.active_board().spec),
+ current
+ );
+ assert!(!input.is_session_dirty());
+ assert!(!input.boards.active_board().appearance_explicit);
+}
+
+#[test]
+fn appearance_apply_merges_untouched_fields_and_preserves_history_and_pen_for_grid_only() {
+ let mut input = editing();
+ let pen = input.color_for_tool(crate::input::Tool::Pen);
+ let frame = input.boards.active_frame();
+ let history = (
+ frame.shapes.len(),
+ frame.undo_stack_len(),
+ frame.redo_stack_len(),
+ );
+ let draft = input.board_picker.appearance.as_mut().unwrap();
+ draft.kind = BoardGridKind::IsometricDots;
+ // Another operation changes an untouched field while the draft is open.
+ input.boards.active_board_mut().spec.grid = BoardGrid::new(BoardGridKind::None, 63);
+ assert!(input.apply_board_appearance());
+ assert_eq!(
+ input.boards.active_board().spec.grid,
+ BoardGrid::new(BoardGridKind::IsometricDots, 63)
+ );
+ assert_eq!(input.color_for_tool(crate::input::Tool::Pen), pen);
+ assert!(input.is_session_dirty());
+ assert!(input.boards.active_board().appearance_explicit);
+ let frame = input.boards.active_frame();
+ assert_eq!(
+ (
+ frame.shapes.len(),
+ frame.undo_stack_len(),
+ frame.redo_stack_len()
+ ),
+ history
+ );
+}
+
+#[test]
+fn appearance_invalid_spacing_conflict_and_identity_change_keep_draft_unapplied() {
+ let mut input = editing();
+ let draft = input.board_picker.appearance.as_mut().unwrap();
+ draft.spacing = "201".into();
+ assert!(!input.apply_board_appearance());
+ assert_eq!(input.board_appearance_edit().unwrap().spacing, "201");
+ assert!(!input.is_session_dirty());
+ input.board_picker.appearance.as_mut().unwrap().spacing = "20".into();
+ input.board_appearance_palette(RED);
+ input.boards.active_board_mut().spec.background = BoardBackground::Solid(BLUE);
+ assert!(!input.apply_board_appearance());
+ assert_eq!(
+ input.boards.active_board().spec.background,
+ BoardBackground::Solid(BLUE)
+ );
+ input.boards.bump_board_identity_generation();
+ assert!(!input.apply_board_appearance());
+ assert!(!input.is_session_dirty());
+}
+
+#[test]
+fn appearance_keyboard_edits_spacing_and_pattern_and_selection_cancels() {
+ let mut input = editing();
+ input.board_appearance_key(Key::Tab);
+ input.board_appearance_key(Key::Right);
+ input.board_appearance_key(Key::Tab);
+ input.board_appearance_key(Key::Backspace);
+ input.board_appearance_key(Key::Backspace);
+ input.board_appearance_key(Key::Char('2'));
+ input.board_appearance_key(Key::Char('0'));
+ assert!(input.apply_board_appearance());
+ assert_eq!(
+ input.boards.active_board().spec.grid,
+ BoardGrid::new(BoardGridKind::Cartesian, 20)
+ );
+ input.board_picker_edit_color_selected_with_measurer(&crate::draw::TextMeasurer::default());
+ input.board_picker_set_selected(0);
+ assert!(input.board_appearance_edit().is_none());
+}
+
+#[test]
+fn appearance_preview_and_cancel_damage_the_sheet_without_changing_the_board() {
+ let mut input = editing();
+ let surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 900, 700).unwrap();
+ let ctx = cairo::Context::new(&surface).unwrap();
+ input.update_board_picker_layout(&ctx, 900, 700);
+ let (x, y, width) = input.board_appearance_rect().unwrap();
+ let original = BoardAppearance::from_spec(&input.boards.active_board().spec);
+ input.dirty_tracker.take_regions(900, 700);
+ for key in [Key::Tab, Key::Right, Key::Escape] {
+ // Unrelated UI damage must not suppress the sheet through a nonempty clip.
+ input
+ .dirty_tracker
+ .mark_rect(crate::util::Rect::new(0, 0, 10, 10).unwrap());
+ assert!(input.board_appearance_key(key));
+ let regions = input.dirty_tracker.take_regions(900, 700);
+ assert!(regions.iter().any(|rect| {
+ f64::from(rect.x) <= x - 12.0
+ && f64::from(rect.y) <= y - 70.0
+ && f64::from(rect.x + rect.width) >= x + width + 12.0
+ && f64::from(rect.y + rect.height) >= y + 222.0
+ }));
+ assert_eq!(
+ BoardAppearance::from_spec(&input.boards.active_board().spec),
+ original
+ );
+ assert!(!input.is_session_dirty());
+ }
+ assert!(input.board_appearance_edit().is_none());
+}
+
+#[test]
+fn visible_palette_click_keeps_pattern_and_spacing_draft_until_apply() {
+ let mut input = editing();
+ let surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 900, 700).unwrap();
+ let ctx = cairo::Context::new(&surface).unwrap();
+ input.update_board_picker_layout(&ctx, 900, 700);
+ input.board_picker.appearance.as_mut().unwrap().kind = BoardGridKind::IsometricDots;
+ input.board_picker.appearance.as_mut().unwrap().spacing = "63".into();
+ let original = BoardAppearance::from_spec(&input.boards.active_board().spec);
+ let layout = input.board_picker_layout().unwrap();
+ let x = (layout.origin_x + layout.padding_x + 1.0) as i32;
+ let y = (layout.palette_top + 1.0) as i32;
+ let expected_color = input.board_picker_palette_color_at(x, y).unwrap();
+ let (left, top, width) = input.board_appearance_rect().unwrap();
+ assert!(
+ f64::from(x) < left - 12.0
+ || f64::from(x) >= left + width + 12.0
+ || f64::from(y) < top - 70.0
+ || f64::from(y) >= top + 222.0
+ );
+ assert!(input.board_appearance_click(x, y));
+ let draft = input.board_appearance_edit().unwrap();
+ assert_eq!(draft.kind, BoardGridKind::IsometricDots);
+ assert_eq!(draft.spacing, "63");
+ assert_eq!(draft.preview().0, expected_color);
+ assert_eq!(
+ BoardAppearance::from_spec(&input.boards.active_board().spec),
+ original
+ );
+ assert!(!input.is_session_dirty());
+ assert!(input.apply_board_appearance());
+ assert_eq!(
+ input.boards.active_board().spec.grid,
+ BoardGrid::new(BoardGridKind::IsometricDots, 63)
+ );
+ assert_eq!(
+ input.boards.active_board().spec.background,
+ BoardBackground::Solid(expected_color)
+ );
+}
+
+#[test]
+fn spacing_presets_select_only_the_matching_value() {
+ let mut input = editing();
+ let surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 900, 700).unwrap();
+ let ctx = cairo::Context::new(&surface).unwrap();
+ input.update_board_picker_layout(&ctx, 900, 700);
+ let (left, top, width) = input.board_appearance_rect().unwrap();
+ let row = (top + 72.0) as i32;
+
+ assert!(input.board_appearance_click((left + width - 60.0) as i32, row));
+ let draft = input.board_appearance_edit().unwrap();
+ assert_eq!(draft.spacing, "20");
+ assert!(draft.spacing_matches(20));
+ assert!(!draft.spacing_matches(40));
+
+ assert!(input.board_appearance_click((left + width - 20.0) as i32, row));
+ let draft = input.board_appearance_edit().unwrap();
+ assert_eq!(draft.spacing, "40");
+ assert!(draft.spacing_matches(40));
+ assert!(!draft.spacing_matches(20));
+
+ input.board_picker.appearance.as_mut().unwrap().spacing = "63".into();
+ let draft = input.board_appearance_edit().unwrap();
+ assert!(!draft.spacing_matches(20) && !draft.spacing_matches(40));
+}
+
+fn laid_out(width: i32, height: i32) -> InputState {
+ let mut input = editing();
+ let surface = cairo::ImageSurface::create(cairo::Format::ARgb32, width, height).unwrap();
+ let ctx = cairo::Context::new(&surface).unwrap();
+ input.update_board_picker_layout(&ctx, width as u32, height as u32);
+ input
+}
+
+fn center((x, y, width, height): (f64, f64, f64, f64)) -> (i32, i32) {
+ ((x + width / 2.0) as i32, (y + height / 2.0) as i32)
+}
+
+#[test]
+fn sheet_floats_over_the_page_column_without_covering_the_board_list() {
+ for (width, height) in [(1920, 1080), (1280, 720), (900, 700)] {
+ let input = laid_out(width, height);
+ let layout = *input.board_picker_layout().unwrap();
+ let (x, _, sheet_width) = input.board_appearance_rect().unwrap();
+
+ assert!(
+ x - 12.0 >= layout.origin_x + layout.list_width,
+ "{width}x{height}: the sheet covers the board list"
+ );
+ assert!(x + sheet_width + 12.0 <= layout.origin_x + layout.width);
+ }
+}
+
+#[test]
+fn sheet_header_close_cancels_and_color_field_takes_focus() {
+ let mut input = laid_out(900, 700);
+ let original = BoardAppearance::from_spec(&input.boards.active_board().spec);
+ let header = input.board_appearance_header().unwrap();
+ input.board_appearance_key(Key::Tab);
+ assert_ne!(
+ input.board_appearance_edit().unwrap().focus,
+ AppearanceField::Color
+ );
+
+ let (x, y) = center(header.color_field);
+ assert!(input.board_appearance_click(x, y));
+ assert_eq!(
+ input.board_appearance_edit().unwrap().focus,
+ AppearanceField::Color
+ );
+
+ input.board_picker.appearance.as_mut().unwrap().kind = BoardGridKind::Cartesian;
+ let (x, y) = center(header.close);
+ assert!(input.board_appearance_click(x, y));
+ assert!(input.board_appearance_edit().is_none());
+ assert!(input.is_board_picker_open());
+ assert_eq!(
+ BoardAppearance::from_spec(&input.boards.active_board().spec),
+ original
+ );
+ assert!(!input.is_session_dirty());
+}
+
+#[test]
+fn opening_and_closing_the_sheet_damage_the_whole_surface() {
+ let mut input = laid_out(900, 700);
+ let full = |regions: &[crate::util::Rect]| {
+ regions
+ .iter()
+ .any(|rect| rect.x <= 0 && rect.y <= 0 && rect.width >= 900 && rect.height >= 700)
+ };
+ input.dirty_tracker.take_regions(900, 700);
+
+ input.board_appearance_key(Key::Escape);
+ assert!(full(&input.dirty_tracker.take_regions(900, 700)));
+
+ input.board_picker_edit_color_selected_with_measurer(&crate::draw::TextMeasurer::default());
+ assert!(input.board_appearance_edit().is_some());
+ assert!(full(&input.dirty_tracker.take_regions(900, 700)));
+}
diff --git a/src/input/state/core/board_picker/layout/cursor.rs b/src/input/state/core/board_picker/layout/cursor.rs
index 5e544bf7c..dae2de1ab 100644
--- a/src/input/state/core/board_picker/layout/cursor.rs
+++ b/src/input/state/core/board_picker/layout/cursor.rs
@@ -30,6 +30,9 @@ impl InputState {
return None;
}
let layout = self.board_picker.layout?;
+ if self.board_appearance_edit().is_some() {
+ return Some(BoardPickerCursorHint::Pointer);
+ }
// Check if point is within the panel
if !self.board_picker_contains_point(x, y) {
diff --git a/src/input/state/core/board_picker/mod.rs b/src/input/state/core/board_picker/mod.rs
index f471c07f7..381496857 100644
--- a/src/input/state/core/board_picker/mod.rs
+++ b/src/input/state/core/board_picker/mod.rs
@@ -1,5 +1,6 @@
#![allow(dead_code)]
+pub(crate) mod appearance;
mod layout;
mod panel;
mod search;
diff --git a/src/input/state/core/board_picker/panel.rs b/src/input/state/core/board_picker/panel.rs
index 6c5a68450..44397f08c 100644
--- a/src/input/state/core/board_picker/panel.rs
+++ b/src/input/state/core/board_picker/panel.rs
@@ -13,6 +13,7 @@ pub struct BoardPickerPanel {
pub(in crate::input::state) state: BoardPickerState,
pub(in crate::input::state) drag: Option,
pub(in crate::input::state) page_drag: Option,
+ pub(in crate::input::state) appearance: Option,
pub(in crate::input::state) page_edit: Option,
pub(in crate::input::state) layout: Option,
pub(in crate::input::state) search: String,
@@ -47,6 +48,7 @@ impl BoardPickerPanel {
self.drag = None;
self.page_drag = None;
self.page_edit = None;
+ self.appearance = None;
self.state = BoardPickerState::Open {
selected: selected_row.unwrap_or(active_index),
hover_index: None,
@@ -69,6 +71,7 @@ impl BoardPickerPanel {
self.drag = None;
self.page_drag = None;
self.page_edit = None;
+ self.appearance = None;
self.last_click = None;
self.clear_search();
layout
@@ -185,6 +188,7 @@ impl Default for BoardPickerPanel {
drag: None,
page_drag: None,
page_edit: None,
+ appearance: None,
layout: None,
search: String::new(),
search_last_input: None,
diff --git a/src/input/state/core/board_picker/state/edit.rs b/src/input/state/core/board_picker/state/edit.rs
index 1720151e5..ee62fcdaf 100644
--- a/src/input/state/core/board_picker/state/edit.rs
+++ b/src/input/state/core/board_picker/state/edit.rs
@@ -12,12 +12,37 @@ use super::super::{
impl InputState {
pub(crate) fn board_picker_clear_edit(&mut self) {
+ if self.board_picker.appearance.take().is_some() {
+ // Closing the paper sheet removes the dim over the whole surface.
+ self.dirty_tracker.mark_full();
+ self.needs_redraw = true;
+ }
if let BoardPickerState::Open { edit, .. } = &mut self.board_picker.state {
*edit = None;
}
}
pub(crate) fn board_picker_start_edit(&mut self, mode: BoardPickerEditMode, buffer: String) {
+ self.board_picker_clear_edit();
+ if mode == BoardPickerEditMode::Color {
+ let Some(index) = self
+ .board_picker_selected_index()
+ .and_then(|row| self.board_picker_board_index_for_row(row))
+ else {
+ return;
+ };
+ if !self.begin_board_appearance(index) {
+ return;
+ }
+ if let Some(draft) = &mut self.board_picker.appearance {
+ draft.set_color_text(buffer.clone());
+ }
+ }
+ let buffer = if mode == BoardPickerEditMode::Color {
+ String::new()
+ } else {
+ buffer
+ };
if let BoardPickerState::Open { edit, .. } = &mut self.board_picker.state {
*edit = Some(BoardPickerEdit { mode, buffer });
}
@@ -29,7 +54,14 @@ impl InputState {
return None;
};
let edit = edit.as_ref()?;
- Some((edit.mode, *selected, edit.buffer.as_str()))
+ Some((
+ edit.mode,
+ *selected,
+ self.board_picker
+ .appearance
+ .as_ref()
+ .map_or(edit.buffer.as_str(), |draft| draft.color.as_str()),
+ ))
}
pub(crate) fn board_picker_edit_buffer_mut(&mut self) -> Option<&mut BoardPickerEdit> {
@@ -320,6 +352,9 @@ impl InputState {
}
pub(crate) fn board_picker_commit_edit(&mut self) -> bool {
+ if self.board_picker.appearance.is_some() {
+ return self.apply_board_appearance();
+ }
let Some((mode, index, buffer)) = self.board_picker_edit_state() else {
return false;
};
@@ -365,6 +400,9 @@ impl InputState {
}
pub(crate) fn board_picker_edit_backspace(&mut self) {
+ if self.board_appearance_key(crate::input::events::Key::Backspace) {
+ return;
+ }
if let Some(edit) = self.board_picker_edit_buffer_mut() {
edit.buffer.pop();
self.needs_redraw = true;
@@ -372,6 +410,9 @@ impl InputState {
}
pub(crate) fn board_picker_edit_append(&mut self, ch: char) {
+ if self.board_appearance_key(crate::input::events::Key::Char(ch)) {
+ return;
+ }
let Some(edit) = self.board_picker_edit_buffer_mut() else {
return;
};
@@ -404,24 +445,6 @@ impl InputState {
}
pub(crate) fn board_picker_apply_palette_color(&mut self, color: Color) -> bool {
- let Some(index) = self.board_picker_selected_index() else {
- return false;
- };
- if self.board_picker_is_new_row(index) {
- return false;
- }
- let Some(board_index) = self.board_picker_board_index_for_row(index) else {
- return false;
- };
- if !self.set_board_background_color(board_index, color) {
- return false;
- }
- if let Some(edit) = self.board_picker_edit_buffer_mut()
- && edit.mode == BoardPickerEditMode::Color
- {
- edit.buffer = color_to_hex(color);
- }
- self.needs_redraw = true;
- true
+ self.board_appearance_palette(color)
}
}
diff --git a/src/input/state/core/board_picker/state/lifecycle.rs b/src/input/state/core/board_picker/state/lifecycle.rs
index 95c58a3d7..f0bfa75c6 100644
--- a/src/input/state/core/board_picker/state/lifecycle.rs
+++ b/src/input/state/core/board_picker/state/lifecycle.rs
@@ -131,6 +131,9 @@ impl InputState {
}
pub(crate) fn board_picker_set_selected(&mut self, index: usize) {
+ if self.board_picker_selected_index() != Some(index) {
+ self.board_picker_clear_edit();
+ }
let row_count = self.board_picker_row_count().max(1);
let next = index.min(row_count.saturating_sub(1));
let previous_board = self.board_picker_page_panel_board_index();
diff --git a/src/input/state/core/session_preflight_exact.rs b/src/input/state/core/session_preflight_exact.rs
index a156e1885..9332db92b 100644
--- a/src/input/state/core/session_preflight_exact.rs
+++ b/src/input/state/core/session_preflight_exact.rs
@@ -111,6 +111,9 @@ fn duplicate_page_in_snapshot(
let insert_at = (page_index + 1).min(pages.len());
pages.insert(insert_at, cloned_page);
snapshot.boards.push(BoardSnapshot {
+ appearance: Some(crate::session::BoardAppearanceSnapshot::capture(
+ source_board,
+ )),
id: source_board.spec.id.clone(),
pages: BoardPagesSnapshot {
active: insert_at,
@@ -164,6 +167,9 @@ fn copy_page_between_boards_in_snapshot(
let mut pages = pages_for_snapshot(&target_board.pages, history_limit);
pages.push(cloned_page);
snapshot.boards.push(BoardSnapshot {
+ appearance: Some(crate::session::BoardAppearanceSnapshot::capture(
+ target_board,
+ )),
id: target_board.spec.id.clone(),
pages: BoardPagesSnapshot {
active: pages.len().saturating_sub(1),
@@ -184,6 +190,7 @@ fn duplicate_active_board_in_snapshot(input: &InputState, snapshot: &mut Session
};
let mut cloned = BoardSnapshot {
+ appearance: snapshot.boards[source_index].appearance.clone(),
id: duplicate_board_id_for_preflight(input, &source_board.spec.id),
pages: snapshot.boards[source_index].pages.clone(),
};
diff --git a/src/input/state/mod.rs b/src/input/state/mod.rs
index 6ddfb4f17..a690b5153 100644
--- a/src/input/state/mod.rs
+++ b/src/input/state/mod.rs
@@ -22,6 +22,7 @@ mod tests;
pub(crate) use actions::key_press::bindings::key_to_action_label_for_test;
pub(crate) use core::board_picker::BoardPickerEditMode;
pub(crate) use core::board_picker::BoardPickerFocus;
+pub(crate) use core::board_picker::appearance::AppearanceField;
pub(crate) use core::board_picker::{
PAGE_DELETE_ICON_MARGIN, PAGE_DELETE_ICON_SIZE, PAGE_NAME_HEIGHT, PAGE_NAME_PADDING,
};
diff --git a/src/input/state/mouse/press/panels.rs b/src/input/state/mouse/press/panels.rs
index 2fd7d6215..889e900c9 100644
--- a/src/input/state/mouse/press/panels.rs
+++ b/src/input/state/mouse/press/panels.rs
@@ -136,6 +136,9 @@ impl InputState {
if !self.is_board_picker_open() {
return false;
}
+ if self.board_appearance_edit().is_some() {
+ return true;
+ }
self.update_pointer_position(x, y);
match button {
MouseButton::Left => {
diff --git a/src/input/state/mouse/release/panels.rs b/src/input/state/mouse/release/panels.rs
index 761119cf1..a74678874 100644
--- a/src/input/state/mouse/release/panels.rs
+++ b/src/input/state/mouse/release/panels.rs
@@ -113,6 +113,9 @@ pub(super) fn handle_board_picker_release(
if !state.is_board_picker_open() {
return false;
}
+ if state.board_appearance_click(x, y) {
+ return true;
+ }
if state.board_picker_is_page_dragging() {
state.board_picker_finish_page_drag_with_measurer(resources.measurer);
return true;
diff --git a/src/input/state/tests/board_picker.rs b/src/input/state/tests/board_picker.rs
index 1d23b4f02..1d53a5738 100644
--- a/src/input/state/tests/board_picker.rs
+++ b/src/input/state/tests/board_picker.rs
@@ -1461,8 +1461,10 @@ fn board_picker_commit_edit_rejects_invalid_colors_and_keeps_edit_open() {
assert!(!input.board_picker_commit_edit());
assert_eq!(
- input.active_toast().map(|toast| toast.message.as_str()),
- Some("Invalid color. Use #RRGGBB or RRGGBB.")
+ input
+ .board_appearance_edit()
+ .and_then(|edit| edit.error.as_deref()),
+ Some("Use a color in #RRGGBB format.")
);
assert_eq!(
input.board_picker_edit_state(),
diff --git a/src/session/catalog/tests.rs b/src/session/catalog/tests.rs
index b19bd1b04..42b80cb47 100644
--- a/src/session/catalog/tests.rs
+++ b/src/session/catalog/tests.rs
@@ -61,6 +61,7 @@ fn sample_snapshot() -> SessionSnapshot {
SessionSnapshot {
active_board_id: "transparent".to_string(),
boards: vec![BoardSnapshot {
+ appearance: None,
id: "transparent".to_string(),
pages: BoardPagesSnapshot {
pages: vec![frame],
diff --git a/src/session/mod.rs b/src/session/mod.rs
index deca4a97d..fda6006cc 100644
--- a/src/session/mod.rs
+++ b/src/session/mod.rs
@@ -33,8 +33,8 @@ pub use options::{
};
#[allow(unused_imports)]
pub use snapshot::{
- BoardPagesSnapshot, BoardSnapshot, SessionSnapshot, ToolStateSnapshot, apply_snapshot,
- load_snapshot, save_snapshot, snapshot_from_input,
+ BoardAppearanceSnapshot, BoardPagesSnapshot, BoardSnapshot, SessionSnapshot, ToolStateSnapshot,
+ apply_snapshot, load_snapshot, save_snapshot, snapshot_from_input,
};
#[allow(unused_imports)]
pub(crate) use snapshot::{
diff --git a/src/session/snapshot/appearance.rs b/src/session/snapshot/appearance.rs
new file mode 100644
index 000000000..e5911d25e
--- /dev/null
+++ b/src/session/snapshot/appearance.rs
@@ -0,0 +1,88 @@
+use crate::config::BoardGridConfig;
+use crate::domain::{BoardBackground, Color};
+use crate::input::boards::{BoardAppearance, BoardPenOrigin, BoardState};
+use serde::{Deserialize, Deserializer, Serialize};
+
+#[derive(Debug, Clone, Serialize, Deserialize)]
+pub struct BoardAppearanceSnapshot {
+ pub background: Option,
+ pub grid: BoardGridConfig,
+ pub default_pen_color: Option,
+ pub auto_adjust_pen: bool,
+ pub pen_origin: BoardPenOrigin,
+ pub explicit: bool,
+}
+
+impl BoardAppearanceSnapshot {
+ pub(crate) fn capture(board: &BoardState) -> Self {
+ Self {
+ background: match board.spec.background {
+ BoardBackground::Transparent => None,
+ BoardBackground::Solid(color) => Some(color),
+ },
+ grid: board.spec.grid.into(),
+ default_pen_color: board.spec.default_pen_color,
+ auto_adjust_pen: board.spec.auto_adjust_pen,
+ pen_origin: board.pen_origin,
+ explicit: board.appearance_explicit,
+ }
+ }
+
+ pub(crate) fn is_valid(&self) -> bool {
+ fn valid(color: Color) -> bool {
+ [color.r, color.g, color.b, color.a]
+ .into_iter()
+ .all(|v| v.is_finite() && (0.0..=1.0).contains(&v))
+ }
+ self.background.is_none_or(valid)
+ && self.default_pen_color.is_none_or(valid)
+ && (8..=200).contains(&self.grid.spacing)
+ }
+
+ pub(crate) fn apply(&self, board: &mut BoardState) {
+ if !self.is_valid() {
+ return;
+ }
+ // The reserved overlay identity must stay transparent.
+ let background = if board.spec.id == crate::domain::BOARD_ID_TRANSPARENT {
+ BoardBackground::Transparent
+ } else {
+ self.background
+ .map_or(BoardBackground::Transparent, BoardBackground::Solid)
+ };
+ BoardAppearance {
+ background,
+ grid: self.grid.into(),
+ default_pen_color: self.default_pen_color,
+ auto_adjust_pen: self.auto_adjust_pen,
+ }
+ .apply_to(&mut board.spec);
+ board.pen_origin = self.pen_origin;
+ board.appearance_explicit = self.explicit;
+ }
+}
+
+pub(super) fn deserialize_appearance<'de, D: Deserializer<'de>>(
+ d: D,
+) -> Result