feat: Add support for tabular/csv data input.
This commit is contained in:
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user