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() }