Skip to main content

elegance/
log_bar.rs

1//! Expandable bottom log bar — a collapsible console for network traffic or event messages.
2//!
3//! [`LogBar`] owns its own ring buffer of [`LogEntry`] rows and renders
4//! a [`egui::Panel::bottom`] with a full-width header strip (click
5//! anywhere on the strip to expand or collapse), a scrollable monospace
6//! log area (capped at 200 pt), and a Clear button. Call
7//! [`LogBar::show`] once per frame inside your `App::ui`, before your
8//! `CentralPanel`.
9//!
10//! Entries are prepended (newest at top) and capped at [`LogBar::max_entries`]
11//! — older rows are silently dropped once the buffer is full.
12//!
13//! # Example
14//!
15//! ```no_run
16//! use elegance::{LogBar, Theme};
17//!
18//! struct App { log: LogBar }
19//!
20//! impl Default for App {
21//!     fn default() -> Self {
22//!         let mut log = LogBar::new().heading("Events");
23//!         log.sys("Connected");
24//!         Self { log }
25//!     }
26//! }
27//!
28//! impl eframe::App for App {
29//!     fn ui(&mut self, ui: &mut egui::Ui, _: &mut eframe::Frame) {
30//!         Theme::slate().install(ui.ctx());
31//!         self.log.show(ui);
32//!         egui::CentralPanel::default().show_inside(ui, |ui| {
33//!             if ui.button("Reload").clicked() {
34//!                 self.log.out("reload_config");
35//!                 self.log.recv("ok");
36//!             }
37//!         });
38//!     }
39//! }
40//! ```
41//!
42//! The four [`LogKind`] variants map to different colours and arrow prefixes
43//! in the spirit of a browser devtools console: outgoing requests, incoming
44//! responses, errors, and plain system messages.
45
46use std::collections::VecDeque;
47use std::hash::Hash;
48
49use egui::{
50    pos2, Color32, CornerRadius, Id, Pos2, Response, Sense, Stroke, Vec2, WidgetInfo, WidgetType,
51};
52
53use crate::theme::Theme;
54use crate::{Button, ButtonSize};
55
56/// How a single log row is styled and prefixed.
57#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum LogKind {
59    /// System or status message — faint text, no arrow.
60    Sys,
61    /// Outgoing request — muted text with a "→" prefix.
62    Out,
63    /// Incoming response — success-green text with a "←" prefix.
64    In,
65    /// Error — danger-red text, no arrow.
66    Err,
67}
68
69/// A single log row.
70#[derive(Debug, Clone)]
71pub struct LogEntry {
72    /// Timestamp string, `HH:MM:SS` UTC by default.
73    pub time: String,
74    /// Row style.
75    pub kind: LogKind,
76    /// Message body.
77    pub msg: String,
78}
79
80const DEFAULT_CAPACITY: usize = 100;
81const SCROLL_MAX_HEIGHT: f32 = 200.0;
82
83/// An expandable log bar anchored to the bottom of the viewport.
84///
85/// Owns its own entry buffer — construct once, store on your app struct,
86/// call [`LogBar::push`] (or one of the shortcuts) from any event handler,
87/// and call [`LogBar::show`] once per frame to render.
88#[derive(Debug)]
89pub struct LogBar {
90    entries: VecDeque<LogEntry>,
91    open: bool,
92    capacity: usize,
93    id_salt: Id,
94    heading: String,
95}
96
97impl Default for LogBar {
98    fn default() -> Self {
99        Self::new()
100    }
101}
102
103impl LogBar {
104    /// Create a log bar with default settings: capacity 100, heading
105    /// `"Message Log"`, starts collapsed.
106    pub fn new() -> Self {
107        Self {
108            entries: VecDeque::new(),
109            open: false,
110            capacity: DEFAULT_CAPACITY,
111            id_salt: Id::new("elegance::log_bar"),
112            heading: "Message Log".into(),
113        }
114    }
115
116    /// Set the label shown in the collapsed header. Default: `"Message Log"`.
117    pub fn heading(mut self, heading: impl Into<String>) -> Self {
118        self.heading = heading.into();
119        self
120    }
121
122    /// Set the maximum number of entries kept. When the buffer is full the
123    /// oldest entry is dropped on each push. Default: 100. Clamped to a
124    /// minimum of 1.
125    pub fn max_entries(mut self, n: usize) -> Self {
126        self.capacity = n.max(1);
127        while self.entries.len() > self.capacity {
128            self.entries.pop_back();
129        }
130        self
131    }
132
133    /// Override the id used for the panel and collapse state. Set this if
134    /// you want more than one `LogBar` in a single app.
135    pub fn id_salt(mut self, salt: impl Hash) -> Self {
136        self.id_salt = Id::new(("elegance::log_bar", salt));
137        self
138    }
139
140    /// Append an entry. The current wall-clock time (`HH:MM:SS` UTC) is
141    /// recorded as the row's timestamp.
142    pub fn push(&mut self, kind: LogKind, msg: impl Into<String>) {
143        self.entries.push_front(LogEntry {
144            time: now_hms(),
145            kind,
146            msg: msg.into(),
147        });
148        while self.entries.len() > self.capacity {
149            self.entries.pop_back();
150        }
151    }
152
153    /// Shortcut for `push(LogKind::Sys, msg)`.
154    pub fn sys(&mut self, msg: impl Into<String>) {
155        self.push(LogKind::Sys, msg);
156    }
157
158    /// Shortcut for `push(LogKind::Out, msg)`.
159    pub fn out(&mut self, msg: impl Into<String>) {
160        self.push(LogKind::Out, msg);
161    }
162
163    /// Shortcut for `push(LogKind::In, msg)` — named `recv` because `in`
164    /// is a Rust keyword.
165    pub fn recv(&mut self, msg: impl Into<String>) {
166        self.push(LogKind::In, msg);
167    }
168
169    /// Shortcut for `push(LogKind::Err, msg)`.
170    pub fn err(&mut self, msg: impl Into<String>) {
171        self.push(LogKind::Err, msg);
172    }
173
174    /// Remove all entries.
175    pub fn clear(&mut self) {
176        self.entries.clear();
177    }
178
179    /// Number of entries currently stored.
180    pub fn len(&self) -> usize {
181        self.entries.len()
182    }
183
184    /// Whether the buffer is empty.
185    pub fn is_empty(&self) -> bool {
186        self.entries.is_empty()
187    }
188
189    /// Whether the bar is currently expanded.
190    pub fn is_open(&self) -> bool {
191        self.open
192    }
193
194    /// Programmatically set the expanded state.
195    pub fn set_open(&mut self, open: bool) {
196        self.open = open;
197    }
198
199    /// Iterate entries from newest to oldest.
200    pub fn entries(&self) -> impl Iterator<Item = &LogEntry> {
201        self.entries.iter()
202    }
203
204    /// Render the bar. Call once per frame, **before** your `CentralPanel`,
205    /// inside your `App::ui` method.
206    pub fn show(&mut self, ui: &mut egui::Ui) {
207        let fill = Theme::current(ui.ctx()).palette.card;
208        egui::Panel::bottom(self.id_salt)
209            .resizable(false)
210            // The header strip claims the full panel width, so the panel
211            // frame itself carries no inner margin. Body content has its
212            // own margin Frame below.
213            .frame(egui::Frame::new().fill(fill))
214            .show_inside(ui, |ui| {
215                let theme = Theme::current(ui.ctx());
216                let count = self.entries.len();
217                let label = if count == 0 {
218                    self.heading.clone()
219                } else {
220                    format!("{}  \u{00b7}  {count}", self.heading)
221                };
222                let was_open = self.open;
223
224                let trigger = panel_header(ui, &theme, &label, was_open);
225                trigger.widget_info(|| {
226                    WidgetInfo::selected(WidgetType::CollapsingHeader, true, was_open, &label)
227                });
228                if trigger.clicked() {
229                    self.open = !self.open;
230                }
231
232                if self.open {
233                    egui::Frame::new()
234                        .inner_margin(egui::Margin::symmetric(16, 6))
235                        .show(ui, |ui| {
236                            ui.add_space(2.0);
237                            egui::ScrollArea::vertical()
238                                .max_height(SCROLL_MAX_HEIGHT)
239                                .auto_shrink([false, true])
240                                .show(ui, |ui| {
241                                    ui.spacing_mut().item_spacing.y = 2.0;
242                                    if self.entries.is_empty() {
243                                        ui.add(egui::Label::new(theme.faint_text("(no messages)")));
244                                    } else {
245                                        for entry in self.entries.iter() {
246                                            log_row(ui, &theme, entry);
247                                        }
248                                    }
249                                });
250                            ui.add_space(6.0);
251                            if ui
252                                .add(Button::new("Clear").outline().size(ButtonSize::Small))
253                                .clicked()
254                            {
255                                self.entries.clear();
256                            }
257                        });
258                }
259            });
260    }
261}
262
263fn now_hms() -> String {
264    let day = now_unix_secs() % 86400;
265    format!("{:02}:{:02}:{:02}", day / 3600, (day / 60) % 60, day % 60)
266}
267
268// `std::time::SystemTime::now()` panics on `wasm32-unknown-unknown`; use the
269// JS `Date` binding there instead.
270#[cfg(not(target_family = "wasm"))]
271fn now_unix_secs() -> u64 {
272    std::time::SystemTime::now()
273        .duration_since(std::time::UNIX_EPOCH)
274        .map(|d| d.as_secs())
275        .unwrap_or(0)
276}
277
278#[cfg(target_family = "wasm")]
279fn now_unix_secs() -> u64 {
280    (js_sys::Date::now() / 1000.0) as u64
281}
282
283/// A collapse trigger that spans the panel's full width. Clicking
284/// anywhere on the strip toggles the bar; the row tints on hover so the
285/// hit area is obvious.
286fn panel_header(ui: &mut egui::Ui, theme: &Theme, label: &str, open: bool) -> Response {
287    let p = &theme.palette;
288    let t = &theme.typography;
289
290    const PAD_X: f32 = 16.0;
291    const PAD_Y: f32 = 10.0;
292    const CHEVRON: f32 = 12.0;
293    const GAP: f32 = 8.0;
294
295    let galley = crate::theme::placeholder_galley(ui, label, t.label, false, f32::INFINITY);
296    let row_h = galley.size().y + PAD_Y * 2.0;
297    let row_w = ui.available_width();
298    let (rect, resp) = ui.allocate_exact_size(Vec2::new(row_w, row_h), Sense::click());
299
300    if ui.is_rect_visible(rect) {
301        let hovered = resp.hovered();
302        let label_color = if hovered { p.text } else { p.text_muted };
303        let chevron_color = if hovered { p.sky } else { p.text_muted };
304
305        if hovered {
306            ui.painter()
307                .rect_filled(rect, CornerRadius::ZERO, p.depth_tint(p.card, 0.12));
308        }
309
310        let chev_center = pos2(rect.min.x + PAD_X + CHEVRON * 0.5, rect.center().y);
311        draw_chevron(ui.painter(), chev_center, CHEVRON, chevron_color, open);
312
313        let text_pos = pos2(
314            rect.min.x + PAD_X + CHEVRON + GAP,
315            rect.center().y - galley.size().y * 0.5,
316        );
317        ui.painter().galley(text_pos, galley, label_color);
318    }
319
320    resp
321}
322
323fn draw_chevron(painter: &egui::Painter, center: Pos2, size: f32, color: Color32, open: bool) {
324    let half = size * 0.3;
325    let points: Vec<Pos2> = if open {
326        // Pointing down.
327        vec![
328            pos2(center.x - half, center.y - half * 0.55),
329            pos2(center.x + half, center.y - half * 0.55),
330            pos2(center.x, center.y + half * 0.75),
331        ]
332    } else {
333        // Pointing right.
334        vec![
335            pos2(center.x - half * 0.55, center.y - half),
336            pos2(center.x - half * 0.55, center.y + half),
337            pos2(center.x + half * 0.75, center.y),
338        ]
339    };
340    painter.add(egui::Shape::convex_polygon(points, color, Stroke::NONE));
341}
342
343fn log_row(ui: &mut egui::Ui, theme: &Theme, entry: &LogEntry) {
344    let p = &theme.palette;
345    let t = &theme.typography;
346    let (color, arrow) = match entry.kind {
347        LogKind::Sys => (p.text_faint, ""),
348        LogKind::Out => (p.text_muted, "\u{2192} "),
349        LogKind::In => (p.success, "\u{2190} "),
350        LogKind::Err => (p.danger, ""),
351    };
352    // `horizontal_top` so a wrapped message aligns to the timestamp's
353    // top edge instead of vertically centering across the row.
354    ui.horizontal_top(|ui| {
355        ui.spacing_mut().item_spacing.x = 0.0;
356        ui.add(egui::Label::new(
357            egui::RichText::new(&entry.time)
358                .monospace()
359                .color(p.text_faint)
360                .size(t.small),
361        ));
362        ui.add_space(10.0);
363        ui.add(
364            egui::Label::new(
365                egui::RichText::new(format!("{arrow}{}", entry.msg))
366                    .monospace()
367                    .color(color)
368                    .size(t.small),
369            )
370            .wrap_mode(egui::TextWrapMode::Wrap),
371        );
372    });
373}