turbo-vision 3.0.0

A Rust implementation of the classic Borland Turbo Vision text-mode UI framework
Documentation
// (C) 2025 - Enzo Lombardi
// Rust guideline compliant October 17th 2025

//! LogWindow view - scrollable log window with `tracing::Subscriber` integration.
//!
//! A window that displays tracing log messages with timestamps, colored log levels,
//! and a black background. Once installed, all `tracing::info!()`, `debug!()`, etc.
//! macros automatically route to the window.
//!
//! # Example
//!
//! ```ignore
//! use turbo_vision::views::log_window::LogWindowBuilder;
//! use turbo_vision::core::geometry::Rect;
//!
//! let log_window = LogWindowBuilder::new()
//!     .bounds(Rect::new(0, 0, 80, 15))
//!     .title("Log")
//!     .min_level(tracing::Level::DEBUG)
//!     .build();
//! app.desktop.add(log_window);
//!
//! // Now tracing macros route here:
//! tracing::info!("Application started");
//! tracing::debug!("Loading config from {:?}", path);
//! ```

use super::group::{Group, GroupLike};
use super::shared::Shared;
use super::terminal_widget::TerminalWidget;
use super::window::{Window, WindowLike, WindowPaletteType};
use crate::core::geometry::Rect;
use crate::core::palette::{Attr, TvColor};
use crate::terminal::Terminal;

use std::cell::RefCell;
use std::rc::Rc;
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::mpsc;

/// A formatted log entry ready for display.
struct LogEntry {
    text: String,
    attr: Attr,
}

/// The `tracing::Subscriber` implementation that sends log entries to the window.
///
/// This is `Send + Sync` (required by tracing) and communicates with the
/// single-threaded `LogWindow` via an `mpsc` channel.
pub struct LogSubscriber {
    sender: mpsc::Sender<LogEntry>,
    min_level: tracing::Level,
}

impl tracing::Subscriber for LogSubscriber {
    fn enabled(&self, metadata: &tracing::Metadata<'_>) -> bool {
        metadata.level() <= &self.min_level
    }

    fn new_span(&self, _attrs: &tracing::span::Attributes<'_>) -> tracing::span::Id {
        static NEXT: AtomicUsize = AtomicUsize::new(1);
        tracing::span::Id::from_u64(NEXT.fetch_add(1, Ordering::Relaxed) as u64)
    }

    fn record(&self, _span: &tracing::span::Id, _values: &tracing::span::Record<'_>) {}

    fn record_follows_from(&self, _span: &tracing::span::Id, _follows: &tracing::span::Id) {}

    fn event(&self, event: &tracing::Event<'_>) {
        let metadata = event.metadata();
        let level = *metadata.level();

        // Format: "HH:MM:SS LEVEL message"
        let now = chrono::Local::now();
        let timestamp = now.format("%H:%M:%S");

        let level_str = match level {
            tracing::Level::ERROR => "ERROR",
            tracing::Level::WARN => "WARN ",
            tracing::Level::INFO => "INFO ",
            tracing::Level::DEBUG => "DEBUG",
            tracing::Level::TRACE => "TRACE",
        };

        // Extract the message from the event
        let mut visitor = MessageVisitor::default();
        event.record(&mut visitor);
        let message = visitor.message;

        let text = format!("{timestamp} {level_str} {message}");
        let attr = level_attr(level);

        // Send is non-blocking; if the receiver is gone, silently drop
        let _ = self.sender.send(LogEntry { text, attr });
    }

    fn enter(&self, _span: &tracing::span::Id) {}

    fn exit(&self, _span: &tracing::span::Id) {}
}

/// Visitor that extracts the message field from a tracing event.
#[derive(Default)]
struct MessageVisitor {
    message: String,
}

impl tracing::field::Visit for MessageVisitor {
    fn record_debug(&mut self, field: &tracing::field::Field, value: &dyn std::fmt::Debug) {
        if field.name() == "message" {
            self.message = format!("{value:?}");
        } else if self.message.is_empty() {
            self.message = format!("{}: {value:?}", field.name());
        } else {
            self.message
                .push_str(&format!(" {}={value:?}", field.name()));
        }
    }

    fn record_str(&mut self, field: &tracing::field::Field, value: &str) {
        if field.name() == "message" {
            self.message = value.to_string();
        } else if self.message.is_empty() {
            self.message = format!("{}: {value}", field.name());
        } else {
            self.message.push_str(&format!(" {}={value}", field.name()));
        }
    }
}

/// Map a tracing level to a color attribute (foreground on black).
fn level_attr(level: tracing::Level) -> Attr {
    match level {
        tracing::Level::ERROR => Attr::new(TvColor::LightRed, TvColor::Black),
        tracing::Level::WARN => Attr::new(TvColor::Yellow, TvColor::Black),
        tracing::Level::INFO => Attr::new(TvColor::White, TvColor::Black),
        tracing::Level::DEBUG => Attr::new(TvColor::LightGray, TvColor::Black),
        tracing::Level::TRACE => Attr::new(TvColor::DarkGray, TvColor::Black),
    }
}

/// LogWindow - a scrollable window displaying tracing log messages.
///
/// Wraps a `Window` + `TerminalWidget` and drains incoming log entries
/// from the `LogSubscriber` channel on each `draw()` call.
pub struct LogWindow {
    window: Window,
    widget: Rc<RefCell<TerminalWidget>>,
    receiver: mpsc::Receiver<LogEntry>,
}

impl LogWindow {
    /// Drain pending log entries from the channel into the terminal widget.
    /// Called automatically during `draw()`.
    fn drain_logs(&mut self) {
        while let Ok(entry) = self.receiver.try_recv() {
            self.widget
                .borrow_mut()
                .append_line_colored(entry.text, entry.attr);
        }
    }

    /// Manually append a log line (bypasses tracing).
    pub fn log(&mut self, level: tracing::Level, message: &str) {
        let now = chrono::Local::now();
        let timestamp = now.format("%H:%M:%S");
        let level_str = match level {
            tracing::Level::ERROR => "ERROR",
            tracing::Level::WARN => "WARN ",
            tracing::Level::INFO => "INFO ",
            tracing::Level::DEBUG => "DEBUG",
            tracing::Level::TRACE => "TRACE",
        };
        let text = format!("{timestamp} {level_str} {message}");
        let attr = level_attr(level);
        self.widget.borrow_mut().append_line_colored(text, attr);
    }

    /// Clear all log entries.
    pub fn clear(&mut self) {
        self.widget.borrow_mut().clear();
    }
}

impl GroupLike for LogWindow {
    fn group(&self) -> &Group {
        self.window.group()
    }
    fn group_mut(&mut self) -> &mut Group {
        self.window.group_mut()
    }
}

impl WindowLike for LogWindow {
    fn window(&self) -> &Window {
        &self.window
    }
    fn window_mut(&mut self) -> &mut Window {
        &mut self.window
    }
}

crate::impl_view_for_window!(LogWindow {
    fn draw(&mut self, terminal: &mut Terminal) {
        self.drain_logs();
        self.window_draw(terminal);
    }
});

/// Builder for creating a LogWindow with tracing integration.
pub struct LogWindowBuilder {
    bounds: Option<Rect>,
    title: Option<String>,
    min_level: tracing::Level,
    max_lines: usize,
}

impl LogWindowBuilder {
    pub fn new() -> Self {
        Self {
            bounds: None,
            title: None,
            min_level: tracing::Level::TRACE,
            max_lines: 10000,
        }
    }

    /// Sets the window bounds (required).
    #[must_use]
    pub fn bounds(mut self, bounds: Rect) -> Self {
        self.bounds = Some(bounds);
        self
    }

    /// Sets the window title (default: "Log").
    #[must_use]
    pub fn title(mut self, title: impl Into<String>) -> Self {
        self.title = Some(title.into());
        self
    }

    /// Sets the minimum tracing level to display (default: TRACE — show everything).
    #[must_use]
    pub fn min_level(mut self, level: tracing::Level) -> Self {
        self.min_level = level;
        self
    }

    /// Sets the maximum scrollback buffer size (default: 10000).
    #[must_use]
    pub fn max_lines(mut self, max_lines: usize) -> Self {
        self.max_lines = max_lines;
        self
    }

    /// Builds the LogWindow and installs the tracing subscriber as the global default.
    ///
    /// # Panics
    ///
    /// Panics if `bounds` is not set, or if a global tracing subscriber is already installed.
    pub fn build(self) -> LogWindow {
        let bounds = self.bounds.expect("LogWindow bounds must be set");
        let title = self.title.unwrap_or_else(|| "Log".to_string());

        // Use blue window as base, then override palette to black-background entries
        // App palette positions 97-104 are the black window colors
        let mut window = Window::new_with_type(bounds, &title, WindowPaletteType::Blue);
        window.set_custom_palette(vec![
            97, 98, 99, 100, 101, 102, 103, 104, // Black window frame/text colors
        ]);

        // Widget bounds are RELATIVE to the window interior (starts at 0,0)
        // Window.add() converts relative → absolute via Group::add()
        let interior_width = bounds.width() - 2;
        let interior_height = bounds.height() - 2;
        let widget_bounds = Rect::new(0, 0, interior_width, interior_height);
        let mut widget = TerminalWidget::new(widget_bounds);
        widget = widget.with_scrollbar();
        widget.set_max_lines(self.max_lines);
        widget.set_auto_scroll(true);

        let widget = Rc::new(RefCell::new(widget));
        window.add(Shared::new(Rc::clone(&widget)));

        let (sender, receiver) = mpsc::channel();

        // Install the tracing subscriber
        let subscriber = LogSubscriber {
            sender,
            min_level: self.min_level,
        };
        // Use try — if a subscriber is already set, log a warning but don't panic
        let _ = tracing::subscriber::set_global_default(subscriber);

        LogWindow {
            window,
            widget,
            receiver,
        }
    }

    /// Builds as a Box for convenience.
    pub fn build_boxed(self) -> Box<LogWindow> {
        Box::new(self.build())
    }
}

impl Default for LogWindowBuilder {
    fn default() -> Self {
        Self::new()
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::views::View;

    #[test]
    fn test_level_attr_colors() {
        let error = level_attr(tracing::Level::ERROR);
        assert_eq!(error, Attr::new(TvColor::LightRed, TvColor::Black));

        let warn = level_attr(tracing::Level::WARN);
        assert_eq!(warn, Attr::new(TvColor::Yellow, TvColor::Black));

        let info = level_attr(tracing::Level::INFO);
        assert_eq!(info, Attr::new(TvColor::White, TvColor::Black));

        let debug = level_attr(tracing::Level::DEBUG);
        assert_eq!(debug, Attr::new(TvColor::LightGray, TvColor::Black));

        let trace = level_attr(tracing::Level::TRACE);
        assert_eq!(trace, Attr::new(TvColor::DarkGray, TvColor::Black));
    }

    #[test]
    fn test_log_window_creation() {
        let log_window = LogWindowBuilder::new()
            .bounds(Rect::new(0, 0, 80, 15))
            .title("Test Log")
            .min_level(tracing::Level::DEBUG)
            .max_lines(500)
            .build();

        assert_eq!(log_window.bounds(), Rect::new(0, 0, 80, 15));
    }

    #[test]
    fn test_log_window_manual_log() {
        let mut log_window = LogWindowBuilder::new()
            .bounds(Rect::new(0, 0, 80, 15))
            .title("Test Log")
            .build();

        log_window.log(tracing::Level::INFO, "test message");
        assert_eq!(log_window.widget.borrow().line_count(), 1);

        log_window.log(tracing::Level::ERROR, "error message");
        assert_eq!(log_window.widget.borrow().line_count(), 2);

        log_window.clear();
        assert_eq!(log_window.widget.borrow().line_count(), 0);
    }
}