Skip to main content

void_audit_tui/widget/
object_list.rs

1//! Object list widget (left panel).
2//!
3//! Displays all repository objects in a scrollable list.
4
5use ratatui::{
6    buffer::Buffer,
7    layout::Rect,
8    style::Style,
9    widgets::{Block, Borders, StatefulWidget, Widget},
10};
11
12use crate::color::ColorTheme;
13use crate::void_backend::{ObjectInfo, ObjectType};
14
15/// State for the object list widget.
16#[derive(Debug)]
17pub struct ObjectListState {
18    /// All objects to display.
19    objects: Vec<ObjectInfo>,
20    /// Currently selected visual index (within viewport).
21    selected: usize,
22    /// Scroll offset (first visible object index).
23    offset: usize,
24}
25
26impl ObjectListState {
27    /// Create a new object list state.
28    pub fn new(objects: Vec<ObjectInfo>) -> Self {
29        Self {
30            objects,
31            selected: 0,
32            offset: 0,
33        }
34    }
35
36    /// Returns the total number of objects.
37    pub fn len(&self) -> usize {
38        self.objects.len()
39    }
40
41    /// Returns true if there are no objects.
42    pub fn is_empty(&self) -> bool {
43        self.objects.is_empty()
44    }
45
46    /// Get the currently selected object.
47    pub fn selected_object(&self) -> Option<&ObjectInfo> {
48        let idx = self.offset + self.selected;
49        self.objects.get(idx)
50    }
51
52    /// Get the selected absolute index.
53    pub fn selected_index(&self) -> usize {
54        self.offset + self.selected
55    }
56
57    /// Move selection down by one.
58    pub fn select_next(&mut self, viewport_height: usize) {
59        let max_idx = self.objects.len().saturating_sub(1);
60        let current_abs = self.offset + self.selected;
61
62        if current_abs < max_idx {
63            if self.selected < viewport_height.saturating_sub(1) {
64                self.selected += 1;
65            } else {
66                self.offset += 1;
67            }
68        }
69    }
70
71    /// Move selection up by one.
72    pub fn select_prev(&mut self) {
73        if self.selected > 0 {
74            self.selected -= 1;
75        } else if self.offset > 0 {
76            self.offset -= 1;
77        }
78    }
79
80    /// Move selection down by half a page.
81    pub fn scroll_down_half(&mut self, viewport_height: usize) {
82        let half = viewport_height / 2;
83        for _ in 0..half {
84            self.select_next(viewport_height);
85        }
86    }
87
88    /// Move selection up by half a page.
89    pub fn scroll_up_half(&mut self, viewport_height: usize) {
90        let half = viewport_height / 2;
91        for _ in 0..half {
92            self.select_prev();
93        }
94    }
95
96    /// Move selection down by a full page.
97    pub fn scroll_down_page(&mut self, viewport_height: usize) {
98        for _ in 0..viewport_height {
99            self.select_next(viewport_height);
100        }
101    }
102
103    /// Move selection up by a full page.
104    pub fn scroll_up_page(&mut self, viewport_height: usize) {
105        for _ in 0..viewport_height {
106            self.select_prev();
107        }
108    }
109
110    /// Jump to the first object.
111    pub fn select_first(&mut self) {
112        self.selected = 0;
113        self.offset = 0;
114    }
115
116    /// Jump to the last object.
117    pub fn select_last(&mut self, viewport_height: usize) {
118        let total = self.objects.len();
119        if total <= viewport_height {
120            self.offset = 0;
121            self.selected = total.saturating_sub(1);
122        } else {
123            self.offset = total - viewport_height;
124            self.selected = viewport_height.saturating_sub(1);
125        }
126    }
127
128    /// Update an object's info (used for lazy categorization).
129    pub fn update_object(&mut self, index: usize, info: ObjectInfo) {
130        if index < self.objects.len() {
131            self.objects[index] = info;
132        }
133    }
134
135    /// Get a mutable reference to an object for updating.
136    pub fn get_object_mut(&mut self, index: usize) -> Option<&mut ObjectInfo> {
137        self.objects.get_mut(index)
138    }
139}
140
141/// The object list widget.
142pub struct ObjectList<'a> {
143    theme: &'a ColorTheme,
144}
145
146impl<'a> ObjectList<'a> {
147    /// Create a new object list widget.
148    pub fn new(theme: &'a ColorTheme) -> Self {
149        Self { theme }
150    }
151}
152
153impl<'a> StatefulWidget for ObjectList<'a> {
154    type State = ObjectListState;
155
156    fn render(self, area: Rect, buf: &mut Buffer, state: &mut Self::State) {
157        // Render border with count
158        let title = format!(" Objects ({}) ", state.len());
159        let block = Block::default().borders(Borders::ALL).title(title);
160        let inner = block.inner(area);
161        block.render(area, buf);
162
163        if state.is_empty() {
164            // Show empty state message
165            let msg = "No objects found";
166            let x = inner.x + (inner.width.saturating_sub(msg.len() as u16)) / 2;
167            let y = inner.y + inner.height / 2;
168            buf.set_string(x, y, msg, Style::default().fg(self.theme.status_warn_fg));
169            return;
170        }
171
172        let viewport_height = inner.height as usize;
173
174        // Column widths
175        let cid_width = 14_u16; // 12 chars + 2 padding
176        let type_width = 10_u16; // [metadata] + padding
177        let format_width = 14_u16; // "metadata/v1" + padding
178        let size_width = inner.width.saturating_sub(cid_width + type_width + format_width);
179
180        // Render visible objects
181        for (i, idx) in (state.offset..).take(viewport_height).enumerate() {
182            if idx >= state.objects.len() {
183                break;
184            }
185
186            let obj = &state.objects[idx];
187            let is_selected = i == state.selected;
188            let y = inner.y + i as u16;
189
190            // Selection styling
191            let base_style = if is_selected {
192                Style::default()
193                    .fg(self.theme.list_selected_fg)
194                    .bg(self.theme.list_selected_bg)
195            } else {
196                Style::default()
197            };
198
199            // Selection indicator
200            let indicator = if is_selected { "> " } else { "  " };
201            buf.set_string(inner.x, y, indicator, base_style);
202
203            // CID (first 12 chars)
204            let cid_style = if is_selected {
205                base_style
206            } else {
207                Style::default().fg(self.theme.list_cid_fg)
208            };
209            buf.set_string(inner.x + 2, y, obj.short_cid(), cid_style);
210
211            // Type badge
212            let type_x = inner.x + 2 + cid_width;
213            let (type_str, type_color) = match obj.object_type {
214                ObjectType::Commit => ("[commit]", self.theme.list_type_commit_fg),
215                ObjectType::Metadata => ("[metadata]", self.theme.list_type_metadata_fg),
216                ObjectType::Manifest => ("[manifest]", self.theme.list_type_metadata_fg),
217                ObjectType::RepoManifest => ("[repo-manifest]", self.theme.list_type_metadata_fg),
218                ObjectType::Shard => ("[shard]", self.theme.list_type_shard_fg),
219                ObjectType::Unknown => ("[unknown]", self.theme.list_type_unknown_fg),
220            };
221            let type_style = if is_selected {
222                base_style
223            } else {
224                Style::default().fg(type_color)
225            };
226            buf.set_string(type_x, y, type_str, type_style);
227
228            // Format
229            let format_x = type_x + type_width;
230            let (format_str, format_color) = if obj.format.is_known() {
231                (obj.format.as_str(), self.theme.list_format_known_fg)
232            } else {
233                (obj.format.as_str(), self.theme.list_format_unknown_fg)
234            };
235            let format_style = if is_selected {
236                base_style
237            } else {
238                Style::default().fg(format_color)
239            };
240            buf.set_string(format_x, y, format_str, format_style);
241
242            // Size (right-aligned)
243            if size_width > 4 {
244                let size_str = format_size(obj.encrypted_size);
245                let size_x = inner.x + inner.width - size_str.len() as u16 - 1;
246                buf.set_string(size_x, y, &size_str, base_style);
247            }
248
249            // Fill any remaining space with selection background if selected
250            if is_selected {
251                for x in inner.x..(inner.x + inner.width) {
252                    if let Some(cell) = buf.cell_mut((x, y)) {
253                        if cell.symbol() == " " {
254                            cell.set_style(base_style);
255                        }
256                    }
257                }
258            }
259        }
260    }
261}
262
263/// Format a size in bytes to human-readable form.
264fn format_size(bytes: usize) -> String {
265    if bytes < 1024 {
266        format!("{}B", bytes)
267    } else if bytes < 1024 * 1024 {
268        format!("{:.1}KB", bytes as f64 / 1024.0)
269    } else {
270        format!("{:.1}MB", bytes as f64 / (1024.0 * 1024.0))
271    }
272}