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}