Skip to main content

studio_worker/ui/
widgets.rs

1//! Small building blocks every page shares: cards, pills, status dots,
2//! buttons, fact rows, copy buttons, empty states.  Rendering only; the
3//! decisions they show are made by the pages' view models.
4
5use 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
10/// Inner margin of a card, in points.
11pub const CARD_PADDING: i8 = 16;
12/// How long a copy button says "Copied", in seconds.
13pub const COPIED_FOR_SECS: f64 = 1.5;
14/// Width of a copy button: fits "Copied" so the label swap never moves it.
15pub const COPY_BUTTON_WIDTH: f32 = 72.0;
16
17/// The frame of a card.
18pub 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
26/// A full-width card around `add`.
27pub 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
37/// Two cards side by side, both as tall as the taller.  Their backgrounds
38/// are painted after both are laid out, in the same frame.
39pub 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
57/// Lay out a card's contents over a background slot; answers the slot and
58/// the card's rectangle.
59fn 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
85/// A page's title and one line saying what it is for.
86pub 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
95/// A small label above a group of cards or facts.
96pub 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
102/// Secondary text.
103pub fn muted(ui: &egui::Ui, text: impl Into<String>) -> RichText {
104    RichText::new(text).color(Palette::of_ui(ui).muted)
105}
106
107/// A rounded, tinted label that says what a state means.
108pub 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
126/// A dot in the colour of `tone`, with a soft halo of strength `glow`
127/// (0 = none) for running work.
128pub 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
140/// The one button that matters on a card: brass-filled.
141pub 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
156/// A quiet button of at least `min_width`, so its label can change
157/// without moving what sits next to it.
158pub 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
165/// Whether a copy button clicked at `clicked_at` still says "Copied" at
166/// `now` (both in egui seconds).
167pub 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
171/// A "Copy" button that puts `text` on the clipboard and says "Copied"
172/// for a moment.  `id_salt` keeps several apart.
173pub 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
197/// Two-column facts: a muted label and its value.
198pub 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
206/// Rows of [`facts`].
207pub struct FactRows<'a> {
208    pub ui: &'a mut egui::Ui,
209}
210
211impl FactRows<'_> {
212    /// A row whose value is plain text.
213    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    /// A row whose value is monospace (ids, paths, URLs).
220    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    /// A monospace row in the colour of `tone` (versions, states of ids).
227    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    /// A row whose value is text in the colour of `tone`.
240    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    /// A row with a custom value.
248    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
256/// An empty state: an icon, a title and one line of guidance.
257pub 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
269/// A tinted box for an error or a failure reason; selectable, wrapping.
270pub fn problem_box(ui: &mut egui::Ui, text: &str) {
271    tinted_box(ui, Tone::Bad, text);
272}
273
274/// A tinted box in the colour of `tone`.
275pub 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
291/// A thin horizontal bar: `segments` of (fraction, colour) over a track.
292pub 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}