diff --git a/README.md b/README.md index c9d5bbfcf..c574e5b41 100644 --- a/README.md +++ b/README.md @@ -117,14 +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** 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 +- 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 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 df4ad5228..ac99c0b8b 100644 --- a/config.example.toml +++ b/config.example.toml @@ -120,6 +120,14 @@ 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"] +# 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 = [] # 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 +1094,23 @@ 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. +# 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). +# +# 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" 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 + # Default fill state for fill-capable shapes default_fill_enabled = false @@ -1152,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) @@ -1172,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 46a5ccded..654b612ae 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(), @@ -72,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/pages/drawing/font.rs b/configurator/src/app/pages/drawing/font.rs index 62a9eb2c4..cbe3f07bf 100644 --- a/configurator/src/app/pages/drawing/font.rs +++ b/configurator/src/app/pages/drawing/font.rs @@ -1,16 +1,25 @@ +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::{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) - .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), ) .combo_row( "Font weight", @@ -51,4 +60,337 @@ 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. +/// +/// 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 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)] +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 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 note = missing_family_note("Wayscriber No Such Font 9000").expect("missing warns"); + assert!(note.contains("Wayscriber No Such Font 9000")); + } + + #[test] + 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 d98ed61d9..38bcfaaeb 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", @@ -118,6 +119,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/search/tests.rs b/configurator/src/app/search/tests.rs index e86ba7b69..649b8e27a 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/app/update/fields/drawing.rs b/configurator/src/app/update/fields/drawing.rs index 1761a9a02..8497834dd 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 338361cdd..3ad1cb6d9 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 a48ac91f9..feaec0587 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 7ac2eeb75..84cc6de09 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 3e89ae37d..4292b3784 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; @@ -72,6 +73,8 @@ 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_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(), @@ -79,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 b421cb14a..9ec666ffb 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; @@ -40,6 +41,11 @@ pub struct ConfigDraft { pub drawing_default_font_size: String, pub drawing_polygon_sides: String, pub drawing_marker_opacity: String, + pub drawing_pen_smoothing: 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, @@ -47,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/font_cycle.rs b/configurator/src/models/config/font_cycle.rs new file mode 100644 index 000000000..382653169 --- /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 902059c1a..05b7a9019 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 6ad13bd9a..a97b26a6e 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; } @@ -327,6 +330,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/tests.rs b/configurator/src/models/config/tests.rs index 672b8311e..5cb79e9ac 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(); @@ -131,6 +145,76 @@ 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.entries(), + config.drawing.font_cycle + ); + + 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 an_emptied_font_cycle_turns_the_action_off_rather_than_restoring_defaults() { + let config = Config::default(); + let mut draft = ConfigDraft::from_config(&config); + + 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_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 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![ + " Sans ".to_string(), + String::new(), + " ".to_string(), + "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, ["Sans", "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 1147a443c..889a2c559 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", @@ -59,10 +67,14 @@ impl ConfigDraft { errors, |value| config.drawing.marker_opacity = value, ); + // 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(); 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 c53a6d315..e61b0f9cb 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, @@ -107,6 +108,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 773d97d9d..44a88803f 100644 --- a/configurator/src/models/keybindings/field/config/read.rs +++ b/configurator/src/models/keybindings/field/config/read.rs @@ -43,6 +43,10 @@ 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::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, 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 53d778a07..b6d5229dc 100644 --- a/configurator/src/models/keybindings/field/config/write.rs +++ b/configurator/src/models/keybindings/field/config/write.rs @@ -44,6 +44,10 @@ 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::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, 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 d0ffd8148..99baeebfa 100644 --- a/configurator/src/models/keybindings/field/labels.rs +++ b/configurator/src/models/keybindings/field/labels.rs @@ -53,6 +53,10 @@ impl KeybindingField { Self::SelectPenTool => "select_pen_tool", 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", 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 f56d9d9a9..e3d1d9ec6 100644 --- a/configurator/src/models/keybindings/field/list.rs +++ b/configurator/src/models/keybindings/field/list.rs @@ -38,6 +38,10 @@ impl KeybindingField { Self::SelectPenTool, Self::SelectEraserTool, Self::ToggleEraserMode, + Self::CycleFontFamily, + Self::OpenFontPicker, + 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 bdf5df41c..661ad1608 100644 --- a/configurator/src/models/keybindings/field/mod.rs +++ b/configurator/src/models/keybindings/field/mod.rs @@ -40,6 +40,10 @@ pub enum KeybindingField { SelectPenTool, SelectEraserTool, ToggleEraserMode, + CycleFontFamily, + OpenFontPicker, + IncreasePenSmoothing, + DecreasePenSmoothing, SelectMarkerTool, SelectStepMarkerTool, SelectLineTool, diff --git a/configurator/src/models/keybindings/field/tab.rs b/configurator/src/models/keybindings/field/tab.rs index 7e1d2103e..8fd65f625 100644 --- a/configurator/src/models/keybindings/field/tab.rs +++ b/configurator/src/models/keybindings/field/tab.rs @@ -28,6 +28,10 @@ impl KeybindingField { | Self::SelectPenTool | Self::SelectEraserTool | Self::ToggleEraserMode + | Self::CycleFontFamily + | Self::OpenFontPicker + | Self::IncreasePenSmoothing + | Self::DecreasePenSmoothing | Self::SelectMarkerTool | Self::SelectStepMarkerTool | Self::SelectLineTool diff --git a/docs/CONFIG.md b/docs/CONFIG.md index 45c1e1aa6..2b67f55c0 100644 --- a/docs/CONFIG.md +++ b/docs/CONFIG.md @@ -238,6 +238,12 @@ 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 + # Default fill state for fill-capable shape tools default_fill_enabled = false @@ -253,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 @@ -370,6 +377,8 @@ 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)) +- **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) @@ -379,6 +388,8 @@ 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 - Font size: 32.0px @@ -388,6 +399,176 @@ 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. + +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 +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, +so the key always goes somewhere. + +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. +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 + +`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. + +| 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 + +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. + +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. + +#### 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. + +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. + +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. + ### `[arrow]` - Arrow Geometry Controls the appearance of arrow annotations. @@ -1945,6 +2126,10 @@ 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 +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 cycle_arrow_style = [] # standard -> pointy -> curved -> double select_spotlight_tool = [] # dim everything except a region diff --git a/src/backend/wayland/backend/event_loop/dispatch.rs b/src/backend/wayland/backend/event_loop/dispatch.rs index fceeba283..315aaa512 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 b14a55636..4c7652e61 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 324c6bbb2..227471564 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/backend/state_init/input_state.rs b/src/backend/wayland/backend/state_init/input_state.rs index 7a6cbca0d..9b3588df6 100644 --- a/src/backend/wayland/backend/state_init/input_state.rs +++ b/src/backend/wayland/backend/state_init/input_state.rs @@ -56,6 +56,8 @@ 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.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/backend/wayland/handlers/keyboard/mod.rs b/src/backend/wayland/handlers/keyboard/mod.rs index 4fdcfffb2..64fafcf6d 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 8ed4b3cd8..c831df209 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 dd601d7b3..1356faf47 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 56bd859cc..6d0cdc249 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/canvas_layer.rs b/src/backend/wayland/state/canvas_layer.rs index 8ee5e8be2..831076105 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 564754d70..6945813ea 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/core/focus.rs b/src/backend/wayland/state/core/focus.rs new file mode 100644 index 000000000..888e5128c --- /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 7404d2d58..ce7dfb642 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 519439a08..b8e5f8131 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 eb33150e4..d8d4d5dd4 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 000000000..964778853 --- /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/pdf_export.rs b/src/backend/wayland/state/pdf_export.rs index 3b0afb2c7..9e00d550f 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 91b124aa0..7435b0f36 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 b76a75d4b..1b832f6fc 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 fe6a61698..381a41d8e 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 d8600fb2e..42a002385 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 3fff6e200..d0634a6c5 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,22 +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); - let outline = crate::draw::text_outline_color(color); - 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 @@ -215,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, @@ -225,6 +211,7 @@ impl WaylandState { font_descriptor, *background_enabled, *wrap_width, + self.config.drawing.text_halo_enabled, ); } Shape::StickyNote { @@ -318,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, @@ -332,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; @@ -354,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 aa7c3f89e..61c5de3bf 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(); } @@ -221,6 +227,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/layout/spec/top.rs b/src/backend/wayland/toolbar/layout/spec/top.rs index 3e245192a..8b636a9f2 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 0bfdbffd4..37635b404 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 6b6e31d91..8a886e71d 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 99f439e2e..3d18a36d0 100644 --- a/src/backend/wayland/toolbar/view/top/build.rs +++ b/src/backend/wayland/toolbar/view/top/build.rs @@ -692,17 +692,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()), ( @@ -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( @@ -796,6 +797,31 @@ 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. + 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); @@ -825,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; @@ -879,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 bdc647219..220476249 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 cc2c3668f..429836a0e 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 6675a7829..6b8bbdffd 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_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, } @@ -194,6 +196,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 +390,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 +421,12 @@ fn draw_canvas_page_contents( }, &replay_ctx, ), - other => render_shape(ctx, other), + other => render_shape_over_with_halo( + ctx, + other, + known_background_luminance, + page.text_halo_enabled, + ), } } @@ -556,3 +578,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/canvas_export/pdf/tests.rs b/src/canvas_export/pdf/tests.rs index 75125c26f..3d9ee12da 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 4cb0b3178..274ab994b 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 ab2bb3854..e2715a70a 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/action_meta/entries/tools.rs b/src/config/action_meta/entries/tools.rs index 72a5a943f..21c03d514 100644 --- a/src/config/action_meta/entries/tools.rs +++ b/src/config/action_meta/entries/tools.rs @@ -224,6 +224,46 @@ 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!( + OpenFontPicker, + "Font Picker", + None, + "Pick a text font from every one installed", + Tools, + true, + 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 f41b534a1..c0ad5ea86 100644 --- a/src/config/action_meta/tests.rs +++ b/src/config/action_meta/tests.rs @@ -176,6 +176,10 @@ const EXPECTED_COMMAND_PALETTE_ACTIONS: &[Action] = &[ Action::SelectStepMarkerTool, Action::SelectEraserTool, Action::ToggleEraserMode, + Action::CycleFontFamily, + Action::OpenFontPicker, + 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 94c9b8bbe..b2496a20e 100644 --- a/src/config/keybindings/config/map/edit.rs +++ b/src/config/keybindings/config/map/edit.rs @@ -98,6 +98,10 @@ 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, + 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 f51e04789..b41182f7a 100644 --- a/src/config/keybindings/config/map/tools.rs +++ b/src/config/keybindings/config/map/tools.rs @@ -28,6 +28,16 @@ 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_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 a7495b4c0..097bb12f2 100644 --- a/src/config/keybindings/config/types/bindings/tools.rs +++ b/src/config/keybindings/config/types/bindings/tools.rs @@ -32,6 +32,22 @@ 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, + + /// Step the text font through `drawing.font_cycle`. + #[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, @@ -105,6 +121,10 @@ 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_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 fd48372a6..921af3ec5 100644 --- a/src/config/keybindings/defaults/tools.rs +++ b/src/config/keybindings/defaults/tools.rs @@ -36,6 +36,28 @@ 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() +} + +/// `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()] +} + +/// 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 06d63a1b1..ffb320b92 100644 --- a/src/config/keybindings/tests.rs +++ b/src/config/keybindings/tests.rs @@ -741,6 +741,10 @@ 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"]), + ("open_font_picker", &[]), + ("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 08d9a0bb3..65cefe06a 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/tests/load.rs b/src/config/tests/load.rs index fe931ceb0..e57169fd1 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/tests/validate.rs b/src/config/tests/validate.rs index 34556a755..bf6ad5f72 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/types/drawing.rs b/src/config/types/drawing.rs index 85e7b4d25..4a33433f8 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,22 @@ 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 + /// 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, @@ -118,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 { @@ -130,6 +153,8 @@ 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(), default_font_size: default_font_size(), @@ -146,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(), } } } @@ -872,6 +898,22 @@ 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 { + DEFAULT_PEN_SMOOTHING +} + fn default_fill_enabled() -> bool { false } @@ -900,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/config/types/mod.rs b/src/config/types/mod.rs index 47b83867c..edf146e6f 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 fd80ce5fa..0e4e118d8 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,39 @@ 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. + // + // 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_lowercase()) + }); + 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!( + "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 3507550a9..72fdfb930 100644 --- a/src/configurator_destination.rs +++ b/src/configurator_destination.rs @@ -110,6 +110,10 @@ pub fn keybindings_section_for_action(action: Action) -> Option bool { + self.weight.trim().eq_ignore_ascii_case("bold") + } + /// Converts this font descriptor to a Pango font description string. /// /// Format: "Family Style Weight Size" @@ -103,3 +113,246 @@ mod tests { assert_eq!(font.to_pango_string(16.0), "JetBrains Mono Light 16"); } } + +/// Both installed-family lists, built from one walk of the font map. +struct FontCatalog { + all: Vec, + 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 08aacc16e..f9fbb38dc 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,17 +38,19 @@ 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, - 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, + 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_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::{ - ArrowLabel, ArrowStyle, BlurStyle, EmbeddedImage, EraserBrush, EraserKind, PolygonKind, - REGULAR_POLYGON_DEFAULT_SIDES, REGULAR_POLYGON_MAX_SIDES, REGULAR_POLYGON_MIN_SIDES, Shape, - StepMarkerLabel, clamp_regular_sides, + 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, }; 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 new file mode 100644 index 000000000..216ffd548 --- /dev/null +++ b/src/draw/render/backdrop_probe.rs @@ -0,0 +1,225 @@ +//! 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 += perceived_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) +} + +/// 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 +} + +/// [`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)] +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_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)); + + 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/blur.rs b/src/draw/render/blur.rs index e28a0bb4c..db69c11fc 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, +) { + 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 { points, @@ -121,7 +148,7 @@ pub fn render_shape(ctx: &cairo::Context, shape: &Shape) { label.size, &label.font_descriptor, ) { - render_text( + render_text_over_with_halo( ctx, layout.x, layout.y, @@ -131,6 +158,8 @@ pub fn render_shape(ctx: &cairo::Context, shape: &Shape) { &label.font_descriptor, ARROW_LABEL_BACKGROUND, None, + known_background_luminance, + text_halo_enabled, ); } } @@ -165,7 +194,7 @@ pub fn render_shape(ctx: &cairo::Context, shape: &Shape) { background_enabled, wrap_width, } => { - render_text( + render_text_over_with_halo( ctx, *x, *y, @@ -175,6 +204,8 @@ pub fn render_shape(ctx: &cairo::Context, shape: &Shape) { font_descriptor, *background_enabled, *wrap_width, + known_background_luminance, + text_halo_enabled, ); } Shape::StepMarker { x, y, color, label } => { @@ -186,7 +217,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 +267,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_with_halo( ctx, baseline_x, baseline_y, @@ -246,6 +277,8 @@ pub fn render_shape(ctx: &cairo::Context, shape: &Shape) { &label.font_descriptor, false, None, + known_background_luminance, + text_halo_enabled, ); } } @@ -284,3 +317,71 @@ pub fn render_shape(ctx: &cairo::Context, shape: &Shape) { } } } + +#[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 e57b8d9a3..3ec1c71c4 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, @@ -35,6 +36,99 @@ pub fn render_text( font_descriptor: &FontDescriptor, background_enabled: bool, wrap_width: Option, +) { + 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, + text, + color, + size, + font_descriptor, + background_enabled, + wrap_width, + None, + halo_enabled, + ); +} + +/// `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, +) { + 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(); @@ -80,14 +174,29 @@ 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 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. @@ -97,7 +206,7 @@ pub fn render_text( 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(); } @@ -113,11 +222,13 @@ pub fn render_text( // 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); @@ -142,8 +253,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 { @@ -271,7 +393,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, @@ -303,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 { @@ -314,14 +436,173 @@ 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 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); 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 +819,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); diff --git a/src/draw/shape/mod.rs b/src/draw/shape/mod.rs index c75feb6ba..698229b09 100644 --- a/src/draw/shape/mod.rs +++ b/src/draw/shape/mod.rs @@ -3,6 +3,7 @@ mod arrow_label; mod bounds; mod polygon; +mod smoothing; mod step_marker; mod text; mod text_cache; @@ -12,6 +13,7 @@ pub use polygon::{ PolygonKind, REGULAR_POLYGON_DEFAULT_SIDES, REGULAR_POLYGON_MAX_SIDES, REGULAR_POLYGON_MIN_SIDES, clamp_regular_sides, }; +pub use smoothing::{MAX_PEN_SMOOTHING, clamp_pen_smoothing, smooth_path, smooth_pressure_path}; pub use types::{ ArrowLabel, ArrowStyle, BlurStyle, EmbeddedImage, EraserBrush, EraserKind, Shape, StepMarkerLabel, diff --git a/src/draw/shape/smoothing.rs b/src/draw/shape/smoothing.rs new file mode 100644 index 000000000..1ac236617 --- /dev/null +++ b/src/draw/shape/smoothing.rs @@ -0,0 +1,240 @@ +//! Release-time smoothing for freehand paths. +//! +//! A pointer path carries the shake of the hand that drew it. Smoothing removes +//! that shake without changing what the stroke is. +//! +//! ## Why this runs on release rather than during the drag +//! +//! Smoothing a point needs the points on both sides of it, so a live smoother +//! cannot draw the newest sample until the next one arrives. The line then trails +//! the cursor by a sample or two. On a projector that lag is visible to the room, +//! which is the opposite of what an annotation tool is for. +//! +//! Running on release instead keeps the live stroke exactly on the pointer, and +//! pays for the smoothing once, on a path that is already complete. +//! +//! ## The filter +//! +//! One pass is the binomial kernel `[1/4, 1/2, 1/4]` over interior points, with +//! both endpoints pinned. `level` is how many passes run, so the levels form a +//! progressively wider filter rather than a set of unrelated behaviors, and +//! level 0 is the identity. +//! +//! Endpoints are pinned because a stroke has to start and stop where the user +//! started and stopped. A filter that moved them would pull an underline off the +//! word it began under. + +/// Highest smoothing level. Past roughly this many passes a stroke stops +/// following its own corners and reads as a different line from the one drawn. +pub const MAX_PEN_SMOOTHING: u8 = 6; + +/// Fewest points a path needs before smoothing can do anything. With two points +/// there is no interior to smooth, and both are pinned. +const MIN_SMOOTHABLE_POINTS: usize = 3; + +/// The centre weight of one pass. The neighbours share the remainder equally. +const CENTER_WEIGHT: f64 = 0.5; + +/// Clamp a configured or stepped level into range. +pub fn clamp_pen_smoothing(level: u8) -> 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 positions of a pressure path, leaving every thickness alone. +/// +/// 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 { + 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 8ff9e56e2..d6f369482 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,14 @@ impl InputState { Action::DecreaseMarkerOpacity => { self.set_marker_opacity(self.marker_opacity - 0.05); } + 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 => { if self.toggle_eraser_mode() { info!("Eraser mode set to {:?}", self.eraser_mode); diff --git a/src/input/state/actions/key_release.rs b/src/input/state/actions/key_release.rs index ebccedf39..d26664e22 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 a6643fbda..42e4ce0b6 100644 --- a/src/input/state/core/base/state/init.rs +++ b/src/input/state/core/base/state/init.rs @@ -95,12 +95,30 @@ 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, spotlight_magnification: crate::draw::DEFAULT_SPOTLIGHT_MAGNIFICATION, 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 5f1f60617..846287db3 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 c91094f47..3ef79dea3 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 @@ -128,6 +131,38 @@ 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 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 47404cd38..060a807cb 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 new file mode 100644 index 000000000..95578d2c6 --- /dev/null +++ b/src/input/state/core/font_cycle.rs @@ -0,0 +1,243 @@ +//! 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, families_match}; + +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. + /// + /// 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| families_match(family, current)) + { + Some(index) => &self.font_cycle[(index + 1) % self.font_cycle.len()], + None => &self.font_cycle[0], + }; + (!families_match(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 + } + + /// 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 Some(next) = self + .first_selected_text_family() + .and_then(|family| self.next_font_family(&family)) + else { + return false; + }; + + let changed = self.apply_family_to_selected_text(&next); + 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::draw::Shape; + 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_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(); + 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/font_picker/input.rs b/src/input/state/core/font_picker/input.rs new file mode 100644 index 000000000..fb4812870 --- /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 000000000..d3ca7dac0 --- /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 000000000..f3a8250a2 --- /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 000000000..e5645845e --- /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 8b439e3d0..398a9517f 100644 --- a/src/input/state/core/mod.rs +++ b/src/input/state/core/mod.rs @@ -6,6 +6,8 @@ pub(crate) mod color_picker_popup; mod command_palette; mod dirty; mod eyedropper; +mod font_cycle; +pub(crate) mod font_picker; mod highlight_controls; mod history; mod ime; @@ -23,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; @@ -68,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 771407f82..b894450ce 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/properties/apply_selection/helpers.rs b/src/input/state/core/properties/apply_selection/helpers.rs index a3ddbe7a3..78de4e259 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/core/text_font.rs b/src/input/state/core/text_font.rs new file mode 100644 index 000000000..dec8aa727 --- /dev/null +++ b/src/input/state/core/text_font.rs @@ -0,0 +1,154 @@ +//! 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::{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(crate) fn selection_has_text(&self) -> bool { + let frame = self.boards.active_frame(); + self.selected_shape_ids().iter().any(|id| { + 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) + } + }) + }) + } + + /// 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 { + 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 + }) + } + + /// 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(); + 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| text_font_descriptor(shape).is_some(), + move |shape| text_font_descriptor_mut(shape).is_some_and(&mut apply), + ); + self.report_selection_apply_result(result, property) + } +} diff --git a/src/input/state/core/tool_controls/settings.rs b/src/input/state/core/tool_controls/settings.rs index c232f1fb4..dcef6ac06 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/core/utility/interaction.rs b/src/input/state/core/utility/interaction.rs index baa97d837..c65477453 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 ae627f89c..47c6201cd 100644 --- a/src/input/state/interaction/actions.rs +++ b/src/input/state/interaction/actions.rs @@ -36,6 +36,10 @@ pub(crate) fn classify_action(action: Action) -> ActionRoute { | Action::DecreaseThickness | Action::IncreaseMarkerOpacity | Action::DecreaseMarkerOpacity + | 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 e3657cc97..4930caa46 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 0cb39755b..b5ea3125f 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 4db9c9810..850d835cb 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 f561c1f30..633076b96 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 93735f45f..57ae319f5 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 7bf07ab47..eefbe7072 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 59f12a646..a15c70ca6 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 49e1c18e2..b710bbf35 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, @@ -59,6 +76,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) }; @@ -85,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; @@ -124,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); } @@ -155,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], @@ -246,3 +302,168 @@ 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)] + } + + /// 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()); + } + + /// 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); + 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/state/render.rs b/src/input/state/render.rs index ed110ad0f..e35d33c21 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 24857c109..7afb3be6e 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 9355f377a..f913f2139 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 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. + 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/input/tool/drawing.rs b/src/input/tool/drawing.rs index b4d584bab..9453dbbc9 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/session/snapshot/apply.rs b/src/session/snapshot/apply.rs index 3519c1ec1..78370e852 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 c13c4067f..1b5280832 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 c88759b98..703f19bb1 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 b85f62089..26e92ed36 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 b6a98454f..dde4f7d2d 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 788c54e32..d30a256b6 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/css.rs b/src/toolbar_gtk/css.rs index fb0a15526..c3f81b481 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.rs b/src/toolbar_gtk/view/top_bar.rs index 897d6ca94..6b439a7c2 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 ed1e7270b..2b0af6a37 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 0192f4d87..ca4593600 100644 --- a/src/toolbar_gtk/view/top_bar/style_pill.rs +++ b/src/toolbar_gtk/view/top_bar/style_pill.rs @@ -32,6 +32,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. @@ -180,16 +199,28 @@ 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(matches!( - control, - model::StylePillControl::OpacitySlider - | model::StylePillControl::SpotlightMagnificationSlider - )); + // 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()); - slider.root.set_size_request(px(STYLE_SLIDER_W), -1); + // 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)); + } + 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| { @@ -231,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()); @@ -249,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); @@ -288,6 +324,38 @@ 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()); + // 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)); + } + 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( @@ -314,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))); @@ -332,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()) { @@ -355,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 5718467a5..cc0c10c7e 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 } @@ -812,6 +814,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, @@ -834,12 +853,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 @@ -903,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"); @@ -929,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), @@ -1247,7 +1315,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 +1679,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 +1744,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 +1766,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/toolbar_gtk/widgets.rs b/src/toolbar_gtk/widgets.rs index 68ac4348e..344095fe0 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. diff --git a/src/ui.rs b/src/ui.rs index e25c41fb5..f0152415d 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; @@ -30,10 +31,12 @@ 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; 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/board_picker.rs b/src/ui/board_picker.rs index 44c8f6c56..081928092 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 4aef6cdc6..3d6ea8cae 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 0e8a01e19..30ba2212d 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 e65f3a391..b4c9b42f9 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 1dcd08b43..79ec076f3 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/color_picker_popup.rs b/src/ui/color_picker_popup.rs index ca26bcf7a..c58695899 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 000000000..c4e2dbee0 --- /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 fd3b5e712..ddc7b66da 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), @@ -150,6 +158,8 @@ 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::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 dc2449967..1e42f7450 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 @@ -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/theme/css.rs b/src/ui/theme/css.rs index a9e48e8b0..0efe1d52d 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 f27a7b1d0..ea1b93ee5 100644 --- a/src/ui/toolbar/apply/mod.rs +++ b/src/ui/toolbar/apply/mod.rs @@ -64,8 +64,11 @@ 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::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 aad318b7e..64d084962 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) } @@ -87,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 336951adb..c95b5d878 100644 --- a/src/ui/toolbar/events.rs +++ b/src/ui/toolbar/events.rs @@ -87,8 +87,14 @@ 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), + /// 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), ToggleFill(bool), diff --git a/src/ui/toolbar/model/event_policy.rs b/src/ui/toolbar/model/event_policy.rs index 974d7ec7e..e56278ed6 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,8 +501,10 @@ fn persistence_for_event(event: &ToolbarEvent) -> ToolbarPersistence { | ToolbarEvent::SetMarkerOpacity(_) | ToolbarEvent::NudgeMarkerOpacity(_) | ToolbarEvent::SetSpotlightMagnification(_) + | ToolbarEvent::SetPenSmoothing(_) | ToolbarEvent::SetEraserMode(_) | ToolbarEvent::SetFont(_) + | ToolbarEvent::SetFontBold(_) | ToolbarEvent::SetFontSize(_) | ToolbarEvent::NudgeFontSize(_) | ToolbarEvent::ToggleFill(_) @@ -580,6 +583,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 b28e9a4a2..2871d9b30 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; 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 +91,12 @@ pub(crate) enum StylePillControl { ThicknessValue, /// Marker opacity slider. OpacitySlider, + /// 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. @@ -109,8 +114,12 @@ 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. + FontFamilyPicker, /// Brush/Stroke eraser mode segmented control (the old checkbox /// semantics as a two-segment control emitting `SetEraserMode`). EraserModeSegment, @@ -226,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 }; } @@ -262,6 +274,9 @@ impl StylePillSpec { if context.show_marker_opacity { controls.push(StylePillControl::OpacitySlider); } + if context.show_pen_smoothing && !plan.drop_style_extras { + controls.push(StylePillControl::PenSmoothingStepper); + } if context.tool_options_kind == ToolOptionsKind::Spotlight { controls.push(StylePillControl::SpotlightMagnificationSlider); } @@ -283,7 +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::FontWeightToggle); + } + controls.push(StylePillControl::FontFamilyPicker); } if context.show_eraser_mode { controls.push(StylePillControl::EraserModeSegment); @@ -321,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 4652f86c8..ddbec385f 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::PenSmoothingStepper => Cow::Borrowed("top.style.pen-smoothing"), Self::SpotlightMagnificationSlider => { Cow::Borrowed("top.style.spotlight-magnification") } @@ -25,7 +26,8 @@ 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) => { Cow::Owned(format!("top.style.sel.{}", selection_kind_slug(kind))) @@ -41,11 +43,15 @@ impl StylePillControl { | Self::SpotlightMagnificationSlider | Self::FontSizeSlider => StylePillRole::Slider, Self::ThicknessValue | Self::FontSizeValue => StylePillRole::Value, - Self::FillToggle | Self::AutoNumberToggle => StylePillRole::Toggle, - Self::CounterReset(_) | Self::ArrowStyleCycle => StylePillRole::Button, - Self::FontFamilySegment | Self::EraserModeSegment => StylePillRole::Segmented, + Self::FillToggle | Self::AutoNumberToggle | Self::FontWeightToggle => { + StylePillRole::Toggle + } + Self::CounterReset(_) | Self::ArrowStyleCycle | Self::FontFamilyPicker => { + StylePillRole::Button + } + Self::EraserModeSegment => StylePillRole::Segmented, Self::SelectionCycle(_) => StylePillRole::Button, - Self::SelectionStepper(_) => StylePillRole::Stepper, + Self::PenSmoothingStepper | Self::SelectionStepper(_) => StylePillRole::Stepper, } } @@ -67,8 +73,12 @@ impl StylePillControl { Self::SpotlightMagnificationSlider => { ToolbarEvent::SetSpotlightMagnification(snapshot.spotlight_magnification) } + 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) } @@ -87,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; } }) @@ -102,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) => { @@ -118,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, } @@ -159,10 +175,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::PenSmoothingStepper => 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 +201,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 + ) + } + /// 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 +262,7 @@ impl StylePillControl { Cow::Borrowed(ToolContext::from_snapshot(snapshot).thickness_label) } Self::OpacitySlider => Cow::Borrowed("Marker opacity"), + 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)), @@ -232,7 +271,10 @@ 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( selection_entry(snapshot, kind) @@ -281,42 +323,39 @@ impl StylePillControl { )), Self::SelectionCycle(kind) => selection_entry(snapshot, kind) .map(|entry| format!("{}: {}", entry.label, entry.value)), - Self::ThicknessSlider - | Self::OpacitySlider - | Self::FontSizeSlider - | Self::FontFamilySegment - | Self::EraserModeSegment - | Self::SelectionStepper(_) => None, + 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 + )), + // 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: 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: snapshot.font.family == "Monospace", - tooltip: "Monospace font".to_string(), - }, - ]), Self::EraserModeSegment => Some([ StylePillSegment { id: "top.style.eraser-mode.brush", @@ -344,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; }; @@ -402,3 +444,73 @@ impl StylePillControl { .expect("this style-pill stepper has minus/plus halves") } } + +/// 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 +/// 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/selection.rs b/src/ui/toolbar/model/style_pill/tests/selection.rs index 235afe140..0be1a782f 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 a7d9a8eae..750fe74bb 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,220 @@ 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_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 stepper = StylePillControl::PenSmoothingStepper; + assert_eq!(stepper.role(), StylePillRole::Stepper); + assert_eq!( + 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!( + toggle.event(&snapshot), + Some(ToolbarEvent::SetFontBold(true)) + ); + + 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; + 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_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 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()); + 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-bold".to_string())); + assert!( + ids.contains(&"top.style.color-chip".to_string()) + && ids.contains(&"top.style.thickness".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:?}" + ); +} + #[test] fn spotlight_state_exposes_an_inline_missing_source_hint() { let mut snapshot = snapshot_for_tool(Tool::Spotlight); @@ -141,6 +355,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]; @@ -418,23 +634,29 @@ 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()); 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", + // Weight and family are independent controls. + "top.style.font-bold", + "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!( @@ -453,20 +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_eq!(segments[0].active, snapshot.font.family == "Sans"); + // 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] @@ -508,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 b8c50beb6..0080e9240 100644 --- a/src/ui/toolbar/model/top_spec/spec.rs +++ b/src/ui/toolbar/model/top_spec/spec.rs @@ -13,6 +13,18 @@ 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 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, } @@ -25,6 +37,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 8ffd458db..2614a6afa 100644 --- a/src/ui/toolbar/snapshot/build.rs +++ b/src/ui/toolbar/snapshot/build.rs @@ -112,11 +112,14 @@ 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, 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 ecace1fff..c1fe16ef8 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 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 stepper 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. @@ -268,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, @@ -428,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, diff --git a/tests/cli.rs b/tests/cli.rs index 26184c40a..0df9efbd3 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),