From 032b7b7be16e2e7fbc00c21d630a3f0f56303422 Mon Sep 17 00:00:00 2001 From: turtwig Date: Wed, 14 Oct 2026 11:00:39 +0100 Subject: [PATCH] Glyphs panel: zoom buttons resize the preview and the step is kept Zoom Out and Zoom In sit to the right of the font family and style menus. They step the preview cells through 16, 22, 30, 42, 56, 72 and 96 pt; the grid still opens at 30 pt, and the buttons stop at the ends. The step is stored on the UI state and written with the other panel choices in ui.json when the app quits. Zero is the default and is left out of the file, so older preferences stay as they are. --- crates/ui-egui/src/panels/glyphs.rs | 203 ++++++++++++++++++++++++++-- crates/ui-egui/src/state.rs | 9 ++ 2 files changed, 202 insertions(+), 10 deletions(-) diff --git a/crates/ui-egui/src/panels/glyphs.rs b/crates/ui-egui/src/panels/glyphs.rs index da2230187..a5675d7e0 100644 --- a/crates/ui-egui/src/panels/glyphs.rs +++ b/crates/ui-egui/src/panels/glyphs.rs @@ -4,7 +4,7 @@ use std::collections::HashMap; -use egui::{Color32, Sense, Ui, vec2}; +use egui::{Color32, Sense, Stroke, StrokeKind, Ui, vec2}; use serde_json::json; use vectorcraft_geom::{Affine, BezPath, PathEl}; use vectorcraft_text::FontDb; @@ -14,7 +14,34 @@ use crate::VectorcraftApp; use crate::theme::Tokens; use crate::widgets::{self, menu_item}; -const CELL: f32 = 30.0; +/// Preview cell sizes for the glyph grid, smallest to largest. The third step (30 pt) is the size +/// the grid used before Zoom Out and Zoom In. Public behaviour: [Glyphs panel overview](https://helpx.adobe.com/illustrator/desktop/design-with-text/special-characters-glyphs/glyphs-panel-overview.html) +/// (Zoom Out reduces the preview, Zoom In increases it). The step sizes themselves are ours. +const CELL_STEPS: [f32; 7] = [16.0, 22.0, 30.0, 42.0, 56.0, 72.0, 96.0]; +const CELL_DEFAULT: usize = 2; + +/// The grid step in [`crate::state::UiState::glyphs_preview`]. `0` means [`CELL_DEFAULT`]; a +/// stored step is `index + 1`. A value past the steps (a hand-edited preferences file) is the +/// default too. +fn cell_index(app: &VectorcraftApp) -> usize { + match usize::from(app.ui.glyphs_preview) { + 0 => CELL_DEFAULT, + n if n <= CELL_STEPS.len() => n - 1, + _ => CELL_DEFAULT, + } +} + +fn set_cell_index(app: &mut VectorcraftApp, index: usize) { + let index = index.min(CELL_STEPS.len() - 1); + // `index + 1` is at most `CELL_STEPS.len()` (7), which fits in a `u8`. + app.ui.glyphs_preview = (index + 1) as u8; +} + +/// One step toward a smaller (`-1`) or larger (`1`) preview, staying inside [`CELL_STEPS`]. +fn nudge_cell(index: usize, delta: i32) -> usize { + let last = CELL_STEPS.len() as i32 - 1; + (index as i32 + delta).clamp(0, last) as usize +} /// Character subsets offered by the Show menu. const SUBSETS: [(&str, u32, u32); 7] = [ @@ -156,17 +183,18 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { }); let chars: Vec<(char, u32)> = face.chars().into_iter().filter(|(c, _)| (lo..=hi).contains(&(*c as u32)) && !c.is_control() && !c.is_whitespace()).collect(); - let cols = ((w / CELL).floor() as usize).max(1); + let cell = CELL_STEPS[cell_index(app)]; + let cols = ((w / cell).floor() as usize).max(1); let rows = chars.len().div_ceil(cols); - let px = (CELL * ui.ctx().pixels_per_point()).round() as u32; - let list_h = (ui.available_height() - 64.0).max(CELL * 3.0); + let px = (cell * ui.ctx().pixels_per_point()).round().clamp(8.0, 256.0) as u32; + let list_h = (ui.available_height() - 64.0).max(cell * 3.0); let hover_id = egui::Id::new("gl-hover"); - egui::ScrollArea::vertical().max_height(list_h).auto_shrink([false, false]).show_rows(ui, CELL, rows, |ui, range| { + egui::ScrollArea::vertical().max_height(list_h).auto_shrink([false, false]).show_rows(ui, cell, rows, |ui, range| { for r in range { ui.horizontal(|ui| { ui.spacing_mut().item_spacing = vec2(0.0, 0.0); for &(c, gid) in chars.iter().skip(r * cols).take(cols) { - let (rect, resp) = ui.allocate_exact_size(vec2(CELL, CELL), Sense::click()); + let (rect, resp) = ui.allocate_exact_size(vec2(cell, cell), Sense::click()); if resp.hovered() { ui.painter().rect_filled(rect, 2, t.hover); ui.ctx().data_mut(|d| d.insert_temp(hover_id, (c, gid))); @@ -198,22 +226,52 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { .color(t.text_dim), ); ui.horizontal(|ui| { + ui.spacing_mut().item_spacing.x = 4.0; // Picks the font the panel browses only: nothing is previewed on the document. + // Zoom Out and Zoom In sit to the right of the family and style menus. + let btn = 22.0; + let menus = (w - btn * 2.0 - 12.0).max(88.0); + let family_w = (menus * 0.62).clamp(48.0, (menus - 40.0).max(48.0)); + let style_w = (menus - family_w).max(40.0); let sample = crate::font_menu::sample_text(app); let look = crate::font_menu::MenuLook::of(app); - let pick = crate::font_menu::font_menu(ui, "gl-family", &family, (w * 0.6).max(80.0), sample.as_deref(), look); + let pick = crate::font_menu::font_menu(ui, "gl-family", &family, family_w, sample.as_deref(), look); if let Some((f, style)) = crate::font_menu::picked(app, pick) { let st = style.unwrap_or_else(|| db.face(&f, "Regular").map_or_else(|| "Regular".into(), |face| face.style.clone())); set_pstate(ui.ctx(), "gl-font", Some((f, st))); } let styles = db.styles(&family); let snames: Vec<&str> = styles.iter().map(String::as_str).collect(); - if let Some(i) = widgets::dropdown_names(ui, "gl-style", &style, &snames, (w * 0.38 - 8.0).max(60.0)) { - set_pstate(ui.ctx(), "gl-font", Some((family.clone(), snames[i].to_string()))); + if let Some(i) = widgets::dropdown_names(ui, "gl-style", &style, &snames, style_w) + && let Some(name) = snames.get(i) + { + set_pstate(ui.ctx(), "gl-font", Some((family.clone(), (*name).to_string()))); + } + let step = cell_index(app); + if zoom_button(ui, "out", "−", "Zoom Out", step > 0) { + set_cell_index(app, nudge_cell(step, -1)); + } + if zoom_button(ui, "in", "+", "Zoom In", step + 1 < CELL_STEPS.len()) { + set_cell_index(app, nudge_cell(step, 1)); } }); } +/// A square [ − ] or [ + ] beside the font menus. `label` is the mark itself (not translated). +fn zoom_button(ui: &mut Ui, id: &str, label: &str, tip: &str, enabled: bool) -> bool { + let t = Tokens::get(ui.ctx()); + let (rect, _) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::hover()); + let sense = if enabled { Sense::click() } else { Sense::hover() }; + let resp = ui.interact(rect, ui.id().with(("gl-zoom", id)), sense); + let bg = if enabled && resp.hovered() { t.hover } else { Color32::TRANSPARENT }; + ui.painter().rect_filled(rect, 2, bg); + let border = if enabled { t.button_border } else { t.border }; + ui.painter().rect_stroke(rect.shrink(0.5), 2, Stroke::new(1.0, border), StrokeKind::Inside); + let color = if enabled { t.text_strong } else { t.text_disabled }; + ui.painter().text(rect.center(), egui::Align2::CENTER_CENTER, label, egui::FontId::proportional(14.0), color); + resp.on_hover_text(tl!(tip)).clicked() && enabled +} + pub fn menu(_app: &mut VectorcraftApp, ui: &mut Ui) { if menu_item(ui, tl!("Use the Selected Text's Font"), true, false) { set_pstate::>(ui.ctx(), "gl-font", None); @@ -226,3 +284,128 @@ pub fn menu(_app: &mut VectorcraftApp, ui: &mut Ui) { } } } + +#[cfg(test)] +mod tests { + use egui::{Pos2, Rect, Shape}; + + use super::*; + + fn frame(ctx: &egui::Context, app: &mut VectorcraftApp, events: Vec) -> (Vec<(String, Pos2)>, Vec) { + let raw = egui::RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(280.0, 420.0))), events, ..Default::default() }; + let mut out = ctx.run_ui(raw, |ui| show(app, ui)); + out.textures_delta.clear(); + let shapes: Vec = out.shapes.into_iter().map(|c| c.shape).collect(); + (text_anchors(&shapes), square_sides(&shapes)) + } + + fn text_anchors(shapes: &[Shape]) -> Vec<(String, Pos2)> { + fn walk(s: &Shape, out: &mut Vec<(String, Pos2)>) { + match s { + Shape::Text(t) => out.push((t.galley.text().to_string(), t.pos)), + Shape::Vec(v) => v.iter().for_each(|s| walk(s, out)), + _ => {} + } + } + let mut out = vec![]; + shapes.iter().for_each(|s| walk(s, &mut out)); + out + } + + /// Sides of the square glyph previews (font-menu icons are smaller). + fn square_sides(shapes: &[Shape]) -> Vec { + fn walk(s: &Shape, out: &mut Vec) { + match s { + Shape::Mesh(m) => { + let r = m.calc_bounds(); + if (r.width() - r.height()).abs() < 1.0 && r.width() >= 14.0 { + out.push(r.width()); + } + } + Shape::Vec(v) => v.iter().for_each(|s| walk(s, out)), + _ => {} + } + } + let mut out = vec![]; + shapes.iter().for_each(|s| walk(s, &mut out)); + out + } + + fn mark(texts: &[(String, Pos2)], label: &str) -> Pos2 { + texts + .iter() + .filter(|(t, _)| t == label) + .map(|(_, p)| *p) + .max_by(|a, b| a.x.total_cmp(&b.x)) + .unwrap_or_else(|| panic!("{label} missing in {texts:?}")) + } + + fn click(ctx: &egui::Context, app: &mut VectorcraftApp, at: Pos2) { + let button = |pressed| egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }; + frame(ctx, app, vec![egui::Event::PointerMoved(at), button(true)]); + frame(ctx, app, vec![button(false)]); + } + + #[test] + fn zoom_stops_at_the_ends() { + assert_eq!(nudge_cell(CELL_DEFAULT, 1), CELL_DEFAULT + 1); + assert_eq!(nudge_cell(CELL_DEFAULT, -1), CELL_DEFAULT - 1); + assert_eq!(nudge_cell(0, -1), 0); + assert_eq!(nudge_cell(CELL_STEPS.len() - 1, 1), CELL_STEPS.len() - 1); + assert_eq!(CELL_STEPS[CELL_DEFAULT], 30.0); + } + + #[test] + fn zoom_buttons_resize_the_glyph_grid() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + let ctx = egui::Context::default(); + let (texts, sides) = frame(&ctx, &mut app, vec![]); + if texts.iter().any(|(t, _)| t.contains("No fonts are available")) { + eprintln!("glyphs zoom: no fonts installed, grid not drawn"); + return; + } + let (minus, plus) = (mark(&texts, "−"), mark(&texts, "+")); + assert!(minus.x + 8.0 < plus.x, "− then + on the right: {minus:?} {plus:?}"); + let family_x = texts.iter().map(|(_, p)| p.x).fold(f32::MAX, f32::min); + assert!(minus.x > family_x + 40.0, "the buttons sit to the right of the font menus"); + assert!(sides.iter().all(|s| (*s - 30.0).abs() < 1.0), "default cells are 30 pt: {sides:?}"); + assert!(!sides.is_empty(), "the grid drew glyphs"); + click(&ctx, &mut app, plus); + let (_texts, sides) = frame(&ctx, &mut app, vec![]); + assert_eq!(cell_index(&app), CELL_DEFAULT + 1); + assert!(sides.iter().all(|s| (*s - 42.0).abs() < 1.0), "Zoom In grows the cells: {sides:?}"); + click(&ctx, &mut app, minus); + let (_texts, sides) = frame(&ctx, &mut app, vec![]); + assert_eq!(cell_index(&app), CELL_DEFAULT); + assert!(sides.iter().all(|s| (*s - 30.0).abs() < 1.0), "Zoom Out restores 30 pt: {sides:?}"); + // Hold Zoom Out until the smallest step, then once more: it stays there. + for _ in 0..CELL_STEPS.len() { + let (texts, _) = frame(&ctx, &mut app, vec![]); + click(&ctx, &mut app, mark(&texts, "−")); + } + assert_eq!(cell_index(&app), 0); + let (texts, sides) = frame(&ctx, &mut app, vec![]); + assert!(sides.iter().all(|s| (*s - CELL_STEPS[0]).abs() < 1.0), "{sides:?}"); + click(&ctx, &mut app, mark(&texts, "−")); + assert_eq!(cell_index(&app), 0); + } + + #[test] + fn the_preview_step_is_kept_in_the_ui_preferences() { + let mut ui = crate::state::UiState::default(); + assert_eq!(ui.glyphs_preview, 0); + assert!(serde_json::to_value(&ui).unwrap().get("glyphs_preview").is_none(), "the default adds nothing to an older preferences file"); + ui.glyphs_preview = (CELL_DEFAULT + 2) as u8; + let saved = serde_json::to_value(&ui).unwrap(); + assert_eq!(saved["glyphs_preview"], serde_json::json!(CELL_DEFAULT + 2)); + let back: crate::state::UiState = serde_json::from_value(saved).unwrap(); + assert_eq!(back.glyphs_preview, (CELL_DEFAULT + 2) as u8); + let old: crate::state::UiState = serde_json::from_value(serde_json::json!({})).unwrap(); + assert_eq!(old.glyphs_preview, 0); + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.ui.glyphs_preview = back.glyphs_preview; + assert_eq!(cell_index(&app), CELL_DEFAULT + 1); + app.ui.glyphs_preview = 99; + assert_eq!(cell_index(&app), CELL_DEFAULT); + } +} diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index d47ac078e..369ff1b76 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -456,6 +456,10 @@ pub struct UiState { /// The Image Trace panel's Advanced section is open (as it was last left). #[serde(default = "yes")] pub image_trace_advanced: bool, + /// Glyphs panel preview step, kept with the other panel choices. `0` is the default 30 pt + /// cell; `1`..=`7` is a step from smallest to largest (`crates/ui-egui/src/panels/glyphs.rs`). + #[serde(default, skip_serializing_if = "is_zero_u8")] + pub glyphs_preview: u8, /// The desktop window's size, position and maximized state, saved when the app quits and /// restored at the next launch (the desktop host reads and writes it; none on the web). #[serde(default, skip_serializing_if = "Option::is_none")] @@ -756,6 +760,7 @@ impl Default for UiState { layers_panel: Default::default(), layers_filter: Default::default(), image_trace_advanced: true, + glyphs_preview: 0, window: None, } } @@ -765,6 +770,10 @@ fn yes() -> bool { true } +fn is_zero_u8(v: &u8) -> bool { + *v == 0 +} + fn default_shortcut_set() -> String { crate::shortcut_editor::PRESETS[0].to_string() }