Skip to main content

ashlar_ui/
form.rs

1//! The settings form pattern: sections of rows (label, description, control), a "modified"
2//! marker with a reset button, a filter that hides rows which do not match a query, and
3//! [`Field`] for a labelled input with a required marker and an announced error.
4//!
5//! Every row's control gets the automation id `<section>.<row>` (passed to the control closure),
6//! so an agent can find and set any preference; its reset button is `<section>.<row>.reset`.
7//! The control's AccessKit node is named by the row label and described by the row description.
8//!
9//! ```no_run
10//! # use ashlar_ui::{controls::Switch, form};
11//! # fn demo(ui: &mut egui::Ui, query: &str, launch: &mut bool) {
12//! form::filter(ui, "settings", query, |f| {
13//!     f.section("general", "General", |s| {
14//!         s.row("launch", "Launch at login")
15//!             .description("Starts with your session")
16//!             .keywords(&["startup", "boot"])
17//!             .bind(launch, false)
18//!             .control(|ui, id, on| Switch::new(id, "", on).show(ui));
19//!     });
20//! });
21//! # }
22//! ```
23//!
24//! Rows render as they are declared, so a row whose control and reset both need the same value
25//! uses [`Row::bind`]; with [`Row::modified`] the caller resets when [`RowOutput::reset`] is set.
26
27use std::ops::Range;
28use std::sync::Arc;
29
30use egui::accesskit::{Invalid, Live};
31use egui::text::{LayoutJob, TextFormat};
32use egui::{Align, Color32, CornerRadius, Layout, Rect, Response, RichText, Sense, Shape, Stroke, StrokeKind, Ui, UiBuilder, pos2};
33
34use crate::automation::{self, Details, Role};
35use crate::content::EmptyState;
36use crate::controls::{Button, Size, Variant};
37use crate::labels::UiLabels;
38use crate::tokens::{self, Tokens};
39
40/// Longest query (in characters) the filter compares; the rest is ignored.
41pub const MAX_QUERY_CHARS: usize = 128;
42
43/// Words the form shows (localized by the app).
44#[derive(Clone, Debug, PartialEq, Eq)]
45pub struct FormLabels {
46    /// Title of the empty state when the filter hides every row.
47    pub no_match: String,
48    /// Body of that empty state.
49    pub no_match_body: String,
50    /// The reset button on a modified row.
51    pub reset: String,
52    /// Spoken name of the required marker on a [`Field`].
53    pub required: String,
54}
55
56impl Default for FormLabels {
57    fn default() -> Self {
58        FormLabels {
59            no_match: "No settings match".into(),
60            no_match_body: "Try a different search.".into(),
61            reset: "Reset".into(),
62            required: "Required".into(),
63        }
64    }
65}
66
67/// The labels a form uses: the app's, or the installed [`crate::labels`].
68#[derive(Clone)]
69enum LabelsRef<'l> {
70    App(&'l FormLabels),
71    Installed(Arc<UiLabels>),
72}
73
74impl LabelsRef<'_> {
75    fn get(&self) -> &FormLabels {
76        match self {
77            LabelsRef::App(l) => l,
78            LabelsRef::Installed(u) => &u.form,
79        }
80    }
81}
82
83/// What a form showed.
84#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
85pub struct FormOutput {
86    /// Rows declared.
87    pub rows: usize,
88    /// Rows shown (all of them when there is no query).
89    pub visible: usize,
90}
91
92impl FormOutput {
93    /// True when the filter hid every row (the empty state was shown).
94    pub fn is_empty(&self) -> bool {
95        self.visible == 0 && self.rows > 0
96    }
97}
98
99/// A form: one or more sections, optionally filtered by a query.
100pub struct Form<'a> {
101    id: &'a str,
102    query: &'a str,
103    labels: Option<&'a FormLabels>,
104}
105
106impl<'a> Form<'a> {
107    /// `id` names the form for automation (its empty state is `<id>.empty`).
108    pub fn new(id: &'a str) -> Self {
109        Form { id, query: "", labels: None }
110    }
111
112    /// Show only rows whose label, description or keywords contain `query` (case-insensitive),
113    /// and every row of a section whose title matches.
114    pub fn query(mut self, query: &'a str) -> Self {
115        self.query = query;
116        self
117    }
118
119    pub fn labels(mut self, labels: &'a FormLabels) -> Self {
120        self.labels = Some(labels);
121        self
122    }
123
124    pub fn show(self, ui: &mut Ui, add: impl FnOnce(&mut FormUi<'_, 'a>)) -> FormOutput {
125        let needle: Vec<char> = self.query.trim().chars().take(MAX_QUERY_CHARS).map(lower).collect();
126        let labels = match self.labels {
127            Some(l) => LabelsRef::App(l),
128            None => LabelsRef::Installed(crate::labels::current(ui.ctx())),
129        };
130        let form_id = self.id;
131        ui.vertical(|ui| {
132            let mut f = FormUi { ui, st: State { needle, labels: labels.clone(), rows: 0, visible: 0, id_buf: String::new() } };
133            add(&mut f);
134            let FormUi { ui, st } = f;
135            let out = FormOutput { rows: st.rows, visible: st.visible };
136            if out.is_empty() {
137                let mut id = st.id_buf;
138                id.clear();
139                id.push_str(form_id);
140                id.push_str(".empty");
141                EmptyState::new(&id, &labels.get().no_match).body(&labels.get().no_match_body).show(ui);
142            }
143            out
144        })
145        .inner
146    }
147}
148
149/// Sections of rows without a filter: shorthand for `Form::new(id).show(..)` with one section.
150pub fn section(ui: &mut Ui, id: &str, title: &str, add: impl FnOnce(&mut Section<'_, '_>)) -> FormOutput {
151    Form::new(id).show(ui, |f| f.section(id, title, add))
152}
153
154/// A form filtered by `query`: hides non-matching rows and sections with no visible rows,
155/// highlights matches and shows "No settings match" when nothing is left.
156pub fn filter<'a>(ui: &mut Ui, id: &'a str, query: &'a str, add: impl FnOnce(&mut FormUi<'_, 'a>)) -> FormOutput {
157    Form::new(id).query(query).show(ui, add)
158}
159
160struct State<'l> {
161    /// Lower-cased query; empty means no filter.
162    needle: Vec<char>,
163    labels: LabelsRef<'l>,
164    rows: usize,
165    visible: usize,
166    /// Reused buffer for `<section>.<row>` ids.
167    id_buf: String,
168}
169
170/// The inside of a [`Form`]: add sections with [`FormUi::section`].
171pub struct FormUi<'u, 'l> {
172    ui: &'u mut Ui,
173    st: State<'l>,
174}
175
176impl<'l> FormUi<'_, 'l> {
177    /// A titled group of rows. It is not shown at all when the filter hides all its rows.
178    pub fn section(&mut self, id: &str, title: &str, add: impl FnOnce(&mut Section<'_, 'l>)) {
179        let title_hit = !self.st.needle.is_empty() && find_ci(title, &self.st.needle).is_some();
180        let mut s = Section { ui: self.ui, st: &mut self.st, id, title, title_hit, frame: None };
181        add(&mut s);
182        s.finish();
183    }
184
185    /// The form's `Ui`, for content between sections.
186    pub fn ui(&mut self) -> &mut Ui {
187        self.ui
188    }
189}
190
191struct Frame {
192    bg: egui::layers::ShapeIdx,
193    rect: Rect,
194    rows: usize,
195}
196
197/// A section of a form: add rows with [`Section::row`].
198pub struct Section<'s, 'l> {
199    ui: &'s mut Ui,
200    st: &'s mut State<'l>,
201    id: &'s str,
202    title: &'s str,
203    title_hit: bool,
204    frame: Option<Frame>,
205}
206
207impl<'s, 'l> Section<'s, 'l> {
208    /// A row with automation id `<section>.<id>`.
209    pub fn row<'r>(&'r mut self, id: &'r str, label: &'r str) -> Row<'r, 's, 'l> {
210        Row { section: self, id, label, description: None, keywords: &[], modified: false, enabled: true }
211    }
212
213    /// Paints the header and reserves the background the first time a row is shown.
214    fn begin(&mut self) -> &mut Frame {
215        let ui = &mut *self.ui;
216        let (id, title) = (self.id, self.title);
217        self.frame.get_or_insert_with(|| {
218            let t = tokens::current(ui.ctx());
219            let header = ui.label(RichText::new(title).size(t.type_scale.heading).strong().color(t.text.primary));
220            ui.ctx().accesskit_node_builder(header.id, |n| {
221                n.set_role(egui::accesskit::Role::Heading);
222                n.set_label(title);
223            });
224            automation::register(ui, &header, id, title, Role::Group);
225            ui.add_space(t.space.xs);
226            let bg = ui.painter().add(Shape::Noop);
227            let top = ui.cursor().top();
228            let full = ui.available_rect_before_wrap();
229            Frame { bg, rect: Rect::from_min_max(pos2(full.left(), top), pos2(full.right(), top)), rows: 0 }
230        })
231    }
232
233    fn finish(self) {
234        let Some(frame) = self.frame else { return };
235        let t = tokens::current(self.ui.ctx());
236        let radius = CornerRadius::same(t.radius.md);
237        if frame.rect.is_finite() {
238            let bg = egui::epaint::RectShape::new(frame.rect, radius, t.surface.raised, Stroke::new(1.0, t.border), StrokeKind::Inside);
239            self.ui.painter().set(frame.bg, bg);
240        }
241        self.ui.add_space(t.space.lg);
242    }
243}
244
245/// One row of a section: label and description on the left, the control on the right.
246#[must_use = "a row is shown by `control`"]
247pub struct Row<'r, 's, 'l> {
248    section: &'r mut Section<'s, 'l>,
249    id: &'r str,
250    label: &'r str,
251    description: Option<&'r str>,
252    keywords: &'r [&'r str],
253    modified: bool,
254    enabled: bool,
255}
256
257/// What a row did this frame.
258#[derive(Clone, Debug, Default)]
259pub struct RowOutput {
260    /// The control's response; `None` when the filter hid the row.
261    pub response: Option<Response>,
262    /// The reset button was clicked (already applied by [`Row::bind`]).
263    pub reset: bool,
264}
265
266impl RowOutput {
267    pub fn visible(&self) -> bool {
268        self.response.is_some()
269    }
270
271    /// The control changed the value, or reset did.
272    pub fn changed(&self) -> bool {
273        self.reset || self.response.as_ref().is_some_and(Response::changed)
274    }
275}
276
277/// Where the filter matched a row.
278#[derive(Default)]
279struct Hits {
280    label: Option<Range<usize>>,
281    description: Option<Range<usize>>,
282}
283
284impl<'r, 's, 'l> Row<'r, 's, 'l> {
285    /// Secondary text under the label; also the control's AccessKit description.
286    pub fn description(mut self, text: &'r str) -> Self {
287        self.description = Some(text);
288        self
289    }
290
291    /// Extra words the filter matches (not shown).
292    pub fn keywords(mut self, words: &'r [&'r str]) -> Self {
293        self.keywords = words;
294        self
295    }
296
297    /// Shows the modified marker and the reset button.
298    pub fn modified(mut self, modified: bool) -> Self {
299        self.modified = modified;
300        self
301    }
302
303    /// Disables the control (and reset).
304    pub fn enabled(mut self, enabled: bool) -> Self {
305        self.enabled = enabled;
306        self
307    }
308
309    /// Ties the row to `value`: modified while it differs from `default`, and reset restores it.
310    pub fn bind<T: PartialEq>(self, value: &'r mut T, default: T) -> Bound<'r, 's, 'l, T> {
311        Bound { row: self, value, default }
312    }
313
314    fn hits(&self) -> Option<Hits> {
315        let needle = &self.section.st.needle;
316        if needle.is_empty() {
317            return Some(Hits::default());
318        }
319        let hits = Hits { label: find_ci(self.label, needle), description: self.description.and_then(|d| find_ci(d, needle)) };
320        let keyword = || self.keywords.iter().any(|k| find_ci(k, needle).is_some());
321        (self.section.title_hit || hits.label.is_some() || hits.description.is_some() || keyword()).then_some(hits)
322    }
323
324    /// Shows the row. `add` draws the control and gets the row's automation id `<section>.<row>`.
325    pub fn control(self, add: impl FnOnce(&mut Ui, &str) -> Response) -> RowOutput {
326        self.section.st.rows = self.section.st.rows.saturating_add(1);
327        let Some(hits) = self.hits() else { return RowOutput::default() };
328        self.section.st.visible = self.section.st.visible.saturating_add(1);
329        let Row { section, id, label, description, modified, enabled, .. } = self;
330        let first = section.begin().rows == 0;
331        let st = &mut *section.st;
332        st.id_buf.clear();
333        st.id_buf.push_str(section.id);
334        st.id_buf.push('.');
335        st.id_buf.push_str(id);
336        let full_id = st.id_buf.as_str();
337        let labels = st.labels.clone();
338        let labels = labels.get();
339        let ui = &mut *section.ui;
340
341        let t = tokens::current(ui.ctx());
342        let spacing = ui.spacing().item_spacing;
343        let full = ui.available_rect_before_wrap();
344        let (left, right, top) = (full.left(), full.right().max(full.left()), ui.cursor().top());
345        let width = right - left;
346        let (pad_x, pad_y) = (t.space.md, t.space.sm);
347        let inner_top = top + pad_y;
348        let bottom_limit = full.bottom().max(inner_top + t.control_height);
349        let salt = ("ashlar_ui::form::row", section.id, id);
350
351        // control (and reset) on the right, vertically centred on the label line
352        let ctl_max = Rect::from_min_max(pos2(left + width * 0.4, inner_top), pos2((right - pad_x).max(left), inner_top + t.control_height));
353        let mut cui = ui.new_child(UiBuilder::new().max_rect(ctl_max).layout(Layout::right_to_left(Align::Center)).id_salt(salt));
354        cui.spacing_mut().item_spacing = spacing;
355        if !enabled {
356            cui.disable();
357        }
358        let response = add(&mut cui, full_id);
359        ui.ctx().accesskit_node_builder(response.id, |n| {
360            n.set_label(label);
361            if let Some(d) = description {
362                n.set_description(d);
363            }
364        });
365        let mut reset = false;
366        if modified {
367            cui.add_space(t.space.sm);
368            let r = Button::new(full_id, &labels.reset).part("reset").variant(Variant::Ghost).size(Size::Sm).show(&mut cui);
369            ui.ctx().accesskit_node_builder(r.id, |n| n.set_description(label));
370            reset = r.clicked();
371        }
372        let ctl_rect = cui.min_rect();
373
374        // label and description on the left
375        let text_left = left + pad_x;
376        let text_right = (ctl_rect.left() - t.space.lg).max(text_left + t.control_height);
377        let text_max = Rect::from_min_max(pos2(text_left, inner_top), pos2(text_right, bottom_limit));
378        let mut tui = ui.new_child(UiBuilder::new().max_rect(text_max).layout(Layout::top_down(Align::Min)).id_salt((salt, "text")));
379        tui.spacing_mut().item_spacing.y = t.space.xxs;
380        let wrap = text_max.width();
381        let hl = (t.selection, t.selection_text);
382        let job = |text: &str, range: Option<Range<usize>>, size: f32, color: Color32| text_job(text, range, size, color, hl, wrap);
383        let rev = egui::util::hash((label, &hits.label, t.type_scale.body.to_bits(), t.text.primary, hl, wrap.to_bits()));
384        let galley = crate::cache::galley(&tui, (salt, "label"), rev, || job(label, hits.label.clone(), t.type_scale.body, t.text.primary));
385        let label_rect = tui.add(egui::Label::new(galley).selectable(false)).rect;
386        if let Some(d) = description {
387            let rev = egui::util::hash((d, &hits.description, t.type_scale.small.to_bits(), t.text.secondary, hl, wrap.to_bits()));
388            let galley =
389                crate::cache::galley(&tui, (salt, "description"), rev, || job(d, hits.description.clone(), t.type_scale.small, t.text.secondary));
390            tui.add(egui::Label::new(galley).selectable(false));
391        }
392        let text_rect = tui.min_rect();
393
394        let bottom = text_rect.bottom().max(ctl_rect.bottom()) + pad_y;
395        let row_rect = Rect::from_min_max(pos2(left, top), pos2(right, bottom.max(top)));
396        if ui.is_rect_visible(row_rect) {
397            let painter = ui.painter();
398            if !first {
399                painter.hline((left + pad_x)..=(right - pad_x).max(left + pad_x), top, Stroke::new(1.0, t.separator));
400            }
401            if modified {
402                let r = (pad_x * 0.2).clamp(2.0, 4.0);
403                painter.circle_filled(pos2(left + pad_x * 0.5, label_rect.center().y), r, t.accent);
404            }
405        }
406        // rows sit flush; the separator divides them
407        let saved = ui.spacing().item_spacing.y;
408        ui.spacing_mut().item_spacing.y = 0.0;
409        ui.allocate_rect(row_rect, Sense::hover());
410        ui.spacing_mut().item_spacing.y = saved;
411        if let Some(frame) = section.frame.as_mut() {
412            frame.rows += 1;
413            frame.rect.max.y = row_rect.bottom();
414        }
415        RowOutput { response: Some(response), reset }
416    }
417}
418
419/// A row tied to a value (see [`Row::bind`]).
420#[must_use = "a row is shown by `control`"]
421pub struct Bound<'r, 's, 'l, T> {
422    row: Row<'r, 's, 'l>,
423    value: &'r mut T,
424    default: T,
425}
426
427impl<T: PartialEq> Bound<'_, '_, '_, T> {
428    /// Shows the row; `add` draws the control for the value and gets the row's automation id.
429    pub fn control(self, add: impl FnOnce(&mut Ui, &str, &mut T) -> Response) -> RowOutput {
430        let Bound { row, value, default } = self;
431        let modified = *value != default;
432        let mut out = row.modified(modified).control(|ui, id| add(ui, id, &mut *value));
433        if out.reset {
434            *value = default;
435            if let Some(r) = out.response.as_mut() {
436                r.mark_changed();
437            }
438        }
439        out
440    }
441}
442
443/// A labelled input: a required marker, a description and an error that is announced.
444pub struct Field<'a> {
445    id: &'a str,
446    label: &'a str,
447    description: Option<&'a str>,
448    required: bool,
449    error: Option<&'a str>,
450    labels: Option<&'a FormLabels>,
451}
452
453impl<'a> Field<'a> {
454    /// `id` is passed to the control; the error text is `<id>.error`.
455    pub fn new(id: &'a str, label: &'a str) -> Self {
456        Field { id, label, description: None, required: false, error: None, labels: None }
457    }
458
459    pub fn description(mut self, text: &'a str) -> Self {
460        self.description = Some(text);
461        self
462    }
463
464    pub fn required(mut self) -> Self {
465        self.required = true;
466        self
467    }
468
469    /// The validation error, shown under the control (replacing the description) and announced.
470    pub fn error(mut self, error: Option<&'a str>) -> Self {
471        self.error = error;
472        self
473    }
474
475    pub fn labels(mut self, labels: &'a FormLabels) -> Self {
476        self.labels = Some(labels);
477        self
478    }
479
480    /// Shows the field; `add` draws the control and gets the field id. Returns its response.
481    pub fn show(self, ui: &mut Ui, add: impl FnOnce(&mut Ui, &str) -> Response) -> Response {
482        let t = tokens::current(ui.ctx());
483        let Field { id, label, description, required, error, labels } = self;
484        let installed = crate::labels::current(ui.ctx());
485        let labels = labels.unwrap_or(&installed.form);
486        ui.vertical(|ui| {
487            ui.spacing_mut().item_spacing.y = t.space.xxs;
488            ui.horizontal(|ui| {
489                ui.spacing_mut().item_spacing.x = t.space.xxs;
490                ui.label(RichText::new(label).size(t.type_scale.body).color(t.text.primary));
491                if required {
492                    let r = ui.label(RichText::new("*").size(t.type_scale.body).color(t.status.danger));
493                    ui.ctx().accesskit_node_builder(r.id, |n| n.set_label(labels.required.as_str()));
494                }
495            });
496            let response = add(ui, id);
497            let help = error.or(description);
498            ui.ctx().accesskit_node_builder(response.id, |n| {
499                n.set_label(label);
500                if let Some(h) = help {
501                    n.set_description(h);
502                }
503                if required {
504                    n.set_required();
505                }
506                if error.is_some() {
507                    n.set_invalid(Invalid::True);
508                }
509            });
510            if let Some(e) = error {
511                let r = ui.label(RichText::new(e).size(t.type_scale.small).color(t.status.danger));
512                ui.ctx().accesskit_node_builder(r.id, |n| n.set_live(Live::Polite));
513                automation::register_fmt(ui, r.rect, format_args!("{id}.error"), e, Role::Alert, true, Details { value: Some(e), selected: None });
514            } else if let Some(d) = description {
515                ui.label(RichText::new(d).size(t.type_scale.small).color(t.text.secondary));
516            }
517            response
518        })
519        .inner
520    }
521}
522
523/// `text` with `range` drawn on the highlight colours.
524fn text_job(text: &str, range: Option<Range<usize>>, size: f32, color: Color32, (hl_bg, hl_fg): (Color32, Color32), wrap: f32) -> LayoutJob {
525    let plain = TextFormat::simple(Tokens::font(size), color);
526    let mut job = LayoutJob::default();
527    job.wrap.max_width = if wrap.is_finite() && wrap > 0.0 { wrap } else { f32::INFINITY };
528    let parts = range.and_then(|r| Some((text.get(..r.start)?, text.get(r.clone())?, text.get(r.end..)?)));
529    match parts {
530        Some((before, hit, after)) => {
531            job.append(before, 0.0, plain.clone());
532            job.append(hit, 0.0, TextFormat { background: hl_bg, color: hl_fg, ..plain.clone() });
533            job.append(after, 0.0, plain);
534        }
535        None => job.append(text, 0.0, plain),
536    }
537    job
538}
539
540fn lower(c: char) -> char {
541    c.to_lowercase().next().unwrap_or(c)
542}
543
544/// Byte range of the first case-insensitive occurrence of `needle` (already lower-cased).
545fn find_ci(hay: &str, needle: &[char]) -> Option<Range<usize>> {
546    if needle.is_empty() {
547        return None;
548    }
549    hay.char_indices().find_map(|(start, _)| {
550        let mut rest = hay.get(start..)?.char_indices();
551        let mut end = start;
552        for &n in needle {
553            let (off, c) = rest.next()?;
554            if lower(c) != n {
555                return None;
556            }
557            end = start + off + c.len_utf8();
558        }
559        Some(start..end)
560    })
561}
562
563#[cfg(test)]
564mod tests {
565    use super::*;
566
567    fn needle(q: &str) -> Vec<char> {
568        q.chars().map(lower).collect()
569    }
570
571    #[test]
572    fn find_is_case_insensitive_and_returns_byte_ranges() {
573        assert_eq!(find_ci("Launch at login", &needle("LOG")), Some(10..13));
574        assert_eq!(find_ci("Größe ändern", &needle("ÄND")), Some(8..12));
575        assert_eq!(find_ci("abc", &needle("abcd")), None);
576        assert_eq!(find_ci("abc", &[]), None);
577    }
578
579    #[test]
580    fn highlight_splits_the_label() {
581        let job = text_job("Launch at login", Some(10..13), 13.0, Color32::WHITE, (Color32::BLUE, Color32::BLACK), 200.0);
582        assert_eq!(job.sections.len(), 3);
583        let bad = text_job("é", Some(0..1), 13.0, Color32::WHITE, (Color32::BLUE, Color32::BLACK), f32::NAN);
584        assert_eq!(bad.sections.len(), 1);
585    }
586}