Browse / github.com/storytold/deckcraft / patch
Ribbon: font name and font size filter as you type (#933)
Feature Request: Combobox for font name selection Just like in PowerPoint: It would be nice to not have to scroll through a big list, and instead support a type-ahead filter. Related request, but somewhat less annoying: Font size combobox that also allows typing, including support for decimals. (Often useful for micro tweaking of layouts.)
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- FabPei (imported from pull request #1055, open)
- Base
f86928d0d50e6fdee166d846ea1a3477ae2658ce- Size
- +155 −19 in 5 files
- Review
- pending
- Shared
- 2026-10-28
- Id
d53add34a3624107e8ef2e9b
How it went
No other operator has reported using it yet.
Diff
raw · ROADMAP.md crates/ui-egui/src/font_filter_tests.rs crates/ui-egui/src/lib.rs crates/ui-egui/src/ribbon.rs crates/ui-egui/src/widgets.rs
@@ -146,6 +146,7 @@ app's JSON control channel (`deckcraft --control PORT`). | 2026-10-22 | Shape Fill and Shape Outline format the members of a selected group, including nested groups, in one undo step. Fill transparency retains each member's effective colour (#1079) | | 2026-10-21 | 3-D contours, extrusion depths and bevels keep their size in points inside a group whose child coordinates scale its shapes: a 1 pt contour on a small extruded cube in a legacy group drew hundreds of points wide, a band across the slide | | 2026-10-20 | Chart number formats with explicit empty positive, negative or zero sections hide the corresponding labels, including all values with three semicolons; a blank whole format still uses General. Synthetic formatter regressions |+| 2026-10-19 | Font name and font size comboboxes filter as you type: the popup opens with a field that narrows the list, names starting with the text first (#933) | | 2026-10-18 | Native chart sidecar bytes move from JSON arrays into referenced ZIP entries, with shared media byte/item limits and bounded reads; older inline sidecars still load. Synthetic format regressions cover large workbooks and malformed members | | 2026-10-18 | Slide thumbnails and the Slide Sorter render only the slides in view: on a deck of more than 600 slides they used to evict their own thumbnails, so the pane flashed white and never finished (#969) | | 2026-10-18 | Resizing with the mouse snaps the edge being dragged to the slide, guides and other shapes: a fixed edge that already lined up used to take the snap and leave the dragged edge free, and a fixed edge near a line shifted the dragged one (#961) |new file mode 100644@@ -0,0 +1,57 @@+//! The type-ahead filter for the font name combobox (#933).++#![allow(clippy::unwrap_used, clippy::expect_used)]++use crate::ribbon::{filter_families, filter_sizes};++fn fams() -> Vec<String> {+ ["Arial".to_string(), "Times New Roman".to_string(), "Calibri".to_string(), "Charcoal".to_string(), "Verdana".to_string()].to_vec()+}++/// An empty query keeps every family, in its original order (the two theme fonts stay on top).+#[test]+fn an_empty_query_keeps_the_whole_list() {+ let v = filter_families(&fams(), "");+ assert_eq!(v.len(), 5);+ assert_eq!(v[0], "Arial");+ assert_eq!(v[1], "Times New Roman");+}++/// Typing filters case-insensitively — "CaL" finds "Calibri" (a case-blind match would find+/// nothing, so the single name below proves the query is folded).+#[test]+fn the_query_matches_without_regard_to_case() {+ let v = filter_families(&fams(), "CaL");+ assert_eq!(v, vec!["Calibri".to_string()]);+}++/// Prefixes come before later matches, so the name being typed rises to the top.+#[test]+fn a_prefix_ranks_before_a_match_in_the_middle() {+ let v = filter_families(&fams(), "cha");+ assert_eq!(v, vec!["Charcoal".to_string()]);+ let v = filter_families(&fams(), "ar");+ assert_eq!(v, vec!["Arial".to_string(), "Charcoal".to_string()]);+}++/// Pasted or padded text still filters: the query is trimmed.+#[test]+fn the_query_is_trimmed_before_matching() {+ let v = filter_families(&fams(), " ver ");+ assert_eq!(v, vec!["Verdana".to_string()]);+}++/// Nothing matches: the list is empty, not the whole list and not a panic.+#[test]+fn no_match_gives_an_empty_list() {+ assert!(filter_families(&fams(), "zzz").is_empty());+}++/// The size combobox filters on what is written there, not on the number's binary value.+#[test]+fn the_sizes_filter_on_their_text() {+ let sizes = [10.0_f64, 18.0, 20.0, 24.0, 96.0];+ assert_eq!(filter_sizes(&sizes, "2"), vec![20.0, 24.0]);+ assert_eq!(filter_sizes(&sizes, "8"), vec![18.0]);+ assert_eq!(filter_sizes(&sizes, ""), sizes.to_vec());+}@@ -2086,6 +2086,8 @@ mod eyedropper_tests; #[cfg(test)] mod find_dialog_tests; #[cfg(test)]+mod font_filter_tests;+#[cfg(test)] mod huge_deck_tests; #[cfg(test)] mod ime_tests;@@ -8,7 +8,9 @@ use serde_json::{Value, json}; use crate::SlideApp; use crate::icons::{self, Icon}; use crate::theme::{self, Tokens};-use crate::widgets::{self, RIBBON_H, big_button, big_button_ex, color_split, dropdown, group, icon_toggle, rows, small_button, small_button_ex};+use crate::widgets::{+ self, RIBBON_H, big_button, big_button_ex, color_split, dropdown, dropdown_filtered, group, icon_toggle, rows, small_button, small_button_ex,+}; pub const TITLE_H: f32 = 38.0; @@ -474,6 +476,47 @@ pub fn font_families(app: &SlideApp) -> Vec<String> { v } +/// The type-ahead of the font name and font size comboboxes (#933): narrow `items` to what+/// `query` names — case-insensitively, entries *starting* with the text first and entries+/// containing it later — while an empty (or blank) query keeps the list exactly as it was, so the+/// popup reads the same before anything is typed. Returns the positions, so the number-backed+/// lists keep their own element type.+fn matching<T: AsRef<str>>(items: &[T], query: &str) -> Vec<usize> {+ let q = query.trim().to_lowercase();+ if q.is_empty() {+ return (0..items.len()).collect();+ }+ let mut before = Vec::new();+ let mut after = Vec::new();+ for (i, item) in items.iter().enumerate() {+ let lower = item.as_ref().to_lowercase();+ if lower.starts_with(&q) {+ before.push(i);+ } else if lower.contains(&q) {+ after.push(i);+ }+ }+ before.extend(after);+ before+}++/// [`matching`] for any list of readable entries — the font family names.+pub fn filter_options<'a, T: AsRef<str>>(items: &'a [T], query: &str) -> Vec<&'a T> {+ matching(items, query).into_iter().map(|i| &items[i]).collect()+}++/// [`filter_options`] for a list of font family names.+pub fn filter_families(fams: &[String], query: &str) -> Vec<String> {+ filter_options(fams, query).into_iter().cloned().collect()+}++/// [`matching`] for the font sizes, which the list holds as numbers and the combobox shows as+/// text (`24`, `8.5`) — so the query matches what the eye reads.+pub fn filter_sizes(sizes: &[f64], query: &str) -> Vec<f64> {+ let labels: Vec<String> = sizes.iter().map(|s| widgets::fmt_num(*s, "")).collect();+ matching(&labels, query).into_iter().map(|i| sizes[i]).collect()+}+ fn font_group(app: &mut SlideApp, ui: &mut Ui, st: &Value) { let en = enabled(app, "format.bold"); group(ui, |ui| {@@ -484,22 +527,27 @@ fn font_group(app: &mut SlideApp, ui: &mut Ui, st: &Value) { let size = st.get("size").and_then(Value::as_f64); let fams = font_families(app); let theme_fonts: Vec<String> = fams.iter().take(2).cloned().collect();- dropdown(ui, "font", &font, 150.0, en, |ui| {- for (k, f) in fams.iter().enumerate() {- let label = if k == 0 && theme_fonts.len() == 2 {- format!("{f} (Headings)")- } else if k == 1 && theme_fonts.len() == 2 {- format!("{f} (Body)")- } else {- f.clone()+ dropdown_filtered(ui, "font", &font, 150.0, en, |ui, typed| {+ let shown = filter_options(&fams, typed);+ let mut theme_shown = false;+ let mut separated = false;+ for f in shown {+ let pos = fams.iter().position(|x| x.as_str() == f.as_str());+ let theme = matches!(pos, Some(0) | Some(1));+ if theme_shown && !theme && !separated {+ ui.separator();+ separated = true;+ }+ let label = match pos {+ Some(0) if theme_fonts.len() == 2 => format!("{f} (Headings)"),+ Some(1) if theme_fonts.len() == 2 => format!("{f} (Body)"),+ _ => f.to_string(), }; if ui.button(egui::RichText::new(label)).clicked() {- run(app, "format.font", json!({"family": f}));+ run(app, "format.font", json!({ "family": f.as_str() })); ui.close(); }- if k == 1 {- ui.separator();- }+ theme_shown |= theme; } ui.separator(); // Fonts installed while DeckCraft runs (e.g. into ~/.local/share/fonts).@@ -509,10 +557,11 @@ fn font_group(app: &mut SlideApp, ui: &mut Ui, st: &Value) { } }); let size_text = size.map(|s| widgets::fmt_num(s, "")).unwrap_or_default();- dropdown(ui, "size", &size_text, 52.0, en, |ui| {- for s in deckcraft_engine::cmd::format::SIZES.iter().chain([72.0, 80.0, 88.0, 96.0].iter()) {- if ui.button(widgets::fmt_num(*s, "")).clicked() {- run(app, "format.size", json!({"size": s}));+ let sizes: Vec<f64> = deckcraft_engine::cmd::format::SIZES.iter().chain([72.0, 80.0, 88.0, 96.0].iter()).copied().collect();+ dropdown_filtered(ui, "size", &size_text, 52.0, en, |ui, typed| {+ for s in filter_sizes(&sizes, typed) {+ if ui.button(widgets::fmt_num(s, "")).clicked() {+ run(app, "format.size", json!({ "size": s })); ui.close(); } }@@ -240,6 +240,19 @@ pub fn rows<R>(ui: &mut Ui, add: impl FnOnce(&mut Ui) -> R) -> R { /// A text dropdown field (font name, size): shows `current`, opens a popup with `add_items`. pub fn dropdown(ui: &mut Ui, id: &str, current: &str, width: f32, enabled: bool, add_items: impl FnOnce(&mut Ui)) -> Response {+ dropdown_impl(ui, id, current, width, enabled, false, |ui, _| add_items(ui))+}++/// A [`dropdown`] with a type-ahead filter (#933): the popup opens with a field at the top, and+/// whatever is typed there reaches `add_items` as its second argument, which narrows its list to+/// those entries. The text belongs to one opening — a closed popup leaves none behind — and the+/// field takes focus when the popup opens, so typing filters at once (the app's own shortcuts are+/// parked while a field is focused, see `SlideApp::keyboard`).+pub fn dropdown_filtered(ui: &mut Ui, id: &str, current: &str, width: f32, enabled: bool, add_items: impl FnOnce(&mut Ui, &str)) -> Response {+ dropdown_impl(ui, id, current, width, enabled, true, add_items)+}++fn dropdown_impl(ui: &mut Ui, id: &str, current: &str, width: f32, enabled: bool, filtered: bool, add_items: impl FnOnce(&mut Ui, &str)) -> Response { let t = Tokens::get(ui.ctx()); let (rect, resp) = ui.allocate_exact_size(vec2(width, 22.0), if enabled { Sense::click() } else { Sense::hover() }); let painter = ui.painter_at(rect);@@ -260,8 +273,22 @@ pub fn dropdown(ui: &mut Ui, id: &str, current: &str, width: f32, enabled: bool, ); chevron(&painter, pos2(rect.max.x - 9.0, rect.center().y), 3.0, t.text_dim); let _ = id;- egui::Popup::menu(&resp).width(width.max(140.0)).show(|ui| {- egui::ScrollArea::vertical().max_height(360.0).show(ui, add_items);+ let typed_id = resp.id.with("typeahead");+ let popup = egui::Popup::menu(&resp).width(width.max(140.0));+ if !filtered || !popup.is_open() {+ ui.data_mut(|d| d.remove::<String>(typed_id));+ }+ popup.show(|ui| {+ let mut typed = String::new();+ if filtered {+ typed = ui.data_mut(|d| d.get_temp::<String>(typed_id).unwrap_or_default());+ let te = ui.add(egui::TextEdit::singleline(&mut typed).hint_text("Type to filter…").desired_width(f32::INFINITY));+ ui.data_mut(|d| d.insert_temp(typed_id, typed.clone()));+ if !te.has_focus() && typed.is_empty() {+ te.request_focus();+ }+ }+ egui::ScrollArea::vertical().max_height(360.0).show(ui, |ui| add_items(ui, &typed)); }); resp }