Compare commits
6 Commits
f6f1efdf8e
...
cd1927fa9f
| Author | SHA1 | Date | |
|---|---|---|---|
|
cd1927fa9f
|
|||
|
8077469d19
|
|||
|
eab872d2ed
|
|||
|
a2e469456a
|
|||
|
18c66ca7a9
|
|||
|
2e29dfc103
|
23
Cargo.lock
generated
23
Cargo.lock
generated
@@ -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,7 +1110,9 @@ 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",
|
||||||
"nucleo-matcher",
|
"nucleo-matcher",
|
||||||
"pikl-test-macros",
|
"pikl-test-macros",
|
||||||
"serde",
|
"serde",
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ tokio = { version = "1.50.0", features = ["sync", "io-util", "rt", "time"] }
|
|||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
nucleo-matcher = "0.3.1"
|
nucleo-matcher = "0.3.1"
|
||||||
fancy-regex = "0.14"
|
fancy-regex = "0.14"
|
||||||
|
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"] }
|
||||||
|
|||||||
@@ -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),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
155
crates/pikl-core/src/model/column.rs
Normal file
155
crates/pikl-core/src/model/column.rs
Normal 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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,6 +20,7 @@ pub enum Mode {
|
|||||||
#[default]
|
#[default]
|
||||||
Insert,
|
Insert,
|
||||||
Normal,
|
Normal,
|
||||||
|
Visual,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A command the menu should process. Frontends and headless
|
/// A command the menu should process. Frontends and headless
|
||||||
@@ -37,6 +38,12 @@ pub enum Action {
|
|||||||
HalfPageUp(usize),
|
HalfPageUp(usize),
|
||||||
HalfPageDown(usize),
|
HalfPageDown(usize),
|
||||||
SetMode(Mode),
|
SetMode(Mode),
|
||||||
|
ToggleSelect,
|
||||||
|
SelectRange { start: usize, end: usize },
|
||||||
|
ClearSelections,
|
||||||
|
SelectAll,
|
||||||
|
UndoSelection,
|
||||||
|
RedoSelection,
|
||||||
Confirm,
|
Confirm,
|
||||||
Quicklist,
|
Quicklist,
|
||||||
Cancel,
|
Cancel,
|
||||||
@@ -53,7 +60,7 @@ pub enum Action {
|
|||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum MenuEvent {
|
pub enum MenuEvent {
|
||||||
StateChanged(ViewState),
|
StateChanged(ViewState),
|
||||||
Selected(Value),
|
Selected(Vec<(Value, usize)>),
|
||||||
Quicklist(Vec<Value>),
|
Quicklist(Vec<Value>),
|
||||||
Cancelled,
|
Cancelled,
|
||||||
}
|
}
|
||||||
@@ -72,12 +79,26 @@ pub struct ViewState {
|
|||||||
pub total_items: usize,
|
pub total_items: usize,
|
||||||
pub total_filtered: usize,
|
pub total_filtered: usize,
|
||||||
pub mode: Mode,
|
pub mode: Mode,
|
||||||
|
pub selection_count: usize,
|
||||||
|
pub multi_enabled: bool,
|
||||||
|
/// Column headers for table mode. None when not in
|
||||||
|
/// table mode.
|
||||||
|
pub columns: Option<Vec<ColumnHeader>>,
|
||||||
/// Monotonically increasing counter. Each call to
|
/// 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.
|
||||||
@@ -87,6 +108,10 @@ pub struct VisibleItem {
|
|||||||
pub label: String,
|
pub label: String,
|
||||||
pub formatted_text: Option<String>,
|
pub formatted_text: Option<String>,
|
||||||
pub index: usize,
|
pub index: usize,
|
||||||
|
pub selected: bool,
|
||||||
|
/// Column cell values for table mode. None when not in
|
||||||
|
/// table mode.
|
||||||
|
pub column_values: Option<Vec<String>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Final outcome of [`crate::menu::MenuRunner::run`]. The
|
/// Final outcome of [`crate::menu::MenuRunner::run`]. The
|
||||||
@@ -94,7 +119,7 @@ pub struct VisibleItem {
|
|||||||
#[must_use]
|
#[must_use]
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum MenuResult {
|
pub enum MenuResult {
|
||||||
Selected { value: Value, index: usize },
|
Selected { items: Vec<(Value, usize)> },
|
||||||
Quicklist { items: Vec<(Value, usize)> },
|
Quicklist { items: Vec<(Value, usize)> },
|
||||||
Cancelled,
|
Cancelled,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)));
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -41,6 +41,11 @@ pub trait Menu: Send + 'static {
|
|||||||
/// for output and hook events.
|
/// for output and hook events.
|
||||||
fn original_index(&self, filtered_index: usize) -> Option<usize>;
|
fn original_index(&self, filtered_index: usize) -> Option<usize>;
|
||||||
|
|
||||||
|
/// Get the JSON value of an item by its original (pre-filter)
|
||||||
|
/// index. Used by multi-select to retrieve selected items
|
||||||
|
/// that may not be in the current filtered set.
|
||||||
|
fn serialize_original(&self, original_index: usize) -> Option<&serde_json::Value>;
|
||||||
|
|
||||||
/// Get the formatted display text for a filtered item,
|
/// Get the formatted display text for a filtered item,
|
||||||
/// if a format template is configured. Returns None if
|
/// if a format template is configured. Returns None if
|
||||||
/// no template is set, in which case the raw label is
|
/// no template is set, in which case the raw label is
|
||||||
@@ -61,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
|
||||||
|
|||||||
@@ -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,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
208
crates/pikl-core/src/runtime/csv_input.rs
Normal file
208
crates/pikl-core/src/runtime/csv_input.rs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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) {
|
||||||
@@ -132,11 +140,25 @@ impl Menu for JsonMenu {
|
|||||||
self.filter.matched_index(filtered_index)
|
self.filter.matched_index(filtered_index)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn serialize_original(&self, original_index: usize) -> Option<&serde_json::Value> {
|
||||||
|
self.items.get(original_index).map(|item| &item.value)
|
||||||
|
}
|
||||||
|
|
||||||
fn formatted_label(&self, filtered_index: usize) -> Option<String> {
|
fn formatted_label(&self, filtered_index: usize) -> Option<String> {
|
||||||
let template = self.format_template.as_ref()?;
|
let template = self.format_template.as_ref()?;
|
||||||
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 {
|
||||||
@@ -173,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();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,13 @@
|
|||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
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;
|
||||||
@@ -23,7 +24,7 @@ pub enum ActionOutcome {
|
|||||||
/// State changed, broadcast to subscribers.
|
/// State changed, broadcast to subscribers.
|
||||||
Broadcast,
|
Broadcast,
|
||||||
/// User confirmed a selection.
|
/// User confirmed a selection.
|
||||||
Selected { value: Value, index: usize },
|
Selected { items: Vec<(Value, usize)> },
|
||||||
/// User cancelled.
|
/// User cancelled.
|
||||||
Cancelled,
|
Cancelled,
|
||||||
/// Menu closed by hook command.
|
/// Menu closed by hook command.
|
||||||
@@ -34,6 +35,109 @@ pub enum ActionOutcome {
|
|||||||
NoOp,
|
NoOp,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Maximum number of undo snapshots to retain. Keeps memory
|
||||||
|
/// bounded when users toggle hundreds of selections.
|
||||||
|
const UNDO_STACK_LIMIT: usize = 50;
|
||||||
|
|
||||||
|
/// Tracks multi-select state: which items are selected (by
|
||||||
|
/// original index), with undo/redo support.
|
||||||
|
pub struct SelectionState {
|
||||||
|
selected: IndexSet<usize>,
|
||||||
|
undo_stack: Vec<IndexSet<usize>>,
|
||||||
|
redo_stack: Vec<IndexSet<usize>>,
|
||||||
|
multi_enabled: bool,
|
||||||
|
selection_order: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SelectionState {
|
||||||
|
fn new() -> Self {
|
||||||
|
Self {
|
||||||
|
selected: IndexSet::new(),
|
||||||
|
undo_stack: Vec::new(),
|
||||||
|
redo_stack: Vec::new(),
|
||||||
|
multi_enabled: false,
|
||||||
|
selection_order: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn push_undo(&mut self) {
|
||||||
|
if self.undo_stack.len() >= UNDO_STACK_LIMIT {
|
||||||
|
self.undo_stack.remove(0);
|
||||||
|
}
|
||||||
|
self.undo_stack.push(self.selected.clone());
|
||||||
|
self.redo_stack.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Toggle an item in/out of the selection set.
|
||||||
|
pub fn toggle(&mut self, original_index: usize) {
|
||||||
|
self.push_undo();
|
||||||
|
if !self.selected.shift_remove(&original_index) {
|
||||||
|
self.selected.insert(original_index);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Force-select a range of items (no toggle).
|
||||||
|
pub fn select_range(&mut self, indices: impl Iterator<Item = usize>) {
|
||||||
|
self.push_undo();
|
||||||
|
for idx in indices {
|
||||||
|
self.selected.insert(idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Select all the given items.
|
||||||
|
pub fn select_all(&mut self, indices: impl Iterator<Item = usize>) {
|
||||||
|
self.push_undo();
|
||||||
|
for idx in indices {
|
||||||
|
self.selected.insert(idx);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Clear all selections.
|
||||||
|
pub fn clear(&mut self) {
|
||||||
|
if self.selected.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
self.push_undo();
|
||||||
|
self.selected.clear();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_selected(&self, original_index: usize) -> bool {
|
||||||
|
self.selected.contains(&original_index)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn count(&self) -> usize {
|
||||||
|
self.selected.len()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Items sorted by original index (input order).
|
||||||
|
pub fn ordered_items(&self) -> Vec<usize> {
|
||||||
|
let mut items: Vec<usize> = self.selected.iter().copied().collect();
|
||||||
|
items.sort_unstable();
|
||||||
|
items
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Items in insertion order (the order they were selected).
|
||||||
|
pub fn insertion_ordered(&self) -> Vec<usize> {
|
||||||
|
self.selected.iter().copied().collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// The menu engine. Wraps any [`Menu`] implementation and
|
/// The menu engine. Wraps any [`Menu`] implementation and
|
||||||
/// drives it with an action/event channel loop. Create one,
|
/// drives it with an action/event channel loop. Create one,
|
||||||
/// grab the action sender and event subscriber, then call
|
/// grab the action sender and event subscriber, then call
|
||||||
@@ -48,6 +152,8 @@ pub struct MenuRunner<M: MutableMenu> {
|
|||||||
dispatcher: Option<DebouncedDispatcher>,
|
dispatcher: Option<DebouncedDispatcher>,
|
||||||
previous_cursor: Option<usize>,
|
previous_cursor: Option<usize>,
|
||||||
generation: u64,
|
generation: u64,
|
||||||
|
selection: SelectionState,
|
||||||
|
visual_anchor: Option<usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl<M: MutableMenu> MenuRunner<M> {
|
impl<M: MutableMenu> MenuRunner<M> {
|
||||||
@@ -72,6 +178,8 @@ impl<M: MutableMenu> MenuRunner<M> {
|
|||||||
dispatcher: None,
|
dispatcher: None,
|
||||||
previous_cursor: None,
|
previous_cursor: None,
|
||||||
generation: 0,
|
generation: 0,
|
||||||
|
selection: SelectionState::new(),
|
||||||
|
visual_anchor: None,
|
||||||
};
|
};
|
||||||
(runner, action_tx)
|
(runner, action_tx)
|
||||||
}
|
}
|
||||||
@@ -99,6 +207,17 @@ impl<M: MutableMenu> MenuRunner<M> {
|
|||||||
self.dispatcher = Some(dispatcher);
|
self.dispatcher = Some(dispatcher);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Enable multi-select mode.
|
||||||
|
pub fn set_multi(&mut self, enabled: bool) {
|
||||||
|
self.selection.multi_enabled = enabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Enable selection-order output (insertion order instead
|
||||||
|
/// of input order).
|
||||||
|
pub fn set_selection_order(&mut self, enabled: bool) {
|
||||||
|
self.selection.selection_order = enabled;
|
||||||
|
}
|
||||||
|
|
||||||
/// Re-run the filter against all items with the current
|
/// Re-run the filter against all items with the current
|
||||||
/// filter text. Updates the viewport with the new count.
|
/// filter text. Updates the viewport with the new count.
|
||||||
fn run_filter(&mut self) {
|
fn run_filter(&mut self) {
|
||||||
@@ -122,20 +241,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 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),
|
||||||
|
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 {
|
||||||
@@ -149,6 +304,9 @@ impl<M: MutableMenu> MenuRunner<M> {
|
|||||||
total_items: self.menu.total(),
|
total_items: self.menu.total(),
|
||||||
total_filtered: self.menu.filtered_count(),
|
total_filtered: self.menu.filtered_count(),
|
||||||
mode: self.mode,
|
mode: self.mode,
|
||||||
|
selection_count: self.selection.count(),
|
||||||
|
multi_enabled: self.selection.multi_enabled,
|
||||||
|
columns,
|
||||||
generation: self.generation,
|
generation: self.generation,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -229,7 +387,78 @@ impl<M: MutableMenu> MenuRunner<M> {
|
|||||||
self.viewport.page_down(n);
|
self.viewport.page_down(n);
|
||||||
ActionOutcome::Broadcast
|
ActionOutcome::Broadcast
|
||||||
}
|
}
|
||||||
|
Action::ToggleSelect => {
|
||||||
|
if !self.selection.multi_enabled {
|
||||||
|
return ActionOutcome::NoOp;
|
||||||
|
}
|
||||||
|
if self.menu.filtered_count() == 0 {
|
||||||
|
return ActionOutcome::NoOp;
|
||||||
|
}
|
||||||
|
let cursor = self.viewport.cursor();
|
||||||
|
if let Some(orig_idx) = self.menu.original_index(cursor) {
|
||||||
|
self.selection.toggle(orig_idx);
|
||||||
|
}
|
||||||
|
ActionOutcome::Broadcast
|
||||||
|
}
|
||||||
|
Action::SelectRange { start, end } => {
|
||||||
|
if !self.selection.multi_enabled {
|
||||||
|
return ActionOutcome::NoOp;
|
||||||
|
}
|
||||||
|
let min = start.min(end);
|
||||||
|
let max = start.max(end);
|
||||||
|
let indices: Vec<usize> = (min..=max)
|
||||||
|
.filter_map(|i| self.menu.original_index(i))
|
||||||
|
.collect();
|
||||||
|
self.selection.select_range(indices.into_iter());
|
||||||
|
ActionOutcome::Broadcast
|
||||||
|
}
|
||||||
|
Action::ClearSelections => {
|
||||||
|
self.selection.clear();
|
||||||
|
ActionOutcome::Broadcast
|
||||||
|
}
|
||||||
|
Action::SelectAll => {
|
||||||
|
if !self.selection.multi_enabled {
|
||||||
|
return ActionOutcome::NoOp;
|
||||||
|
}
|
||||||
|
let indices: Vec<usize> = (0..self.menu.filtered_count())
|
||||||
|
.filter_map(|i| self.menu.original_index(i))
|
||||||
|
.collect();
|
||||||
|
self.selection.select_all(indices.into_iter());
|
||||||
|
ActionOutcome::Broadcast
|
||||||
|
}
|
||||||
|
Action::UndoSelection => {
|
||||||
|
self.selection.undo();
|
||||||
|
ActionOutcome::Broadcast
|
||||||
|
}
|
||||||
|
Action::RedoSelection => {
|
||||||
|
self.selection.redo();
|
||||||
|
ActionOutcome::Broadcast
|
||||||
|
}
|
||||||
Action::Confirm => {
|
Action::Confirm => {
|
||||||
|
if self.menu.filtered_count() == 0 && self.selection.count() == 0 {
|
||||||
|
return ActionOutcome::NoOp;
|
||||||
|
}
|
||||||
|
if self.selection.multi_enabled && self.selection.count() > 0 {
|
||||||
|
// Multi-select: return all selected items
|
||||||
|
let indices = if self.selection.selection_order {
|
||||||
|
self.selection.insertion_ordered()
|
||||||
|
} else {
|
||||||
|
self.selection.ordered_items()
|
||||||
|
};
|
||||||
|
let items: Vec<(Value, usize)> = indices
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|orig_idx| {
|
||||||
|
self.menu
|
||||||
|
.serialize_original(orig_idx)
|
||||||
|
.map(|v| (v.clone(), orig_idx))
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
if items.is_empty() {
|
||||||
|
return ActionOutcome::NoOp;
|
||||||
|
}
|
||||||
|
ActionOutcome::Selected { items }
|
||||||
|
} else {
|
||||||
|
// Single-select: cursor item as one-element vec
|
||||||
if self.menu.filtered_count() == 0 {
|
if self.menu.filtered_count() == 0 {
|
||||||
return ActionOutcome::NoOp;
|
return ActionOutcome::NoOp;
|
||||||
}
|
}
|
||||||
@@ -237,12 +466,12 @@ impl<M: MutableMenu> MenuRunner<M> {
|
|||||||
let index = self.menu.original_index(cursor).unwrap_or(0);
|
let index = self.menu.original_index(cursor).unwrap_or(0);
|
||||||
match self.menu.serialize_filtered(cursor) {
|
match self.menu.serialize_filtered(cursor) {
|
||||||
Some(value) => ActionOutcome::Selected {
|
Some(value) => ActionOutcome::Selected {
|
||||||
value: value.clone(),
|
items: vec![(value.clone(), index)],
|
||||||
index,
|
|
||||||
},
|
},
|
||||||
None => ActionOutcome::NoOp,
|
None => ActionOutcome::NoOp,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
Action::Quicklist => {
|
Action::Quicklist => {
|
||||||
if self.menu.filtered_count() == 0 {
|
if self.menu.filtered_count() == 0 {
|
||||||
return ActionOutcome::NoOp;
|
return ActionOutcome::NoOp;
|
||||||
@@ -271,6 +500,11 @@ impl<M: MutableMenu> MenuRunner<M> {
|
|||||||
}
|
}
|
||||||
Action::SetMode(m) => {
|
Action::SetMode(m) => {
|
||||||
debug!(mode = ?m, "mode changed");
|
debug!(mode = ?m, "mode changed");
|
||||||
|
if m == Mode::Visual {
|
||||||
|
self.visual_anchor = Some(self.viewport.cursor());
|
||||||
|
} else if self.mode == Mode::Visual {
|
||||||
|
self.visual_anchor = None;
|
||||||
|
}
|
||||||
self.mode = m;
|
self.mode = m;
|
||||||
ActionOutcome::Broadcast
|
ActionOutcome::Broadcast
|
||||||
}
|
}
|
||||||
@@ -367,22 +601,24 @@ impl<M: MutableMenu> MenuRunner<M> {
|
|||||||
// Check for cursor movement -> Hover
|
// Check for cursor movement -> Hover
|
||||||
self.check_cursor_hover();
|
self.check_cursor_hover();
|
||||||
}
|
}
|
||||||
ActionOutcome::Selected { value, index } => {
|
ActionOutcome::Selected { items } => {
|
||||||
info!(index, "item selected");
|
let count = items.len();
|
||||||
// Emit Select event
|
info!(count, "item(s) selected");
|
||||||
|
// Emit Select event for the first item (primary selection)
|
||||||
|
if let Some((value, index)) = items.first() {
|
||||||
self.emit_hook(HookEvent::Select {
|
self.emit_hook(HookEvent::Select {
|
||||||
item: value.clone(),
|
item: value.clone(),
|
||||||
index,
|
index: *index,
|
||||||
});
|
});
|
||||||
|
}
|
||||||
// Emit Close event
|
// Emit Close event
|
||||||
self.emit_hook(HookEvent::Close);
|
self.emit_hook(HookEvent::Close);
|
||||||
|
|
||||||
let _ = self.event_tx.send(MenuEvent::Selected(value.clone()));
|
let _ = self.event_tx.send(MenuEvent::Selected(items.clone()));
|
||||||
return Ok(MenuResult::Selected { value, index });
|
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 {
|
||||||
@@ -391,9 +627,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 => {
|
||||||
@@ -482,7 +716,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 { value, .. } if value.as_str() == Some("beta")));
|
assert!(
|
||||||
|
matches!(&outcome, ActionOutcome::Selected { items } if items[0].0.as_str() == Some("beta"))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -605,8 +841,8 @@ mod tests {
|
|||||||
let _ = tx.send(Action::Confirm).await;
|
let _ = tx.send(Action::Confirm).await;
|
||||||
|
|
||||||
// Should get Selected event
|
// Should get Selected event
|
||||||
if let Ok(MenuEvent::Selected(value)) = rx.recv().await {
|
if let Ok(MenuEvent::Selected(items)) = rx.recv().await {
|
||||||
assert_eq!(value.as_str(), Some("beta"));
|
assert_eq!(items[0].0.as_str(), Some("beta"));
|
||||||
}
|
}
|
||||||
|
|
||||||
let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled));
|
let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled));
|
||||||
@@ -668,10 +904,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(v)) if v.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 value, .. }) if value.as_str() == Some("alpha")));
|
assert!(
|
||||||
|
matches!(result, Ok(MenuResult::Selected { ref items, .. }) if items[0].0.as_str() == Some("alpha"))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -693,7 +933,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 value, .. }) if value.as_str() == Some("gamma")));
|
assert!(
|
||||||
|
matches!(result, Ok(MenuResult::Selected { ref items, .. }) if items[0].0.as_str() == Some("gamma"))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -718,7 +960,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 value, .. }) if value.as_str() == Some("delta")));
|
assert!(
|
||||||
|
matches!(result, Ok(MenuResult::Selected { ref items, .. }) if items[0].0.as_str() == Some("delta"))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -753,7 +997,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 value, .. }) if value.as_str() == Some("epsilon")));
|
assert!(
|
||||||
|
matches!(result, Ok(MenuResult::Selected { ref items, .. }) if items[0].0.as_str() == Some("epsilon"))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
@@ -805,7 +1051,7 @@ mod tests {
|
|||||||
// Must get "banana". Filter was applied before confirm ran.
|
// Must get "banana". Filter was applied before confirm ran.
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
result,
|
result,
|
||||||
Ok(MenuResult::Selected { ref value, .. }) if value.as_str() == Some("banana")
|
Ok(MenuResult::Selected { ref items, .. }) if items[0].0.as_str() == Some("banana")
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -831,7 +1077,7 @@ mod tests {
|
|||||||
// Cursor at index 3 -> "delta"
|
// Cursor at index 3 -> "delta"
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
result,
|
result,
|
||||||
Ok(MenuResult::Selected { ref value, .. }) if value.as_str() == Some("delta")
|
Ok(MenuResult::Selected { ref items, .. }) if items[0].0.as_str() == Some("delta")
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -927,7 +1173,7 @@ mod tests {
|
|||||||
// Must find "zephyr". It was added before the filter ran.
|
// Must find "zephyr". It was added before the filter ran.
|
||||||
assert!(matches!(
|
assert!(matches!(
|
||||||
result,
|
result,
|
||||||
Ok(MenuResult::Selected { ref value, .. }) if value.as_str() == Some("zephyr")
|
Ok(MenuResult::Selected { ref items, .. }) if items[0].0.as_str() == Some("zephyr")
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1008,7 +1254,7 @@ mod tests {
|
|||||||
assert!(m.menu.filtered_count() >= 1);
|
assert!(m.menu.filtered_count() >= 1);
|
||||||
let outcome = m.apply_action(Action::Confirm);
|
let outcome = m.apply_action(Action::Confirm);
|
||||||
// "delta" is at original index 3
|
// "delta" is at original index 3
|
||||||
assert!(matches!(outcome, ActionOutcome::Selected { index: 3, .. }));
|
assert!(matches!(&outcome, ActionOutcome::Selected { items } if items[0].1 == 3));
|
||||||
}
|
}
|
||||||
|
|
||||||
// -- Quicklist tests --
|
// -- Quicklist tests --
|
||||||
@@ -1020,10 +1266,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:?}"),
|
||||||
@@ -1173,4 +1416,400 @@ mod tests {
|
|||||||
assert!(events.contains(&HookEventKind::Open));
|
assert!(events.contains(&HookEventKind::Open));
|
||||||
assert!(events.contains(&HookEventKind::Filter));
|
assert!(events.contains(&HookEventKind::Filter));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -- SelectionState unit tests --
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn selection_toggle_adds_and_removes() {
|
||||||
|
let mut s = SelectionState::new();
|
||||||
|
s.multi_enabled = true;
|
||||||
|
s.toggle(3);
|
||||||
|
assert!(s.is_selected(3));
|
||||||
|
assert_eq!(s.count(), 1);
|
||||||
|
s.toggle(3);
|
||||||
|
assert!(!s.is_selected(3));
|
||||||
|
assert_eq!(s.count(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn selection_undo_redo() {
|
||||||
|
let mut s = SelectionState::new();
|
||||||
|
s.multi_enabled = true;
|
||||||
|
s.toggle(1);
|
||||||
|
s.toggle(2);
|
||||||
|
assert_eq!(s.count(), 2);
|
||||||
|
s.undo();
|
||||||
|
assert_eq!(s.count(), 1);
|
||||||
|
assert!(s.is_selected(1));
|
||||||
|
assert!(!s.is_selected(2));
|
||||||
|
s.redo();
|
||||||
|
assert_eq!(s.count(), 2);
|
||||||
|
assert!(s.is_selected(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn selection_undo_on_empty_is_noop() {
|
||||||
|
let mut s = SelectionState::new();
|
||||||
|
s.undo();
|
||||||
|
assert_eq!(s.count(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn selection_redo_on_empty_is_noop() {
|
||||||
|
let mut s = SelectionState::new();
|
||||||
|
s.redo();
|
||||||
|
assert_eq!(s.count(), 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn selection_clear_then_undo_restores() {
|
||||||
|
let mut s = SelectionState::new();
|
||||||
|
s.multi_enabled = true;
|
||||||
|
s.toggle(0);
|
||||||
|
s.toggle(2);
|
||||||
|
assert_eq!(s.count(), 2);
|
||||||
|
s.clear();
|
||||||
|
assert_eq!(s.count(), 0);
|
||||||
|
s.undo();
|
||||||
|
assert_eq!(s.count(), 2);
|
||||||
|
assert!(s.is_selected(0));
|
||||||
|
assert!(s.is_selected(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn selection_select_range() {
|
||||||
|
let mut s = SelectionState::new();
|
||||||
|
s.multi_enabled = true;
|
||||||
|
s.select_range([1, 2, 3].into_iter());
|
||||||
|
assert_eq!(s.count(), 3);
|
||||||
|
assert!(s.is_selected(1));
|
||||||
|
assert!(s.is_selected(2));
|
||||||
|
assert!(s.is_selected(3));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn selection_select_all() {
|
||||||
|
let mut s = SelectionState::new();
|
||||||
|
s.multi_enabled = true;
|
||||||
|
s.select_all([0, 1, 2, 3].into_iter());
|
||||||
|
assert_eq!(s.count(), 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn selection_ordered_items_sorted() {
|
||||||
|
let mut s = SelectionState::new();
|
||||||
|
s.multi_enabled = true;
|
||||||
|
s.toggle(3);
|
||||||
|
s.toggle(1);
|
||||||
|
s.toggle(0);
|
||||||
|
assert_eq!(s.ordered_items(), vec![0, 1, 3]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn selection_insertion_ordered() {
|
||||||
|
let mut s = SelectionState::new();
|
||||||
|
s.multi_enabled = true;
|
||||||
|
s.toggle(3);
|
||||||
|
s.toggle(1);
|
||||||
|
s.toggle(0);
|
||||||
|
assert_eq!(s.insertion_ordered(), vec![3, 1, 0]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn selection_new_action_clears_redo() {
|
||||||
|
let mut s = SelectionState::new();
|
||||||
|
s.multi_enabled = true;
|
||||||
|
s.toggle(1);
|
||||||
|
s.toggle(2);
|
||||||
|
s.undo(); // redo stack has the {1,2} state
|
||||||
|
s.toggle(3); // new action, should clear redo
|
||||||
|
s.redo(); // should be noop
|
||||||
|
assert_eq!(s.count(), 2);
|
||||||
|
assert!(s.is_selected(1));
|
||||||
|
assert!(s.is_selected(3));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Multi-select integration tests (apply_action) --
|
||||||
|
|
||||||
|
fn ready_multi_menu() -> MenuRunner<JsonMenu> {
|
||||||
|
let (mut m, _tx) = test_menu();
|
||||||
|
m.set_multi(true);
|
||||||
|
m.run_filter();
|
||||||
|
m.apply_action(Action::Resize { height: 10 });
|
||||||
|
m
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn toggle_then_confirm_returns_selected() {
|
||||||
|
let mut m = ready_multi_menu();
|
||||||
|
m.apply_action(Action::MoveDown(1)); // cursor on "beta"
|
||||||
|
m.apply_action(Action::ToggleSelect);
|
||||||
|
let outcome = m.apply_action(Action::Confirm);
|
||||||
|
match outcome {
|
||||||
|
ActionOutcome::Selected { items } => {
|
||||||
|
assert_eq!(items.len(), 1);
|
||||||
|
assert_eq!(items[0].0.as_str(), Some("beta"));
|
||||||
|
assert_eq!(items[0].1, 1);
|
||||||
|
}
|
||||||
|
other => panic!("expected Selected, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn toggle_two_items_confirm_returns_both() {
|
||||||
|
let mut m = ready_multi_menu();
|
||||||
|
m.apply_action(Action::ToggleSelect); // select "alpha" (0)
|
||||||
|
m.apply_action(Action::MoveDown(2)); // cursor on "gamma" (2)
|
||||||
|
m.apply_action(Action::ToggleSelect);
|
||||||
|
let outcome = m.apply_action(Action::Confirm);
|
||||||
|
match outcome {
|
||||||
|
ActionOutcome::Selected { items } => {
|
||||||
|
assert_eq!(items.len(), 2);
|
||||||
|
// Default order = input order
|
||||||
|
assert_eq!(items[0].0.as_str(), Some("alpha"));
|
||||||
|
assert_eq!(items[1].0.as_str(), Some("gamma"));
|
||||||
|
}
|
||||||
|
other => panic!("expected Selected, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn toggle_then_untoggle_falls_back_to_cursor() {
|
||||||
|
let mut m = ready_multi_menu();
|
||||||
|
m.apply_action(Action::MoveDown(1)); // cursor on "beta"
|
||||||
|
m.apply_action(Action::ToggleSelect);
|
||||||
|
m.apply_action(Action::ToggleSelect); // untoggle
|
||||||
|
let outcome = m.apply_action(Action::Confirm);
|
||||||
|
match outcome {
|
||||||
|
ActionOutcome::Selected { items } => {
|
||||||
|
// No selections, fallback to cursor
|
||||||
|
assert_eq!(items.len(), 1);
|
||||||
|
assert_eq!(items[0].0.as_str(), Some("beta"));
|
||||||
|
}
|
||||||
|
other => panic!("expected Selected, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn multi_no_selections_returns_cursor() {
|
||||||
|
let mut m = ready_multi_menu();
|
||||||
|
m.apply_action(Action::MoveDown(2)); // cursor on "gamma"
|
||||||
|
let outcome = m.apply_action(Action::Confirm);
|
||||||
|
match outcome {
|
||||||
|
ActionOutcome::Selected { items } => {
|
||||||
|
assert_eq!(items.len(), 1);
|
||||||
|
assert_eq!(items[0].0.as_str(), Some("gamma"));
|
||||||
|
}
|
||||||
|
other => panic!("expected Selected, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn select_all_then_confirm() {
|
||||||
|
let mut m = ready_multi_menu();
|
||||||
|
m.apply_action(Action::SelectAll);
|
||||||
|
let outcome = m.apply_action(Action::Confirm);
|
||||||
|
match outcome {
|
||||||
|
ActionOutcome::Selected { items } => {
|
||||||
|
assert_eq!(items.len(), 4);
|
||||||
|
let labels: Vec<&str> = items.iter().filter_map(|(v, _)| v.as_str()).collect();
|
||||||
|
assert_eq!(labels, vec!["alpha", "beta", "gamma", "delta"]);
|
||||||
|
}
|
||||||
|
other => panic!("expected Selected, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn clear_selections_after_select() {
|
||||||
|
let mut m = ready_multi_menu();
|
||||||
|
m.apply_action(Action::ToggleSelect); // select alpha
|
||||||
|
m.apply_action(Action::ClearSelections);
|
||||||
|
assert_eq!(m.selection.count(), 0);
|
||||||
|
let outcome = m.apply_action(Action::Confirm);
|
||||||
|
match outcome {
|
||||||
|
ActionOutcome::Selected { items } => {
|
||||||
|
assert_eq!(items.len(), 1);
|
||||||
|
assert_eq!(items[0].0.as_str(), Some("alpha")); // cursor item
|
||||||
|
}
|
||||||
|
other => panic!("expected Selected, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn undo_redo_through_apply_action() {
|
||||||
|
let mut m = ready_multi_menu();
|
||||||
|
m.apply_action(Action::ToggleSelect); // select alpha
|
||||||
|
m.apply_action(Action::MoveDown(1));
|
||||||
|
m.apply_action(Action::ToggleSelect); // select beta
|
||||||
|
assert_eq!(m.selection.count(), 2);
|
||||||
|
m.apply_action(Action::UndoSelection);
|
||||||
|
assert_eq!(m.selection.count(), 1);
|
||||||
|
assert!(m.selection.is_selected(0)); // alpha
|
||||||
|
assert!(!m.selection.is_selected(1)); // beta undone
|
||||||
|
m.apply_action(Action::RedoSelection);
|
||||||
|
assert_eq!(m.selection.count(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn selections_survive_filter_change() {
|
||||||
|
let mut m = ready_multi_menu();
|
||||||
|
m.apply_action(Action::ToggleSelect); // select alpha (orig idx 0)
|
||||||
|
m.apply_action(Action::MoveDown(1));
|
||||||
|
m.apply_action(Action::ToggleSelect); // select beta (orig idx 1)
|
||||||
|
// Change filter
|
||||||
|
m.apply_action(Action::UpdateFilter("del".to_string()));
|
||||||
|
// Selections should survive
|
||||||
|
assert_eq!(m.selection.count(), 2);
|
||||||
|
assert!(m.selection.is_selected(0));
|
||||||
|
assert!(m.selection.is_selected(1));
|
||||||
|
// Confirm should return the selected items (alpha, beta) even
|
||||||
|
// though they're not in the filtered set
|
||||||
|
let outcome = m.apply_action(Action::Confirm);
|
||||||
|
match outcome {
|
||||||
|
ActionOutcome::Selected { items } => {
|
||||||
|
assert_eq!(items.len(), 2);
|
||||||
|
assert_eq!(items[0].0.as_str(), Some("alpha"));
|
||||||
|
assert_eq!(items[1].0.as_str(), Some("beta"));
|
||||||
|
}
|
||||||
|
other => panic!("expected Selected, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn multi_disabled_toggle_is_noop() {
|
||||||
|
let mut m = ready_menu(); // not multi
|
||||||
|
let outcome = m.apply_action(Action::ToggleSelect);
|
||||||
|
assert!(matches!(outcome, ActionOutcome::NoOp));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn select_range_selects_all_in_range() {
|
||||||
|
let mut m = ready_multi_menu();
|
||||||
|
m.apply_action(Action::SelectRange { start: 1, end: 3 });
|
||||||
|
assert_eq!(m.selection.count(), 3);
|
||||||
|
assert!(!m.selection.is_selected(0));
|
||||||
|
assert!(m.selection.is_selected(1));
|
||||||
|
assert!(m.selection.is_selected(2));
|
||||||
|
assert!(m.selection.is_selected(3));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn visual_anchor_set_on_visual_mode() {
|
||||||
|
let mut m = ready_multi_menu();
|
||||||
|
m.apply_action(Action::MoveDown(2)); // cursor at 2
|
||||||
|
m.apply_action(Action::SetMode(Mode::Visual));
|
||||||
|
assert_eq!(m.visual_anchor, Some(2));
|
||||||
|
m.apply_action(Action::SetMode(Mode::Normal));
|
||||||
|
assert_eq!(m.visual_anchor, None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn view_state_shows_selection() {
|
||||||
|
let mut m = ready_multi_menu();
|
||||||
|
m.apply_action(Action::ToggleSelect); // select alpha
|
||||||
|
let vs = m.build_view_state();
|
||||||
|
assert!(vs.multi_enabled);
|
||||||
|
assert_eq!(vs.selection_count, 1);
|
||||||
|
assert!(vs.visible_items[0].selected);
|
||||||
|
assert!(!vs.visible_items[1].selected);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Column / table mode tests --
|
||||||
|
|
||||||
|
use crate::model::column::ColumnConfig;
|
||||||
|
|
||||||
|
fn table_menu() -> MenuRunner<JsonMenu> {
|
||||||
|
let items = vec![
|
||||||
|
Item::new(
|
||||||
|
serde_json::json!({"label": "alice", "meta": {"age": "30", "city": "toronto"}}),
|
||||||
|
"label",
|
||||||
|
),
|
||||||
|
Item::new(
|
||||||
|
serde_json::json!({"label": "bob", "meta": {"age": "25", "city": "vancouver"}}),
|
||||||
|
"label",
|
||||||
|
),
|
||||||
|
Item::new(
|
||||||
|
serde_json::json!({"label": "charlie", "meta": {"age": "35", "city": "montreal"}}),
|
||||||
|
"label",
|
||||||
|
),
|
||||||
|
];
|
||||||
|
let mut json_menu = JsonMenu::new(items, "label".to_string());
|
||||||
|
json_menu.set_column_config(ColumnConfig::parse("label,meta.age:Age,meta.city:City"));
|
||||||
|
let (runner, _tx) = MenuRunner::new(json_menu);
|
||||||
|
runner
|
||||||
|
}
|
||||||
|
|
||||||
|
fn ready_table_menu() -> MenuRunner<JsonMenu> {
|
||||||
|
let mut m = table_menu();
|
||||||
|
m.run_filter();
|
||||||
|
m.apply_action(Action::Resize { height: 10 });
|
||||||
|
m
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn view_state_has_columns_when_config_set() {
|
||||||
|
let mut m = ready_table_menu();
|
||||||
|
let vs = m.build_view_state();
|
||||||
|
assert!(vs.columns.is_some(), "columns should be Some");
|
||||||
|
let cols = vs.columns.as_ref().unwrap();
|
||||||
|
assert_eq!(cols.len(), 3);
|
||||||
|
assert_eq!(cols[0].display_name, "label");
|
||||||
|
assert_eq!(cols[1].display_name, "Age");
|
||||||
|
assert_eq!(cols[2].display_name, "City");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn view_state_column_widths_computed() {
|
||||||
|
let mut m = ready_table_menu();
|
||||||
|
let vs = m.build_view_state();
|
||||||
|
let cols = vs.columns.as_ref().unwrap();
|
||||||
|
// "label" header is 5 chars, "charlie" is 7 chars: width should be 7
|
||||||
|
assert_eq!(cols[0].width, 7);
|
||||||
|
// "Age" header is 3 chars, max cell "35" is 2 chars: width should be 3
|
||||||
|
assert_eq!(cols[1].width, 3);
|
||||||
|
// "City" header is 4 chars, "vancouver" is 9 chars: width should be 9
|
||||||
|
assert_eq!(cols[2].width, 9);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn view_state_items_have_column_values() {
|
||||||
|
let mut m = ready_table_menu();
|
||||||
|
let vs = m.build_view_state();
|
||||||
|
let item = &vs.visible_items[0];
|
||||||
|
assert!(item.column_values.is_some());
|
||||||
|
let vals = item.column_values.as_ref().unwrap();
|
||||||
|
assert_eq!(vals, &["alice", "30", "toronto"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn view_state_no_columns_without_config() {
|
||||||
|
let mut m = ready_menu();
|
||||||
|
let vs = m.build_view_state();
|
||||||
|
assert!(vs.columns.is_none());
|
||||||
|
assert!(vs.visible_items[0].column_values.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn table_mode_filter_still_works() {
|
||||||
|
let mut m = ready_table_menu();
|
||||||
|
m.apply_action(Action::UpdateFilter("bob".to_string()));
|
||||||
|
let vs = m.build_view_state();
|
||||||
|
assert_eq!(vs.total_filtered, 1);
|
||||||
|
assert_eq!(vs.visible_items[0].label, "bob");
|
||||||
|
// Column values should still be present
|
||||||
|
let vals = vs.visible_items[0].column_values.as_ref().unwrap();
|
||||||
|
assert_eq!(vals, &["bob", "25", "vancouver"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn table_mode_column_widths_change_with_filter() {
|
||||||
|
let mut m = ready_table_menu();
|
||||||
|
m.apply_action(Action::UpdateFilter("alice".to_string()));
|
||||||
|
let vs = m.build_view_state();
|
||||||
|
let cols = vs.columns.as_ref().unwrap();
|
||||||
|
// Only "alice" visible now, so label width = max(5, 5) = 5
|
||||||
|
assert_eq!(cols[0].width, 5);
|
||||||
|
// City "toronto" (7) vs header "City" (4): width = 7
|
||||||
|
assert_eq!(cols[2].width, 7);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -132,20 +132,26 @@ pub fn parse_action(line_number: usize, line: &str) -> Result<ScriptAction, Scri
|
|||||||
line_number,
|
line_number,
|
||||||
line,
|
line,
|
||||||
"set-mode",
|
"set-mode",
|
||||||
"missing mode value (insert or normal)".to_string(),
|
"missing mode value (insert, normal, or visual)".to_string(),
|
||||||
));
|
));
|
||||||
};
|
};
|
||||||
match mode_str.trim() {
|
match mode_str.trim() {
|
||||||
"insert" => Ok(ScriptAction::Core(Action::SetMode(Mode::Insert))),
|
"insert" => Ok(ScriptAction::Core(Action::SetMode(Mode::Insert))),
|
||||||
"normal" => Ok(ScriptAction::Core(Action::SetMode(Mode::Normal))),
|
"normal" => Ok(ScriptAction::Core(Action::SetMode(Mode::Normal))),
|
||||||
|
"visual" => Ok(ScriptAction::Core(Action::SetMode(Mode::Visual))),
|
||||||
other => Err(invalid_arg(
|
other => Err(invalid_arg(
|
||||||
line_number,
|
line_number,
|
||||||
line,
|
line,
|
||||||
"set-mode",
|
"set-mode",
|
||||||
format!("unknown mode '{other}', expected insert or normal"),
|
format!("unknown mode '{other}', expected insert, normal, or visual"),
|
||||||
)),
|
)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
"toggle-select" => Ok(ScriptAction::Core(Action::ToggleSelect)),
|
||||||
|
"select-all" => Ok(ScriptAction::Core(Action::SelectAll)),
|
||||||
|
"deselect-all" => Ok(ScriptAction::Core(Action::ClearSelections)),
|
||||||
|
"undo-selection" => Ok(ScriptAction::Core(Action::UndoSelection)),
|
||||||
|
"redo-selection" => Ok(ScriptAction::Core(Action::RedoSelection)),
|
||||||
"confirm" => Ok(ScriptAction::Core(Action::Confirm)),
|
"confirm" => Ok(ScriptAction::Core(Action::Confirm)),
|
||||||
"cancel" => Ok(ScriptAction::Core(Action::Cancel)),
|
"cancel" => Ok(ScriptAction::Core(Action::Cancel)),
|
||||||
"resize" => {
|
"resize" => {
|
||||||
@@ -439,7 +445,10 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_set_mode_invalid() {
|
fn parse_set_mode_invalid() {
|
||||||
assert!(parse_action(1, "set-mode visual").is_err());
|
assert_eq!(
|
||||||
|
parse_action(1, "set-mode visual").unwrap_or(ScriptAction::Comment),
|
||||||
|
ScriptAction::Core(Action::SetMode(Mode::Visual))
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -645,6 +654,30 @@ mod tests {
|
|||||||
assert!(parse_action(1, "half-page-up xyz").is_err());
|
assert!(parse_action(1, "half-page-up xyz").is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_selection_actions() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_action(1, "toggle-select").unwrap_or(ScriptAction::Comment),
|
||||||
|
ScriptAction::Core(Action::ToggleSelect)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_action(1, "select-all").unwrap_or(ScriptAction::Comment),
|
||||||
|
ScriptAction::Core(Action::SelectAll)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_action(1, "deselect-all").unwrap_or(ScriptAction::Comment),
|
||||||
|
ScriptAction::Core(Action::ClearSelections)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_action(1, "undo-selection").unwrap_or(ScriptAction::Comment),
|
||||||
|
ScriptAction::Core(Action::UndoSelection)
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_action(1, "redo-selection").unwrap_or(ScriptAction::Comment),
|
||||||
|
ScriptAction::Core(Action::RedoSelection)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_set_mode_wrong_case() {
|
fn parse_set_mode_wrong_case() {
|
||||||
assert!(parse_action(1, "set-mode Insert").is_err());
|
assert!(parse_action(1, "set-mode Insert").is_err());
|
||||||
|
|||||||
@@ -286,6 +286,48 @@ pikl_tests! {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
menu mod multi_select_basic {
|
||||||
|
items: ["alpha", "bravo", "charlie", "delta"];
|
||||||
|
multi: true;
|
||||||
|
|
||||||
|
test toggle_one_then_confirm {
|
||||||
|
actions: [toggle-select, confirm]
|
||||||
|
selected_items: ["alpha"]
|
||||||
|
}
|
||||||
|
|
||||||
|
test toggle_two_then_confirm {
|
||||||
|
actions: [toggle-select, move-down, move-down, toggle-select, confirm]
|
||||||
|
selected_items: ["alpha", "charlie"]
|
||||||
|
}
|
||||||
|
|
||||||
|
test no_selection_falls_back_to_cursor {
|
||||||
|
actions: [move-down, confirm]
|
||||||
|
selected: "bravo"
|
||||||
|
}
|
||||||
|
|
||||||
|
test select_all_then_confirm {
|
||||||
|
actions: [select-all, confirm]
|
||||||
|
selected_items: ["alpha", "bravo", "charlie", "delta"]
|
||||||
|
}
|
||||||
|
|
||||||
|
test clear_selections_resets {
|
||||||
|
actions: [toggle-select, move-down, toggle-select, clear-selections, confirm]
|
||||||
|
// Falls back to cursor item (bravo, since we moved down once)
|
||||||
|
selected: "bravo"
|
||||||
|
}
|
||||||
|
|
||||||
|
test undo_restores_previous {
|
||||||
|
actions: [toggle-select, move-down, toggle-select, undo-selection, confirm]
|
||||||
|
// Undo reverts the second toggle, only alpha remains
|
||||||
|
selected_items: ["alpha"]
|
||||||
|
}
|
||||||
|
|
||||||
|
test redo_after_undo {
|
||||||
|
actions: [toggle-select, move-down, toggle-select, undo-selection, redo-selection, confirm]
|
||||||
|
selected_items: ["alpha", "bravo"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
nav mod half_page_small_height {
|
nav mod half_page_small_height {
|
||||||
viewport: { height: 2, count: 10 };
|
viewport: { height: 2, count: 10 };
|
||||||
|
|
||||||
|
|||||||
@@ -92,12 +92,15 @@ fn gen_headless(case: &TestCase, fixtures: &Fixtures) -> syn::Result<TokenStream
|
|||||||
// Build script string from actions
|
// Build script string from actions
|
||||||
let script = build_headless_script(&case.actions);
|
let script = build_headless_script(&case.actions);
|
||||||
|
|
||||||
// Build extra CLI args (e.g. --label-key)
|
// Build extra CLI args (e.g. --label-key, --multi)
|
||||||
let extra_args: Vec<TokenStream> = if let Some(ref key) = fixtures.label_key {
|
let mut extra_args: Vec<TokenStream> = Vec::new();
|
||||||
vec![quote! { "--label-key" }, quote! { #key }]
|
if let Some(ref key) = fixtures.label_key {
|
||||||
} else {
|
extra_args.push(quote! { "--label-key" });
|
||||||
Vec::new()
|
extra_args.push(quote! { #key });
|
||||||
};
|
}
|
||||||
|
if fixtures.multi == Some(true) {
|
||||||
|
extra_args.push(quote! { "--multi" });
|
||||||
|
}
|
||||||
|
|
||||||
// Build assertions
|
// Build assertions
|
||||||
let mut asserts = Vec::new();
|
let mut asserts = Vec::new();
|
||||||
@@ -138,6 +141,16 @@ fn gen_headless(case: &TestCase, fixtures: &Fixtures) -> syn::Result<TokenStream
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if asserts.is_empty() {
|
||||||
|
return Err(syn::Error::new(
|
||||||
|
case.name.span(),
|
||||||
|
format!(
|
||||||
|
"headless test '{}' has no assertions (expected stdout, stderr contains, or exit)",
|
||||||
|
case.name
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
Ok(quote! {
|
Ok(quote! {
|
||||||
#[test]
|
#[test]
|
||||||
fn #test_name() {
|
fn #test_name() {
|
||||||
@@ -327,7 +340,9 @@ fn gen_menu(case: &TestCase, fixtures: &Fixtures) -> syn::Result<TokenStream> {
|
|||||||
} else if let Some(ref expected) = case.selected {
|
} else if let Some(ref expected) = case.selected {
|
||||||
quote! {
|
quote! {
|
||||||
match &result {
|
match &result {
|
||||||
Ok(MenuResult::Selected { value, .. }) => {
|
Ok(MenuResult::Selected { items, .. }) => {
|
||||||
|
assert!(!items.is_empty(), "expected Selected with items, got empty");
|
||||||
|
let value = &items[0].0;
|
||||||
let got = value.as_str()
|
let got = value.as_str()
|
||||||
.or_else(|| value.get(#label_key).and_then(|v| v.as_str()))
|
.or_else(|| value.get(#label_key).and_then(|v| v.as_str()))
|
||||||
.unwrap_or("");
|
.unwrap_or("");
|
||||||
@@ -339,20 +354,51 @@ fn gen_menu(case: &TestCase, fixtures: &Fixtures) -> syn::Result<TokenStream> {
|
|||||||
other => panic!("expected Selected, got: {:?}", other),
|
other => panic!("expected Selected, got: {:?}", other),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else if let Some(ref expected) = case.selected_items {
|
||||||
|
quote! {
|
||||||
|
match &result {
|
||||||
|
Ok(MenuResult::Selected { items, .. }) => {
|
||||||
|
let mut labels: Vec<&str> = items.iter()
|
||||||
|
.filter_map(|(v, _)| v.as_str()
|
||||||
|
.or_else(|| v.get(#label_key).and_then(|v| v.as_str())))
|
||||||
|
.collect();
|
||||||
|
labels.sort();
|
||||||
|
let mut expected = vec![#(#expected),*];
|
||||||
|
expected.sort();
|
||||||
|
assert_eq!(
|
||||||
|
labels, expected,
|
||||||
|
"expected selected_items {:?}, got {:?}", expected, labels
|
||||||
|
);
|
||||||
|
}
|
||||||
|
other => panic!("expected Selected, got: {:?}", other),
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
// No assertion on result. Probably an error, but let it compile.
|
return Err(syn::Error::new(
|
||||||
quote! {}
|
case.name.span(),
|
||||||
|
format!(
|
||||||
|
"menu test '{}' has no result assertion (expected selected, selected_items, or cancelled)",
|
||||||
|
case.name
|
||||||
|
),
|
||||||
|
));
|
||||||
};
|
};
|
||||||
|
|
||||||
// If test expects cancellation via sender drop (no cancel action, no confirm),
|
// If test expects cancellation via sender drop (no cancel action, no confirm),
|
||||||
// we need to drop tx after sending actions.
|
// we need to drop tx after sending actions.
|
||||||
let drop_sender = quote! { drop(tx); };
|
let drop_sender = quote! { drop(tx); };
|
||||||
|
|
||||||
|
let multi_setup = if fixtures.multi == Some(true) {
|
||||||
|
quote! { menu.set_multi(true); }
|
||||||
|
} else {
|
||||||
|
quote! {}
|
||||||
|
};
|
||||||
|
|
||||||
Ok(quote! {
|
Ok(quote! {
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn #test_name() {
|
async fn #test_name() {
|
||||||
let items = vec![#(#item_exprs),*];
|
let items = vec![#(#item_exprs),*];
|
||||||
let (menu, tx) = MenuRunner::new(JsonMenu::new(items, #label_key.to_string()));
|
let (mut menu, tx) = MenuRunner::new(JsonMenu::new(items, #label_key.to_string()));
|
||||||
|
#multi_setup
|
||||||
let mut rx = menu.subscribe();
|
let mut rx = menu.subscribe();
|
||||||
let handle = tokio::spawn(async move { menu.run().await });
|
let handle = tokio::spawn(async move { menu.run().await });
|
||||||
|
|
||||||
@@ -427,6 +473,12 @@ fn menu_action_variant(name: &str) -> syn::Result<TokenStream> {
|
|||||||
"half-page-down" => quote! { Action::HalfPageDown(1) },
|
"half-page-down" => quote! { Action::HalfPageDown(1) },
|
||||||
"set-mode-insert" => quote! { Action::SetMode(pikl_core::event::Mode::Insert) },
|
"set-mode-insert" => quote! { Action::SetMode(pikl_core::event::Mode::Insert) },
|
||||||
"set-mode-normal" => quote! { Action::SetMode(pikl_core::event::Mode::Normal) },
|
"set-mode-normal" => quote! { Action::SetMode(pikl_core::event::Mode::Normal) },
|
||||||
|
"set-mode-visual" => quote! { Action::SetMode(pikl_core::event::Mode::Visual) },
|
||||||
|
"toggle-select" => quote! { Action::ToggleSelect },
|
||||||
|
"select-all" => quote! { Action::SelectAll },
|
||||||
|
"clear-selections" => quote! { Action::ClearSelections },
|
||||||
|
"undo-selection" => quote! { Action::UndoSelection },
|
||||||
|
"redo-selection" => quote! { Action::RedoSelection },
|
||||||
_ => {
|
_ => {
|
||||||
return Err(syn::Error::new(
|
return Err(syn::Error::new(
|
||||||
Span::call_site(),
|
Span::call_site(),
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ pub struct Fixtures {
|
|||||||
pub items: Option<Vec<String>>,
|
pub items: Option<Vec<String>>,
|
||||||
pub label_key: Option<String>,
|
pub label_key: Option<String>,
|
||||||
pub viewport: Option<(usize, usize)>, // (height, count)
|
pub viewport: Option<(usize, usize)>, // (height, count)
|
||||||
|
pub multi: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct TestCase {
|
pub struct TestCase {
|
||||||
@@ -44,6 +45,7 @@ pub struct TestCase {
|
|||||||
pub cursor: Option<usize>,
|
pub cursor: Option<usize>,
|
||||||
pub offset: Option<usize>,
|
pub offset: Option<usize>,
|
||||||
pub selected: Option<String>,
|
pub selected: Option<String>,
|
||||||
|
pub selected_items: Option<Vec<String>>,
|
||||||
pub cancelled: bool,
|
pub cancelled: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -89,6 +91,7 @@ impl Parse for TestModule {
|
|||||||
items: None,
|
items: None,
|
||||||
label_key: None,
|
label_key: None,
|
||||||
viewport: None,
|
viewport: None,
|
||||||
|
multi: None,
|
||||||
};
|
};
|
||||||
let mut tests = Vec::new();
|
let mut tests = Vec::new();
|
||||||
|
|
||||||
@@ -120,11 +123,18 @@ impl Parse for TestModule {
|
|||||||
fixtures.viewport = Some(parse_viewport_def(&content)?);
|
fixtures.viewport = Some(parse_viewport_def(&content)?);
|
||||||
eat_semi(&content);
|
eat_semi(&content);
|
||||||
}
|
}
|
||||||
|
"multi" => {
|
||||||
|
consume_ident_or_keyword(&content)?;
|
||||||
|
content.parse::<Token![:]>()?;
|
||||||
|
let val: syn::LitBool = content.parse()?;
|
||||||
|
fixtures.multi = Some(val.value);
|
||||||
|
eat_semi(&content);
|
||||||
|
}
|
||||||
_ => {
|
_ => {
|
||||||
return Err(syn::Error::new(
|
return Err(syn::Error::new(
|
||||||
content.span(),
|
content.span(),
|
||||||
format!(
|
format!(
|
||||||
"unexpected field '{ident_str}', expected test, items, label_key, or viewport"
|
"unexpected field '{ident_str}', expected test, items, label_key, viewport, or multi"
|
||||||
),
|
),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@@ -163,6 +173,7 @@ fn parse_test_case(input: ParseStream) -> syn::Result<TestCase> {
|
|||||||
cursor: None,
|
cursor: None,
|
||||||
offset: None,
|
offset: None,
|
||||||
selected: None,
|
selected: None,
|
||||||
|
selected_items: None,
|
||||||
cancelled: false,
|
cancelled: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -224,6 +235,10 @@ fn parse_test_case(input: ParseStream) -> syn::Result<TestCase> {
|
|||||||
let val: LitStr = content.parse()?;
|
let val: LitStr = content.parse()?;
|
||||||
case.selected = Some(val.value());
|
case.selected = Some(val.value());
|
||||||
}
|
}
|
||||||
|
"selected_items" => {
|
||||||
|
content.parse::<Token![:]>()?;
|
||||||
|
case.selected_items = Some(parse_string_list(&content)?);
|
||||||
|
}
|
||||||
"cancelled" => {
|
"cancelled" => {
|
||||||
// Just the keyword presence means true. Optionally parse `: true`.
|
// Just the keyword presence means true. Optionally parse `: true`.
|
||||||
if content.peek(Token![:]) {
|
if content.peek(Token![:]) {
|
||||||
|
|||||||
@@ -71,24 +71,36 @@ async fn run_inner(
|
|||||||
// Send initial resize
|
// Send initial resize
|
||||||
let size = terminal.size()?;
|
let size = terminal.size()?;
|
||||||
let list_height = size.height.saturating_sub(1);
|
let list_height = size.height.saturating_sub(1);
|
||||||
let _ = action_tx
|
if action_tx
|
||||||
.send(Action::Resize {
|
.send(Action::Resize {
|
||||||
height: list_height,
|
height: list_height,
|
||||||
})
|
})
|
||||||
.await;
|
.await
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
tracing::warn!("core channel closed before TUI started");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Local copy of filter text for immediate cursor positioning.
|
||||||
|
// Synced from core on every StateChanged. Core is the source
|
||||||
|
// of truth: if IPC or a hook changes the filter, the next
|
||||||
|
// broadcast overwrites this.
|
||||||
let mut filter_text = String::new();
|
let mut filter_text = String::new();
|
||||||
let mut view_state: Option<ViewState> = None;
|
let mut view_state: Option<ViewState> = None;
|
||||||
let mut event_stream = EventStream::new();
|
let mut event_stream = EventStream::new();
|
||||||
let mut mode = Mode::Insert;
|
let mut mode = Mode::Insert;
|
||||||
let mut pending = PendingKey::None;
|
let mut pending = PendingKey::None;
|
||||||
let mut last_generation: u64 = 0;
|
let mut last_generation: u64 = 0;
|
||||||
|
let mut multi_enabled = false;
|
||||||
|
let mut visual_anchor: Option<usize> = None;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
if let Some(ref vs) = view_state {
|
if let Some(ref vs) = view_state {
|
||||||
let ft = filter_text.clone();
|
let ft = filter_text.clone();
|
||||||
|
let anchor = visual_anchor;
|
||||||
terminal.draw(|frame| {
|
terminal.draw(|frame| {
|
||||||
render_menu(frame, vs, &ft);
|
render_menu(frame, vs, &ft, anchor);
|
||||||
})?;
|
})?;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,18 +112,37 @@ async fn run_inner(
|
|||||||
let event = event_result?;
|
let event = event_result?;
|
||||||
match event {
|
match event {
|
||||||
Event::Key(key) => {
|
Event::Key(key) => {
|
||||||
if let Some(action) = map_key_event(key, &mut filter_text, mode, &mut pending) {
|
// Handle multi-action keys (Tab, Space in normal/visual)
|
||||||
|
// before the single-action map_key_event path.
|
||||||
|
let handled = handle_multi_action_key(
|
||||||
|
key, mode, multi_enabled, &mut visual_anchor,
|
||||||
|
&view_state, action_tx,
|
||||||
|
).await;
|
||||||
|
if !handled
|
||||||
|
&& let Some(action) = map_key_event(key, &mut filter_text, mode, &mut pending)
|
||||||
|
{
|
||||||
// Track mode locally for key mapping
|
// Track mode locally for key mapping
|
||||||
if let Action::SetMode(m) = &action {
|
if let Action::SetMode(m) = &action {
|
||||||
|
if *m == Mode::Visual {
|
||||||
|
if let Some(ref vs) = view_state {
|
||||||
|
visual_anchor = Some(vs.cursor);
|
||||||
|
}
|
||||||
|
} else if mode == Mode::Visual {
|
||||||
|
visual_anchor = None;
|
||||||
|
}
|
||||||
mode = *m;
|
mode = *m;
|
||||||
pending = PendingKey::None;
|
pending = PendingKey::None;
|
||||||
}
|
}
|
||||||
let _ = action_tx.send(action).await;
|
if action_tx.send(action).await.is_err() {
|
||||||
|
break; // core is gone
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Event::Resize(_, h) => {
|
Event::Resize(_, h) => {
|
||||||
let list_height = h.saturating_sub(1);
|
let list_height = h.saturating_sub(1);
|
||||||
let _ = action_tx.send(Action::Resize { height: list_height }).await;
|
if action_tx.send(Action::Resize { height: list_height }).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
@@ -136,6 +167,7 @@ async fn run_inner(
|
|||||||
mode = vs.mode;
|
mode = vs.mode;
|
||||||
pending = PendingKey::None;
|
pending = PendingKey::None;
|
||||||
}
|
}
|
||||||
|
multi_enabled = vs.multi_enabled;
|
||||||
view_state = Some(vs);
|
view_state = Some(vs);
|
||||||
}
|
}
|
||||||
Ok(MenuEvent::Selected(_) | MenuEvent::Quicklist(_) | MenuEvent::Cancelled) => {
|
Ok(MenuEvent::Selected(_) | MenuEvent::Quicklist(_) | MenuEvent::Cancelled) => {
|
||||||
@@ -155,12 +187,112 @@ async fn run_inner(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Handle keys that need to send multiple actions (Tab, Space
|
||||||
|
/// in normal mode, visual mode apply/cancel). Returns true
|
||||||
|
/// if the key was consumed.
|
||||||
|
async fn handle_multi_action_key(
|
||||||
|
key: KeyEvent,
|
||||||
|
mode: Mode,
|
||||||
|
multi_enabled: bool,
|
||||||
|
visual_anchor: &mut Option<usize>,
|
||||||
|
view_state: &Option<ViewState>,
|
||||||
|
action_tx: &mpsc::Sender<Action>,
|
||||||
|
) -> bool {
|
||||||
|
match mode {
|
||||||
|
Mode::Insert => {
|
||||||
|
if !multi_enabled {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Tab => {
|
||||||
|
let _ = action_tx.send(Action::ToggleSelect).await;
|
||||||
|
let _ = action_tx.send(Action::MoveDown(1)).await;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
KeyCode::BackTab => {
|
||||||
|
let _ = action_tx.send(Action::ToggleSelect).await;
|
||||||
|
let _ = action_tx.send(Action::MoveUp(1)).await;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Mode::Normal => {
|
||||||
|
if !multi_enabled {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
match (key.code, key.modifiers) {
|
||||||
|
(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
|
||||||
|
}
|
||||||
|
(KeyCode::Tab, _) => {
|
||||||
|
let _ = action_tx.send(Action::ToggleSelect).await;
|
||||||
|
let _ = action_tx.send(Action::MoveDown(1)).await;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
(KeyCode::BackTab, _) => {
|
||||||
|
let _ = action_tx.send(Action::ToggleSelect).await;
|
||||||
|
let _ = action_tx.send(Action::MoveUp(1)).await;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Mode::Visual => {
|
||||||
|
match (key.code, key.modifiers) {
|
||||||
|
(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;
|
||||||
|
}
|
||||||
|
*visual_anchor = None;
|
||||||
|
let _ = action_tx.send(Action::SetMode(Mode::Normal)).await;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 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) {
|
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;
|
||||||
@@ -169,9 +301,10 @@ fn render_menu(frame: &mut ratatui::Frame, vs: &ViewState, filter_text: &str) {
|
|||||||
let mode_indicator = match vs.mode {
|
let mode_indicator = match vs.mode {
|
||||||
Mode::Insert => "[I]",
|
Mode::Insert => "[I]",
|
||||||
Mode::Normal => "[N]",
|
Mode::Normal => "[N]",
|
||||||
|
Mode::Visual => "[V]",
|
||||||
};
|
};
|
||||||
|
|
||||||
let prompt = Paragraph::new(Line::from(vec![
|
let mut prompt_spans = vec![
|
||||||
Span::styled(
|
Span::styled(
|
||||||
format!("{mode_indicator}> "),
|
format!("{mode_indicator}> "),
|
||||||
Style::default().fg(Color::Cyan),
|
Style::default().fg(Color::Cyan),
|
||||||
@@ -181,7 +314,16 @@ fn render_menu(frame: &mut ratatui::Frame, vs: &ViewState, filter_text: &str) {
|
|||||||
format!(" {filtered_count}/{total_count}"),
|
format!(" {filtered_count}/{total_count}"),
|
||||||
Style::default().fg(Color::DarkGray),
|
Style::default().fg(Color::DarkGray),
|
||||||
),
|
),
|
||||||
]));
|
];
|
||||||
|
|
||||||
|
if vs.selection_count > 0 {
|
||||||
|
prompt_spans.push(Span::styled(
|
||||||
|
format!(" [{}]", vs.selection_count),
|
||||||
|
Style::default().fg(Color::Yellow),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let prompt = Paragraph::new(Line::from(prompt_spans));
|
||||||
frame.render_widget(prompt, chunks[0]);
|
frame.render_widget(prompt, chunks[0]);
|
||||||
|
|
||||||
// mode_indicator len + "> " = mode_indicator.len() + 2
|
// mode_indicator len + "> " = mode_indicator.len() + 2
|
||||||
@@ -192,23 +334,111 @@ fn render_menu(frame: &mut ratatui::Frame, vs: &ViewState, filter_text: &str) {
|
|||||||
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
|
||||||
|
let visual_range = if vs.mode == Mode::Visual {
|
||||||
|
visual_anchor.map(|anchor| {
|
||||||
|
let min = anchor.min(vs.cursor);
|
||||||
|
let max = anchor.max(vs.cursor);
|
||||||
|
min..=max
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
let items: Vec<ListItem> = vs
|
let items: Vec<ListItem> = vs
|
||||||
.visible_items
|
.visible_items
|
||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.map(|(i, vi)| {
|
.map(|(i, vi)| {
|
||||||
let style = if i == vs.cursor {
|
let in_visual = visual_range.as_ref().is_some_and(|r| r.contains(&i));
|
||||||
|
let mut style = if i == vs.cursor {
|
||||||
Style::default().add_modifier(Modifier::REVERSED)
|
Style::default().add_modifier(Modifier::REVERSED)
|
||||||
|
} else if in_visual {
|
||||||
|
Style::default().fg(Color::Black).bg(Color::Blue)
|
||||||
} else {
|
} else {
|
||||||
Style::default()
|
Style::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if vi.selected {
|
||||||
|
style = style.fg(Color::Green);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Table mode: render cells padded to column widths
|
||||||
|
if let (Some(col_vals), Some(columns)) = (&vi.column_values, &vs.columns) {
|
||||||
|
let marker = if vs.multi_enabled {
|
||||||
|
if vi.selected { "* " } else { " " }
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
let cell_spans: Vec<Span> = columns
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.flat_map(|(ci, col)| {
|
||||||
|
let mut spans = Vec::new();
|
||||||
|
if ci > 0 {
|
||||||
|
spans.push(Span::styled(
|
||||||
|
" \u{2502} ",
|
||||||
|
Style::default().fg(Color::DarkGray),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let cell = col_vals.get(ci).map(|s| s.as_str()).unwrap_or("");
|
||||||
|
spans.push(Span::raw(format!(
|
||||||
|
"{:<width$}",
|
||||||
|
cell,
|
||||||
|
width = col.width as usize
|
||||||
|
)));
|
||||||
|
spans
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let mut all_spans = Vec::new();
|
||||||
|
if !marker.is_empty() {
|
||||||
|
all_spans.push(Span::raw(marker));
|
||||||
|
}
|
||||||
|
all_spans.extend(cell_spans);
|
||||||
|
ListItem::new(Line::from(all_spans)).style(style)
|
||||||
|
} else {
|
||||||
|
let marker = if vi.selected { "* " } else { " " };
|
||||||
let text = vi.formatted_text.as_deref().unwrap_or(vi.label.as_str());
|
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)
|
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
|
||||||
@@ -223,6 +453,7 @@ fn map_key_event(
|
|||||||
match mode {
|
match mode {
|
||||||
Mode::Insert => map_insert_mode(key, filter_text),
|
Mode::Insert => map_insert_mode(key, filter_text),
|
||||||
Mode::Normal => map_normal_mode(key, pending),
|
Mode::Normal => map_normal_mode(key, pending),
|
||||||
|
Mode::Visual => map_visual_mode(key, pending),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -289,6 +520,12 @@ fn map_normal_mode(key: KeyEvent, pending: &mut PendingKey) -> Option<Action> {
|
|||||||
(KeyCode::Char('q'), m) if !m.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => {
|
(KeyCode::Char('q'), m) if !m.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => {
|
||||||
Some(Action::Cancel)
|
Some(Action::Cancel)
|
||||||
}
|
}
|
||||||
|
(KeyCode::Char('V'), _) => Some(Action::SetMode(Mode::Visual)),
|
||||||
|
(KeyCode::Char('U'), _) => Some(Action::ClearSelections),
|
||||||
|
(KeyCode::Char('u'), m) if !m.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => {
|
||||||
|
Some(Action::UndoSelection)
|
||||||
|
}
|
||||||
|
(KeyCode::Char('r'), KeyModifiers::CONTROL) => Some(Action::RedoSelection),
|
||||||
(KeyCode::Enter, _) => Some(Action::Confirm),
|
(KeyCode::Enter, _) => Some(Action::Confirm),
|
||||||
(KeyCode::Esc, _) => Some(Action::Cancel),
|
(KeyCode::Esc, _) => Some(Action::Cancel),
|
||||||
(KeyCode::Up, _) => Some(Action::MoveUp(1)),
|
(KeyCode::Up, _) => Some(Action::MoveUp(1)),
|
||||||
@@ -297,6 +534,41 @@ fn map_normal_mode(key: KeyEvent, pending: &mut PendingKey) -> Option<Action> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Visual mode: movement keys extend the range, V cancels,
|
||||||
|
/// Space/Enter apply (handled in handle_multi_action_key).
|
||||||
|
fn map_visual_mode(key: KeyEvent, pending: &mut PendingKey) -> Option<Action> {
|
||||||
|
// Handle pending `g` key for `gg` sequence
|
||||||
|
if *pending == PendingKey::G {
|
||||||
|
*pending = PendingKey::None;
|
||||||
|
if key.code == KeyCode::Char('g')
|
||||||
|
&& !key
|
||||||
|
.modifiers
|
||||||
|
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT)
|
||||||
|
{
|
||||||
|
return Some(Action::MoveToTop);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match (key.code, key.modifiers) {
|
||||||
|
(KeyCode::Char('j'), m) | (KeyCode::Down, m) if !m.intersects(KeyModifiers::ALT) => {
|
||||||
|
Some(Action::MoveDown(1))
|
||||||
|
}
|
||||||
|
(KeyCode::Char('k'), m) | (KeyCode::Up, m) if !m.intersects(KeyModifiers::ALT) => {
|
||||||
|
Some(Action::MoveUp(1))
|
||||||
|
}
|
||||||
|
(KeyCode::Char('G'), _) => Some(Action::MoveToBottom),
|
||||||
|
(KeyCode::Char('g'), m) if !m.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => {
|
||||||
|
*pending = PendingKey::G;
|
||||||
|
None
|
||||||
|
}
|
||||||
|
// V cancels visual mode, no selection applied
|
||||||
|
(KeyCode::Char('V'), _) => Some(Action::SetMode(Mode::Normal)),
|
||||||
|
// Escape always cancels the menu
|
||||||
|
(KeyCode::Esc, _) => Some(Action::Cancel),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -331,16 +603,22 @@ mod tests {
|
|||||||
label: "alpha".into(),
|
label: "alpha".into(),
|
||||||
formatted_text: None,
|
formatted_text: None,
|
||||||
index: 0,
|
index: 0,
|
||||||
|
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,
|
||||||
|
column_values: None,
|
||||||
},
|
},
|
||||||
VisibleItem {
|
VisibleItem {
|
||||||
label: "charlie".into(),
|
label: "charlie".into(),
|
||||||
formatted_text: None,
|
formatted_text: None,
|
||||||
index: 2,
|
index: 2,
|
||||||
|
selected: false,
|
||||||
|
column_values: None,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
cursor: 0,
|
cursor: 0,
|
||||||
@@ -348,6 +626,9 @@ mod tests {
|
|||||||
total_items: 5,
|
total_items: 5,
|
||||||
total_filtered: 3,
|
total_filtered: 3,
|
||||||
mode: Mode::Insert,
|
mode: Mode::Insert,
|
||||||
|
selection_count: 0,
|
||||||
|
multi_enabled: false,
|
||||||
|
columns: None,
|
||||||
generation: 1,
|
generation: 1,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -703,7 +984,7 @@ mod tests {
|
|||||||
let mut terminal = Terminal::new(backend).ok().expect("test terminal");
|
let mut terminal = Terminal::new(backend).ok().expect("test terminal");
|
||||||
terminal
|
terminal
|
||||||
.draw(|frame| {
|
.draw(|frame| {
|
||||||
render_menu(frame, vs, filter);
|
render_menu(frame, vs, filter, None);
|
||||||
})
|
})
|
||||||
.ok()
|
.ok()
|
||||||
.expect("draw");
|
.expect("draw");
|
||||||
@@ -820,6 +1101,9 @@ mod tests {
|
|||||||
total_items: 0,
|
total_items: 0,
|
||||||
total_filtered: 0,
|
total_filtered: 0,
|
||||||
mode: Mode::Insert,
|
mode: Mode::Insert,
|
||||||
|
selection_count: 0,
|
||||||
|
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, "");
|
||||||
@@ -853,7 +1137,7 @@ mod tests {
|
|||||||
let mut terminal = Terminal::new(backend).ok().expect("test terminal");
|
let mut terminal = Terminal::new(backend).ok().expect("test terminal");
|
||||||
terminal
|
terminal
|
||||||
.draw(|frame| {
|
.draw(|frame| {
|
||||||
render_menu(frame, &vs, "hi");
|
render_menu(frame, &vs, "hi", None);
|
||||||
})
|
})
|
||||||
.ok()
|
.ok()
|
||||||
.expect("draw");
|
.expect("draw");
|
||||||
@@ -871,7 +1155,7 @@ mod tests {
|
|||||||
let mut terminal = Terminal::new(backend).ok().expect("test terminal");
|
let mut terminal = Terminal::new(backend).ok().expect("test terminal");
|
||||||
terminal
|
terminal
|
||||||
.draw(|frame| {
|
.draw(|frame| {
|
||||||
render_menu(frame, &vs, "");
|
render_menu(frame, &vs, "", None);
|
||||||
})
|
})
|
||||||
.ok()
|
.ok()
|
||||||
.expect("draw");
|
.expect("draw");
|
||||||
@@ -893,6 +1177,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();
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -81,7 +76,9 @@ impl HookHandler for ShellHandlerHook {
|
|||||||
let kind = event.kind();
|
let kind = event.kind();
|
||||||
if let Some(tx) = self.event_txs.get(&kind) {
|
if let Some(tx) = self.event_txs.get(&kind) {
|
||||||
// Non-blocking send. If the channel is full, drop the event.
|
// Non-blocking send. If the channel is full, drop the event.
|
||||||
let _ = tx.try_send(event);
|
if let Err(e) = tx.try_send(event) {
|
||||||
|
tracing::debug!(error = %e, "handler event channel full, dropping event");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -133,19 +130,17 @@ 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 = match serde_json::to_string(&event) {
|
||||||
if stdin_writer
|
Ok(j) => j,
|
||||||
.write_all(json.as_bytes())
|
Err(e) => {
|
||||||
.await
|
tracing::warn!(error = %e, "failed to serialize hook event, skipping");
|
||||||
.is_err()
|
continue;
|
||||||
{
|
}
|
||||||
|
};
|
||||||
|
if stdin_writer.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,10 +152,7 @@ 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),
|
|
||||||
child.wait(),
|
|
||||||
)
|
|
||||||
.await
|
.await
|
||||||
.is_ok();
|
.is_ok();
|
||||||
|
|
||||||
@@ -177,10 +169,7 @@ 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),
|
|
||||||
child.wait(),
|
|
||||||
)
|
|
||||||
.await
|
.await
|
||||||
.is_ok();
|
.is_ok();
|
||||||
if !termed {
|
if !termed {
|
||||||
@@ -194,11 +183,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(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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");
|
||||||
|
|||||||
@@ -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};
|
||||||
@@ -119,6 +121,22 @@ struct Cli {
|
|||||||
/// Wrap output in structured JSON with action metadata
|
/// Wrap output in structured JSON with action metadata
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
structured: bool,
|
structured: bool,
|
||||||
|
|
||||||
|
/// Enable multi-select mode (Space/Tab to toggle, V for visual)
|
||||||
|
#[arg(long)]
|
||||||
|
multi: bool,
|
||||||
|
|
||||||
|
/// 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<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
@@ -177,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);
|
||||||
@@ -193,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!(
|
||||||
@@ -214,10 +252,11 @@ fn main() {
|
|||||||
let start_mode = match cli.start_mode.as_str() {
|
let start_mode = match cli.start_mode.as_str() {
|
||||||
"insert" => Mode::Insert,
|
"insert" => Mode::Insert,
|
||||||
"normal" => Mode::Normal,
|
"normal" => Mode::Normal,
|
||||||
|
"visual" => Mode::Visual,
|
||||||
other => {
|
other => {
|
||||||
let _ = writeln!(
|
let _ = writeln!(
|
||||||
std::io::stderr().lock(),
|
std::io::stderr().lock(),
|
||||||
"pikl: unknown mode '{other}', expected insert or normal"
|
"pikl: unknown mode '{other}', expected insert, normal, or visual"
|
||||||
);
|
);
|
||||||
std::process::exit(2);
|
std::process::exit(2);
|
||||||
}
|
}
|
||||||
@@ -225,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());
|
||||||
@@ -243,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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -309,12 +352,10 @@ struct CompositeHookHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl HookHandler for CompositeHookHandler {
|
impl HookHandler for CompositeHookHandler {
|
||||||
fn handle(
|
fn handle(&self, event: pikl_core::hook::HookEvent) -> Result<(), PiklError> {
|
||||||
&self,
|
// Exec hooks are fire-and-forget: errors are logged
|
||||||
event: pikl_core::hook::HookEvent,
|
// internally by ShellExecHandler, not propagated here.
|
||||||
) -> Result<(), PiklError> {
|
// Handler hooks are bidirectional, so errors propagate.
|
||||||
// Both fire. Exec is fire-and-forget, handler may
|
|
||||||
// send responses through action_tx.
|
|
||||||
let _ = self.exec.handle(event.clone());
|
let _ = self.exec.handle(event.clone());
|
||||||
if let Some(ref h) = self.handler {
|
if let Some(ref h) = self.handler {
|
||||||
h.handle(event)?;
|
h.handle(event)?;
|
||||||
@@ -330,9 +371,12 @@ 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_selection_order(cli.selection_order);
|
||||||
|
|
||||||
if let Some((_handler, dispatcher)) = build_hook_handler(cli, &action_tx) {
|
if let Some((_handler, dispatcher)) = build_hook_handler(cli, &action_tx) {
|
||||||
menu.set_dispatcher(dispatcher);
|
menu.set_dispatcher(dispatcher);
|
||||||
@@ -377,9 +421,12 @@ 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_selection_order(cli.selection_order);
|
||||||
|
|
||||||
if let Some((_handler, dispatcher)) = build_hook_handler(cli, &action_tx) {
|
if let Some((_handler, dispatcher)) = build_hook_handler(cli, &action_tx) {
|
||||||
menu.set_dispatcher(dispatcher);
|
menu.set_dispatcher(dispatcher);
|
||||||
@@ -409,16 +456,20 @@ async fn run_interactive(
|
|||||||
fn handle_result(result: Result<MenuResult, PiklError>, cli: &Cli) {
|
fn handle_result(result: Result<MenuResult, PiklError>, cli: &Cli) {
|
||||||
let mut out = std::io::stdout().lock();
|
let mut out = std::io::stdout().lock();
|
||||||
match result {
|
match result {
|
||||||
Ok(MenuResult::Selected { value, index }) => {
|
Ok(MenuResult::Selected { items }) => {
|
||||||
if cli.structured {
|
if cli.structured {
|
||||||
|
for (value, index) in items {
|
||||||
let output = OutputItem {
|
let output = OutputItem {
|
||||||
value,
|
value,
|
||||||
action: OutputAction::Select,
|
action: OutputAction::Select,
|
||||||
index,
|
index,
|
||||||
};
|
};
|
||||||
let _ = write_output_json(&mut out, &output);
|
let _ = write_output_json(&mut out, &output);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
let _ = write_plain_value(&mut out, &value);
|
for (value, _) in &items {
|
||||||
|
let _ = write_plain_value(&mut out, value);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(MenuResult::Quicklist { items }) => {
|
Ok(MenuResult::Quicklist { items }) => {
|
||||||
@@ -457,7 +508,7 @@ fn handle_result(result: Result<MenuResult, PiklError>, cli: &Cli) {
|
|||||||
|
|
||||||
/// Serialize an OutputItem as JSON and write it to the given writer.
|
/// Serialize an OutputItem as JSON and write it to the given writer.
|
||||||
fn write_output_json(writer: &mut impl Write, output: &OutputItem) -> Result<(), std::io::Error> {
|
fn write_output_json(writer: &mut impl Write, output: &OutputItem) -> Result<(), std::io::Error> {
|
||||||
let json = serde_json::to_string(output).unwrap_or_default();
|
let json = serde_json::to_string(output).map_err(|e| std::io::Error::other(e.to_string()))?;
|
||||||
writeln!(writer, "{json}")
|
writeln!(writer, "{json}")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -467,7 +518,8 @@ fn write_plain_value(writer: &mut impl Write, value: &Value) -> Result<(), std::
|
|||||||
match value {
|
match value {
|
||||||
Value::String(s) => writeln!(writer, "{s}"),
|
Value::String(s) => writeln!(writer, "{s}"),
|
||||||
_ => {
|
_ => {
|
||||||
let json = serde_json::to_string(value).unwrap_or_default();
|
let json =
|
||||||
|
serde_json::to_string(value).map_err(|e| std::io::Error::other(e.to_string()))?;
|
||||||
writeln!(writer, "{json}")
|
writeln!(writer, "{json}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -476,15 +528,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)) {
|
||||||
@@ -503,6 +566,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.
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -284,6 +284,44 @@ pikl_tests! {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
headless mod multi_select_headless {
|
||||||
|
items: ["alpha", "bravo", "charlie"];
|
||||||
|
multi: true;
|
||||||
|
|
||||||
|
test toggle_and_confirm {
|
||||||
|
actions: [toggle-select, confirm]
|
||||||
|
stdout: "alpha"
|
||||||
|
exit: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
test toggle_two_and_confirm {
|
||||||
|
actions: [toggle-select, move-down, move-down, toggle-select, confirm]
|
||||||
|
stdout: "alpha"
|
||||||
|
exit: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
test select_all_and_confirm {
|
||||||
|
actions: [select-all, confirm]
|
||||||
|
stdout: "alpha"
|
||||||
|
exit: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
test deselect_all_clears {
|
||||||
|
actions: [toggle-select, deselect-all, confirm]
|
||||||
|
// Falls back to cursor item
|
||||||
|
stdout: "alpha"
|
||||||
|
exit: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
test without_multi_toggle_is_noop {
|
||||||
|
// This test does NOT have multi: true (uses the shared fixture)
|
||||||
|
// but toggle-select still just selects cursor on confirm
|
||||||
|
actions: [toggle-select, confirm]
|
||||||
|
stdout: "alpha"
|
||||||
|
exit: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
headless mod pipeline_headless {
|
headless mod pipeline_headless {
|
||||||
items: ["error_log", "warning_temp", "info_log"];
|
items: ["error_log", "warning_temp", "info_log"];
|
||||||
|
|
||||||
|
|||||||
@@ -60,9 +60,23 @@ selection. Useful for live preview pipelines.
|
|||||||
|
|
||||||
### CSV / TSV
|
### CSV / TSV
|
||||||
|
|
||||||
`--input-format csv` or `--input-format tsv` parses columnar
|
`--input-format csv` or `--input-format tsv` parses delimited
|
||||||
input. First row is headers, which become field names. Maps
|
input. First row is always headers, which become field names.
|
||||||
naturally to table/column display mode.
|
|
||||||
|
Each row becomes a JSON object. Known fields (`label`,
|
||||||
|
`sublabel`, `icon`, `group`) are promoted to the top level.
|
||||||
|
Everything else goes into a `meta` object. If no column
|
||||||
|
matches the label key, the first column is used as `label`.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# CSV rows become normal Items, so filtering, hooks, and
|
||||||
|
# multi-select all work unchanged.
|
||||||
|
echo "name,age,city\nalice,30,toronto" | pikl --input-format csv
|
||||||
|
```
|
||||||
|
|
||||||
|
When `--input-format csv` or `tsv` is used without
|
||||||
|
`--columns`, column config is auto-generated from the CSV
|
||||||
|
headers.
|
||||||
|
|
||||||
## Event Hooks
|
## Event Hooks
|
||||||
|
|
||||||
@@ -80,7 +94,7 @@ There are two ways to respond to them: **exec hooks** and
|
|||||||
| `on-select` | User confirms (Enter) | Apply the choice |
|
| `on-select` | User confirms (Enter) | Apply the choice |
|
||||||
| `on-cancel` | User cancels (Escape) | Revert preview |
|
| `on-cancel` | User cancels (Escape) | Revert preview |
|
||||||
| `on-filter` | Filter text changes | Dynamic item reloading |
|
| `on-filter` | Filter text changes | Dynamic item reloading |
|
||||||
| `on-mark` | User marks/unmarks an item | Visual feedback |
|
| `on-toggle` | User toggles an item's selection | Visual feedback (deferred) |
|
||||||
|
|
||||||
### Exec Hooks (fire-and-forget)
|
### Exec Hooks (fire-and-forget)
|
||||||
|
|
||||||
@@ -383,16 +397,14 @@ designed.
|
|||||||
| `Ctrl+D` / `Ctrl+U` | Half-page down / up |
|
| `Ctrl+D` / `Ctrl+U` | Half-page down / up |
|
||||||
| `Ctrl+F` / `Ctrl+B` | Full page down / up |
|
| `Ctrl+F` / `Ctrl+B` | Full page down / up |
|
||||||
| `/` | Enter filter (insert) mode |
|
| `/` | Enter filter (insert) mode |
|
||||||
| `n` / `N` | Next / previous filter match |
|
| `Space` | Toggle select + move down (`--multi`) |
|
||||||
| `m{a-z}` | Set mark at current position |
|
| `V` | Enter visual line mode (`--multi`) |
|
||||||
| `'{a-z}` | Jump to mark |
|
| `u` | Undo last selection change (`--multi`) |
|
||||||
| `"a` | Select into register `a` |
|
| `U` | Clear all selections (`--multi`) |
|
||||||
| `v` | Toggle multi-select on current item |
|
| `Ctrl+R` | Redo selection change (`--multi`) |
|
||||||
| `V` | Visual line select mode |
|
|
||||||
| `Enter` | Confirm selection |
|
| `Enter` | Confirm selection |
|
||||||
| `Escape` | Cancel and exit |
|
| `Escape` | Cancel and exit |
|
||||||
| `q` | Cancel and exit |
|
| `q` | Cancel and exit |
|
||||||
| `Space` | Toggle select current item and move down |
|
|
||||||
|
|
||||||
`H`/`M`/`L` (viewport-relative jumps) are deferred. They're
|
`H`/`M`/`L` (viewport-relative jumps) are deferred. They're
|
||||||
nice-to-have but not essential for the first pass of vim
|
nice-to-have but not essential for the first pass of vim
|
||||||
@@ -400,12 +412,26 @@ navigation.
|
|||||||
|
|
||||||
### Multi-Select
|
### Multi-Select
|
||||||
|
|
||||||
- `Space` or `v` toggles selection on current item
|
Requires `--multi` flag. Without it, toggle/select actions
|
||||||
- `V` enters visual line mode, moving up/down selects ranges
|
are no-ops and confirm always returns a single item.
|
||||||
- All selected items are emitted on confirm
|
|
||||||
- `"a` through `"z`: select into named registers (like vim
|
- `Space` or `Tab` toggles selection on the cursor item and
|
||||||
yanking)
|
moves down. `Shift+Tab` toggles and moves up.
|
||||||
- Registers can be recalled or included in output
|
- `V` enters visual line mode: `j`/`k` extend the range,
|
||||||
|
`Space`/`Enter` force-selects the range and returns to
|
||||||
|
normal mode, `V` cancels back to normal with no change.
|
||||||
|
- `u` undoes the last selection change, `Ctrl+R` redoes.
|
||||||
|
Undo/redo stacks track snapshots of the full selection set.
|
||||||
|
- `U` clears all selections (undoable).
|
||||||
|
- Selections survive filter changes. Toggling item "foo",
|
||||||
|
then filtering to something else, then confirming still
|
||||||
|
includes "foo" in the output.
|
||||||
|
- `--selection-order` flag: output items in the order they
|
||||||
|
were selected (insertion order) instead of the default
|
||||||
|
input order.
|
||||||
|
- Confirm with selections: outputs all selected items.
|
||||||
|
Confirm with no selections in multi mode: falls back to
|
||||||
|
the cursor item as a single-element result.
|
||||||
|
|
||||||
### Drill-Down
|
### Drill-Down
|
||||||
|
|
||||||
@@ -429,11 +455,26 @@ pikl --format '{icon} {label} <dim>{sublabel}</dim>'
|
|||||||
### Table / Column Mode
|
### Table / Column Mode
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
pikl --columns label,meta.res,meta.size --column-separator ' │ '
|
pikl --columns label,meta.res:Resolution,meta.size
|
||||||
```
|
```
|
||||||
|
|
||||||
Auto-aligns columns. Sortable per-column with keybinds.
|
`--columns` takes a comma-separated list of field paths.
|
||||||
CSV/TSV input maps naturally here.
|
Each column can have a display alias after a colon
|
||||||
|
(`meta.res:Resolution`). Auto-aligns columns by computing
|
||||||
|
widths from header names and cell values (capped at 60
|
||||||
|
chars). Column widths are computed in core and included in
|
||||||
|
`ViewState`, so the GUI frontend gets them for free.
|
||||||
|
|
||||||
|
The header row renders bold/underlined with `│` separators.
|
||||||
|
Item rows pad cells to column widths within the same `List`
|
||||||
|
widget, so cursor/selection/visual-mode highlighting stays
|
||||||
|
identical to the non-table path.
|
||||||
|
|
||||||
|
When `--input-format csv` is used without `--columns`,
|
||||||
|
columns are auto-generated from the CSV headers.
|
||||||
|
|
||||||
|
**Future:** column sorting keybinds, cell/column selection,
|
||||||
|
horizontal scrolling for wide tables.
|
||||||
|
|
||||||
### Preview Pane
|
### Preview Pane
|
||||||
|
|
||||||
@@ -479,7 +520,7 @@ A few built-in themes ship with pikl. `--theme monokai` or
|
|||||||
## Sessions
|
## Sessions
|
||||||
|
|
||||||
`--session name` persists the menu's state (filter text,
|
`--session name` persists the menu's state (filter text,
|
||||||
scroll position, selected items, marks, registers) across
|
scroll position, selections) across
|
||||||
invocations. State lives in
|
invocations. State lives in
|
||||||
`~/.local/state/pikl/sessions/`.
|
`~/.local/state/pikl/sessions/`.
|
||||||
|
|
||||||
@@ -745,8 +786,14 @@ find-walls --json | pikl \
|
|||||||
--on-cancel 'restore-wallpaper' \
|
--on-cancel 'restore-wallpaper' \
|
||||||
--on-hover-debounce 100
|
--on-hover-debounce 100
|
||||||
|
|
||||||
# Process killer with table view
|
# CSV table view
|
||||||
ps aux --no-headers | pikl --input-format tsv --columns 1,10,2 --multi
|
echo "name,age,city\nalice,30,toronto\nbob,25,vancouver" | pikl --input-format csv
|
||||||
|
|
||||||
|
# CSV with column subset and aliases
|
||||||
|
cat data.csv | pikl --input-format csv --columns "name,meta.age:Age"
|
||||||
|
|
||||||
|
# TSV table view
|
||||||
|
ps aux --no-headers | pikl --input-format tsv --multi
|
||||||
|
|
||||||
# Drill-down file browser
|
# Drill-down file browser
|
||||||
pikl --manifest ~/.config/pikl/filebrowser.toml --session filebrowser
|
pikl --manifest ~/.config/pikl/filebrowser.toml --session filebrowser
|
||||||
|
|||||||
@@ -170,33 +170,47 @@ through hooks and structured I/O. A handler hook can
|
|||||||
receive hover events and emit commands to modify menu
|
receive hover events and emit commands to modify menu
|
||||||
state.
|
state.
|
||||||
|
|
||||||
## Phase 4: Multi-Select & Registers
|
## Phase 4: Multi-Select
|
||||||
|
|
||||||
Power selection features.
|
Selection with undo/redo. No marks or registers: filtering
|
||||||
|
already handles navigation, and selections surviving filter
|
||||||
|
changes covers the register use case.
|
||||||
|
|
||||||
**Deliverables:**
|
**Deliverables:**
|
||||||
- Space to toggle select, V for visual line mode
|
- `--multi` flag to gate all multi-select behaviour
|
||||||
- Multi-select output (multiple JSON lines)
|
- `Space`/`Tab` toggle-select, `Shift+Tab` toggle-up
|
||||||
- Named registers (`"a` through `"z`)
|
- `V` visual line mode (force-select ranges)
|
||||||
- Marks (`m{a-z}`, `'{a-z}`)
|
- `u`/`Ctrl+R` undo/redo selection changes, `U` clear all
|
||||||
- `--multi` flag to enable multi-select mode
|
- Selections survive filter changes (tracked by original index)
|
||||||
|
- `--selection-order` flag for insertion-order output
|
||||||
|
- Multi-item output: one JSON line per selected item
|
||||||
|
- Action-fd commands: toggle-select, select-all, deselect-all,
|
||||||
|
undo-selection, redo-selection
|
||||||
|
- Test DSL: `multi` fixture, `selected_items` assertion
|
||||||
|
|
||||||
**Done when:** You can select multiple items, store them
|
**Done when:** `seq 1 20 | pikl --multi`, toggle a few items
|
||||||
in registers, and get them all in the output.
|
with Space, confirm, and all selected items appear on stdout.
|
||||||
|
|
||||||
## Phase 5: Table Mode & CSV
|
## Phase 5: Table Mode & CSV ✓
|
||||||
|
|
||||||
Columnar data display.
|
CSV/TSV input parsing and columnar table rendering.
|
||||||
|
|
||||||
**Deliverables:**
|
**Deliverables:**
|
||||||
- `--columns` flag for table layout
|
- `--input-format csv` and `--input-format tsv` for delimited input
|
||||||
- Auto-alignment
|
- `--columns` flag for table layout with optional aliases
|
||||||
|
- Auto-generated column config from CSV headers
|
||||||
|
- Column widths computed in core (GUI gets them for free)
|
||||||
|
- Table header row with bold/underline styling
|
||||||
|
- CSV rows become normal Items (JSON objects), so filtering,
|
||||||
|
field filters, hooks, multi-select, and output all work unchanged
|
||||||
|
|
||||||
|
**Deferred to future:**
|
||||||
|
- Cell/column selection in table mode
|
||||||
- Column sorting keybinds
|
- Column sorting keybinds
|
||||||
- `--input-format csv` and `--input-format tsv`
|
- Horizontal scrolling for wide tables
|
||||||
- Column-specific filtering
|
|
||||||
|
|
||||||
**Done when:** `ps aux | pikl --input-format tsv --columns 1,10,2`
|
**Done when:** `echo "name,age\nalice,30\nbob,25" | pikl --input-format csv`
|
||||||
renders a clean table.
|
renders a navigable table with headers.
|
||||||
|
|
||||||
## Phase 6: Sessions & IPC
|
## Phase 6: Sessions & IPC
|
||||||
|
|
||||||
@@ -204,8 +218,7 @@ Persistence and external control.
|
|||||||
|
|
||||||
**Deliverables:**
|
**Deliverables:**
|
||||||
- `--session name` for state persistence
|
- `--session name` for state persistence
|
||||||
- Session state: filter, scroll position, selections,
|
- Session state: filter, scroll position, selections
|
||||||
marks, registers
|
|
||||||
- Session history log file
|
- Session history log file
|
||||||
- Unix socket IPC while running
|
- Unix socket IPC while running
|
||||||
- IPC commands: push/remove/update items, set filter,
|
- IPC commands: push/remove/update items, set filter,
|
||||||
|
|||||||
122
examples/demo.sh
122
examples/demo.sh
@@ -275,6 +275,110 @@ pipeline_filter_demo() {
|
|||||||
ITEMS
|
ITEMS
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ── Phase 4 demos ────────────────────────────────────────
|
||||||
|
|
||||||
|
multi_select_basic() {
|
||||||
|
echo "Multi-select: toggle items with Space or Tab, confirm with Enter." >&2
|
||||||
|
echo "Undo with u, redo with Ctrl+R, clear all with U." >&2
|
||||||
|
echo "" >&2
|
||||||
|
printf "apple\nbanana\ncherry\ndate\nelderberry\nfig\ngrape\nhoneydew\n" \
|
||||||
|
| pikl --multi
|
||||||
|
}
|
||||||
|
|
||||||
|
multi_select_packages() {
|
||||||
|
echo "Pick packages to install. Space to toggle, Enter to confirm." >&2
|
||||||
|
echo "Try V to enter visual mode, then j/k to extend, Space to apply." >&2
|
||||||
|
echo "" >&2
|
||||||
|
cat <<'ITEMS' | pikl --multi --format '{label} - {sublabel}'
|
||||||
|
{"label": "ripgrep", "sublabel": "fast recursive grep"}
|
||||||
|
{"label": "fd", "sublabel": "find alternative"}
|
||||||
|
{"label": "bat", "sublabel": "cat with syntax highlighting"}
|
||||||
|
{"label": "eza", "sublabel": "modern ls replacement"}
|
||||||
|
{"label": "delta", "sublabel": "better git diff"}
|
||||||
|
{"label": "zoxide", "sublabel": "smarter cd"}
|
||||||
|
{"label": "starship", "sublabel": "cross-shell prompt"}
|
||||||
|
{"label": "tokei", "sublabel": "code line counter"}
|
||||||
|
{"label": "hyperfine", "sublabel": "benchmarking tool"}
|
||||||
|
{"label": "procs", "sublabel": "modern ps replacement"}
|
||||||
|
ITEMS
|
||||||
|
}
|
||||||
|
|
||||||
|
multi_select_structured() {
|
||||||
|
echo "Multi-select with --structured output. Each selected item" >&2
|
||||||
|
echo "is a JSON line with action and index fields." >&2
|
||||||
|
echo "" >&2
|
||||||
|
printf "alpha\nbravo\ncharlie\ndelta\necho\nfoxtrot\n" \
|
||||||
|
| 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=(
|
||||||
@@ -297,6 +401,16 @@ scenarios=(
|
|||||||
"Handler hook (event logging)"
|
"Handler hook (event logging)"
|
||||||
"Handler hook (add items on hover)"
|
"Handler hook (add items on hover)"
|
||||||
"---"
|
"---"
|
||||||
|
"Multi-select (basic)"
|
||||||
|
"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)"
|
"on-select-exec hook (legacy)"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -319,6 +433,14 @@ run_scenario() {
|
|||||||
*"Exec hooks"*) exec_hooks_demo ;;
|
*"Exec hooks"*) exec_hooks_demo ;;
|
||||||
*"Handler hook (event"*) handler_hook_demo ;;
|
*"Handler hook (event"*) handler_hook_demo ;;
|
||||||
*"Handler hook (add"*) handler_add_items_demo ;;
|
*"Handler hook (add"*) handler_add_items_demo ;;
|
||||||
|
*"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 ;;
|
*"on-select-exec"*) on_select_hook ;;
|
||||||
"---")
|
"---")
|
||||||
echo "that's a separator, not a scenario" >&2
|
echo "that's a separator, not a scenario" >&2
|
||||||
|
|||||||
Reference in New Issue
Block a user