void-audit-tui 0.0.4

Audit viewer TUI for void — integrity and encryption inspection
Documentation
//! Object list widget (left panel).
//!
//! Displays all repository objects in a scrollable list.

use ratatui::{
    buffer::Buffer,
    layout::Rect,
    style::Style,
    widgets::{Block, Borders, StatefulWidget, Widget},
};

use crate::color::ColorTheme;
use crate::void_backend::{ObjectInfo, ObjectType};

/// State for the object list widget.
#[derive(Debug)]
pub struct ObjectListState {
    /// All objects to display.
    objects: Vec<ObjectInfo>,
    /// Currently selected visual index (within viewport).
    selected: usize,
    /// Scroll offset (first visible object index).
    offset: usize,
}

impl ObjectListState {
    /// Create a new object list state.
    pub fn new(objects: Vec<ObjectInfo>) -> Self {
        Self {
            objects,
            selected: 0,
            offset: 0,
        }
    }

    /// Returns the total number of objects.
    pub fn len(&self) -> usize {
        self.objects.len()
    }

    /// Returns true if there are no objects.
    pub fn is_empty(&self) -> bool {
        self.objects.is_empty()
    }

    /// Get the currently selected object.
    pub fn selected_object(&self) -> Option<&ObjectInfo> {
        let idx = self.offset + self.selected;
        self.objects.get(idx)
    }

    /// Get the selected absolute index.
    pub fn selected_index(&self) -> usize {
        self.offset + self.selected
    }

    /// Move selection down by one.
    pub fn select_next(&mut self, viewport_height: usize) {
        let max_idx = self.objects.len().saturating_sub(1);
        let current_abs = self.offset + self.selected;

        if current_abs < max_idx {
            if self.selected < viewport_height.saturating_sub(1) {
                self.selected += 1;
            } else {
                self.offset += 1;
            }
        }
    }

    /// Move selection up by one.
    pub fn select_prev(&mut self) {
        if self.selected > 0 {
            self.selected -= 1;
        } else if self.offset > 0 {
            self.offset -= 1;
        }
    }

    /// Move selection down by half a page.
    pub fn scroll_down_half(&mut self, viewport_height: usize) {
        let half = viewport_height / 2;
        for _ in 0..half {
            self.select_next(viewport_height);
        }
    }

    /// Move selection up by half a page.
    pub fn scroll_up_half(&mut self, viewport_height: usize) {
        let half = viewport_height / 2;
        for _ in 0..half {
            self.select_prev();
        }
    }

    /// Move selection down by a full page.
    pub fn scroll_down_page(&mut self, viewport_height: usize) {
        for _ in 0..viewport_height {
            self.select_next(viewport_height);
        }
    }

    /// Move selection up by a full page.
    pub fn scroll_up_page(&mut self, viewport_height: usize) {
        for _ in 0..viewport_height {
            self.select_prev();
        }
    }

    /// Jump to the first object.
    pub fn select_first(&mut self) {
        self.selected = 0;
        self.offset = 0;
    }

    /// Jump to the last object.
    pub fn select_last(&mut self, viewport_height: usize) {
        let total = self.objects.len();
        if total <= viewport_height {
            self.offset = 0;
            self.selected = total.saturating_sub(1);
        } else {
            self.offset = total - viewport_height;
            self.selected = viewport_height.saturating_sub(1);
        }
    }

    /// Update an object's info (used for lazy categorization).
    pub fn update_object(&mut self, index: usize, info: ObjectInfo) {
        if index < self.objects.len() {
            self.objects[index] = info;
        }
    }

    /// Get a mutable reference to an object for updating.
    pub fn get_object_mut(&mut self, index: usize) -> Option<&mut ObjectInfo> {
        self.objects.get_mut(index)
    }
}

/// The object list widget.
pub struct ObjectList<'a> {
    theme: &'a ColorTheme,
}

impl<'a> ObjectList<'a> {
    /// Create a new object list widget.
    pub fn new(theme: &'a ColorTheme) -> Self {
        Self { theme }
    }
}

impl<'a> StatefulWidget for ObjectList<'a> {
    type State = ObjectListState;

    fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
        // Render border with count
        let title = format!(" Objects ({}) ", state.len());
        let block = Block::default().borders(Borders::ALL).title(title);
        let inner = block.inner(area);
        block.render(area, buf);

        if state.is_empty() {
            // Show empty state message
            let msg = "No objects found";
            let x = inner.x + (inner.width.saturating_sub(msg.len() as u16)) / 2;
            let y = inner.y + inner.height / 2;
            buf.set_string(x, y, msg, Style::default().fg(self.theme.status_warn_fg));
            return;
        }

        let viewport_height = inner.height as usize;

        // Column widths
        let cid_width = 14_u16; // 12 chars + 2 padding
        let type_width = 10_u16; // [metadata] + padding
        let format_width = 14_u16; // "metadata/v1" + padding
        let size_width = inner.width.saturating_sub(cid_width + type_width + format_width);

        // Render visible objects
        for (i, idx) in (state.offset..).take(viewport_height).enumerate() {
            if idx >= state.objects.len() {
                break;
            }

            let obj = &state.objects[idx];
            let is_selected = i == state.selected;
            let y = inner.y + i as u16;

            // Selection styling
            let base_style = if is_selected {
                Style::default()
                    .fg(self.theme.list_selected_fg)
                    .bg(self.theme.list_selected_bg)
            } else {
                Style::default()
            };

            // Selection indicator
            let indicator = if is_selected { "> " } else { "  " };
            buf.set_string(inner.x, y, indicator, base_style);

            // CID (first 12 chars)
            let cid_style = if is_selected {
                base_style
            } else {
                Style::default().fg(self.theme.list_cid_fg)
            };
            buf.set_string(inner.x + 2, y, obj.short_cid(), cid_style);

            // Type badge
            let type_x = inner.x + 2 + cid_width;
            let (type_str, type_color) = match obj.object_type {
                ObjectType::Commit => ("[commit]", self.theme.list_type_commit_fg),
                ObjectType::Metadata => ("[metadata]", self.theme.list_type_metadata_fg),
                ObjectType::Manifest => ("[manifest]", self.theme.list_type_metadata_fg),
                ObjectType::RepoManifest => ("[repo-manifest]", self.theme.list_type_metadata_fg),
                ObjectType::Shard => ("[shard]", self.theme.list_type_shard_fg),
                ObjectType::Unknown => ("[unknown]", self.theme.list_type_unknown_fg),
            };
            let type_style = if is_selected {
                base_style
            } else {
                Style::default().fg(type_color)
            };
            buf.set_string(type_x, y, type_str, type_style);

            // Format
            let format_x = type_x + type_width;
            let (format_str, format_color) = if obj.format.is_known() {
                (obj.format.as_str(), self.theme.list_format_known_fg)
            } else {
                (obj.format.as_str(), self.theme.list_format_unknown_fg)
            };
            let format_style = if is_selected {
                base_style
            } else {
                Style::default().fg(format_color)
            };
            buf.set_string(format_x, y, format_str, format_style);

            // Size (right-aligned)
            if size_width > 4 {
                let size_str = format_size(obj.encrypted_size);
                let size_x = inner.x + inner.width - size_str.len() as u16 - 1;
                buf.set_string(size_x, y, &size_str, base_style);
            }

            // Fill any remaining space with selection background if selected
            if is_selected {
                for x in inner.x..(inner.x + inner.width) {
                    if let Some(cell) = buf.cell_mut((x, y)) {
                        if cell.symbol() == " " {
                            cell.set_style(base_style);
                        }
                    }
                }
            }
        }
    }
}

/// Format a size in bytes to human-readable form.
fn format_size(bytes: usize) -> String {
    if bytes < 1024 {
        format!("{}B", bytes)
    } else if bytes < 1024 * 1024 {
        format!("{:.1}KB", bytes as f64 / 1024.0)
    } else {
        format!("{:.1}MB", bytes as f64 / (1024.0 * 1024.0))
    }
}