diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 20aacc7f9a..7de7b1de95 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -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)) }) diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 48072baa85..0adf31b0f2 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -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)>, pub(crate) type_transform_preview: Option, + /// A font hovered in the Type tool's font picker, shown live (`type_font_preview`). + pub(crate) type_font_preview: Option, /// Channel thumbnails for one document snapshot; view-only revisions reuse their pixels. channel_thumbs: Option<(DocId, std::sync::Weak, bool, Vec)>, /// 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) { diff --git a/crates/ui-egui/src/type_font_picker_tests.rs b/crates/ui-egui/src/type_font_picker_tests.rs index 6e67eeb53c..137a4a5090 100644 --- a/crates/ui-egui/src/type_font_picker_tests.rs +++ b/crates/ui-egui/src/type_font_picker_tests.rs @@ -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 { } 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::(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::(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::>() + }; + + 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"]); } diff --git a/crates/ui-egui/src/type_font_preview.rs b/crates/ui-egui/src/type_font_preview.rs new file mode 100644 index 0000000000..4c652ed7ce --- /dev/null +++ b/crates/ui-egui/src/type_font_preview.rs @@ -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, + shown: Option>, + 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::(hover_id); + d.remove::(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, 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 { + 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 +} diff --git a/crates/ui-egui/src/type_tool.rs b/crates/ui-egui/src/type_tool.rs index 5022c43d93..55e1dad241 100644 --- a/crates/ui-egui/src/type_tool.rs +++ b/crates/ui-egui/src/type_tool.rs @@ -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 = 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::(search_id)); ui.close(); } + + let mut hovered: Option = 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::(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::(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::(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])));