cu-tuimon 1.0.0-rc1

Shared Ratatui TUI rendering for Copper monitors.
Documentation
use crate::MonitorModel;
use crate::palette;
use ratatui::layout::Rect;
use ratatui::style::Style;

#[cfg(debug_assertions)]
mod cfg_debug_assertions {
    pub use cu29::clock::CuTime;
    pub use cu29_log::{CuLogEntry, CuLogLevel};
    pub use cu29_log_runtime::format_message_only;
    pub use cu29_log_runtime::register_live_log_listener;
    pub use ratatui::style::Color;
    pub use std::collections::HashMap;
}
#[cfg(debug_assertions)]
use cfg_debug_assertions::*;

#[cfg(feature = "stderr_capture")]
mod cfg_stderr_capture {
    pub use gag::BufferRedirect;
    pub use std::io::Read;
}
#[cfg(feature = "stderr_capture")]
use cfg_stderr_capture::*;

#[derive(Default)]
pub struct LogPane {
    pub area: Option<Rect>,
    pub lines: Vec<StyledLine>,
    pub selection: LogSelection,
    pub offset_from_bottom: usize,
}

#[derive(Clone, Copy, Debug, Default)]
pub struct LogSelection {
    anchor: Option<SelectionPoint>,
    cursor: Option<SelectionPoint>,
}

impl LogSelection {
    pub fn clear(&mut self) {
        self.anchor = None;
        self.cursor = None;
    }

    pub fn start(&mut self, point: SelectionPoint) {
        self.anchor = Some(point);
        self.cursor = Some(point);
    }

    pub fn update(&mut self, point: SelectionPoint) {
        if self.anchor.is_some() {
            self.cursor = Some(point);
        }
    }

    pub fn range(&self) -> Option<(SelectionPoint, SelectionPoint)> {
        let anchor = self.anchor?;
        let cursor = self.cursor?;
        if (anchor.row, anchor.col) <= (cursor.row, cursor.col) {
            Some((anchor, cursor))
        } else {
            Some((cursor, anchor))
        }
    }
}

#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub struct SelectionPoint {
    pub row: usize,
    pub col: usize,
}

#[derive(Clone, Debug)]
pub struct StyledRun {
    pub start: usize,
    pub end: usize,
    pub style: Style,
}

#[derive(Clone, Debug)]
pub struct StyledLine {
    pub text: String,
    pub runs: Vec<StyledRun>,
}

#[cfg_attr(
    not(any(debug_assertions, feature = "stderr_capture")),
    allow(dead_code)
)]
#[derive(Clone)]
enum CaptureTarget {
    Model(MonitorModel),
    Stdout,
}

pub struct MonitorLogCapture {
    #[cfg_attr(not(feature = "stderr_capture"), allow(dead_code))]
    target: CaptureTarget,
    #[cfg(debug_assertions)]
    registered_live_listener: bool,
    #[cfg(feature = "stderr_capture")]
    stderr_redirect: Option<BufferRedirect>,
}

impl MonitorLogCapture {
    pub fn to_model(model: MonitorModel) -> Self {
        let target = CaptureTarget::Model(model);
        Self::new(target, true)
    }

    pub fn to_stdout() -> Self {
        Self::new(CaptureTarget::Stdout, false)
    }

    fn new(target: CaptureTarget, _capture_stderr: bool) -> Self {
        #[cfg(debug_assertions)]
        register_live_log_listener({
            let target = target.clone();
            move |entry, format_str, param_names| {
                target.handle_structured_log(entry, format_str, param_names);
            }
        });

        Self {
            target,
            #[cfg(debug_assertions)]
            registered_live_listener: true,
            #[cfg(feature = "stderr_capture")]
            stderr_redirect: if _capture_stderr {
                BufferRedirect::stderr().ok()
            } else {
                None
            },
        }
    }

    pub fn poll(&mut self) {
        #[cfg(feature = "stderr_capture")]
        if let Some(stderr_redirect) = self.stderr_redirect.as_mut() {
            let mut buffer = String::new();
            if let Err(err) = stderr_redirect.read_to_string(&mut buffer) {
                return eprintln!("cu_tuimon log pane: failed to read stderr buffer: {err}");
            }
            if buffer.is_empty() {
                return;
            }

            for line in buffer.lines() {
                let styled_line = styled_line_from_stderr(line);
                match &self.target {
                    CaptureTarget::Model(model) => model.push_log_line(styled_line),
                    CaptureTarget::Stdout => eprintln!("{line}"),
                }
            }
        }
    }
}

impl Drop for MonitorLogCapture {
    fn drop(&mut self) {
        self.poll();
        #[cfg(debug_assertions)]
        if self.registered_live_listener {
            cu29_log_runtime::unregister_live_log_listener();
        }
    }
}

impl CaptureTarget {
    #[cfg(debug_assertions)]
    fn handle_structured_log(&self, entry: &CuLogEntry, format_str: &str, param_names: &[&str]) {
        match self {
            Self::Model(model) => {
                model.push_log_line(styled_line_from_structured(entry, format_str, param_names));
            }
            Self::Stdout => {
                if let Some(line) = format_headless_log_line(entry, format_str, param_names) {
                    println!("{line}");
                }
            }
        }
    }
}

#[cfg(debug_assertions)]
fn format_headless_log_line(
    entry: &CuLogEntry,
    format_str: &str,
    param_names: &[&str],
) -> Option<String> {
    let params: Vec<String> = entry.params.iter().map(|value| value.to_string()).collect();
    let named_params: HashMap<String, String> = param_names
        .iter()
        .zip(params.iter())
        .map(|(name, value)| (name.to_string(), value.clone()))
        .collect();

    format_message_only(format_str, params.as_slice(), &named_params)
        .ok()
        .map(|message| {
            format!(
                "{} [{:?}] {}",
                format_timestamp(entry.time),
                entry.level,
                message
            )
        })
}

#[cfg(debug_assertions)]
fn styled_line_from_structured(
    entry: &CuLogEntry,
    format_str: &str,
    param_names: &[&str],
) -> StyledLine {
    let params: Vec<String> = entry.params.iter().map(|value| value.to_string()).collect();
    let named_params: HashMap<String, String> = param_names
        .iter()
        .zip(params.iter())
        .map(|(name, value)| (name.to_string(), value.clone()))
        .collect();

    let timestamp = format_timestamp(entry.time);
    let level_text = format!("[{:?}]", entry.level);
    let (message_text, param_spans) =
        build_message_with_runs(format_str, params.as_slice(), &named_params);
    let mut message_runs = Vec::new();
    let mut cursor = 0usize;
    let mut sorted_spans = param_spans;
    sorted_spans.sort_by_key(|span| span.0);

    for (start, end) in sorted_spans {
        if start > cursor {
            message_runs.push(StyledRun {
                start: cursor,
                end: start,
                style: Style::default().fg(palette::GRAY),
            });
        }
        message_runs.push(StyledRun {
            start,
            end,
            style: Style::default().fg(palette::MAGENTA),
        });
        cursor = end;
    }

    if cursor < message_text.chars().count() {
        message_runs.push(StyledRun {
            start: cursor,
            end: message_text.chars().count(),
            style: Style::default().fg(palette::GRAY),
        });
    }

    let prefix = format!("{timestamp} {level_text} ");
    let prefix_len = prefix.chars().count();
    let mut runs = vec![
        StyledRun {
            start: 0,
            end: timestamp.chars().count(),
            style: Style::default().fg(palette::BLUE),
        },
        StyledRun {
            start: timestamp.chars().count() + 1,
            end: timestamp.chars().count() + 1 + level_text.chars().count(),
            style: Style::default().fg(color_for_level(entry.level)),
        },
    ];

    for run in message_runs {
        runs.push(StyledRun {
            start: prefix_len + run.start,
            end: prefix_len + run.end,
            style: run.style,
        });
    }

    StyledLine {
        text: format!("{prefix}{message_text}"),
        runs,
    }
}

#[cfg_attr(not(feature = "stderr_capture"), allow(dead_code))]
pub fn styled_line_from_stderr(text: &str) -> StyledLine {
    StyledLine {
        text: text.to_string(),
        runs: vec![StyledRun {
            start: 0,
            end: text.chars().count(),
            style: Style::default().fg(palette::RED),
        }],
    }
}

#[cfg(debug_assertions)]
fn color_for_level(level: CuLogLevel) -> Color {
    match level {
        CuLogLevel::Debug => palette::GREEN,
        CuLogLevel::Info => palette::GRAY,
        CuLogLevel::Warning => palette::YELLOW,
        CuLogLevel::Error | CuLogLevel::Critical => palette::RED,
    }
}

#[cfg(debug_assertions)]
fn format_timestamp(time: CuTime) -> String {
    let nanos = time.as_nanos();
    let total_ms = nanos / 1_000_000;
    let millis = total_ms % 1_000;
    let total_seconds = total_ms / 1_000;
    let seconds = total_seconds % 60;
    let minutes = (total_seconds / 60) % 60;
    let hours = (total_seconds / 3_600) % 24;
    format!("{hours:02}:{minutes:02}:{seconds:02}.{millis:03}")
}

#[cfg(debug_assertions)]
fn build_message_with_runs(
    format_str: &str,
    params: &[String],
    named_params: &HashMap<String, String>,
) -> (String, Vec<(usize, usize)>) {
    let mut output = String::new();
    let mut param_spans = Vec::new();
    let mut anonymous = params.iter();
    let mut iter = format_str.char_indices().peekable();

    while let Some((index, ch)) = iter.next() {
        if ch == '{' {
            let start_idx = index + ch.len_utf8();
            if let Some(end) = format_str[start_idx..].find('}') {
                let end_idx = start_idx + end;
                let placeholder = &format_str[start_idx..end_idx];
                let replacement = if placeholder.is_empty() {
                    anonymous.next()
                } else {
                    named_params.get(placeholder)
                };

                if let Some(replacement) = replacement {
                    let span_start = output.chars().count();
                    output.push_str(replacement);
                    let span_end = output.chars().count();
                    param_spans.push((span_start, span_end));

                    let skip_to = end_idx + '}'.len_utf8();
                    while let Some((next_idx, _)) = iter.peek().copied() {
                        if next_idx < skip_to {
                            iter.next();
                        } else {
                            break;
                        }
                    }
                    continue;
                }
            }
        }

        output.push(ch);
    }

    (output, param_spans)
}