1use std::collections::HashMap;
2
3use crossterm::event::KeyCode;
4
5use crate::{
6 Component,
7 Event,
8 Focusable,
9 InputResult,
10 RenderError,
11 Rendered,
12 theme::{
13 Palette,
14 Style,
15 Theme,
16 stylize,
17 },
18};
19
20pub struct Column {
22 pub key: String,
23 pub label: String,
24 pub width: Option<u16>,
25 pub sortable: bool,
26}
27
28impl Column {
29 pub fn new(key: impl Into<String>, label: impl Into<String>) -> Self {
31 Self {
32 key: key.into(),
33 label: label.into(),
34 width: None,
35 sortable: false,
36 }
37 }
38
39 pub fn width(mut self, w: u16) -> Self {
41 self.width = Some(w);
42 self
43 }
44
45 pub fn sortable(mut self) -> Self {
47 self.sortable = true;
48 self
49 }
50}
51
52pub struct Row {
54 cells: HashMap<String, String>,
55}
56
57impl Row {
58 pub fn new(cells: HashMap<String, String>) -> Self {
60 Self { cells }
61 }
62
63 pub fn get(&self, key: &str) -> Option<&str> {
65 self.cells.get(key).map(|s| s.as_str())
66 }
67}
68
69pub struct Table {
75 columns: Vec<Column>,
76 rows: Vec<Row>,
77 selected: usize,
78 sort_column: Option<usize>,
79 sort_ascending: bool,
80 focused: bool,
81}
82
83impl Table {
84 pub fn new(columns: Vec<Column>, rows: Vec<Row>) -> Self {
86 Self {
87 columns,
88 rows,
89 selected: 0,
90 sort_column: None,
91 sort_ascending: true,
92 focused: false,
93 }
94 }
95
96 pub fn selected(&self) -> usize {
98 self.selected
99 }
100
101 pub fn set_selected(&mut self, index: usize) {
103 self.selected = index.min(self.rows.len().saturating_sub(1));
104 }
105
106 pub fn set_sort_column(&mut self, column: Option<usize>) {
108 self.sort_column = column;
109 }
110
111 pub fn set_sort_ascending(&mut self, ascending: bool) {
113 self.sort_ascending = ascending;
114 }
115
116 fn compute_column_widths(&self, total_width: u16) -> Vec<u16> {
117 let num_cols = self.columns.len();
118 if num_cols == 0 {
119 return Vec::new();
120 }
121
122 let separator_width = (num_cols.saturating_sub(1)) as u16;
123 let prefix_width = 2u16;
124 let budget = total_width
125 .saturating_sub(prefix_width)
126 .saturating_sub(separator_width);
127
128 if budget == 0 {
129 return vec![0; num_cols];
130 }
131
132 let mut widths = Vec::with_capacity(num_cols);
133 let mut flex_indices = Vec::new();
134 let mut fixed_total = 0u16;
135
136 for (i, col) in self.columns.iter().enumerate() {
137 if let Some(w) = col.width {
138 let w = w.min(budget);
139 widths.push(w);
140 fixed_total += w;
141 } else {
142 widths.push(0);
143 flex_indices.push(i);
144 }
145 }
146
147 if !flex_indices.is_empty() {
148 let flex_budget = budget.saturating_sub(fixed_total);
149 let flex_width = if flex_budget > 0 {
150 flex_budget / flex_indices.len() as u16
151 } else {
152 1
153 };
154 for &i in &flex_indices {
155 widths[i] = flex_width.max(1);
156 }
157 }
158
159 let total: u16 = widths.iter().sum();
161 if total > budget && budget > 0 {
162 for w in &mut widths {
163 *w = (*w as u32 * budget as u32 / total as u32) as u16;
164 }
165 }
166
167 widths
168 }
169}
170
171impl Focusable for Table {
172 fn focused(&self) -> bool {
173 self.focused
174 }
175
176 fn set_focused(&mut self, focused: bool) {
177 self.focused = focused;
178 }
179}
180
181impl Component for Table {
182 fn render(&self, width: u16) -> Result<Rendered, RenderError> {
183 let theme = Theme::current();
184
185 if self.columns.is_empty() {
186 return Ok(Rendered {
187 lines: Vec::new(),
188 cursor: None,
189 images: Vec::new(),
190 });
191 }
192
193 let separator_count = self.columns.len().saturating_sub(1) as u16;
194 let min_width = 2u16 + separator_count;
195 if width < min_width {
196 return Ok(Rendered {
197 lines: Vec::new(),
198 cursor: None,
199 images: Vec::new(),
200 });
201 }
202
203 let widths = self.compute_column_widths(width);
204 let mut lines = Vec::new();
205
206 let header_style = Style::new().fg(theme.text_primary()).bold();
208 let mut header_parts = vec![stylize(" ", &header_style)];
209 for (i, col) in self.columns.iter().enumerate() {
210 let mut label = col.label.clone();
211 if let Some(sort_idx) = self.sort_column &&
212 sort_idx == i &&
213 col.sortable
214 {
215 let indicator = if self.sort_ascending { "▲" } else { "▼" };
216 label.push_str(indicator);
217 }
218
219 let cell_width = widths.get(i).copied().unwrap_or(0);
220 let cell = if cell_width == 0 {
221 String::new()
222 } else {
223 let truncated = crate::utils::truncate_to_width(&label, cell_width, "…");
224 format!("{:<width$}", truncated, width = cell_width as usize)
225 };
226 header_parts.push(stylize(&cell, &header_style));
227
228 if i + 1 < self.columns.len() {
229 header_parts.push(" ".to_string());
230 }
231 }
232 lines.push(header_parts.concat());
233
234 let sep_line = "─".repeat(width as usize);
236 let sep_style = Style::new().fg(theme.border_default());
237 lines.push(stylize(&sep_line, &sep_style));
238
239 let accent_style = Style::new().fg(theme.accent()).bold();
241 let text_style = Style::new().fg(theme.text_primary());
242
243 for (row_idx, row) in self.rows.iter().enumerate() {
244 let is_selected = row_idx == self.selected;
245 let row_style = if is_selected && self.focused {
246 &accent_style
247 } else {
248 &text_style
249 };
250
251 let prefix = if is_selected && self.focused {
252 stylize("> ", row_style)
253 } else {
254 " ".to_string()
255 };
256
257 let mut row_parts = vec![prefix];
258 for (col_idx, col) in self.columns.iter().enumerate() {
259 let cell_width = widths.get(col_idx).copied().unwrap_or(0);
260 let cell_text = row.get(&col.key).unwrap_or("");
261 let cell = if cell_width == 0 {
262 String::new()
263 } else {
264 let truncated = crate::utils::truncate_to_width(cell_text, cell_width, "…");
265 format!("{:<width$}", truncated, width = cell_width as usize)
266 };
267 row_parts.push(stylize(&cell, row_style));
268
269 if col_idx + 1 < self.columns.len() {
270 row_parts.push(" ".to_string());
271 }
272 }
273 lines.push(row_parts.concat());
274 }
275
276 Ok(Rendered {
277 lines,
278 cursor: None,
279 images: Vec::new(),
280 })
281 }
282
283 fn handle_input(&mut self, event: &Event) -> InputResult {
284 use crossterm::event::KeyModifiers;
285 if let Event::Key(key) = event {
286 match key.code {
287 | KeyCode::Down => {
288 if self.selected + 1 < self.rows.len() {
289 self.selected += 1;
290 }
291 InputResult::Handled
292 },
293 | KeyCode::Up => {
294 if self.selected > 0 {
295 self.selected -= 1;
296 }
297 InputResult::Handled
298 },
299 | KeyCode::Char('j') if !key.modifiers.contains(KeyModifiers::CONTROL) => {
300 if self.selected + 1 < self.rows.len() {
301 self.selected += 1;
302 }
303 InputResult::Handled
304 },
305 | KeyCode::Char('k') if !key.modifiers.contains(KeyModifiers::CONTROL) => {
306 if self.selected > 0 {
307 self.selected -= 1;
308 }
309 InputResult::Handled
310 },
311 | _ => InputResult::Ignored,
312 }
313 } else {
314 InputResult::Ignored
315 }
316 }
317
318 fn as_focusable(&self) -> Option<&dyn Focusable> {
319 Some(self)
320 }
321
322 fn as_focusable_mut(&mut self) -> Option<&mut dyn Focusable> {
323 Some(self)
324 }
325}
326
327#[cfg(test)]
328mod tests {
329 use std::collections::HashMap;
330
331 use crossterm::event::KeyCode;
332
333 use super::*;
334 use crate::Event;
335
336 #[test]
337 fn table_new() {
338 let cols = vec![Column::new("name", "Name")];
339 let rows = vec![Row::new(HashMap::from([(
340 "name".to_string(),
341 "Alice".to_string(),
342 )]))];
343 let table = Table::new(cols, rows);
344 assert_eq!(table.selected(), 0);
345 }
346
347 #[test]
348 fn table_set_selected_clamps() {
349 let cols = vec![Column::new("name", "Name")];
350 let rows = vec![
351 Row::new(HashMap::from([("name".to_string(), "Alice".to_string())])),
352 Row::new(HashMap::from([("name".to_string(), "Bob".to_string())])),
353 ];
354 let mut table = Table::new(cols, rows);
355 table.set_selected(100);
356 assert_eq!(table.selected(), 1);
357 }
358
359 #[test]
360 fn table_renders_header_and_rows() {
361 Theme::with(Theme::Light, || {
362 let cols = vec![Column::new("name", "Name")];
363 let rows = vec![Row::new(HashMap::from([(
364 "name".to_string(),
365 "Alice".to_string(),
366 )]))];
367 let table = Table::new(cols, rows);
368 let rendered = table.render(40).unwrap();
369 assert_eq!(rendered.lines.len(), 3); assert!(rendered.lines[0].contains("Name"));
371 });
372 }
373
374 #[test]
375 fn table_selected_row_focused() {
376 Theme::with(Theme::Light, || {
377 let cols = vec![Column::new("name", "Name")];
378 let rows = vec![Row::new(HashMap::from([(
379 "name".to_string(),
380 "Alice".to_string(),
381 )]))];
382 let mut table = Table::new(cols, rows);
383 table.set_focused(true);
384 let rendered = table.render(40).unwrap();
385 assert!(rendered.lines[2].contains("> "));
386 });
387 }
388
389 #[test]
390 fn table_navigation() {
391 let cols = vec![Column::new("name", "Name")];
392 let rows = vec![
393 Row::new(HashMap::from([("name".to_string(), "Alice".to_string())])),
394 Row::new(HashMap::from([("name".to_string(), "Bob".to_string())])),
395 ];
396 let mut table = Table::new(cols, rows);
397 table.set_focused(true);
398 table.handle_input(&Event::Key(crossterm::event::KeyEvent::new(
399 KeyCode::Down,
400 crossterm::event::KeyModifiers::empty(),
401 )));
402 assert_eq!(table.selected(), 1);
403 }
404
405 #[test]
406 fn table_j_k_navigation() {
407 let cols = vec![Column::new("name", "Name")];
408 let rows = vec![
409 Row::new(HashMap::from([("name".to_string(), "Alice".to_string())])),
410 Row::new(HashMap::from([("name".to_string(), "Bob".to_string())])),
411 ];
412 let mut table = Table::new(cols, rows);
413 table.set_focused(true);
414 table.handle_input(&Event::Key(crossterm::event::KeyEvent::new(
415 KeyCode::Char('j'),
416 crossterm::event::KeyModifiers::empty(),
417 )));
418 assert_eq!(table.selected(), 1);
419 table.handle_input(&Event::Key(crossterm::event::KeyEvent::new(
420 KeyCode::Char('k'),
421 crossterm::event::KeyModifiers::empty(),
422 )));
423 assert_eq!(table.selected(), 0);
424 }
425
426 #[test]
427 fn table_sort_indicator() {
428 Theme::with(Theme::Light, || {
429 let cols = vec![Column::new("name", "Name").sortable()];
430 let rows = vec![Row::new(HashMap::from([(
431 "name".to_string(),
432 "Alice".to_string(),
433 )]))];
434 let mut table = Table::new(cols, rows);
435 table.set_sort_column(Some(0));
436 table.set_sort_ascending(true);
437 let rendered = table.render(40).unwrap();
438 assert!(rendered.lines[0].contains("▲"));
439 });
440 }
441
442 #[test]
443 fn table_empty_columns() {
444 let cols: Vec<Column> = vec![];
445 let rows: Vec<Row> = vec![];
446 let table = Table::new(cols, rows);
447 let rendered = table.render(40).unwrap();
448 assert!(rendered.lines.is_empty());
449 }
450
451 #[test]
452 fn table_unfocused_no_accent_prefix() {
453 Theme::with(Theme::Light, || {
454 let cols = vec![Column::new("name", "Name")];
455 let rows = vec![Row::new(HashMap::from([(
456 "name".to_string(),
457 "Alice".to_string(),
458 )]))];
459 let table = Table::new(cols, rows);
460 let rendered = table.render(40).unwrap();
461 assert!(!rendered.lines[2].contains("> "));
462 });
463 }
464}