Skip to main content

basalt_tui/
debug_log.rs

1//! In-TUI debug log overlay.
2//!
3//! A [`tracing`] [`Layer`] captures every event into a bounded, process-global ring
4//! buffer. The [`DebugLogModal`] overlay renders a snapshot of that buffer on top of the
5//! application: a live console that can be toggled at any time without interfering with
6//! normal usage.
7
8use 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
30/// Maximum number of retained log entries. Oldest entries are evicted past this.
31const CAPACITY: usize = 2000;
32
33/// A single captured log record, cheap to clone for rendering snapshots.
34#[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/// Severity of a log record. Ordered from least to most severe so that a minimum-level
43/// filter is a simple `>=` comparison.
44#[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    /// Fixed-width label so columns stay aligned across rows.
56    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    /// Next level in a wrapping cycle, used by the overlay's level filter.
77    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
105/// Process start, used to render a monotonic relative timestamp per entry.
106fn start() -> Instant {
107    static START: OnceLock<Instant> = OnceLock::new();
108    *START.get_or_init(Instant::now)
109}
110
111/// Pushes an entry into a bounded buffer, evicting the oldest once at capacity.
112fn 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
119/// Snapshots the entries matching `min_level`, newest last.
120fn 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
133/// Empties the ring buffer.
134pub fn clear() {
135    if let Ok(mut buffer) = buffer().lock() {
136        buffer.clear();
137    }
138}
139
140/// Registers the capturing [`Layer`] as the global tracing subscriber. Call once at startup.
141pub 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
150/// A [`tracing`] layer that records every event into the ring buffer.
151struct DebugLogLayer;
152
153impl<S: Subscriber> Layer<S> for DebugLogLayer {
154    // Capture everything; the overlay does its own level filtering.
155    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/// Collects an event's `message` field and appends any structured fields as `key=value`.
178#[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    /// Cursor over the rendered rows. `None` follows the newest line.
208    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    // The cursor is clamped to the real row count during render, which is the only
230    // place the wrapped layout (and therefore the row count) is known.
231    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
261/// Bottom-docked overlay area: lower half of the screen, full width minus the app margin.
262fn 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
272/// Number of visible log rows: the overlay minus its two borders.
273fn list_height(area: Rect) -> usize {
274    area.height.saturating_sub(2) as usize
275}
276
277/// Renders one entry as wrapped rows. The first row carries the timestamp, level
278/// and target; wrapped continuation rows sit flush left.
279fn 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    // Reserve room for the prefix on the first row only; continuations are flush left.
286    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    /// Resident memory in MiB, supplied by the caller so the widget stays pure.
311    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        // Borders (2), horizontal padding (2) and the cursor gutter (2) leave this
330        // many columns for text.
331        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        // Default the cursor to the newest row, and keep it within bounds.
345        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    // Touches the process-global buffer, so it is the only global-state test.
445    #[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                // Fixed memory keeps the snapshot stable; live memory comes from the app.
465                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}