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),