UI: add inline font family search in Character and Properties

This commit is contained in:
kikikikier
2026-10-10 17:14:06 +03:00
parent 813c7c4650
commit abd44404f8
4 changed files with 269 additions and 48 deletions
+1 -7
View File
@@ -318,13 +318,7 @@ fn text_section(
// Font family and style.
let fr = Rect::from_min_size(pos2(x0, y), vec2(w, 22.0));
let pop = egui::Id::new("props-font-pop");
if widgets::dropdown(ui, fr, &doc.font, &t, egui::Id::new("props-font")).clicked() {
widgets::open_popup(ui, pop);
}
app.auto.add("properties.text.font", fr, "Font family");
// Every installed family (built only while the menu is open: it can be long).
let fams: Vec<String> = if widgets::popup_is_open(ui, pop) { effectcraft_engine::text_families() } else { vec![] };
if let Some(f) = widgets::popup_menu(ui, pop, fr.left_bottom(), &fams, fams.iter().position(|f| *f == doc.font)).and_then(|i| fams.get(i)) {
if let Some(f) = super::text_panels::font_picker(app, ui, pop, fr, &doc.font, "properties.text.font", true) {
set(actions, json!({"font": f}));
}
y += 28.0;
+154 -39
View File
@@ -96,11 +96,7 @@ pub fn character(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
// Font family + style.
let fr = Rect::from_min_size(pos2(x0, y), vec2(w, 22.0));
let pop = egui::Id::new("char-font-pop");
if widgets::dropdown(ui, fr, &doc.font, &t, egui::Id::new("char-font")).clicked() && enabled {
widgets::open_popup(ui, pop);
}
app.auto.add("character.font", fr, "Font family");
if let Some(f) = font_popup(app, ui, pop, fr.left_bottom(), &doc.font) {
if let Some(f) = font_picker(app, ui, pop, fr, &doc.font, "character.font", enabled) {
actions.push(json!({"font": f}));
}
y += 28.0;
@@ -551,47 +547,166 @@ pub fn paragraph(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
}
/// The font menu: recent fonts first, names in English or the fonts' own language, and a
/// "Sample" preview in each font (Settings ▸ Type).
fn font_popup(app: &EffectcraftApp, ui: &mut egui::Ui, id: egui::Id, pos: egui::Pos2, current: &str) -> Option<String> {
if !widgets::popup_is_open(ui, id) {
#[derive(Clone, Default)]
struct FontSearch {
query: String,
selected: usize,
}
/// Shared font menu for Character and Properties: search, recent fonts and previews.
pub(super) fn font_picker(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: egui::Id, rect: Rect, current: &str, auto: &str, enabled: bool) -> Option<String> {
let state_id = id.with("search-state");
app.auto.add(auto, rect, "Font family");
if !widgets::popup_is_open(ui, id) || !enabled {
ui.data_mut(|d| d.remove::<FontSearch>(state_id));
if widgets::dropdown(ui, rect, current, &app.tokens, id.with("field")).clicked() && enabled {
widgets::open_popup(ui, id);
}
return None;
}
let rows = effectcraft_engine::font_menu(&app.session.prefs);
let saved = ui.data(|d| d.get_temp::<FontSearch>(state_id));
let fresh = saved.is_none();
let mut state = saved.unwrap_or_default();
let search_id = id.with("search");
let (up, down, enter, escape) = ui.input_mut(|i| {
(
i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowUp),
i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowDown),
i.consume_key(egui::Modifiers::NONE, egui::Key::Enter),
i.consume_key(egui::Modifiers::NONE, egui::Key::Escape),
)
});
let preview = app.session.prefs.type_.font_preview;
let t = app.tokens;
let seps = rows.iter().filter(|r| r.family.is_empty()).count();
widgets::popup_list(ui, id, pos, Rect::NOTHING, rows.len().saturating_sub(seps), seps, |ui| {
ui.set_min_width(if preview { 300.0 } else { 180.0 });
let mut chosen = None;
for r in &rows {
if r.family.is_empty() {
ui.separator();
continue;
// The font field itself becomes editable. The popup contains only results.
let arrow = Rect::from_min_max(pos2(rect.max.x - 22.0, rect.min.y), rect.max);
let edit_rect = Rect::from_min_max(rect.min, pos2(arrow.min.x, rect.max.y));
let search = ui.put(edit_rect, egui::TextEdit::singleline(&mut state.query).id(search_id).hint_text(current).font(Tokens::ui(12.0)));
app.auto.add(&format!("{auto}.search"), search.rect, "Search fonts");
if fresh {
search.request_focus();
}
ui.memory_mut(|m| m.set_focus_lock_filter(search_id, egui::EventFilter { vertical_arrows: true, escape: true, ..Default::default() }));
if widgets::icon_button(ui, arrow, crate::icons::Icon::ChevronDown, false, &t, id.with("arrow")).clicked() {
ui.data_mut(|d| {
d.insert_temp(id.with("open"), false);
d.remove::<FontSearch>(state_id);
});
ui.memory_mut(|m| m.surrender_focus(search_id));
return None;
}
app.auto.add(&format!("{auto}.toggle"), arrow, "Close font list");
let mut chosen = None;
let screen = ui.ctx().content_rect();
let below = screen.bottom() - rect.bottom() - 16.0;
let above = below < 100.0 && rect.top() - screen.top() > below;
let max_height = (if above { rect.top() - screen.top() - 16.0 } else { below }).clamp(28.0, 380.0);
let anchor = if above { rect.left_top() } else { rect.left_bottom() };
let pivot = if above { Align2::LEFT_BOTTOM } else { Align2::LEFT_TOP };
widgets::note_open_popup_list(ui.ctx());
let area = egui::Area::new(id.with("area")).order(egui::Order::Foreground).pivot(pivot).fixed_pos(anchor).show(ui.ctx(), |ui| {
egui::Frame::popup(ui.style()).show(ui, |ui| {
ui.set_width(rect.width().max(180.0));
let query = state.query.trim().to_lowercase();
let mut seen = std::collections::HashSet::new();
let filtered: Vec<_> = rows
.iter()
.filter(|r| {
!r.family.is_empty()
&& (query.is_empty()
|| ((r.family.to_lowercase().contains(&query) || r.display.to_lowercase().contains(&query)) && seen.insert(&r.family)))
})
.collect();
if fresh {
state.selected = filtered.iter().position(|r| r.family == current).unwrap_or(0);
}
let resp = ui.selectable_label(r.family == current, &r.display);
if preview && ui.is_rect_visible(resp.rect) {
let key = egui::Id::new(("font-preview", &r.family));
let lines: std::sync::Arc<Vec<Vec<[f32; 2]>>> = match ui.data(|d| d.get_temp(key)) {
Some(l) => l,
None => {
let l = std::sync::Arc::new(effectcraft_engine::font_preview(&r.family, 14.0));
ui.data_mut(|d| d.insert_temp(key, l.clone()));
l
}
};
let o = pos2(resp.rect.max.x - 120.0, resp.rect.center().y + 5.0);
for poly in lines.iter() {
let pts: Vec<egui::Pos2> = poly.iter().map(|p| o + vec2(p[0], p[1])).collect();
ui.painter().add(egui::Shape::line(pts, egui::Stroke::new(1.0, t.text_dim)));
if search.changed() {
state.selected = 0;
}
let last = filtered.len().saturating_sub(1);
state.selected = state.selected.min(last);
if down {
state.selected = state.selected.saturating_add(1).min(last);
}
if up {
state.selected = state.selected.saturating_sub(1);
}
if enter {
chosen = filtered.get(state.selected).map(|r| r.family.clone());
}
// Give the result list its actual height even when the previous query had
// only one row. A cached tiny Area must not collapse a reopened long list.
let height = (filtered.len() as f32 * (24.0 + ui.spacing().item_spacing.y) + 16.0).clamp(28.0, max_height);
ui.set_height(height);
let mut scroll = egui::ScrollArea::vertical().id_salt(id.with("results")).max_height(height).min_scrolled_height(height);
if search.changed() {
scroll = scroll.vertical_scroll_offset(0.0);
}
scroll.show(ui, |ui| {
if filtered.is_empty() {
let r = ui.label("No matching fonts");
app.auto.add(&format!("{auto}.empty"), r.rect, "No matching fonts");
}
}
if resp.clicked() {
chosen = Some(r.family.clone());
}
}
chosen
})
let mut was_recent = false;
for (index, r) in filtered.iter().enumerate() {
if query.is_empty() && was_recent && !r.recent {
ui.separator();
}
was_recent = r.recent;
let row_width = ui.available_width();
let label_width = if preview { row_width - 100.0 } else { row_width };
let resp = ui.add_sized([row_width, 24.0], egui::Button::selectable(index == state.selected, ""));
resp.widget_info(|| egui::WidgetInfo::selected(egui::WidgetType::SelectableLabel, ui.is_enabled(), index == state.selected, &r.display));
let label_rect = Rect::from_min_size(resp.rect.min + vec2(5.0, 0.0), vec2(label_width - 5.0, resp.rect.height()));
ui.painter().with_clip_rect(label_rect.intersect(ui.clip_rect())).text(
label_rect.left_center(),
Align2::LEFT_CENTER,
&r.display,
Tokens::ui(12.0),
t.text,
);
if ui.is_rect_visible(resp.rect) {
app.auto.add(&format!("{auto}.option.{}", r.family), resp.rect, &r.display);
}
if index == state.selected && (fresh || up || down) {
resp.scroll_to_me(None);
}
if preview && ui.is_rect_visible(resp.rect) {
let key = egui::Id::new(("font-preview", &r.family));
let lines: std::sync::Arc<Vec<Vec<[f32; 2]>>> = match ui.data(|d| d.get_temp(key)) {
Some(l) => l,
None => {
let l = std::sync::Arc::new(effectcraft_engine::font_preview(&r.family, 14.0));
ui.data_mut(|d| d.insert_temp(key, l.clone()));
l
}
};
let o = pos2(resp.rect.max.x - 90.0, resp.rect.center().y + 5.0);
for poly in lines.iter() {
let pts: Vec<egui::Pos2> = poly.iter().map(|p| o + vec2(p[0], p[1])).collect();
ui.painter().add(egui::Shape::line(pts, egui::Stroke::new(1.0, t.text_dim)));
}
}
if resp.clicked() {
chosen = Some(r.family.clone());
}
resp.on_hover_text(&r.display);
}
});
});
});
let outside = widgets::pressed_outside(ui.ctx(), &area.response) && !ui.input(|i| i.pointer.interact_pos().is_some_and(|p| rect.contains(p)));
if chosen.is_some() || outside || escape {
ui.data_mut(|d| {
d.insert_temp(id.with("open"), false);
d.remove::<FontSearch>(state_id);
});
ui.memory_mut(|m| m.surrender_focus(search_id));
} else {
ui.data_mut(|d| d.insert_temp(state_id, state));
}
chosen
}
/// Align panel: Align Layers to Selection / Composition, the six align buttons and the six
+7 -2
View File
@@ -520,8 +520,7 @@ pub fn popup_list<R>(ui: &mut Ui, id: egui::Id, pos: egui::Pos2, anchor: Rect, r
egui::ScrollArea::vertical().max_height(max_h).min_scrolled_height((est - 16.0).min(max_h)).show(ui, |ui| chosen = body(ui));
});
});
let pass = ui.ctx().cumulative_pass_nr();
ui.ctx().data_mut(|d| d.insert_temp(popup_list_pass_id(), pass));
note_open_popup_list(ui.ctx());
let outside = pressed_outside(ui.ctx(), &area.response) && !ui.input(|i| i.pointer.interact_pos()).is_some_and(|p| anchor.contains(p));
if chosen.is_some() || outside || ui.input(|i| i.key_pressed(egui::Key::Escape)) {
ui.data_mut(|d| d.insert_temp(id.with("open"), false));
@@ -529,6 +528,12 @@ pub fn popup_list<R>(ui: &mut Ui, id: egui::Id, pos: egui::Pos2, anchor: Rect, r
chosen
}
/// Custom popup lists also hold the app's keyboard shortcuts while open.
pub(crate) fn note_open_popup_list(ctx: &egui::Context) {
let pass = ctx.cumulative_pass_nr();
ctx.data_mut(|d| d.insert_temp(popup_list_pass_id(), pass));
}
/// The pass a [`popup_list`] was last drawn in.
fn popup_list_pass_id() -> egui::Id {
egui::Id::new("popup-list-pass")
+107
View File
@@ -0,0 +1,107 @@
//! Real keyboard/pointer coverage for the font menus in both text panels.
use effectcraft_engine::{Session, commands::text_edit::layer_doc, project::LayerId};
use effectcraft_ui_egui::{EffectcraftApp, dock::PanelKind};
use egui::{Event, Key, Modifiers, PointerButton};
use egui_kittest::Harness;
use serde_json::json;
fn click(h: &mut Harness<'_, EffectcraftApp>, id: &str) {
let e = h.state().auto.find(id).unwrap_or_else(|| panic!("missing {id}")).clone();
let pos = egui::pos2(e.rect[0] + e.rect[2] / 2.0, e.rect[1] + e.rect[3] / 2.0);
h.input_mut().events.push(Event::PointerMoved(pos));
for pressed in [true, false] {
h.input_mut().events.push(Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE });
h.run_steps(2);
}
}
fn key(h: &mut Harness<'_, EffectcraftApp>, key: Key) {
for pressed in [true, false] {
h.input_mut().events.push(Event::Key { key, physical_key: None, pressed, repeat: false, modifiers: Modifiers::NONE });
}
h.run_steps(3);
}
fn type_text(h: &mut Harness<'_, EffectcraftApp>, text: &str) {
h.input_mut().events.push(Event::Text(text.into()));
h.run_steps(3);
}
#[test]
fn font_search_filters_selects_and_undoes_in_both_panels() {
for (panel, prefix) in [(PanelKind::Character, "character.font"), (PanelKind::Properties, "properties.text.font")] {
let mut s = Session::default();
s.execute("comp.new", json!({"width": 320, "height": 180})).unwrap();
let lid = s.execute("layer.newText", json!({"text": "Keep this text", "font": "Inter"})).unwrap()["layer"].as_u64().unwrap();
let builder = Harness::builder().with_size(egui::vec2(1600.0, 1000.0));
let mut h = if std::env::var_os("EC_SNAPSHOT_DIR").is_some() {
builder.wgpu().build_eframe(move |_| EffectcraftApp::new(s))
} else {
builder.build_eframe(move |_| EffectcraftApp::new(s))
};
h.state_mut().show_panel(panel);
h.run_steps(3);
click(&mut h, prefix);
assert!(h.state().auto.find(&format!("{prefix}.search")).is_some());
assert!(effectcraft_ui_egui::widgets::any_menu_open(&h.ctx), "font search must hold app shortcuts like other popup lists");
let field = h.state().auto.find(prefix).unwrap();
let search = h.state().auto.find(&format!("{prefix}.search")).unwrap();
assert!(
(field.rect[1] + field.rect[3] / 2.0 - search.rect[1] - search.rect[3] / 2.0).abs() < 1.0,
"font selection and search must occupy the same row"
);
// Search gets focus on open. Mixed case and surrounding whitespace are accepted.
type_text(&mut h, " jEtBrAiNs ");
let rows = h.state().auto.query(&format!("{prefix}.option."));
assert!(!rows.is_empty());
assert!(rows.iter().all(|r| r.label.to_lowercase().contains("jetbrains")));
if let Ok(dir) = std::env::var("EC_SNAPSHOT_DIR") {
h.render().unwrap().save(format!("{dir}/{prefix}-search.png")).unwrap();
}
assert_eq!(layer_doc(&h.state().session, LayerId(lid)).unwrap().font, "Inter", "typing only filters");
key(&mut h, Key::Enter);
assert_eq!(layer_doc(&h.state().session, LayerId(lid)).unwrap().font, "JetBrains Mono");
assert_eq!(layer_doc(&h.state().session, LayerId(lid)).unwrap().text, "Keep this text");
h.state_mut().session.execute("edit.undo", json!({})).unwrap();
assert_eq!(layer_doc(&h.state().session, LayerId(lid)).unwrap().font, "Inter");
click(&mut h, prefix);
type_text(&mut h, "no_such_font_123456789");
assert!(h.state().auto.query(&format!("{prefix}.option.")).is_empty());
assert!(h.state().auto.find(&format!("{prefix}.empty")).is_some());
key(&mut h, Key::Enter);
assert!(h.state().auto.find(&format!("{prefix}.search")).is_some(), "no result keeps menu open");
key(&mut h, Key::Escape);
assert_eq!(layer_doc(&h.state().session, LayerId(lid)).unwrap().font, "Inter");
click(&mut h, prefix);
// Reopening clears the previous query, and mouse selection still works.
h.run_steps(2);
if let Ok(dir) = std::env::var("EC_SNAPSHOT_DIR") {
h.render().unwrap().save(format!("{dir}/{prefix}-reopened.png")).unwrap();
}
assert!(h.state().auto.query(&format!("{prefix}.option.")).len() > 1);
type_text(&mut h, "JetBrains");
click(&mut h, &format!("{prefix}.option.JetBrains Mono"));
assert_eq!(layer_doc(&h.state().session, LayerId(lid)).unwrap().font, "JetBrains Mono");
}
}
#[test]
fn font_search_preserves_edited_text_and_applies_only_to_selected_characters() {
let mut s = Session::default();
s.execute("comp.new", json!({"width": 320, "height": 180})).unwrap();
let lid = s.execute("layer.newText", json!({"text": "First second", "font": "Inter"})).unwrap()["layer"].as_u64().unwrap();
s.execute("text.edit", json!({"layer": lid})).unwrap();
s.execute("text.setSelection", json!({"start": 0, "end": 5})).unwrap();
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).build_eframe(move |_| EffectcraftApp::new(s));
h.state_mut().show_panel(PanelKind::Character);
h.run_steps(3);
click(&mut h, "character.font");
type_text(&mut h, "noto serif");
key(&mut h, Key::ArrowDown);
key(&mut h, Key::ArrowUp);
key(&mut h, Key::Enter);
let doc = layer_doc(&h.state().session, LayerId(lid)).unwrap();
assert_eq!(doc.text, "First second");
assert_eq!(doc.style_at(0).font, "Noto Serif");
assert_eq!(doc.style_at(8).font, "Inter");
}