Skip to main content

ashlar_ui/content/
banner.rs

1//! An inline message across a view: a notice, a warning, an error, with an optional action.
2
3use egui::accesskit::{Live, Role as AkRole};
4use egui::{Color32, CornerRadius, Rect, Response, Sense, Shape, Stroke, StrokeKind, Ui, UiBuilder, WidgetInfo, WidgetType, pos2, vec2};
5
6use crate::automation::{self, Details, Role};
7use crate::controls::{Button, Size, Tone, Variant, mix};
8use crate::tokens::{self, Tokens};
9
10/// Width of the coloured stripe on the leading edge.
11const STRIPE: f32 = 3.0;
12
13/// A message with a tone, an optional title, action and close button. It is a live region:
14/// screen readers announce it when it appears (assertively for [`Tone::Danger`], politely
15/// otherwise). The app keeps whether it is shown.
16pub struct Banner<'a> {
17    id: &'a str,
18    tone: Tone,
19    text: &'a str,
20    title: Option<&'a str>,
21    action: Option<&'a str>,
22    dismiss: Option<&'a str>,
23}
24
25/// What [`Banner::show`] reports.
26pub struct BannerOutput {
27    /// The whole banner.
28    pub response: Response,
29    /// The action button was clicked.
30    pub action: bool,
31    /// The close button was clicked: hide the banner.
32    pub dismissed: bool,
33}
34
35/// Fill, border and stripe colours of a banner.
36pub(crate) fn colors(t: &Tokens, tone: Tone) -> (Color32, Color32, Color32) {
37    let strong = tone.color(t);
38    (mix(t.surface.raised, strong, 0.125), mix(t.border, strong, 0.375), strong)
39}
40
41impl<'a> Banner<'a> {
42    pub fn new(id: &'a str, tone: Tone, text: &'a str) -> Self {
43        Banner { id, tone, text, title: None, action: None, dismiss: None }
44    }
45    /// A bold first line.
46    pub fn title(mut self, title: &'a str) -> Self {
47        self.title = Some(title);
48        self
49    }
50    /// A button on the trailing side (registered as `<id>.action`).
51    pub fn action(mut self, label: &'a str) -> Self {
52        self.action = Some(label);
53        self
54    }
55    /// A close button with this accessible name (registered as `<id>.dismiss`).
56    pub fn dismissible(mut self, label: &'a str) -> Self {
57        self.dismiss = Some(label);
58        self
59    }
60
61    pub fn show(self, ui: &mut Ui) -> BannerOutput {
62        let t = tokens::current(ui.ctx());
63        let pad = t.space.md;
64        let width = ui.available_width().clamp(4.0 * t.control_height, 8000.0);
65        let origin = ui.available_rect_before_wrap().min;
66        // the background goes under everything drawn below; its height is known at the end
67        let background = ui.painter().add(Shape::Noop);
68        let stripe = ui.painter().add(Shape::Noop);
69
70        // trailing buttons first: the text wraps in what they leave
71        let mut action = false;
72        let mut dismissed = false;
73        let row = Rect::from_min_size(pos2(origin.x + STRIPE + pad, origin.y + pad), vec2(width - STRIPE - 2.0 * pad, t.control_height));
74        let mut buttons_w = 0.0;
75        if self.action.is_some() || self.dismiss.is_some() {
76            let mut child = ui.new_child(UiBuilder::new().max_rect(row).layout(egui::Layout::right_to_left(egui::Align::Center)));
77            child.spacing_mut().item_spacing.x = t.space.xs;
78            if let Some(label) = self.dismiss {
79                dismissed = dismiss_button(&mut child, &t, self.id, label).clicked();
80            }
81            if let Some(label) = self.action {
82                action = Button::new(self.id, label).part("action").size(Size::Sm).variant(Variant::Secondary).show(&mut child).clicked();
83            }
84            buttons_w = child.min_rect().width() + t.space.md;
85        }
86
87        let text_w = (row.width() - buttons_w).max(t.control_height);
88        let text_x = row.left();
89        let mut y = origin.y + pad;
90        let size = t.type_scale.body;
91        let title_galley = self.title.map(|title| wrapped(ui, (self.id, "title"), title, size, text_w));
92        let body_galley = wrapped(ui, (self.id, "text"), self.text, size, text_w);
93        let text_h = title_galley.as_ref().map_or(0.0, |g| g.size().y + t.space.xxs) + body_galley.size().y;
94        let height = text_h.max(t.control_height) + 2.0 * pad;
95        let (rect, response) = ui.allocate_exact_size(vec2(width, height), Sense::hover());
96
97        let (fill, border, strong) = colors(&t, self.tone);
98        let radius = CornerRadius::same(t.radius.md);
99        ui.painter().set(background, Shape::rect_filled(rect, radius, fill));
100        let stripe_rect = Rect::from_min_size(rect.min, vec2(STRIPE, rect.height()));
101        ui.painter().set(stripe, Shape::rect_filled(stripe_rect, CornerRadius { nw: t.radius.md, sw: t.radius.md, ne: 0, se: 0 }, strong));
102        ui.painter().rect_stroke(rect, radius, Stroke::new(1.0, border), StrokeKind::Inside);
103        // centre a single line on the buttons' row
104        if text_h < t.control_height {
105            y += (t.control_height - text_h) / 2.0;
106        }
107        if let Some(g) = title_galley {
108            let h = g.size().y;
109            ui.painter().galley(pos2(text_x, y), g, t.text.primary);
110            y += h + t.space.xxs;
111        }
112        ui.painter().galley(pos2(text_x, y), body_galley, t.text.primary);
113
114        let (label, text) = (self.title.unwrap_or(self.text), self.text);
115        let assertive = self.tone == Tone::Danger;
116        response.widget_info(|| WidgetInfo::labeled(WidgetType::Label, true, label));
117        ui.ctx().accesskit_node_builder(response.id, |node| {
118            node.set_role(if assertive { AkRole::Alert } else { AkRole::Status });
119            node.set_live(if assertive { Live::Assertive } else { Live::Polite });
120            node.set_label(label);
121            // the name carries the text: a value would make readers say it twice
122            node.clear_value();
123            if label != text {
124                node.set_description(text);
125            }
126        });
127        automation::register_with(ui, &response, self.id, label, Role::Alert, Details { value: Some(text), selected: None });
128        BannerOutput { response, action, dismissed }
129    }
130}
131
132/// `text` wrapped at `width`, from the shared galley cache.
133fn wrapped(ui: &Ui, key: (&str, &str), text: &str, size: f32, width: f32) -> std::sync::Arc<egui::Galley> {
134    let revision = crate::cache::hash_of(&(text, size.to_bits(), width.to_bits()));
135    crate::cache::galley(ui, ("ashlar_ui::content::banner", key), revision, || {
136        egui::text::LayoutJob::simple(text.to_owned(), egui::FontId::proportional(size), Color32::PLACEHOLDER, width)
137    })
138}
139
140/// A small × button.
141fn dismiss_button(ui: &mut Ui, t: &Tokens, id: &str, label: &str) -> Response {
142    let side = (t.control_height - 4.0).max(12.0);
143    let (rect, response) = ui.allocate_exact_size(vec2(side, side), Sense::click());
144    response.widget_info(|| WidgetInfo::labeled(WidgetType::Button, ui.is_enabled(), label));
145    if ui.is_rect_visible(rect) {
146        let painter = ui.painter();
147        let radius = CornerRadius::same(t.radius.sm);
148        if response.is_pointer_button_down_on() {
149            painter.rect_filled(rect, radius, t.surface.pressed);
150        } else if response.hovered() {
151            painter.rect_filled(rect, radius, t.surface.hover);
152        }
153        let c = rect.center();
154        let h = (side * 0.2).round();
155        let stroke = Stroke::new(1.5, t.text.secondary);
156        painter.line_segment([pos2(c.x - h, c.y - h), pos2(c.x + h, c.y + h)], stroke);
157        painter.line_segment([pos2(c.x - h, c.y + h), pos2(c.x + h, c.y - h)], stroke);
158        if response.has_focus() {
159            crate::controls::focus_ring(painter, rect, radius, t);
160        }
161    }
162    automation::register_fmt(ui, rect, format_args!("{id}.dismiss"), label, Role::Button, ui.is_enabled(), Details::default());
163    response
164}