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