feat: Add support for tabular/csv data input.

This commit is contained in:
2026-03-14 14:54:13 -04:00
parent eab872d2ed
commit 8077469d19
24 changed files with 1137 additions and 133 deletions

View File

@@ -209,7 +209,9 @@ async fn handle_multi_action_key(
return false;
}
match (key.code, key.modifiers) {
(KeyCode::Char(' '), m) if !m.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => {
(KeyCode::Char(' '), m)
if !m.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) =>
{
let _ = action_tx.send(Action::ToggleSelect).await;
let _ = action_tx.send(Action::MoveDown(1)).await;
true
@@ -229,14 +231,18 @@ async fn handle_multi_action_key(
}
Mode::Visual => {
match (key.code, key.modifiers) {
(KeyCode::Char(' '), m) | (KeyCode::Enter, m) if !m.intersects(KeyModifiers::ALT) => {
(KeyCode::Char(' '), m) | (KeyCode::Enter, m)
if !m.intersects(KeyModifiers::ALT) =>
{
// Apply visual selection
if let (Some(anchor), Some(vs)) = (*visual_anchor, view_state) {
let cursor = vs.cursor;
let _ = action_tx.send(Action::SelectRange {
start: anchor,
end: cursor,
}).await;
let _ = action_tx
.send(Action::SelectRange {
start: anchor,
end: cursor,
})
.await;
}
*visual_anchor = None;
let _ = action_tx.send(Action::SetMode(Mode::Normal)).await;
@@ -250,10 +256,30 @@ async fn handle_multi_action_key(
/// Render the menu into the given frame. Extracted from the
/// event loop so it can be tested with a [`TestBackend`].
fn render_menu(frame: &mut ratatui::Frame, vs: &ViewState, filter_text: &str, visual_anchor: Option<usize>) {
fn render_menu(
frame: &mut ratatui::Frame,
vs: &ViewState,
filter_text: &str,
visual_anchor: Option<usize>,
) {
let is_table = vs.columns.is_some();
let constraints = if is_table {
vec![
Constraint::Length(1), // prompt
Constraint::Length(1), // header row
Constraint::Min(1), // items
]
} else {
vec![
Constraint::Length(1), // prompt
Constraint::Min(1), // items
]
};
let chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Length(1), Constraint::Min(1)])
.constraints(constraints)
.split(frame.area());
let filtered_count = vs.total_filtered;
@@ -295,6 +321,35 @@ fn render_menu(frame: &mut ratatui::Frame, vs: &ViewState, filter_text: &str, vi
frame.set_cursor_position(((prompt_prefix_len + filter_text.len()) as u16, 0));
}
// Render table header if in table mode
if let Some(ref columns) = vs.columns {
let header_chunk = chunks[1];
let header_spans: Vec<Span> = columns
.iter()
.enumerate()
.flat_map(|(i, col)| {
let mut spans = Vec::new();
if i > 0 {
spans.push(Span::styled(
" \u{2502} ",
Style::default().fg(Color::DarkGray),
));
}
spans.push(Span::styled(
format!("{:<width$}", col.display_name, width = col.width as usize),
Style::default()
.add_modifier(Modifier::BOLD)
.add_modifier(Modifier::UNDERLINED),
));
spans
})
.collect();
let header_line = Paragraph::new(Line::from(header_spans));
frame.render_widget(header_line, header_chunk);
}
let list_chunk = if is_table { chunks[2] } else { chunks[1] };
// Determine visual range for highlighting
let visual_range = if vs.mode == Mode::Visual {
visual_anchor.map(|anchor| {
@@ -324,18 +379,53 @@ fn render_menu(frame: &mut ratatui::Frame, vs: &ViewState, filter_text: &str, vi
style = style.fg(Color::Green);
}
let marker = if vi.selected { "* " } else { " " };
let text = vi.formatted_text.as_deref().unwrap_or(vi.label.as_str());
if vs.multi_enabled {
ListItem::new(format!("{marker}{text}")).style(style)
// Table mode: render cells padded to column widths
if let (Some(col_vals), Some(columns)) = (&vi.column_values, &vs.columns) {
let marker = if vs.multi_enabled {
if vi.selected { "* " } else { " " }
} else {
""
};
let cell_spans: Vec<Span> = columns
.iter()
.enumerate()
.flat_map(|(ci, col)| {
let mut spans = Vec::new();
if ci > 0 {
spans.push(Span::styled(
" \u{2502} ",
Style::default().fg(Color::DarkGray),
));
}
let cell = col_vals.get(ci).map(|s| s.as_str()).unwrap_or("");
spans.push(Span::raw(format!(
"{:<width$}",
cell,
width = col.width as usize
)));
spans
})
.collect();
let mut all_spans = Vec::new();
if !marker.is_empty() {
all_spans.push(Span::raw(marker));
}
all_spans.extend(cell_spans);
ListItem::new(Line::from(all_spans)).style(style)
} else {
ListItem::new(text).style(style)
let marker = if vi.selected { "* " } else { " " };
let text = vi.formatted_text.as_deref().unwrap_or(vi.label.as_str());
if vs.multi_enabled {
ListItem::new(format!("{marker}{text}")).style(style)
} else {
ListItem::new(text).style(style)
}
}
})
.collect();
let list = List::new(items);
frame.render_widget(list, chunks[1]);
frame.render_widget(list, list_chunk);
}
/// Map a crossterm key event to an [`Action`], updating
@@ -501,18 +591,21 @@ mod tests {
formatted_text: None,
index: 0,
selected: false,
column_values: None,
},
VisibleItem {
label: "bravo".into(),
formatted_text: None,
index: 1,
selected: false,
column_values: None,
},
VisibleItem {
label: "charlie".into(),
formatted_text: None,
index: 2,
selected: false,
column_values: None,
},
],
cursor: 0,
@@ -522,6 +615,7 @@ mod tests {
mode: Mode::Insert,
selection_count: 0,
multi_enabled: false,
columns: None,
generation: 1,
}
}
@@ -996,6 +1090,7 @@ mod tests {
mode: Mode::Insert,
selection_count: 0,
multi_enabled: false,
columns: None,
generation: 1,
};
let backend = render_to_backend(30, 4, &vs, "");
@@ -1069,6 +1164,157 @@ mod tests {
);
}
// -- Table mode rendering tests --
use pikl_core::event::ColumnHeader;
fn table_view_state() -> ViewState {
ViewState {
visible_items: vec![
VisibleItem {
label: "alice".into(),
formatted_text: None,
index: 0,
selected: false,
column_values: Some(vec!["alice".into(), "30".into(), "toronto".into()]),
},
VisibleItem {
label: "bob".into(),
formatted_text: None,
index: 1,
selected: false,
column_values: Some(vec!["bob".into(), "25".into(), "vancouver".into()]),
},
],
cursor: 0,
filter_text: Arc::from(""),
total_items: 2,
total_filtered: 2,
mode: Mode::Insert,
selection_count: 0,
multi_enabled: false,
columns: Some(vec![
ColumnHeader {
display_name: "name".into(),
width: 9,
},
ColumnHeader {
display_name: "age".into(),
width: 3,
},
ColumnHeader {
display_name: "city".into(),
width: 9,
},
]),
generation: 1,
}
}
#[test]
fn table_renders_header_row() {
let vs = table_view_state();
let backend = render_to_backend(50, 6, &vs, "");
// Row 0 = prompt, Row 1 = header, Row 2+ = items
let header = line_text(&backend, 1);
assert!(
header.contains("name"),
"header should contain 'name': got '{header}'"
);
assert!(
header.contains("age"),
"header should contain 'age': got '{header}'"
);
assert!(
header.contains("city"),
"header should contain 'city': got '{header}'"
);
}
#[test]
fn table_header_has_bold_style() {
let vs = table_view_state();
let backend = render_to_backend(50, 6, &vs, "");
let buf = backend.buffer();
// Row 1 col 0 should be bold (header row)
let cell = &buf[(0, 1)];
assert!(
cell.modifier.contains(Modifier::BOLD),
"header should be bold"
);
}
#[test]
fn table_items_start_at_row_2() {
let vs = table_view_state();
let backend = render_to_backend(50, 6, &vs, "");
let row2 = line_text(&backend, 2);
let row3 = line_text(&backend, 3);
assert!(
row2.contains("alice"),
"row 2 should have alice: got '{row2}'"
);
assert!(row3.contains("bob"), "row 3 should have bob: got '{row3}'");
}
#[test]
fn table_cells_contain_values() {
let vs = table_view_state();
let backend = render_to_backend(50, 6, &vs, "");
let row2 = line_text(&backend, 2);
assert!(
row2.contains("toronto"),
"row should contain city value: got '{row2}'"
);
assert!(
row2.contains("30"),
"row should contain age value: got '{row2}'"
);
}
#[test]
fn table_cursor_row_has_reversed_style() {
let vs = table_view_state(); // cursor at 0
let backend = render_to_backend(50, 6, &vs, "");
let buf = backend.buffer();
// Row 2 (first item in table mode) should be REVERSED
let cell = &buf[(0, 2)];
assert!(
cell.modifier.contains(Modifier::REVERSED),
"cursor row should have REVERSED style in table mode"
);
// Row 3 should not
let cell2 = &buf[(0, 3)];
assert!(
!cell2.modifier.contains(Modifier::REVERSED),
"non-cursor row should not have REVERSED in table mode"
);
}
#[test]
fn table_header_has_separators() {
let vs = table_view_state();
let backend = render_to_backend(50, 6, &vs, "");
let header = line_text(&backend, 1);
assert!(
header.contains('\u{2502}'),
"header should contain column separator: got '{header}'"
);
}
#[test]
fn table_without_columns_renders_normally() {
// When columns is None, should render like a normal list (no header row)
let vs = sample_view_state();
let backend = render_to_backend(30, 6, &vs, "");
// Row 1 should be items, not a header
let row1 = line_text(&backend, 1);
assert!(
row1.contains("alpha"),
"without columns, row 1 should be first item: got '{row1}'"
);
}
#[test]
fn pending_cleared_on_mode_key() {
let mut ft = String::new();