use egui_extras::{Column, TableBuilder};
use std::{
collections::VecDeque,
sync::{Arc, Mutex},
};
use tracing::{Level, level_filters::LevelFilter};
use tracing_subscriber::Layer;
use web_time::SystemTime;
pub struct TraceView {
capture: TraceCapture,
id: egui::Id,
level: LevelFilter,
}
#[derive(Clone)]
struct TraceViewState {
text_filter: String,
auto_scroll: bool,
show_time: bool,
show_date: bool,
show_level: bool,
show_target: bool,
}
#[derive(Clone)]
pub struct TraceEntry {
pub level: Level,
pub message: String,
pub target: String,
pub timestamp: SystemTime,
}
#[derive(Clone)]
pub struct TraceCapture {
entries: Arc<Mutex<VecDeque<TraceEntry>>>,
max_entries: usize,
}
pub struct TraceCaptureLayer<S> {
capture: TraceCapture,
_subscriber: std::marker::PhantomData<S>,
}
#[derive(Default)]
struct MessageVisitor {
message: String,
}
impl TraceEntry {
fn format_timestamp(&self, show_date: bool) -> String {
let system_time = crate::system_time_from_web(self.timestamp).expect("failed to convert");
if show_date {
crate::widget::format_local_datetime(system_time)
} else {
crate::widget::format_local_time(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
}
}
impl TraceCapture {
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<TraceEntry> {
self.entries.lock().unwrap().iter().cloned().collect()
}
pub fn clear(&self) {
self.entries.lock().unwrap().clear();
}
pub fn layer<S>(self) -> TraceCaptureLayer<S>
where
S: tracing::Subscriber,
{
TraceCaptureLayer {
capture: self,
_subscriber: std::marker::PhantomData,
}
}
fn add_entry(&self, entry: TraceEntry) {
let mut entries = self.entries.lock().unwrap();
entries.push_back(entry);
while entries.len() > self.max_entries {
entries.pop_front();
}
}
}
impl TraceView {
pub fn new(id: egui::Id, capture: TraceCapture, level: LevelFilter) -> Self {
Self { capture, id, level }
}
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::<TraceViewState>(state_id))
.unwrap_or_else(|| TraceViewState {
text_filter: String::new(),
auto_scroll: true,
show_time: true,
show_date: true,
show_level: true,
show_target: false,
});
ui.horizontal(|ui| {
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let h = ui.spacing().interact_size.y;
let btn = ui
.add_sized([h, h], egui::Button::new(crate::widget::OPTIONS_GLYPH))
.on_hover_text("trace options");
egui::Popup::menu(&btn)
.close_behavior(egui::PopupCloseBehavior::CloseOnClickOutside)
.show(|ui| {
#[allow(unused_variables)]
let level_response = ui.label(format!("Level: {}", self.level));
#[cfg(not(target_arch = "wasm32"))]
level_response.on_hover_text("adjust with RUST_LOG env var at startup");
ui.checkbox(&mut state.auto_scroll, "Auto-scroll");
ui.separator();
ui.label("Columns:");
ui.checkbox(&mut state.show_time, "Time");
ui.add_enabled(
state.show_time,
egui::Checkbox::new(&mut state.show_date, "Date"),
)
.on_hover_text("include the date in the Time column");
ui.checkbox(&mut state.show_level, "Level");
ui.checkbox(&mut state.show_target, "Target");
ui.separator();
if ui.button("Clear").clicked() {
self.capture.clear();
}
});
egui::TextEdit::singleline(&mut state.text_filter)
.desired_width(ui.available_width())
.hint_text("🔎 Filter")
.show(ui);
});
});
ui.separator();
let mut entries = self.capture.get_entries();
entries.retain(|entry| match self.level {
LevelFilter::OFF => false,
LevelFilter::ERROR => entry.level <= Level::ERROR,
LevelFilter::WARN => entry.level <= Level::WARN,
LevelFilter::INFO => entry.level <= Level::INFO,
LevelFilter::DEBUG => entry.level <= Level::DEBUG,
LevelFilter::TRACE => true,
});
if !state.text_filter.is_empty() {
let filter = state.text_filter.to_lowercase();
entries.retain(|entry| {
let hay = format!("{} {}", entry.message, entry.target).to_lowercase();
filter.split_whitespace().all(|term| hay.contains(term))
});
}
entries.reverse();
let runs = crate::widget::group_runs(&entries, |a, b| {
a.level == b.level && a.message == b.message && a.target == b.target
});
let (show_time, show_date, show_level, show_target) = (
state.show_time,
state.show_date,
state.show_level,
state.show_target,
);
let mut table = TableBuilder::new(ui).striped(true).resizable(true);
if show_time {
table = table.column(Column::auto().at_least(80.0)); }
if show_level {
table = table.column(Column::auto().at_least(50.0)); }
if show_target {
table = table.column(Column::auto().at_least(80.0)); }
table
.column(Column::remainder().at_least(100.0)) .header(20.0, |mut header| {
if show_time {
header.col(|ui| {
ui.strong("Time");
});
}
if show_level {
header.col(|ui| {
ui.strong("Level");
});
}
if show_target {
header.col(|ui| {
ui.strong("Target");
});
}
header.col(|ui| {
ui.strong("Message");
});
})
.body(|mut body| {
let row_h = 18.0;
let n_rows = runs.len();
let text_color = body.ui_mut().style().visuals.text_color();
body.rows(row_h, n_rows, |mut row| {
let (idx, count) = runs[row.index()];
let entry = &entries[idx];
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
};
if show_time {
row.col(|ui| {
let text = entry.format_timestamp(show_date);
ui.colored_label(text_color, text);
});
}
if show_level {
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);
});
}
if show_target {
row.col(|ui| {
ui.colored_label(text_color, &entry.target);
});
}
row.col(|ui| {
ui.horizontal(|ui| {
if count > 1 {
ui.colored_label(
text_color.gamma_multiply(0.7),
format!("×{count}"),
)
.on_hover_text("occurrences collapsed (repeated log)");
}
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));
}
}
impl Default for TraceCapture {
fn default() -> Self {
Self::new(Self::DEFAULT_MAX_ENTRIES)
}
}
impl<S> Clone for TraceCaptureLayer<S> {
fn clone(&self) -> Self {
Self {
capture: self.capture.clone(),
_subscriber: std::marker::PhantomData,
}
}
}
impl<S> Layer<S> for TraceCaptureLayer<S>
where
S: tracing::Subscriber,
{
fn on_event(
&self,
event: &tracing::Event<'_>,
_ctx: tracing_subscriber::layer::Context<'_, S>,
) {
let metadata = event.metadata();
let mut visitor = MessageVisitor::default();
event.record(&mut visitor);
let entry = TraceEntry {
level: *metadata.level(),
message: visitor.message,
target: metadata.target().to_string(),
timestamp: SystemTime::now(),
};
self.capture.add_entry(entry);
}
}
impl tracing::field::Visit for MessageVisitor {
fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
if field.name() == "message" {
if !self.message.is_empty() {
self.message.push(' ');
}
self.message.push_str(&format!("{:?}", value));
}
}
fn record_str(&mut self, field: &tracing::field::Field, value: &str) {
if field.name() == "message" {
if !self.message.is_empty() {
self.message.push(' ');
}
self.message.push_str(value);
}
}
}