Merge #352: fix(ui): step through the font picker with Up and Down (#345) (@vxMacDev)

This commit is contained in:
bflatastic
2026-10-15 19:55:02 +07:00
2 changed files with 121 additions and 12 deletions
+1 -1
View File
@@ -746,7 +746,7 @@ fn text_properties(ui: &mut Ui, id: &str, def: &TextStyle, out: &mut Frame) {
(family_resp, style_resp)
});
crate::right::menu_popup(&family_resp).width(260.0).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| {
if let Some(f) = crate::typography::font_picker(ui, Some(&def.font_family), family_resp.clicked()) {
if let Some(f) = crate::typography::font_picker(ui, Some(&def.font_family), family_resp.clicked()).picked {
set(out, &|d| {
d.font_family = f.clone();
let styles = figcraft_text::family_styles(&f);
+120 -11
View File
@@ -181,10 +181,14 @@ fn font_rows(app: &mut FigApp, ui: &mut Ui, ids: &[NodeId], styles: &[TextStyle]
let w = ui.available_width();
let resp = widgets::dropdown(ui, family.as_deref().unwrap_or(tr(MIXED)), w, None);
menu_popup(&resp).width(w.max(240.0)).close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside).show(|ui| {
if let Some(f) = font_picker(ui, family.as_deref(), resp.clicked()) {
let pick = font_picker(ui, family.as_deref(), resp.clicked());
if let Some(f) = pick.picked {
remember_family(ui.ctx(), &f);
apply_style(app, "fontFamily", json!(f));
ui.close();
} else if let Some(f) = pick.highlighted {
// The canvas shows the family the arrow keys are on.
app.preview_text(ui.ctx(), "text.style", text_style_params(ids, range, json!({"fontFamily": f})));
}
});
});
@@ -1061,6 +1065,8 @@ pub(crate) fn parse_letter_spacing(s: &str, cur: Option<LetterSpacing>) -> Optio
const RECENT_KEY: &str = "figcraft_recent_fonts";
const ROW: f32 = 26.0;
/// The font list's tallest (it scrolls past that).
const LIST_HEIGHT: f32 = 380.0;
fn recent_families(ctx: &egui::Context) -> Vec<String> {
ctx.data_mut(|d| d.get_persisted::<Vec<String>>(Id::new(RECENT_KEY))).unwrap_or_default()
@@ -1080,14 +1086,31 @@ enum PickRow {
Family(String),
}
/// The searchable family list. Returns the family picked.
pub(crate) fn font_picker(ui: &mut Ui, current: Option<&str>, just_opened: bool) -> Option<String> {
/// What the family list did this frame.
#[derive(Default)]
pub(crate) struct FontPick {
/// The family clicked, or the one Enter picked.
pub picked: Option<String>,
/// The family Up and Down are on (Enter picks it).
pub highlighted: Option<String>,
}
/// The searchable family list: click a family, or step through it with Up and Down and press Enter.
pub(crate) fn font_picker(ui: &mut Ui, current: Option<&str>, just_opened: bool) -> FontPick {
let t = Tokens::get(ui.ctx());
let q_id = Id::new("font_query");
// The row Up and Down are on, and the search it is a row of.
let hi_id = Id::new("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)));
if just_opened {
search.request_focus();
@@ -1096,9 +1119,6 @@ pub(crate) fn font_picker(ui: &mut Ui, current: Option<&str>, just_opened: bool)
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();
if search.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter)) {
return matches.into_iter().next();
}
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();
@@ -1110,13 +1130,65 @@ pub(crate) fn font_picker(ui: &mut Ui, current: Option<&str>, just_opened: bool)
}
if matches.is_empty() {
widgets::label(ui, tr("No fonts match"), t.text_tertiary);
return None;
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 };
}
let mut budget = 4;
let mut picked = None;
egui::ScrollArea::vertical().max_height(380.0).auto_shrink([false, true]).show_rows(ui, ROW, rows.len(), |ui, range| {
for r in rows.get(range).unwrap_or(&[]) {
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 {
@@ -1124,7 +1196,7 @@ pub(crate) fn font_picker(ui: &mut Ui, current: Option<&str>, just_opened: bool)
ui.painter().text(pos2(rect.min.x + 8.0, rect.center().y), Align2::LEFT_CENTER, *h, semibold(10.5), t.text_secondary);
}
PickRow::Family(f) => {
let hovered = resp.hovered();
let hovered = resp.hovered() || hi == Some(i);
if hovered {
ui.painter().rect_filled(rect, CornerRadius::same(5), t.accent);
}
@@ -1160,7 +1232,8 @@ pub(crate) fn font_picker(ui: &mut Ui, current: Option<&str>, just_opened: bool)
}
}
});
picked
ui.data_mut(|d| d.insert_temp(hi_id.with("view"), (shown.state.offset.y, shown.inner_rect.height())));
FontPick { highlighted: hi.and_then(family), picked }
}
#[cfg(test)]
@@ -1207,6 +1280,42 @@ mod tests {
assert_eq!(parse_letter_spacing("x", Some(LetterSpacing::Percent(0.0))), None);
}
/// One frame of the font picker with `events`.
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
}
fn key(key: egui::Key) -> egui::Event {
egui::Event::Key { key, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE }
}
#[test]
fn arrow_keys_step_through_the_families_and_enter_picks() {
// #345
figcraft_text::scan_system_fonts();
let all = figcraft_text::family_names();
if all.len() < 3 {
eprintln!("fewer than three font families; skipping");
return;
}
let ctx = egui::Context::default();
let current = all[1].as_str();
assert!(pick(&ctx, current, true, vec![]).highlighted.is_none());
// The first step starts from the current family; each press is one step.
let down = pick(&ctx, current, false, vec![key(egui::Key::ArrowDown)]);
assert_eq!(down.highlighted.as_deref(), Some(all[2].as_str()));
let up = pick(&ctx, current, false, vec![key(egui::Key::ArrowUp), key(egui::Key::ArrowUp)]);
assert_eq!(up.highlighted.as_deref(), Some(all[0].as_str()));
// At the top it stays; Enter picks the family it is on, not the first.
assert_eq!(pick(&ctx, current, false, vec![key(egui::Key::ArrowUp)]).highlighted.as_deref(), Some(all[0].as_str()));
pick(&ctx, current, false, vec![key(egui::Key::ArrowDown)]);
let enter = pick(&ctx, current, false, vec![key(egui::Key::Enter)]);
assert_eq!(enter.picked.as_deref(), Some(all[1].as_str()));
}
#[test]
fn axis_values_round_by_axis_width() {
let axis = |min: f64, max: f64| figcraft_text::Axis { tag: "test".into(), name: "Test".into(), min, default: min, max, hidden: false };