From 104a223d45f21788f41becd407078a69211ca334 Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Tue, 25 Aug 2026 23:28:46 +0200 Subject: [PATCH 1/6] feat(drawing): smooth freehand strokes on release Run the configured binomial smoothing passes after a pen or marker stroke finishes, keeping both endpoints pinned so live input remains responsive and committed strokes lose pointer shake. Smooth positions while preserving every tablet pressure sample and its thickness detail. Eraser paths remain raw because their geometry determines what is removed. Store the resulting points on the committed shape and expose unbound step actions for adjusting the smoothing level. --- README.md | 1 + config.example.toml | 10 + .../src/app/pages/drawing/defaults.rs | 6 + .../src/models/config/draft/from_config.rs | 1 + configurator/src/models/config/draft/mod.rs | 1 + configurator/src/models/config/setters.rs | 1 + .../src/models/config/to_config/drawing.rs | 10 +- configurator/src/models/fields/toggles.rs | 1 + .../models/keybindings/field/config/read.rs | 2 + .../models/keybindings/field/config/write.rs | 2 + .../src/models/keybindings/field/labels.rs | 2 + .../src/models/keybindings/field/list.rs | 2 + .../src/models/keybindings/field/mod.rs | 2 + .../src/models/keybindings/field/tab.rs | 2 + docs/CONFIG.md | 40 +++ .../wayland/backend/state_init/input_state.rs | 1 + src/config/action_meta/entries/tools.rs | 20 ++ src/config/action_meta/tests.rs | 2 + src/config/keybindings/config/map/edit.rs | 2 + src/config/keybindings/config/map/tools.rs | 8 + .../config/types/bindings/tools.rs | 10 + src/config/keybindings/defaults/tools.rs | 10 + src/config/keybindings/tests.rs | 2 + src/config/mod.rs | 47 ++-- src/config/types/drawing.rs | 18 ++ src/config/types/mod.rs | 1 + src/config/validate/drawing.rs | 11 +- src/configurator_destination.rs | 2 + src/domain/action.rs | 2 + src/domain/tests.rs | 2 + src/draw/mod.rs | 3 +- src/draw/shape/mod.rs | 2 + src/draw/shape/smoothing.rs | 237 ++++++++++++++++++ src/input/state/actions/action_tools.rs | 21 ++ src/input/state/core/base/state/init.rs | 3 + src/input/state/core/base/state/structs.rs | 3 + .../state/core/tool_controls/settings.rs | 30 +++ src/input/state/interaction/actions.rs | 2 + src/input/state/mouse/release/drawing.rs | 82 ++++++ src/input/tool/drawing.rs | 15 +- .../help_overlay/sections/builder/sections.rs | 8 + 41 files changed, 596 insertions(+), 31 deletions(-) create mode 100644 src/draw/shape/smoothing.rs diff --git a/README.md b/README.md index c9d5bbfc..e2a42c19 100644 --- a/README.md +++ b/README.md @@ -117,6 +117,7 @@ The v0.9.23+ prebuilt `wayscriber` packages require glibc 2.39 and GTK 4.12 — ### Drawing and editing - Freehand pen, highlighter, eraser (circle/rect) +- Pen smoothing: finished strokes are cleaned up on release, so the live line never lags the cursor (`[drawing] pen_smoothing`, 0-6) - Shapes: lines, rectangles, ellipses, polygons (with fill toggle) - Arrows in four styles - standard, pointy, curved (drag its handle to route around what is in the way), and double-ended - with optional auto-numbered labels; step markers for walkthroughs - Blur tool with four styles: soften, pixelate, secure (flattens the region to one color), and black out diff --git a/config.example.toml b/config.example.toml index df4ad522..0bdcf13c 100644 --- a/config.example.toml +++ b/config.example.toml @@ -120,6 +120,9 @@ select_step_marker_tool = [] select_eraser_tool = ["D"] # Toggle eraser behavior mode toggle_eraser_mode = ["Ctrl+Shift+E"] +# Step stroke smoothing up and down (unbound by default) +increase_pen_smoothing = [] +decrease_pen_smoothing = [] # Select the spotlight tool (unbound by default) select_spotlight_tool = [] # Step the blur tool through blur/pixelate/secure/black-out (unbound by default) @@ -1086,6 +1089,13 @@ default_blur_style = "gaussian" # Default marker opacity multiplier (0.05 - 0.90). Multiplies the current color alpha. marker_opacity = 0.32 +# How much a finished freehand or marker stroke is smoothed (0 - 6). +# +# 0 keeps the exact path the pointer drew. Higher values clean up the shake of +# the hand. Smoothing runs when you lift the pen, never while you draw, so the +# live stroke always sits exactly on the pointer. Neither endpoint ever moves. +pen_smoothing = 3 + # Default fill state for fill-capable shapes default_fill_enabled = false diff --git a/configurator/src/app/pages/drawing/defaults.rs b/configurator/src/app/pages/drawing/defaults.rs index 46a5ccde..7abbfa30 100644 --- a/configurator/src/app/pages/drawing/defaults.rs +++ b/configurator/src/app/pages/drawing/defaults.rs @@ -48,6 +48,12 @@ pub(super) fn build(page: &mut PageBuilder) { |value| Message::TextChanged(TextField::DrawingMarkerOpacity, value), |app| validate_f64_range(&app.draft.drawing_marker_opacity, 0.05, 0.9), ) + .entry_row_validated( + "Pen smoothing (0-6)", + |app| app.draft.drawing_pen_smoothing.clone(), + |value| Message::TextChanged(TextField::DrawingPenSmoothing, value), + |app| validate_usize_range(&app.draft.drawing_pen_smoothing, 0, 6), + ) .entry_row_validated( "Undo stack limit", |app| app.draft.drawing_undo_stack_limit.clone(), diff --git a/configurator/src/models/config/draft/from_config.rs b/configurator/src/models/config/draft/from_config.rs index 3e89ae37..eca0784f 100644 --- a/configurator/src/models/config/draft/from_config.rs +++ b/configurator/src/models/config/draft/from_config.rs @@ -72,6 +72,7 @@ impl ConfigDraft { drawing_default_font_size: format_float(config.drawing.default_font_size), drawing_polygon_sides: config.drawing.polygon_sides.to_string(), drawing_marker_opacity: format_float(config.drawing.marker_opacity), + drawing_pen_smoothing: config.drawing.pen_smoothing.to_string(), drawing_hit_test_tolerance: format_float(config.drawing.hit_test_tolerance), drawing_hit_test_linear_threshold: config.drawing.hit_test_linear_threshold.to_string(), drawing_undo_stack_limit: config.drawing.undo_stack_limit.to_string(), diff --git a/configurator/src/models/config/draft/mod.rs b/configurator/src/models/config/draft/mod.rs index b421cb14..cb97a4a4 100644 --- a/configurator/src/models/config/draft/mod.rs +++ b/configurator/src/models/config/draft/mod.rs @@ -40,6 +40,7 @@ pub struct ConfigDraft { pub drawing_default_font_size: String, pub drawing_polygon_sides: String, pub drawing_marker_opacity: String, + pub drawing_pen_smoothing: String, pub drawing_hit_test_tolerance: String, pub drawing_hit_test_linear_threshold: String, pub drawing_undo_stack_limit: String, diff --git a/configurator/src/models/config/setters.rs b/configurator/src/models/config/setters.rs index 6ad13bd9..c8925366 100644 --- a/configurator/src/models/config/setters.rs +++ b/configurator/src/models/config/setters.rs @@ -327,6 +327,7 @@ impl ConfigDraft { TextField::DrawingFontSize => self.drawing_default_font_size = value, TextField::DrawingPolygonSides => self.drawing_polygon_sides = value, TextField::DrawingMarkerOpacity => self.drawing_marker_opacity = value, + TextField::DrawingPenSmoothing => self.drawing_pen_smoothing = value, TextField::DrawingFontFamily => self.drawing_font_family = value, TextField::DrawingFontWeight => { self.drawing_font_weight = value; diff --git a/configurator/src/models/config/to_config/drawing.rs b/configurator/src/models/config/to_config/drawing.rs index 1147a443..92333d3c 100644 --- a/configurator/src/models/config/to_config/drawing.rs +++ b/configurator/src/models/config/to_config/drawing.rs @@ -4,7 +4,7 @@ use super::super::parse::{ }; use crate::models::error::FormError; use wayscriber::config::Config; -use wayscriber::draw::{REGULAR_POLYGON_MAX_SIDES, REGULAR_POLYGON_MIN_SIDES}; +use wayscriber::draw::{MAX_PEN_SMOOTHING, REGULAR_POLYGON_MAX_SIDES, REGULAR_POLYGON_MIN_SIDES}; use wayscriber::input::state::{MAX_STROKE_THICKNESS, MIN_STROKE_THICKNESS}; use wayscriber::input::{DragBindableTool, DragTool}; @@ -51,6 +51,14 @@ impl ConfigDraft { errors, |value| config.drawing.polygon_sides = value, ); + parse_u8_in_range( + &self.drawing_pen_smoothing, + "drawing.pen_smoothing", + 0, + MAX_PEN_SMOOTHING, + errors, + |value| config.drawing.pen_smoothing = value, + ); parse_field_in_range( &self.drawing_marker_opacity, "drawing.marker_opacity", diff --git a/configurator/src/models/fields/toggles.rs b/configurator/src/models/fields/toggles.rs index c53a6d31..268b6024 100644 --- a/configurator/src/models/fields/toggles.rs +++ b/configurator/src/models/fields/toggles.rs @@ -107,6 +107,7 @@ pub enum TextField { DrawingFontSize, DrawingPolygonSides, DrawingMarkerOpacity, + DrawingPenSmoothing, DrawingFontFamily, DrawingFontWeight, DrawingFontStyle, diff --git a/configurator/src/models/keybindings/field/config/read.rs b/configurator/src/models/keybindings/field/config/read.rs index 773d97d9..df3d1f9d 100644 --- a/configurator/src/models/keybindings/field/config/read.rs +++ b/configurator/src/models/keybindings/field/config/read.rs @@ -43,6 +43,8 @@ impl KeybindingField { Self::SelectPenTool => &config.tools.select_pen_tool, Self::SelectEraserTool => &config.tools.select_eraser_tool, Self::ToggleEraserMode => &config.tools.toggle_eraser_mode, + Self::IncreasePenSmoothing => &config.tools.increase_pen_smoothing, + Self::DecreasePenSmoothing => &config.tools.decrease_pen_smoothing, Self::SelectMarkerTool => &config.tools.select_marker_tool, Self::SelectStepMarkerTool => &config.tools.select_step_marker_tool, Self::SelectLineTool => &config.tools.select_line_tool, diff --git a/configurator/src/models/keybindings/field/config/write.rs b/configurator/src/models/keybindings/field/config/write.rs index 53d778a0..2b282f1f 100644 --- a/configurator/src/models/keybindings/field/config/write.rs +++ b/configurator/src/models/keybindings/field/config/write.rs @@ -44,6 +44,8 @@ impl KeybindingField { Self::SelectPenTool => config.tools.select_pen_tool = value, Self::SelectEraserTool => config.tools.select_eraser_tool = value, Self::ToggleEraserMode => config.tools.toggle_eraser_mode = value, + Self::IncreasePenSmoothing => config.tools.increase_pen_smoothing = value, + Self::DecreasePenSmoothing => config.tools.decrease_pen_smoothing = value, Self::SelectMarkerTool => config.tools.select_marker_tool = value, Self::SelectStepMarkerTool => config.tools.select_step_marker_tool = value, Self::SelectLineTool => config.tools.select_line_tool = value, diff --git a/configurator/src/models/keybindings/field/labels.rs b/configurator/src/models/keybindings/field/labels.rs index d0ffd814..7455f519 100644 --- a/configurator/src/models/keybindings/field/labels.rs +++ b/configurator/src/models/keybindings/field/labels.rs @@ -53,6 +53,8 @@ impl KeybindingField { Self::SelectPenTool => "select_pen_tool", Self::SelectEraserTool => "select_eraser_tool", Self::ToggleEraserMode => "toggle_eraser_mode", + Self::IncreasePenSmoothing => "increase_pen_smoothing", + Self::DecreasePenSmoothing => "decrease_pen_smoothing", Self::SelectMarkerTool => "select_marker_tool", Self::SelectStepMarkerTool => "select_step_marker_tool", Self::SelectLineTool => "select_line_tool", diff --git a/configurator/src/models/keybindings/field/list.rs b/configurator/src/models/keybindings/field/list.rs index f56d9d9a..7b843f69 100644 --- a/configurator/src/models/keybindings/field/list.rs +++ b/configurator/src/models/keybindings/field/list.rs @@ -38,6 +38,8 @@ impl KeybindingField { Self::SelectPenTool, Self::SelectEraserTool, Self::ToggleEraserMode, + Self::IncreasePenSmoothing, + Self::DecreasePenSmoothing, Self::SelectMarkerTool, Self::SelectStepMarkerTool, Self::SelectLineTool, diff --git a/configurator/src/models/keybindings/field/mod.rs b/configurator/src/models/keybindings/field/mod.rs index bdf5df41..97b8dffc 100644 --- a/configurator/src/models/keybindings/field/mod.rs +++ b/configurator/src/models/keybindings/field/mod.rs @@ -40,6 +40,8 @@ pub enum KeybindingField { SelectPenTool, SelectEraserTool, ToggleEraserMode, + IncreasePenSmoothing, + DecreasePenSmoothing, SelectMarkerTool, SelectStepMarkerTool, SelectLineTool, diff --git a/configurator/src/models/keybindings/field/tab.rs b/configurator/src/models/keybindings/field/tab.rs index 7e1d2103..e168f0f4 100644 --- a/configurator/src/models/keybindings/field/tab.rs +++ b/configurator/src/models/keybindings/field/tab.rs @@ -28,6 +28,8 @@ impl KeybindingField { | Self::SelectPenTool | Self::SelectEraserTool | Self::ToggleEraserMode + | Self::IncreasePenSmoothing + | Self::DecreasePenSmoothing | Self::SelectMarkerTool | Self::SelectStepMarkerTool | Self::SelectLineTool diff --git a/docs/CONFIG.md b/docs/CONFIG.md index 45c1e1aa..2e46ea53 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -238,6 +238,9 @@ default_blur_style = "gaussian" # Default marker opacity multiplier (0.05 - 0.90). Multiplies the current color alpha. marker_opacity = 0.32 +# Smoothing applied to a finished freehand or marker stroke (0 - 6) +pen_smoothing = 3 + # Default fill state for fill-capable shape tools default_fill_enabled = false @@ -370,6 +373,7 @@ drag_tool = "default" - **Blur style**: Run **Cycle Blur Style** from the command palette to step through blur → pixelate → secure → black out (unbound by default; bind `cycle_blur_style`) - **Arrow style**: Run **Cycle Arrow Style** from the command palette to step through standard → pointy → curved → double (unbound by default; bind `cycle_arrow_style`). With arrows selected it restyles those in one undo step; with nothing selected it sets the style for the next arrow - **Marker opacity**: Use Ctrl+Alt + / +- **Pen smoothing**: Run **Increase / Decrease Pen Smoothing** from the command palette, or bind `increase_pen_smoothing` / `decrease_pen_smoothing` (see [Pen smoothing](#pen-smoothing)) - **Regular polygon sides**: Use the Shapes popover Sides control (range: 3-12) - **Font size**: Use Ctrl+Shift++/Ctrl+Shift+- or Shift + scroll (range: 8-72px) @@ -379,6 +383,7 @@ drag_tool = "default" - Eraser size: 12.0px - Eraser mode: Brush - Marker opacity: 0.32 +- Pen smoothing: 3 of 6 - Fill enabled: false - Polygon sides: 5 - Font size: 32.0px @@ -388,6 +393,39 @@ drag_tool = "default" - Undo stack limit: 100 - Drag mapping: Drag=Pen, Shift+Drag=Line, Ctrl+Drag=Rect, Ctrl+Shift+Drag=Arrow, Tab+Drag=Ellipse +#### Pen smoothing + +A pointer path carries the shake of the hand that drew it. `pen_smoothing` +removes that shake from freehand and marker strokes. + +```toml +[drawing] +pen_smoothing = 3 # 0 - 6, where 0 keeps the exact drawn path +``` + +**Smoothing runs when you lift the pen, not while you draw.** Smoothing a point +needs the points on either side of it, so a live smoother cannot draw the newest +sample until the next one arrives, and the line trails the cursor. On a projector +that lag is visible to the room. Running on release keeps the live stroke exactly +on the pointer and pays for the smoothing once, on a finished path. + +Both endpoints are pinned. A stroke starts and stops where you started and +stopped it, at every level. + +| Level | Result | +|-------|--------| +| 0 | The exact path you drew | +| 3 | The default. Clean, and still your line | +| 6 | Very smooth | + +The level applies to the Pen and the Marker. The Eraser is not smoothed: its path +decides what gets erased, so moving it would change the result rather than the +look. Tablet pressure is left alone — it is real detail, not shake. + +Nothing new is written to a session file. The level is a tool setting, so a +stroke is stored as the points it ended up with, and existing sessions are +unaffected. + ### `[arrow]` - Arrow Geometry Controls the appearance of arrow annotations. @@ -1945,6 +1983,8 @@ select_marker_tool = ["H"] select_step_marker_tool = [] select_eraser_tool = ["D"] toggle_eraser_mode = ["Ctrl+Shift+E"] +increase_pen_smoothing = [] # clean up finished strokes more +decrease_pen_smoothing = [] # keep more of the drawn path cycle_blur_style = [] # blur -> pixelate -> secure -> black out cycle_arrow_style = [] # standard -> pointy -> curved -> double select_spotlight_tool = [] # dim everything except a region diff --git a/src/backend/wayland/backend/state_init/input_state.rs b/src/backend/wayland/backend/state_init/input_state.rs index 7a6cbca0..382364d5 100644 --- a/src/backend/wayland/backend/state_init/input_state.rs +++ b/src/backend/wayland/backend/state_init/input_state.rs @@ -56,6 +56,7 @@ pub(super) fn build_input_state(config: &Config) -> InputState { input_state.polygon_sides = clamp_regular_sides(config.drawing.polygon_sides); input_state.blur_style = config.drawing.default_blur_style; input_state.arrow_style = config.arrow.style; + input_state.set_pen_smoothing(config.drawing.pen_smoothing); input_state.spotlight_dim_opacity = config.spotlight.dim_opacity; input_state.spotlight_feather = config.spotlight.feather; input_state.spotlight_magnification = config.spotlight.magnification; diff --git a/src/config/action_meta/entries/tools.rs b/src/config/action_meta/entries/tools.rs index 72a5a943..954b3f5e 100644 --- a/src/config/action_meta/entries/tools.rs +++ b/src/config/action_meta/entries/tools.rs @@ -224,6 +224,26 @@ pub const ENTRIES: &[ActionMeta] = &[ true, true ), + meta!( + IncreasePenSmoothing, + "Increase Pen Smoothing", + None, + "Clean up finished strokes more", + Tools, + true, + true, + true + ), + meta!( + DecreasePenSmoothing, + "Decrease Pen Smoothing", + None, + "Keep more of the drawn path", + Tools, + true, + true, + true + ), meta!( CycleBlurStyle, "Cycle Blur Style", diff --git a/src/config/action_meta/tests.rs b/src/config/action_meta/tests.rs index f41b534a..3a756091 100644 --- a/src/config/action_meta/tests.rs +++ b/src/config/action_meta/tests.rs @@ -176,6 +176,8 @@ const EXPECTED_COMMAND_PALETTE_ACTIONS: &[Action] = &[ Action::SelectStepMarkerTool, Action::SelectEraserTool, Action::ToggleEraserMode, + Action::IncreasePenSmoothing, + Action::DecreasePenSmoothing, Action::SelectSpotlightTool, Action::CycleBlurStyle, Action::CycleArrowStyle, diff --git a/src/config/keybindings/config/map/edit.rs b/src/config/keybindings/config/map/edit.rs index 94c9b8bb..a784679a 100644 --- a/src/config/keybindings/config/map/edit.rs +++ b/src/config/keybindings/config/map/edit.rs @@ -98,6 +98,8 @@ define_action_binding_accessors! { SelectStepMarkerTool => tools.select_step_marker_tool, SelectEraserTool => tools.select_eraser_tool, ToggleEraserMode => tools.toggle_eraser_mode, + IncreasePenSmoothing => tools.increase_pen_smoothing, + DecreasePenSmoothing => tools.decrease_pen_smoothing, CycleBlurStyle => tools.cycle_blur_style, CycleArrowStyle => tools.cycle_arrow_style, SelectPenTool => tools.select_pen_tool, diff --git a/src/config/keybindings/config/map/tools.rs b/src/config/keybindings/config/map/tools.rs index f51e0478..94f278c0 100644 --- a/src/config/keybindings/config/map/tools.rs +++ b/src/config/keybindings/config/map/tools.rs @@ -28,6 +28,14 @@ impl KeybindingsConfig { )?; inserter.insert_all(&self.tools.select_eraser_tool, Action::SelectEraserTool)?; inserter.insert_all(&self.tools.toggle_eraser_mode, Action::ToggleEraserMode)?; + inserter.insert_all( + &self.tools.increase_pen_smoothing, + Action::IncreasePenSmoothing, + )?; + inserter.insert_all( + &self.tools.decrease_pen_smoothing, + Action::DecreasePenSmoothing, + )?; inserter.insert_all(&self.tools.cycle_blur_style, Action::CycleBlurStyle)?; inserter.insert_all(&self.tools.cycle_arrow_style, Action::CycleArrowStyle)?; inserter.insert_all(&self.tools.select_pen_tool, Action::SelectPenTool)?; diff --git a/src/config/keybindings/config/types/bindings/tools.rs b/src/config/keybindings/config/types/bindings/tools.rs index a7495b4c..0185574a 100644 --- a/src/config/keybindings/config/types/bindings/tools.rs +++ b/src/config/keybindings/config/types/bindings/tools.rs @@ -32,6 +32,14 @@ pub struct ToolKeybindingsConfig { #[serde(default = "default_toggle_eraser_mode")] pub toggle_eraser_mode: Vec, + /// Raise the release-time smoothing applied to finished strokes. + #[serde(default = "default_increase_pen_smoothing")] + pub increase_pen_smoothing: Vec, + + /// Lower it. At 0 a stroke keeps the exact path the pointer drew. + #[serde(default = "default_decrease_pen_smoothing")] + pub decrease_pen_smoothing: Vec, + #[serde(default = "default_cycle_blur_style")] pub cycle_blur_style: Vec, @@ -105,6 +113,8 @@ impl Default for ToolKeybindingsConfig { select_step_marker_tool: default_select_step_marker_tool(), select_eraser_tool: default_select_eraser_tool(), toggle_eraser_mode: default_toggle_eraser_mode(), + increase_pen_smoothing: default_increase_pen_smoothing(), + decrease_pen_smoothing: default_decrease_pen_smoothing(), cycle_blur_style: default_cycle_blur_style(), cycle_arrow_style: default_cycle_arrow_style(), select_pen_tool: default_select_pen_tool(), diff --git a/src/config/keybindings/defaults/tools.rs b/src/config/keybindings/defaults/tools.rs index fd48372a..ae524038 100644 --- a/src/config/keybindings/defaults/tools.rs +++ b/src/config/keybindings/defaults/tools.rs @@ -36,6 +36,16 @@ pub(crate) fn default_toggle_eraser_mode() -> Vec { /// Unbound by default, like the blur tool itself; reachable from the command /// palette until the user binds a chord. +/// Unbound by default, like `cycle_blur_style`. Every free chord near the +/// drawing keys already means something, and smoothing is set once and left. +pub(crate) fn default_increase_pen_smoothing() -> Vec { + Vec::new() +} + +pub(crate) fn default_decrease_pen_smoothing() -> Vec { + Vec::new() +} + pub(crate) fn default_cycle_blur_style() -> Vec { Vec::new() } diff --git a/src/config/keybindings/tests.rs b/src/config/keybindings/tests.rs index 06d63a1b..96b992fa 100644 --- a/src/config/keybindings/tests.rs +++ b/src/config/keybindings/tests.rs @@ -741,6 +741,8 @@ const DEFAULT_BINDING_SNAPSHOT: &[(&str, &[&str])] = &[ ("select_step_marker_tool", &[]), ("select_eraser_tool", &["D"]), ("toggle_eraser_mode", &["Ctrl+Shift+E"]), + ("increase_pen_smoothing", &[]), + ("decrease_pen_smoothing", &[]), ("cycle_blur_style", &[]), ("cycle_arrow_style", &[]), ("select_pen_tool", &["F"]), diff --git a/src/config/mod.rs b/src/config/mod.rs index 08d9a0bb..65cefe06 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -61,29 +61,30 @@ pub use migration::{MigrationChange, MigrationPreview}; #[allow(unused_imports)] pub use types::{ ArrowConfig, BoardBackgroundConfig, BoardColorConfig, BoardConfig, BoardItemConfig, - BoardsConfig, CaptureConfig, ClickHighlightConfig, DEFAULT_OCR_LANGUAGES, 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, PdfLabelConfig, PdfLabelContentMode, - PdfLabelPosition, PdfOrientation, PdfPageSize, PdfTransparentBackground, PerformanceConfig, - PresenterModeConfig, PresenterToolBehavior, PresenterToolbarMode, PresetSlotsConfig, - PresetToolSettingConfig, PresetToolStatesConfig, QUICK_COLOR_RENDER_LIMIT, QuickColorConfig, - QuickColorPalette, QuickColorPaletteEntry, QuickColorSlot, QuickColorWrite, QuickColorsConfig, - RegionCaptureConfig, RenderColorMappingConfig, RenderProfileConfig, RenderProfileExportMode, - RenderProfilesConfig, ResolvedToolbarItems, SessionCompression, SessionConfig, - SessionStorageMode, SpotlightConfig, StatusBarItem, StatusBarStyle, ToolPresetConfig, - ToolbarBackendKind, ToolbarConfig, ToolbarGroupId, ToolbarItemCategory, ToolbarItemDefinition, - ToolbarItemId, ToolbarItemOrderConfig, ToolbarItemOrderGroup, ToolbarItemSurface, - ToolbarItemsConfig, ToolbarLayoutMode, ToolbarModeOverride, ToolbarModeOverrides, - ToolbarRebindModifier, ToolbarSectionFlag, ToolbarSectionVisibility, TopDisplayMode, - TrayConfig, TrayIconStyle, UiConfig, UpdatesConfig, ZoomChipDisplay, - default_quick_color_for_index, fold_legacy_section_flags, resolve_section_visibility, - section_flag_for_item, set_section_visibility, toolbar_item_definitions, toolbar_item_ids, - toolbar_item_order_group, validate_capture_format, validate_filename_template, - validate_ocr_languages, validate_pdf_label_template, + 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, + PdfLabelConfig, PdfLabelContentMode, PdfLabelPosition, PdfOrientation, PdfPageSize, + PdfTransparentBackground, PerformanceConfig, PresenterModeConfig, PresenterToolBehavior, + PresenterToolbarMode, PresetSlotsConfig, PresetToolSettingConfig, PresetToolStatesConfig, + QUICK_COLOR_RENDER_LIMIT, QuickColorConfig, QuickColorPalette, QuickColorPaletteEntry, + QuickColorSlot, QuickColorWrite, QuickColorsConfig, RegionCaptureConfig, + RenderColorMappingConfig, RenderProfileConfig, RenderProfileExportMode, RenderProfilesConfig, + ResolvedToolbarItems, SessionCompression, SessionConfig, SessionStorageMode, SpotlightConfig, + StatusBarItem, StatusBarStyle, ToolPresetConfig, ToolbarBackendKind, ToolbarConfig, + ToolbarGroupId, ToolbarItemCategory, ToolbarItemDefinition, ToolbarItemId, + ToolbarItemOrderConfig, ToolbarItemOrderGroup, ToolbarItemSurface, ToolbarItemsConfig, + ToolbarLayoutMode, ToolbarModeOverride, ToolbarModeOverrides, ToolbarRebindModifier, + ToolbarSectionFlag, ToolbarSectionVisibility, TopDisplayMode, TrayConfig, TrayIconStyle, + UiConfig, UpdatesConfig, ZoomChipDisplay, default_quick_color_for_index, + fold_legacy_section_flags, resolve_section_visibility, section_flag_for_item, + set_section_visibility, toolbar_item_definitions, toolbar_item_ids, toolbar_item_order_group, + validate_capture_format, validate_filename_template, validate_ocr_languages, + validate_pdf_label_template, }; #[cfg(feature = "tablet-input")] #[allow(unused_imports)] diff --git a/src/config/types/drawing.rs b/src/config/types/drawing.rs index 85e7b4d2..1e0abd32 100644 --- a/src/config/types/drawing.rs +++ b/src/config/types/drawing.rs @@ -9,6 +9,9 @@ pub const QUICK_COLOR_RENDER_LIMIT: usize = 24; /// Default tolerance used when selecting or targeting drawn shapes. pub(crate) const DEFAULT_HIT_TEST_TOLERANCE: f64 = 6.0; +/// Default release-time smoothing level for freehand and marker strokes. +pub const DEFAULT_PEN_SMOOTHING: u8 = 3; + /// Drawing-related settings. /// /// Controls the default appearance of drawing tools when the overlay first opens. @@ -49,6 +52,14 @@ pub struct DrawingConfig { #[serde(default = "default_marker_opacity")] pub marker_opacity: f64, + /// Release-time smoothing passes for freehand and marker strokes (0 - 6). + /// + /// 0 keeps the exact path the pointer drew. Higher values clean up the + /// finished stroke without moving either of its endpoints. The live stroke + /// always follows the raw pointer, whatever this is set to. + #[serde(default = "default_pen_smoothing")] + pub pen_smoothing: u8, + /// Whether shapes start filled when applicable #[serde(default = "default_fill_enabled")] pub default_fill_enabled: bool, @@ -130,6 +141,7 @@ impl Default for DrawingConfig { default_eraser_mode: default_eraser_mode(), default_blur_style: default_blur_style(), marker_opacity: default_marker_opacity(), + pen_smoothing: default_pen_smoothing(), default_fill_enabled: default_fill_enabled(), polygon_sides: default_polygon_sides(), default_font_size: default_font_size(), @@ -872,6 +884,12 @@ fn default_marker_opacity() -> f64 { 0.32 } +/// A middle setting. Enough to take the shake out of a normal hand, little +/// enough that a deliberate corner is still a corner. +fn default_pen_smoothing() -> u8 { + DEFAULT_PEN_SMOOTHING +} + fn default_fill_enabled() -> bool { false } diff --git a/src/config/types/mod.rs b/src/config/types/mod.rs index 47b83867..edf146e6 100644 --- a/src/config/types/mod.rs +++ b/src/config/types/mod.rs @@ -35,6 +35,7 @@ pub use capture::{ pub use click_highlight::ClickHighlightConfig; pub use context_menu::ContextMenuUiConfig; pub(crate) use drawing::DEFAULT_HIT_TEST_TOLERANCE; +pub use drawing::DEFAULT_PEN_SMOOTHING; pub use drawing::{ DragButtonConfig, DrawingConfig, MouseDragToolsConfig, QUICK_COLOR_RENDER_LIMIT, QuickColorConfig, QuickColorPalette, QuickColorPaletteEntry, QuickColorSlot, QuickColorWrite, diff --git a/src/config/validate/drawing.rs b/src/config/validate/drawing.rs index fd80ce5f..b5894ea5 100644 --- a/src/config/validate/drawing.rs +++ b/src/config/validate/drawing.rs @@ -1,6 +1,6 @@ use super::Config; use crate::config::types::DEFAULT_HIT_TEST_TOLERANCE; -use crate::draw::shape::{REGULAR_POLYGON_MAX_SIDES, REGULAR_POLYGON_MIN_SIDES}; +use crate::draw::shape::{MAX_PEN_SMOOTHING, REGULAR_POLYGON_MAX_SIDES, REGULAR_POLYGON_MIN_SIDES}; use crate::input::state::{MAX_STROKE_THICKNESS, MIN_STROKE_THICKNESS}; impl Config { @@ -37,6 +37,15 @@ impl Config { } // Marker opacity: 0.05 - 0.9 + // Pen smoothing: 0 - MAX_PEN_SMOOTHING passes + if self.drawing.pen_smoothing > MAX_PEN_SMOOTHING { + log::warn!( + "Invalid pen_smoothing {}, clamping to 0-{MAX_PEN_SMOOTHING} range", + self.drawing.pen_smoothing + ); + self.drawing.pen_smoothing = MAX_PEN_SMOOTHING; + } + if !(0.05..=0.9).contains(&self.drawing.marker_opacity) { log::warn!( "Invalid marker_opacity {:.2}, clamping to 0.05-0.90 range", diff --git a/src/configurator_destination.rs b/src/configurator_destination.rs index 3507550a..76d32cd2 100644 --- a/src/configurator_destination.rs +++ b/src/configurator_destination.rs @@ -110,6 +110,8 @@ pub fn keybindings_section_for_action(action: Action) -> Option u8 { + level.min(MAX_PEN_SMOOTHING) +} + +/// Smooth an `(x, y)` path. Level 0 returns the path unchanged. +pub fn smooth_path(points: &[(i32, i32)], level: u8) -> Vec<(i32, i32)> { + let Some(smoothed) = smooth_points(points, level, |&(x, y)| (f64::from(x), f64::from(y))) + else { + return points.to_vec(); + }; + smoothed + .into_iter() + .map(|(x, y)| (round_to_i32(x), round_to_i32(y))) + .collect() +} + +/// Smooth the position of a pressure path, leaving every thickness alone. +/// +/// Thickness came from the tablet, not from the hand's aim, so smoothing it +/// would erase real pressure detail while fixing nothing. +pub fn smooth_pressure_path(points: &[(i32, i32, f32)], level: u8) -> Vec<(i32, i32, f32)> { + let Some(smoothed) = smooth_points(points, level, |&(x, y, _)| (f64::from(x), f64::from(y))) + else { + return points.to_vec(); + }; + points + .iter() + .zip(smoothed) + .map(|(&(_, _, thickness), (x, y))| (round_to_i32(x), round_to_i32(y), thickness)) + .collect() +} + +/// The shared filter. `None` means the caller should keep its input as it is. +fn smooth_points( + points: &[T], + level: u8, + position: impl Fn(&T) -> (f64, f64), +) -> Option> { + let level = clamp_pen_smoothing(level); + if level == 0 || points.len() < MIN_SMOOTHABLE_POINTS { + return None; + } + + // One f64 buffer for the whole run: rounding to integers between passes + // would quantize away exactly the sub-pixel corrections being applied. + let mut current: Vec<(f64, f64)> = points.iter().map(&position).collect(); + if current + .iter() + .any(|(x, y)| !x.is_finite() || !y.is_finite()) + { + return None; + } + let mut next = current.clone(); + let neighbor_weight = (1.0 - CENTER_WEIGHT) / 2.0; + + for _ in 0..level { + for index in 1..current.len() - 1 { + let previous = current[index - 1]; + let point = current[index]; + let following = current[index + 1]; + next[index] = ( + previous.0 * neighbor_weight + + point.0 * CENTER_WEIGHT + + following.0 * neighbor_weight, + previous.1 * neighbor_weight + + point.1 * CENTER_WEIGHT + + following.1 * neighbor_weight, + ); + } + std::mem::swap(&mut current, &mut next); + } + Some(current) +} + +fn round_to_i32(value: f64) -> i32 { + value + .round() + .clamp(f64::from(i32::MIN), f64::from(i32::MAX)) as i32 +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A straight run with one sample knocked sideways, as a shaky hand makes. + fn spike() -> Vec<(i32, i32)> { + vec![(0, 0), (10, 0), (20, 12), (30, 0), (40, 0)] + } + + #[test] + fn level_zero_returns_the_exact_path_that_was_drawn() { + let points = spike(); + + assert_eq!(smooth_path(&points, 0), points); + } + + #[test] + fn smoothing_pulls_a_spike_back_toward_its_neighbours() { + let points = spike(); + + let smoothed = smooth_path(&points, 3); + + assert_eq!(smoothed.len(), points.len()); + assert!( + smoothed[2].1 < points[2].1, + "the spike at index 2 must come down, not stay at 12" + ); + assert!(smoothed[2].1 > 0, "and not be flattened away entirely"); + } + + #[test] + fn a_higher_level_smooths_more_than_a_lower_one() { + let points = spike(); + + let light = smooth_path(&points, 1)[2].1; + let heavy = smooth_path(&points, 6)[2].1; + + assert!( + heavy < light, + "level 6 must pull the spike further than level 1" + ); + } + + #[test] + fn both_endpoints_never_move() { + let points = spike(); + + for level in 0..=MAX_PEN_SMOOTHING { + let smoothed = smooth_path(&points, level); + assert_eq!( + smoothed.first(), + points.first(), + "level {level} moved the start of the stroke" + ); + assert_eq!( + smoothed.last(), + points.last(), + "level {level} moved the end of the stroke" + ); + } + } + + #[test] + fn a_straight_line_survives_every_level_unchanged() { + let points = vec![(0, 0), (10, 0), (20, 0), (30, 0)]; + + for level in 0..=MAX_PEN_SMOOTHING { + assert_eq!(smooth_path(&points, level), points, "level {level}"); + } + } + + #[test] + fn paths_too_short_to_have_an_interior_are_returned_as_they_are() { + for points in [vec![], vec![(5, 5)], vec![(5, 5), (9, 9)]] { + assert_eq!(smooth_path(&points, 6), points); + } + } + + #[test] + fn the_level_is_clamped_rather_than_trusted() { + let points = spike(); + + assert_eq!(clamp_pen_smoothing(200), MAX_PEN_SMOOTHING); + assert_eq!( + smooth_path(&points, 200), + smooth_path(&points, MAX_PEN_SMOOTHING) + ); + } + + #[test] + fn a_pressure_path_keeps_every_thickness_while_its_positions_move() { + let points = vec![ + (0, 0, 1.0f32), + (10, 0, 4.0), + (20, 12, 9.0), + (30, 0, 4.0), + (40, 0, 1.0), + ]; + + let smoothed = smooth_pressure_path(&points, 3); + + assert_eq!( + smoothed.iter().map(|&(_, _, t)| t).collect::>(), + points.iter().map(|&(_, _, t)| t).collect::>(), + "tablet pressure is real detail and is not the hand's shake" + ); + assert!(smoothed[2].1 < points[2].1); + assert_eq!(smoothed.first().map(|&(x, y, _)| (x, y)), Some((0, 0))); + } + + #[test] + fn smoothing_never_changes_how_many_points_a_stroke_has() { + let points = spike(); + + for level in 0..=MAX_PEN_SMOOTHING { + assert_eq!(smooth_path(&points, level).len(), points.len()); + } + } +} diff --git a/src/input/state/actions/action_tools.rs b/src/input/state/actions/action_tools.rs index 8ff9e56e..3eb80f77 100644 --- a/src/input/state/actions/action_tools.rs +++ b/src/input/state/actions/action_tools.rs @@ -6,6 +6,25 @@ use log::info; use super::super::{InputState, PendingToolbarPersistence}; impl InputState { + /// Step the smoothing level and say where it landed. + /// + /// The toast names the level because smoothing has no visible effect until + /// the next stroke is finished: without it the key would appear dead. + fn announce_pen_smoothing(&mut self, delta: i32) { + if !self.nudge_pen_smoothing(delta) { + return; + } + let level = self.pen_smoothing; + let max = crate::draw::shape::MAX_PEN_SMOOTHING; + info!("Pen smoothing set to {level}/{max}"); + let message = if level == 0 { + "Pen smoothing off".to_string() + } else { + format!("Pen smoothing {level}/{max}") + }; + self.push_toast(ToastPriority::Info, "pen-smoothing", Toast::info(message)); + } + pub(in crate::input::state) fn handle_tool_action(&mut self, action: Action) -> bool { if let Some(tool) = Tool::from_select_action(action) { if tool == Tool::Highlight { @@ -37,6 +56,8 @@ impl InputState { Action::DecreaseMarkerOpacity => { self.set_marker_opacity(self.marker_opacity - 0.05); } + Action::IncreasePenSmoothing => self.announce_pen_smoothing(1), + Action::DecreasePenSmoothing => self.announce_pen_smoothing(-1), Action::ToggleEraserMode => { if self.toggle_eraser_mode() { info!("Eraser mode set to {:?}", self.eraser_mode); diff --git a/src/input/state/core/base/state/init.rs b/src/input/state/core/base/state/init.rs index a6643fbd..097d1cf3 100644 --- a/src/input/state/core/base/state/init.rs +++ b/src/input/state/core/base/state/init.rs @@ -95,6 +95,9 @@ impl InputState { eraser_kind: EraserKind::Circle, eraser_mode, marker_opacity, + pen_smoothing: crate::draw::shape::clamp_pen_smoothing( + crate::config::DEFAULT_PEN_SMOOTHING, + ), blur_style: BlurStyle::default(), spotlight_dim_opacity: 0.6, spotlight_feather: 0.35, diff --git a/src/input/state/core/base/state/structs.rs b/src/input/state/core/base/state/structs.rs index c91094f4..a5efca9d 100644 --- a/src/input/state/core/base/state/structs.rs +++ b/src/input/state/core/base/state/structs.rs @@ -116,6 +116,9 @@ pub struct InputState { pub eraser_mode: EraserMode, /// Opacity multiplier for marker tool strokes pub marker_opacity: f64, + /// Release-time smoothing passes applied to finished freehand and marker + /// strokes. 0 keeps the exact drawn path. + pub pen_smoothing: u8, /// How the blur tool obscures the region it covers pub blur_style: BlurStyle, /// Alpha of the dim layer outside every spotlight diff --git a/src/input/state/core/tool_controls/settings.rs b/src/input/state/core/tool_controls/settings.rs index c232f1fb..dcef6ac0 100644 --- a/src/input/state/core/tool_controls/settings.rs +++ b/src/input/state/core/tool_controls/settings.rs @@ -218,6 +218,36 @@ impl InputState { true } + /// Steps the release-time smoothing level. Returns true if it changed. + /// + /// Clamped at both ends rather than wrapped: 0 and the maximum are both + /// settings someone deliberately sits on, and wrapping from one to the + /// other on a stray keypress would change every later stroke. + pub fn nudge_pen_smoothing(&mut self, delta: i32) -> bool { + let next = crate::draw::shape::clamp_pen_smoothing( + i32::from(self.pen_smoothing) + .saturating_add(delta) + .clamp(0, i32::from(crate::draw::shape::MAX_PEN_SMOOTHING)) as u8, + ); + if next == self.pen_smoothing { + return false; + } + self.pen_smoothing = next; + self.mark_session_dirty(); + true + } + + /// Sets the level directly, for config load and session restore. + pub fn set_pen_smoothing(&mut self, level: u8) -> bool { + let level = crate::draw::shape::clamp_pen_smoothing(level); + if level == self.pen_smoothing { + return false; + } + self.pen_smoothing = level; + self.mark_session_dirty(); + true + } + /// Sets the magnification stored on newly drawn spotlights. /// /// Deliberately requests no warning feedback: this changes what the *next* diff --git a/src/input/state/interaction/actions.rs b/src/input/state/interaction/actions.rs index ae627f89..d839d244 100644 --- a/src/input/state/interaction/actions.rs +++ b/src/input/state/interaction/actions.rs @@ -36,6 +36,8 @@ pub(crate) fn classify_action(action: Action) -> ActionRoute { | Action::DecreaseThickness | Action::IncreaseMarkerOpacity | Action::DecreaseMarkerOpacity + | Action::IncreasePenSmoothing + | Action::DecreasePenSmoothing | Action::SelectSelectionTool | Action::SelectMarkerTool | Action::SelectStepMarkerTool diff --git a/src/input/state/mouse/release/drawing.rs b/src/input/state/mouse/release/drawing.rs index 49e1c18e..196a2a3d 100644 --- a/src/input/state/mouse/release/drawing.rs +++ b/src/input/state/mouse/release/drawing.rs @@ -59,6 +59,7 @@ pub(super) fn finish_drawing(state: &mut InputState, tool: Tool, release: Drawin eraser_size: state.eraser_size, eraser_kind: state.eraser_kind, pressure_variation_threshold: state.pressure_variation_threshold, + pen_smoothing: state.pen_smoothing, }; tool.finish_stroke(snapshot) }; @@ -246,3 +247,84 @@ fn append_segment_damage_regions( } } } + +#[cfg(test)] +mod tests { + use crate::draw::Shape; + use crate::input::Tool; + use crate::input::state::test_support::make_test_input_state; + + /// A straight run with one sample knocked sideways, as a shaky hand makes. + fn shaky_path() -> Vec<(i32, i32)> { + vec![(0, 0), (10, 0), (20, 12), (30, 0), (40, 0)] + } + + /// Draw `path` with `tool` at the state's current smoothing level and return + /// the points the committed shape ended up with. + fn drawn_points(level: u8, tool: Tool) -> Vec<(i32, i32)> { + let mut state = make_test_input_state(); + state.set_pen_smoothing(level); + state.set_tool_override(Some(tool)); + + let path = shaky_path(); + let first = path[0]; + let last = *path.last().unwrap(); + state.on_mouse_press(crate::input::MouseButton::Left, first.0, first.1); + for &(x, y) in &path[1..] { + state.on_mouse_motion(x, y); + } + state.on_mouse_release(crate::input::MouseButton::Left, last.0, last.1); + + let shape = state + .boards + .active_frame() + .shapes + .last() + .expect("the release committed a shape") + .shape + .clone(); + match shape { + Shape::Freehand { points, .. } | Shape::MarkerStroke { points, .. } => points, + other => panic!("expected a path stroke, got {other:?}"), + } + } + + #[test] + fn a_committed_pen_stroke_is_smoothed_at_the_configured_level() { + let raw = drawn_points(0, Tool::Pen); + let smoothed = drawn_points(4, Tool::Pen); + + let raw_spike = raw.iter().map(|&(_, y)| y).max().unwrap(); + let smoothed_spike = smoothed.iter().map(|&(_, y)| y).max().unwrap(); + assert!( + smoothed_spike < raw_spike, + "level 4 must pull the spike down from {raw_spike}, got {smoothed_spike}" + ); + } + + #[test] + fn level_zero_commits_the_exact_path_the_pointer_drew() { + assert_eq!(drawn_points(0, Tool::Pen), shaky_path()); + } + + #[test] + fn a_smoothed_stroke_still_starts_and_ends_where_the_pointer_did() { + let path = shaky_path(); + let smoothed = drawn_points(6, Tool::Pen); + + assert_eq!(smoothed.first(), path.first()); + assert_eq!(smoothed.last(), path.last()); + } + + #[test] + fn the_marker_is_smoothed_on_the_same_setting_as_the_pen() { + let raw = drawn_points(0, Tool::Marker); + let smoothed = drawn_points(4, Tool::Marker); + + assert_ne!( + raw, smoothed, + "a highlighter is drawn by hand too and shakes the same way" + ); + assert_eq!(smoothed.first(), raw.first()); + } +} diff --git a/src/input/tool/drawing.rs b/src/input/tool/drawing.rs index b4d584ba..9453dbbc 100644 --- a/src/input/tool/drawing.rs +++ b/src/input/tool/drawing.rs @@ -1,4 +1,7 @@ -use crate::draw::shape::{bounding_box_for_blur, bounding_box_for_eraser, bounding_box_for_points}; +use crate::draw::shape::{ + bounding_box_for_blur, bounding_box_for_eraser, bounding_box_for_points, smooth_path, + smooth_pressure_path, +}; use crate::draw::{ ArrowLabel, ArrowStyle, BlurRectParams, BlurStyle, Color, EraserBrush, EraserKind, Shape, }; @@ -49,6 +52,8 @@ pub(crate) struct ToolStrokeSnapshot { pub(crate) eraser_size: f64, pub(crate) eraser_kind: EraserKind, pub(crate) pressure_variation_threshold: f64, + /// Release-time smoothing passes for path tools. 0 keeps the exact path. + pub(crate) pen_smoothing: u8, } /// Immutable inputs needed to turn one completed polygon drag into a shape. @@ -455,7 +460,7 @@ fn finish_path_stroke( snapshot.pressure_variation_threshold, ) { - let points = snapshot + let points: Vec<_> = snapshot .points .into_iter() .zip(snapshot.point_thicknesses) @@ -463,7 +468,7 @@ fn finish_path_stroke( .collect(); return FinishedToolStroke::Shape { shape: Shape::FreehandPressure { - points, + points: smooth_pressure_path(&points, snapshot.pen_smoothing), color: snapshot.color, }, usage, @@ -472,7 +477,7 @@ fn finish_path_stroke( FinishedToolStroke::Shape { shape: Shape::Freehand { - points: snapshot.points, + points: smooth_path(&snapshot.points, snapshot.pen_smoothing), color: snapshot.color, thick: snapshot.size, }, @@ -481,7 +486,7 @@ fn finish_path_stroke( } ToolPathKind::Marker => FinishedToolStroke::Shape { shape: Shape::MarkerStroke { - points: snapshot.points, + points: smooth_path(&snapshot.points, snapshot.pen_smoothing), color: marker_color_with_opacity(snapshot.color, snapshot.marker_opacity), thick: snapshot.size, }, diff --git a/src/ui/help_overlay/sections/builder/sections.rs b/src/ui/help_overlay/sections/builder/sections.rs index fd3b5e71..68b2316d 100644 --- a/src/ui/help_overlay/sections/builder/sections.rs +++ b/src/ui/help_overlay/sections/builder/sections.rs @@ -122,6 +122,14 @@ pub(super) fn build_main_sections( ), "Adjust thickness", ), + row( + bindings_or_fallback( + bindings, + &[Action::IncreasePenSmoothing, Action::DecreasePenSmoothing], + NOT_BOUND_LABEL, + ), + "Adjust stroke smoothing", + ), ], badges: color_badges.clone(), icon: Some(toolbar_icons::draw_icon_pen), From 73a1e84c19f9c4d5622d4817b7eb89de4173cc6e Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Tue, 25 Aug 2026 23:37:23 +0200 Subject: [PATCH 2/6] fix(text): take the halo's contrast from the background, not the text MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Text is drawn with a contrasting outline so it reads over a busy desktop. `text_outline_color` chose that outline from the colour of the *text*: bright text took a black halo, dark text took a white one. That reads the wrong input. A halo exists to separate the glyphs from what is behind them, so what is behind them is what it has to contrast with. Red has a weighted brightness of 0.30, so the rule called red dark and gave it a white halo — on a whiteboard, a white halo on a white page. The regression test measures the old behaviour at exactly zero dark pixels around red glyphs on white. The halo now comes from the luminance of what is already painted under the label, sampled from the render target immediately before the glyphs go on. Reading the target rather than the captured desktop image is what makes a label over a blur, over a filled shape, or over a board colour all answer correctly; none of them would if the probe looked at the raw screen capture. The probe scales the region into a fixed 8x4 scratch surface, so it costs one small paint per label per frame and does not grow with the size of the text. It copies through a fresh context rather than borrowing the live target's buffer, which is the same technique the spotlight magnifier uses. A transparent board with no frozen or zoomed capture has nothing to sample: the desktop shows through the compositor and those pixels were never ours. The probe reports that it does not know, and the old text-colour rule stays as the fallback, so behaviour there is unchanged. The guaranteed win is the solid boards, where the background is always known. The caret in the inline text editor asks the same question at its own position, so a caret cannot disagree with the glyphs it sits among. --- README.md | 2 +- docs/CONFIG.md | 14 ++ .../wayland/state/render/canvas/text.rs | 13 +- src/draw/mod.rs | 5 +- src/draw/render/backdrop_probe.rs | 205 ++++++++++++++++++ src/draw/render/mod.rs | 2 + src/draw/render/text.rs | 121 ++++++++++- 7 files changed, 349 insertions(+), 13 deletions(-) create mode 100644 src/draw/render/backdrop_probe.rs diff --git a/README.md b/README.md index e2a42c19..59f0cf99 100644 --- a/README.md +++ b/README.md @@ -122,7 +122,7 @@ The v0.9.23+ prebuilt `wayscriber` packages require glibc 2.39 and GTK 4.12 — - Arrows in four styles - standard, pointy, curved (drag its handle to route around what is in the way), and double-ended - with optional auto-numbered labels; step markers for walkthroughs - Blur tool with four styles: soften, pixelate, secure (flattens the region to one color), and black out - Spotlight tool: dims everything except the regions you draw, with optional 1×–4× magnification -- Multiline text and sticky notes with smoothing +- Multiline text and sticky notes with smoothing; text halos take their contrast from the background the label sits on, so a label stays readable on any board or frozen screen - Selection: Alt-drag, V tool, properties panel - Duplicate (Ctrl+D), delete (Delete), undo/redo - Color picker, screen eyedropper with a magnified pixel loupe, palettes, size via hotkeys or scroll diff --git a/docs/CONFIG.md b/docs/CONFIG.md index 2e46ea53..811e181e 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -393,6 +393,20 @@ drag_tool = "default" - Undo stack limit: 100 - Drag mapping: Drag=Pen, Shift+Drag=Line, Ctrl+Drag=Rect, Ctrl+Shift+Drag=Arrow, Tab+Drag=Ellipse +#### Text halo + +Text is drawn with a contrasting outline so it stays readable over any +background. Wayscriber picks that halo color from **what the label sits on**, +sampled from the canvas just before the glyphs are painted. + +That means a whiteboard, a blackboard, a frozen screen, a zoomed screen, and a +region already covered by a blur or a filled shape all give the right answer. + +On a transparent board with no frozen or zoomed capture there is nothing to +sample — the desktop shows through the compositor and those pixels were never +Wayscriber's to read. The halo then falls back to a rule based on the text color +itself, which is what every case used before. + #### Pen smoothing A pointer path carries the shake of the hand that drew it. `pen_smoothing` diff --git a/src/backend/wayland/state/render/canvas/text.rs b/src/backend/wayland/state/render/canvas/text.rs index 3fff6e20..afba071f 100644 --- a/src/backend/wayland/state/render/canvas/text.rs +++ b/src/backend/wayland/state/render/canvas/text.rs @@ -114,7 +114,18 @@ impl WaylandState { // Widths come from the draw layer so the damage tracker sizes the // caret's repaint rectangle from the exact same numbers. let line_width = crate::draw::caret_line_width(size); - let outline = crate::draw::text_outline_color(color); + // The caret stands where the glyphs will, so it asks the same + // question they do: what is behind this point on the canvas? + let background_luminance = crate::draw::painted_background_luminance( + ctx, + ( + caret_x, + top, + crate::draw::caret_outline_width(size), + bottom - top, + ), + ); + let outline = crate::draw::text_outline_color(color, background_luminance); ctx.set_source_rgba(outline.r, outline.g, outline.b, outline.a); ctx.set_line_width(crate::draw::caret_outline_width(size)); ctx.move_to(caret_x, top); diff --git a/src/draw/mod.rs b/src/draw/mod.rs index 8c728d7b..a9462f07 100644 --- a/src/draw/mod.rs +++ b/src/draw/mod.rs @@ -31,8 +31,9 @@ pub use render::{ BlurRectParams, EraserReplayContext, IMMUTABLE_RASTER_SOURCE_TOKEN, SpotlightMagnifierMetrics, SpotlightMagnifierOutcome, SpotlightMagnifierScratch, SpotlightMagnifierSource, SpotlightPass, SpotlightRegion, SpotlightSnapshotStrategy, caret_line_width, caret_outline_width, - render_blur_rect, render_board_background, render_click_highlight, render_freehand_borrowed, - render_marker_stroke_borrowed, render_selection_halo, render_selection_handles, render_shape, + painted_background_luminance, render_blur_rect, render_board_background, + render_click_highlight, render_freehand_borrowed, render_marker_stroke_borrowed, + render_selection_halo, render_selection_handles, render_shape, render_spotlight_magnification_pass, render_spotlight_pass, render_sticky_note, render_text, selection_handle_rects, spotlight_regions_for_frame, sticky_note_foreground, text_outline_color, diff --git a/src/draw/render/backdrop_probe.rs b/src/draw/render/backdrop_probe.rs new file mode 100644 index 00000000..610865ba --- /dev/null +++ b/src/draw/render/backdrop_probe.rs @@ -0,0 +1,205 @@ +//! How bright is what is already painted under a rectangle? +//! +//! Text on the overlay needs a halo that contrasts with its background. The +//! only thing that knows the background is the surface itself, after the +//! backdrop and every earlier shape have been painted onto it and before the +//! text goes on top. So the probe reads the render target rather than the +//! captured desktop image: a label over a blur, over a filled rectangle, or +//! over a board colour all answer correctly, and none of them would if the +//! probe looked at the raw screen capture instead. +//! +//! On a transparent board with no frozen or zoomed capture there is nothing +//! painted under the label — the desktop shows through the compositor and its +//! pixels were never ours. The probe reports `None` there, which is honest, and +//! the caller keeps its previous behaviour. + +use crate::draw::Color; + +/// Width and height of the scratch surface the region is downsampled into. +/// +/// The probe wants one average, not detail, so the region is scaled into a +/// fixed tiny surface. Cairo does the averaging during the paint, and the cost +/// stops depending on how large the text is. +const PROBE_WIDTH: i32 = 8; +const PROBE_HEIGHT: i32 = 4; + +/// Alpha at or above which a sampled pixel counts as background rather than as +/// a hole the desktop shows through. +const OPAQUE_ALPHA: u8 = 200; + +/// Fraction of samples that must be opaque before the average means anything. +/// Under this the label straddles an edge or floats over live desktop, and a +/// guess would be worse than the caller's fallback. +const MIN_OPAQUE_FRACTION: f64 = 0.5; + +/// Relative luminance of what is painted under `bounds`, in user-space +/// coordinates, or `None` when too little of it is opaque to judge. +pub fn painted_luminance(ctx: &cairo::Context, bounds: (f64, f64, f64, f64)) -> Option { + let (x, y, width, height) = bounds; + if !(x.is_finite() && y.is_finite() && width.is_finite() && height.is_finite()) + || width <= 0.0 + || height <= 0.0 + { + return None; + } + + let target = ctx.target(); + let (target_width, target_height) = target_size(&target)?; + + // The region is in user space and the target is in device pixels; the + // canvas transform between them can be a zoom, a pan, or both. + let (device_x, device_y) = ctx.user_to_device(x, y); + let (far_x, far_y) = ctx.user_to_device(x + width, y + height); + let left = device_x.min(far_x).floor().max(0.0); + let top = device_y.min(far_y).floor().max(0.0); + let right = device_x.max(far_x).ceil().min(f64::from(target_width)); + let bottom = device_y.max(far_y).ceil().min(f64::from(target_height)); + if right - left < 1.0 || bottom - top < 1.0 { + return None; + } + + let probe = + cairo::ImageSurface::create(cairo::Format::ARgb32, PROBE_WIDTH, PROBE_HEIGHT).ok()?; + { + let copy = cairo::Context::new(&probe).ok()?; + copy.set_operator(cairo::Operator::Source); + copy.scale( + f64::from(PROBE_WIDTH) / (right - left), + f64::from(PROBE_HEIGHT) / (bottom - top), + ); + target.flush(); + copy.set_source_surface(&target, -left, -top).ok()?; + copy.paint().ok()?; + } + let mut probe = probe; + probe.flush(); + + average_luminance(&mut probe) +} + +fn target_size(target: &cairo::Surface) -> Option<(i32, i32)> { + let image = cairo::ImageSurface::try_from(target.clone()).ok()?; + let (width, height) = (image.width(), image.height()); + (width > 0 && height > 0).then_some((width, height)) +} + +/// Mean relative luminance of the opaque samples, or `None`. +fn average_luminance(probe: &mut cairo::ImageSurface) -> Option { + let stride = usize::try_from(probe.stride()).ok()?; + let width = usize::try_from(probe.width()).ok()?; + let height = usize::try_from(probe.height()).ok()?; + let data = probe.data().ok()?; + + let mut total = 0.0; + let mut opaque = 0usize; + for row in 0..height { + for column in 0..width { + let offset = row * stride + column * 4; + let Some(pixel) = data.get(offset..offset + 4) else { + continue; + }; + // Cairo ARGB32 is native-endian and premultiplied. + let alpha = pixel[3]; + if alpha < OPAQUE_ALPHA { + continue; + } + let scale = f64::from(alpha) / 255.0; + let blue = f64::from(pixel[0]) / 255.0 / scale; + let green = f64::from(pixel[1]) / 255.0 / scale; + let red = f64::from(pixel[2]) / 255.0 / scale; + total += relative_luminance(red, green, blue); + opaque += 1; + } + } + + let samples = width * height; + if samples == 0 || (opaque as f64) < samples as f64 * MIN_OPAQUE_FRACTION { + return None; + } + Some(total / opaque as f64) +} + +/// Weighted luminance, the same formula the board pen-contrast helper uses. +pub(super) fn relative_luminance(red: f64, green: f64, blue: f64) -> f64 { + red * 0.299 + green * 0.587 + blue * 0.114 +} + +/// Relative luminance of a colour, ignoring its alpha. +pub(super) fn color_luminance(color: Color) -> f64 { + relative_luminance(color.r, color.g, color.b) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn filled_target(color: (f64, f64, f64, f64)) -> (cairo::ImageSurface, cairo::Context) { + let surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 100, 60).unwrap(); + let ctx = cairo::Context::new(&surface).unwrap(); + ctx.set_source_rgba(color.0, color.1, color.2, color.3); + let _ = ctx.paint(); + (surface, ctx) + } + + #[test] + fn a_white_background_reads_as_bright_and_a_black_one_as_dark() { + let (_white, ctx) = filled_target((1.0, 1.0, 1.0, 1.0)); + let bright = painted_luminance(&ctx, (10.0, 10.0, 40.0, 20.0)).expect("opaque"); + assert!(bright > 0.9, "got {bright}"); + + let (_black, ctx) = filled_target((0.0, 0.0, 0.0, 1.0)); + let dark = painted_luminance(&ctx, (10.0, 10.0, 40.0, 20.0)).expect("opaque"); + assert!(dark < 0.1, "got {dark}"); + } + + #[test] + fn a_transparent_target_reports_that_it_does_not_know() { + let surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 100, 60).unwrap(); + let ctx = cairo::Context::new(&surface).unwrap(); + + assert!( + painted_luminance(&ctx, (10.0, 10.0, 40.0, 20.0)).is_none(), + "live desktop shows through here; the pixels were never ours to read" + ); + } + + #[test] + fn the_probe_reads_what_was_painted_rather_than_the_whole_surface() { + let (_surface, ctx) = filled_target((0.0, 0.0, 0.0, 1.0)); + ctx.set_source_rgba(1.0, 1.0, 1.0, 1.0); + ctx.rectangle(0.0, 0.0, 50.0, 60.0); + let _ = ctx.fill(); + + let over_white = painted_luminance(&ctx, (5.0, 10.0, 40.0, 20.0)).expect("opaque"); + let over_black = painted_luminance(&ctx, (55.0, 10.0, 40.0, 20.0)).expect("opaque"); + + assert!(over_white > 0.9, "got {over_white}"); + assert!(over_black < 0.1, "got {over_black}"); + } + + #[test] + fn the_probe_follows_the_canvas_transform() { + let (_surface, ctx) = filled_target((0.0, 0.0, 0.0, 1.0)); + ctx.set_source_rgba(1.0, 1.0, 1.0, 1.0); + ctx.rectangle(50.0, 0.0, 50.0, 60.0); + let _ = ctx.fill(); + + // Under a 2x zoom, user-space x=25 is device x=50: the white half. + ctx.scale(2.0, 2.0); + let sampled = painted_luminance(&ctx, (26.0, 5.0, 20.0, 10.0)).expect("opaque"); + + assert!( + sampled > 0.9, + "the probe must map user space through the transform, got {sampled}" + ); + } + + #[test] + fn a_degenerate_or_offscreen_rectangle_asks_for_nothing() { + let (_surface, ctx) = filled_target((1.0, 1.0, 1.0, 1.0)); + + assert!(painted_luminance(&ctx, (10.0, 10.0, 0.0, 20.0)).is_none()); + assert!(painted_luminance(&ctx, (10.0, 10.0, f64::NAN, 20.0)).is_none()); + assert!(painted_luminance(&ctx, (500.0, 500.0, 40.0, 20.0)).is_none()); + } +} diff --git a/src/draw/render/mod.rs b/src/draw/render/mod.rs index 337ff3aa..99fd4d22 100644 --- a/src/draw/render/mod.rs +++ b/src/draw/render/mod.rs @@ -1,5 +1,7 @@ //! Cairo-based rendering functions for shapes. +mod backdrop_probe; +pub use backdrop_probe::painted_luminance as painted_background_luminance; mod background; mod blur; mod highlight; diff --git a/src/draw/render/text.rs b/src/draw/render/text.rs index e57b8d9a..3026a131 100644 --- a/src/draw/render/text.rs +++ b/src/draw/render/text.rs @@ -1,3 +1,4 @@ +use super::backdrop_probe; use crate::draw::shape::{ TextMeasurement, measure_text_with_context, sticky_note_layout, sticky_note_layout_text, sticky_note_text_layout, @@ -80,12 +81,23 @@ pub fn render_text( }); let content = measurement.content_extents(wrap_width); - // Calculate brightness to determine background/stroke color - let outline = text_outline_color(color); - // Adjust y position (Pango measures from top-left, we want baseline) let adjusted_y = y as f64 - measurement.baseline; + // Read the background before anything is painted over it. The halo has to + // contrast with what the label sits on, and this is the last moment at + // which the surface still shows only that. + let background_luminance = backdrop_probe::painted_luminance( + ctx, + ( + x as f64 + content.x, + adjusted_y + content.y, + content.width, + content.height, + ), + ); + let outline = text_outline_color(color, background_luminance); + // First pass: draw semi-transparent background rectangle (if enabled) if background_enabled && content.width > 0.0 && content.height > 0.0 { let padding = size * 0.15; @@ -142,8 +154,19 @@ pub fn caret_outline_width(size: f64) -> f64 { } /// Opaque contrasting color used to outline plain text and its separate caret. -pub fn text_outline_color(color: Color) -> Color { - let brightness = color.r * 0.299 + color.g * 0.587 + color.b * 0.114; +/// +/// `background_luminance` is what the label actually sits on, when that is +/// known. It is the right input: a halo exists to separate the glyphs from the +/// background, so the background is what it has to contrast with. +/// +/// `None` falls back to deriving the halo from the text color, which is what +/// this did for every case before. That rule is wrong whenever the two agree — +/// red is dark by the weighted measure, so red text always took a white halo, +/// including on a white page, which is the exact case a halo prevents. It stays +/// as the fallback only because a transparent overlay over a live desktop has no +/// background pixels to read, and a guess from the text color beats no halo. +pub fn text_outline_color(color: Color, background_luminance: Option) -> Color { + let brightness = background_luminance.unwrap_or_else(|| backdrop_probe::color_luminance(color)); if brightness > 0.5 { Color::new(0.0, 0.0, 0.0, 1.0) } else { @@ -314,14 +337,94 @@ mod tests { } #[test] - fn text_outline_contrasts_with_light_and_dark_caret_colors() { - let light_outline = text_outline_color(Color::new(1.0, 1.0, 1.0, 1.0)); + fn a_known_background_decides_the_halo_whatever_colour_the_text_is() { + let red = Color::new(0.96, 0.2, 0.25, 1.0); + + let on_white = text_outline_color(red, Some(1.0)); + assert!(on_white.r < 0.5, "red on a white page needs a dark halo"); + + let on_black = text_outline_color(red, Some(0.0)); + assert!( + on_black.r > 0.5, + "the same red on a dark page needs a light one" + ); + } + + #[test] + fn rendered_red_text_takes_a_dark_halo_on_a_white_board_and_a_light_one_on_a_black_board() { + fn near_black_pixels(board: (f64, f64, f64)) -> usize { + let mut surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 400, 120).unwrap(); + { + let ctx = cairo::Context::new(&surface).unwrap(); + ctx.set_source_rgb(board.0, board.1, board.2); + let _ = ctx.paint(); + render_text( + &ctx, + 20, + 80, + "Read me", + Color::new(0.96, 0.2, 0.25, 1.0), + 36.0, + &FontDescriptor::default(), + false, + None, + ); + } + surface.flush(); + let stride = surface.stride() as usize; + let data = surface.data().unwrap(); + let mut count = 0; + for row in 0..120usize { + for column in 0..400usize { + let offset = row * stride + column * 4; + let (b, g, r) = (data[offset], data[offset + 1], data[offset + 2]); + if r < 40 && g < 40 && b < 40 { + count += 1; + } + } + } + count + } + + let on_white = near_black_pixels((1.0, 1.0, 1.0)); + assert!( + on_white > 200, + "a dark halo must appear around red glyphs on a whiteboard, found {on_white} dark pixels" + ); + + let on_black = near_black_pixels((0.0, 0.0, 0.0)); + assert!( + on_black < 400 * 120, + "a light halo on a blackboard must leave some non-black pixels" + ); + } + + #[test] + fn an_unknown_background_falls_back_to_the_colour_of_the_text() { + let light_outline = text_outline_color(Color::new(1.0, 1.0, 1.0, 1.0), None); assert!(light_outline.r < 0.5); - let dark_outline = text_outline_color(Color::new(0.0, 0.0, 0.0, 1.0)); + let dark_outline = text_outline_color(Color::new(0.0, 0.0, 0.0, 1.0), None); assert!(dark_outline.r > 0.5); } + #[test] + fn red_text_on_a_white_page_no_longer_gets_a_white_halo() { + // The defect this replaced: the rule read the text colour, and red has + // a weighted brightness of 0.30, so red always took a white halo. On a + // whiteboard that is a white halo on a white page. + let red = Color::new(0.96, 0.2, 0.25, 1.0); + + assert!( + text_outline_color(red, None).r > 0.5, + "the old rule is preserved as the fallback" + ); + assert!( + text_outline_color(red, Some(0.95)).r < 0.5, + "but a known white background now wins" + ); + } + #[test] fn foreground_is_dark_on_light_notes_and_light_on_dark_notes() { let light = sticky_note_foreground(Color::new(0.95, 0.9, 0.2, 1.0)); @@ -538,7 +641,7 @@ mod tests { let mut surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 600, 420).unwrap(); { let ctx = cairo::Context::new(&surface).unwrap(); - let outline = text_outline_color(color); + let outline = text_outline_color(color, None); ctx.set_source_rgba(outline.r, outline.g, outline.b, outline.a); ctx.set_line_width(caret_outline_width(size)); ctx.move_to(caret_x, top); From f7bee0ce7c622de4927f2ba1e017a041767b1b9b Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Tue, 25 Aug 2026 23:46:31 +0200 Subject: [PATCH 3/6] feat(text): step the font from the keyboard with Shift+T Changing the text font meant the toolbar or the config file. The toolbar's two buttons cover Sans and Monospace; a third configured family had no control at all and no key reached any of them. Shift+T now steps the font through `[drawing] font_cycle`, a short list that defaults to Sans, Monospace, Serif. `T` enters text mode, so its neighbour is where the font that text will be written in belongs; omasnap #103 picked the same chord for the same job. The list is deliberately short rather than every installed font. A font change mid-demo is a choice between two or three looks, not a font picker, and a key that walks 400 families is a key nobody presses twice. With text or a sticky note selected the step restyles that text and leaves the tool setting alone, which is the idiom the shape and blur tools already use for their variants. A mixed selection takes its step from the first selected text shape, so it converges on one family rather than fanning out. A family that is not in the list steps to the first entry: the list says where the action can go, not where the font has been. Blank and repeated entries are dropped at config load. A repeat would make the key look like it skipped, and a blank name resolves to whatever the font system falls back to. An empty list turns the action off and says so. Wayscriber stays ahead of the PR this comes from: omasnap bundles three fixed faces, while any installed family with any weight and style is already valid here. Only the keyboard route was missing. --- README.md | 1 + config.example.toml | 6 + configurator/src/app/pages/drawing/font.rs | 5 + .../src/models/config/draft/from_config.rs | 1 + configurator/src/models/config/draft/mod.rs | 1 + configurator/src/models/config/setters.rs | 1 + .../src/models/config/to_config/drawing.rs | 7 + configurator/src/models/fields/toggles.rs | 1 + .../models/keybindings/field/config/read.rs | 1 + .../models/keybindings/field/config/write.rs | 1 + .../src/models/keybindings/field/labels.rs | 1 + .../src/models/keybindings/field/list.rs | 1 + .../src/models/keybindings/field/mod.rs | 1 + .../src/models/keybindings/field/tab.rs | 1 + docs/CONFIG.md | 29 ++ .../wayland/backend/state_init/input_state.rs | 1 + src/config/action_meta/entries/tools.rs | 10 + src/config/action_meta/tests.rs | 1 + src/config/keybindings/config/map/edit.rs | 1 + src/config/keybindings/config/map/tools.rs | 1 + .../config/types/bindings/tools.rs | 5 + src/config/keybindings/defaults/tools.rs | 6 + src/config/keybindings/tests.rs | 1 + src/config/types/drawing.rs | 19 ++ src/config/validate/drawing.rs | 20 ++ src/configurator_destination.rs | 1 + src/domain/action.rs | 1 + src/domain/tests.rs | 1 + src/input/state/actions/action_tools.rs | 3 + src/input/state/core/base/state/init.rs | 1 + src/input/state/core/base/state/structs.rs | 2 + src/input/state/core/font_cycle.rs | 265 ++++++++++++++++++ src/input/state/core/mod.rs | 1 + .../properties/apply_selection/helpers.rs | 6 +- src/input/state/interaction/actions.rs | 1 + .../help_overlay/sections/builder/sections.rs | 1 + 36 files changed, 403 insertions(+), 3 deletions(-) create mode 100644 src/input/state/core/font_cycle.rs diff --git a/README.md b/README.md index 59f0cf99..cf950b8e 100644 --- a/README.md +++ b/README.md @@ -126,6 +126,7 @@ The v0.9.23+ prebuilt `wayscriber` packages require glibc 2.39 and GTK 4.12 — - Selection: Alt-drag, V tool, properties panel - Duplicate (Ctrl+D), delete (Delete), undo/redo - Color picker, screen eyedropper with a magnified pixel loupe, palettes, size via hotkeys or scroll +- Text font cycling with Shift+T over a configurable list (`[drawing] font_cycle`); with text selected it restyles that text - Render color profiles for print/projector/light-theme preview - Radial menu at cursor (Middle-click): quick tool/color selection with recent colors, press-flick-release tool commits, plus a draggable outer size ring and scroll size adjust diff --git a/config.example.toml b/config.example.toml index 0bdcf13c..78fcefbe 100644 --- a/config.example.toml +++ b/config.example.toml @@ -120,6 +120,8 @@ select_step_marker_tool = [] select_eraser_tool = ["D"] # Toggle eraser behavior mode toggle_eraser_mode = ["Ctrl+Shift+E"] +# Step the text font through drawing.font_cycle +cycle_font_family = ["Shift+T"] # Step stroke smoothing up and down (unbound by default) increase_pen_smoothing = [] decrease_pen_smoothing = [] @@ -1089,6 +1091,10 @@ default_blur_style = "gaussian" # Default marker opacity multiplier (0.05 - 0.90). Multiplies the current color alpha. marker_opacity = 0.32 +# Font families the "Cycle Font Family" action steps through (Shift+T). +# Any installed family name works. An empty list turns the action off. +font_cycle = ["Sans", "Monospace", "Serif"] + # How much a finished freehand or marker stroke is smoothed (0 - 6). # # 0 keeps the exact path the pointer drew. Higher values clean up the shake of diff --git a/configurator/src/app/pages/drawing/font.rs b/configurator/src/app/pages/drawing/font.rs index 62a9eb2c..9e4845dc 100644 --- a/configurator/src/app/pages/drawing/font.rs +++ b/configurator/src/app/pages/drawing/font.rs @@ -12,6 +12,11 @@ pub(super) fn build(page: &mut PageBuilder) { |app| app.draft.drawing_font_family.clone(), |value| Message::TextChanged(TextField::DrawingFontFamily, value), ) + .entry_row( + "Font cycle list (comma separated)", + |app| app.draft.drawing_font_cycle.clone(), + |value| Message::TextChanged(TextField::DrawingFontCycle, value), + ) .combo_row( "Font weight", "", diff --git a/configurator/src/models/config/draft/from_config.rs b/configurator/src/models/config/draft/from_config.rs index eca0784f..37598c3e 100644 --- a/configurator/src/models/config/draft/from_config.rs +++ b/configurator/src/models/config/draft/from_config.rs @@ -73,6 +73,7 @@ impl ConfigDraft { drawing_polygon_sides: config.drawing.polygon_sides.to_string(), drawing_marker_opacity: format_float(config.drawing.marker_opacity), drawing_pen_smoothing: config.drawing.pen_smoothing.to_string(), + drawing_font_cycle: config.drawing.font_cycle.join(", "), drawing_hit_test_tolerance: format_float(config.drawing.hit_test_tolerance), drawing_hit_test_linear_threshold: config.drawing.hit_test_linear_threshold.to_string(), drawing_undo_stack_limit: config.drawing.undo_stack_limit.to_string(), diff --git a/configurator/src/models/config/draft/mod.rs b/configurator/src/models/config/draft/mod.rs index cb97a4a4..106a1eec 100644 --- a/configurator/src/models/config/draft/mod.rs +++ b/configurator/src/models/config/draft/mod.rs @@ -41,6 +41,7 @@ pub struct ConfigDraft { pub drawing_polygon_sides: String, pub drawing_marker_opacity: String, pub drawing_pen_smoothing: String, + pub drawing_font_cycle: String, pub drawing_hit_test_tolerance: String, pub drawing_hit_test_linear_threshold: String, pub drawing_undo_stack_limit: String, diff --git a/configurator/src/models/config/setters.rs b/configurator/src/models/config/setters.rs index c8925366..5626b677 100644 --- a/configurator/src/models/config/setters.rs +++ b/configurator/src/models/config/setters.rs @@ -328,6 +328,7 @@ impl ConfigDraft { TextField::DrawingPolygonSides => self.drawing_polygon_sides = value, TextField::DrawingMarkerOpacity => self.drawing_marker_opacity = value, TextField::DrawingPenSmoothing => self.drawing_pen_smoothing = value, + TextField::DrawingFontCycle => self.drawing_font_cycle = value, TextField::DrawingFontFamily => self.drawing_font_family = value, TextField::DrawingFontWeight => { self.drawing_font_weight = value; diff --git a/configurator/src/models/config/to_config/drawing.rs b/configurator/src/models/config/to_config/drawing.rs index 92333d3c..9513392e 100644 --- a/configurator/src/models/config/to_config/drawing.rs +++ b/configurator/src/models/config/to_config/drawing.rs @@ -67,6 +67,13 @@ impl ConfigDraft { errors, |value| config.drawing.marker_opacity = value, ); + config.drawing.font_cycle = self + .drawing_font_cycle + .split(',') + .map(str::trim) + .filter(|family| !family.is_empty()) + .map(str::to_string) + .collect(); config.drawing.font_family = self.drawing_font_family.clone(); config.drawing.font_weight = self.drawing_font_weight.clone(); config.drawing.font_style = self.drawing_font_style.clone(); diff --git a/configurator/src/models/fields/toggles.rs b/configurator/src/models/fields/toggles.rs index 268b6024..d4903a82 100644 --- a/configurator/src/models/fields/toggles.rs +++ b/configurator/src/models/fields/toggles.rs @@ -108,6 +108,7 @@ pub enum TextField { DrawingPolygonSides, DrawingMarkerOpacity, DrawingPenSmoothing, + DrawingFontCycle, DrawingFontFamily, DrawingFontWeight, DrawingFontStyle, diff --git a/configurator/src/models/keybindings/field/config/read.rs b/configurator/src/models/keybindings/field/config/read.rs index df3d1f9d..13c23378 100644 --- a/configurator/src/models/keybindings/field/config/read.rs +++ b/configurator/src/models/keybindings/field/config/read.rs @@ -43,6 +43,7 @@ impl KeybindingField { Self::SelectPenTool => &config.tools.select_pen_tool, Self::SelectEraserTool => &config.tools.select_eraser_tool, Self::ToggleEraserMode => &config.tools.toggle_eraser_mode, + Self::CycleFontFamily => &config.tools.cycle_font_family, Self::IncreasePenSmoothing => &config.tools.increase_pen_smoothing, Self::DecreasePenSmoothing => &config.tools.decrease_pen_smoothing, Self::SelectMarkerTool => &config.tools.select_marker_tool, diff --git a/configurator/src/models/keybindings/field/config/write.rs b/configurator/src/models/keybindings/field/config/write.rs index 2b282f1f..6e41bbc0 100644 --- a/configurator/src/models/keybindings/field/config/write.rs +++ b/configurator/src/models/keybindings/field/config/write.rs @@ -44,6 +44,7 @@ impl KeybindingField { Self::SelectPenTool => config.tools.select_pen_tool = value, Self::SelectEraserTool => config.tools.select_eraser_tool = value, Self::ToggleEraserMode => config.tools.toggle_eraser_mode = value, + Self::CycleFontFamily => config.tools.cycle_font_family = value, Self::IncreasePenSmoothing => config.tools.increase_pen_smoothing = value, Self::DecreasePenSmoothing => config.tools.decrease_pen_smoothing = value, Self::SelectMarkerTool => config.tools.select_marker_tool = value, diff --git a/configurator/src/models/keybindings/field/labels.rs b/configurator/src/models/keybindings/field/labels.rs index 7455f519..6ad53b48 100644 --- a/configurator/src/models/keybindings/field/labels.rs +++ b/configurator/src/models/keybindings/field/labels.rs @@ -53,6 +53,7 @@ impl KeybindingField { Self::SelectPenTool => "select_pen_tool", Self::SelectEraserTool => "select_eraser_tool", Self::ToggleEraserMode => "toggle_eraser_mode", + Self::CycleFontFamily => "cycle_font_family", Self::IncreasePenSmoothing => "increase_pen_smoothing", Self::DecreasePenSmoothing => "decrease_pen_smoothing", Self::SelectMarkerTool => "select_marker_tool", diff --git a/configurator/src/models/keybindings/field/list.rs b/configurator/src/models/keybindings/field/list.rs index 7b843f69..ce7663d8 100644 --- a/configurator/src/models/keybindings/field/list.rs +++ b/configurator/src/models/keybindings/field/list.rs @@ -38,6 +38,7 @@ impl KeybindingField { Self::SelectPenTool, Self::SelectEraserTool, Self::ToggleEraserMode, + Self::CycleFontFamily, Self::IncreasePenSmoothing, Self::DecreasePenSmoothing, Self::SelectMarkerTool, diff --git a/configurator/src/models/keybindings/field/mod.rs b/configurator/src/models/keybindings/field/mod.rs index 97b8dffc..14dba01f 100644 --- a/configurator/src/models/keybindings/field/mod.rs +++ b/configurator/src/models/keybindings/field/mod.rs @@ -40,6 +40,7 @@ pub enum KeybindingField { SelectPenTool, SelectEraserTool, ToggleEraserMode, + CycleFontFamily, IncreasePenSmoothing, DecreasePenSmoothing, SelectMarkerTool, diff --git a/configurator/src/models/keybindings/field/tab.rs b/configurator/src/models/keybindings/field/tab.rs index e168f0f4..9d858c0a 100644 --- a/configurator/src/models/keybindings/field/tab.rs +++ b/configurator/src/models/keybindings/field/tab.rs @@ -28,6 +28,7 @@ impl KeybindingField { | Self::SelectPenTool | Self::SelectEraserTool | Self::ToggleEraserMode + | Self::CycleFontFamily | Self::IncreasePenSmoothing | Self::DecreasePenSmoothing | Self::SelectMarkerTool diff --git a/docs/CONFIG.md b/docs/CONFIG.md index 811e181e..549a9565 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -238,6 +238,9 @@ default_blur_style = "gaussian" # Default marker opacity multiplier (0.05 - 0.90). Multiplies the current color alpha. marker_opacity = 0.32 +# Font families that Shift+T steps through +font_cycle = ["Sans", "Monospace", "Serif"] + # Smoothing applied to a finished freehand or marker stroke (0 - 6) pen_smoothing = 3 @@ -374,6 +377,7 @@ drag_tool = "default" - **Arrow style**: Run **Cycle Arrow Style** from the command palette to step through standard → pointy → curved → double (unbound by default; bind `cycle_arrow_style`). With arrows selected it restyles those in one undo step; with nothing selected it sets the style for the next arrow - **Marker opacity**: Use Ctrl+Alt + / - **Pen smoothing**: Run **Increase / Decrease Pen Smoothing** from the command palette, or bind `increase_pen_smoothing` / `decrease_pen_smoothing` (see [Pen smoothing](#pen-smoothing)) +- **Text font**: Shift+T steps through `font_cycle` (see [Font cycle](#font-cycle)) - **Regular polygon sides**: Use the Shapes popover Sides control (range: 3-12) - **Font size**: Use Ctrl+Shift++/Ctrl+Shift+- or Shift + scroll (range: 8-72px) @@ -383,6 +387,7 @@ drag_tool = "default" - Eraser size: 12.0px - Eraser mode: Brush - Marker opacity: 0.32 +- Font cycle: Sans, Monospace, Serif - Pen smoothing: 3 of 6 - Fill enabled: false - Polygon sides: 5 @@ -393,6 +398,29 @@ drag_tool = "default" - Undo stack limit: 100 - Drag mapping: Drag=Pen, Shift+Drag=Line, Ctrl+Drag=Rect, Ctrl+Shift+Drag=Arrow, Tab+Drag=Ellipse +#### Font cycle + +`font_family` sets the font text is written in. `font_cycle` is the short list +that Shift+T steps through, for changing it without leaving the +overlay. + +```toml +[drawing] +font_cycle = ["Sans", "Monospace", "Serif"] +``` + +Any installed family name is valid. Blank and repeated entries are dropped when +the configuration loads, because a repeat makes the key look like it skipped. +An empty list turns the action off. + +With text or a sticky note selected, Shift+T restyles that text and +leaves the tool setting alone. With nothing selected it sets what the next label +will be written in. A family that is not in the list steps to the first entry, +so the key always goes somewhere. + +The toolbar's Sans/Mono buttons are a separate two-way shortcut and are not +affected by this list. + #### Text halo Text is drawn with a contrasting outline so it stays readable over any @@ -1997,6 +2025,7 @@ select_marker_tool = ["H"] select_step_marker_tool = [] select_eraser_tool = ["D"] toggle_eraser_mode = ["Ctrl+Shift+E"] +cycle_font_family = ["Shift+T"] # step the text font through drawing.font_cycle increase_pen_smoothing = [] # clean up finished strokes more decrease_pen_smoothing = [] # keep more of the drawn path cycle_blur_style = [] # blur -> pixelate -> secure -> black out diff --git a/src/backend/wayland/backend/state_init/input_state.rs b/src/backend/wayland/backend/state_init/input_state.rs index 382364d5..9b3588df 100644 --- a/src/backend/wayland/backend/state_init/input_state.rs +++ b/src/backend/wayland/backend/state_init/input_state.rs @@ -57,6 +57,7 @@ pub(super) fn build_input_state(config: &Config) -> InputState { input_state.blur_style = config.drawing.default_blur_style; input_state.arrow_style = config.arrow.style; input_state.set_pen_smoothing(config.drawing.pen_smoothing); + input_state.set_font_cycle(config.drawing.font_cycle.clone()); input_state.spotlight_dim_opacity = config.spotlight.dim_opacity; input_state.spotlight_feather = config.spotlight.feather; input_state.spotlight_magnification = config.spotlight.magnification; diff --git a/src/config/action_meta/entries/tools.rs b/src/config/action_meta/entries/tools.rs index 954b3f5e..9d3c88d5 100644 --- a/src/config/action_meta/entries/tools.rs +++ b/src/config/action_meta/entries/tools.rs @@ -224,6 +224,16 @@ pub const ENTRIES: &[ActionMeta] = &[ true, true ), + meta!( + CycleFontFamily, + "Cycle Font Family", + None, + "Step the text font through the configured list", + Tools, + true, + true, + true + ), meta!( IncreasePenSmoothing, "Increase Pen Smoothing", diff --git a/src/config/action_meta/tests.rs b/src/config/action_meta/tests.rs index 3a756091..6087a521 100644 --- a/src/config/action_meta/tests.rs +++ b/src/config/action_meta/tests.rs @@ -176,6 +176,7 @@ const EXPECTED_COMMAND_PALETTE_ACTIONS: &[Action] = &[ Action::SelectStepMarkerTool, Action::SelectEraserTool, Action::ToggleEraserMode, + Action::CycleFontFamily, Action::IncreasePenSmoothing, Action::DecreasePenSmoothing, Action::SelectSpotlightTool, diff --git a/src/config/keybindings/config/map/edit.rs b/src/config/keybindings/config/map/edit.rs index a784679a..41e5f796 100644 --- a/src/config/keybindings/config/map/edit.rs +++ b/src/config/keybindings/config/map/edit.rs @@ -100,6 +100,7 @@ define_action_binding_accessors! { ToggleEraserMode => tools.toggle_eraser_mode, IncreasePenSmoothing => tools.increase_pen_smoothing, DecreasePenSmoothing => tools.decrease_pen_smoothing, + CycleFontFamily => tools.cycle_font_family, CycleBlurStyle => tools.cycle_blur_style, CycleArrowStyle => tools.cycle_arrow_style, SelectPenTool => tools.select_pen_tool, diff --git a/src/config/keybindings/config/map/tools.rs b/src/config/keybindings/config/map/tools.rs index 94f278c0..a809ef76 100644 --- a/src/config/keybindings/config/map/tools.rs +++ b/src/config/keybindings/config/map/tools.rs @@ -36,6 +36,7 @@ impl KeybindingsConfig { &self.tools.decrease_pen_smoothing, Action::DecreasePenSmoothing, )?; + inserter.insert_all(&self.tools.cycle_font_family, Action::CycleFontFamily)?; inserter.insert_all(&self.tools.cycle_blur_style, Action::CycleBlurStyle)?; inserter.insert_all(&self.tools.cycle_arrow_style, Action::CycleArrowStyle)?; inserter.insert_all(&self.tools.select_pen_tool, Action::SelectPenTool)?; diff --git a/src/config/keybindings/config/types/bindings/tools.rs b/src/config/keybindings/config/types/bindings/tools.rs index 0185574a..1e80e68d 100644 --- a/src/config/keybindings/config/types/bindings/tools.rs +++ b/src/config/keybindings/config/types/bindings/tools.rs @@ -40,6 +40,10 @@ pub struct ToolKeybindingsConfig { #[serde(default = "default_decrease_pen_smoothing")] pub decrease_pen_smoothing: Vec, + /// Step the text font through `drawing.font_cycle`. + #[serde(default = "default_cycle_font_family")] + pub cycle_font_family: Vec, + #[serde(default = "default_cycle_blur_style")] pub cycle_blur_style: Vec, @@ -115,6 +119,7 @@ impl Default for ToolKeybindingsConfig { toggle_eraser_mode: default_toggle_eraser_mode(), increase_pen_smoothing: default_increase_pen_smoothing(), decrease_pen_smoothing: default_decrease_pen_smoothing(), + cycle_font_family: default_cycle_font_family(), cycle_blur_style: default_cycle_blur_style(), cycle_arrow_style: default_cycle_arrow_style(), select_pen_tool: default_select_pen_tool(), diff --git a/src/config/keybindings/defaults/tools.rs b/src/config/keybindings/defaults/tools.rs index ae524038..469db3a0 100644 --- a/src/config/keybindings/defaults/tools.rs +++ b/src/config/keybindings/defaults/tools.rs @@ -46,6 +46,12 @@ pub(crate) fn default_decrease_pen_smoothing() -> Vec { Vec::new() } +/// `T` enters text mode, so `Shift+T` is the natural neighbour for the font +/// that text will be written in. Omasnap uses the same chord for the same job. +pub(crate) fn default_cycle_font_family() -> Vec { + vec!["Shift+T".to_string()] +} + pub(crate) fn default_cycle_blur_style() -> Vec { Vec::new() } diff --git a/src/config/keybindings/tests.rs b/src/config/keybindings/tests.rs index 96b992fa..f4ededb6 100644 --- a/src/config/keybindings/tests.rs +++ b/src/config/keybindings/tests.rs @@ -741,6 +741,7 @@ const DEFAULT_BINDING_SNAPSHOT: &[(&str, &[&str])] = &[ ("select_step_marker_tool", &[]), ("select_eraser_tool", &["D"]), ("toggle_eraser_mode", &["Ctrl+Shift+E"]), + ("cycle_font_family", &["Shift+T"]), ("increase_pen_smoothing", &[]), ("decrease_pen_smoothing", &[]), ("cycle_blur_style", &[]), diff --git a/src/config/types/drawing.rs b/src/config/types/drawing.rs index 1e0abd32..a18f8535 100644 --- a/src/config/types/drawing.rs +++ b/src/config/types/drawing.rs @@ -52,6 +52,14 @@ pub struct DrawingConfig { #[serde(default = "default_marker_opacity")] pub marker_opacity: f64, + /// Font families the **Cycle Font Family** action steps through. + /// + /// Any installed family name is valid. An empty list turns the action off. + /// This does not restrict the font a text shape can carry; it is the short + /// list worth reaching for from the keyboard mid-demo. + #[serde(default = "default_font_cycle")] + pub font_cycle: Vec, + /// Release-time smoothing passes for freehand and marker strokes (0 - 6). /// /// 0 keeps the exact path the pointer drew. Higher values clean up the @@ -141,6 +149,7 @@ impl Default for DrawingConfig { default_eraser_mode: default_eraser_mode(), default_blur_style: default_blur_style(), marker_opacity: default_marker_opacity(), + font_cycle: default_font_cycle(), pen_smoothing: default_pen_smoothing(), default_fill_enabled: default_fill_enabled(), polygon_sides: default_polygon_sides(), @@ -884,6 +893,16 @@ fn default_marker_opacity() -> f64 { 0.32 } +/// The three families every desktop has, in the order most people want them: +/// prose, code, then something with serifs for contrast on a slide. +fn default_font_cycle() -> Vec { + vec![ + "Sans".to_string(), + "Monospace".to_string(), + "Serif".to_string(), + ] +} + /// A middle setting. Enough to take the shake out of a normal hand, little /// enough that a deliberate corner is still a corner. fn default_pen_smoothing() -> u8 { diff --git a/src/config/validate/drawing.rs b/src/config/validate/drawing.rs index b5894ea5..fe599445 100644 --- a/src/config/validate/drawing.rs +++ b/src/config/validate/drawing.rs @@ -37,6 +37,26 @@ impl Config { } // Marker opacity: 0.05 - 0.9 + // Font cycle: drop blank entries and repeats, keeping the given order. + // A repeat would make the action appear to skip, and a blank name would + // resolve to whatever the font system falls back to. + let mut seen = std::collections::BTreeSet::new(); + let before = self.drawing.font_cycle.len(); + self.drawing.font_cycle.retain(|family| { + let trimmed = family.trim(); + !trimmed.is_empty() && seen.insert(trimmed.to_string()) + }); + if self.drawing.font_cycle.len() != before { + log::warn!( + "Dropped {} blank or repeated entries from drawing.font_cycle", + before - self.drawing.font_cycle.len() + ); + } + for family in &mut self.drawing.font_cycle { + let trimmed = family.trim().to_string(); + *family = trimmed; + } + // Pen smoothing: 0 - MAX_PEN_SMOOTHING passes if self.drawing.pen_smoothing > MAX_PEN_SMOOTHING { log::warn!( diff --git a/src/configurator_destination.rs b/src/configurator_destination.rs index 76d32cd2..e3bb3cb5 100644 --- a/src/configurator_destination.rs +++ b/src/configurator_destination.rs @@ -112,6 +112,7 @@ pub fn keybindings_section_for_action(action: Action) -> Option { self.set_marker_opacity(self.marker_opacity - 0.05); } + Action::CycleFontFamily => { + self.cycle_font_family(); + } Action::IncreasePenSmoothing => self.announce_pen_smoothing(1), Action::DecreasePenSmoothing => self.announce_pen_smoothing(-1), Action::ToggleEraserMode => { diff --git a/src/input/state/core/base/state/init.rs b/src/input/state/core/base/state/init.rs index 097d1cf3..f0f0f2a4 100644 --- a/src/input/state/core/base/state/init.rs +++ b/src/input/state/core/base/state/init.rs @@ -104,6 +104,7 @@ impl InputState { spotlight_magnification: crate::draw::DEFAULT_SPOTLIGHT_MAGNIFICATION, current_font_size: font_size, font_descriptor, + font_cycle: Vec::new(), text_background_enabled, text_wrap_width: None, text_input_mode: TextInputMode::Plain, diff --git a/src/input/state/core/base/state/structs.rs b/src/input/state/core/base/state/structs.rs index a5efca9d..8f30f994 100644 --- a/src/input/state/core/base/state/structs.rs +++ b/src/input/state/core/base/state/structs.rs @@ -131,6 +131,8 @@ pub struct InputState { pub current_font_size: f64, /// Font descriptor for text rendering (family, weight, style) pub font_descriptor: FontDescriptor, + /// Families the font-cycle action steps through. Empty turns it off. + pub(crate) font_cycle: Vec, /// Whether to draw background behind text pub text_background_enabled: bool, /// Optional wrap width for text input (None = auto) diff --git a/src/input/state/core/font_cycle.rs b/src/input/state/core/font_cycle.rs new file mode 100644 index 00000000..c8598697 --- /dev/null +++ b/src/input/state/core/font_cycle.rs @@ -0,0 +1,265 @@ +//! Stepping the text font from the keyboard. +//! +//! `[drawing] font_cycle` is a short list of families worth reaching for while +//! presenting: prose, code, and one with serifs by default. The action walks +//! that list rather than every installed font, because a mid-demo font change is +//! a choice between two or three looks, not a font picker. +//! +//! With text selected the step restyles that text. With nothing selected it sets +//! what the next label will be written in. That is the same idiom the shape and +//! blur tools already use for their variants. + +use super::InputState; +use crate::draw::{FontDescriptor, Shape}; + +impl InputState { + /// Install the configured list. Blank and repeated names are the config + /// layer's problem and have already been removed by the time this runs. + pub fn set_font_cycle(&mut self, families: Vec) { + self.font_cycle = families; + } + + /// The family after `current` in the list, or `None` when the list cannot + /// offer a different one. + /// + /// A family that is not in the list steps to the first entry rather than + /// nowhere: the list is where the action can go, not a claim about where the + /// font has been. + pub(crate) fn next_font_family(&self, current: &str) -> Option { + if self.font_cycle.is_empty() { + return None; + } + let next = match self.font_cycle.iter().position(|family| family == current) { + Some(index) => &self.font_cycle[(index + 1) % self.font_cycle.len()], + None => &self.font_cycle[0], + }; + (next != current).then(|| next.clone()) + } + + /// Step the font and say what it landed on. + /// + /// The toast names the family because a font change has no visible effect + /// until something is typed, and because a family name is the only way to + /// tell two similar faces apart at a glance. + pub(crate) fn cycle_font_family(&mut self) -> bool { + if self.font_cycle.is_empty() { + self.push_toast( + super::ToastPriority::Info, + FONT_CYCLE_TOAST_SOURCE, + super::Toast::warning("No fonts configured to cycle through."), + ); + return false; + } + + // A selection takes the step, so the gesture edits what the user is + // looking at rather than a setting they cannot see. + if self.selection_has_text() { + return self.cycle_selected_font_family(); + } + + let Some(next) = self.next_font_family(&self.font_descriptor.family) else { + return false; + }; + let descriptor = FontDescriptor::new( + next.clone(), + self.font_descriptor.weight.clone(), + self.font_descriptor.style.clone(), + ); + if !self.set_font_descriptor(descriptor) { + return false; + } + log::info!("Text font family set to {next}"); + self.push_toast( + super::ToastPriority::Info, + FONT_CYCLE_TOAST_SOURCE, + super::Toast::info(format!("Font: {next}")), + ); + true + } + + /// Whether the selection holds anything a font applies to. + fn selection_has_text(&self) -> bool { + let frame = self.boards.active_frame(); + self.selected_shape_ids().iter().any(|id| { + matches!( + frame.shape(*id).map(|drawn| &drawn.shape), + Some(Shape::Text { .. } | Shape::StickyNote { .. }) + ) + }) + } + + /// Step every selected text shape to the next family in the list. + /// + /// The step is decided once, from the first selected text shape, so a mixed + /// selection converges on one family instead of fanning out further. + fn cycle_selected_font_family(&mut self) -> bool { + let current = { + let frame = self.boards.active_frame(); + self.selected_shape_ids().iter().find_map(|id| { + match frame.shape(*id).map(|drawn| &drawn.shape) { + Some( + Shape::Text { + font_descriptor, .. + } + | Shape::StickyNote { + font_descriptor, .. + }, + ) => Some(font_descriptor.family.clone()), + _ => None, + } + }) + }; + let Some(next) = current.and_then(|family| self.next_font_family(&family)) else { + return false; + }; + + let target = next.clone(); + let result = self.apply_selection_change( + |shape| matches!(shape, Shape::Text { .. } | Shape::StickyNote { .. }), + move |shape| match shape { + Shape::Text { + font_descriptor, .. + } + | Shape::StickyNote { + font_descriptor, .. + } if font_descriptor.family != target => { + font_descriptor.family = target.clone(); + true + } + _ => false, + }, + ); + + let changed = self.report_selection_apply_result(result, "font"); + if changed { + log::info!("Selected text font family set to {next}"); + } + changed + } +} + +const FONT_CYCLE_TOAST_SOURCE: &str = "font-cycle"; + +#[cfg(test)] +mod tests { + use super::*; + use crate::input::state::test_support::make_test_input_state; + + fn state_with_cycle() -> InputState { + let mut state = make_test_input_state(); + state.set_font_cycle(vec![ + "Sans".to_string(), + "Monospace".to_string(), + "Serif".to_string(), + ]); + state + } + + #[test] + fn the_step_walks_the_list_and_wraps_at_the_end() { + let state = state_with_cycle(); + + assert_eq!(state.next_font_family("Sans").as_deref(), Some("Monospace")); + assert_eq!( + state.next_font_family("Monospace").as_deref(), + Some("Serif") + ); + assert_eq!(state.next_font_family("Serif").as_deref(), Some("Sans")); + } + + #[test] + fn a_family_outside_the_list_steps_to_the_first_entry() { + let state = state_with_cycle(); + + assert_eq!( + state.next_font_family("Comic Sans MS").as_deref(), + Some("Sans"), + "the list says where the action can go, not where the font has been" + ); + } + + #[test] + fn a_one_entry_list_has_nowhere_to_step() { + let mut state = make_test_input_state(); + state.set_font_cycle(vec!["Sans".to_string()]); + + assert_eq!(state.next_font_family("Sans"), None); + assert_eq!(state.next_font_family("Serif").as_deref(), Some("Sans")); + } + + #[test] + fn an_empty_list_turns_the_action_off_rather_than_panicking() { + let mut state = make_test_input_state(); + state.set_font_cycle(Vec::new()); + + assert_eq!(state.next_font_family("Sans"), None); + assert!(!state.cycle_font_family()); + } + + #[test] + fn cycling_with_nothing_selected_sets_what_the_next_label_uses() { + let mut state = state_with_cycle(); + let before = state.font_descriptor.family.clone(); + + assert!(state.cycle_font_family()); + + assert_ne!(state.font_descriptor.family, before); + assert!(state.font_cycle.contains(&state.font_descriptor.family)); + } + + #[test] + fn cycling_with_text_selected_restyles_that_text_and_leaves_the_tool_alone() { + let mut state = state_with_cycle(); + let tool_font = state.font_descriptor.family.clone(); + let id = state.boards.active_frame_mut().add_shape(Shape::Text { + x: 10, + y: 10, + text: "hello".to_string(), + color: crate::draw::Color::new(1.0, 1.0, 1.0, 1.0), + size: 24.0, + font_descriptor: FontDescriptor::new( + "Sans".to_string(), + "normal".to_string(), + "normal".to_string(), + ), + background_enabled: false, + wrap_width: None, + }); + state.set_selection(vec![id]); + + assert!(state.cycle_font_family()); + + let frame = state.boards.active_frame(); + let Some(Shape::Text { + font_descriptor, .. + }) = frame.shape(id).map(|drawn| &drawn.shape) + else { + panic!("the text shape is still there"); + }; + assert_eq!(font_descriptor.family, "Monospace"); + assert_eq!( + state.font_descriptor.family, tool_font, + "restyling a selection must not also change what the next label uses" + ); + } + + #[test] + fn a_selection_with_no_text_in_it_falls_through_to_the_tool_font() { + let mut state = state_with_cycle(); + let before = state.font_descriptor.family.clone(); + let id = state.boards.active_frame_mut().add_shape(Shape::Rect { + x: 0, + y: 0, + w: 10, + h: 10, + fill: false, + color: crate::draw::Color::new(1.0, 1.0, 1.0, 1.0), + thick: 2.0, + }); + state.set_selection(vec![id]); + + assert!(state.cycle_font_family()); + + assert_ne!(state.font_descriptor.family, before); + } +} diff --git a/src/input/state/core/mod.rs b/src/input/state/core/mod.rs index 8b439e3d..4834923b 100644 --- a/src/input/state/core/mod.rs +++ b/src/input/state/core/mod.rs @@ -6,6 +6,7 @@ pub(crate) mod color_picker_popup; mod command_palette; mod dirty; mod eyedropper; +mod font_cycle; mod highlight_controls; mod history; mod ime; diff --git a/src/input/state/core/properties/apply_selection/helpers.rs b/src/input/state/core/properties/apply_selection/helpers.rs index a3ddbe7a..78de4e25 100644 --- a/src/input/state/core/properties/apply_selection/helpers.rs +++ b/src/input/state/core/properties/apply_selection/helpers.rs @@ -4,7 +4,7 @@ use crate::draw::{Color, Shape}; use crate::input::state::{Toast, ToastPriority}; #[derive(Default)] -pub(super) struct SelectionApplyResult { +pub(in crate::input::state::core) struct SelectionApplyResult { pub(super) changed: usize, pub(super) locked: usize, pub(super) applicable: usize, @@ -55,7 +55,7 @@ impl InputState { if mixed { Some(true) } else { Some(!first) } } - pub(super) fn apply_selection_change( + pub(in crate::input::state::core) fn apply_selection_change( &mut self, mut applicable: A, mut apply: F, @@ -143,7 +143,7 @@ impl InputState { result } - pub(super) fn report_selection_apply_result( + pub(in crate::input::state::core) fn report_selection_apply_result( &mut self, result: SelectionApplyResult, label: &str, diff --git a/src/input/state/interaction/actions.rs b/src/input/state/interaction/actions.rs index d839d244..ffa0d64f 100644 --- a/src/input/state/interaction/actions.rs +++ b/src/input/state/interaction/actions.rs @@ -38,6 +38,7 @@ pub(crate) fn classify_action(action: Action) -> ActionRoute { | Action::DecreaseMarkerOpacity | Action::IncreasePenSmoothing | Action::DecreasePenSmoothing + | Action::CycleFontFamily | Action::SelectSelectionTool | Action::SelectMarkerTool | Action::SelectStepMarkerTool diff --git a/src/ui/help_overlay/sections/builder/sections.rs b/src/ui/help_overlay/sections/builder/sections.rs index 68b2316d..b6e74371 100644 --- a/src/ui/help_overlay/sections/builder/sections.rs +++ b/src/ui/help_overlay/sections/builder/sections.rs @@ -158,6 +158,7 @@ pub(super) fn build_main_sections( rows: vec![ action_row(bindings, Action::EnterTextMode, NOT_BOUND_LABEL), action_row(bindings, Action::EnterStickyNoteMode, NOT_BOUND_LABEL), + action_row(bindings, Action::CycleFontFamily, NOT_BOUND_LABEL), action_row(bindings, Action::IncreaseFontSize, NOT_BOUND_LABEL), action_row(bindings, Action::DecreaseFontSize, NOT_BOUND_LABEL), action_row(bindings, Action::ToggleFill, NOT_BOUND_LABEL), From 947cebc040054b6860bdcf5fd427bf592a0ba66a Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Wed, 26 Aug 2026 11:50:01 +0200 Subject: [PATCH 4/6] feat(drawing): integrate smoothing and font polish Add the system font picker, toolbar controls, configurator fields, and documentation for the new drawing settings. Preserve smoothing state and preview damage, make text halos backdrop-aware on screen and in export, and harden modal input, focus teardown, and asynchronous font catalog loading. --- README.md | 5 +- config.example.toml | 7 + configurator/src/app/pages/drawing/font.rs | 87 ++- configurator/src/models/config/tests.rs | 79 ++ .../src/models/config/to_config/drawing.rs | 20 +- .../models/keybindings/field/config/read.rs | 1 + .../models/keybindings/field/config/write.rs | 1 + .../src/models/keybindings/field/labels.rs | 1 + .../src/models/keybindings/field/list.rs | 1 + .../src/models/keybindings/field/mod.rs | 1 + .../src/models/keybindings/field/tab.rs | 1 + docs/CONFIG.md | 88 ++- .../wayland/backend/event_loop/dispatch.rs | 1 + src/backend/wayland/backend/event_loop/mod.rs | 6 + .../wayland/backend/event_loop/render.rs | 5 + src/backend/wayland/handlers/keyboard/mod.rs | 16 +- src/backend/wayland/handlers/pointer/axis.rs | 133 +++- src/backend/wayland/session/tests.rs | 1 + src/backend/wayland/state.rs | 5 + src/backend/wayland/state/core/focus.rs | 18 + src/backend/wayland/state/core/init.rs | 4 + src/backend/wayland/state/core/mod.rs | 1 + .../wayland/state/core/output/focus.rs | 3 +- src/backend/wayland/state/font_catalog.rs | 64 ++ src/backend/wayland/state/render/ui.rs | 4 + src/backend/wayland/toolbar/events.rs | 2 + src/backend/wayland/toolbar/hit.rs | 6 + .../wayland/toolbar/layout/spec/top.rs | 4 + src/backend/wayland/toolbar/render/paint.rs | 2 +- src/backend/wayland/toolbar/view/top.rs | 10 + src/backend/wayland/toolbar/view/top/build.rs | 35 +- src/canvas_export/page.rs | 67 +- src/config/action_meta/entries/tools.rs | 10 + src/config/action_meta/tests.rs | 1 + src/config/keybindings/config/map/edit.rs | 1 + src/config/keybindings/config/map/tools.rs | 1 + .../config/types/bindings/tools.rs | 5 + src/config/keybindings/defaults/tools.rs | 6 + src/config/keybindings/tests.rs | 1 + src/config/tests/validate.rs | 29 + src/config/validate/drawing.rs | 6 +- src/configurator_destination.rs | 1 + src/domain/action.rs | 1 + src/domain/tests.rs | 1 + src/draw/font.rs | 243 ++++++ src/draw/mod.rs | 18 +- src/draw/render/backdrop_probe.rs | 32 +- src/draw/render/blur.rs | 2 +- src/draw/render/mod.rs | 8 +- src/draw/render/shapes.rs | 25 +- src/draw/render/text.rs | 39 +- src/draw/shape/smoothing.rs | 9 +- src/input/state/actions/action_tools.rs | 3 + src/input/state/actions/key_release.rs | 1 + src/input/state/core/base/state/init.rs | 14 + src/input/state/core/base/state/modifiers.rs | 54 ++ src/input/state/core/base/state/structs.rs | 30 + src/input/state/core/eyedropper.rs | 21 - src/input/state/core/font_cycle.rs | 76 +- src/input/state/core/font_picker/input.rs | 364 +++++++++ src/input/state/core/font_picker/layout.rs | 344 +++++++++ src/input/state/core/font_picker/mod.rs | 345 +++++++++ src/input/state/core/font_picker/tests.rs | 691 ++++++++++++++++++ src/input/state/core/mod.rs | 8 +- src/input/state/core/modal.rs | 145 +++- src/input/state/core/text_font.rs | 74 ++ src/input/state/core/utility/interaction.rs | 7 + src/input/state/interaction/actions.rs | 1 + .../state/interaction/adapters/keyboard.rs | 11 + src/input/state/interaction/adapters/mod.rs | 19 +- .../state/interaction/adapters/pointer.rs | 33 + src/input/state/interaction/keyboard.rs | 17 + src/input/state/interaction/outcome.rs | 1 + src/input/state/interaction/pointer.rs | 6 + src/input/state/mod.rs | 8 +- src/input/state/mouse/release/drawing.rs | 139 ++++ src/input/tool/catalog.rs | 11 + src/session/snapshot/apply.rs | 3 + src/session/snapshot/tests.rs | 1 + src/session/snapshot/types.rs | 6 + src/session/storage/tests.rs | 1 + src/session/tests/limits.rs | 1 + src/session/tests/snapshot.rs | 45 ++ src/toolbar_gtk/view/top_bar.rs | 3 + src/toolbar_gtk/view/top_bar/controls.rs | 2 +- src/toolbar_gtk/view/top_bar/style_pill.rs | 74 +- src/toolbar_gtk/view/top_bar/tests.rs | 54 +- src/ui.rs | 2 + src/ui/color_picker_popup.rs | 2 +- src/ui/font_picker.rs | 435 +++++++++++ .../help_overlay/sections/builder/sections.rs | 1 + src/ui/theme.rs | 5 + src/ui/toolbar/apply/mod.rs | 2 + src/ui/toolbar/apply/tools.rs | 13 + src/ui/toolbar/events.rs | 5 + src/ui/toolbar/model/activation.rs | 12 + src/ui/toolbar/model/event_policy.rs | 3 + src/ui/toolbar/model/style_pill.rs | 14 +- src/ui/toolbar/model/style_pill/control.rs | 96 ++- .../model/style_pill/tests/tool_states.rs | 117 ++- src/ui/toolbar/model/top_spec/spec.rs | 9 + src/ui/toolbar/snapshot/build.rs | 1 + src/ui/toolbar/snapshot/types.rs | 13 + tests/cli.rs | 1 + 104 files changed, 4283 insertions(+), 179 deletions(-) create mode 100644 src/backend/wayland/state/core/focus.rs create mode 100644 src/backend/wayland/state/font_catalog.rs create mode 100644 src/input/state/core/font_picker/input.rs create mode 100644 src/input/state/core/font_picker/layout.rs create mode 100644 src/input/state/core/font_picker/mod.rs create mode 100644 src/input/state/core/font_picker/tests.rs create mode 100644 src/input/state/core/text_font.rs create mode 100644 src/ui/font_picker.rs diff --git a/README.md b/README.md index cf950b8e..476e59eb 100644 --- a/README.md +++ b/README.md @@ -117,16 +117,17 @@ The v0.9.23+ prebuilt `wayscriber` packages require glibc 2.39 and GTK 4.12 — ### Drawing and editing - Freehand pen, highlighter, eraser (circle/rect) -- Pen smoothing: finished strokes are cleaned up on release, so the live line never lags the cursor (`[drawing] pen_smoothing`, 0-6) +- Pen smoothing: finished pen and marker strokes are cleaned up on release, so the live line never lags the cursor (`[drawing] pen_smoothing`, 0-6, or the toolbar's **Smoothing** slider); tablet pressure values are preserved, and the level is remembered with the session - Shapes: lines, rectangles, ellipses, polygons (with fill toggle) - Arrows in four styles - standard, pointy, curved (drag its handle to route around what is in the way), and double-ended - with optional auto-numbered labels; step markers for walkthroughs - Blur tool with four styles: soften, pixelate, secure (flattens the region to one color), and black out - Spotlight tool: dims everything except the regions you draw, with optional 1×–4× magnification -- Multiline text and sticky notes with smoothing; text halos take their contrast from the background the label sits on, so a label stays readable on any board or frozen screen +- Multiline text and sticky notes with smoothing; text halos take their contrast from the background the label sits on, so a label stays readable over a board, a filled shape, or a frozen screen (a live transparent board has no pixels to sample and falls back to the text color) - Selection: Alt-drag, V tool, properties panel - Duplicate (Ctrl+D), delete (Delete), undo/redo - Color picker, screen eyedropper with a magnified pixel loupe, palettes, size via hotkeys or scroll - Text font cycling with Shift+T over a configurable list (`[drawing] font_cycle`); with text selected it restyles that text +- Font picker over every installed family (**Font Picker** in the command palette, or the toolbar's font button beside Sans/Mono), with search, a monospace filter, wheel scrolling, accelerating arrow-key repeat, and each row drawn in its own font - Render color profiles for print/projector/light-theme preview - Radial menu at cursor (Middle-click): quick tool/color selection with recent colors, press-flick-release tool commits, plus a draggable outer size ring and scroll size adjust diff --git a/config.example.toml b/config.example.toml index 78fcefbe..1d37f11a 100644 --- a/config.example.toml +++ b/config.example.toml @@ -122,6 +122,9 @@ select_eraser_tool = ["D"] toggle_eraser_mode = ["Ctrl+Shift+E"] # Step the text font through drawing.font_cycle cycle_font_family = ["Shift+T"] +# Open the font picker over every installed family (unbound by default; +# also on the command palette as "Font Picker") +open_font_picker = [] # Step stroke smoothing up and down (unbound by default) increase_pen_smoothing = [] decrease_pen_smoothing = [] @@ -1093,6 +1096,7 @@ marker_opacity = 0.32 # Font families the "Cycle Font Family" action steps through (Shift+T). # Any installed family name works. An empty list turns the action off. +# Names are matched without case, so ["Sans", "sans"] loads as one entry. font_cycle = ["Sans", "Monospace", "Serif"] # How much a finished freehand or marker stroke is smoothed (0 - 6). @@ -1100,6 +1104,9 @@ font_cycle = ["Sans", "Monospace", "Serif"] # 0 keeps the exact path the pointer drew. Higher values clean up the shake of # the hand. Smoothing runs when you lift the pen, never while you draw, so the # live stroke always sits exactly on the pointer. Neither endpoint ever moves. +# +# Also on the toolbar as the "Smoothing" slider while the Pen or Marker is up. +# A session remembers the level it was saved at; this is the starting value. pen_smoothing = 3 # Default fill state for fill-capable shapes diff --git a/configurator/src/app/pages/drawing/font.rs b/configurator/src/app/pages/drawing/font.rs index 9e4845dc..603e95e5 100644 --- a/configurator/src/app/pages/drawing/font.rs +++ b/configurator/src/app/pages/drawing/font.rs @@ -1,5 +1,6 @@ use crate::messages::Message; use crate::models::{FontStyleOption, FontWeightOption, TextField}; +use wayscriber::draw::family_is_installed; use super::super::super::search::SearchArea; use super::super::PageBuilder; @@ -7,15 +8,17 @@ use super::{conditional_section, section_entry_row}; pub(super) fn build(page: &mut PageBuilder) { page.group_in_area("Font", SearchArea::DrawingFont) - .entry_row( + .entry_row_validated( "Font family", |app| app.draft.drawing_font_family.clone(), |value| Message::TextChanged(TextField::DrawingFontFamily, value), + |app| validate_installed_family(&app.draft.drawing_font_family), ) - .entry_row( + .entry_row_validated( "Font cycle list (comma separated)", |app| app.draft.drawing_font_cycle.clone(), |value| Message::TextChanged(TextField::DrawingFontCycle, value), + |app| validate_installed_family_list(&app.draft.drawing_font_cycle), ) .combo_row( "Font weight", @@ -57,3 +60,83 @@ pub(super) fn build(page: &mut PageBuilder) { |_app| None, ); } + +/// Warn about a family the font system cannot find. +/// +/// Pango resolves an unknown family to whatever fontconfig substitutes, with no +/// error anywhere, so a typo renders in a different face and looks like the +/// setting was ignored. Naming it here is the only place the user finds out. +/// +/// A blank field is not an error: it means "leave the built-in default". +fn validate_installed_family(value: &str) -> Option { + let trimmed = value.trim(); + if trimmed.is_empty() || family_is_installed(trimmed) { + return None; + } + Some(format!( + "\"{trimmed}\" is not installed; text falls back to another font" + )) +} + +/// The same check across a comma-separated list, naming every missing family. +fn validate_installed_family_list(value: &str) -> Option { + let missing: Vec<&str> = value + .split(',') + .map(str::trim) + .filter(|family| !family.is_empty() && !family_is_installed(family)) + .collect(); + match missing.len() { + 0 => None, + 1 => Some(format!("\"{}\" is not installed", missing[0])), + _ => Some(format!("Not installed: {}", missing.join(", "))), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn installed() -> String { + wayscriber::draw::system_font_families() + .first() + .expect("at least one family") + .clone() + } + + #[test] + fn an_installed_family_raises_no_warning_and_a_blank_field_is_allowed() { + assert_eq!(validate_installed_family(&installed()), None); + assert_eq!(validate_installed_family(""), None); + assert_eq!(validate_installed_family(" "), None); + } + + #[test] + fn a_missing_family_is_named_so_a_typo_is_findable() { + let message = validate_installed_family("Wayscriber No Such Font 9000") + .expect("a missing family warns"); + + assert!(message.contains("Wayscriber No Such Font 9000")); + } + + #[test] + fn the_list_check_names_every_missing_family_and_ignores_the_present_ones() { + let present = installed(); + + assert_eq!( + validate_installed_family_list(&format!("{present}, {present}")), + None + ); + + let message = validate_installed_family_list(&format!("{present}, Nope One, Nope Two")) + .expect("missing families warn"); + assert!(message.contains("Nope One")); + assert!(message.contains("Nope Two")); + assert!(!message.contains(&present)); + } + + #[test] + fn blank_and_trailing_separators_in_the_list_are_not_treated_as_missing_fonts() { + assert_eq!(validate_installed_family_list(""), None); + assert_eq!(validate_installed_family_list(" , , "), None); + } +} diff --git a/configurator/src/models/config/tests.rs b/configurator/src/models/config/tests.rs index 672b8311..06a72463 100644 --- a/configurator/src/models/config/tests.rs +++ b/configurator/src/models/config/tests.rs @@ -131,6 +131,85 @@ fn config_draft_round_trips_region_capture_settings() { assert!(!round_trip.capture.region.show_legend); } +#[test] +fn config_draft_round_trips_the_font_cycle_list() { + let mut config = Config::default(); + config.drawing.font_cycle = vec![ + "Sans".to_string(), + "JetBrains Mono".to_string(), + "Noto Serif".to_string(), + ]; + + let draft = ConfigDraft::from_config(&config); + assert_eq!(draft.drawing_font_cycle, "Sans, JetBrains Mono, Noto Serif"); + + let round_trip = draft + .to_config(&config) + .expect("font cycle should round trip"); + assert_eq!(round_trip.drawing.font_cycle, config.drawing.font_cycle); +} + +#[test] +fn font_cycle_editing_tolerates_spacing_and_trailing_separators() { + let config = Config::default(); + let mut draft = ConfigDraft::from_config(&config); + + draft.set_text( + TextField::DrawingFontCycle, + " Sans ,, Serif , ".to_string(), + ); + + let round_trip = draft.to_config(&config).expect("config"); + assert_eq!(round_trip.drawing.font_cycle, vec!["Sans", "Serif"]); +} + +#[test] +fn an_emptied_font_cycle_field_turns_the_action_off_rather_than_restoring_defaults() { + let config = Config::default(); + let mut draft = ConfigDraft::from_config(&config); + + draft.set_text(TextField::DrawingFontCycle, String::new()); + + let round_trip = draft.to_config(&config).expect("config"); + assert!(round_trip.drawing.font_cycle.is_empty()); +} + +#[test] +fn a_save_that_never_touched_the_font_cycle_field_keeps_a_family_with_a_comma_in_it() { + // The field is one comma-separated line, so a family whose own name has a + // comma cannot be recovered from the text. That is a limit on editing it, + // not a licence to rewrite it: saving an unrelated page must not corrupt a + // list the user never opened. + let mut config = Config::default(); + config.drawing.font_cycle = vec!["Weird, Font".to_string(), "Sans".to_string()]; + + let mut draft = ConfigDraft::from_config(&config); + draft.set_text(TextField::DrawingThickness, "5".to_string()); + let round_trip = draft.to_config(&config).expect("config"); + + assert_eq!(round_trip.drawing.font_cycle, config.drawing.font_cycle); +} + +#[test] +fn editing_the_font_cycle_field_re_reads_it_and_cannot_express_a_comma() { + // Documented limitation, pinned so the day it matters this test says so. + // The config file itself is a TOML array and can still express one. + let mut config = Config::default(); + config.drawing.font_cycle = vec!["Weird, Font".to_string()]; + + let mut draft = ConfigDraft::from_config(&config); + draft.set_text( + TextField::DrawingFontCycle, + "Weird, Font, Serif".to_string(), + ); + let round_trip = draft.to_config(&config).expect("config"); + + assert_eq!( + round_trip.drawing.font_cycle, + vec!["Weird", "Font", "Serif"] + ); +} + #[test] fn config_draft_round_trips_capture_drawing_preference() { let config = Config::default(); diff --git a/configurator/src/models/config/to_config/drawing.rs b/configurator/src/models/config/to_config/drawing.rs index 9513392e..e3b69d7b 100644 --- a/configurator/src/models/config/to_config/drawing.rs +++ b/configurator/src/models/config/to_config/drawing.rs @@ -67,13 +67,19 @@ impl ConfigDraft { errors, |value| config.drawing.marker_opacity = value, ); - config.drawing.font_cycle = self - .drawing_font_cycle - .split(',') - .map(str::trim) - .filter(|family| !family.is_empty()) - .map(str::to_string) - .collect(); + // The field is one comma-separated line, so a family whose own name + // contains a comma cannot be recovered from it. An untouched field must + // still save the list it was shown: only re-parse once the text stops + // matching what the document rendered into it. + if self.drawing_font_cycle != config.drawing.font_cycle.join(", ") { + config.drawing.font_cycle = self + .drawing_font_cycle + .split(',') + .map(str::trim) + .filter(|family| !family.is_empty()) + .map(str::to_string) + .collect(); + } config.drawing.font_family = self.drawing_font_family.clone(); config.drawing.font_weight = self.drawing_font_weight.clone(); config.drawing.font_style = self.drawing_font_style.clone(); diff --git a/configurator/src/models/keybindings/field/config/read.rs b/configurator/src/models/keybindings/field/config/read.rs index 13c23378..44a88803 100644 --- a/configurator/src/models/keybindings/field/config/read.rs +++ b/configurator/src/models/keybindings/field/config/read.rs @@ -44,6 +44,7 @@ impl KeybindingField { Self::SelectEraserTool => &config.tools.select_eraser_tool, Self::ToggleEraserMode => &config.tools.toggle_eraser_mode, Self::CycleFontFamily => &config.tools.cycle_font_family, + Self::OpenFontPicker => &config.tools.open_font_picker, Self::IncreasePenSmoothing => &config.tools.increase_pen_smoothing, Self::DecreasePenSmoothing => &config.tools.decrease_pen_smoothing, Self::SelectMarkerTool => &config.tools.select_marker_tool, diff --git a/configurator/src/models/keybindings/field/config/write.rs b/configurator/src/models/keybindings/field/config/write.rs index 6e41bbc0..b6d5229d 100644 --- a/configurator/src/models/keybindings/field/config/write.rs +++ b/configurator/src/models/keybindings/field/config/write.rs @@ -45,6 +45,7 @@ impl KeybindingField { Self::SelectEraserTool => config.tools.select_eraser_tool = value, Self::ToggleEraserMode => config.tools.toggle_eraser_mode = value, Self::CycleFontFamily => config.tools.cycle_font_family = value, + Self::OpenFontPicker => config.tools.open_font_picker = value, Self::IncreasePenSmoothing => config.tools.increase_pen_smoothing = value, Self::DecreasePenSmoothing => config.tools.decrease_pen_smoothing = value, Self::SelectMarkerTool => config.tools.select_marker_tool = value, diff --git a/configurator/src/models/keybindings/field/labels.rs b/configurator/src/models/keybindings/field/labels.rs index 6ad53b48..99baeebf 100644 --- a/configurator/src/models/keybindings/field/labels.rs +++ b/configurator/src/models/keybindings/field/labels.rs @@ -54,6 +54,7 @@ impl KeybindingField { Self::SelectEraserTool => "select_eraser_tool", Self::ToggleEraserMode => "toggle_eraser_mode", Self::CycleFontFamily => "cycle_font_family", + Self::OpenFontPicker => "open_font_picker", Self::IncreasePenSmoothing => "increase_pen_smoothing", Self::DecreasePenSmoothing => "decrease_pen_smoothing", Self::SelectMarkerTool => "select_marker_tool", diff --git a/configurator/src/models/keybindings/field/list.rs b/configurator/src/models/keybindings/field/list.rs index ce7663d8..e3d1d9ec 100644 --- a/configurator/src/models/keybindings/field/list.rs +++ b/configurator/src/models/keybindings/field/list.rs @@ -39,6 +39,7 @@ impl KeybindingField { Self::SelectEraserTool, Self::ToggleEraserMode, Self::CycleFontFamily, + Self::OpenFontPicker, Self::IncreasePenSmoothing, Self::DecreasePenSmoothing, Self::SelectMarkerTool, diff --git a/configurator/src/models/keybindings/field/mod.rs b/configurator/src/models/keybindings/field/mod.rs index 14dba01f..661ad160 100644 --- a/configurator/src/models/keybindings/field/mod.rs +++ b/configurator/src/models/keybindings/field/mod.rs @@ -41,6 +41,7 @@ pub enum KeybindingField { SelectEraserTool, ToggleEraserMode, CycleFontFamily, + OpenFontPicker, IncreasePenSmoothing, DecreasePenSmoothing, SelectMarkerTool, diff --git a/configurator/src/models/keybindings/field/tab.rs b/configurator/src/models/keybindings/field/tab.rs index 9d858c0a..8fd65f62 100644 --- a/configurator/src/models/keybindings/field/tab.rs +++ b/configurator/src/models/keybindings/field/tab.rs @@ -29,6 +29,7 @@ impl KeybindingField { | Self::SelectEraserTool | Self::ToggleEraserMode | Self::CycleFontFamily + | Self::OpenFontPicker | Self::IncreasePenSmoothing | Self::DecreasePenSmoothing | Self::SelectMarkerTool diff --git a/docs/CONFIG.md b/docs/CONFIG.md index 549a9565..870cfa9d 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -377,7 +377,7 @@ drag_tool = "default" - **Arrow style**: Run **Cycle Arrow Style** from the command palette to step through standard → pointy → curved → double (unbound by default; bind `cycle_arrow_style`). With arrows selected it restyles those in one undo step; with nothing selected it sets the style for the next arrow - **Marker opacity**: Use Ctrl+Alt + / - **Pen smoothing**: Run **Increase / Decrease Pen Smoothing** from the command palette, or bind `increase_pen_smoothing` / `decrease_pen_smoothing` (see [Pen smoothing](#pen-smoothing)) -- **Text font**: Shift+T steps through `font_cycle` (see [Font cycle](#font-cycle)) +- **Text font**: Shift+T steps through `font_cycle`; **Font Picker** in the command palette opens the full list (see [Font cycle](#font-cycle) and [Font picker](#font-picker)) - **Regular polygon sides**: Use the Shapes popover Sides control (range: 3-12) - **Font size**: Use Ctrl+Shift++/Ctrl+Shift+- or Shift + scroll (range: 8-72px) @@ -419,7 +419,57 @@ will be written in. A family that is not in the list steps to the first entry, so the key always goes somewhere. The toolbar's Sans/Mono buttons are a separate two-way shortcut and are not -affected by this list. +affected by this list. Beside them is a button showing the family in use, which +opens the font picker below. + +Family names are matched without regard to case, the way fontconfig resolves +them: `sans` and `Sans` are one font, so `["Sans", "sans"]` loads as one entry. + +#### Font picker + +`font_cycle` is the short list you reach for mid-demo. The **Font Picker** is +the long way round: a modal over every font installed on the system, for the +times the list does not have what you want. + +It applies a font the same way Shift+T does — to selected text, or to +the tool. It does not edit `font_cycle`; that list is set in the config file or +the configurator. Use the picker to find out what a family looks like, then put +its name in the list if you want it a keystroke away. + +Run **Font Picker** from the command palette, bind `open_font_picker`, or click +the font button in the toolbar's style pill — the one showing the family in use, +next to Sans/Mono. + +| Key | Does | +|-----|------| +| Type | Filter by name | +| PgUp PgDn Home End | Move the highlight | +| Wheel | Scroll three rows a tick | +| Tab | Switch between all fonts and monospace only | +| Enter | Apply | +| Esc | Cancel | + +Holding an arrow or a page key keeps moving, and speeds up the longer you hold +it — a list of every installed font is too long to cross at one flat rate. Let +go and it starts over at the slow rate, so a short press is still one row. + +The wheel belongs to the picker while it is open. It does not reach the pen +behind the panel — which is also true of the colour picker, the precise-entry +popup, the board picker, a context menu, and the eyedropper and region +selectors. + +Every row is drawn in the font it names, because nobody picks a typeface by +reading its name. The picker opens on the font already in use, and fonts chosen +here come back to the top of the list next time. + +The panel sizes itself to the output it comes up on: a short screen shows fewer +rows rather than a panel running off the bottom edge. Long family names are +shortened with an ellipsis rather than written over what is next to them. + +The font list is read once, the first time the picker opens — one enumeration +covering both the full list and the monospace filter, so Tab costs +nothing. It is deliberately not read at startup: the overlay is spawned per +keybind toggle, so anything on that path is paid every time you reach for it. #### Text halo @@ -429,9 +479,19 @@ sampled from the canvas just before the glyphs are painted. That means a whiteboard, a blackboard, a frozen screen, a zoomed screen, and a region already covered by a blur or a filled shape all give the right answer. +PNG export samples the same way, so an exported image matches the screen. -On a transparent board with no frozen or zoomed capture there is nothing to -sample — the desktop shows through the compositor and those pixels were never +PDF export cannot be sampled — a PDF page is vector, with no pixels to read +back. There the page's own background color is used instead. A board on a plain +background therefore picks the same halo in PDF as it does on screen; a label +sitting on top of a filled shape or a blur does not, because in PDF nothing can +see what was painted underneath it. A page whose backdrop is an image falls back +further still, because one brightness for a whole photograph would be a guess. + +Export to PNG when a label sits over other drawing and the halo has to match. + +On a transparent board with no frozen or zoomed capture there is also nothing to +sample: the desktop shows through the compositor and those pixels were never Wayscriber's to read. The halo then falls back to a rule based on the text color itself, which is what every case used before. @@ -462,11 +522,22 @@ stopped it, at every level. The level applies to the Pen and the Marker. The Eraser is not smoothed: its path decides what gets erased, so moving it would change the result rather than the -look. Tablet pressure is left alone — it is real detail, not shake. +look. + +A tablet stroke is smoothed too, because its path shakes like any other. Its +**pressure values** are not touched — each smoothed point keeps the thickness +that was sampled with it, so pen dynamics survive. + +A stroke is stored as the points it ended up with, so nothing about smoothing +changes how a shape is written. The level itself is remembered with the rest of +the tool settings, so a session restores at the level it was saved at. A session +written before this existed has no level recorded and restores at whatever +`pen_smoothing` your config says. -Nothing new is written to a session file. The level is a tool setting, so a -stroke is stored as the points it ended up with, and existing sessions are -unaffected. +The level is also on the toolbar, as a **Smoothing** slider in the style pill +whenever the Pen or Marker is up. It reads `Off` at zero. The slider is one of +the first things the pill drops on a narrow output; the actions below still +reach it there. ### `[arrow]` - Arrow Geometry @@ -2026,6 +2097,7 @@ select_step_marker_tool = [] select_eraser_tool = ["D"] toggle_eraser_mode = ["Ctrl+Shift+E"] cycle_font_family = ["Shift+T"] # step the text font through drawing.font_cycle +open_font_picker = [] # pick from every installed family increase_pen_smoothing = [] # clean up finished strokes more decrease_pen_smoothing = [] # keep more of the drawn path cycle_blur_style = [] # blur -> pixelate -> secure -> black out diff --git a/src/backend/wayland/backend/event_loop/dispatch.rs b/src/backend/wayland/backend/event_loop/dispatch.rs index fceeba28..315aaa51 100644 --- a/src/backend/wayland/backend/event_loop/dispatch.rs +++ b/src/backend/wayland/backend/event_loop/dispatch.rs @@ -29,6 +29,7 @@ fn route_woken_sources( signals: &mut OverlaySignalState, ) -> Result<(), anyhow::Error> { route_woken_persistence(state); + state.drain_font_catalog_prewarm(); state.drain_runtime_ui_completions(); state.drain_system_input_events(); diff --git a/src/backend/wayland/backend/event_loop/mod.rs b/src/backend/wayland/backend/event_loop/mod.rs index b14a5563..4c7652e6 100644 --- a/src/backend/wayland/backend/event_loop/mod.rs +++ b/src/backend/wayland/backend/event_loop/mod.rs @@ -134,6 +134,7 @@ pub(super) fn run_event_loop( let autosave_timeout = session_save::autosave_timeout(state, now); let focus_exit_timeout = state.focus_exit_timeout(now); let command_palette_repeat_timeout = state.input_state.command_palette_repeat_timeout(now); + let font_picker_repeat_timeout = state.input_state.font_picker_repeat_timeout(now); let capture_timeout = capture::capture_timeout(state, now); let interaction_timeout = interaction::interaction_timeout(state.spotlight_wheel_idle_deadline, now); @@ -171,6 +172,7 @@ pub(super) fn run_event_loop( }; let timeout = min_timeout(timeout, toolbar_handoff_timeout); let timeout = min_timeout(timeout, command_palette_repeat_timeout); + let timeout = min_timeout(timeout, font_picker_repeat_timeout); let timeout = min_timeout(timeout, capture_timeout); let timeout = min_timeout(timeout, interaction_timeout); let timeout = min_timeout(timeout, durable_action_timeout); @@ -263,6 +265,10 @@ pub(super) fn run_event_loop( state.input_state.needs_redraw = true; } + if state.input_state.tick_font_picker_repeat(Instant::now()) { + state.input_state.needs_redraw = true; + } + // Synthesize auto-repeat for a held key (sctk's calloop repeat is not // wired to this manual loop). `dispatch_key_repeat` sets needs_redraw // as its routed action requires. diff --git a/src/backend/wayland/backend/event_loop/render.rs b/src/backend/wayland/backend/event_loop/render.rs index 324c6bbb..22747156 100644 --- a/src/backend/wayland/backend/event_loop/render.rs +++ b/src/backend/wayland/backend/event_loop/render.rs @@ -114,6 +114,11 @@ pub(super) fn maybe_render( *last_render_time = Some(render_end); state.input_state.needs_redraw = keep_rendering || state.input_state.has_pending_history(); + // Font enumeration is slow enough to miss a frame budget, but + // starting it before this point would move that cost onto + // startup. The worker wakes the event loop when the cache is + // ready so an already-open picker can replace its loading row. + state.start_font_catalog_prewarm(); let chrome_hover_after = ( state.input_state.status_hud_hover, state.input_state.zoom_chip_hover, diff --git a/src/backend/wayland/handlers/keyboard/mod.rs b/src/backend/wayland/handlers/keyboard/mod.rs index 4fdcfffb..64fafcf6 100644 --- a/src/backend/wayland/handlers/keyboard/mod.rs +++ b/src/backend/wayland/handlers/keyboard/mod.rs @@ -80,21 +80,7 @@ impl KeyboardHandler for WaylandState { _serial: u32, ) { debug!("Keyboard focus left"); - self.set_keyboard_focus(false); - self.set_overlay_ready(false); - self.clear_toolbar_focus(); - - // When the compositor moves focus away from our surface (e.g. to a portal - // dialog, another layer surface, or a different window), it's possible for - // us to miss some key release events. To avoid leaving modifiers "stuck" - // and breaking shortcuts/tools, aggressively reset our modifier state on - // focus loss. - self.input_state.reset_modifiers(); - self.sync_region_square_modifier(false); - self.input_state.clear_command_palette_repeat(); - self.clear_key_repeat(); - self.set_board_pan_key_held(false); - self.stop_board_pan(); + self.teardown_keyboard_focus(); match xdg_focus_leave_action( self.surface.is_xdg_window(), diff --git a/src/backend/wayland/handlers/pointer/axis.rs b/src/backend/wayland/handlers/pointer/axis.rs index 8ed4b3cd..c831df20 100644 --- a/src/backend/wayland/handlers/pointer/axis.rs +++ b/src/backend/wayland/handlers/pointer/axis.rs @@ -25,6 +25,35 @@ fn scroll_direction(vertical: AxisScroll) -> i32 { } } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum AxisSurfaceRoute { + Consumed, + ScrollTopPopover, + Canvas, +} + +/// Resolves screen-modal and toolbar ownership together, so toolbar's early +/// return cannot be moved ahead of selector ownership without changing this +/// tested decision. +fn axis_surface_route( + input_state: &InputState, + over_toolbar: bool, + over_top_toolbar: bool, + scroll_direction: i32, +) -> AxisSurfaceRoute { + if input_state.screen_modal_is_active() { + AxisSurfaceRoute::Consumed + } else if over_toolbar { + if scroll_direction != 0 && over_top_toolbar { + AxisSurfaceRoute::ScrollTopPopover + } else { + AxisSurfaceRoute::Consumed + } + } else { + AxisSurfaceRoute::Canvas + } +} + fn finalize_spotlight_wheel_if_axis_stopped( input_state: &mut InputState, spotlight_wheel_idle_deadline: &mut Option, @@ -135,6 +164,15 @@ impl WaylandState { return; } + // The font picker's own list. Three rows per tick, and the selection + // rides along so Enter still applies the highlighted row. + if self.input_state.is_font_picker_open() { + if scroll_direction != 0 { + self.input_state.font_picker_wheel_scroll(scroll_direction); + } + return; + } + if self.input_state.show_help { if scroll_direction != 0 { let delta = if scroll_direction > 0 { 1.0 } else { -1.0 }; @@ -161,16 +199,36 @@ impl WaylandState { ) { return; } - if on_toolbar || self.pointer_over_toolbar() { - if scroll_direction != 0 && self.wheel_over_top_toolbar(&event.surface, event.position) - { - // With a Canvas/Session/Settings popover open, the wheel scrolls - // its capped viewport; otherwise a top-strip wheel stays a - // no-op (it never falls through to thickness/zoom). + let over_toolbar = on_toolbar || self.pointer_over_toolbar(); + let over_top_toolbar = + over_toolbar && self.wheel_over_top_toolbar(&event.surface, event.position); + match axis_surface_route( + &self.input_state, + over_toolbar, + over_top_toolbar, + scroll_direction, + ) { + // Screen selectors own pointer input across every Wayscriber + // surface, including toolbar popovers left open beneath them. A + // top-strip wheel without a scrollable popover is also consumed. + AxisSurfaceRoute::Consumed => return, + // Canvas/Session/Settings popovers scroll their capped viewport. + AxisSurfaceRoute::ScrollTopPopover => { self.scroll_top_popover_by_wheel(scroll_direction); + return; } + AxisSurfaceRoute::Canvas => {} + } + // Everything below this line acts on the canvas or the active tool. + // A surface covering the canvas has to stop here even when it has + // nothing to scroll, or a wheel tick over it edits the tool behind it — + // which is what a colour picker, a precise-entry popup, and the font + // picker all used to do. The registry says which surfaces those are, so + // the next one added is covered without touching this file. + if self.input_state.modal_owns_wheel() { return; } + if self.input_state.modifiers.ctrl && self.input_state.modifiers.alt { if scroll_direction != 0 { let zoom_in = scroll_direction < 0; @@ -270,6 +328,11 @@ fn try_handle_board_picker_page_panel_axis( if !input_state.is_board_picker_open() || scroll_direction == 0 { return false; } + // A page context menu is the one surface that deliberately stays open over + // the picker, so it is also the one that can be scrolled out from under. + if input_state.is_context_menu_open() { + return false; + } let x = position.0.round() as i32; let y = position.1.round() as i32; if !input_state.board_picker_page_panel_content_at(x, y) { @@ -330,6 +393,64 @@ mod tests { assert_eq!(layout.page_scroll_row, 1); } + #[test] + fn an_active_screen_modal_prevents_the_toolbar_scroll_route() { + let mut input_state = make_test_input_state(); + input_state.activate_eyedropper(None); + + assert_eq!( + axis_surface_route(&input_state, true, true, 1), + AxisSurfaceRoute::Consumed + ); + + input_state.cancel_eyedropper(); + assert_eq!( + axis_surface_route(&input_state, true, true, 1), + AxisSurfaceRoute::ScrollTopPopover, + "without the selector the same wheel reaches the toolbar popover" + ); + } + + #[test] + fn a_page_context_menu_takes_the_wheel_from_the_picker_under_it() { + // The page context menu is the one surface that deliberately stays open + // over the board picker, which makes it the one that can have the list + // scrolled out from under it. + let mut input_state = make_test_input_state(); + input_state.open_board_picker(); + let board_index = input_state + .board_picker_page_panel_board_index() + .expect("page panel board index"); + set_board_page_count(&mut input_state, board_index, 80); + update_picker_layout(&mut input_state); + let layout = *input_state.board_picker_layout().expect("layout"); + let position = (layout.page_viewport_x + 1.0, layout.page_viewport_y + 1.0); + input_state.board_picker_set_focus(BoardPickerFocus::PagePanel); + + input_state.open_page_context_menu((10, 10), board_index, 0); + assert!(input_state.is_context_menu_open()); + assert!( + input_state.is_board_picker_open(), + "this pair deliberately coexists; without that there is no defect" + ); + + assert!( + !try_handle_board_picker_page_panel_axis(&mut input_state, position, 1), + "the menu on top owns the wheel" + ); + update_picker_layout(&mut input_state); + let layout = *input_state.board_picker_layout().expect("layout"); + assert_eq!(layout.page_scroll_row, 0, "the list behind must not move"); + + // And with the menu dismissed the picker takes it back. + input_state.close_context_menu(); + assert!(try_handle_board_picker_page_panel_axis( + &mut input_state, + position, + 1 + )); + } + #[test] fn value120_keeps_shared_axis_routing_in_direction_space() { assert_eq!( diff --git a/src/backend/wayland/session/tests.rs b/src/backend/wayland/session/tests.rs index dd601d7b..1356faf4 100644 --- a/src/backend/wayland/session/tests.rs +++ b/src/backend/wayland/session/tests.rs @@ -219,6 +219,7 @@ fn sample_tool_state() -> stored_session::ToolStateSnapshot { eraser_mode: EraserMode::Brush, blur_style: Default::default(), recent_colors: Vec::new(), + pen_smoothing: None, marker_opacity: Some(0.32), spotlight_magnification: None, fill_enabled: Some(false), diff --git a/src/backend/wayland/state.rs b/src/backend/wayland/state.rs index 56bd859c..6d0cdc24 100644 --- a/src/backend/wayland/state.rs +++ b/src/backend/wayland/state.rs @@ -104,6 +104,7 @@ mod core; mod data; mod desktop_open; mod eyedropper; +mod font_catalog; mod gtk_toolbar; mod helpers; mod input_actions; @@ -242,6 +243,10 @@ pub(super) struct WaylandState { // Input state pub(super) input_state: InputState, + /// One-shot worker that enumerates the system font catalog after the first + /// committed frame instead of inside a picker-opening input callback. + pub(super) font_catalog_prewarm: RuntimeOperationController<(), Duration>, + pub(super) font_catalog_prewarm_started: bool, /// Wake handle the input HUD's system reader pokes after sending chips. /// Cloned from the shared runtime source at startup so the reader can be /// started and stopped whenever the HUD toggles. diff --git a/src/backend/wayland/state/core/focus.rs b/src/backend/wayland/state/core/focus.rs new file mode 100644 index 00000000..888e5128 --- /dev/null +++ b/src/backend/wayland/state/core/focus.rs @@ -0,0 +1,18 @@ +use super::super::WaylandState; + +impl WaylandState { + /// Retire every keyboard-owned transient when focus is lost. + /// + /// Both the compositor's keyboard-leave callback and layer-output surface + /// recreation enter through this state lifecycle boundary. + pub(in crate::backend::wayland) fn teardown_keyboard_focus(&mut self) { + self.set_keyboard_focus(false); + self.set_overlay_ready(false); + self.clear_toolbar_focus(); + self.input_state.clear_focus_owned_key_state(); + self.sync_region_square_modifier(false); + self.clear_key_repeat(); + self.set_board_pan_key_held(false); + self.stop_board_pan(); + } +} diff --git a/src/backend/wayland/state/core/init.rs b/src/backend/wayland/state/core/init.rs index 7404d2d5..ce7dfb64 100644 --- a/src/backend/wayland/state/core/init.rs +++ b/src/backend/wayland/state/core/init.rs @@ -102,6 +102,8 @@ impl WaylandState { let buffer_count = config.performance.buffer_count as usize; let runtime_operation_ids = RuntimeOperationIdSource::new(); + let font_catalog_prewarm = + RuntimeOperationController::new(runtime_operation_ids.clone(), runtime_wake.clone()); let clipboard_publish = RuntimeOperationController::new(runtime_operation_ids.clone(), runtime_wake.clone()); let clipboard_paste = @@ -144,6 +146,8 @@ impl WaylandState { runtime_ui_unavailable, runtime_ui_unavailable_previews: Default::default(), input_state, + font_catalog_prewarm, + font_catalog_prewarm_started: false, palette_recents, clipboard_publish, clipboard_paste, diff --git a/src/backend/wayland/state/core/mod.rs b/src/backend/wayland/state/core/mod.rs index 519439a0..b8e5f813 100644 --- a/src/backend/wayland/state/core/mod.rs +++ b/src/backend/wayland/state/core/mod.rs @@ -1,4 +1,5 @@ mod accessors; +mod focus; mod init; mod output; mod overlay; diff --git a/src/backend/wayland/state/core/output/focus.rs b/src/backend/wayland/state/core/output/focus.rs index eb33150e..d8d4d5dd 100644 --- a/src/backend/wayland/state/core/output/focus.rs +++ b/src/backend/wayland/state/core/output/focus.rs @@ -100,13 +100,12 @@ impl WaylandState { } info!("Switching layer overlay to {}", target_label); + self.teardown_keyboard_focus(); self.recreate_layer_surface_for_output(qh, &target_output); self.surface.set_current_output(target_output); self.set_has_seen_surface_enter(false); self.refresh_active_output_label(); self.begin_session_output_transition(target_identity, "output switch"); - self.set_keyboard_focus(false); - self.set_overlay_ready(false); self.input_state.needs_redraw = true; self.sync_toolbar_visibility(qh); } diff --git a/src/backend/wayland/state/font_catalog.rs b/src/backend/wayland/state/font_catalog.rs new file mode 100644 index 00000000..96477885 --- /dev/null +++ b/src/backend/wayland/state/font_catalog.rs @@ -0,0 +1,64 @@ +//! Off-dispatch loading for the process-wide system font catalog. + +use std::time::Instant; + +use crate::backend::wayland::RuntimeOperationPoll; + +use super::WaylandState; + +impl WaylandState { + /// Start the one-time catalog walk after a frame has reached the compositor. + pub(in crate::backend::wayland) fn start_font_catalog_prewarm(&mut self) { + if self.font_catalog_prewarm_started { + return; + } + if self.input_state.font_picker_load_failed() { + return; + } + if crate::draw::system_font_catalog_is_ready() { + self.font_catalog_prewarm_started = true; + return; + } + + match self + .font_catalog_prewarm + .try_submit((), "wayscriber-font-catalog", || { + let started = Instant::now(); + crate::draw::prewarm_system_font_catalog(); + started.elapsed() + }) { + Ok(_) => self.font_catalog_prewarm_started = true, + Err(failure) => { + let (error, ()) = failure.into_parts(); + log::warn!("Failed to start system font catalog prewarm: {error}"); + self.input_state.fail_font_picker_catalog_load(); + } + } + } + + /// Apply a completed catalog to a picker that opened while it was loading. + pub(in crate::backend::wayland) fn drain_font_catalog_prewarm(&mut self) { + match self.font_catalog_prewarm.poll() { + RuntimeOperationPoll::Idle | RuntimeOperationPoll::Pending { .. } => {} + RuntimeOperationPoll::Ready { + outcome: elapsed, .. + } => { + log::debug!( + "System font catalog prewarm completed in {:.1} ms", + elapsed.as_secs_f64() * 1000.0 + ); + self.input_state.finish_font_picker_catalog_load(); + } + RuntimeOperationPoll::ProducerFailed { reason, .. } => { + log::warn!("System font catalog prewarm worker failed: {reason}"); + self.font_catalog_prewarm_started = false; + self.input_state.fail_font_picker_catalog_load(); + } + RuntimeOperationPoll::Disconnected { .. } => { + log::warn!("System font catalog prewarm worker disconnected"); + self.font_catalog_prewarm_started = false; + self.input_state.fail_font_picker_catalog_load(); + } + } + } +} diff --git a/src/backend/wayland/state/render/ui.rs b/src/backend/wayland/state/render/ui.rs index aa7c3f89..8021523f 100644 --- a/src/backend/wayland/state/render/ui.rs +++ b/src/backend/wayland/state/render/ui.rs @@ -221,6 +221,10 @@ impl WaylandState { self.input_state.clear_color_picker_popup_layout(); } + if !capture_picker && self.input_state.is_font_picker_open() { + crate::ui::render_font_picker(ctx, &self.input_state, width, height); + } + if !capture_picker && self.input_state.is_precision_entry_open() { // Anchor under the top strip (the pill is its bottom row): // the same base position both the inline fallback and the diff --git a/src/backend/wayland/toolbar/events.rs b/src/backend/wayland/toolbar/events.rs index 43950b79..b8046264 100644 --- a/src/backend/wayland/toolbar/events.rs +++ b/src/backend/wayland/toolbar/events.rs @@ -13,6 +13,7 @@ pub enum HitKind { max: f64, }, DragSetSpotlightMagnification, + DragSetPenSmoothing, DragSetFontSize, DragUndoDelay, DragRedoDelay, @@ -49,6 +50,7 @@ impl HitKind { HitKind::DragSetThickness { .. } | HitKind::DragSetMarkerOpacity { .. } | HitKind::DragSetSpotlightMagnification + | HitKind::DragSetPenSmoothing | HitKind::DragSetFontSize | HitKind::DragUndoDelay | HitKind::DragRedoDelay diff --git a/src/backend/wayland/toolbar/hit.rs b/src/backend/wayland/toolbar/hit.rs index a9838be2..4a548c90 100644 --- a/src/backend/wayland/toolbar/hit.rs +++ b/src/backend/wayland/toolbar/hit.rs @@ -155,6 +155,12 @@ fn event_for_hit(hit: &HitRegion, x: f64, y: f64, phase: HitPhase) -> Option slider_event_for_hit( + ToolbarSliderTarget::PenSmoothing, + ToolbarSliderSpec::PEN_SMOOTHING, + hit, + x, + ), DragSetFontSize => slider_event_for_hit( ToolbarSliderTarget::FontSize, ToolbarSliderSpec::FONT_SIZE, diff --git a/src/backend/wayland/toolbar/layout/spec/top.rs b/src/backend/wayland/toolbar/layout/spec/top.rs index 3e245192..8b636a9f 100644 --- a/src/backend/wayland/toolbar/layout/spec/top.rs +++ b/src/backend/wayland/toolbar/layout/spec/top.rs @@ -59,6 +59,10 @@ impl ToolbarLayoutSpec { pub(in crate::backend::wayland::toolbar) const TOP_STYLE_AUTO_NUMBER_W: f64 = 108.0; /// Counter reset button width. pub(in crate::backend::wayland::toolbar) const TOP_STYLE_RESET_W: f64 = 56.0; + /// Font-family picker button width. Wider than the counter reset because + /// it carries a family name rather than a fixed word; the model already + /// shortens names to fit. + pub(in crate::backend::wayland::toolbar) const TOP_STYLE_FONT_PICK_W: f64 = 96.0; /// Two-segment control width. pub(in crate::backend::wayland::toolbar) const TOP_STYLE_SEGMENT_W: f64 = 120.0; /// Extra clear gap before a segmented control in the pill, on top of the diff --git a/src/backend/wayland/toolbar/render/paint.rs b/src/backend/wayland/toolbar/render/paint.rs index 0bfdbffd..37635b40 100644 --- a/src/backend/wayland/toolbar/render/paint.rs +++ b/src/backend/wayland/toolbar/render/paint.rs @@ -102,7 +102,7 @@ fn paint_preset_color_swatch( ctx.set_source_rgba(color.0, color.1, color.2, color.3); swatch_path(ctx); let _ = ctx.fill(); - let luminance = 0.299 * color.0 + 0.587 * color.1 + 0.114 * color.2; + let luminance = crate::draw::perceived_luminance(color.0, color.1, color.2); set_color( ctx, if luminance < 0.3 { diff --git a/src/backend/wayland/toolbar/view/top.rs b/src/backend/wayland/toolbar/view/top.rs index 6b6e31d9..8a886e71 100644 --- a/src/backend/wayland/toolbar/view/top.rs +++ b/src/backend/wayland/toolbar/view/top.rs @@ -110,6 +110,16 @@ pub fn plan_top_strip(snapshot: &ToolbarSnapshot) -> TopStripPlan { return plan; } + // Before the pill yields entirely, it sheds its two secondary controls. + // Both are reachable elsewhere (a keybinding, the command palette), and + // losing them costs less than losing the color chip and size slider with + // the rest of the pill. + plan.drop_style_extras = true; + if fits(&plan) { + sort_dropped_items(&mut plan, &visible_tools, &visible_utilities); + return plan; + } + // Last-resort compact presentation keeps the protected core available // while switching text buttons to icons and tightening spacing. plan.compact = true; diff --git a/src/backend/wayland/toolbar/view/top/build.rs b/src/backend/wayland/toolbar/view/top/build.rs index 99f439e2..1bc71267 100644 --- a/src/backend/wayland/toolbar/view/top/build.rs +++ b/src/backend/wayland/toolbar/view/top/build.rs @@ -660,6 +660,7 @@ fn push_style_pill( model::StylePillControl::ThicknessSlider | model::StylePillControl::OpacitySlider | model::StylePillControl::SpotlightMagnificationSlider + | model::StylePillControl::PenSmoothingSlider | model::StylePillControl::FontSizeSlider => { let (slider_spec, value) = control.slider_value(snapshot); let event = control.click_event(snapshot); @@ -675,6 +676,7 @@ fn push_style_pill( model::StylePillControl::SpotlightMagnificationSlider => { HitKind::DragSetSpotlightMagnification } + model::StylePillControl::PenSmoothingSlider => HitKind::DragSetPenSmoothing, _ => HitKind::DragSetFontSize, }; let rect = ( @@ -692,17 +694,15 @@ fn push_style_pill( Some(Interaction { event, kind, - tooltip: None, + // Most sliders are self-explanatory and carry none; + // the model decides, so both frontends agree. + tooltip: control.tooltip(snapshot), }), )); x += ToolbarLayoutSpec::TOP_STYLE_SLIDER_W + gap; // The opacity slider carries its readout as decoration; the // thickness/text-size numerals are distinct value controls. - if matches!( - control, - model::StylePillControl::OpacitySlider - | model::StylePillControl::SpotlightMagnificationSlider - ) { + if control.carries_inline_readout() { nodes.push(WidgetNode::decor( format!("{}.readout", control.id()), ( @@ -796,6 +796,29 @@ fn push_style_pill( )); x += ToolbarLayoutSpec::TOP_STYLE_RESET_W + gap; } + model::StylePillControl::FontFamilyPicker => { + // The family in use, as a button onto the overlay's picker. + // Same shape as the counter reset, wider because the label is + // a name rather than a fixed word. + nodes.push(WidgetNode::new( + id, + ( + x, + center(row_h), + ToolbarLayoutSpec::TOP_STYLE_FONT_PICK_W, + row_h, + ), + WidgetKind::TextButton { + label: LabelSpec::new(control.label(snapshot), TOP_LABEL_FONT_SIZE, true), + style: ButtonStyle::plain(), + }, + Some(Interaction::click( + control.click_event(snapshot), + control.tooltip(snapshot), + )), + )); + x += ToolbarLayoutSpec::TOP_STYLE_FONT_PICK_W + gap; + } model::StylePillControl::SelectionCycle(_) | model::StylePillControl::ArrowStyleCycle => { let enabled = control.enabled(snapshot); diff --git a/src/canvas_export/page.rs b/src/canvas_export/page.rs index 6675a782..f7321888 100644 --- a/src/canvas_export/page.rs +++ b/src/canvas_export/page.rs @@ -4,8 +4,8 @@ use crate::capture::CaptureError; use crate::draw::{ BlurRectParams, Color, EraserReplayContext, Frame, Shape, SpotlightMagnifierOutcome, SpotlightMagnifierScratch, SpotlightMagnifierSource, SpotlightPass, render_blur_rect, - render_eraser_stroke, render_shape, render_spotlight_magnification_pass, render_spotlight_pass, - spotlight_regions_for_frame, + render_eraser_stroke, render_shape_over, render_spotlight_magnification_pass, + render_spotlight_pass, spotlight_regions_for_frame, }; use crate::screen_pixels::ScreenImage; @@ -194,6 +194,16 @@ pub(crate) struct ExportBackdrop { } impl ExportBackdrop { + /// Relative luminance of a solid page colour, when the backdrop is one. + /// + /// `None` for transparent and image backdrops: a transparent page has no + /// colour to report, and an image's brightness varies across the page, so + /// one number for the whole of it would be a guess. + pub(crate) fn solid_luminance(&self) -> Option { + self.bg_color + .map(|color| crate::draw::perceived_luminance(color.r, color.g, color.b)) + } + pub(crate) fn new(snapshot: &CanvasExportBackdropSnapshot) -> Result { match snapshot { CanvasExportBackdropSnapshot::Transparent => Ok(Self { @@ -378,6 +388,11 @@ fn draw_canvas_page_contents( backdrop.paint(ctx); } let replay_ctx = backdrop.replay_context(); + // 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. + // Raster exports ignore it and probe, which also sees the shapes underneath. + let known_background_luminance = backdrop.solid_luminance(); for drawn_shape in &page.frame.shapes { match &drawn_shape.shape { @@ -404,7 +419,7 @@ fn draw_canvas_page_contents( }, &replay_ctx, ), - other => render_shape(ctx, other), + other => render_shape_over(ctx, other, known_background_luminance), } } @@ -556,3 +571,49 @@ mod tests { assert_eq!(retained.data.as_ptr(), pixels); } } + +#[cfg(test)] +mod backdrop_luminance_tests { + use super::*; + + #[test] + fn a_solid_page_reports_its_own_brightness_for_text_to_contrast_with() { + let white = ExportBackdrop::new(&CanvasExportBackdropSnapshot::Solid(Color::new( + 1.0, 1.0, 1.0, 1.0, + ))) + .expect("backdrop"); + let black = ExportBackdrop::new(&CanvasExportBackdropSnapshot::Solid(Color::new( + 0.0, 0.0, 0.0, 1.0, + ))) + .expect("backdrop"); + + assert!(white.solid_luminance().expect("known") > 0.9); + assert!(black.solid_luminance().expect("known") < 0.1); + } + + #[test] + fn a_transparent_page_has_no_colour_to_report() { + let backdrop = + ExportBackdrop::new(&CanvasExportBackdropSnapshot::Transparent).expect("backdrop"); + + assert_eq!(backdrop.solid_luminance(), None); + } + + #[test] + fn a_whiteboard_pdf_and_a_whiteboard_on_screen_choose_the_same_halo() { + // The screen probes and gets ~1.0; the PDF page cannot be probed and + // falls back to this. Both must reach the same decision, or an exported + // board looks different from the board it was exported from. + let whiteboard = ExportBackdrop::new(&CanvasExportBackdropSnapshot::Solid(Color::new( + 1.0, 1.0, 1.0, 1.0, + ))) + .expect("backdrop"); + let red = Color::new(0.96, 0.2, 0.25, 1.0); + + let on_screen = crate::draw::text_outline_color(red, Some(1.0)); + let in_pdf = crate::draw::text_outline_color(red, whiteboard.solid_luminance()); + + assert_eq!(on_screen, in_pdf); + assert!(in_pdf.r < 0.5, "and it is the dark halo"); + } +} diff --git a/src/config/action_meta/entries/tools.rs b/src/config/action_meta/entries/tools.rs index 9d3c88d5..21c03d51 100644 --- a/src/config/action_meta/entries/tools.rs +++ b/src/config/action_meta/entries/tools.rs @@ -234,6 +234,16 @@ pub const ENTRIES: &[ActionMeta] = &[ true, true ), + meta!( + OpenFontPicker, + "Font Picker", + None, + "Pick a text font from every one installed", + Tools, + true, + true, + true + ), meta!( IncreasePenSmoothing, "Increase Pen Smoothing", diff --git a/src/config/action_meta/tests.rs b/src/config/action_meta/tests.rs index 6087a521..c0ad5ea8 100644 --- a/src/config/action_meta/tests.rs +++ b/src/config/action_meta/tests.rs @@ -177,6 +177,7 @@ const EXPECTED_COMMAND_PALETTE_ACTIONS: &[Action] = &[ Action::SelectEraserTool, Action::ToggleEraserMode, Action::CycleFontFamily, + Action::OpenFontPicker, Action::IncreasePenSmoothing, Action::DecreasePenSmoothing, Action::SelectSpotlightTool, diff --git a/src/config/keybindings/config/map/edit.rs b/src/config/keybindings/config/map/edit.rs index 41e5f796..b2496a20 100644 --- a/src/config/keybindings/config/map/edit.rs +++ b/src/config/keybindings/config/map/edit.rs @@ -101,6 +101,7 @@ define_action_binding_accessors! { IncreasePenSmoothing => tools.increase_pen_smoothing, DecreasePenSmoothing => tools.decrease_pen_smoothing, CycleFontFamily => tools.cycle_font_family, + OpenFontPicker => tools.open_font_picker, CycleBlurStyle => tools.cycle_blur_style, CycleArrowStyle => tools.cycle_arrow_style, SelectPenTool => tools.select_pen_tool, diff --git a/src/config/keybindings/config/map/tools.rs b/src/config/keybindings/config/map/tools.rs index a809ef76..b41182f7 100644 --- a/src/config/keybindings/config/map/tools.rs +++ b/src/config/keybindings/config/map/tools.rs @@ -37,6 +37,7 @@ impl KeybindingsConfig { Action::DecreasePenSmoothing, )?; inserter.insert_all(&self.tools.cycle_font_family, Action::CycleFontFamily)?; + inserter.insert_all(&self.tools.open_font_picker, Action::OpenFontPicker)?; inserter.insert_all(&self.tools.cycle_blur_style, Action::CycleBlurStyle)?; inserter.insert_all(&self.tools.cycle_arrow_style, Action::CycleArrowStyle)?; inserter.insert_all(&self.tools.select_pen_tool, Action::SelectPenTool)?; diff --git a/src/config/keybindings/config/types/bindings/tools.rs b/src/config/keybindings/config/types/bindings/tools.rs index 1e80e68d..097bb12f 100644 --- a/src/config/keybindings/config/types/bindings/tools.rs +++ b/src/config/keybindings/config/types/bindings/tools.rs @@ -44,6 +44,10 @@ pub struct ToolKeybindingsConfig { #[serde(default = "default_cycle_font_family")] pub cycle_font_family: Vec, + /// Open the system font picker. + #[serde(default = "default_open_font_picker")] + pub open_font_picker: Vec, + #[serde(default = "default_cycle_blur_style")] pub cycle_blur_style: Vec, @@ -120,6 +124,7 @@ impl Default for ToolKeybindingsConfig { increase_pen_smoothing: default_increase_pen_smoothing(), decrease_pen_smoothing: default_decrease_pen_smoothing(), cycle_font_family: default_cycle_font_family(), + open_font_picker: default_open_font_picker(), cycle_blur_style: default_cycle_blur_style(), cycle_arrow_style: default_cycle_arrow_style(), select_pen_tool: default_select_pen_tool(), diff --git a/src/config/keybindings/defaults/tools.rs b/src/config/keybindings/defaults/tools.rs index 469db3a0..921af3ec 100644 --- a/src/config/keybindings/defaults/tools.rs +++ b/src/config/keybindings/defaults/tools.rs @@ -52,6 +52,12 @@ pub(crate) fn default_cycle_font_family() -> Vec { vec!["Shift+T".to_string()] } +/// Unbound by default. `Shift+T` already covers the mid-demo case, and the +/// picker is a setup gesture reached from the command palette. +pub(crate) fn default_open_font_picker() -> Vec { + Vec::new() +} + pub(crate) fn default_cycle_blur_style() -> Vec { Vec::new() } diff --git a/src/config/keybindings/tests.rs b/src/config/keybindings/tests.rs index f4ededb6..ffb320b9 100644 --- a/src/config/keybindings/tests.rs +++ b/src/config/keybindings/tests.rs @@ -742,6 +742,7 @@ const DEFAULT_BINDING_SNAPSHOT: &[(&str, &[&str])] = &[ ("select_eraser_tool", &["D"]), ("toggle_eraser_mode", &["Ctrl+Shift+E"]), ("cycle_font_family", &["Shift+T"]), + ("open_font_picker", &[]), ("increase_pen_smoothing", &[]), ("decrease_pen_smoothing", &[]), ("cycle_blur_style", &[]), diff --git a/src/config/tests/validate.rs b/src/config/tests/validate.rs index 34556a75..bf6ad5f7 100644 --- a/src/config/tests/validate.rs +++ b/src/config/tests/validate.rs @@ -1560,3 +1560,32 @@ fn an_unparseable_binding_does_not_disturb_conflict_resolution() { Action::SelectPenTool ); } + +#[test] +fn validate_and_clamp_drops_blank_and_repeated_font_cycle_entries() { + let mut config = Config::default(); + config.drawing.font_cycle = vec![ + " Sans ".to_string(), + String::new(), + "Serif".to_string(), + " ".to_string(), + "Sans".to_string(), + ]; + + config.validate_and_clamp(); + + assert_eq!(config.drawing.font_cycle, ["Sans", "Serif"]); +} + +#[test] +fn a_font_cycle_repeat_in_another_case_is_still_a_repeat() { + // Fontconfig resolves a family name without regard to case, so these two + // entries are one font. Keeping both would leave a step that changes the + // spelling and nothing a viewer can see. + let mut config = Config::default(); + config.drawing.font_cycle = vec!["Sans".to_string(), "sans".to_string()]; + + config.validate_and_clamp(); + + assert_eq!(config.drawing.font_cycle, ["Sans"]); +} diff --git a/src/config/validate/drawing.rs b/src/config/validate/drawing.rs index fe599445..0e4e118d 100644 --- a/src/config/validate/drawing.rs +++ b/src/config/validate/drawing.rs @@ -40,11 +40,15 @@ impl Config { // Font cycle: drop blank entries and repeats, keeping the given order. // A repeat would make the action appear to skip, and a blank name would // resolve to whatever the font system falls back to. + // + // Repeats are judged without case, the way fontconfig resolves a family + // name. `["Sans", "sans"]` is one font written twice, and keeping both + // would leave a step that changes only the spelling. let mut seen = std::collections::BTreeSet::new(); let before = self.drawing.font_cycle.len(); self.drawing.font_cycle.retain(|family| { let trimmed = family.trim(); - !trimmed.is_empty() && seen.insert(trimmed.to_string()) + !trimmed.is_empty() && seen.insert(trimmed.to_lowercase()) }); if self.drawing.font_cycle.len() != before { log::warn!( diff --git a/src/configurator_destination.rs b/src/configurator_destination.rs index e3bb3cb5..72fdfb93 100644 --- a/src/configurator_destination.rs +++ b/src/configurator_destination.rs @@ -113,6 +113,7 @@ pub fn keybindings_section_for_action(action: Action) -> Option, + monospace: Vec, +} + +static FONT_CATALOG: std::sync::OnceLock = std::sync::OnceLock::new(); + +/// The installed families, enumerated once and kept for the life of the process. +/// +/// One walk, not one per list. The monospace names are a filter over the same +/// families the full list holds, so reading the font map twice would pay the +/// ~23 ms enumeration again to learn nothing new — and would make pressing +/// `Tab` in the picker cost what opening it did. +/// +/// The list changes only when fonts are installed or removed, which does not +/// happen while an overlay is up. +/// +/// The Wayland backend prewarms this cache on a worker after its first committed +/// frame. Synchronous callers such as the configurator can still initialize it +/// on demand, but input dispatch uses the non-blocking readiness probes below. +fn font_catalog() -> &'static FontCatalog { + FONT_CATALOG.get_or_init(|| { + use pango::prelude::{FontFamilyExt, FontMapExt}; + let listed = pangocairo::FontMap::new() + .list_families() + .iter() + .map(|family| (family.name().to_string(), family.is_monospace())) + .collect(); + let catalog = build_font_catalog(listed); + log::debug!( + "Enumerated {} system font families ({} monospace)", + catalog.all.len(), + catalog.monospace.len() + ); + catalog + }) +} + +/// Build the process-wide catalog on the calling thread. +/// +/// The Wayland backend calls this only from its prewarm worker. Keeping the +/// operation here ensures every caller still shares the same one-time cache. +pub(crate) fn prewarm_system_font_catalog() { + let _ = font_catalog(); +} + +/// Whether font enumeration has completed, without starting it. +pub(crate) fn system_font_catalog_is_ready() -> bool { + FONT_CATALOG.get().is_some() +} + +/// Installed families when the cache is ready, without enumerating fonts. +pub(crate) fn try_system_font_families() -> Option<&'static [String]> { + FONT_CATALOG.get().map(|catalog| catalog.all.as_slice()) +} + +/// Installed monospace families when the cache is ready, without enumerating. +pub(crate) fn try_monospace_font_families() -> Option<&'static [String]> { + FONT_CATALOG + .get() + .map(|catalog| catalog.monospace.as_slice()) +} + +/// Sort, de-duplicate, and split what the font map listed. +/// +/// Separate from the enumeration so the rules can be tested against a list +/// chosen for the purpose. What a given machine has installed is not a fixture: +/// a desktop with no case-variant families cannot show whether de-duplication +/// handles them. +fn build_font_catalog(mut listed: Vec<(String, bool)>) -> FontCatalog { + listed.sort_by_key(|(name, _)| normalized_family_name(name)); + // Sorted by the same key the comparison uses, so variants land next to each + // other; de-duplicated by `families_match` rather than exact text, or a + // backend offering both `Sans` and `sans` lists one font twice. + listed.dedup_by(|(left, _), (right, _)| families_match(left, right)); + + let monospace = listed + .iter() + .filter(|(_, monospace)| *monospace) + .map(|(name, _)| name.clone()) + .collect(); + let all = listed.into_iter().map(|(name, _)| name).collect(); + FontCatalog { all, monospace } +} + +/// Font families installed on this system, sorted, with duplicates removed. +/// +/// See [`font_catalog`] for when the enumeration happens and why it is cached. +pub fn system_font_families() -> &'static [String] { + &font_catalog().all +} + +/// Families the font system reports as monospace, in the same order. +/// +/// The group people reach for when annotating code, and a small enough slice of +/// a typical system — 10 of 269 on the machine this was measured on — to be +/// worth offering as its own filter. Free once the full list has been built. +pub fn monospace_font_families() -> &'static [String] { + &font_catalog().monospace +} + +/// Whether `family` is installed, compared without case. +/// +/// Pango resolves an unknown family to whatever fontconfig substitutes, with no +/// error and no warning, so a typo in a configured family silently renders in +/// something else. This is how a caller can say so. +pub fn family_is_installed(family: &str) -> bool { + let wanted = family.trim(); + if wanted.is_empty() { + return false; + } + system_font_families() + .iter() + .any(|name| families_match(name, wanted)) +} + +/// Whether two strings name the same family. +/// +/// One rule for the whole program. Fontconfig resolves a family without regard +/// to case, so `sans` and `Sans` are one font; anything that compares family +/// names exactly ends up treating them as two, and a step from one to the other +/// changes only the spelling. Not `eq_ignore_ascii_case`: family names are not +/// all Latin. +pub fn families_match(left: &str, right: &str) -> bool { + normalized_family_name(left) == normalized_family_name(right) +} + +fn normalized_family_name(family: &str) -> String { + family.trim().to_lowercase() +} + +#[cfg(test)] +mod system_font_tests { + use super::*; + + #[test] + fn the_system_list_is_non_empty_sorted_and_free_of_repeats() { + let families = system_font_families(); + + assert!( + !families.is_empty(), + "a system with no fonts at all cannot render text either" + ); + let mut sorted = families.to_vec(); + sorted.sort_by_key(|name| normalized_family_name(name)); + assert_eq!(families, sorted.as_slice()); + let mut unique = sorted.clone(); + unique.dedup(); + assert_eq!(unique.len(), families.len()); + } + + #[test] + fn the_list_is_enumerated_once_and_reused() { + let first = system_font_families(); + let second = system_font_families(); + + assert!( + std::ptr::eq(first, second), + "the second call must not re-enumerate" + ); + } + + #[test] + fn both_lists_come_from_one_enumeration() { + // Reading the font map a second time would pay the ~23 ms walk again to + // learn nothing new, and would make pressing Tab in the picker cost + // what opening it did. + let all = system_font_families(); + let monospace = monospace_font_families(); + + assert!(std::ptr::eq(all, system_font_families())); + assert!(std::ptr::eq(monospace, monospace_font_families())); + // Same catalog: the monospace list is in the full list's own order. + let mut positions = monospace + .iter() + .map(|name| all.iter().position(|other| other == name)); + assert!( + positions.all(|position| position.is_some()), + "the filter must be over the list, not a second walk" + ); + } + + #[test] + fn the_catalog_holds_one_entry_per_family_however_it_is_spaced_or_spelled() { + // `families_match` is the program's one rule for family identity, and + // the catalog has to obey it too, or a backend offering both `Sans` and + // `sans` lists one font twice. + let catalog = build_font_catalog(vec![ + ("Sans".to_string(), false), + ("JetBrains Mono".to_string(), true), + ("sans".to_string(), false), + ("SANS".to_string(), false), + (" sAnS ".to_string(), false), + ]); + + assert_eq!(catalog.all, ["JetBrains Mono", "Sans"]); + assert_eq!(catalog.monospace, ["JetBrains Mono"]); + } + + #[test] + fn the_catalog_is_sorted_without_regard_to_case() { + let catalog = build_font_catalog(vec![ + ("Zapfino".to_string(), false), + ("adwaita Sans".to_string(), false), + ("Liberation Mono".to_string(), true), + ]); + + assert_eq!(catalog.all, ["adwaita Sans", "Liberation Mono", "Zapfino"]); + } + + #[test] + fn monospace_families_are_a_subset_of_the_whole_list() { + let all = system_font_families(); + + for family in monospace_font_families() { + assert!( + all.contains(family), + "{family} is missing from the full list" + ); + } + } + + #[test] + fn an_installed_family_is_recognized_whatever_case_it_is_written_in() { + let family = system_font_families() + .first() + .expect("at least one family") + .clone(); + + assert!(family_is_installed(&family)); + assert!(family_is_installed(&family.to_uppercase())); + assert!(family_is_installed(&format!(" {family} "))); + } + + #[test] + fn a_family_that_is_not_installed_is_reported_as_missing() { + assert!(!family_is_installed("Wayscriber No Such Font 9000")); + assert!(!family_is_installed("")); + assert!(!family_is_installed(" ")); + } +} diff --git a/src/draw/mod.rs b/src/draw/mod.rs index a9462f07..92e8cc1a 100644 --- a/src/draw/mod.rs +++ b/src/draw/mod.rs @@ -20,7 +20,14 @@ pub mod spotlight; pub use canvas_set::{BoardPages, PageDeleteOutcome}; pub use color::Color; pub use dirty::{DirtyFullReason, DirtyRegionReport, DirtyTracker}; -pub use font::FontDescriptor; +pub use font::{ + FontDescriptor, families_match, family_is_installed, monospace_font_families, + system_font_families, +}; +pub(crate) use font::{ + prewarm_system_font_catalog, system_font_catalog_is_ready, try_monospace_font_families, + try_system_font_families, +}; pub use frame::{DrawnShape, Frame, ShapeId}; #[allow(unused_imports)] pub(crate) use render::render_eraser_stroke; @@ -31,9 +38,9 @@ pub use render::{ BlurRectParams, EraserReplayContext, IMMUTABLE_RASTER_SOURCE_TOKEN, SpotlightMagnifierMetrics, SpotlightMagnifierOutcome, SpotlightMagnifierScratch, SpotlightMagnifierSource, SpotlightPass, SpotlightRegion, SpotlightSnapshotStrategy, caret_line_width, caret_outline_width, - painted_background_luminance, render_blur_rect, render_board_background, + 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_handles, render_shape, + render_selection_halo, render_selection_handles, render_shape, render_shape_over, render_spotlight_magnification_pass, render_spotlight_pass, render_sticky_note, render_text, selection_handle_rects, spotlight_regions_for_frame, sticky_note_foreground, text_outline_color, @@ -41,9 +48,8 @@ pub use render::{ #[allow(unused_imports)] pub use shape::{ ArrowLabel, ArrowStyle, BlurStyle, EmbeddedImage, EraserBrush, EraserKind, MAX_PEN_SMOOTHING, - PolygonKind, - REGULAR_POLYGON_DEFAULT_SIDES, REGULAR_POLYGON_MAX_SIDES, REGULAR_POLYGON_MIN_SIDES, Shape, - StepMarkerLabel, clamp_regular_sides, + PolygonKind, REGULAR_POLYGON_DEFAULT_SIDES, REGULAR_POLYGON_MAX_SIDES, + REGULAR_POLYGON_MIN_SIDES, Shape, StepMarkerLabel, clamp_regular_sides, }; pub use spotlight::{ DEFAULT_SPOTLIGHT_MAGNIFICATION, MAX_SPOTLIGHT_MAGNIFICATION, MIN_SPOTLIGHT_MAGNIFICATION, diff --git a/src/draw/render/backdrop_probe.rs b/src/draw/render/backdrop_probe.rs index 610865ba..216ffd54 100644 --- a/src/draw/render/backdrop_probe.rs +++ b/src/draw/render/backdrop_probe.rs @@ -107,7 +107,7 @@ fn average_luminance(probe: &mut cairo::ImageSurface) -> Option { let blue = f64::from(pixel[0]) / 255.0 / scale; let green = f64::from(pixel[1]) / 255.0 / scale; let red = f64::from(pixel[2]) / 255.0 / scale; - total += relative_luminance(red, green, blue); + total += perceived_luminance(red, green, blue); opaque += 1; } } @@ -119,14 +119,21 @@ fn average_luminance(probe: &mut cairo::ImageSurface) -> Option { Some(total / opaque as f64) } -/// Weighted luminance, the same formula the board pen-contrast helper uses. -pub(super) fn relative_luminance(red: f64, green: f64, blue: f64) -> f64 { +/// Rec. 601 luma — the "is this light or dark?" metric every contrast decision +/// on the canvas uses: the text halo, the step-marker outline, the sticky-note +/// foreground, and the board pen-contrast helper. +/// +/// Deliberately not [`crate::ui::theme::relative_luminance`], which is Rec. 709 +/// and answers a different question for the overlay chrome. The two disagree +/// most on green, so a shared name for both would be a bug waiting to be +/// written: keep the names apart and pick by what is being contrasted. +pub fn perceived_luminance(red: f64, green: f64, blue: f64) -> f64 { red * 0.299 + green * 0.587 + blue * 0.114 } -/// Relative luminance of a colour, ignoring its alpha. -pub(super) fn color_luminance(color: Color) -> f64 { - relative_luminance(color.r, color.g, color.b) +/// [`perceived_luminance`] of a colour, ignoring its alpha. +pub(crate) fn color_luminance(color: Color) -> f64 { + perceived_luminance(color.r, color.g, color.b) } #[cfg(test)] @@ -194,6 +201,19 @@ mod tests { ); } + #[test] + fn a_vector_target_declines_rather_than_guessing() { + // A PDF page has no pixels to read back. The probe says so, and the + // caller's known page colour takes over; see `render_text_over`. + let surface = + cairo::PdfSurface::for_stream(200.0, 100.0, Vec::::new()).expect("pdf surface"); + let ctx = cairo::Context::new(&surface).unwrap(); + ctx.set_source_rgb(1.0, 1.0, 1.0); + let _ = ctx.paint(); + + assert!(painted_luminance(&ctx, (10.0, 10.0, 40.0, 20.0)).is_none()); + } + #[test] fn a_degenerate_or_offscreen_rectangle_asks_for_nothing() { let (_surface, ctx) = filled_target((1.0, 1.0, 1.0, 1.0)); diff --git a/src/draw/render/blur.rs b/src/draw/render/blur.rs index e28a0bb4..db69c11f 100644 --- a/src/draw/render/blur.rs +++ b/src/draw/render/blur.rs @@ -368,7 +368,7 @@ fn average_surface_stats(surface: &mut cairo::ImageSurface) -> Option, +) { match shape { Shape::Freehand { points, @@ -121,7 +133,7 @@ pub fn render_shape(ctx: &cairo::Context, shape: &Shape) { label.size, &label.font_descriptor, ) { - render_text( + render_text_over( ctx, layout.x, layout.y, @@ -131,6 +143,7 @@ pub fn render_shape(ctx: &cairo::Context, shape: &Shape) { &label.font_descriptor, ARROW_LABEL_BACKGROUND, None, + known_background_luminance, ); } } @@ -165,7 +178,7 @@ pub fn render_shape(ctx: &cairo::Context, shape: &Shape) { background_enabled, wrap_width, } => { - render_text( + render_text_over( ctx, *x, *y, @@ -175,6 +188,7 @@ pub fn render_shape(ctx: &cairo::Context, shape: &Shape) { font_descriptor, *background_enabled, *wrap_width, + known_background_luminance, ); } Shape::StepMarker { x, y, color, label } => { @@ -186,7 +200,7 @@ pub fn render_shape(ctx: &cairo::Context, shape: &Shape) { a: (alpha * 0.9).clamp(0.0, 1.0), ..*color }; - let brightness = color.r * 0.299 + color.g * 0.587 + color.b * 0.114; + let brightness = super::color_luminance(*color); let (outline_color, text_color) = if brightness > 0.6 { ( Color { @@ -236,7 +250,7 @@ pub fn render_shape(ctx: &cairo::Context, shape: &Shape) { let center_offset_y = metrics.ink_y + metrics.ink_height / 2.0; let baseline_x = (*x as f64 - center_offset_x).round() as i32; let baseline_y = (*y as f64 - center_offset_y + metrics.baseline).round() as i32; - render_text( + render_text_over( ctx, baseline_x, baseline_y, @@ -246,6 +260,7 @@ pub fn render_shape(ctx: &cairo::Context, shape: &Shape) { &label.font_descriptor, false, None, + known_background_luminance, ); } } diff --git a/src/draw/render/text.rs b/src/draw/render/text.rs index 3026a131..9282c8d3 100644 --- a/src/draw/render/text.rs +++ b/src/draw/render/text.rs @@ -36,6 +36,40 @@ pub fn render_text( font_descriptor: &FontDescriptor, background_enabled: bool, wrap_width: Option, +) { + render_text_over( + ctx, + x, + y, + text, + color, + size, + font_descriptor, + background_enabled, + wrap_width, + None, + ); +} + +/// `render_text`, plus what the caller knows about the background. +/// +/// `known_background_luminance` is used only when the render target cannot be +/// read back. A vector target — a PDF page — has no pixels to probe, so without +/// it a board exported to PDF would pick a different halo from the same board on +/// screen. Raster targets ignore it and probe, which is strictly better: the +/// probe sees the shapes painted under the label as well as the backdrop. +#[allow(clippy::too_many_arguments)] +pub fn render_text_over( + ctx: &cairo::Context, + x: i32, + y: i32, + text: &str, + color: Color, + size: f64, + font_descriptor: &FontDescriptor, + background_enabled: bool, + wrap_width: Option, + known_background_luminance: Option, ) { // Save context state to prevent settings from leaking to other drawing operations ctx.save().ok(); @@ -95,7 +129,8 @@ pub fn render_text( content.width, content.height, ), - ); + ) + .or(known_background_luminance); let outline = text_outline_color(color, background_luminance); // First pass: draw semi-transparent background rectangle (if enabled) @@ -294,7 +329,7 @@ fn render_sticky_note_layout( /// live caret matches the committed note text instead of vanishing into the /// background fill. pub fn sticky_note_foreground(background: Color) -> Color { - let brightness = background.r * 0.299 + background.g * 0.587 + background.b * 0.114; + let brightness = super::color_luminance(background); if brightness > 0.6 { Color { r: 0.12, diff --git a/src/draw/shape/smoothing.rs b/src/draw/shape/smoothing.rs index c94e1369..1ac23661 100644 --- a/src/draw/shape/smoothing.rs +++ b/src/draw/shape/smoothing.rs @@ -52,10 +52,13 @@ pub fn smooth_path(points: &[(i32, i32)], level: u8) -> Vec<(i32, i32)> { .collect() } -/// Smooth the position of a pressure path, leaving every thickness alone. +/// Smooth the positions of a pressure path, leaving every thickness alone. /// -/// Thickness came from the tablet, not from the hand's aim, so smoothing it -/// would erase real pressure detail while fixing nothing. +/// The path of a pressure stroke shakes exactly like any other, so it is +/// smoothed like any other. The *pressure values* are not: they came from the +/// tablet rather than from the hand's aim, and averaging them would erase real +/// detail while fixing nothing. Each smoothed position keeps the thickness that +/// was sampled with it. pub fn smooth_pressure_path(points: &[(i32, i32, f32)], level: u8) -> Vec<(i32, i32, f32)> { let Some(smoothed) = smooth_points(points, level, |&(x, y, _)| (f64::from(x), f64::from(y))) else { diff --git a/src/input/state/actions/action_tools.rs b/src/input/state/actions/action_tools.rs index 2498e685..d6f36948 100644 --- a/src/input/state/actions/action_tools.rs +++ b/src/input/state/actions/action_tools.rs @@ -59,6 +59,9 @@ impl InputState { Action::CycleFontFamily => { self.cycle_font_family(); } + Action::OpenFontPicker => { + self.open_font_picker(); + } Action::IncreasePenSmoothing => self.announce_pen_smoothing(1), Action::DecreasePenSmoothing => self.announce_pen_smoothing(-1), Action::ToggleEraserMode => { diff --git a/src/input/state/actions/key_release.rs b/src/input/state/actions/key_release.rs index ebccedf3..d26664e2 100644 --- a/src/input/state/actions/key_release.rs +++ b/src/input/state/actions/key_release.rs @@ -8,6 +8,7 @@ impl InputState { /// Currently only tracks modifier key releases to update the modifier state. pub fn on_key_release(&mut self, key: Key) { self.release_command_palette_repeat_key(key); + self.release_font_picker_repeat_key(key); let was_modifier = matches!( key, Key::Shift | Key::Ctrl | Key::Alt | Key::Super | Key::Tab diff --git a/src/input/state/core/base/state/init.rs b/src/input/state/core/base/state/init.rs index f0f0f2a4..42e4ce0b 100644 --- a/src/input/state/core/base/state/init.rs +++ b/src/input/state/core/base/state/init.rs @@ -105,6 +105,20 @@ impl InputState { current_font_size: font_size, font_descriptor, font_cycle: Vec::new(), + font_picker_open: false, + font_picker_loading: false, + font_picker_load_failed: false, + font_picker_query: String::new(), + font_picker_selected: 0, + font_picker_scroll: 0, + font_picker_filter: crate::input::state::FontPickerFilter::All, + font_picker_target: crate::input::state::FontPickerTarget::ToolDefault, + font_picker_recents: Vec::new(), + font_picker_results: std::cell::RefCell::new(None), + font_picker_repeat_key: None, + font_picker_repeat_next_tick: None, + font_picker_repeat_started: None, + font_picker_last_panel: None, text_background_enabled, text_wrap_width: None, text_input_mode: TextInputMode::Plain, diff --git a/src/input/state/core/base/state/modifiers.rs b/src/input/state/core/base/state/modifiers.rs index 5f1f6061..846287db 100644 --- a/src/input/state/core/base/state/modifiers.rs +++ b/src/input/state/core/base/state/modifiers.rs @@ -21,6 +21,17 @@ impl InputState { } } + /// Clears key state whose release can be lost with keyboard focus. + /// + /// The backend owns its own repeat and board-pan latches; this is the + /// `InputState` half shared by protocol focus leave and synthetic focus + /// loss during layer-output recreation. + pub(crate) fn clear_focus_owned_key_state(&mut self) { + self.reset_modifiers(); + self.clear_command_palette_repeat(); + self.clear_font_picker_repeat(); + } + /// Synchronize modifier state from backend-provided values (e.g. compositor). /// /// This lets us correct cases where a key release event was missed but the compositor's @@ -36,3 +47,46 @@ impl InputState { } } } + +#[cfg(test)] +mod tests { + use std::time::Instant; + + use crate::input::Key; + use crate::input::state::test_support::make_test_input_state; + + #[test] + fn focus_loss_clears_modal_repeats_and_modifiers() { + let mut state = make_test_input_state(); + state.toggle_command_palette(); + assert!(state.handle_command_palette_key(Key::Down)); + state.sync_modifiers(true, true, true, true); + state.modifiers.tab = true; + assert!( + state + .command_palette_repeat_timeout(Instant::now()) + .is_some() + ); + + state.clear_focus_owned_key_state(); + + assert!( + state + .command_palette_repeat_timeout(Instant::now()) + .is_none() + ); + assert!(!state.modifiers.shift); + assert!(!state.modifiers.ctrl); + assert!(!state.modifiers.alt); + assert!(!state.modifiers.logo); + assert!(!state.modifiers.tab); + + state.open_font_picker(); + assert!(state.handle_font_picker_key(Key::Down, None)); + assert!(state.font_picker_repeat_timeout(Instant::now()).is_some()); + + state.clear_focus_owned_key_state(); + + assert!(state.font_picker_repeat_timeout(Instant::now()).is_none()); + } +} diff --git a/src/input/state/core/base/state/structs.rs b/src/input/state/core/base/state/structs.rs index 8f30f994..3ef79dea 100644 --- a/src/input/state/core/base/state/structs.rs +++ b/src/input/state/core/base/state/structs.rs @@ -133,6 +133,36 @@ pub struct InputState { pub font_descriptor: FontDescriptor, /// Families the font-cycle action steps through. Empty turns it off. pub(crate) font_cycle: Vec, + /// Whether the system font picker owns input. + pub(crate) font_picker_open: bool, + /// The picker opened before the worker-built system catalog was ready. + pub(crate) font_picker_loading: bool, + /// The latest catalog worker failed while this picker was open. + pub(crate) font_picker_load_failed: bool, + pub(crate) font_picker_query: String, + pub(crate) font_picker_selected: usize, + pub(crate) font_picker_scroll: usize, + pub(crate) font_picker_filter: crate::input::state::FontPickerFilter, + /// What a chosen row changes, decided when the picker opens so its caption + /// cannot disagree with what Enter does. + pub(crate) font_picker_target: crate::input::state::FontPickerTarget, + /// Families chosen here, most recent first. + pub(crate) font_picker_recents: Vec, + /// Ranked results memoized on the query and filter, because scoring walks + /// every installed family and the renderer asks more than once per frame. + pub(crate) font_picker_results: std::cell::RefCell, + /// Navigation key held in the font picker, and when its next repeat is due. + /// + /// The picker blocks the backend's canvas repeat timer (a held key must not + /// reach the drawing behind a modal), so it runs its own — the same + /// arrangement the command palette uses. + pub(crate) font_picker_repeat_key: Option, + pub(crate) font_picker_repeat_next_tick: Option, + /// When the held key went down, which is what the repeat ramps from. + pub(crate) font_picker_repeat_started: Option, + /// Panel rectangle the last frame drew, so a move can repaint the panel it + /// is leaving as well as the one it is arriving at. + pub(crate) font_picker_last_panel: Option, /// Whether to draw background behind text pub text_background_enabled: bool, /// Optional wrap width for text input (None = auto) diff --git a/src/input/state/core/eyedropper.rs b/src/input/state/core/eyedropper.rs index 47404cd3..060a807c 100644 --- a/src/input/state/core/eyedropper.rs +++ b/src/input/state/core/eyedropper.rs @@ -92,27 +92,6 @@ impl InputState { .flatten() } - /// Close everything a screen-region modal must not compete with, and - /// cancel any unfinished gesture. Shared by the eyedropper and OCR: both - /// take over pointer input entirely while they are up. - pub(crate) fn prepare_for_screen_modal(&mut self) { - self.cancel_active_interaction(); - if self.show_help { - self.toggle_help_overlay(); - } - if self.command_palette_open { - self.toggle_command_palette(); - } - self.tour_active = false; - self.close_radial_menu(); - self.close_context_menu(); - self.close_properties_panel(); - self.close_board_picker(); - if self.is_color_picker_popup_open() { - self.close_color_picker_popup(true); - } - } - pub(crate) fn set_eyedropper_pending_capture(&mut self, source: EyedropperCaptureSource) { self.eyedropper_ui_state = EyedropperUiState::PendingCapture { source, diff --git a/src/input/state/core/font_cycle.rs b/src/input/state/core/font_cycle.rs index c8598697..95578d2c 100644 --- a/src/input/state/core/font_cycle.rs +++ b/src/input/state/core/font_cycle.rs @@ -10,7 +10,7 @@ //! blur tools already use for their variants. use super::InputState; -use crate::draw::{FontDescriptor, Shape}; +use crate::draw::{FontDescriptor, families_match}; impl InputState { /// Install the configured list. Blank and repeated names are the config @@ -25,15 +25,23 @@ impl InputState { /// A family that is not in the list steps to the first entry rather than /// nowhere: the list is where the action can go, not a claim about where the /// font has been. + /// + /// Names are matched without case, because fontconfig resolves them that + /// way. Comparing exactly would make `sans` a family the list does not hold, + /// and the first step would restyle nothing but the spelling. pub(crate) fn next_font_family(&self, current: &str) -> Option { if self.font_cycle.is_empty() { return None; } - let next = match self.font_cycle.iter().position(|family| family == current) { + let next = match self + .font_cycle + .iter() + .position(|family| families_match(family, current)) + { Some(index) => &self.font_cycle[(index + 1) % self.font_cycle.len()], None => &self.font_cycle[0], }; - (next != current).then(|| next.clone()) + (!families_match(next, current)).then(|| next.clone()) } /// Step the font and say what it landed on. @@ -77,60 +85,19 @@ impl InputState { true } - /// Whether the selection holds anything a font applies to. - fn selection_has_text(&self) -> bool { - let frame = self.boards.active_frame(); - self.selected_shape_ids().iter().any(|id| { - matches!( - frame.shape(*id).map(|drawn| &drawn.shape), - Some(Shape::Text { .. } | Shape::StickyNote { .. }) - ) - }) - } - /// Step every selected text shape to the next family in the list. /// /// The step is decided once, from the first selected text shape, so a mixed /// selection converges on one family instead of fanning out further. fn cycle_selected_font_family(&mut self) -> bool { - let current = { - let frame = self.boards.active_frame(); - self.selected_shape_ids().iter().find_map(|id| { - match frame.shape(*id).map(|drawn| &drawn.shape) { - Some( - Shape::Text { - font_descriptor, .. - } - | Shape::StickyNote { - font_descriptor, .. - }, - ) => Some(font_descriptor.family.clone()), - _ => None, - } - }) - }; - let Some(next) = current.and_then(|family| self.next_font_family(&family)) else { + let Some(next) = self + .first_selected_text_family() + .and_then(|family| self.next_font_family(&family)) + else { return false; }; - let target = next.clone(); - let result = self.apply_selection_change( - |shape| matches!(shape, Shape::Text { .. } | Shape::StickyNote { .. }), - move |shape| match shape { - Shape::Text { - font_descriptor, .. - } - | Shape::StickyNote { - font_descriptor, .. - } if font_descriptor.family != target => { - font_descriptor.family = target.clone(); - true - } - _ => false, - }, - ); - - let changed = self.report_selection_apply_result(result, "font"); + let changed = self.apply_family_to_selected_text(&next); if changed { log::info!("Selected text font family set to {next}"); } @@ -143,6 +110,7 @@ const FONT_CYCLE_TOAST_SOURCE: &str = "font-cycle"; #[cfg(test)] mod tests { use super::*; + use crate::draw::Shape; use crate::input::state::test_support::make_test_input_state; fn state_with_cycle() -> InputState { @@ -178,6 +146,16 @@ mod tests { ); } + #[test] + fn a_family_spelled_in_another_case_is_the_same_family() { + let state = state_with_cycle(); + + // Fontconfig resolves `sans` and `Sans` to one font. A step from the + // first to the second would change the spelling and nothing else. + assert_eq!(state.next_font_family("sans").as_deref(), Some("Monospace")); + assert_eq!(state.next_font_family("SERIF").as_deref(), Some("Sans")); + } + #[test] fn a_one_entry_list_has_nowhere_to_step() { let mut state = make_test_input_state(); diff --git a/src/input/state/core/font_picker/input.rs b/src/input/state/core/font_picker/input.rs new file mode 100644 index 00000000..fb481287 --- /dev/null +++ b/src/input/state/core/font_picker/input.rs @@ -0,0 +1,364 @@ +//! Keyboard and pointer handling while the font picker owns input. + +use std::time::{Duration, Instant}; + +use super::InputState; +use super::layout::{font_picker_layout, font_picker_row_at}; +use crate::input::events::Key; + +/// Rows one wheel tick moves. Three rather than the command palette's one: a +/// palette holds tens of commands and this holds every font installed, so a +/// tick that moves a single row turns a 269-family list into 269 ticks. +const FONT_PICKER_WHEEL_ROWS: usize = 3; + +/// How long a navigation key must be held before it starts repeating. +const REPEAT_INITIAL_DELAY: Duration = Duration::from_millis(280); +/// Interval the repeat starts at, matching the command palette's. +const REPEAT_INTERVAL: Duration = Duration::from_millis(55); +/// Interval the repeat ramps down to while the key stays held. +const REPEAT_FAST_INTERVAL: Duration = Duration::from_millis(20); +/// How long of holding it takes to reach [`REPEAT_FAST_INTERVAL`]. +/// +/// The palette repeats at one flat rate, which is right for a list of tens. +/// This list is every font on the system, and crossing it at the flat rate +/// takes about fifteen seconds — long enough that people give up and reach for +/// the mouse. Ramping keeps a short press precise and makes a long hold +/// actually travel. +const REPEAT_RAMP: Duration = Duration::from_millis(1000); + +/// Whether holding this key should keep moving the highlight. +/// +/// Navigation only. A query is a handful of characters, so `Backspace` repeat +/// would be a way to lose one by accident rather than a way to get anywhere. +fn repeats(key: Key) -> bool { + matches!(key, Key::Up | Key::Down | Key::PageUp | Key::PageDown) +} + +impl InputState { + /// Route one key while the picker is open. Returns whether it was consumed. + /// + /// Every printable character goes into the query, so a family name can be + /// typed straight in without a mode change. That is also why the filter + /// toggle is `Tab` rather than a letter. + pub(crate) fn handle_font_picker_key(&mut self, key: Key, text: Option<&str>) -> bool { + if !self.font_picker_open { + return false; + } + match key { + Key::Escape => { + self.close_font_picker(); + } + Key::Return => { + self.commit_font_picker(); + } + Key::Tab => { + self.font_picker_filter = self.font_picker_filter.next(); + self.font_picker_results.replace(None); + self.reset_font_picker_position(); + } + Key::Down => self.move_font_picker_selection(1), + Key::Up => self.move_font_picker_selection(-1), + Key::PageDown => { + let page = self.font_picker_page() as i64; + self.move_font_picker_selection(page); + } + Key::PageUp => { + let page = self.font_picker_page() as i64; + self.move_font_picker_selection(-page); + } + Key::Home => self.set_font_picker_selection(0), + Key::End => { + let last = self.font_picker_families().len().saturating_sub(1); + self.set_font_picker_selection(last); + } + Key::Space => { + self.font_picker_query.push(' '); + self.font_picker_results.replace(None); + self.reset_font_picker_position(); + } + Key::Backspace => { + self.font_picker_query.pop(); + self.font_picker_results.replace(None); + self.reset_font_picker_position(); + } + _ => { + let Some(text) = + text.filter(|text| !text.is_empty() && text.chars().all(|c| !c.is_control())) + else { + // Unhandled keys are still swallowed: a modal that let + // stray keys through to the canvas would draw behind itself. + return true; + }; + self.font_picker_query.push_str(text); + self.font_picker_results.replace(None); + self.reset_font_picker_position(); + } + } + // A held navigation key keeps moving. The backend's own repeat timer is + // retired while a modal is engaged, or it would feed the canvas behind + // this panel, so the picker owns its repeat the way the palette does. + if repeats(key) { + self.start_font_picker_repeat(key); + } else { + self.clear_font_picker_repeat(); + } + self.mark_font_picker_dirty(); + true + } + + /// Repaint the panel rather than the screen. + /// + /// A held arrow ticks up to fifty times a second; a full-surface repaint at + /// that rate is the whole canvas re-rendered per row. The panel is a known + /// rectangle, and a query that changes the result count changes its height, + /// so the panel being left is repainted along with the one arriving. + pub(crate) fn mark_font_picker_dirty(&mut self) { + self.needs_redraw = true; + if !self.font_picker_open { + self.font_picker_last_panel = None; + self.dirty_tracker.mark_full(); + return; + } + let panel = self.font_picker_panel_bounds(); + self.dirty_tracker.mark_optional_rect(panel); + if self.font_picker_last_panel != panel { + self.dirty_tracker + .mark_optional_rect(self.font_picker_last_panel); + } + self.font_picker_last_panel = panel; + } + + /// The panel's rectangle, grown to cover the shadow it casts. + pub(in crate::input::state::core) fn font_picker_panel_bounds( + &self, + ) -> Option { + const SHADOW: f64 = 4.0; + let layout = font_picker_layout( + self.screen_width, + self.screen_height, + self.font_picker_families().len(), + ); + crate::util::Rect::new( + (layout.panel_x - SHADOW).floor() as i32, + (layout.panel_y - SHADOW).floor() as i32, + (layout.panel_width + SHADOW * 2.0).ceil() as i32, + (layout.panel_height + SHADOW * 2.0).ceil() as i32, + ) + } + + /// Move the highlight by `delta`, clamped rather than wrapped. + /// + /// Clamped because the list can be hundreds long: wrapping from the top to + /// the bottom of 269 families is never what an arrow key meant. + pub(crate) fn move_font_picker_selection(&mut self, delta: i64) { + let count = self.font_picker_families().len(); + if count == 0 { + return; + } + let next = (self.font_picker_selected as i64) + .saturating_add(delta) + .clamp(0, count as i64 - 1) as usize; + self.set_font_picker_selection(next); + } + + /// Highlight `index` and scroll the window the least amount that shows it. + pub(crate) fn set_font_picker_selection(&mut self, index: usize) { + let count = self.font_picker_families().len(); + if count == 0 { + self.font_picker_selected = 0; + self.font_picker_scroll = 0; + return; + } + self.font_picker_selected = index.min(count - 1); + // The window the surface actually shows, not the ceiling. A short + // output draws fewer rows, and scrolling by the ceiling would leave the + // highlight on a row below the panel's bottom edge. + let visible = self.font_picker_visible_rows(count); + if self.font_picker_selected < self.font_picker_scroll { + self.font_picker_scroll = self.font_picker_selected; + } else if self.font_picker_selected >= self.font_picker_scroll + visible { + self.font_picker_scroll = self.font_picker_selected + 1 - visible; + } + let max_scroll = count.saturating_sub(visible); + self.font_picker_scroll = self.font_picker_scroll.min(max_scroll); + self.needs_redraw = true; + } + + /// Rows the current surface has room to show, floored at one so the scroll + /// arithmetic always has a window to work with. + pub(crate) fn font_picker_visible_rows(&self, row_count: usize) -> usize { + font_picker_layout(self.screen_width, self.screen_height, row_count) + .visible_rows + .max(1) + } + + /// How far Page Up and Page Down move: one screenful of this surface. + fn font_picker_page(&self) -> usize { + self.font_picker_visible_rows(self.font_picker_families().len()) + } + + /// Back to the top after the result list changed under the highlight. + fn reset_font_picker_position(&mut self) { + self.font_picker_selected = 0; + self.font_picker_scroll = 0; + } + + /// Scroll the list by one wheel tick. + /// + /// The window moves and the highlight comes along only when the window + /// would leave it behind — the same arrangement the command palette uses, + /// so `Enter` always applies the row that is highlighted rather than + /// whatever happens to be under the pointer. + pub(crate) fn font_picker_wheel_scroll(&mut self, direction: i32) { + if direction == 0 || !self.font_picker_open { + return; + } + let count = self.font_picker_families().len(); + let window = self.font_picker_visible_rows(count); + let max_scroll = count.saturating_sub(window); + let next = if direction > 0 { + (self.font_picker_scroll + FONT_PICKER_WHEEL_ROWS).min(max_scroll) + } else { + self.font_picker_scroll + .saturating_sub(FONT_PICKER_WHEEL_ROWS) + }; + if next == self.font_picker_scroll { + return; + } + self.font_picker_scroll = next; + self.font_picker_selected = self + .font_picker_selected + .clamp(next, (next + window).saturating_sub(1).min(count - 1)); + self.mark_font_picker_dirty(); + } + + fn start_font_picker_repeat(&mut self, key: Key) { + let now = Instant::now(); + // A different key restarts the ramp; the same key held keeps it. + if self.font_picker_repeat_key != Some(key) { + self.font_picker_repeat_key = Some(key); + self.font_picker_repeat_started = Some(now); + self.font_picker_repeat_next_tick = Some(now + REPEAT_INITIAL_DELAY); + } + } + + pub(crate) fn clear_font_picker_repeat(&mut self) { + self.font_picker_repeat_key = None; + self.font_picker_repeat_next_tick = None; + self.font_picker_repeat_started = None; + } + + /// Stop repeating when the held key comes up. + pub(crate) fn release_font_picker_repeat_key(&mut self, key: Key) { + if self.font_picker_repeat_key == Some(key) { + self.clear_font_picker_repeat(); + } + } + + /// Gap to the next repeat, ramping from [`REPEAT_INTERVAL`] down to + /// [`REPEAT_FAST_INTERVAL`] over [`REPEAT_RAMP`] of holding. + fn font_picker_repeat_interval(&self, now: Instant) -> Duration { + let Some(started) = self.font_picker_repeat_started else { + return REPEAT_INTERVAL; + }; + let repeating = now + .saturating_duration_since(started) + .saturating_sub(REPEAT_INITIAL_DELAY); + let progress = (repeating.as_secs_f64() / REPEAT_RAMP.as_secs_f64()).clamp(0.0, 1.0); + let slow = REPEAT_INTERVAL.as_secs_f64(); + let fast = REPEAT_FAST_INTERVAL.as_secs_f64(); + Duration::from_secs_f64(slow + (fast - slow) * progress) + } + + /// Time until the next repeat, for the event loop's timeout. Without it the + /// loop sleeps until a real event and a held key never moves again. + pub(crate) fn font_picker_repeat_timeout(&self, now: Instant) -> Option { + if !self.font_picker_open { + return None; + } + self.font_picker_repeat_next_tick + .map(|next| next.saturating_duration_since(now)) + } + + /// Fire one repeat if due. Returns whether anything moved. + pub(crate) fn tick_font_picker_repeat(&mut self, now: Instant) -> bool { + if !self.font_picker_open { + self.clear_font_picker_repeat(); + return false; + } + let Some(key) = self.font_picker_repeat_key else { + return false; + }; + let Some(next) = self.font_picker_repeat_next_tick else { + return false; + }; + if now < next { + return false; + } + let before = self.font_picker_selected; + match key { + Key::Up => self.move_font_picker_selection(-1), + Key::Down => self.move_font_picker_selection(1), + Key::PageUp => { + let page = self.font_picker_page() as i64; + self.move_font_picker_selection(-page); + } + Key::PageDown => { + let page = self.font_picker_page() as i64; + self.move_font_picker_selection(page); + } + _ => return false, + } + // Rescheduled from `now`, not from the deadline: a long frame must not + // leave a burst of catch-up ticks queued behind it. + self.font_picker_repeat_next_tick = Some(now + self.font_picker_repeat_interval(now)); + let moved = self.font_picker_selected != before; + if moved { + self.mark_font_picker_dirty(); + } + moved + } + + /// Highlight the row under the pointer. Returns whether one was hit. + pub(crate) fn font_picker_hover(&mut self, x: f64, y: f64) -> bool { + if !self.font_picker_open { + return false; + } + let families = self.font_picker_families(); + let layout = font_picker_layout(self.screen_width, self.screen_height, families.len()); + let Some(index) = font_picker_row_at(layout, &families, self.font_picker_scroll, x, y) + else { + return false; + }; + if index != self.font_picker_selected { + self.font_picker_selected = index; + self.mark_font_picker_dirty(); + } + true + } + + /// Apply the row under the pointer. Returns whether the press was consumed. + /// + /// A press outside the panel closes the picker, which is what clicking away + /// from a modal means everywhere else in the overlay. + pub(crate) fn font_picker_press(&mut self, x: f64, y: f64) -> bool { + if !self.font_picker_open { + return false; + } + let families = self.font_picker_families(); + let layout = font_picker_layout(self.screen_width, self.screen_height, families.len()); + if let Some(index) = font_picker_row_at(layout, &families, self.font_picker_scroll, x, y) { + self.set_font_picker_selection(index); + self.commit_font_picker(); + return true; + } + let inside_panel = x >= layout.panel_x + && x <= layout.panel_x + layout.panel_width + && y >= layout.panel_y + && y <= layout.panel_y + layout.panel_height; + if !inside_panel { + self.close_font_picker(); + } + true + } +} diff --git a/src/input/state/core/font_picker/layout.rs b/src/input/state/core/font_picker/layout.rs new file mode 100644 index 00000000..d3ca7dac --- /dev/null +++ b/src/input/state/core/font_picker/layout.rs @@ -0,0 +1,344 @@ +//! Where the font picker's panel and rows sit. +//! +//! Pure geometry from the surface size, so the renderer and the pointer hit +//! test cannot disagree about which row is under the cursor. + +/// Most rows the panel will ever show. Enough to scan, few enough that laying +/// each one out in its own font stays bounded whatever the system has installed. +/// +/// A ceiling, not a promise: a short surface shows fewer. Ask the layout for +/// [`FontPickerLayout::visible_rows`] rather than assuming this many. +pub const FONT_PICKER_MAX_VISIBLE: usize = 12; + +const PANEL_WIDTH: f64 = 520.0; +const MIN_PANEL_WIDTH: f64 = 240.0; +const PANEL_TOP_RATIO: f64 = 0.14; +const PADDING: f64 = 16.0; +const QUERY_HEIGHT: f64 = 44.0; +const ROW_HEIGHT: f64 = 40.0; +const CAPTION_HEIGHT: f64 = 26.0; +const LIST_GAP: f64 = 10.0; + +/// Everything in the panel that is not list: its own padding, the query line, +/// the gap under it, and the caption. +const PANEL_CHROME_HEIGHT: f64 = PADDING * 2.0 + QUERY_HEIGHT + LIST_GAP + CAPTION_HEIGHT; + +#[derive(Debug, Clone, Copy, PartialEq)] +pub struct FontPickerLayout { + pub panel_x: f64, + pub panel_y: f64, + pub panel_width: f64, + pub panel_height: f64, + pub query_x: f64, + pub query_y: f64, + pub query_width: f64, + pub query_height: f64, + pub list_x: f64, + pub list_y: f64, + pub list_width: f64, + pub row_height: f64, + /// Rows the panel has room to draw, never more than + /// [`FONT_PICKER_MAX_VISIBLE`] and never more than the surface fits. + /// + /// Zero when nothing matched: the list reserves height anyway, so the + /// "no matches" note has a place of its own. + pub visible_rows: usize, + /// Height reserved for the list. At least one row even when `visible_rows` + /// is zero. + pub list_height: f64, + pub caption_y: f64, +} + +/// One laid-out row, in surface coordinates. +#[derive(Debug, Clone, PartialEq)] +pub struct FontPickerRow { + pub family: String, + pub index: usize, + pub x: f64, + pub y: f64, + pub width: f64, + pub height: f64, + pub selected: bool, + /// Whether this family is the one currently in use. + pub current: bool, +} + +/// Panel geometry for a surface, and how many rows fit in it. +/// +/// Three things bound the list, and the smallest wins: how many results there +/// are, [`FONT_PICKER_MAX_VISIBLE`], and how many rows the surface itself has +/// room for. The last is why this takes the surface size — a fixed twelve rows +/// is a 592px panel, taller than some outputs an overlay comes up on, and a +/// panel taller than its surface hides the very rows the arrow keys move into. +/// +/// One row is always reserved even with nothing to show, so the "no matches" +/// note lands in the list rather than on the caption. +pub fn font_picker_layout( + surface_width: u32, + surface_height: u32, + row_count: usize, +) -> FontPickerLayout { + let width = f64::from(surface_width.max(1)); + let height = f64::from(surface_height.max(1)); + + let panel_width = PANEL_WIDTH.min(width - PADDING * 2.0).max(MIN_PANEL_WIDTH); + + // Height left for rows once the panel's chrome and the margin around the + // panel are taken out. Floors at one row: a picker showing none is no use, + // and on a surface that small the panel overflowing is the lesser problem. + let room = height - PADDING * 2.0 - PANEL_CHROME_HEIGHT; + let fits = ((room / ROW_HEIGHT).floor().max(1.0)) as usize; + let visible_rows = row_count.min(FONT_PICKER_MAX_VISIBLE).min(fits); + let list_height = visible_rows.max(1) as f64 * ROW_HEIGHT; + let panel_height = PANEL_CHROME_HEIGHT + list_height; + + let panel_x = ((width - panel_width) / 2.0).max(0.0); + let panel_y = (height * PANEL_TOP_RATIO) + .min((height - panel_height - PADDING).max(0.0)) + .max(0.0); + + let query_x = panel_x + PADDING; + let query_y = panel_y + PADDING; + let query_width = panel_width - PADDING * 2.0; + + FontPickerLayout { + panel_x, + panel_y, + panel_width, + panel_height, + query_x, + query_y, + query_width, + query_height: QUERY_HEIGHT, + list_x: query_x, + list_y: query_y + QUERY_HEIGHT + LIST_GAP, + list_width: query_width, + row_height: ROW_HEIGHT, + visible_rows, + list_height, + caption_y: panel_y + panel_height - PADDING - CAPTION_HEIGHT / 2.0, + } +} + +/// The rows a layout shows, for the window starting at `scroll`. +pub fn font_picker_rows( + layout: FontPickerLayout, + families: &[String], + scroll: usize, + selected: usize, + current: &str, +) -> Vec { + families + .iter() + .enumerate() + .skip(scroll) + .take(layout.visible_rows) + .enumerate() + .map(|(offset, (index, family))| FontPickerRow { + family: family.clone(), + index, + x: layout.list_x, + y: layout.list_y + offset as f64 * layout.row_height, + width: layout.list_width, + height: layout.row_height, + selected: index == selected, + current: crate::draw::families_match(family, current), + }) + .collect() +} + +/// The row index under a surface point, if any. +pub fn font_picker_row_at( + layout: FontPickerLayout, + families: &[String], + scroll: usize, + x: f64, + y: f64, +) -> Option { + if x < layout.list_x || x > layout.list_x + layout.list_width { + return None; + } + let offset = ((y - layout.list_y) / layout.row_height).floor(); + if offset < 0.0 || offset >= layout.visible_rows as f64 { + return None; + } + let index = scroll.checked_add(offset as usize)?; + (index < families.len()).then_some(index) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn families(count: usize) -> Vec { + (0..count).map(|index| format!("Family {index}")).collect() + } + + #[test] + fn the_panel_is_centred_and_stays_inside_the_surface() { + let layout = font_picker_layout(1920, 1080, 40); + + assert!(layout.panel_x > 0.0); + assert!((layout.panel_x + layout.panel_width) <= 1920.0); + assert!((layout.panel_y + layout.panel_height) <= 1080.0); + assert_eq!(layout.visible_rows, FONT_PICKER_MAX_VISIBLE); + } + + #[test] + fn a_short_list_shrinks_the_panel_instead_of_leaving_an_empty_well() { + let short = font_picker_layout(1920, 1080, 3); + let long = font_picker_layout(1920, 1080, 40); + + assert_eq!(short.visible_rows, 3); + assert!(short.panel_height < long.panel_height); + } + + #[test] + fn a_narrow_surface_still_produces_a_usable_panel() { + let layout = font_picker_layout(300, 400, 40); + + assert!(layout.panel_width >= MIN_PANEL_WIDTH); + assert!(layout.panel_x >= 0.0); + assert!(layout.panel_y >= 0.0); + } + + #[test] + fn a_short_surface_shows_fewer_rows_rather_than_a_panel_taller_than_it_is() { + // Twelve rows is a 592px panel. On a 400px-tall output that hangs off + // the bottom, and the rows past the edge are ones the arrow keys can + // still move into. + let layout = font_picker_layout(300, 400, 40); + + assert!( + layout.visible_rows < FONT_PICKER_MAX_VISIBLE, + "got {} rows on a 400px surface", + layout.visible_rows + ); + assert!( + layout.panel_y + layout.panel_height <= 400.0, + "panel runs to {}, past the 400px surface", + layout.panel_y + layout.panel_height + ); + } + + #[test] + fn a_tall_surface_is_still_capped_at_the_visible_maximum() { + let layout = font_picker_layout(1920, 2160, 400); + + assert_eq!(layout.visible_rows, FONT_PICKER_MAX_VISIBLE); + } + + #[test] + fn a_search_that_matches_nothing_still_reserves_a_row_of_list() { + // The "no font matches that" note is drawn in the list. With no height + // reserved it lands on the caption. + let layout = font_picker_layout(1920, 1080, 0); + + assert_eq!(layout.visible_rows, 0); + assert!(layout.list_height >= layout.row_height); + assert!( + layout.list_y + layout.list_height <= layout.caption_y, + "the note's row runs into the caption baseline" + ); + } + + #[test] + fn rows_start_at_the_scroll_offset_and_stop_at_the_window() { + let layout = font_picker_layout(1920, 1080, 40); + let families = families(40); + + let rows = font_picker_rows(layout, &families, 5, 7, "Family 9"); + + assert_eq!(rows.len(), FONT_PICKER_MAX_VISIBLE); + assert_eq!(rows[0].index, 5); + assert!(rows.iter().find(|row| row.index == 7).unwrap().selected); + assert!(rows.iter().find(|row| row.index == 9).unwrap().current); + } + + #[test] + fn a_row_hit_test_agrees_with_where_the_rows_were_drawn() { + let layout = font_picker_layout(1920, 1080, 40); + let families = families(40); + let rows = font_picker_rows(layout, &families, 4, 4, ""); + + for row in &rows { + let hit = font_picker_row_at( + layout, + &families, + 4, + row.x + row.width / 2.0, + row.y + row.height / 2.0, + ); + assert_eq!(hit, Some(row.index)); + } + } + + #[test] + fn points_outside_the_list_hit_nothing() { + let layout = font_picker_layout(1920, 1080, 40); + let families = families(40); + + assert_eq!( + font_picker_row_at( + layout, + &families, + 0, + layout.list_x - 5.0, + layout.list_y + 5.0 + ), + None + ); + assert_eq!( + font_picker_row_at( + layout, + &families, + 0, + layout.list_x + 5.0, + layout.list_y - 5.0 + ), + None + ); + assert_eq!( + font_picker_row_at( + layout, + &families, + 0, + layout.list_x + 5.0, + layout.list_y + layout.row_height * 100.0 + ), + None + ); + } + + #[test] + fn the_last_page_of_a_short_list_does_not_invent_rows() { + let layout = font_picker_layout(1920, 1080, 5); + let families = families(5); + + let rows = font_picker_rows(layout, &families, 3, 3, ""); + + assert_eq!(rows.len(), 2, "only two rows remain past index 3"); + // The second of those two rows is real and answers. + assert_eq!( + font_picker_row_at( + layout, + &families, + 3, + layout.list_x + 5.0, + layout.list_y + layout.row_height * 1.5 + ), + Some(4) + ); + // Past it there is nothing, even though the window has room drawn for it. + assert_eq!( + font_picker_row_at( + layout, + &families, + 3, + layout.list_x + 5.0, + layout.list_y + layout.row_height * 2.5 + ), + None + ); + } +} diff --git a/src/input/state/core/font_picker/mod.rs b/src/input/state/core/font_picker/mod.rs new file mode 100644 index 00000000..f3a8250a --- /dev/null +++ b/src/input/state/core/font_picker/mod.rs @@ -0,0 +1,345 @@ +//! Choosing a text font by looking at it. +//! +//! `Shift+T` steps through the short configured list. This is the other half: +//! a modal over every family the system has, so the short list is something the +//! user picked rather than something they inherited. +//! +//! Two design decisions worth stating, because both cost something: +//! +//! **Every row renders in its own font.** That is the whole point of a font +//! picker — nobody chooses a typeface by reading its name. Only the visible +//! rows are laid out, so the cost is bounded by the window rather than by the +//! 269 families a normal desktop has. +//! +//! **The list is enumerated once, off the input thread.** The Wayland backend +//! prewarms the process-wide catalog after its first committed frame. If the +//! picker wins that race, it opens immediately with a loading row and fills in +//! when the worker wakes the event loop. + +mod input; +mod layout; + +pub use layout::{FontPickerLayout, FontPickerRow, font_picker_layout, font_picker_rows}; + +use super::InputState; +use crate::draw::{ + FontDescriptor, families_match, system_font_catalog_is_ready, try_monospace_font_families, + try_system_font_families, +}; +use crate::input::state::core::command_palette::fuzzy_score; + +/// The picker's memoized result list, keyed by what produced it. +pub type FontPickerResults = Option<((String, FontPickerFilter), Vec)>; + +/// How many families the picker keeps as recently used. +const RECENT_LIMIT: usize = 5; + +/// Which slice of the system list the picker is showing. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum FontPickerFilter { + /// Every installed family. + #[default] + All, + /// Only the families the font system reports as monospace. + Monospace, +} + +impl FontPickerFilter { + pub fn label(self) -> &'static str { + match self { + Self::All => "All fonts", + Self::Monospace => "Monospace", + } + } + + pub fn next(self) -> Self { + match self { + Self::All => Self::Monospace, + Self::Monospace => Self::All, + } + } +} + +/// What the picker will change when a row is chosen. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FontPickerTarget { + /// Nothing is selected: the choice sets what the next label uses. + ToolDefault, + /// Text is selected: the choice restyles it. + Selection, +} + +impl FontPickerTarget { + pub fn label(self) -> &'static str { + match self { + Self::ToolDefault => "Sets the font for new text", + Self::Selection => "Restyles the selected text", + } + } +} + +impl InputState { + pub(crate) fn is_font_picker_open(&self) -> bool { + self.font_picker_open + } + + pub fn font_picker_is_loading(&self) -> bool { + self.font_picker_loading + } + + pub fn font_picker_load_failed(&self) -> bool { + self.font_picker_load_failed + } + + pub fn font_picker_query(&self) -> &str { + &self.font_picker_query + } + + pub fn font_picker_filter(&self) -> FontPickerFilter { + self.font_picker_filter + } + + pub fn font_picker_selected(&self) -> usize { + self.font_picker_selected + } + + pub fn font_picker_scroll(&self) -> usize { + self.font_picker_scroll + } + + /// What choosing a row would change, decided when the picker opens so the + /// caption cannot disagree with what Enter does. + pub fn font_picker_target(&self) -> FontPickerTarget { + self.font_picker_target + } + + /// Open the picker over the system font list without enumerating fonts in + /// input dispatch. + pub(crate) fn open_font_picker(&mut self) { + self.open_font_picker_with_catalog_ready(system_font_catalog_is_ready()); + } + + fn open_font_picker_with_catalog_ready(&mut self, catalog_ready: bool) { + self.close_modals_for_open(super::modal::ModalSurface::FontPicker); + self.font_picker_open = true; + self.font_picker_loading = !catalog_ready; + self.font_picker_load_failed = false; + self.font_picker_query.clear(); + self.font_picker_filter = FontPickerFilter::All; + self.font_picker_target = if self.selection_has_text() { + FontPickerTarget::Selection + } else { + FontPickerTarget::ToolDefault + }; + self.font_picker_results.replace(None); + // Start on the font in use, so the picker opens showing where you are. + // + // Centred in the window this surface actually has room for, not in the + // twelve-row ceiling: half of twelve is below the bottom of a six-row + // panel, which would open the picker scrolled past the very row it + // means to be showing. + self.position_font_picker_on_current_family(); + // Reopening on top of an open picker must not leave the previous key + // still repeating into the fresh list. + self.clear_font_picker_repeat(); + // Record the panel this open is about to paint. Partial repaints damage + // the panel they are leaving as well as the one they are arriving at, + // and the first query is usually the one that shrinks the panel most — + // without a starting point, the taller panel's lower half is never + // repainted and stays on screen under the shorter one. + self.font_picker_last_panel = self.font_picker_panel_bounds(); + self.dirty_tracker.mark_full(); + self.needs_redraw = true; + } + + /// Fill a picker that opened while the worker was enumerating fonts. + /// + /// Returns whether the open surface changed and needs repainting. + pub(crate) fn finish_font_picker_catalog_load(&mut self) -> bool { + if !self.font_picker_open || !self.font_picker_loading { + return false; + } + debug_assert!(system_font_catalog_is_ready()); + self.font_picker_loading = false; + self.font_picker_load_failed = false; + self.font_picker_results.replace(None); + self.position_font_picker_on_current_family(); + self.mark_font_picker_dirty(); + true + } + + /// Replace the loading row with a stable error if the background worker + /// could not produce a catalog. Reopening gives the backend one fresh try. + pub(crate) fn fail_font_picker_catalog_load(&mut self) -> bool { + if !self.font_picker_open || !self.font_picker_loading { + return false; + } + self.font_picker_loading = false; + self.font_picker_load_failed = true; + self.font_picker_results.replace(None); + self.mark_font_picker_dirty(); + true + } + + fn position_font_picker_on_current_family(&mut self) { + if self.font_picker_loading { + self.font_picker_selected = 0; + self.font_picker_scroll = 0; + return; + } + let current = self.font_picker_current_family(); + let families = self.font_picker_families(); + let window = self.font_picker_visible_rows(families.len()); + self.font_picker_selected = families + .iter() + .position(|family| families_match(family, ¤t)) + .unwrap_or(0); + self.font_picker_scroll = self + .font_picker_selected + .saturating_sub(window / 2) + .min(families.len().saturating_sub(window)); + } + + pub(crate) fn close_font_picker(&mut self) { + if !self.font_picker_open { + return; + } + self.font_picker_open = false; + self.font_picker_loading = false; + self.font_picker_load_failed = false; + self.font_picker_query.clear(); + self.font_picker_results.replace(None); + self.clear_font_picker_repeat(); + self.font_picker_last_panel = None; + // The scrim covered the whole surface, so the whole surface comes back. + self.dirty_tracker.mark_full(); + self.needs_redraw = true; + } + + /// The family the picker considers current: the selected text's, or the + /// tool's when nothing is selected. + pub(crate) fn font_picker_current_family(&self) -> String { + if self.font_picker_target == FontPickerTarget::Selection + && let Some(family) = self.first_selected_text_family() + { + return family; + } + self.font_descriptor.family.clone() + } + + /// Families most recently chosen here, most recent first. + pub fn font_picker_recents(&self) -> &[String] { + &self.font_picker_recents + } + + /// The filtered, ranked list the picker is showing. + /// + /// Memoized on the query and filter: the renderer asks for it more than + /// once per frame, and scoring walks every installed family. + pub fn font_picker_families(&self) -> Vec { + let key = (self.font_picker_query.clone(), self.font_picker_filter); + if let Some((cached_key, cached)) = self.font_picker_results.borrow().as_ref() + && *cached_key == key + { + return cached.clone(); + } + let ranked = self.rank_font_families(); + self.font_picker_results + .replace(Some((key, ranked.clone()))); + ranked + } + + fn rank_font_families(&self) -> Vec { + if self.font_picker_loading || self.font_picker_load_failed { + return Vec::new(); + } + let source: &[String] = match self.font_picker_filter { + FontPickerFilter::All => try_system_font_families(), + FontPickerFilter::Monospace => try_monospace_font_families(), + } + .unwrap_or(&[]); + if source.is_empty() { + return Vec::new(); + }; + let query = self.font_picker_query.trim().to_lowercase(); + + if query.is_empty() { + // No query: recents first, then the rest in the system's order, so + // the list is stable and the fonts you use are within reach. + let mut ordered: Vec = self + .font_picker_recents + .iter() + .filter(|family| source.iter().any(|name| families_match(name, family))) + .cloned() + .collect(); + let rest: Vec = source + .iter() + .filter(|name| !ordered.iter().any(|kept| families_match(kept, name))) + .cloned() + .collect(); + ordered.extend(rest); + return ordered; + } + + let mut scored: Vec<(i32, &String)> = source + .iter() + .filter_map(|family| { + let score = fuzzy_score(&query, family); + (score > 0).then_some((score, family)) + }) + .collect(); + // Score descending, then the system's own order, so equal matches do not + // reshuffle as the query grows. + scored.sort_by_key(|(score, _)| std::cmp::Reverse(*score)); + scored + .into_iter() + .map(|(_, family)| family.clone()) + .collect() + } + + /// Apply the highlighted family and close. + pub(crate) fn commit_font_picker(&mut self) -> bool { + let families = self.font_picker_families(); + let Some(family) = families.get(self.font_picker_selected).cloned() else { + self.close_font_picker(); + return false; + }; + let applied = self.apply_font_family(&family); + if applied { + self.remember_font_picker_choice(&family); + log::info!("Font picker applied {family}"); + self.push_toast( + super::ToastPriority::Info, + FONT_PICKER_TOAST_SOURCE, + super::Toast::info(format!("Font: {family}")), + ); + } + self.close_font_picker(); + applied + } + + /// Set `family` on the selection, or on the tool when nothing is selected. + fn apply_font_family(&mut self, family: &str) -> bool { + if self.font_picker_target == FontPickerTarget::Selection && self.selection_has_text() { + return self.apply_family_to_selected_text(family); + } + + self.set_font_descriptor(FontDescriptor::new( + family.to_string(), + self.font_descriptor.weight.clone(), + self.font_descriptor.style.clone(), + )) + } + + fn remember_font_picker_choice(&mut self, family: &str) { + self.font_picker_recents + .retain(|existing| !families_match(existing, family)); + self.font_picker_recents.insert(0, family.to_string()); + self.font_picker_recents.truncate(RECENT_LIMIT); + } +} + +const FONT_PICKER_TOAST_SOURCE: &str = "font-picker"; + +#[cfg(test)] +mod tests; diff --git a/src/input/state/core/font_picker/tests.rs b/src/input/state/core/font_picker/tests.rs new file mode 100644 index 00000000..e5645845 --- /dev/null +++ b/src/input/state/core/font_picker/tests.rs @@ -0,0 +1,691 @@ +use super::layout::FONT_PICKER_MAX_VISIBLE; +use super::*; +use crate::draw::{Color, Shape, system_font_families}; +use crate::input::events::Key; +use crate::input::state::test_support::make_test_input_state; + +fn installed_family() -> String { + system_font_families() + .first() + .expect("at least one family") + .clone() +} + +/// Open the production picker with its process-wide catalog ready. +/// +/// The Wayland runtime normally prewarms this after its first committed frame. +/// Unit tests for filtering, selection, and scrolling arrange that same ready +/// state explicitly so they cannot pass or fail according to which unrelated +/// font test happened to initialize the global cache first. +fn open_ready_font_picker(state: &mut InputState) { + crate::draw::prewarm_system_font_catalog(); + state.open_font_picker(); + assert!(!state.font_picker_is_loading()); +} + +/// One text mutation that cannot fuzzy-match any installed family: it has more +/// characters than the longest candidate, so even subsequence matching fails. +fn impossible_family_query() -> String { + let longest = system_font_families() + .iter() + .map(|family| family.chars().count()) + .max() + .unwrap_or(0); + "x".repeat(longest + 1) +} + +fn text_shape(family: &str) -> Shape { + Shape::Text { + x: 10, + y: 10, + text: "hello".to_string(), + color: Color::new(1.0, 1.0, 1.0, 1.0), + size: 24.0, + font_descriptor: FontDescriptor::new( + family.to_string(), + "normal".to_string(), + "normal".to_string(), + ), + background_enabled: false, + wrap_width: None, + } +} + +#[test] +fn first_open_can_show_loading_without_enumerating_in_input_dispatch() { + const CHILD_ENV: &str = "WAYSCRIBER_COLD_FONT_PICKER_TEST_CHILD"; + const TEST_NAME: &str = "input::state::core::font_picker::tests::first_open_can_show_loading_without_enumerating_in_input_dispatch"; + + // Font catalog storage is process-global and other font tests can warm it + // in parallel. Run the production opener in a fresh test process so the + // first-open condition is deterministic and a synchronous call added + // before the loading branch cannot hide behind test ordering. + if std::env::var_os(CHILD_ENV).is_none() { + let status = std::process::Command::new(std::env::current_exe().expect("test binary")) + .arg(TEST_NAME) + .arg("--exact") + .arg("--test-threads=1") + .env(CHILD_ENV, "1") + .status() + .expect("run isolated cold font-picker test"); + assert!(status.success(), "isolated cold font-picker test failed"); + return; + } + + assert!(!crate::draw::system_font_catalog_is_ready()); + let mut state = make_test_input_state(); + + state.open_font_picker(); + + assert!(state.is_font_picker_open()); + assert!(state.font_picker_is_loading()); + assert!( + !crate::draw::system_font_catalog_is_ready(), + "opening input dispatch must only probe the cache, never enumerate it" + ); + assert!(state.font_picker_families().is_empty()); + assert_eq!(state.font_picker_selected(), 0); + assert_eq!(state.font_picker_scroll(), 0); +} + +#[test] +fn catalog_completion_populates_a_picker_that_opened_in_loading_state() { + let mut state = make_test_input_state(); + state.open_font_picker_with_catalog_ready(false); + let _ = state.take_dirty_regions(); + + crate::draw::prewarm_system_font_catalog(); + assert!(state.finish_font_picker_catalog_load()); + + assert!(!state.font_picker_is_loading()); + assert!(!state.font_picker_load_failed()); + assert!(!state.font_picker_families().is_empty()); + assert!( + !state.take_dirty_regions().is_empty(), + "catalog completion must repaint the loading surface" + ); +} + +#[test] +fn catalog_worker_failure_is_visible_and_reopening_allows_a_retry() { + let mut state = make_test_input_state(); + state.open_font_picker_with_catalog_ready(false); + + assert!(state.fail_font_picker_catalog_load()); + assert!(!state.font_picker_is_loading()); + assert!(state.font_picker_load_failed()); + assert!(state.font_picker_families().is_empty()); + + state.close_font_picker(); + state.open_font_picker_with_catalog_ready(false); + assert!(state.font_picker_is_loading()); + assert!(!state.font_picker_load_failed()); +} + +#[test] +fn opening_lists_every_installed_family_and_closing_forgets_the_query() { + let mut state = make_test_input_state(); + + open_ready_font_picker(&mut state); + assert!(state.is_font_picker_open()); + assert_eq!( + state.font_picker_families().len(), + system_font_families().len() + ); + + state.handle_font_picker_key(Key::Char('x'), Some("x")); + assert_eq!(state.font_picker_query(), "x"); + + state.close_font_picker(); + assert!(!state.is_font_picker_open()); + assert!(state.font_picker_query().is_empty()); +} + +#[test] +fn the_picker_opens_on_the_font_already_in_use() { + let mut state = make_test_input_state(); + let target = system_font_families() + .get(3) + .cloned() + .unwrap_or_else(installed_family); + state.set_font_descriptor(FontDescriptor::new( + target.clone(), + "normal".to_string(), + "normal".to_string(), + )); + + open_ready_font_picker(&mut state); + + let families = state.font_picker_families(); + assert_eq!(families[state.font_picker_selected()], target); +} + +#[test] +fn typing_narrows_the_list_and_backspace_widens_it_again() { + let mut state = make_test_input_state(); + open_ready_font_picker(&mut state); + let all = state.font_picker_families().len(); + + for ch in "zzzz".chars() { + state.handle_font_picker_key(Key::Char(ch), Some(&ch.to_string())); + } + assert!(state.font_picker_families().len() < all); + + for _ in 0..4 { + state.handle_font_picker_key(Key::Backspace, None); + } + assert_eq!(state.font_picker_families().len(), all); +} + +#[test] +fn a_query_that_matches_nothing_leaves_an_empty_list_rather_than_the_whole_one() { + let mut state = make_test_input_state(); + open_ready_font_picker(&mut state); + + for ch in "qqzzxxjj".chars() { + state.handle_font_picker_key(Key::Char(ch), Some(&ch.to_string())); + } + + assert!(state.font_picker_families().is_empty()); + // Committing an empty list must close cleanly rather than index into it. + assert!(!state.commit_font_picker()); + assert!(!state.is_font_picker_open()); +} + +#[test] +fn arrow_keys_clamp_at_both_ends_instead_of_wrapping() { + let mut state = make_test_input_state(); + open_ready_font_picker(&mut state); + let count = state.font_picker_families().len(); + + state.set_font_picker_selection(0); + state.handle_font_picker_key(Key::Up, None); + assert_eq!( + state.font_picker_selected(), + 0, + "wrapping to the bottom of a 269-item list is never what Up meant" + ); + + state.handle_font_picker_key(Key::End, None); + assert_eq!(state.font_picker_selected(), count - 1); + state.handle_font_picker_key(Key::Down, None); + assert_eq!(state.font_picker_selected(), count - 1); +} + +#[test] +fn the_scroll_window_follows_the_highlight_by_the_least_it_can() { + let mut state = make_test_input_state(); + state.update_screen_dimensions(1920, 1080); + open_ready_font_picker(&mut state); + let window = state.font_picker_visible_rows(state.font_picker_families().len()); + assert_eq!( + window, FONT_PICKER_MAX_VISIBLE, + "a 1080p output has room for the full window" + ); + + state.set_font_picker_selection(0); + assert_eq!(state.font_picker_scroll(), 0); + + state.set_font_picker_selection(window); + assert_eq!( + state.font_picker_scroll(), + 1, + "stepping one past the window scrolls one row, not a page" + ); + + state.set_font_picker_selection(0); + assert_eq!(state.font_picker_scroll(), 0); +} + +#[test] +fn a_short_output_still_opens_with_the_font_in_use_on_screen() { + // The picker opens centred on the current font. Centring on half the + // twelve-row ceiling puts the highlight below a six-row panel, so the one + // row the picker exists to show is the one row you cannot see. + let mut state = make_test_input_state(); + state.update_screen_dimensions(600, 400); + let families = system_font_families(); + if families.len() < 25 { + return; + } + let target = families[20].clone(); + state.set_font_descriptor(FontDescriptor::new( + target.clone(), + "normal".to_string(), + "normal".to_string(), + )); + + open_ready_font_picker(&mut state); + let window = state.font_picker_visible_rows(state.font_picker_families().len()); + + assert_eq!(state.font_picker_selected(), 20); + let scroll = state.font_picker_scroll(); + assert!( + (scroll..scroll + window).contains(&state.font_picker_selected()), + "the current font must be on screen: rows {scroll}..{} show {} of {window}", + scroll + window, + state.font_picker_selected() + ); +} + +#[test] +fn a_short_output_scrolls_by_the_rows_it_actually_shows() { + // The panel shrinks to fit the surface, so the window the scroll math uses + // has to shrink with it. Scrolling by the twelve-row ceiling on an output + // that draws six would leave the highlight below the panel's bottom edge — + // on a row nobody can see. + let mut state = make_test_input_state(); + state.update_screen_dimensions(600, 400); + open_ready_font_picker(&mut state); + let window = state.font_picker_visible_rows(state.font_picker_families().len()); + assert!( + window < FONT_PICKER_MAX_VISIBLE, + "a 400px-tall output cannot show the full window, got {window}" + ); + + state.set_font_picker_selection(0); + assert_eq!(state.font_picker_scroll(), 0); + + state.set_font_picker_selection(window); + assert_eq!( + state.font_picker_scroll(), + 1, + "the row one past the visible window must scroll into view" + ); + assert!( + state.font_picker_selected() < state.font_picker_scroll() + window, + "the highlight must stay inside the rows the panel draws" + ); +} + +#[test] +fn tab_switches_to_monospace_and_back() { + let mut state = make_test_input_state(); + open_ready_font_picker(&mut state); + let all = state.font_picker_families().len(); + + state.handle_font_picker_key(Key::Tab, None); + assert_eq!(state.font_picker_filter(), FontPickerFilter::Monospace); + assert!(state.font_picker_families().len() <= all); + + state.handle_font_picker_key(Key::Tab, None); + assert_eq!(state.font_picker_filter(), FontPickerFilter::All); + assert_eq!(state.font_picker_families().len(), all); +} + +#[test] +fn choosing_a_font_with_nothing_selected_sets_what_the_next_label_uses() { + let mut state = make_test_input_state(); + open_ready_font_picker(&mut state); + state.set_font_picker_selection(2); + let chosen = state.font_picker_families()[2].clone(); + + assert!(state.commit_font_picker()); + + assert_eq!(state.font_descriptor.family, chosen); + assert!(!state.is_font_picker_open()); +} + +#[test] +fn choosing_a_font_with_text_selected_restyles_it_and_leaves_the_tool_alone() { + let mut state = make_test_input_state(); + let tool_font = state.font_descriptor.family.clone(); + let id = state + .boards + .active_frame_mut() + .add_shape(text_shape("Sans")); + state.set_selection(vec![id]); + + open_ready_font_picker(&mut state); + assert_eq!(state.font_picker_target(), FontPickerTarget::Selection); + state.set_font_picker_selection(2); + let chosen = state.font_picker_families()[2].clone(); + assert!(state.commit_font_picker()); + + let frame = state.boards.active_frame(); + let Some(Shape::Text { + font_descriptor, .. + }) = frame.shape(id).map(|drawn| &drawn.shape) + else { + panic!("the text shape survives"); + }; + assert_eq!(font_descriptor.family, chosen); + assert_eq!( + state.font_descriptor.family, tool_font, + "restyling a selection must not also change what the next label uses" + ); +} + +#[test] +fn chosen_fonts_come_back_to_the_top_of_an_unfiltered_list() { + let mut state = make_test_input_state(); + open_ready_font_picker(&mut state); + state.set_font_picker_selection(4); + let chosen = state.font_picker_families()[4].clone(); + state.commit_font_picker(); + + open_ready_font_picker(&mut state); + + assert_eq!(state.font_picker_recents().first(), Some(&chosen)); + assert_eq!( + state.font_picker_families().first(), + Some(&chosen), + "a font you just used should be within reach next time" + ); +} + +#[test] +fn recents_keep_the_most_recent_first_without_repeats() { + let mut state = make_test_input_state(); + let families = system_font_families(); + let (first, second) = (families[0].clone(), families[1].clone()); + + for family in [&first, &second, &first] { + open_ready_font_picker(&mut state); + let index = state + .font_picker_families() + .iter() + .position(|name| name == family) + .expect("family is listed"); + state.set_font_picker_selection(index); + state.commit_font_picker(); + } + + assert_eq!(state.font_picker_recents(), [first, second]); +} + +#[test] +fn escape_closes_without_changing_anything() { + let mut state = make_test_input_state(); + let before = state.font_descriptor.family.clone(); + open_ready_font_picker(&mut state); + state.set_font_picker_selection(3); + + state.handle_font_picker_key(Key::Escape, None); + + assert!(!state.is_font_picker_open()); + assert_eq!(state.font_descriptor.family, before); + assert!(state.font_picker_recents().is_empty()); +} + +#[test] +fn stray_keys_are_swallowed_rather_than_reaching_the_canvas_behind_the_modal() { + let mut state = make_test_input_state(); + open_ready_font_picker(&mut state); + + assert!(state.handle_font_picker_key(Key::Delete, None)); + assert!(state.handle_font_picker_key(Key::Ctrl, None)); + assert!(state.is_font_picker_open()); +} + +#[test] +fn a_closed_picker_consumes_nothing() { + let mut state = make_test_input_state(); + + assert!(!state.handle_font_picker_key(Key::Escape, None)); + assert!(!state.font_picker_hover(10.0, 10.0)); + assert!(!state.font_picker_press(10.0, 10.0)); +} + +/// A picker open on a surface big enough for the full twelve-row window. +fn open_picker() -> InputState { + let mut state = make_test_input_state(); + state.update_screen_dimensions(1920, 1080); + open_ready_font_picker(&mut state); + state.set_font_picker_selection(0); + state +} + +#[test] +fn a_wheel_tick_moves_the_window_three_rows() { + let mut state = open_picker(); + if state.font_picker_families().len() < 40 { + return; + } + + state.font_picker_wheel_scroll(1); + assert_eq!(state.font_picker_scroll(), 3); + state.font_picker_wheel_scroll(1); + assert_eq!(state.font_picker_scroll(), 6); + state.font_picker_wheel_scroll(-1); + assert_eq!(state.font_picker_scroll(), 3); +} + +#[test] +fn the_wheel_stops_at_both_ends_of_the_list() { + let mut state = open_picker(); + let count = state.font_picker_families().len(); + let window = state.font_picker_visible_rows(count); + if count < 40 { + return; + } + + for _ in 0..count { + state.font_picker_wheel_scroll(1); + } + assert_eq!( + state.font_picker_scroll(), + count - window, + "the last page is the end; there is nothing past it to show" + ); + + for _ in 0..count { + state.font_picker_wheel_scroll(-1); + } + assert_eq!(state.font_picker_scroll(), 0); +} + +#[test] +fn scrolling_carries_the_highlight_only_when_it_would_be_left_behind() { + let mut state = open_picker(); + let window = state.font_picker_visible_rows(state.font_picker_families().len()); + if state.font_picker_families().len() < 40 { + return; + } + + // Highlight a row further down the window; one tick still leaves it visible. + state.set_font_picker_selection(5); + state.font_picker_wheel_scroll(1); + assert_eq!( + state.font_picker_selected(), + 5, + "a row still on screen keeps the highlight, so Enter applies what is lit" + ); + + // Keep going until the window has moved past it. + state.font_picker_wheel_scroll(1); + state.font_picker_wheel_scroll(1); + let scroll = state.font_picker_scroll(); + assert!(state.font_picker_selected() >= scroll); + assert!(state.font_picker_selected() < scroll + window); +} + +#[test] +fn a_held_arrow_repeats_after_a_delay_and_stops_on_release() { + use std::time::{Duration, Instant}; + + let mut state = open_picker(); + if state.font_picker_families().len() < 40 { + return; + } + let now = Instant::now(); + + state.handle_font_picker_key(Key::Down, None); + assert_eq!( + state.font_picker_selected(), + 1, + "the press itself moves one" + ); + assert!( + state.font_picker_repeat_timeout(now).is_some(), + "holding a navigation key has to wake the loop, or it never moves again" + ); + assert!( + !state.tick_font_picker_repeat(now), + "nothing repeats before the initial delay" + ); + + assert!(state.tick_font_picker_repeat(now + Duration::from_millis(300))); + assert_eq!(state.font_picker_selected(), 2); + + state.on_key_release(Key::Down); + assert_eq!(state.font_picker_repeat_timeout(now), None); + assert!(!state.tick_font_picker_repeat(now + Duration::from_secs(5))); + assert_eq!(state.font_picker_selected(), 2); +} + +#[test] +fn a_long_hold_repeats_faster_than_a_short_one() { + use std::time::{Duration, Instant}; + + // The list runs to hundreds of families. At the command palette's flat rate + // crossing it takes about fifteen seconds, which is long enough that people + // give up and reach for the mouse. + let mut state = open_picker(); + if state.font_picker_families().len() < 60 { + return; + } + let start = Instant::now(); + state.handle_font_picker_key(Key::Down, None); + + let steps_in = |state: &mut InputState, from: Duration, window: Duration| { + let deadline = start + from + window; + let mut at = start + from; + let mut steps = 0; + while at <= deadline { + if state.tick_font_picker_repeat(at) { + steps += 1; + } + at += Duration::from_millis(5); + } + steps + }; + + let early = steps_in( + &mut state, + Duration::from_millis(300), + Duration::from_millis(500), + ); + let late = steps_in( + &mut state, + Duration::from_millis(2000), + Duration::from_millis(500), + ); + + assert!( + late > early, + "the same half-second of holding must travel further later: {early} then {late}" + ); +} + +#[test] +fn moving_the_highlight_repaints_the_panel_rather_than_the_screen() { + // A held arrow ticks up to fifty times a second. Marking the whole surface + // each time is the entire canvas re-rendered per row. + let mut state = open_picker(); + let _ = state.take_dirty_regions(); + + state.handle_font_picker_key(Key::Down, None); + let regions = state.take_dirty_regions(); + + assert!(!regions.is_empty(), "the move has to repaint something"); + assert!( + regions + .iter() + .all(|rect| rect.width < 1920 || rect.height < 1080), + "a row move must not repaint the whole surface, got {regions:?}" + ); +} + +#[test] +fn the_first_query_that_shrinks_the_list_repaints_the_panel_it_is_leaving() { + // Opening on the installed catalog draws a tall panel; the first query can + // cut it to no rows. Partial repaints clip to their damage, so unless the + // taller panel is damaged too its lower half stays on screen underneath. + let mut state = make_test_input_state(); + state.update_screen_dimensions(1920, 1080); + open_ready_font_picker(&mut state); + let tall = state + .font_picker_panel_bounds() + .expect("an open picker has a panel"); + let _ = state.take_dirty_regions(); + + // A query no family can match shrinks the panel to its smallest in one + // mutation. Any earlier mutation would record the tall panel as a side + // effect and hide the defect this test protects. + let query = impossible_family_query(); + state.handle_font_picker_key(Key::Char('x'), Some(&query)); + assert!(state.font_picker_families().is_empty()); + let short = state + .font_picker_panel_bounds() + .expect("an open picker has a panel"); + assert!( + short.height < tall.height, + "this fixture needs the panel to actually shrink: {tall:?} then {short:?}" + ); + + let regions = state.take_dirty_regions(); + let bottom_y = tall.y + tall.height - 2; + let mid_x = tall.x + tall.width / 2; + assert!( + regions.iter().any(|rect| rect.contains(mid_x, bottom_y)), + "the tall panel's bottom edge must be repainted, got {regions:?}" + ); +} + +#[test] +fn the_first_narrowing_query_after_resize_repaints_the_resized_panel() { + let mut state = make_test_input_state(); + state.update_screen_dimensions(1920, 1080); + open_ready_font_picker(&mut state); + let opening = state + .font_picker_panel_bounds() + .expect("an open picker has a panel"); + + // The backend fully repaints a configured resize. Move the still-tall + // panel far enough that damage at its old position cannot cover it. + state.update_screen_dimensions(800, 900); + let resized = state + .font_picker_panel_bounds() + .expect("the resized picker has a panel"); + let bottom_y = resized.y + resized.height - 2; + let mid_x = resized.x + resized.width / 2; + assert_ne!(opening, resized, "the fixture must move the panel"); + assert!( + !opening.contains(mid_x, bottom_y), + "the old panel must not accidentally cover the probe point" + ); + let _ = state.take_dirty_regions(); + + let query = impossible_family_query(); + state.handle_font_picker_key(Key::Char('x'), Some(&query)); + assert!(state.font_picker_families().is_empty()); + + let regions = state.take_dirty_regions(); + assert!( + regions.iter().any(|rect| rect.contains(mid_x, bottom_y)), + "the resized tall panel's bottom edge must be repainted, got {regions:?}" + ); +} + +#[test] +fn reopening_the_picker_does_not_leave_the_old_key_repeating() { + use std::time::{Duration, Instant}; + + let mut state = make_test_input_state(); + state.update_screen_dimensions(1920, 1080); + open_ready_font_picker(&mut state); + state.handle_font_picker_key(Key::Down, None); + assert!(state.font_picker_repeat_timeout(Instant::now()).is_some()); + + open_ready_font_picker(&mut state); + + assert_eq!( + state.font_picker_repeat_timeout(Instant::now()), + None, + "a fresh picker must not inherit a key the last one was repeating" + ); + let selected = state.font_picker_selected(); + assert!(!state.tick_font_picker_repeat(Instant::now() + Duration::from_secs(2))); + assert_eq!(state.font_picker_selected(), selected); +} diff --git a/src/input/state/core/mod.rs b/src/input/state/core/mod.rs index 4834923b..398a9517 100644 --- a/src/input/state/core/mod.rs +++ b/src/input/state/core/mod.rs @@ -7,6 +7,7 @@ mod command_palette; mod dirty; mod eyedropper; mod font_cycle; +pub(crate) mod font_picker; mod highlight_controls; mod history; mod ime; @@ -24,6 +25,7 @@ mod session; mod session_preflight; mod session_preflight_exact; mod status_hud; +mod text_font; mod tool_controls; mod tour; pub(crate) mod utility; @@ -69,10 +71,14 @@ pub use command_palette::{ COMMAND_PALETTE_MAX_VISIBLE, CommandPaletteCursorHint, CommandPaletteListRow, }; pub use eyedropper::{EyedropperCaptureSource, EyedropperUiState}; +#[allow(unused_imports)] +pub use font_picker::{ + FontPickerFilter, FontPickerLayout, FontPickerResults, FontPickerRow, FontPickerTarget, + font_picker_layout, font_picker_rows, +}; #[cfg(test)] pub(crate) use ime::build_text_input_preview; pub use ime::{ImeCompositionState, ImePreedit}; -#[allow(unused_imports)] pub use menus::{ ContextMenuCursorHint, ContextMenuEntry, ContextMenuKind, ContextMenuState, MenuCommand, }; diff --git a/src/input/state/core/modal.rs b/src/input/state/core/modal.rs index 771407f8..b894450c 100644 --- a/src/input/state/core/modal.rs +++ b/src/input/state/core/modal.rs @@ -21,19 +21,21 @@ pub(crate) enum ModalSurface { RadialMenu, PrecisionEntry, ColorPicker, + FontPicker, ContextMenu, BoardPicker, PropertiesPanel, } impl ModalSurface { - pub(crate) const ALL: [ModalSurface; 9] = [ + pub(crate) const ALL: [ModalSurface; 10] = [ ModalSurface::Tour, ModalSurface::CommandPalette, ModalSurface::HelpOverlay, ModalSurface::RadialMenu, ModalSurface::PrecisionEntry, ModalSurface::ColorPicker, + ModalSurface::FontPicker, ModalSurface::ContextMenu, ModalSurface::BoardPicker, ModalSurface::PropertiesPanel, @@ -63,9 +65,29 @@ impl ModalSurface { fn blocks_canvas_key_repeat(self) -> bool { matches!( self, - ModalSurface::CommandPalette | ModalSurface::ColorPicker | ModalSurface::PrecisionEntry + ModalSurface::CommandPalette + | ModalSurface::ColorPicker + | ModalSurface::FontPicker + | ModalSurface::PrecisionEntry ) } + + /// Whether a wheel tick belongs to this surface rather than the canvas. + /// + /// The axis handler ends in a fall-through that adjusts stroke thickness + /// (or text size with Shift). Every surface that covers the canvas has to + /// stop the wheel before it gets there, or scrolling over a modal silently + /// edits the tool behind it. That was a per-surface `if` in the handler and + /// three surfaces had been forgotten, so the rule lives here: a surface + /// that covers the canvas owns the wheel, whether or not it has anything to + /// scroll. + /// + /// The properties panel is deliberately out. It docks beside the canvas + /// rather than over it, and the canvas stays drawable underneath — so the + /// wheel still means what it means everywhere else. + fn owns_wheel(self) -> bool { + !matches!(self, ModalSurface::PropertiesPanel) + } } impl InputState { @@ -78,6 +100,7 @@ impl InputState { ModalSurface::RadialMenu => self.is_radial_menu_open(), ModalSurface::PrecisionEntry => self.is_precision_entry_open(), ModalSurface::ColorPicker => self.is_color_picker_popup_open(), + ModalSurface::FontPicker => self.is_font_picker_open(), ModalSurface::ContextMenu => self.is_context_menu_open(), ModalSurface::BoardPicker => self.is_board_picker_open(), ModalSurface::PropertiesPanel => self.is_properties_panel_open(), @@ -121,6 +144,7 @@ impl InputState { self.cancel_precision_entry(); } ModalSurface::ColorPicker => self.close_color_picker_popup(true), + ModalSurface::FontPicker => self.close_font_picker(), ModalSurface::ContextMenu => self.close_context_menu(), ModalSurface::BoardPicker => self.close_board_picker(), ModalSurface::PropertiesPanel => self.close_properties_panel(), @@ -138,6 +162,25 @@ impl InputState { } } + /// Close everything a screen-region modal must not compete with, and + /// cancel any unfinished gesture. Shared by the eyedropper and OCR: both + /// take over pointer input entirely while they are up. + /// + /// Every registered surface, rather than a list kept by hand. The hand list + /// had drifted: the font picker and the precise-entry popup were both + /// missing, so a selector opened over one of them hid it and left it to + /// reappear when the selector closed. Going through the registry also means + /// each surface is dismissed by its own closer — the tour used to be a bare + /// flag clear here, which left the toolbar chrome it hides still hidden. + pub(crate) fn prepare_for_screen_modal(&mut self) { + self.cancel_active_interaction(); + for surface in ModalSurface::ALL { + if self.modal_is_open(surface) { + self.close_modal(surface); + } + } + } + /// True when another interaction captures keyboard input ahead of the /// canvas editor. While one is active the canvas IME must stay disabled: /// composed text bypasses normal key routing and would otherwise leak @@ -165,6 +208,23 @@ impl InputState { .any(|surface| surface.blocks_canvas_key_repeat() && self.modal_is_open(surface)) } + /// Whether an open surface claims the wheel, so an axis frame must not + /// fall through to the canvas tool behind it. + /// + /// Surfaces with something to scroll handle their own frames before this is + /// consulted; this is what swallows the rest. + pub fn modal_owns_wheel(&self) -> bool { + // The eyedropper and the region selectors are not registry surfaces but + // cover the screen just as completely. `is_active` rather than + // `is_engaged`, matching the press/motion/release boundary: while a + // capture is still pending nothing is drawn over the canvas and the + // pointer still belongs to it. + self.screen_modal_is_active() + || ModalSurface::ALL + .into_iter() + .any(|surface| surface.owns_wheel() && self.modal_is_open(surface)) + } + /// Whether either screen-region modal — the eyedropper or the generalized /// OCR/capture/measure region selector — has been asked for, including /// while a capture-backed purpose still waits on its screen image. @@ -191,3 +251,84 @@ impl InputState { self.eyedropper_is_active() || self.region_is_active() } } + +#[cfg(test)] +mod wheel_tests { + use super::ModalSurface; + use crate::input::state::test_support::make_test_input_state; + + #[test] + fn a_surface_covering_the_canvas_claims_the_wheel() { + // Without this the axis handler falls through to the tool behind the + // panel, and scrolling over a modal quietly changes the pen. + let mut state = make_test_input_state(); + assert!(!state.modal_owns_wheel(), "nothing is open"); + + state.open_font_picker(); + assert!(state.modal_owns_wheel(), "font picker"); + state.close_font_picker(); + + state.open_color_picker_popup(); + assert!(state.modal_owns_wheel(), "colour picker"); + state.close_color_picker_popup(false); + + state.open_precision_entry(crate::ui::toolbar::PrecisionEntryTarget::Thickness); + assert!(state.modal_owns_wheel(), "precision entry"); + } + + #[test] + fn a_screen_selector_claims_the_wheel_the_way_it_claims_the_pointer() { + // The eyedropper and the region selectors are not registry surfaces but + // cover the screen just as completely. Press, motion, and release all + // stop at them; the wheel used to carry on to zoom, Spotlight, and + // stroke thickness behind them. + let mut state = make_test_input_state(); + state.activate_eyedropper(None); + + assert!(state.eyedropper_is_active()); + assert!(state.modal_owns_wheel()); + } + + #[test] + fn a_screen_selector_closes_every_registered_surface_it_covers() { + // Not a list kept by hand: one that drifts leaves a surface hidden + // under the selector, to reappear when it closes. + let mut state = make_test_input_state(); + state.open_font_picker(); + assert!(state.is_font_picker_open()); + + state.prepare_for_screen_modal(); + + assert!(!state.is_font_picker_open()); + assert!( + ModalSurface::ALL + .into_iter() + .all(|surface| !state.modal_is_open(surface)), + "a screen selector leaves nothing open behind it" + ); + } + + #[test] + fn the_properties_panel_leaves_the_wheel_to_the_canvas() { + // It docks beside the canvas rather than over it, and the canvas stays + // drawable underneath, so the wheel still means what it means elsewhere. + let mut state = make_test_input_state(); + let id = state + .boards + .active_frame_mut() + .add_shape(crate::draw::Shape::Rect { + x: 0, + y: 0, + w: 10, + h: 10, + fill: false, + color: crate::draw::Color::new(1.0, 1.0, 1.0, 1.0), + thick: 2.0, + }); + state.set_selection(vec![id]); + assert!(state.show_properties_panel()); + + assert!(state.is_properties_panel_open()); + assert!(!state.modal_owns_wheel()); + } +} diff --git a/src/input/state/core/text_font.rs b/src/input/state/core/text_font.rs new file mode 100644 index 00000000..55f6f336 --- /dev/null +++ b/src/input/state/core/text_font.rs @@ -0,0 +1,74 @@ +//! Applying a font family to text, shared by the two ways of choosing one. +//! +//! `Shift+T` steps through a short configured list; the font picker offers every +//! installed family. They differ only in how the family is chosen — what happens +//! to the text afterwards is one behaviour, and lives here so the two cannot +//! drift into disagreeing about which shapes a font reaches or what a partly +//! applied change reports. + +use super::InputState; +use crate::draw::{Shape, families_match}; + +impl InputState { + /// Whether the selection holds anything a font applies to. + pub(in crate::input::state::core) fn selection_has_text(&self) -> bool { + let frame = self.boards.active_frame(); + self.selected_shape_ids().iter().any(|id| { + matches!( + frame.shape(*id).map(|drawn| &drawn.shape), + Some(Shape::Text { .. } | Shape::StickyNote { .. }) + ) + }) + } + + /// The family of the first selected text shape, if any. + /// + /// One shape decides for the whole selection. A mixed selection then + /// converges on a single family rather than each shape stepping away from + /// wherever it happened to be. + pub(in crate::input::state::core) fn first_selected_text_family(&self) -> Option { + let frame = self.boards.active_frame(); + self.selected_shape_ids().iter().find_map(|id| { + match frame.shape(*id).map(|drawn| &drawn.shape) { + Some( + Shape::Text { + font_descriptor, .. + } + | Shape::StickyNote { + font_descriptor, .. + }, + ) => Some(font_descriptor.family.clone()), + _ => None, + } + }) + } + + /// Restyle every selected text shape to `family`, and report the result the + /// way the properties panel does. + /// + /// Returns whether anything changed. A shape already in that family is left + /// alone — matched without case, because fontconfig resolves names that way + /// and rewriting `Sans` as `sans` is not an edit. + pub(in crate::input::state::core) fn apply_family_to_selected_text( + &mut self, + family: &str, + ) -> bool { + let target = family.to_string(); + let result = self.apply_selection_change( + |shape| matches!(shape, Shape::Text { .. } | Shape::StickyNote { .. }), + move |shape| match shape { + Shape::Text { + font_descriptor, .. + } + | Shape::StickyNote { + font_descriptor, .. + } if !families_match(&font_descriptor.family, &target) => { + font_descriptor.family = target.clone(); + true + } + _ => false, + }, + ); + self.report_selection_apply_result(result, "font") + } +} diff --git a/src/input/state/core/utility/interaction.rs b/src/input/state/core/utility/interaction.rs index baa97d83..c6547745 100644 --- a/src/input/state/core/utility/interaction.rs +++ b/src/input/state/core/utility/interaction.rs @@ -175,6 +175,13 @@ impl InputState { pub fn update_screen_dimensions(&mut self, width: u32, height: u32) { self.screen_width = width; self.screen_height = height; + // A surface resize is painted with full damage by the backend. Make + // that newly painted geometry the picker's damage baseline, or the + // next narrowing query clears the panel from before the resize instead + // of the tall panel now visible at its new position. + if self.font_picker_open { + self.font_picker_last_panel = self.font_picker_panel_bounds(); + } } /// Cancels the current text input session and restores any edited shape. diff --git a/src/input/state/interaction/actions.rs b/src/input/state/interaction/actions.rs index ffa0d64f..47c6201c 100644 --- a/src/input/state/interaction/actions.rs +++ b/src/input/state/interaction/actions.rs @@ -39,6 +39,7 @@ pub(crate) fn classify_action(action: Action) -> ActionRoute { | Action::IncreasePenSmoothing | Action::DecreasePenSmoothing | Action::CycleFontFamily + | Action::OpenFontPicker | Action::SelectSelectionTool | Action::SelectMarkerTool | Action::SelectStepMarkerTool diff --git a/src/input/state/interaction/adapters/keyboard.rs b/src/input/state/interaction/adapters/keyboard.rs index e3657cc9..4930caa4 100644 --- a/src/input/state/interaction/adapters/keyboard.rs +++ b/src/input/state/interaction/adapters/keyboard.rs @@ -71,6 +71,17 @@ pub(crate) fn handle_color_picker_key(state: &mut InputState, key: Key) -> Optio .then_some(RoutingOutcome::Consumed(ConsumedBy::ColorPickerPopup)) } +/// The picker types into its own query, so it needs the character the key +/// produced rather than only the key itself. +pub(crate) fn handle_font_picker_key( + state: &mut InputState, + key: Key, + text: Option<&str>, +) -> Option { + (state.is_font_picker_open() && state.handle_font_picker_key(key, text)) + .then_some(RoutingOutcome::Consumed(ConsumedBy::FontPicker)) +} + pub(crate) fn handle_context_menu_key(state: &mut InputState, key: Key) -> Option { (state.is_context_menu_open() && state.handle_context_menu_key(key)) .then_some(RoutingOutcome::Consumed(ConsumedBy::ContextMenu)) diff --git a/src/input/state/interaction/adapters/mod.rs b/src/input/state/interaction/adapters/mod.rs index 0cb39755..b5ea3125 100644 --- a/src/input/state/interaction/adapters/mod.rs +++ b/src/input/state/interaction/adapters/mod.rs @@ -11,18 +11,19 @@ pub(crate) use active_motion::{ pub(crate) use keyboard::{ action_for_key_binding, handle_board_picker_key, handle_building_polygon_key, handle_color_picker_key, handle_command_palette_key, handle_context_menu_key, - handle_drawing_escape_cancel_key, handle_global_modifier_key, handle_help_overlay_key, - handle_idle_selection_cancel_key, handle_pending_delete_cancel_key, handle_precision_entry_key, - handle_properties_panel_key, handle_radial_menu_key, handle_return_edit_selected_text_key, - handle_text_input_key, handle_top_popover_dismiss_key, handle_tour_key, + handle_drawing_escape_cancel_key, handle_font_picker_key, handle_global_modifier_key, + handle_help_overlay_key, handle_idle_selection_cancel_key, handle_pending_delete_cancel_key, + handle_precision_entry_key, handle_properties_panel_key, handle_radial_menu_key, + handle_return_edit_selected_text_key, handle_text_input_key, handle_top_popover_dismiss_key, + handle_tour_key, }; pub(crate) use pointer::{ close_properties_panel_before_tool_routing, finish_pointer_interaction, handle_board_picker_motion, handle_board_picker_press, handle_building_polygon_non_left_press, handle_color_picker_motion, handle_color_picker_press, handle_context_menu_motion, - handle_left_context_menu_press, handle_middle_press, handle_properties_panel_motion, - handle_properties_panel_press, handle_radial_menu_motion, handle_radial_menu_press, - handle_radial_menu_release, handle_release_overlays, handle_right_press, - handle_status_hud_press, handle_tool_button_press, handle_unbound_left_press, - handle_zoom_chip_press, update_pointer_positions, + handle_font_picker_motion, handle_font_picker_press, handle_left_context_menu_press, + handle_middle_press, handle_properties_panel_motion, handle_properties_panel_press, + handle_radial_menu_motion, handle_radial_menu_press, handle_radial_menu_release, + handle_release_overlays, handle_right_press, handle_status_hud_press, handle_tool_button_press, + handle_unbound_left_press, handle_zoom_chip_press, update_pointer_positions, }; diff --git a/src/input/state/interaction/adapters/pointer.rs b/src/input/state/interaction/adapters/pointer.rs index 4db9c981..850d835c 100644 --- a/src/input/state/interaction/adapters/pointer.rs +++ b/src/input/state/interaction/adapters/pointer.rs @@ -356,6 +356,39 @@ pub(crate) fn handle_color_picker_motion( Some(RoutingOutcome::Consumed(ConsumedBy::ColorPickerPopup)) } +pub(crate) fn handle_font_picker_motion( + state: &mut InputState, + points: PointerPoints, +) -> Option { + if !state.is_font_picker_open() { + return None; + } + let screen = points.screen(); + state.font_picker_hover(f64::from(screen.x()), f64::from(screen.y())); + // Consumed whether or not a row was hit: the modal owns the pointer while + // it is up, or a hover would reach the canvas behind it. + Some(RoutingOutcome::Consumed(ConsumedBy::FontPicker)) +} + +pub(crate) fn handle_font_picker_press( + state: &mut InputState, + button: MouseButton, + points: PointerPoints, +) -> Option { + if !state.is_font_picker_open() { + return None; + } + if button != MouseButton::Left { + // Any other button dismisses, the way right-click leaves the other + // pickers rather than doing something surprising inside them. + state.close_font_picker(); + return Some(RoutingOutcome::Consumed(ConsumedBy::FontPicker)); + } + let screen = points.screen(); + state.font_picker_press(f64::from(screen.x()), f64::from(screen.y())); + Some(RoutingOutcome::Consumed(ConsumedBy::FontPicker)) +} + pub(crate) fn handle_board_picker_motion( state: &mut InputState, points: PointerPoints, diff --git a/src/input/state/interaction/keyboard.rs b/src/input/state/interaction/keyboard.rs index f561c1f3..633076b9 100644 --- a/src/input/state/interaction/keyboard.rs +++ b/src/input/state/interaction/keyboard.rs @@ -44,6 +44,11 @@ fn route_key_event(state: &mut InputState, key: Key, is_repeat: bool) -> Routing if let Some(outcome) = adapters::handle_color_picker_key(state, key) { return outcome; } + if let Some(outcome) = + adapters::handle_font_picker_key(state, key, font_picker_text(key).as_deref()) + { + return outcome; + } if let Some(outcome) = adapters::handle_context_menu_key(state, key) { return outcome; } @@ -112,3 +117,15 @@ fn match_action_for_key_binding( Ok(state.match_keyboard_chord(&key_str, is_repeat, now)) } + +/// The character a key contributes to the font picker's query. +/// +/// The router carries `Key`, not the compositor's text, so a printable key is +/// reconstructed from `Key::Char`. Everything else contributes nothing and the +/// picker's own match arms decide what it means. +fn font_picker_text(key: Key) -> Option { + match key { + Key::Char(ch) if !ch.is_control() => Some(ch.to_string()), + _ => None, + } +} diff --git a/src/input/state/interaction/outcome.rs b/src/input/state/interaction/outcome.rs index 93735f45..57ae319f 100644 --- a/src/input/state/interaction/outcome.rs +++ b/src/input/state/interaction/outcome.rs @@ -17,6 +17,7 @@ pub(crate) enum ConsumedBy { HelpOverlay, RadialMenu, ColorPickerPopup, + FontPicker, PrecisionEntry, ContextMenu, BoardPicker, diff --git a/src/input/state/interaction/pointer.rs b/src/input/state/interaction/pointer.rs index 7bf07ab4..eefbe707 100644 --- a/src/input/state/interaction/pointer.rs +++ b/src/input/state/interaction/pointer.rs @@ -23,6 +23,9 @@ pub(crate) fn route_pointer_press(state: &mut InputState, event: PointerPress) - // The precise-entry popup is keyboard-only: any overlay press cancels // it and the press then routes normally. let _ = state.cancel_precision_entry(); + if let Some(outcome) = adapters::handle_font_picker_press(state, event.button(), points) { + return outcome; + } if let Some(outcome) = adapters::handle_color_picker_press(state, event.button(), points) { return outcome; } @@ -74,6 +77,9 @@ pub(crate) fn route_pointer_motion(state: &mut InputState, event: PointerMotion) if let Some(outcome) = adapters::handle_radial_menu_motion(state, points) { return outcome; } + if let Some(outcome) = adapters::handle_font_picker_motion(state, points) { + return outcome; + } if let Some(outcome) = adapters::handle_color_picker_motion(state, points) { return outcome; } diff --git a/src/input/state/mod.rs b/src/input/state/mod.rs index 59f12a64..a15c70ca 100644 --- a/src/input/state/mod.rs +++ b/src/input/state/mod.rs @@ -33,7 +33,8 @@ pub use core::{ ColorPickerCursorHint, ColorPickerPopupLayout, ColorPickerPopupState, CommandPaletteCursorHint, CommandPaletteListRow, CompassDir, CompositorCapabilities, ContextMenuCursorHint, ContextMenuEntry, ContextMenuKind, ContextMenuState, DesktopEnvironment, DrawingState, - EyedropperCaptureSource, EyedropperUiState, HelpOverlayClick, HelpOverlayCursorHint, + EyedropperCaptureSource, EyedropperUiState, FontPickerFilter, FontPickerLayout, + FontPickerResults, FontPickerRow, FontPickerTarget, HelpOverlayClick, HelpOverlayCursorHint, HelpOverlayReleaseOutcome, ImeCompositionState, ImePreedit, InputState, MAX_STROKE_THICKNESS, MIN_STROKE_THICKNESS, OutputFocusAction, PRESET_FEEDBACK_DURATION_MS, PRESET_TOAST_DURATION_MS, PickerDrag, PrecisionEntryState, PresetAction, PresetFeedbackKind, PressureThicknessEditMode, @@ -44,8 +45,9 @@ pub use core::{ ScreenCaptureSource, SelectionAxis, SelectionHandle, SelectionPolicy, SelectionPropertyEntry, SelectionPropertyKind, SelectionState, ShellMode, TextInputMode, Toast, ToastPriority, ToastPushOutcome, ToastQueue, TourStep, UI_TOAST_DURATION_MS, UiToastKind, ZoomAction, - color_picker_rgb_to_hsv, compass_slice, size_ring_angle_for_value, size_ring_value_for_angle, - slice_parent, sub_ring_child_count, sub_ring_children, + color_picker_rgb_to_hsv, compass_slice, font_picker_layout, font_picker_rows, + size_ring_angle_for_value, size_ring_value_for_angle, slice_parent, sub_ring_child_count, + sub_ring_children, }; #[allow(unused_imports)] pub(crate) use core::{ diff --git a/src/input/state/mouse/release/drawing.rs b/src/input/state/mouse/release/drawing.rs index 196a2a3d..b710bbf3 100644 --- a/src/input/state/mouse/release/drawing.rs +++ b/src/input/state/mouse/release/drawing.rs @@ -25,6 +25,23 @@ pub(super) fn finish_drawing(state: &mut InputState, tool: Tool, release: Drawin &release.point_thicknesses, drawing_thickness, ); + // Smoothing commits a different path from the one the preview drew, so the + // preview's pixels can sit outside the committed shape's damage and stay on + // screen as a ghost: rendering clears only the damage clip. + // + // Damage the raw path's own split regions rather than its bounding box. A + // long diagonal stroke's box is nearly the whole screen, and re-marking it + // would undo the split-damage work that `finished_path_damage_regions` + // exists to do. Computed here because the snapshot takes the points next. + let raw_preview_damage = (state.pen_smoothing > 0) + .then(|| { + raw_preview_damage_regions( + &release.points, + drawing_thickness, + &release.point_thicknesses, + ) + }) + .flatten(); let finished = if tool.polygon_template().is_some() { let snapshot = PolygonStrokeSnapshot { tool, @@ -86,6 +103,12 @@ pub(super) fn finish_drawing(state: &mut InputState, tool: Tool, release: Drawin if crate::draw::spotlight_magnification_is_active(magnification) ); let path_damage = finished_path_damage_regions(&shape, bounds); + // `Shape::Freehand` only, deliberately. This covers the case where a + // pressure preview drew wide samples and the release then *downgraded* to a + // plain Freehand at the tool's own thickness, leaving the preview wider than + // anything the committed shape damages. A committed `FreehandPressure` keeps + // the sampled thicknesses it was drawn with, so its own damage is already as + // wide as the preview was and it needs no help here. let preserve_provisional_cleanup = matches!(shape, Shape::Freehand { .. }) && pressure_preview_exceeds_final_width; @@ -125,6 +148,11 @@ pub(super) fn finish_drawing(state: &mut InputState, tool: Tool, release: Drawin for region in path_damage { state.dirty_tracker.mark_rect(region); } + // Only present when smoothing moved the path out from under what + // the preview drew. + for region in raw_preview_damage.into_iter().flatten() { + state.dirty_tracker.mark_rect(region); + } if preserve_provisional_cleanup { state.dirty_tracker.mark_optional_rect(provisional_bounds); } @@ -156,6 +184,33 @@ pub(super) fn finish_drawing(state: &mut InputState, tool: Tool, release: Drawin } } +/// Split damage covering the raw path a live preview drew, or `None` when the +/// path is too short to have drawn anything. +/// +/// The width has to be the widest the preview could have drawn, not the width +/// the release settled on. A tablet preview draws each sample at its own +/// pressure width, and a hard press early in a stroke can be many times the +/// tool's thickness; damaging only the tool width would leave the outer edge of +/// that press on screen once smoothing moved the path out from under it. +/// +/// The result is inflated the way the marker's own damage is, so one +/// calculation covers the widest preview any path tool draws. +fn raw_preview_damage_regions( + points: &[(i32, i32)], + thickness: f64, + point_thicknesses: &[f32], +) -> Option> { + if points.len() < 2 { + return None; + } + let widest = point_thicknesses + .iter() + .fold(thickness, |widest, &sample| widest.max(f64::from(sample))); + let width = (widest * 1.35).max(widest + 1.0); + let fallback = bounding_box_for_points(points, width)?; + Some(split_path_damage_regions(points, width, fallback)) +} + fn pressure_preview_exceeds_final_freehand_width( point_count: usize, point_thicknesses: &[f32], @@ -250,10 +305,35 @@ fn append_segment_damage_regions( #[cfg(test)] mod tests { + use super::raw_preview_damage_regions; use crate::draw::Shape; use crate::input::Tool; use crate::input::state::test_support::make_test_input_state; + #[test] + fn raw_preview_damage_covers_the_widest_pressure_sample_not_the_tool_width() { + // The tool is set to 2px, but the tablet drew one sample 40px wide. + // Smoothing moves the committed path off that sample, so the pixels + // the wide press painted are only repainted if this width knows about + // it. + let points = vec![(100, 100), (300, 100)]; + let with_pressure = raw_preview_damage_regions(&points, 2.0, &[2.0, 40.0]) + .expect("a two-point path has damage"); + let without = raw_preview_damage_regions(&points, 2.0, &[2.0, 2.0]) + .expect("a two-point path has damage"); + + // 15px off the path: inside a 40px stroke, well outside a 2px one. + let (x, y) = (200, 115); + assert!( + with_pressure.iter().any(|rect| rect.contains(x, y)), + "the wide sample's own pixels must be repainted, got {with_pressure:?}" + ); + assert!( + !without.iter().any(|rect| rect.contains(x, y)), + "a stroke that stayed thin must not pay for width it never drew" + ); + } + /// A straight run with one sample knocked sideways, as a shaky hand makes. fn shaky_path() -> Vec<(i32, i32)> { vec![(0, 0), (10, 0), (20, 12), (30, 0), (40, 0)] @@ -316,6 +396,65 @@ mod tests { assert_eq!(smoothed.last(), path.last()); } + /// Draw the shaky path at `level` and return the damage the release left. + fn damage_after_drawing(level: u8) -> Vec { + let mut state = make_test_input_state(); + state.set_pen_smoothing(level); + state.set_tool_override(Some(Tool::Pen)); + let path = shaky_path(); + let first = path[0]; + let last = *path.last().unwrap(); + state.on_mouse_press(crate::input::MouseButton::Left, first.0, first.1); + for &(x, y) in &path[1..] { + state.on_mouse_motion(x, y); + } + let _ = state.take_dirty_regions(); + state.on_mouse_release(crate::input::MouseButton::Left, last.0, last.1); + state.take_dirty_regions() + } + + fn covers(regions: &[crate::util::Rect], x: i32, y: i32) -> bool { + regions.iter().any(|rect| rect.contains(x, y)) + } + + #[test] + fn a_smoothed_release_repaints_where_the_preview_drew_the_raw_path() { + // The preview drew through the spike at (20, 12); the committed stroke + // does not go there. Nothing repaints those pixels unless the release + // says so, and they stay on screen as a ghost. + let regions = damage_after_drawing(6); + + assert!( + covers(®ions, 20, 12), + "the raw spike the preview drew must be repainted, got {regions:?}" + ); + } + + #[test] + fn a_smoothed_release_keeps_the_split_damage_rather_than_the_whole_path_box() { + // The raw path is repainted by its own split regions. Re-marking its + // bounding box instead would undo the split-damage optimization, which + // on a long diagonal stroke is most of the screen. + let regions = damage_after_drawing(6); + let path = shaky_path(); + let full = crate::draw::shape::bounding_box_for_points(&path, 64.0).unwrap(); + + assert!( + !regions.iter().any(|rect| rect.width >= full.width + && rect.height >= full.height + && rect.x <= full.x + && rect.y <= full.y), + "got a region covering the whole path box: {regions:?}" + ); + } + + #[test] + fn an_unsmoothed_release_still_damages_the_stroke_it_committed() { + let regions = damage_after_drawing(0); + + assert!(covers(®ions, 20, 12), "got {regions:?}"); + } + #[test] fn the_marker_is_smoothed_on_the_same_setting_as_the_pen() { let raw = drawn_points(0, Tool::Marker); diff --git a/src/input/tool/catalog.rs b/src/input/tool/catalog.rs index 9355f377..301fba67 100644 --- a/src/input/tool/catalog.rs +++ b/src/input/tool/catalog.rs @@ -479,6 +479,17 @@ impl Tool { self.descriptor().drawing } + /// Whether `[drawing] pen_smoothing` changes what this tool commits. + /// + /// The setting is one number for the whole program, but the control for it + /// is not: offering a smoothing slider while the Line or Blur tool is up + /// would be a control that does nothing to the shape about to be drawn. + /// Accumulated paths — freehand and marker — are the ones smoothed on + /// release. + pub(crate) fn smooths_strokes(self) -> bool { + matches!(self.drawing_behavior(), ToolDrawingBehavior::Path { .. }) + } + pub(crate) fn settings_slot(self) -> ToolSettingsSlot { self.profile().settings_slot } diff --git a/src/session/snapshot/apply.rs b/src/session/snapshot/apply.rs index 3519c1ec..78370e85 100644 --- a/src/session/snapshot/apply.rs +++ b/src/session/snapshot/apply.rs @@ -111,6 +111,9 @@ pub(crate) fn apply_tool_state_snapshot(input: &mut InputState, tool_state: Tool let _ = input.set_eraser_mode(tool_state.eraser_mode); let _ = input.set_blur_style(tool_state.blur_style); input.restore_recent_colors(tool_state.recent_colors.clone()); + if let Some(level) = tool_state.pen_smoothing { + let _ = input.set_pen_smoothing(level); + } if let Some(opacity) = tool_state.marker_opacity { let _ = input.set_marker_opacity(opacity); } diff --git a/src/session/snapshot/tests.rs b/src/session/snapshot/tests.rs index c13c4067..1b528083 100644 --- a/src/session/snapshot/tests.rs +++ b/src/session/snapshot/tests.rs @@ -72,6 +72,7 @@ fn sample_tool_state() -> ToolStateSnapshot { eraser_mode: EraserMode::Brush, blur_style: Default::default(), recent_colors: Vec::new(), + pen_smoothing: None, marker_opacity: Some(0.32), spotlight_magnification: None, fill_enabled: Some(false), diff --git a/src/session/snapshot/types.rs b/src/session/snapshot/types.rs index c88759b9..703f19bb 100644 --- a/src/session/snapshot/types.rs +++ b/src/session/snapshot/types.rs @@ -77,6 +77,10 @@ pub struct ToolStateSnapshot { pub recent_colors: Vec, #[serde(default)] pub marker_opacity: Option, + /// Release-time stroke smoothing. Absent in sessions written before it + /// existed, which restore the configured level instead. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pen_smoothing: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub spotlight_magnification: Option, #[serde(default)] @@ -116,6 +120,7 @@ impl ToolStateSnapshot { blur_style: input.blur_style, recent_colors: input.recent_colors().to_vec(), marker_opacity: Some(input.marker_opacity), + pen_smoothing: Some(input.pen_smoothing), spotlight_magnification: Some(input.spotlight_magnification), fill_enabled: Some(input.fill_enabled), tool_override: input.session_tool_override(), @@ -155,6 +160,7 @@ impl ToolStateSnapshot { blur_style: config.drawing.default_blur_style, recent_colors: Vec::new(), marker_opacity: Some(config.drawing.marker_opacity), + pen_smoothing: Some(config.drawing.pen_smoothing), spotlight_magnification: Some(config.spotlight.magnification), fill_enabled: Some(config.drawing.default_fill_enabled), tool_override: None, diff --git a/src/session/storage/tests.rs b/src/session/storage/tests.rs index b85f6208..26e92ed3 100644 --- a/src/session/storage/tests.rs +++ b/src/session/storage/tests.rs @@ -53,6 +53,7 @@ fn sample_tool_state() -> ToolStateSnapshot { eraser_mode: crate::input::EraserMode::Brush, blur_style: Default::default(), recent_colors: Vec::new(), + pen_smoothing: None, marker_opacity: Some(0.32), spotlight_magnification: None, fill_enabled: Some(false), diff --git a/src/session/tests/limits.rs b/src/session/tests/limits.rs index b6a98454..dde4f7d2 100644 --- a/src/session/tests/limits.rs +++ b/src/session/tests/limits.rs @@ -34,6 +34,7 @@ fn save_snapshot_errors_when_payload_exceeds_max_file_size() { eraser_mode: EraserMode::Brush, blur_style: Default::default(), recent_colors: Vec::new(), + pen_smoothing: None, marker_opacity: Some(0.32), spotlight_magnification: None, fill_enabled: Some(false), diff --git a/src/session/tests/snapshot.rs b/src/session/tests/snapshot.rs index 788c54e3..d30a256b 100644 --- a/src/session/tests/snapshot.rs +++ b/src/session/tests/snapshot.rs @@ -51,6 +51,48 @@ fn snapshot_includes_frames_and_tool_state() { assert!(snapshot.tool_state.is_some()); } +#[test] +fn non_default_pen_smoothing_survives_snapshot_serialization_and_restore() { + let mut source = dummy_input_state(); + let _ = source.set_pen_smoothing(5); + assert_eq!(source.pen_smoothing, 5, "the fixture must be non-default"); + + let captured = ToolStateSnapshot::from_input_state(&source); + let encoded = serde_json::to_vec(&captured).expect("serialize tool snapshot"); + let decoded: ToolStateSnapshot = + serde_json::from_slice(&encoded).expect("deserialize tool snapshot"); + + let mut restored = dummy_input_state(); + let _ = restored.set_pen_smoothing(1); + apply_tool_state_snapshot(&mut restored, decoded); + + assert_eq!(restored.pen_smoothing, 5); +} + +#[test] +fn legacy_snapshot_without_pen_smoothing_preserves_the_configured_level() { + let source = dummy_input_state(); + let mut legacy = + serde_json::to_value(ToolStateSnapshot::from_input_state(&source)).expect("tool snapshot"); + let object = legacy.as_object_mut().expect("tool snapshot is an object"); + assert!( + object.remove("pen_smoothing").is_some(), + "the fixture must remove a field current sessions write" + ); + let decoded: ToolStateSnapshot = + serde_json::from_value(legacy).expect("legacy tool snapshot still loads"); + assert_eq!(decoded.pen_smoothing, None); + + let mut restored = dummy_input_state(); + let _ = restored.set_pen_smoothing(4); + apply_tool_state_snapshot(&mut restored, decoded); + + assert_eq!( + restored.pen_smoothing, 4, + "a missing legacy field must leave the config-seeded value alone" + ); +} + #[test] fn snapshot_uses_pre_light_mode_tool_state() { let mut options = SessionOptions::new(PathBuf::from("/tmp"), "display-light"); @@ -320,6 +362,7 @@ fn apply_legacy_snapshot_preserves_config_initialized_font_descriptor() { eraser_mode: EraserMode::Brush, blur_style: Default::default(), recent_colors: Vec::new(), + pen_smoothing: None, marker_opacity: Some(0.32), spotlight_magnification: None, fill_enabled: Some(false), @@ -375,6 +418,7 @@ fn apply_snapshot_clamps_restored_per_tool_thicknesses() { eraser_mode: EraserMode::Brush, blur_style: Default::default(), recent_colors: Vec::new(), + pen_smoothing: None, marker_opacity: Some(0.32), spotlight_magnification: None, fill_enabled: Some(false), @@ -425,6 +469,7 @@ fn apply_legacy_snapshot_uses_font_derived_step_marker_size() { eraser_mode: EraserMode::Brush, blur_style: Default::default(), recent_colors: Vec::new(), + pen_smoothing: None, marker_opacity: Some(0.32), spotlight_magnification: None, fill_enabled: Some(false), diff --git a/src/toolbar_gtk/view/top_bar.rs b/src/toolbar_gtk/view/top_bar.rs index 897d6ca9..6b439a7c 100644 --- a/src/toolbar_gtk/view/top_bar.rs +++ b/src/toolbar_gtk/view/top_bar.rs @@ -81,6 +81,9 @@ const STYLE_SEL_VALUE_W: f64 = 64.0; /// label would hug both edges instead of sitting centered with the same /// breathing room the builtin gives it. const STYLE_RESET_W: f64 = 56.0; +/// `ToolbarLayoutSpec::TOP_STYLE_FONT_PICK_W`. Carries a family name rather +/// than a fixed word, so it is wider than the counter reset. +const STYLE_FONT_PICK_W: f64 = 96.0; /// `ToolbarLayoutSpec::TOP_STYLE_STEP_W`. const STYLE_STEP_W: f64 = 20.0; /// Segment tab height (matches the Settings pane's segmented tabs). diff --git a/src/toolbar_gtk/view/top_bar/controls.rs b/src/toolbar_gtk/view/top_bar/controls.rs index ed1e7270..2b0af6a3 100644 --- a/src/toolbar_gtk/view/top_bar/controls.rs +++ b/src/toolbar_gtk/view/top_bar/controls.rs @@ -284,7 +284,7 @@ impl TopBar { ctx.set_source_rgba(r, g, b, a); swatch_path(ctx); let _ = ctx.fill(); - let luminance = 0.299 * r + 0.587 * g + 0.114 * b; + let luminance = crate::draw::perceived_luminance(r, g, b); set_color( ctx, if luminance < 0.3 { diff --git a/src/toolbar_gtk/view/top_bar/style_pill.rs b/src/toolbar_gtk/view/top_bar/style_pill.rs index 0192f4d8..c07e4086 100644 --- a/src/toolbar_gtk/view/top_bar/style_pill.rs +++ b/src/toolbar_gtk/view/top_bar/style_pill.rs @@ -21,6 +21,16 @@ fn format_pt(value: f64) -> String { format!("{value:.0}pt") } +/// Smoothing readout. Matches `StylePillControl::value_text`: zero passes is a +/// state worth naming, not a quantity. +fn format_smoothing(value: f64) -> String { + if value.round() <= 0.0 { + "Off".to_string() + } else { + format!("{value:.0}") + } +} + /// Pill button on the shared `sized_button` chassis: non-focusable and /// releasing window keyboard focus on click, like every other top-bar /// control. The GTK bars must never retain keyboard focus — the popups the @@ -32,6 +42,25 @@ fn pill_button(label: &str, width: f64, height: f64) -> gtk4::Button { button } +/// Hold a button's label inside the slot the layout planned for it. +/// +/// `set_size_request` is a *minimum* in GTK: a label wider than the request +/// grows the button and pushes the rest of the pill off the plan. Shortening +/// the string by character count is not enough, because a display face draws +/// twelve wide characters wider than twelve narrow ones — and a family name is +/// drawn by the toolbar's own font at whatever width that font gives it. +/// +/// Applied only where the label is a name the system supplied rather than a +/// word this program chose, which today is the font button alone. +fn bound_button_label(button: >k4::Button) { + if let Some(label) = button.child().and_downcast::() { + label.set_ellipsize(pango::EllipsizeMode::End); + // Natural width stops asking for the whole string, so the size request + // is what decides the slot. The label still fills it when drawn. + label.set_max_width_chars(1); + } +} + impl TopBar { /// Appends the inline unavailable-state label for a control that can carry /// one, and registers its updater. @@ -154,6 +183,7 @@ impl TopBar { model::StylePillControl::ThicknessSlider | model::StylePillControl::OpacitySlider | model::StylePillControl::SpotlightMagnificationSlider + | model::StylePillControl::PenSmoothingSlider | model::StylePillControl::FontSizeSlider => { let (slider_spec, value) = control.slider_value(snapshot); let format = match control { @@ -162,6 +192,7 @@ impl TopBar { model::StylePillControl::SpotlightMagnificationSlider => { crate::draw::format_spotlight_magnification } + model::StylePillControl::PenSmoothingSlider => format_smoothing, _ => format_pt, }; let sender = self.feedback.clone(); @@ -176,6 +207,9 @@ impl TopBar { model::StylePillControl::SpotlightMagnificationSlider => { ToolbarEvent::SetSpotlightMagnification(value) } + model::StylePillControl::PenSmoothingSlider => { + ToolbarEvent::SetPenSmoothing(value.round().clamp(0.0, 255.0) as u8) + } _ => ToolbarEvent::SetFontSize(value), }; send_event(&sender, event); @@ -183,12 +217,11 @@ impl TopBar { // The thickness/text-size readouts are distinct numeral // controls; only the opacity slider keeps its built-in // readout. - slider.set_value_label_visible(matches!( - control, - model::StylePillControl::OpacitySlider - | model::StylePillControl::SpotlightMagnificationSlider - )); + slider.set_value_label_visible(control.carries_inline_readout()); set_semantic_widget_id(&slider.root, control.id().as_ref()); + if let Some(tooltip) = control.tooltip(snapshot) { + slider.root.set_tooltip_text(Some(&tooltip)); + } slider.root.set_size_request(px(STYLE_SLIDER_W), -1); slider.root.set_valign(gtk4::Align::Center); append_gap(&pill, slider.root.upcast_ref(), gap); @@ -199,6 +232,9 @@ impl TopBar { model::StylePillControl::SpotlightMagnificationSlider => { snapshot.spotlight_magnification } + model::StylePillControl::PenSmoothingSlider => { + f64::from(snapshot.pen_smoothing) + } _ => snapshot.font_size, }; slider.set_value(value); @@ -288,6 +324,34 @@ impl TopBar { } })); } + model::StylePillControl::FontFamilyPicker => { + // The family in use, as a button onto the overlay's font + // picker — the same route the color chip takes to the + // gradient picker. + let button = pill_button( + control.label(snapshot).as_ref(), + sz(STYLE_FONT_PICK_W), + sz(STYLE_ROW_H), + ); + bound_button_label(&button); + set_semantic_widget_id(&button, control.id().as_ref()); + if let Some(tooltip) = control.tooltip(snapshot) { + button.set_tooltip_text(Some(&tooltip)); + } + let sender = self.feedback.clone(); + let event = control.click_event(snapshot); + button.connect_clicked(move |_| { + send_event(&sender, event.clone()); + }); + append_gap(&pill, button.upcast_ref(), gap); + self.updaters.borrow_mut().push(Box::new(move |snapshot| { + button.set_label(control.label(snapshot).as_ref()); + // `set_label` can replace the child, taking the bound + // with it. + bound_button_label(&button); + button.set_tooltip_text(control.tooltip(snapshot).as_deref()); + })); + } model::StylePillControl::SelectionCycle(_) | model::StylePillControl::ArrowStyleCycle => { let button = pill_button( diff --git a/src/toolbar_gtk/view/top_bar/tests.rs b/src/toolbar_gtk/view/top_bar/tests.rs index 5718467a..c3a0e65b 100644 --- a/src/toolbar_gtk/view/top_bar/tests.rs +++ b/src/toolbar_gtk/view/top_bar/tests.rs @@ -812,6 +812,23 @@ fn assert_gtk_control_widget(widget: >k4::Widget, expected: &SemanticControlRe /// Assert one GTK style-pill widget against its shared-spec control: widget /// class per role, live label/value text, tooltip, active state, and the /// segment halves' labels/actives for segmented controls. +/// Width the font button asks for, or `None` when this widget is not it. +/// +/// The only pill label the system supplies rather than this program, so it is +/// the only one whose width is not known in advance. `set_size_request` is a +/// *minimum* in GTK: an unbounded label grows the button past the slot the +/// layout planned and pushes the rest of the pill off the arrangement the +/// builtin toolbar drew from the same plan. +fn font_button_natural_width( + widget: >k4::Widget, + control: model::StylePillControl, +) -> Option { + if control != model::StylePillControl::FontFamilyPicker { + return None; + } + Some(widget.measure(gtk4::Orientation::Horizontal, -1).1) +} + fn assert_gtk_style_widget( widget: >k4::Widget, control: model::StylePillControl, @@ -1247,7 +1264,7 @@ fn expected_style_pill_nodes( continue; } nodes.push((id.clone(), StylePillNodeExpectation::Control(control))); - if control == model::StylePillControl::OpacitySlider { + if control.carries_inline_readout() { nodes.push(( format!("{id}.readout"), StylePillNodeExpectation::Readout(control), @@ -1611,9 +1628,22 @@ fn actual_gtk_widgets_match_the_shared_contract_without_presenting_a_window() { ); let mut text_mode = style_pill_tool_snapshot(®ular, Tool::Pen); text_mode.text_active = true; - scenarios.push(("text-mode", text_mode)); + scenarios.push(("text-mode", text_mode.clone())); + // A family name far wider than the font button's planned slot. + let mut long_font = text_mode; + long_font.font = crate::draw::FontDescriptor::new( + "Noto Sans Mono CJK JP ExtraCondensed Black".to_string(), + "normal".to_string(), + "normal".to_string(), + ); + scenarios.push(("long-font-name", long_font)); scenarios.push(("selection", style_pill_selection_snapshot(®ular))); + // Font-button widths by scenario, checked against each other after the + // loop: the slot is theme-dependent, but it must not depend on the name. + let mut font_button_widths: std::collections::BTreeMap<&str, i32> = + std::collections::BTreeMap::new(); + for (name, snapshot) in scenarios { let plan = plan_top_strip(&snapshot); let spec = super::strip::top_toolbar_spec(&snapshot, &plan); @@ -1663,6 +1693,9 @@ fn actual_gtk_widgets_match_the_shared_contract_without_presenting_a_window() { .find(|(control_id, _)| *control_id == id) { assert_gtk_style_widget(&widget, *control, &snapshot); + if let Some(width) = font_button_natural_width(&widget, *control) { + font_button_widths.insert(name, width); + } continue; } let Some(control) = expected.iter().find_map(|record| match record { @@ -1682,6 +1715,23 @@ fn actual_gtk_widgets_match_the_shared_contract_without_presenting_a_window() { detach_test_popovers(&mut top); } + // The font button's width must come from the layout plan, not from the + // family it happens to name. Shortening the string by character count is + // not enough on its own: a display face draws twelve wide characters wider + // than twelve narrow ones. + let short = font_button_widths + .get("text-mode") + .copied() + .expect("the text-mode pill has a font button"); + let long = font_button_widths + .get("long-font-name") + .copied() + .expect("the long-font-name pill has a font button"); + assert_eq!( + long, short, + "the font button grew from {short}px to {long}px for a longer family name" + ); + // Compact plans normally drop quick colors before reaching the last // degradation step. Keep a direct adapter case so the presentation // contract cannot silently diverge if that planner policy changes. diff --git a/src/ui.rs b/src/ui.rs index e25c41fb..5ee480f4 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -8,6 +8,7 @@ mod command_palette; pub mod constants; mod context_menu; mod eyedropper_loupe; +mod font_picker; mod help_overlay; mod input_hud; mod measure_badge; @@ -34,6 +35,7 @@ pub use color_picker_popup::{color_picker_popup_visual_geometry, render_color_pi pub use command_palette::{command_palette_visual_geometry, render_command_palette}; pub use context_menu::render_context_menu; pub(crate) use eyedropper_loupe::{compute_eyedropper_loupe_layout, render_eyedropper_loupe}; +pub use font_picker::render_font_picker; #[allow(unused_imports)] pub use help_overlay::HelpOverlayBindings; #[cfg(test)] diff --git a/src/ui/color_picker_popup.rs b/src/ui/color_picker_popup.rs index ca26bcf7..c5869589 100644 --- a/src/ui/color_picker_popup.rs +++ b/src/ui/color_picker_popup.rs @@ -547,7 +547,7 @@ fn draw_preview_swatch(ctx: &cairo::Context, x: f64, y: f64, size: f64, color: C let _ = ctx.fill(); // Border - let luminance = 0.299 * color.r + 0.587 * color.g + 0.114 * color.b; + let luminance = crate::draw::perceived_luminance(color.r, color.g, color.b); if luminance < 0.3 { constants::set_color(ctx, SWATCH_BORDER_ON_DARK); } else { diff --git a/src/ui/font_picker.rs b/src/ui/font_picker.rs new file mode 100644 index 00000000..c4e2dbee --- /dev/null +++ b/src/ui/font_picker.rs @@ -0,0 +1,435 @@ +//! Drawing the system font picker. +//! +//! Each row is laid out in the family it names. That is the point of the whole +//! surface — nobody chooses a typeface by reading its name — and it is why the +//! visible window is capped: only the rows on screen are ever laid out, so a +//! system with 269 families costs the same as one with 12. + +use crate::input::state::{ + FontPickerLayout, FontPickerRow, FontPickerTarget, InputState, font_picker_layout, +}; +use crate::ui::primitives::draw_rounded_rect; +use crate::ui::theme::{self, overlay}; + +/// Point size the family names are drawn at. +const ROW_FONT_SIZE: f64 = 17.0; +/// Point size of the query line and the caption. +const CHROME_FONT_SIZE: f64 = 14.0; +/// Inset from a row's left edge to its text. +const ROW_TEXT_INSET: f64 = 12.0; +/// Width of the marker beside the family already in use. +const CURRENT_MARK_WIDTH: f64 = 3.0; +/// Clear space kept between two labels sharing one line. +const TEXT_GAP: f64 = 12.0; +/// Shortest the thumb gets, so a 269-family list still leaves something to see. +const SCROLL_THUMB_MIN_HEIGHT: f64 = 20.0; + +/// Draw the picker. Does nothing when it is closed. +pub fn render_font_picker(ctx: &cairo::Context, state: &InputState, width: u32, height: u32) { + if !state.is_font_picker_open() { + return; + } + let families = state.font_picker_families(); + let layout = font_picker_layout(width, height, families.len()); + let current = state.font_picker_current_family(); + let rows = crate::input::state::font_picker_rows( + layout, + &families, + state.font_picker_scroll(), + state.font_picker_selected(), + ¤t, + ); + + let _ = ctx.save(); + scrim(ctx, width, height); + panel(ctx, layout); + query_line( + ctx, + layout, + state, + (!state.font_picker_is_loading()).then_some(families.len()), + ); + for row in &rows { + draw_row(ctx, row); + } + scroll_indicator(ctx, layout, families.len(), state.font_picker_scroll()); + if state.font_picker_is_loading() { + loading_note(ctx, layout); + } else if state.font_picker_load_failed() { + unavailable_note(ctx, layout); + } else if families.is_empty() { + empty_note(ctx, layout); + } + caption(ctx, layout, state); + let _ = ctx.restore(); +} + +/// Dim the canvas so the panel reads as modal, matching the other pickers. +fn scrim(ctx: &cairo::Context, width: u32, height: u32) { + ctx.set_source_rgba(0.0, 0.0, 0.0, overlay::OVERLAY_DIM_MEDIUM); + ctx.rectangle(0.0, 0.0, f64::from(width), f64::from(height)); + let _ = ctx.fill(); +} + +fn panel(ctx: &cairo::Context, layout: FontPickerLayout) { + let radius = overlay::RADIUS_LG; + theme::set_color(ctx, overlay::SHADOW_DEEP); + draw_rounded_rect( + ctx, + layout.panel_x + 1.0, + layout.panel_y + 2.0, + layout.panel_width, + layout.panel_height, + radius, + ); + let _ = ctx.fill(); + theme::set_color(ctx, overlay::PANEL_BG_MODAL); + draw_rounded_rect( + ctx, + layout.panel_x, + layout.panel_y, + layout.panel_width, + layout.panel_height, + radius, + ); + let _ = ctx.fill(); + theme::set_color(ctx, overlay::BORDER_MODAL); + ctx.set_line_width(1.0); + draw_rounded_rect( + ctx, + layout.panel_x + 0.5, + layout.panel_y + 0.5, + layout.panel_width - 1.0, + layout.panel_height - 1.0, + radius - 0.5, + ); + let _ = ctx.stroke(); +} + +fn query_line( + ctx: &cairo::Context, + layout: FontPickerLayout, + state: &InputState, + match_count: Option, +) { + theme::set_color(ctx, overlay::INPUT_BG); + draw_rounded_rect( + ctx, + layout.query_x, + layout.query_y, + layout.query_width, + layout.query_height, + overlay::RADIUS_MD, + ); + let _ = ctx.fill(); + + let query = state.font_picker_query(); + let (text, dim) = if query.is_empty() { + ("Type to find a font".to_string(), true) + } else { + (query.to_string(), false) + }; + let baseline = layout.query_y + layout.query_height / 2.0 + CHROME_FONT_SIZE / 2.5; + + // Match count on the right, so a query that narrows to nothing says so + // before the empty list has to. Measured first: it is short and always + // wanted, so it is the query that gives way when the two would collide. + let count = match_count + .map(|count| count.to_string()) + .unwrap_or_else(|| "…".to_string()); + let count_width = text_width(ctx, "Sans", CHROME_FONT_SIZE, &count); + theme::set_color(ctx, overlay::TEXT_HINT); + draw_text_right( + ctx, + "Sans", + CHROME_FONT_SIZE, + layout.query_x + layout.query_width - ROW_TEXT_INSET, + baseline, + count_width, + &count, + ); + + theme::set_color( + ctx, + if dim { + overlay::TEXT_HINT + } else { + overlay::TEXT_PRIMARY + }, + ); + draw_text( + ctx, + "Sans", + CHROME_FONT_SIZE, + layout.query_x + ROW_TEXT_INSET, + baseline, + layout.query_width - ROW_TEXT_INSET * 2.0 - count_width - TEXT_GAP, + &text, + ); +} + +fn draw_row(ctx: &cairo::Context, row: &FontPickerRow) { + if row.selected { + theme::set_color(ctx, overlay::BG_SELECTION); + draw_rounded_rect(ctx, row.x, row.y, row.width, row.height, overlay::RADIUS_SM); + let _ = ctx.fill(); + } + if row.current { + theme::set_color(ctx, overlay::ACCENT_BRIGHT); + ctx.rectangle( + row.x, + row.y + row.height * 0.2, + CURRENT_MARK_WIDTH, + row.height * 0.6, + ); + let _ = ctx.fill(); + } + + theme::set_color( + ctx, + if row.selected { + overlay::TEXT_PRIMARY + } else { + overlay::TEXT_SECONDARY + }, + ); + // The family draws its own name. Pango falls back per glyph, so a font with + // no Latin coverage still shows something readable rather than a row of + // empty boxes. + // + // A family name can be long and a display face can be wide, so the row + // ellipsizes rather than writing past its own edge and out of the panel. + draw_text( + ctx, + &row.family, + ROW_FONT_SIZE, + row.x + ROW_TEXT_INSET, + row.y + row.height / 2.0 + ROW_FONT_SIZE / 2.5, + row.width - ROW_TEXT_INSET * 2.0, + &row.family, + ); +} + +/// Where the window sits in the list, as a thumb on a track. +/// +/// The list runs to hundreds of families and the panel shows a dozen. Without +/// this there is nothing on screen that says whether you are near the top, the +/// middle, or the end. Same track the command palette draws. +fn scroll_indicator(ctx: &cairo::Context, layout: FontPickerLayout, total: usize, scroll: usize) { + if total <= layout.visible_rows || layout.visible_rows == 0 { + return; + } + let width = theme::toolbar::SCROLLBAR_WIDTH; + let radius = theme::toolbar::SCROLLBAR_RADIUS; + let track_x = layout.list_x + layout.list_width - width; + let track_h = layout.list_height; + + theme::set_color(ctx, theme::toolbar::COLOR_SCROLLBAR_TRACK); + draw_rounded_rect(ctx, track_x, layout.list_y, width, track_h, radius); + let _ = ctx.fill(); + + let visible = layout.visible_rows as f64 / total as f64; + let thumb_h = (track_h * visible) + .max(SCROLL_THUMB_MIN_HEIGHT) + .min(track_h); + let range = total.saturating_sub(layout.visible_rows); + let progress = if range > 0 { + (scroll as f64 / range as f64).clamp(0.0, 1.0) + } else { + 0.0 + }; + let thumb_y = layout.list_y + progress * (track_h - thumb_h); + + theme::set_color(ctx, theme::toolbar::COLOR_SCROLLBAR_SLIDER); + draw_rounded_rect(ctx, track_x, thumb_y, width, thumb_h, radius); + let _ = ctx.fill(); +} + +/// The note that stands in for the list when nothing matched. +/// +/// It sits in the row of list height the layout reserves for exactly this, so +/// it cannot land on the caption underneath. +fn empty_note(ctx: &cairo::Context, layout: FontPickerLayout) { + theme::set_color(ctx, overlay::TEXT_HINT); + draw_text( + ctx, + "Sans", + CHROME_FONT_SIZE, + layout.list_x + ROW_TEXT_INSET, + layout.list_y + layout.list_height / 2.0 + CHROME_FONT_SIZE / 2.5, + layout.list_width - ROW_TEXT_INSET * 2.0, + "No font matches that", + ); +} + +fn loading_note(ctx: &cairo::Context, layout: FontPickerLayout) { + theme::set_color(ctx, overlay::TEXT_HINT); + draw_text( + ctx, + "Sans", + CHROME_FONT_SIZE, + layout.list_x + ROW_TEXT_INSET, + layout.list_y + layout.list_height / 2.0 + CHROME_FONT_SIZE / 2.5, + layout.list_width - ROW_TEXT_INSET * 2.0, + "Loading system fonts…", + ); +} + +fn unavailable_note(ctx: &cairo::Context, layout: FontPickerLayout) { + theme::set_color(ctx, overlay::TEXT_HINT); + draw_text( + ctx, + "Sans", + CHROME_FONT_SIZE, + layout.list_x + ROW_TEXT_INSET, + layout.list_y + layout.list_height / 2.0 + CHROME_FONT_SIZE / 2.5, + layout.list_width - ROW_TEXT_INSET * 2.0, + "System fonts could not be loaded", + ); +} + +fn caption(ctx: &cairo::Context, layout: FontPickerLayout, state: &InputState) { + let target = match state.font_picker_target() { + FontPickerTarget::Selection => FontPickerTarget::Selection.label(), + FontPickerTarget::ToolDefault => FontPickerTarget::ToolDefault.label(), + }; + theme::set_color(ctx, overlay::TEXT_HINT); + + let size = CHROME_FONT_SIZE - 1.0; + let left = format!("{target} · Tab: {}", state.font_picker_filter().label()); + const KEYS: &str = "Enter apply · Esc cancel"; + let (left_width, keys_width) = share_line( + layout.query_width - TEXT_GAP, + text_width(ctx, "Sans", size, &left), + text_width(ctx, "Sans", size, KEYS), + ); + + draw_text_right( + ctx, + "Sans", + size, + layout.query_x + layout.query_width, + layout.caption_y, + keys_width, + KEYS, + ); + draw_text( + ctx, + "Sans", + size, + layout.query_x, + layout.caption_y, + left_width, + &left, + ); +} + +/// Split one line between two labels that both want room. +/// +/// Whoever is measured first must not simply take what it asks for: doing that +/// left the caption reading "Tab:…" beside a fully drawn key list, which is the +/// half with less to say. A label that fits inside its share keeps only what it +/// needs and hands the rest over; when both overrun, they halve the line. +fn share_line(available: f64, left: f64, right: f64) -> (f64, f64) { + if left + right <= available { + return (left, right); + } + let half = available / 2.0; + if right <= half { + (available - right, right) + } else if left <= half { + (left, available - left) + } else { + (half, half) + } +} + +/// A Pango layout for `text`, clipped to `max_width` with an ellipsis. +/// +/// Everything the panel draws is user-supplied or system-supplied and none of +/// it is bounded: a query is as long as it is typed, and a family name is as +/// wide as its own display face draws it. Ellipsizing is what keeps any of it +/// from writing over the neighbouring text or out through the panel edge. +fn bounded_layout( + ctx: &cairo::Context, + family: &str, + size: f64, + max_width: f64, + text: &str, +) -> pango::Layout { + let layout = pangocairo::functions::create_layout(ctx); + let description = pango::FontDescription::from_string(&format!("{family} {size}")); + layout.set_font_description(Some(&description)); + layout.set_text(text); + layout.set_ellipsize(pango::EllipsizeMode::End); + layout.set_width((max_width.max(0.0) * f64::from(pango::SCALE)) as i32); + layout +} + +/// Natural width of `text`, for callers dividing a row between two labels. +fn text_width(ctx: &cairo::Context, family: &str, size: f64, text: &str) -> f64 { + let layout = pangocairo::functions::create_layout(ctx); + let description = pango::FontDescription::from_string(&format!("{family} {size}")); + layout.set_font_description(Some(&description)); + layout.set_text(text); + f64::from(layout.extents().1.width()) / f64::from(pango::SCALE) +} + +fn draw_text( + ctx: &cairo::Context, + family: &str, + size: f64, + x: f64, + y: f64, + max_width: f64, + text: &str, +) { + if max_width <= 0.0 { + return; + } + let layout = bounded_layout(ctx, family, size, max_width, text); + let baseline = f64::from(layout.baseline()) / f64::from(pango::SCALE); + ctx.move_to(x, y - baseline); + pangocairo::functions::show_layout(ctx, &layout); +} + +fn draw_text_right( + ctx: &cairo::Context, + family: &str, + size: f64, + right: f64, + y: f64, + max_width: f64, + text: &str, +) { + if max_width <= 0.0 { + return; + } + let layout = bounded_layout(ctx, family, size, max_width, text); + let width = (f64::from(layout.extents().1.width()) / f64::from(pango::SCALE)).min(max_width); + let baseline = f64::from(layout.baseline()) / f64::from(pango::SCALE); + ctx.move_to(right - width, y - baseline); + pangocairo::functions::show_layout(ctx, &layout); +} + +#[cfg(test)] +mod caption_tests { + use super::share_line; + + #[test] + fn two_labels_that_both_fit_keep_their_own_widths() { + assert_eq!(share_line(400.0, 150.0, 120.0), (150.0, 120.0)); + } + + #[test] + fn a_short_label_yields_its_slack_rather_than_its_share() { + // The keys line is short; the caption gets everything it leaves. + assert_eq!(share_line(400.0, 500.0, 120.0), (280.0, 120.0)); + assert_eq!(share_line(400.0, 120.0, 500.0), (120.0, 280.0)); + } + + #[test] + fn two_labels_that_both_overrun_halve_the_line() { + assert_eq!(share_line(400.0, 500.0, 500.0), (200.0, 200.0)); + } +} diff --git a/src/ui/help_overlay/sections/builder/sections.rs b/src/ui/help_overlay/sections/builder/sections.rs index b6e74371..ddc7b66d 100644 --- a/src/ui/help_overlay/sections/builder/sections.rs +++ b/src/ui/help_overlay/sections/builder/sections.rs @@ -159,6 +159,7 @@ pub(super) fn build_main_sections( action_row(bindings, Action::EnterTextMode, NOT_BOUND_LABEL), action_row(bindings, Action::EnterStickyNoteMode, NOT_BOUND_LABEL), action_row(bindings, Action::CycleFontFamily, NOT_BOUND_LABEL), + action_row(bindings, Action::OpenFontPicker, NOT_BOUND_LABEL), action_row(bindings, Action::IncreaseFontSize, NOT_BOUND_LABEL), action_row(bindings, Action::DecreaseFontSize, NOT_BOUND_LABEL), action_row(bindings, Action::ToggleFill, NOT_BOUND_LABEL), diff --git a/src/ui/theme.rs b/src/ui/theme.rs index dc244996..b2f7403d 100644 --- a/src/ui/theme.rs +++ b/src/ui/theme.rs @@ -850,6 +850,11 @@ const STATUS_PALETTE_LUMINANCE_THRESHOLD: f64 = 0.5; const CURSOR_PREVIEW_OUTLINE_LUMINANCE_THRESHOLD: f64 = 0.6; /// Rec. 709 relative luminance of an RGB color (0.0–1.0 channels). +/// +/// For overlay chrome. The canvas contrast decisions — text halo, step-marker +/// outline, sticky-note foreground, swatch hairlines — use Rec. 601 luma +/// instead, as [`crate::draw::perceived_luminance`]. Two names because they are +/// two metrics; they disagree most on green. #[inline] pub fn relative_luminance(r: f64, g: f64, b: f64) -> f64 { 0.2126 * r + 0.7152 * g + 0.0722 * b diff --git a/src/ui/toolbar/apply/mod.rs b/src/ui/toolbar/apply/mod.rs index f27a7b1d..3887b81c 100644 --- a/src/ui/toolbar/apply/mod.rs +++ b/src/ui/toolbar/apply/mod.rs @@ -64,6 +64,8 @@ impl InputState { ToolbarEvent::SetSpotlightMagnification(value) => { self.apply_toolbar_set_spotlight_magnification(value) } + ToolbarEvent::SetPenSmoothing(level) => self.apply_toolbar_set_pen_smoothing(level), + ToolbarEvent::OpenFontPicker => self.apply_toolbar_open_font_picker(), ToolbarEvent::SetEraserMode(mode) => self.apply_toolbar_set_eraser_mode(mode), ToolbarEvent::SetFont(descriptor) => self.apply_toolbar_set_font(descriptor), ToolbarEvent::SetFontSize(size) => self.apply_toolbar_set_font_size(size), diff --git a/src/ui/toolbar/apply/tools.rs b/src/ui/toolbar/apply/tools.rs index aad318b7..911fffcc 100644 --- a/src/ui/toolbar/apply/tools.rs +++ b/src/ui/toolbar/apply/tools.rs @@ -79,6 +79,19 @@ impl InputState { self.set_spotlight_magnification(value) } + pub(super) fn apply_toolbar_set_pen_smoothing(&mut self, level: u8) -> bool { + self.set_pen_smoothing(level) + } + + /// Open the overlay's system font picker from the toolbar. + /// + /// The same route the color chip takes to the gradient picker: the toolbar + /// asks, the overlay owns the modal. + pub(super) fn apply_toolbar_open_font_picker(&mut self) -> bool { + self.open_font_picker(); + true + } + pub(super) fn apply_toolbar_set_eraser_mode(&mut self, mode: EraserMode) -> bool { self.set_eraser_mode(mode) } diff --git a/src/ui/toolbar/events.rs b/src/ui/toolbar/events.rs index 336951ad..4de7de90 100644 --- a/src/ui/toolbar/events.rs +++ b/src/ui/toolbar/events.rs @@ -87,8 +87,13 @@ pub enum ToolbarEvent { SetMarkerOpacity(f64), NudgeMarkerOpacity(f64), SetSpotlightMagnification(f64), + /// Smoothing passes applied to freehand and marker strokes on release. + SetPenSmoothing(u8), SetEraserMode(EraserMode), SetFont(FontDescriptor), + /// Open the overlay's system font picker. The toolbar's own font control + /// offers two families; every installed one lives behind this. + OpenFontPicker, SetFontSize(f64), NudgeFontSize(f64), ToggleFill(bool), diff --git a/src/ui/toolbar/model/activation.rs b/src/ui/toolbar/model/activation.rs index 18b4ff94..7789df62 100644 --- a/src/ui/toolbar/model/activation.rs +++ b/src/ui/toolbar/model/activation.rs @@ -73,6 +73,11 @@ impl ToolbarSlider { ToolbarSliderTarget::SpotlightMagnification => { ToolbarEvent::SetSpotlightMagnification(value) } + // Whole passes only: the spec snaps to a step of one, so the cast + // is the value the slider already settled on. + ToolbarSliderTarget::PenSmoothing => { + ToolbarEvent::SetPenSmoothing(value.round().clamp(0.0, 255.0) as u8) + } ToolbarSliderTarget::FontSize => ToolbarEvent::SetFontSize(value), ToolbarSliderTarget::UndoDelay => ToolbarEvent::SetUndoDelay(value), ToolbarSliderTarget::RedoDelay => ToolbarEvent::SetRedoDelay(value), @@ -96,6 +101,7 @@ pub(crate) enum ToolbarSliderTarget { Thickness, MarkerOpacity, SpotlightMagnification, + PenSmoothing, FontSize, UndoDelay, RedoDelay, @@ -136,6 +142,12 @@ impl ToolbarSliderSpec { step: Some(crate::draw::SPOTLIGHT_MAGNIFICATION_STEP), snap_to_step: true, }; + pub(crate) const PEN_SMOOTHING: Self = Self { + min: 0.0, + max: crate::draw::MAX_PEN_SMOOTHING as f64, + step: Some(1.0), + snap_to_step: true, + }; pub(crate) const THICKNESS: Self = Self { min: MIN_STROKE_THICKNESS, max: MAX_STROKE_THICKNESS, diff --git a/src/ui/toolbar/model/event_policy.rs b/src/ui/toolbar/model/event_policy.rs index 974d7ec7..8806b315 100644 --- a/src/ui/toolbar/model/event_policy.rs +++ b/src/ui/toolbar/model/event_policy.rs @@ -176,6 +176,7 @@ pub(crate) fn action_for_event(event: &ToolbarEvent) -> Option { ToolbarEvent::OpenAbout => Some(Action::OpenAbout), ToolbarEvent::OpenCommandPalette => Some(Action::ToggleCommandPalette), ToolbarEvent::PickScreenColor => Some(Action::PickScreenColor), + ToolbarEvent::OpenFontPicker => Some(Action::OpenFontPicker), _ => None, } } @@ -500,6 +501,7 @@ fn persistence_for_event(event: &ToolbarEvent) -> ToolbarPersistence { | ToolbarEvent::SetMarkerOpacity(_) | ToolbarEvent::NudgeMarkerOpacity(_) | ToolbarEvent::SetSpotlightMagnification(_) + | ToolbarEvent::SetPenSmoothing(_) | ToolbarEvent::SetEraserMode(_) | ToolbarEvent::SetFont(_) | ToolbarEvent::SetFontSize(_) @@ -580,6 +582,7 @@ fn persistence_for_event(event: &ToolbarEvent) -> ToolbarPersistence { | ToolbarEvent::PasteHexColor | ToolbarEvent::EditHexColor | ToolbarEvent::OpenColorPickerPopup + | ToolbarEvent::OpenFontPicker | ToolbarEvent::OpenPrecisionEntry(_) | ToolbarEvent::CommitPrecisionEntry { .. } | ToolbarEvent::CancelPrecisionEntry diff --git a/src/ui/toolbar/model/style_pill.rs b/src/ui/toolbar/model/style_pill.rs index b28e9a4a..e5fccfb5 100644 --- a/src/ui/toolbar/model/style_pill.rs +++ b/src/ui/toolbar/model/style_pill.rs @@ -25,7 +25,7 @@ use std::borrow::Cow; use crate::config::{ Action, QuickColorPalette, action_label, action_short_label, toolbar_item_ids as ids, }; -use crate::draw::FontDescriptor; +use crate::draw::{FontDescriptor, families_match}; use crate::input::{EraserMode, SelectionPropertyEntry, SelectionPropertyKind}; use crate::label_format::{format_binding_label, format_quick_color_tooltip}; use crate::ui::toolbar::{ToolContext, ToolOptionsKind, ToolbarEvent, ToolbarSnapshot}; @@ -92,6 +92,8 @@ pub(crate) enum StylePillControl { ThicknessValue, /// Marker opacity slider. OpacitySlider, + /// Pen/marker smoothing slider, in whole passes. + PenSmoothingSlider, /// Spotlight magnification slider. SpotlightMagnificationSlider, /// Shape fill toggle. @@ -111,6 +113,10 @@ pub(crate) enum StylePillControl { FontSizeValue, /// Sans/Mono font family segmented control. FontFamilySegment, + /// Button showing the family in use; opens the overlay's font picker over + /// every installed family. The segment beside it covers the two the + /// toolbar has always offered; this is how the rest are reachable. + FontFamilyPicker, /// Brush/Stroke eraser mode segmented control (the old checkbox /// semantics as a two-segment control emitting `SetEraserMode`). EraserModeSegment, @@ -262,6 +268,9 @@ impl StylePillSpec { if context.show_marker_opacity { controls.push(StylePillControl::OpacitySlider); } + if context.show_pen_smoothing && !plan.drop_style_extras { + controls.push(StylePillControl::PenSmoothingSlider); + } if context.tool_options_kind == ToolOptionsKind::Spotlight { controls.push(StylePillControl::SpotlightMagnificationSlider); } @@ -284,6 +293,9 @@ impl StylePillSpec { controls.push(StylePillControl::FontSizeSlider); controls.push(StylePillControl::FontSizeValue); controls.push(StylePillControl::FontFamilySegment); + if !plan.drop_style_extras { + controls.push(StylePillControl::FontFamilyPicker); + } } if context.show_eraser_mode { controls.push(StylePillControl::EraserModeSegment); diff --git a/src/ui/toolbar/model/style_pill/control.rs b/src/ui/toolbar/model/style_pill/control.rs index 4652f86c..3b7ff67e 100644 --- a/src/ui/toolbar/model/style_pill/control.rs +++ b/src/ui/toolbar/model/style_pill/control.rs @@ -8,6 +8,7 @@ impl StylePillControl { Self::ThicknessSlider => Cow::Borrowed("top.style.thickness"), Self::ThicknessValue => Cow::Borrowed("top.style.thickness-value"), Self::OpacitySlider => Cow::Borrowed("top.style.opacity"), + Self::PenSmoothingSlider => Cow::Borrowed("top.style.pen-smoothing"), Self::SpotlightMagnificationSlider => { Cow::Borrowed("top.style.spotlight-magnification") } @@ -26,6 +27,7 @@ impl StylePillControl { Self::FontSizeSlider => Cow::Borrowed("top.style.font-size"), Self::FontSizeValue => Cow::Borrowed("top.style.font-size-value"), Self::FontFamilySegment => Cow::Borrowed("top.style.font-family"), + Self::FontFamilyPicker => Cow::Borrowed("top.style.font-family-picker"), Self::EraserModeSegment => Cow::Borrowed("top.style.eraser-mode"), Self::SelectionCycle(kind) | Self::SelectionStepper(kind) => { Cow::Owned(format!("top.style.sel.{}", selection_kind_slug(kind))) @@ -39,10 +41,13 @@ impl StylePillControl { Self::ThicknessSlider | Self::OpacitySlider | Self::SpotlightMagnificationSlider + | Self::PenSmoothingSlider | Self::FontSizeSlider => StylePillRole::Slider, Self::ThicknessValue | Self::FontSizeValue => StylePillRole::Value, Self::FillToggle | Self::AutoNumberToggle => StylePillRole::Toggle, - Self::CounterReset(_) | Self::ArrowStyleCycle => StylePillRole::Button, + Self::CounterReset(_) | Self::ArrowStyleCycle | Self::FontFamilyPicker => { + StylePillRole::Button + } Self::FontFamilySegment | Self::EraserModeSegment => StylePillRole::Segmented, Self::SelectionCycle(_) => StylePillRole::Button, Self::SelectionStepper(_) => StylePillRole::Stepper, @@ -67,6 +72,8 @@ impl StylePillControl { Self::SpotlightMagnificationSlider => { ToolbarEvent::SetSpotlightMagnification(snapshot.spotlight_magnification) } + Self::PenSmoothingSlider => ToolbarEvent::SetPenSmoothing(snapshot.pen_smoothing), + Self::FontFamilyPicker => ToolbarEvent::OpenFontPicker, Self::FontSizeSlider => ToolbarEvent::SetFontSize(snapshot.font_size), Self::FillToggle => ToolbarEvent::ToggleFill(!snapshot.fill_enabled), Self::AutoNumberToggle => { @@ -134,6 +141,10 @@ impl StylePillControl { ToolbarSliderSpec::SPOTLIGHT_MAGNIFICATION, snapshot.spotlight_magnification, )), + Self::PenSmoothingSlider => Some(( + ToolbarSliderSpec::PEN_SMOOTHING, + f64::from(snapshot.pen_smoothing), + )), Self::FontSizeSlider => Some((ToolbarSliderSpec::FONT_SIZE, snapshot.font_size)), _ => None, } @@ -159,10 +170,18 @@ impl StylePillControl { Self::SpotlightMagnificationSlider => Some( crate::draw::format_spotlight_magnification(snapshot.spotlight_magnification), ), + // "Off" rather than "0": the number is a count of passes, and zero + // of them is a state worth naming rather than a quantity. + Self::PenSmoothingSlider => Some(if snapshot.pen_smoothing == 0 { + "Off".to_string() + } else { + snapshot.pen_smoothing.to_string() + }), Self::FontSizeSlider | Self::FontSizeValue => { Some(format!("{:.0}pt", snapshot.font_size)) } Self::ArrowStyleCycle => Some(snapshot.arrow_style.label().to_string()), + Self::FontFamilyPicker => Some(short_family_label(&snapshot.font.family)), Self::SelectionCycle(kind) | Self::SelectionStepper(kind) => { selection_entry(snapshot, kind).map(|entry| entry.value.clone()) } @@ -177,6 +196,20 @@ impl StylePillControl { .expect("this style-pill control has a live value") } + /// Whether this slider shows its value on its own rather than beside a + /// separate numeral button. + /// + /// Thickness and text size have numeral buttons of their own (which open + /// the precise-entry popup); the rest carry the readout inline. Both + /// frontends and the contract test key on this so they cannot disagree + /// about which sliders draw a value. + pub(crate) fn carries_inline_readout(self) -> bool { + matches!( + self, + Self::OpacitySlider | Self::SpotlightMagnificationSlider | Self::PenSmoothingSlider + ) + } + /// Whether this control can ever carry an inline status, and so needs a /// slot reserved for one even while the status is empty. Frontends that /// build widgets once and update them later key on this. @@ -224,6 +257,7 @@ impl StylePillControl { Cow::Borrowed(ToolContext::from_snapshot(snapshot).thickness_label) } Self::OpacitySlider => Cow::Borrowed("Marker opacity"), + Self::PenSmoothingSlider => Cow::Borrowed("Smoothing"), Self::SpotlightMagnificationSlider => Cow::Borrowed("Spotlight magnification"), Self::FontSizeSlider => Cow::Borrowed("Text size"), Self::ThicknessValue => Cow::Owned(format!("{:.0}px", snapshot.thickness)), @@ -233,6 +267,9 @@ impl StylePillControl { Self::AutoNumberToggle => Cow::Borrowed("Auto-number"), Self::CounterReset(_) => Cow::Borrowed("Reset"), Self::FontFamilySegment => Cow::Borrowed("Font"), + // The family in use, shortened: the pill is width-planned, and a + // display face can be named at any length. + Self::FontFamilyPicker => Cow::Owned(short_family_label(&snapshot.font.family)), Self::EraserModeSegment => Cow::Borrowed("Eraser mode"), Self::SelectionCycle(kind) | Self::SelectionStepper(kind) => Cow::Owned( selection_entry(snapshot, kind) @@ -281,6 +318,14 @@ impl StylePillControl { )), Self::SelectionCycle(kind) => selection_entry(snapshot, kind) .map(|entry| format!("{}: {}", entry.label, entry.value)), + Self::PenSmoothingSlider => Some( + "Smooth freehand and marker strokes when the pen lifts. Off keeps the exact path." + .to_string(), + ), + Self::FontFamilyPicker => Some(format!( + "{} - choose from every installed font", + snapshot.font.family + )), Self::ThicknessSlider | Self::OpacitySlider | Self::FontSizeSlider @@ -302,7 +347,7 @@ impl StylePillControl { "bold".to_string(), "normal".to_string(), )), - active: snapshot.font.family == "Sans", + active: families_match(&snapshot.font.family, "Sans"), tooltip: "Sans font".to_string(), }, StylePillSegment { @@ -313,7 +358,7 @@ impl StylePillControl { "normal".to_string(), "normal".to_string(), )), - active: snapshot.font.family == "Monospace", + active: families_match(&snapshot.font.family, "Monospace"), tooltip: "Monospace font".to_string(), }, ]), @@ -402,3 +447,48 @@ impl StylePillControl { .expect("this style-pill stepper has minus/plus halves") } } + +/// A family name cut to something a width-planned pill can hold. +/// +/// The full name is in the tooltip. Truncating here rather than in the +/// frontends keeps both toolbars showing the same string, which is what the +/// shared model is for. +fn short_family_label(family: &str) -> String { + const MAX_CHARS: usize = 12; + let mut chars = family.chars(); + let head: String = chars.by_ref().take(MAX_CHARS).collect(); + if chars.next().is_some() { + format!("{}\u{2026}", head.trim_end()) + } else { + head + } +} + +#[cfg(test)] +mod family_label_tests { + use super::short_family_label; + + #[test] + fn a_short_family_name_is_shown_whole() { + assert_eq!(short_family_label("Sans"), "Sans"); + assert_eq!(short_family_label("JetBrains M"), "JetBrains M"); + } + + #[test] + fn a_long_family_name_is_cut_rather_than_widening_the_pill() { + let label = short_family_label("Noto Sans CJK JP Black"); + + assert!(label.chars().count() <= 13, "got {label:?}"); + assert!(label.ends_with('\u{2026}'), "got {label:?}"); + } + + #[test] + fn cutting_counts_characters_not_bytes() { + // A name that is not Latin still gets a readable head rather than a + // slice through the middle of a code point. + let label = short_family_label("\u{6e90}\u{754c}\u{9ed1}\u{4f53} CN Regular Extra"); + + assert!(label.ends_with('\u{2026}'), "got {label:?}"); + assert!(label.starts_with('\u{6e90}'), "got {label:?}"); + } +} diff --git a/src/ui/toolbar/model/style_pill/tests/tool_states.rs b/src/ui/toolbar/model/style_pill/tests/tool_states.rs index a7d9a8ea..db4ab834 100644 --- a/src/ui/toolbar/model/style_pill/tests/tool_states.rs +++ b/src/ui/toolbar/model/style_pill/tests/tool_states.rs @@ -54,6 +54,92 @@ fn spotlight_state_is_a_magnification_slider_without_stroke_controls() { assert_eq!(slider.value_text(&snapshot).as_deref(), Some("2.25x")); } +#[test] +fn the_smoothing_slider_reads_and_writes_whole_passes() { + let mut snapshot = snapshot_for_tool(Tool::Pen); + snapshot.pen_smoothing = 3; + + let slider = StylePillControl::PenSmoothingSlider; + assert_eq!( + slider.event(&snapshot), + Some(ToolbarEvent::SetPenSmoothing(3)) + ); + assert_eq!( + slider.slider(&snapshot), + Some((ToolbarSliderSpec::PEN_SMOOTHING, 3.0)) + ); + assert_eq!(slider.value_text(&snapshot).as_deref(), Some("3")); + + // Zero passes is a state, not a quantity. + snapshot.pen_smoothing = 0; + assert_eq!(slider.value_text(&snapshot).as_deref(), Some("Off")); +} + +#[test] +fn the_smoothing_slider_follows_the_tool_it_can_change() { + // Pen and Marker accumulate the paths smoothing runs on. Line and Blur + // share the Stroke control group but draw no path, so a slider there + // would be a control that does nothing to what is about to be drawn. + for tool in [Tool::Pen, Tool::Marker] { + let spec = StylePillSpec::build(&snapshot_for_tool(tool), &plan()); + assert!( + control_ids(&spec).contains(&"top.style.pen-smoothing".to_string()), + "{tool:?} draws a smoothed stroke" + ); + } + for tool in [Tool::Line, Tool::Blur, Tool::Rect, Tool::Eraser] { + let spec = StylePillSpec::build(&snapshot_for_tool(tool), &plan()); + assert!( + !control_ids(&spec).contains(&"top.style.pen-smoothing".to_string()), + "{tool:?} draws nothing smoothing reaches" + ); + } +} + +#[test] +fn the_font_button_shows_the_family_in_use_and_opens_the_picker() { + let mut snapshot = snapshot(); + snapshot.text_active = true; + snapshot.font = crate::draw::FontDescriptor::new( + "Noto Sans CJK JP Black".to_string(), + "normal".to_string(), + "normal".to_string(), + ); + + let button = StylePillControl::FontFamilyPicker; + assert_eq!(button.event(&snapshot), Some(ToolbarEvent::OpenFontPicker)); + assert_eq!(button.role(), StylePillRole::Button); + assert!( + button.label(&snapshot).chars().count() <= 13, + "the pill is width-planned; got {:?}", + button.label(&snapshot) + ); + assert_eq!( + button.tooltip(&snapshot).as_deref(), + Some("Noto Sans CJK JP Black - choose from every installed font"), + "the full name has to be readable somewhere" + ); +} + +#[test] +fn a_squeezed_pill_sheds_its_extras_before_it_sheds_the_color_chip() { + let mut snapshot = snapshot_for_tool(Tool::Pen); + snapshot.show_text_controls = true; + let mut squeezed = plan(); + squeezed.drop_style_extras = true; + + let ids = control_ids(&StylePillSpec::build(&snapshot, &squeezed)); + + assert!(!ids.contains(&"top.style.pen-smoothing".to_string())); + assert!(!ids.contains(&"top.style.font-family-picker".to_string())); + assert!( + ids.contains(&"top.style.color-chip".to_string()) + && ids.contains(&"top.style.thickness".to_string()) + && ids.contains(&"top.style.font-family".to_string()), + "the pill's core stays: {ids:?}" + ); +} + #[test] fn spotlight_state_exposes_an_inline_missing_source_hint() { let mut snapshot = snapshot_for_tool(Tool::Spotlight); @@ -141,6 +227,8 @@ fn stroke_state_orders_chip_swatches_slider_and_numeral() { expected.extend((0..swatch_count).map(|index| format!("top.style.swatch.{index}"))); expected.push("top.style.thickness".to_string()); expected.push("top.style.thickness-value".to_string()); + // The pen draws the strokes smoothing applies to, so the pill offers it. + expected.push("top.style.pen-smoothing".to_string()); assert_eq!(control_ids(&spec), expected); let chip = spec.controls()[0]; @@ -425,16 +513,22 @@ fn text_state_is_swatches_size_and_font_segment() { assert_eq!(spec.state(), StylePillState::Text); let ids = control_ids(&spec); assert!(ids.contains(&"top.style.color-chip".to_string())); - let tail: Vec<_> = ids.iter().rev().take(3).rev().cloned().collect(); + let tail: Vec<_> = ids.iter().rev().take(4).rev().cloned().collect(); assert_eq!( tail, [ "top.style.font-size", "top.style.font-size-value", "top.style.font-family", + // Sans/Mono are the two the segment offers; this reaches the rest. + "top.style.font-family-picker", ] ); assert!(!ids.contains(&"top.style.thickness".to_string())); + assert!( + !ids.contains(&"top.style.pen-smoothing".to_string()), + "typing text draws no stroke for smoothing to reach" + ); let slider = StylePillControl::FontSizeSlider; assert_eq!( @@ -466,7 +560,26 @@ fn text_state_is_swatches_size_and_font_segment() { &segments[1].event, ToolbarEvent::SetFont(font) if font.family == "Monospace" )); - assert_eq!(segments[0].active, snapshot.font.family == "Sans"); + assert!(segments[0].active); + assert!(!segments[1].active); +} + +#[test] +fn font_family_segments_use_the_shared_trimmed_case_insensitive_identity() { + let mut snapshot = snapshot(); + snapshot.font.family = " sAnS ".to_string(); + let segments = StylePillControl::FontFamilySegment + .segments(&snapshot) + .expect("font segments"); + assert!(segments[0].active); + assert!(!segments[1].active); + + snapshot.font.family = " MONOSPACE ".to_string(); + let segments = StylePillControl::FontFamilySegment + .segments(&snapshot) + .expect("font segments"); + assert!(!segments[0].active); + assert!(segments[1].active); } #[test] diff --git a/src/ui/toolbar/model/top_spec/spec.rs b/src/ui/toolbar/model/top_spec/spec.rs index b8c50beb..2ded8120 100644 --- a/src/ui/toolbar/model/top_spec/spec.rs +++ b/src/ui/toolbar/model/top_spec/spec.rs @@ -13,6 +13,14 @@ pub(crate) struct TopStripPlan { /// non-essential island and yield first under width pressure, before any /// tool or utility leaves the strip. pub(crate) drop_presets: bool, + /// Whether the style pill has shed its secondary controls for width. + /// + /// The rung directly above `compact`, which hides the pill outright. The + /// smoothing slider and the font-family picker button are the two controls + /// with somewhere else to be — a keybinding and the command palette — so + /// they leave before the color chip, the size slider, and the rest of the + /// pill do. + pub(crate) drop_style_extras: bool, pub(crate) compact: bool, } @@ -25,6 +33,7 @@ impl TopStripPlan { dropped_tools: Vec::new(), dropped_utilities: Vec::new(), drop_presets: false, + drop_style_extras: false, compact: false, } } diff --git a/src/ui/toolbar/snapshot/build.rs b/src/ui/toolbar/snapshot/build.rs index 8ffd458d..e4b1c190 100644 --- a/src/ui/toolbar/snapshot/build.rs +++ b/src/ui/toolbar/snapshot/build.rs @@ -112,6 +112,7 @@ impl ToolbarSnapshot { eraser_kind, eraser_mode, marker_opacity: state.marker_opacity, + pen_smoothing: state.pen_smoothing, spotlight_magnification: state.spotlight_magnification, // Filled in by the backend that renders the canvas; see the field. spotlight_magnifier_source: None, diff --git a/src/ui/toolbar/snapshot/types.rs b/src/ui/toolbar/snapshot/types.rs index ecace1ff..7a041164 100644 --- a/src/ui/toolbar/snapshot/types.rs +++ b/src/ui/toolbar/snapshot/types.rs @@ -72,6 +72,12 @@ pub struct ToolContext { pub show_polygon_sides_control: bool, /// Whether font controls should be shown pub show_font_controls: bool, + /// Whether the pen-smoothing slider should be shown. + /// + /// Follows the tool rather than the setting: smoothing is one number for + /// the whole program, but it only reaches strokes the pen and marker + /// accumulate, so a Line or Blur tool has nothing for the slider to do. + pub show_pen_smoothing: bool, } impl ToolContext { @@ -100,6 +106,7 @@ impl ToolContext { show_marker_opacity: snapshot.show_marker_opacity_section, show_polygon_sides_control: false, show_font_controls: true, + show_pen_smoothing: false, }; } @@ -130,6 +137,7 @@ impl ToolContext { if effective_tool == Tool::RegularPolygon { ctx.show_polygon_sides_control = true; } + ctx.show_pen_smoothing = effective_tool.smooths_strokes(); ctx } @@ -147,6 +155,8 @@ impl ToolContext { show_marker_opacity: profile.show_marker_opacity(), show_polygon_sides_control: false, show_font_controls: false, + // Set from the tool by `from_snapshot`; a profile alone cannot say. + show_pen_smoothing: false, } } @@ -178,6 +188,7 @@ impl ToolContext { show_marker_opacity, show_polygon_sides_control, show_font_controls, + show_pen_smoothing: true, } } } @@ -255,6 +266,8 @@ pub struct ToolbarSnapshot { pub eraser_kind: EraserKind, pub eraser_mode: EraserMode, pub marker_opacity: f64, + /// Smoothing passes applied to freehand and marker strokes on release. + pub pen_smoothing: u8, pub spotlight_magnification: f64, /// Whether the active canvas has complete pixels for magnifying a Spotlight, /// or `None` when no backend has answered yet. diff --git a/tests/cli.rs b/tests/cli.rs index 26184c40..0df9efbd 100644 --- a/tests/cli.rs +++ b/tests/cli.rs @@ -256,6 +256,7 @@ fn saved_tool_state() -> wayscriber::session::ToolStateSnapshot { eraser_mode: wayscriber::input::EraserMode::Brush, blur_style: Default::default(), recent_colors: Vec::new(), + pen_smoothing: None, marker_opacity: Some(0.32), spotlight_magnification: None, fill_enabled: Some(false), From d34f63e9be050ff56fdc8afe1fef3ece79a0c00d Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Wed, 26 Aug 2026 13:07:17 +0200 Subject: [PATCH 5/6] fix(drawing): finish font controls polish --- config.example.toml | 2 + configurator/src/app/pages/drawing/font.rs | 324 ++++++++++++++++-- configurator/src/app/search/terms.rs | 3 + configurator/src/app/update/fields/drawing.rs | 51 +++ configurator/src/app/update/fields/tests.rs | 38 +- configurator/src/app/update/mod.rs | 6 + configurator/src/messages.rs | 6 + .../src/models/config/draft/from_config.rs | 3 +- configurator/src/models/config/draft/mod.rs | 6 +- configurator/src/models/config/font_cycle.rs | 250 ++++++++++++++ configurator/src/models/config/mod.rs | 1 + configurator/src/models/config/setters.rs | 1 - configurator/src/models/config/tests.rs | 63 ++-- .../src/models/config/to_config/drawing.rs | 16 +- configurator/src/models/fields/toggles.rs | 1 - docs/CONFIG.md | 9 + src/toolbar_gtk/view/top_bar/style_pill.rs | 17 +- src/toolbar_gtk/view/top_bar/tests.rs | 37 +- src/toolbar_gtk/widgets.rs | 13 +- 19 files changed, 745 insertions(+), 102 deletions(-) create mode 100644 configurator/src/models/config/font_cycle.rs diff --git a/config.example.toml b/config.example.toml index 1d37f11a..665fc990 100644 --- a/config.example.toml +++ b/config.example.toml @@ -1097,6 +1097,8 @@ marker_opacity = 0.32 # Font families the "Cycle Font Family" action steps through (Shift+T). # Any installed family name works. An empty list turns the action off. # Names are matched without case, so ["Sans", "sans"] loads as one entry. +# The configurator edits this as a row per font, each picking from the +# families installed on the machine. font_cycle = ["Sans", "Monospace", "Serif"] # How much a finished freehand or marker stroke is smoothed (0 - 6). diff --git a/configurator/src/app/pages/drawing/font.rs b/configurator/src/app/pages/drawing/font.rs index 603e95e5..cbe3f07b 100644 --- a/configurator/src/app/pages/drawing/font.rs +++ b/configurator/src/app/pages/drawing/font.rs @@ -1,10 +1,17 @@ +use std::rc::Rc; + +use relm4::{ComponentSender, adw, gtk}; + +use adw::prelude::*; + use crate::messages::Message; use crate::models::{FontStyleOption, FontWeightOption, TextField}; -use wayscriber::draw::family_is_installed; +use wayscriber::draw::{families_match, family_is_installed, system_font_families}; use super::super::super::search::SearchArea; -use super::super::PageBuilder; -use super::{conditional_section, section_entry_row}; +use super::super::super::state::ConfiguratorApp; +use super::super::{PageBuilder, set_selected_blocked}; +use super::{boxed_list, conditional_section, icon_button, section_entry_row}; pub(super) fn build(page: &mut PageBuilder) { page.group_in_area("Font", SearchArea::DrawingFont) @@ -14,12 +21,6 @@ pub(super) fn build(page: &mut PageBuilder) { |value| Message::TextChanged(TextField::DrawingFontFamily, value), |app| validate_installed_family(&app.draft.drawing_font_family), ) - .entry_row_validated( - "Font cycle list (comma separated)", - |app| app.draft.drawing_font_cycle.clone(), - |value| Message::TextChanged(TextField::DrawingFontCycle, value), - |app| validate_installed_family_list(&app.draft.drawing_font_cycle), - ) .combo_row( "Font weight", "", @@ -59,6 +60,258 @@ pub(super) fn build(page: &mut PageBuilder) { |value| Message::TextChanged(TextField::DrawingFontStyle, value), |_app| None, ); + + build_font_cycle(page); +} + +/// `GTK_INVALID_LIST_POSITION`: what a `GtkSingleSelection` reads as "nothing +/// selected". This preserves a missing configured family instead of making +/// the first installed family look selected. +const NO_SELECTION: u32 = u32::MAX; + +/// The ordered list `Shift+T` walks, as one row per family. +/// +/// It used to be a comma-separated line, which asked for a family name spelled +/// exactly right and could not express one containing a comma. A row per entry +/// removes both, and lets each row offer every installed family rather than +/// asking the user to know what is installed. +fn build_font_cycle(page: &mut PageBuilder) { + page.group_in_area("Font cycle", SearchArea::DrawingFont); + + // An activatable ActionRow rather than AdwButtonRow: ButtonRow needs + // libadwaita 1.6 and the crate's feature floor is 1.4 (Ubuntu 24.04). + let add = adw::ActionRow::builder() + .title("Add font") + .subtitle("Shift+T steps through this list in order") + .activatable(true) + .build(); + add.add_prefix(>k::Image::from_icon_name("list-add-symbolic")); + { + let sender = page.sender(); + add.connect_activated(move |_| sender.input(Message::FontCycleAdded)); + } + page.custom(&add); + + let empty = gtk::Label::builder() + .label("No fonts listed: Shift+T does nothing until one is added.") + .wrap(true) + .xalign(0.0) + .margin_top(6) + .css_classes(["dim-label"]) + .build(); + page.custom(&empty); + page.bind({ + let empty = empty.clone(); + move |app, _summary| { + empty.set_visible(app.draft.drawing_font_cycle.is_empty()); + } + }); + + let list = boxed_list(); + list.set_margin_top(6); + page.custom(&list); + + // One model for every row. 269 families is a list worth building once, and + // sharing it means a row added later costs nothing to populate. + let installed: Rc<[String]> = system_font_families().to_vec().into(); + let installed_names: Vec<&str> = installed.iter().map(String::as_str).collect(); + let families = gtk::StringList::new(&installed_names); + + let sender = page.sender(); + let mut rows: Vec = Vec::new(); + page.bind(move |app, _summary| { + let layouts = font_cycle_layouts(app); + if !rows + .iter() + .map(|row| row.layout) + .eq(layouts.iter().copied()) + { + rows = rebuild_font_cycle(&list, &families, Rc::clone(&installed), &layouts, &sender); + } + for (family, row) in app + .draft + .drawing_font_cycle + .entries() + .iter() + .zip(rows.iter()) + { + (row.refresh)(family); + } + }); +} + +/// Everything a row shows before the family is written into it. +/// +/// The rebuild trigger, as with quick colors: the binding compares what it +/// built against what the model asks for now. The chosen family stays out — +/// rebuilding a row would close the dropdown the user is searching in. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct FontCycleLayout { + index: usize, + can_move_up: bool, + can_move_down: bool, +} + +fn font_cycle_layouts(app: &ConfiguratorApp) -> Vec { + let count = app.draft.drawing_font_cycle.len(); + (0..count) + .map(|index| FontCycleLayout { + index, + can_move_up: index > 0, + can_move_down: index + 1 < count, + }) + .collect() +} + +type FontRowRefresh = Box; + +struct BoundFontRow { + layout: FontCycleLayout, + refresh: FontRowRefresh, +} + +fn rebuild_font_cycle( + list: >k::ListBox, + families: >k::StringList, + family_names: Rc<[String]>, + layouts: &[FontCycleLayout], + sender: &ComponentSender, +) -> Vec { + while let Some(child) = list.first_child() { + list.remove(&child); + } + + layouts + .iter() + .map(|layout| { + let (row, refresh) = + build_font_cycle_row(*layout, families, Rc::clone(&family_names), sender); + list.append(&row); + BoundFontRow { + layout: *layout, + refresh, + } + }) + .collect() +} + +fn build_font_cycle_row( + layout: FontCycleLayout, + families: >k::StringList, + family_names: Rc<[String]>, + sender: &ComponentSender, +) -> (adw::ComboRow, FontRowRefresh) { + let index = layout.index; + let row = adw::ComboRow::builder() + .title(format!("{}.", index + 1)) + .model(families) + // 269 families is past what scrolling a menu can find. The search box + // needs an expression to know which property to match on. + .enable_search(true) + .expression(gtk::PropertyExpression::new( + gtk::StringObject::static_type(), + None::, + "string", + )) + .build(); + row.set_list_factory(Some(&family_preview_factory())); + + let up = icon_button("go-up-symbolic", "Move up"); + up.set_sensitive(layout.can_move_up); + { + let sender = sender.clone(); + up.connect_clicked(move |_| sender.input(Message::FontCycleMoved(index, -1))); + } + let down = icon_button("go-down-symbolic", "Move down"); + down.set_sensitive(layout.can_move_down); + { + let sender = sender.clone(); + down.connect_clicked(move |_| sender.input(Message::FontCycleMoved(index, 1))); + } + let remove = icon_button("user-trash-symbolic", "Remove"); + { + let sender = sender.clone(); + remove.connect_clicked(move |_| sender.input(Message::FontCycleRemoved(index))); + } + row.add_suffix(&up); + row.add_suffix(&down); + row.add_suffix(&remove); + + let handler = { + let sender = sender.clone(); + row.connect_selected_notify(move |row| { + let Some(family) = selected_family(row) else { + return; + }; + sender.input(Message::FontCycleChanged(index, family)); + }) + }; + + let refresh_row = row.clone(); + let refresh: FontRowRefresh = Box::new(move |family: &str| { + let position = family_position(&family_names, family); + set_selected_blocked(&refresh_row, &handler, position); + // A family in the file that this machine does not have still shows its + // name, rather than silently reading as whatever sits at position zero. + refresh_row.set_subtitle(&missing_family_note(family).unwrap_or_default()); + }); + + (row, refresh) +} + +/// A list factory that draws every family in its own face. +/// +/// The point of the whole control: nobody picks a typeface by reading its name. +/// The same reason the in-overlay picker lays each of its rows out in the font +/// it names. +fn family_preview_factory() -> gtk::SignalListItemFactory { + let factory = gtk::SignalListItemFactory::new(); + factory.connect_setup(|_, item| { + let Some(item) = item.downcast_ref::() else { + return; + }; + let label = gtk::Label::builder().xalign(0.0).build(); + item.set_child(Some(&label)); + }); + factory.connect_bind(|_, item| { + let Some(item) = item.downcast_ref::() else { + return; + }; + let Some(label) = item.child().and_downcast::() else { + return; + }; + let Some(family) = item + .item() + .and_downcast::() + .map(|object| object.string().to_string()) + else { + return; + }; + label.set_label(&family); + let attributes = gtk::pango::AttrList::new(); + attributes.insert(gtk::pango::AttrFontDesc::new( + >k::pango::FontDescription::from_string(&family), + )); + label.set_attributes(Some(&attributes)); + }); + factory +} + +/// The family the row is showing, if the model has one at that position. +fn selected_family(row: &adw::ComboRow) -> Option { + row.selected_item() + .and_downcast::() + .map(|object| object.string().to_string()) +} + +/// Where `family` sits in the installed catalog, or no selection when the +/// catalog does not hold it. +fn family_position(families: &[String], family: &str) -> u32 { + families + .iter() + .position(|installed| families_match(installed, family)) + .and_then(|position| u32::try_from(position).ok()) + .unwrap_or(NO_SELECTION) } /// Warn about a family the font system cannot find. @@ -78,18 +331,18 @@ fn validate_installed_family(value: &str) -> Option { )) } -/// The same check across a comma-separated list, naming every missing family. -fn validate_installed_family_list(value: &str) -> Option { - let missing: Vec<&str> = value - .split(',') - .map(str::trim) - .filter(|family| !family.is_empty() && !family_is_installed(family)) - .collect(); - match missing.len() { - 0 => None, - 1 => Some(format!("\"{}\" is not installed", missing[0])), - _ => Some(format!("Not installed: {}", missing.join(", "))), +/// The same check for one row of the cycle list. +/// +/// A configuration written on another machine can name a family this one does +/// not have. The dropdown cannot show it — its model is what is installed — so +/// without this the row would fall back to position zero and read as a font the +/// file never asked for. +fn missing_family_note(family: &str) -> Option { + let trimmed = family.trim(); + if trimmed.is_empty() || family_is_installed(trimmed) { + return None; } + Some(format!("\"{trimmed}\" is not installed on this system")) } #[cfg(test)] @@ -119,24 +372,25 @@ mod tests { } #[test] - fn the_list_check_names_every_missing_family_and_ignores_the_present_ones() { - let present = installed(); - - assert_eq!( - validate_installed_family_list(&format!("{present}, {present}")), - None - ); + fn a_cycle_row_naming_a_font_this_machine_lacks_says_so() { + // The dropdown's model is what is installed, so it cannot show the + // family a config written elsewhere asked for. Without the note the row + // falls back to position zero and reads as a font nobody chose. + assert_eq!(missing_family_note(&installed()), None); + assert_eq!(missing_family_note(""), None); - let message = validate_installed_family_list(&format!("{present}, Nope One, Nope Two")) - .expect("missing families warn"); - assert!(message.contains("Nope One")); - assert!(message.contains("Nope Two")); - assert!(!message.contains(&present)); + let note = missing_family_note("Wayscriber No Such Font 9000").expect("missing warns"); + assert!(note.contains("Wayscriber No Such Font 9000")); } #[test] - fn blank_and_trailing_separators_in_the_list_are_not_treated_as_missing_fonts() { - assert_eq!(validate_installed_family_list(""), None); - assert_eq!(validate_installed_family_list(" , , "), None); + fn a_missing_cycle_family_is_unselected_instead_of_impersonating_the_first_font() { + let families = vec!["Sans".to_string(), "Serif".to_string()]; + + assert_eq!(family_position(&families, "sans"), 0); + assert_eq!( + family_position(&families, "Wayscriber No Such Font 9000"), + NO_SELECTION + ); } } diff --git a/configurator/src/app/search/terms.rs b/configurator/src/app/search/terms.rs index d98ed61d..75b40491 100644 --- a/configurator/src/app/search/terms.rs +++ b/configurator/src/app/search/terms.rs @@ -118,6 +118,9 @@ pub(super) const DRAWING_FONT_TERMS: &[&str] = &[ "custom or numeric weight", "font style", "custom style", + "font cycle", + "cycle", + "add font", "text", "family", "weight", diff --git a/configurator/src/app/update/fields/drawing.rs b/configurator/src/app/update/fields/drawing.rs index 1761a9a0..8497834d 100644 --- a/configurator/src/app/update/fields/drawing.rs +++ b/configurator/src/app/update/fields/drawing.rs @@ -141,6 +141,57 @@ impl ConfiguratorApp { Vec::new() } + pub(in crate::app::update) fn handle_font_cycle_added(&mut self) -> Vec { + match self.draft.drawing_font_cycle.add() { + Ok(()) => { + self.status = StatusMessage::idle(); + self.refresh_dirty_flag(); + } + Err(error) => self.status = StatusMessage::warning(error.to_string()), + } + Vec::new() + } + + pub(in crate::app::update) fn handle_font_cycle_removed( + &mut self, + index: usize, + ) -> Vec { + self.status = StatusMessage::idle(); + if self.draft.drawing_font_cycle.remove(index) { + self.refresh_dirty_flag(); + } + Vec::new() + } + + pub(in crate::app::update) fn handle_font_cycle_moved( + &mut self, + index: usize, + delta: isize, + ) -> Vec { + self.status = StatusMessage::idle(); + if self.draft.drawing_font_cycle.move_entry(index, delta) { + self.refresh_dirty_flag(); + } + Vec::new() + } + + pub(in crate::app::update) fn handle_font_cycle_changed( + &mut self, + index: usize, + family: String, + ) -> Vec { + match self.draft.drawing_font_cycle.set(index, family) { + Ok(changed) => { + self.status = StatusMessage::idle(); + if changed { + self.refresh_dirty_flag(); + } + } + Err(error) => self.status = StatusMessage::warning(error.to_string()), + } + Vec::new() + } + /// Carries each surviving quick-color edit buffer to its row's new index. /// /// The buffer can be half-typed and therefore absent from the draft. Add, diff --git a/configurator/src/app/update/fields/tests.rs b/configurator/src/app/update/fields/tests.rs index 338361cd..3ad1cb6d 100644 --- a/configurator/src/app/update/fields/tests.rs +++ b/configurator/src/app/update/fields/tests.rs @@ -2,7 +2,7 @@ use super::*; use wayscriber::config::{ColorSpec, Config}; use crate::app::state::ConfiguratorApp; -use crate::models::{ColorMode, ColorPickerId, NamedColorOption}; +use crate::models::{ColorMode, ColorPickerId, ConfigDraft, NamedColorOption}; #[test] fn quick_color_mode_change_to_rgb_materializes_named_hex_preview() { @@ -156,3 +156,39 @@ fn quick_color_label_edit_does_not_change_slot_colors() { NamedColorOption::Red ); } + +#[test] +fn choosing_a_duplicate_font_keeps_the_list_and_explains_the_rejection() { + let (mut app, _effects) = ConfiguratorApp::new_app(); + let mut config = Config::default(); + config.drawing.font_cycle = vec!["Sans".to_string(), "Serif".to_string()]; + app.draft = ConfigDraft::from_config(&config); + + let _ = app.handle_font_cycle_changed(1, "sans".to_string()); + + assert_eq!(app.draft.drawing_font_cycle.entries(), ["Sans", "Serif"]); + let feedback = app + .status + .text() + .expect("duplicate selection stays visible"); + assert!(feedback.contains("already in the font cycle")); + assert!(feedback.contains("remove its other row")); +} + +#[test] +fn add_font_never_creates_a_blank_or_duplicate_when_the_catalog_is_exhausted() { + let (mut app, _effects) = ConfiguratorApp::new_app(); + let mut config = Config::default(); + config.drawing.font_cycle = wayscriber::draw::system_font_families().to_vec(); + app.draft = ConfigDraft::from_config(&config); + let before = app.draft.drawing_font_cycle.entries().to_vec(); + + let _ = app.handle_font_cycle_added(); + + assert_eq!(app.draft.drawing_font_cycle.entries(), before); + let feedback = app.status.text().expect("exhausted catalog stays visible"); + assert!( + feedback.contains("already in the font cycle") + || feedback.contains("No installed fonts are available") + ); +} diff --git a/configurator/src/app/update/mod.rs b/configurator/src/app/update/mod.rs index a48ac91f..feaec058 100644 --- a/configurator/src/app/update/mod.rs +++ b/configurator/src/app/update/mod.rs @@ -134,6 +134,12 @@ impl ConfiguratorApp { Message::QuickColorAdded => self.handle_quick_color_added(), Message::QuickColorRemoved(index) => self.handle_quick_color_removed(index), Message::QuickColorMoved(index, delta) => self.handle_quick_color_moved(index, delta), + Message::FontCycleAdded => self.handle_font_cycle_added(), + Message::FontCycleRemoved(index) => self.handle_font_cycle_removed(index), + Message::FontCycleMoved(index, delta) => self.handle_font_cycle_moved(index, delta), + Message::FontCycleChanged(index, family) => { + self.handle_font_cycle_changed(index, family) + } Message::QuickColorModeChanged(index, mode) => { self.handle_quick_color_mode_changed(index, mode) } diff --git a/configurator/src/messages.rs b/configurator/src/messages.rs index 7ac2eeb7..84cc6de0 100644 --- a/configurator/src/messages.rs +++ b/configurator/src/messages.rs @@ -109,6 +109,12 @@ pub enum Message { QuickColorAdded, QuickColorRemoved(usize), QuickColorMoved(usize, isize), + /// Append a row to the font cycle, on a family the list does not hold yet. + FontCycleAdded, + FontCycleRemoved(usize), + FontCycleMoved(usize, isize), + /// One row picked a different family. + FontCycleChanged(usize, String), QuickColorModeChanged(usize, ColorMode), QuickNamedColorSelected(usize, NamedColorOption), EraserModeChanged(EraserModeOption), diff --git a/configurator/src/models/config/draft/from_config.rs b/configurator/src/models/config/draft/from_config.rs index 37598c3e..62b137de 100644 --- a/configurator/src/models/config/draft/from_config.rs +++ b/configurator/src/models/config/draft/from_config.rs @@ -15,6 +15,7 @@ use super::super::super::fields::{ use super::super::super::keybindings::KeybindingsDraft; use super::super::super::util::format_float; use super::super::boards::BoardsDraft; +use super::super::font_cycle::FontCycleDraft; use super::super::presets::PresetsDraft; use super::super::quick_colors::QuickColorsDraft; use super::super::render_profiles::RenderProfilesDraft; @@ -73,7 +74,7 @@ impl ConfigDraft { drawing_polygon_sides: config.drawing.polygon_sides.to_string(), drawing_marker_opacity: format_float(config.drawing.marker_opacity), drawing_pen_smoothing: config.drawing.pen_smoothing.to_string(), - drawing_font_cycle: config.drawing.font_cycle.join(", "), + drawing_font_cycle: FontCycleDraft::from_entries(config.drawing.font_cycle.clone()), drawing_hit_test_tolerance: format_float(config.drawing.hit_test_tolerance), drawing_hit_test_linear_threshold: config.drawing.hit_test_linear_threshold.to_string(), drawing_undo_stack_limit: config.drawing.undo_stack_limit.to_string(), diff --git a/configurator/src/models/config/draft/mod.rs b/configurator/src/models/config/draft/mod.rs index 106a1eec..9c83539c 100644 --- a/configurator/src/models/config/draft/mod.rs +++ b/configurator/src/models/config/draft/mod.rs @@ -14,6 +14,7 @@ use super::super::fields::{ use super::super::fields::{PressureThicknessEditModeOption, PressureThicknessEntryModeOption}; use super::super::keybindings::KeybindingsDraft; use super::boards::BoardsDraft; +use super::font_cycle::FontCycleDraft; use super::presets::PresetsDraft; use super::quick_colors::QuickColorsDraft; use super::render_profiles::RenderProfilesDraft; @@ -41,7 +42,10 @@ pub struct ConfigDraft { pub drawing_polygon_sides: String, pub drawing_marker_opacity: String, pub drawing_pen_smoothing: String, - pub drawing_font_cycle: String, + /// Families `Shift+T` steps through, in order. A list rather than a + /// comma-separated line: a family name can contain a comma, and the row + /// editor picks from what is installed instead of asking for exact text. + pub drawing_font_cycle: FontCycleDraft, pub drawing_hit_test_tolerance: String, pub drawing_hit_test_linear_threshold: String, pub drawing_undo_stack_limit: String, diff --git a/configurator/src/models/config/font_cycle.rs b/configurator/src/models/config/font_cycle.rs new file mode 100644 index 00000000..38265316 --- /dev/null +++ b/configurator/src/models/config/font_cycle.rs @@ -0,0 +1,250 @@ +//! The font-cycle list as an ordered set of families. +//! +//! `[drawing] font_cycle` is a TOML array, and this is the editor's copy of it. +//! It used to be one comma-separated line, which meant a family whose own name +//! contained a comma could not be typed back in — and made every entry a string +//! the user had to spell exactly. Keeping the list a list removes both. + +use std::fmt; + +use wayscriber::draw::{families_match, system_font_families}; + +/// A rejected row edit that the configurator can leave visible and actionable. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum FontCycleEditError { + BlankFamily, + DuplicateFamily(String), + NoInstalledFonts, + AllInstalledFontsListed, + MissingEntry, +} + +impl fmt::Display for FontCycleEditError { + fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::BlankFamily => formatter.write_str("A font cycle entry cannot be blank"), + Self::DuplicateFamily(family) => write!( + formatter, + "\"{family}\" is already in the font cycle; choose a different font or remove its other row" + ), + Self::NoInstalledFonts => { + formatter.write_str("No installed fonts are available to add") + } + Self::AllInstalledFontsListed => { + formatter.write_str("Every installed font is already in the font cycle") + } + Self::MissingEntry => formatter.write_str("That font cycle row no longer exists"), + } + } +} + +/// The editor's ordered font-cycle entries. +/// +/// Owning the entries here keeps blank and duplicate families from becoming +/// representable through the row editor. Config files from older versions are +/// normalized at the draft boundary while retaining their original order. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct FontCycleDraft { + entries: Vec, +} + +impl FontCycleDraft { + pub(crate) fn from_entries(entries: Vec) -> Self { + let mut normalized = Vec::with_capacity(entries.len()); + for family in entries { + let family = family.trim(); + if family.is_empty() + || normalized + .iter() + .any(|held: &String| families_match(held, family)) + { + continue; + } + normalized.push(family.to_string()); + } + Self { + entries: normalized, + } + } + + pub(crate) fn entries(&self) -> &[String] { + &self.entries + } + + pub(crate) fn is_empty(&self) -> bool { + self.entries.is_empty() + } + + pub(crate) fn len(&self) -> usize { + self.entries.len() + } + + #[cfg(test)] + pub(crate) fn clear(&mut self) { + self.entries.clear(); + } + + pub(crate) fn add(&mut self) -> Result<(), FontCycleEditError> { + self.add_from_installed(system_font_families()) + } + + fn add_from_installed(&mut self, installed: &[String]) -> Result<(), FontCycleEditError> { + if installed.is_empty() { + return Err(FontCycleEditError::NoInstalledFonts); + } + let Some(family) = installed + .iter() + .find(|family| !self.entries.iter().any(|held| families_match(held, family))) + else { + return Err(FontCycleEditError::AllInstalledFontsListed); + }; + self.entries.push(family.clone()); + Ok(()) + } + + pub(crate) fn remove(&mut self, index: usize) -> bool { + if index >= self.entries.len() { + return false; + } + self.entries.remove(index); + true + } + + pub(crate) fn move_entry(&mut self, index: usize, delta: isize) -> bool { + if self.entries.is_empty() || index >= self.entries.len() { + return false; + } + let Some(target) = index.checked_add_signed(delta) else { + return false; + }; + if target >= self.entries.len() { + return false; + } + self.entries.swap(index, target); + true + } + + /// Set one row's family without allowing a blank or repeated identity. + pub(crate) fn set(&mut self, index: usize, family: String) -> Result { + let Some(current) = self.entries.get(index) else { + return Err(FontCycleEditError::MissingEntry); + }; + let family = family.trim(); + if family.is_empty() { + return Err(FontCycleEditError::BlankFamily); + } + if families_match(current, family) { + return Ok(false); + } + if self + .entries + .iter() + .enumerate() + .any(|(other, held)| other != index && families_match(held, family)) + { + return Err(FontCycleEditError::DuplicateFamily(family.to_string())); + } + self.entries[index] = family.to_string(); + Ok(true) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn draft(entries: &[&str]) -> FontCycleDraft { + FontCycleDraft::from_entries(entries.iter().map(|entry| (*entry).to_string()).collect()) + } + + #[test] + fn loading_normalizes_blanks_and_duplicate_identities_once() { + let draft = draft(&[" Sans ", "", "sans", "Serif"]); + + assert_eq!(draft.entries(), ["Sans", "Serif"]); + } + + #[test] + fn adding_a_row_uses_an_installed_family_not_already_listed() { + let mut draft = draft(&["Sans"]); + let installed = vec!["sans".to_string(), "Serif".to_string()]; + + assert_eq!(draft.add_from_installed(&installed), Ok(())); + assert_eq!(draft.entries(), ["Sans", "Serif"]); + } + + #[test] + fn adding_is_rejected_when_no_valid_entry_exists() { + let mut no_fonts = draft(&[]); + assert_eq!( + no_fonts.add_from_installed(&[]), + Err(FontCycleEditError::NoInstalledFonts) + ); + assert!(no_fonts.is_empty()); + + let mut exhaustive = draft(&["Sans", "Serif"]); + let installed = vec!["sans".to_string(), "SERIF".to_string()]; + assert_eq!( + exhaustive.add_from_installed(&installed), + Err(FontCycleEditError::AllInstalledFontsListed) + ); + assert_eq!(exhaustive.entries(), ["Sans", "Serif"]); + } + + #[test] + fn a_row_cannot_be_set_to_a_family_another_row_already_holds() { + let mut draft = draft(&["Sans", "Serif"]); + + assert_eq!( + draft.set(1, "sans".to_string()), + Err(FontCycleEditError::DuplicateFamily("sans".to_string())) + ); + assert_eq!(draft.entries(), ["Sans", "Serif"]); + + assert_eq!(draft.set(1, "Monospace".to_string()), Ok(true)); + assert_eq!(draft.entries(), ["Sans", "Monospace"]); + } + + #[test] + fn blank_and_missing_row_edits_are_rejected() { + let mut draft = draft(&["Sans"]); + + assert_eq!( + draft.set(0, " ".to_string()), + Err(FontCycleEditError::BlankFamily) + ); + assert_eq!( + draft.set(4, "Serif".to_string()), + Err(FontCycleEditError::MissingEntry) + ); + assert_eq!(draft.entries(), ["Sans"]); + } + + #[test] + fn moving_keeps_the_order_the_cycle_walks() { + let mut draft = draft(&["A", "B", "C"]); + + assert!(draft.move_entry(0, 1)); + assert_eq!(draft.entries(), ["B", "A", "C"]); + assert!(draft.move_entry(2, -1)); + assert_eq!(draft.entries(), ["B", "C", "A"]); + } + + #[test] + fn moving_past_either_end_does_nothing() { + let mut draft = draft(&["A", "B"]); + + assert!(!draft.move_entry(0, -1)); + assert!(!draft.move_entry(1, 1)); + assert_eq!(draft.entries(), ["A", "B"]); + } + + #[test] + fn the_last_row_can_be_removed_because_an_empty_list_turns_the_action_off() { + let mut draft = draft(&["A"]); + + assert!(draft.remove(0)); + assert!(draft.is_empty()); + assert!(!draft.remove(0)); + } +} diff --git a/configurator/src/models/config/mod.rs b/configurator/src/models/config/mod.rs index 902059c1..05b7a901 100644 --- a/configurator/src/models/config/mod.rs +++ b/configurator/src/models/config/mod.rs @@ -1,5 +1,6 @@ mod boards; mod draft; +mod font_cycle; mod parse; mod performance_fields; mod presets; diff --git a/configurator/src/models/config/setters.rs b/configurator/src/models/config/setters.rs index 5626b677..c8925366 100644 --- a/configurator/src/models/config/setters.rs +++ b/configurator/src/models/config/setters.rs @@ -328,7 +328,6 @@ impl ConfigDraft { TextField::DrawingPolygonSides => self.drawing_polygon_sides = value, TextField::DrawingMarkerOpacity => self.drawing_marker_opacity = value, TextField::DrawingPenSmoothing => self.drawing_pen_smoothing = value, - TextField::DrawingFontCycle => self.drawing_font_cycle = value, TextField::DrawingFontFamily => self.drawing_font_family = value, TextField::DrawingFontWeight => { self.drawing_font_weight = value; diff --git a/configurator/src/models/config/tests.rs b/configurator/src/models/config/tests.rs index 06a72463..ebe779f4 100644 --- a/configurator/src/models/config/tests.rs +++ b/configurator/src/models/config/tests.rs @@ -141,7 +141,10 @@ fn config_draft_round_trips_the_font_cycle_list() { ]; let draft = ConfigDraft::from_config(&config); - assert_eq!(draft.drawing_font_cycle, "Sans, JetBrains Mono, Noto Serif"); + assert_eq!( + draft.drawing_font_cycle.entries(), + config.drawing.font_cycle + ); let round_trip = draft .to_config(&config) @@ -150,64 +153,52 @@ fn config_draft_round_trips_the_font_cycle_list() { } #[test] -fn font_cycle_editing_tolerates_spacing_and_trailing_separators() { - let config = Config::default(); - let mut draft = ConfigDraft::from_config(&config); - - draft.set_text( - TextField::DrawingFontCycle, - " Sans ,, Serif , ".to_string(), - ); - - let round_trip = draft.to_config(&config).expect("config"); - assert_eq!(round_trip.drawing.font_cycle, vec!["Sans", "Serif"]); -} - -#[test] -fn an_emptied_font_cycle_field_turns_the_action_off_rather_than_restoring_defaults() { +fn an_emptied_font_cycle_turns_the_action_off_rather_than_restoring_defaults() { let config = Config::default(); let mut draft = ConfigDraft::from_config(&config); - draft.set_text(TextField::DrawingFontCycle, String::new()); + draft.drawing_font_cycle.clear(); let round_trip = draft.to_config(&config).expect("config"); assert!(round_trip.drawing.font_cycle.is_empty()); } #[test] -fn a_save_that_never_touched_the_font_cycle_field_keeps_a_family_with_a_comma_in_it() { - // The field is one comma-separated line, so a family whose own name has a - // comma cannot be recovered from the text. That is a limit on editing it, - // not a licence to rewrite it: saving an unrelated page must not corrupt a - // list the user never opened. +fn a_family_name_containing_a_comma_now_round_trips() { + // The editor used to hold this list as one comma-separated line, so a + // family whose own name contained a comma could not survive being edited. + // The list is a list now; there is nothing left to parse. let mut config = Config::default(); config.drawing.font_cycle = vec!["Weird, Font".to_string(), "Sans".to_string()]; let mut draft = ConfigDraft::from_config(&config); + assert_eq!( + draft.drawing_font_cycle.entries(), + config.drawing.font_cycle + ); + + // An unrelated edit elsewhere still saves the list untouched. draft.set_text(TextField::DrawingThickness, "5".to_string()); let round_trip = draft.to_config(&config).expect("config"); - assert_eq!(round_trip.drawing.font_cycle, config.drawing.font_cycle); } #[test] -fn editing_the_font_cycle_field_re_reads_it_and_cannot_express_a_comma() { - // Documented limitation, pinned so the day it matters this test says so. - // The config file itself is a TOML array and can still express one. +fn blank_font_cycle_entries_are_dropped_on_save() { + // A row can only be set from the installed list, so this is belt and + // braces against a config file that carried one. let mut config = Config::default(); - config.drawing.font_cycle = vec!["Weird, Font".to_string()]; + config.drawing.font_cycle = vec![ + " Sans ".to_string(), + String::new(), + " ".to_string(), + "Serif".to_string(), + ]; - let mut draft = ConfigDraft::from_config(&config); - draft.set_text( - TextField::DrawingFontCycle, - "Weird, Font, Serif".to_string(), - ); + let draft = ConfigDraft::from_config(&config); let round_trip = draft.to_config(&config).expect("config"); - assert_eq!( - round_trip.drawing.font_cycle, - vec!["Weird", "Font", "Serif"] - ); + assert_eq!(round_trip.drawing.font_cycle, ["Sans", "Serif"]); } #[test] diff --git a/configurator/src/models/config/to_config/drawing.rs b/configurator/src/models/config/to_config/drawing.rs index e3b69d7b..6f73cf33 100644 --- a/configurator/src/models/config/to_config/drawing.rs +++ b/configurator/src/models/config/to_config/drawing.rs @@ -67,19 +67,9 @@ impl ConfigDraft { errors, |value| config.drawing.marker_opacity = value, ); - // The field is one comma-separated line, so a family whose own name - // contains a comma cannot be recovered from it. An untouched field must - // still save the list it was shown: only re-parse once the text stops - // matching what the document rendered into it. - if self.drawing_font_cycle != config.drawing.font_cycle.join(", ") { - config.drawing.font_cycle = self - .drawing_font_cycle - .split(',') - .map(str::trim) - .filter(|family| !family.is_empty()) - .map(str::to_string) - .collect(); - } + // A list in, a list out. Nothing to parse and nothing a family name can + // contain that would break the round trip. + config.drawing.font_cycle = self.drawing_font_cycle.entries().to_vec(); config.drawing.font_family = self.drawing_font_family.clone(); config.drawing.font_weight = self.drawing_font_weight.clone(); config.drawing.font_style = self.drawing_font_style.clone(); diff --git a/configurator/src/models/fields/toggles.rs b/configurator/src/models/fields/toggles.rs index d4903a82..268b6024 100644 --- a/configurator/src/models/fields/toggles.rs +++ b/configurator/src/models/fields/toggles.rs @@ -108,7 +108,6 @@ pub enum TextField { DrawingPolygonSides, DrawingMarkerOpacity, DrawingPenSmoothing, - DrawingFontCycle, DrawingFontFamily, DrawingFontWeight, DrawingFontStyle, diff --git a/docs/CONFIG.md b/docs/CONFIG.md index 870cfa9d..4333c072 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -413,6 +413,13 @@ Any installed family name is valid. Blank and repeated entries are dropped when the configuration loads, because a repeat makes the key look like it skipped. An empty list turns the action off. +In the configurator this is a row per font under **Font cycle**, each row a +searchable dropdown over everything installed — every family drawn in its own +face, so you pick one by looking at it. Rows move up and down, and the order +they are in is the order Shift+T walks. A family your config names +that this machine does not have says so under the row rather than quietly +showing a different one. + With text or a sticky note selected, Shift+T restyles that text and leaves the tool setting alone. With nothing selected it sets what the next label will be written in. A family that is not in the list steps to the first entry, @@ -424,6 +431,8 @@ opens the font picker below. Family names are matched without regard to case, the way fontconfig resolves them: `sans` and `Sans` are one font, so `["Sans", "sans"]` loads as one entry. +A family name containing a comma is fine — the list is a TOML array, and the +configurator edits it as a list rather than as one line of text. #### Font picker diff --git a/src/toolbar_gtk/view/top_bar/style_pill.rs b/src/toolbar_gtk/view/top_bar/style_pill.rs index c07e4086..b8a9f568 100644 --- a/src/toolbar_gtk/view/top_bar/style_pill.rs +++ b/src/toolbar_gtk/view/top_bar/style_pill.rs @@ -214,15 +214,22 @@ impl TopBar { }; send_event(&sender, event); }); - // The thickness/text-size readouts are distinct numeral - // controls; only the opacity slider keeps its built-in - // readout. - slider.set_value_label_visible(control.carries_inline_readout()); + // Thickness/text-size use distinct numeral controls. The + // other readouts sit beside a full-width track, matching + // the built-in toolbar instead of borrowing track space. + let carries_readout = control.carries_inline_readout(); + slider.configure_inline_readout(carries_readout, px(STYLE_VALUE_W)); set_semantic_widget_id(&slider.root, control.id().as_ref()); if let Some(tooltip) = control.tooltip(snapshot) { slider.root.set_tooltip_text(Some(&tooltip)); } - slider.root.set_size_request(px(STYLE_SLIDER_W), -1); + let slider_width = STYLE_SLIDER_W + + if carries_readout { + STYLE_PILL_GAP + STYLE_VALUE_W + } else { + 0.0 + }; + slider.root.set_size_request(px(slider_width), -1); slider.root.set_valign(gtk4::Align::Center); append_gap(&pill, slider.root.upcast_ref(), gap); self.updaters.borrow_mut().push(Box::new(move |snapshot| { diff --git a/src/toolbar_gtk/view/top_bar/tests.rs b/src/toolbar_gtk/view/top_bar/tests.rs index c3a0e65b..29539e73 100644 --- a/src/toolbar_gtk/view/top_bar/tests.rs +++ b/src/toolbar_gtk/view/top_bar/tests.rs @@ -851,12 +851,39 @@ fn assert_gtk_style_widget( } model::StylePillRole::Slider => { // SliderRow: a box hosting the hand-drawn track DrawingArea. - assert!(widget.is::(), "{id} slider row"); - assert!( - find_control_surface(widget) - .is_some_and(|surface| surface.is::()), - "{id} slider track" + let row = widget + .clone() + .downcast::() + .unwrap_or_else(|_| panic!("{id} is a slider row")); + let track = row.first_child().expect("slider track"); + assert!(track.is::(), "{id} slider track"); + let value = track.next_sibling().expect("slider value readout"); + let value = value + .downcast::() + .unwrap_or_else(|_| panic!("{id} value readout is a label")); + let carries_readout = control.carries_inline_readout(); + assert_eq!( + value.property::("visible"), + carries_readout, + "{id} readout visibility" + ); + let expected_width = if carries_readout { + STYLE_SLIDER_W + STYLE_PILL_GAP + STYLE_VALUE_W + } else { + STYLE_SLIDER_W + }; + assert_eq!( + row.width_request(), + expected_width.round() as i32, + "{id} keeps the shared track width when its readout is visible" ); + if carries_readout { + assert_eq!( + value.xalign(), + 0.0, + "{id} places its readout next to the track" + ); + } } model::StylePillRole::Value => { let button = widget diff --git a/src/toolbar_gtk/widgets.rs b/src/toolbar_gtk/widgets.rs index 68ac4348..344095fe 100644 --- a/src/toolbar_gtk/widgets.rs +++ b/src/toolbar_gtk/widgets.rs @@ -632,10 +632,17 @@ impl SliderRow { } } - /// Hide the built-in readout when a separate numeral control shows the - /// value (the style pill pairs its sliders with distinct value buttons). - pub(super) fn set_value_label_visible(&self, visible: bool) { + /// Show an inline readout in a fixed slot immediately after the track. + /// Other slider rows keep their natural five-character, right-aligned + /// readout; the style pill instead mirrors the built-in toolbar's track + + /// readout geometry. + pub(super) fn configure_inline_readout(&self, visible: bool, width: i32) { self.value_label.set_visible(visible); + if visible { + self.value_label.set_width_chars(-1); + self.value_label.set_size_request(width, -1); + self.value_label.set_xalign(0.0); + } } /// Applies a backend value unless the user is mid-drag. From 7a5153875437e311eafc45d920e0f223359e26ef Mon Sep 17 00:00:00 2001 From: devmobasa <4170275+devmobasa@users.noreply.github.com> Date: Wed, 26 Aug 2026 18:08:04 +0200 Subject: [PATCH 6/6] feat(drawing): complete toolbar and text polish --- README.md | 6 +- config.example.toml | 11 +- .../src/app/pages/drawing/defaults.rs | 6 + configurator/src/app/search/terms.rs | 1 + configurator/src/app/search/tests.rs | 18 +- .../src/models/config/draft/from_config.rs | 1 + configurator/src/models/config/draft/mod.rs | 1 + configurator/src/models/config/setters.rs | 3 + configurator/src/models/config/tests.rs | 14 ++ .../src/models/config/to_config/drawing.rs | 1 + configurator/src/models/fields/toggles.rs | 1 + docs/CONFIG.md | 35 ++- src/backend/wayland/state/canvas_layer.rs | 10 +- src/backend/wayland/state/capture.rs | 1 + src/backend/wayland/state/pdf_export.rs | 5 + src/backend/wayland/state/pdf_export/tests.rs | 1 + .../wayland/state/region_capture/delivery.rs | 1 + .../state/region_capture/tests/picker.rs | 1 + .../wayland/state/render/canvas/mod.rs | 18 +- .../wayland/state/render/canvas/text.rs | 110 +++++++--- src/backend/wayland/state/render/ui.rs | 8 +- src/backend/wayland/toolbar/events.rs | 2 - src/backend/wayland/toolbar/hit.rs | 6 - src/backend/wayland/toolbar/view/top/build.rs | 27 +-- src/backend/wayland/toolbar/view/top/tests.rs | 33 ++- src/canvas_export/mod.rs | 48 ++++- src/canvas_export/page.rs | 11 +- src/canvas_export/pdf/tests.rs | 36 +++- src/canvas_export/png.rs | 3 + src/canvas_export/region.rs | 68 +++++- src/config/tests/load.rs | 14 ++ src/config/types/drawing.rs | 9 + src/draw/font.rs | 10 + src/draw/mod.rs | 7 +- src/draw/render/mod.rs | 6 +- src/draw/render/shapes.rs | 96 ++++++++- src/draw/render/text.rs | 181 ++++++++++++++-- src/input/state/core/text_font.rs | 144 ++++++++++--- src/input/state/render.rs | 29 ++- src/input/state/tests/tool_controls.rs | 195 +++++++++++++++++ src/input/tool/catalog.rs | 2 +- src/toolbar_gtk/css.rs | 6 +- src/toolbar_gtk/view/top_bar/style_pill.rs | 57 ++--- src/toolbar_gtk/view/top_bar/tests.rs | 26 ++- src/ui.rs | 1 + src/ui/board_picker.rs | 19 +- src/ui/board_picker/page_panel.rs | 3 + .../page_panel/thumbnail/cards.rs | 4 + .../page_panel/thumbnail/content.rs | 61 +++++- .../page_panel/thumbnail/types.rs | 3 + src/ui/theme.rs | 2 +- src/ui/theme/css.rs | 2 +- src/ui/toolbar/apply/mod.rs | 1 + src/ui/toolbar/apply/tools.rs | 4 + src/ui/toolbar/events.rs | 5 +- src/ui/toolbar/model/activation.rs | 12 -- src/ui/toolbar/model/event_policy.rs | 1 + src/ui/toolbar/model/style_pill.rs | 30 +-- src/ui/toolbar/model/style_pill/control.rs | 116 ++++++---- .../model/style_pill/tests/selection.rs | 49 +++++ .../model/style_pill/tests/tool_states.rs | 202 +++++++++++++----- src/ui/toolbar/model/top_spec/spec.rs | 14 +- src/ui/toolbar/snapshot/build.rs | 2 + src/ui/toolbar/snapshot/types.rs | 20 +- 64 files changed, 1492 insertions(+), 328 deletions(-) diff --git a/README.md b/README.md index 476e59eb..c574e5b4 100644 --- a/README.md +++ b/README.md @@ -117,17 +117,17 @@ The v0.9.23+ prebuilt `wayscriber` packages require glibc 2.39 and GTK 4.12 — ### Drawing and editing - Freehand pen, highlighter, eraser (circle/rect) -- Pen smoothing: finished pen and marker strokes are cleaned up on release, so the live line never lags the cursor (`[drawing] pen_smoothing`, 0-6, or the toolbar's **Smoothing** slider); tablet pressure values are preserved, and the level is remembered with the session +- Pen smoothing: finished pen and marker strokes are cleaned up on release, so the live line never lags the cursor (`[drawing] pen_smoothing`, 0-6, or the toolbar's **Smoothing** stepper); tablet pressure values are preserved, and the level is remembered with the session - Shapes: lines, rectangles, ellipses, polygons (with fill toggle) - Arrows in four styles - standard, pointy, curved (drag its handle to route around what is in the way), and double-ended - with optional auto-numbered labels; step markers for walkthroughs - Blur tool with four styles: soften, pixelate, secure (flattens the region to one color), and black out - Spotlight tool: dims everything except the regions you draw, with optional 1×–4× magnification -- Multiline text and sticky notes with smoothing; text halos take their contrast from the background the label sits on, so a label stays readable over a board, a filled shape, or a frozen screen (a live transparent board has no pixels to sample and falls back to the text color) +- Multiline text and sticky notes with smoothing; optional text halos take their contrast from the background the label sits on, so a label stays readable over a board, a filled shape, or a frozen screen (disable with `[drawing] text_halo_enabled = false`; a live transparent board has no pixels to sample and falls back to the text color) - Selection: Alt-drag, V tool, properties panel - Duplicate (Ctrl+D), delete (Delete), undo/redo - Color picker, screen eyedropper with a magnified pixel loupe, palettes, size via hotkeys or scroll - Text font cycling with Shift+T over a configurable list (`[drawing] font_cycle`); with text selected it restyles that text -- Font picker over every installed family (**Font Picker** in the command palette, or the toolbar's font button beside Sans/Mono), with search, a monospace filter, wheel scrolling, accelerating arrow-key repeat, and each row drawn in its own font +- Font picker over every installed family (**Font Picker** in the command palette, or the toolbar's current-family button), with search, a monospace filter, wheel scrolling, accelerating arrow-key repeat, and each row drawn in its own font - Render color profiles for print/projector/light-theme preview - Radial menu at cursor (Middle-click): quick tool/color selection with recent colors, press-flick-release tool commits, plus a draggable outer size ring and scroll size adjust diff --git a/config.example.toml b/config.example.toml index 665fc990..ac99c0b8 100644 --- a/config.example.toml +++ b/config.example.toml @@ -1107,7 +1107,7 @@ font_cycle = ["Sans", "Monospace", "Serif"] # the hand. Smoothing runs when you lift the pen, never while you draw, so the # live stroke always sits exactly on the pointer. Neither endpoint ever moves. # -# Also on the toolbar as the "Smoothing" slider while the Pen or Marker is up. +# Also on the toolbar as the "Smoothing" stepper while the Pen or Marker is up. # A session remembers the level it was saved at; this is the starting value. pen_smoothing = 3 @@ -1177,7 +1177,9 @@ tab_drag_tool = "ellipse" font_family = "Sans" # Font weight: "normal", "bold", "light", "ultralight", "heavy", "ultrabold" -# Or numeric: 100-900 (400=normal, 700=bold) +# Or numeric: 100-900 (400 is regular; 700 renders at a bold weight). +# The toolbar Bold toggle is checked only for the literal word "bold" and writes +# "bold" or "normal"; numeric weights such as 700 therefore leave it unchecked. # Try different weights: # font_weight = "normal" # Regular weight # font_weight = "bold" # Bold (default, best visibility) @@ -1197,6 +1199,11 @@ font_style = "normal" # Default: false (no background, cleaner look with just stroke outline) text_background_enabled = false +# Draw a contrasting outline around text and text-entry carets. +# Set to false for plain glyphs without the light/dark halo. +# Default: true +text_halo_enabled = true + # Ordered quick colors used by shortcuts, toolbar swatches, and radial menu. # The first eight entries map to R/G/B/Y/O/P/W/K; if fewer are configured by # hand, missing shortcut positions use built-in defaults and help-overlay badges diff --git a/configurator/src/app/pages/drawing/defaults.rs b/configurator/src/app/pages/drawing/defaults.rs index 7abbfa30..654b612a 100644 --- a/configurator/src/app/pages/drawing/defaults.rs +++ b/configurator/src/app/pages/drawing/defaults.rs @@ -78,6 +78,12 @@ pub(super) fn build(page: &mut PageBuilder) { |app| app.draft.drawing_text_background_enabled, |value| Message::ToggleChanged(ToggleField::DrawingTextBackground, value), ) + .switch_row( + "Enable text halo", + "Draw a contrasting outline around text", + |app| app.draft.drawing_text_halo_enabled, + |value| Message::ToggleChanged(ToggleField::DrawingTextHalo, value), + ) .switch_row( "Start shapes filled", "", diff --git a/configurator/src/app/search/terms.rs b/configurator/src/app/search/terms.rs index 75b40491..38bcfaae 100644 --- a/configurator/src/app/search/terms.rs +++ b/configurator/src/app/search/terms.rs @@ -104,6 +104,7 @@ pub(super) const DRAWING_DEFAULT_TERMS: &[&str] = &[ "fill", "start shapes filled", "enable text background", + "enable text halo", "hit test", "hit-test tolerance px", "hit-test threshold", diff --git a/configurator/src/app/search/tests.rs b/configurator/src/app/search/tests.rs index e86ba7b6..649b8e27 100644 --- a/configurator/src/app/search/tests.rs +++ b/configurator/src/app/search/tests.rs @@ -112,7 +112,12 @@ fn field_level_terms_do_not_force_whole_tab_visible() { #[test] fn exact_drawing_default_labels_match_defaults_section() { - for query in ["font size pt", "eraser size px", "enable text background"] { + for query in [ + "font size pt", + "eraser size px", + "enable text background", + "enable text halo", + ] { let (mut app, _effects) = ConfiguratorApp::new_app(); app.search_query = SearchQuery::new(query); @@ -126,6 +131,17 @@ fn exact_drawing_default_labels_match_defaults_section() { } } +#[test] +fn halo_search_reveals_the_drawing_defaults_section() { + let (mut app, _effects) = ConfiguratorApp::new_app(); + app.search_query = SearchQuery::new("halo"); + + let summary = app.search_summary(); + let drawing = summary.tab(TabId::Drawing).expect("drawing match"); + + assert!(drawing.area_matches(SearchArea::DrawingDefaults)); +} + #[test] fn exact_drawing_color_and_font_labels_match_their_sections() { let cases = [ diff --git a/configurator/src/models/config/draft/from_config.rs b/configurator/src/models/config/draft/from_config.rs index 62b137de..4292b378 100644 --- a/configurator/src/models/config/draft/from_config.rs +++ b/configurator/src/models/config/draft/from_config.rs @@ -82,6 +82,7 @@ impl ConfigDraft { drawing_font_weight: weight_value, drawing_font_style: style_value, drawing_text_background_enabled: config.drawing.text_background_enabled, + drawing_text_halo_enabled: config.drawing.text_halo_enabled, drawing_default_fill_enabled: config.drawing.default_fill_enabled, drawing_drag_tool: ToolOption::from_drag_bindable_tool(config.drawing.drag_tool), drawing_shift_drag_tool: ToolOption::from_drag_bindable_tool( diff --git a/configurator/src/models/config/draft/mod.rs b/configurator/src/models/config/draft/mod.rs index 9c83539c..9ec666ff 100644 --- a/configurator/src/models/config/draft/mod.rs +++ b/configurator/src/models/config/draft/mod.rs @@ -53,6 +53,7 @@ pub struct ConfigDraft { pub drawing_font_weight: String, pub drawing_font_style: String, pub drawing_text_background_enabled: bool, + pub drawing_text_halo_enabled: bool, pub drawing_default_fill_enabled: bool, pub drawing_drag_tool: ToolOption, pub drawing_shift_drag_tool: ToolOption, diff --git a/configurator/src/models/config/setters.rs b/configurator/src/models/config/setters.rs index c8925366..a97b26a6 100644 --- a/configurator/src/models/config/setters.rs +++ b/configurator/src/models/config/setters.rs @@ -151,6 +151,9 @@ impl ConfigDraft { ToggleField::DrawingTextBackground => { self.drawing_text_background_enabled = value; } + ToggleField::DrawingTextHalo => { + self.drawing_text_halo_enabled = value; + } ToggleField::DrawingFillEnabled => { self.drawing_default_fill_enabled = value; } diff --git a/configurator/src/models/config/tests.rs b/configurator/src/models/config/tests.rs index ebe779f4..5cb79e9a 100644 --- a/configurator/src/models/config/tests.rs +++ b/configurator/src/models/config/tests.rs @@ -105,6 +105,20 @@ fn config_draft_round_trips_shape_size_readout_preference() { assert!(round_trip.ui.show_shape_size_readout); } +#[test] +fn config_draft_round_trips_text_halo_preference() { + let mut config = Config::default(); + config.drawing.text_halo_enabled = false; + let mut draft = ConfigDraft::from_config(&config); + assert!(!draft.drawing_text_halo_enabled); + + draft.set_toggle(ToggleField::DrawingTextHalo, true); + let round_trip = draft + .to_config(&config) + .expect("text halo preference should round trip"); + assert!(round_trip.drawing.text_halo_enabled); +} + #[test] fn config_draft_round_trips_region_capture_settings() { let config = Config::default(); diff --git a/configurator/src/models/config/to_config/drawing.rs b/configurator/src/models/config/to_config/drawing.rs index 6f73cf33..889a2c55 100644 --- a/configurator/src/models/config/to_config/drawing.rs +++ b/configurator/src/models/config/to_config/drawing.rs @@ -74,6 +74,7 @@ impl ConfigDraft { config.drawing.font_weight = self.drawing_font_weight.clone(); config.drawing.font_style = self.drawing_font_style.clone(); config.drawing.text_background_enabled = self.drawing_text_background_enabled; + config.drawing.text_halo_enabled = self.drawing_text_halo_enabled; config.drawing.default_fill_enabled = self.drawing_default_fill_enabled; config.drawing.drag_tool = legacy_tool( self.drawing_drag_tools.left.drag_tool, diff --git a/configurator/src/models/fields/toggles.rs b/configurator/src/models/fields/toggles.rs index 268b6024..e61b0f9c 100644 --- a/configurator/src/models/fields/toggles.rs +++ b/configurator/src/models/fields/toggles.rs @@ -1,6 +1,7 @@ #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ToggleField { DrawingTextBackground, + DrawingTextHalo, DrawingFillEnabled, PerformanceVsync, UiShowStatusBar, diff --git a/docs/CONFIG.md b/docs/CONFIG.md index 4333c072..2b67f55c 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -259,6 +259,7 @@ font_family = "Sans" font_weight = "bold" font_style = "normal" text_background_enabled = false +text_halo_enabled = true # Hit-test tuning + undo retention hit_test_tolerance = 6.0 @@ -413,6 +414,17 @@ Any installed family name is valid. Blank and repeated entries are dropped when the configuration loads, because a repeat makes the key look like it skipped. An empty list turns the action off. +The toolbar's style pill carries a **Bold** toggle and a button showing the +family in use; the button opens the font picker. Bold applies to selected text, +or to the next label you type. Under width pressure, Bold leaves with the +smoothing stepper; the family button remains until the whole style pill is +hidden in the most compact layout. + +Bold is a literal two-state control: it is checked for `font_weight = "bold"` +and writes `"bold"` or `"normal"`. Numeric weights such as `700` still render at +that Pango weight, but leave the toggle unchecked because the toggle cannot +represent every numeric value. + In the configurator this is a row per font under **Font cycle**, each row a searchable dropdown over everything installed — every family drawn in its own face, so you pick one by looking at it. Rows move up and down, and the order @@ -425,9 +437,8 @@ leaves the tool setting alone. With nothing selected it sets what the next label will be written in. A family that is not in the list steps to the first entry, so the key always goes somewhere. -The toolbar's Sans/Mono buttons are a separate two-way shortcut and are not -affected by this list. Beside them is a button showing the family in use, which -opens the font picker below. +The toolbar family button is independent of this list: it shows the current +family and opens the full picker below. Family names are matched without regard to case, the way fontconfig resolves them: `sans` and `Sans` are one font, so `["Sans", "sans"]` loads as one entry. @@ -446,8 +457,7 @@ the configurator. Use the picker to find out what a family looks like, then put its name in the list if you want it a keystroke away. Run **Font Picker** from the command palette, bind `open_font_picker`, or click -the font button in the toolbar's style pill — the one showing the family in use, -next to Sans/Mono. +the font button in the toolbar's style pill — the one showing the family in use. | Key | Does | |-----|------| @@ -486,6 +496,17 @@ Text is drawn with a contrasting outline so it stays readable over any background. Wayscriber picks that halo color from **what the label sits on**, sampled from the canvas just before the glyphs are painted. +The halo is enabled by default. Disable it globally for the overlay, text-entry +caret, page thumbnails, and canvas exports with: + +```toml +[drawing] +text_halo_enabled = false +``` + +This removes the contrasting outline. `text_background_enabled` is separate, +so its optional box still appears when enabled. + That means a whiteboard, a blackboard, a frozen screen, a zoomed screen, and a region already covered by a blur or a filled shape all give the right answer. PNG export samples the same way, so an exported image matches the screen. @@ -543,8 +564,8 @@ the tool settings, so a session restores at the level it was saved at. A session written before this existed has no level recorded and restores at whatever `pen_smoothing` your config says. -The level is also on the toolbar, as a **Smoothing** slider in the style pill -whenever the Pen or Marker is up. It reads `Off` at zero. The slider is one of +The level is also on the toolbar, as a **Smoothing** stepper in the style pill +whenever the Pen or Marker is up. It reads `Off` at zero. The stepper is one of the first things the pill drops on a narrow output; the actions below still reach it there. diff --git a/src/backend/wayland/state/canvas_layer.rs b/src/backend/wayland/state/canvas_layer.rs index 8ee5e8be..83107610 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, + text_halo_enabled: bool, board_key: (usize, usize), valid: bool, } @@ -53,6 +54,7 @@ impl CanvasLayerCache { shapes_len: 0, last_shape_id: None, background: None, + text_halo_enabled: true, board_key: (0, 0), valid: false, } @@ -93,6 +95,7 @@ pub(in crate::backend::wayland) fn render_committed_shape( ctx: &cairo::Context, drawn_shape: &crate::draw::DrawnShape, replay_ctx: &crate::draw::EraserReplayContext<'_>, + text_halo_enabled: bool, ) { match &drawn_shape.shape { crate::draw::Shape::EraserStroke { points, brush } => { @@ -121,7 +124,7 @@ pub(in crate::backend::wayland) fn render_committed_shape( ); } other => { - crate::draw::render_shape(ctx, other); + crate::draw::render_shape_with_halo(ctx, other, text_halo_enabled); } } } @@ -165,6 +168,7 @@ impl WaylandState { crate::input::BoardBackground::Solid(color) => Some(*color), crate::input::BoardBackground::Transparent => None, }; + let text_halo_enabled = self.config.drawing.text_halo_enabled; let board_key = ( self.input_state.boards.active_index(), self.input_state.boards.active_page_index(), @@ -182,6 +186,7 @@ impl WaylandState { && cache.shapes_len == shapes_len && cache.last_shape_id == last_shape_id && cache.background == background + && cache.text_halo_enabled == text_halo_enabled && cache.board_key == board_key; let covers_view = view_x >= cache.world_x && view_y >= cache.world_y @@ -266,7 +271,7 @@ impl WaylandState { if let Some(bbox) = drawn_shape.bounding_box() && rects_intersect(bbox, bake_bounds) { - render_committed_shape(&bake_ctx, drawn_shape, &replay_ctx); + render_committed_shape(&bake_ctx, drawn_shape, &replay_ctx, text_halo_enabled); } } } @@ -284,6 +289,7 @@ impl WaylandState { cache.shapes_len = shapes_len; cache.last_shape_id = last_shape_id; cache.background = background; + cache.text_halo_enabled = text_halo_enabled; cache.board_key = board_key; cache.valid = true; debug!( diff --git a/src/backend/wayland/state/capture.rs b/src/backend/wayland/state/capture.rs index 564754d7..6945813e 100644 --- a/src/backend/wayland/state/capture.rs +++ b/src/backend/wayland/state/capture.rs @@ -401,6 +401,7 @@ impl WaylandState { .clone_without_history(), }, render_profile: self.input_state.export_render_profile(), + text_halo_enabled: self.config.drawing.text_halo_enabled, } } diff --git a/src/backend/wayland/state/pdf_export.rs b/src/backend/wayland/state/pdf_export.rs index 3b0afb2c..9e00d550 100644 --- a/src/backend/wayland/state/pdf_export.rs +++ b/src/backend/wayland/state/pdf_export.rs @@ -41,6 +41,7 @@ impl WaylandState { scope, config: &self.config.export.pdf, desktop_backdrop, + text_halo_enabled: self.config.drawing.text_halo_enabled, spotlight: SpotlightPassSnapshot { dim_opacity: self.input_state.spotlight_dim_opacity, feather: self.input_state.spotlight_feather, @@ -75,6 +76,7 @@ struct BoardPdfExportBuildContext<'a> { scope: PdfExportScope, config: &'a crate::config::PdfExportConfig, desktop_backdrop: Option, + text_halo_enabled: bool, spotlight: SpotlightPassSnapshot, } @@ -90,6 +92,7 @@ fn build_board_pdf_export_snapshot( scope, config, desktop_backdrop, + text_halo_enabled, spotlight, } = context; @@ -138,6 +141,7 @@ fn build_board_pdf_export_snapshot( viewport_height: logical_height, origin_x, origin_y, + text_halo_enabled, spotlight, }, metadata: PdfPageMetadata::new( @@ -168,6 +172,7 @@ fn build_board_pdf_export_snapshot( viewport_height: logical_height, origin_x: 0, origin_y: 0, + text_halo_enabled, spotlight, }, metadata: PdfPageMetadata::new( diff --git a/src/backend/wayland/state/pdf_export/tests.rs b/src/backend/wayland/state/pdf_export/tests.rs index 91b124aa..7435b0f3 100644 --- a/src/backend/wayland/state/pdf_export/tests.rs +++ b/src/backend/wayland/state/pdf_export/tests.rs @@ -32,6 +32,7 @@ fn snapshot_context<'a>( scope: PdfExportScope::ActiveBoard, config, desktop_backdrop: None, + text_halo_enabled: true, spotlight: Default::default(), } } diff --git a/src/backend/wayland/state/region_capture/delivery.rs b/src/backend/wayland/state/region_capture/delivery.rs index b76a75d4..1b832f6f 100644 --- a/src/backend/wayland/state/region_capture/delivery.rs +++ b/src/backend/wayland/state/region_capture/delivery.rs @@ -313,6 +313,7 @@ impl WaylandState { .boards .active_frame() .clone_without_history(), + text_halo_enabled: self.config.drawing.text_halo_enabled, spotlight: crate::canvas_export::SpotlightPassSnapshot { dim_opacity: self.input_state.spotlight_dim_opacity, feather: self.input_state.spotlight_feather, diff --git a/src/backend/wayland/state/region_capture/tests/picker.rs b/src/backend/wayland/state/region_capture/tests/picker.rs index fe6a6169..381a41d8 100644 --- a/src/backend/wayland/state/region_capture/tests/picker.rs +++ b/src/backend/wayland/state/region_capture/tests/picker.rs @@ -135,6 +135,7 @@ fn the_render_job_composes_drawings_when_asked_and_stays_raw_otherwise() { }, selection: ImagePixelRect::new(0, 0, 3, 2, (3, 2)).expect("selection"), frame, + text_halo_enabled: true, spotlight: SpotlightPassSnapshot { dim_opacity: 0.0, feather: 0.0, diff --git a/src/backend/wayland/state/render/canvas/mod.rs b/src/backend/wayland/state/render/canvas/mod.rs index d8600fb2..42a00238 100644 --- a/src/backend/wayland/state/render/canvas/mod.rs +++ b/src/backend/wayland/state/render/canvas/mod.rs @@ -106,6 +106,7 @@ impl WaylandState { let canvas_transform_active = self.canvas_transform_active(); let (canvas_origin_x, canvas_origin_y) = self.canvas_view_origin(); let shapes_total = self.input_state.boards.active_frame().shapes.len(); + let text_halo_enabled = self.config.drawing.text_halo_enabled; // For pure pan transforms, serve the board background and committed // shapes from the baked layer cache: pan frames force full damage, so @@ -188,7 +189,12 @@ impl WaylandState { // thousands of shapes to Cairo still incurs overhead for geometry processing. // A simple bounding box check here eliminates that overhead. let render_drawn_shape = |drawn_shape: &crate::draw::DrawnShape| { - super::super::canvas_layer::render_committed_shape(ctx, drawn_shape, &replay_ctx) + super::super::canvas_layer::render_committed_shape( + ctx, + drawn_shape, + &replay_ctx, + text_halo_enabled, + ) }; // Compute bounding box of all damage regions for fast rejection @@ -415,9 +421,13 @@ impl WaylandState { crate::draw::render_blur_rect(ctx, params, &replay_ctx); true } - _ => self - .input_state - .render_provisional_shape_for_damage(ctx, mx, my, damage_world), + _ => self.input_state.render_provisional_shape_for_damage( + ctx, + mx, + my, + damage_world, + text_halo_enabled, + ), }; if let (Some(perf), Some(provisional_start)) = (perf.as_mut(), provisional_start) { perf.provisional_points = provisional_points; diff --git a/src/backend/wayland/state/render/canvas/text.rs b/src/backend/wayland/state/render/canvas/text.rs index afba071f..d0634a6c 100644 --- a/src/backend/wayland/state/render/canvas/text.rs +++ b/src/backend/wayland/state/render/canvas/text.rs @@ -43,7 +43,7 @@ impl WaylandState { ); match self.input_state.text_input_mode { crate::input::TextInputMode::Plain => { - crate::draw::render_text( + crate::draw::render_text_with_halo( ctx, *x, *y, @@ -53,6 +53,7 @@ impl WaylandState { &self.input_state.font_descriptor, self.input_state.text_background_enabled, self.input_state.text_wrap_width, + self.config.drawing.text_halo_enabled, ); } crate::input::TextInputMode::StickyNote => { @@ -110,33 +111,7 @@ impl WaylandState { let caret_x = x as f64 + geom.x; let top = y as f64 + geom.y_from_baseline; let bottom = top + geom.height; - ctx.save().ok(); - // Widths come from the draw layer so the damage tracker sizes the - // caret's repaint rectangle from the exact same numbers. - let line_width = crate::draw::caret_line_width(size); - // The caret stands where the glyphs will, so it asks the same - // question they do: what is behind this point on the canvas? - let background_luminance = crate::draw::painted_background_luminance( - ctx, - ( - caret_x, - top, - crate::draw::caret_outline_width(size), - bottom - top, - ), - ); - let outline = crate::draw::text_outline_color(color, background_luminance); - ctx.set_source_rgba(outline.r, outline.g, outline.b, outline.a); - ctx.set_line_width(crate::draw::caret_outline_width(size)); - ctx.move_to(caret_x, top); - ctx.line_to(caret_x, bottom); - let _ = ctx.stroke(); - ctx.set_source_rgba(color.r, color.g, color.b, color.a); - ctx.set_line_width(line_width); - ctx.move_to(caret_x, top); - ctx.line_to(caret_x, bottom); - let _ = ctx.stroke(); - ctx.restore().ok(); + render_caret_stroke(ctx, caret_x, top, bottom, color, size, &self.config.drawing); } /// Underline the IME preedit span (a byte range into `preview_text`) so @@ -226,7 +201,7 @@ impl WaylandState { background_enabled, wrap_width, } if !text.is_empty() => { - crate::draw::render_text( + crate::draw::render_text_with_halo( ctx, *x, *y, @@ -236,6 +211,7 @@ impl WaylandState { font_descriptor, *background_enabled, *wrap_width, + self.config.drawing.text_halo_enabled, ); } Shape::StickyNote { @@ -329,6 +305,46 @@ impl WaylandState { } } +fn render_caret_stroke( + ctx: &cairo::Context, + caret_x: f64, + top: f64, + bottom: f64, + color: crate::draw::Color, + size: f64, + drawing: &crate::config::DrawingConfig, +) { + ctx.save().ok(); + // Widths come from the draw layer so the damage tracker sizes the caret's + // repaint rectangle from the exact same numbers. + let line_width = crate::draw::caret_line_width(size); + if drawing.text_halo_enabled { + // The caret stands where the glyphs will, so it asks the same question + // they do: what is behind this point on the canvas? + let background_luminance = crate::draw::painted_background_luminance( + ctx, + ( + caret_x, + top, + crate::draw::caret_outline_width(size), + bottom - top, + ), + ); + let outline = crate::draw::text_outline_color(color, background_luminance); + ctx.set_source_rgba(outline.r, outline.g, outline.b, outline.a); + ctx.set_line_width(crate::draw::caret_outline_width(size)); + ctx.move_to(caret_x, top); + ctx.line_to(caret_x, bottom); + let _ = ctx.stroke(); + } + ctx.set_source_rgba(color.r, color.g, color.b, color.a); + ctx.set_line_width(line_width); + ctx.move_to(caret_x, top); + ctx.line_to(caret_x, bottom); + let _ = ctx.stroke(); + ctx.restore().ok(); +} + fn text_preview_decoration_color( mode: crate::input::TextInputMode, current_color: crate::draw::Color, @@ -343,7 +359,8 @@ fn text_preview_decoration_color( #[cfg(test)] mod tests { - use super::text_preview_decoration_color; + use super::{render_caret_stroke, text_preview_decoration_color}; + use crate::config::DrawingConfig; use crate::draw::Color; use crate::input::TextInputMode; @@ -365,4 +382,37 @@ mod tests { background ); } + + fn caret_pixels(text_halo_enabled: bool) -> Vec { + let mut surface = + cairo::ImageSurface::create(cairo::Format::ARgb32, 80, 100).expect("caret surface"); + { + let ctx = cairo::Context::new(&surface).expect("caret context"); + ctx.set_source_rgb(1.0, 1.0, 1.0); + ctx.paint().expect("white backdrop"); + let drawing = DrawingConfig { + text_halo_enabled, + ..DrawingConfig::default() + }; + render_caret_stroke( + &ctx, + 40.0, + 20.0, + 80.0, + Color::new(0.96, 0.2, 0.25, 1.0), + 36.0, + &drawing, + ); + } + surface.flush(); + surface.data().expect("caret pixels").to_vec() + } + + #[test] + fn text_entry_caret_honours_the_configured_halo_setting() { + assert!( + caret_pixels(true) != caret_pixels(false), + "caret rendering must read DrawingConfig::text_halo_enabled", + ); + } } diff --git a/src/backend/wayland/state/render/ui.rs b/src/backend/wayland/state/render/ui.rs index 8021523f..61c5de3b 100644 --- a/src/backend/wayland/state/render/ui.rs +++ b/src/backend/wayland/state/render/ui.rs @@ -208,7 +208,13 @@ impl WaylandState { if !capture_picker && self.input_state.is_board_picker_open() { self.input_state .update_board_picker_layout(ctx, width, height); - crate::ui::render_board_picker(ctx, &self.input_state, width, height); + crate::ui::render_board_picker_with_halo( + ctx, + &self.input_state, + width, + height, + self.config.drawing.text_halo_enabled, + ); } else { self.input_state.clear_board_picker_layout(); } diff --git a/src/backend/wayland/toolbar/events.rs b/src/backend/wayland/toolbar/events.rs index b8046264..43950b79 100644 --- a/src/backend/wayland/toolbar/events.rs +++ b/src/backend/wayland/toolbar/events.rs @@ -13,7 +13,6 @@ pub enum HitKind { max: f64, }, DragSetSpotlightMagnification, - DragSetPenSmoothing, DragSetFontSize, DragUndoDelay, DragRedoDelay, @@ -50,7 +49,6 @@ impl HitKind { HitKind::DragSetThickness { .. } | HitKind::DragSetMarkerOpacity { .. } | HitKind::DragSetSpotlightMagnification - | HitKind::DragSetPenSmoothing | HitKind::DragSetFontSize | HitKind::DragUndoDelay | HitKind::DragRedoDelay diff --git a/src/backend/wayland/toolbar/hit.rs b/src/backend/wayland/toolbar/hit.rs index 4a548c90..a9838be2 100644 --- a/src/backend/wayland/toolbar/hit.rs +++ b/src/backend/wayland/toolbar/hit.rs @@ -155,12 +155,6 @@ fn event_for_hit(hit: &HitRegion, x: f64, y: f64, phase: HitPhase) -> Option slider_event_for_hit( - ToolbarSliderTarget::PenSmoothing, - ToolbarSliderSpec::PEN_SMOOTHING, - hit, - x, - ), DragSetFontSize => slider_event_for_hit( ToolbarSliderTarget::FontSize, ToolbarSliderSpec::FONT_SIZE, diff --git a/src/backend/wayland/toolbar/view/top/build.rs b/src/backend/wayland/toolbar/view/top/build.rs index 1bc71267..3d18a36d 100644 --- a/src/backend/wayland/toolbar/view/top/build.rs +++ b/src/backend/wayland/toolbar/view/top/build.rs @@ -660,7 +660,6 @@ fn push_style_pill( model::StylePillControl::ThicknessSlider | model::StylePillControl::OpacitySlider | model::StylePillControl::SpotlightMagnificationSlider - | model::StylePillControl::PenSmoothingSlider | model::StylePillControl::FontSizeSlider => { let (slider_spec, value) = control.slider_value(snapshot); let event = control.click_event(snapshot); @@ -676,7 +675,6 @@ fn push_style_pill( model::StylePillControl::SpotlightMagnificationSlider => { HitKind::DragSetSpotlightMagnification } - model::StylePillControl::PenSmoothingSlider => HitKind::DragSetPenSmoothing, _ => HitKind::DragSetFontSize, }; let rect = ( @@ -755,11 +753,14 @@ fn push_style_pill( )); x += ToolbarLayoutSpec::TOP_STYLE_VALUE_W + gap; } - model::StylePillControl::FillToggle | model::StylePillControl::AutoNumberToggle => { - let w = if control == model::StylePillControl::FillToggle { - ToolbarLayoutSpec::TOP_STYLE_FILL_W - } else { - ToolbarLayoutSpec::TOP_STYLE_AUTO_NUMBER_W + model::StylePillControl::FillToggle + | model::StylePillControl::AutoNumberToggle + | model::StylePillControl::FontWeightToggle => { + let w = match control { + model::StylePillControl::AutoNumberToggle => { + ToolbarLayoutSpec::TOP_STYLE_AUTO_NUMBER_W + } + _ => ToolbarLayoutSpec::TOP_STYLE_FILL_W, }; let toggle_h = ToolbarLayoutSpec::TOP_STYLE_TOGGLE_H; nodes.push(WidgetNode::new( @@ -797,6 +798,8 @@ fn push_style_pill( x += ToolbarLayoutSpec::TOP_STYLE_RESET_W + gap; } model::StylePillControl::FontFamilyPicker => { + // Keep the family button clear of the size numeral to its left. + x += ToolbarLayoutSpec::TOP_STYLE_SEGMENT_LEAD; // The family in use, as a button onto the overlay's picker. // Same shape as the counter reset, wider because the label is // a name rather than a fixed word. @@ -848,7 +851,8 @@ fn push_style_pill( )); x += ToolbarLayoutSpec::TOP_STYLE_SEL_VALUE_W + gap; } - model::StylePillControl::SelectionStepper(_) => { + model::StylePillControl::PenSmoothingStepper + | model::StylePillControl::SelectionStepper(_) => { let enabled = control.enabled(snapshot); let steps = control.required_steps(snapshot); let step_w = ToolbarLayoutSpec::TOP_STYLE_STEP_W; @@ -902,11 +906,10 @@ fn push_style_pill( gap, ); } - model::StylePillControl::FontFamilySegment - | model::StylePillControl::EraserModeSegment => { + model::StylePillControl::EraserModeSegment => { let segments = control.required_segments(snapshot); - // A clear gap before the segment so Sans│Mono never crowd the - // preceding numeral ("72pt") to its left (M7-C3). + // Keep the mode segments visually separate from the preceding + // eraser-size controls. x += ToolbarLayoutSpec::TOP_STYLE_SEGMENT_LEAD; let rect = ( x, diff --git a/src/backend/wayland/toolbar/view/top/tests.rs b/src/backend/wayland/toolbar/view/top/tests.rs index bdc64721..22047624 100644 --- a/src/backend/wayland/toolbar/view/top/tests.rs +++ b/src/backend/wayland/toolbar/view/top/tests.rs @@ -884,7 +884,7 @@ fn style_pill_morphs_per_tool() { Some("Reset numbering to 1 (next: 4)") ); - // Text: pt-labelled size slider plus the Sans/Mono segment. + // Text: pt-labelled size slider plus independent weight/family controls. let mut text = snapshot(); text.text_active = true; let tree = build(&text); @@ -905,24 +905,16 @@ fn style_pill_morphs_per_tool() { } other => panic!("numeral kind, got {other:?}"), } - assert!(matches!( - &tree - .node_by_id(&"top.style.font-family".into()) - .expect("font family segment") - .kind, - WidgetKind::SegmentedControl { left, right, .. } - if left.text == "Sans" && right.text == "Mono" - )); - for (id, family) in [ - ("top.style.font-family.sans", "Sans"), - ("top.style.font-family.mono", "Monospace"), - ] { - let half = tree.node_by_id(&id.into()).expect("family half"); - assert!(matches!( - &half.interact.as_ref().unwrap().event, - ToolbarEvent::SetFont(font) if font.family == family - )); - } + // The family control shows the family in use and opens the full picker. + let picker = tree + .node_by_id(&"top.style.font-family-picker".into()) + .expect("font button"); + assert!(matches!(&picker.kind, WidgetKind::TextButton { .. })); + assert_eq!( + picker.interact.as_ref().unwrap().event, + ToolbarEvent::OpenFontPicker + ); + assert!(!style_ids(&tree).contains(&"top.style.font-family".to_string())); assert!(!style_ids(&tree).contains(&"top.style.thickness".to_string())); } @@ -961,6 +953,8 @@ fn style_pill_geometry_holds_per_tool_and_select_hides_the_pill() { disabled: false, }, ]; + selection.selection_has_text = true; + selection.selected_text_bold = Some(false); let (w, h) = top_size(&selection); let tree = build_top_view(&selection, w as f64, h as f64); let style = tree @@ -978,6 +972,7 @@ fn style_pill_geometry_holds_per_tool_and_select_hides_the_pill() { "top.style.sel.thickness.minus", "top.style.sel.thickness.value", "top.style.sel.thickness.plus", + "top.style.font-bold", ] ); let rects = top_input_rects(&selection, w as f64, h as f64).expect("island input rects"); diff --git a/src/canvas_export/mod.rs b/src/canvas_export/mod.rs index cc2c3668..429836a0 100644 --- a/src/canvas_export/mod.rs +++ b/src/canvas_export/mod.rs @@ -23,7 +23,7 @@ mod tests { use crate::canvas_export::page::draw_canvas_page; use crate::canvas_export::png::render_canvas_surface; use crate::config::{PdfExportConfig, RenderColorMappingConfig, RenderProfileConfig}; - use crate::draw::{BLACK, BlurStyle, Frame, RED, Shape, WHITE}; + use crate::draw::{BLACK, BlurStyle, FontDescriptor, Frame, RED, Shape, WHITE}; use crate::render_profiles::RenderColorProfile; fn snapshot(frame: Frame, viewport: CanvasExportViewport) -> CanvasExportSnapshot { @@ -32,6 +32,7 @@ mod tests { backdrop: CanvasExportBackdropSnapshot::Transparent, board: BoardExportSnapshot { frame }, render_profile: None, + text_halo_enabled: true, spotlight: Default::default(), } } @@ -44,6 +45,7 @@ mod tests { viewport_height: 20, origin_x: 0, origin_y: 0, + text_halo_enabled: true, spotlight: Default::default(), } } @@ -126,6 +128,50 @@ mod tests { assert_eq!(pixel(&mut surface, 0, 0), 0); } + #[test] + fn canvas_export_honours_disabled_text_halo() { + let mut frame = Frame::new(); + frame.add_shape(Shape::Text { + x: 20, + y: 80, + text: "Read me".to_string(), + color: RED, + size: 36.0, + font_descriptor: FontDescriptor::default(), + background_enabled: false, + wrap_width: None, + }); + let mut export = snapshot( + frame, + CanvasExportViewport { + logical_width: 400, + logical_height: 120, + scale: 1, + origin_x: 0, + origin_y: 0, + }, + ); + export.backdrop = CanvasExportBackdropSnapshot::Solid(WHITE); + export.text_halo_enabled = false; + + let mut surface = render_canvas_surface(&export).expect("surface"); + surface.flush(); + let stride = surface.stride() as usize; + let data = surface.data().expect("surface pixels"); + let mut near_black = 0; + let mut red = 0; + for row in 0..120usize { + for column in 0..400usize { + let offset = row * stride + column * 4; + let (b, g, r) = (data[offset], data[offset + 1], data[offset + 2]); + near_black += usize::from(r < 40 && g < 40 && b < 40); + red += usize::from(r > 180 && g < 120 && b < 120); + } + } + assert_eq!(near_black, 0, "export must not add a disabled halo"); + assert!(red > 200, "export must keep the text itself visible"); + } + #[test] fn draw_canvas_page_uses_explicit_output_scale() { let mut frame = Frame::new(); diff --git a/src/canvas_export/page.rs b/src/canvas_export/page.rs index f7321888..6b8bbdff 100644 --- a/src/canvas_export/page.rs +++ b/src/canvas_export/page.rs @@ -4,7 +4,7 @@ use crate::capture::CaptureError; use crate::draw::{ BlurRectParams, Color, EraserReplayContext, Frame, Shape, SpotlightMagnifierOutcome, SpotlightMagnifierScratch, SpotlightMagnifierSource, SpotlightPass, render_blur_rect, - render_eraser_stroke, render_shape_over, render_spotlight_magnification_pass, + render_eraser_stroke, render_shape_over_with_halo, render_spotlight_magnification_pass, render_spotlight_pass, spotlight_regions_for_frame, }; use crate::screen_pixels::ScreenImage; @@ -17,6 +17,8 @@ pub struct CanvasPageExportSnapshot { pub viewport_height: u32, pub origin_x: i32, pub origin_y: i32, + /// Whether text shapes and labels receive a contrasting outline. + pub text_halo_enabled: bool, /// Dim/feather settings for the spotlight pass, mirroring the live overlay. pub spotlight: SpotlightPassSnapshot, } @@ -419,7 +421,12 @@ fn draw_canvas_page_contents( }, &replay_ctx, ), - other => render_shape_over(ctx, other, known_background_luminance), + other => render_shape_over_with_halo( + ctx, + other, + known_background_luminance, + page.text_halo_enabled, + ), } } diff --git a/src/canvas_export/pdf/tests.rs b/src/canvas_export/pdf/tests.rs index 75125c26..3d9ee12d 100644 --- a/src/canvas_export/pdf/tests.rs +++ b/src/canvas_export/pdf/tests.rs @@ -1,6 +1,6 @@ use super::*; use crate::config::{PdfExportConfig, PdfFitMode, PdfOrientation, PdfPageSize}; -use crate::draw::Frame; +use crate::draw::{FontDescriptor, Frame, RED, Shape, WHITE}; use std::process::Command; #[test] @@ -160,6 +160,39 @@ fn rendered_pdf_reports_page_count_and_sizes_when_pdfinfo_is_available() { assert!(text.contains("300 x 200") || text.contains("200 x 300")); } +fn text_pdf(text_halo_enabled: bool) -> Vec { + let source = CanvasExportRect::new(0.0, 0.0, 400.0, 120.0).expect("source"); + let mut page = pdf_page(400.0, 120.0, source, 0, 1); + page.page.backdrop = CanvasExportBackdropSnapshot::Solid(WHITE); + page.page.text_halo_enabled = text_halo_enabled; + page.page.frame.add_shape(Shape::Text { + x: 20, + y: 80, + text: "Read me".to_string(), + color: RED, + size: 36.0, + font_descriptor: FontDescriptor::default(), + background_enabled: false, + wrap_width: None, + }); + render_board_pdf(&BoardPdfExportSnapshot { + pages: vec![page], + labels: Default::default(), + }) + .expect("text PDF renders") +} + +#[test] +fn pdf_export_honours_the_text_halo_setting() { + let disabled = text_pdf(false); + let enabled = text_pdf(true); + assert_ne!( + disabled.len(), + enabled.len(), + "vector PDF output must contain the configured text rendering", + ); +} + fn pdf_page( width: f64, height: f64, @@ -175,6 +208,7 @@ fn pdf_page( viewport_height: 100, origin_x: 0, origin_y: 0, + text_halo_enabled: true, spotlight: Default::default(), }, metadata: PdfPageMetadata::new( diff --git a/src/canvas_export/png.rs b/src/canvas_export/png.rs index 4cb0b317..274ab994 100644 --- a/src/canvas_export/png.rs +++ b/src/canvas_export/png.rs @@ -14,6 +14,8 @@ pub struct CanvasExportSnapshot { pub backdrop: CanvasExportBackdropSnapshot, pub board: BoardExportSnapshot, pub render_profile: Option, + /// Whether exported text receives a contrasting outline. + pub text_halo_enabled: bool, /// Spotlight appearance, mirrored from the live overlay. pub spotlight: SpotlightPassSnapshot, } @@ -136,6 +138,7 @@ fn canvas_page_from_snapshot(snapshot: &CanvasExportSnapshot) -> CanvasPageExpor viewport_height: snapshot.viewport.logical_height, origin_x: snapshot.viewport.origin_x, origin_y: snapshot.viewport.origin_y, + text_halo_enabled: snapshot.text_halo_enabled, spotlight: snapshot.spotlight, } } diff --git a/src/canvas_export/region.rs b/src/canvas_export/region.rs index ab2bb385..e2715a70 100644 --- a/src/canvas_export/region.rs +++ b/src/canvas_export/region.rs @@ -21,6 +21,7 @@ pub(crate) struct CanvasRegionExportSnapshot { pub source: CanvasRegionSource, pub selection: ImagePixelRect, pub frame: Frame, + pub text_halo_enabled: bool, pub spotlight: SpotlightPassSnapshot, } @@ -175,6 +176,7 @@ pub(crate) fn render_canvas_region_png( viewport_height: working_height, origin_x: working_source_rect.x.floor() as i32, origin_y: working_source_rect.y.floor() as i32, + text_halo_enabled: snapshot.text_halo_enabled, spotlight: snapshot.spotlight, }; let destination = CanvasExportRect::new( @@ -228,7 +230,7 @@ pub(crate) fn render_canvas_region_png( #[cfg(test)] mod tests { use super::*; - use crate::draw::{BlurStyle, EraserBrush, EraserKind, Frame, RED, Shape}; + use crate::draw::{BlurStyle, EraserBrush, EraserKind, FontDescriptor, Frame, RED, Shape}; fn solid_source(width: u32, height: u32, pixel: u32) -> CanvasRegionSource { CanvasRegionSource { @@ -254,6 +256,61 @@ mod tests { u32::from_ne_bytes(data[offset..offset + 4].try_into().expect("pixel")) } + #[test] + fn region_export_honours_the_disabled_text_halo() { + let width = 400_u32; + let height = 120_u32; + let source = CanvasRegionSource { + image: Arc::new(ScreenImage { + data: (0..width.saturating_mul(height)) + .flat_map(|_| 0xFFFF_FFFF_u32.to_ne_bytes()) + .collect(), + width, + height, + stride: (width * 4) as i32, + }), + logical_bounds: CanvasExportRect::new(0.0, 0.0, f64::from(width), f64::from(height)) + .unwrap(), + }; + let mut frame = Frame::new(); + frame.add_shape(Shape::Text { + x: 20, + y: 80, + text: "Read me".to_string(), + color: RED, + size: 36.0, + font_descriptor: FontDescriptor::default(), + background_enabled: false, + wrap_width: None, + }); + let rendered = render_canvas_region_png(CanvasRegionExportSnapshot { + source, + selection: ImagePixelRect::new(0, 0, width, height, (width, height)).unwrap(), + frame, + text_halo_enabled: false, + spotlight: SpotlightPassSnapshot::default(), + }) + .expect("region text renders"); + + let mut surface = cairo::ImageSurface::create_from_png(&mut rendered.bytes.as_slice()) + .expect("region PNG decodes"); + surface.flush(); + let stride = surface.stride() as usize; + let data = surface.data().expect("decoded pixels"); + let mut near_black = 0; + let mut red = 0; + for row in 0..height as usize { + for column in 0..width as usize { + let offset = row * stride + column * 4; + let (b, g, r) = (data[offset], data[offset + 1], data[offset + 2]); + near_black += usize::from(r < 40 && g < 40 && b < 40); + red += usize::from(r > 180 && g < 120 && b < 120); + } + } + assert_eq!(near_black, 0, "region export must not add a disabled halo"); + assert!(red > 200, "region export must keep the text visible"); + } + #[test] fn region_renderer_maps_world_shapes_into_native_crop_pixels_and_clips() { let backdrop = 0xFF20_3040; @@ -280,6 +337,7 @@ mod tests { source: solid_source(8, 8, backdrop), selection: ImagePixelRect::new(0, 0, 8, 8, (8, 8)).unwrap(), frame, + text_halo_enabled: true, spotlight: SpotlightPassSnapshot::default(), }) .expect("region renders"); @@ -307,6 +365,7 @@ mod tests { source: solid_source(8, 8, backdrop), selection: ImagePixelRect::new(2, 2, 4, 4, (8, 8)).unwrap(), frame, + text_halo_enabled: true, spotlight: SpotlightPassSnapshot::default(), }) .expect("region renders"); @@ -334,6 +393,7 @@ mod tests { source, selection, frame: Frame::new(), + text_halo_enabled: true, spotlight: SpotlightPassSnapshot::default(), }) .expect("raw crop renders"); @@ -375,6 +435,7 @@ mod tests { source, selection: ImagePixelRect::new(0, 0, 8, 8, (8, 8)).unwrap(), frame, + text_halo_enabled: true, spotlight: SpotlightPassSnapshot { dim_opacity: 0.6, feather: 0.0, @@ -415,6 +476,7 @@ mod tests { source, selection: ImagePixelRect::new(4, 0, 4, 8, (8, 8)).unwrap(), frame, + text_halo_enabled: true, spotlight: SpotlightPassSnapshot { dim_opacity: 0.6, feather: 0.0, @@ -438,6 +500,7 @@ mod tests { source, selection: ImagePixelRect::new(0, 0, 1, 1, (8, 8)).unwrap(), frame: Frame::new(), + text_halo_enabled: true, spotlight: SpotlightPassSnapshot::default(), }); assert!(matches!(result, Err(CaptureError::ImageError(_)))); @@ -467,6 +530,7 @@ mod tests { source: solid_source(8, 8, backdrop), selection: ImagePixelRect::new(0, 0, 8, 8, (8, 8)).unwrap(), frame, + text_halo_enabled: true, spotlight: SpotlightPassSnapshot::default(), }) .expect("region renders"); @@ -517,6 +581,7 @@ mod tests { ) .unwrap(), frame: frame.clone_without_history(), + text_halo_enabled: true, spotlight: SpotlightPassSnapshot::default(), }) .expect("full region renders"); @@ -525,6 +590,7 @@ mod tests { source, selection, frame, + text_halo_enabled: true, spotlight: SpotlightPassSnapshot::default(), }) .expect("cropped region renders"); diff --git a/src/config/tests/load.rs b/src/config/tests/load.rs index fe931ceb..e57169fd 100644 --- a/src/config/tests/load.rs +++ b/src/config/tests/load.rs @@ -104,6 +104,20 @@ fn capture_drawings_default_on_and_explicit_false_round_trips() { assert!(!reloaded.capture.include_drawings); } +#[test] +fn text_halo_defaults_on_and_explicit_false_round_trips() { + let defaults: Config = toml::from_str("").expect("empty config should use defaults"); + assert!(defaults.drawing.text_halo_enabled); + + let disabled: Config = toml::from_str("[drawing]\ntext_halo_enabled = false\n") + .expect("text halo preference should parse"); + assert!(!disabled.drawing.text_halo_enabled); + + let serialized = toml::to_string(&disabled).expect("text halo preference serializes"); + let reloaded: Config = toml::from_str(&serialized).expect("serialized preference reloads"); + assert!(!reloaded.drawing.text_halo_enabled); +} + #[test] fn region_capture_rejects_unknown_picker_values() { let error = toml::from_str::("[capture.region]\npicker = 'automatic'\n") diff --git a/src/config/types/drawing.rs b/src/config/types/drawing.rs index a18f8535..4a33433f 100644 --- a/src/config/types/drawing.rs +++ b/src/config/types/drawing.rs @@ -137,6 +137,10 @@ pub struct DrawingConfig { /// Enable semi-transparent background box behind text for better contrast #[serde(default = "default_text_background")] pub text_background_enabled: bool, + + /// Draw a contrasting outline around text for readability. + #[serde(default = "default_text_halo")] + pub text_halo_enabled: bool, } impl Default for DrawingConfig { @@ -167,6 +171,7 @@ impl Default for DrawingConfig { font_weight: default_font_weight(), font_style: default_font_style(), text_background_enabled: default_text_background(), + text_halo_enabled: default_text_halo(), } } } @@ -937,6 +942,10 @@ fn default_text_background() -> bool { false } +fn default_text_halo() -> bool { + true +} + fn default_hit_test_tolerance() -> f64 { DEFAULT_HIT_TEST_TOLERANCE } diff --git a/src/draw/font.rs b/src/draw/font.rs index 5e0cd040..55edb049 100644 --- a/src/draw/font.rs +++ b/src/draw/font.rs @@ -30,6 +30,16 @@ impl FontDescriptor { } } + /// Whether this descriptor asks for bold. + /// + /// One rule, because two places decide on it: the toolbar's toggle shows + /// its state from this and `to_pango_string` renders from the same field. + /// A numeric weight is not bold — the toggle writes the word, and a config + /// that asks for `700` is asking for something the toggle cannot express. + pub fn is_bold(&self) -> bool { + self.weight.trim().eq_ignore_ascii_case("bold") + } + /// Converts this font descriptor to a Pango font description string. /// /// Format: "Family Style Weight Size" diff --git a/src/draw/mod.rs b/src/draw/mod.rs index 92e8cc1a..f9fbb38d 100644 --- a/src/draw/mod.rs +++ b/src/draw/mod.rs @@ -41,9 +41,10 @@ pub use render::{ 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_handles, render_shape, render_shape_over, - render_spotlight_magnification_pass, render_spotlight_pass, render_sticky_note, render_text, - selection_handle_rects, spotlight_regions_for_frame, sticky_note_foreground, - text_outline_color, + render_shape_over_with_halo, render_shape_with_halo, render_spotlight_magnification_pass, + render_spotlight_pass, render_sticky_note, render_text, render_text_over_with_halo, + render_text_with_halo, selection_handle_rects, spotlight_regions_for_frame, + sticky_note_foreground, text_outline_color, }; #[allow(unused_imports)] pub use shape::{ diff --git a/src/draw/render/mod.rs b/src/draw/render/mod.rs index 5e6e3aaa..0b9d1c17 100644 --- a/src/draw/render/mod.rs +++ b/src/draw/render/mod.rs @@ -25,7 +25,9 @@ pub use pressure_strokes::render_freehand_pressure_borrowed; pub(crate) use pressure_strokes::render_freehand_pressure_preview_borrowed; pub(crate) use primitives::{render_polygon_preview, with_saved_state}; pub use selection::{render_selection_halo, render_selection_handles, selection_handle_rects}; -pub use shapes::{render_shape, render_shape_over}; +pub use shapes::{ + render_shape, render_shape_over, render_shape_over_with_halo, render_shape_with_halo, +}; pub use spotlight::{ IMMUTABLE_RASTER_SOURCE_TOKEN, SpotlightMagnifierMetrics, SpotlightMagnifierOutcome, SpotlightMagnifierScratch, SpotlightMagnifierSource, SpotlightPass, SpotlightRegion, @@ -37,6 +39,6 @@ pub use strokes::{render_freehand_borrowed, render_marker_stroke_borrowed}; pub(crate) use text::render_sticky_note_preview; pub use text::{ caret_line_width, caret_outline_width, render_sticky_note, render_text, render_text_over, - sticky_note_foreground, text_outline_color, + render_text_over_with_halo, render_text_with_halo, sticky_note_foreground, text_outline_color, }; pub use types::EraserReplayContext; diff --git a/src/draw/render/shapes.rs b/src/draw/render/shapes.rs index a3098a4c..1bee4aa2 100644 --- a/src/draw/render/shapes.rs +++ b/src/draw/render/shapes.rs @@ -4,7 +4,7 @@ use super::image::render_image_shape; use super::pressure_strokes::render_freehand_pressure_borrowed; use super::primitives::{render_arrow, render_ellipse, render_line, render_polygon, render_rect}; use super::strokes::{render_freehand_borrowed, render_marker_stroke_borrowed}; -use super::text::{render_sticky_note, render_text_over}; +use super::text::{render_sticky_note, render_text_over_with_halo}; use crate::draw::Color; use crate::draw::shape::Shape; use crate::draw::shape::{ @@ -21,7 +21,12 @@ use crate::draw::shape::{ /// * `ctx` - Cairo drawing context to render to /// * `shape` - The shape to render pub fn render_shape(ctx: &cairo::Context, shape: &Shape) { - render_shape_over(ctx, shape, None); + render_shape_with_halo(ctx, shape, true); +} + +/// [`render_shape`], with explicit control over text outlines within the shape. +pub fn render_shape_with_halo(ctx: &cairo::Context, shape: &Shape, text_halo_enabled: bool) { + render_shape_over_with_halo(ctx, shape, None, text_halo_enabled); } /// `render_shape`, plus what the caller knows about the background behind it. @@ -32,6 +37,16 @@ pub fn render_shape_over( ctx: &cairo::Context, shape: &Shape, known_background_luminance: Option, +) { + render_shape_over_with_halo(ctx, shape, known_background_luminance, true); +} + +/// [`render_shape_over`], with explicit control over text outlines. +pub fn render_shape_over_with_halo( + ctx: &cairo::Context, + shape: &Shape, + known_background_luminance: Option, + text_halo_enabled: bool, ) { match shape { Shape::Freehand { @@ -133,7 +148,7 @@ pub fn render_shape_over( label.size, &label.font_descriptor, ) { - render_text_over( + render_text_over_with_halo( ctx, layout.x, layout.y, @@ -144,6 +159,7 @@ pub fn render_shape_over( ARROW_LABEL_BACKGROUND, None, known_background_luminance, + text_halo_enabled, ); } } @@ -178,7 +194,7 @@ pub fn render_shape_over( background_enabled, wrap_width, } => { - render_text_over( + render_text_over_with_halo( ctx, *x, *y, @@ -189,6 +205,7 @@ pub fn render_shape_over( *background_enabled, *wrap_width, known_background_luminance, + text_halo_enabled, ); } Shape::StepMarker { x, y, color, label } => { @@ -250,7 +267,7 @@ pub fn render_shape_over( let center_offset_y = metrics.ink_y + metrics.ink_height / 2.0; let baseline_x = (*x as f64 - center_offset_x).round() as i32; let baseline_y = (*y as f64 - center_offset_y + metrics.baseline).round() as i32; - render_text_over( + render_text_over_with_halo( ctx, baseline_x, baseline_y, @@ -261,6 +278,7 @@ pub fn render_shape_over( false, None, known_background_luminance, + text_halo_enabled, ); } } @@ -299,3 +317,71 @@ pub fn render_shape_over( } } } + +#[cfg(test)] +mod tests { + use super::render_shape_with_halo; + use crate::draw::{ArrowLabel, ArrowStyle, Color, FontDescriptor, Shape, StepMarkerLabel}; + + fn rendered_pixels(shape: &Shape, text_halo_enabled: bool) -> Vec { + let mut surface = + cairo::ImageSurface::create(cairo::Format::ARgb32, 500, 220).expect("shape surface"); + { + let ctx = cairo::Context::new(&surface).expect("shape context"); + ctx.set_source_rgb(1.0, 1.0, 1.0); + ctx.paint().expect("white backdrop"); + render_shape_with_halo(&ctx, shape, text_halo_enabled); + } + surface.flush(); + surface.data().expect("shape pixels").to_vec() + } + + #[test] + fn labelled_shapes_honour_the_text_halo_setting() { + let red = Color::new(0.96, 0.2, 0.25, 1.0); + let font_descriptor = FontDescriptor::default(); + let cases = [ + ( + "arrow label", + Shape::Arrow { + x1: 60, + y1: 110, + x2: 440, + y2: 110, + color: red, + thick: 4.0, + arrow_length: 20.0, + arrow_angle: 30.0, + head_at_end: true, + style: ArrowStyle::Standard, + bend: 0.0, + label: Some(ArrowLabel { + value: 7, + size: 36.0, + font_descriptor: font_descriptor.clone(), + }), + }, + ), + ( + "step-marker label", + Shape::StepMarker { + x: 250, + y: 110, + color: red, + label: StepMarkerLabel { + value: 8, + size: 36.0, + font_descriptor, + }, + }, + ), + ]; + + for (name, shape) in cases { + assert!( + rendered_pixels(&shape, true) != rendered_pixels(&shape, false), + "{name} must forward the halo setting to its text renderer", + ); + } + } +} diff --git a/src/draw/render/text.rs b/src/draw/render/text.rs index 9282c8d3..3ec1c71c 100644 --- a/src/draw/render/text.rs +++ b/src/draw/render/text.rs @@ -37,7 +37,35 @@ pub fn render_text( background_enabled: bool, wrap_width: Option, ) { - render_text_over( + render_text_with_halo( + ctx, + x, + y, + text, + color, + size, + font_descriptor, + background_enabled, + wrap_width, + true, + ); +} + +/// [`render_text`], with explicit control over its contrasting outline. +#[allow(clippy::too_many_arguments)] +pub fn render_text_with_halo( + ctx: &cairo::Context, + x: i32, + y: i32, + text: &str, + color: Color, + size: f64, + font_descriptor: &FontDescriptor, + background_enabled: bool, + wrap_width: Option, + halo_enabled: bool, +) { + render_text_over_with_halo( ctx, x, y, @@ -48,6 +76,7 @@ pub fn render_text( background_enabled, wrap_width, None, + halo_enabled, ); } @@ -70,6 +99,36 @@ pub fn render_text_over( background_enabled: bool, wrap_width: Option, known_background_luminance: Option, +) { + render_text_over_with_halo( + ctx, + x, + y, + text, + color, + size, + font_descriptor, + background_enabled, + wrap_width, + known_background_luminance, + true, + ); +} + +/// [`render_text_over`], with explicit control over its contrasting outline. +#[allow(clippy::too_many_arguments)] +pub fn render_text_over_with_halo( + ctx: &cairo::Context, + x: i32, + y: i32, + text: &str, + color: Color, + size: f64, + font_descriptor: &FontDescriptor, + background_enabled: bool, + wrap_width: Option, + known_background_luminance: Option, + halo_enabled: bool, ) { // Save context state to prevent settings from leaking to other drawing operations ctx.save().ok(); @@ -121,20 +180,23 @@ pub fn render_text_over( // Read the background before anything is painted over it. The halo has to // contrast with what the label sits on, and this is the last moment at // which the surface still shows only that. - let background_luminance = backdrop_probe::painted_luminance( - ctx, - ( - x as f64 + content.x, - adjusted_y + content.y, - content.width, - content.height, - ), - ) - .or(known_background_luminance); - let outline = text_outline_color(color, background_luminance); + let contrast = (halo_enabled || background_enabled).then(|| { + let background_luminance = backdrop_probe::painted_luminance( + ctx, + ( + x as f64 + content.x, + adjusted_y + content.y, + content.width, + content.height, + ), + ) + .or(known_background_luminance); + text_outline_color(color, background_luminance) + }); // First pass: draw semi-transparent background rectangle (if enabled) if background_enabled && content.width > 0.0 && content.height > 0.0 { + let contrast = contrast.expect("text background requests a contrast color"); let padding = size * 0.15; // Union ink and logical extents: ink preserves italic overhangs while // logical cells retain leading/trailing whitespace advances. @@ -144,7 +206,7 @@ pub fn render_text_over( content.width + padding * 2.0, content.height + padding * 2.0, ); - ctx.set_source_rgba(outline.r, outline.g, outline.b, 0.3); + ctx.set_source_rgba(contrast.r, contrast.g, contrast.b, 0.3); let _ = ctx.fill(); } @@ -160,11 +222,13 @@ pub fn render_text_over( // Create path from layout for stroking pangocairo::functions::layout_path(ctx, &layout); - // Fully opaque stroke for maximum contrast and crispness - ctx.set_source_rgba(outline.r, outline.g, outline.b, outline.a); - ctx.set_line_width(size * 0.06); - ctx.set_line_join(cairo::LineJoin::Round); - let _ = ctx.stroke_preserve(); + if let Some(outline) = contrast.filter(|_| halo_enabled) { + // Fully opaque stroke for maximum contrast and crispness. + ctx.set_source_rgba(outline.r, outline.g, outline.b, outline.a); + ctx.set_line_width(size * 0.06); + ctx.set_line_join(cairo::LineJoin::Round); + let _ = ctx.stroke_preserve(); + } // Fill with bright, full-intensity color ctx.set_source_rgba(color.r, color.g, color.b, color.a); @@ -361,7 +425,7 @@ fn draw_round_rect(ctx: &cairo::Context, x: f64, y: f64, w: f64, h: f64, r: f64) mod tests { use super::{ Color, FontDescriptor, caret_outline_width, render_sticky_note, render_sticky_note_preview, - render_text, sticky_note_foreground, text_outline_color, + render_text, render_text_with_halo, sticky_note_foreground, text_outline_color, }; fn alpha_at(surface: &mut cairo::ImageSurface, x: i32, y: i32) -> u8 { @@ -434,6 +498,85 @@ mod tests { ); } + #[test] + fn disabled_text_halo_paints_no_dark_outline_pixels() { + let mut surface = cairo::ImageSurface::create(cairo::Format::ARgb32, 400, 120).unwrap(); + { + let ctx = cairo::Context::new(&surface).unwrap(); + ctx.set_source_rgb(1.0, 1.0, 1.0); + let _ = ctx.paint(); + render_text_with_halo( + &ctx, + 20, + 80, + "Read me", + Color::new(0.96, 0.2, 0.25, 1.0), + 36.0, + &FontDescriptor::default(), + false, + None, + false, + ); + } + surface.flush(); + let stride = surface.stride() as usize; + let data = surface.data().unwrap(); + let mut near_black = 0; + let mut red = 0; + for row in 0..120usize { + for column in 0..400usize { + let offset = row * stride + column * 4; + let (b, g, r) = (data[offset], data[offset + 1], data[offset + 2]); + near_black += usize::from(r < 40 && g < 40 && b < 40); + red += usize::from(r > 180 && g < 120 && b < 120); + } + } + assert_eq!(near_black, 0, "the disabled halo must not paint an outline"); + assert!( + red > 200, + "disabling the halo must leave the text itself visible" + ); + } + + #[test] + fn disabled_halo_keeps_the_optional_text_background() { + let text = "A "; + let font = FontDescriptor::default(); + let size = 20.0; + let origin = (20, 60); + let caret = crate::draw::shape::caret_geometry_text( + text, + &font.to_pango_string(size), + None, + text.len(), + ) + .expect("trailing-space caret geometry"); + let sample_x = origin.0 + caret.x.round() as i32; + let sample_y = origin.1 + (caret.y_from_baseline + caret.height / 2.0).round() as i32; + let mut surface = + cairo::ImageSurface::create(cairo::Format::ARgb32, 400, 120).expect("text surface"); + { + let ctx = cairo::Context::new(&surface).expect("text context"); + render_text_with_halo( + &ctx, + origin.0, + origin.1, + text, + Color::new(1.0, 1.0, 1.0, 1.0), + size, + &font, + true, + None, + false, + ); + } + + assert!( + alpha_at(&mut surface, sample_x, sample_y) > 0, + "disabling the halo must not remove the independent background box", + ); + } + #[test] fn an_unknown_background_falls_back_to_the_colour_of_the_text() { let light_outline = text_outline_color(Color::new(1.0, 1.0, 1.0, 1.0), None); diff --git a/src/input/state/core/text_font.rs b/src/input/state/core/text_font.rs index 55f6f336..dec8aa72 100644 --- a/src/input/state/core/text_font.rs +++ b/src/input/state/core/text_font.rs @@ -7,17 +7,63 @@ //! applied change reports. use super::InputState; -use crate::draw::{Shape, families_match}; +use crate::draw::{FontDescriptor, Shape, families_match}; + +fn text_font_descriptor(shape: &Shape) -> Option<&FontDescriptor> { + match shape { + Shape::Text { + font_descriptor, .. + } + | Shape::StickyNote { + font_descriptor, .. + } => Some(font_descriptor), + _ => None, + } +} + +fn text_font_descriptor_mut(shape: &mut Shape) -> Option<&mut FontDescriptor> { + match shape { + Shape::Text { + font_descriptor, .. + } + | Shape::StickyNote { + font_descriptor, .. + } => Some(font_descriptor), + _ => None, + } +} impl InputState { /// Whether the selection holds anything a font applies to. - pub(in crate::input::state::core) fn selection_has_text(&self) -> bool { + pub(crate) fn selection_has_text(&self) -> bool { let frame = self.boards.active_frame(); self.selected_shape_ids().iter().any(|id| { - matches!( - frame.shape(*id).map(|drawn| &drawn.shape), - Some(Shape::Text { .. } | Shape::StickyNote { .. }) - ) + frame + .shape(*id) + .and_then(|drawn| text_font_descriptor(&drawn.shape)) + .is_some() + }) + } + + fn first_selected_text_descriptor(&self) -> Option<&FontDescriptor> { + let frame = self.boards.active_frame(); + self.selected_shape_ids().iter().find_map(|id| { + frame + .shape(*id) + .and_then(|drawn| text_font_descriptor(&drawn.shape)) + }) + } + + fn first_editable_selected_text_descriptor(&self) -> Option<&FontDescriptor> { + let frame = self.boards.active_frame(); + self.selected_shape_ids().iter().find_map(|id| { + frame.shape(*id).and_then(|drawn| { + if drawn.locked { + None + } else { + text_font_descriptor(&drawn.shape) + } + }) }) } @@ -27,19 +73,48 @@ impl InputState { /// converges on a single family rather than each shape stepping away from /// wherever it happened to be. pub(in crate::input::state::core) fn first_selected_text_family(&self) -> Option { - let frame = self.boards.active_frame(); - self.selected_shape_ids().iter().find_map(|id| { - match frame.shape(*id).map(|drawn| &drawn.shape) { - Some( - Shape::Text { - font_descriptor, .. - } - | Shape::StickyNote { - font_descriptor, .. - }, - ) => Some(font_descriptor.family.clone()), - _ => None, + self.first_selected_text_descriptor() + .map(|descriptor| descriptor.family.clone()) + } + + /// Bold state of the first editable selected text target, if there is one. + /// + /// A mixed selection converges when the user clicks rather than borrowing + /// state from either a locked shape or the unrelated tool default. + pub(crate) fn first_editable_selected_text_is_bold(&self) -> Option { + self.first_editable_selected_text_descriptor() + .map(FontDescriptor::is_bold) + } + + /// Turn bold on or off, on selected text when there is any and on the tool + /// otherwise — the same target rule the font picker uses for a family. + /// + /// Writes the words `bold` and `normal`. A configuration asking for a + /// numeric weight is asking for something a two-state control cannot say, + /// so turning bold off from here lands on `normal` rather than restoring + /// whatever number was there. + pub(crate) fn set_font_bold(&mut self, bold: bool) -> bool { + let weight = if bold { "bold" } else { "normal" }; + if self.selection_has_text() { + return self.apply_weight_to_selected_text(weight); + } + let descriptor = crate::draw::FontDescriptor::new( + self.font_descriptor.family.clone(), + weight.to_string(), + self.font_descriptor.style.clone(), + ); + self.set_font_descriptor(descriptor) + } + + /// Restyle every selected text shape to `weight`. + fn apply_weight_to_selected_text(&mut self, weight: &str) -> bool { + let target = weight.to_string(); + self.apply_descriptor_to_selected_text("weight", move |descriptor| { + if descriptor.weight.eq_ignore_ascii_case(&target) { + return false; } + descriptor.weight = target.clone(); + true }) } @@ -54,21 +129,26 @@ impl InputState { family: &str, ) -> bool { let target = family.to_string(); + self.apply_descriptor_to_selected_text("font", move |descriptor| { + if families_match(&descriptor.family, &target) { + return false; + } + descriptor.family = target.clone(); + true + }) + } + + /// Apply one font-descriptor mutation to every editable selected text + /// shape, preserving shared lock, undo, damage, and partial-result reporting. + fn apply_descriptor_to_selected_text( + &mut self, + property: &'static str, + mut apply: impl FnMut(&mut FontDescriptor) -> bool, + ) -> bool { let result = self.apply_selection_change( - |shape| matches!(shape, Shape::Text { .. } | Shape::StickyNote { .. }), - move |shape| match shape { - Shape::Text { - font_descriptor, .. - } - | Shape::StickyNote { - font_descriptor, .. - } if !families_match(&font_descriptor.family, &target) => { - font_descriptor.family = target.clone(); - true - } - _ => false, - }, + |shape| text_font_descriptor(shape).is_some(), + move |shape| text_font_descriptor_mut(shape).is_some_and(&mut apply), ); - self.report_selection_apply_result(result, "font") + self.report_selection_apply_result(result, property) } } diff --git a/src/input/state/render.rs b/src/input/state/render.rs index ed110ad0..e35d33c2 100644 --- a/src/input/state/render.rs +++ b/src/input/state/render.rs @@ -2,6 +2,7 @@ use crate::draw::render::{render_freehand_pressure_preview_borrowed, render_poly use crate::draw::shape::bounding_box_for_points; use crate::draw::{ Color, Shape, render_freehand_borrowed, render_marker_stroke_borrowed, render_shape, + render_shape_with_halo, }; use crate::input::Tool; use crate::input::tool::{ @@ -101,6 +102,7 @@ impl InputState { &self, ctx: &cairo::Context, stroke: ProvisionalToolStroke<'_>, + text_halo_enabled: bool, ) -> bool { match stroke { ProvisionalToolStroke::BorrowedFreehand { @@ -138,7 +140,7 @@ impl InputState { true } ProvisionalToolStroke::Shape(shape) => { - render_shape(ctx, &shape); + render_shape_with_halo(ctx, &shape, text_halo_enabled); true } ProvisionalToolStroke::BlurReplayPreview(params) => { @@ -164,6 +166,7 @@ impl InputState { ctx: &cairo::Context, stroke: ProvisionalToolStroke<'_>, damage_regions: &[Rect], + text_halo_enabled: bool, ) -> bool { match stroke { ProvisionalToolStroke::BorrowedFreehand { @@ -249,7 +252,7 @@ impl InputState { } true } - other => self.render_provisional_tool_stroke(ctx, other), + other => self.render_provisional_tool_stroke(ctx, other, text_halo_enabled), } } @@ -270,11 +273,21 @@ impl InputState { ctx: &cairo::Context, current_x: i32, current_y: i32, + ) -> bool { + self.render_provisional_shape_with_halo(ctx, current_x, current_y, true) + } + + pub(crate) fn render_provisional_shape_with_halo( + &self, + ctx: &cairo::Context, + current_x: i32, + current_y: i32, + text_halo_enabled: bool, ) -> bool { match &self.state { DrawingState::Drawing { .. } => { let stroke = self.provisional_tool_stroke(current_x, current_y); - self.render_provisional_tool_stroke(ctx, stroke) + self.render_provisional_tool_stroke(ctx, stroke, text_halo_enabled) } DrawingState::Selecting { start_x, @@ -330,13 +343,19 @@ impl InputState { current_x: i32, current_y: i32, damage_regions: &[Rect], + text_halo_enabled: bool, ) -> bool { if matches!(self.state, DrawingState::Drawing { .. }) { let stroke = self.provisional_tool_stroke(current_x, current_y); - return self.render_provisional_tool_stroke_for_damage(ctx, stroke, damage_regions); + return self.render_provisional_tool_stroke_for_damage( + ctx, + stroke, + damage_regions, + text_halo_enabled, + ); } - self.render_provisional_shape(ctx, current_x, current_y) + self.render_provisional_shape_with_halo(ctx, current_x, current_y, text_halo_enabled) } } diff --git a/src/input/state/tests/tool_controls.rs b/src/input/state/tests/tool_controls.rs index 24857c10..7afb3be6 100644 --- a/src/input/state/tests/tool_controls.rs +++ b/src/input/state/tests/tool_controls.rs @@ -2,6 +2,7 @@ use super::*; use crate::config::{PresenterToolBehavior, PresetToolStatesConfig, ToolPresetConfig}; use crate::draw::{ArrowStyle, BlurStyle}; use crate::input::{DragBinding, DragToolBindings, PerToolDrawingSettings}; +use crate::ui::toolbar::model::{StylePillControl, StylePillSpec, TopStripPlan}; use crate::ui::toolbar::{ToolContext, ToolOptionsKind, ToolbarEvent, ToolbarSnapshot}; #[test] @@ -2433,3 +2434,197 @@ fn cycling_arrow_style_with_a_non_arrow_selected_falls_back_to_the_default() { assert_eq!(state.arrow_style, ArrowStyle::Pointy); } + +#[test] +fn bold_reaches_selected_text_and_otherwise_sets_what_the_next_label_uses() { + // The same target rule the font picker uses for a family: edit what the + // user is looking at, or set the tool when they are looking at nothing. + let mut state = create_test_input_state(); + let tool_weight = state.font_descriptor.weight.clone(); + let id = state.boards.active_frame_mut().add_shape(Shape::Text { + x: 10, + y: 10, + text: "hello".to_string(), + color: crate::draw::Color::new(1.0, 1.0, 1.0, 1.0), + size: 24.0, + font_descriptor: crate::draw::FontDescriptor::new( + "Sans".to_string(), + "normal".to_string(), + "normal".to_string(), + ), + background_enabled: false, + wrap_width: None, + }); + state.set_selection(vec![id]); + + assert!(state.apply_toolbar_event(ToolbarEvent::SetFontBold(true))); + + let frame = state.boards.active_frame(); + let Some(Shape::Text { + font_descriptor, .. + }) = frame.shape(id).map(|drawn| &drawn.shape) + else { + panic!("the text shape is still there"); + }; + assert!(font_descriptor.is_bold()); + assert_eq!( + state.font_descriptor.weight, tool_weight, + "restyling a selection must not also change what the next label uses" + ); + + // Nothing selected: the tool takes it instead. The built-in default weight + // is already bold, so this starts by turning it off — which is the state a + // user had no way back out of once the Sans/Mono segment was removed. + state.clear_selection(); + assert!(state.apply_toolbar_event(ToolbarEvent::SetFontBold(false))); + assert!(!state.font_descriptor.is_bold()); + assert!(state.apply_toolbar_event(ToolbarEvent::SetFontBold(true))); + assert!(state.font_descriptor.is_bold()); +} + +#[test] +fn rendered_bold_control_reads_and_mutates_the_selected_text_target() { + // Regression: the tool default is bold while the selected text is normal. + // Building the shared rendered-control spec must produce an unchecked + // toggle whose click bolds the selection, not a checked toggle whose click + // sends the no-op "turn normal" event. + let mut state = create_test_input_state(); + state.set_font_descriptor(crate::draw::FontDescriptor::new( + "Sans".to_string(), + "bold".to_string(), + "normal".to_string(), + )); + let id = state.boards.active_frame_mut().add_shape(Shape::Text { + x: 10, + y: 10, + text: "selected".to_string(), + color: crate::draw::Color::new(1.0, 1.0, 1.0, 1.0), + size: 24.0, + font_descriptor: crate::draw::FontDescriptor::new( + "Serif".to_string(), + "normal".to_string(), + "normal".to_string(), + ), + background_enabled: false, + wrap_width: None, + }); + state.set_selection(vec![id]); + state.set_tool_override(Some(Tool::Select)); + + let snapshot = ToolbarSnapshot::from_input(&state); + let spec = StylePillSpec::build(&snapshot, &TopStripPlan::unconstrained()); + let bold = spec + .controls() + .iter() + .copied() + .find(|control| *control == StylePillControl::FontWeightToggle) + .expect("selected text renders a Bold control"); + assert!(!bold.active(&snapshot)); + let event = bold.click_event(&snapshot); + assert_eq!(event, ToolbarEvent::SetFontBold(true)); + + assert!(state.apply_toolbar_event(event)); + let frame = state.boards.active_frame(); + let Some(Shape::Text { + font_descriptor, .. + }) = frame.shape(id).map(|drawn| &drawn.shape) + else { + panic!("the selected text is still there"); + }; + assert!(font_descriptor.is_bold()); + assert!( + state.font_descriptor.is_bold(), + "selected-text mutation leaves the tool default alone" + ); +} + +#[test] +fn rendered_bold_control_skips_locked_text_and_disables_without_an_editable_target() { + let mut state = create_test_input_state(); + let locked = state.boards.active_frame_mut().add_shape(Shape::Text { + x: 10, + y: 10, + text: "locked bold".to_string(), + color: crate::draw::Color::new(1.0, 1.0, 1.0, 1.0), + size: 24.0, + font_descriptor: crate::draw::FontDescriptor::new( + "Sans".to_string(), + "bold".to_string(), + "normal".to_string(), + ), + background_enabled: false, + wrap_width: None, + }); + state + .boards + .active_frame_mut() + .shape_mut(locked) + .expect("locked text") + .locked = true; + let editable = state.boards.active_frame_mut().add_shape(Shape::Text { + x: 20, + y: 20, + text: "editable normal".to_string(), + color: crate::draw::Color::new(1.0, 1.0, 1.0, 1.0), + size: 24.0, + font_descriptor: crate::draw::FontDescriptor::new( + "Sans".to_string(), + "normal".to_string(), + "normal".to_string(), + ), + background_enabled: false, + wrap_width: None, + }); + state.set_selection(vec![locked, editable]); + state.set_tool_override(Some(Tool::Select)); + + let snapshot = ToolbarSnapshot::from_input(&state); + let spec = StylePillSpec::build(&snapshot, &TopStripPlan::unconstrained()); + let bold = spec + .controls() + .iter() + .copied() + .find(|control| *control == StylePillControl::FontWeightToggle) + .expect("selected text renders a Bold control"); + assert!(bold.enabled(&snapshot)); + assert!(!bold.active(&snapshot), "editable normal text owns state"); + assert_eq!(bold.click_event(&snapshot), ToolbarEvent::SetFontBold(true)); + assert!(state.apply_toolbar_event(bold.click_event(&snapshot))); + + let frame = state.boards.active_frame(); + let is_bold = |id| match &frame.shape(id).expect("selected text").shape { + Shape::Text { + font_descriptor, .. + } => font_descriptor.is_bold(), + other => panic!("expected text, got {other:?}"), + }; + assert!(is_bold(locked), "the locked bold shape stays bold"); + assert!(is_bold(editable), "the editable normal shape becomes bold"); + + state + .boards + .active_frame_mut() + .shape_mut(editable) + .expect("editable text") + .locked = true; + let snapshot = ToolbarSnapshot::from_input(&state); + assert!(snapshot.selection_has_text); + assert_eq!(snapshot.selected_text_bold, None); + assert!(!bold.enabled(&snapshot)); +} + +#[test] +fn turning_bold_off_leaves_the_family_and_style_alone() { + let mut state = create_test_input_state(); + state.set_font_descriptor(crate::draw::FontDescriptor::new( + "Serif".to_string(), + "bold".to_string(), + "italic".to_string(), + )); + + assert!(state.apply_toolbar_event(ToolbarEvent::SetFontBold(false))); + + assert_eq!(state.font_descriptor.family, "Serif"); + assert_eq!(state.font_descriptor.style, "italic"); + assert!(!state.font_descriptor.is_bold()); +} diff --git a/src/input/tool/catalog.rs b/src/input/tool/catalog.rs index 301fba67..f913f213 100644 --- a/src/input/tool/catalog.rs +++ b/src/input/tool/catalog.rs @@ -482,7 +482,7 @@ impl Tool { /// Whether `[drawing] pen_smoothing` changes what this tool commits. /// /// The setting is one number for the whole program, but the control for it - /// is not: offering a smoothing slider while the Line or Blur tool is up + /// is not: offering a smoothing stepper while the Line or Blur tool is up /// would be a control that does nothing to the shape about to be drawn. /// Accumulated paths — freehand and marker — are the ones smoothed on /// release. diff --git a/src/toolbar_gtk/css.rs b/src/toolbar_gtk/css.rs index fb0a1552..c3f81b48 100644 --- a/src/toolbar_gtk/css.rs +++ b/src/toolbar_gtk/css.rs @@ -245,9 +245,9 @@ window.wayscriber-toolbar {{ background-color: {segment_active}; box-shadow: none; }} -/* The style pill's Sans│Mono segment gets horizontal breathing room and a - rounded pill. Scoped to `.pill` so Settings layout-mode tabs keep their - flush styling and the builtin segmented-control metrics stay matched. */ +/* Style-pill mode segments get horizontal breathing room and a rounded pill. + Scoped to `.pill` so Settings layout-mode tabs keep their flush styling and + the builtin segmented-control metrics stay matched. */ .wayscriber-toolbar .pill button.tab {{ padding: {spacing_xs}px {segment_pad_h}px; border-radius: {radius_button}px; diff --git a/src/toolbar_gtk/view/top_bar/style_pill.rs b/src/toolbar_gtk/view/top_bar/style_pill.rs index b8a9f568..ca459360 100644 --- a/src/toolbar_gtk/view/top_bar/style_pill.rs +++ b/src/toolbar_gtk/view/top_bar/style_pill.rs @@ -21,16 +21,6 @@ fn format_pt(value: f64) -> String { format!("{value:.0}pt") } -/// Smoothing readout. Matches `StylePillControl::value_text`: zero passes is a -/// state worth naming, not a quantity. -fn format_smoothing(value: f64) -> String { - if value.round() <= 0.0 { - "Off".to_string() - } else { - format!("{value:.0}") - } -} - /// Pill button on the shared `sized_button` chassis: non-focusable and /// releasing window keyboard focus on click, like every other top-bar /// control. The GTK bars must never retain keyboard focus — the popups the @@ -183,7 +173,6 @@ impl TopBar { model::StylePillControl::ThicknessSlider | model::StylePillControl::OpacitySlider | model::StylePillControl::SpotlightMagnificationSlider - | model::StylePillControl::PenSmoothingSlider | model::StylePillControl::FontSizeSlider => { let (slider_spec, value) = control.slider_value(snapshot); let format = match control { @@ -192,7 +181,6 @@ impl TopBar { model::StylePillControl::SpotlightMagnificationSlider => { crate::draw::format_spotlight_magnification } - model::StylePillControl::PenSmoothingSlider => format_smoothing, _ => format_pt, }; let sender = self.feedback.clone(); @@ -207,9 +195,6 @@ impl TopBar { model::StylePillControl::SpotlightMagnificationSlider => { ToolbarEvent::SetSpotlightMagnification(value) } - model::StylePillControl::PenSmoothingSlider => { - ToolbarEvent::SetPenSmoothing(value.round().clamp(0.0, 255.0) as u8) - } _ => ToolbarEvent::SetFontSize(value), }; send_event(&sender, event); @@ -220,6 +205,12 @@ impl TopBar { let carries_readout = control.carries_inline_readout(); slider.configure_inline_readout(carries_readout, px(STYLE_VALUE_W)); set_semantic_widget_id(&slider.root, control.id().as_ref()); + // A bare track with a numeral beside it has no visible + // name, so the accessible one is all a screen reader has. + let accessible_label = control.label(snapshot); + slider + .root + .update_property(&[gtk4::accessible::Property::Label(&accessible_label)]); if let Some(tooltip) = control.tooltip(snapshot) { slider.root.set_tooltip_text(Some(&tooltip)); } @@ -239,9 +230,6 @@ impl TopBar { model::StylePillControl::SpotlightMagnificationSlider => { snapshot.spotlight_magnification } - model::StylePillControl::PenSmoothingSlider => { - f64::from(snapshot.pen_smoothing) - } _ => snapshot.font_size, }; slider.set_value(value); @@ -274,7 +262,9 @@ impl TopBar { button.set_label(&control.required_value_text(snapshot)); })); } - model::StylePillControl::FillToggle | model::StylePillControl::AutoNumberToggle => { + model::StylePillControl::FillToggle + | model::StylePillControl::AutoNumberToggle + | model::StylePillControl::FontWeightToggle => { let check = gtk4::CheckButton::with_label(control.label(snapshot).as_ref()); check.add_css_class("mini"); set_semantic_widget_id(&check, control.id().as_ref()); @@ -292,6 +282,9 @@ impl TopBar { model::StylePillControl::FillToggle => { ToolbarEvent::ToggleFill(check.is_active()) } + model::StylePillControl::FontWeightToggle => { + ToolbarEvent::SetFontBold(check.is_active()) + } _ => ToolbarEvent::ToggleArrowLabels(check.is_active()), }; send_event(&sender, event); @@ -342,6 +335,10 @@ impl TopBar { ); bound_button_label(&button); set_semantic_widget_id(&button, control.id().as_ref()); + // The same clear gap the builtin puts before this button, + // so the family name does not crowd the "72pt" numeral on + // one toolbar and not the other. + button.set_margin_start(px(STYLE_SEGMENT_LEAD)); if let Some(tooltip) = control.tooltip(snapshot) { button.set_tooltip_text(Some(&tooltip)); } @@ -385,15 +382,25 @@ impl TopBar { button.set_tooltip_text(control.tooltip(snapshot).as_deref()); })); } - model::StylePillControl::SelectionStepper(_) => { - let row = gtk4::Box::new(gtk4::Orientation::Horizontal, px(2.0)); + model::StylePillControl::PenSmoothingStepper + | model::StylePillControl::SelectionStepper(_) => { + // No spacing between the halves: the builtin lays the three + // parts out abutting, at step + value + step exactly, and + // the width planner budgets that. Two 2px child gaps here + // would make this widget 4px wider than the arrangement the + // planner declared fits. + let row = gtk4::Box::new(gtk4::Orientation::Horizontal, 0); set_semantic_widget_id(&row, control.id().as_ref()); + // A row of "− 3 +" says nothing about what it steps. + let accessible_label = control.label(snapshot); + row.update_property(&[gtk4::accessible::Property::Label(&accessible_label)]); row.set_valign(gtk4::Align::Center); let steps = control.required_steps(snapshot); let mut handles: Vec = Vec::new(); let minus = pill_button(steps[0].label, sz(STYLE_STEP_W), sz(STYLE_ROW_H)); set_semantic_widget_id(&minus, steps[0].id); minus.set_tooltip_text(Some(&steps[0].tooltip)); + minus.update_property(&[gtk4::accessible::Property::Label(&steps[0].tooltip)]); row.append(&minus); handles.push(minus.clone()); let value = gtk4::Label::new(Some(&control.required_value_text(snapshot))); @@ -403,6 +410,7 @@ impl TopBar { let plus = pill_button(steps[1].label, sz(STYLE_STEP_W), sz(STYLE_ROW_H)); set_semantic_widget_id(&plus, steps[1].id); plus.set_tooltip_text(Some(&steps[1].tooltip)); + plus.update_property(&[gtk4::accessible::Property::Label(&steps[1].tooltip)]); row.append(&plus); handles.push(plus.clone()); for (button, step) in handles.iter().zip(steps.iter()) { @@ -426,13 +434,12 @@ impl TopBar { // slider has. self.append_style_status_label(&pill, control, snapshot, px(gap)); } - model::StylePillControl::FontFamilySegment - | model::StylePillControl::EraserModeSegment => { + model::StylePillControl::EraserModeSegment => { let row = gtk4::Box::new(gtk4::Orientation::Horizontal, px(2.0)); set_semantic_widget_id(&row, control.id().as_ref()); row.set_valign(gtk4::Align::Center); - // A clear gap before the segment so Sans│Mono never crowd - // the preceding numeral ("72pt") to its left (M7-C3). + // Keep the mode segments visually separate from the + // preceding eraser-size controls. row.set_margin_start(px(STYLE_SEGMENT_LEAD)); let segments = control.required_segments(snapshot); let mut handles: Vec<(gtk4::Button, &'static str)> = Vec::new(); diff --git a/src/toolbar_gtk/view/top_bar/tests.rs b/src/toolbar_gtk/view/top_bar/tests.rs index 29539e73..cc0c10c7 100644 --- a/src/toolbar_gtk/view/top_bar/tests.rs +++ b/src/toolbar_gtk/view/top_bar/tests.rs @@ -566,6 +566,8 @@ fn style_pill_selection_snapshot(base: &ToolbarSnapshot) -> ToolbarSnapshot { selection_property_entry("Thickness", "3.0px", K::Thickness, false), selection_property_entry("Fill", "Locked", K::Fill, true), ]; + snapshot.selection_has_text = true; + snapshot.selected_text_bold = Some(false); snapshot } @@ -947,10 +949,29 @@ fn assert_gtk_style_widget( control.tooltip(snapshot).as_deref(), "{id} tooltip" ); + if control == model::StylePillControl::FontFamilyPicker { + // The builtin puts a clear gap before this button so the family + // name does not crowd the "72pt" numeral to its left. Without + // the matching margin here the two toolbars space it + // differently. + assert!( + button.margin_start() > 0, + "{id} lost the leading gap the builtin gives it" + ); + } } model::StylePillRole::Stepper => { let steps = control.steps(snapshot).expect("stepper halves"); - assert!(widget.is::(), "{id} stepper row"); + let row = widget + .clone() + .downcast::() + .unwrap_or_else(|_| panic!("{id} is a stepper row")); + // The builtin lays the three parts out abutting and the width + // planner budgets step + value + step exactly. Child spacing here + // would make the widget wider than the plan says it is. + assert_eq!(row.spacing(), 0, "{id} stepper spacing"); + // "− 3 +" says nothing about what it steps. + assert_accessible_label(widget, &control.label(snapshot), &id); let minus = widget.first_child().expect("stepper minus half"); let value = minus.next_sibling().expect("stepper value readout"); let plus = value.next_sibling().expect("stepper plus half"); @@ -973,6 +994,9 @@ fn assert_gtk_style_widget( "{} tooltip", step.id ); + // A tooltip is not an accessible name: a screen reader on + // these halves would otherwise announce "−" and "+". + assert_accessible_label(button.upcast_ref(), &step.tooltip, step.id); assert_eq!( button.is_sensitive(), control.enabled(snapshot), diff --git a/src/ui.rs b/src/ui.rs index 5ee480f4..f0152415 100644 --- a/src/ui.rs +++ b/src/ui.rs @@ -31,6 +31,7 @@ mod tour; pub(crate) use arrow_bend_handle::render_arrow_bend_handle; pub use board_picker::render_board_picker; +pub(crate) use board_picker::render_board_picker_with_halo; pub use color_picker_popup::{color_picker_popup_visual_geometry, render_color_picker_popup}; pub use command_palette::{command_palette_visual_geometry, render_command_palette}; pub use context_menu::render_context_menu; diff --git a/src/ui/board_picker.rs b/src/ui/board_picker.rs index 44c8f6c5..08192809 100644 --- a/src/ui/board_picker.rs +++ b/src/ui/board_picker.rs @@ -21,6 +21,16 @@ pub fn render_board_picker( input_state: &InputState, screen_width: u32, screen_height: u32, +) { + render_board_picker_with_halo(ctx, input_state, screen_width, screen_height, true); +} + +pub(crate) fn render_board_picker_with_halo( + ctx: &cairo::Context, + input_state: &InputState, + screen_width: u32, + screen_height: u32, + text_halo_enabled: bool, ) { if !input_state.is_board_picker_open() { return; @@ -136,7 +146,14 @@ pub fn render_board_picker( render_board_rows(ctx, input_state, layout, board_count, max_count); render_board_palette(ctx, input_state, layout); - render_page_panel(ctx, input_state, layout, screen_width, screen_height); + render_page_panel( + ctx, + input_state, + layout, + screen_width, + screen_height, + text_halo_enabled, + ); let _ = ctx.restore(); } diff --git a/src/ui/board_picker/page_panel.rs b/src/ui/board_picker/page_panel.rs index 4aef6cdc..3d6ea8ca 100644 --- a/src/ui/board_picker/page_panel.rs +++ b/src/ui/board_picker/page_panel.rs @@ -32,6 +32,7 @@ pub(super) fn render_page_panel( layout: BoardPickerLayout, screen_width: u32, screen_height: u32, + text_halo_enabled: bool, ) { if !layout.page_panel_enabled { return; @@ -145,6 +146,7 @@ pub(super) fn render_page_panel( height: layout.page_thumb_height, screen_width, screen_height, + text_halo_enabled, page_number: index + 1, page_name: page.page_name(), is_active, @@ -179,6 +181,7 @@ pub(super) fn render_page_panel( thumb_h: layout.page_thumb_height, screen_width, screen_height, + text_halo_enabled, page_number: hover_index + 1, }); } diff --git a/src/ui/board_picker/page_panel/thumbnail/cards.rs b/src/ui/board_picker/page_panel/thumbnail/cards.rs index 0e8a01e1..30ba2212 100644 --- a/src/ui/board_picker/page_panel/thumbnail/cards.rs +++ b/src/ui/board_picker/page_panel/thumbnail/cards.rs @@ -33,6 +33,7 @@ pub(in crate::ui::board_picker::page_panel) fn render_page_thumbnail(args: PageT height, screen_width, screen_height, + text_halo_enabled, page_number, page_name, is_active, @@ -66,6 +67,7 @@ pub(in crate::ui::board_picker::page_panel) fn render_page_thumbnail(args: PageT height, screen_width, screen_height, + text_halo_enabled, }); if is_active { @@ -293,6 +295,7 @@ pub(in crate::ui::board_picker::page_panel) fn render_page_preview(args: PagePre thumb_h, screen_width, screen_height, + text_halo_enabled, page_number, } = args; let base_w = thumb_w * PREVIEW_SCALE; @@ -338,6 +341,7 @@ pub(in crate::ui::board_picker::page_panel) fn render_page_preview(args: PagePre height: preview_h, screen_width, screen_height, + text_halo_enabled, }); let label = frame diff --git a/src/ui/board_picker/page_panel/thumbnail/content.rs b/src/ui/board_picker/page_panel/thumbnail/content.rs index e65f3a39..b4c9b42f 100644 --- a/src/ui/board_picker/page_panel/thumbnail/content.rs +++ b/src/ui/board_picker/page_panel/thumbnail/content.rs @@ -1,7 +1,7 @@ use crate::draw::{ EraserReplayContext, SpotlightMagnifierScratch, SpotlightMagnifierSource, SpotlightPass, - render_eraser_stroke, render_shape, render_spotlight_magnification_pass, render_spotlight_pass, - spotlight_regions_for_frame, + render_eraser_stroke, render_shape_with_halo, render_spotlight_magnification_pass, + render_spotlight_pass, spotlight_regions_for_frame, }; use crate::input::BoardBackground; use crate::input::state::{PAGE_NAME_HEIGHT, PAGE_NAME_PADDING}; @@ -35,6 +35,7 @@ pub(super) fn render_page_content(args: PageContentArgs<'_>) { height, screen_width, screen_height, + text_halo_enabled, } = args; let radius = RADIUS_STD; let _ = ctx.save(); @@ -71,7 +72,14 @@ pub(super) fn render_page_content(args: PageContentArgs<'_>) { let _ = ctx.save(); ctx.translate(x + inset + offset_x, y + inset + offset_y); ctx.scale(scale, scale); - render_frame_shapes(ctx, frame, background, screen_width, screen_height); + render_frame_shapes( + ctx, + frame, + background, + screen_width, + screen_height, + text_halo_enabled, + ); let _ = ctx.restore(); let _ = ctx.restore(); } @@ -82,6 +90,7 @@ fn render_frame_shapes( background: &BoardBackground, target_width: u32, target_height: u32, + text_halo_enabled: bool, ) { let eraser_ctx = EraserReplayContext { pattern: None, @@ -103,7 +112,7 @@ fn render_frame_shapes( render_eraser_stroke(ctx, points, brush, &eraser_ctx); } _ => { - render_shape(ctx, &drawn.shape); + render_shape_with_halo(ctx, &drawn.shape, text_halo_enabled); } } } @@ -241,7 +250,7 @@ pub(super) fn render_page_name_label( #[cfg(test)] mod tests { use super::*; - use crate::draw::{Color, Frame, Shape}; + use crate::draw::{Color, FontDescriptor, Frame, Shape}; fn thumbnail_pixels(background: &BoardBackground, magnification: f64) -> Vec { let surface = @@ -282,6 +291,7 @@ mod tests { height: 90.0, screen_width: 400, screen_height: 300, + text_halo_enabled: true, }); } let mut surface = surface; @@ -289,6 +299,47 @@ mod tests { surface.data().expect("thumbnail pixels").to_vec() } + fn text_thumbnail_pixels(text_halo_enabled: bool) -> Vec { + let mut surface = + cairo::ImageSurface::create(cairo::Format::ARgb32, 120, 90).expect("thumbnail surface"); + { + let ctx = cairo::Context::new(&surface).expect("thumbnail context"); + let mut frame = Frame::new(); + frame.add_shape(Shape::Text { + x: 60, + y: 160, + text: "Read me".to_string(), + color: Color::new(0.96, 0.2, 0.25, 1.0), + size: 48.0, + font_descriptor: FontDescriptor::default(), + background_enabled: false, + wrap_width: None, + }); + render_page_content(PageContentArgs { + ctx: &ctx, + frame: &frame, + background: &BoardBackground::Solid(Color::new(1.0, 1.0, 1.0, 1.0)), + x: 0.0, + y: 0.0, + width: 120.0, + height: 90.0, + screen_width: 400, + screen_height: 300, + text_halo_enabled, + }); + } + surface.flush(); + surface.data().expect("thumbnail pixels").to_vec() + } + + #[test] + fn a_page_thumbnail_honours_the_text_halo_setting() { + assert!( + text_thumbnail_pixels(true) != text_thumbnail_pixels(false), + "thumbnail text must forward the halo setting to the shape renderer", + ); + } + #[test] fn a_solid_board_thumbnail_magnifies_its_spotlight() { let background = BoardBackground::Solid(Color { diff --git a/src/ui/board_picker/page_panel/thumbnail/types.rs b/src/ui/board_picker/page_panel/thumbnail/types.rs index 1dcd08b4..79ec076f 100644 --- a/src/ui/board_picker/page_panel/thumbnail/types.rs +++ b/src/ui/board_picker/page_panel/thumbnail/types.rs @@ -12,6 +12,7 @@ pub(in crate::ui::board_picker::page_panel) struct PageThumbnailArgs<'a> { pub(in crate::ui::board_picker::page_panel) height: f64, pub(in crate::ui::board_picker::page_panel) screen_width: u32, pub(in crate::ui::board_picker::page_panel) screen_height: u32, + pub(in crate::ui::board_picker::page_panel) text_halo_enabled: bool, pub(in crate::ui::board_picker::page_panel) page_number: usize, pub(in crate::ui::board_picker::page_panel) page_name: Option<&'a str>, pub(in crate::ui::board_picker::page_panel) is_active: bool, @@ -34,6 +35,7 @@ pub(in crate::ui::board_picker::page_panel) struct PagePreviewArgs<'a> { pub(in crate::ui::board_picker::page_panel) thumb_h: f64, pub(in crate::ui::board_picker::page_panel) screen_width: u32, pub(in crate::ui::board_picker::page_panel) screen_height: u32, + pub(in crate::ui::board_picker::page_panel) text_halo_enabled: bool, pub(in crate::ui::board_picker::page_panel) page_number: usize, } @@ -47,4 +49,5 @@ pub(in crate::ui::board_picker::page_panel) struct PageContentArgs<'a> { pub(in crate::ui::board_picker::page_panel) height: f64, pub(in crate::ui::board_picker::page_panel) screen_width: u32, pub(in crate::ui::board_picker::page_panel) screen_height: u32, + pub(in crate::ui::board_picker::page_panel) text_halo_enabled: bool, } diff --git a/src/ui/theme.rs b/src/ui/theme.rs index b2f7403d..1e42f745 100644 --- a/src/ui/theme.rs +++ b/src/ui/theme.rs @@ -425,7 +425,7 @@ pub mod toolbar { /// filling the whole half. pub const SEGMENT_PADDING: f64 = SPACING_SM; /// Horizontal breathing room between a segment label and its edge (the - /// GTK `.tab` horizontal padding), so "Sans│Mono" never crowd the seam. + /// GTK `.tab` horizontal padding), so short labels never crowd the seam. pub const SEGMENT_LABEL_PAD_H: f64 = SPACING_STD; /// Extra clear gap before a segmented control in the style pill, on top of /// the standard control gap, so the segment does not crowd the numeral diff --git a/src/ui/theme/css.rs b/src/ui/theme/css.rs index a9e48e8b..0efe1d52 100644 --- a/src/ui/theme/css.rs +++ b/src/ui/theme/css.rs @@ -144,7 +144,7 @@ pub struct GtkStylesheetValues { pub pad_island_compact: i32, pub pad_popover: i32, /// Horizontal padding of a segmented-control `.tab` label (M7-C3), so - /// "Sans│Mono" are not crammed against the tab edges. + /// short labels are not crammed against the tab edges. pub segment_pad_h: i32, pub check_size: i32, pub font_label: i32, diff --git a/src/ui/toolbar/apply/mod.rs b/src/ui/toolbar/apply/mod.rs index 3887b81c..ea1b93ee 100644 --- a/src/ui/toolbar/apply/mod.rs +++ b/src/ui/toolbar/apply/mod.rs @@ -68,6 +68,7 @@ impl InputState { ToolbarEvent::OpenFontPicker => self.apply_toolbar_open_font_picker(), ToolbarEvent::SetEraserMode(mode) => self.apply_toolbar_set_eraser_mode(mode), ToolbarEvent::SetFont(descriptor) => self.apply_toolbar_set_font(descriptor), + ToolbarEvent::SetFontBold(bold) => self.apply_toolbar_set_font_bold(bold), ToolbarEvent::SetFontSize(size) => self.apply_toolbar_set_font_size(size), ToolbarEvent::NudgeFontSize(delta) => { self.apply_toolbar_set_font_size(self.current_font_size + delta) diff --git a/src/ui/toolbar/apply/tools.rs b/src/ui/toolbar/apply/tools.rs index 911fffcc..64d08496 100644 --- a/src/ui/toolbar/apply/tools.rs +++ b/src/ui/toolbar/apply/tools.rs @@ -100,6 +100,10 @@ impl InputState { self.set_font_descriptor(descriptor) } + pub(super) fn apply_toolbar_set_font_bold(&mut self, bold: bool) -> bool { + self.set_font_bold(bold) + } + pub(super) fn apply_toolbar_set_font_size(&mut self, size: f64) -> bool { self.set_font_size(size) } diff --git a/src/ui/toolbar/events.rs b/src/ui/toolbar/events.rs index 4de7de90..c95b5d87 100644 --- a/src/ui/toolbar/events.rs +++ b/src/ui/toolbar/events.rs @@ -91,8 +91,9 @@ pub enum ToolbarEvent { SetPenSmoothing(u8), SetEraserMode(EraserMode), SetFont(FontDescriptor), - /// Open the overlay's system font picker. The toolbar's own font control - /// offers two families; every installed one lives behind this. + /// Turn bold on or off for selected text, or for the next label typed. + SetFontBold(bool), + /// Open the overlay's system font picker from the current-family button. OpenFontPicker, SetFontSize(f64), NudgeFontSize(f64), diff --git a/src/ui/toolbar/model/activation.rs b/src/ui/toolbar/model/activation.rs index 7789df62..18b4ff94 100644 --- a/src/ui/toolbar/model/activation.rs +++ b/src/ui/toolbar/model/activation.rs @@ -73,11 +73,6 @@ impl ToolbarSlider { ToolbarSliderTarget::SpotlightMagnification => { ToolbarEvent::SetSpotlightMagnification(value) } - // Whole passes only: the spec snaps to a step of one, so the cast - // is the value the slider already settled on. - ToolbarSliderTarget::PenSmoothing => { - ToolbarEvent::SetPenSmoothing(value.round().clamp(0.0, 255.0) as u8) - } ToolbarSliderTarget::FontSize => ToolbarEvent::SetFontSize(value), ToolbarSliderTarget::UndoDelay => ToolbarEvent::SetUndoDelay(value), ToolbarSliderTarget::RedoDelay => ToolbarEvent::SetRedoDelay(value), @@ -101,7 +96,6 @@ pub(crate) enum ToolbarSliderTarget { Thickness, MarkerOpacity, SpotlightMagnification, - PenSmoothing, FontSize, UndoDelay, RedoDelay, @@ -142,12 +136,6 @@ impl ToolbarSliderSpec { step: Some(crate::draw::SPOTLIGHT_MAGNIFICATION_STEP), snap_to_step: true, }; - pub(crate) const PEN_SMOOTHING: Self = Self { - min: 0.0, - max: crate::draw::MAX_PEN_SMOOTHING as f64, - step: Some(1.0), - snap_to_step: true, - }; pub(crate) const THICKNESS: Self = Self { min: MIN_STROKE_THICKNESS, max: MAX_STROKE_THICKNESS, diff --git a/src/ui/toolbar/model/event_policy.rs b/src/ui/toolbar/model/event_policy.rs index 8806b315..e56278ed 100644 --- a/src/ui/toolbar/model/event_policy.rs +++ b/src/ui/toolbar/model/event_policy.rs @@ -504,6 +504,7 @@ fn persistence_for_event(event: &ToolbarEvent) -> ToolbarPersistence { | ToolbarEvent::SetPenSmoothing(_) | ToolbarEvent::SetEraserMode(_) | ToolbarEvent::SetFont(_) + | ToolbarEvent::SetFontBold(_) | ToolbarEvent::SetFontSize(_) | ToolbarEvent::NudgeFontSize(_) | ToolbarEvent::ToggleFill(_) diff --git a/src/ui/toolbar/model/style_pill.rs b/src/ui/toolbar/model/style_pill.rs index e5fccfb5..2871d9b3 100644 --- a/src/ui/toolbar/model/style_pill.rs +++ b/src/ui/toolbar/model/style_pill.rs @@ -25,7 +25,6 @@ use std::borrow::Cow; use crate::config::{ Action, QuickColorPalette, action_label, action_short_label, toolbar_item_ids as ids, }; -use crate::draw::{FontDescriptor, families_match}; use crate::input::{EraserMode, SelectionPropertyEntry, SelectionPropertyKind}; use crate::label_format::{format_binding_label, format_quick_color_tooltip}; use crate::ui::toolbar::{ToolContext, ToolOptionsKind, ToolbarEvent, ToolbarSnapshot}; @@ -92,8 +91,12 @@ pub(crate) enum StylePillControl { ThicknessValue, /// Marker opacity slider. OpacitySlider, - /// Pen/marker smoothing slider, in whole passes. - PenSmoothingSlider, + /// Pen/marker smoothing, as a −/value/+ stepper. + /// + /// A stepper rather than a slider: the range is seven whole passes, which + /// on a 110px track is 18px of travel per step and fiddly to land on. It + /// also keeps the pill from reading as a row of near-identical bars. + PenSmoothingStepper, /// Spotlight magnification slider. SpotlightMagnificationSlider, /// Shape fill toggle. @@ -111,11 +114,11 @@ pub(crate) enum StylePillControl { FontSizeSlider, /// Live text-size numeral; clicking opens the precise-entry popup. FontSizeValue, - /// Sans/Mono font family segmented control. - FontFamilySegment, + /// Bold on/off for selected text, or for the next label when no text is + /// selected. Font family and weight remain independent choices. + FontWeightToggle, /// Button showing the family in use; opens the overlay's font picker over - /// every installed family. The segment beside it covers the two the - /// toolbar has always offered; this is how the rest are reachable. + /// every installed family. FontFamilyPicker, /// Brush/Stroke eraser mode segmented control (the old checkbox /// semantics as a two-segment control emitting `SetEraserMode`). @@ -232,11 +235,14 @@ impl StylePillSpec { } if state == StylePillState::Selection { - let controls = snapshot + let mut controls: Vec<_> = snapshot .selection_properties .iter() .map(|entry| selection_control_for_kind(entry.kind)) .collect(); + if snapshot.selection_has_text && !plan.drop_style_extras { + controls.push(StylePillControl::FontWeightToggle); + } return Self { state, controls }; } @@ -269,7 +275,7 @@ impl StylePillSpec { controls.push(StylePillControl::OpacitySlider); } if context.show_pen_smoothing && !plan.drop_style_extras { - controls.push(StylePillControl::PenSmoothingSlider); + controls.push(StylePillControl::PenSmoothingStepper); } if context.tool_options_kind == ToolOptionsKind::Spotlight { controls.push(StylePillControl::SpotlightMagnificationSlider); @@ -292,10 +298,10 @@ impl StylePillSpec { if context.show_font_controls { controls.push(StylePillControl::FontSizeSlider); controls.push(StylePillControl::FontSizeValue); - controls.push(StylePillControl::FontFamilySegment); if !plan.drop_style_extras { - controls.push(StylePillControl::FontFamilyPicker); + controls.push(StylePillControl::FontWeightToggle); } + controls.push(StylePillControl::FontFamilyPicker); } if context.show_eraser_mode { controls.push(StylePillControl::EraserModeSegment); @@ -333,7 +339,7 @@ impl StylePillSpec { // Select: docks the selection properties while a selection // exists; hidden otherwise. ToolOptionsKind::None => { - if snapshot.selection_properties.is_empty() { + if snapshot.selection_properties.is_empty() && !snapshot.selection_has_text { StylePillState::Hidden } else { StylePillState::Selection diff --git a/src/ui/toolbar/model/style_pill/control.rs b/src/ui/toolbar/model/style_pill/control.rs index 3b7ff67e..ddbec385 100644 --- a/src/ui/toolbar/model/style_pill/control.rs +++ b/src/ui/toolbar/model/style_pill/control.rs @@ -8,7 +8,7 @@ impl StylePillControl { Self::ThicknessSlider => Cow::Borrowed("top.style.thickness"), Self::ThicknessValue => Cow::Borrowed("top.style.thickness-value"), Self::OpacitySlider => Cow::Borrowed("top.style.opacity"), - Self::PenSmoothingSlider => Cow::Borrowed("top.style.pen-smoothing"), + Self::PenSmoothingStepper => Cow::Borrowed("top.style.pen-smoothing"), Self::SpotlightMagnificationSlider => { Cow::Borrowed("top.style.spotlight-magnification") } @@ -26,7 +26,7 @@ impl StylePillControl { } Self::FontSizeSlider => Cow::Borrowed("top.style.font-size"), Self::FontSizeValue => Cow::Borrowed("top.style.font-size-value"), - Self::FontFamilySegment => Cow::Borrowed("top.style.font-family"), + Self::FontWeightToggle => Cow::Borrowed("top.style.font-bold"), Self::FontFamilyPicker => Cow::Borrowed("top.style.font-family-picker"), Self::EraserModeSegment => Cow::Borrowed("top.style.eraser-mode"), Self::SelectionCycle(kind) | Self::SelectionStepper(kind) => { @@ -41,16 +41,17 @@ impl StylePillControl { Self::ThicknessSlider | Self::OpacitySlider | Self::SpotlightMagnificationSlider - | Self::PenSmoothingSlider | Self::FontSizeSlider => StylePillRole::Slider, Self::ThicknessValue | Self::FontSizeValue => StylePillRole::Value, - Self::FillToggle | Self::AutoNumberToggle => StylePillRole::Toggle, + Self::FillToggle | Self::AutoNumberToggle | Self::FontWeightToggle => { + StylePillRole::Toggle + } Self::CounterReset(_) | Self::ArrowStyleCycle | Self::FontFamilyPicker => { StylePillRole::Button } - Self::FontFamilySegment | Self::EraserModeSegment => StylePillRole::Segmented, + Self::EraserModeSegment => StylePillRole::Segmented, Self::SelectionCycle(_) => StylePillRole::Button, - Self::SelectionStepper(_) => StylePillRole::Stepper, + Self::PenSmoothingStepper | Self::SelectionStepper(_) => StylePillRole::Stepper, } } @@ -72,10 +73,12 @@ impl StylePillControl { Self::SpotlightMagnificationSlider => { ToolbarEvent::SetSpotlightMagnification(snapshot.spotlight_magnification) } - Self::PenSmoothingSlider => ToolbarEvent::SetPenSmoothing(snapshot.pen_smoothing), Self::FontFamilyPicker => ToolbarEvent::OpenFontPicker, Self::FontSizeSlider => ToolbarEvent::SetFontSize(snapshot.font_size), Self::FillToggle => ToolbarEvent::ToggleFill(!snapshot.fill_enabled), + Self::FontWeightToggle => { + ToolbarEvent::SetFontBold(!snapshot.font_bold_target_is_bold()) + } Self::AutoNumberToggle => { ToolbarEvent::ToggleArrowLabels(!snapshot.arrow_label_enabled) } @@ -94,7 +97,7 @@ impl StylePillControl { Self::SelectionCycle(kind) => { ToolbarEvent::AdjustSelectionProperty { kind, direction: 1 } } - Self::FontFamilySegment | Self::EraserModeSegment | Self::SelectionStepper(_) => { + Self::EraserModeSegment | Self::PenSmoothingStepper | Self::SelectionStepper(_) => { return None; } }) @@ -109,6 +112,11 @@ impl StylePillControl { pub(crate) fn enabled(self, snapshot: &ToolbarSnapshot) -> bool { match self { + // A text selection owns Bold even when every selected text shape + // is locked. In that case there is no editable mutation target. + Self::FontWeightToggle => { + !snapshot.selection_has_text || snapshot.selected_text_bold.is_some() + } // Locked/mixed-locked entries surface as disabled controls, // exactly like the greyed rows of the properties popup. Self::SelectionCycle(kind) | Self::SelectionStepper(kind) => { @@ -125,6 +133,7 @@ impl StylePillControl { snapshot.quick_colors.rendered_entries()[index].color == snapshot.color } Self::FillToggle => snapshot.fill_enabled, + Self::FontWeightToggle => snapshot.font_bold_target_is_bold(), Self::AutoNumberToggle => snapshot.arrow_label_enabled, _ => false, } @@ -141,10 +150,6 @@ impl StylePillControl { ToolbarSliderSpec::SPOTLIGHT_MAGNIFICATION, snapshot.spotlight_magnification, )), - Self::PenSmoothingSlider => Some(( - ToolbarSliderSpec::PEN_SMOOTHING, - f64::from(snapshot.pen_smoothing), - )), Self::FontSizeSlider => Some((ToolbarSliderSpec::FONT_SIZE, snapshot.font_size)), _ => None, } @@ -172,7 +177,7 @@ impl StylePillControl { ), // "Off" rather than "0": the number is a count of passes, and zero // of them is a state worth naming rather than a quantity. - Self::PenSmoothingSlider => Some(if snapshot.pen_smoothing == 0 { + Self::PenSmoothingStepper => Some(if snapshot.pen_smoothing == 0 { "Off".to_string() } else { snapshot.pen_smoothing.to_string() @@ -206,7 +211,7 @@ impl StylePillControl { pub(crate) fn carries_inline_readout(self) -> bool { matches!( self, - Self::OpacitySlider | Self::SpotlightMagnificationSlider | Self::PenSmoothingSlider + Self::OpacitySlider | Self::SpotlightMagnificationSlider ) } @@ -257,7 +262,7 @@ impl StylePillControl { Cow::Borrowed(ToolContext::from_snapshot(snapshot).thickness_label) } Self::OpacitySlider => Cow::Borrowed("Marker opacity"), - Self::PenSmoothingSlider => Cow::Borrowed("Smoothing"), + Self::PenSmoothingStepper => Cow::Borrowed("Smoothing"), Self::SpotlightMagnificationSlider => Cow::Borrowed("Spotlight magnification"), Self::FontSizeSlider => Cow::Borrowed("Text size"), Self::ThicknessValue => Cow::Owned(format!("{:.0}px", snapshot.thickness)), @@ -266,9 +271,9 @@ impl StylePillControl { Self::ArrowStyleCycle => Cow::Borrowed("Arrow style"), Self::AutoNumberToggle => Cow::Borrowed("Auto-number"), Self::CounterReset(_) => Cow::Borrowed("Reset"), - Self::FontFamilySegment => Cow::Borrowed("Font"), // The family in use, shortened: the pill is width-planned, and a // display face can be named at any length. + Self::FontWeightToggle => Cow::Borrowed("Bold"), Self::FontFamilyPicker => Cow::Owned(short_family_label(&snapshot.font.family)), Self::EraserModeSegment => Cow::Borrowed("Eraser mode"), Self::SelectionCycle(kind) | Self::SelectionStepper(kind) => Cow::Owned( @@ -318,50 +323,39 @@ impl StylePillControl { )), Self::SelectionCycle(kind) => selection_entry(snapshot, kind) .map(|entry| format!("{}: {}", entry.label, entry.value)), - Self::PenSmoothingSlider => Some( + Self::PenSmoothingStepper => Some( "Smooth freehand and marker strokes when the pen lifts. Off keeps the exact path." .to_string(), ), + Self::FontWeightToggle => { + Some("Bold. Applies to selected text, or to the next label you type.".to_string()) + } Self::FontFamilyPicker => Some(format!( "{} - choose from every installed font", snapshot.font.family )), - Self::ThicknessSlider - | Self::OpacitySlider - | Self::FontSizeSlider - | Self::FontFamilySegment - | Self::EraserModeSegment - | Self::SelectionStepper(_) => None, + // The pill shows sliders as bare tracks with a numeral beside + // them, so a hover is the only place they can say which is which. + // The context decides the thickness wording because one slider + // targets the pen, the marker, or the eraser depending on what is + // active. + Self::ThicknessSlider => { + Some(match ToolContext::from_snapshot(snapshot).thickness_label { + "Eraser size" => "How wide the eraser rubs out.".to_string(), + label => format!("{label} of the next stroke you draw."), + }) + } + Self::OpacitySlider => { + Some("How much of the page shows through a highlighter stroke.".to_string()) + } + Self::FontSizeSlider => Some("Point size of the next label you type.".to_string()), + Self::EraserModeSegment | Self::SelectionStepper(_) => None, } } /// Segment halves of the segmented controls, in reading order. pub(crate) fn segments(self, snapshot: &ToolbarSnapshot) -> Option<[StylePillSegment; 2]> { match self { - Self::FontFamilySegment => Some([ - StylePillSegment { - id: "top.style.font-family.sans", - label: "Sans", - event: ToolbarEvent::SetFont(FontDescriptor::new( - "Sans".to_string(), - "bold".to_string(), - "normal".to_string(), - )), - active: families_match(&snapshot.font.family, "Sans"), - tooltip: "Sans font".to_string(), - }, - StylePillSegment { - id: "top.style.font-family.mono", - label: "Mono", - event: ToolbarEvent::SetFont(FontDescriptor::new( - "Monospace".to_string(), - "normal".to_string(), - "normal".to_string(), - )), - active: families_match(&snapshot.font.family, "Monospace"), - tooltip: "Monospace font".to_string(), - }, - ]), Self::EraserModeSegment => Some([ StylePillSegment { id: "top.style.eraser-mode.brush", @@ -389,6 +383,9 @@ impl StylePillControl { /// The −/+ halves of a selection stepper, in reading order. pub(crate) fn steps(self, snapshot: &ToolbarSnapshot) -> Option<[StylePillStep; 2]> { + if self == Self::PenSmoothingStepper { + return Some(pen_smoothing_steps(snapshot)); + } let Self::SelectionStepper(kind) = self else { return None; }; @@ -448,6 +445,31 @@ impl StylePillControl { } } +/// The smoothing stepper's halves, clamped to the range the setting accepts. +/// +/// Each half carries the level it would land on rather than a direction: the +/// pill's events are absolute, and computing the target here keeps the clamp in +/// one place instead of in both frontends. +fn pen_smoothing_steps(snapshot: &ToolbarSnapshot) -> [StylePillStep; 2] { + let level = snapshot.pen_smoothing; + [ + StylePillStep { + id: "top.style.pen-smoothing.minus", + label: "\u{2212}", + event: ToolbarEvent::SetPenSmoothing(level.saturating_sub(1)), + tooltip: "Less smoothing".to_string(), + }, + StylePillStep { + id: "top.style.pen-smoothing.plus", + label: "+", + event: ToolbarEvent::SetPenSmoothing( + level.saturating_add(1).min(crate::draw::MAX_PEN_SMOOTHING), + ), + tooltip: "More smoothing".to_string(), + }, + ] +} + /// A family name cut to something a width-planned pill can hold. /// /// The full name is in the tooltip. Truncating here rather than in the diff --git a/src/ui/toolbar/model/style_pill/tests/selection.rs b/src/ui/toolbar/model/style_pill/tests/selection.rs index 235afe14..0be1a782 100644 --- a/src/ui/toolbar/model/style_pill/tests/selection.rs +++ b/src/ui/toolbar/model/style_pill/tests/selection.rs @@ -71,6 +71,55 @@ fn select_with_a_selection_docks_the_property_entries_in_order() { assert!(StylePillSpec::build(&empty, &plan()).controls().is_empty()); } +#[test] +fn selected_text_adds_a_bold_toggle_driven_by_the_selection() { + let mut snapshot = selection_snapshot(); + snapshot.selection_has_text = true; + snapshot.selected_text_bold = Some(false); + + let spec = StylePillSpec::build(&snapshot, &plan()); + assert_eq!( + spec.controls().last(), + Some(&StylePillControl::FontWeightToggle) + ); + assert!(!StylePillControl::FontWeightToggle.active(&snapshot)); + assert_eq!( + StylePillControl::FontWeightToggle.event(&snapshot), + Some(ToolbarEvent::SetFontBold(true)) + ); + + snapshot.font.weight = "normal".to_string(); + snapshot.selected_text_bold = Some(true); + assert!(StylePillControl::FontWeightToggle.active(&snapshot)); + assert_eq!( + StylePillControl::FontWeightToggle.event(&snapshot), + Some(ToolbarEvent::SetFontBold(false)) + ); + + let mut narrow = plan(); + narrow.drop_style_extras = true; + assert!( + !StylePillSpec::build(&snapshot, &narrow) + .controls() + .contains(&StylePillControl::FontWeightToggle) + ); +} + +#[test] +fn an_all_locked_text_selection_disables_bold() { + let mut snapshot = selection_snapshot(); + snapshot.selection_has_text = true; + snapshot.selected_text_bold = None; + + let spec = StylePillSpec::build(&snapshot, &plan()); + assert!( + spec.controls() + .contains(&StylePillControl::FontWeightToggle) + ); + assert!(!StylePillControl::FontWeightToggle.enabled(&snapshot)); + assert!(!StylePillControl::FontWeightToggle.active(&snapshot)); +} + #[test] fn selection_cycles_step_forward_through_the_apply_machinery() { let snapshot = selection_snapshot(); diff --git a/src/ui/toolbar/model/style_pill/tests/tool_states.rs b/src/ui/toolbar/model/style_pill/tests/tool_states.rs index db4ab834..750fe74b 100644 --- a/src/ui/toolbar/model/style_pill/tests/tool_states.rs +++ b/src/ui/toolbar/model/style_pill/tests/tool_states.rs @@ -55,30 +55,156 @@ fn spotlight_state_is_a_magnification_slider_without_stroke_controls() { } #[test] -fn the_smoothing_slider_reads_and_writes_whole_passes() { +fn the_smoothing_stepper_moves_one_whole_pass_at_a_time() { + // A stepper rather than a slider: seven whole passes across a 110px track + // is 18px per step, and the row of near-identical bars was the thing that + // made the pill hard to read. let mut snapshot = snapshot_for_tool(Tool::Pen); snapshot.pen_smoothing = 3; - let slider = StylePillControl::PenSmoothingSlider; + let stepper = StylePillControl::PenSmoothingStepper; + assert_eq!(stepper.role(), StylePillRole::Stepper); assert_eq!( - slider.event(&snapshot), - Some(ToolbarEvent::SetPenSmoothing(3)) + stepper.event(&snapshot), + None, + "a stepper keeps its events on its halves" ); + assert_eq!(stepper.value_text(&snapshot).as_deref(), Some("3")); + + let steps = stepper.required_steps(&snapshot); + assert_eq!(steps[0].event, ToolbarEvent::SetPenSmoothing(2)); + assert_eq!(steps[1].event, ToolbarEvent::SetPenSmoothing(4)); + + // Zero passes is a state, not a quantity. + snapshot.pen_smoothing = 0; + assert_eq!(stepper.value_text(&snapshot).as_deref(), Some("Off")); +} + +#[test] +fn every_slider_says_what_it_does_and_carries_a_name() { + // The pill draws sliders as bare tracks with a numeral beside them, so a + // hover is the only place they can say which is which. `label` is what both + // frontends hand to the accessibility layer; a slider with neither is three + // anonymous bars to anyone not looking at the numerals. + let mut snapshot = snapshot_for_tool(Tool::Pen); + snapshot.show_marker_opacity_section = true; + snapshot.show_text_controls = true; + + for control in [ + StylePillControl::ThicknessSlider, + StylePillControl::OpacitySlider, + StylePillControl::FontSizeSlider, + StylePillControl::SpotlightMagnificationSlider, + ] { + assert_eq!(control.role(), StylePillRole::Slider); + assert!( + control + .tooltip(&snapshot) + .is_some_and(|text| !text.is_empty()), + "{control:?} has nothing to say on hover" + ); + assert!( + !control.label(&snapshot).is_empty(), + "{control:?} has no accessible name" + ); + } +} + +#[test] +fn the_thickness_tooltip_follows_what_the_slider_is_actually_sizing() { + // One slider targets the pen, the marker, or the eraser depending on what + // is active, so a fixed wording would be wrong two thirds of the time. + let mut snapshot = snapshot_for_tool(Tool::Eraser); + snapshot.thickness_targets_eraser = true; + let eraser = StylePillControl::ThicknessSlider + .tooltip(&snapshot) + .expect("a tooltip"); + assert!(eraser.to_lowercase().contains("eraser"), "got {eraser:?}"); + + let pen = StylePillControl::ThicknessSlider + .tooltip(&snapshot_for_tool(Tool::Pen)) + .expect("a tooltip"); + assert!(!pen.to_lowercase().contains("eraser"), "got {pen:?}"); +} + +#[test] +fn bold_has_a_control_of_its_own() { + // Family selection must not decide weight, so Bold has an independent + // control with an independent event. + let mut snapshot = snapshot(); + snapshot.text_active = true; + + let toggle = StylePillControl::FontWeightToggle; + assert_eq!(toggle.role(), StylePillRole::Toggle); + assert_eq!(toggle.label(&snapshot), "Bold"); + + snapshot.font = crate::draw::FontDescriptor::new( + "Sans".to_string(), + "normal".to_string(), + "normal".to_string(), + ); + assert!(!toggle.active(&snapshot)); assert_eq!( - slider.slider(&snapshot), - Some((ToolbarSliderSpec::PEN_SMOOTHING, 3.0)) + toggle.event(&snapshot), + Some(ToolbarEvent::SetFontBold(true)) ); - assert_eq!(slider.value_text(&snapshot).as_deref(), Some("3")); - // Zero passes is a state, not a quantity. + snapshot.font = crate::draw::FontDescriptor::new( + "Sans".to_string(), + "Bold".to_string(), + "normal".to_string(), + ); + assert!( + toggle.active(&snapshot), + "the weight is compared without case, like every other font identity" + ); + assert_eq!( + toggle.event(&snapshot), + Some(ToolbarEvent::SetFontBold(false)) + ); + + let ids = control_ids(&StylePillSpec::build(&snapshot, &plan())); + assert!(ids.contains(&"top.style.font-bold".to_string())); +} + +#[test] +fn a_numeric_weight_does_not_read_as_bold() { + // The toggle writes words. A config asking for 700 is asking for something + // a two-state control cannot say, so it must not claim to be showing it. + let mut snapshot = snapshot(); + snapshot.font = crate::draw::FontDescriptor::new( + "Sans".to_string(), + "700".to_string(), + "normal".to_string(), + ); + + assert!(!StylePillControl::FontWeightToggle.active(&snapshot)); +} + +#[test] +fn the_smoothing_stepper_stops_at_both_ends_of_the_range() { + let mut snapshot = snapshot_for_tool(Tool::Pen); + snapshot.pen_smoothing = 0; - assert_eq!(slider.value_text(&snapshot).as_deref(), Some("Off")); + let steps = StylePillControl::PenSmoothingStepper.required_steps(&snapshot); + assert_eq!( + steps[0].event, + ToolbarEvent::SetPenSmoothing(0), + "there is nothing below off" + ); + + snapshot.pen_smoothing = crate::draw::MAX_PEN_SMOOTHING; + let steps = StylePillControl::PenSmoothingStepper.required_steps(&snapshot); + assert_eq!( + steps[1].event, + ToolbarEvent::SetPenSmoothing(crate::draw::MAX_PEN_SMOOTHING) + ); } #[test] -fn the_smoothing_slider_follows_the_tool_it_can_change() { +fn the_smoothing_stepper_follows_the_tool_it_can_change() { // Pen and Marker accumulate the paths smoothing runs on. Line and Blur - // share the Stroke control group but draw no path, so a slider there + // share the Stroke control group but draw no path, so a stepper there // would be a control that does nothing to what is about to be drawn. for tool in [Tool::Pen, Tool::Marker] { let spec = StylePillSpec::build(&snapshot_for_tool(tool), &plan()); @@ -131,11 +257,13 @@ fn a_squeezed_pill_sheds_its_extras_before_it_sheds_the_color_chip() { let ids = control_ids(&StylePillSpec::build(&snapshot, &squeezed)); assert!(!ids.contains(&"top.style.pen-smoothing".to_string())); - assert!(!ids.contains(&"top.style.font-family-picker".to_string())); + assert!(!ids.contains(&"top.style.font-bold".to_string())); assert!( ids.contains(&"top.style.color-chip".to_string()) && ids.contains(&"top.style.thickness".to_string()) - && ids.contains(&"top.style.font-family".to_string()), + // The only font control there is now, so it stays: dropping it + // would leave no way to change the family from the toolbar at all. + && ids.contains(&"top.style.font-family-picker".to_string()), "the pill's core stays: {ids:?}" ); } @@ -506,7 +634,7 @@ fn eraser_state_is_size_slider_plus_mode_segment_without_color() { } #[test] -fn text_state_is_swatches_size_and_font_segment() { +fn text_state_is_swatches_size_and_one_font_control() { let mut snapshot = snapshot(); snapshot.text_active = true; let spec = StylePillSpec::build(&snapshot, &plan()); @@ -519,8 +647,8 @@ fn text_state_is_swatches_size_and_font_segment() { [ "top.style.font-size", "top.style.font-size-value", - "top.style.font-family", - // Sans/Mono are the two the segment offers; this reaches the rest. + // Weight and family are independent controls. + "top.style.font-bold", "top.style.font-family-picker", ] ); @@ -547,39 +675,13 @@ fn text_state_is_swatches_size_and_font_segment() { Some(format!("{:.0}pt", snapshot.font_size)) ); - let segments = StylePillControl::FontFamilySegment - .segments(&snapshot) - .expect("font segments"); - assert_eq!(segments[0].label, "Sans"); - assert_eq!(segments[1].label, "Mono"); - assert!(matches!( - &segments[0].event, - ToolbarEvent::SetFont(font) if font.family == "Sans" - )); - assert!(matches!( - &segments[1].event, - ToolbarEvent::SetFont(font) if font.family == "Monospace" - )); - assert!(segments[0].active); - assert!(!segments[1].active); -} - -#[test] -fn font_family_segments_use_the_shared_trimmed_case_insensitive_identity() { - let mut snapshot = snapshot(); - snapshot.font.family = " sAnS ".to_string(); - let segments = StylePillControl::FontFamilySegment - .segments(&snapshot) - .expect("font segments"); - assert!(segments[0].active); - assert!(!segments[1].active); - - snapshot.font.family = " MONOSPACE ".to_string(); - let segments = StylePillControl::FontFamilySegment - .segments(&snapshot) - .expect("font segments"); - assert!(!segments[0].active); - assert!(segments[1].active); + // There is one family control: the current-family picker. + let ids = control_ids(&StylePillSpec::build(&snapshot, &plan())); + assert!( + !ids.contains(&"top.style.font-family".to_string()), + "no duplicate family control: {ids:?}" + ); + assert!(ids.contains(&"top.style.font-family-picker".to_string())); } #[test] @@ -621,7 +723,7 @@ fn settings_overrides_extend_the_stroke_state() { let ids = control_ids(&StylePillSpec::build(&snapshot, &plan())); assert!(ids.contains(&"top.style.opacity".to_string())); assert!(ids.contains(&"top.style.font-size".to_string())); - assert!(ids.contains(&"top.style.font-family".to_string())); + assert!(ids.contains(&"top.style.font-family-picker".to_string())); } #[test] diff --git a/src/ui/toolbar/model/top_spec/spec.rs b/src/ui/toolbar/model/top_spec/spec.rs index 2ded8120..0080e924 100644 --- a/src/ui/toolbar/model/top_spec/spec.rs +++ b/src/ui/toolbar/model/top_spec/spec.rs @@ -15,11 +15,15 @@ pub(crate) struct TopStripPlan { pub(crate) drop_presets: bool, /// Whether the style pill has shed its secondary controls for width. /// - /// The rung directly above `compact`, which hides the pill outright. The - /// smoothing slider and the font-family picker button are the two controls - /// with somewhere else to be — a keybinding and the command palette — so - /// they leave before the color chip, the size slider, and the rest of the - /// pill do. + /// The rung directly above `compact`, which hides the pill outright. + /// + /// The smoothing stepper and the bold toggle leave first. Both are choices + /// made once for a session rather than adjusted mid-demo, unlike the color, + /// the thickness, and the size beside them — and the rung below this one + /// takes the whole pill, so shedding two controls is strictly better than + /// losing all of them. + /// + /// The font button stays because it is the toolbar's family chooser. pub(crate) drop_style_extras: bool, pub(crate) compact: bool, } diff --git a/src/ui/toolbar/snapshot/build.rs b/src/ui/toolbar/snapshot/build.rs index e4b1c190..2614a6af 100644 --- a/src/ui/toolbar/snapshot/build.rs +++ b/src/ui/toolbar/snapshot/build.rs @@ -118,6 +118,8 @@ impl ToolbarSnapshot { spotlight_magnifier_source: None, selection_spotlight_magnification: state.selection_spotlight_magnification(), font: state.font_descriptor.clone(), + selection_has_text: state.selection_has_text(), + selected_text_bold: state.first_editable_selected_text_is_bold(), font_size: state.current_font_size, text_active, note_active, diff --git a/src/ui/toolbar/snapshot/types.rs b/src/ui/toolbar/snapshot/types.rs index 7a041164..c1fe16ef 100644 --- a/src/ui/toolbar/snapshot/types.rs +++ b/src/ui/toolbar/snapshot/types.rs @@ -72,11 +72,11 @@ pub struct ToolContext { pub show_polygon_sides_control: bool, /// Whether font controls should be shown pub show_font_controls: bool, - /// Whether the pen-smoothing slider should be shown. + /// Whether the pen-smoothing stepper should be shown. /// /// Follows the tool rather than the setting: smoothing is one number for /// the whole program, but it only reaches strokes the pen and marker - /// accumulate, so a Line or Blur tool has nothing for the slider to do. + /// accumulate, so a Line or Blur tool has nothing for the stepper to do. pub show_pen_smoothing: bool, } @@ -281,6 +281,13 @@ pub struct ToolbarSnapshot { /// which reports on the selected shape rather than the tool default. pub selection_spotlight_magnification: Option, pub font: FontDescriptor, + /// Whether the selection contains any text or sticky note, including locked shapes. + pub selection_has_text: bool, + /// Bold state of the first editable selected text or sticky note, when present. + /// The Bold control prefers this over the tool default because its event + /// mutates editable selected text first. A text selection with `None` here + /// has no editable Bold target. + pub selected_text_bold: Option, pub font_size: f64, pub text_active: bool, pub note_active: bool, @@ -441,6 +448,15 @@ pub struct ToolbarSnapshot { } impl ToolbarSnapshot { + /// Checked state of the same target a Bold event will mutate. + pub(crate) fn font_bold_target_is_bold(&self) -> bool { + match self.selected_text_bold { + Some(bold) => bold, + None if self.selection_has_text => false, + None => self.font.is_bold(), + } + } + pub fn status_bar_item_visible(&self, item: crate::config::StatusBarItem) -> bool { match item { crate::config::StatusBarItem::ActiveOutput => self.show_active_output_badge,