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::{
29    app::{calc_scroll_amount, Message as AppMessage, ScrollAmount},
30    config::Theme,
31};
32
33/// Maximum number of retained log entries. Oldest entries are evicted past this.
34const CAPACITY: usize = 2000;
35
36/// A single captured log record, cheap to clone for rendering snapshots.
37#[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/// Severity of a log record. Ordered from least to most severe so that a minimum-level
46/// filter is a simple `>=` comparison.
47#[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    /// Fixed-width label so columns stay aligned across rows.
59    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    /// Next level in a wrapping cycle, used by the overlay's level filter.
80    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
108/// Process start, used to render a monotonic relative timestamp per entry.
109fn start() -> Instant {
110    static START: OnceLock<Instant> = OnceLock::new();
111    *START.get_or_init(Instant::now)
112}
113
114/// Pushes an entry into a bounded buffer, evicting the oldest once at capacity.
115fn 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
122/// Snapshots the entries matching `min_level`, newest last.
123fn 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
136/// Empties the ring buffer.
137pub fn clear() {
138    if let Ok(mut buffer) = buffer().lock() {
139        buffer.clear();
140    }
141}
142
143/// Registers the capturing [`Layer`] as the global tracing subscriber. Call once at startup.
144pub 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
153/// A [`tracing`] layer that records every event into the ring buffer.
154struct DebugLogLayer;
155
156impl<S: Subscriber> Layer<S> for DebugLogLayer {
157    // Capture everything; the overlay does its own level filtering.
158    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/// Collects an event's `message` field and appends any structured fields as `key=value`.
181#[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    /// Cursor over the rendered rows. `None` follows the newest line.
211    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    // The cursor is clamped to the real row count during render, which is the only
233    // place the wrapped layout (and therefore the row count) is known.
234    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
264/// Bottom-docked overlay area: lower half of the screen, full width minus the app margin.
265fn 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
275/// Number of visible log rows: the overlay minus its two borders.
276fn list_height(area: Rect) -> usize {
277    area.height.saturating_sub(2) as usize
278}
279
280/// Renders one entry as wrapped rows. The first row carries the timestamp, level
281/// and target; wrapped continuation rows sit flush left.
282fn 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    // Reserve room for the prefix on the first row only; continuations are flush left.
289    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    /// Resident memory in MiB, supplied by the caller so the widget stays pure.
315    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        // Borders (2), horizontal padding (2) and the cursor gutter (2) leave this
335        // many columns for text.
336        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        // Default the cursor to the newest row, and keep it within bounds.
352        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    // Touches the process-global buffer, so it is the only global-state test.
453    #[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                // Fixed memory keeps the snapshot stable; live memory comes from the app.
473                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}