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

@@ -17,6 +17,7 @@ tracing = "0.1"
nucleo-matcher = "0.3.1"
fancy-regex = "0.14"
indexmap = "2"
csv = "1"
[dev-dependencies]
tokio = { version = "1.50.0", features = ["sync", "process", "io-util", "rt", "macros", "rt-multi-thread", "test-util"] }

View File

@@ -20,6 +20,9 @@ pub enum PiklError {
#[error("channel closed")]
ChannelClosed,
#[error("csv parse error: {0}")]
CsvParse(String),
#[error("{0}")]
Script(#[from] crate::script::action_fd::ScriptError),
}

View File

@@ -12,6 +12,7 @@ pub mod script;
pub mod error;
// Re-export submodules at crate root so the public API stays flat.
pub use model::column;
pub use model::event;
pub use model::item;
pub use model::output;
@@ -22,6 +23,7 @@ pub use query::navigation;
pub use query::pipeline;
pub use query::regex_filter;
pub use query::strategy;
pub use runtime::csv_input;
pub use runtime::debounce;
pub use runtime::hook;
pub use runtime::input;

View File

@@ -0,0 +1,155 @@
//! Column configuration for table mode. Defines which fields
//! to display as columns and how to label them.
use crate::item::resolve_field_path;
use serde_json::Value;
/// A single column definition: which field to extract and
/// what to call it in the header.
#[derive(Debug, Clone)]
pub struct ColumnDef {
/// Dotted path into the item's JSON value (e.g. "meta.res").
pub field: String,
/// Display name for the column header. Defaults to the
/// field path if no alias is given.
pub display_name: String,
}
/// Column layout for table mode. Controls which fields are
/// shown and in what order.
#[derive(Debug, Clone)]
pub struct ColumnConfig {
pub columns: Vec<ColumnDef>,
}
impl ColumnConfig {
/// Parse a column spec string like "label,meta.res:Resolution,meta.size".
/// Columns are comma-separated. Each column can have an alias
/// after the first colon: "field:Alias".
pub fn parse(spec: &str) -> Self {
let columns = spec
.split(',')
.filter(|s| !s.is_empty())
.map(|part| {
let part = part.trim();
if let Some((field, alias)) = part.split_once(':') {
ColumnDef {
field: field.trim().to_string(),
display_name: alias.trim().to_string(),
}
} else {
ColumnDef {
field: part.to_string(),
display_name: part.to_string(),
}
}
})
.collect();
Self { columns }
}
/// Build a ColumnConfig from raw header names. Used when
/// auto-generating columns from CSV headers.
pub fn from_headers(headers: &[String]) -> Self {
let columns = headers
.iter()
.map(|h| ColumnDef {
field: h.clone(),
display_name: h.clone(),
})
.collect();
Self { columns }
}
/// Resolve each column's value from an item's JSON value.
/// Missing fields produce an empty string.
pub fn resolve_values(&self, value: &Value) -> Vec<String> {
self.columns
.iter()
.map(|col| {
resolve_field_path(value, &col.field)
.map(value_to_cell)
.unwrap_or_default()
})
.collect()
}
}
/// Convert a JSON value to a cell string for display.
fn value_to_cell(v: &Value) -> String {
match v {
Value::String(s) => s.clone(),
Value::Number(n) => n.to_string(),
Value::Bool(b) => b.to_string(),
Value::Null => String::new(),
other => other.to_string(),
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn parse_simple_columns() {
let config = ColumnConfig::parse("label,age,city");
assert_eq!(config.columns.len(), 3);
assert_eq!(config.columns[0].field, "label");
assert_eq!(config.columns[0].display_name, "label");
assert_eq!(config.columns[2].field, "city");
}
#[test]
fn parse_with_alias() {
let config = ColumnConfig::parse("label,meta.res:Resolution");
assert_eq!(config.columns.len(), 2);
assert_eq!(config.columns[1].field, "meta.res");
assert_eq!(config.columns[1].display_name, "Resolution");
}
#[test]
fn parse_empty_string() {
let config = ColumnConfig::parse("");
assert!(config.columns.is_empty());
}
#[test]
fn parse_trailing_comma() {
let config = ColumnConfig::parse("label,age,");
assert_eq!(config.columns.len(), 2);
}
#[test]
fn resolve_flat_values() {
let config = ColumnConfig::parse("label,age");
let value = json!({"label": "alice", "age": 30});
let cells = config.resolve_values(&value);
assert_eq!(cells, vec!["alice", "30"]);
}
#[test]
fn resolve_nested_values() {
let config = ColumnConfig::parse("label,meta.res");
let value = json!({"label": "test", "meta": {"res": "1080p"}});
let cells = config.resolve_values(&value);
assert_eq!(cells, vec!["test", "1080p"]);
}
#[test]
fn resolve_missing_field() {
let config = ColumnConfig::parse("label,nope");
let value = json!({"label": "test"});
let cells = config.resolve_values(&value);
assert_eq!(cells, vec!["test", ""]);
}
#[test]
fn from_headers_builds_identity() {
let headers = vec!["name".to_string(), "age".to_string()];
let config = ColumnConfig::from_headers(&headers);
assert_eq!(config.columns.len(), 2);
assert_eq!(config.columns[0].field, "name");
assert_eq!(config.columns[0].display_name, "name");
}
}

View File

@@ -81,12 +81,24 @@ pub struct ViewState {
pub mode: Mode,
pub selection_count: usize,
pub multi_enabled: bool,
/// Column headers for table mode. None when not in
/// table mode.
pub columns: Option<Vec<ColumnHeader>>,
/// Monotonically increasing counter. Each call to
/// `build_view_state()` bumps this, so frontends can
/// detect duplicate broadcasts and skip redundant redraws.
pub generation: u64,
}
/// Column header with display name and computed width for
/// table mode rendering.
#[must_use]
#[derive(Debug, Clone)]
pub struct ColumnHeader {
pub display_name: String,
pub width: u16,
}
/// A single item in the current viewport window. Has the
/// display label pre-resolved and the position in the
/// filtered list.
@@ -97,6 +109,9 @@ pub struct VisibleItem {
pub formatted_text: Option<String>,
pub index: usize,
pub selected: bool,
/// Column cell values for table mode. None when not in
/// table mode.
pub column_values: Option<Vec<String>>,
}
/// Final outcome of [`crate::menu::MenuRunner::run`]. The

View File

@@ -225,28 +225,19 @@ mod tests {
#[test]
fn icon_from_object() {
let item = Item::new(
json!({"label": "Firefox", "icon": "firefox.png"}),
"label",
);
let item = Item::new(json!({"label": "Firefox", "icon": "firefox.png"}), "label");
assert_eq!(item.icon(), Some("firefox.png"));
}
#[test]
fn group_from_object() {
let item = Item::new(
json!({"label": "Firefox", "group": "browsers"}),
"label",
);
let item = Item::new(json!({"label": "Firefox", "group": "browsers"}), "label");
assert_eq!(item.group(), Some("browsers"));
}
#[test]
fn meta_from_object() {
let item = Item::new(
json!({"label": "test", "meta": {"res": 1080}}),
"label",
);
let item = Item::new(json!({"label": "test", "meta": {"res": 1080}}), "label");
let meta = item.meta();
assert!(meta.is_some());
assert_eq!(meta.and_then(|m| m.get("res")), Some(&json!(1080)));

View File

@@ -1,3 +1,4 @@
pub mod column;
pub mod event;
pub mod item;
pub mod output;

View File

@@ -2,8 +2,8 @@
//! item value with action context and original index so
//! hooks and downstream tools know what happened.
use serde::ser::SerializeMap;
use serde::Serialize;
use serde::ser::SerializeMap;
use serde_json::Value;
/// What the user did to produce this output.

View File

@@ -66,6 +66,17 @@ pub trait Menu: Send + 'static {
})
.collect()
}
/// Get the column values for a filtered item, if table
/// mode is active. Returns None when no column config is set.
fn column_values(&self, _filtered_index: usize) -> Option<Vec<String>> {
None
}
/// Get the column config, if one is set.
fn column_config(&self) -> Option<&crate::model::column::ColumnConfig> {
None
}
}
/// Extension of [`Menu`] with mutation methods. Required by

View File

@@ -170,12 +170,7 @@ impl FilterPipeline {
})
}
FilterKind::Field { path } => {
Self::eval_field(
stage,
&input_indices,
&self.item_values,
path,
)
Self::eval_field(stage, &input_indices, &self.item_values, path)
}
};

View File

@@ -10,7 +10,9 @@ pub enum FilterKind {
/// Field-specific filter: `meta.res:3840` matches
/// items where the dotted path resolves to a value
/// containing the query text.
Field { path: String },
Field {
path: String,
},
}
/// A parsed filter segment with its kind, inversion flag,
@@ -86,7 +88,9 @@ pub fn parse_segment(segment: &str) -> ParsedSegment<'_> {
&& let Some((path, value)) = try_parse_field_filter(rest)
{
return ParsedSegment {
kind: FilterKind::Field { path: path.to_string() },
kind: FilterKind::Field {
path: path.to_string(),
},
inverse: true,
query: value,
};
@@ -95,7 +99,9 @@ pub fn parse_segment(segment: &str) -> ParsedSegment<'_> {
// Check for field filter: field.path:value
if let Some((path, value)) = try_parse_field_filter(segment) {
return ParsedSegment {
kind: FilterKind::Field { path: path.to_string() },
kind: FilterKind::Field {
path: path.to_string(),
},
inverse: false,
query: value,
};
@@ -150,7 +156,10 @@ fn try_parse_field_filter(segment: &str) -> Option<(&str, &str)> {
return None;
}
// Path chars must be alphanumeric, underscore, or dot
if !path.chars().all(|c| c.is_alphanumeric() || c == '_' || c == '.') {
if !path
.chars()
.all(|c| c.is_alphanumeric() || c == '_' || c == '.')
{
return None;
}

View File

@@ -0,0 +1,208 @@
//! CSV/TSV input parser. Reads delimited data into [`Item`]s
//! with structured JSON values. First row is always headers.
use std::io::Read;
use serde_json::{Map, Value};
use crate::error::PiklError;
use crate::item::Item;
/// Input format for stdin parsing.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum InputFormat {
/// Auto-detect: try JSON lines first, fall back to plain text.
Auto,
/// Comma-separated values.
Csv,
/// Tab-separated values.
Tsv,
}
impl InputFormat {
/// Parse from a CLI string. Returns None for unknown formats.
pub fn from_str_opt(s: &str) -> Option<Self> {
match s {
"auto" => Some(Self::Auto),
"csv" => Some(Self::Csv),
"tsv" => Some(Self::Tsv),
_ => None,
}
}
fn delimiter(self) -> u8 {
match self {
Self::Tsv => b'\t',
_ => b',',
}
}
}
/// Result of parsing CSV/TSV input.
pub struct CsvParseResult {
pub items: Vec<Item>,
pub headers: Vec<String>,
}
/// Known top-level fields that get promoted out of meta.
const KNOWN_FIELDS: &[&str] = &["label", "sublabel", "icon", "group"];
/// Read CSV or TSV from a reader, producing structured Items.
///
/// Each row becomes a JSON object. The `label_key` controls
/// which column becomes the display label. If no column matches
/// `label_key`, the first column is used as `label`.
///
/// Known fields (label, sublabel, icon, group) are promoted to
/// the top level. Everything else goes into a `meta` object.
pub fn read_csv_items_sync(
reader: impl Read,
format: InputFormat,
label_key: &str,
) -> Result<CsvParseResult, PiklError> {
let mut csv_reader = csv::ReaderBuilder::new()
.delimiter(format.delimiter())
.has_headers(true)
.from_reader(reader);
let raw_headers = csv_reader
.headers()
.map_err(|e| PiklError::CsvParse(e.to_string()))?
.clone();
if raw_headers.is_empty() {
return Err(PiklError::CsvParse("no headers found".to_string()));
}
let headers: Vec<String> = raw_headers.iter().map(|h| h.to_string()).collect();
// Figure out which column is the label. If label_key matches
// a header, use that. Otherwise first column becomes "label".
let has_label_column = headers.iter().any(|h| h == label_key);
let mut items = Vec::new();
for result in csv_reader.records() {
let record = result.map_err(|e| PiklError::CsvParse(e.to_string()))?;
let mut top = Map::new();
let mut meta = Map::new();
for (i, field_value) in record.iter().enumerate() {
let Some(header) = headers.get(i) else {
continue;
};
if !has_label_column && i == 0 {
// First column becomes label when no explicit label column exists
top.insert("label".to_string(), Value::String(field_value.to_string()));
// Also keep it in meta under its original header name
meta.insert(header.clone(), Value::String(field_value.to_string()));
} else if KNOWN_FIELDS.contains(&header.as_str()) {
top.insert(header.clone(), Value::String(field_value.to_string()));
} else {
meta.insert(header.clone(), Value::String(field_value.to_string()));
}
}
if !meta.is_empty() {
top.insert("meta".to_string(), Value::Object(meta));
}
let value = Value::Object(top);
items.push(Item::new(value, label_key));
}
Ok(CsvParseResult { items, headers })
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
use std::io::Cursor;
#[test]
fn basic_csv() {
let data = "name,age,city\nalice,30,toronto\nbob,25,vancouver\n";
let result = read_csv_items_sync(Cursor::new(data), InputFormat::Csv, "label").unwrap();
assert_eq!(result.items.len(), 2);
assert_eq!(result.headers, vec!["name", "age", "city"]);
// First column becomes label when no "label" header
assert_eq!(result.items[0].label(), "alice");
assert_eq!(result.items[1].label(), "bob");
}
#[test]
fn csv_with_label_header() {
let data = "label,age\nalice,30\nbob,25\n";
let result = read_csv_items_sync(Cursor::new(data), InputFormat::Csv, "label").unwrap();
assert_eq!(result.items[0].label(), "alice");
// "age" should be in meta
assert_eq!(result.items[0].field_value("meta.age"), Some(&json!("30")));
}
#[test]
fn tsv_input() {
let data = "name\tage\nalice\t30\nbob\t25\n";
let result = read_csv_items_sync(Cursor::new(data), InputFormat::Tsv, "label").unwrap();
assert_eq!(result.items.len(), 2);
assert_eq!(result.items[0].label(), "alice");
}
#[test]
fn known_fields_promoted() {
let data = "label,sublabel,icon,group,extra\nfoo,bar,ic.png,grp,val\n";
let result = read_csv_items_sync(Cursor::new(data), InputFormat::Csv, "label").unwrap();
let item = &result.items[0];
assert_eq!(item.label(), "foo");
assert_eq!(item.sublabel(), Some("bar"));
assert_eq!(item.icon(), Some("ic.png"));
assert_eq!(item.group(), Some("grp"));
assert_eq!(item.field_value("meta.extra"), Some(&json!("val")));
}
#[test]
fn first_column_label_also_in_meta() {
let data = "name,age\nalice,30\n";
let result = read_csv_items_sync(Cursor::new(data), InputFormat::Csv, "label").unwrap();
// First column promoted to label
assert_eq!(result.items[0].label(), "alice");
// Original header name also in meta
assert_eq!(
result.items[0].field_value("meta.name"),
Some(&json!("alice"))
);
}
#[test]
fn quoted_fields() {
let data = "name,bio\nalice,\"likes, commas\"\n";
let result = read_csv_items_sync(Cursor::new(data), InputFormat::Csv, "label").unwrap();
assert_eq!(
result.items[0].field_value("meta.bio"),
Some(&json!("likes, commas"))
);
}
#[test]
fn empty_input_errors() {
let data = "";
let result = read_csv_items_sync(Cursor::new(data), InputFormat::Csv, "label");
assert!(result.is_err());
}
#[test]
fn headers_only_no_rows() {
let data = "name,age\n";
let result = read_csv_items_sync(Cursor::new(data), InputFormat::Csv, "label").unwrap();
assert!(result.items.is_empty());
assert_eq!(result.headers, vec!["name", "age"]);
}
#[test]
fn input_format_from_str() {
assert_eq!(InputFormat::from_str_opt("csv"), Some(InputFormat::Csv));
assert_eq!(InputFormat::from_str_opt("tsv"), Some(InputFormat::Tsv));
assert_eq!(InputFormat::from_str_opt("auto"), Some(InputFormat::Auto));
assert_eq!(InputFormat::from_str_opt("xml"), None);
}
}

View File

@@ -36,10 +36,7 @@ pub struct DebouncedDispatcher {
}
impl DebouncedDispatcher {
pub fn new(
handler: Arc<dyn HookHandler>,
action_tx: mpsc::Sender<Action>,
) -> Self {
pub fn new(handler: Arc<dyn HookHandler>, action_tx: mpsc::Sender<Action>) -> Self {
Self {
handler,
_action_tx: action_tx,
@@ -71,11 +68,7 @@ impl DebouncedDispatcher {
/// Dispatch a hook event through the debounce system.
pub fn dispatch(&mut self, event: HookEvent) {
let kind = event.kind();
let mode = self
.modes
.get(&kind)
.cloned()
.unwrap_or(DebounceMode::None);
let mode = self.modes.get(&kind).cloned().unwrap_or(DebounceMode::None);
tracing::debug!(event_kind = ?kind, mode = ?mode, "dispatching hook event");

View File

@@ -188,9 +188,8 @@ mod tests {
#[test]
fn response_add_items() {
let json = r#"{"action": "add_items", "items": [{"label": "new"}]}"#;
let resp: HookResponse = serde_json::from_str(json).unwrap_or_else(|e| {
std::unreachable!("parse failed: {e}")
});
let resp: HookResponse =
serde_json::from_str(json).unwrap_or_else(|e| std::unreachable!("parse failed: {e}"));
assert_eq!(
resp,
HookResponse::AddItems {
@@ -202,9 +201,8 @@ mod tests {
#[test]
fn response_replace_items() {
let json = r#"{"action": "replace_items", "items": ["a", "b"]}"#;
let resp: HookResponse = serde_json::from_str(json).unwrap_or_else(|e| {
std::unreachable!("parse failed: {e}")
});
let resp: HookResponse =
serde_json::from_str(json).unwrap_or_else(|e| std::unreachable!("parse failed: {e}"));
assert_eq!(
resp,
HookResponse::ReplaceItems {
@@ -216,9 +214,8 @@ mod tests {
#[test]
fn response_remove_items() {
let json = r#"{"action": "remove_items", "indices": [0, 3, 5]}"#;
let resp: HookResponse = serde_json::from_str(json).unwrap_or_else(|e| {
std::unreachable!("parse failed: {e}")
});
let resp: HookResponse =
serde_json::from_str(json).unwrap_or_else(|e| std::unreachable!("parse failed: {e}"));
assert_eq!(
resp,
HookResponse::RemoveItems {
@@ -230,9 +227,8 @@ mod tests {
#[test]
fn response_set_filter() {
let json = r#"{"action": "set_filter", "text": "hello"}"#;
let resp: HookResponse = serde_json::from_str(json).unwrap_or_else(|e| {
std::unreachable!("parse failed: {e}")
});
let resp: HookResponse =
serde_json::from_str(json).unwrap_or_else(|e| std::unreachable!("parse failed: {e}"));
assert_eq!(
resp,
HookResponse::SetFilter {
@@ -244,9 +240,8 @@ mod tests {
#[test]
fn response_close() {
let json = r#"{"action": "close"}"#;
let resp: HookResponse = serde_json::from_str(json).unwrap_or_else(|e| {
std::unreachable!("parse failed: {e}")
});
let resp: HookResponse =
serde_json::from_str(json).unwrap_or_else(|e| std::unreachable!("parse failed: {e}"));
assert_eq!(resp, HookResponse::Close);
}

View File

@@ -5,6 +5,7 @@
use crate::filter::Filter;
use crate::format::FormatTemplate;
use crate::item::Item;
use crate::model::column::ColumnConfig;
use crate::model::traits::{Menu, MutableMenu};
use crate::pipeline::FilterPipeline;
@@ -19,6 +20,7 @@ pub struct JsonMenu {
filter: FilterPipeline,
filter_fields: Vec<String>,
format_template: Option<FormatTemplate>,
column_config: Option<ColumnConfig>,
}
impl JsonMenu {
@@ -36,6 +38,7 @@ impl JsonMenu {
filter,
filter_fields: vec!["label".to_string()],
format_template: None,
column_config: None,
}
}
@@ -52,6 +55,11 @@ impl JsonMenu {
self.format_template = Some(template);
}
/// Set the column config for table mode rendering.
pub fn set_column_config(&mut self, config: ColumnConfig) {
self.column_config = Some(config);
}
/// Rebuild the filter pipeline from scratch. Called after
/// filter_fields change or item mutations.
fn rebuild_pipeline(&mut self) {
@@ -141,6 +149,16 @@ impl Menu for JsonMenu {
let orig_idx = self.filter.matched_index(filtered_index)?;
Some(template.render(&self.items[orig_idx].value))
}
fn column_values(&self, filtered_index: usize) -> Option<Vec<String>> {
let config = self.column_config.as_ref()?;
let orig_idx = self.filter.matched_index(filtered_index)?;
Some(config.resolve_values(&self.items[orig_idx].value))
}
fn column_config(&self) -> Option<&ColumnConfig> {
self.column_config.as_ref()
}
}
impl MutableMenu for JsonMenu {
@@ -177,7 +195,11 @@ impl MutableMenu for JsonMenu {
self.items.remove(idx);
}
}
tracing::debug!(count, remaining = self.items.len(), "items removed from menu");
tracing::debug!(
count,
remaining = self.items.len(),
"items removed from menu"
);
self.rebuild_pipeline();
}
}

View File

@@ -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);
}
}

View File

@@ -1,3 +1,4 @@
pub mod csv_input;
pub mod debounce;
pub mod hook;
pub mod input;