use egui_extras::{Column, TableBuilder};
use log::{Level, Metadata, Record};
use std::{
collections::VecDeque,
sync::{Arc, Mutex},
};
use web_time::SystemTime;
pub struct LogView {
logger: Logger,
id: egui::Id,
}
#[derive(Clone)]
struct LogViewState {
level_filter: log::LevelFilter,
target_filter: String,
auto_scroll: bool,
}
#[derive(Clone)]
pub struct LogEntry {
pub level: Level,
pub message: String,
pub target: String,
pub timestamp: SystemTime,
}
impl LogEntry {
fn format_timestamp(&self) -> String {
let system_time = crate::system_time_from_web(self.timestamp).expect("failed to convert");
crate::widget::format_local_datetime(system_time)
}
fn freshness(&self) -> f32 {
let now = SystemTime::now();
if let Ok(duration) = now.duration_since(self.timestamp) {
if duration.as_secs_f32() >= 1.0 {
return 0.0;
}
return (1.0 - duration.as_secs_f32()).powf(3.0);
}
0.0
}
}
#[derive(Clone)]
pub struct Logger {
entries: Arc<Mutex<VecDeque<LogEntry>>>,
max_entries: usize,
}
impl Logger {
pub const DEFAULT_MAX_ENTRIES: usize = 1_000;
pub fn new(max_entries: usize) -> Self {
Self {
entries: Arc::new(Mutex::new(VecDeque::new())),
max_entries,
}
}
pub fn get_entries(&self) -> Vec<LogEntry> {
self.entries.lock().unwrap().iter().cloned().collect()
}
pub fn clear(&self) {
self.entries.lock().unwrap().clear();
}
}
impl Default for Logger {
fn default() -> Self {
Self::new(Self::DEFAULT_MAX_ENTRIES)
}
}
impl log::Log for Logger {
fn enabled(&self, _metadata: &Metadata) -> bool {
true
}
fn log(&self, record: &Record) {
let entry = LogEntry {
level: record.level(),
message: record.args().to_string(),
target: record.target().to_string(),
timestamp: SystemTime::now(),
};
let mut entries = self.entries.lock().unwrap();
entries.push_back(entry);
while entries.len() > self.max_entries {
entries.pop_front();
}
}
fn flush(&self) {}
}
impl LogView {
pub fn new(id: egui::Id, logger: Logger) -> Self {
Self { logger, id }
}
pub fn show(&mut self, ui: &mut egui::Ui) {
let state_id = self.id.with("state");
let mut state = ui
.memory_mut(|mem| mem.data.get_temp::<LogViewState>(state_id))
.unwrap_or_else(|| LogViewState {
level_filter: log::max_level(),
target_filter: String::new(),
auto_scroll: true,
});
ui.horizontal(|ui| {
ui.label("Level:");
let init_filter = state.level_filter.clone();
egui::ComboBox::from_id_salt(self.id.with("level_filter"))
.selected_text(format!("{}", state.level_filter))
.show_ui(ui, |ui| {
ui.selectable_value(&mut state.level_filter, log::LevelFilter::Off, "Off");
ui.selectable_value(&mut state.level_filter, log::LevelFilter::Error, "Error");
ui.selectable_value(&mut state.level_filter, log::LevelFilter::Warn, "Warn");
ui.selectable_value(&mut state.level_filter, log::LevelFilter::Info, "Info");
ui.selectable_value(&mut state.level_filter, log::LevelFilter::Debug, "Debug");
ui.selectable_value(&mut state.level_filter, log::LevelFilter::Trace, "Trace");
});
if init_filter != state.level_filter {
log::set_max_level(state.level_filter);
}
ui.separator();
ui.checkbox(&mut state.auto_scroll, "Auto-scroll");
ui.separator();
if ui.button("Clear").clicked() {
self.logger.clear();
}
});
ui.separator();
ui.horizontal(|ui| {
ui.label("Target:");
ui.text_edit_singleline(&mut state.target_filter);
});
ui.separator();
let mut entries = self.logger.get_entries();
entries.retain(|entry| entry.level <= state.level_filter);
if !state.target_filter.is_empty() {
let filter = state.target_filter.to_lowercase();
entries.retain(|entry| entry.target.to_lowercase().contains(&filter));
}
entries.reverse();
TableBuilder::new(ui)
.striped(true)
.resizable(true)
.column(Column::auto().at_least(80.0)) .column(Column::auto().at_least(50.0)) .column(Column::auto().at_least(80.0)) .column(Column::remainder().at_least(100.0)) .header(20.0, |mut header| {
header.col(|ui| {
ui.strong("Time");
});
header.col(|ui| {
ui.strong("Level");
});
header.col(|ui| {
ui.strong("Target");
});
header.col(|ui| {
ui.strong("Message");
});
})
.body(|mut body| {
let row_h = 18.0;
let n_rows = entries.len();
let text_color = body.ui_mut().style().visuals.text_color();
body.rows(row_h, n_rows, |mut row| {
let entry = &entries[row.index()];
let freshness = entry.freshness();
let fresh = freshness > 0.0;
let text_color = if fresh {
let hl_col = egui::Color32::WHITE;
text_color.lerp_to_gamma(hl_col, freshness)
} else {
text_color
};
row.col(|ui| {
let text = entry.format_timestamp();
ui.colored_label(text_color, text);
});
row.col(|ui| {
let (color, text) = match entry.level {
Level::Error => (egui::Color32::from_rgb(255, 100, 100), "ERROR"),
Level::Warn => (egui::Color32::from_rgb(255, 200, 100), "WARN"),
Level::Info => (egui::Color32::from_rgb(100, 200, 255), "INFO"),
Level::Debug => (egui::Color32::GRAY, "DEBUG"),
Level::Trace => (egui::Color32::DARK_GRAY, "TRACE"),
};
ui.colored_label(color, text);
});
row.col(|ui| {
ui.colored_label(text_color, &entry.target);
});
row.col(|ui| {
ui.colored_label(text_color, &entry.message);
});
if fresh {
row.response().ctx.request_repaint();
}
});
});
if state.auto_scroll {
ui.scroll_to_cursor(Some(egui::Align::BOTTOM));
}
ui.memory_mut(|mem| mem.data.insert_temp(state_id, state));
}
}