feat: Add support for tabular/csv data input.
This commit is contained in:
@@ -9,9 +9,9 @@ use indexmap::IndexSet;
|
||||
use tokio::sync::{broadcast, mpsc};
|
||||
use tracing::{debug, info, trace};
|
||||
|
||||
use crate::debounce::{hook_response_to_action, DebouncedDispatcher};
|
||||
use crate::debounce::{DebouncedDispatcher, hook_response_to_action};
|
||||
use crate::error::PiklError;
|
||||
use crate::event::{Action, MenuEvent, MenuResult, Mode, ViewState, VisibleItem};
|
||||
use crate::event::{Action, ColumnHeader, MenuEvent, MenuResult, Mode, ViewState, VisibleItem};
|
||||
use crate::hook::{HookEvent, HookHandler};
|
||||
use crate::model::traits::MutableMenu;
|
||||
use crate::navigation::Viewport;
|
||||
@@ -97,14 +97,16 @@ impl SelectionState {
|
||||
/// Undo the last selection change.
|
||||
pub fn undo(&mut self) {
|
||||
if let Some(prev) = self.undo_stack.pop() {
|
||||
self.redo_stack.push(std::mem::replace(&mut self.selected, prev));
|
||||
self.redo_stack
|
||||
.push(std::mem::replace(&mut self.selected, prev));
|
||||
}
|
||||
}
|
||||
|
||||
/// Redo a previously undone selection change.
|
||||
pub fn redo(&mut self) {
|
||||
if let Some(next) = self.redo_stack.pop() {
|
||||
self.undo_stack.push(std::mem::replace(&mut self.selected, next));
|
||||
self.undo_stack
|
||||
.push(std::mem::replace(&mut self.selected, next));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -232,22 +234,56 @@ impl<M: MutableMenu> MenuRunner<M> {
|
||||
fn build_view_state(&mut self) -> ViewState {
|
||||
self.generation += 1;
|
||||
let range = self.viewport.visible_range();
|
||||
let has_columns = self.menu.column_config().is_some();
|
||||
let visible_items: Vec<VisibleItem> = range
|
||||
.clone()
|
||||
.filter_map(|i| {
|
||||
self.menu.filtered_label(i).map(|label| {
|
||||
let formatted_text = self.menu.formatted_label(i);
|
||||
let orig_idx = self.menu.original_index(i).unwrap_or(0);
|
||||
let column_values = if has_columns {
|
||||
self.menu.column_values(i)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
VisibleItem {
|
||||
label: label.to_string(),
|
||||
formatted_text,
|
||||
index: i,
|
||||
selected: self.selection.is_selected(orig_idx),
|
||||
column_values,
|
||||
}
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
|
||||
// Compute column headers with widths if table mode is active
|
||||
let columns = self.menu.column_config().map(|config| {
|
||||
config
|
||||
.columns
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(col_idx, col_def)| {
|
||||
let header_len = col_def.display_name.len();
|
||||
let max_cell = visible_items
|
||||
.iter()
|
||||
.filter_map(|vi| {
|
||||
vi.column_values
|
||||
.as_ref()
|
||||
.and_then(|cv| cv.get(col_idx))
|
||||
.map(|s| s.len())
|
||||
})
|
||||
.max()
|
||||
.unwrap_or(0);
|
||||
let width = header_len.max(max_cell).min(60) as u16;
|
||||
ColumnHeader {
|
||||
display_name: col_def.display_name.clone(),
|
||||
width,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
});
|
||||
|
||||
let cursor = if self.menu.filtered_count() == 0 {
|
||||
0
|
||||
} else {
|
||||
@@ -263,6 +299,7 @@ impl<M: MutableMenu> MenuRunner<M> {
|
||||
mode: self.mode,
|
||||
selection_count: self.selection.count(),
|
||||
multi_enabled: self.selection.multi_enabled,
|
||||
columns,
|
||||
generation: self.generation,
|
||||
}
|
||||
}
|
||||
@@ -404,7 +441,9 @@ impl<M: MutableMenu> MenuRunner<M> {
|
||||
let items: Vec<(Value, usize)> = indices
|
||||
.into_iter()
|
||||
.filter_map(|orig_idx| {
|
||||
self.menu.serialize_original(orig_idx).map(|v| (v.clone(), orig_idx))
|
||||
self.menu
|
||||
.serialize_original(orig_idx)
|
||||
.map(|v| (v.clone(), orig_idx))
|
||||
})
|
||||
.collect();
|
||||
if items.is_empty() {
|
||||
@@ -572,8 +611,7 @@ impl<M: MutableMenu> MenuRunner<M> {
|
||||
return Ok(MenuResult::Selected { items });
|
||||
}
|
||||
ActionOutcome::Quicklist { items } => {
|
||||
let values: Vec<Value> =
|
||||
items.iter().map(|(v, _)| v.clone()).collect();
|
||||
let values: Vec<Value> = items.iter().map(|(v, _)| v.clone()).collect();
|
||||
let count = values.len();
|
||||
info!(count, "quicklist returned");
|
||||
self.emit_hook(HookEvent::Quicklist {
|
||||
@@ -582,9 +620,7 @@ impl<M: MutableMenu> MenuRunner<M> {
|
||||
});
|
||||
self.emit_hook(HookEvent::Close);
|
||||
|
||||
let _ = self
|
||||
.event_tx
|
||||
.send(MenuEvent::Quicklist(values));
|
||||
let _ = self.event_tx.send(MenuEvent::Quicklist(values));
|
||||
return Ok(MenuResult::Quicklist { items });
|
||||
}
|
||||
ActionOutcome::Cancelled => {
|
||||
@@ -673,7 +709,9 @@ mod tests {
|
||||
let mut m = ready_menu();
|
||||
m.apply_action(Action::MoveDown(1));
|
||||
let outcome = m.apply_action(Action::Confirm);
|
||||
assert!(matches!(&outcome, ActionOutcome::Selected { items } if items[0].0.as_str() == Some("beta")));
|
||||
assert!(
|
||||
matches!(&outcome, ActionOutcome::Selected { items } if items[0].0.as_str() == Some("beta"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -859,10 +897,14 @@ mod tests {
|
||||
let _ = tx.send(Action::Confirm).await;
|
||||
|
||||
let event = rx.recv().await;
|
||||
assert!(matches!(&event, Ok(MenuEvent::Selected(items)) if items[0].0.as_str() == Some("alpha")));
|
||||
assert!(
|
||||
matches!(&event, Ok(MenuEvent::Selected(items)) if items[0].0.as_str() == Some("alpha"))
|
||||
);
|
||||
|
||||
let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled));
|
||||
assert!(matches!(result, Ok(MenuResult::Selected { ref items, .. }) if items[0].0.as_str() == Some("alpha")));
|
||||
assert!(
|
||||
matches!(result, Ok(MenuResult::Selected { ref items, .. }) if items[0].0.as_str() == Some("alpha"))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -884,7 +926,9 @@ mod tests {
|
||||
let _ = tx.send(Action::Confirm).await;
|
||||
|
||||
let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled));
|
||||
assert!(matches!(result, Ok(MenuResult::Selected { ref items, .. }) if items[0].0.as_str() == Some("gamma")));
|
||||
assert!(
|
||||
matches!(result, Ok(MenuResult::Selected { ref items, .. }) if items[0].0.as_str() == Some("gamma"))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -909,7 +953,9 @@ mod tests {
|
||||
let _ = tx.send(Action::Confirm).await;
|
||||
|
||||
let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled));
|
||||
assert!(matches!(result, Ok(MenuResult::Selected { ref items, .. }) if items[0].0.as_str() == Some("delta")));
|
||||
assert!(
|
||||
matches!(result, Ok(MenuResult::Selected { ref items, .. }) if items[0].0.as_str() == Some("delta"))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -944,7 +990,9 @@ mod tests {
|
||||
let _ = tx.send(Action::Confirm).await;
|
||||
|
||||
let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled));
|
||||
assert!(matches!(result, Ok(MenuResult::Selected { ref items, .. }) if items[0].0.as_str() == Some("epsilon")));
|
||||
assert!(
|
||||
matches!(result, Ok(MenuResult::Selected { ref items, .. }) if items[0].0.as_str() == Some("epsilon"))
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
@@ -1211,10 +1259,7 @@ mod tests {
|
||||
match outcome {
|
||||
ActionOutcome::Quicklist { items } => {
|
||||
assert_eq!(items.len(), 4);
|
||||
let labels: Vec<&str> = items
|
||||
.iter()
|
||||
.filter_map(|(v, _)| v.as_str())
|
||||
.collect();
|
||||
let labels: Vec<&str> = items.iter().filter_map(|(v, _)| v.as_str()).collect();
|
||||
assert_eq!(labels, vec!["alpha", "beta", "gamma", "delta"]);
|
||||
}
|
||||
other => panic!("expected Quicklist, got {other:?}"),
|
||||
@@ -1661,4 +1706,103 @@ mod tests {
|
||||
assert!(vs.visible_items[0].selected);
|
||||
assert!(!vs.visible_items[1].selected);
|
||||
}
|
||||
|
||||
// -- Column / table mode tests --
|
||||
|
||||
use crate::model::column::ColumnConfig;
|
||||
|
||||
fn table_menu() -> MenuRunner<JsonMenu> {
|
||||
let items = vec![
|
||||
Item::new(
|
||||
serde_json::json!({"label": "alice", "meta": {"age": "30", "city": "toronto"}}),
|
||||
"label",
|
||||
),
|
||||
Item::new(
|
||||
serde_json::json!({"label": "bob", "meta": {"age": "25", "city": "vancouver"}}),
|
||||
"label",
|
||||
),
|
||||
Item::new(
|
||||
serde_json::json!({"label": "charlie", "meta": {"age": "35", "city": "montreal"}}),
|
||||
"label",
|
||||
),
|
||||
];
|
||||
let mut json_menu = JsonMenu::new(items, "label".to_string());
|
||||
json_menu.set_column_config(ColumnConfig::parse("label,meta.age:Age,meta.city:City"));
|
||||
let (runner, _tx) = MenuRunner::new(json_menu);
|
||||
runner
|
||||
}
|
||||
|
||||
fn ready_table_menu() -> MenuRunner<JsonMenu> {
|
||||
let mut m = table_menu();
|
||||
m.run_filter();
|
||||
m.apply_action(Action::Resize { height: 10 });
|
||||
m
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn view_state_has_columns_when_config_set() {
|
||||
let mut m = ready_table_menu();
|
||||
let vs = m.build_view_state();
|
||||
assert!(vs.columns.is_some(), "columns should be Some");
|
||||
let cols = vs.columns.as_ref().unwrap();
|
||||
assert_eq!(cols.len(), 3);
|
||||
assert_eq!(cols[0].display_name, "label");
|
||||
assert_eq!(cols[1].display_name, "Age");
|
||||
assert_eq!(cols[2].display_name, "City");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn view_state_column_widths_computed() {
|
||||
let mut m = ready_table_menu();
|
||||
let vs = m.build_view_state();
|
||||
let cols = vs.columns.as_ref().unwrap();
|
||||
// "label" header is 5 chars, "charlie" is 7 chars: width should be 7
|
||||
assert_eq!(cols[0].width, 7);
|
||||
// "Age" header is 3 chars, max cell "35" is 2 chars: width should be 3
|
||||
assert_eq!(cols[1].width, 3);
|
||||
// "City" header is 4 chars, "vancouver" is 9 chars: width should be 9
|
||||
assert_eq!(cols[2].width, 9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn view_state_items_have_column_values() {
|
||||
let mut m = ready_table_menu();
|
||||
let vs = m.build_view_state();
|
||||
let item = &vs.visible_items[0];
|
||||
assert!(item.column_values.is_some());
|
||||
let vals = item.column_values.as_ref().unwrap();
|
||||
assert_eq!(vals, &["alice", "30", "toronto"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn view_state_no_columns_without_config() {
|
||||
let mut m = ready_menu();
|
||||
let vs = m.build_view_state();
|
||||
assert!(vs.columns.is_none());
|
||||
assert!(vs.visible_items[0].column_values.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_mode_filter_still_works() {
|
||||
let mut m = ready_table_menu();
|
||||
m.apply_action(Action::UpdateFilter("bob".to_string()));
|
||||
let vs = m.build_view_state();
|
||||
assert_eq!(vs.total_filtered, 1);
|
||||
assert_eq!(vs.visible_items[0].label, "bob");
|
||||
// Column values should still be present
|
||||
let vals = vs.visible_items[0].column_values.as_ref().unwrap();
|
||||
assert_eq!(vals, &["bob", "25", "vancouver"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_mode_column_widths_change_with_filter() {
|
||||
let mut m = ready_table_menu();
|
||||
m.apply_action(Action::UpdateFilter("alice".to_string()));
|
||||
let vs = m.build_view_state();
|
||||
let cols = vs.columns.as_ref().unwrap();
|
||||
// Only "alice" visible now, so label width = max(5, 5) = 5
|
||||
assert_eq!(cols[0].width, 5);
|
||||
// City "toronto" (7) vs header "City" (4): width = 7
|
||||
assert_eq!(cols[2].width, 7);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user