1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
58pub enum LogKind {
59 Sys,
61 Out,
63 In,
65 Err,
67}
68
69#[derive(Debug, Clone)]
71pub struct LogEntry {
72 pub time: String,
74 pub kind: LogKind,
76 pub msg: String,
78}
79
80const DEFAULT_CAPACITY: usize = 100;
81const SCROLL_MAX_HEIGHT: f32 = 200.0;
82
83#[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 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 pub fn heading(mut self, heading: impl Into<String>) -> Self {
118 self.heading = heading.into();
119 self
120 }
121
122 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 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 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 pub fn sys(&mut self, msg: impl Into<String>) {
155 self.push(LogKind::Sys, msg);
156 }
157
158 pub fn out(&mut self, msg: impl Into<String>) {
160 self.push(LogKind::Out, msg);
161 }
162
163 pub fn recv(&mut self, msg: impl Into<String>) {
166 self.push(LogKind::In, msg);
167 }
168
169 pub fn err(&mut self, msg: impl Into<String>) {
171 self.push(LogKind::Err, msg);
172 }
173
174 pub fn clear(&mut self) {
176 self.entries.clear();
177 }
178
179 pub fn len(&self) -> usize {
181 self.entries.len()
182 }
183
184 pub fn is_empty(&self) -> bool {
186 self.entries.is_empty()
187 }
188
189 pub fn is_open(&self) -> bool {
191 self.open
192 }
193
194 pub fn set_open(&mut self, open: bool) {
196 self.open = open;
197 }
198
199 pub fn entries(&self) -> impl Iterator<Item = &LogEntry> {
201 self.entries.iter()
202 }
203
204 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 .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#[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
283fn 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 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 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 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}