hjkl-picker 0.5.0

Fuzzy picker subsystem for hjkl-based apps — file, grep, and custom sources.
Documentation
//! Picker preview-pane renderer.
//!
//! Self-contained ratatui widget: feed in a [`Picker`], a
//! [`PreviewHighlighter`], a [`PreviewTheme`], and a target [`Rect`] —
//! [`preview_pane`] handles snapshot, highlight dispatch, and `BufferView`
//! rendering. No syntax-layer types leak through the API; consumers route
//! their own grammar / LSP / regex pipeline through the trait.

use hjkl_buffer::{BufferView, Gutter, Viewport};
use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui::widgets::{Block, Borders, Paragraph};

use crate::highlight::PreviewHighlighter;
use crate::picker::Picker;
use crate::preview::PreviewSpans;

/// Visual styling for [`preview_pane`]. Pre-computed `Style`s rather than
/// raw `Color`s so consumers retain full control (modifiers, bg/fg layering).
pub struct PreviewTheme {
    /// Border around the preview block.
    pub border: Style,
    /// Gutter (line-number column) foreground style.
    pub gutter: Style,
    /// Style for non-text glyphs (tabs, trailing whitespace markers).
    pub non_text: Style,
    /// Background painted across the cursor row when a match is active.
    pub cursor_line: Style,
}

/// Render the picker preview pane into `area`.
///
/// Pulls the active preview's path + buffer bytes from `picker`, dispatches to
/// `highlighter` for spans, and draws the result via `BufferView`. When the
/// active source has no preview path (e.g. ephemeral diff text), the pane
/// renders monochrome — the highlighter is not consulted.
pub fn preview_pane(
    frame: &mut Frame,
    picker: &Picker,
    highlighter: &dyn PreviewHighlighter,
    theme: &PreviewTheme,
    area: Rect,
) {
    let label = picker.preview_label().unwrap_or("(none)").to_string();
    let status = picker.preview_status();
    let title = if status.is_empty() {
        format!(" preview — {label} ")
    } else {
        format!(" preview — {label} [{status}] ")
    };
    let block = Block::default()
        .borders(Borders::ALL)
        .border_style(theme.border)
        .title(title);
    let inner = block.inner(area);
    frame.render_widget(block, area);

    if !status.is_empty() {
        // Skipped (binary / oversized / I/O error) — show the status tag in
        // the body too for visibility on narrow terminals where the title
        // might be clipped.
        let para = Paragraph::new(format!("  ({status})"));
        frame.render_widget(para, inner);
        return;
    }

    let buf = picker.preview_buffer();
    let line_count = buf.lines().len();

    // Compute spans only when the source advertises a preview path. Sources
    // serving virtual content (diff text, log messages) skip highlighting.
    let preview_spans = match picker.preview_path() {
        Some(path) => {
            let mut bytes = buf.lines().join("\n").into_bytes();
            if !bytes.is_empty() {
                bytes.push(b'\n');
            }
            highlighter.spans_for(path, &bytes)
        }
        None => PreviewSpans::default(),
    };

    let gw = gutter_width(line_count.max(1));
    let viewport = Viewport {
        top_row: picker.preview_top_row(),
        top_col: 0,
        width: inner.width.saturating_sub(gw),
        height: inner.height,
        text_width: inner.width.saturating_sub(gw),
        ..Viewport::default()
    };
    let resolver = |id: u32| {
        preview_spans
            .styles
            .get(id as usize)
            .copied()
            .unwrap_or_default()
    };
    let cursor_line_bg = if picker.preview_match_row().is_some() {
        theme.cursor_line
    } else {
        Style::default()
    };
    let view = BufferView {
        buffer: buf,
        viewport: &viewport,
        selection: None,
        resolver: &resolver,
        cursor_line_bg,
        cursor_column_bg: Style::default(),
        selection_bg: Style::default(),
        cursor_style: Style::default(),
        gutter: Some(Gutter {
            width: gw,
            style: theme.gutter,
            line_offset: picker.preview_line_offset(),
            ..Default::default()
        }),
        search_bg: Style::default(),
        signs: &[],
        conceals: &[],
        spans: &preview_spans.by_row,
        search_pattern: None,
        non_text_style: theme.non_text,
        diag_overlays: &[],
    };
    frame.render_widget(view, inner);
}

/// Gutter width for preview pane: digit count + 1-column trailing spacer,
/// floored to neovim's default `numberwidth` of 4.
fn gutter_width(line_count: usize) -> u16 {
    const NUMBERWIDTH: usize = 4;
    let needed = line_count.to_string().len() + 1;
    needed.max(NUMBERWIDTH) as u16
}