From b61715889a42461c9a5494b5e055fcd0aabadba1 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Sat, 10 Oct 2026 01:56:01 -0400 Subject: [PATCH] UI: one searchable font menu everywhere; the Contextual Task Bar can be moved (#143) * UI: every font field opens the same searchable font menu, with the search ready for typing and hidden system fonts left out * UI: the Contextual Task Bar moves by its grip and stays where it is dropped, with Pin Bar Position and Reset Bar Position in its menu * Merge origin/main; one test_window harness with wheel, panel_rect and click_at Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Brandon Thomas Co-authored-by: Claude Opus 5.5 --- crates/ui-egui/src/dialogs.rs | 30 +- crates/ui-egui/src/lib.rs | 17 ++ crates/ui-egui/src/menus.rs | 30 ++ crates/ui-egui/src/panels/glyphs.rs | 11 +- crates/ui-egui/src/panels/mod.rs | 388 +++++++++++++++++------- crates/ui-egui/src/panels/properties.rs | 16 +- crates/ui-egui/src/taskbar.rs | 184 +++++++++-- 7 files changed, 502 insertions(+), 174 deletions(-) diff --git a/crates/ui-egui/src/dialogs.rs b/crates/ui-egui/src/dialogs.rs index 9cd7823..7af1a32 100644 --- a/crates/ui-egui/src/dialogs.rs +++ b/crates/ui-egui/src/dialogs.rs @@ -1739,14 +1739,14 @@ fn regex_ok(p: &str) -> Result<(), ()> { fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { let name = d.s("name"); - let Some(st) = app.session.active() else { return }; - let Some(style) = st.doc.styles.para(&name).cloned() else { + let Some(doc) = app.session.active().map(|s| s.doc.clone()) else { return }; + let Some(style) = doc.styles.para(&name).cloned() else { ui.label(format!("No style named {name}")); return; }; - let names: Vec = st.doc.styles.paragraph.iter().map(|p| p.name.clone()).filter(|n| *n != name).collect(); - let (pp, cp) = st.doc.styles.resolve_para_style(&name); - let units = st.doc.settings.horizontal_units; + let names: Vec = doc.styles.paragraph.iter().map(|p| p.name.clone()).filter(|n| *n != name).collect(); + let (pp, cp) = doc.styles.resolve_para_style(&name); + let units = doc.settings.horizontal_units; let pv = serde_json::to_value(&pp).unwrap_or_default(); let cv = serde_json::to_value(&cp).unwrap_or_default(); let cur = |d: &Dialog, k: &str, base: &Value| d.fields.get(k).cloned().unwrap_or_else(|| base.clone()); @@ -2020,15 +2020,12 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo } "chars" => { let fonts = crate::panels::fonts(app); - let menu = crate::panels::font_menu(app); egui::Grid::new("psc").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Font Family:")); let fam = cur(d, "c.fontFamily", &cv["fontFamily"]).as_str().unwrap_or("").to_string(); - egui::ComboBox::from_id_salt("psfam").selected_text(crate::panels::font_label(app, &menu, &fam)).width(200.0).show_ui(ui, |ui| { - if let Some(f) = crate::panels::font_menu_rows(app, ui, &menu, &fam) { - d.fields.insert("c.fontFamily".into(), json!(f)); - } - }); + if let Some(f) = crate::panels::font_combo(app, ui, "psfam", &fam, 200.0) { + d.fields.insert("c.fontFamily".into(), json!(f)); + } ui.end_row(); crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Font Style:")); let sty = cur(d, "c.fontStyle", &cv["fontStyle"]).as_str().unwrap_or("").to_string(); @@ -2157,10 +2154,10 @@ fn paragraph_style_options(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialo } "color" => { let cur_fill = cur(d, "c.fill", &cv["fill"]).as_str().unwrap_or("").to_string(); - let swatches: Vec = st.doc.swatches.iter().map(|s| s.name.clone()).collect(); + let swatches: Vec = doc.swatches.iter().map(|s| s.name.clone()).collect(); egui::ScrollArea::vertical().max_height(260.0).show(ui, |ui| { for sw in swatches { - let (c, g) = crate::widgets::swatch_colors(&st.doc, &sw, 1.0); + let (c, g) = crate::widgets::swatch_colors(&doc, &sw, 1.0); ui.horizontal(|ui| { let (r, _) = ui.allocate_exact_size(egui::vec2(14.0, 14.0), egui::Sense::hover()); crate::widgets::paint_chip(ui.painter(), r, c, g); @@ -2678,12 +2675,11 @@ fn find_font(app: &mut DesignApp, ui: &mut egui::Ui, d: &mut Dialog) { ui.add_space(8.0); crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Replace With")).font(semibold(12.0))); let db = crate::panels::fonts(app); - let menu = crate::panels::font_menu(app); - let english = app.session.prefs.show_font_names_in_english; - let fam_opts: Vec<(&str, &str)> = menu.iter().map(|f| (f.family.as_str(), f.label(english))).collect(); egui::Grid::new("ff_to").num_columns(2).spacing([8.0, 6.0]).show(ui, |ui| { crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Font Family:")); - combo(ui, d, "toFamily", &fam_opts); + if let Some(f) = crate::panels::font_combo(app, ui, "toFamily", &d.s("toFamily"), 170.0) { + d.fields.insert("toFamily".into(), json!(f)); + } ui.end_row(); let styles = db.styles(&d.s("toFamily")); let st_opts: Vec<(&str, &str)> = styles.iter().map(|s| (s.as_str(), s.as_str())).collect(); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 89ffd1a..16c4e1e 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -81,6 +81,7 @@ pub struct SavedWorkspace { pub name: String, pub control_bar: bool, pub task_bar: bool, + pub task_bar_pin: Option<[f32; 2]>, pub tools_double_column: bool, pub dock_tab: String, pub dock_expanded: bool, @@ -189,6 +190,12 @@ pub struct UiState { pub snap_zone: f64, /// Window > Contextual Task Bar. pub task_bar: bool, + /// Where the Contextual Task Bar is pinned: its top-left, in points from the canvas's top-left + /// (`None`: it follows the selection). + pub task_bar_pin: Option<[f32; 2]>, + /// Where the Contextual Task Bar was last shown, like [`Self::task_bar_pin`]. + #[serde(skip)] + pub task_bar_at: Option<[f32; 2]>, /// Help › About DesignCraft is open. pub about: bool, /// The About window's tab: 0 About, 1 Contributors, 2 Models (`about::ABOUT_TABS`). @@ -264,6 +271,8 @@ impl Default for UiState { smart_spacing: true, snap_zone: 4.0, task_bar: true, + task_bar_pin: None, + task_bar_at: None, about: false, about_tab: 0, pending_urls: Vec::new(), @@ -1090,4 +1099,12 @@ pub(crate) mod test_window { pub fn panel_rect(h: &Harness<'static, Window>, id: &str) -> egui::Rect { egui::containers::panel::PanelState::load(&h.ctx, egui::Id::new(id)).map(|s| s.outer_rect).unwrap() } + + /// A primary click at `pos`, then a few frames. + pub fn click_at(h: &mut Harness<'static, Window>, pos: egui::Pos2) { + h.hover_at(pos); + h.drag_at(pos); + h.drop_at(pos); + h.run_steps(4); + } } diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index ea41f81..53bee42 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -129,6 +129,13 @@ pub const UI_COMMANDS: &[(&str, &str, Option<&str>, &str)] = &[ ("window.split", "Split Window", None, "{on?: bool} — two views of the document side by side, each with its own zoom and scroll"), ("window.newWindow", "New Window", None, "{on?: bool} — another view of the active document in its own window"), ("window.taskBar", "Contextual Task Bar", None, "{}"), + ( + "window.taskBarPin", + "Pin Bar Position", + None, + "{on?: bool, at?: [x, y] (points from the canvas's top-left)} — the Contextual Task Bar stays where it is (or at `at`) instead of following the selection; with neither, toggles", + ), + ("window.taskBarReset", "Reset Bar Position", None, "{} — the Contextual Task Bar follows the selection again, under it"), ("help.discord", "Join the ArtCraft Discord…", None, "{} — opens https://discord.gg/artcraft in the browser"), ("help.appPage", "DesignCraft Website…", None, "{} — opens https://getartcraft.com/apps/designcraft"), ("help.github", "DesignCraft on GitHub…", None, "{} — opens https://github.com/storytold/designcraft"), @@ -1286,6 +1293,26 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option flag(&mut app.ui.task_bar), + "window.taskBarPin" => { + let at = match p.get("at") { + None | Some(Value::Null) => None, + Some(v) => match v.as_array().map(|a| a.iter().map(Value::as_f64).collect::>()).as_deref() { + Some([Some(x), Some(y)]) if x.is_finite() && y.is_finite() => Some([x.clamp(-1e6, 1e6) as f32, y.clamp(-1e6, 1e6) as f32]), + _ => return Some(Err("window.taskBarPin: `at` is [x, y] in points".into())), + }, + }; + let on = p.get("on").and_then(Value::as_bool).unwrap_or(at.is_some() || app.ui.task_bar_pin.is_none()); + app.ui.task_bar_pin = match (on, at.or(app.ui.task_bar_pin).or(app.ui.task_bar_at)) { + (false, _) => None, + (true, Some(at)) => Some(at), + (true, None) => return Some(Err("window.taskBarPin: the Contextual Task Bar isn't showing; give `at`".into())), + }; + Ok(json!(app.ui.task_bar_pin)) + } + "window.taskBarReset" => { + app.ui.task_bar_pin = None; + Ok(Value::Null) + } "help.about" => { if let Some(tab) = p.get("tab").and_then(Value::as_str) { let Some(i) = crate::about::ABOUT_TABS.iter().position(|t| t.eq_ignore_ascii_case(tab)) else { @@ -1320,6 +1347,7 @@ pub fn run_ui(app: &mut DesignApp, id: &str, p: &Value) -> Option Option Option { "app.language" => app.ui.language == params.get("lang").and_then(Value::as_str).unwrap_or(""), "app.flattener" => app.ui.flattener == params.get("preset").and_then(Value::as_str).unwrap_or(""), "window.taskBar" => app.ui.task_bar, + "window.taskBarPin" => app.ui.task_bar_pin.is_some(), "window.toolsDoubleColumn" => app.ui.tools_double_column, "view.togglePreview" => app.ui.screen_mode == crate::ScreenMode::Preview, "window.brightness" => params.get("brightness").and_then(Value::as_str) == Some(app.ui.brightness.id()), diff --git a/crates/ui-egui/src/panels/glyphs.rs b/crates/ui-egui/src/panels/glyphs.rs index d0fa75e..3fb9d00 100644 --- a/crates/ui-egui/src/panels/glyphs.rs +++ b/crates/ui-egui/src/panels/glyphs.rs @@ -33,13 +33,10 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { } let (db, scope) = (super::fonts(app), super::font_scope(app)); ui.horizontal(|ui| { - let menu = super::font_menu(app); - egui::ComboBox::from_id_salt("glyph_family").selected_text(super::font_label(app, &menu, &st.family)).width(140.0).show_ui(ui, |ui| { - if let Some(f) = super::font_menu_rows(app, ui, &menu, &st.family) { - st.style = db.styles(&f).into_iter().next().unwrap_or_else(|| "Regular".into()); - st.family = f; - } - }); + if let Some(f) = super::font_combo(app, ui, "glyph_family", &st.family, 140.0) { + st.style = db.styles(&f).into_iter().next().unwrap_or_else(|| "Regular".into()); + st.family = f; + } egui::ComboBox::from_id_salt("glyph_style").selected_text(&st.style).width(80.0).show_ui(ui, |ui| { for s in db.styles(&st.family) { if ui.selectable_label(s == st.style, &s).clicked() { diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 4bc1ad0..3015567 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -112,8 +112,10 @@ pub fn fonts(app: &DesignApp) -> designcraft_fonts::ScopedFonts<'static> { /// The font menus' families in menu order: Western (and other scripts) first, then Japanese, /// Simplified Chinese, Traditional Chinese and Korean, each alphabetical by the name shown (CJK /// families by their native names unless Show Font Names in English). +/// macOS's hidden system families (named with a leading `.`) are left out. pub fn font_menu(app: &DesignApp) -> Vec { let mut v = fonts(app).family_infos(); + v.retain(|f| !f.family.starts_with('.')); designcraft_fonts::sort_for_menu(&mut v, app.session.prefs.show_font_names_in_english); v } @@ -124,29 +126,6 @@ pub fn font_label(app: &DesignApp, menu: &[designcraft_fonts::FamilyInfo], famil menu.iter().find(|f| f.family == family).map_or(family, |f| f.label(english)).to_string() } -/// `menu` as rows of a popup or combo box: the name shown (the English name on hover when they -/// differ), a separator between groups. Returns the family clicked. -pub fn font_menu_rows(app: &DesignApp, ui: &mut egui::Ui, menu: &[designcraft_fonts::FamilyInfo], current: &str) -> Option { - let english = app.session.prefs.show_font_names_in_english; - let mut pick = None; - let mut group = None; - for f in menu { - if group.is_some_and(|g| g != f.group) { - ui.separator(); - } - group = Some(f.group); - let label = f.label(english); - let mut resp = ui.selectable_label(f.family == current, label); - if label != f.family { - resp = resp.on_hover_text(&f.family); - } - if resp.clicked() { - pick = Some(f.family.clone()); - } - } - pick -} - /// The active document's font scope (0 without a document or fonts of its own). pub fn font_scope(app: &DesignApp) -> u32 { app.session.active().map_or(0, |d| d.doc.font_scope) @@ -236,108 +215,189 @@ fn paint_star(p: &egui::Painter, c: egui::Pos2, r: f32, filled: bool, color: egu } } -/// The Font menu: search, favourites (★, Show Favorites Only), and each family's name shown in -/// that family. -pub fn font_family_picker(app: &mut DesignApp, ui: &mut egui::Ui, current: &str, width: f32) { +/// The font menu's state while it is open: the search, Show Favorites Only, the match ↑/↓ highlight +/// and the frame it was last shown (a menu not shown the frame before has just opened). +#[derive(Clone, Default)] +struct FontMenuState { + query: String, + only_favs: bool, + highlight: Option, + frame: Option, +} + +/// The Font menu, inside any font field's popup or combo box: a search field (it has the keyboard +/// while the menu is open, and starts empty each time it opens), favourites (★, Show Favorites +/// Only), and each family's name with a sample in that family, the script groups apart. ↑/↓ move +/// through the matches and Return picks one. Returns the family picked and closes the menu; the +/// search, Favorites and the stars keep it open (the popup closes on a click outside only). +pub fn font_menu_body(app: &mut DesignApp, ui: &mut egui::Ui, menu: &[designcraft_fonts::FamilyInfo], current: &str, width: f32) -> Option { let (fonts, scope) = (fonts(app), font_scope(app)); - let menu = font_menu(app); let english = app.session.prefs.show_font_names_in_english; - let shown_current = font_label(app, &menu, current); let mut favs = app.session.prefs.favorite_fonts.clone(); let mut pick = None; let mut favs_changed = false; let state_id = egui::Id::new("font_menu_state"); - let (mut query, mut only_favs): (String, bool) = ui.data(|d| d.get_temp(state_id)).unwrap_or_default(); + let mut st: FontMenuState = ui.data(|d| d.get_temp(state_id)).unwrap_or_default(); + let frame = ui.ctx().cumulative_frame_nr(); + if st.frame.is_none_or(|f| f.saturating_add(1) < frame) { + st.query.clear(); + st.highlight = None; + } + st.frame = Some(frame); let t = crate::theme::Tokens::get(ui.ctx()); - egui::ComboBox::from_id_salt("font_family") - .selected_text(if current.is_empty() { "—" } else { &shown_current }) - .width(width) - .height(420.0) - // The search field, Favorites and the stars keep the menu open; picking a font closes it. - .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) - .show_ui(ui, |ui| { - ui.set_min_width(300.0); - ui.horizontal(|ui| { - ui.add( - egui::TextEdit::singleline(&mut query) - .hint_text(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Search fonts"))) - .desired_width(200.0), - ); - ui.toggle_value(&mut only_favs, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Favorites"))).on_hover_ui(|ui| { - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Show Favorites Only")); - }); + ui.set_width(width.max(300.0)); + let search = ui + .horizontal(|ui| { + let search = ui.add( + egui::TextEdit::singleline(&mut st.query) + .hint_text(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Search fonts"))) + .desired_width(200.0) + // Return picks the highlighted match instead of leaving the field. + .return_key(None), + ); + ui.toggle_value(&mut st.only_favs, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Favorites"))).on_hover_ui(|ui| { + crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "Show Favorites Only")); }); - let q = query.to_lowercase(); - let ppp = ui.ctx().pixels_per_point(); - let shown: Vec<&designcraft_fonts::FamilyInfo> = - menu.iter().filter(|f| f.matches(&q) && (!only_favs || favs.contains(&f.family))).collect(); - let mut group = None; - for info in shown { - // A separator between the Western fonts and each CJK language's. - if group.is_some_and(|g| g != info.group) { - ui.separator(); - } - group = Some(info.group); - let f = &info.family; - let label = info.label(english); - let (row, mut resp) = ui.allocate_exact_size(egui::vec2(ui.available_width(), 24.0), egui::Sense::click()); - if !ui.is_rect_visible(row) { - continue; - } - if label != f { - resp = resp.on_hover_text(f); - } - if f == current { - ui.painter().rect_filled(row, 0.0, t.row_selected); - } else if resp.hovered() { - ui.painter().rect_filled(row, 0.0, t.hover); - } - // Favourite star. - let star = egui::Rect::from_min_size(row.min + egui::vec2(2.0, 4.0), egui::vec2(16.0, 16.0)); - let fav = favs.contains(f); - let sr = ui.interact(star, ui.id().with(("fav", f)), egui::Sense::click()); - paint_star(ui.painter(), star.center(), 6.5, fav, if fav { t.accent } else { t.text_dim }); - if sr.clicked() { - if fav { - favs.retain(|x| x != f); - } else { - favs.push(f.clone()); - } - favs_changed = true; - } - ui.painter().text(row.min + egui::vec2(22.0, 12.0), egui::Align2::LEFT_CENTER, label, egui::FontId::proportional(12.0), t.text); - // The name in its own face (rendered once per family and scale). - let key = egui::Id::new(("font_preview", f, scope, (ppp * 100.0) as u32, t.text.to_array())); - let tex: Option = ui.data(|d| d.get_temp(key)); - let tex = tex.unwrap_or_else(|| { - let img = designcraft_render::glyphs::text_line(&fonts, f, "Regular", "Sample", (18.0 * ppp) as u32, t.text.to_array()); - let ci = egui::ColorImage::from_rgba_premultiplied([img.width as usize, img.height as usize], &img.pixels); - let h = ui.ctx().load_texture(format!("font_preview_{f}"), ci, egui::TextureOptions::LINEAR); - ui.data_mut(|d| d.insert_temp(key, h.clone())); - h - }); - let size = tex.size_vec2() / ppp; - let at = egui::pos2(row.max.x - size.x - 4.0, row.center().y - size.y / 2.0); - ui.painter().image( - tex.id(), - egui::Rect::from_min_size(at, size), - egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), - egui::Color32::WHITE, - ); - if resp.clicked() { - pick = Some(f.clone()); - ui.close(); - } + search + }) + .inner; + // Typing always goes to the search, never to the document under the menu. + if !search.has_focus() { + search.request_focus(); + } + let q = st.query.to_lowercase(); + let shown: Vec<&designcraft_fonts::FamilyInfo> = menu.iter().filter(|f| f.matches(&q) && (!st.only_favs || favs.contains(&f.family))).collect(); + if search.changed() { + st.highlight = if q.is_empty() { None } else { Some(0) }; + } + let (down, up, enter) = ui.input(|i| (i.key_pressed(egui::Key::ArrowDown), i.key_pressed(egui::Key::ArrowUp), i.key_pressed(egui::Key::Enter))); + let last = shown.len().checked_sub(1); + st.highlight = match (st.highlight, last) { + (_, None) => None, + (None, Some(_)) if down => Some(0), + (Some(h), Some(l)) if down => Some((h + 1).min(l)), + (Some(h), Some(l)) if up => Some(h.saturating_sub(1).min(l)), + (h, Some(l)) => h.map(|h| h.min(l)), + }; + if enter && let Some(f) = st.highlight.and_then(|h| shown.get(h)) { + pick = Some(f.family.clone()); + } + let ppp = ui.ctx().pixels_per_point(); + egui::ScrollArea::vertical().max_height(380.0).auto_shrink([false, true]).show(ui, |ui| { + let mut group = None; + for (k, info) in shown.iter().enumerate() { + // A separator between the Western fonts and each CJK language's. + if group.is_some_and(|g| g != info.group) { + ui.separator(); } - }); - ui.data_mut(|d| d.insert_temp(state_id, (query, only_favs))); + group = Some(info.group); + let f = &info.family; + let label = info.label(english); + let (row, mut resp) = ui.allocate_exact_size(egui::vec2(ui.available_width(), 24.0), egui::Sense::click()); + let highlighted = st.highlight == Some(k); + if highlighted && (down || up) { + ui.scroll_to_rect(row, None); + } + if !ui.is_rect_visible(row) { + continue; + } + resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, true, f == current, label)); + if label != f { + resp = resp.on_hover_text(f); + } + if f == current { + ui.painter().rect_filled(row, 0.0, t.row_selected); + } else if highlighted || resp.hovered() { + ui.painter().rect_filled(row, 0.0, t.hover); + } + // Favourite star. + let star = egui::Rect::from_min_size(row.min + egui::vec2(2.0, 4.0), egui::vec2(16.0, 16.0)); + let fav = favs.contains(f); + let sr = ui.interact(star, ui.id().with(("fav", f)), egui::Sense::click()); + paint_star(ui.painter(), star.center(), 6.5, fav, if fav { t.accent } else { t.text_dim }); + if sr.clicked() { + if fav { + favs.retain(|x| x != f); + } else { + favs.push(f.clone()); + } + favs_changed = true; + } + ui.painter().text(row.min + egui::vec2(22.0, 12.0), egui::Align2::LEFT_CENTER, label, egui::FontId::proportional(12.0), t.text); + // The name in its own face (rendered once per family and scale). + let key = egui::Id::new(("font_preview", f, scope, (ppp * 100.0) as u32, t.text.to_array())); + let tex: Option = ui.data(|d| d.get_temp(key)); + let tex = tex.unwrap_or_else(|| { + let img = designcraft_render::glyphs::text_line(&fonts, f, "Regular", "Sample", (18.0 * ppp) as u32, t.text.to_array()); + let ci = egui::ColorImage::from_rgba_premultiplied([img.width as usize, img.height as usize], &img.pixels); + let h = ui.ctx().load_texture(format!("font_preview_{f}"), ci, egui::TextureOptions::LINEAR); + ui.data_mut(|d| d.insert_temp(key, h.clone())); + h + }); + let size = tex.size_vec2() / ppp; + let at = egui::pos2(row.max.x - size.x - 4.0, row.center().y - size.y / 2.0); + ui.painter().image( + tex.id(), + egui::Rect::from_min_size(at, size), + egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), + egui::Color32::WHITE, + ); + if resp.clicked() && !sr.clicked() { + pick = Some(f.clone()); + } + } + }); + ui.data_mut(|d| d.insert_temp(state_id, st)); if favs_changed { let _ = app.run("prefs.set", json!({ "favoriteFonts": favs })); } - if let Some(f) = pick { - let styles = fonts.styles(&f); - let style = if styles.iter().any(|s| s == "Regular") { "Regular".to_string() } else { styles.first().cloned().unwrap_or_default() }; - let _ = app.run("type.char", json!({"attrs": {"fontFamily": f, "fontStyle": style}})); + if pick.is_some() { + ui.close(); + } + pick +} + +/// A font family combo box showing `current` by its menu name, with [`font_menu_body`] as its menu. +/// Returns the family picked. +pub fn font_combo( + app: &mut DesignApp, + ui: &mut egui::Ui, + id_salt: impl std::hash::Hash + std::fmt::Debug, + current: &str, + width: f32, +) -> Option { + let menu = font_menu(app); + let shown = if current.is_empty() { "—".to_string() } else { font_label(app, &menu, current) }; + egui::ComboBox::from_id_salt(id_salt) + .selected_text(shown) + .width(width) + .height(440.0) + .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) + .show_ui(ui, |ui| font_menu_body(app, ui, &menu, current, width)) + .inner + .flatten() +} + +/// [`font_menu_body`] in a popup under `field` (a font field the caller draws). Returns the family +/// picked. +pub fn font_popup(app: &mut DesignApp, field: &egui::Response, menu: &[designcraft_fonts::FamilyInfo], current: &str) -> Option { + egui::Popup::menu(field) + .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) + .show(|ui| font_menu_body(app, ui, menu, current, field.rect.width())) + .and_then(|r| r.inner) +} + +/// Set the text's font to `family`, in its Regular style (else its first). +pub fn apply_font_family(app: &mut DesignApp, family: &str) { + let styles = fonts(app).styles(family); + let style = if styles.iter().any(|s| s == "Regular") { "Regular".to_string() } else { styles.first().cloned().unwrap_or_default() }; + let _ = app.run("type.char", json!({"attrs": {"fontFamily": family, "fontStyle": style}})); +} + +/// The text's font family field: [`font_combo`], applied to the text. +pub fn font_family_picker(app: &mut DesignApp, ui: &mut egui::Ui, current: &str, width: f32) { + if let Some(f) = font_combo(app, ui, "font_family", current, width) { + apply_font_family(app, &f); } } @@ -397,4 +457,106 @@ mod font_menu_tests { app.run("prefs.set", json!({"showFontNamesInEnglish": true})).unwrap(); assert_eq!(font_label(&app, &font_menu(&app), FAMILY), FAMILY); } + + #[test] + fn hidden_system_fonts_stay_out_of_the_font_menus() { + const HIDDEN: &str = ".DC UI Test Hidden"; + designcraft_fonts::FontDb::global().add_font(font_with(HIDDEN, &['a']).unwrap()); + let app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + assert!(fonts(&app).family_infos().iter().any(|f| f.family == HIDDEN), "installed"); + let menu = font_menu(&app); + assert!(menu.iter().all(|f| !f.family.starts_with('.')), "no hidden family is listed"); + // Text that already uses one still shows its name. + assert_eq!(font_label(&app, &menu, HIDDEN), HIDDEN); + } +} + +#[cfg(test)] +mod font_menu_ui_tests { + use egui::vec2; + use egui_kittest::Harness; + use egui_kittest::kittest::Queryable; + use serde_json::json; + + use crate::test_window::{self, Window, click_at}; + + /// The Type tool's selection over a new text frame's text, with the Control bar on. Returns the + /// story. + fn typing() -> (Harness<'static, Window>, u64) { + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.ui.control_bar = true; + let mut h = test_window::open(app, vec2(1440.0, 900.0)); + let app = &mut h.state_mut().app; + let r = app.run("frame.create", json!({"rect": [72, 72, 300, 200], "content": "text", "text": "Hello", "caret": true})).unwrap(); + app.run("tool.select", json!({"tool": "type"})).unwrap(); + app.run("text.select", json!({"story": r["story"], "anchor": 0, "focus": 5})).unwrap(); + h.run_steps(4); + (h, r["story"].as_u64().unwrap()) + } + + fn story_text(h: &mut Harness<'static, Window>, story: u64) -> String { + h.state_mut().app.run("story.get", json!({"story": story})).unwrap()["text"].as_str().unwrap().to_string() + } + + /// The Control bar's font field, showing `family`. + fn field(h: &Harness<'static, Window>, family: &str) -> egui::Rect { + h.get_by(|n| n.role() == egui::accesskit::Role::ComboBox && n.value().as_deref() == Some(family)).rect() + } + + /// The text in the focused search field. + fn search(h: &Harness<'static, Window>) -> String { + h.get_by(|n| n.role() == egui::accesskit::Role::TextInput && n.is_focused()).value().unwrap_or_default() + } + + fn family(h: &mut Harness<'static, Window>) -> String { + h.state_mut().app.run("type.selectionAttrs", json!({})).unwrap()["chars"]["fontFamily"].as_str().unwrap().to_string() + } + + #[test] + fn typing_in_the_font_menu_searches_and_never_reaches_the_story() { + let (mut h, story) = typing(); + let before = story_text(&mut h, story); + let at = field(&h, designcraft_fonts::DEFAULT_FAMILY); + click_at(&mut h, at.center()); + assert!(h.ctx.text_edit_focused(), "the search field takes the keyboard when the menu opens"); + h.event(egui::Event::Text("source sans".into())); + h.run_steps(4); + assert_eq!(search(&h), "source sans"); + assert_eq!(story_text(&mut h, story), before, "the typing went to the search, not the story"); + assert!(h.query_by_label("Source Sans 3").is_some(), "a match is listed"); + assert!(h.query_by_label(designcraft_fonts::DEFAULT_FAMILY).is_none(), "families that don't match aren't"); + // ↓ and Return pick the first match and close the menu. + h.key_press(egui::Key::ArrowDown); + h.run_steps(2); + h.key_press(egui::Key::Enter); + h.run_steps(4); + assert_eq!(family(&mut h), "Source Sans 3"); + assert_eq!(story_text(&mut h, story), before); + assert!(!h.ctx.text_edit_focused(), "the menu closed"); + // Reopened, the search starts empty. + let at = field(&h, "Source Sans 3"); + click_at(&mut h, at.center()); + assert_eq!(search(&h), "", "the search starts empty"); + // A click on a family picks it. + h.event(egui::Event::Text("serif 4".into())); + h.run_steps(4); + let row = h.get_by_label(designcraft_fonts::DEFAULT_FAMILY).rect(); + click_at(&mut h, row.center()); + assert_eq!(family(&mut h), designcraft_fonts::DEFAULT_FAMILY); + // Escape closes the menu and leaves the text as it was. + let at = field(&h, designcraft_fonts::DEFAULT_FAMILY); + click_at(&mut h, at.center()); + // A star marks a favourite and keeps the menu open. + h.event(egui::Event::Text("serif 4".into())); + h.run_steps(4); + let row = h.get_by_label(designcraft_fonts::DEFAULT_FAMILY).rect(); + click_at(&mut h, row.min + vec2(10.0, 12.0)); + assert!(h.state().app.session.prefs.favorite_fonts.iter().any(|f| f == designcraft_fonts::DEFAULT_FAMILY)); + assert_eq!(search(&h), "serif 4", "the menu stays open"); + h.key_press(egui::Key::Escape); + h.run_steps(4); + assert!(!h.ctx.text_edit_focused(), "the menu closed"); + assert_eq!(story_text(&mut h, story), before); + assert!(h.state().app.session.active().unwrap().selection.text.is_some(), "the text is still selected"); + } } diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index ee58947..5d81e29 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -999,20 +999,8 @@ fn character_section(app: &mut DesignApp, ui: &mut Ui) { egui::FontId::proportional(11.5), t.text, ); - let mut pick_fam = None; - egui::Popup::menu(&resp).show(|ui| { - ui.set_min_width(fw); - egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| { - if let Some(f) = super::font_menu_rows(app, ui, &menu, &fam) { - pick_fam = Some(f); - ui.close(); - } - }); - }); - if let Some(f) = pick_fam { - let styles = fonts.styles(&f); - let style = if styles.iter().any(|s| s == "Regular") { "Regular".to_string() } else { styles.first().cloned().unwrap_or_default() }; - let _ = app.run("type.char", json!({"attrs": {"fontFamily": f, "fontStyle": style}})); + if let Some(f) = super::font_popup(app, &resp, &menu, &fam) { + super::apply_font_family(app, &f); } ui.add_space(1.0); let styles = fonts.styles(&fam); diff --git a/crates/ui-egui/src/taskbar.rs b/crates/ui-egui/src/taskbar.rs index 47b05ff..9881542 100644 --- a/crates/ui-egui/src/taskbar.rs +++ b/crates/ui-egui/src/taskbar.rs @@ -10,10 +10,16 @@ use crate::{DesignApp, icons, panels}; pub const HEIGHT: f32 = 36.0; -/// Show the bar under `sel` (screen rect of the selection), kept inside `canvas`. +/// The grip that moves the bar. +const GRIP: &str = "task_bar_grip"; + +/// Show the bar under `sel` (screen rect of the selection), or where it is pinned, kept inside +/// `canvas`. pub fn show(app: &mut DesignApp, ctx: &egui::Context, sel: Rect, canvas: Rect) { + let grip_id = egui::Id::new(GRIP); // (Two separate context calls: `is_pointer_over_egui` inside an `input` closure deadlocks.) - if !app.ui.task_bar || (ctx.input(|i| i.pointer.any_down()) && !ctx.is_pointer_over_egui()) { + let dragging = ctx.dragged_id() == Some(grip_id); + if !app.ui.task_bar || (ctx.input(|i| i.pointer.any_down()) && !ctx.is_pointer_over_egui() && !dragging) { return; } let Some(st) = app.session.active() else { return }; @@ -25,16 +31,23 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context, sel: Rect, canvas: Rect) { let id = egui::Id::new("task_bar"); // Width from the previous frame (content-sized). let w = ctx.memory(|m| m.area_rect(id).map(|r| r.width())).unwrap_or(420.0); - let mut x = sel.center().x - w / 2.0; - x = x.clamp(canvas.min.x + 8.0, (canvas.max.x - w - 8.0).max(canvas.min.x + 8.0)); - let mut y = sel.max.y + 14.0; - if y + HEIGHT > canvas.max.y - 8.0 { - y = (sel.min.y - HEIGHT - 14.0).max(canvas.min.y + 8.0); - } - if y + HEIGHT > canvas.max.y - 4.0 { - y = canvas.max.y - HEIGHT - 8.0; - } - egui::Area::new(id).order(egui::Order::Middle).fixed_pos(pos2(x, y)).show(ctx, |ui| { + let at = match app.ui.task_bar_pin { + Some([x, y]) => inside(canvas.min + vec2(x, y), w, canvas), + None => { + let mut x = sel.center().x - w / 2.0; + x = x.clamp(canvas.min.x + 8.0, (canvas.max.x - w - 8.0).max(canvas.min.x + 8.0)); + let mut y = sel.max.y + 14.0; + if y + HEIGHT > canvas.max.y - 8.0 { + y = (sel.min.y - HEIGHT - 14.0).max(canvas.min.y + 8.0); + } + if y + HEIGHT > canvas.max.y - 4.0 { + y = canvas.max.y - HEIGHT - 8.0; + } + pos2(x, y) + } + }; + app.ui.task_bar_at = Some([at.x - canvas.min.x, at.y - canvas.min.y]); + egui::Area::new(id).order(egui::Order::Middle).fixed_pos(at).show(ctx, |ui| { egui::Frame::NONE .fill(t.panel) .stroke(Stroke::new(1.0, t.border)) @@ -45,8 +58,17 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context, sel: Rect, canvas: Rect) { ui.set_height(HEIGHT); ui.horizontal_centered(|ui| { ui.spacing_mut().item_spacing.x = 6.0; + // Dragging the grip moves the bar, and pins it where it is dropped. let (g, _) = ui.allocate_exact_size(vec2(12.0, 20.0), Sense::hover()); - icons::paint(ui.painter(), g, "grip", t.icon); + let grip = ui.interact(g, grip_id, Sense::drag()); + icons::paint(ui.painter(), g, "grip", if grip.hovered() || grip.dragged() { t.text_strong } else { t.icon }); + if grip.dragged() { + ui.ctx().set_cursor_icon(egui::CursorIcon::Grabbing); + let to = inside(at + grip.drag_delta(), w, canvas) - canvas.min; + let _ = app.run("window.taskBarPin", json!({"at": [to.x, to.y]})); + } else if grip.hovered() { + ui.ctx().set_cursor_icon(egui::CursorIcon::Grab); + } let text_frame = panels::sel_info(app).is_some_and(|i| i.is_text && i.count == 1); if has_text || text_frame { text_controls(app, ui, has_text); @@ -59,6 +81,13 @@ pub fn show(app: &mut DesignApp, ctx: &egui::Context, sel: Rect, canvas: Rect) { }); } +/// `at` moved as little as needed for a bar `w` wide to sit inside `canvas` (8 pt in). +fn inside(at: egui::Pos2, w: f32, canvas: Rect) -> egui::Pos2 { + let x = at.x.min(canvas.max.x - w - 8.0).max(canvas.min.x + 8.0); + let y = at.y.min(canvas.max.y - HEIGHT - 8.0).max(canvas.min.y + 8.0); + pos2(x, y) +} + fn split_button(ui: &mut Ui, icon: &str, label: &str) -> (egui::Response, egui::Response) { let t = Tokens::get(ui.ctx()); let g = crate::rtl::plain(ui.ctx(), label, egui::FontId::proportional(11.0), t.text); @@ -109,15 +138,10 @@ fn text_controls(app: &mut DesignApp, ui: &mut Ui, in_text: bool) { let sty = c["fontStyle"].as_str().unwrap_or("").to_string(); let fonts = panels::fonts(app); let menu = panels::font_menu(app); - let english = app.session.prefs.show_font_names_in_english; - let labels: Vec = menu.iter().map(|f| f.label(english).to_string()).collect(); - let cur = menu.iter().position(|f| f.family == fam); let shown = if fam.is_empty() { "—".to_string() } else { panels::font_label(app, &menu, &fam) }; - if let Some(f) = widgets::dropdown_list(ui, &shown, 150.0, &labels, cur).and_then(|k| menu.get(k)) { - let f = f.family.clone(); - let styles = fonts.styles(&f); - let style = if styles.iter().any(|s| s == "Regular") { "Regular".to_string() } else { styles.first().cloned().unwrap_or_default() }; - let _ = app.run("type.char", json!({"attrs": {"fontFamily": f, "fontStyle": style}})); + let field = widgets::dropdown(ui, &shown, 150.0); + if let Some(f) = panels::font_popup(app, &field, &menu, &fam) { + panels::apply_font_family(app, &f); } let styles = fonts.styles(&fam); let cur = styles.iter().position(|s| *s == sty); @@ -251,8 +275,14 @@ fn more(app: &mut DesignApp, ui: &mut Ui) { app.ui.task_bar = false; ui.close(); } - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Properties Panel"))).clicked() { - let _ = app.run("window.panel", json!({"panel": "properties"})); + let pinned = app.ui.task_bar_pin.is_some(); + let pin = crate::i18n::tr(&app.ui.language, "Pin Bar Position"); + if ui.button(crate::rtl::widget(ui, if pinned { format!("✓ {pin}") } else { format!(" {pin}") })).clicked() { + let _ = app.run("window.taskBarPin", json!({"on": !pinned})); + ui.close(); + } + if ui.add_enabled(pinned, egui::Button::new(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Reset Bar Position")))).clicked() { + let _ = app.run("window.taskBarReset", json!({})); ui.close(); } }); @@ -263,3 +293,111 @@ fn sep(ui: &mut Ui) { let (r, _) = ui.allocate_exact_size(vec2(1.0, 24.0), Sense::hover()); ui.painter().line_segment([r.center_top(), r.center_bottom()], Stroke::new(1.0, t.border)); } + +#[cfg(test)] +mod tests { + use egui::{pos2, vec2}; + use egui_kittest::Harness; + use egui_kittest::kittest::Queryable; + use serde_json::json; + + use crate::test_window::{self, Window, click_at}; + + /// A selected frame, with the task bar under it. + fn window() -> (Harness<'static, Window>, u64) { + let app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + let mut h = test_window::open(app, vec2(1440.0, 900.0)); + let app = &mut h.state_mut().app; + let id = app.run("frame.create", json!({"rect": [72, 72, 300, 200], "content": "text", "text": "Hello"})).unwrap()["id"].as_u64().unwrap(); + app.run("selection.set", json!({"ids": [id]})).unwrap(); + h.run_steps(4); + (h, id) + } + + fn bar(h: &Harness<'static, Window>) -> egui::Rect { + h.ctx.memory(|m| m.area_rect(egui::Id::new("task_bar"))).unwrap() + } + + fn grip(h: &Harness<'static, Window>) -> egui::Pos2 { + let b = bar(h); + pos2(b.min.x + 10.0, b.center().y) + } + + fn drag(h: &mut Harness<'static, Window>, from: egui::Pos2, by: egui::Vec2) { + h.hover_at(from); + h.drag_at(from); + h.run_steps(2); + for k in 1..=8 { + h.hover_at(from + by * (k as f32 / 8.0)); + h.run_steps(1); + } + h.drop_at(from + by); + h.run_steps(4); + } + + fn more(h: &mut Harness<'static, Window>) { + let b = bar(h); + click_at(h, pos2(b.max.x - 19.0, b.center().y)); + } + + #[test] + fn pinning_is_a_command() { + let mut app = crate::DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + let run = |app: &mut crate::DesignApp, id: &str, p| crate::menus::run_ui(app, id, &p).unwrap(); + assert!(run(&mut app, "window.taskBarPin", json!({})).is_err(), "nowhere to pin a bar that isn't showing"); + assert!(run(&mut app, "window.taskBarPin", json!({"at": [1, "x"]})).is_err()); + run(&mut app, "window.taskBarPin", json!({"at": [40, 60]})).unwrap(); + assert_eq!(app.ui.task_bar_pin, Some([40.0, 60.0])); + assert_eq!(crate::menus::checked(&app, "window.taskBarPin", &json!({})), Some(true)); + run(&mut app, "window.taskBarPin", json!({})).unwrap(); + assert_eq!(app.ui.task_bar_pin, None, "toggled off"); + app.ui.task_bar_at = Some([10.0, 20.0]); + run(&mut app, "window.taskBarPin", json!({"on": true})).unwrap(); + assert_eq!(app.ui.task_bar_pin, Some([10.0, 20.0]), "pinned where it shows"); + run(&mut app, "window.taskBarReset", json!({})).unwrap(); + assert_eq!(app.ui.task_bar_pin, None); + // A workspace keeps the bar's place. + app.ui.task_bar_pin = Some([5.0, 6.0]); + run(&mut app, "window.newWorkspace", json!({"name": "Mine"})).unwrap(); + app.ui.task_bar_pin = None; + run(&mut app, "window.workspace", json!({"name": "Mine"})).unwrap(); + assert_eq!(app.ui.task_bar_pin, Some([5.0, 6.0])); + } + + #[test] + fn the_grip_moves_the_bar_and_it_stays_there() { + let (mut h, id) = window(); + let before = bar(&h); + let g = grip(&h); + drag(&mut h, g, vec2(200.0, 150.0)); + let after = bar(&h); + assert!((after.min - before.min - vec2(200.0, 150.0)).length() < 2.0, "dragged from {before:?} to {after:?}"); + assert!(h.state().app.ui.task_bar_pin.is_some(), "pinned where it was dropped"); + // A moved selection leaves it where it is. + h.state_mut().app.run("transform.set", json!({"ids": [id], "y": 300})).unwrap(); + h.run_steps(4); + assert_eq!(bar(&h).min, after.min); + // Dragged past the canvas, it stays inside. + let canvas = h.state().app.canvas_rect.unwrap(); + let g = grip(&h); + drag(&mut h, g, vec2(5000.0, 5000.0)); + assert!(canvas.contains_rect(bar(&h)), "{:?} outside the canvas {canvas:?}", bar(&h)); + // Reset Bar Position: back under the selection. + more(&mut h); + let at = h.get_by_label("Reset Bar Position").rect().center(); + click_at(&mut h, at); + assert_eq!(h.state().app.ui.task_bar_pin, None); + // Pin Bar Position keeps it where it is. + more(&mut h); + let at = h.get_by_label_contains("Pin Bar Position").rect().center(); + click_at(&mut h, at); + let at = bar(&h).min - canvas.min; + let [x, y] = h.state().app.ui.task_bar_pin.unwrap(); + assert!((vec2(x, y) - at).length() < 1.0, "pinned at {x}, {y}; showing at {at:?}"); + // Hide Bar. + more(&mut h); + let at = h.get_by_label("Hide Bar").rect().center(); + click_at(&mut h, at); + assert!(!h.state().app.ui.task_bar); + } +}