1use std::{
9 collections::VecDeque,
10 fmt::{self, Write},
11 sync::{Mutex, OnceLock},
12 time::{Duration, Instant},
13};
14
15use ratatui::{
16 buffer::Buffer,
17 layout::{Alignment, Constraint, Flex, Layout, Rect, Size},
18 style::{Color, Style, Stylize},
19 text::{Line, Span},
20 widgets::{
21 Block, BorderType, Clear, List, ListItem, ListState, Padding, Scrollbar,
22 ScrollbarOrientation, ScrollbarState, StatefulWidget, Widget,
23 },
24};
25use tracing::{field::Field, field::Visit, level_filters::LevelFilter, Event, Subscriber};
26use tracing_subscriber::{layer::Context, Layer};
27
28use crate::app::{calc_scroll_amount, Message as AppMessage, ScrollAmount};
29
30const CAPACITY: usize = 2000;
32
33#[derive(Clone, Debug, PartialEq)]
35pub struct LogEntry {
36 pub level: LogLevel,
37 pub target: String,
38 pub message: String,
39 pub elapsed: Duration,
40}
41
42#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Ord, PartialOrd, clap::ValueEnum)]
45pub enum LogLevel {
46 #[default]
47 Trace,
48 Debug,
49 Info,
50 Warn,
51 Error,
52}
53
54impl LogLevel {
55 pub fn label(self) -> &'static str {
57 match self {
58 LogLevel::Trace => "TRACE",
59 LogLevel::Debug => "DEBUG",
60 LogLevel::Info => "INFO ",
61 LogLevel::Warn => "WARN ",
62 LogLevel::Error => "ERROR",
63 }
64 }
65
66 pub fn color(self) -> Color {
67 match self {
68 LogLevel::Trace => Color::DarkGray,
69 LogLevel::Debug => Color::Blue,
70 LogLevel::Info => Color::Green,
71 LogLevel::Warn => Color::Yellow,
72 LogLevel::Error => Color::Red,
73 }
74 }
75
76 fn next(self) -> Self {
78 match self {
79 LogLevel::Trace => LogLevel::Debug,
80 LogLevel::Debug => LogLevel::Info,
81 LogLevel::Info => LogLevel::Warn,
82 LogLevel::Warn => LogLevel::Error,
83 LogLevel::Error => LogLevel::Trace,
84 }
85 }
86}
87
88impl From<tracing::Level> for LogLevel {
89 fn from(level: tracing::Level) -> Self {
90 match level {
91 tracing::Level::TRACE => LogLevel::Trace,
92 tracing::Level::DEBUG => LogLevel::Debug,
93 tracing::Level::INFO => LogLevel::Info,
94 tracing::Level::WARN => LogLevel::Warn,
95 tracing::Level::ERROR => LogLevel::Error,
96 }
97 }
98}
99
100fn buffer() -> &'static Mutex<VecDeque<LogEntry>> {
101 static LOG_BUFFER: OnceLock<Mutex<VecDeque<LogEntry>>> = OnceLock::new();
102 LOG_BUFFER.get_or_init(|| Mutex::new(VecDeque::with_capacity(CAPACITY)))
103}
104
105fn start() -> Instant {
107 static START: OnceLock<Instant> = OnceLock::new();
108 *START.get_or_init(Instant::now)
109}
110
111fn push_bounded(buffer: &mut VecDeque<LogEntry>, entry: LogEntry) {
113 if buffer.len() == CAPACITY {
114 buffer.pop_front();
115 }
116 buffer.push_back(entry);
117}
118
119fn snapshot(min_level: LogLevel) -> Vec<LogEntry> {
121 buffer()
122 .lock()
123 .map(|buffer| {
124 buffer
125 .iter()
126 .filter(|entry| entry.level >= min_level)
127 .cloned()
128 .collect()
129 })
130 .unwrap_or_default()
131}
132
133pub fn clear() {
135 if let Ok(mut buffer) = buffer().lock() {
136 buffer.clear();
137 }
138}
139
140pub fn init() {
142 use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
143
144 start();
145 let _ = tracing_subscriber::registry()
146 .with(DebugLogLayer)
147 .try_init();
148}
149
150struct DebugLogLayer;
152
153impl<S: Subscriber> Layer<S> for DebugLogLayer {
154 fn max_level_hint(&self) -> Option<LevelFilter> {
156 Some(LevelFilter::TRACE)
157 }
158
159 fn on_event(&self, event: &Event<'_>, _ctx: Context<'_, S>) {
160 let mut visitor = MessageVisitor::default();
161 event.record(&mut visitor);
162
163 let metadata = event.metadata();
164 let entry = LogEntry {
165 level: (*metadata.level()).into(),
166 target: metadata.target().to_string(),
167 message: format!("{}{}", visitor.message, visitor.fields),
168 elapsed: start().elapsed(),
169 };
170
171 if let Ok(mut buffer) = buffer().lock() {
172 push_bounded(&mut buffer, entry);
173 }
174 }
175}
176
177#[derive(Default)]
179struct MessageVisitor {
180 message: String,
181 fields: String,
182}
183
184impl Visit for MessageVisitor {
185 fn record_debug(&mut self, field: &Field, value: &dyn fmt::Debug) {
186 if field.name() == "message" {
187 self.message = format!("{value:?}");
188 } else {
189 let _ = write!(self.fields, " {}={value:?}", field.name());
190 }
191 }
192}
193
194#[derive(Clone, Debug, PartialEq)]
195pub enum Message {
196 Toggle,
197 Close,
198 Clear,
199 CycleLevel,
200 ScrollUp(ScrollAmount),
201 ScrollDown(ScrollAmount),
202}
203
204#[derive(Debug, Clone, Default, PartialEq)]
205pub struct DebugLogModalState {
206 pub visible: bool,
207 pub list_state: ListState,
209 pub min_level: LogLevel,
210 pub scrollbar_state: ScrollbarState,
211}
212
213impl DebugLogModalState {
214 fn cursor(&self) -> usize {
215 self.list_state.selected().unwrap_or(0)
216 }
217
218 fn cycle_level(&mut self) {
219 self.min_level = self.min_level.next();
220 self.list_state.select(None);
221 }
222}
223
224pub fn update<'a>(
225 message: &Message,
226 screen_size: Size,
227 state: &mut DebugLogModalState,
228) -> Option<AppMessage<'a>> {
229 let page = list_height(overlay_area(Rect::new(
232 0,
233 0,
234 screen_size.width,
235 screen_size.height,
236 )));
237
238 match message {
239 Message::Toggle => state.visible = !state.visible,
240 Message::Close => state.visible = false,
241 Message::Clear => {
242 clear();
243 state.list_state.select(None);
244 }
245 Message::CycleLevel => state.cycle_level(),
246 Message::ScrollUp(amount) => {
247 let target = state
248 .cursor()
249 .saturating_sub(calc_scroll_amount(amount, page));
250 state.list_state.select(Some(target));
251 }
252 Message::ScrollDown(amount) => {
253 let target = state.cursor() + calc_scroll_amount(amount, page);
254 state.list_state.select(Some(target));
255 }
256 };
257
258 None
259}
260
261fn overlay_area(area: Rect) -> Rect {
263 let [area] = Layout::vertical([Constraint::Percentage(50)])
264 .flex(Flex::End)
265 .areas(area);
266 let [area] = Layout::horizontal([Constraint::Fill(1)])
267 .horizontal_margin(1)
268 .areas(area);
269 area
270}
271
272fn list_height(area: Rect) -> usize {
274 area.height.saturating_sub(2) as usize
275}
276
277fn entry_rows(entry: &LogEntry, text_width: usize) -> Vec<Line<'static>> {
280 let timestamp = format!("{:<8} ", format!("{:.3}s", entry.elapsed.as_secs_f64()));
281 let level = format!("{} ", entry.level.label());
282 let target = format!("{} ", entry.target);
283 let prefix_width = timestamp.chars().count() + level.chars().count() + target.chars().count();
284
285 let reserved = " ".repeat(prefix_width.min(text_width.saturating_sub(1)));
287 let options = textwrap::Options::new(text_width.max(1)).initial_indent(&reserved);
288
289 textwrap::wrap(&entry.message, options)
290 .iter()
291 .enumerate()
292 .map(|(row, part)| {
293 if row == 0 {
294 let message = part.strip_prefix(reserved.as_str()).unwrap_or(part);
295 Line::from(vec![
296 Span::from(timestamp.clone()).dark_gray(),
297 Span::from(level.clone()).fg(entry.level.color()),
298 Span::from(target.clone()).dark_gray(),
299 Span::from(message.to_string()),
300 ])
301 } else {
302 Line::from(part.to_string())
303 }
304 })
305 .collect()
306}
307
308pub struct DebugLogModal {
309 pub border_type: BorderType,
310 pub memory_mb: Option<f64>,
312}
313
314impl DebugLogModal {
315 pub fn new(border_type: BorderType, memory_mb: Option<f64>) -> Self {
316 Self {
317 border_type,
318 memory_mb,
319 }
320 }
321}
322
323impl StatefulWidget for DebugLogModal {
324 type State = DebugLogModalState;
325
326 fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
327 let area = overlay_area(area);
328 let page = list_height(area);
329 let text_width = area.width.saturating_sub(6) as usize;
332
333 let entries = snapshot(state.min_level);
334 let rows: Vec<Line> = if entries.is_empty() {
335 vec![Line::from(Span::from("No log entries").dark_gray())]
336 } else {
337 entries
338 .iter()
339 .flat_map(|entry| entry_rows(entry, text_width))
340 .collect()
341 };
342 let total = rows.len();
343
344 let cursor = state
346 .list_state
347 .selected()
348 .unwrap_or(usize::MAX)
349 .min(total.saturating_sub(1));
350 state.list_state.select(Some(cursor));
351
352 let memory = self
353 .memory_mb
354 .map(|mb| format!("{mb:.1} MB"))
355 .unwrap_or_else(|| "? MB".to_string());
356 let title = format!(
357 " Debug Log ({}) - {memory} ",
358 state.min_level.label().trim()
359 );
360 let block = Block::bordered()
361 .dark_gray()
362 .border_type(self.border_type)
363 .padding(Padding::horizontal(1))
364 .title_style(Style::default().italic().bold())
365 .title(title)
366 .title(Line::from(" (g<) ").alignment(Alignment::Right));
367
368 Widget::render(Clear, area, buf);
369 StatefulWidget::render(
370 List::new(rows.into_iter().map(ListItem::new).collect::<Vec<_>>())
371 .block(block)
372 .fg(Color::default())
373 .highlight_symbol("▎ "),
374 area,
375 buf,
376 &mut state.list_state,
377 );
378
379 if total > page {
380 state.scrollbar_state = ScrollbarState::new(total).position(cursor);
381 StatefulWidget::render(
382 Scrollbar::new(ScrollbarOrientation::VerticalRight),
383 area,
384 buf,
385 &mut state.scrollbar_state,
386 );
387 }
388 }
389}
390
391#[cfg(test)]
392mod tests {
393 use super::*;
394 use insta::assert_snapshot;
395 use ratatui::{backend::TestBackend, Terminal};
396
397 fn entry(level: LogLevel, message: &str) -> LogEntry {
398 LogEntry {
399 level,
400 target: "basalt_tui::app".to_string(),
401 message: message.to_string(),
402 elapsed: Duration::from_millis(1234),
403 }
404 }
405
406 #[test]
407 fn level_from_tracing() {
408 assert_eq!(LogLevel::from(tracing::Level::TRACE), LogLevel::Trace);
409 assert_eq!(LogLevel::from(tracing::Level::ERROR), LogLevel::Error);
410 }
411
412 #[test]
413 fn levels_are_ordered_by_severity() {
414 assert!(LogLevel::Trace < LogLevel::Error);
415 assert!(LogLevel::Info < LogLevel::Warn);
416 }
417
418 #[test]
419 fn push_bounded_evicts_oldest() {
420 let mut buffer = VecDeque::new();
421 for index in 0..CAPACITY + 5 {
422 push_bounded(
423 &mut buffer,
424 entry(LogLevel::Info, &format!("entry {index}")),
425 );
426 }
427 assert_eq!(buffer.len(), CAPACITY);
428 assert_eq!(buffer.front().unwrap().message, "entry 5");
429 assert_eq!(
430 buffer.back().unwrap().message,
431 format!("entry {}", CAPACITY + 4)
432 );
433 }
434
435 #[test]
436 fn cycle_level_advances_and_resets_cursor() {
437 let mut state = DebugLogModalState::default();
438 state.list_state.select(Some(7));
439 state.cycle_level();
440 assert_eq!(state.min_level, LogLevel::Debug);
441 assert_eq!(state.list_state.selected(), None);
442 }
443
444 #[test]
446 fn render_overlay() {
447 clear();
448 let entries = [
449 entry(LogLevel::Trace, "entering run loop"),
450 entry(LogLevel::Debug, "refreshed 142 entries"),
451 entry(LogLevel::Info, "vault opened: Notes"),
452 entry(LogLevel::Warn, "wiki link update failed"),
453 entry(LogLevel::Error, "failed to create note"),
454 ];
455 if let Ok(mut buffer) = buffer().lock() {
456 entries
457 .into_iter()
458 .for_each(|e| push_bounded(&mut buffer, e));
459 }
460
461 let mut terminal = Terminal::new(TestBackend::new(60, 12)).unwrap();
462 terminal
463 .draw(|frame| {
464 DebugLogModal::new(BorderType::Rounded, Some(24.1)).render(
466 frame.area(),
467 frame.buffer_mut(),
468 &mut DebugLogModalState {
469 visible: true,
470 ..Default::default()
471 },
472 );
473 })
474 .unwrap();
475
476 assert_snapshot!(terminal.backend());
477 clear();
478 }
479}