1use 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
40pub const MAX_QUERY_CHARS: usize = 128;
42
43#[derive(Clone, Debug, PartialEq, Eq)]
45pub struct FormLabels {
46 pub no_match: String,
48 pub no_match_body: String,
50 pub reset: String,
52 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#[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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
85pub struct FormOutput {
86 pub rows: usize,
88 pub visible: usize,
90}
91
92impl FormOutput {
93 pub fn is_empty(&self) -> bool {
95 self.visible == 0 && self.rows > 0
96 }
97}
98
99pub struct Form<'a> {
101 id: &'a str,
102 query: &'a str,
103 labels: Option<&'a FormLabels>,
104}
105
106impl<'a> Form<'a> {
107 pub fn new(id: &'a str) -> Self {
109 Form { id, query: "", labels: None }
110 }
111
112 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
149pub 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
154pub 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 needle: Vec<char>,
163 labels: LabelsRef<'l>,
164 rows: usize,
165 visible: usize,
166 id_buf: String,
168}
169
170pub struct FormUi<'u, 'l> {
172 ui: &'u mut Ui,
173 st: State<'l>,
174}
175
176impl<'l> FormUi<'_, 'l> {
177 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 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
197pub 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 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 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#[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#[derive(Clone, Debug, Default)]
259pub struct RowOutput {
260 pub response: Option<Response>,
262 pub reset: bool,
264}
265
266impl RowOutput {
267 pub fn visible(&self) -> bool {
268 self.response.is_some()
269 }
270
271 pub fn changed(&self) -> bool {
273 self.reset || self.response.as_ref().is_some_and(Response::changed)
274 }
275}
276
277#[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 pub fn description(mut self, text: &'r str) -> Self {
287 self.description = Some(text);
288 self
289 }
290
291 pub fn keywords(mut self, words: &'r [&'r str]) -> Self {
293 self.keywords = words;
294 self
295 }
296
297 pub fn modified(mut self, modified: bool) -> Self {
299 self.modified = modified;
300 self
301 }
302
303 pub fn enabled(mut self, enabled: bool) -> Self {
305 self.enabled = enabled;
306 self
307 }
308
309 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 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 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 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 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#[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 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
443pub 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 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 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 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
523fn 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
544fn 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}