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

22
Cargo.lock generated
View File

@@ -310,6 +310,27 @@ dependencies = [
"phf", "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]] [[package]]
name = "darling" name = "darling"
version = "0.23.0" version = "0.23.0"
@@ -1089,6 +1110,7 @@ dependencies = [
name = "pikl-core" name = "pikl-core"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"csv",
"fancy-regex 0.14.0", "fancy-regex 0.14.0",
"indexmap", "indexmap",
"nucleo-matcher", "nucleo-matcher",

View File

@@ -17,6 +17,7 @@ tracing = "0.1"
nucleo-matcher = "0.3.1" nucleo-matcher = "0.3.1"
fancy-regex = "0.14" fancy-regex = "0.14"
indexmap = "2" indexmap = "2"
csv = "1"
[dev-dependencies] [dev-dependencies]
tokio = { version = "1.50.0", features = ["sync", "process", "io-util", "rt", "macros", "rt-multi-thread", "test-util"] } 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")] #[error("channel closed")]
ChannelClosed, ChannelClosed,
#[error("csv parse error: {0}")]
CsvParse(String),
#[error("{0}")] #[error("{0}")]
Script(#[from] crate::script::action_fd::ScriptError), Script(#[from] crate::script::action_fd::ScriptError),
} }

View File

@@ -12,6 +12,7 @@ pub mod script;
pub mod error; pub mod error;
// Re-export submodules at crate root so the public API stays flat. // Re-export submodules at crate root so the public API stays flat.
pub use model::column;
pub use model::event; pub use model::event;
pub use model::item; pub use model::item;
pub use model::output; pub use model::output;
@@ -22,6 +23,7 @@ pub use query::navigation;
pub use query::pipeline; pub use query::pipeline;
pub use query::regex_filter; pub use query::regex_filter;
pub use query::strategy; pub use query::strategy;
pub use runtime::csv_input;
pub use runtime::debounce; pub use runtime::debounce;
pub use runtime::hook; pub use runtime::hook;
pub use runtime::input; 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 mode: Mode,
pub selection_count: usize, pub selection_count: usize,
pub multi_enabled: bool, 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 /// Monotonically increasing counter. Each call to
/// `build_view_state()` bumps this, so frontends can /// `build_view_state()` bumps this, so frontends can
/// detect duplicate broadcasts and skip redundant redraws. /// detect duplicate broadcasts and skip redundant redraws.
pub generation: u64, 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 /// A single item in the current viewport window. Has the
/// display label pre-resolved and the position in the /// display label pre-resolved and the position in the
/// filtered list. /// filtered list.
@@ -97,6 +109,9 @@ pub struct VisibleItem {
pub formatted_text: Option<String>, pub formatted_text: Option<String>,
pub index: usize, pub index: usize,
pub selected: bool, 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 /// Final outcome of [`crate::menu::MenuRunner::run`]. The

View File

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

View File

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

View File

@@ -66,6 +66,17 @@ pub trait Menu: Send + 'static {
}) })
.collect() .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 /// Extension of [`Menu`] with mutation methods. Required by

View File

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

View File

@@ -10,7 +10,9 @@ pub enum FilterKind {
/// Field-specific filter: `meta.res:3840` matches /// Field-specific filter: `meta.res:3840` matches
/// items where the dotted path resolves to a value /// items where the dotted path resolves to a value
/// containing the query text. /// containing the query text.
Field { path: String }, Field {
path: String,
},
} }
/// A parsed filter segment with its kind, inversion flag, /// 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) && let Some((path, value)) = try_parse_field_filter(rest)
{ {
return ParsedSegment { return ParsedSegment {
kind: FilterKind::Field { path: path.to_string() }, kind: FilterKind::Field {
path: path.to_string(),
},
inverse: true, inverse: true,
query: value, query: value,
}; };
@@ -95,7 +99,9 @@ pub fn parse_segment(segment: &str) -> ParsedSegment<'_> {
// Check for field filter: field.path:value // Check for field filter: field.path:value
if let Some((path, value)) = try_parse_field_filter(segment) { if let Some((path, value)) = try_parse_field_filter(segment) {
return ParsedSegment { return ParsedSegment {
kind: FilterKind::Field { path: path.to_string() }, kind: FilterKind::Field {
path: path.to_string(),
},
inverse: false, inverse: false,
query: value, query: value,
}; };
@@ -150,7 +156,10 @@ fn try_parse_field_filter(segment: &str) -> Option<(&str, &str)> {
return None; return None;
} }
// Path chars must be alphanumeric, underscore, or dot // 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; 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 { impl DebouncedDispatcher {
pub fn new( pub fn new(handler: Arc<dyn HookHandler>, action_tx: mpsc::Sender<Action>) -> Self {
handler: Arc<dyn HookHandler>,
action_tx: mpsc::Sender<Action>,
) -> Self {
Self { Self {
handler, handler,
_action_tx: action_tx, _action_tx: action_tx,
@@ -71,11 +68,7 @@ impl DebouncedDispatcher {
/// Dispatch a hook event through the debounce system. /// Dispatch a hook event through the debounce system.
pub fn dispatch(&mut self, event: HookEvent) { pub fn dispatch(&mut self, event: HookEvent) {
let kind = event.kind(); let kind = event.kind();
let mode = self let mode = self.modes.get(&kind).cloned().unwrap_or(DebounceMode::None);
.modes
.get(&kind)
.cloned()
.unwrap_or(DebounceMode::None);
tracing::debug!(event_kind = ?kind, mode = ?mode, "dispatching hook event"); tracing::debug!(event_kind = ?kind, mode = ?mode, "dispatching hook event");

View File

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

View File

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

View File

@@ -9,9 +9,9 @@ use indexmap::IndexSet;
use tokio::sync::{broadcast, mpsc}; use tokio::sync::{broadcast, mpsc};
use tracing::{debug, info, trace}; 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::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::hook::{HookEvent, HookHandler};
use crate::model::traits::MutableMenu; use crate::model::traits::MutableMenu;
use crate::navigation::Viewport; use crate::navigation::Viewport;
@@ -97,14 +97,16 @@ impl SelectionState {
/// Undo the last selection change. /// Undo the last selection change.
pub fn undo(&mut self) { pub fn undo(&mut self) {
if let Some(prev) = self.undo_stack.pop() { 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. /// Redo a previously undone selection change.
pub fn redo(&mut self) { pub fn redo(&mut self) {
if let Some(next) = self.redo_stack.pop() { 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 { fn build_view_state(&mut self) -> ViewState {
self.generation += 1; self.generation += 1;
let range = self.viewport.visible_range(); let range = self.viewport.visible_range();
let has_columns = self.menu.column_config().is_some();
let visible_items: Vec<VisibleItem> = range let visible_items: Vec<VisibleItem> = range
.clone() .clone()
.filter_map(|i| { .filter_map(|i| {
self.menu.filtered_label(i).map(|label| { self.menu.filtered_label(i).map(|label| {
let formatted_text = self.menu.formatted_label(i); let formatted_text = self.menu.formatted_label(i);
let orig_idx = self.menu.original_index(i).unwrap_or(0); 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 { VisibleItem {
label: label.to_string(), label: label.to_string(),
formatted_text, formatted_text,
index: i, index: i,
selected: self.selection.is_selected(orig_idx), selected: self.selection.is_selected(orig_idx),
column_values,
} }
}) })
}) })
.collect(); .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 { let cursor = if self.menu.filtered_count() == 0 {
0 0
} else { } else {
@@ -263,6 +299,7 @@ impl<M: MutableMenu> MenuRunner<M> {
mode: self.mode, mode: self.mode,
selection_count: self.selection.count(), selection_count: self.selection.count(),
multi_enabled: self.selection.multi_enabled, multi_enabled: self.selection.multi_enabled,
columns,
generation: self.generation, generation: self.generation,
} }
} }
@@ -404,7 +441,9 @@ impl<M: MutableMenu> MenuRunner<M> {
let items: Vec<(Value, usize)> = indices let items: Vec<(Value, usize)> = indices
.into_iter() .into_iter()
.filter_map(|orig_idx| { .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(); .collect();
if items.is_empty() { if items.is_empty() {
@@ -572,8 +611,7 @@ impl<M: MutableMenu> MenuRunner<M> {
return Ok(MenuResult::Selected { items }); return Ok(MenuResult::Selected { items });
} }
ActionOutcome::Quicklist { items } => { ActionOutcome::Quicklist { items } => {
let values: Vec<Value> = let values: Vec<Value> = items.iter().map(|(v, _)| v.clone()).collect();
items.iter().map(|(v, _)| v.clone()).collect();
let count = values.len(); let count = values.len();
info!(count, "quicklist returned"); info!(count, "quicklist returned");
self.emit_hook(HookEvent::Quicklist { self.emit_hook(HookEvent::Quicklist {
@@ -582,9 +620,7 @@ impl<M: MutableMenu> MenuRunner<M> {
}); });
self.emit_hook(HookEvent::Close); self.emit_hook(HookEvent::Close);
let _ = self let _ = self.event_tx.send(MenuEvent::Quicklist(values));
.event_tx
.send(MenuEvent::Quicklist(values));
return Ok(MenuResult::Quicklist { items }); return Ok(MenuResult::Quicklist { items });
} }
ActionOutcome::Cancelled => { ActionOutcome::Cancelled => {
@@ -673,7 +709,9 @@ mod tests {
let mut m = ready_menu(); let mut m = ready_menu();
m.apply_action(Action::MoveDown(1)); m.apply_action(Action::MoveDown(1));
let outcome = m.apply_action(Action::Confirm); 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] #[test]
@@ -859,10 +897,14 @@ mod tests {
let _ = tx.send(Action::Confirm).await; let _ = tx.send(Action::Confirm).await;
let event = rx.recv().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)); 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] #[tokio::test]
@@ -884,7 +926,9 @@ mod tests {
let _ = tx.send(Action::Confirm).await; let _ = tx.send(Action::Confirm).await;
let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled)); 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] #[tokio::test]
@@ -909,7 +953,9 @@ mod tests {
let _ = tx.send(Action::Confirm).await; let _ = tx.send(Action::Confirm).await;
let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled)); 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] #[tokio::test]
@@ -944,7 +990,9 @@ mod tests {
let _ = tx.send(Action::Confirm).await; let _ = tx.send(Action::Confirm).await;
let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled)); 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] #[tokio::test]
@@ -1211,10 +1259,7 @@ mod tests {
match outcome { match outcome {
ActionOutcome::Quicklist { items } => { ActionOutcome::Quicklist { items } => {
assert_eq!(items.len(), 4); assert_eq!(items.len(), 4);
let labels: Vec<&str> = items let labels: Vec<&str> = items.iter().filter_map(|(v, _)| v.as_str()).collect();
.iter()
.filter_map(|(v, _)| v.as_str())
.collect();
assert_eq!(labels, vec!["alpha", "beta", "gamma", "delta"]); assert_eq!(labels, vec!["alpha", "beta", "gamma", "delta"]);
} }
other => panic!("expected Quicklist, got {other:?}"), other => panic!("expected Quicklist, got {other:?}"),
@@ -1661,4 +1706,103 @@ mod tests {
assert!(vs.visible_items[0].selected); assert!(vs.visible_items[0].selected);
assert!(!vs.visible_items[1].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 debounce;
pub mod hook; pub mod hook;
pub mod input; pub mod input;

View File

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

View File

@@ -12,9 +12,7 @@ use tokio::sync::mpsc;
use pikl_core::error::PiklError; use pikl_core::error::PiklError;
use pikl_core::event::Action; use pikl_core::event::Action;
use pikl_core::hook::{ use pikl_core::hook::{HookEvent, HookEventKind, HookHandler, parse_hook_response};
parse_hook_response, HookEvent, HookEventKind, HookHandler,
};
/// A persistent handler hook process. Spawns a child process, /// A persistent handler hook process. Spawns a child process,
/// writes events to its stdin as JSON lines, reads responses /// writes events to its stdin as JSON lines, reads responses
@@ -27,10 +25,7 @@ pub struct ShellHandlerHook {
impl ShellHandlerHook { impl ShellHandlerHook {
/// Build from CLI flags. Returns None if no handler hooks are configured. /// Build from CLI flags. Returns None if no handler hooks are configured.
pub fn from_cli( pub fn from_cli(cli: &crate::Cli, action_tx: mpsc::Sender<Action>) -> Option<Self> {
cli: &crate::Cli,
action_tx: mpsc::Sender<Action>,
) -> Option<Self> {
let mut handlers: Vec<(HookEventKind, &str)> = Vec::new(); let mut handlers: Vec<(HookEventKind, &str)> = Vec::new();
if let Some(ref cmd) = cli.on_open { 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 // Writer loop: reads events from channel, writes to stdin
while let Some(event) = event_rx.recv().await { while let Some(event) = event_rx.recv().await {
let json = serde_json::to_string(&event).unwrap_or_default(); let json = serde_json::to_string(&event).unwrap_or_default();
if stdin_writer if stdin_writer.write_all(json.as_bytes()).await.is_err() {
.write_all(json.as_bytes())
.await
.is_err()
{
break; break;
} }
if stdin_writer if stdin_writer.write_all(b"\n").await.is_err() {
.write_all(b"\n")
.await
.is_err()
{
break; break;
} }
if stdin_writer.flush().await.is_err() { if stdin_writer.flush().await.is_err() {
@@ -157,12 +144,9 @@ async fn run_handler_process(
drop(stdin_writer); drop(stdin_writer);
// Wait up to 1s for child to exit after EOF // Wait up to 1s for child to exit after EOF
let exited = tokio::time::timeout( let exited = tokio::time::timeout(std::time::Duration::from_secs(1), child.wait())
std::time::Duration::from_secs(1), .await
child.wait(), .is_ok();
)
.await
.is_ok();
if !exited { if !exited {
// Send SIGTERM on Unix, then wait another second // 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); libc::kill(pid as libc::pid_t, libc::SIGTERM);
} }
} }
let termed = tokio::time::timeout( let termed = tokio::time::timeout(std::time::Duration::from_secs(1), child.wait())
std::time::Duration::from_secs(1), .await
child.wait(), .is_ok();
)
.await
.is_ok();
if !termed { if !termed {
let _ = child.kill().await; let _ = child.kill().await;
} }
@@ -194,11 +175,7 @@ async fn run_handler_process(
} }
// Wait for the reader to finish // Wait for the reader to finish
let _ = tokio::time::timeout( let _ = tokio::time::timeout(std::time::Duration::from_secs(2), reader_handle).await;
std::time::Duration::from_secs(2),
reader_handle,
)
.await;
Ok(()) Ok(())
} }

View File

@@ -121,8 +121,7 @@ impl HookHandler for ShellExecHandler {
if let Some(cmd) = self.commands.get(&kind) { if let Some(cmd) = self.commands.get(&kind) {
let cmd = cmd.clone(); let cmd = cmd.clone();
// Serialize event as JSON for the hook's stdin // Serialize event as JSON for the hook's stdin
let event_json = let event_json = serde_json::to_value(&event).unwrap_or(serde_json::Value::Null);
serde_json::to_value(&event).unwrap_or(serde_json::Value::Null);
tokio::spawn(async move { tokio::spawn(async move {
if let Err(e) = run_hook(&cmd, &event_json).await { if let Err(e) = run_hook(&cmd, &event_json).await {
tracing::warn!(error = %e, command = %cmd, "exec hook failed"); tracing::warn!(error = %e, command = %cmd, "exec hook failed");

View File

@@ -7,6 +7,8 @@ use std::time::Duration;
use clap::Parser; use clap::Parser;
use pikl_core::column::ColumnConfig;
use pikl_core::csv_input::{self, InputFormat};
use pikl_core::debounce::{DebounceMode, DebouncedDispatcher}; use pikl_core::debounce::{DebounceMode, DebouncedDispatcher};
use pikl_core::error::PiklError; use pikl_core::error::PiklError;
use pikl_core::event::{Action, MenuResult, Mode}; use pikl_core::event::{Action, MenuResult, Mode};
@@ -127,6 +129,14 @@ struct Cli {
/// Output selected items in selection order instead of input order /// Output selected items in selection order instead of input order
#[arg(long)] #[arg(long)]
selection_order: bool, 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<String>,
} }
fn main() { fn main() {
@@ -185,8 +195,21 @@ fn main() {
.stdin_timeout .stdin_timeout
.unwrap_or(if script.is_some() { 30 } else { 0 }); .unwrap_or(if script.is_some() { 30 } else { 0 });
let items = match read_stdin_with_timeout(timeout, &cli.label_key) { let input_format = match InputFormat::from_str_opt(&cli.input_format) {
Ok(items) => items, 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) => { Err(e) => {
let _ = writeln!(std::io::stderr().lock(), "pikl: {e}"); let _ = writeln!(std::io::stderr().lock(), "pikl: {e}");
std::process::exit(2); std::process::exit(2);
@@ -201,6 +224,13 @@ fn main() {
std::process::exit(2); 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 // STEP 3: Build menu, start runtime
let rt = tokio::runtime::Runtime::new().unwrap_or_else(|e| { let rt = tokio::runtime::Runtime::new().unwrap_or_else(|e| {
let _ = writeln!( let _ = writeln!(
@@ -234,17 +264,18 @@ fn main() {
// STEP 4: Branch on headless vs interactive // STEP 4: Branch on headless vs interactive
let result = if let Some(script) = script { 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 { } 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 // STEP 5: Handle result
handle_result(result, &cli); handle_result(result, &cli);
} }
/// Build a JsonMenu with optional filter_fields and format template. /// Build a JsonMenu with optional filter_fields, format template,
fn build_menu(items: Vec<Item>, cli: &Cli) -> JsonMenu { /// and column config.
fn build_menu(items: Vec<Item>, cli: &Cli, column_config: Option<ColumnConfig>) -> JsonMenu {
let mut menu = JsonMenu::new(items, cli.label_key.clone()); let mut menu = JsonMenu::new(items, cli.label_key.clone());
if let Some(ref fields) = cli.filter_fields { if let Some(ref fields) = cli.filter_fields {
menu.set_filter_fields(fields.clone()); menu.set_filter_fields(fields.clone());
@@ -252,6 +283,9 @@ fn build_menu(items: Vec<Item>, cli: &Cli) -> JsonMenu {
if let Some(ref template) = cli.format { if let Some(ref template) = cli.format {
menu.set_format_template(FormatTemplate::parse(template)); menu.set_format_template(FormatTemplate::parse(template));
} }
if let Some(config) = column_config {
menu.set_column_config(config);
}
menu menu
} }
@@ -318,10 +352,7 @@ struct CompositeHookHandler {
} }
impl HookHandler for CompositeHookHandler { impl HookHandler for CompositeHookHandler {
fn handle( fn handle(&self, event: pikl_core::hook::HookEvent) -> Result<(), PiklError> {
&self,
event: pikl_core::hook::HookEvent,
) -> Result<(), PiklError> {
// Both fire. Exec is fire-and-forget, handler may // Both fire. Exec is fire-and-forget, handler may
// send responses through action_tx. // send responses through action_tx.
let _ = self.exec.handle(event.clone()); let _ = self.exec.handle(event.clone());
@@ -339,8 +370,9 @@ async fn run_headless(
cli: &Cli, cli: &Cli,
script: Vec<ScriptAction>, script: Vec<ScriptAction>,
start_mode: Mode, start_mode: Mode,
column_config: Option<ColumnConfig>,
) -> Result<MenuResult, PiklError> { ) -> Result<MenuResult, PiklError> {
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_initial_mode(start_mode);
menu.set_multi(cli.multi); menu.set_multi(cli.multi);
menu.set_selection_order(cli.selection_order); menu.set_selection_order(cli.selection_order);
@@ -388,8 +420,9 @@ async fn run_interactive(
items: Vec<Item>, items: Vec<Item>,
cli: &Cli, cli: &Cli,
start_mode: Mode, start_mode: Mode,
column_config: Option<ColumnConfig>,
) -> Result<MenuResult, PiklError> { ) -> Result<MenuResult, PiklError> {
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_initial_mode(start_mode);
menu.set_multi(cli.multi); menu.set_multi(cli.multi);
menu.set_selection_order(cli.selection_order); 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, /// Read items from stdin. If `timeout_secs` is non-zero,
/// spawn a thread and bail if it doesn't finish in time. /// spawn a thread and bail if it doesn't finish in time.
/// A timeout of 0 means no timeout (blocking read). /// A timeout of 0 means no timeout (blocking read).
fn read_stdin_with_timeout(timeout_secs: u64, label_key: &str) -> Result<Vec<Item>, 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<Item>, Option<Vec<String>>), PiklError> {
if timeout_secs == 0 { 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 label_key = label_key.to_string();
let (tx, rx) = std::sync::mpsc::channel(); let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || { 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)) { 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<Vec<Ite
} }
} }
/// Read stdin as either JSON/plain or CSV/TSV depending on format.
fn read_stdin_inner(
reader: impl std::io::BufRead,
label_key: &str,
format: InputFormat,
) -> Result<(Vec<Item>, Option<Vec<String>>), 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 /// Redirect stdin to `/dev/tty` so the TUI can read keyboard
/// input after stdin was consumed for piped items. Flushes /// input after stdin was consumed for piped items. Flushes
/// stale input so crossterm starts clean. /// stale input so crossterm starts clean.

View File

@@ -67,3 +67,76 @@ fn headless_actions_after_show_ui_exits_2() {
"expected show-ui error, got: {stderr}" "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}"
);
}

View File

@@ -311,6 +311,74 @@ multi_select_structured() {
| pikl --multi --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 ───────────────────────────────────────── # ── Scenario menu ─────────────────────────────────────────
scenarios=( scenarios=(
@@ -337,6 +405,12 @@ scenarios=(
"Multi-select (packages)" "Multi-select (packages)"
"Multi-select (structured output)" "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)" "on-select-exec hook (legacy)"
) )
@@ -362,6 +436,11 @@ run_scenario() {
*"Multi-select (basic)"*) multi_select_basic ;; *"Multi-select (basic)"*) multi_select_basic ;;
*"Multi-select (pack"*) multi_select_packages ;; *"Multi-select (pack"*) multi_select_packages ;;
*"Multi-select (struct"*) multi_select_structured ;; *"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 ;; *"on-select-exec"*) on_select_hook ;;
"---") "---")
echo "that's a separator, not a scenario" >&2 echo "that's a separator, not a scenario" >&2