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