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.
This commit is contained in:
Ahmed5Emad
2026-10-16 12:31:50 +03:00
parent 833f0d2c27
commit 2cac36a4e2
7 changed files with 48 additions and 13 deletions
+1 -1
View File
@@ -332,7 +332,7 @@ pub fn specs() -> Vec<CommandSpec> {
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")?;
+2 -2
View File
@@ -316,8 +316,8 @@ pub struct DocState {
versions_changed: bool,
/// A version shown in place of the document (read-only).
pub preview: Option<VersionPreview>,
/// 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<Arc<Document>>,
/// The document as of the last version saved or restored (none yet): autosaves, restores and
/// merges only add a checkpoint for changes since.
@@ -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();
+2 -2
View File
@@ -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.
+1 -1
View File
@@ -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;
+1 -1
View File
@@ -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);
+16 -6
View File
@@ -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<String> {
/// 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<String>, Option<String>) {
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<String> = 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<PickRow> = 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)]