1use 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
10const STRIPE: f32 = 3.0;
12
13pub 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
25pub struct BannerOutput {
27 pub response: Response,
29 pub action: bool,
31 pub dismissed: bool,
33}
34
35pub(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 pub fn title(mut self, title: &'a str) -> Self {
47 self.title = Some(title);
48 self
49 }
50 pub fn action(mut self, label: &'a str) -> Self {
52 self.action = Some(label);
53 self
54 }
55 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 let background = ui.painter().add(Shape::Noop);
68 let stripe = ui.painter().add(Shape::Noop);
69
70 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 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 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
132fn 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
140fn 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}