From 2cac36a4e22ca0b177c9852875a6f959f8966be7 Mon Sep 17 00:00:00 2001 From: Ahmed5Emad Date: Fri, 16 Oct 2026 12:31:50 +0300 Subject: [PATCH] Live font preview on hover in font picker (#653) Hovering a family in the Typography font dropdown previews it on the canvas via text.preview; closing without picking restores the original, clicking applies it as one undo step. --- crates/engine/src/cmd/text.rs | 2 +- crates/engine/src/lib.rs | 4 ++-- crates/engine/src/text_style_picker_tests.rs | 25 ++++++++++++++++++++ crates/engine/src/textpreview.rs | 4 ++-- crates/ui-egui/src/lib.rs | 2 +- crates/ui-egui/src/style_picker.rs | 2 +- crates/ui-egui/src/typography.rs | 22 ++++++++++++----- 7 files changed, 48 insertions(+), 13 deletions(-) diff --git a/crates/engine/src/cmd/text.rs b/crates/engine/src/cmd/text.rs index 14ea1762..3b861895 100644 --- a/crates/engine/src/cmd/text.rs +++ b/crates/engine/src/cmd/text.rs @@ -332,7 +332,7 @@ pub fn specs() -> Vec { spec!( "text.preview", "Preview text change", - "{command: style.apply|library.applyStyle|text.setVariations|text.style, params} shows what the command would do on the canvas without an undo step (a hovered text style or named instance, a dragged axis slider), in place of an earlier preview; text.endPreview or any other command ends it, so running the command for real commits it as one undo step", + "{command: style.apply|library.applyStyle|text.setVariations|text.style, params} shows what the command would do on the canvas without an undo step (a hovered font family or style, text style or named instance, a dragged axis slider), in place of an earlier preview; text.endPreview or any other command ends it, so running the command for real commits it as one undo step", false, |s, p| { let c = req_string("text.preview", p, "command")?; diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 3bc49a90..8c6ca635 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -316,8 +316,8 @@ pub struct DocState { versions_changed: bool, /// A version shown in place of the document (read-only). pub preview: Option, - /// The document under a live text preview (a hovered text style or font instance, a dragged - /// axis slider): `doc` shows the preview until [`textpreview::end`] puts this back. + /// The document under a live text preview (a hovered font family or style, text style or + /// font instance, a dragged axis slider): `doc` shows the preview until [`textpreview::end`] puts this back. pub text_preview: Option>, /// The document as of the last version saved or restored (none yet): autosaves, restores and /// merges only add a checkpoint for changes since. diff --git a/crates/engine/src/text_style_picker_tests.rs b/crates/engine/src/text_style_picker_tests.rs index 37c7dc5c..6ee8cc2c 100644 --- a/crates/engine/src/text_style_picker_tests.rs +++ b/crates/engine/src/text_style_picker_tests.rs @@ -212,6 +212,31 @@ fn previews_while_editing_keep_the_edit() { assert!(links(&s, t).iter().all(Option::is_none)); } +#[test] +fn previews_show_a_hovered_font_family() { + let mut s = new_session(); + let t = text(&mut s, "Preview me"); + s.ds_mut().unwrap().mark_saved(); + let before = s.doc().unwrap().clone(); + let undo = s.ds().unwrap().history.undo.len(); + // Hovering a font family shows it on the canvas: no undo step, not dirty. + s.execute("text.preview", &json!({"command": "text.style", "params": {"ids": [t], "fontFamily": "Arial"}})).unwrap(); + assert_eq!(data(&s, t).style_at(0).font_family, "Arial"); + assert_eq!(s.ds().unwrap().history.undo.len(), undo); + assert!(!s.ds().unwrap().is_dirty()); + // Moving to another family replaces the preview, from the document itself. + s.execute("text.preview", &json!({"command": "text.style", "params": {"ids": [t], "fontFamily": "Georgia"}})).unwrap(); + assert_eq!(data(&s, t).style_at(0).font_family, "Georgia"); + // Closing without picking puts the document back as it was. + assert_eq!(s.execute("text.endPreview", &json!({})).unwrap()["ended"], json!(true)); + assert_eq!(*s.doc().unwrap(), before); + // Clicking commits it as one undo step. + s.execute("text.style", &json!({"ids": [t], "fontFamily": "Georgia"})).unwrap(); + assert_eq!(data(&s, t).style_at(0).font_family, "Georgia"); + assert_eq!(s.ds().unwrap().history.undo.len(), undo + 1); + assert!(s.ds().unwrap().text_preview.is_none()); +} + #[test] fn previews_refuse_what_they_cant_show() { let mut s = new_session(); diff --git a/crates/engine/src/textpreview.rs b/crates/engine/src/textpreview.rs index 507eccc3..da101d5e 100644 --- a/crates/engine/src/textpreview.rs +++ b/crates/engine/src/textpreview.rs @@ -1,6 +1,6 @@ //! Live text previews: what a text command would do, shown on the canvas without an undo step — -//! while the pointer is over a text style in the style browser or a font's named instance, or -//! while a variable font axis slider is dragged. The document under the preview is kept and comes +//! while the pointer is over a font family or style, a text style in the style browser or a +//! font's named instance, or while a variable font axis slider is dragged. The document under the preview is kept and comes //! back when the preview ends ([`end`], `text.endPreview`). Every other command ends it first, so //! committing (running the command for real) applies to the document itself as one undo step, and //! Escape just ends the preview. diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 613f18f9..d98b9156 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -583,7 +583,7 @@ impl FigApp { } /// Show what `command params` would do on the canvas as a live preview (`text.preview`) while - /// a text style, a named instance or an axis slider is hovered or dragged. Call it every frame + /// a font family, font style, text style, named instance or axis slider is hovered or dragged. Call it every frame /// the preview should stay: it ends on the first frame nothing asks for it. pub fn preview_text(&mut self, ctx: &egui::Context, command: &str, params: Value) { self.ui.text_preview_wanted = true; diff --git a/crates/ui-egui/src/style_picker.rs b/crates/ui-egui/src/style_picker.rs index 3a4e7b11..1eec3e38 100644 --- a/crates/ui-egui/src/style_picker.rs +++ b/crates/ui-egui/src/style_picker.rs @@ -746,7 +746,7 @@ fn text_properties(ui: &mut Ui, id: &str, def: &TextStyle, out: &mut Frame) { (family_resp, style_resp) }); crate::right::menu_popup(&family_resp).width(260.0).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { - if let Some(f) = crate::typography::font_picker(ui, Some(&def.font_family), family_resp.clicked()) { + if let Some(f) = crate::typography::font_picker(ui, Some(&def.font_family), family_resp.clicked()).0 { set(out, &|d| { d.font_family = f.clone(); let styles = figcraft_text::family_styles(&f); diff --git a/crates/ui-egui/src/typography.rs b/crates/ui-egui/src/typography.rs index 873ac0f0..de304d6a 100644 --- a/crates/ui-egui/src/typography.rs +++ b/crates/ui-egui/src/typography.rs @@ -181,7 +181,12 @@ fn font_rows(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], styles: &[TextStyle] let w = ui.available_width(); let resp = widgets::dropdown(ui, family.as_deref().unwrap_or(tr(MIXED)), w, None); menu_popup(&resp).width(w.max(240.0)).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { - if let Some(f) = font_picker(ui, family.as_deref(), resp.clicked()) { + let (picked, hovered) = font_picker(ui, family.as_deref(), resp.clicked()); + if let Some(h) = hovered { + // The canvas shows the hovered family; closing without picking ends the preview. + app.preview_text(ui.ctx(), "text.style", text_style_params(ids, range, json!({"fontFamily": h}))); + } + if let Some(f) = picked { remember_family(ui.ctx(), &f); apply_style(app, "fontFamily", json!(f)); ui.close(); @@ -1049,8 +1054,9 @@ enum PickRow { Family(String), } -/// The searchable family list. Returns the family picked. -pub(crate) fn font_picker(ui: &mut Ui, current: Option<&str>, just_opened: bool) -> Option { +/// The searchable family list. Returns the family picked and the family hovered (the caller +/// previews the hovered one on the canvas: closing without picking restores the original). +pub(crate) fn font_picker(ui: &mut Ui, current: Option<&str>, just_opened: bool) -> (Option, Option) { let t = Tokens::get(ui.ctx()); let q_id = Id::new("font_query"); let mut q: String = ui.data(|d| d.get_temp(q_id)).unwrap_or_default(); @@ -1066,7 +1072,7 @@ pub(crate) fn font_picker(ui: &mut Ui, current: Option<&str>, just_opened: bool) let all = figcraft_text::family_names(); let matches: Vec = all.iter().filter(|f| ql.is_empty() || f.to_lowercase().contains(&ql)).cloned().collect(); if search.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { - return matches.into_iter().next(); + return (matches.into_iter().next(), None); } let mut rows: Vec = Vec::new(); if ql.is_empty() { @@ -1079,11 +1085,12 @@ pub(crate) fn font_picker(ui: &mut Ui, current: Option<&str>, just_opened: bool) } if matches.is_empty() { widgets::label(ui, tr("No fonts match"), t.text_tertiary); - return None; + return (None, None); } rows.extend(matches.into_iter().map(PickRow::Family)); let mut budget = 4; let mut picked = None; + let mut hovered_family = None; egui::ScrollArea::vertical().max_height(380.0).auto_shrink([false, true]).show_rows(ui, ROW, rows.len(), |ui, range| { for r in rows.get(range).unwrap_or(&[]) { let w = ui.available_width(); @@ -1125,11 +1132,14 @@ pub(crate) fn font_picker(ui: &mut Ui, current: Option<&str>, just_opened: bool) if resp.clicked() { picked = Some(f.clone()); } + if resp.hovered() { + hovered_family = Some(f.clone()); + } } } } }); - picked + (picked, hovered_family) } #[cfg(test)]