feat(ui): live font preview on hover (#2431)

* feat(ui): preview fonts on hover

* test(ui): exercise font preview through eframe

---------

Co-authored-by: Brandon Thomas <[email protected]>
This commit is contained in:
Ehsan FazliandBrandon Thomas authored and GitHub committed 2026-10-10 19:11:27 -04:00
1 parent 325c5b3194
commit d9ae73e95f
5 files changed
+359 -5

No files matched your search

+10
View File
@@ -912,6 +912,9 @@ pub(crate) fn display_doc(app: &mut PhotocraftApp, idx: usize) -> (std::sync::Ar
if let Some(shown) = crate::type_transform::display_doc(app, idx) {
return shown;
}
if let Some(shown) = crate::type_font_preview::display_doc(app, idx) {
return shown;
}
// Puppet / Perspective Warp previews hide the layer they draw on a mesh.
if let Some(shown) = crate::distort_ui::display_doc(app, idx) {
return shown;
@@ -1159,6 +1162,13 @@ pub(crate) fn damage_since(app: &PhotocraftApp, idx: usize, seen: (u64, u64), no
{
return Some(if r.is_empty() { r } else { r.inflate(effect_reach(&st.doc.layers)) });
}
// Between hovered fonts (and the document): the text layer's old/new ink bounds.
if seen.0 == now.0
&& let Some(st) = app.session.documents().get(idx)
&& let Some(r) = crate::type_font_preview::damage(app, st.doc.id, now.0, seen.1 ^ display_key, now.1)
{
return Some(if r.is_empty() { r } else { r.inflate(effect_reach(&st.doc.layers)) });
}
let l = live_stroke(app, idx).filter(|l| seen.0 == now.0 && l.display_key() == now.1)?;
let r = l.since(seen.1 ^ display_key)?;
Some(if r.is_empty() { r } else { r.inflate(effect_reach(&l.stroke.doc().layers)) })
+5
View File
@@ -163,6 +163,7 @@ mod tool_cursor;
pub mod tool_feedback;
pub mod transform_tex;
pub mod transform_tool;
mod type_font_preview;
pub mod type_panels_ui;
pub mod type_tool;
mod type_transform;
@@ -573,6 +574,8 @@ pub struct PhotocraftApp {
/// Type tool layout cache: ((doc, revision, layer), layout).
pub(crate) type_layout: Option<((u64, u64, u64), std::sync::Arc<photocraft_text::TextLayout>)>,
pub(crate) type_transform_preview: Option<type_transform::Preview>,
/// A font hovered in the Type tool's font picker, shown live (`type_font_preview`).
pub(crate) type_font_preview: Option<type_font_preview::TypeFontPreview>,
/// Channel thumbnails for one document snapshot; view-only revisions reuse their pixels.
channel_thumbs: Option<(DocId, std::sync::Weak<Document>, bool, Vec<egui::TextureHandle>)>,
/// Channels panel overlays / channel views drawn over the canvas, per document id.
@@ -680,6 +683,7 @@ impl PhotocraftApp {
channel_views: HashMap::new(),
type_layout: None,
type_transform_preview: None,
type_font_preview: None,
guide_drag: None,
crop: Default::default(),
hover_doc: None,
@@ -1338,6 +1342,7 @@ impl eframe::App for PhotocraftApp {
}
self.last_frame_time = now;
self.sync_views();
crate::type_font_preview::follow_hover(self, ctx);
self.check_gpu(ctx);
#[cfg(all(debug_assertions, not(target_arch = "wasm32")))]
if self.live_tokens.poll(ctx, self.ui.theme) {
+184 -2
View File
@@ -19,7 +19,6 @@ fn harness() -> Harness<'static, State> {
let state = State { current: "Family 00".into(), families, popup: egui::Id::NULL };
let mut h = Harness::builder().with_size(vec2(600.0, 900.0)).build_ui_state(
|ui, s: &mut State| {
// `ComboBox::from_id_salt` wraps its salt in an `IdSalt`; the popup id is the button's + "popup".
s.popup = ui.make_persistent_id(egui::IdSalt::new("type-font")).with("popup");
let fams = s.families.clone();
font_picker_in(ui, &mut s.current, 170.0, &fams);
@@ -37,10 +36,35 @@ fn popup_rect(h: &Harness<'static, State>) -> Option<Rect> {
}
fn open(h: &mut Harness<'static, State>) {
h.get_by_role(Role::ComboBox).click();
h.query_all_by_role(Role::ComboBox).next().unwrap().click();
h.run();
}
#[test]
fn hovering_a_font_row_sets_preview_and_leaving_clears_it() {
let mut h = harness();
open(&mut h);
let row = h.get_by_label("Family 02").rect().center();
let popup = popup_rect(&h).expect("font menu open");
assert!(popup.contains(row), "row {row:?} is outside popup {popup:?}");
h.hover_at(row);
h.run_steps(2);
let hover_id = egui::Id::new("type-font-hover");
assert_eq!(h.ctx.input(|i| i.pointer.interact_pos()), Some(row));
assert!(popup_rect(&h).is_some(), "popup closes while moving pointer to the row");
assert_eq!(
h.ctx.data(|d| d.get_temp::<String>(hover_id)).as_deref(),
Some("Family 02"),
"row {row:?}, popup {popup:?}, top layer {:?}",
h.ctx.layer_id_at(row)
);
let search = h.get_by_role(Role::TextInput).rect().center();
h.hover_at(search);
h.run();
assert!(h.ctx.data(|d| d.get_temp::<String>(hover_id)).is_none());
}
#[test]
fn clearing_the_query_restores_the_menu_height() {
let mut h = harness();
@@ -193,4 +217,162 @@ fn font_cycle_in_options_bar_edits_the_layer_and_can_be_undone() {
h.run();
h.state_mut().run("edit.undo", json!({})).unwrap();
assert_eq!(font(h.state()), "Inter");
h.state_mut().run("edit.redo", json!({})).unwrap();
assert_eq!(font(h.state()), "JetBrains Mono");
}
#[test]
fn hovering_a_font_does_not_pollute_history_and_clears_on_close() {
use crate::{PhotocraftApp, Services};
use serde_json::json;
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Services::default());
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
app.run("type.create", json!({"text": "Sample", "font": "Inter", "size": 18})).unwrap();
let layer_id = app.session.active().unwrap().active_layer.unwrap();
let history_before = app.session.active().unwrap().history.entries().len();
// Simulate hovering a font directly via the preview module
let ctx = egui::Context::default();
ctx.data_mut(|d| d.insert_temp(egui::Id::new("type-font-hover"), "JetBrains Mono".to_string()));
crate::type_font_preview::follow_hover(&mut app, &ctx);
// Verify a preview document is generated
let (_, preview_key) = crate::type_font_preview::display_doc(&mut app, 0).expect("preview document");
// Verify the original document's history is NOT polluted
let history_after = app.session.active().unwrap().history.entries().len();
assert_eq!(history_before, history_after, "Hovering a font does not create an undo step");
let doc = app.session.active().unwrap().doc.id;
let revision = app.session.active().unwrap().revision;
assert!(crate::type_font_preview::damage(&app, doc, revision, 0, preview_key).is_some(), "preview bounds survive dismissal for canvas invalidation");
// Verify the actual document layer still has the original font
let original_font = match &app.session.active().unwrap().doc.layer(layer_id).unwrap().content {
photocraft_doc::LayerContent::Text(t) => t.font_family.clone(),
_ => panic!("type layer"),
};
assert_eq!(original_font, "Inter", "Hovering does not commit the font to the document");
// Simulate the font menu closing (hover cleared)
crate::type_font_preview::follow_hover(&mut app, &ctx);
let no_preview = crate::type_font_preview::display_doc(&mut app, 0);
assert!(no_preview.is_none(), "No preview document is returned after clearing");
crate::type_font_preview::set(&mut app, "Unavailable Font".into());
assert!(crate::type_font_preview::display_doc(&mut app, 0).is_none(), "unavailable fonts do not render a misleading fallback preview");
}
#[test]
fn options_bar_hover_previews_on_canvas_without_committing() {
use crate::{PhotocraftApp, Services};
use photocraft_doc::LayerContent;
use serde_json::json;
struct PreviewApp {
app: PhotocraftApp,
}
impl eframe::App for PreviewApp {
fn logic(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
crate::type_font_preview::follow_hover(&mut self.app, ctx);
}
fn ui(&mut self, ui: &mut egui::Ui, _frame: &mut eframe::Frame) {
ui.horizontal(|ui| crate::type_tool::options_bar(&mut self.app, ui));
}
}
let mut h = Harness::builder().with_size(vec2(900.0, 700.0)).build_eframe(|cc| {
PhotocraftApp::setup_context(&cc.egui_ctx, crate::theme::ThemeKind::ALL[0]);
PreviewApp { app: PhotocraftApp::new(photocraft_engine::Session::new(), Services::default()) }
});
h.state_mut().app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
h.state_mut().app.run("type.create", json!({"text": "Sample", "font": "Inter", "size": 18})).unwrap();
let layer = h.state().app.session.active().unwrap().active_layer.unwrap();
h.state_mut().app.ui.tool = crate::state::Tool::Type;
h.run_steps(3);
h.query_all_by_role(Role::ComboBox).next().unwrap().click();
h.run();
h.get_by_role(Role::TextInput).type_text("JetBrains Mono");
h.run();
h.get_by_label("JetBrains Mono").hover();
h.run();
let (preview, _) = crate::canvas::display_doc(&mut h.state_mut().app, 0);
let LayerContent::Text(preview_text) = &preview.layer(layer).unwrap().content else {
panic!("type layer");
};
assert_eq!(preview_text.char_runs()[0].style.font_family, "JetBrains Mono");
let LayerContent::Text(original_text) = &h.state().app.session.active().unwrap().doc.layer(layer).unwrap().content else {
panic!("type layer");
};
assert_eq!(original_text.char_runs()[0].style.font_family, "Inter");
assert_eq!(h.state().app.session.active().unwrap().history.entries().len(), 2, "hover adds no history entry");
h.get_by_role(Role::TextInput).hover();
h.run();
let (restored, key) = crate::canvas::display_doc(&mut h.state_mut().app, 0);
let LayerContent::Text(restored_text) = &restored.layer(layer).unwrap().content else {
panic!("type layer");
};
assert_eq!(restored_text.char_runs()[0].style.font_family, "Inter");
assert_eq!(key, 0, "leaving the font row restores committed canvas content");
h.get_by_label("JetBrains Mono").hover();
h.run();
h.get_by_label("JetBrains Mono").click();
h.run();
let (committed, key) = crate::canvas::display_doc(&mut h.state_mut().app, 0);
let LayerContent::Text(committed_text) = &committed.layer(layer).unwrap().content else {
panic!("type layer");
};
assert_eq!(committed_text.char_runs()[0].style.font_family, "JetBrains Mono");
assert_eq!(key, 0, "selection replaces the preview with committed content");
assert_eq!(h.state().app.session.active().unwrap().history.entries().len(), 3, "selection adds one history step");
let committed_font = |app: &PhotocraftApp| {
let LayerContent::Text(text) = &app.session.active().unwrap().doc.layer(layer).unwrap().content else {
panic!("type layer");
};
text.char_runs()[0].style.font_family.clone()
};
h.state_mut().app.run("edit.undo", json!({})).unwrap();
assert_eq!(committed_font(&h.state().app), "Inter");
h.state_mut().app.run("edit.redo", json!({})).unwrap();
assert_eq!(committed_font(&h.state().app), "JetBrains Mono");
}
#[test]
fn hovering_a_font_previews_only_the_selected_characters() {
use crate::{PhotocraftApp, Services};
use serde_json::json;
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Services::default());
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
app.run("type.create", json!({"text": "abcdef", "font": "Inter", "size": 18})).unwrap();
let layer = app.session.active().unwrap().active_layer.unwrap();
app.run("type.setStyle", json!({"layer": layer.0, "range": [0, 2], "font": "JetBrains Mono"})).unwrap();
app.ui.text_edit = Some(crate::state::TextEdit {
layer: layer.0,
caret: 5,
anchor: 3,
session: "font-preview-test".into(),
created: false,
dragging: false,
resize: None,
preedit: None,
});
crate::type_font_preview::set(&mut app, "Inter".into());
let (preview, _) = crate::type_font_preview::display_doc(&mut app, 0).expect("preview document");
let fonts = |doc: &photocraft_doc::Document| {
let photocraft_doc::LayerContent::Text(text) = &doc.layer(layer).unwrap().content else {
panic!("type layer");
};
text.char_runs().into_iter().flat_map(|run| std::iter::repeat_n(run.style.font_family, run.len)).collect::<Vec<_>>()
};
assert_eq!(fonts(&preview), ["JetBrains Mono", "JetBrains Mono", "Inter", "Inter", "Inter", "Inter"]);
assert_eq!(fonts(&app.session.active().unwrap().doc), ["JetBrains Mono", "JetBrains Mono", "Inter", "Inter", "Inter", "Inter"]);
}
+137
View File
@@ -0,0 +1,137 @@
//! Live canvas preview for hovered fonts in the Type tool's font picker (#2315).
use std::collections::VecDeque;
use std::sync::Arc;
use photocraft_doc::{DocId, Document, LayerContent, LayerId};
use photocraft_geom::Rect;
use crate::PhotocraftApp;
const TAG: u64 = 1 << 60;
#[derive(Clone)]
pub struct TypeFontPreview {
document: DocId,
revision: u64,
layer: LayerId,
range: Option<[usize; 2]>,
family: Option<String>,
shown: Option<Arc<Document>>,
key: u64,
original_area: Rect,
areas: VecDeque<(u64, Rect)>,
}
pub(crate) fn follow_hover(app: &mut PhotocraftApp, ctx: &egui::Context) {
let hover_id = egui::Id::new("type-font-hover");
let family = ctx.data_mut(|d| {
let family = d.get_temp::<String>(hover_id);
d.remove::<String>(hover_id);
family
});
if let Some(family) = family {
set(app, family);
} else if app.type_font_preview.is_some() {
clear(app);
}
}
pub(crate) fn display_doc(app: &mut PhotocraftApp, idx: usize) -> Option<(Arc<Document>, u64)> {
let p = app.type_font_preview.as_ref()?.clone();
let family = p.family.as_deref()?;
let st = app.session.documents().get(idx)?;
if st.doc.id != p.document || st.revision != p.revision {
return None;
}
if !photocraft_text::shared().lock().unwrap_or_else(|e| e.into_inner()).fonts.has_family(family) {
return None;
}
if let Some(shown) = &p.shown {
return Some((shown.clone(), p.key));
}
// Clone the original document to avoid mutating the session's document
let original_doc = st.doc.clone();
let mut doc = (*st.doc).clone();
let layer = p.layer;
let family = family.to_string();
let range = p.range;
{
let l = doc.layer_mut(layer)?;
if let LayerContent::Text(t) = &mut l.content {
let (start, end) = range.map_or((0, t.text.len()), |[a, b]| (photocraft_text::byte_index(&t.text, a), photocraft_text::byte_index(&t.text, b)));
photocraft_engine::type_cmds::style_range(t, start, end, &|style| {
style.font_family.clone_from(&family);
style.postscript_name = None;
});
photocraft_engine::type_cmds::refresh(&original_doc, t);
} else {
return None;
}
}
let area = doc.layer(layer)?.surface().map_or(Rect::EMPTY, |surface| surface.tile_bounds());
let shown = Arc::new(photocraft_engine::mode_cmds::display_document(&doc).unwrap_or(doc));
let key = p.key;
if let Some(p) = app.type_font_preview.as_mut() {
if p.areas.len() >= 128 {
p.areas.pop_front();
}
p.areas.push_back((key, area));
p.shown = Some(shown.clone());
}
Some((shown, key))
}
pub(crate) fn set(app: &mut PhotocraftApp, family: String) {
let Some((layer, range)) = crate::type_tool::target(app) else {
clear(app);
return;
};
let layer = LayerId(layer);
let Some(st) = app.session.active() else {
clear(app);
return;
};
if !matches!(st.doc.layer(layer).map(|l| &l.content), Some(LayerContent::Text(_))) {
clear(app);
return;
}
photocraft_text::served::request(&family);
if app.type_font_preview.as_ref().is_some_and(|p| {
p.family.as_deref() == Some(family.as_str()) && p.document == st.doc.id && p.revision == st.revision && p.layer == layer && p.range == range
}) {
return;
}
let doc_id = st.doc.id;
let revision = st.revision;
let original_area = st.doc.layer(layer).and_then(|l| l.surface()).map_or(Rect::EMPTY, |s| s.tile_bounds());
let previous = app.type_font_preview.take().filter(|p| p.document == doc_id && p.revision == revision && p.layer == layer && p.range == range);
let (areas, original_area) = previous.map_or((VecDeque::new(), original_area), |p| (p.areas, p.original_area));
let key = preview_key(revision, layer, range, &family);
app.type_font_preview = Some(TypeFontPreview { document: doc_id, revision, layer, range, family: Some(family), shown: None, key, original_area, areas });
}
pub(crate) fn clear(app: &mut PhotocraftApp) {
if let Some(p) = app.type_font_preview.as_mut() {
p.family = None;
p.shown = None;
}
}
pub(crate) fn damage(app: &PhotocraftApp, doc: DocId, revision: u64, before: u64, after: u64) -> Option<Rect> {
if before == after {
return None;
}
let p = app.type_font_preview.as_ref().filter(|p| p.document == doc && p.revision == revision)?;
let area = |key| if key == 0 { Some(p.original_area) } else { p.areas.iter().find(|(k, _)| *k == key).map(|(_, r)| *r) };
Some(area(before)?.union(&area(after)?))
}
fn preview_key(revision: u64, layer: LayerId, range: Option<[usize; 2]>, family: &str) -> u64 {
let range_key = range.map_or(0, |[start, end]| (start as u64).rotate_left(17) ^ end as u64);
let h = family.bytes().fold(revision.wrapping_mul(0x9e37_79b9_7f4a_7c15) ^ layer.0 ^ range_key, |h, b| h.wrapping_mul(31).wrapping_add(u64::from(b)));
(h & (TAG - 1)) | TAG
}
+23 -3
View File
@@ -870,7 +870,8 @@ pub(crate) fn family_picker_in(ui: &mut egui::Ui, salt: &str, current: &mut Stri
// Clicks inside the menu (the search field, the scroll bar) must not close it (#1369);
// picking a font closes it explicitly below.
.close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside);
combo.show_ui(ui, |ui| {
let response = combo.show_ui(ui, |ui| {
let (pass_id, focus_id, height_id) = (search_id.with("pass"), search_id.with("focus"), search_id.with("height"));
let pass = ui.ctx().cumulative_pass_nr();
let last_pass: Option<u64> = ui.data(|d| d.get_temp(pass_id));
@@ -911,10 +912,13 @@ pub(crate) fn family_picker_in(ui: &mut egui::Ui, salt: &str, current: &mut Stri
photocraft_text::served::request(f);
}
}
if enter && filtered.iter().any(|f| *f == current) {
let accept = enter && filtered.iter().any(|f| *f == current);
if accept {
ui.data_mut(|d| d.remove::<String>(search_id));
ui.close();
}
let mut hovered: Option<String> = None;
for f in filtered {
let response = ui.add_sized(
[330.0, 28.0],
@@ -928,12 +932,16 @@ pub(crate) fn family_picker_in(ui: &mut egui::Ui, salt: &str, current: &mut Stri
if ui.is_rect_visible(response.rect) {
crate::font_preview::paint(ui, f, response.rect);
}
if response.hovered() {
hovered = Some(f.clone());
}
if response.clicked() {
*current = f.clone();
// A served family not fetched yet: start now, before any text needs it.
photocraft_text::served::request(f);
changed = true;
ui.data_mut(|d| d.remove::<String>(search_id));
hovered = None;
ui.close();
}
}
@@ -946,12 +954,24 @@ pub(crate) fn family_picker_in(ui: &mut egui::Ui, salt: &str, current: &mut Stri
} else if let Some(h) = ui.data(|d| d.get_temp::<f32>(height_id)) {
ui.set_min_height(h.min(FONT_MENU_HEIGHT));
}
if accept { None } else { hovered }
});
let hovered_font = response.inner.unwrap_or_default();
if salt == "type-font" {
let hover_id = egui::Id::new("type-font-hover");
if let Some(f) = hovered_font {
ui.ctx().data_mut(|d| d.insert_temp(hover_id, f));
} else {
ui.ctx().data_mut(|d| d.remove::<String>(hover_id));
}
}
changed
}
/// Representative for the displayed properties: the edit layer, else a selected type layer.
fn target(app: &PhotocraftApp) -> Option<(u64, Option<[usize; 2]>)> {
pub(crate) fn target(app: &PhotocraftApp) -> Option<(u64, Option<[usize; 2]>)> {
if let Some(ed) = &app.ui.text_edit {
let (a, b) = (ed.caret.min(ed.anchor), ed.caret.max(ed.anchor));
return Some((ed.layer, (a < b).then_some([a, b])));