//! The Logs tab: what the terminal would have shown. //! //! Everything here comes from [`quicksearch_core::log`], which background //! threads write through instead of printing. Launched from a desktop //! launcher there is no terminal to read, and this is the only place a //! "cannot read that folder" warning is visible. use quicksearch_core::log::{self, Level, LogLine}; use crate::format::group_thousands; use crate::ui_util::hint; /// How often to repaint while the tab is open: log lines arrive on threads /// that never wake the UI, so an idle window would sit on a stale list. const REFRESH_MS: u64 = 500; pub struct LogsTab { /// Copy of the ring, refreshed only when the recorded count moves. lines: Vec, /// [`log::recorded`] as of the last refresh. seen: u64, dropped: u64, filter: String, warnings_only: bool, /// Keep the newest line in view. Scrolling up releases the view anyway /// (egui unsticks a scroll area the user moves, and re-sticks it when /// they return to the bottom); unticking this stops it following at all. follow: bool, /// Indices into `lines` that pass both filters. /// /// Cached rather than rebuilt per frame: `keep` lowercases each line to /// compare it, so with a filter typed this was up to [`log::CAPACITY`] /// (5,000) string allocations every frame, on a tab that repaints twice a /// second by itself and on every input frame besides. shown: Vec, /// What `shown` was computed from — refresh counter, filter text, /// warnings-only — so it can be rebuilt exactly when one of them moves. shown_key: (u64, String, bool), } impl LogsTab { pub fn new() -> LogsTab { LogsTab { lines: Vec::new(), seen: 0, dropped: 0, filter: String::new(), warnings_only: false, follow: true, shown: Vec::new(), // `u64::MAX` so the first frame always counts as stale, whatever // the ring's counter happens to be. shown_key: (u64::MAX, String::new(), false), } } /// Rebuild [`LogsTab::shown`] if any of its inputs moved. fn resync_shown(&mut self) { if self.shown_key.0 == self.seen && self.shown_key.2 == self.warnings_only && self.shown_key.1 == self.filter { return; } let needle = self.filter.to_lowercase(); let warnings_only = self.warnings_only; let mut shown = std::mem::take(&mut self.shown); shown.clear(); shown.extend( self.lines .iter() .enumerate() .filter(|(_, l)| keep(l, &needle, warnings_only)) .map(|(i, _)| i), ); self.shown = shown; self.shown_key = (self.seen, self.filter.clone(), warnings_only); } fn refresh(&mut self) { self.lines = log::snapshot(); self.seen = log::recorded(); self.dropped = log::dropped(); } pub fn ui(&mut self, ui: &mut egui::Ui) { if log::recorded() != self.seen { self.refresh(); } ui.ctx() .request_repaint_after(std::time::Duration::from_millis(REFRESH_MS)); // The filter and warnings-only widgets are drawn below, so a change // lands on the next frame — the same one-frame lag this always had, // and the tab repaints immediately anyway. self.resync_shown(); let shown = std::mem::take(&mut self.shown); let mut cleared = false; ui.horizontal(|ui| { ui.checkbox(&mut self.follow, "Follow") .on_hover_text("Scroll to the newest line as it arrives"); ui.checkbox(&mut self.warnings_only, "Warnings only"); ui.add( egui::TextEdit::singleline(&mut self.filter) .desired_width(200.0) .hint_text("Filter"), ); if ui .add_enabled(!shown.is_empty(), egui::Button::new("Copy")) .on_hover_text("Copy the lines shown below to the clipboard") .clicked() { let joined = shown .iter() .map(|&i| self.lines[i].text.as_str()) .collect::>() .join("\n"); ui.ctx().copy_text(joined); } if ui .add_enabled(!self.lines.is_empty(), egui::Button::new("Clear")) .clicked() { log::clear(); cleared = true; } ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let count = if shown.len() == self.lines.len() { format!("{} lines", group_thousands(self.lines.len() as u64)) } else { format!( "{} of {} lines", group_thousands(shown.len() as u64), group_thousands(self.lines.len() as u64) ) }; ui.label(hint(count)); }); }); if cleared { // `shown` indexes lines that no longer exist. Handing the buffer // back keeps its capacity; `refresh` moves `seen`, which is what // makes the next frame rebuild the contents. self.shown = shown; self.refresh(); return; } if self.dropped > 0 { ui.label( egui::RichText::new(format!( "{} earlier lines were dropped; the newest {} are kept.", group_thousands(self.dropped), group_thousands(log::CAPACITY as u64), )) .small() .weak(), ); } ui.separator(); if self.lines.is_empty() { ui.label( egui::RichText::new( "Nothing logged yet. Warnings from indexing, watching folders and \ opening files appear here — the same lines the terminal would show.", ) .weak(), ); self.shown = shown; return; } if shown.is_empty() { ui.label(egui::RichText::new("No lines match the filter.").weak()); self.shown = shown; return; } // `show_rows` assumes every row is exactly one line tall, so long // paths extend into a horizontal scroll rather than wrapping. ui.style_mut().wrap_mode = Some(egui::TextWrapMode::Extend); let row_height = ui.text_style_height(&egui::TextStyle::Monospace); let scroll = egui::ScrollArea::both() .auto_shrink([false; 2]) .stick_to_bottom(self.follow) .show_rows(ui, row_height, shown.len(), |ui, range| { for &i in &shown[range] { let line = &self.lines[i]; ui.horizontal(|ui| { ui.label(egui::RichText::new(fmt_clock(line.at)).monospace().weak()); let text = egui::RichText::new(&line.text).monospace(); match line.level { Level::Warn => { ui.colored_label(ui.visuals().warn_fg_color, text); } Level::Info => { ui.label(text); } } }); } }); crate::ui_util::more_below_hint(ui, &scroll); self.shown = shown; } } /// Whether a line survives the tab's two filters. `needle` must already be /// lowercased. fn keep(line: &LogLine, needle: &str, warnings_only: bool) -> bool { if warnings_only && line.level != Level::Warn { return false; } needle.is_empty() || line.text.to_lowercase().contains(needle) } /// `HH:MM:SS` local time. fn fmt_clock(unix_secs: u64) -> String { use chrono::TimeZone; // Not `as`: that cast wraps a huge value into a negative one — a valid // 1969 timestamp that would render as a plausible time. let secs = i64::try_from(unix_secs).unwrap_or(i64::MAX); match chrono::Local.timestamp_opt(secs, 0) { chrono::LocalResult::Single(dt) => dt.format("%H:%M:%S").to_string(), _ => "--:--:--".to_string(), } } #[cfg(test)] mod tests { use super::{fmt_clock, keep}; use quicksearch_core::log::{Level, LogLine}; fn line(level: Level, text: &str) -> LogLine { LogLine { at: 1_700_000_000, level, text: text.to_string(), } } #[test] fn an_empty_filter_keeps_everything() { assert!(keep(&line(Level::Info, "anything"), "", false)); assert!(keep(&line(Level::Warn, "anything"), "", false)); } #[test] fn the_filter_ignores_case_on_both_sides() { let l = line(Level::Warn, "Warning: cannot read /Home/Photos"); assert!(keep(&l, "photos", false), "needle case must not matter"); assert!(keep(&l, "cannot read", false), "nor the line's"); assert!(!keep(&l, "videos", false)); } #[test] fn warnings_only_hides_informational_lines() { assert!(!keep(&line(Level::Info, "shutting down"), "", true)); assert!(keep(&line(Level::Warn, "cannot read"), "", true)); } /// Both filters apply, not either. #[test] fn the_two_filters_compose() { assert!(!keep(&line(Level::Info, "cannot read"), "cannot", true)); assert!(!keep(&line(Level::Warn, "cannot read"), "missing", true)); assert!(keep(&line(Level::Warn, "cannot read"), "cannot", true)); } #[test] fn a_clock_stamp_is_fixed_width() { assert_eq!(fmt_clock(0).len(), 8, "epoch renders as a time, not a date"); assert_eq!(fmt_clock(1_700_000_000).len(), 8); } /// Beyond what a local calendar can represent, the row still lines up. #[test] fn an_out_of_range_stamp_falls_back() { assert_eq!(fmt_clock(u64::MAX), "--:--:--"); } }