diff --git a/Cargo.lock b/Cargo.lock index 413011ab..555ed9af 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -942,12 +942,60 @@ dependencies = [ [[package]] name = "craft-docpath" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=495e5971bc024aa96e6ed318cb6e7cd3a0103184#495e5971bc024aa96e6ed318cb6e7cd3a0103184" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" + +[[package]] +name = "craft-fonts-build" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" + +[[package]] +name = "craft-i18n" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +dependencies = [ + "fixed_decimal", + "icu_decimal", + "icu_locale_core", + "icu_locale_fallback", + "icu_normalizer", + "icu_plurals", + "icu_properties", + "icu_provider", + "icu_provider_adapters", + "icu_provider_blob", + "log", + "objc2-foundation 0.3.2", + "sys-locale", + "unicode-bidi", + "windows-registry", + "writeable", +] + +[[package]] +name = "craft-ui" +version = "0.1.0" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" +dependencies = [ + "craft-fonts-build", + "craft-i18n", + "egui", + "epaint", + "js-sys", + "log", + "objc2 0.6.5", + "objc2-app-kit 0.3.2", + "objc2-foundation 0.3.2", + "skrifa 0.44.0", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] [[package]] name = "craft-update" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=495e5971bc024aa96e6ed318cb6e7cd3a0103184#495e5971bc024aa96e6ed318cb6e7cd3a0103184" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" dependencies = [ "chrono", "dunce", @@ -972,7 +1020,7 @@ dependencies = [ [[package]] name = "craft-window" version = "0.1.0" -source = "git+https://github.com/storytold/craft-libs?rev=495e5971bc024aa96e6ed318cb6e7cd3a0103184#495e5971bc024aa96e6ed318cb6e7cd3a0103184" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" dependencies = [ "wayland-client", ] @@ -1844,6 +1892,7 @@ version = "0.11.2" dependencies = [ "base64 0.22.1", "chrono", + "craft-ui", "craft-update", "egui", "egui_extras", @@ -1913,6 +1962,17 @@ version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" +[[package]] +name = "fixed_decimal" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2214fb00f669ee96bee5bb29dfb1ddabe058b2bc426817efc79afa03c9bc55ae" +dependencies = [ + "displaydoc", + "smallvec", + "writeable", +] + [[package]] name = "fixedbitset" version = "0.5.7" @@ -2399,9 +2459,9 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.4.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe090a42a0d3140fed8c63263ed1fecc02422cb2f4bfe55cd8de2de1b8e105dd" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" dependencies = [ "displaydoc", "potential_utf", @@ -2412,43 +2472,110 @@ dependencies = [ ] [[package]] -name = "icu_locale_core" -version = "2.4.0" +name = "icu_decimal" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25d29b57c17c6528d0dc4c2141cfa2d08200465acb537a3909c8b9b8a4a984c0" +checksum = "9eb8f655bba2d0c0459e43a5b0e6cd3cd388109898fb3d85d048165e8b0abd08" +dependencies = [ + "displaydoc", + "fixed_decimal", + "icu_locale_core", + "icu_pattern", + "icu_plurals", + "icu_provider", + "serde", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" dependencies = [ "displaydoc", "litemap", + "serde", "tinystr", "writeable", "zerovec", ] [[package]] -name = "icu_normalizer" -version = "2.4.0" +name = "icu_locale_fallback" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "404a3b8c486f205c87fe7f23ab6894d84a4ad76d6e4671ae1981ba7def87c599" +checksum = "251af8e57c9400e3eb58242fe5b8b1152b2a64fdf4cf632f923c38ccee6f2fa9" +dependencies = [ + "icu_locale_core", + "icu_locale_fallback_data", + "icu_provider", + "potential_utf", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locale_fallback_data" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "decf2a22ec8fa68f1a0c1129a3f8583f8f8bc24e8b9ccbe98ead99f62a4dc3a8" + +[[package]] +name = "icu_normalizer" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" dependencies = [ "icu_collections", "icu_normalizer_data", "icu_properties", "icu_provider", "smallvec", + "utf16_iter", + "utf8_iter", + "write16", "zerovec", ] [[package]] name = "icu_normalizer_data" -version = "2.4.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20f0088bfb7441f7a37fa0c9e20cb1e9cba1cac6b9ce8cd2a6dc44294f8e8cbc" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" + +[[package]] +name = "icu_pattern" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc43ce0e76ee8cb0482dc6e2dbb9b76278e8038f45daabf2a2877577e2ceb18f" +dependencies = [ + "displaydoc", + "either", + "serde", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_plurals" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e475e6766ef87b1d3c1f97be6363995b0bfaeddbc789671615df598a97ff0593" +dependencies = [ + "displaydoc", + "fixed_decimal", + "icu_provider", + "serde", + "zerovec", +] [[package]] name = "icu_properties" -version = "2.4.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ea83d991c56158f7cf898bf86436c1dfe7c07ee82fe88951f2695c9f37a379f" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" dependencies = [ "displaydoc", "icu_collections", @@ -2461,18 +2588,21 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.4.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b98f29af82d02b163fe50161388350e743bb3107cc8e8c2f7314cb505faddfcf" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" [[package]] name = "icu_provider" -version = "2.4.0" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "413099315636c588ee8f011190b329b971a09a8fae9d157e01dcae8379468514" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" dependencies = [ "displaydoc", "icu_locale_core", + "postcard", + "serde", + "stable_deref_trait", "writeable", "yoke", "zerofrom", @@ -2480,6 +2610,30 @@ dependencies = [ "zerovec", ] +[[package]] +name = "icu_provider_adapters" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df887bc4f55dbc773c7c45a0c7dfa75be886f7b7812830a71ab7271cc1801820" +dependencies = [ + "icu_locale_fallback", + "icu_provider", +] + +[[package]] +name = "icu_provider_blob" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5916b44fac319d3f58e4d8d805a627df19e27937b582cd99fcde3bb6b7ce1b0e" +dependencies = [ + "icu_provider", + "postcard", + "serde", + "writeable", + "zerotrie", + "zerovec", +] + [[package]] name = "idna" version = "1.1.0" @@ -2731,7 +2885,7 @@ checksum = "e2db585e1d738fc771bf08a151420d3ed193d9d895a36df7f6f8a9456b911ddc" [[package]] name = "krilla" version = "0.8.2" -source = "git+https://github.com/storytold/craft-libs?rev=495e5971bc024aa96e6ed318cb6e7cd3a0103184#495e5971bc024aa96e6ed318cb6e7cd3a0103184" +source = "git+https://github.com/storytold/craft-libs?rev=92d392cff132eaf53e13afff77f38b5c11b73aa0#92d392cff132eaf53e13afff77f38b5c11b73aa0" dependencies = [ "base64 0.22.1", "bumpalo", @@ -2844,6 +2998,9 @@ name = "litemap" version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" +dependencies = [ + "serde_core", +] [[package]] name = "litrs" @@ -3684,6 +3841,8 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" dependencies = [ + "serde_core", + "writeable", "zerovec", ] @@ -4681,6 +4840,18 @@ dependencies = [ "syn 3.0.9", ] +[[package]] +name = "sys-locale" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eab9a99a024a169fe8a903cf9d4a3b3601109bcc13bd9e3c6fff259138626c4" +dependencies = [ + "js-sys", + "libc", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "tar" version = "0.4.46" @@ -4855,6 +5026,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a" dependencies = [ "displaydoc", + "serde_core", "zerovec", ] @@ -5165,6 +5337,12 @@ dependencies = [ "xmlwriter", ] +[[package]] +name = "utf16_iter" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" + [[package]] name = "utf8-zero" version = "0.8.1" @@ -6152,11 +6330,20 @@ dependencies = [ "read-fonts 0.39.2", ] +[[package]] +name = "write16" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" + [[package]] name = "writeable" version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" +dependencies = [ + "either", +] [[package]] name = "x11-dl" @@ -6424,8 +6611,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ "displaydoc", + "litemap", + "serde_core", "yoke", "zerofrom", + "zerovec", ] [[package]] @@ -6434,6 +6624,7 @@ version = "0.11.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40" dependencies = [ + "serde", "yoke", "zerofrom", "zerovec-derive", diff --git a/Cargo.toml b/Cargo.toml index f6fca285..fd2fbb81 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -35,13 +35,15 @@ figcraft-plugin = { path = "crates/plugin" } # craft-libs crates all pin one rev (craftrules standards/shared-libraries.md): bump them together. # Which paths stored in a document may be followed (craft-libs). -craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184" } +craft-docpath = { version = "0.1.0", git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } # Who draws the window frame on Linux — the compositor or the app — so every Craft app shows the # same native titlebar per desktop (craft-libs). FigCraft only reads the probe result and maps it # to its own `TitleBar`, so it does not enable craft-window's optional `egui` helpers (which would # pull a second egui family version). -craft-window = { git = "https://github.com/storytold/craft-libs", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184" } -craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184", features = ["egui"] } +craft-window = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } +craft-update = { git = "https://github.com/storytold/craft-libs", version = "=0.1.0", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0", features = ["egui"] } +# Shared dropdown menus: a combo box whose menu grows back after shrinking (craft-libs). +craft-ui = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } # foundation kurbo = { version = "0.13", features = ["serde"] } @@ -160,4 +162,4 @@ winit = { git = "https://github.com/storytold/winit.git", rev = "a517b3846ee8457 # craft-libs' vendored krilla 0.8.2: decoded PNGs are capped at 1 GiB and reserved fallibly, and a # panic while drawing unwinds without aborting. -krilla = { git = "https://github.com/storytold/craft-libs", rev = "495e5971bc024aa96e6ed318cb6e7cd3a0103184" } +krilla = { git = "https://github.com/storytold/craft-libs", rev = "92d392cff132eaf53e13afff77f38b5c11b73aa0" } diff --git a/clippy.toml b/clippy.toml index 724e05c5..36cc87a4 100644 --- a/clippy.toml +++ b/clippy.toml @@ -4,3 +4,6 @@ allow-unwrap-in-tests = true allow-expect-in-tests = true allow-panic-in-tests = true too-many-arguments-threshold = 10 +disallowed-types = [ + { path = "egui::ComboBox", reason = "use craft_ui::ComboBox: egui's combo box menu can't grow back while open (egui#5225)" }, +] diff --git a/crates/ui-egui/Cargo.toml b/crates/ui-egui/Cargo.toml index 04f09a37..78447d7b 100644 --- a/crates/ui-egui/Cargo.toml +++ b/crates/ui-egui/Cargo.toml @@ -9,6 +9,7 @@ description = "FigCraft egui frontend (the Figma-style UI: canvas, toolbar, laye workspace = true [dependencies] +craft-ui = { workspace = true } figcraft-geom = { workspace = true } figcraft-color = { workspace = true } figcraft-doc = { workspace = true } diff --git a/crates/ui-egui/src/dialogs/credits.rs b/crates/ui-egui/src/dialogs/credits.rs index 90df5ee3..957c86b2 100644 --- a/crates/ui-egui/src/dialogs/credits.rs +++ b/crates/ui-egui/src/dialogs/credits.rs @@ -267,7 +267,7 @@ fn contributors_in(ui: &mut egui::Ui, contributors: &[Contributor], total_commit } ui.separator(); ui.label(RichText::new(tr("Sort")).color(t.text_secondary)); - egui::ComboBox::from_id_salt("credits_sort").selected_text(v.key.label().0).show_ui(ui, |ui| { + craft_ui::ComboBox::from_id_salt("credits_sort").selected_text(v.key.label().0).show_ui(ui, |ui| { for k in SortKey::ALL { if ui.selectable_label(v.key == k, k.label().0).clicked() { v.key = k; diff --git a/crates/ui-egui/src/font_menu_regrow_tests.rs b/crates/ui-egui/src/font_menu_regrow_tests.rs new file mode 100644 index 00000000..b4a9d880 --- /dev/null +++ b/crates/ui-egui/src/font_menu_regrow_tests.rs @@ -0,0 +1,122 @@ +//! The font family menu follows its list's height while it is open: filtering to nothing shrinks +//! it, clearing the filter grows it back, and the search field stays where it is (egui#5225). +use egui::accesskit::{Node, Role}; +use egui::{Event, Key, Modifiers, Pos2, RawInput, Rect, vec2}; +use figcraft_engine::Session; +use serde_json::json; + +use crate::{FigApp, NoServices}; + +struct Panel { + app: FigApp, + ctx: egui::Context, + time: f64, +} + +impl Panel { + fn new() -> Self { + let mut s = Session::new(); + let text = s.execute("text.create", &json!({"text": "Typography"})).unwrap()["id"].as_u64().unwrap(); + s.execute("select.set", &json!({"ids": [text]})).unwrap(); + let mut p = Self { app: FigApp::new(s, Box::new(NoServices)), ctx: egui::Context::default(), time: 0.0 }; + crate::theme::install_fonts(&p.ctx, false, 0); + crate::theme::apply(&p.ctx, crate::theme::Tokens::light()); + p.ctx.enable_accesskit(); + for _ in 0..3 { + p.frame(vec![]); + } + p + } + + fn frame(&mut self, events: Vec) -> Vec { + self.time += 1.0 / 60.0; + let input = + RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(280.0, 800.0))), time: Some(self.time), events, ..Default::default() }; + let mut out = self.ctx.run_ui(input, |ui| { + let ds = self.app.session.ds().unwrap(); + let doc = ds.doc.clone(); + let sel = ds.selection.clone(); + crate::typography::typography(&mut self.app, ui, &doc, &sel); + }); + out.textures_delta.clear(); + out.platform_output.accesskit_update.map(|u| u.nodes.into_iter().map(|(_, n)| n).collect()).unwrap_or_default() + } + + /// Settled frames; returns the last frame's accessibility tree. + fn settle(&mut self, events: Vec) -> Vec { + let mut nodes = self.frame(events); + for _ in 0..4 { + nodes = self.frame(vec![]); + } + nodes + } + + fn click(&mut self, at: Pos2) { + self.frame(vec![Event::PointerMoved(at)]); + let button = |pressed| Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed, modifiers: Modifiers::NONE }; + self.frame(vec![button(true)]); + self.settle(vec![button(false)]); + } + + /// The popup's rectangle: the one foreground area that wasn't there before the menu opened. + fn menu_rect(&self, before: &[egui::LayerId]) -> Rect { + let layers: Vec = self.ctx.memory(|m| m.layer_ids().collect()); + layers + .into_iter() + .filter(|l| l.order == egui::Order::Foreground && !before.contains(l)) + .filter_map(|l| self.ctx.memory(|m| m.area_rect(l.id))) + .max_by(|a, b| a.height().total_cmp(&b.height())) + .expect("the font menu is open") + } +} + +fn centre(n: &Node) -> Pos2 { + let b = n.bounds().unwrap(); + Pos2::new(((b.x0 + b.x1) / 2.0) as f32, ((b.y0 + b.y1) / 2.0) as f32) +} + +/// The top of the search field: the text input as wide as `menu` (the panel's own fields are narrower). +fn search_top(nodes: &[Node], menu: Rect) -> f32 { + let field = nodes + .iter() + .filter(|n| n.role() == Role::TextInput) + .filter_map(|n| n.bounds()) + .find(|b| menu.contains(Pos2::new(b.x0 as f32 + 1.0, b.y0 as f32 + 1.0)) && b.x1 - b.x0 > f64::from(menu.width()) - 40.0) + .expect("the menu has a search field"); + field.y0 as f32 +} + +#[test] +fn the_font_menu_regrows_after_a_search_matches_nothing() { + figcraft_text::scan_system_fonts(); + if figcraft_text::family_names().len() < 3 { + eprintln!("fewer than three font families; skipping"); + return; + } + let mut p = Panel::new(); + let nodes = p.settle(vec![]); + let family = + nodes.iter().find(|n| n.role() == Role::ComboBox && n.value() == Some("Inter")).expect("the Typography section has a font family field"); + let before: Vec = p.ctx.memory(|m| m.layer_ids().collect()); + p.click(centre(family)); + let nodes = p.settle(vec![]); + let open = p.menu_rect(&before); + let top = search_top(&nodes, open); + assert!(open.height() > 100.0, "the menu lists fonts: {open:?}"); + assert!(open.width() >= 240.0, "the menu keeps the 240 minimum width under a narrower field: {open:?}"); + + let type_text = |p: &mut Panel, s: &str| p.settle(vec![Event::Text(s.into())]); + let nodes = type_text(&mut p, "zzzzqqqq"); + let none = p.menu_rect(&before); + assert!(none.height() < open.height() - 20.0, "no match shrinks the menu: {open:?} → {none:?}"); + assert_eq!(search_top(&nodes, none), top, "the search field stays put while the list is empty"); + + let mut nodes = nodes; + for _ in 0.."zzzzqqqq".len() { + let key = Event::Key { key: Key::Backspace, physical_key: None, pressed: true, repeat: false, modifiers: Modifiers::NONE }; + nodes = p.settle(vec![key]); + } + let again = p.menu_rect(&before); + assert!((again.height() - open.height()).abs() < 1.0, "clearing the search grows the menu back: {open:?} → {again:?}"); + assert_eq!(search_top(&nodes, again), top, "the search field stays put when the list grows back"); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 1eb714e9..4c1ed337 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -78,6 +78,8 @@ mod export_preview_tests; #[cfg(test)] mod field_tests; #[cfg(test)] +mod font_menu_regrow_tests; +#[cfg(test)] mod frame_label_tests; #[cfg(test)] mod gap_handle_tests; diff --git a/crates/ui-egui/src/panels/typography/fields.rs b/crates/ui-egui/src/panels/typography/fields.rs index fb5d7ad8..86fae14e 100644 --- a/crates/ui-egui/src/panels/typography/fields.rs +++ b/crates/ui-egui/src/panels/typography/fields.rs @@ -22,21 +22,17 @@ pub(super) fn font_rows( row(ui, 32.0, |ui| { let w = ui.available_width(); let resp = font_dropdown(app, ui, ids, "fontFamily", family.as_deref(), w, editing); - menu_popup(&resp).width(w.max(240.0)).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { - if !editing && apply_variable_item(ui, ids, "fontFamily", &resp) { - return; - } - let pick = font_picker(ui, family.as_deref(), resp.clicked()); + let before_search = |ui: &mut Ui| !editing && apply_variable_item(ui, ids, "fontFamily", &resp); + if let Some(pick) = font_picker(ui, &resp, family.as_deref(), w.max(240.0), before_search) { if let Some(f) = pick.picked { remember_family(ui.ctx(), &f); apply_style(app, "fontFamily", json!(f)); - ui.close(); } else if let Some(h) = pick.hovered.or(pick.highlighted) { // The canvas shows the hovered family (or the one the arrow keys are on); // closing without picking ends the preview. app.preview_text(ui.ctx(), "text.style", text_style_params(ids, range, json!({"fontFamily": h}))); } - }); + } }); // Style + size. diff --git a/crates/ui-egui/src/panels/typography/glyphs.rs b/crates/ui-egui/src/panels/typography/glyphs.rs index 13e0f6ef..3b71b7c4 100644 --- a/crates/ui-egui/src/panels/typography/glyphs.rs +++ b/crates/ui-egui/src/panels/typography/glyphs.rs @@ -191,12 +191,33 @@ mod tests { assert_eq!(parse_letter_spacing("x", Some(LetterSpacing::Percent(0.0))), None); } - /// One frame of the font picker with `events`. + /// One frame of the font picker under a field, with `events`. `opened` clicks the field first + /// (a press frame, then the release frame this returns). fn pick(ctx: &egui::Context, current: &str, opened: bool, events: Vec) -> FontPick { - let mut out = FontPick::default(); - let mut shown = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| out = font_picker(ui, Some(current), opened)); - shown.textures_delta.clear(); - out + let field = egui::Rect::from_min_size(egui::pos2(10.0, 10.0), egui::vec2(200.0, 24.0)); + let frame = |events: Vec| { + let mut out = FontPick::default(); + let mut shown = ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| { + let resp = ui.interact(field, egui::Id::unique("font_field"), egui::Sense::click()); + out = font_picker(ui, &resp, Some(current), 240.0, |_| false).unwrap_or_default(); + }); + shown.textures_delta.clear(); + out + }; + if opened { + let button = |pressed| egui::Event::PointerButton { + pos: field.center(), + button: egui::PointerButton::Primary, + pressed, + modifiers: egui::Modifiers::NONE, + }; + // The field is laid out once before the pointer can hit it. + frame(vec![]); + frame(vec![egui::Event::PointerMoved(field.center())]); + frame(vec![button(true)]); + return frame(vec![button(false)]); + } + frame(events) } fn key(key: egui::Key) -> egui::Event { diff --git a/crates/ui-egui/src/panels/typography/picker.rs b/crates/ui-egui/src/panels/typography/picker.rs index 824fc43e..c7d8bea2 100644 --- a/crates/ui-egui/src/panels/typography/picker.rs +++ b/crates/ui-egui/src/panels/typography/picker.rs @@ -41,149 +41,199 @@ pub(crate) struct FontPick { pub hovered: Option, } -/// The searchable family list: click a family, or step through it with Up and Down and press Enter. -/// Hovering a family previews it on the canvas (closing without picking restores the original). -pub(crate) fn font_picker(ui: &mut Ui, current: Option<&str>, just_opened: bool) -> FontPick { +/// The most the font menu is tall (its search field and the "Apply variable" row above the list +/// included); the list scrolls past that. +const MENU_HEIGHT: f32 = LIST_HEIGHT + 40.0; + +/// What the menu's header worked out for its list. +#[derive(Default)] +struct Header { + rows: Vec, + /// The row Up and Down are on. + hi: Option, + /// The rows Up and Down moved this frame (the list scrolls to the row they are on). + step: i64, + /// Enter picked this family. + entered: Option, +} + +/// The searchable family menu under `anchor`: click a family, or step through it with Up and Down +/// and press Enter. Hovering a family previews it on the canvas (closing without picking restores +/// the original). `before_search` draws the rows above the search field and returns true when one +/// of them handled the menu (it closed it). None while the menu is closed. +/// +/// The search field and those rows stay in place; only the list scrolls. Headers ("Recent", +/// "All fonts") are as tall as family rows, so the list is uniform and virtualised. +pub(crate) fn font_picker( + ui: &Ui, + anchor: &egui::Response, + current: Option<&str>, + width: f32, + before_search: impl FnOnce(&mut Ui) -> bool, +) -> Option { let t = Tokens::get(ui.ctx()); + let just_opened = anchor.clicked(); + let hi_id = Id::unique("font_highlight"); let q_id = Id::unique("font_query"); - // The row Up and Down are on, and the search it is a row of. - let hi_id = Id::unique("font_highlight"); - let mut q: String = ui.data(|d| d.get_temp(q_id)).unwrap_or_default(); - if just_opened { - q.clear(); - ui.data_mut(|d| d.remove::<(String, usize)>(hi_id)); - } - // Taken before the search field sees them (it would move its cursor). - let step = ui.input_mut(|i| { - i.count_and_consume_key(egui::Modifiers::NONE, egui::Key::ArrowDown) as i64 - - i.count_and_consume_key(egui::Modifiers::NONE, egui::Key::ArrowUp) as i64 - }); - let search = ui.add(egui::TextEdit::singleline(&mut q).hint_text(tr("Search fonts")).desired_width(ui.available_width()).font(regular(11.5))); - crate::widgets::select_all_on_focus(ui, &search, &q); - if just_opened { - search.request_focus(); - } - ui.data_mut(|d| d.insert_temp(q_id, q.clone())); - let ql = q.trim().to_lowercase(); - let all = figcraft_text::family_names(); - let matches: Vec = all.iter().filter(|f| ql.is_empty() || f.to_lowercase().contains(&ql)).cloned().collect(); - let mut rows: Vec = Vec::new(); - if ql.is_empty() { - let recent: Vec = recent_families(ui.ctx()).into_iter().filter(|f| all.iter().any(|a| a.eq_ignore_ascii_case(f))).collect(); - if !recent.is_empty() { - rows.push(PickRow::Header(tr("Recent"))); - rows.extend(recent.into_iter().map(PickRow::Family)); - rows.push(PickRow::Header(tr("All fonts"))); - } - } - if matches.is_empty() { - widgets::label(ui, tr("No fonts match"), t.text_tertiary); - return FontPick::default(); - } - rows.extend(matches.into_iter().map(PickRow::Family)); - let family = |i: usize| match rows.get(i) { - Some(PickRow::Family(f)) => Some(f.clone()), - _ => None, - }; - // A new search starts again; the first step starts from the current family. - let mut hi = ui.data(|d| d.get_temp::<(String, usize)>(hi_id)).filter(|(hq, _)| *hq == q).map(|(_, i)| i); - let stride = ROW + ui.spacing().item_spacing.y; - let mut offset = None; - if step != 0 { - let next = |i: usize| if step > 0 { i.checked_add(1) } else { i.checked_sub(1) }; - let mut from = - hi.or_else(|| rows.iter().rposition(|r| matches!(r, PickRow::Family(f) if current.is_some_and(|c| c.eq_ignore_ascii_case(f))))); - for _ in 0..step.unsigned_abs() { - let mut at = match from { - Some(i) => next(i), - None if step > 0 => Some(0), - None => rows.len().checked_sub(1), - }; - // Past the headers; at either end it stays where it was. - while let Some(i) = at.filter(|i| *i < rows.len() && family(*i).is_none()) { - at = next(i); - } - if let Some(i) = at.filter(|i| family(*i).is_some()) { - from = Some(i); - hi = Some(i); - } - } - if let Some(i) = hi { - // Scrolled just enough to show it (the list is shorter than its tallest where the - // window has less room). - let (seen, height): (f32, f32) = ui.data(|d| d.get_temp(hi_id.with("view"))).unwrap_or((0.0, LIST_HEIGHT)); - let top = i as f32 * stride; - offset = Some(seen.max(top + stride - height).min(top)); - } - } - match hi { - Some(i) => ui.data_mut(|d| { - d.insert_temp(hi_id, (q.clone(), i)); - }), - None => ui.data_mut(|d| d.remove::<(String, usize)>(hi_id)), - } - if search.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { - let first = rows.iter().find_map(|r| match r { - PickRow::Family(f) => Some(f.clone()), - PickRow::Header(_) => None, - }); - return FontPick { picked: hi.and_then(family).or(first), highlighted: None, hovered: None }; - } - let mut budget = 4; - let mut picked = None; - let mut hovered_family = None; - let mut area = egui::ScrollArea::vertical().max_height(LIST_HEIGHT).auto_shrink([false, true]); - if let Some(y) = offset { - area = area.vertical_scroll_offset(y); - } - let shown = area.show_rows(ui, ROW, rows.len(), |ui, range| { - for (i, r) in rows.iter().enumerate().skip(range.start).take(range.len()) { - let w = ui.available_width(); - let (rect, resp) = ui.allocate_exact_size(vec2(w, ROW), if matches!(r, PickRow::Family(_)) { Sense::click() } else { Sense::hover() }); - match r { - PickRow::Header(h) => { - ui.painter().text(pos2(rect.min.x + 8.0, rect.center().y), Align2::LEFT_CENTER, *h, semibold(10.5), t.text_secondary); + craft_ui::combo::Menu::new(ui, anchor) + .height(MENU_HEIGHT) + .close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside) + .style(crate::right::menu_style) + .show_rows_with_header( + |ui| { + // The menu is at least `width` wide, even under a narrower field. + ui.set_min_width(width); + if before_search(ui) { + return Header::default(); + } + let mut q: String = ui.data(|d| d.get_temp(q_id)).unwrap_or_default(); + if just_opened { + q.clear(); + ui.data_mut(|d| d.remove::<(String, usize)>(hi_id)); + } + // Taken before the search field sees them (it would move its cursor). + let step = ui.input_mut(|i| { + i.count_and_consume_key(egui::Modifiers::NONE, egui::Key::ArrowDown) as i64 + - i.count_and_consume_key(egui::Modifiers::NONE, egui::Key::ArrowUp) as i64 + }); + let search = + ui.add(egui::TextEdit::singleline(&mut q).hint_text(tr("Search fonts")).desired_width(ui.available_width()).font(regular(11.5))); + crate::widgets::select_all_on_focus(ui, &search, &q); + if just_opened { + search.request_focus(); } - PickRow::Family(f) => { - let hovered = resp.hovered() || hi == Some(i); - if hovered { - ui.painter().rect_filled(rect, CornerRadius::same(5), t.accent); + ui.data_mut(|d| d.insert_temp(q_id, q.clone())); + let ql = q.trim().to_lowercase(); + let all = figcraft_text::family_names(); + let matches: Vec = all.iter().filter(|f| ql.is_empty() || f.to_lowercase().contains(&ql)).cloned().collect(); + let mut rows: Vec = Vec::new(); + if ql.is_empty() { + let recent: Vec = + recent_families(ui.ctx()).into_iter().filter(|f| all.iter().any(|a| a.eq_ignore_ascii_case(f))).collect(); + if !recent.is_empty() { + rows.push(PickRow::Header(tr("Recent"))); + rows.extend(recent.into_iter().map(PickRow::Family)); + rows.push(PickRow::Header(tr("All fonts"))); } - let col = if hovered { t.accent_on } else { t.text }; - if current.is_some_and(|c| c.eq_ignore_ascii_case(f)) { - icons::paint( - ui.painter(), - Rect::from_center_size(pos2(rect.min.x + 10.0, rect.center().y), vec2(12.0, 12.0)), - Icon::Check, - col, - ); + } + if matches.is_empty() { + widgets::label(ui, tr("No fonts match"), t.text_tertiary); + return Header::default(); + } + rows.extend(matches.into_iter().map(PickRow::Family)); + let family = |i: usize| match rows.get(i) { + Some(PickRow::Family(f)) => Some(f.clone()), + _ => None, + }; + // A new search starts again; the first step starts from the current family. + let mut hi = ui.data(|d| d.get_temp::<(String, usize)>(hi_id)).filter(|(hq, _)| *hq == q).map(|(_, i)| i); + if step != 0 { + let next = |i: usize| if step > 0 { i.checked_add(1) } else { i.checked_sub(1) }; + let mut from = hi + .or_else(|| rows.iter().rposition(|r| matches!(r, PickRow::Family(f) if current.is_some_and(|c| c.eq_ignore_ascii_case(f))))); + for _ in 0..step.unsigned_abs() { + let mut at = match from { + Some(i) => next(i), + None if step > 0 => Some(0), + None => rows.len().checked_sub(1), + }; + // Past the headers; at either end it stays where it was. + while let Some(i) = at.filter(|i| *i < rows.len() && family(*i).is_none()) { + at = next(i); + } + if let Some(i) = at.filter(|i| family(*i).is_some()) { + from = Some(i); + hi = Some(i); + } } - let text_min = pos2(rect.min.x + 22.0, rect.min.y); - match crate::font_preview::family(ui.ctx(), f, 13.0, &mut budget) { - Some(tex) => { - crate::font_preview::paint( - ui.painter(), - &tex, - pos2(text_min.x, rect.center().y), - rect.max.x - text_min.x - 6.0, - ROW, - col, - ); + } + match hi { + Some(i) => ui.data_mut(|d| { + d.insert_temp(hi_id, (q.clone(), i)); + }), + None => ui.data_mut(|d| d.remove::<(String, usize)>(hi_id)), + } + let mut entered = None; + if search.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) { + let first = rows.iter().find_map(|r| match r { + PickRow::Family(f) => Some(f.clone()), + PickRow::Header(_) => None, + }); + entered = hi.and_then(family).or(first); + ui.close(); + } + Header { rows, hi, step, entered } + }, + ROW, + |h| h.rows.len(), + |ui, range, h| { + if let Some(f) = h.entered { + return FontPick { picked: Some(f), highlighted: None, hovered: None }; + } + if let (Some(i), true) = (h.hi, h.step != 0) { + // Scrolled just enough to show the row Up or Down moved to; it may be out of + // the range laid out, so its rect comes from the list's top. + let stride = ROW + ui.spacing().item_spacing.y; + let top = ui.max_rect().top() - range.start as f32 * stride + i as f32 * stride; + ui.scroll_to_rect(Rect::from_min_size(pos2(ui.max_rect().left(), top), vec2(ui.max_rect().width(), ROW)), None); + } + let mut budget = 4; + let mut picked = None; + let mut hovered_family = None; + let hi = h.hi; + for (i, r) in h.rows.iter().enumerate().skip(range.start).take(range.len()) { + let w = ui.available_width(); + let (rect, resp) = + ui.allocate_exact_size(vec2(w, ROW), if matches!(r, PickRow::Family(_)) { Sense::click() } else { Sense::hover() }); + match r { + PickRow::Header(label) => { + ui.painter().text(pos2(rect.min.x + 8.0, rect.center().y), Align2::LEFT_CENTER, *label, semibold(10.5), t.text_secondary); } - None => { - ui.painter().text(pos2(text_min.x, rect.center().y), Align2::LEFT_CENTER, f, regular(12.0), col); + PickRow::Family(f) => { + let hovered = resp.hovered() || hi == Some(i); + if hovered { + ui.painter().rect_filled(rect, CornerRadius::same(5), t.accent); + } + let col = if hovered { t.accent_on } else { t.text }; + if current.is_some_and(|c| c.eq_ignore_ascii_case(f)) { + icons::paint( + ui.painter(), + Rect::from_center_size(pos2(rect.min.x + 10.0, rect.center().y), vec2(12.0, 12.0)), + Icon::Check, + col, + ); + } + let text_min = pos2(rect.min.x + 22.0, rect.min.y); + match crate::font_preview::family(ui.ctx(), f, 13.0, &mut budget) { + Some(tex) => { + crate::font_preview::paint( + ui.painter(), + &tex, + pos2(text_min.x, rect.center().y), + rect.max.x - text_min.x - 6.0, + ROW, + col, + ); + } + None => { + ui.painter().text(pos2(text_min.x, rect.center().y), Align2::LEFT_CENTER, f, regular(12.0), col); + } + } + if resp.clicked() { + picked = Some(f.clone()); + ui.close(); + } + if resp.hovered() { + hovered_family = Some(f.clone()); + } } - } - if resp.clicked() { - picked = Some(f.clone()); - } - if resp.hovered() { - hovered_family = Some(f.clone()); } } - } - } - }); - ui.data_mut(|d| d.insert_temp(hi_id.with("view"), (shown.state.clamped_offset().y, shown.inner_rect.height()))); - FontPick { highlighted: hi.and_then(family), picked, hovered: hovered_family } + let highlighted = hi.and_then(|i| match h.rows.get(i) { + Some(PickRow::Family(f)) => Some(f.clone()), + _ => None, + }); + FontPick { highlighted, picked, hovered: hovered_family } + }, + ) } diff --git a/crates/ui-egui/src/widgets/style_picker.rs b/crates/ui-egui/src/widgets/style_picker.rs index f97dfeb9..04e29397 100644 --- a/crates/ui-egui/src/widgets/style_picker.rs +++ b/crates/ui-egui/src/widgets/style_picker.rs @@ -857,25 +857,26 @@ fn text_property_fields(ui: &mut Ui, doc: &Document, style: &Style, def: &TextSt /// The font menu: Apply variable, then the installed fonts. Picking a font keeps its style when the /// family has one, else falls back to Regular or the first style. -fn font_family_menu(id: &str, def: &TextStyle, family_resp: &Response, out: &mut Frame) { - crate::right::menu_popup(family_resp).width(260.0).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| { - if crate::right::menu_item(ui, tr("Apply variable"), "") { +fn font_family_menu(ui: &Ui, id: &str, def: &TextStyle, family_resp: &Response, out: &mut Frame) { + let apply_variable = |ui: &mut Ui| { + let picked = crate::right::menu_item(ui, tr("Apply variable"), ""); + if picked { crate::variable_ui::open_style(ui, id, "fontFamily", VariableType::String, family_resp.rect.left_bottom()); ui.close(); - return; + } else { + ui.separator(); } - ui.separator(); - if let Some(f) = crate::typography::font_picker(ui, Some(&def.font_family), family_resp.clicked()).picked { - set_text(def, id, out, |d| { - d.font_family = f.clone(); - let styles = figcraft_text::family_styles(&f); - if !styles.is_empty() && !styles.contains(&d.font_style) { - d.set_font_style(styles.iter().find(|s| s.eq_ignore_ascii_case("Regular")).or(styles.first()).cloned().unwrap_or_default()); - } - }); - ui.close(); - } - }); + picked + }; + if let Some(f) = crate::typography::font_picker(ui, family_resp, Some(&def.font_family), 260.0, apply_variable).and_then(|p| p.picked) { + set_text(def, id, out, |d| { + d.font_family = f.clone(); + let styles = figcraft_text::family_styles(&f); + if !styles.is_empty() && !styles.contains(&d.font_style) { + d.set_font_style(styles.iter().find(|s| s.eq_ignore_ascii_case("Regular")).or(styles.first()).cloned().unwrap_or_default()); + } + }); + } } /// The font style menu: Apply variable, then the styles of the current font, the current one checked. @@ -909,7 +910,7 @@ fn text_properties(ui: &mut Ui, doc: &Document, style: &Style, def: &TextStyle, // The dropdowns' menus open outside the dark scope (they're regular popups). let (family_resp, style_resp) = crate::theme::on_dark(ui, |ui| text_property_fields(ui, doc, style, def, out)); if let Some(family_resp) = family_resp { - font_family_menu(id, def, &family_resp, out); + font_family_menu(ui, id, def, &family_resp, out); } if let Some(sr) = style_resp { font_style_menu(id, def, &sr, out);