diff --git a/Cargo.lock b/Cargo.lock index 9effbe2..5306d90 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -310,6 +310,27 @@ dependencies = [ "phf", ] +[[package]] +name = "csv" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" +dependencies = [ + "csv-core", + "itoa", + "ryu", + "serde_core", +] + +[[package]] +name = "csv-core" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" +dependencies = [ + "memchr", +] + [[package]] name = "darling" version = "0.23.0" @@ -1089,6 +1110,7 @@ dependencies = [ name = "pikl-core" version = "0.1.0" dependencies = [ + "csv", "fancy-regex 0.14.0", "indexmap", "nucleo-matcher", diff --git a/crates/pikl-core/Cargo.toml b/crates/pikl-core/Cargo.toml index 944a3a6..46a6ddd 100644 --- a/crates/pikl-core/Cargo.toml +++ b/crates/pikl-core/Cargo.toml @@ -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"] } diff --git a/crates/pikl-core/src/error.rs b/crates/pikl-core/src/error.rs index c1d3a81..72b6611 100644 --- a/crates/pikl-core/src/error.rs +++ b/crates/pikl-core/src/error.rs @@ -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), } diff --git a/crates/pikl-core/src/lib.rs b/crates/pikl-core/src/lib.rs index e5f3499..92bb145 100644 --- a/crates/pikl-core/src/lib.rs +++ b/crates/pikl-core/src/lib.rs @@ -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; diff --git a/crates/pikl-core/src/model/column.rs b/crates/pikl-core/src/model/column.rs new file mode 100644 index 0000000..3750dac --- /dev/null +++ b/crates/pikl-core/src/model/column.rs @@ -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, +} + +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 { + 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"); + } +} diff --git a/crates/pikl-core/src/model/event.rs b/crates/pikl-core/src/model/event.rs index 573ba0c..1164168 100644 --- a/crates/pikl-core/src/model/event.rs +++ b/crates/pikl-core/src/model/event.rs @@ -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>, /// 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, pub index: usize, pub selected: bool, + /// Column cell values for table mode. None when not in + /// table mode. + pub column_values: Option>, } /// Final outcome of [`crate::menu::MenuRunner::run`]. The diff --git a/crates/pikl-core/src/model/item.rs b/crates/pikl-core/src/model/item.rs index a9acda3..24ddad6 100644 --- a/crates/pikl-core/src/model/item.rs +++ b/crates/pikl-core/src/model/item.rs @@ -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))); diff --git a/crates/pikl-core/src/model/mod.rs b/crates/pikl-core/src/model/mod.rs index 186085c..22e40a2 100644 --- a/crates/pikl-core/src/model/mod.rs +++ b/crates/pikl-core/src/model/mod.rs @@ -1,3 +1,4 @@ +pub mod column; pub mod event; pub mod item; pub mod output; diff --git a/crates/pikl-core/src/model/output.rs b/crates/pikl-core/src/model/output.rs index e06d39d..09dad29 100644 --- a/crates/pikl-core/src/model/output.rs +++ b/crates/pikl-core/src/model/output.rs @@ -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. diff --git a/crates/pikl-core/src/model/traits.rs b/crates/pikl-core/src/model/traits.rs index 076a386..16f07c4 100644 --- a/crates/pikl-core/src/model/traits.rs +++ b/crates/pikl-core/src/model/traits.rs @@ -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> { + 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 diff --git a/crates/pikl-core/src/query/pipeline.rs b/crates/pikl-core/src/query/pipeline.rs index 9c6bc3e..9b41673 100644 --- a/crates/pikl-core/src/query/pipeline.rs +++ b/crates/pikl-core/src/query/pipeline.rs @@ -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) } }; diff --git a/crates/pikl-core/src/query/strategy.rs b/crates/pikl-core/src/query/strategy.rs index bbc3c0e..2c91f00 100644 --- a/crates/pikl-core/src/query/strategy.rs +++ b/crates/pikl-core/src/query/strategy.rs @@ -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; } diff --git a/crates/pikl-core/src/runtime/csv_input.rs b/crates/pikl-core/src/runtime/csv_input.rs new file mode 100644 index 0000000..13204ab --- /dev/null +++ b/crates/pikl-core/src/runtime/csv_input.rs @@ -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 { + 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, + pub headers: Vec, +} + +/// 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 { + 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 = 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); + } +} diff --git a/crates/pikl-core/src/runtime/debounce.rs b/crates/pikl-core/src/runtime/debounce.rs index 5b5b1df..412ac26 100644 --- a/crates/pikl-core/src/runtime/debounce.rs +++ b/crates/pikl-core/src/runtime/debounce.rs @@ -36,10 +36,7 @@ pub struct DebouncedDispatcher { } impl DebouncedDispatcher { - pub fn new( - handler: Arc, - action_tx: mpsc::Sender, - ) -> Self { + pub fn new(handler: Arc, action_tx: mpsc::Sender) -> 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"); diff --git a/crates/pikl-core/src/runtime/hook.rs b/crates/pikl-core/src/runtime/hook.rs index eb3135e..ad348a9 100644 --- a/crates/pikl-core/src/runtime/hook.rs +++ b/crates/pikl-core/src/runtime/hook.rs @@ -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); } diff --git a/crates/pikl-core/src/runtime/json_menu.rs b/crates/pikl-core/src/runtime/json_menu.rs index 2ea3750..07253a4 100644 --- a/crates/pikl-core/src/runtime/json_menu.rs +++ b/crates/pikl-core/src/runtime/json_menu.rs @@ -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, format_template: Option, + column_config: Option, } 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> { + 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(); } } diff --git a/crates/pikl-core/src/runtime/menu.rs b/crates/pikl-core/src/runtime/menu.rs index e8c6e1e..5559834 100644 --- a/crates/pikl-core/src/runtime/menu.rs +++ b/crates/pikl-core/src/runtime/menu.rs @@ -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 MenuRunner { 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 = 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 MenuRunner { mode: self.mode, selection_count: self.selection.count(), multi_enabled: self.selection.multi_enabled, + columns, generation: self.generation, } } @@ -404,7 +441,9 @@ impl MenuRunner { 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 MenuRunner { return Ok(MenuResult::Selected { items }); } ActionOutcome::Quicklist { items } => { - let values: Vec = - items.iter().map(|(v, _)| v.clone()).collect(); + let values: Vec = 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 MenuRunner { }); 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 { + 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 { + 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); + } } diff --git a/crates/pikl-core/src/runtime/mod.rs b/crates/pikl-core/src/runtime/mod.rs index 018cae4..93fbe24 100644 --- a/crates/pikl-core/src/runtime/mod.rs +++ b/crates/pikl-core/src/runtime/mod.rs @@ -1,3 +1,4 @@ +pub mod csv_input; pub mod debounce; pub mod hook; pub mod input; diff --git a/crates/pikl-tui/src/lib.rs b/crates/pikl-tui/src/lib.rs index dae39c6..f826624 100644 --- a/crates/pikl-tui/src/lib.rs +++ b/crates/pikl-tui/src/lib.rs @@ -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) { +fn render_menu( + frame: &mut ratatui::Frame, + vs: &ViewState, + filter_text: &str, + visual_anchor: Option, +) { + 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 = 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!("{: = 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!( + "{: 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(); diff --git a/crates/pikl/src/handler.rs b/crates/pikl/src/handler.rs index 8b45ebf..163e53d 100644 --- a/crates/pikl/src/handler.rs +++ b/crates/pikl/src/handler.rs @@ -12,9 +12,7 @@ use tokio::sync::mpsc; use pikl_core::error::PiklError; use pikl_core::event::Action; -use pikl_core::hook::{ - parse_hook_response, HookEvent, HookEventKind, HookHandler, -}; +use pikl_core::hook::{HookEvent, HookEventKind, HookHandler, parse_hook_response}; /// A persistent handler hook process. Spawns a child process, /// writes events to its stdin as JSON lines, reads responses @@ -27,10 +25,7 @@ pub struct ShellHandlerHook { impl ShellHandlerHook { /// Build from CLI flags. Returns None if no handler hooks are configured. - pub fn from_cli( - cli: &crate::Cli, - action_tx: mpsc::Sender, - ) -> Option { + pub fn from_cli(cli: &crate::Cli, action_tx: mpsc::Sender) -> Option { let mut handlers: Vec<(HookEventKind, &str)> = Vec::new(); if let Some(ref cmd) = cli.on_open { @@ -134,18 +129,10 @@ async fn run_handler_process( // Writer loop: reads events from channel, writes to stdin while let Some(event) = event_rx.recv().await { let json = serde_json::to_string(&event).unwrap_or_default(); - if stdin_writer - .write_all(json.as_bytes()) - .await - .is_err() - { + if stdin_writer.write_all(json.as_bytes()).await.is_err() { break; } - if stdin_writer - .write_all(b"\n") - .await - .is_err() - { + if stdin_writer.write_all(b"\n").await.is_err() { break; } if stdin_writer.flush().await.is_err() { @@ -157,12 +144,9 @@ async fn run_handler_process( drop(stdin_writer); // Wait up to 1s for child to exit after EOF - let exited = tokio::time::timeout( - std::time::Duration::from_secs(1), - child.wait(), - ) - .await - .is_ok(); + let exited = tokio::time::timeout(std::time::Duration::from_secs(1), child.wait()) + .await + .is_ok(); if !exited { // Send SIGTERM on Unix, then wait another second @@ -177,12 +161,9 @@ async fn run_handler_process( libc::kill(pid as libc::pid_t, libc::SIGTERM); } } - let termed = tokio::time::timeout( - std::time::Duration::from_secs(1), - child.wait(), - ) - .await - .is_ok(); + let termed = tokio::time::timeout(std::time::Duration::from_secs(1), child.wait()) + .await + .is_ok(); if !termed { let _ = child.kill().await; } @@ -194,11 +175,7 @@ async fn run_handler_process( } // Wait for the reader to finish - let _ = tokio::time::timeout( - std::time::Duration::from_secs(2), - reader_handle, - ) - .await; + let _ = tokio::time::timeout(std::time::Duration::from_secs(2), reader_handle).await; Ok(()) } diff --git a/crates/pikl/src/hook.rs b/crates/pikl/src/hook.rs index ea26390..75987fe 100644 --- a/crates/pikl/src/hook.rs +++ b/crates/pikl/src/hook.rs @@ -121,8 +121,7 @@ impl HookHandler for ShellExecHandler { if let Some(cmd) = self.commands.get(&kind) { let cmd = cmd.clone(); // Serialize event as JSON for the hook's stdin - let event_json = - serde_json::to_value(&event).unwrap_or(serde_json::Value::Null); + let event_json = serde_json::to_value(&event).unwrap_or(serde_json::Value::Null); tokio::spawn(async move { if let Err(e) = run_hook(&cmd, &event_json).await { tracing::warn!(error = %e, command = %cmd, "exec hook failed"); diff --git a/crates/pikl/src/main.rs b/crates/pikl/src/main.rs index a140a92..14fe216 100644 --- a/crates/pikl/src/main.rs +++ b/crates/pikl/src/main.rs @@ -7,6 +7,8 @@ use std::time::Duration; use clap::Parser; +use pikl_core::column::ColumnConfig; +use pikl_core::csv_input::{self, InputFormat}; use pikl_core::debounce::{DebounceMode, DebouncedDispatcher}; use pikl_core::error::PiklError; use pikl_core::event::{Action, MenuResult, Mode}; @@ -127,6 +129,14 @@ struct Cli { /// Output selected items in selection order instead of input order #[arg(long)] selection_order: bool, + + /// Input format: auto, csv, or tsv + #[arg(long, value_name = "FORMAT", default_value = "auto")] + input_format: String, + + /// Columns to display in table mode (e.g. "label,meta.age:Age") + #[arg(long, value_name = "COLS")] + columns: Option, } fn main() { @@ -185,8 +195,21 @@ fn main() { .stdin_timeout .unwrap_or(if script.is_some() { 30 } else { 0 }); - let items = match read_stdin_with_timeout(timeout, &cli.label_key) { - Ok(items) => items, + let input_format = match InputFormat::from_str_opt(&cli.input_format) { + Some(f) => f, + None => { + let _ = writeln!( + std::io::stderr().lock(), + "pikl: unknown input format '{}', expected auto, csv, or tsv", + cli.input_format + ); + std::process::exit(2); + } + }; + + let (items, csv_headers) = match read_stdin_with_timeout(timeout, &cli.label_key, input_format) + { + Ok(r) => r, Err(e) => { let _ = writeln!(std::io::stderr().lock(), "pikl: {e}"); std::process::exit(2); @@ -201,6 +224,13 @@ fn main() { std::process::exit(2); } + // Resolve column config: explicit --columns, or auto-generate from CSV headers + let column_config = if let Some(ref cols) = cli.columns { + Some(ColumnConfig::parse(cols)) + } else { + csv_headers.map(|h| ColumnConfig::from_headers(&h)) + }; + // STEP 3: Build menu, start runtime let rt = tokio::runtime::Runtime::new().unwrap_or_else(|e| { let _ = writeln!( @@ -234,17 +264,18 @@ fn main() { // STEP 4: Branch on headless vs interactive let result = if let Some(script) = script { - rt.block_on(run_headless(items, &cli, script, start_mode)) + rt.block_on(run_headless(items, &cli, script, start_mode, column_config)) } else { - rt.block_on(run_interactive(items, &cli, start_mode)) + rt.block_on(run_interactive(items, &cli, start_mode, column_config)) }; // STEP 5: Handle result handle_result(result, &cli); } -/// Build a JsonMenu with optional filter_fields and format template. -fn build_menu(items: Vec, cli: &Cli) -> JsonMenu { +/// Build a JsonMenu with optional filter_fields, format template, +/// and column config. +fn build_menu(items: Vec, cli: &Cli, column_config: Option) -> JsonMenu { let mut menu = JsonMenu::new(items, cli.label_key.clone()); if let Some(ref fields) = cli.filter_fields { menu.set_filter_fields(fields.clone()); @@ -252,6 +283,9 @@ fn build_menu(items: Vec, cli: &Cli) -> JsonMenu { if let Some(ref template) = cli.format { menu.set_format_template(FormatTemplate::parse(template)); } + if let Some(config) = column_config { + menu.set_column_config(config); + } menu } @@ -318,10 +352,7 @@ struct CompositeHookHandler { } impl HookHandler for CompositeHookHandler { - fn handle( - &self, - event: pikl_core::hook::HookEvent, - ) -> Result<(), PiklError> { + fn handle(&self, event: pikl_core::hook::HookEvent) -> Result<(), PiklError> { // Both fire. Exec is fire-and-forget, handler may // send responses through action_tx. let _ = self.exec.handle(event.clone()); @@ -339,8 +370,9 @@ async fn run_headless( cli: &Cli, script: Vec, start_mode: Mode, + column_config: Option, ) -> Result { - let (mut menu, action_tx) = MenuRunner::new(build_menu(items, cli)); + let (mut menu, action_tx) = MenuRunner::new(build_menu(items, cli, column_config)); menu.set_initial_mode(start_mode); menu.set_multi(cli.multi); menu.set_selection_order(cli.selection_order); @@ -388,8 +420,9 @@ async fn run_interactive( items: Vec, cli: &Cli, start_mode: Mode, + column_config: Option, ) -> Result { - let (mut menu, action_tx) = MenuRunner::new(build_menu(items, cli)); + let (mut menu, action_tx) = MenuRunner::new(build_menu(items, cli, column_config)); menu.set_initial_mode(start_mode); menu.set_multi(cli.multi); menu.set_selection_order(cli.selection_order); @@ -493,15 +526,26 @@ fn write_plain_value(writer: &mut impl Write, value: &Value) -> Result<(), std:: /// Read items from stdin. If `timeout_secs` is non-zero, /// spawn a thread and bail if it doesn't finish in time. /// A timeout of 0 means no timeout (blocking read). -fn read_stdin_with_timeout(timeout_secs: u64, label_key: &str) -> Result, PiklError> { +/// +/// Returns (items, optional_csv_headers). CSV headers are +/// returned so column config can be auto-generated. +fn read_stdin_with_timeout( + timeout_secs: u64, + label_key: &str, + format: InputFormat, +) -> Result<(Vec, Option>), PiklError> { if timeout_secs == 0 { - return read_items_sync(std::io::stdin().lock(), label_key); + return read_stdin_inner(std::io::stdin().lock(), label_key, format); } let label_key = label_key.to_string(); let (tx, rx) = std::sync::mpsc::channel(); std::thread::spawn(move || { - let _ = tx.send(read_items_sync(std::io::stdin().lock(), &label_key)); + let _ = tx.send(read_stdin_inner( + std::io::stdin().lock(), + &label_key, + format, + )); }); match rx.recv_timeout(Duration::from_secs(timeout_secs)) { @@ -520,6 +564,24 @@ fn read_stdin_with_timeout(timeout_secs: u64, label_key: &str) -> Result Result<(Vec, Option>), PiklError> { + match format { + InputFormat::Auto => { + let items = read_items_sync(reader, label_key)?; + Ok((items, None)) + } + InputFormat::Csv | InputFormat::Tsv => { + let result = csv_input::read_csv_items_sync(reader, format, label_key)?; + Ok((result.items, Some(result.headers))) + } + } +} + /// Redirect stdin to `/dev/tty` so the TUI can read keyboard /// input after stdin was consumed for piped items. Flushes /// stale input so crossterm starts clean. diff --git a/crates/pikl/tests/headless.rs b/crates/pikl/tests/headless.rs index 3f496ad..5eb4d8c 100644 --- a/crates/pikl/tests/headless.rs +++ b/crates/pikl/tests/headless.rs @@ -67,3 +67,76 @@ fn headless_actions_after_show_ui_exits_2() { "expected show-ui error, got: {stderr}" ); } + +// -- CSV/TSV integration tests -- + +#[test] +fn csv_confirm_first() { + let (stdout, _stderr, code) = common::run_pikl( + "name,age\nalice,30\nbob,25\n", + "confirm\n", + &["--input-format", "csv"], + ); + assert_eq!(code, 0, "expected exit 0, stderr: {_stderr}"); + // Plain output should show the JSON object + assert!( + stdout.contains("alice"), + "expected alice in stdout, got: {stdout}" + ); +} + +#[test] +fn csv_with_columns_subset() { + let (stdout, _stderr, code) = common::run_pikl( + "name,age,city\nalice,30,toronto\nbob,25,vancouver\n", + "confirm\n", + &["--input-format", "csv", "--columns", "name,city"], + ); + assert_eq!(code, 0, "expected exit 0, stderr: {_stderr}"); + assert!( + stdout.contains("alice"), + "expected alice in stdout, got: {stdout}" + ); +} + +#[test] +fn csv_filter_then_confirm() { + let (stdout, _stderr, code) = common::run_pikl( + "name,age\nalice,30\nbob,25\ncharlie,35\n", + "filter bob\nconfirm\n", + &["--input-format", "csv"], + ); + assert_eq!(code, 0, "expected exit 0, stderr: {_stderr}"); + assert!( + stdout.contains("bob"), + "expected bob in stdout, got: {stdout}" + ); +} + +#[test] +fn csv_multi_select() { + let (stdout, _stderr, code) = common::run_pikl( + "name,age\nalice,30\nbob,25\n", + "toggle-select\nmove-down\ntoggle-select\nconfirm\n", + &["--input-format", "csv", "--multi"], + ); + assert_eq!(code, 0, "expected exit 0, stderr: {_stderr}"); + assert!( + stdout.contains("alice") && stdout.contains("bob"), + "expected both alice and bob in stdout, got: {stdout}" + ); +} + +#[test] +fn tsv_confirm_first() { + let (stdout, _stderr, code) = common::run_pikl( + "name\tage\nalice\t30\nbob\t25\n", + "confirm\n", + &["--input-format", "tsv"], + ); + assert_eq!(code, 0, "expected exit 0, stderr: {_stderr}"); + assert!( + stdout.contains("alice"), + "expected alice in stdout, got: {stdout}" + ); +} diff --git a/examples/demo.sh b/examples/demo.sh index 9e8aa9e..643befb 100755 --- a/examples/demo.sh +++ b/examples/demo.sh @@ -311,6 +311,74 @@ multi_select_structured() { | pikl --multi --structured } +# ── Phase 5 demos ──────────────────────────────────────── + +csv_table() { + echo "CSV table mode. Columns auto-generated from headers." >&2 + echo "Navigate with j/k, filter normally." >&2 + echo "" >&2 + cat <<'CSV' | pikl --input-format csv +name,role,language,editor +Alice,backend,Rust,Neovim +Bob,frontend,TypeScript,VS Code +Charlie,devops,Go,Helix +Diana,data,Python,Jupyter +Eve,security,Rust,Neovim +Frank,mobile,Kotlin,IntelliJ +CSV +} + +tsv_table() { + echo "TSV table mode. Same as CSV but tab-delimited." >&2 + echo "" >&2 + printf "pid\tuser\tcommand\n101\troot\tinit\n202\tmaple\tnvim\n303\tmaple\tbash\n404\troot\tsshd\n" \ + | pikl --input-format tsv +} + +csv_columns_alias() { + echo "CSV with --columns to pick a subset and rename headers." >&2 + echo "Only showing name and language (aliased to 'Lang')." >&2 + echo "" >&2 + cat <<'CSV' | pikl --input-format csv --columns "name,meta.language:Lang" +name,role,language,editor +Alice,backend,Rust,Neovim +Bob,frontend,TypeScript,VS Code +Charlie,devops,Go,Helix +Diana,data,Python,Jupyter +Eve,security,Rust,Neovim +CSV +} + +csv_multi_select() { + echo "CSV + multi-select. Toggle rows with Space, confirm with Enter." >&2 + echo "" >&2 + cat <<'CSV' | pikl --input-format csv --multi +package,version,size +ripgrep,14.1,2.1M +fd,10.2,1.8M +bat,0.24,3.4M +eza,0.18,2.0M +delta,0.17,4.2M +tokei,12.1,1.5M +CSV +} + +csv_filter() { + echo "CSV with filtering. Type to filter rows, field filters work too." >&2 + echo "Try: meta.category:rolling" >&2 + echo "" >&2 + cat <<'CSV' | pikl --input-format csv +label,category,init +Arch Linux,rolling,systemd +NixOS,rolling,systemd +Void Linux,rolling,runit +Debian,stable,systemd +Alpine,stable,openrc +Fedora,semi-rolling,systemd +Gentoo,rolling,openrc +CSV +} + # ── Scenario menu ───────────────────────────────────────── scenarios=( @@ -337,6 +405,12 @@ scenarios=( "Multi-select (packages)" "Multi-select (structured output)" "---" + "CSV table (auto columns)" + "TSV table (processes)" + "CSV with --columns aliases" + "CSV + multi-select" + "CSV + field filter" + "---" "on-select-exec hook (legacy)" ) @@ -362,6 +436,11 @@ run_scenario() { *"Multi-select (basic)"*) multi_select_basic ;; *"Multi-select (pack"*) multi_select_packages ;; *"Multi-select (struct"*) multi_select_structured ;; + *"CSV table"*) csv_table ;; + *"TSV table"*) tsv_table ;; + *"CSV with --columns"*) csv_columns_alias ;; + *"CSV + multi"*) csv_multi_select ;; + *"CSV + field"*) csv_filter ;; *"on-select-exec"*) on_select_hook ;; "---") echo "that's a separator, not a scenario" >&2