collective

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

diff --git a/ROADMAP.md b/ROADMAP.md--- a/ROADMAP.md+++ b/ROADMAP.md@@ -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) |diff --git a/crates/ui-egui/src/font_filter_tests.rs b/crates/ui-egui/src/font_filter_tests.rsnew file mode 100644--- /dev/null+++ b/crates/ui-egui/src/font_filter_tests.rs@@ -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());+}diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs--- a/crates/ui-egui/src/lib.rs+++ b/crates/ui-egui/src/lib.rs@@ -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;diff --git a/crates/ui-egui/src/ribbon.rs b/crates/ui-egui/src/ribbon.rs--- a/crates/ui-egui/src/ribbon.rs+++ b/crates/ui-egui/src/ribbon.rs@@ -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();                         }                     }diff --git a/crates/ui-egui/src/widgets.rs b/crates/ui-egui/src/widgets.rs--- a/crates/ui-egui/src/widgets.rs+++ b/crates/ui-egui/src/widgets.rs@@ -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 }