use std::ops::Range;
use std::sync::Arc;
use egui::accesskit::{Invalid, Live};
use egui::text::{LayoutJob, TextFormat};
use egui::{Align, Color32, CornerRadius, Layout, Rect, Response, RichText, Sense, Shape, Stroke, StrokeKind, Ui, UiBuilder, pos2};
use crate::automation::{self, Details, Role};
use crate::content::EmptyState;
use crate::controls::{Button, Size, Variant};
use crate::labels::UiLabels;
use crate::tokens::{self, Tokens};
pub const MAX_QUERY_CHARS: usize = 128;
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct FormLabels {
pub no_match: String,
pub no_match_body: String,
pub reset: String,
pub required: String,
}
impl Default for FormLabels {
fn default() -> Self {
FormLabels {
no_match: "No settings match".into(),
no_match_body: "Try a different search.".into(),
reset: "Reset".into(),
required: "Required".into(),
}
}
}
#[derive(Clone)]
enum LabelsRef<'l> {
App(&'l FormLabels),
Installed(Arc<UiLabels>),
}
impl LabelsRef<'_> {
fn get(&self) -> &FormLabels {
match self {
LabelsRef::App(l) => l,
LabelsRef::Installed(u) => &u.form,
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct FormOutput {
pub rows: usize,
pub visible: usize,
}
impl FormOutput {
pub fn is_empty(&self) -> bool {
self.visible == 0 && self.rows > 0
}
}
pub struct Form<'a> {
id: &'a str,
query: &'a str,
labels: Option<&'a FormLabels>,
}
impl<'a> Form<'a> {
pub fn new(id: &'a str) -> Self {
Form { id, query: "", labels: None }
}
pub fn query(mut self, query: &'a str) -> Self {
self.query = query;
self
}
pub fn labels(mut self, labels: &'a FormLabels) -> Self {
self.labels = Some(labels);
self
}
pub fn show(self, ui: &mut Ui, add: impl FnOnce(&mut FormUi<'_, 'a>)) -> FormOutput {
let needle: Vec<char> = self.query.trim().chars().take(MAX_QUERY_CHARS).map(lower).collect();
let labels = match self.labels {
Some(l) => LabelsRef::App(l),
None => LabelsRef::Installed(crate::labels::current(ui.ctx())),
};
let form_id = self.id;
ui.vertical(|ui| {
let mut f = FormUi { ui, st: State { needle, labels: labels.clone(), rows: 0, visible: 0, id_buf: String::new() } };
add(&mut f);
let FormUi { ui, st } = f;
let out = FormOutput { rows: st.rows, visible: st.visible };
if out.is_empty() {
let mut id = st.id_buf;
id.clear();
id.push_str(form_id);
id.push_str(".empty");
EmptyState::new(&id, &labels.get().no_match).body(&labels.get().no_match_body).show(ui);
}
out
})
.inner
}
}
pub fn section(ui: &mut Ui, id: &str, title: &str, add: impl FnOnce(&mut Section<'_, '_>)) -> FormOutput {
Form::new(id).show(ui, |f| f.section(id, title, add))
}
pub fn filter<'a>(ui: &mut Ui, id: &'a str, query: &'a str, add: impl FnOnce(&mut FormUi<'_, 'a>)) -> FormOutput {
Form::new(id).query(query).show(ui, add)
}
struct State<'l> {
needle: Vec<char>,
labels: LabelsRef<'l>,
rows: usize,
visible: usize,
id_buf: String,
}
pub struct FormUi<'u, 'l> {
ui: &'u mut Ui,
st: State<'l>,
}
impl<'l> FormUi<'_, 'l> {
pub fn section(&mut self, id: &str, title: &str, add: impl FnOnce(&mut Section<'_, 'l>)) {
let title_hit = !self.st.needle.is_empty() && find_ci(title, &self.st.needle).is_some();
let mut s = Section { ui: self.ui, st: &mut self.st, id, title, title_hit, frame: None };
add(&mut s);
s.finish();
}
pub fn ui(&mut self) -> &mut Ui {
self.ui
}
}
struct Frame {
bg: egui::layers::ShapeIdx,
rect: Rect,
rows: usize,
}
pub struct Section<'s, 'l> {
ui: &'s mut Ui,
st: &'s mut State<'l>,
id: &'s str,
title: &'s str,
title_hit: bool,
frame: Option<Frame>,
}
impl<'s, 'l> Section<'s, 'l> {
pub fn row<'r>(&'r mut self, id: &'r str, label: &'r str) -> Row<'r, 's, 'l> {
Row { section: self, id, label, description: None, keywords: &[], modified: false, enabled: true }
}
fn begin(&mut self) -> &mut Frame {
let ui = &mut *self.ui;
let (id, title) = (self.id, self.title);
self.frame.get_or_insert_with(|| {
let t = tokens::current(ui.ctx());
let header = ui.label(RichText::new(title).size(t.type_scale.heading).strong().color(t.text.primary));
ui.ctx().accesskit_node_builder(header.id, |n| {
n.set_role(egui::accesskit::Role::Heading);
n.set_label(title);
});
automation::register(ui, &header, id, title, Role::Group);
ui.add_space(t.space.xs);
let bg = ui.painter().add(Shape::Noop);
let top = ui.cursor().top();
let full = ui.available_rect_before_wrap();
Frame { bg, rect: Rect::from_min_max(pos2(full.left(), top), pos2(full.right(), top)), rows: 0 }
})
}
fn finish(self) {
let Some(frame) = self.frame else { return };
let t = tokens::current(self.ui.ctx());
let radius = CornerRadius::same(t.radius.md);
if frame.rect.is_finite() {
let bg = egui::epaint::RectShape::new(frame.rect, radius, t.surface.raised, Stroke::new(1.0, t.border), StrokeKind::Inside);
self.ui.painter().set(frame.bg, bg);
}
self.ui.add_space(t.space.lg);
}
}
#[must_use = "a row is shown by `control`"]
pub struct Row<'r, 's, 'l> {
section: &'r mut Section<'s, 'l>,
id: &'r str,
label: &'r str,
description: Option<&'r str>,
keywords: &'r [&'r str],
modified: bool,
enabled: bool,
}
#[derive(Clone, Debug, Default)]
pub struct RowOutput {
pub response: Option<Response>,
pub reset: bool,
}
impl RowOutput {
pub fn visible(&self) -> bool {
self.response.is_some()
}
pub fn changed(&self) -> bool {
self.reset || self.response.as_ref().is_some_and(Response::changed)
}
}
#[derive(Default)]
struct Hits {
label: Option<Range<usize>>,
description: Option<Range<usize>>,
}
impl<'r, 's, 'l> Row<'r, 's, 'l> {
pub fn description(mut self, text: &'r str) -> Self {
self.description = Some(text);
self
}
pub fn keywords(mut self, words: &'r [&'r str]) -> Self {
self.keywords = words;
self
}
pub fn modified(mut self, modified: bool) -> Self {
self.modified = modified;
self
}
pub fn enabled(mut self, enabled: bool) -> Self {
self.enabled = enabled;
self
}
pub fn bind<T: PartialEq>(self, value: &'r mut T, default: T) -> Bound<'r, 's, 'l, T> {
Bound { row: self, value, default }
}
fn hits(&self) -> Option<Hits> {
let needle = &self.section.st.needle;
if needle.is_empty() {
return Some(Hits::default());
}
let hits = Hits { label: find_ci(self.label, needle), description: self.description.and_then(|d| find_ci(d, needle)) };
let keyword = || self.keywords.iter().any(|k| find_ci(k, needle).is_some());
(self.section.title_hit || hits.label.is_some() || hits.description.is_some() || keyword()).then_some(hits)
}
pub fn control(self, add: impl FnOnce(&mut Ui, &str) -> Response) -> RowOutput {
self.section.st.rows = self.section.st.rows.saturating_add(1);
let Some(hits) = self.hits() else { return RowOutput::default() };
self.section.st.visible = self.section.st.visible.saturating_add(1);
let Row { section, id, label, description, modified, enabled, .. } = self;
let first = section.begin().rows == 0;
let st = &mut *section.st;
st.id_buf.clear();
st.id_buf.push_str(section.id);
st.id_buf.push('.');
st.id_buf.push_str(id);
let full_id = st.id_buf.as_str();
let labels = st.labels.clone();
let labels = labels.get();
let ui = &mut *section.ui;
let t = tokens::current(ui.ctx());
let spacing = ui.spacing().item_spacing;
let full = ui.available_rect_before_wrap();
let (left, right, top) = (full.left(), full.right().max(full.left()), ui.cursor().top());
let width = right - left;
let (pad_x, pad_y) = (t.space.md, t.space.sm);
let inner_top = top + pad_y;
let bottom_limit = full.bottom().max(inner_top + t.control_height);
let salt = ("ashlar_ui::form::row", section.id, id);
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));
let mut cui = ui.new_child(UiBuilder::new().max_rect(ctl_max).layout(Layout::right_to_left(Align::Center)).id_salt(salt));
cui.spacing_mut().item_spacing = spacing;
if !enabled {
cui.disable();
}
let response = add(&mut cui, full_id);
ui.ctx().accesskit_node_builder(response.id, |n| {
n.set_label(label);
if let Some(d) = description {
n.set_description(d);
}
});
let mut reset = false;
if modified {
cui.add_space(t.space.sm);
let r = Button::new(full_id, &labels.reset).part("reset").variant(Variant::Ghost).size(Size::Sm).show(&mut cui);
ui.ctx().accesskit_node_builder(r.id, |n| n.set_description(label));
reset = r.clicked();
}
let ctl_rect = cui.min_rect();
let text_left = left + pad_x;
let text_right = (ctl_rect.left() - t.space.lg).max(text_left + t.control_height);
let text_max = Rect::from_min_max(pos2(text_left, inner_top), pos2(text_right, bottom_limit));
let mut tui = ui.new_child(UiBuilder::new().max_rect(text_max).layout(Layout::top_down(Align::Min)).id_salt((salt, "text")));
tui.spacing_mut().item_spacing.y = t.space.xxs;
let wrap = text_max.width();
let hl = (t.selection, t.selection_text);
let job = |text: &str, range: Option<Range<usize>>, size: f32, color: Color32| text_job(text, range, size, color, hl, wrap);
let rev = egui::util::hash((label, &hits.label, t.type_scale.body.to_bits(), t.text.primary, hl, wrap.to_bits()));
let galley = crate::cache::galley(&tui, (salt, "label"), rev, || job(label, hits.label.clone(), t.type_scale.body, t.text.primary));
let label_rect = tui.add(egui::Label::new(galley).selectable(false)).rect;
if let Some(d) = description {
let rev = egui::util::hash((d, &hits.description, t.type_scale.small.to_bits(), t.text.secondary, hl, wrap.to_bits()));
let galley =
crate::cache::galley(&tui, (salt, "description"), rev, || job(d, hits.description.clone(), t.type_scale.small, t.text.secondary));
tui.add(egui::Label::new(galley).selectable(false));
}
let text_rect = tui.min_rect();
let bottom = text_rect.bottom().max(ctl_rect.bottom()) + pad_y;
let row_rect = Rect::from_min_max(pos2(left, top), pos2(right, bottom.max(top)));
if ui.is_rect_visible(row_rect) {
let painter = ui.painter();
if !first {
painter.hline((left + pad_x)..=(right - pad_x).max(left + pad_x), top, Stroke::new(1.0, t.separator));
}
if modified {
let r = (pad_x * 0.2).clamp(2.0, 4.0);
painter.circle_filled(pos2(left + pad_x * 0.5, label_rect.center().y), r, t.accent);
}
}
let saved = ui.spacing().item_spacing.y;
ui.spacing_mut().item_spacing.y = 0.0;
ui.allocate_rect(row_rect, Sense::hover());
ui.spacing_mut().item_spacing.y = saved;
if let Some(frame) = section.frame.as_mut() {
frame.rows += 1;
frame.rect.max.y = row_rect.bottom();
}
RowOutput { response: Some(response), reset }
}
}
#[must_use = "a row is shown by `control`"]
pub struct Bound<'r, 's, 'l, T> {
row: Row<'r, 's, 'l>,
value: &'r mut T,
default: T,
}
impl<T: PartialEq> Bound<'_, '_, '_, T> {
pub fn control(self, add: impl FnOnce(&mut Ui, &str, &mut T) -> Response) -> RowOutput {
let Bound { row, value, default } = self;
let modified = *value != default;
let mut out = row.modified(modified).control(|ui, id| add(ui, id, &mut *value));
if out.reset {
*value = default;
if let Some(r) = out.response.as_mut() {
r.mark_changed();
}
}
out
}
}
pub struct Field<'a> {
id: &'a str,
label: &'a str,
description: Option<&'a str>,
required: bool,
error: Option<&'a str>,
labels: Option<&'a FormLabels>,
}
impl<'a> Field<'a> {
pub fn new(id: &'a str, label: &'a str) -> Self {
Field { id, label, description: None, required: false, error: None, labels: None }
}
pub fn description(mut self, text: &'a str) -> Self {
self.description = Some(text);
self
}
pub fn required(mut self) -> Self {
self.required = true;
self
}
pub fn error(mut self, error: Option<&'a str>) -> Self {
self.error = error;
self
}
pub fn labels(mut self, labels: &'a FormLabels) -> Self {
self.labels = Some(labels);
self
}
pub fn show(self, ui: &mut Ui, add: impl FnOnce(&mut Ui, &str) -> Response) -> Response {
let t = tokens::current(ui.ctx());
let Field { id, label, description, required, error, labels } = self;
let installed = crate::labels::current(ui.ctx());
let labels = labels.unwrap_or(&installed.form);
ui.vertical(|ui| {
ui.spacing_mut().item_spacing.y = t.space.xxs;
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = t.space.xxs;
ui.label(RichText::new(label).size(t.type_scale.body).color(t.text.primary));
if required {
let r = ui.label(RichText::new("*").size(t.type_scale.body).color(t.status.danger));
ui.ctx().accesskit_node_builder(r.id, |n| n.set_label(labels.required.as_str()));
}
});
let response = add(ui, id);
let help = error.or(description);
ui.ctx().accesskit_node_builder(response.id, |n| {
n.set_label(label);
if let Some(h) = help {
n.set_description(h);
}
if required {
n.set_required();
}
if error.is_some() {
n.set_invalid(Invalid::True);
}
});
if let Some(e) = error {
let r = ui.label(RichText::new(e).size(t.type_scale.small).color(t.status.danger));
ui.ctx().accesskit_node_builder(r.id, |n| n.set_live(Live::Polite));
automation::register_fmt(ui, r.rect, format_args!("{id}.error"), e, Role::Alert, true, Details { value: Some(e), selected: None });
} else if let Some(d) = description {
ui.label(RichText::new(d).size(t.type_scale.small).color(t.text.secondary));
}
response
})
.inner
}
}
fn text_job(text: &str, range: Option<Range<usize>>, size: f32, color: Color32, (hl_bg, hl_fg): (Color32, Color32), wrap: f32) -> LayoutJob {
let plain = TextFormat::simple(Tokens::font(size), color);
let mut job = LayoutJob::default();
job.wrap.max_width = if wrap.is_finite() && wrap > 0.0 { wrap } else { f32::INFINITY };
let parts = range.and_then(|r| Some((text.get(..r.start)?, text.get(r.clone())?, text.get(r.end..)?)));
match parts {
Some((before, hit, after)) => {
job.append(before, 0.0, plain.clone());
job.append(hit, 0.0, TextFormat { background: hl_bg, color: hl_fg, ..plain.clone() });
job.append(after, 0.0, plain);
}
None => job.append(text, 0.0, plain),
}
job
}
fn lower(c: char) -> char {
c.to_lowercase().next().unwrap_or(c)
}
fn find_ci(hay: &str, needle: &[char]) -> Option<Range<usize>> {
if needle.is_empty() {
return None;
}
hay.char_indices().find_map(|(start, _)| {
let mut rest = hay.get(start..)?.char_indices();
let mut end = start;
for &n in needle {
let (off, c) = rest.next()?;
if lower(c) != n {
return None;
}
end = start + off + c.len_utf8();
}
Some(start..end)
})
}
#[cfg(test)]
mod tests {
use super::*;
fn needle(q: &str) -> Vec<char> {
q.chars().map(lower).collect()
}
#[test]
fn find_is_case_insensitive_and_returns_byte_ranges() {
assert_eq!(find_ci("Launch at login", &needle("LOG")), Some(10..13));
assert_eq!(find_ci("Größe ändern", &needle("ÄND")), Some(8..12));
assert_eq!(find_ci("abc", &needle("abcd")), None);
assert_eq!(find_ci("abc", &[]), None);
}
#[test]
fn highlight_splits_the_label() {
let job = text_job("Launch at login", Some(10..13), 13.0, Color32::WHITE, (Color32::BLUE, Color32::BLACK), 200.0);
assert_eq!(job.sections.len(), 3);
let bad = text_job("é", Some(0..1), 13.0, Color32::WHITE, (Color32::BLUE, Color32::BLACK), f32::NAN);
assert_eq!(bad.sections.len(), 1);
}
}