zellij-sheets 0.1.2

Terminal-based spreadsheet viewer powered by Zellij
Documentation
// Zellij Sheets - UI Rendering Module

use crate::config::ThemeConfig;
use crate::layout::{fit_cell, ColumnLayout, LayoutEngine};
use crate::state::{cell_matches_query, DataType, SearchDirection, SheetsState, StatusLevel};
use thiserror::Error;

#[derive(Debug, Error)]
pub enum UiError {
    #[error("IO error: {0}")]
    IoError(#[from] std::io::Error),

    #[error("Rendering error: {0}")]
    RenderError(String),

    #[error("Color formatting error: {0}")]
    ColorError(String),
}

pub type Result<T> = std::result::Result<T, UiError>;

#[derive(Default)]
pub struct Colors {
    pub foreground: Option<String>,
    pub background: Option<String>,
}

impl Colors {
    pub fn with_fg(fg: impl Into<String>) -> Self {
        Self {
            foreground: Some(fg.into()),
            ..Default::default()
        }
    }

    pub fn with_bg(bg: impl Into<String>) -> Self {
        Self {
            background: Some(bg.into()),
            ..Default::default()
        }
    }

    pub fn reset(&self) -> String {
        "\x1b[0m".to_string()
    }

    pub fn apply(&self) -> String {
        let mut result = String::new();
        if let Some(fg) = &self.foreground {
            result.push_str(&format!("\x1b[{}m", fg));
        }
        if let Some(bg) = &self.background {
            result.push_str(&format!("\x1b[{}m", bg));
        }
        result
    }
}

pub struct UiRenderer {
    use_colors: bool,
    theme: Option<ThemeConfig>,
    layout_engine: LayoutEngine,
}

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

impl UiRenderer {
    pub fn new() -> Self {
        Self {
            use_colors: true,
            theme: None,
            layout_engine: LayoutEngine::new(),
        }
    }

    pub fn with_theme(theme: ThemeConfig) -> Self {
        Self {
            use_colors: true,
            theme: Some(theme),
            layout_engine: LayoutEngine::new(),
        }
    }

    pub fn set_use_colors(&mut self, use_colors: bool) {
        self.use_colors = use_colors;
    }

    pub fn set_theme(&mut self, theme: ThemeConfig) {
        self.theme = Some(theme);
    }

    pub fn draw_ui(&self, state: &SheetsState) -> Result<String> {
        let mut lines = Vec::new();
        lines.push(self.draw_header(state)?);
        lines.push(self.draw_separator(state)?);
        self.draw_data_rows(&mut lines, state)?;
        lines.push(self.draw_footer(state)?);
        Ok(lines.join("\n"))
    }

    fn draw_header(&self, state: &SheetsState) -> Result<String> {
        let theme = self.get_theme();
        let header_style = format!(
            "\x1b[48;5;{}m\x1b[38;5;{}m",
            theme.header_background, theme.header_text
        );
        let reset = "\x1b[0m";
        let mode_label = state
            .get_view_mode()
            .map(|m| format!("{:?}", m))
            .unwrap_or_default();

        let header = format!(
            "{}Zellij Sheets{} | {} | {} rows | {}{}",
            header_style,
            reset,
            state.file_name(),
            state.row_count(),
            mode_label,
            reset,
        );

        Ok(header)
    }

    fn draw_separator(&self, state: &SheetsState) -> Result<String> {
        let theme = self.get_theme();
        let width = state.get_width().unwrap_or(80);
        Ok(format!(
            "\x1b[48;5;{}m\x1b[38;5;{}m{}\x1b[0m",
            theme.header_background,
            theme.header_text,
            "─".repeat(width)
        ))
    }

    fn draw_data_rows(&self, lines: &mut Vec<String>, state: &SheetsState) -> Result<()> {
        let width = state.get_width().unwrap_or(80);
        let layouts = self.layout_engine.resolve(&state.layout_cache, width);
        let (start, end) = state.row_range();
        let visible_cols = state.visible_cols();

        if let Some(headers) = state.headers() {
            lines.push(self.render_row(headers, state, &layouts, true, None, visible_cols)?);
        }

        for row in start..end {
            if let Some(values) = state.get_row(row) {
                let is_selected = row == state.selected_row();
                let prefix = if is_selected { ">" } else { " " };
                let row_line =
                    self.render_row(&values, state, &layouts, false, Some(row), visible_cols)?;
                lines.push(format!("{}{}", prefix, row_line));
            }
        }

        Ok(())
    }

    fn draw_footer(&self, state: &SheetsState) -> Result<String> {
        let theme = self.get_theme();
        let sep_style = format!(
            "\x1b[48;5;{}m\x1b[38;5;{}m",
            theme.header_background, theme.header_text
        );
        let reset = "\x1b[0m";
        let mut footer = format!(
            "{}Keys: Arrows, h/j/k/l, / ? n N, PgUp/PgDn, Home/End, q/Ctrl-C{}",
            sep_style, reset
        );
        footer.push_str(&format!(
            " | row {} col {}",
            state.selected_row() + 1,
            state.selected_col() + 1
        ));
        if let Ok(Some(query)) = state.get_search_query() {
            let prefix = match state.search_direction() {
                SearchDirection::Forward => '/',
                SearchDirection::Backward => '?',
            };
            if state.is_search_active() || !query.is_empty() {
                footer.push_str(&format!(" | {prefix}{query}"));
            }
        }

        if let Ok(messages) = state.get_status_messages() {
            if let Some(msg) = messages.iter().next_back() {
                let level_color = match msg.level {
                    StatusLevel::Info => "34",
                    StatusLevel::Success => "32",
                    StatusLevel::Warning => "33",
                    StatusLevel::Error => "31",
                };
                footer.push_str(&format!(" | \x1b[{}m{}\x1b[0m", level_color, msg.message));
            }
        }

        Ok(footer)
    }

    fn render_row(
        &self,
        values: &[String],
        state: &SheetsState,
        layouts: &[ColumnLayout],
        is_header: bool,
        row_index: Option<usize>,
        visible_cols: usize,
    ) -> Result<String> {
        let mut cells = Vec::new();
        let theme = self.get_theme();
        let col_offset = state.col_offset();

        for (col, value) in values
            .iter()
            .enumerate()
            .skip(col_offset)
            .take(visible_cols)
        {
            let width = layouts.get(col).map(|l| l.resolved_width).unwrap_or(8);
            let fitted = fit_cell(value, width);
            let is_selected_col = col == state.selected_col();
            let is_selected_cell = row_index == Some(state.selected_row()) && is_selected_col;
            let matches_search = state
                .get_search_query()
                .ok()
                .flatten()
                .is_some_and(|query| cell_matches_query(value, &query));

            let cell_value = if is_header {
                if is_selected_col {
                    format!(
                        "\x1b[1;48;5;{}m\x1b[38;5;{}m{}\x1b[0m",
                        theme.selected_background, theme.selected_text, fitted
                    )
                } else {
                    format!(
                        "\x1b[1;48;5;{}m\x1b[38;5;{}m{}\x1b[0m",
                        theme.header_background, theme.header_text, fitted
                    )
                }
            } else if is_selected_cell {
                format!(
                    "\x1b[48;5;{}m\x1b[38;5;{}m{}\x1b[0m",
                    theme.selected_background, theme.selected_text, fitted
                )
            } else if matches_search {
                format!(
                    "\x1b[48;5;{}m\x1b[38;5;{}m{}\x1b[0m",
                    theme.column_header_background, theme.column_header_text, fitted
                )
            } else {
                let color_code = match state.get_data_type(col).unwrap_or(DataType::String) {
                    DataType::Number => theme.accent_colors.number.as_str(),
                    DataType::Boolean => theme.accent_colors.boolean.as_str(),
                    DataType::Empty => theme.accent_colors.date.as_str(),
                    DataType::String => theme.accent_colors.string.as_str(),
                };
                format!(
                    "\x1b[48;5;{}m\x1b[38;5;{}m{}\x1b[0m",
                    theme.background, color_code, fitted
                )
            };

            cells.push(cell_value);
        }

        Ok(cells.join(" | "))
    }

    fn get_theme(&self) -> ThemeConfig {
        self.theme.clone().unwrap_or_default()
    }

    pub fn get_color(&self, color_code: &str) -> String {
        let color = if color_code.is_empty() {
            "0"
        } else {
            color_code
        };
        if self.use_colors {
            format!("\x1b[38;5;{}m", color)
        } else {
            String::new()
        }
    }
}

pub fn draw_error(message: &str) -> String {
    format!("\x1b[31mError: {}\x1b[0m", message)
}

pub fn draw_warning(message: &str) -> String {
    format!("\x1b[33mWarning: {}\x1b[0m", message)
}

pub fn draw_info(message: &str) -> String {
    format!("\x1b[34mInfo: {}\x1b[0m", message)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::config::SheetsConfig;
    use std::sync::Arc;

    #[test]
    fn test_ui_renderer_creation() {
        let renderer = UiRenderer::new();
        assert!(renderer
            .draw_ui(&SheetsState::new(Arc::new(SheetsConfig::default())))
            .is_ok());
    }

    #[test]
    fn test_theme_config_default() {
        let theme = ThemeConfig::default();
        assert_eq!(theme.header_background, "#0055AA");
        assert_eq!(theme.header_text, "#FFFFFF");
    }

    #[test]
    fn test_colors_default() {
        let colors = Colors::default();
        assert!(colors.foreground.is_none());
        assert!(colors.background.is_none());
    }
}