1use eframe::egui::{self, vec2, Color32, CornerRadius, Frame, Margin, RichText, Sense};
6
7use super::icons::{self, Icon};
8use super::theme::{self, stroke, Palette, Tone, CARD_RADIUS, CONTROL_RADIUS};
9
10pub const CARD_PADDING: i8 = 16;
12pub const COPIED_FOR_SECS: f64 = 1.5;
14pub const COPY_BUTTON_WIDTH: f32 = 72.0;
16
17pub fn card_frame(p: &Palette) -> Frame {
19 Frame::new()
20 .fill(p.card)
21 .stroke(stroke(1.0, p.line))
22 .corner_radius(CornerRadius::same(CARD_RADIUS))
23 .inner_margin(Margin::same(CARD_PADDING))
24}
25
26pub fn card<R>(ui: &mut egui::Ui, add: impl FnOnce(&mut egui::Ui) -> R) -> R {
28 let p = Palette::of_ui(ui);
29 card_frame(p)
30 .show(ui, |ui| {
31 ui.set_width(ui.available_width());
32 add(ui)
33 })
34 .inner
35}
36
37pub fn card_pair(
40 ui: &mut egui::Ui,
41 left: impl FnOnce(&mut egui::Ui),
42 right: impl FnOnce(&mut egui::Ui),
43) {
44 let p = *Palette::of_ui(ui);
45 ui.columns(2, |cols| {
46 let (left_bg, left_rect) = card_body(&mut cols[0], left);
47 let (right_bg, right_rect) = card_body(&mut cols[1], right);
48 let height = left_rect.height().max(right_rect.height());
49 for (col, bg, rect) in [(0, left_bg, left_rect), (1, right_bg, right_rect)] {
50 let rect = egui::Rect::from_min_size(rect.min, vec2(rect.width(), height));
51 cols[col].painter().set(bg, card_shape(&p, rect));
52 cols[col].allocate_rect(rect, Sense::hover());
53 }
54 });
55}
56
57fn card_body(
60 ui: &mut egui::Ui,
61 add: impl FnOnce(&mut egui::Ui),
62) -> (egui::layers::ShapeIdx, egui::Rect) {
63 let bg = ui.painter().add(egui::Shape::Noop);
64 let rect = Frame::new()
65 .inner_margin(Margin::same(CARD_PADDING))
66 .show(ui, |ui| {
67 ui.set_width(ui.available_width());
68 add(ui);
69 })
70 .response
71 .rect;
72 (bg, rect)
73}
74
75fn card_shape(p: &Palette, rect: egui::Rect) -> egui::Shape {
76 egui::Shape::Rect(egui::epaint::RectShape::new(
77 rect,
78 CornerRadius::same(CARD_RADIUS),
79 p.card,
80 stroke(1.0, p.line),
81 egui::StrokeKind::Inside,
82 ))
83}
84
85pub fn page_title(ui: &mut egui::Ui, title: &str, subtitle: &str) {
87 let p = Palette::of_ui(ui);
88 ui.label(RichText::new(title).heading().color(p.text));
89 if !subtitle.is_empty() {
90 ui.label(RichText::new(subtitle).color(p.muted));
91 }
92 ui.add_space(12.0);
93}
94
95pub fn section_label(ui: &mut egui::Ui, text: &str) {
97 let p = Palette::of_ui(ui);
98 ui.label(RichText::new(text).small().strong().color(p.muted));
99 ui.add_space(2.0);
100}
101
102pub fn muted(ui: &egui::Ui, text: impl Into<String>) -> RichText {
104 RichText::new(text).color(Palette::of_ui(ui).muted)
105}
106
107pub fn pill(ui: &mut egui::Ui, text: &str, tone: Tone) -> egui::Response {
109 let p = Palette::of_ui(ui);
110 let font = egui::TextStyle::Small.resolve(ui.style());
111 let galley = ui
112 .painter()
113 .layout_no_wrap(text.to_string(), font, p.tone(tone));
114 let size = galley.size() + vec2(16.0, 6.0);
115 let (rect, response) = ui.allocate_exact_size(size, Sense::hover());
116 ui.painter().rect_filled(
117 rect,
118 CornerRadius::same(CONTROL_RADIUS * 2),
119 p.tone_soft(tone),
120 );
121 ui.painter()
122 .galley(rect.center() - galley.size() / 2.0, galley, p.tone(tone));
123 response
124}
125
126pub fn status_dot(ui: &mut egui::Ui, tone: Tone, glow: f32) -> egui::Response {
129 let p = Palette::of_ui(ui);
130 let (rect, response) = ui.allocate_exact_size(vec2(14.0, 14.0), Sense::hover());
131 let colour = p.tone(tone);
132 if glow > 0.0 {
133 ui.painter()
134 .circle_filled(rect.center(), 7.0, theme::with_alpha(colour, 0.35 * glow));
135 }
136 ui.painter().circle_filled(rect.center(), 4.0, colour);
137 response
138}
139
140pub fn primary_button(
142 ui: &mut egui::Ui,
143 text: &str,
144 enabled: bool,
145 min_width: f32,
146) -> egui::Response {
147 let p = Palette::of_ui(ui);
148 ui.add_enabled(
149 enabled,
150 egui::Button::new(RichText::new(text).strong().color(p.on_accent))
151 .fill(p.accent)
152 .min_size(vec2(min_width, 30.0)),
153 )
154}
155
156pub fn button(ui: &mut egui::Ui, text: &str, enabled: bool, min_width: f32) -> egui::Response {
159 ui.add_enabled(
160 enabled,
161 egui::Button::new(text).min_size(vec2(min_width, 30.0)),
162 )
163}
164
165pub fn shows_copied(clicked_at: Option<f64>, now: f64) -> bool {
168 clicked_at.is_some_and(|at| now >= at && now - at < COPIED_FOR_SECS)
169}
170
171pub fn copy_button(
174 ui: &mut egui::Ui,
175 id_salt: impl std::hash::Hash,
176 label: &str,
177 text: &str,
178) -> egui::Response {
179 let id = ui.id().with(("copy", id_salt));
180 let now = ui.input(|i| i.time);
181 let clicked_at: Option<f64> = ui.data(|d| d.get_temp(id));
182 let copied = shows_copied(clicked_at, now);
183 let shown = if copied { "Copied" } else { label };
184 let response =
185 button(ui, shown, true, COPY_BUTTON_WIDTH).on_hover_text("Copy to the clipboard");
186 if response.clicked() {
187 ui.ctx().copy_text(text.to_string());
188 ui.data_mut(|d| d.insert_temp(id, now));
189 }
190 if copied {
191 ui.ctx()
192 .request_repaint_after(std::time::Duration::from_millis(250));
193 }
194 response
195}
196
197pub fn facts(ui: &mut egui::Ui, id: &str, add: impl FnOnce(&mut FactRows<'_>)) {
199 egui::Grid::new(id)
200 .num_columns(2)
201 .spacing([16.0, 8.0])
202 .min_col_width(88.0)
203 .show(ui, |ui| add(&mut FactRows { ui }));
204}
205
206pub struct FactRows<'a> {
208 pub ui: &'a mut egui::Ui,
209}
210
211impl FactRows<'_> {
212 pub fn text(&mut self, label: &str, value: impl Into<String>) {
214 self.row(label, |ui| {
215 ui.add(egui::Label::new(value.into()).wrap());
216 });
217 }
218
219 pub fn mono(&mut self, label: &str, value: &str) {
221 self.row(label, |ui| {
222 ui.add(egui::Label::new(RichText::new(value).monospace()).wrap());
223 });
224 }
225
226 pub fn mono_toned(&mut self, label: &str, value: &str, tone: Tone) {
228 let colour = Palette::of_ui(self.ui).tone(tone);
229 let colour = if tone == Tone::Neutral {
230 Palette::of_ui(self.ui).text
231 } else {
232 colour
233 };
234 self.row(label, |ui| {
235 ui.add(egui::Label::new(RichText::new(value).monospace().color(colour)).wrap());
236 });
237 }
238
239 pub fn toned(&mut self, label: &str, value: impl Into<String>, tone: Tone) {
241 let colour = Palette::of_ui(self.ui).tone(tone);
242 self.row(label, |ui| {
243 ui.add(egui::Label::new(RichText::new(value.into()).color(colour)).wrap());
244 });
245 }
246
247 pub fn row(&mut self, label: &str, add: impl FnOnce(&mut egui::Ui)) {
249 let text = muted(self.ui, label);
250 self.ui.label(text);
251 self.ui.horizontal_wrapped(add);
252 self.ui.end_row();
253 }
254}
255
256pub fn empty_state(ui: &mut egui::Ui, icon: Icon, title: &str, body: &str) {
258 let p = Palette::of_ui(ui);
259 ui.vertical_centered(|ui| {
260 ui.add_space(8.0);
261 icons::show(ui, icon, 32.0, p.muted);
262 ui.add_space(6.0);
263 ui.label(RichText::new(title).strong().color(p.text));
264 ui.label(RichText::new(body).color(p.muted));
265 ui.add_space(8.0);
266 });
267}
268
269pub fn problem_box(ui: &mut egui::Ui, text: &str) {
271 tinted_box(ui, Tone::Bad, text);
272}
273
274pub fn tinted_box(ui: &mut egui::Ui, tone: Tone, text: &str) {
276 let p = Palette::of_ui(ui);
277 Frame::new()
278 .fill(p.tone_soft(tone))
279 .corner_radius(CornerRadius::same(CONTROL_RADIUS))
280 .inner_margin(Margin::symmetric(10, 8))
281 .show(ui, |ui| {
282 ui.set_width(ui.available_width());
283 ui.add(
284 egui::Label::new(RichText::new(text).color(p.tone(tone)))
285 .wrap()
286 .selectable(true),
287 );
288 });
289}
290
291pub fn meter(ui: &mut egui::Ui, width: f32, height: f32, segments: &[(f32, Color32)]) {
293 let p = Palette::of_ui(ui);
294 let (rect, _) = ui.allocate_exact_size(vec2(width, height), Sense::hover());
295 let radius = CornerRadius::same((height / 2.0) as u8);
296 ui.painter().rect_filled(rect, radius, p.neutral_soft);
297 let mut x = rect.left();
298 for (fraction, colour) in segments {
299 let w = (fraction.clamp(0.0, 1.0) * rect.width()).min(rect.right() - x);
300 if w <= 0.0 {
301 continue;
302 }
303 let seg = egui::Rect::from_min_size(egui::pos2(x, rect.top()), vec2(w, height));
304 ui.painter().rect_filled(seg, radius, *colour);
305 x += w;
306 }
307 ui.painter()
308 .rect_stroke(rect, radius, stroke(1.0, p.line), egui::StrokeKind::Inside);
309}
310
311#[cfg(test)]
312mod tests {
313 use super::*;
314
315 #[test]
316 fn a_copy_button_says_copied_for_a_moment() {
317 assert!(!shows_copied(None, 10.0));
318 assert!(shows_copied(Some(10.0), 10.0));
319 assert!(shows_copied(Some(10.0), 11.4));
320 assert!(!shows_copied(Some(10.0), 11.6));
321 assert!(!shows_copied(Some(10.0), 9.0), "a clock that went back");
322 }
323
324 #[test]
325 fn the_widgets_draw_in_both_themes() {
326 for dark in [true, false] {
327 egui::__run_test_ui(|ui| {
328 ui.ctx().set_visuals(theme::visuals(Palette::of(dark)));
329 page_title(ui, "Title", "What it is for");
330 section_label(ui, "SECTION");
331 card(ui, |ui| {
332 for tone in Tone::ALL {
333 pill(ui, "pill", tone);
334 status_dot(ui, tone, 0.8);
335 }
336 primary_button(ui, "Go", true, 90.0);
337 button(ui, "Later", false, 90.0);
338 copy_button(ui, "id", "Copy", "text");
339 facts(ui, "facts", |rows| {
340 rows.text("Label", "value");
341 rows.mono("Id", "w-1");
342 rows.toned("State", "ok", Tone::Good);
343 rows.mono_toned("Version", "1.0", Tone::Neutral);
344 rows.mono_toned("Daemon", "0.9", Tone::Busy);
345 });
346 empty_state(ui, Icon::Jobs, "Nothing yet", "Wait a moment.");
347 problem_box(ui, "it broke");
348 meter(ui, 120.0, 8.0, &[(0.4, Color32::RED), (0.8, Color32::BLUE)]);
349 });
350 card_pair(
351 ui,
352 |ui| {
353 ui.label("short");
354 },
355 |ui| {
356 ui.label("tall");
357 ui.label("taller");
358 },
359 );
360 });
361 }
362 }
363}