Add font previews and keyboard cycling to font menus (#1746)

Co-authored-by: highperfocused <highperfocused@pm.me>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
mroxso 2026-10-10 03:43:59 +02:00 committed by GitHub
parent 995c895c33
commit 16ad6e1079
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 268 additions and 25 deletions

View File

@ -0,0 +1,100 @@
//! Small font-menu samples, rendered by the same font database as document text.
use std::collections::VecDeque;
use std::sync::PoisonError;
use egui::{ColorImage, Rect, TextureHandle, TextureOptions, pos2, vec2};
use photocraft_color::{ColorMode, PixelFormat, SampleType};
use photocraft_doc::TextLayer;
use photocraft_geom::Affine;
const SAMPLE: &str = "AaBbCc";
const WIDTH: f32 = 100.0;
const HEIGHT: f32 = 24.0;
const CACHE_LIMIT: usize = 128;
/// Family, display scale, and the font database generations, so a family installed or fetched
/// (served fonts) after its first miss gets a sample once it is available.
type Key = (String, u32, (u64, u64));
#[derive(Clone, Default)]
struct Cache(VecDeque<(Key, Option<TextureHandle>)>);
fn image(engine: &mut photocraft_text::TextEngine, family: &str, scale: u32) -> Option<ColorImage> {
// Unknown/unreadable families keep their plain menu label, rather than a misleading fallback.
if !engine.fonts.has_family(family) {
return None;
}
let chars = engine.fonts.charmap(family, 400, false);
let text = if SAMPLE.chars().all(|c| chars.binary_search(&c).is_ok()) {
SAMPLE.into()
} else {
// Symbol/script-only faces must show their own glyphs, not the Latin fallback font.
chars.into_iter().filter(|c| !c.is_whitespace()).take(6).collect::<String>()
};
if text.is_empty() {
return None;
}
let layer = TextLayer { text, font_family: family.into(), size_pt: 18.0 * scale as f32, ..Default::default() };
let layout = engine.layout(&layer, 72.0);
let bounds = layout.bounds()?;
let width = (WIDTH as u32 * scale) as usize;
let height = (HEIGHT as u32 * scale) as usize;
let transform = Affine::translate(f64::from(-bounds[0]), f64::from((height as f32 - (bounds[3] - bounds[1])) / 2.0 - bounds[1]));
// Reject oversized ink before the rasterizer allocates a surface (malformed installed fonts).
let ink = photocraft_text::render::ink_rect(&layout, &transform);
if ink.is_empty() || ink.width() > width as u32 * 4 || ink.height() > height as u32 * 4 {
return None;
}
let rendered = photocraft_text::render::rasterize(&layout, &transform, PixelFormat::new(ColorMode::Rgb, SampleType::U8, true), layer.antialias);
let mut rgba = vec![[0u8; 4]; width * height];
rendered.surface.read_rgba8_into(photocraft_geom::Rect::new(0, 0, width as i32, height as i32), &mut rgba);
Some(ColorImage::from_rgba_unmultiplied([width, height], &rgba.into_iter().flat_map(|p| [255, 255, 255, p[3]]).collect::<Vec<_>>()))
}
pub(crate) fn paint(ui: &egui::Ui, family: &str, row: Rect) {
let ctx = ui.ctx();
let scale = ctx.pixels_per_point().ceil().clamp(1.0, 4.0) as u32;
let key = (family.to_owned(), scale, (photocraft_text::fonts::generation(), photocraft_text::served::generation()));
let id = egui::Id::new("font-menu-previews");
let cached = ctx.data_mut(|d| {
let cache = d.get_temp_mut_or_default::<Cache>(id);
let index = cache.0.iter().position(|(k, _)| k == &key)?;
let entry = cache.0.remove(index)?;
let texture = entry.1.clone();
cache.0.push_back(entry);
Some(texture)
});
let texture = cached.unwrap_or_else(|| {
let texture = image(&mut photocraft_text::shared().lock().unwrap_or_else(PoisonError::into_inner), family, scale)
.map(|img| ctx.load_texture(format!("font-sample-{family}"), img, TextureOptions::LINEAR));
ctx.data_mut(|d| {
let cache = d.get_temp_mut_or_default::<Cache>(id);
if cache.0.len() >= CACHE_LIMIT {
cache.0.pop_front();
}
cache.0.push_back((key, texture.clone()));
});
texture
});
if let Some(texture) = texture {
let rect = Rect::from_min_size(pos2(row.right() - WIDTH - 8.0, row.center().y - HEIGHT / 2.0), vec2(WIDTH, HEIGHT));
ui.painter().image(texture.id(), rect, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), crate::theme::Tokens::get(ctx).text);
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn previews_use_the_requested_face_and_scale() {
let mut engine = photocraft_text::TextEngine::new();
let sans = image(&mut engine, "Inter", 1).expect("bundled sans");
let mono = image(&mut engine, "JetBrains Mono", 1).expect("bundled mono");
assert_eq!(sans.size, [100, 24]);
assert!(sans.pixels.iter().any(|p| p.a() > 0));
assert_ne!(sans.pixels, mono.pixels, "samples use different font outlines");
assert_eq!(image(&mut engine, "Inter", 2).unwrap().size, [200, 48]);
assert!(image(&mut engine, "Missing font", 1).is_none());
}
}

View File

@ -66,6 +66,7 @@ pub mod file_open;
pub mod file_ui;
pub mod fill_ui;
pub mod filter_dialog;
mod font_preview;
#[cfg(not(target_arch = "wasm32"))]
mod filter_preview_worker;
pub mod gallery_ui;

View File

@ -89,3 +89,108 @@ fn reopening_focuses_the_search_field_and_clicking_it_keeps_the_menu_open() {
assert_eq!(h.state().current, "Zeta Sans");
assert!(popup_rect(&h).is_none(), "choosing a font closes the menu");
}
#[test]
fn arrows_apply_fonts_without_closing_and_enter_accepts() {
let mut h = harness();
open(&mut h);
h.key_press(Key::ArrowDown);
h.run();
assert_eq!(h.state().current, "Family 01");
assert!(popup_rect(&h).is_some());
h.key_press(Key::ArrowDown);
h.key_press(Key::ArrowUp);
h.run();
assert_eq!(h.state().current, "Family 01");
h.key_press(Key::Enter);
h.run();
assert!(popup_rect(&h).is_none());
}
#[test]
fn arrows_follow_the_filtered_list_and_handle_no_matches() {
let mut h = harness();
open(&mut h);
h.get_by_role(Role::TextInput).type_text("Family 5");
h.run();
h.key_press(Key::ArrowDown);
h.run();
assert_eq!(h.state().current, "Family 50");
h.key_press(Key::ArrowUp);
h.run();
assert_eq!(h.state().current, "Family 50", "clamp at the first match");
h.get_by_role(Role::TextInput).type_text("missing");
h.run();
h.key_press(Key::ArrowDown);
h.key_press(Key::ArrowUp);
h.key_press(Key::Enter);
h.run();
assert_eq!(h.state().current, "Family 50");
assert!(popup_rect(&h).is_some(), "no match cannot accept an unrelated font");
}
#[test]
fn arrows_scroll_the_selected_font_into_view() {
let mut h = harness();
open(&mut h);
for _ in 0..59 {
h.key_press(Key::ArrowDown);
h.run();
}
assert_eq!(h.state().current, "Family 59");
let row = h.get_by_label("Family 59");
assert!(popup_rect(&h).unwrap().contains(row.rect().center()));
h.key_press(Key::ArrowDown);
h.key_press(Key::ArrowDown);
h.run();
assert_eq!(h.state().current, "Zeta Sans", "clamp at the last font");
}
#[test]
fn empty_font_list_ignores_navigation() {
let mut h = harness();
h.state_mut().families.clear();
open(&mut h);
h.key_press(Key::ArrowDown);
h.key_press(Key::ArrowUp);
h.key_press(Key::Enter);
h.run();
assert_eq!(h.state().current, "Family 00");
assert!(popup_rect(&h).is_some());
}
#[test]
fn font_cycle_in_options_bar_edits_the_layer_and_can_be_undone() {
use crate::{PhotocraftApp, Services};
use serde_json::json;
let mut app = PhotocraftApp::new(photocraft_engine::Session::new(), Services::default());
app.run("file.new", json!({"width": 200, "height": 100})).unwrap();
app.run("type.create", json!({"text": "Sample", "font": "Inter", "size": 18})).unwrap();
let mut h = Harness::builder().with_size(vec2(900.0, 700.0)).build_ui_state(
|ui, app| {
if ui.ctx().fonts(|f| f.families().contains(&egui::FontFamily::Name("medium".into()))) {
ui.horizontal(|ui| crate::type_tool::options_bar(app, ui));
}
},
app,
);
crate::PhotocraftApp::setup_context(&h.ctx, Default::default());
h.run_steps(4);
h.query_all_by_role(Role::ComboBox).next().unwrap().click();
h.run();
h.get_by_role(Role::TextInput).type_text("JetBrains");
h.key_press(Key::ArrowDown);
h.run();
let font = |app: &PhotocraftApp| {
let doc = &app.session.active().unwrap().doc;
match &doc.layer(app.session.active().unwrap().active_layer.unwrap()).unwrap().content {
photocraft_doc::LayerContent::Text(t) => t.font_family.clone(),
_ => panic!("type layer"),
}
};
assert_eq!(font(h.state()), "JetBrains Mono");
h.key_press(Key::Enter);
h.run();
h.state_mut().run("edit.undo", json!({})).unwrap();
assert_eq!(font(h.state()), "Inter");
}

View File

@ -495,26 +495,11 @@ fn options_editor(app: &mut PhotocraftApp, ui: &mut egui::Ui, paragraph: bool, s
/// Searchable family combo box.
fn family_picker(ui: &mut egui::Ui, salt: &str, current: &mut String) -> bool {
let mut changed = false;
let search_id = egui::Id::new(("family-search", salt));
let shown = if current.is_empty() { photocraft_text::fonts::DEFAULT_FAMILY.to_string() } else { current.clone() };
egui::ComboBox::from_id_salt(salt).selected_text(shown.clone()).width(140.0).height(360.0).icon(crate::widgets::chevron_icon).show_ui(ui, |ui| {
let mut q: String = ui.data(|d| d.get_temp(search_id)).unwrap_or_default();
let r = ui.add(egui::TextEdit::singleline(&mut q).hint_text(tl!("Search fonts")).desired_width(180.0));
if !r.has_focus() && q.is_empty() {
r.request_focus();
}
ui.data_mut(|d| d.insert_temp(search_id, q.clone()));
let ql = q.to_lowercase();
for f in crate::type_tool::families().iter().filter(|f| ql.is_empty() || f.to_lowercase().contains(&ql)) {
if ui.selectable_label(*f == shown, f).clicked() {
*current = f.clone();
photocraft_text::served::request(f);
changed = true;
ui.data_mut(|d| d.remove::<String>(search_id));
}
}
});
let mut shown = if current.is_empty() { photocraft_text::fonts::DEFAULT_FAMILY.to_string() } else { current.clone() };
let changed = crate::type_tool::family_picker_in(ui, salt, &mut shown, 140.0, &crate::type_tool::families());
if changed {
*current = shown;
}
changed
}

View File

@ -775,17 +775,24 @@ pub fn style_label(style: &str) -> String {
/// Searchable font-family combo box.
fn font_picker(ui: &mut egui::Ui, current: &mut String, width: f32) -> bool {
font_picker_in(ui, current, width, &families())
family_picker_in(ui, "type-font", current, width, &families())
}
/// Maximum height of the font menu.
const FONT_MENU_HEIGHT: f32 = 460.0;
/// [`font_picker`] over a given family list (tests pass their own).
#[cfg(test)]
fn font_picker_in(ui: &mut egui::Ui, current: &mut String, width: f32, families: &[String]) -> bool {
family_picker_in(ui, "type-font", current, width, families)
}
/// Shared by the Type options, Character, style and Glyphs panels. Selection changes still
/// flow through each caller's existing engine command.
pub(crate) fn family_picker_in(ui: &mut egui::Ui, salt: &str, current: &mut String, width: f32, families: &[String]) -> bool {
let mut changed = false;
let search_id = ui.id().with("font-search");
let combo = egui::ComboBox::from_id_salt("type-font")
let search_id = ui.id().with(("font-search", salt));
let combo = egui::ComboBox::from_id_salt(salt)
.selected_text(current.as_str())
.width(width)
.height(FONT_MENU_HEIGHT)
@ -802,6 +809,10 @@ fn font_picker_in(ui: &mut egui::Ui, current: &mut String, width: f32, families:
let opened = last_pass.is_none_or(|p| p.saturating_add(1) < pass);
let mut focus = opened || ui.data(|d| d.get_temp(focus_id)).unwrap_or(false);
let mut q: String = ui.data(|d| d.get_temp(search_id)).unwrap_or_default();
// Consume these before TextEdit and the canvas can interpret them as caret movement.
let down = ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowDown));
let up = ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowUp));
let enter = ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Enter));
let r = ui.add(egui::TextEdit::singleline(&mut q).hint_text(tl!("Search fonts")).desired_width(200.0));
if r.has_focus() {
focus = false;
@ -814,8 +825,38 @@ fn font_picker_in(ui: &mut egui::Ui, current: &mut String, width: f32, families:
d.insert_temp(search_id, q.clone());
});
let ql = q.to_lowercase();
for f in families.iter().filter(|f| ql.is_empty() || f.to_lowercase().contains(&ql)) {
if ui.selectable_label(f == current, f).clicked() {
let filtered: Vec<&String> = families.iter().filter(|f| ql.is_empty() || f.to_lowercase().contains(&ql)).collect();
if down || up {
let index = filtered.iter().position(|f| *f == current);
let next = match index {
Some(i) if down => i.saturating_add(1).min(filtered.len().saturating_sub(1)),
Some(i) => i.saturating_sub(1),
None if down => 0,
None => filtered.len().saturating_sub(1),
};
if let Some(f) = filtered.get(next) {
changed = **f != *current;
current.clone_from(f);
// A served family not fetched yet: start now, as a click does.
photocraft_text::served::request(f);
}
}
if enter && filtered.iter().any(|f| *f == current) {
ui.data_mut(|d| d.remove::<String>(search_id));
ui.close();
}
for f in filtered {
let response = ui.add_sized(
[330.0, 28.0],
egui::Button::selectable(f == current, f).truncate().right_text(egui::Atom::custom(ui.id().with(("font-sample", f)), egui::vec2(100.0, 24.0))),
);
if (down || up) && f == current {
response.scroll_to_me(Some(egui::Align::Center));
}
if ui.is_rect_visible(response.rect) {
crate::font_preview::paint(ui, f, response.rect);
}
if response.clicked() {
*current = f.clone();
// A served family not fetched yet: start now, before any text needs it.
photocraft_text::served::request(f);

View File

@ -88,6 +88,17 @@ Menus never run off the window: the menu bar's menus and submenus scroll with ar
Use `ui.click {x,y}`, `ui.move`, `ui.key` and `ui.type` (synthetic input in screen points) to open menus, popups and context menus, then `ui.screenshot`.
## Font menus
The Type options bar, Character panel, Character/Paragraph Style editors and Glyphs panel
share a searchable family picker. Each visible row shows an `AaBbCc` sample rendered by
PhotoCraft's text engine in that family (script/symbol fonts use characters they support).
Samples are cached with a bounded cache and follow display scale and theme text colour.
Builds without system fonts preview the bundled fonts.
While the menu is open, Up/Down applies the previous/next matching family and scrolls it into
view; Enter accepts the selection and closes the menu. Escape closes it, keeping already
applied changes (Edit › Undo restores text-layer font changes).
## Preferences
Preferences has **Apply**, **OK** and **Cancel**. Apply saves the edited sections and keeps the