mirror of
https://github.com/storytold/vectorcraft.git
synced 2026-10-24 17:56:50 -06:00
Merge pull request #1930 from turtwig/fix/glyphs-preview-zoom
Glyphs panel: zoom buttons resize the preview and the step is kept
This commit is contained in:
@@ -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::<Option<(String, String)>>(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<egui::Event>) -> (Vec<(String, Pos2)>, Vec<f32>) {
|
||||
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<Shape> = 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<f32> {
|
||||
fn walk(s: &Shape, out: &mut Vec<f32>) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user