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 a
4//! [`egui::Panel::bottom`] with a [`CollapsingSection`](crate::CollapsingSection)
5//! trigger, a scrollable monospace log area (capped at 200 pt), and a Clear
6//! button. Call [`LogBar::show`] once per frame inside your `App::ui`,
7//! before your `CentralPanel`.
8//!
9//! Entries are prepended (newest at top) and capped at [`LogBar::max_entries`]
10//! — older rows are silently dropped once the buffer is full.
11//!
12//! # Example
13//!
14//! ```no_run
15//! use elegance::{LogBar, Theme};
16//!
17//! struct App { log: LogBar }
18//!
19//! impl Default for App {
20//!     fn default() -> Self {
21//!         let mut log = LogBar::new().heading("Events");
22//!         log.sys("Connected");
23//!         Self { log }
24//!     }
25//! }
26//!
27//! impl eframe::App for App {
28//!     fn ui(&mut self, ui: &mut egui::Ui, _: &mut eframe::Frame) {
29//!         Theme::slate().install(ui.ctx());
30//!         self.log.show(ui);
31//!         egui::CentralPanel::default().show_inside(ui, |ui| {
32//!             if ui.button("Reload").clicked() {
33//!                 self.log.out("reload_config");
34//!                 self.log.recv("ok");
35//!             }
36//!         });
37//!     }
38//! }
39//! ```
40//!
41//! The four [`LogKind`] variants map to different colours and arrow prefixes
42//! in the spirit of a browser devtools console: outgoing requests, incoming
43//! responses, errors, and plain system messages.
44
45use std::collections::VecDeque;
46use std::hash::Hash;
47
48use egui::Id;
49
50use crate::theme::Theme;
51use crate::{Button, ButtonSize, CollapsingSection};
52
53/// How a single log row is styled and prefixed.
54#[derive(Debug, Clone, Copy, PartialEq, Eq)]
55pub enum LogKind {
56    /// System or status message — faint text, no arrow.
57    Sys,
58    /// Outgoing request — muted text with a "→" prefix.
59    Out,
60    /// Incoming response — success-green text with a "←" prefix.
61    In,
62    /// Error — danger-red text, no arrow.
63    Err,
64}
65
66/// A single log row.
67#[derive(Debug, Clone)]
68pub struct LogEntry {
69    /// Timestamp string, `HH:MM:SS` UTC by default.
70    pub time: String,
71    /// Row style.
72    pub kind: LogKind,
73    /// Message body.
74    pub msg: String,
75}
76
77const DEFAULT_CAPACITY: usize = 100;
78const SCROLL_MAX_HEIGHT: f32 = 200.0;
79
80/// An expandable log bar anchored to the bottom of the viewport.
81///
82/// Owns its own entry buffer — construct once, store on your app struct,
83/// call [`LogBar::push`] (or one of the shortcuts) from any event handler,
84/// and call [`LogBar::show`] once per frame to render.
85#[derive(Debug)]
86pub struct LogBar {
87    entries: VecDeque<LogEntry>,
88    open: bool,
89    capacity: usize,
90    id_salt: Id,
91    heading: String,
92}
93
94impl Default for LogBar {
95    fn default() -> Self {
96        Self::new()
97    }
98}
99
100impl LogBar {
101    /// Create a log bar with default settings: capacity 100, heading
102    /// `"Message Log"`, starts collapsed.
103    pub fn new() -> Self {
104        Self {
105            entries: VecDeque::new(),
106            open: false,
107            capacity: DEFAULT_CAPACITY,
108            id_salt: Id::new("elegance::log_bar"),
109            heading: "Message Log".into(),
110        }
111    }
112
113    /// Set the label shown in the collapsed header. Default: `"Message Log"`.
114    pub fn heading(mut self, heading: impl Into<String>) -> Self {
115        self.heading = heading.into();
116        self
117    }
118
119    /// Set the maximum number of entries kept. When the buffer is full the
120    /// oldest entry is dropped on each push. Default: 100. Clamped to a
121    /// minimum of 1.
122    pub fn max_entries(mut self, n: usize) -> Self {
123        self.capacity = n.max(1);
124        while self.entries.len() > self.capacity {
125            self.entries.pop_back();
126        }
127        self
128    }
129
130    /// Override the id used for the panel and collapse state. Set this if
131    /// you want more than one `LogBar` in a single app.
132    pub fn id_salt(mut self, salt: impl Hash) -> Self {
133        self.id_salt = Id::new(("elegance::log_bar", salt));
134        self
135    }
136
137    /// Append an entry. The current wall-clock time (`HH:MM:SS` UTC) is
138    /// recorded as the row's timestamp.
139    pub fn push(&mut self, kind: LogKind, msg: impl Into<String>) {
140        self.entries.push_front(LogEntry {
141            time: now_hms(),
142            kind,
143            msg: msg.into(),
144        });
145        while self.entries.len() > self.capacity {
146            self.entries.pop_back();
147        }
148    }
149
150    /// Shortcut for `push(LogKind::Sys, msg)`.
151    pub fn sys(&mut self, msg: impl Into<String>) {
152        self.push(LogKind::Sys, msg);
153    }
154
155    /// Shortcut for `push(LogKind::Out, msg)`.
156    pub fn out(&mut self, msg: impl Into<String>) {
157        self.push(LogKind::Out, msg);
158    }
159
160    /// Shortcut for `push(LogKind::In, msg)` — named `recv` because `in`
161    /// is a Rust keyword.
162    pub fn recv(&mut self, msg: impl Into<String>) {
163        self.push(LogKind::In, msg);
164    }
165
166    /// Shortcut for `push(LogKind::Err, msg)`.
167    pub fn err(&mut self, msg: impl Into<String>) {
168        self.push(LogKind::Err, msg);
169    }
170
171    /// Remove all entries.
172    pub fn clear(&mut self) {
173        self.entries.clear();
174    }
175
176    /// Number of entries currently stored.
177    pub fn len(&self) -> usize {
178        self.entries.len()
179    }
180
181    /// Whether the buffer is empty.
182    pub fn is_empty(&self) -> bool {
183        self.entries.is_empty()
184    }
185
186    /// Whether the bar is currently expanded.
187    pub fn is_open(&self) -> bool {
188        self.open
189    }
190
191    /// Programmatically set the expanded state.
192    pub fn set_open(&mut self, open: bool) {
193        self.open = open;
194    }
195
196    /// Iterate entries from newest to oldest.
197    pub fn entries(&self) -> impl Iterator<Item = &LogEntry> {
198        self.entries.iter()
199    }
200
201    /// Render the bar. Call once per frame, **before** your `CentralPanel`,
202    /// inside your `App::ui` method.
203    pub fn show(&mut self, ui: &mut egui::Ui) {
204        let fill = Theme::current(ui.ctx()).palette.card;
205        egui::Panel::bottom(self.id_salt)
206            .resizable(false)
207            .frame(
208                egui::Frame::new()
209                    .fill(fill)
210                    .inner_margin(egui::Margin::symmetric(16, 6)),
211            )
212            .show_inside(ui, |ui| {
213                let theme = Theme::current(ui.ctx());
214                let count = self.entries.len();
215                let label = if count == 0 {
216                    self.heading.clone()
217                } else {
218                    format!("{}  \u{00b7}  {count}", self.heading)
219                };
220
221                let mut clear = false;
222                CollapsingSection::new(self.id_salt.with("collapse"), label)
223                    .open(&mut self.open)
224                    .show(ui, |ui| {
225                        ui.add_space(2.0);
226                        egui::ScrollArea::vertical()
227                            .max_height(SCROLL_MAX_HEIGHT)
228                            .auto_shrink([false, true])
229                            .show(ui, |ui| {
230                                ui.spacing_mut().item_spacing.y = 2.0;
231                                if self.entries.is_empty() {
232                                    ui.add(egui::Label::new(theme.faint_text("(no messages)")));
233                                } else {
234                                    for entry in self.entries.iter() {
235                                        log_row(ui, &theme, entry);
236                                    }
237                                }
238                            });
239                        ui.add_space(6.0);
240                        if ui
241                            .add(Button::new("Clear").outline().size(ButtonSize::Small))
242                            .clicked()
243                        {
244                            clear = true;
245                        }
246                    });
247                if clear {
248                    self.entries.clear();
249                }
250            });
251    }
252}
253
254fn now_hms() -> String {
255    let day = now_unix_secs() % 86400;
256    format!("{:02}:{:02}:{:02}", day / 3600, (day / 60) % 60, day % 60)
257}
258
259// `std::time::SystemTime::now()` panics on `wasm32-unknown-unknown`; use the
260// JS `Date` binding there instead.
261#[cfg(not(target_family = "wasm"))]
262fn now_unix_secs() -> u64 {
263    std::time::SystemTime::now()
264        .duration_since(std::time::UNIX_EPOCH)
265        .map(|d| d.as_secs())
266        .unwrap_or(0)
267}
268
269#[cfg(target_family = "wasm")]
270fn now_unix_secs() -> u64 {
271    (js_sys::Date::now() / 1000.0) as u64
272}
273
274fn log_row(ui: &mut egui::Ui, theme: &Theme, entry: &LogEntry) {
275    let p = &theme.palette;
276    let t = &theme.typography;
277    let (color, arrow) = match entry.kind {
278        LogKind::Sys => (p.text_faint, ""),
279        LogKind::Out => (p.text_muted, "\u{2192} "),
280        LogKind::In => (p.success, "\u{2190} "),
281        LogKind::Err => (p.danger, ""),
282    };
283    ui.horizontal(|ui| {
284        ui.spacing_mut().item_spacing.x = 0.0;
285        ui.add(egui::Label::new(
286            egui::RichText::new(&entry.time)
287                .monospace()
288                .color(p.text_faint)
289                .size(t.small),
290        ));
291        ui.add_space(10.0);
292        ui.add(egui::Label::new(
293            egui::RichText::new(format!("{arrow}{}", entry.msg))
294                .monospace()
295                .color(color)
296                .size(t.small),
297        ));
298    });
299}