gitlogue 0.9.0

A Git history screensaver - watch your code rewrite itself
Documentation
use ratatui::{
    layout::Rect,
    style::{Color, Modifier, Style},
    text::{Line, Span},
    widgets::{Block, Padding},
    Frame,
};

use crate::animation::{ActivePane, AnimationEngine};
use crate::theme::Theme;
use crate::widgets::SelectableParagraph;

pub struct EditorPane;

struct HighlightContext<'a> {
    line_content: &'a str,
    line_num: usize,
    show_cursor: bool,
    cursor_col: usize,
    cursor_line: usize,
    old_highlights: &'a [crate::syntax::HighlightSpan],
    new_highlights: &'a [crate::syntax::HighlightSpan],
    old_line_offsets: &'a [usize],
    new_line_offsets: &'a [usize],
    line_offset: isize,
    theme: &'a Theme,
}

impl EditorPane {
    pub fn render(&self, f: &mut Frame, area: Rect, engine: &AnimationEngine, theme: &Theme) {
        let block = Block::default()
            .style(Style::default().bg(theme.background_right))
            .padding(Padding::vertical(1));

        let content_height = area.height.saturating_sub(2) as usize; // Subtract top and bottom padding
        let scroll_offset = engine.buffer.scroll_offset;
        let buffer_lines = &engine.buffer.lines;
        let line_num_width = format!("{}", buffer_lines.len()).len().max(3);

        let visible_lines: Vec<Line> = buffer_lines
            .iter()
            .skip(scroll_offset)
            .take(content_height)
            .enumerate()
            .map(|(idx, line_content)| {
                let line_num = scroll_offset + idx;
                self.build_line(line_content, line_num, line_num_width, engine, theme)
            })
            .collect();

        // Calculate selected line index in visible_lines
        let selected_line_index = if engine.buffer.cursor_line >= scroll_offset {
            let idx = engine.buffer.cursor_line - scroll_offset;
            if idx < visible_lines.len() {
                Some(idx)
            } else {
                None
            }
        } else {
            None
        };

        let content = SelectableParagraph::new(visible_lines)
            .block(block)
            .selected_line(selected_line_index)
            .selected_style(Style::default().bg(theme.editor_cursor_line_bg))
            .background_style(Style::default().bg(theme.background_right))
            .padding(Padding::horizontal(2))
            .dim(20, 0.6);
        f.render_widget(content, area);
    }

    fn build_line(
        &self,
        line_content: &str,
        line_num: usize,
        line_num_width: usize,
        engine: &AnimationEngine,
        theme: &Theme,
    ) -> Line<'_> {
        let cursor_line = engine.buffer.cursor_line;
        let is_cursor_line = line_num == cursor_line;

        let mut spans = Vec::new();

        spans.push(self.render_line_number(line_num, is_cursor_line, line_num_width, theme));

        spans.push(Span::styled(
            "  ",
            Style::default().fg(theme.editor_separator),
        ));

        let show_cursor =
            is_cursor_line && engine.cursor_visible && engine.active_pane == ActivePane::Editor;

        let line_spans = self.highlight_line(HighlightContext {
            line_content,
            line_num,
            show_cursor,
            cursor_col: engine.buffer.cursor_col,
            cursor_line: engine.buffer.cursor_line,
            old_highlights: &engine.buffer.old_highlights,
            new_highlights: &engine.buffer.new_highlights,
            old_line_offsets: &engine.buffer.old_content_line_offsets,
            new_line_offsets: &engine.buffer.new_content_line_offsets,
            line_offset: engine.line_offset,
            theme,
        });

        spans.extend(line_spans);

        Line::from(spans)
    }

    fn render_line_number(
        &self,
        line_num: usize,
        is_cursor_line: bool,
        width: usize,
        theme: &Theme,
    ) -> Span<'_> {
        let line_num_str = format!("{:>width$} ", line_num + 1, width = width);

        if is_cursor_line {
            Span::styled(
                line_num_str,
                Style::default()
                    .fg(theme.editor_line_number_cursor)
                    .add_modifier(Modifier::BOLD),
            )
        } else {
            Span::styled(line_num_str, Style::default().fg(theme.editor_line_number))
        }
    }

    fn highlight_line(&self, ctx: HighlightContext<'_>) -> Vec<Span<'_>> {
        let (highlights, line_offsets) = self.select_highlights_and_offsets(
            ctx.line_num,
            ctx.cursor_line,
            ctx.old_highlights,
            ctx.new_highlights,
            ctx.old_line_offsets,
            ctx.new_line_offsets,
        );

        let byte_offset = self.calculate_byte_offset(
            ctx.line_num,
            ctx.cursor_line,
            ctx.line_offset,
            line_offsets,
        );

        let line_highlights =
            self.filter_line_highlights(highlights, byte_offset, ctx.line_content.len());

        self.apply_highlights(&line_highlights, byte_offset, &ctx)
    }

    fn select_highlights_and_offsets<'a>(
        &self,
        line_num: usize,
        cursor_line: usize,
        old_highlights: &'a [crate::syntax::HighlightSpan],
        new_highlights: &'a [crate::syntax::HighlightSpan],
        old_line_offsets: &'a [usize],
        new_line_offsets: &'a [usize],
    ) -> (&'a [crate::syntax::HighlightSpan], &'a [usize]) {
        if line_num <= cursor_line {
            (new_highlights, new_line_offsets)
        } else {
            (old_highlights, old_line_offsets)
        }
    }

    fn calculate_byte_offset(
        &self,
        line_num: usize,
        cursor_line: usize,
        line_offset: isize,
        line_offsets: &[usize],
    ) -> usize {
        let target_line = if line_num > cursor_line {
            ((line_num as isize) - line_offset).max(0) as usize
        } else {
            line_num
        };

        line_offsets
            .get(target_line)
            .copied()
            .unwrap_or_else(|| *line_offsets.last().unwrap_or(&0))
    }

    fn filter_line_highlights(
        &self,
        highlights: &[crate::syntax::HighlightSpan],
        byte_offset: usize,
        line_len: usize,
    ) -> Vec<(usize, usize, crate::syntax::TokenType)> {
        let line_end = byte_offset + line_len;
        highlights
            .iter()
            .filter_map(|h| {
                if h.start < line_end && h.end > byte_offset {
                    Some((h.start, h.end, h.token_type))
                } else {
                    None
                }
            })
            .collect()
    }

    fn apply_highlights(
        &self,
        line_highlights: &[(usize, usize, crate::syntax::TokenType)],
        byte_offset: usize,
        ctx: &HighlightContext,
    ) -> Vec<Span<'_>> {
        let chars: Vec<char> = ctx.line_content.chars().collect();
        let mut spans = Vec::new();

        let mut relative_byte = 0;
        for (char_idx, ch) in chars.iter().enumerate() {
            let char_byte_start = byte_offset + relative_byte;
            let char_byte_end = char_byte_start + ch.len_utf8();
            relative_byte += ch.len_utf8();

            let color =
                self.get_char_color(char_byte_start, char_byte_end, line_highlights, ctx.theme);

            if ctx.show_cursor && char_idx == ctx.cursor_col {
                // Cursor character - bright highlight
                spans.push(Span::styled(
                    ch.to_string(),
                    Style::default()
                        .bg(ctx.theme.editor_cursor_char_bg)
                        .fg(ctx.theme.editor_cursor_char_fg)
                        .add_modifier(Modifier::BOLD),
                ));
            } else {
                // Normal character
                spans.push(Span::styled(ch.to_string(), Style::default().fg(color)));
            }
        }

        if ctx.show_cursor && ctx.cursor_col >= chars.len() {
            spans.push(Span::styled(
                " ",
                Style::default()
                    .bg(ctx.theme.editor_cursor_char_bg)
                    .fg(ctx.theme.editor_cursor_char_fg)
                    .add_modifier(Modifier::BOLD),
            ));
        }

        spans
    }

    fn get_char_color(
        &self,
        char_byte_start: usize,
        char_byte_end: usize,
        line_highlights: &[(usize, usize, crate::syntax::TokenType)],
        theme: &Theme,
    ) -> Color {
        line_highlights
            .iter()
            .find(|h| char_byte_start >= h.0 && char_byte_end <= h.1)
            .map(|h| h.2.color(theme))
            .unwrap_or(theme.syntax_variable) // Use theme color instead of Color::White
    }
}