fix(ui): dropdown menus grow back after a search with no matches (#1429)

* feat(ui-egui): font family menu keeps its search field and grows back after a filter clears

Repin craft-libs to the shared dropdown (craft-ui), move the font family menu to
craft_ui::combo::Menu with the search in a fixed header, and use craft_ui::ComboBox
for the credits sort. clippy bans egui::ComboBox.

* chore(deps): craft-libs 60d60d48 (dropdown fixes)

* chore(deps): craft-libs 4d7b82f7 (dropdown fixes)

* chore(deps): craft-libs 92d392cf (#387 merged)

* fix(ui-egui): craft-ui on every target (wasm uses the shared ComboBox too)
This commit is contained in:
aiongg
2026-10-27 10:28:10 -04:00
committed by GitHub
parent 43af145a12
commit bdb7e84606
11 changed files with 579 additions and 190 deletions
Generated
+211 -20
View File
@@ -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",
+6 -4
View File
@@ -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" }
+3
View File
@@ -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)" },
]
+1
View File
@@ -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 }
+1 -1
View File
@@ -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;
@@ -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<Event>) -> Vec<Node> {
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<Event>) -> Vec<Node> {
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<egui::LayerId> = 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<egui::LayerId> = 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");
}
+2
View File
@@ -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;
@@ -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.
+26 -5
View File
@@ -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<egui::Event>) -> 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<egui::Event>| {
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 {
+186 -136
View File
@@ -41,149 +41,199 @@ pub(crate) struct FontPick {
pub hovered: Option<String>,
}
/// 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<PickRow>,
/// The row Up and Down are on.
hi: Option<usize>,
/// 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<String>,
}
/// 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<FontPick> {
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<String> = all.iter().filter(|f| ql.is_empty() || f.to_lowercase().contains(&ql)).cloned().collect();
let mut rows: Vec<PickRow> = Vec::new();
if ql.is_empty() {
let recent: Vec<String> = 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<String> = all.iter().filter(|f| ql.is_empty() || f.to_lowercase().contains(&ql)).cloned().collect();
let mut rows: Vec<PickRow> = Vec::new();
if ql.is_empty() {
let recent: Vec<String> =
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 }
},
)
}
+18 -17
View File
@@ -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);