Files
wordcraft/crates/ui-egui/src/widgets.rs
T
34511263a7 Customize Ribbon and Quick Access Toolbar (#379) (#384)
* Customize Keyboard dialog (#368)

Tools > Customize Keyboard (tools.customizeKeyboard): pick a category and
a command, see its keys, press a combination to see which command uses it,
then Assign / Remove / Reset All. Also reachable from File > Options
(Keyboard shortcuts: Customize...) and command search.

- engine: KeyMap (assigned key -> command, removed built-in keys), consulted
  before the registry's built-in shortcuts; keys compared in one canonical
  form. Saved maps read leniently: junk entries and unknown commands are
  dropped, never failing the rest of the preferences.
- tools.customizeKeyboard: with params (assign/remove/reset/command/key) it
  only changes or reports the map; without params it opens the dialog.
- ui: key dispatch and menu/tooltip shortcut text use the user map; the map
  persists in ui.json; more keys (Q, F1-F12, 4/6/7, Insert) can be bound.
- i18n strings in every catalog; docs/ui-parity, gaps #24, ROADMAP log.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Customize Ribbon and Quick Access Toolbar (#379)

Tools > Customize Ribbon (tools.customizeRibbon): one dialog with two
pages. Ribbon: commands by category next to the ribbon's tabs; show or
hide and reorder tabs, add custom tabs and custom groups (on built-in or
custom tabs) holding any commands, rename, remove, reset. Quick Access
Toolbar: add, remove and reorder its commands, reset.

- engine: RibbonLayout (tab order and visibility, custom groups, the
  toolbar's commands). Saved layouts read leniently: junk, duplicates,
  reserved names and unknown commands are dropped.
- tools.customizeRibbon: with params (hide/show, newTab, newGroup, add,
  remove, rename, move, qatAdd/qatRemove/qatMove, reset, layout) it only
  changes or reports the layout; without params it opens the dialog.
- ui: the tab strip and ribbon follow the layout (custom groups draw the
  commands' own icons, else a lettered tile); the Quick Access Toolbar
  shows the user's commands; right-click any ribbon button to add it to
  (or remove it from) the toolbar or open the dialog; the toolbar's ...
  button opens a menu of common commands and More Commands... (#376);
  File > Options has a second Customize button; layout persists in
  ui.json under `ribbon`.
- Open and Print icons drawn in code; i18n strings in every catalog;
  docs/ui-parity, gaps #24, ROADMAP log, parity checklist.

Fixes #376.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
2026-10-11 12:06:17 -04:00

875 lines
38 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Ribbon widgets: large and small buttons, split buttons, groups, combo fields, colour grids.
use egui::{Align2, Color32, CornerRadius, Rect, Response, Sense, Stroke, Ui, pos2, vec2};
use serde_json::Value;
use crate::theme::{Tokens, regular};
use crate::{WordApp, icons};
/// Height of the ribbon content area (without group labels).
pub const CONTENT_H: f32 = 66.0;
pub const LABEL_H: f32 = 16.0;
/// Shortcut text for a command (`⌘B` on macOS, `Ctrl+B` elsewhere): its first key in effect,
/// custom keys included (Customize Keyboard).
pub fn shortcut_text(app: &WordApp, id: &str) -> String {
let keys = app.session.keymap.keys_for(&app.session.registry, id);
keys.first().map(|k| key_text(k)).unwrap_or_default()
}
/// A key as the platform writes it (`⌘⇧K` on macOS, `Ctrl+Shift+K` elsewhere).
pub fn key_text(sc: &str) -> String {
if sc.is_empty() {
return String::new();
}
if cfg!(target_os = "macos") {
sc.replace("Mod+", "⌘").replace("Shift+", "⇧").replace("Alt+", "⌥").replace("Ctrl+", "⌃")
} else {
sc.replace("Mod+", "Ctrl+")
}
}
/// `label` is already in the interface language.
fn tooltip(app: &WordApp, resp: Response, label: &str, id: &str) -> Response {
let sc = shortcut_text(app, id);
let desc = app.session.registry.get(id).map(|s| crate::i18n::location(s.location)).unwrap_or_default();
let enabled = app.session.registry.get(id).map(|s| (s.enabled)(&app.session).is_none()).unwrap_or(true);
resp.on_hover_ui(|ui| {
ui.set_max_width(260.0);
if sc.is_empty() {
ui.label(egui::RichText::new(label).font(crate::theme::semibold(12.0)));
} else {
ui.label(egui::RichText::new(format!("{label} ({sc})")).font(crate::theme::semibold(12.0)));
}
if !desc.is_empty() {
ui.label(egui::RichText::new(desc).small().weak());
}
if !enabled {
ui.label(egui::RichText::new(tl!("Not available right now")).small().weak());
}
})
}
/// Record a keytip badge over a control's rect when keytips are showing commands. `ui` reaches
/// the phase and badge list through egui memory, so widgets without `&mut WordApp` can record too.
fn keytip_badge(ui: &Ui, rect: Rect, id: &str, name: &str) {
if ui.data(|d| d.get_temp::<crate::keytips::Phase>(crate::keytips::phase_id())) != Some(crate::keytips::Phase::Commands) {
return;
}
let tab = ui.data(|d| d.get_temp::<String>(crate::keytips::tab_id())).unwrap_or_default();
let controls = crate::keytips::controls_public(&tab);
// Match by command id first; fall back to tooltip prefix, since widget tooltips carry
// extra text ("Paste (⌘V)") the canonical control name does not.
let Some(i) = controls
.iter()
.position(|c| c.id == id && id != "ui.dialog")
.or_else(|| controls.iter().position(|c| c.tip == name))
.or_else(|| controls.iter().position(|c| name.starts_with(c.tip)))
else {
return;
};
let letter = ui.data(|d| d.get_temp::<Vec<String>>(crate::keytips::letters_id().with(&tab))).and_then(|l| l.get(i).cloned());
if let Some(l) = letter {
ui.data_mut(|d| {
let mut v: Vec<(Rect, String)> = d.get_temp::<Vec<(Rect, String)>>(crate::keytips::rects_id()).unwrap_or_default();
v.push((rect, l));
d.insert_temp(crate::keytips::rects_id(), v);
});
}
}
/// Whether a command exists and is enabled.
pub fn enabled(app: &WordApp, id: &str) -> bool {
match app.session.registry.get(id) {
Some(s) => (s.enabled)(&app.session).is_none(),
// Zotero: one command at a time, and only in the desktop app.
None if id.starts_with("ui.zotero.") => app.zotero.busy.is_none() && !cfg!(target_arch = "wasm32"),
None => id.starts_with("ui."),
}
}
fn bg(ui: &Ui, r: Rect, resp: &Response, checked: bool, t: &Tokens) {
let fill = if resp.is_pointer_button_down_on() {
t.pressed
} else if checked {
t.checked
} else if resp.hovered() {
t.hover
} else {
return;
};
ui.painter().rect_filled(r, CornerRadius::same(4), fill);
if checked {
ui.painter().rect_stroke(r, CornerRadius::same(4), Stroke::new(1.0, t.accent.linear_multiply(0.5)), egui::StrokeKind::Inside);
}
}
/// A large ribbon button: 32 px icon over a (possibly two-line) label.
pub fn big(ui: &mut Ui, app: &mut WordApp, icon: &str, label: &str, id: &str, params: Value, menu: bool) -> Response {
big_button(ui, app, icon, label, id, params, menu, false)
}
/// A large ribbon button for a mode that stays on (`checked` draws it pressed), run without params.
pub fn big_toggle(ui: &mut Ui, app: &mut WordApp, icon: &str, label: &str, id: &str, checked: bool) -> Response {
big_button(ui, app, icon, label, id, Value::Object(Default::default()), false, checked)
}
#[allow(clippy::too_many_arguments)]
fn big_button(ui: &mut Ui, app: &mut WordApp, icon: &str, label: &str, id: &str, params: Value, menu: bool, checked: bool) -> Response {
let label = tl!(label);
let t = Tokens::get(ui.ctx());
let galley_w =
label.split('\n').map(|l| ui.ctx().fonts_mut(|f| f.layout_no_wrap(l.to_string(), regular(11.5), t.text).size().x)).fold(0.0, f32::max);
let w = (galley_w + 12.0).max(44.0);
let (r, resp) = ui.allocate_exact_size(vec2(w, CONTENT_H), Sense::click());
keytip_badge(ui, r, id, &label.replace('\n', " "));
let on = enabled(app, id);
bg(ui, r, &resp, checked, &t);
let ic = Rect::from_center_size(pos2(r.center().x, r.min.y + 20.0), vec2(32.0, 32.0));
let (c, a) = if on { (t.icon, t.accent) } else { (t.text_disabled, t.text_disabled) };
icons::paint(ui.painter(), ic, icon, c, a);
let mut y = r.min.y + 43.0;
let lines: Vec<&str> = label.split('\n').collect();
for (i, l) in lines.iter().enumerate() {
let txt = if menu && i + 1 == lines.len() { format!("{l} ▾") } else { (*l).to_string() };
ui.painter().text(pos2(r.center().x, y), Align2::CENTER_TOP, txt, regular(11.5), if on { t.text } else { t.text_disabled });
y += 13.0;
}
let resp = tooltip(app, resp, &label.replace('\n', " "), id);
command_menu(&resp, app, id);
if resp.clicked() && on && !menu {
let _ = app.run(id, params);
}
resp
}
/// A small button: 16 px icon with an optional label; `checked` draws the toggled state.
pub fn small(ui: &mut Ui, app: &mut WordApp, icon: &str, label: Option<&str>, tip: &str, id: &str, params: Value, checked: bool) -> Response {
let (label, tip) = (label.map(|l| tl!(l)), tl!(tip));
let t = Tokens::get(ui.ctx());
let text_w = label.map(|l| ui.ctx().fonts_mut(|f| f.layout_no_wrap(l.to_string(), regular(11.5), t.text).size().x) + 6.0).unwrap_or(0.0);
let (r, resp) = ui.allocate_exact_size(vec2(24.0 + text_w, 22.0), Sense::click());
keytip_badge(ui, r, id, tip);
let on = enabled(app, id);
bg(ui, r, &resp, checked, &t);
let ic = Rect::from_center_size(pos2(r.min.x + 12.0, r.center().y), vec2(17.0, 17.0));
let (c, a) = if on { (t.icon, t.accent) } else { (t.text_disabled, t.text_disabled) };
icons::paint(ui.painter(), ic, icon, c, a);
if let Some(l) = label {
ui.painter().text(pos2(r.min.x + 24.0, r.center().y), Align2::LEFT_CENTER, l, regular(11.5), if on { t.text } else { t.text_disabled });
}
let resp = tooltip(app, resp, tip, id);
command_menu(&resp, app, id);
if resp.clicked() && on {
let _ = app.run(id, params);
}
resp
}
/// A split button: the left part runs `id`, the arrow opens a menu filled by `menu`.
pub fn split(
ui: &mut Ui,
app: &mut WordApp,
icon: &str,
tip: &str,
id: &str,
params: Value,
checked: bool,
swatch: Option<Color32>,
menu: impl FnOnce(&mut Ui, &mut WordApp),
) {
let tip = tl!(tip);
let t = Tokens::get(ui.ctx());
let (r, resp) = ui.allocate_exact_size(vec2(24.0, 22.0), Sense::click());
keytip_badge(ui, r, id, tip);
let (ar, aresp) = ui.allocate_exact_size(vec2(11.0, 22.0), Sense::click());
bg(ui, r, &resp, checked, &t);
bg(ui, ar, &aresp, false, &t);
let ic = Rect::from_center_size(r.center(), vec2(17.0, 17.0));
icons::paint(ui.painter(), ic, icon, t.icon, t.accent);
if let Some(sw) = swatch {
ui.painter().rect_filled(Rect::from_min_max(pos2(r.min.x + 4.0, r.max.y - 5.0), pos2(r.max.x - 4.0, r.max.y - 2.0)), 0.0, sw);
}
icons::paint(ui.painter(), Rect::from_center_size(ar.center(), vec2(10.0, 10.0)), "dropdown", t.icon, t.accent);
let resp = tooltip(app, resp, tip, id);
command_menu(&resp, app, id);
if resp.clicked() {
let _ = app.run(id, params);
}
egui::Popup::menu(&aresp).show(|ui| {
ui.set_min_width(180.0);
menu(ui, app);
});
}
/// A button that only opens a menu (icon + label + ▾).
pub fn menu_button(
ui: &mut Ui,
app: &mut WordApp,
icon: &str,
label: Option<&str>,
tip: &str,
big_btn: bool,
menu: impl FnOnce(&mut Ui, &mut WordApp),
) {
let (label, tip) = (label.map(|l| tl!(l)), tl!(tip));
let t = Tokens::get(ui.ctx());
let resp = if big_btn {
let galley_w = label
.unwrap_or("")
.split('\n')
.map(|l| ui.ctx().fonts_mut(|f| f.layout_no_wrap(l.to_string(), regular(11.5), t.text).size().x))
.fold(0.0, f32::max);
let w = (galley_w + 22.0).max(44.0);
let (r, resp) = ui.allocate_exact_size(vec2(w, CONTENT_H), Sense::click());
keytip_badge(ui, r, "menu", tip);
bg(ui, r, &resp, false, &t);
icons::paint(ui.painter(), Rect::from_center_size(pos2(r.center().x, r.min.y + 20.0), vec2(32.0, 32.0)), icon, t.icon, t.accent);
let lines: Vec<&str> = label.unwrap_or("").split('\n').collect();
let mut y = r.min.y + 43.0;
for (i, l) in lines.iter().enumerate() {
let txt = if i + 1 == lines.len() { format!("{l} ▾") } else { (*l).to_string() };
ui.painter().text(pos2(r.center().x, y), Align2::CENTER_TOP, txt, regular(11.5), t.text);
y += 13.0;
}
resp
} else {
let text_w = label.map(|l| ui.ctx().fonts_mut(|f| f.layout_no_wrap(l.to_string(), regular(11.5), t.text).size().x) + 6.0).unwrap_or(0.0);
let (r, resp) = ui.allocate_exact_size(vec2(24.0 + text_w + 10.0, 22.0), Sense::click());
keytip_badge(ui, r, "menu", tip);
bg(ui, r, &resp, false, &t);
icons::paint(ui.painter(), Rect::from_center_size(pos2(r.min.x + 12.0, r.center().y), vec2(17.0, 17.0)), icon, t.icon, t.accent);
if let Some(l) = label {
ui.painter().text(pos2(r.min.x + 24.0, r.center().y), Align2::LEFT_CENTER, l, regular(11.5), t.text);
}
icons::paint(ui.painter(), Rect::from_center_size(pos2(r.max.x - 6.0, r.center().y), vec2(9.0, 9.0)), "dropdown", t.icon, t.accent);
resp
};
let resp = resp.on_hover_text(tip);
egui::Popup::menu(&resp).show(|ui| {
ui.set_min_width(200.0);
menu(ui, app);
});
}
/// A ribbon group: content, a centred label below and a divider on the right.
pub fn group(ui: &mut Ui, title: &str, launcher: Option<&str>, app: &mut WordApp, add: impl FnOnce(&mut Ui, &mut WordApp)) {
// The English title keys the launcher's id; the drawn title is translated.
group_titled(ui, title, tl!(title), launcher, app, add);
}
/// A ribbon group whose title is shown as is (a custom group's name, Customize Ribbon).
pub fn group_titled(ui: &mut Ui, id_title: &str, title: &str, launcher: Option<&str>, app: &mut WordApp, add: impl FnOnce(&mut Ui, &mut WordApp)) {
let t = Tokens::get(ui.ctx());
let label_w = ui.ctx().fonts_mut(|f| f.layout_no_wrap(title.to_string(), regular(11.0), t.group_label).size().x);
let start = ui.cursor().min;
let inner = ui.vertical(|ui| {
ui.set_min_width(label_w + if launcher.is_some() { 22.0 } else { 10.0 });
ui.allocate_ui_with_layout(vec2(ui.available_width(), CONTENT_H), egui::Layout::left_to_right(egui::Align::Min), |ui| {
ui.spacing_mut().item_spacing = vec2(1.0, 1.0);
add(ui, app);
});
});
let r = inner.response.rect;
let r = Rect::from_min_max(start, pos2(r.max.x, start.y + CONTENT_H + LABEL_H));
ui.painter().text(pos2(r.center().x, r.max.y - 8.0), Align2::CENTER_CENTER, title, regular(11.0), t.group_label);
if let Some(cmd) = launcher {
let lr = Rect::from_center_size(pos2(r.max.x - 6.0, r.max.y - 8.0), vec2(11.0, 11.0));
let resp = ui.interact(lr, ui.id().with(("launch", id_title)), Sense::click());
if resp.hovered() {
ui.painter().rect_filled(lr.expand(2.0), 2.0, t.hover);
}
icons::paint(ui.painter(), lr, "launcher", t.text_dim, t.accent);
if resp.on_hover_text(crate::i18n::fmt(tl!("{group} settings"), &[("group", title)])).clicked() {
let _ = app.run(cmd, serde_json::json!({}));
}
}
ui.add_space(5.0);
let x = ui.cursor().min.x;
ui.painter().line_segment([pos2(x, start.y + 4.0), pos2(x, start.y + CONTENT_H + LABEL_H - 4.0)], Stroke::new(1.0, t.border));
ui.add_space(6.0);
}
/// The icon a command's button shows outside its own ribbon group (custom groups, the Quick
/// Access Toolbar): the one its ribbon button uses, which is usually named after the id's last
/// part. Names the icon set doesn't have draw a lettered tile ([`icons::paint`]).
pub fn command_icon(id: &str) -> &str {
const ICONS: &[(&str, &str)] = &[
("arrange.wrap", "wrapText"),
("draw.highlighter", "highlight"),
("format.border", "charborder"),
("format.color", "fontcolor"),
("format.growFont", "grow"),
("format.shrinkFont", "shrink"),
("format.strikethrough", "strike"),
("format.highlight", "highlight"),
("insert.signatureLine", "signature"),
("mailings.envelopes", "envelope"),
("mailings.insertField", "mergeField"),
("para.ltr", "textLtr"),
("para.rtl", "textRtl"),
("para.bullets", "bullets"),
("para.numbering", "numbering"),
("table.insertRowAbove", "insertAbove"),
("view.commentsPane", "showComments"),
("view.marks", "pilcrow"),
("edit.repeat", "redo"),
("file.new", "blankPage"),
("tools.customizeRibbon", "more"),
];
ICONS.iter().find(|(c, _)| *c == id).map_or_else(|| id.rsplit('.').next().unwrap_or(id), |(_, icon)| *icon)
}
/// Right-click on a command's button: put it on (or take it off) the Quick Access Toolbar, or
/// open Customize Ribbon (#379).
pub fn command_menu(resp: &Response, app: &mut WordApp, id: &str) {
resp.context_menu(|ui| {
let known = app.session.registry.get(id).is_some();
let on_qat = app.session.ribbon.qat().contains(&id);
if known && !on_qat && ui.button(tl!("Add to Quick Access Toolbar")).clicked() {
let _ = app.run("tools.customizeRibbon", serde_json::json!({"qatAdd": id}));
ui.close();
}
if on_qat && ui.button(tl!("Remove from Quick Access Toolbar")).clicked() {
let _ = app.run("tools.customizeRibbon", serde_json::json!({"qatRemove": id}));
ui.close();
}
if known {
ui.separator();
}
customize_menu_items(ui, app);
});
}
/// "Customize the Ribbon…" and "Customize Quick Access Toolbar…" (context menus).
pub fn customize_menu_items(ui: &mut Ui, app: &mut WordApp) {
if ui.button(tl!("Customize the Ribbon…")).clicked() {
crate::dialogs_ribbon::open(app, crate::dialogs_ribbon::Page::Ribbon);
ui.close();
}
if ui.button(tl!("Customize Quick Access Toolbar…")).clicked() {
crate::dialogs_ribbon::open(app, crate::dialogs_ribbon::Page::QuickAccess);
ui.close();
}
}
/// A row of small buttons (used inside a vertical stack).
pub fn row(ui: &mut Ui, add: impl FnOnce(&mut Ui)) {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing = vec2(1.0, 0.0);
add(ui);
});
}
/// Word's standard colour grid (theme colours + tints, standard colours). Returns a picked hex.
pub fn color_grid(ui: &mut Ui, theme: &[wordcraft_doc::Rgb]) -> Option<String> {
let mut picked = None;
ui.label(egui::RichText::new(tl!("Theme Colors")).small().weak());
let base: Vec<wordcraft_doc::Rgb> = theme.iter().take(10).copied().collect();
let tint = |c: wordcraft_doc::Rgb, k: f32| {
let f = |v: u8| if k >= 0.0 { (v as f32 + (255.0 - v as f32) * k) as u8 } else { (v as f32 * (1.0 + k)) as u8 };
wordcraft_doc::Rgb(f(c.0), f(c.1), f(c.2))
};
let order = [1usize, 0, 3, 2, 4, 5, 6, 7, 8, 9];
for k in [0.0f32, 0.8, 0.6, 0.4, -0.25, -0.5] {
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing = vec2(3.0, if k == 0.0 { 6.0 } else { 0.0 });
for i in order {
let Some(c) = base.get(i).copied() else { continue };
let c = tint(c, k);
if swatch(ui, c) {
picked = Some(c.hex());
}
}
});
}
ui.add_space(4.0);
ui.label(egui::RichText::new(tl!("Standard Colors")).small().weak());
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing = vec2(3.0, 0.0);
for hex in ["C00000", "FF0000", "FFC000", "FFFF00", "92D050", "00B050", "00B0F0", "0070C0", "002060", "7030A0"] {
if let Some(c) = wordcraft_doc::Rgb::parse(hex)
&& swatch(ui, c)
{
picked = Some(hex.to_string());
}
}
});
picked
}
fn swatch(ui: &mut Ui, c: wordcraft_doc::Rgb) -> bool {
let (r, resp) = ui.allocate_exact_size(vec2(15.0, 15.0), Sense::click());
ui.painter().rect_filled(r, 0.0, Color32::from_rgb(c.0, c.1, c.2));
let t = Tokens::get(ui.ctx());
ui.painter().rect_stroke(
r,
0.0,
Stroke::new(if resp.hovered() { 1.5 } else { 0.5 }, if resp.hovered() { t.accent } else { t.border_strong }),
egui::StrokeKind::Inside,
);
resp.on_hover_text(format!("#{}", c.hex())).clicked()
}
/// A ribbon-style text field with a dropdown list. Returns a chosen/typed value; Enter commits
/// the typed text as is (the font size box relies on that: "1" must not become "10").
pub fn combo(
ui: &mut Ui,
id: &str,
width: f32,
current: &str,
items: &[String],
preview: Option<&dyn Fn(&mut Ui, &str) -> Response>,
) -> Option<String> {
combo_impl(ui, id, width, current, items, preview, false)
}
/// The font box: a [`combo`] where typing lists the matching families under the field (#359).
/// Up/Down move the highlight, Enter or a click applies the highlighted family ("arial" applies
/// "Arial"), Escape restores the current font. Text matching no family is applied as typed, as
/// Word allows fonts that are not installed.
pub fn font_combo(
ui: &mut Ui,
id: &str,
width: f32,
current: &str,
items: &[String],
preview: Option<&dyn Fn(&mut Ui, &str) -> Response>,
) -> Option<String> {
combo_impl(ui, id, width, current, items, preview, true)
}
/// Indices of the `items` matching `query`, ignoring case: exact matches, then the names starting
/// with it, then those containing it, each group in list order. A blank query matches everything.
pub fn font_matches(query: &str, items: &[String]) -> Vec<usize> {
let q = query.trim().to_lowercase();
if q.is_empty() {
return (0..items.len()).collect();
}
let (mut exact, mut prefix, mut inner) = (Vec::new(), Vec::new(), Vec::new());
for (i, it) in items.iter().enumerate() {
let name = it.to_lowercase();
if name == q {
exact.push(i);
} else if name.starts_with(&q) {
prefix.push(i);
} else if name.contains(&q) {
inner.push(i);
}
}
exact.extend(prefix);
exact.extend(inner);
exact
}
/// A searching combo's state between frames.
#[derive(Clone, Default)]
struct ComboSearch {
/// The match list is showing (the user typed since focusing the field).
open: bool,
/// The query `matches` were computed for, and the list length then.
query: String,
items_len: usize,
matches: std::sync::Arc<Vec<usize>>,
/// Index into `matches`.
highlight: usize,
/// Where the list was drawn last frame, its scroll offset and visible height.
popup: Option<Rect>,
offset: f32,
view_h: f32,
}
fn combo_impl(
ui: &mut Ui,
id: &str,
width: f32,
current: &str,
items: &[String],
preview: Option<&dyn Fn(&mut Ui, &str) -> Response>,
search: bool,
) -> Option<String> {
let mut out = None;
let edit_id = ui.id().with(("combo_text", id));
let te_id = ui.id().with(("combo_edit", id));
let search_id = ui.id().with(("combo_search", id));
let mut text = ui.data_mut(|d| d.get_temp::<String>(edit_id)).unwrap_or_else(|| current.to_string());
let mut s: ComboSearch = if search { ui.data_mut(|d| d.get_temp(search_id)).unwrap_or_default() } else { ComboSearch::default() };
let mut focused = false;
let t = Tokens::get(ui.ctx());
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing = vec2(0.0, 0.0);
// Up/Down move the list's highlight: take them before the text field moves its cursor.
let mut step = 0i32;
if s.open && ui.memory(|m| m.has_focus(te_id)) {
ui.input_mut(|i| {
if i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowDown) {
step += 1;
}
if i.consume_key(egui::Modifiers::NONE, egui::Key::ArrowUp) {
step -= 1;
}
});
}
let te = egui::TextEdit::singleline(&mut text).id(te_id).desired_width(width - 16.0).font(regular(12.0)).margin(vec2(4.0, 3.0));
let resp = ui.add(te);
keytip_badge(ui, resp.rect, "combo", id);
focused = resp.has_focus();
let enter = resp.lost_focus() && ui.input(|i| i.key_pressed(egui::Key::Enter));
let mut reveal = step != 0;
if search && resp.changed() {
s.open = true;
s.highlight = 0;
reveal = true;
}
let mut regain_focus = false;
if s.open {
if s.query != text || s.items_len != items.len() {
s.matches = std::sync::Arc::new(font_matches(&text, items));
s.query = text.clone();
s.items_len = items.len();
}
let last = s.matches.len().saturating_sub(1);
s.highlight = match step {
1.. => s.highlight.saturating_add(1),
0 => s.highlight,
_ => s.highlight.saturating_sub(1),
}
.min(last);
if enter {
let picked = s.matches.get(s.highlight).and_then(|&i| items.get(i));
out = Some(match picked {
Some(name) if !text.trim().is_empty() => name.clone(),
_ => text.clone(),
});
s.open = false;
} else if ui.input(|i| i.key_pressed(egui::Key::Escape) || i.key_pressed(egui::Key::Tab)) {
s.open = false;
} else if !focused {
// A click in the list takes focus from the field: keep the list, give focus back.
let over = ui.input(|i| i.pointer.latest_pos()).is_some_and(|p| s.popup.is_some_and(|r| r.contains(p)));
if over {
regain_focus = true;
} else {
s.open = false;
}
}
} else if enter {
out = Some(text.clone());
}
let (ar, aresp) = ui.allocate_exact_size(vec2(16.0, resp.rect.height()), Sense::click());
ui.painter().rect(ar, 0.0, if aresp.hovered() { t.hover } else { t.input }, Stroke::new(1.0, t.input_border), egui::StrokeKind::Inside);
icons::paint(ui.painter(), Rect::from_center_size(ar.center(), vec2(9.0, 9.0)), "dropdown", t.icon, t.accent);
egui::Popup::menu(&aresp).show(|ui| {
if let Some(v) = combo_list(ui, width, current, items, preview) {
out = Some(v);
ui.close();
}
});
if s.open && !s.matches.is_empty() {
let matches = s.matches.clone();
let scroll = reveal.then_some((s.offset, s.view_h));
let shown = egui::Area::new(search_id.with("list")).order(egui::Order::Tooltip).fixed_pos(resp.rect.left_bottom()).show(ui.ctx(), |ui| {
egui::Frame::popup(ui.style())
.show(ui, |ui| {
let item = |k: usize| matches.get(k).and_then(|&i| items.get(i));
list_rows(ui, width, current, matches.len(), &item, Some(s.highlight), scroll, preview)
})
.inner
});
s.popup = Some(shown.response.rect);
let (picked, offset, view_h) = shown.inner;
(s.offset, s.view_h) = (offset, view_h);
if let Some(v) = picked {
out = Some(v);
s.open = false;
regain_focus = false;
ui.memory_mut(|m| m.surrender_focus(te_id));
}
} else {
s.popup = None;
}
if regain_focus && s.open {
ui.memory_mut(|m| m.request_focus(te_id));
}
});
let keep = (focused || s.open) && out.is_none();
ui.data_mut(|d| {
if keep {
d.insert_temp(edit_id, text);
} else {
d.remove::<String>(edit_id);
}
if search {
d.insert_temp(search_id, s);
}
});
out
}
/// Height of a row drawn by a combo's preview closure (`Previews::font_preview_fn`'s entries).
pub const COMBO_PREVIEW_ROW_H: f32 = 24.0;
/// A combo's scrolling list. Only the rows in view are built: the font menu lists every installed
/// family and each preview loads that font, so building all rows loaded every font on the system
/// (gigabytes on large font collections) and froze the app (#121).
pub fn combo_list(ui: &mut Ui, width: f32, current: &str, items: &[String], preview: Option<&dyn Fn(&mut Ui, &str) -> Response>) -> Option<String> {
list_rows(ui, width, current, items.len(), &|k| items.get(k), None, None, preview).0
}
/// The rows of [`combo_list`], virtualised, for `n` items fetched by index. With a `highlight`
/// that row is marked instead of `current`; `scroll` (last frame's offset and visible height)
/// scrolls the highlight into view. Returns the clicked item, the scroll offset and the height.
fn list_rows<'a>(
ui: &mut Ui,
width: f32,
current: &str,
n: usize,
item: &dyn Fn(usize) -> Option<&'a String>,
highlight: Option<usize>,
scroll: Option<(f32, f32)>,
preview: Option<&dyn Fn(&mut Ui, &str) -> Response>,
) -> (Option<String>, f32, f32) {
let mut out = None;
let row_h = if preview.is_some() { COMBO_PREVIEW_ROW_H } else { ui.spacing().interact_size.y };
let mut area = egui::ScrollArea::vertical().max_height(420.0);
if let (Some(h), Some((offset, view_h))) = (highlight, scroll) {
let top = h as f32 * (row_h + ui.spacing().item_spacing.y);
let bottom = top + row_h;
let offset = if top < offset || view_h <= 0.0 {
top
} else if bottom > offset + view_h {
bottom - view_h
} else {
offset
};
area = area.vertical_scroll_offset(offset.max(0.0));
}
let fill = Tokens::get(ui.ctx()).checked;
let shown = area.show_rows(ui, row_h, n, |ui, range| {
ui.set_min_width(width + 60.0);
for k in range {
let Some(it) = item(k) else { continue };
let lit = highlight == Some(k);
let clicked = match preview {
Some(p) => {
if lit {
let r = Rect::from_min_size(ui.cursor().min, vec2(ui.available_width(), row_h));
ui.painter().rect_filled(r, 3.0, fill);
}
p(ui, it).clicked()
}
None => ui.selectable_label(if highlight.is_some() { lit } else { it == current }, it.as_str()).clicked(),
};
if clicked {
out = Some(it.clone());
}
}
});
(out, shown.state.offset.y, shown.inner_rect.height())
}
/// How a drawn button looks this frame. Hover fades in over egui's animation time and the
/// pressed state blends in while the pointer is down, as on egui's own buttons. Framed buttons
/// take egui's widget visuals (fill, stroke, text colour, expansion) so they match `ui.button`;
/// frameless ones take the ribbon's hover/pressed tokens, like [`small`] and [`big`].
pub struct DrawnState {
pub fill: Color32,
pub stroke: Stroke,
pub text: Color32,
pub expansion: f32,
}
pub fn drawn_state(ui: &Ui, resp: &Response, framed: bool) -> DrawnState {
let pressed = resp.is_pointer_button_down_on();
let h = ui.ctx().animate_bool_responsive(resp.id.with("hover"), resp.hovered() || pressed);
let p = ui.ctx().animate_bool_with_time(resp.id.with("press"), pressed, 0.06);
if framed {
let w = &ui.visuals().widgets;
let mix = |a: Color32, b: Color32, c: Color32| a.lerp_to_gamma(b, h).lerp_to_gamma(c, p);
let num = |a: f32, b: f32, c: f32| egui::lerp(egui::lerp(a..=b, h)..=c, p);
DrawnState {
fill: mix(w.inactive.weak_bg_fill, w.hovered.weak_bg_fill, w.active.weak_bg_fill),
stroke: Stroke::new(
num(w.inactive.bg_stroke.width, w.hovered.bg_stroke.width, w.active.bg_stroke.width),
mix(w.inactive.bg_stroke.color, w.hovered.bg_stroke.color, w.active.bg_stroke.color),
),
text: mix(w.inactive.fg_stroke.color, w.hovered.fg_stroke.color, w.active.fg_stroke.color),
expansion: num(w.inactive.expansion, w.hovered.expansion, w.active.expansion),
}
} else {
let t = Tokens::get(ui.ctx());
DrawnState {
fill: Color32::TRANSPARENT.lerp_to_gamma(t.hover, h).lerp_to_gamma(t.pressed, p),
stroke: Stroke::NONE,
text: t.text,
expansion: 0.0,
}
}
}
/// A frameless 22 px icon button (pane close buttons): ribbon-style hover and press feedback.
pub fn icon_button(ui: &mut Ui, icon: &str, tip: &str) -> Response {
let t = Tokens::get(ui.ctx());
let (r, resp) = ui.allocate_exact_size(vec2(22.0, 22.0), Sense::click());
let st = drawn_state(ui, &resp, false);
ui.painter().rect_filled(r, CornerRadius::same(4), st.fill);
icons::paint(ui.painter(), Rect::from_center_size(r.center(), vec2(14.0, 14.0)), icon, t.icon, t.accent);
resp.on_hover_text(tip)
}
/// A text button with a drawn icon in front. Symbols such as ✎ or 💬 aren't in the interface
/// fonts and would draw as empty boxes, so icons are painted (`icons.rs`) rather than typed.
/// It looks and reacts like `ui.button` (see [`drawn_state`]).
pub fn icon_text_button(ui: &mut Ui, icon: &str, text: &str, font: egui::FontId) -> Response {
let t = Tokens::get(ui.ctx());
let galley = ui.painter().layout_no_wrap(text.to_string(), font, Color32::PLACEHOLDER);
let pad = ui.spacing().button_padding;
let icon_w = 16.0;
let size = vec2(pad.x + icon_w + 4.0 + galley.size().x + pad.x, galley.size().y.max(icon_w) + 2.0 * pad.y);
let (r, resp) = ui.allocate_exact_size(size, Sense::click());
let st = drawn_state(ui, &resp, true);
let r = r.expand(st.expansion);
ui.painter().rect(r, ui.visuals().widgets.inactive.corner_radius, st.fill, st.stroke, egui::StrokeKind::Inside);
icons::paint(
ui.painter(),
Rect::from_center_size(pos2(r.min.x + pad.x + icon_w / 2.0, r.center().y), vec2(icon_w, icon_w)),
icon,
t.icon,
t.accent,
);
ui.painter().galley(pos2(r.min.x + pad.x + icon_w + 4.0, r.center().y - galley.size().y / 2.0), galley, st.text);
resp
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn drawn_buttons_fade_in_on_hover_and_darken_while_pressed() {
// #305's drawn replacements for ✕, 💬, ✎ and ➕ must react like egui's buttons.
let ctx = egui::Context::default();
crate::theme::apply(&ctx, crate::theme::Appearance::Light);
let clock = std::cell::Cell::new(0u32);
let seen = std::cell::Cell::new((Rect::NOTHING, Rect::NOTHING, Color32::PLACEHOLDER, Color32::PLACEHOLDER));
// One 60 fps frame; returns both buttons' rects and fills (framed, frameless).
let frame = |events: Vec<egui::Event>| {
clock.set(clock.get() + 1);
let input = egui::RawInput { time: Some(f64::from(clock.get()) / 60.0), predicted_dt: 1.0 / 60.0, events, ..Default::default() };
ctx.run_ui(input, |ui| {
let framed = icon_text_button(ui, "comment", "Comments", regular(12.0));
let frameless = icon_button(ui, "close", "Close");
let (a, b) = (drawn_state(ui, &framed, true).fill, drawn_state(ui, &frameless, false).fill);
seen.set((framed.rect, frameless.rect, a, b));
})
.drop_without_applying_deltas();
seen.get()
};
// Half a second of frames, so animations finish; returns the last one.
let settle = || {
for _ in 0..29 {
frame(vec![]);
}
frame(vec![])
};
let press = |pos, pressed| egui::Event::PointerButton { pos, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() };
let w = ctx.global_style().visuals.widgets.clone();
let t = Tokens::light();
let (framed, frameless, idle, idle_bare) = settle();
assert_eq!(idle, w.inactive.weak_bg_fill);
assert_eq!(idle_bare, Color32::TRANSPARENT);
// Hover fades in over a few frames rather than jumping, then settles on egui's hovered fill.
let first = frame(vec![egui::Event::PointerMoved(framed.center())]).2;
let between = |c: Color32, a: Color32, b: Color32| a.r().min(b.r()) < c.r() && c.r() < a.r().max(b.r());
assert!(between(first, idle, w.hovered.weak_bg_fill), "hover should animate: {idle:?} → {first:?} → {:?}", w.hovered.weak_bg_fill);
assert_eq!(settle().2, w.hovered.weak_bg_fill);
// Held down: blends to the pressed (active) fill.
frame(vec![press(framed.center(), true)]);
assert_eq!(settle().2, w.active.weak_bg_fill);
frame(vec![press(framed.center(), false)]);
// The frameless close button uses the ribbon's hover and pressed tokens; the other fades out.
frame(vec![egui::Event::PointerMoved(frameless.center())]);
let after = settle();
assert_eq!(after.3, t.hover);
assert_eq!(after.2, w.inactive.weak_bg_fill);
frame(vec![press(frameless.center(), true)]);
assert_eq!(settle().3, t.pressed);
}
#[test]
fn font_matches_puts_exact_and_prefix_before_substring() {
let items: Vec<String> = ["Arial Black", "Arial", "Courier New", "Times New Roman", "Roman Serif"].map(String::from).to_vec();
// Case-insensitive; the exact name first, then prefixes, then other substrings.
assert_eq!(font_matches("ARIAL", &items), vec![1, 0]);
assert_eq!(font_matches("roman", &items), vec![4, 3]);
assert_eq!(font_matches(" new ", &items), vec![2, 3]);
assert!(font_matches("zapfino", &items).is_empty());
assert_eq!(font_matches("", &items), vec![0, 1, 2, 3, 4]);
}
/// Focuses a fresh combo, types `typed`, presses `keys` (one per frame) and returns its value.
fn type_into_combo(search: bool, items: &[String], typed: &str, keys: &[egui::Key]) -> Option<String> {
let ctx = egui::Context::default();
let key = |k| egui::Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers: egui::Modifiers::NONE };
let mut frames = vec![vec![], vec![egui::Event::Text(typed.into())]];
frames.extend(keys.iter().map(|&k| vec![key(k)]));
let mut out = None;
for (n, events) in frames.into_iter().enumerate() {
ctx.run_ui(egui::RawInput { events, ..Default::default() }, |ui| {
if n == 0 {
ui.memory_mut(|m| m.request_focus(ui.id().with(("combo_edit", "f"))));
}
let v = if search { font_combo(ui, "f", 150.0, "", items, None) } else { combo(ui, "f", 150.0, "", items, None) };
out = out.take().or(v);
})
.drop_without_applying_deltas();
}
out
}
#[test]
fn typing_in_the_font_box_finds_the_family() {
// #359: typing "arial" or "roman" picks the installed family; the size box stays literal.
use egui::Key::{ArrowDown, Enter, Escape};
let fonts: Vec<String> = ["Arial", "Arial Black", "Courier New", "Times New Roman"].map(String::from).to_vec();
assert_eq!(type_into_combo(true, &fonts, "arial", &[Enter]).as_deref(), Some("Arial"));
assert_eq!(type_into_combo(true, &fonts, "roman", &[Enter]).as_deref(), Some("Times New Roman"));
assert_eq!(type_into_combo(true, &fonts, "arial", &[ArrowDown, Enter]).as_deref(), Some("Arial Black"));
// Not installed: applied as typed, as Word allows.
assert_eq!(type_into_combo(true, &fonts, "Zapfino", &[Enter]).as_deref(), Some("Zapfino"));
assert_eq!(type_into_combo(true, &fonts, "arial", &[Escape]), None);
let sizes: Vec<String> = ["10", "11", "12"].map(String::from).to_vec();
assert_eq!(type_into_combo(false, &sizes, "1", &[Enter]).as_deref(), Some("1"));
}
#[test]
fn a_long_combo_list_builds_only_the_rows_in_view() {
// #121: the font menu built (and so loaded the font of) every installed family.
let items: Vec<String> = (0..5000).map(|i| format!("Family {i}")).collect();
let ctx = egui::Context::default();
let built = std::cell::RefCell::new(Vec::new());
let row = |ui: &mut Ui, name: &str| {
built.borrow_mut().push(name.to_string());
ui.allocate_exact_size(vec2(260.0, COMBO_PREVIEW_ROW_H), Sense::click()).1
};
for _ in 0..3 {
built.borrow_mut().clear();
ctx.run_ui(egui::RawInput::default(), |ui| {
assert_eq!(combo_list(ui, 150.0, "", &items, Some(&row)), None);
})
.drop_without_applying_deltas();
let b = built.borrow();
assert!(!b.is_empty() && b.len() <= 420 / COMBO_PREVIEW_ROW_H as usize + 2, "built {} rows", b.len());
assert_eq!(b[0], "Family 0");
}
// Without previews too.
ctx.run_ui(egui::RawInput::default(), |ui| {
assert_eq!(combo_list(ui, 52.0, "8", &items, None), None);
})
.drop_without_applying_deltas();
}
}