use std::collections::VecDeque;
use std::hash::Hash;
use egui::{
pos2, Color32, CornerRadius, Id, Pos2, Response, Sense, Stroke, Vec2, WidgetInfo, WidgetType,
};
use crate::theme::Theme;
use crate::{Button, ButtonSize};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum LogKind {
Sys,
Out,
In,
Err,
}
#[derive(Debug, Clone)]
pub struct LogEntry {
pub time: String,
pub kind: LogKind,
pub msg: String,
}
const DEFAULT_CAPACITY: usize = 100;
const SCROLL_MAX_HEIGHT: f32 = 200.0;
#[derive(Debug)]
pub struct LogBar {
entries: VecDeque<LogEntry>,
open: bool,
capacity: usize,
id_salt: Id,
heading: String,
}
impl Default for LogBar {
fn default() -> Self {
Self::new()
}
}
impl LogBar {
pub fn new() -> Self {
Self {
entries: VecDeque::new(),
open: false,
capacity: DEFAULT_CAPACITY,
id_salt: Id::new("elegance::log_bar"),
heading: "Message Log".into(),
}
}
pub fn heading(mut self, heading: impl Into<String>) -> Self {
self.heading = heading.into();
self
}
pub fn max_entries(mut self, n: usize) -> Self {
self.capacity = n.max(1);
while self.entries.len() > self.capacity {
self.entries.pop_back();
}
self
}
pub fn id_salt(mut self, salt: impl Hash) -> Self {
self.id_salt = Id::new(("elegance::log_bar", salt));
self
}
pub fn push(&mut self, kind: LogKind, msg: impl Into<String>) {
self.entries.push_front(LogEntry {
time: now_hms(),
kind,
msg: msg.into(),
});
while self.entries.len() > self.capacity {
self.entries.pop_back();
}
}
pub fn sys(&mut self, msg: impl Into<String>) {
self.push(LogKind::Sys, msg);
}
pub fn out(&mut self, msg: impl Into<String>) {
self.push(LogKind::Out, msg);
}
pub fn recv(&mut self, msg: impl Into<String>) {
self.push(LogKind::In, msg);
}
pub fn err(&mut self, msg: impl Into<String>) {
self.push(LogKind::Err, msg);
}
pub fn clear(&mut self) {
self.entries.clear();
}
pub fn len(&self) -> usize {
self.entries.len()
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn is_open(&self) -> bool {
self.open
}
pub fn set_open(&mut self, open: bool) {
self.open = open;
}
pub fn entries(&self) -> impl Iterator<Item = &LogEntry> {
self.entries.iter()
}
pub fn show(&mut self, ui: &mut egui::Ui) {
let fill = Theme::current(ui.ctx()).palette.card;
egui::Panel::bottom(self.id_salt)
.resizable(false)
.frame(egui::Frame::new().fill(fill))
.show_inside(ui, |ui| {
let theme = Theme::current(ui.ctx());
let count = self.entries.len();
let label = if count == 0 {
self.heading.clone()
} else {
format!("{} \u{00b7} {count}", self.heading)
};
let was_open = self.open;
let trigger = panel_header(ui, &theme, &label, was_open);
trigger.widget_info(|| {
WidgetInfo::selected(WidgetType::CollapsingHeader, true, was_open, &label)
});
if trigger.clicked() {
self.open = !self.open;
}
if self.open {
egui::Frame::new()
.inner_margin(egui::Margin::symmetric(16, 6))
.show(ui, |ui| {
ui.add_space(2.0);
egui::ScrollArea::vertical()
.max_height(SCROLL_MAX_HEIGHT)
.auto_shrink([false, true])
.show(ui, |ui| {
ui.spacing_mut().item_spacing.y = 2.0;
if self.entries.is_empty() {
ui.add(egui::Label::new(theme.faint_text("(no messages)")));
} else {
for entry in self.entries.iter() {
log_row(ui, &theme, entry);
}
}
});
ui.add_space(6.0);
if ui
.add(Button::new("Clear").outline().size(ButtonSize::Small))
.clicked()
{
self.entries.clear();
}
});
}
});
}
}
fn now_hms() -> String {
let day = now_unix_secs() % 86400;
format!("{:02}:{:02}:{:02}", day / 3600, (day / 60) % 60, day % 60)
}
#[cfg(not(target_family = "wasm"))]
fn now_unix_secs() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0)
}
#[cfg(target_family = "wasm")]
fn now_unix_secs() -> u64 {
(js_sys::Date::now() / 1000.0) as u64
}
fn panel_header(ui: &mut egui::Ui, theme: &Theme, label: &str, open: bool) -> Response {
let p = &theme.palette;
let t = &theme.typography;
const PAD_X: f32 = 16.0;
const PAD_Y: f32 = 10.0;
const CHEVRON: f32 = 12.0;
const GAP: f32 = 8.0;
let galley = crate::theme::placeholder_galley(ui, label, t.label, false, f32::INFINITY);
let row_h = galley.size().y + PAD_Y * 2.0;
let row_w = ui.available_width();
let (rect, resp) = ui.allocate_exact_size(Vec2::new(row_w, row_h), Sense::click());
if ui.is_rect_visible(rect) {
let hovered = resp.hovered();
let label_color = if hovered { p.text } else { p.text_muted };
let chevron_color = if hovered { p.sky } else { p.text_muted };
if hovered {
ui.painter()
.rect_filled(rect, CornerRadius::ZERO, p.depth_tint(p.card, 0.12));
}
let chev_center = pos2(rect.min.x + PAD_X + CHEVRON * 0.5, rect.center().y);
draw_chevron(ui.painter(), chev_center, CHEVRON, chevron_color, open);
let text_pos = pos2(
rect.min.x + PAD_X + CHEVRON + GAP,
rect.center().y - galley.size().y * 0.5,
);
ui.painter().galley(text_pos, galley, label_color);
}
resp
}
fn draw_chevron(painter: &egui::Painter, center: Pos2, size: f32, color: Color32, open: bool) {
let half = size * 0.3;
let points: Vec<Pos2> = if open {
vec![
pos2(center.x - half, center.y - half * 0.55),
pos2(center.x + half, center.y - half * 0.55),
pos2(center.x, center.y + half * 0.75),
]
} else {
vec![
pos2(center.x - half * 0.55, center.y - half),
pos2(center.x - half * 0.55, center.y + half),
pos2(center.x + half * 0.75, center.y),
]
};
painter.add(egui::Shape::convex_polygon(points, color, Stroke::NONE));
}
fn log_row(ui: &mut egui::Ui, theme: &Theme, entry: &LogEntry) {
let p = &theme.palette;
let t = &theme.typography;
let (color, arrow) = match entry.kind {
LogKind::Sys => (p.text_faint, ""),
LogKind::Out => (p.text_muted, "\u{2192} "),
LogKind::In => (p.success, "\u{2190} "),
LogKind::Err => (p.danger, ""),
};
ui.horizontal_top(|ui| {
ui.spacing_mut().item_spacing.x = 0.0;
ui.add(egui::Label::new(
egui::RichText::new(&entry.time)
.monospace()
.color(p.text_faint)
.size(t.small),
));
ui.add_space(10.0);
ui.add(
egui::Label::new(
egui::RichText::new(format!("{arrow}{}", entry.msg))
.monospace()
.color(color)
.size(t.small),
)
.wrap_mode(egui::TextWrapMode::Wrap),
);
});
}