Compare commits

...

13 Commits

Author SHA1 Message Date
f81c8a20d2 fix
Some checks failed
CI / Lint (push) Has been cancelled
CI / Test (push) Has been cancelled
2026-03-16 11:07:51 -04:00
5e076b8682 feat: Add MacOS support.
Some checks failed
CI / Lint (push) Has been cancelled
CI / Test (push) Has been cancelled
2026-03-15 12:17:39 -04:00
47437f536b feat: Add support for action modifier keys like ctrl,shift,alt.
Some checks failed
CI / Lint (push) Has been cancelled
CI / Test (push) Has been cancelled
2026-03-15 11:56:26 -04:00
0d241841bc feat(gui): Add Wayland GUI frontend to menu system. 2026-03-15 10:33:54 -04:00
e77ecb447e feat: Add streaming pipe support for async loading in. 2026-03-15 01:33:38 -04:00
3b574e89ab example: Add IPC remote control example.
Some checks failed
CI / Test (push) Has been cancelled
CI / Lint (push) Has been cancelled
2026-03-14 17:05:18 -04:00
aad4c16d6e feat: Add support IPC socket control over menu. 2026-03-14 17:02:16 -04:00
cd1927fa9f refactor: Pre-phase-6 review cleanup.
Some checks failed
CI / Lint (push) Has been cancelled
CI / Test (push) Has been cancelled
- Propagate serialization errors in output path
instead of unwrap_or_default() silently writing
blank lines (main.rs, handler.rs).
- Cap selection undo stack at 50 entries to bound
memory on heavy multi-select usage (menu.rs).
- Break TUI event loop on dead action channel
instead of silently dropping keypresses (lib.rs).
- Log dropped handler hook events when channel is
full (handler.rs).
- Reject menu and headless DSL tests that have no
assertions at compile time (codegen.rs).
- Document filter_text ownership design in TUI and
exec/handler error asymmetry in CompositeHandler.
2026-03-14 15:26:31 -04:00
8077469d19 feat: Add support for tabular/csv data input. 2026-03-14 14:54:13 -04:00
eab872d2ed doc: Remove ideas for full tabular data plans. 2026-03-14 14:52:23 -04:00
a2e469456a example: Add new multi-select cases to the demo script. 2026-03-14 13:57:51 -04:00
18c66ca7a9 doc: Remove ideas for marks and registers from plans. 2026-03-14 13:56:22 -04:00
2e29dfc103 feat: Add new system for multi-selections. 2026-03-14 13:55:55 -04:00
46 changed files with 9605 additions and 600 deletions

3464
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -3,6 +3,7 @@ resolver = "3"
members = [ members = [
"crates/pikl-core", "crates/pikl-core",
"crates/pikl-tui", "crates/pikl-tui",
"crates/pikl-gui",
"crates/pikl", "crates/pikl",
"crates/pikl-test-macros", "crates/pikl-test-macros",
] ]

View File

@@ -131,7 +131,7 @@ pins the version and pulls in rust-analyzer + clippy.
## Current Status ## Current Status
Phases 1 through 3 are complete. pikl works as a TUI menu with: Phases 1 through 6 are complete. pikl works as a TUI menu with:
- Fuzzy, exact, regex, and inverse filtering with `|` pipeline chaining - Fuzzy, exact, regex, and inverse filtering with `|` pipeline chaining
- Vim navigation (j/k, gg/G, Ctrl+D/U, Ctrl+F/B, modes) - Vim navigation (j/k, gg/G, Ctrl+D/U, Ctrl+F/B, modes)
- Structured JSON I/O with sublabel, icon, group, and arbitrary metadata - Structured JSON I/O with sublabel, icon, group, and arbitrary metadata
@@ -140,6 +140,31 @@ Phases 1 through 3 are complete. pikl works as a TUI menu with:
- Format templates (`--format '{label} - {sublabel}'`) - Format templates (`--format '{label} - {sublabel}'`)
- Field-scoped filtering (`--filter-fields`, `meta.res:3840` syntax) - Field-scoped filtering (`--filter-fields`, `meta.res:3840` syntax)
- Headless scripting via `--action-fd` - Headless scripting via `--action-fd`
- Multi-select with undo/redo and visual line mode
- Table mode for CSV/TSV input with auto-generated or custom columns
- IPC control via Unix socket (`--ipc`, `--session`)
- Session filter history with Ctrl+P/Ctrl+N recall
### IPC example
Run pikl with IPC in one terminal, control it from another:
```sh
# terminal 1: start pikl with IPC enabled
echo -e "alpha\nbeta\ngamma\ndelta" | pikl --ipc --session demo
# terminal 2: query state over the socket
echo '{"action":"get_state","id":"1"}' \
| socat - UNIX-CONNECT:/run/user/$(id -u)/pikl-demo.sock
# terminal 2: add items dynamically
echo '{"action":"add_items","items":["epsilon","zeta"]}' \
| socat -t 0.1 - UNIX-CONNECT:/run/user/$(id -u)/pikl-demo.sock
```
There's an interactive remote control demo at
[examples/ipc-remote.sh](examples/ipc-remote.sh)
(see [examples/ipc-remote.md](examples/ipc-remote.md) for the walkthrough).
## Platform Support ## Platform Support

View File

@@ -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"] }

View File

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

View File

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

View File

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

View File

@@ -9,6 +9,8 @@
use std::sync::Arc; use std::sync::Arc;
use serde::ser::SerializeSeq;
use serde::Serialize;
use serde_json::Value; use serde_json::Value;
use crate::hook::HookResponse; use crate::hook::HookResponse;
@@ -20,6 +22,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 +40,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,
@@ -46,6 +55,65 @@ pub enum Action {
RemoveItems(Vec<usize>), RemoveItems(Vec<usize>),
ProcessHookResponse(HookResponse), ProcessHookResponse(HookResponse),
CloseMenu, CloseMenu,
StreamingDone,
}
/// Modifier keys held during an action. Serializes as an
/// array of strings (e.g. `["shift", "ctrl"]`). Empty
/// modifiers serialize as `[]`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct Modifiers {
pub shift: bool,
pub ctrl: bool,
pub alt: bool,
}
impl Modifiers {
/// True when no modifiers are held.
pub fn is_empty(&self) -> bool {
!self.shift && !self.ctrl && !self.alt
}
}
impl Serialize for Modifiers {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
let count = self.shift as usize + self.ctrl as usize + self.alt as usize;
let mut seq = serializer.serialize_seq(Some(count))?;
if self.shift {
seq.serialize_element("shift")?;
}
if self.ctrl {
seq.serialize_element("ctrl")?;
}
if self.alt {
seq.serialize_element("alt")?;
}
seq.end()
}
}
/// An [`Action`] paired with the modifier keys that were
/// held when it was triggered. Frontends produce these,
/// the menu engine consumes them.
#[derive(Debug, Clone, PartialEq)]
pub struct ModifiedAction {
pub action: Action,
pub modifiers: Modifiers,
}
impl ModifiedAction {
/// Wrap an action with default (empty) modifiers.
pub fn new(action: Action) -> Self {
Self {
action,
modifiers: Modifiers::default(),
}
}
/// Wrap an action with specific modifiers.
pub fn with_modifiers(action: Action, modifiers: Modifiers) -> Self {
Self { action, modifiers }
}
} }
/// Broadcast from the menu loop to all subscribers /// Broadcast from the menu loop to all subscribers
@@ -53,7 +121,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 +140,28 @@ 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,
/// True while stdin is still streaming items in.
pub streaming: 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 +171,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 +182,92 @@ pub struct VisibleItem {
#[must_use] #[must_use]
#[derive(Debug)] #[derive(Debug)]
pub enum MenuResult { pub enum MenuResult {
Selected { value: Value, index: usize }, Selected {
Quicklist { items: Vec<(Value, usize)> }, items: Vec<(Value, usize)>,
Cancelled, filter_text: String,
modifiers: Modifiers,
},
Quicklist {
items: Vec<(Value, usize)>,
filter_text: String,
modifiers: Modifiers,
},
Cancelled {
modifiers: Modifiers,
},
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn modifiers_empty_serializes_as_empty_array() {
let mods = Modifiers::default();
let json = serde_json::to_value(&mods).unwrap_or_default();
assert_eq!(json, json!([]));
}
#[test]
fn modifiers_single_serializes() {
let mods = Modifiers {
shift: true,
ctrl: false,
alt: false,
};
let json = serde_json::to_value(&mods).unwrap_or_default();
assert_eq!(json, json!(["shift"]));
}
#[test]
fn modifiers_multiple_serializes() {
let mods = Modifiers {
shift: true,
ctrl: true,
alt: false,
};
let json = serde_json::to_value(&mods).unwrap_or_default();
assert_eq!(json, json!(["shift", "ctrl"]));
}
#[test]
fn modifiers_all_serializes() {
let mods = Modifiers {
shift: true,
ctrl: true,
alt: true,
};
let json = serde_json::to_value(&mods).unwrap_or_default();
assert_eq!(json, json!(["shift", "ctrl", "alt"]));
}
#[test]
fn modifiers_is_empty() {
assert!(Modifiers::default().is_empty());
assert!(!Modifiers {
shift: true,
ctrl: false,
alt: false
}
.is_empty());
}
#[test]
fn modified_action_new_has_empty_modifiers() {
let ma = ModifiedAction::new(Action::Confirm);
assert!(ma.modifiers.is_empty());
}
#[test]
fn modified_action_with_modifiers() {
let mods = Modifiers {
shift: true,
ctrl: false,
alt: false,
};
let ma = ModifiedAction::with_modifiers(Action::Confirm, mods);
assert!(ma.modifiers.shift);
assert!(!ma.modifiers.ctrl);
}
} }

View File

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

View File

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

View File

@@ -2,10 +2,12 @@
//! 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;
use crate::event::Modifiers;
/// What the user did to produce this output. /// What the user did to produce this output.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
@@ -25,27 +27,35 @@ pub struct OutputItem {
pub value: Value, pub value: Value,
pub action: OutputAction, pub action: OutputAction,
pub index: usize, pub index: usize,
pub modifiers: Modifiers,
} }
impl Serialize for OutputItem { impl Serialize for OutputItem {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> { fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
let has_mods = !self.modifiers.is_empty();
match &self.value { match &self.value {
Value::Object(map) => { Value::Object(map) => {
// Flatten: merge object fields with action/index // Flatten: merge object fields with action/index
let mut s = serializer.serialize_map(Some(map.len() + 2))?; let mut s = serializer.serialize_map(None)?;
for (k, v) in map { for (k, v) in map {
s.serialize_entry(k, v)?; s.serialize_entry(k, v)?;
} }
s.serialize_entry("action", &self.action)?; s.serialize_entry("action", &self.action)?;
s.serialize_entry("index", &self.index)?; s.serialize_entry("index", &self.index)?;
if has_mods {
s.serialize_entry("modifiers", &self.modifiers)?;
}
s.end() s.end()
} }
_ => { _ => {
// Non-object: put value in a "value" field // Non-object: put value in a "value" field
let mut s = serializer.serialize_map(Some(3))?; let mut s = serializer.serialize_map(None)?;
s.serialize_entry("value", &self.value)?; s.serialize_entry("value", &self.value)?;
s.serialize_entry("action", &self.action)?; s.serialize_entry("action", &self.action)?;
s.serialize_entry("index", &self.index)?; s.serialize_entry("index", &self.index)?;
if has_mods {
s.serialize_entry("modifiers", &self.modifiers)?;
}
s.end() s.end()
} }
} }
@@ -63,6 +73,7 @@ mod tests {
value: json!({"label": "Firefox", "url": "https://firefox.com"}), value: json!({"label": "Firefox", "url": "https://firefox.com"}),
action: OutputAction::Select, action: OutputAction::Select,
index: 3, index: 3,
modifiers: Modifiers::default(),
}; };
let json = serde_json::to_value(&item).unwrap_or_default(); let json = serde_json::to_value(&item).unwrap_or_default();
assert_eq!(json["label"], "Firefox"); assert_eq!(json["label"], "Firefox");
@@ -77,6 +88,7 @@ mod tests {
value: json!("hello"), value: json!("hello"),
action: OutputAction::Select, action: OutputAction::Select,
index: 0, index: 0,
modifiers: Modifiers::default(),
}; };
let json = serde_json::to_value(&item).unwrap_or_default(); let json = serde_json::to_value(&item).unwrap_or_default();
assert_eq!(json["value"], "hello"); assert_eq!(json["value"], "hello");
@@ -90,6 +102,7 @@ mod tests {
value: json!(null), value: json!(null),
action: OutputAction::Cancel, action: OutputAction::Cancel,
index: 0, index: 0,
modifiers: Modifiers::default(),
}; };
let json = serde_json::to_value(&item).unwrap_or_default(); let json = serde_json::to_value(&item).unwrap_or_default();
assert_eq!(json["action"], "cancel"); assert_eq!(json["action"], "cancel");
@@ -101,6 +114,7 @@ mod tests {
value: json!("test"), value: json!("test"),
action: OutputAction::Quicklist, action: OutputAction::Quicklist,
index: 2, index: 2,
modifiers: Modifiers::default(),
}; };
let json = serde_json::to_value(&item).unwrap_or_default(); let json = serde_json::to_value(&item).unwrap_or_default();
assert_eq!(json["action"], "quicklist"); assert_eq!(json["action"], "quicklist");
@@ -113,6 +127,7 @@ mod tests {
value: json!("alpha"), value: json!("alpha"),
action: OutputAction::Select, action: OutputAction::Select,
index: 0, index: 0,
modifiers: Modifiers::default(),
}; };
let serialized = serde_json::to_string(&item).unwrap_or_default(); let serialized = serde_json::to_string(&item).unwrap_or_default();
assert!( assert!(
@@ -120,4 +135,61 @@ mod tests {
"output should contain the value text: {serialized}" "output should contain the value text: {serialized}"
); );
} }
#[test]
fn output_item_omits_empty_modifiers() {
let item = OutputItem {
value: json!("test"),
action: OutputAction::Select,
index: 0,
modifiers: Modifiers::default(),
};
let json = serde_json::to_value(&item).unwrap_or_default();
assert!(json.get("modifiers").is_none(), "empty modifiers should be omitted");
}
#[test]
fn output_item_includes_modifiers_when_present() {
let item = OutputItem {
value: json!("test"),
action: OutputAction::Select,
index: 0,
modifiers: Modifiers {
shift: true,
ctrl: false,
alt: false,
},
};
let json = serde_json::to_value(&item).unwrap_or_default();
assert_eq!(json["modifiers"], json!(["shift"]));
}
#[test]
fn output_item_object_with_modifiers() {
let item = OutputItem {
value: json!({"label": "Firefox"}),
action: OutputAction::Select,
index: 0,
modifiers: Modifiers {
shift: true,
ctrl: true,
alt: false,
},
};
let json = serde_json::to_value(&item).unwrap_or_default();
assert_eq!(json["label"], "Firefox");
assert_eq!(json["modifiers"], json!(["shift", "ctrl"]));
}
#[test]
fn output_item_object_omits_empty_modifiers() {
let item = OutputItem {
value: json!({"label": "Firefox"}),
action: OutputAction::Select,
index: 0,
modifiers: Modifiers::default(),
};
let json = serde_json::to_value(&item).unwrap_or_default();
assert!(json.get("modifiers").is_none());
}
} }

View File

@@ -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

View File

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

View File

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

View File

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

View File

@@ -10,7 +10,7 @@ use tokio::sync::mpsc;
use tokio::task::JoinHandle; use tokio::task::JoinHandle;
use tracing::Instrument; use tracing::Instrument;
use crate::event::Action; use crate::event::{Action, ModifiedAction};
use crate::hook::{HookEvent, HookEventKind, HookHandler, HookResponse}; use crate::hook::{HookEvent, HookEventKind, HookHandler, HookResponse};
/// Debounce mode for a hook event kind. /// Debounce mode for a hook event kind.
@@ -30,16 +30,13 @@ pub enum DebounceMode {
/// behavior. Each event kind can have its own mode. /// behavior. Each event kind can have its own mode.
pub struct DebouncedDispatcher { pub struct DebouncedDispatcher {
handler: Arc<dyn HookHandler>, handler: Arc<dyn HookHandler>,
_action_tx: mpsc::Sender<Action>, _action_tx: mpsc::Sender<ModifiedAction>,
modes: HashMap<HookEventKind, DebounceMode>, modes: HashMap<HookEventKind, DebounceMode>,
in_flight: HashMap<HookEventKind, JoinHandle<()>>, in_flight: HashMap<HookEventKind, JoinHandle<()>>,
} }
impl DebouncedDispatcher { impl DebouncedDispatcher {
pub fn new( pub fn new(handler: Arc<dyn HookHandler>, action_tx: mpsc::Sender<ModifiedAction>) -> 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");
@@ -156,6 +149,7 @@ pub fn hook_response_to_action(resp: HookResponse) -> Action {
mod tests { mod tests {
use super::*; use super::*;
use crate::error::PiklError; use crate::error::PiklError;
use crate::event::Modifiers;
use serde_json::json; use serde_json::json;
use std::sync::Mutex; use std::sync::Mutex;
@@ -224,12 +218,15 @@ mod tests {
// Rapid-fire filter events // Rapid-fire filter events
dispatcher.dispatch(HookEvent::Filter { dispatcher.dispatch(HookEvent::Filter {
text: "a".to_string(), text: "a".to_string(),
modifiers: Modifiers::default(),
}); });
dispatcher.dispatch(HookEvent::Filter { dispatcher.dispatch(HookEvent::Filter {
text: "ab".to_string(), text: "ab".to_string(),
modifiers: Modifiers::default(),
}); });
dispatcher.dispatch(HookEvent::Filter { dispatcher.dispatch(HookEvent::Filter {
text: "abc".to_string(), text: "abc".to_string(),
modifiers: Modifiers::default(),
}); });
// Advance past debounce window. sleep(0) processes // Advance past debounce window. sleep(0) processes
@@ -258,12 +255,14 @@ mod tests {
dispatcher.dispatch(HookEvent::Hover { dispatcher.dispatch(HookEvent::Hover {
item: json!("a"), item: json!("a"),
index: 0, index: 0,
modifiers: Modifiers::default(),
}); });
// Wait a bit, then send second hover which cancels first // Wait a bit, then send second hover which cancels first
tokio::time::sleep(Duration::from_millis(50)).await; tokio::time::sleep(Duration::from_millis(50)).await;
dispatcher.dispatch(HookEvent::Hover { dispatcher.dispatch(HookEvent::Hover {
item: json!("b"), item: json!("b"),
index: 1, index: 1,
modifiers: Modifiers::default(),
}); });
// Advance past debounce for the second event // Advance past debounce for the second event

View File

@@ -7,6 +7,7 @@ use serde::{Deserialize, Serialize};
use serde_json::Value; use serde_json::Value;
use crate::error::PiklError; use crate::error::PiklError;
use crate::event::Modifiers;
/// A lifecycle event emitted by the menu engine. Handler /// A lifecycle event emitted by the menu engine. Handler
/// hooks receive these as JSON lines on stdin. The `event` /// hooks receive these as JSON lines on stdin. The `event`
@@ -16,11 +17,33 @@ use crate::error::PiklError;
pub enum HookEvent { pub enum HookEvent {
Open, Open,
Close, Close,
Hover { item: Value, index: usize }, Hover {
Select { item: Value, index: usize }, item: Value,
Cancel, index: usize,
Filter { text: String }, #[serde(skip_serializing_if = "Modifiers::is_empty")]
Quicklist { items: Vec<Value>, count: usize }, modifiers: Modifiers,
},
Select {
item: Value,
index: usize,
#[serde(skip_serializing_if = "Modifiers::is_empty")]
modifiers: Modifiers,
},
Cancel {
#[serde(skip_serializing_if = "Modifiers::is_empty")]
modifiers: Modifiers,
},
Filter {
text: String,
#[serde(skip_serializing_if = "Modifiers::is_empty")]
modifiers: Modifiers,
},
Quicklist {
items: Vec<Value>,
count: usize,
#[serde(skip_serializing_if = "Modifiers::is_empty")]
modifiers: Modifiers,
},
} }
/// Discriminant for [`HookEvent`], used as a key for /// Discriminant for [`HookEvent`], used as a key for
@@ -44,7 +67,7 @@ impl HookEvent {
HookEvent::Close => HookEventKind::Close, HookEvent::Close => HookEventKind::Close,
HookEvent::Hover { .. } => HookEventKind::Hover, HookEvent::Hover { .. } => HookEventKind::Hover,
HookEvent::Select { .. } => HookEventKind::Select, HookEvent::Select { .. } => HookEventKind::Select,
HookEvent::Cancel => HookEventKind::Cancel, HookEvent::Cancel { .. } => HookEventKind::Cancel,
HookEvent::Filter { .. } => HookEventKind::Filter, HookEvent::Filter { .. } => HookEventKind::Filter,
HookEvent::Quicklist { .. } => HookEventKind::Quicklist, HookEvent::Quicklist { .. } => HookEventKind::Quicklist,
} }
@@ -116,11 +139,14 @@ mod tests {
let event = HookEvent::Hover { let event = HookEvent::Hover {
item: json!({"label": "test"}), item: json!({"label": "test"}),
index: 5, index: 5,
modifiers: Modifiers::default(),
}; };
let json = serde_json::to_value(&event).unwrap_or_default(); let json = serde_json::to_value(&event).unwrap_or_default();
assert_eq!(json["event"], "hover"); assert_eq!(json["event"], "hover");
assert_eq!(json["item"]["label"], "test"); assert_eq!(json["item"]["label"], "test");
assert_eq!(json["index"], 5); assert_eq!(json["index"], 5);
// Empty modifiers should be omitted
assert!(json.get("modifiers").is_none());
} }
#[test] #[test]
@@ -128,6 +154,7 @@ mod tests {
let event = HookEvent::Select { let event = HookEvent::Select {
item: json!("hello"), item: json!("hello"),
index: 0, index: 0,
modifiers: Modifiers::default(),
}; };
let json = serde_json::to_value(&event).unwrap_or_default(); let json = serde_json::to_value(&event).unwrap_or_default();
assert_eq!(json["event"], "select"); assert_eq!(json["event"], "select");
@@ -137,14 +164,19 @@ mod tests {
#[test] #[test]
fn event_cancel_serializes() { fn event_cancel_serializes() {
let json = serde_json::to_value(&HookEvent::Cancel).unwrap_or_default(); let json = serde_json::to_value(&HookEvent::Cancel {
modifiers: Modifiers::default(),
})
.unwrap_or_default();
assert_eq!(json["event"], "cancel"); assert_eq!(json["event"], "cancel");
assert!(json.get("modifiers").is_none());
} }
#[test] #[test]
fn event_filter_serializes() { fn event_filter_serializes() {
let event = HookEvent::Filter { let event = HookEvent::Filter {
text: "foo".to_string(), text: "foo".to_string(),
modifiers: Modifiers::default(),
}; };
let json = serde_json::to_value(&event).unwrap_or_default(); let json = serde_json::to_value(&event).unwrap_or_default();
assert_eq!(json["event"], "filter"); assert_eq!(json["event"], "filter");
@@ -160,7 +192,8 @@ mod tests {
assert_eq!( assert_eq!(
HookEvent::Hover { HookEvent::Hover {
item: json!(null), item: json!(null),
index: 0 index: 0,
modifiers: Modifiers::default(),
} }
.kind(), .kind(),
HookEventKind::Hover HookEventKind::Hover
@@ -168,15 +201,23 @@ mod tests {
assert_eq!( assert_eq!(
HookEvent::Select { HookEvent::Select {
item: json!(null), item: json!(null),
index: 0 index: 0,
modifiers: Modifiers::default(),
} }
.kind(), .kind(),
HookEventKind::Select HookEventKind::Select
); );
assert_eq!(HookEvent::Cancel.kind(), HookEventKind::Cancel); assert_eq!(
HookEvent::Cancel {
modifiers: Modifiers::default()
}
.kind(),
HookEventKind::Cancel
);
assert_eq!( assert_eq!(
HookEvent::Filter { HookEvent::Filter {
text: String::new() text: String::new(),
modifiers: Modifiers::default(),
} }
.kind(), .kind(),
HookEventKind::Filter HookEventKind::Filter
@@ -188,9 +229,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 +242,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 +255,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 +268,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 +281,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);
} }
@@ -296,6 +332,7 @@ mod tests {
let event = HookEvent::Quicklist { let event = HookEvent::Quicklist {
items: vec![json!("alpha"), json!("beta")], items: vec![json!("alpha"), json!("beta")],
count: 2, count: 2,
modifiers: Modifiers::default(),
}; };
let json = serde_json::to_value(&event).unwrap_or_default(); let json = serde_json::to_value(&event).unwrap_or_default();
assert_eq!(json["event"], "quicklist"); assert_eq!(json["event"], "quicklist");
@@ -308,6 +345,7 @@ mod tests {
let event = HookEvent::Quicklist { let event = HookEvent::Quicklist {
items: vec![], items: vec![],
count: 0, count: 0,
modifiers: Modifiers::default(),
}; };
assert_eq!(event.kind(), HookEventKind::Quicklist); assert_eq!(event.kind(), HookEventKind::Quicklist);
} }
@@ -319,6 +357,7 @@ mod tests {
let event = HookEvent::Hover { let event = HookEvent::Hover {
item: json!({"label": "Firefox", "url": "https://firefox.com"}), item: json!({"label": "Firefox", "url": "https://firefox.com"}),
index: 2, index: 2,
modifiers: Modifiers::default(),
}; };
let serialized = serde_json::to_string(&event).unwrap_or_default(); let serialized = serde_json::to_string(&event).unwrap_or_default();
let parsed: Value = serde_json::from_str(&serialized).unwrap_or_default(); let parsed: Value = serde_json::from_str(&serialized).unwrap_or_default();
@@ -326,4 +365,35 @@ mod tests {
assert_eq!(parsed["item"]["label"], "Firefox"); assert_eq!(parsed["item"]["label"], "Firefox");
assert_eq!(parsed["index"], 2); assert_eq!(parsed["index"], 2);
} }
// -- Modifier serialization in hook events --
#[test]
fn event_select_with_modifiers() {
let event = HookEvent::Select {
item: json!("test"),
index: 0,
modifiers: Modifiers {
shift: true,
ctrl: false,
alt: false,
},
};
let json = serde_json::to_value(&event).unwrap_or_default();
assert_eq!(json["modifiers"], json!(["shift"]));
}
#[test]
fn event_cancel_with_modifiers() {
let event = HookEvent::Cancel {
modifiers: Modifiers {
shift: true,
ctrl: true,
alt: false,
},
};
let json = serde_json::to_value(&event).unwrap_or_default();
assert_eq!(json["event"], "cancel");
assert_eq!(json["modifiers"], json!(["shift", "ctrl"]));
}
} }

View File

@@ -8,6 +8,15 @@ use tokio::io::AsyncBufReadExt;
use crate::error::PiklError; use crate::error::PiklError;
use crate::item::Item; use crate::item::Item;
/// Parse a single line into a JSON Value. Tries JSON first,
/// falls back to wrapping as a string. Used by the streaming
/// stdin reader in the CLI binary, which sends raw Values
/// via `Action::AddItems`.
pub fn parse_line_to_value(line: &str) -> serde_json::Value {
serde_json::from_str::<serde_json::Value>(line)
.unwrap_or_else(|_| serde_json::Value::String(line.to_string()))
}
/// Try to parse a line as JSON. Falls back to wrapping /// Try to parse a line as JSON. Falls back to wrapping
/// it as a plain-text string. The `label_key` controls /// it as a plain-text string. The `label_key` controls
/// which JSON key is used as the display label for object /// which JSON key is used as the display label for object
@@ -116,4 +125,39 @@ mod tests {
assert_eq!(items.len(), 1); assert_eq!(items.len(), 1);
assert_eq!(items[0].label(), "quoted string"); assert_eq!(items[0].label(), "quoted string");
} }
#[test]
fn parse_line_to_value_plain_text() {
let v = parse_line_to_value("hello world");
assert_eq!(v, serde_json::Value::String("hello world".to_string()));
}
#[test]
fn parse_line_to_value_json_object() {
let v = parse_line_to_value(r#"{"label": "foo", "count": 42}"#);
assert!(v.is_object());
assert_eq!(v["label"], "foo");
assert_eq!(v["count"], 42);
}
#[test]
fn parse_line_to_value_invalid_json() {
let v = parse_line_to_value("{not valid json}");
assert_eq!(
v,
serde_json::Value::String("{not valid json}".to_string())
);
}
#[test]
fn parse_line_to_value_json_string() {
let v = parse_line_to_value(r#""quoted""#);
assert_eq!(v, serde_json::Value::String("quoted".to_string()));
}
#[test]
fn parse_line_to_value_json_number() {
let v = parse_line_to_value("42");
assert_eq!(v, serde_json::json!(42));
}
} }

View File

@@ -5,6 +5,7 @@
use crate::filter::Filter; use crate::filter::Filter;
use crate::format::FormatTemplate; use crate::format::FormatTemplate;
use crate::item::Item; use crate::item::Item;
use crate::model::column::ColumnConfig;
use crate::model::traits::{Menu, MutableMenu}; use crate::model::traits::{Menu, MutableMenu};
use crate::pipeline::FilterPipeline; use crate::pipeline::FilterPipeline;
@@ -19,6 +20,7 @@ pub struct JsonMenu {
filter: FilterPipeline, filter: FilterPipeline,
filter_fields: Vec<String>, filter_fields: Vec<String>,
format_template: Option<FormatTemplate>, format_template: Option<FormatTemplate>,
column_config: Option<ColumnConfig>,
} }
impl JsonMenu { impl JsonMenu {
@@ -36,6 +38,7 @@ impl JsonMenu {
filter, filter,
filter_fields: vec!["label".to_string()], filter_fields: vec!["label".to_string()],
format_template: None, format_template: None,
column_config: None,
} }
} }
@@ -52,6 +55,11 @@ impl JsonMenu {
self.format_template = Some(template); self.format_template = Some(template);
} }
/// Set the column config for table mode rendering.
pub fn set_column_config(&mut self, config: ColumnConfig) {
self.column_config = Some(config);
}
/// Rebuild the filter pipeline from scratch. Called after /// Rebuild the filter pipeline from scratch. Called after
/// filter_fields change or item mutations. /// filter_fields change or item mutations.
fn rebuild_pipeline(&mut self) { fn rebuild_pipeline(&mut self) {
@@ -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();
} }
} }

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -14,7 +14,7 @@ pub mod parse;
use tokio::sync::mpsc; use tokio::sync::mpsc;
use crate::error::PiklError; use crate::error::PiklError;
use crate::event::Action; use crate::event::{Action, ModifiedAction, Modifiers};
pub use error::{ScriptError, ScriptErrorKind}; pub use error::{ScriptError, ScriptErrorKind};
pub use parse::{load_script, parse_action}; pub use parse::{load_script, parse_action};
@@ -22,7 +22,7 @@ pub use parse::{load_script, parse_action};
/// A parsed action from an action-fd script. /// A parsed action from an action-fd script.
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub enum ScriptAction { pub enum ScriptAction {
Core(Action), Core(Action, Modifiers),
ShowUi, ShowUi,
ShowTui, ShowTui,
ShowGui, ShowGui,
@@ -41,14 +41,14 @@ pub enum ShowAction {
/// Returns the Show* variant if the script ends with one, or None. /// Returns the Show* variant if the script ends with one, or None.
pub async fn run_script( pub async fn run_script(
script: Vec<ScriptAction>, script: Vec<ScriptAction>,
tx: &mpsc::Sender<Action>, tx: &mpsc::Sender<ModifiedAction>,
) -> Result<Option<ShowAction>, PiklError> { ) -> Result<Option<ShowAction>, PiklError> {
let mut show = None; let mut show = None;
for action in script { for action in script {
match action { match action {
ScriptAction::Core(action) => { ScriptAction::Core(action, modifiers) => {
tx.send(action) tx.send(ModifiedAction { action, modifiers })
.await .await
.map_err(|_| PiklError::ChannelClosed)?; .map_err(|_| PiklError::ChannelClosed)?;
} }
@@ -69,9 +69,9 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn run_script_sends_actions_in_order() { async fn run_script_sends_actions_in_order() {
let script = vec![ let script = vec![
ScriptAction::Core(Action::UpdateFilter("hello".to_string())), ScriptAction::Core(Action::UpdateFilter("hello".to_string()), Modifiers::default()),
ScriptAction::Core(Action::MoveDown(1)), ScriptAction::Core(Action::MoveDown(1), Modifiers::default()),
ScriptAction::Core(Action::Confirm), ScriptAction::Core(Action::Confirm, Modifiers::default()),
]; ];
let (tx, mut rx) = mpsc::channel(16); let (tx, mut rx) = mpsc::channel(16);
let result = run_script(script, &tx).await; let result = run_script(script, &tx).await;
@@ -79,14 +79,14 @@ mod tests {
assert_eq!(result.unwrap_or(Some(ShowAction::Ui)), None); assert_eq!(result.unwrap_or(Some(ShowAction::Ui)), None);
// Verify order // Verify order
assert!(matches!(rx.recv().await, Some(Action::UpdateFilter(s)) if s == "hello")); assert!(matches!(rx.recv().await, Some(ModifiedAction { action: Action::UpdateFilter(s), .. }) if s == "hello"));
assert!(matches!(rx.recv().await, Some(Action::MoveDown(1)))); assert!(matches!(rx.recv().await, Some(ModifiedAction { action: Action::MoveDown(1), .. })));
assert!(matches!(rx.recv().await, Some(Action::Confirm))); assert!(matches!(rx.recv().await, Some(ModifiedAction { action: Action::Confirm, .. })));
} }
#[tokio::test] #[tokio::test]
async fn run_script_returns_none_without_show() { async fn run_script_returns_none_without_show() {
let script = vec![ScriptAction::Core(Action::Confirm)]; let script = vec![ScriptAction::Core(Action::Confirm, Modifiers::default())];
let (tx, _rx) = mpsc::channel(16); let (tx, _rx) = mpsc::channel(16);
let result = run_script(script, &tx).await; let result = run_script(script, &tx).await;
assert!(result.is_ok()); assert!(result.is_ok());
@@ -96,7 +96,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn run_script_returns_show_action() { async fn run_script_returns_show_action() {
let script = vec![ let script = vec![
ScriptAction::Core(Action::UpdateFilter("test".to_string())), ScriptAction::Core(Action::UpdateFilter("test".to_string()), Modifiers::default()),
ScriptAction::ShowUi, ScriptAction::ShowUi,
]; ];
let (tx, _rx) = mpsc::channel(16); let (tx, _rx) = mpsc::channel(16);

View File

@@ -4,7 +4,7 @@
use std::io::BufRead; use std::io::BufRead;
use crate::event::{Action, Mode}; use crate::event::{Action, Mode, Modifiers};
use super::ScriptAction; use super::ScriptAction;
use super::error::{ScriptError, ScriptErrorKind}; use super::error::{ScriptError, ScriptErrorKind};
@@ -82,6 +82,30 @@ fn parse_positive_u16(
} }
} }
/// Parse modifier prefixes from the start of a trimmed line.
/// Returns (modifiers, remaining_line).
fn parse_modifier_prefix(line: &str) -> Result<(Modifiers, &str), Option<String>> {
let mut mods = Modifiers::default();
let mut rest = line;
while let Some(stripped) = rest.strip_prefix('+') {
// Find the end of this modifier token (next space or '+')
let end = stripped
.find([' ', '+'])
.unwrap_or(stripped.len());
let token = &stripped[..end];
match token {
"shift" => mods.shift = true,
"ctrl" => mods.ctrl = true,
"alt" => mods.alt = true,
other => return Err(Some(format!("unknown modifier '+{other}'"))),
}
rest = stripped[end..].trim_start();
}
Ok((mods, rest))
}
/// Parse a single line into a ScriptAction. /// Parse a single line into a ScriptAction.
pub fn parse_action(line_number: usize, line: &str) -> Result<ScriptAction, ScriptError> { pub fn parse_action(line_number: usize, line: &str) -> Result<ScriptAction, ScriptError> {
let trimmed = line.trim(); let trimmed = line.trim();
@@ -90,41 +114,66 @@ pub fn parse_action(line_number: usize, line: &str) -> Result<ScriptAction, Scri
return Ok(ScriptAction::Comment); return Ok(ScriptAction::Comment);
} }
let (cmd, arg) = match trimmed.split_once(' ') { // Parse optional modifier prefixes (+shift, +ctrl, +alt)
let (modifiers, rest) = parse_modifier_prefix(trimmed).map_err(|msg| ScriptError {
line: line_number,
source_line: line.to_string(),
kind: ScriptErrorKind::InvalidArgument {
action: String::new(),
message: msg.unwrap_or_default(),
},
})?;
if rest.is_empty() {
return Err(ScriptError {
line: line_number,
source_line: line.to_string(),
kind: ScriptErrorKind::InvalidArgument {
action: String::new(),
message: "modifier prefix without an action".to_string(),
},
});
}
let (cmd, arg) = match rest.split_once(' ') {
Some((c, a)) => (c, Some(a)), Some((c, a)) => (c, Some(a)),
None => (trimmed, None), None => (rest, None),
};
let core = |action: Action| -> Result<ScriptAction, ScriptError> {
Ok(ScriptAction::Core(action, modifiers))
}; };
match cmd { match cmd {
"filter" => { "filter" => {
let text = arg.unwrap_or(""); let text = arg.unwrap_or("");
Ok(ScriptAction::Core(Action::UpdateFilter(text.to_string()))) core(Action::UpdateFilter(text.to_string()))
} }
"move-up" => { "move-up" => {
let n = parse_count(line_number, line, "move-up", arg)?; let n = parse_count(line_number, line, "move-up", arg)?;
Ok(ScriptAction::Core(Action::MoveUp(n))) core(Action::MoveUp(n))
} }
"move-down" => { "move-down" => {
let n = parse_count(line_number, line, "move-down", arg)?; let n = parse_count(line_number, line, "move-down", arg)?;
Ok(ScriptAction::Core(Action::MoveDown(n))) core(Action::MoveDown(n))
} }
"move-to-top" => Ok(ScriptAction::Core(Action::MoveToTop)), "move-to-top" => core(Action::MoveToTop),
"move-to-bottom" => Ok(ScriptAction::Core(Action::MoveToBottom)), "move-to-bottom" => core(Action::MoveToBottom),
"page-up" => { "page-up" => {
let n = parse_count(line_number, line, "page-up", arg)?; let n = parse_count(line_number, line, "page-up", arg)?;
Ok(ScriptAction::Core(Action::PageUp(n))) core(Action::PageUp(n))
} }
"page-down" => { "page-down" => {
let n = parse_count(line_number, line, "page-down", arg)?; let n = parse_count(line_number, line, "page-down", arg)?;
Ok(ScriptAction::Core(Action::PageDown(n))) core(Action::PageDown(n))
} }
"half-page-up" => { "half-page-up" => {
let n = parse_count(line_number, line, "half-page-up", arg)?; let n = parse_count(line_number, line, "half-page-up", arg)?;
Ok(ScriptAction::Core(Action::HalfPageUp(n))) core(Action::HalfPageUp(n))
} }
"half-page-down" => { "half-page-down" => {
let n = parse_count(line_number, line, "half-page-down", arg)?; let n = parse_count(line_number, line, "half-page-down", arg)?;
Ok(ScriptAction::Core(Action::HalfPageDown(n))) core(Action::HalfPageDown(n))
} }
"set-mode" => { "set-mode" => {
let Some(mode_str) = arg else { let Some(mode_str) = arg else {
@@ -132,29 +181,36 @@ 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" => core(Action::SetMode(Mode::Insert)),
"normal" => Ok(ScriptAction::Core(Action::SetMode(Mode::Normal))), "normal" => core(Action::SetMode(Mode::Normal)),
"visual" => 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"),
)), )),
} }
} }
"confirm" => Ok(ScriptAction::Core(Action::Confirm)), "toggle-select" => core(Action::ToggleSelect),
"cancel" => Ok(ScriptAction::Core(Action::Cancel)), "select-all" => core(Action::SelectAll),
"deselect-all" => core(Action::ClearSelections),
"undo-selection" => core(Action::UndoSelection),
"redo-selection" => core(Action::RedoSelection),
"confirm" => core(Action::Confirm),
"cancel" => core(Action::Cancel),
"resize" => { "resize" => {
let height = parse_positive_u16(line_number, line, "resize", arg)?; let height = parse_positive_u16(line_number, line, "resize", arg)?;
Ok(ScriptAction::Core(Action::Resize { height })) core(Action::Resize { height })
} }
"show-ui" => Ok(ScriptAction::ShowUi), "show-ui" => Ok(ScriptAction::ShowUi),
"show-tui" => Ok(ScriptAction::ShowTui), "show-tui" => Ok(ScriptAction::ShowTui),
"show-gui" => Ok(ScriptAction::ShowGui), "show-gui" => Ok(ScriptAction::ShowGui),
"streaming-done" => core(Action::StreamingDone),
_ => Err(ScriptError { _ => Err(ScriptError {
line: line_number, line: line_number,
source_line: line.to_string(), source_line: line.to_string(),
@@ -237,7 +293,7 @@ mod tests {
assert!(result.is_ok()); assert!(result.is_ok());
assert_eq!( assert_eq!(
result.unwrap_or(ScriptAction::Comment), result.unwrap_or(ScriptAction::Comment),
ScriptAction::Core(Action::UpdateFilter("hello world".to_string())) ScriptAction::Core(Action::UpdateFilter("hello world".to_string()), Modifiers::default())
); );
} }
@@ -247,7 +303,7 @@ mod tests {
assert!(result.is_ok()); assert!(result.is_ok());
assert_eq!( assert_eq!(
result.unwrap_or(ScriptAction::Comment), result.unwrap_or(ScriptAction::Comment),
ScriptAction::Core(Action::UpdateFilter(String::new())) ScriptAction::Core(Action::UpdateFilter(String::new()), Modifiers::default())
); );
} }
@@ -255,27 +311,27 @@ mod tests {
fn parse_movement_actions() { fn parse_movement_actions() {
assert_eq!( assert_eq!(
parse_action(1, "move-up").unwrap_or(ScriptAction::Comment), parse_action(1, "move-up").unwrap_or(ScriptAction::Comment),
ScriptAction::Core(Action::MoveUp(1)) ScriptAction::Core(Action::MoveUp(1), Modifiers::default())
); );
assert_eq!( assert_eq!(
parse_action(1, "move-down").unwrap_or(ScriptAction::Comment), parse_action(1, "move-down").unwrap_or(ScriptAction::Comment),
ScriptAction::Core(Action::MoveDown(1)) ScriptAction::Core(Action::MoveDown(1), Modifiers::default())
); );
assert_eq!( assert_eq!(
parse_action(1, "move-to-top").unwrap_or(ScriptAction::Comment), parse_action(1, "move-to-top").unwrap_or(ScriptAction::Comment),
ScriptAction::Core(Action::MoveToTop) ScriptAction::Core(Action::MoveToTop, Modifiers::default())
); );
assert_eq!( assert_eq!(
parse_action(1, "move-to-bottom").unwrap_or(ScriptAction::Comment), parse_action(1, "move-to-bottom").unwrap_or(ScriptAction::Comment),
ScriptAction::Core(Action::MoveToBottom) ScriptAction::Core(Action::MoveToBottom, Modifiers::default())
); );
assert_eq!( assert_eq!(
parse_action(1, "page-up").unwrap_or(ScriptAction::Comment), parse_action(1, "page-up").unwrap_or(ScriptAction::Comment),
ScriptAction::Core(Action::PageUp(1)) ScriptAction::Core(Action::PageUp(1), Modifiers::default())
); );
assert_eq!( assert_eq!(
parse_action(1, "page-down").unwrap_or(ScriptAction::Comment), parse_action(1, "page-down").unwrap_or(ScriptAction::Comment),
ScriptAction::Core(Action::PageDown(1)) ScriptAction::Core(Action::PageDown(1), Modifiers::default())
); );
} }
@@ -283,19 +339,19 @@ mod tests {
fn parse_movement_with_count() { fn parse_movement_with_count() {
assert_eq!( assert_eq!(
parse_action(1, "move-up 5").unwrap_or(ScriptAction::Comment), parse_action(1, "move-up 5").unwrap_or(ScriptAction::Comment),
ScriptAction::Core(Action::MoveUp(5)) ScriptAction::Core(Action::MoveUp(5), Modifiers::default())
); );
assert_eq!( assert_eq!(
parse_action(1, "move-down 3").unwrap_or(ScriptAction::Comment), parse_action(1, "move-down 3").unwrap_or(ScriptAction::Comment),
ScriptAction::Core(Action::MoveDown(3)) ScriptAction::Core(Action::MoveDown(3), Modifiers::default())
); );
assert_eq!( assert_eq!(
parse_action(1, "page-up 2").unwrap_or(ScriptAction::Comment), parse_action(1, "page-up 2").unwrap_or(ScriptAction::Comment),
ScriptAction::Core(Action::PageUp(2)) ScriptAction::Core(Action::PageUp(2), Modifiers::default())
); );
assert_eq!( assert_eq!(
parse_action(1, "page-down 10").unwrap_or(ScriptAction::Comment), parse_action(1, "page-down 10").unwrap_or(ScriptAction::Comment),
ScriptAction::Core(Action::PageDown(10)) ScriptAction::Core(Action::PageDown(10), Modifiers::default())
); );
} }
@@ -317,11 +373,11 @@ mod tests {
fn parse_confirm_cancel() { fn parse_confirm_cancel() {
assert_eq!( assert_eq!(
parse_action(1, "confirm").unwrap_or(ScriptAction::Comment), parse_action(1, "confirm").unwrap_or(ScriptAction::Comment),
ScriptAction::Core(Action::Confirm) ScriptAction::Core(Action::Confirm, Modifiers::default())
); );
assert_eq!( assert_eq!(
parse_action(1, "cancel").unwrap_or(ScriptAction::Comment), parse_action(1, "cancel").unwrap_or(ScriptAction::Comment),
ScriptAction::Core(Action::Cancel) ScriptAction::Core(Action::Cancel, Modifiers::default())
); );
} }
@@ -329,7 +385,7 @@ mod tests {
fn parse_resize_valid() { fn parse_resize_valid() {
assert_eq!( assert_eq!(
parse_action(1, "resize 25").unwrap_or(ScriptAction::Comment), parse_action(1, "resize 25").unwrap_or(ScriptAction::Comment),
ScriptAction::Core(Action::Resize { height: 25 }) ScriptAction::Core(Action::Resize { height: 25 }, Modifiers::default())
); );
} }
@@ -398,19 +454,19 @@ mod tests {
fn parse_half_page_actions() { fn parse_half_page_actions() {
assert_eq!( assert_eq!(
parse_action(1, "half-page-up").unwrap_or(ScriptAction::Comment), parse_action(1, "half-page-up").unwrap_or(ScriptAction::Comment),
ScriptAction::Core(Action::HalfPageUp(1)) ScriptAction::Core(Action::HalfPageUp(1), Modifiers::default())
); );
assert_eq!( assert_eq!(
parse_action(1, "half-page-down").unwrap_or(ScriptAction::Comment), parse_action(1, "half-page-down").unwrap_or(ScriptAction::Comment),
ScriptAction::Core(Action::HalfPageDown(1)) ScriptAction::Core(Action::HalfPageDown(1), Modifiers::default())
); );
assert_eq!( assert_eq!(
parse_action(1, "half-page-up 3").unwrap_or(ScriptAction::Comment), parse_action(1, "half-page-up 3").unwrap_or(ScriptAction::Comment),
ScriptAction::Core(Action::HalfPageUp(3)) ScriptAction::Core(Action::HalfPageUp(3), Modifiers::default())
); );
assert_eq!( assert_eq!(
parse_action(1, "half-page-down 2").unwrap_or(ScriptAction::Comment), parse_action(1, "half-page-down 2").unwrap_or(ScriptAction::Comment),
ScriptAction::Core(Action::HalfPageDown(2)) ScriptAction::Core(Action::HalfPageDown(2), Modifiers::default())
); );
} }
@@ -424,11 +480,11 @@ mod tests {
fn parse_set_mode() { fn parse_set_mode() {
assert_eq!( assert_eq!(
parse_action(1, "set-mode insert").unwrap_or(ScriptAction::Comment), parse_action(1, "set-mode insert").unwrap_or(ScriptAction::Comment),
ScriptAction::Core(Action::SetMode(Mode::Insert)) ScriptAction::Core(Action::SetMode(Mode::Insert), Modifiers::default())
); );
assert_eq!( assert_eq!(
parse_action(1, "set-mode normal").unwrap_or(ScriptAction::Comment), parse_action(1, "set-mode normal").unwrap_or(ScriptAction::Comment),
ScriptAction::Core(Action::SetMode(Mode::Normal)) ScriptAction::Core(Action::SetMode(Mode::Normal), Modifiers::default())
); );
} }
@@ -439,7 +495,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), Modifiers::default())
);
} }
#[test] #[test]
@@ -509,7 +568,7 @@ mod tests {
ParsedLine { ParsedLine {
line_number: 1, line_number: 1,
source: "filter test".into(), source: "filter test".into(),
action: ScriptAction::Core(Action::UpdateFilter("test".into())), action: ScriptAction::Core(Action::UpdateFilter("test".into()), Modifiers::default()),
}, },
ParsedLine { ParsedLine {
line_number: 2, line_number: 2,
@@ -531,7 +590,7 @@ mod tests {
ParsedLine { ParsedLine {
line_number: 2, line_number: 2,
source: "confirm".into(), source: "confirm".into(),
action: ScriptAction::Core(Action::Confirm), action: ScriptAction::Core(Action::Confirm, Modifiers::default()),
}, },
]; ];
let err = validate_show_last(&actions).unwrap_err(); let err = validate_show_last(&actions).unwrap_err();
@@ -545,7 +604,7 @@ mod tests {
ParsedLine { ParsedLine {
line_number: 1, line_number: 1,
source: "filter x".into(), source: "filter x".into(),
action: ScriptAction::Core(Action::UpdateFilter("x".into())), action: ScriptAction::Core(Action::UpdateFilter("x".into()), Modifiers::default()),
}, },
ParsedLine { ParsedLine {
line_number: 2, line_number: 2,
@@ -555,7 +614,7 @@ mod tests {
ParsedLine { ParsedLine {
line_number: 3, line_number: 3,
source: "confirm".into(), source: "confirm".into(),
action: ScriptAction::Core(Action::Confirm), action: ScriptAction::Core(Action::Confirm, Modifiers::default()),
}, },
]; ];
let err = validate_show_last(&actions).unwrap_err(); let err = validate_show_last(&actions).unwrap_err();
@@ -579,12 +638,12 @@ mod tests {
ParsedLine { ParsedLine {
line_number: 1, line_number: 1,
source: "filter x".into(), source: "filter x".into(),
action: ScriptAction::Core(Action::UpdateFilter("x".into())), action: ScriptAction::Core(Action::UpdateFilter("x".into()), Modifiers::default()),
}, },
ParsedLine { ParsedLine {
line_number: 2, line_number: 2,
source: "confirm".into(), source: "confirm".into(),
action: ScriptAction::Core(Action::Confirm), action: ScriptAction::Core(Action::Confirm, Modifiers::default()),
}, },
]; ];
assert!(validate_show_last(&actions).is_ok()); assert!(validate_show_last(&actions).is_ok());
@@ -645,6 +704,38 @@ 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, Modifiers::default())
);
assert_eq!(
parse_action(1, "select-all").unwrap_or(ScriptAction::Comment),
ScriptAction::Core(Action::SelectAll, Modifiers::default())
);
assert_eq!(
parse_action(1, "deselect-all").unwrap_or(ScriptAction::Comment),
ScriptAction::Core(Action::ClearSelections, Modifiers::default())
);
assert_eq!(
parse_action(1, "undo-selection").unwrap_or(ScriptAction::Comment),
ScriptAction::Core(Action::UndoSelection, Modifiers::default())
);
assert_eq!(
parse_action(1, "redo-selection").unwrap_or(ScriptAction::Comment),
ScriptAction::Core(Action::RedoSelection, Modifiers::default())
);
}
#[test]
fn parse_streaming_done() {
assert_eq!(
parse_action(1, "streaming-done").unwrap_or(ScriptAction::Comment),
ScriptAction::Core(Action::StreamingDone, Modifiers::default())
);
}
#[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());
@@ -657,11 +748,11 @@ mod tests {
// Extra whitespace around the mode value should be trimmed // Extra whitespace around the mode value should be trimmed
assert_eq!( assert_eq!(
parse_action(1, "set-mode insert ").unwrap_or(ScriptAction::Comment), parse_action(1, "set-mode insert ").unwrap_or(ScriptAction::Comment),
ScriptAction::Core(Action::SetMode(Mode::Insert)) ScriptAction::Core(Action::SetMode(Mode::Insert), Modifiers::default())
); );
assert_eq!( assert_eq!(
parse_action(1, "set-mode normal ").unwrap_or(ScriptAction::Comment), parse_action(1, "set-mode normal ").unwrap_or(ScriptAction::Comment),
ScriptAction::Core(Action::SetMode(Mode::Normal)) ScriptAction::Core(Action::SetMode(Mode::Normal), Modifiers::default())
); );
} }
@@ -672,16 +763,96 @@ mod tests {
assert!(result.is_ok()); assert!(result.is_ok());
let actions = result.unwrap_or_default(); let actions = result.unwrap_or_default();
assert_eq!(actions.len(), 5); assert_eq!(actions.len(), 5);
assert_eq!(actions[0], ScriptAction::Core(Action::HalfPageUp(2))); assert_eq!(actions[0], ScriptAction::Core(Action::HalfPageUp(2), Modifiers::default()));
assert_eq!( assert_eq!(
actions[1], actions[1],
ScriptAction::Core(Action::SetMode(Mode::Normal)) ScriptAction::Core(Action::SetMode(Mode::Normal), Modifiers::default())
); );
assert_eq!( assert_eq!(
actions[2], actions[2],
ScriptAction::Core(Action::SetMode(Mode::Insert)) ScriptAction::Core(Action::SetMode(Mode::Insert), Modifiers::default())
); );
assert_eq!(actions[3], ScriptAction::Core(Action::HalfPageDown(1))); assert_eq!(actions[3], ScriptAction::Core(Action::HalfPageDown(1), Modifiers::default()));
assert_eq!(actions[4], ScriptAction::Core(Action::Confirm)); assert_eq!(actions[4], ScriptAction::Core(Action::Confirm, Modifiers::default()));
}
// -- Modifier prefix tests --
#[test]
fn parse_shift_prefix() {
let result = parse_action(1, "+shift confirm");
assert!(result.is_ok());
let expected_mods = Modifiers {
shift: true,
ctrl: false,
alt: false,
};
assert_eq!(
result.unwrap_or(ScriptAction::Comment),
ScriptAction::Core(Action::Confirm, expected_mods)
);
}
#[test]
fn parse_multiple_modifier_prefixes() {
let result = parse_action(1, "+ctrl+alt move-down 3");
assert!(result.is_ok());
let expected_mods = Modifiers {
shift: false,
ctrl: true,
alt: true,
};
assert_eq!(
result.unwrap_or(ScriptAction::Comment),
ScriptAction::Core(Action::MoveDown(3), expected_mods)
);
}
#[test]
fn parse_all_modifier_prefixes() {
let result = parse_action(1, "+shift+ctrl+alt cancel");
assert!(result.is_ok());
let expected_mods = Modifiers {
shift: true,
ctrl: true,
alt: true,
};
assert_eq!(
result.unwrap_or(ScriptAction::Comment),
ScriptAction::Core(Action::Cancel, expected_mods)
);
}
#[test]
fn parse_unknown_modifier_is_error() {
let result = parse_action(1, "+super confirm");
assert!(result.is_err());
}
#[test]
fn parse_modifier_without_action_is_error() {
let result = parse_action(1, "+shift");
assert!(result.is_err());
}
#[test]
fn parse_no_modifier_prefix_has_empty_modifiers() {
let result = parse_action(1, "confirm");
assert!(result.is_ok());
assert_eq!(
result.unwrap_or(ScriptAction::Comment),
ScriptAction::Core(Action::Confirm, Modifiers::default())
);
}
#[test]
fn parse_modifier_with_space_separated() {
// "+shift confirm" should work
let result = parse_action(1, "+shift confirm");
assert!(result.is_ok());
if let Ok(ScriptAction::Core(_, mods)) = result {
assert!(mods.shift);
assert!(!mods.ctrl);
}
} }
} }

View File

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

View File

@@ -0,0 +1,18 @@
[package]
name = "pikl-gui"
description = "GUI frontend for pikl-menu (iced-based, with platform-specific windowing)."
version.workspace = true
edition.workspace = true
license.workspace = true
[lints]
workspace = true
[dependencies]
pikl-core = { path = "../pikl-core" }
iced = { version = "0.14", features = ["tokio"] }
tokio = { version = "1", features = ["sync", "macros", "rt"] }
tracing = "0.1"
[target.'cfg(target_os = "linux")'.dependencies]
iced_layershell = "0.15"

681
crates/pikl-gui/src/lib.rs Normal file
View File

@@ -0,0 +1,681 @@
//! GUI frontend for pikl-menu. Platform-aware: uses Wayland
//! layer-shell on Linux, native window overlay on macOS.
use std::sync::{Mutex, OnceLock};
use iced::keyboard::key::Named;
use iced::keyboard::{Key, Modifiers};
use iced::widget::{column, container, row, scrollable, text, text_input, Column};
use iced::{Color, Element, Font, Length, Subscription, Task, Theme};
use tokio::sync::{broadcast, mpsc};
#[cfg(target_os = "linux")]
use iced_layershell::to_layer_message;
use pikl_core::event::{
Action, MenuEvent, ModifiedAction, Modifiers as PiklModifiers, Mode, ViewState, VisibleItem,
};
/// Number of visible items in the list viewport.
const VIEWPORT_HEIGHT: u16 = 20;
/// GUI error type. Wraps platform-specific errors from the
/// iced backends into a single Display-able type.
#[derive(Debug)]
pub struct GuiError(String);
impl std::fmt::Display for GuiError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.0)
}
}
impl std::error::Error for GuiError {}
/// Pending key state for multi-key sequences (e.g. `gg`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PendingKey {
None,
G,
}
/// Messages for the iced application. On Linux the
/// `#[to_layer_message]` macro adds layer-shell variants
/// that the wildcard arm in `update` handles.
#[cfg_attr(target_os = "linux", to_layer_message)]
#[derive(Debug, Clone)]
enum Message {
/// A state snapshot arrived from the core engine.
StateChanged(ViewState),
/// The core signalled that the menu is done.
Done,
/// Text input changed (insert mode filter).
FilterChanged(String),
/// A keyboard event from iced.
KeyPressed(Key, Modifiers),
/// Fire-and-forget action sent, nothing to do.
Sent,
}
/// Global slot for the bridge receiver. Used by the
/// subscription fn pointer since `Subscription::run`
/// doesn't accept closures.
static BRIDGE_RX: OnceLock<Mutex<Option<mpsc::Receiver<Message>>>> = OnceLock::new();
/// Global slot for the action sender. Moved into the Pikl
/// struct during boot (called exactly once by iced).
static ACTION_TX: OnceLock<Mutex<Option<mpsc::Sender<ModifiedAction>>>> = OnceLock::new();
/// Top-level GUI state.
struct Pikl {
/// Channel to send actions into the core engine.
action_tx: mpsc::Sender<ModifiedAction>,
/// Current view snapshot from core.
view_state: Option<ViewState>,
/// Local copy of filter text for the text input widget.
filter_text: String,
/// Current input mode (synced from core).
mode: Mode,
/// Pending key for multi-key sequences.
pending: PendingKey,
/// Whether multi-select is enabled (synced from core).
multi_enabled: bool,
/// Visual mode anchor index.
visual_anchor: Option<usize>,
/// Last generation seen, to skip duplicate broadcasts.
last_generation: u64,
/// Whether we've focused the input after the first render.
initial_focus_done: bool,
/// ID for the filter text input widget.
filter_input_id: iced::widget::Id,
}
/// Boot closure shared between platforms. Creates the initial
/// Pikl state and returns a focus task for the filter input.
fn boot() -> (Pikl, Task<Message>) {
let input_id = iced::widget::Id::unique();
let atx = ACTION_TX
.get()
.and_then(|m| m.lock().unwrap_or_else(|e| e.into_inner()).take())
.unwrap_or_else(|| {
let (tx, _) = mpsc::channel::<ModifiedAction>(1);
tx
});
let pikl = Pikl {
action_tx: atx,
view_state: None,
filter_text: String::new(),
mode: Mode::Insert,
pending: PendingKey::None,
multi_enabled: false,
visual_anchor: None,
last_generation: 0,
initial_focus_done: false,
filter_input_id: input_id.clone(),
};
(pikl, iced::widget::operation::focus(input_id))
}
// ── Platform: Wayland layer-shell (Linux) ──────────────
#[cfg(target_os = "linux")]
pub fn run(
action_tx: mpsc::Sender<ModifiedAction>,
event_rx: broadcast::Receiver<MenuEvent>,
) -> Result<(), GuiError> {
use iced_layershell::build_pattern::application as layer_application;
use iced_layershell::reexport::{Anchor, KeyboardInteractivity, Layer};
use iced_layershell::settings::{LayerShellSettings, Settings};
setup_bridge(action_tx, event_rx);
layer_application(boot, "pikl", update, view)
.settings(Settings {
layer_settings: LayerShellSettings {
layer: Layer::Overlay,
anchor: Anchor::Top | Anchor::Left | Anchor::Right,
size: Some((0, 600)),
keyboard_interactivity: KeyboardInteractivity::Exclusive,
exclusive_zone: -1,
margin: (200, 400, 0, 400),
..Default::default()
},
..Default::default()
})
.subscription(subscription)
.theme(theme)
.run()
.map_err(|e| GuiError(format!("{e}")))
}
// ── Platform: native window overlay (macOS) ────────────
#[cfg(target_os = "macos")]
pub fn run(
action_tx: mpsc::Sender<ModifiedAction>,
event_rx: broadcast::Receiver<MenuEvent>,
) -> Result<(), GuiError> {
setup_bridge(action_tx, event_rx);
iced::application(boot, update, view)
.title("pikl")
.window_size((800, 600))
.centered()
.decorations(false)
.level(iced::window::Level::AlwaysOnTop)
.subscription(subscription)
.theme(theme)
.run()
.map_err(|e| GuiError(format!("{e}")))
}
// ── Shared setup ───────────────────────────────────────
/// Send initial resize to core, spawn the bridge thread,
/// and stash channels in global slots.
fn setup_bridge(
action_tx: mpsc::Sender<ModifiedAction>,
event_rx: broadcast::Receiver<MenuEvent>,
) {
// try_send rather than blocking_send: this is the first
// message on a fresh channel so it always has capacity,
// and try_send works regardless of whether we're inside
// a tokio runtime context (macOS calls this from the
// main thread outside block_on).
let _ = action_tx.try_send(ModifiedAction::new(Action::Resize {
height: VIEWPORT_HEIGHT,
}));
let (bridge_tx, bridge_rx) = mpsc::channel::<Message>(64);
std::thread::spawn(move || {
bridge_core_events(event_rx, bridge_tx);
});
let _ = BRIDGE_RX.set(Mutex::new(Some(bridge_rx)));
let _ = ACTION_TX.set(Mutex::new(Some(action_tx)));
}
/// Read core events from the broadcast channel and forward
/// them as Messages. Runs on a dedicated thread with its
/// own tokio runtime so it works regardless of whether the
/// caller's runtime is still alive.
fn bridge_core_events(
mut event_rx: broadcast::Receiver<MenuEvent>,
bridge_tx: mpsc::Sender<Message>,
) {
let rt = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(rt) => rt,
Err(_) => return,
};
rt.block_on(async {
loop {
match event_rx.recv().await {
Ok(MenuEvent::StateChanged(vs)) => {
if bridge_tx.send(Message::StateChanged(vs)).await.is_err() {
break;
}
}
Ok(
MenuEvent::Selected(_)
| MenuEvent::Quicklist(_)
| MenuEvent::Cancelled,
) => {
let _ = bridge_tx.send(Message::Done).await;
break;
}
Err(broadcast::error::RecvError::Lagged(n)) => {
tracing::warn!(skipped = n, "GUI fell behind on state broadcasts");
}
Err(broadcast::error::RecvError::Closed) => {
let _ = bridge_tx.send(Message::Done).await;
break;
}
}
}
});
}
// ── Update / View / Subscription ───────────────────────
fn update(state: &mut Pikl, message: Message) -> Task<Message> {
#[allow(unreachable_patterns)]
match message {
Message::StateChanged(vs) => {
if vs.generation == state.last_generation {
return Task::none();
}
state.last_generation = vs.generation;
if &*vs.filter_text != state.filter_text.as_str() {
state.filter_text = vs.filter_text.to_string();
}
if vs.mode != state.mode {
state.mode = vs.mode;
state.pending = PendingKey::None;
}
state.multi_enabled = vs.multi_enabled;
state.view_state = Some(vs);
// Re-focus the input on the first state update.
// The boot focus task can fire before the widget
// tree exists, so this catches the case where the
// input starts unfocused.
if !state.initial_focus_done {
state.initial_focus_done = true;
iced::widget::operation::focus(state.filter_input_id.clone())
} else {
Task::none()
}
}
Message::Done => iced::exit(),
Message::FilterChanged(new_text) => {
if state.mode != Mode::Insert {
return Task::none();
}
state.filter_text = new_text.clone();
let tx = state.action_tx.clone();
Task::perform(
async move {
let _ = tx.send(ModifiedAction::new(Action::UpdateFilter(new_text))).await;
},
|_| Message::Sent,
)
}
Message::KeyPressed(key, modifiers) => handle_key(state, key, modifiers),
Message::Sent => Task::none(),
_ => Task::none(),
}
}
fn view(state: &Pikl) -> Element<'_, Message> {
let filter_input = text_input("filter...", &state.filter_text)
.id(state.filter_input_id.clone())
.on_input(Message::FilterChanged)
.padding(8)
.size(16)
.font(Font::MONOSPACE);
let mode_indicator = match state.mode {
Mode::Insert => "[I]",
Mode::Normal => "[N]",
Mode::Visual => "[V]",
};
let counts = if let Some(vs) = &state.view_state {
let streaming = if vs.streaming { "..." } else { "" };
let sel = if vs.selection_count > 0 {
format!(" [{}]", vs.selection_count)
} else {
String::new()
};
format!(
"{}/{}{streaming}{sel}",
vs.total_filtered, vs.total_items
)
} else {
String::from("0/0")
};
let prompt_row = row![
text(mode_indicator)
.font(Font::MONOSPACE)
.size(16)
.color(Color::from_rgb(0.4, 0.8, 1.0)),
filter_input,
text(counts)
.font(Font::MONOSPACE)
.size(14)
.color(Color::from_rgb(0.5, 0.5, 0.5)),
]
.spacing(8)
.align_y(iced::Alignment::Center)
.padding(4);
let items_list: Element<Message> = if let Some(vs) = &state.view_state {
let items: Vec<Element<Message>> = vs
.visible_items
.iter()
.enumerate()
.map(|(i, vi)| render_item(state, i, vi, vs))
.collect();
scrollable(
Column::with_children(items)
.spacing(0)
.width(Length::Fill),
)
.height(Length::Fill)
.into()
} else {
text("loading...").font(Font::MONOSPACE).size(14).into()
};
container(
column![prompt_row, items_list]
.spacing(0)
.width(Length::Fill),
)
.style(|_theme| container::Style {
background: Some(iced::Background::Color(Color::from_rgb(
0.12, 0.12, 0.15,
))),
..Default::default()
})
.width(Length::Fill)
.height(Length::Fill)
.into()
}
fn theme(_state: &Pikl) -> Theme {
Theme::Dark
}
fn render_item<'a>(
state: &Pikl,
index: usize,
vi: &VisibleItem,
vs: &ViewState,
) -> Element<'a, Message> {
let is_cursor = index == vs.cursor;
let in_visual = if vs.mode == Mode::Visual {
state.visual_anchor.is_some_and(|anchor| {
let min = anchor.min(vs.cursor);
let max = anchor.max(vs.cursor);
(min..=max).contains(&index)
})
} else {
false
};
let display_text = vi.formatted_text.as_deref().unwrap_or(vi.label.as_str());
let label = if state.multi_enabled {
let marker = if vi.selected { "* " } else { " " };
format!("{marker}{display_text}")
} else {
display_text.to_string()
};
let text_color = if vi.selected {
Color::from_rgb(0.4, 0.9, 0.4)
} else if is_cursor || in_visual {
Color::from_rgb(0.95, 0.95, 0.95)
} else {
Color::from_rgb(0.8, 0.8, 0.8)
};
let bg_color = if is_cursor {
Color::from_rgb(0.25, 0.25, 0.35)
} else if in_visual {
Color::from_rgb(0.15, 0.2, 0.35)
} else {
Color::TRANSPARENT
};
container(
text(label)
.font(Font::MONOSPACE)
.size(15)
.color(text_color),
)
.style(move |_theme| container::Style {
background: Some(iced::Background::Color(bg_color)),
..Default::default()
})
.padding([4, 10])
.width(Length::Fill)
.into()
}
fn subscription(_state: &Pikl) -> Subscription<Message> {
let keyboard_sub = iced::event::listen_with(|event, _status, _id| match event {
iced::Event::Keyboard(iced::keyboard::Event::KeyPressed {
key,
modifiers,
..
}) => Some(Message::KeyPressed(key, modifiers)),
_ => None,
});
let core_sub = Subscription::run(core_event_stream);
Subscription::batch([keyboard_sub, core_sub])
}
/// Stream factory for the core event subscription. Takes the
/// bridge receiver from the global slot and yields Messages
/// until the channel closes.
fn core_event_stream() -> impl iced::futures::Stream<Item = Message> {
let rx = BRIDGE_RX
.get()
.and_then(|m| m.lock().unwrap_or_else(|e| e.into_inner()).take());
iced::futures::stream::unfold(rx, |state| async move {
let mut rx = state?;
match rx.recv().await {
Some(msg) => Some((msg, Some(rx))),
None => Some((Message::Done, None)),
}
})
}
// ── Keyboard handling ──────────────────────────────────
/// Convert iced keyboard modifiers to pikl Modifiers.
fn iced_modifiers(m: &iced::keyboard::Modifiers) -> PiklModifiers {
PiklModifiers {
shift: m.shift(),
ctrl: m.control(),
alt: m.alt(),
}
}
/// Handle a keyboard event and return the appropriate task.
fn handle_key(state: &mut Pikl, key: Key, modifiers: Modifiers) -> Task<Message> {
let action = match state.mode {
Mode::Insert => map_insert_key(state, &key, &modifiers),
Mode::Normal => map_normal_key(state, &key, &modifiers),
Mode::Visual => map_visual_key(state, &key, &modifiers),
};
if let Some(action) = action {
if let Action::SetMode(m) = &action {
if *m == Mode::Visual {
if let Some(vs) = &state.view_state {
state.visual_anchor = Some(vs.cursor);
}
} else if state.mode == Mode::Visual {
state.visual_anchor = None;
}
state.mode = *m;
state.pending = PendingKey::None;
}
let focus_task = if matches!(action, Action::SetMode(Mode::Insert)) {
iced::widget::operation::focus(state.filter_input_id.clone())
} else {
Task::none()
};
let tx = state.action_tx.clone();
let mods = iced_modifiers(&modifiers);
let send_task = Task::perform(
async move {
let _ = tx.send(ModifiedAction::with_modifiers(action, mods)).await;
},
|_| Message::Sent,
);
Task::batch([send_task, focus_task])
} else {
Task::none()
}
}
/// Map keys in insert mode. Returns None for keys that the
/// text_input widget handles (regular characters, backspace).
fn map_insert_key(state: &mut Pikl, key: &Key, modifiers: &Modifiers) -> Option<Action> {
let ctrl = modifiers.control();
match key {
Key::Named(Named::Escape) => Some(Action::Cancel),
Key::Named(Named::Enter) => Some(Action::Confirm),
Key::Named(Named::ArrowUp) => Some(Action::MoveUp(1)),
Key::Named(Named::ArrowDown) => Some(Action::MoveDown(1)),
Key::Named(Named::PageUp) => Some(Action::PageUp(1)),
Key::Named(Named::PageDown) => Some(Action::PageDown(1)),
Key::Named(Named::Tab) if state.multi_enabled => {
let tx = state.action_tx.clone();
let mods = iced_modifiers(modifiers);
tokio::spawn(async move {
let _ = tx.send(ModifiedAction::with_modifiers(Action::ToggleSelect, mods)).await;
let _ = tx.send(ModifiedAction::with_modifiers(Action::MoveDown(1), mods)).await;
});
None
}
Key::Character(c) if ctrl && c.as_str() == "p" => Some(Action::MoveUp(1)),
Key::Character(c) if ctrl && c.as_str() == "n" => {
Some(Action::SetMode(Mode::Normal))
}
Key::Character(c) if ctrl && c.as_str() == "q" => Some(Action::Quicklist),
_ => None,
}
}
/// Map keys in normal mode.
fn map_normal_key(state: &mut Pikl, key: &Key, modifiers: &Modifiers) -> Option<Action> {
let ctrl = modifiers.control();
if state.pending == PendingKey::G {
state.pending = PendingKey::None;
if let Key::Character(c) = key
&& c.as_str() == "g"
&& !ctrl
{
return Some(Action::MoveToTop);
}
}
match key {
Key::Character(c) if !ctrl => match c.as_str() {
"j" => Some(Action::MoveDown(1)),
"k" => Some(Action::MoveUp(1)),
"G" => Some(Action::MoveToBottom),
"g" => {
state.pending = PendingKey::G;
None
}
"/" => Some(Action::SetMode(Mode::Insert)),
"q" => Some(Action::Cancel),
"V" => Some(Action::SetMode(Mode::Visual)),
"U" => Some(Action::ClearSelections),
"u" => Some(Action::UndoSelection),
" " if state.multi_enabled => {
let tx = state.action_tx.clone();
let mods = iced_modifiers(modifiers);
tokio::spawn(async move {
let _ = tx.send(ModifiedAction::with_modifiers(Action::ToggleSelect, mods)).await;
let _ = tx.send(ModifiedAction::with_modifiers(Action::MoveDown(1), mods)).await;
});
None
}
_ => None,
},
Key::Character(c) if ctrl => match c.as_str() {
"d" => Some(Action::HalfPageDown(1)),
"u" => Some(Action::HalfPageUp(1)),
"f" => Some(Action::PageDown(1)),
"b" => Some(Action::PageUp(1)),
"e" => Some(Action::SetMode(Mode::Insert)),
"q" => Some(Action::Quicklist),
"r" => Some(Action::RedoSelection),
_ => None,
},
Key::Named(Named::Enter) => Some(Action::Confirm),
Key::Named(Named::Escape) => Some(Action::Cancel),
Key::Named(Named::ArrowUp) => Some(Action::MoveUp(1)),
Key::Named(Named::ArrowDown) => Some(Action::MoveDown(1)),
Key::Named(Named::Tab) if state.multi_enabled => {
let tx = state.action_tx.clone();
let mods = iced_modifiers(modifiers);
tokio::spawn(async move {
let _ = tx.send(ModifiedAction::with_modifiers(Action::ToggleSelect, mods)).await;
let _ = tx.send(ModifiedAction::with_modifiers(Action::MoveDown(1), mods)).await;
});
None
}
_ => None,
}
}
/// Map keys in visual mode.
fn map_visual_key(state: &mut Pikl, key: &Key, modifiers: &Modifiers) -> Option<Action> {
let ctrl = modifiers.control();
if state.pending == PendingKey::G {
state.pending = PendingKey::None;
if let Key::Character(c) = key
&& c.as_str() == "g"
&& !ctrl
{
return Some(Action::MoveToTop);
}
}
match key {
Key::Character(c) if !ctrl => match c.as_str() {
"j" => Some(Action::MoveDown(1)),
"k" => Some(Action::MoveUp(1)),
"G" => Some(Action::MoveToBottom),
"g" => {
state.pending = PendingKey::G;
None
}
"V" => Some(Action::SetMode(Mode::Normal)),
" " => {
if let (Some(anchor), Some(vs)) =
(state.visual_anchor, &state.view_state)
{
let tx = state.action_tx.clone();
let start = anchor;
let end = vs.cursor;
let mods = iced_modifiers(modifiers);
tokio::spawn(async move {
let _ = tx
.send(ModifiedAction::with_modifiers(Action::SelectRange { start, end }, mods))
.await;
let _ = tx.send(ModifiedAction::with_modifiers(Action::SetMode(Mode::Normal), mods)).await;
});
}
state.visual_anchor = None;
None
}
_ => None,
},
Key::Named(Named::Enter) => {
if let (Some(anchor), Some(vs)) =
(state.visual_anchor, &state.view_state)
{
let tx = state.action_tx.clone();
let start = anchor;
let end = vs.cursor;
let mods = iced_modifiers(modifiers);
tokio::spawn(async move {
let _ = tx
.send(ModifiedAction::with_modifiers(Action::SelectRange { start, end }, mods))
.await;
let _ = tx.send(ModifiedAction::with_modifiers(Action::SetMode(Mode::Normal), mods)).await;
});
}
state.visual_anchor = None;
None
}
Key::Named(Named::ArrowDown) => Some(Action::MoveDown(1)),
Key::Named(Named::ArrowUp) => Some(Action::MoveUp(1)),
Key::Named(Named::Escape) => Some(Action::Cancel),
_ => None,
}
}

View File

@@ -24,6 +24,7 @@ fn gen_module(module: &TestModule) -> syn::Result<TokenStream> {
TestKind::Filter => gen_filter(case, &module.fixtures)?, TestKind::Filter => gen_filter(case, &module.fixtures)?,
TestKind::Nav => gen_nav(case, &module.fixtures)?, TestKind::Nav => gen_nav(case, &module.fixtures)?,
TestKind::Menu => gen_menu(case, &module.fixtures)?, TestKind::Menu => gen_menu(case, &module.fixtures)?,
TestKind::Ipc => gen_ipc(case, &module.fixtures)?,
}; };
test_fns.push(tokens); test_fns.push(tokens);
} }
@@ -58,11 +59,20 @@ fn gen_imports(kind: TestKind) -> TokenStream {
TestKind::Menu => { TestKind::Menu => {
quote! { quote! {
use pikl_core::item::Item; use pikl_core::item::Item;
use pikl_core::event::{Action, MenuEvent, MenuResult}; use pikl_core::event::{Action, MenuEvent, MenuResult, ModifiedAction};
use pikl_core::menu::MenuRunner; use pikl_core::menu::MenuRunner;
use pikl_core::json_menu::JsonMenu; use pikl_core::json_menu::JsonMenu;
} }
} }
TestKind::Ipc => {
quote! {
use pikl_core::item::Item;
use pikl_core::event::{Action, MenuEvent, MenuResult, ModifiedAction};
use pikl_core::menu::MenuRunner;
use pikl_core::json_menu::JsonMenu;
use crate::ipc::server::IpcServer;
}
}
} }
} }
@@ -92,12 +102,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 +151,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() {
@@ -166,8 +189,8 @@ fn build_headless_script(actions: &[ActionExpr]) -> String {
script.push_str(line); script.push_str(line);
script.push('\n'); script.push('\n');
} }
ActionExpr::AddItems(_) => { ActionExpr::AddItems(_) | ActionExpr::IpcSend(_) | ActionExpr::IpcExpect(_) => {
// Not applicable for headless. Items come from stdin. // Not applicable for headless.
} }
} }
} }
@@ -178,18 +201,12 @@ fn build_headless_script(actions: &[ActionExpr]) -> String {
// Filter // Filter
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// Generate a filter unit test: create items, push them
/// into a `FuzzyFilter`, set the query, and assert on
/// matched labels.
fn gen_filter(case: &TestCase, fixtures: &Fixtures) -> syn::Result<TokenStream> { fn gen_filter(case: &TestCase, fixtures: &Fixtures) -> syn::Result<TokenStream> {
let test_name = &case.name; let test_name = &case.name;
let item_exprs = gen_item_constructors(fixtures); let item_exprs = gen_item_constructors(fixtures);
let query = case.query.as_deref().unwrap_or(""); let query = case.query.as_deref().unwrap_or("");
let mut asserts = Vec::new(); let mut asserts = Vec::new();
if let Some(ref expected) = case.match_labels { if let Some(ref expected) = case.match_labels {
asserts.push(quote! { asserts.push(quote! {
let labels: Vec<&str> = (0..f.matched_count()) let labels: Vec<&str> = (0..f.matched_count())
@@ -222,11 +239,8 @@ fn gen_filter(case: &TestCase, fixtures: &Fixtures) -> syn::Result<TokenStream>
// Nav // Nav
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// Generate a navigation unit test: create a viewport, run
/// movement actions, and assert on cursor/offset.
fn gen_nav(case: &TestCase, fixtures: &Fixtures) -> syn::Result<TokenStream> { fn gen_nav(case: &TestCase, fixtures: &Fixtures) -> syn::Result<TokenStream> {
let test_name = &case.name; let test_name = &case.name;
let (height, count) = fixtures.viewport.unwrap_or((10, 20)); let (height, count) = fixtures.viewport.unwrap_or((10, 20));
let height_lit = height; let height_lit = height;
let count_lit = count; let count_lit = count;
@@ -234,22 +248,14 @@ fn gen_nav(case: &TestCase, fixtures: &Fixtures) -> syn::Result<TokenStream> {
let action_calls = gen_nav_actions(&case.actions)?; let action_calls = gen_nav_actions(&case.actions)?;
let mut asserts = Vec::new(); let mut asserts = Vec::new();
if let Some(cursor) = case.cursor { if let Some(cursor) = case.cursor {
asserts.push(quote! { asserts.push(quote! {
assert_eq!( assert_eq!(v.cursor(), #cursor, "expected cursor {}, got {}", #cursor, v.cursor());
v.cursor(), #cursor,
"expected cursor {}, got {}", #cursor, v.cursor()
);
}); });
} }
if let Some(offset) = case.offset { if let Some(offset) = case.offset {
asserts.push(quote! { asserts.push(quote! {
assert_eq!( assert_eq!(v.scroll_offset(), #offset, "expected offset {}, got {}", #offset, v.scroll_offset());
v.scroll_offset(), #offset,
"expected offset {}, got {}", #offset, v.scroll_offset()
);
}); });
} }
@@ -265,8 +271,6 @@ fn gen_nav(case: &TestCase, fixtures: &Fixtures) -> syn::Result<TokenStream> {
}) })
} }
/// Convert DSL action names to `Viewport` method calls
/// (e.g. `move-down` becomes `v.move_down()`).
fn gen_nav_actions(actions: &[ActionExpr]) -> syn::Result<Vec<TokenStream>> { fn gen_nav_actions(actions: &[ActionExpr]) -> syn::Result<Vec<TokenStream>> {
let mut calls = Vec::new(); let mut calls = Vec::new();
for action in actions { for action in actions {
@@ -275,12 +279,8 @@ fn gen_nav_actions(actions: &[ActionExpr]) -> syn::Result<Vec<TokenStream>> {
let method = Ident::new(&name.replace('-', "_"), Span::call_site()); let method = Ident::new(&name.replace('-', "_"), Span::call_site());
let needs_count = matches!( let needs_count = matches!(
name.as_str(), name.as_str(),
"move-up" "move-up" | "move-down" | "page-up" | "page-down"
| "move-down" | "half-page-up" | "half-page-down"
| "page-up"
| "page-down"
| "half-page-up"
| "half-page-down"
); );
if needs_count { if needs_count {
calls.push(quote! { v.#method(1); }); calls.push(quote! { v.#method(1); });
@@ -291,10 +291,7 @@ fn gen_nav_actions(actions: &[ActionExpr]) -> syn::Result<Vec<TokenStream>> {
_ => { _ => {
return Err(syn::Error::new( return Err(syn::Error::new(
Span::call_site(), Span::call_site(),
format!( format!("nav tests only support simple actions, got: {:?}", action_debug(action)),
"nav tests only support simple actions, got: {:?}",
action_debug(action)
),
)); ));
} }
} }
@@ -306,104 +303,65 @@ fn gen_nav_actions(actions: &[ActionExpr]) -> syn::Result<Vec<TokenStream>> {
// Menu // Menu
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// Generate an async menu state machine test: create a menu,
/// send actions, and assert on the final result (selected
/// item or cancellation).
fn gen_menu(case: &TestCase, fixtures: &Fixtures) -> syn::Result<TokenStream> { fn gen_menu(case: &TestCase, fixtures: &Fixtures) -> syn::Result<TokenStream> {
let test_name = &case.name; let test_name = &case.name;
let item_exprs = gen_item_constructors(fixtures); let item_exprs = gen_item_constructors(fixtures);
let label_key = fixtures.label_key.as_deref().unwrap_or("label"); let label_key = fixtures.label_key.as_deref().unwrap_or("label");
let action_sends = gen_menu_actions(&case.actions)?; let action_sends = gen_menu_actions(&case.actions)?;
let result_assert = gen_result_assert(case, label_key)?;
let result_assert = if case.cancelled { let multi_setup = if fixtures.multi == Some(true) {
quote! { quote! { menu.set_multi(true); }
assert!(
matches!(result, Ok(MenuResult::Cancelled)),
"expected Cancelled, got: {:?}", result.as_ref().map(|r| format!("{:?}", r))
);
}
} else if let Some(ref expected) = case.selected {
quote! {
match &result {
Ok(MenuResult::Selected { value, .. }) => {
let got = value.as_str()
.or_else(|| value.get(#label_key).and_then(|v| v.as_str()))
.unwrap_or("");
assert_eq!(
got, #expected,
"expected selected {:?}, got value: {:?}", #expected, value
);
}
other => panic!("expected Selected, got: {:?}", other),
}
}
} else { } else {
// No assertion on result. Probably an error, but let it compile.
quote! {} quote! {}
}; };
// If test expects cancellation via sender drop (no cancel action, no confirm),
// we need to drop tx after sending actions.
let drop_sender = quote! { drop(tx); };
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 });
// Wait for initial state broadcast. let _ = rx.recv().await;
let _ = tx.send(ModifiedAction::new(Action::Resize { height: 50 })).await;
let _ = rx.recv().await; let _ = rx.recv().await;
// Give the viewport some height so confirms work.
let _ = tx.send(Action::Resize { height: 50 }).await;
let _ = rx.recv().await;
// Send all actions.
#(#action_sends)* #(#action_sends)*
// Drop sender so menu loop can exit. drop(tx);
#drop_sender
let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled)); let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled { modifiers: pikl_core::event::Modifiers::default() }));
#result_assert #result_assert
} }
}) })
} }
/// Convert DSL actions to `tx.send(Action::...)` calls for
/// menu tests.
fn gen_menu_actions(actions: &[ActionExpr]) -> syn::Result<Vec<TokenStream>> { fn gen_menu_actions(actions: &[ActionExpr]) -> syn::Result<Vec<TokenStream>> {
let mut sends = Vec::new(); let mut sends = Vec::new();
for action in actions { for action in actions {
let expr = match action { let expr = match action {
ActionExpr::Simple(name) => { ActionExpr::Simple(name) => {
let variant = menu_action_variant(name)?; let variant = menu_action_variant(name)?;
quote! { let _ = tx.send(#variant).await; } quote! { let _ = tx.send(ModifiedAction::new(#variant)).await; }
} }
ActionExpr::Filter(query) => { ActionExpr::Filter(query) => {
quote! { quote! { let _ = tx.send(ModifiedAction::new(Action::UpdateFilter(#query.to_string()))).await; }
let _ = tx.send(Action::UpdateFilter(#query.to_string())).await;
}
} }
ActionExpr::AddItems(items) => { ActionExpr::AddItems(items) => {
let item_exprs: Vec<TokenStream> = items let item_exprs: Vec<TokenStream> = items
.iter() .iter()
.map(|s| quote! { serde_json::Value::String(#s.to_string()) }) .map(|s| quote! { serde_json::Value::String(#s.to_string()) })
.collect(); .collect();
quote! { quote! { let _ = tx.send(ModifiedAction::new(Action::AddItems(vec![#(#item_exprs),*]))).await; }
let _ = tx.send(Action::AddItems(vec![#(#item_exprs),*])).await;
}
} }
ActionExpr::Raw(_) => { ActionExpr::Raw(_) => {
return Err(syn::Error::new( return Err(syn::Error::new(Span::call_site(), "raw actions are only supported in headless tests"));
Span::call_site(), }
"raw actions are only supported in headless tests", ActionExpr::IpcSend(_) | ActionExpr::IpcExpect(_) => {
)); return Err(syn::Error::new(Span::call_site(), "ipc actions are only supported in ipc tests"));
} }
}; };
sends.push(expr); sends.push(expr);
@@ -411,8 +369,6 @@ fn gen_menu_actions(actions: &[ActionExpr]) -> syn::Result<Vec<TokenStream>> {
Ok(sends) Ok(sends)
} }
/// Map a DSL action name like `"move-down"` to the
/// corresponding `Action::MoveDown` token stream.
fn menu_action_variant(name: &str) -> syn::Result<TokenStream> { fn menu_action_variant(name: &str) -> syn::Result<TokenStream> {
let tokens = match name { let tokens = match name {
"confirm" => quote! { Action::Confirm }, "confirm" => quote! { Action::Confirm },
@@ -427,38 +383,220 @@ 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(), format!("unknown menu action: '{name}'")));
Span::call_site(),
format!("unknown menu action: '{name}'"),
));
} }
}; };
Ok(tokens) Ok(tokens)
} }
// ---------------------------------------------------------------------------
// IPC
// ---------------------------------------------------------------------------
/// Generate an async IPC integration test: create a menu,
/// start an IPC server, connect via Unix socket, and send/
/// receive JSON commands.
fn gen_ipc(case: &TestCase, fixtures: &Fixtures) -> syn::Result<TokenStream> {
let test_name = &case.name;
let test_name_str = test_name.to_string();
let item_exprs = gen_item_constructors(fixtures);
let label_key = fixtures.label_key.as_deref().unwrap_or("label");
let multi_setup = if fixtures.multi == Some(true) {
quote! { menu.set_multi(true); }
} else {
quote! {}
};
let has_expect = case.actions.iter().any(|a| matches!(a, ActionExpr::IpcExpect(_)));
let has_result = case.selected.is_some() || case.selected_items.is_some() || case.cancelled;
if !has_expect && !has_result {
return Err(syn::Error::new(
case.name.span(),
format!(
"ipc test '{}' has no assertions (expected ipc-expect, selected, selected_items, or cancelled)",
case.name
),
));
}
let ipc_actions = gen_ipc_actions(&case.actions)?;
let result_assert = if has_result {
gen_result_assert(case, label_key)?
} else {
quote! { let _ = result; }
};
Ok(quote! {
#[tokio::test]
async fn #test_name() {
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::UnixStream;
use std::time::Duration;
let items = vec![#(#item_exprs),*];
let (mut menu, tx) = MenuRunner::new(JsonMenu::new(items, #label_key.to_string()));
#multi_setup
let event_sender = menu.event_sender();
let ipc_event_rx = menu.subscribe();
let handle = tokio::spawn(async move { menu.run().await });
let _ = tx.send(ModifiedAction::new(Action::Resize { height: 50 })).await;
tokio::time::sleep(Duration::from_millis(30)).await;
// Start IPC server on a temp socket
let socket_path = std::env::temp_dir().join(
format!("pikl-ipc-{}-{}.sock", std::process::id(), #test_name_str)
);
let _ = std::fs::remove_file(&socket_path);
let ipc_server = IpcServer::new(
socket_path.clone(), tx.clone(), event_sender,
);
assert!(ipc_server.start(ipc_event_rx).is_ok(), "failed to start IPC server");
tokio::time::sleep(Duration::from_millis(10)).await;
let stream = match UnixStream::connect(&socket_path).await {
Ok(s) => s,
Err(e) => {
drop(tx);
drop(ipc_server);
let _ = handle.await;
assert!(false, "failed to connect to IPC socket: {e}");
return;
}
};
let (read_half, write_half) = stream.into_split();
let mut reader = BufReader::new(read_half);
let mut writer = write_half;
#(#ipc_actions)*
drop(writer);
drop(tx);
drop(ipc_server);
let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled { modifiers: pikl_core::event::Modifiers::default() }));
#result_assert
}
})
}
fn gen_ipc_actions(actions: &[ActionExpr]) -> syn::Result<Vec<TokenStream>> {
let mut stmts = Vec::new();
for action in actions {
match action {
ActionExpr::IpcSend(line) => {
stmts.push(quote! {
assert!(writer.write_all(#line.as_bytes()).await.is_ok(), "ipc write failed");
assert!(writer.write_all(b"\n").await.is_ok(), "ipc write newline failed");
assert!(writer.flush().await.is_ok(), "ipc flush failed");
tokio::time::sleep(Duration::from_millis(30)).await;
});
}
ActionExpr::IpcExpect(expected) => {
stmts.push(quote! {
{
let mut __resp = String::new();
let __read = tokio::time::timeout(
Duration::from_secs(5),
reader.read_line(&mut __resp)
).await;
assert!(__read.is_ok(), "IPC response timed out after 5s");
let __n = __read.unwrap_or(Ok(0)).unwrap_or(0);
assert!(__n > 0, "IPC connection closed before response");
assert!(
__resp.contains(#expected),
"expected IPC response to contain {:?}, got: {}",
#expected, __resp.trim()
);
}
});
}
_ => {
return Err(syn::Error::new(
Span::call_site(),
format!("ipc tests only support ipc-send and ipc-expect actions, got: {:?}", action_debug(action)),
));
}
}
}
Ok(stmts)
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Shared helpers // Shared helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// Generate `Item::from_plain_text("...")` expressions for
/// each item in the fixtures.
fn gen_item_constructors(fixtures: &Fixtures) -> Vec<TokenStream> { fn gen_item_constructors(fixtures: &Fixtures) -> Vec<TokenStream> {
match &fixtures.items { match &fixtures.items {
Some(items) => items Some(items) => items.iter().map(|s| quote! { Item::from_plain_text(#s) }).collect(),
.iter()
.map(|s| quote! { Item::from_plain_text(#s) })
.collect(),
None => Vec::new(), None => Vec::new(),
} }
} }
/// Format an action expression for use in error messages.
fn action_debug(action: &ActionExpr) -> String { fn action_debug(action: &ActionExpr) -> String {
match action { match action {
ActionExpr::Simple(name) => name.clone(), ActionExpr::Simple(name) => name.clone(),
ActionExpr::Filter(q) => format!("filter \"{q}\""), ActionExpr::Filter(q) => format!("filter \"{q}\""),
ActionExpr::Raw(r) => format!("raw \"{r}\""), ActionExpr::Raw(r) => format!("raw \"{r}\""),
ActionExpr::AddItems(items) => format!("add-items {:?}", items), ActionExpr::AddItems(items) => format!("add-items {:?}", items),
ActionExpr::IpcSend(s) => format!("ipc-send \"{s}\""),
ActionExpr::IpcExpect(s) => format!("ipc-expect \"{s}\""),
}
}
/// Generate the result assertion for menu/ipc tests.
fn gen_result_assert(case: &TestCase, label_key: &str) -> syn::Result<TokenStream> {
if case.cancelled {
Ok(quote! {
assert!(
matches!(result, Ok(MenuResult::Cancelled { .. })),
"expected Cancelled, got: {:?}", result.as_ref().map(|r| format!("{:?}", r))
);
})
} else if let Some(ref expected) = case.selected {
Ok(quote! {
match &result {
Ok(MenuResult::Selected { items, .. }) => {
assert!(!items.is_empty(), "expected Selected with items, got empty");
let value = &items[0].0;
let got = value.as_str()
.or_else(|| value.get(#label_key).and_then(|v| v.as_str()))
.unwrap_or("");
assert_eq!(got, #expected, "expected selected {:?}, got value: {:?}", #expected, value);
}
other => panic!("expected Selected, got: {:?}", other),
}
})
} else if let Some(ref expected) = case.selected_items {
Ok(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 {
Err(syn::Error::new(
case.name.span(),
format!("test '{}' has no result assertion (expected selected, selected_items, or cancelled)", case.name),
))
} }
} }

View File

@@ -25,12 +25,14 @@ pub enum TestKind {
Filter, Filter,
Nav, Nav,
Menu, Menu,
Ipc,
} }
pub struct Fixtures { 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 +46,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,
} }
@@ -56,6 +59,10 @@ pub enum ActionExpr {
Raw(String), Raw(String),
/// `add-items ["a", "b", "c"]` /// `add-items ["a", "b", "c"]`
AddItems(Vec<String>), AddItems(Vec<String>),
/// `ipc-send "json line"` — send a JSON command over the IPC socket
IpcSend(String),
/// `ipc-expect "text"` — read a response line and assert it contains text
IpcExpect(String),
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -89,6 +96,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 +128,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 +178,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 +240,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![:]) {
@@ -285,6 +305,14 @@ fn parse_action_expr(input: ParseStream) -> syn::Result<ActionExpr> {
let items = parse_string_list(input)?; let items = parse_string_list(input)?;
Ok(ActionExpr::AddItems(items)) Ok(ActionExpr::AddItems(items))
} }
"ipc-send" => {
let val: LitStr = input.parse()?;
Ok(ActionExpr::IpcSend(val.value()))
}
"ipc-expect" => {
let val: LitStr = input.parse()?;
Ok(ActionExpr::IpcExpect(val.value()))
}
_ => Ok(ActionExpr::Simple(name)), _ => Ok(ActionExpr::Simple(name)),
} }
} }
@@ -302,9 +330,10 @@ fn parse_kind(input: ParseStream) -> syn::Result<TestKind> {
"filter" => Ok(TestKind::Filter), "filter" => Ok(TestKind::Filter),
"nav" => Ok(TestKind::Nav), "nav" => Ok(TestKind::Nav),
"menu" => Ok(TestKind::Menu), "menu" => Ok(TestKind::Menu),
"ipc" => Ok(TestKind::Ipc),
other => Err(syn::Error::new( other => Err(syn::Error::new(
ident.span(), ident.span(),
format!("unknown test kind '{other}', expected headless, filter, nav, or menu"), format!("unknown test kind '{other}', expected headless, filter, nav, menu, or ipc"),
)), )),
} }
} }

View File

@@ -14,7 +14,7 @@ use ratatui::text::{Line, Span};
use ratatui::widgets::{List, ListItem, Paragraph}; use ratatui::widgets::{List, ListItem, Paragraph};
use tokio::sync::{broadcast, mpsc}; use tokio::sync::{broadcast, mpsc};
use pikl_core::event::{Action, MenuEvent, Mode, ViewState}; use pikl_core::event::{Action, MenuEvent, ModifiedAction, Modifiers, Mode, ViewState};
/// Pending key state for multi-key sequences (e.g. `gg`). /// Pending key state for multi-key sequences (e.g. `gg`).
/// TUI-local, not part of core state. /// TUI-local, not part of core state.
@@ -32,13 +32,23 @@ pub fn restore_terminal() {
let _ = crossterm::execute!(std::io::stderr(), crossterm::terminal::LeaveAlternateScreen); let _ = crossterm::execute!(std::io::stderr(), crossterm::terminal::LeaveAlternateScreen);
} }
/// Convert crossterm key modifiers to pikl Modifiers.
fn crossterm_modifiers(km: KeyModifiers) -> Modifiers {
Modifiers {
shift: km.contains(KeyModifiers::SHIFT),
ctrl: km.contains(KeyModifiers::CONTROL),
alt: km.contains(KeyModifiers::ALT),
}
}
/// Start the TUI. Enters the alternate screen, runs the /// Start the TUI. Enters the alternate screen, runs the
/// event loop, and restores the terminal on exit. Translates /// event loop, and restores the terminal on exit. Translates
/// crossterm key events into [`Action`]s and renders /// crossterm key events into [`Action`]s and renders
/// [`ViewState`] snapshots. /// [`ViewState`] snapshots.
pub async fn run( pub async fn run(
action_tx: mpsc::Sender<Action>, action_tx: mpsc::Sender<ModifiedAction>,
mut event_rx: broadcast::Receiver<MenuEvent>, mut event_rx: broadcast::Receiver<MenuEvent>,
filter_history: Option<Vec<String>>,
) -> std::io::Result<()> { ) -> std::io::Result<()> {
crossterm::terminal::enable_raw_mode()?; crossterm::terminal::enable_raw_mode()?;
crossterm::execute!(std::io::stderr(), crossterm::terminal::EnterAlternateScreen,)?; crossterm::execute!(std::io::stderr(), crossterm::terminal::EnterAlternateScreen,)?;
@@ -53,7 +63,7 @@ pub async fn run(
let _ = crossterm::event::read()?; let _ = crossterm::event::read()?;
} }
let result = run_inner(&action_tx, &mut event_rx, &mut terminal).await; let result = run_inner(&action_tx, &mut event_rx, &mut terminal, filter_history).await;
// Always clean up terminal, even on error // Always clean up terminal, even on error
restore_terminal(); restore_terminal();
@@ -64,31 +74,49 @@ pub async fn run(
/// Inner event loop. Separated from [`run`] so terminal /// Inner event loop. Separated from [`run`] so terminal
/// cleanup always happens even if this returns an error. /// cleanup always happens even if this returns an error.
async fn run_inner( async fn run_inner(
action_tx: &mpsc::Sender<Action>, action_tx: &mpsc::Sender<ModifiedAction>,
event_rx: &mut broadcast::Receiver<MenuEvent>, event_rx: &mut broadcast::Receiver<MenuEvent>,
terminal: &mut Terminal<CrosstermBackend<std::io::Stderr>>, terminal: &mut Terminal<CrosstermBackend<std::io::Stderr>>,
filter_history: Option<Vec<String>>,
) -> std::io::Result<()> { ) -> std::io::Result<()> {
// 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(ModifiedAction::new(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;
// Session filter history cycling state
let history = filter_history.unwrap_or_default();
let mut history_cursor: Option<usize> = None;
let mut pre_history_text: Option<String> = 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 +128,93 @@ 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) { // History cycling: Ctrl+P/Ctrl+N in insert mode
// with a non-empty history list.
if mode == Mode::Insert && !history.is_empty() {
let history_handled = match (key.code, key.modifiers) {
(KeyCode::Char('p'), KeyModifiers::CONTROL) | (KeyCode::Up, _) => {
if pre_history_text.is_none() {
pre_history_text = Some(filter_text.clone());
}
let new_cursor = match history_cursor {
Some(c) if c > 0 => c - 1,
Some(_) => 0,
None => history.len() - 1,
};
history_cursor = Some(new_cursor);
filter_text.clone_from(&history[new_cursor]);
let _ = action_tx
.send(ModifiedAction::new(Action::UpdateFilter(filter_text.clone())))
.await;
true
}
(KeyCode::Char('n'), KeyModifiers::CONTROL) => {
if let Some(c) = history_cursor {
if c + 1 < history.len() {
history_cursor = Some(c + 1);
filter_text.clone_from(&history[c + 1]);
} else {
// Past the end: restore original text
history_cursor = None;
if let Some(ref saved) = pre_history_text {
filter_text.clone_from(saved);
}
pre_history_text = None;
}
let _ = action_tx
.send(ModifiedAction::new(Action::UpdateFilter(filter_text.clone())))
.await;
true
} else {
// Not cycling, fall through to normal Ctrl+N
false
}
}
_ => {
// Any other key resets history cycling
if history_cursor.is_some() {
history_cursor = None;
pre_history_text = None;
}
false
}
};
if history_handled {
continue;
}
}
// 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(ModifiedAction::with_modifiers(action, crossterm_modifiers(key.modifiers))).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(ModifiedAction::new(Action::Resize { height: list_height })).await.is_err() {
break;
}
} }
_ => {} _ => {}
} }
@@ -136,6 +239,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 +259,118 @@ 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<ModifiedAction>,
) -> bool {
match mode {
Mode::Insert => {
if !multi_enabled {
return false;
}
match key.code {
KeyCode::Tab => {
let mods = crossterm_modifiers(key.modifiers);
let _ = action_tx.send(ModifiedAction::with_modifiers(Action::ToggleSelect, mods)).await;
let _ = action_tx.send(ModifiedAction::with_modifiers(Action::MoveDown(1), mods)).await;
true
}
KeyCode::BackTab => {
let mods = crossterm_modifiers(key.modifiers);
let _ = action_tx.send(ModifiedAction::with_modifiers(Action::ToggleSelect, mods)).await;
let _ = action_tx.send(ModifiedAction::with_modifiers(Action::MoveUp(1), mods)).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 mods = crossterm_modifiers(key.modifiers);
let _ = action_tx.send(ModifiedAction::with_modifiers(Action::ToggleSelect, mods)).await;
let _ = action_tx.send(ModifiedAction::with_modifiers(Action::MoveDown(1), mods)).await;
true
}
(KeyCode::Tab, _) => {
let mods = crossterm_modifiers(key.modifiers);
let _ = action_tx.send(ModifiedAction::with_modifiers(Action::ToggleSelect, mods)).await;
let _ = action_tx.send(ModifiedAction::with_modifiers(Action::MoveDown(1), mods)).await;
true
}
(KeyCode::BackTab, _) => {
let mods = crossterm_modifiers(key.modifiers);
let _ = action_tx.send(ModifiedAction::with_modifiers(Action::ToggleSelect, mods)).await;
let _ = action_tx.send(ModifiedAction::with_modifiers(Action::MoveUp(1), mods)).await;
true
}
_ => false,
}
}
Mode::Visual => {
match (key.code, key.modifiers) {
(KeyCode::Char(' '), m) | (KeyCode::Enter, m)
if !m.intersects(KeyModifiers::ALT) =>
{
let mods = crossterm_modifiers(key.modifiers);
// Apply visual selection
if let (Some(anchor), Some(vs)) = (*visual_anchor, view_state) {
let cursor = vs.cursor;
let _ = action_tx
.send(ModifiedAction::with_modifiers(Action::SelectRange {
start: anchor,
end: cursor,
}, mods))
.await;
}
*visual_anchor = None;
let _ = action_tx.send(ModifiedAction::with_modifiers(Action::SetMode(Mode::Normal), mods)).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,19 +379,32 @@ 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),
), ),
Span::raw(filter_text), Span::raw(filter_text),
Span::styled( Span::styled(
format!(" {filtered_count}/{total_count}"), format!(
" {filtered_count}/{total_count}{}",
if vs.streaming { "..." } else { "" }
),
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 +415,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()
}; };
let text = vi.formatted_text.as_deref().unwrap_or(vi.label.as_str());
ListItem::new(text).style(style) 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());
if vs.multi_enabled {
ListItem::new(format!("{marker}{text}")).style(style)
} else {
ListItem::new(text).style(style)
}
}
}) })
.collect(); .collect();
let list = List::new(items); let list = List::new(items);
frame.render_widget(list, chunks[1]); frame.render_widget(list, list_chunk);
} }
/// Map a crossterm key event to an [`Action`], updating /// Map a crossterm key event to an [`Action`], updating
@@ -223,6 +534,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 +601,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 +615,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 +684,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 +707,10 @@ 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,
streaming: false,
columns: None,
generation: 1, generation: 1,
} }
} }
@@ -703,7 +1066,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 +1183,10 @@ 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,
streaming: false,
columns: None,
generation: 1, generation: 1,
}; };
let backend = render_to_backend(30, 4, &vs, ""); let backend = render_to_backend(30, 4, &vs, "");
@@ -828,6 +1195,29 @@ mod tests {
assert!(prompt.contains("0/0")); assert!(prompt.contains("0/0"));
} }
#[test]
fn streaming_indicator_shows_ellipsis() {
let mut vs = sample_view_state();
vs.streaming = true;
let backend = render_to_backend(40, 5, &vs, "");
let prompt = line_text(&backend, 0);
assert!(
prompt.contains("..."),
"streaming indicator should show '...': {prompt}"
);
}
#[test]
fn no_streaming_indicator_when_done() {
let vs = sample_view_state();
let backend = render_to_backend(40, 5, &vs, "");
let prompt = line_text(&backend, 0);
assert!(
!prompt.contains("..."),
"should not show '...' when not streaming: {prompt}"
);
}
#[test] #[test]
fn narrow_viewport_truncates() { fn narrow_viewport_truncates() {
let vs = sample_view_state(); let vs = sample_view_state();
@@ -853,7 +1243,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 +1261,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 +1283,158 @@ 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,
streaming: false,
columns: Some(vec![
ColumnHeader {
display_name: "name".into(),
width: 9,
},
ColumnHeader {
display_name: "age".into(),
width: 3,
},
ColumnHeader {
display_name: "city".into(),
width: 9,
},
]),
generation: 1,
}
}
#[test]
fn table_renders_header_row() {
let vs = table_view_state();
let backend = render_to_backend(50, 6, &vs, "");
// Row 0 = prompt, Row 1 = header, Row 2+ = items
let header = line_text(&backend, 1);
assert!(
header.contains("name"),
"header should contain 'name': got '{header}'"
);
assert!(
header.contains("age"),
"header should contain 'age': got '{header}'"
);
assert!(
header.contains("city"),
"header should contain 'city': got '{header}'"
);
}
#[test]
fn table_header_has_bold_style() {
let vs = table_view_state();
let backend = render_to_backend(50, 6, &vs, "");
let buf = backend.buffer();
// Row 1 col 0 should be bold (header row)
let cell = &buf[(0, 1)];
assert!(
cell.modifier.contains(Modifier::BOLD),
"header should be bold"
);
}
#[test]
fn table_items_start_at_row_2() {
let vs = table_view_state();
let backend = render_to_backend(50, 6, &vs, "");
let row2 = line_text(&backend, 2);
let row3 = line_text(&backend, 3);
assert!(
row2.contains("alice"),
"row 2 should have alice: got '{row2}'"
);
assert!(row3.contains("bob"), "row 3 should have bob: got '{row3}'");
}
#[test]
fn table_cells_contain_values() {
let vs = table_view_state();
let backend = render_to_backend(50, 6, &vs, "");
let row2 = line_text(&backend, 2);
assert!(
row2.contains("toronto"),
"row should contain city value: got '{row2}'"
);
assert!(
row2.contains("30"),
"row should contain age value: got '{row2}'"
);
}
#[test]
fn table_cursor_row_has_reversed_style() {
let vs = table_view_state(); // cursor at 0
let backend = render_to_backend(50, 6, &vs, "");
let buf = backend.buffer();
// Row 2 (first item in table mode) should be REVERSED
let cell = &buf[(0, 2)];
assert!(
cell.modifier.contains(Modifier::REVERSED),
"cursor row should have REVERSED style in table mode"
);
// Row 3 should not
let cell2 = &buf[(0, 3)];
assert!(
!cell2.modifier.contains(Modifier::REVERSED),
"non-cursor row should not have REVERSED in table mode"
);
}
#[test]
fn table_header_has_separators() {
let vs = table_view_state();
let backend = render_to_backend(50, 6, &vs, "");
let header = line_text(&backend, 1);
assert!(
header.contains('\u{2502}'),
"header should contain column separator: got '{header}'"
);
}
#[test]
fn table_without_columns_renders_normally() {
// When columns is None, should render like a normal list (no header row)
let vs = sample_view_state();
let backend = render_to_backend(30, 6, &vs, "");
// Row 1 should be items, not a header
let row1 = line_text(&backend, 1);
assert!(
row1.contains("alpha"),
"without columns, row 1 should be first item: got '{row1}'"
);
}
#[test] #[test]
fn pending_cleared_on_mode_key() { fn pending_cleared_on_mode_key() {
let mut ft = String::new(); let mut ft = String::new();

View File

@@ -15,8 +15,10 @@ path = "src/main.rs"
[dependencies] [dependencies]
pikl-core = { path = "../pikl-core" } pikl-core = { path = "../pikl-core" }
pikl-tui = { path = "../pikl-tui" } pikl-tui = { path = "../pikl-tui" }
pikl-gui = { path = "../pikl-gui" }
clap = { version = "4", features = ["derive"] } clap = { version = "4", features = ["derive"] }
tokio = { version = "1", features = ["rt-multi-thread", "process", "signal", "io-util"] } tokio = { version = "1", features = ["rt-multi-thread", "process", "signal", "io-util", "net"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1" serde_json = "1"
tracing = "0.1" tracing = "0.1"
tracing-subscriber = "0.3" tracing-subscriber = "0.3"

View File

@@ -11,10 +11,8 @@ use tokio::process::Command;
use tokio::sync::mpsc; use tokio::sync::mpsc;
use pikl_core::error::PiklError; use pikl_core::error::PiklError;
use pikl_core::event::Action; use pikl_core::event::{Action, ModifiedAction};
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<ModifiedAction>) -> 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(())
} }
@@ -94,7 +91,7 @@ impl HookHandler for ShellHandlerHook {
async fn run_handler_process( async fn run_handler_process(
command: &str, command: &str,
mut event_rx: mpsc::Receiver<HookEvent>, mut event_rx: mpsc::Receiver<HookEvent>,
action_tx: mpsc::Sender<Action>, action_tx: mpsc::Sender<ModifiedAction>,
) -> Result<(), PiklError> { ) -> Result<(), PiklError> {
let mut child = Command::new("sh") let mut child = Command::new("sh")
.arg("-c") .arg("-c")
@@ -123,7 +120,7 @@ async fn run_handler_process(
let mut lines = stdout_reader.lines(); let mut lines = stdout_reader.lines();
while let Ok(Some(line)) = lines.next_line().await { while let Ok(Some(line)) = lines.next_line().await {
if let Some(resp) = parse_hook_response(&line) { if let Some(resp) = parse_hook_response(&line) {
let action = Action::ProcessHookResponse(resp); let action = ModifiedAction::new(Action::ProcessHookResponse(resp));
if reader_action_tx.send(action).await.is_err() { if reader_action_tx.send(action).await.is_err() {
break; break;
} }
@@ -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,12 +152,9 @@ async fn run_handler_process(
drop(stdin_writer); drop(stdin_writer);
// Wait up to 1s for child to exit after EOF // Wait up to 1s for child to exit after EOF
let exited = tokio::time::timeout( let exited = tokio::time::timeout(std::time::Duration::from_secs(1), child.wait())
std::time::Duration::from_secs(1), .await
child.wait(), .is_ok();
)
.await
.is_ok();
if !exited { if !exited {
// Send SIGTERM on Unix, then wait another second // Send SIGTERM on Unix, then wait another second
@@ -177,12 +169,9 @@ async fn run_handler_process(
libc::kill(pid as libc::pid_t, libc::SIGTERM); libc::kill(pid as libc::pid_t, libc::SIGTERM);
} }
} }
let termed = tokio::time::timeout( let termed = tokio::time::timeout(std::time::Duration::from_secs(1), child.wait())
std::time::Duration::from_secs(1), .await
child.wait(), .is_ok();
)
.await
.is_ok();
if !termed { if !termed {
let _ = child.kill().await; let _ = child.kill().await;
} }
@@ -194,11 +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(())
} }

View File

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

View File

@@ -0,0 +1,120 @@
//! IPC integration tests. Uses the proc macro DSL to set up
//! a menu, start an IPC server, and interact over a Unix socket.
use pikl_test_macros::pikl_tests;
pikl_tests! {
ipc mod ipc_get_state {
items: ["alpha", "beta", "gamma"];
test get_state_returns_item_count {
actions: [
ipc-send r#"{"action":"get_state","id":"1"}"#,
ipc-expect r#""total_items":3"#,
]
}
test get_state_returns_filter_text {
actions: [
ipc-send r#"{"action":"get_state","id":"1"}"#,
ipc-expect r#""filter_text":"""#,
]
}
}
ipc mod ipc_set_filter {
items: ["alpha", "beta", "gamma"];
test set_filter_then_get_state {
actions: [
ipc-send r#"{"action":"set_filter","text":"al"}"#,
ipc-send r#"{"action":"get_state","id":"1"}"#,
ipc-expect r#""total_filtered":1"#,
]
}
}
ipc mod ipc_add_items {
items: ["alpha", "beta"];
test add_items_increases_count {
actions: [
ipc-send r#"{"action":"add_items","items":["delta","epsilon"]}"#,
ipc-send r#"{"action":"get_state","id":"1"}"#,
ipc-expect r#""total_items":4"#,
]
}
}
ipc mod ipc_navigation {
items: ["alpha", "beta", "gamma"];
test move_down_changes_cursor {
actions: [
ipc-send r#"{"action":"move_down"}"#,
ipc-send r#"{"action":"get_state","id":"1"}"#,
ipc-expect r#""cursor":1"#,
]
}
test move_to_bottom_then_state {
actions: [
ipc-send r#"{"action":"move_to_bottom"}"#,
ipc-send r#"{"action":"get_state","id":"1"}"#,
ipc-expect r#""cursor":2"#,
]
}
}
ipc mod ipc_confirm {
items: ["alpha", "beta", "gamma"];
test confirm_selects_cursor_item {
actions: [
ipc-send r#"{"action":"confirm"}"#,
]
selected: "alpha"
}
test move_then_confirm {
actions: [
ipc-send r#"{"action":"move_down"}"#,
ipc-send r#"{"action":"confirm"}"#,
]
selected: "beta"
}
}
ipc mod ipc_cancel {
items: ["alpha", "beta"];
test cancel_via_ipc {
actions: [
ipc-send r#"{"action":"cancel"}"#,
]
cancelled
}
}
ipc mod ipc_selection {
items: ["alpha", "beta", "gamma"];
test get_selection_initially_empty {
actions: [
ipc-send r#"{"action":"get_selection","id":"1"}"#,
ipc-expect r#""indices":[]"#,
]
}
}
ipc mod ipc_errors {
items: ["alpha"];
test bad_json_returns_error {
actions: [
ipc-send r#"not valid json"#,
ipc-expect r#""error":"#,
]
}
}
}

View File

@@ -0,0 +1,55 @@
//! IPC module. Provides external control of pikl via Unix socket.
//! Newline-delimited JSON protocol. Off by default, enabled with `--ipc`.
pub mod protocol;
pub mod server;
#[cfg(test)]
mod integration;
use std::path::PathBuf;
use pikl_core::error::PiklError;
/// Compute the socket path for a pikl IPC session.
///
/// Named session: `/run/user/$UID/pikl-{session}.sock`
/// No session: `/run/user/$UID/pikl-{pid}.sock`
pub fn socket_path(session: Option<&str>) -> Result<PathBuf, PiklError> {
let uid = unsafe { libc::getuid() };
let run_dir = PathBuf::from(format!("/run/user/{uid}"));
if !run_dir.exists() {
return Err(PiklError::Io(std::io::Error::new(
std::io::ErrorKind::NotFound,
format!("/run/user/{uid} does not exist"),
)));
}
let name = match session {
Some(s) => format!("pikl-{s}.sock"),
None => format!("pikl-{}.sock", std::process::id()),
};
Ok(run_dir.join(name))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn socket_path_with_session() {
let path = socket_path(Some("test"));
// Should succeed on Linux systems with /run/user/$UID
if let Ok(p) = path {
assert!(p.to_string_lossy().contains("pikl-test.sock"));
}
}
#[test]
fn socket_path_without_session() {
let path = socket_path(None);
if let Ok(p) = path {
let pid = std::process::id();
assert!(p.to_string_lossy().contains(&format!("pikl-{pid}.sock")));
}
}
}

View File

@@ -0,0 +1,212 @@
//! IPC protocol types. Newline-delimited JSON over Unix socket.
//! Write commands are fire-and-forget, read commands use an `id`
//! field for request/response correlation.
use pikl_core::event::{Action, Mode, ViewState};
use serde::{Deserialize, Serialize};
use serde_json::Value;
/// Command sent from an IPC client to pikl.
#[derive(Debug, Deserialize)]
#[serde(tag = "action", rename_all = "snake_case")]
pub enum IpcCommand {
// -- Write commands (no response) --
AddItems { items: Vec<Value> },
ReplaceItems { items: Vec<Value> },
RemoveItems { indices: Vec<usize> },
SetFilter { text: String },
MoveUp,
MoveDown,
MoveToTop,
MoveToBottom,
PageUp,
PageDown,
ToggleSelect,
SelectAll,
ClearSelections,
Confirm,
Cancel,
Close,
// -- Read commands (require id) --
GetState { id: String },
GetSelection { id: String },
// -- Subscription --
Subscribe { events: Vec<String> },
Unsubscribe,
}
/// Response sent from pikl to an IPC client.
#[derive(Debug, Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum IpcResponse {
State {
id: String,
filter_text: String,
cursor: usize,
total_items: usize,
total_filtered: usize,
mode: String,
selection_count: usize,
},
Selection {
id: String,
indices: Vec<usize>,
},
Event {
event: String,
#[serde(skip_serializing_if = "Option::is_none")]
data: Option<Value>,
},
Error {
#[serde(skip_serializing_if = "Option::is_none")]
id: Option<String>,
error: String,
},
}
/// Convert a write-type IpcCommand into a core Action.
/// Returns None for read/subscribe commands (handled by the server).
pub fn ipc_command_to_action(cmd: &IpcCommand) -> Option<Action> {
match cmd {
IpcCommand::AddItems { items } => Some(Action::AddItems(items.clone())),
IpcCommand::ReplaceItems { items } => Some(Action::ReplaceItems(items.clone())),
IpcCommand::RemoveItems { indices } => Some(Action::RemoveItems(indices.clone())),
IpcCommand::SetFilter { text } => Some(Action::UpdateFilter(text.clone())),
IpcCommand::MoveUp => Some(Action::MoveUp(1)),
IpcCommand::MoveDown => Some(Action::MoveDown(1)),
IpcCommand::MoveToTop => Some(Action::MoveToTop),
IpcCommand::MoveToBottom => Some(Action::MoveToBottom),
IpcCommand::PageUp => Some(Action::PageUp(1)),
IpcCommand::PageDown => Some(Action::PageDown(1)),
IpcCommand::ToggleSelect => Some(Action::ToggleSelect),
IpcCommand::SelectAll => Some(Action::SelectAll),
IpcCommand::ClearSelections => Some(Action::ClearSelections),
IpcCommand::Confirm => Some(Action::Confirm),
IpcCommand::Cancel => Some(Action::Cancel),
IpcCommand::Close => Some(Action::CloseMenu),
IpcCommand::GetState { .. }
| IpcCommand::GetSelection { .. }
| IpcCommand::Subscribe { .. }
| IpcCommand::Unsubscribe => None,
}
}
/// Build an IpcResponse::State from a cached ViewState.
pub fn view_state_to_response(id: String, vs: &ViewState) -> IpcResponse {
let mode = match vs.mode {
Mode::Insert => "insert",
Mode::Normal => "normal",
Mode::Visual => "visual",
};
IpcResponse::State {
id,
filter_text: vs.filter_text.to_string(),
cursor: vs.cursor,
total_items: vs.total_items,
total_filtered: vs.total_filtered,
mode: mode.to_string(),
selection_count: vs.selection_count,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn deserialize_write_commands() -> Result<(), Box<dyn std::error::Error>> {
let cases = vec![
(r#"{"action":"move_up"}"#, "MoveUp"),
(r#"{"action":"move_down"}"#, "MoveDown"),
(r#"{"action":"move_to_top"}"#, "MoveToTop"),
(r#"{"action":"move_to_bottom"}"#, "MoveToBottom"),
(r#"{"action":"page_up"}"#, "PageUp"),
(r#"{"action":"page_down"}"#, "PageDown"),
(r#"{"action":"toggle_select"}"#, "ToggleSelect"),
(r#"{"action":"select_all"}"#, "SelectAll"),
(r#"{"action":"clear_selections"}"#, "ClearSelections"),
(r#"{"action":"confirm"}"#, "Confirm"),
(r#"{"action":"cancel"}"#, "Cancel"),
(r#"{"action":"close"}"#, "Close"),
];
for (json, name) in cases {
let cmd: IpcCommand = serde_json::from_str(json)?;
assert!(
ipc_command_to_action(&cmd).is_some(),
"{name} should produce an action"
);
}
Ok(())
}
#[test]
fn deserialize_set_filter() -> Result<(), Box<dyn std::error::Error>> {
let json = r#"{"action":"set_filter","text":"hello"}"#;
let cmd: IpcCommand = serde_json::from_str(json)?;
let action = ipc_command_to_action(&cmd);
assert!(matches!(action, Some(Action::UpdateFilter(ref t)) if t == "hello"));
Ok(())
}
#[test]
fn deserialize_add_items() -> Result<(), Box<dyn std::error::Error>> {
let json = r#"{"action":"add_items","items":["foo","bar"]}"#;
let cmd: IpcCommand = serde_json::from_str(json)?;
let action = ipc_command_to_action(&cmd);
assert!(matches!(action, Some(Action::AddItems(ref items)) if items.len() == 2));
Ok(())
}
#[test]
fn deserialize_remove_items() -> Result<(), Box<dyn std::error::Error>> {
let json = r#"{"action":"remove_items","indices":[0,2]}"#;
let cmd: IpcCommand = serde_json::from_str(json)?;
let action = ipc_command_to_action(&cmd);
assert!(matches!(action, Some(Action::RemoveItems(ref idx)) if idx == &[0, 2]));
Ok(())
}
#[test]
fn deserialize_replace_items() -> Result<(), Box<dyn std::error::Error>> {
let json = r#"{"action":"replace_items","items":["a","b","c"]}"#;
let cmd: IpcCommand = serde_json::from_str(json)?;
let action = ipc_command_to_action(&cmd);
assert!(matches!(action, Some(Action::ReplaceItems(ref items)) if items.len() == 3));
Ok(())
}
#[test]
fn deserialize_get_state() -> Result<(), Box<dyn std::error::Error>> {
let json = r#"{"action":"get_state","id":"req1"}"#;
let cmd: IpcCommand = serde_json::from_str(json)?;
assert!(ipc_command_to_action(&cmd).is_none());
Ok(())
}
#[test]
fn deserialize_get_selection() -> Result<(), Box<dyn std::error::Error>> {
let json = r#"{"action":"get_selection","id":"req2"}"#;
let cmd: IpcCommand = serde_json::from_str(json)?;
assert!(ipc_command_to_action(&cmd).is_none());
Ok(())
}
#[test]
fn deserialize_subscribe() -> Result<(), Box<dyn std::error::Error>> {
let json = r#"{"action":"subscribe","events":["hover","filter"]}"#;
let cmd: IpcCommand = serde_json::from_str(json)?;
assert!(ipc_command_to_action(&cmd).is_none());
Ok(())
}
#[test]
fn deserialize_unsubscribe() -> Result<(), Box<dyn std::error::Error>> {
let json = r#"{"action":"unsubscribe"}"#;
let cmd: IpcCommand = serde_json::from_str(json)?;
assert!(ipc_command_to_action(&cmd).is_none());
Ok(())
}
}

View File

@@ -0,0 +1,331 @@
//! IPC server. Accepts Unix socket connections and translates
//! JSON commands into Actions. Each client gets its own task.
use std::path::PathBuf;
use std::sync::Arc;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::UnixListener;
use tokio::sync::{broadcast, mpsc, watch, RwLock};
use tracing::{info, warn};
use pikl_core::event::{MenuEvent, ModifiedAction, ViewState};
use super::protocol::{ipc_command_to_action, view_state_to_response, IpcCommand, IpcResponse};
/// IPC server that listens on a Unix socket and bridges
/// external commands into the menu's action channel.
/// Dropping the server cancels all background tasks and
/// removes the socket file.
pub struct IpcServer {
socket_path: PathBuf,
action_tx: mpsc::Sender<ModifiedAction>,
state: Arc<RwLock<Option<ViewState>>>,
event_tx: broadcast::Sender<MenuEvent>,
/// Dropping the sender signals all tasks to shut down.
_shutdown_tx: watch::Sender<bool>,
}
impl IpcServer {
pub fn new(
socket_path: PathBuf,
action_tx: mpsc::Sender<ModifiedAction>,
event_tx: broadcast::Sender<MenuEvent>,
) -> Self {
Self {
socket_path,
action_tx,
state: Arc::new(RwLock::new(None)),
event_tx,
_shutdown_tx: watch::channel(false).0,
}
}
/// Start listening. Spawns background tasks for state caching
/// and connection handling. Returns immediately.
pub fn start(&self, event_rx: broadcast::Receiver<MenuEvent>) -> Result<(), std::io::Error> {
// Remove stale socket from a previous crash
if self.socket_path.exists() {
std::fs::remove_file(&self.socket_path)?;
}
let listener = {
let std_listener = std::os::unix::net::UnixListener::bind(&self.socket_path)?;
std_listener.set_nonblocking(true)?;
UnixListener::from_std(std_listener)?
};
// Set socket permissions to 0o700 (owner only)
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = std::fs::Permissions::from_mode(0o700);
std::fs::set_permissions(&self.socket_path, perms)?;
}
info!(path = %self.socket_path.display(), "IPC server listening");
// State cache task: keep a copy of the latest ViewState
let state = Arc::clone(&self.state);
let mut cache_rx = event_rx;
let mut shutdown_rx = self._shutdown_tx.subscribe();
tokio::spawn(async move {
loop {
tokio::select! {
event = cache_rx.recv() => {
match event {
Ok(MenuEvent::StateChanged(vs)) => {
*state.write().await = Some(vs);
}
Ok(
MenuEvent::Selected(_)
| MenuEvent::Quicklist(_)
| MenuEvent::Cancelled,
) => break,
Err(broadcast::error::RecvError::Lagged(n)) => {
warn!(skipped = n, "IPC state cache fell behind");
}
Err(broadcast::error::RecvError::Closed) => break,
}
}
_ = shutdown_rx.changed() => break,
}
}
});
// Accept loop
let action_tx = self.action_tx.clone();
let state = Arc::clone(&self.state);
let event_tx = self.event_tx.clone();
let mut shutdown_rx = self._shutdown_tx.subscribe();
tokio::spawn(async move {
loop {
tokio::select! {
result = listener.accept() => {
match result {
Ok((stream, _addr)) => {
let action_tx = action_tx.clone();
let state = Arc::clone(&state);
let event_tx = event_tx.clone();
tokio::spawn(async move {
if let Err(e) =
handle_connection(stream, action_tx, state, event_tx).await
{
warn!(%e, "IPC connection error");
}
});
}
Err(e) => {
if e.kind() == std::io::ErrorKind::InvalidInput {
break;
}
warn!(%e, "IPC accept error");
}
}
}
_ = shutdown_rx.changed() => break,
}
}
});
Ok(())
}
}
impl Drop for IpcServer {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.socket_path);
}
}
/// Handle a single client connection. Reads JSON lines,
/// dispatches actions, sends responses.
async fn handle_connection(
stream: tokio::net::UnixStream,
action_tx: mpsc::Sender<ModifiedAction>,
state: Arc<RwLock<Option<ViewState>>>,
event_tx: broadcast::Sender<MenuEvent>,
) -> Result<(), std::io::Error> {
let (read_half, mut write_half) = stream.into_split();
let mut reader = BufReader::new(read_half);
let mut line = String::new();
loop {
line.clear();
let n = reader.read_line(&mut line).await?;
if n == 0 {
break; // client disconnected
}
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
let cmd: IpcCommand = match serde_json::from_str(trimmed) {
Ok(c) => c,
Err(e) => {
warn!(input = trimmed, %e, "bad IPC command");
let resp = IpcResponse::Error {
id: None,
error: format!("invalid JSON: {e}"),
};
write_response(&mut write_half, &resp).await?;
continue;
}
};
match cmd {
// Write commands: convert to action, send, no response
ref c if ipc_command_to_action(c).is_some() => {
if let Some(action) = ipc_command_to_action(&cmd) {
let _ = action_tx.send(ModifiedAction::new(action)).await;
}
}
// Read: get_state
IpcCommand::GetState { ref id } => {
let guard = state.read().await;
let resp = match &*guard {
Some(vs) => view_state_to_response(id.clone(), vs),
None => IpcResponse::Error {
id: Some(id.clone()),
error: "no state available yet".to_string(),
},
};
write_response(&mut write_half, &resp).await?;
}
// Read: get_selection
IpcCommand::GetSelection { ref id } => {
let guard = state.read().await;
let resp = match &*guard {
Some(vs) => {
let indices: Vec<usize> = vs
.visible_items
.iter()
.filter(|vi| vi.selected)
.map(|vi| vi.index)
.collect();
IpcResponse::Selection {
id: id.clone(),
indices,
}
}
None => IpcResponse::Error {
id: Some(id.clone()),
error: "no state available yet".to_string(),
},
};
write_response(&mut write_half, &resp).await?;
}
// Subscribe: forward matching events as JSON lines
IpcCommand::Subscribe { ref events } => {
let event_filter: Vec<String> =
events.iter().map(|s| s.to_lowercase()).collect();
let mut sub_rx = event_tx.subscribe();
// Spawn a forwarding task. It runs until the client
// disconnects (write fails) or the menu closes.
let mut write_half_clone = write_half;
tokio::spawn(async move {
loop {
match sub_rx.recv().await {
Ok(MenuEvent::StateChanged(vs)) => {
if event_filter.contains(&"state".to_string()) {
let resp = IpcResponse::Event {
event: "state".to_string(),
data: Some(serde_json::json!({
"filter_text": vs.filter_text.to_string(),
"cursor": vs.cursor,
"total_items": vs.total_items,
"total_filtered": vs.total_filtered,
"selection_count": vs.selection_count,
})),
};
if write_response(&mut write_half_clone, &resp)
.await
.is_err()
{
break;
}
}
}
Ok(MenuEvent::Selected(items)) => {
if event_filter.contains(&"select".to_string()) {
let resp = IpcResponse::Event {
event: "select".to_string(),
data: Some(serde_json::json!({
"items": items.iter()
.map(|(v, i)| serde_json::json!({"value": v, "index": i}))
.collect::<Vec<_>>()
})),
};
if write_response(&mut write_half_clone, &resp)
.await
.is_err()
{
break;
}
}
break;
}
Ok(MenuEvent::Quicklist(items)) => {
if event_filter.contains(&"quicklist".to_string()) {
let resp = IpcResponse::Event {
event: "quicklist".to_string(),
data: Some(serde_json::json!({"items": items})),
};
if write_response(&mut write_half_clone, &resp)
.await
.is_err()
{
break;
}
}
break;
}
Ok(MenuEvent::Cancelled) => {
if event_filter.contains(&"cancel".to_string()) {
let resp = IpcResponse::Event {
event: "cancel".to_string(),
data: None,
};
let _ =
write_response(&mut write_half_clone, &resp).await;
}
break;
}
Err(broadcast::error::RecvError::Lagged(_)) => continue,
Err(broadcast::error::RecvError::Closed) => break,
}
}
});
// Subscribe takes over the write half, so we
// can't continue reading. The connection is now
// subscription-only until it closes.
return Ok(());
}
IpcCommand::Unsubscribe => {
// Nothing to do if not subscribed
}
_ => {}
}
}
Ok(())
}
/// Write a JSON response as a single line.
async fn write_response(
writer: &mut tokio::net::unix::OwnedWriteHalf,
resp: &IpcResponse,
) -> Result<(), std::io::Error> {
let json = serde_json::to_string(resp).map_err(|e| std::io::Error::other(e.to_string()))?;
writer.write_all(json.as_bytes()).await?;
writer.write_all(b"\n").await?;
writer.flush().await
}

View File

@@ -1,5 +1,7 @@
mod handler; mod handler;
mod hook; mod hook;
mod ipc;
mod session;
use std::io::{BufReader, IsTerminal, Write}; use std::io::{BufReader, IsTerminal, Write};
use std::sync::Arc; use std::sync::Arc;
@@ -7,12 +9,14 @@ 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, ModifiedAction, Mode};
use pikl_core::format::FormatTemplate; use pikl_core::format::FormatTemplate;
use pikl_core::hook::{HookEventKind, HookHandler}; use pikl_core::hook::{HookEventKind, HookHandler};
use pikl_core::input::read_items_sync; use pikl_core::input::{parse_line_to_value, read_items_sync};
use pikl_core::item::Item; use pikl_core::item::Item;
use pikl_core::json_menu::JsonMenu; use pikl_core::json_menu::JsonMenu;
use pikl_core::menu::MenuRunner; use pikl_core::menu::MenuRunner;
@@ -23,6 +27,39 @@ use serde_json::Value;
use handler::ShellHandlerHook; use handler::ShellHandlerHook;
use hook::ShellExecHandler; use hook::ShellExecHandler;
/// Which frontend to launch.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum FrontendMode {
Tui,
Gui,
}
/// Resolve `--mode` flag into a concrete frontend. "auto"
/// picks GUI when a graphical environment is detected:
/// `$WAYLAND_DISPLAY` on Linux, always on macOS. Falls
/// back to TUI otherwise.
fn resolve_frontend_mode(mode_str: &str) -> Option<FrontendMode> {
match mode_str {
"tui" => Some(FrontendMode::Tui),
"gui" => Some(FrontendMode::Gui),
"auto" => {
#[cfg(target_os = "macos")]
{
Some(FrontendMode::Gui)
}
#[cfg(not(target_os = "macos"))]
{
if std::env::var_os("WAYLAND_DISPLAY").is_some() {
Some(FrontendMode::Gui)
} else {
Some(FrontendMode::Tui)
}
}
}
_ => None,
}
}
#[derive(Parser)] #[derive(Parser)]
#[command( #[command(
name = "pikl", name = "pikl",
@@ -119,6 +156,34 @@ 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>,
/// Enable IPC control via Unix socket
#[arg(long)]
ipc: bool,
/// Session name (enables filter history, names the IPC socket)
#[arg(long)]
session: Option<String>,
/// Frontend mode: tui, gui, or auto (default: auto)
#[arg(long, value_name = "MODE", default_value = "auto")]
mode: String,
} }
fn main() { fn main() {
@@ -173,25 +238,74 @@ fn main() {
std::process::exit(2); std::process::exit(2);
} }
let timeout = cli let input_format = match InputFormat::from_str_opt(&cli.input_format) {
.stdin_timeout Some(f) => f,
.unwrap_or(if script.is_some() { 30 } else { 0 }); None => {
let _ = writeln!(
let items = match read_stdin_with_timeout(timeout, &cli.label_key) { std::io::stderr().lock(),
Ok(items) => items, "pikl: unknown input format '{}', expected auto, csv, or tsv",
Err(e) => { cli.input_format
let _ = writeln!(std::io::stderr().lock(), "pikl: {e}"); );
std::process::exit(2); std::process::exit(2);
} }
}; };
if items.is_empty() { // Detect streaming mode: piped stdin, no action-fd, not CSV/TSV.
let _ = writeln!( let streaming = script.is_none()
std::io::stderr().lock(), && !std::io::stdin().is_terminal()
"pikl: empty input. nothing to pick from" && !matches!(input_format, InputFormat::Csv | InputFormat::Tsv);
);
std::process::exit(2); // Save the original stdin fd for the streaming reader before
} // we dup2 /dev/tty onto fd 0.
#[cfg(unix)]
let saved_stdin_fd: Option<i32> = if streaming {
let fd = unsafe { libc::dup(libc::STDIN_FILENO) };
if fd < 0 {
let _ = writeln!(
std::io::stderr().lock(),
"pikl: failed to dup stdin: {}",
std::io::Error::last_os_error()
);
std::process::exit(2);
}
Some(fd)
} else {
None
};
let (items, csv_headers) = if streaming {
// Streaming mode: start with empty items, read in background later.
(Vec::new(), None)
} else {
let timeout = cli
.stdin_timeout
.unwrap_or(if script.is_some() { 30 } else { 0 });
let (items, csv_headers) =
match read_stdin_with_timeout(timeout, &cli.label_key, input_format) {
Ok(r) => r,
Err(e) => {
let _ = writeln!(std::io::stderr().lock(), "pikl: {e}");
std::process::exit(2);
}
};
if items.is_empty() {
let _ = writeln!(
std::io::stderr().lock(),
"pikl: empty input. nothing to pick from"
);
std::process::exit(2);
}
(items, csv_headers)
};
// 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| {
@@ -202,8 +316,23 @@ fn main() {
std::process::exit(2); std::process::exit(2);
}); });
// Resolve frontend mode
let frontend_mode = match resolve_frontend_mode(&cli.mode) {
Some(m) => m,
None => {
let _ = writeln!(
std::io::stderr().lock(),
"pikl: unknown --mode '{}', expected auto, tui, or gui",
cli.mode
);
std::process::exit(2);
}
};
// Reopen stdin from /dev/tty before entering async context. // Reopen stdin from /dev/tty before entering async context.
// Only needed for TUI mode (GUI doesn't read from the terminal).
if script.is_none() if script.is_none()
&& frontend_mode == FrontendMode::Tui
&& let Err(e) = reopen_stdin_from_tty() && let Err(e) = reopen_stdin_from_tty()
{ {
let _ = writeln!(std::io::stderr().lock(), "pikl: {e}"); let _ = writeln!(std::io::stderr().lock(), "pikl: {e}");
@@ -214,28 +343,74 @@ 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);
} }
}; };
// Load session filter history if --session is set
let mut history = cli.session.as_ref().and_then(|name| {
match session::FilterHistory::load(name) {
Ok(h) => Some(h),
Err(e) => {
tracing::warn!(%e, "failed to load session history");
None
}
}
});
// STEP 4: Branch on headless vs interactive // STEP 4: Branch on headless vs interactive
#[cfg(unix)]
let saved_fd = saved_stdin_fd;
#[cfg(not(unix))]
let saved_fd: Option<i32> = None;
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 if cfg!(target_os = "macos") && frontend_mode == FrontendMode::Gui {
// macOS requires the GUI event loop on the main thread
// (AppKit/winit constraint). We can't run it inside
// block_on, so the GUI gets the main thread and tokio
// work is spawned onto the runtime's worker threads.
run_gui_main_thread(
&rt, items, &cli, start_mode, column_config, streaming, saved_fd,
)
} else { } else {
rt.block_on(run_interactive(items, &cli, start_mode)) let history_entries = history.as_ref().map(|h| h.entries().to_vec());
rt.block_on(run_interactive(
items,
&cli,
start_mode,
column_config,
history_entries,
streaming,
saved_fd,
frontend_mode,
))
}; };
// Save filter text to session history on confirm
if let Some(ref mut h) = history
&& let Ok(MenuResult::Selected { ref filter_text, .. })
| Ok(MenuResult::Quicklist { ref filter_text, .. }) = result
{
let _ = h.append(filter_text).inspect_err(|e| {
tracing::warn!(%e, "failed to save session history");
});
}
// 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,13 +418,16 @@ 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
} }
/// Build the composite hook handler from CLI flags, if any hooks are specified. /// Build the composite hook handler from CLI flags, if any hooks are specified.
fn build_hook_handler( fn build_hook_handler(
cli: &Cli, cli: &Cli,
action_tx: &tokio::sync::mpsc::Sender<Action>, action_tx: &tokio::sync::mpsc::Sender<ModifiedAction>,
) -> Option<(Arc<dyn HookHandler>, DebouncedDispatcher)> { ) -> Option<(Arc<dyn HookHandler>, DebouncedDispatcher)> {
let exec_handler = ShellExecHandler::from_cli(cli); let exec_handler = ShellExecHandler::from_cli(cli);
let handler_hook = ShellHandlerHook::from_cli(cli, action_tx.clone()); let handler_hook = ShellHandlerHook::from_cli(cli, action_tx.clone());
@@ -309,12 +487,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 +506,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);
@@ -341,7 +520,9 @@ async fn run_headless(
let event_rx = menu.subscribe(); let event_rx = menu.subscribe();
// Default headless viewport // Default headless viewport
let _ = action_tx.send(Action::Resize { height: 50 }).await; let _ = action_tx
.send(ModifiedAction::new(Action::Resize { height: 50 }))
.await;
let menu_handle = tokio::spawn(menu.run()); let menu_handle = tokio::spawn(menu.run());
@@ -354,7 +535,7 @@ async fn run_headless(
match show_action { match show_action {
ShowAction::Ui | ShowAction::Tui | ShowAction::Gui => { ShowAction::Ui | ShowAction::Tui | ShowAction::Gui => {
let tui_handle = tokio::spawn(pikl_tui::run(action_tx, event_rx)); let tui_handle = tokio::spawn(pikl_tui::run(action_tx, event_rx, None));
let result = menu_handle let result = menu_handle
.await .await
.map_err(|e| PiklError::Io(std::io::Error::other(e.to_string())))??; .map_err(|e| PiklError::Io(std::io::Error::other(e.to_string())))??;
@@ -377,9 +558,17 @@ async fn run_interactive(
items: Vec<Item>, items: Vec<Item>,
cli: &Cli, cli: &Cli,
start_mode: Mode, start_mode: Mode,
column_config: Option<ColumnConfig>,
filter_history: Option<Vec<String>>,
streaming: bool,
saved_stdin_fd: Option<i32>,
frontend_mode: FrontendMode,
) -> 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);
menu.set_streaming(streaming);
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);
@@ -387,21 +576,249 @@ async fn run_interactive(
let event_rx = menu.subscribe(); let event_rx = menu.subscribe();
// Start IPC server if requested
let _ipc_server = if cli.ipc {
let sock_path = ipc::socket_path(cli.session.as_deref())?;
let server = ipc::server::IpcServer::new(
sock_path,
action_tx.clone(),
menu.event_sender(),
);
let ipc_event_rx = menu.subscribe();
server
.start(ipc_event_rx)
.map_err(PiklError::Io)?;
Some(server)
} else {
None
};
// Handle SIGINT/SIGTERM: restore terminal and exit cleanly. // Handle SIGINT/SIGTERM: restore terminal and exit cleanly.
let signal_tx = action_tx.clone(); let signal_tx = action_tx.clone();
let signal_frontend = frontend_mode;
tokio::spawn(async move { tokio::spawn(async move {
if let Ok(()) = tokio::signal::ctrl_c().await { match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
pikl_tui::restore_terminal(); Ok(mut sigterm) => {
let _ = signal_tx.send(Action::Cancel).await; tokio::select! {
_ = tokio::signal::ctrl_c() => {}
_ = sigterm.recv() => {}
}
}
Err(e) => {
tracing::warn!(%e, "failed to register SIGTERM handler, falling back to SIGINT only");
let _ = tokio::signal::ctrl_c().await;
}
} }
if signal_frontend == FrontendMode::Tui {
pikl_tui::restore_terminal();
}
let _ = signal_tx.send(ModifiedAction::new(Action::Cancel)).await;
}); });
let tui_handle = tokio::spawn(async move { pikl_tui::run(action_tx, event_rx).await }); // Spawn background stdin reader for streaming mode.
if let Some(fd) = saved_stdin_fd {
let stream_tx = action_tx.clone();
tokio::task::spawn_blocking(move || {
#[cfg(unix)]
{
use std::io::BufRead;
use std::os::unix::io::FromRawFd;
let result = menu.run().await; let file = unsafe { std::fs::File::from_raw_fd(fd) };
let mut reader = std::io::BufReader::new(file);
let mut batch = Vec::with_capacity(100);
let mut line = String::new();
let _ = tui_handle.await; loop {
result line.clear();
match reader.read_line(&mut line) {
Ok(0) => break,
Ok(_) => {
let trimmed = line.trim_end();
if !trimmed.is_empty() {
batch.push(parse_line_to_value(trimmed));
}
if (batch.len() >= 100 || reader.buffer().is_empty())
&& !batch.is_empty()
{
let _ = stream_tx.blocking_send(ModifiedAction::new(Action::AddItems(
std::mem::take(&mut batch),
)));
}
}
Err(e) => {
tracing::warn!(%e, "stdin read error");
break;
}
}
}
if !batch.is_empty() {
let _ = stream_tx.blocking_send(ModifiedAction::new(Action::AddItems(batch)));
}
let _ = stream_tx.blocking_send(ModifiedAction::new(Action::StreamingDone));
}
});
}
match frontend_mode {
FrontendMode::Tui => {
let tui_handle = tokio::spawn(async move {
pikl_tui::run(action_tx, event_rx, filter_history).await
});
let result = menu.run().await;
let _ = tui_handle.await;
result
}
FrontendMode::Gui => {
// Spawn the core event loop on a background task.
let menu_handle = tokio::spawn(menu.run());
// On Linux, spawn_blocking is fine since Wayland doesn't
// have the main-thread restriction.
let gui_handle = tokio::task::spawn_blocking(move || {
pikl_gui::run(action_tx, event_rx)
.map_err(|e| PiklError::Io(std::io::Error::other(e.to_string())))
});
if let Err(e) = gui_handle.await {
return Err(PiklError::Io(std::io::Error::other(e.to_string())));
}
// Wait for core to finish.
match menu_handle.await {
Ok(result) => result,
Err(e) => Err(PiklError::Io(std::io::Error::other(e.to_string()))),
}
}
}
}
/// Run GUI on the main thread for macOS. AppKit requires
/// the event loop on the main thread, so we can't use
/// block_on (which would nest the iced event loop inside
/// tokio's reactor). Instead, spawn all background work
/// onto the runtime's worker threads and run the GUI
/// directly.
fn run_gui_main_thread(
rt: &tokio::runtime::Runtime,
items: Vec<Item>,
cli: &Cli,
start_mode: Mode,
column_config: Option<ColumnConfig>,
streaming: bool,
saved_stdin_fd: Option<i32>,
) -> Result<MenuResult, PiklError> {
let (mut menu, action_tx) = MenuRunner::new(build_menu(items, cli, column_config));
menu.set_initial_mode(start_mode);
menu.set_multi(cli.multi);
menu.set_selection_order(cli.selection_order);
menu.set_streaming(streaming);
if let Some((_handler, dispatcher)) = build_hook_handler(cli, &action_tx) {
menu.set_dispatcher(dispatcher);
}
let event_rx = menu.subscribe();
// Start IPC server if requested
let _ipc_server = if cli.ipc {
let sock_path = ipc::socket_path(cli.session.as_deref())?;
let server = ipc::server::IpcServer::new(
sock_path,
action_tx.clone(),
menu.event_sender(),
);
let ipc_event_rx = menu.subscribe();
server.start(ipc_event_rx).map_err(PiklError::Io)?;
Some(server)
} else {
None
};
// Enter the runtime context so tokio::spawn works
// without being inside block_on.
let _guard = rt.enter();
// Signal handler
let signal_tx = action_tx.clone();
tokio::spawn(async move {
match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
Ok(mut sigterm) => {
tokio::select! {
_ = tokio::signal::ctrl_c() => {}
_ = sigterm.recv() => {}
}
}
Err(e) => {
tracing::warn!(%e, "failed to register SIGTERM handler, falling back to SIGINT only");
let _ = tokio::signal::ctrl_c().await;
}
}
let _ = signal_tx.send(ModifiedAction::new(Action::Cancel)).await;
});
// Streaming stdin reader
if let Some(fd) = saved_stdin_fd {
let stream_tx = action_tx.clone();
tokio::task::spawn_blocking(move || {
#[cfg(unix)]
{
use std::io::BufRead;
use std::os::unix::io::FromRawFd;
let file = unsafe { std::fs::File::from_raw_fd(fd) };
let mut reader = std::io::BufReader::new(file);
let mut batch = Vec::with_capacity(100);
let mut line = String::new();
loop {
line.clear();
match reader.read_line(&mut line) {
Ok(0) => break,
Ok(_) => {
let trimmed = line.trim_end();
if !trimmed.is_empty() {
batch.push(parse_line_to_value(trimmed));
}
if (batch.len() >= 100 || reader.buffer().is_empty())
&& !batch.is_empty()
{
let _ = stream_tx.blocking_send(ModifiedAction::new(
Action::AddItems(std::mem::take(&mut batch)),
));
}
}
Err(e) => {
tracing::warn!(%e, "stdin read error");
break;
}
}
}
if !batch.is_empty() {
let _ =
stream_tx.blocking_send(ModifiedAction::new(Action::AddItems(batch)));
}
let _ =
stream_tx.blocking_send(ModifiedAction::new(Action::StreamingDone));
}
});
}
// Core menu on a background task
let menu_handle = tokio::spawn(menu.run());
// GUI on the main thread (the whole point of this function)
pikl_gui::run(action_tx, event_rx)
.map_err(|e| PiklError::Io(std::io::Error::other(e.to_string())))?;
// GUI exited. Collect the menu result.
rt.block_on(async {
match menu_handle.await {
Ok(result) => result,
Err(e) => Err(PiklError::Io(std::io::Error::other(e.to_string()))),
}
})
} }
/// Process the menu result: print output to stdout and /// Process the menu result: print output to stdout and
@@ -409,25 +826,16 @@ 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 {
if cli.structured { items, modifiers, ..
let output = OutputItem { }) => {
value,
action: OutputAction::Select,
index,
};
let _ = write_output_json(&mut out, &output);
} else {
let _ = write_plain_value(&mut out, &value);
}
}
Ok(MenuResult::Quicklist { items }) => {
if cli.structured { if cli.structured {
for (value, index) in items { for (value, index) in items {
let output = OutputItem { let output = OutputItem {
value, value,
action: OutputAction::Quicklist, action: OutputAction::Select,
index, index,
modifiers,
}; };
let _ = write_output_json(&mut out, &output); let _ = write_output_json(&mut out, &output);
} }
@@ -437,12 +845,32 @@ fn handle_result(result: Result<MenuResult, PiklError>, cli: &Cli) {
} }
} }
} }
Ok(MenuResult::Cancelled) => { Ok(MenuResult::Quicklist {
items, modifiers, ..
}) => {
if cli.structured {
for (value, index) in items {
let output = OutputItem {
value,
action: OutputAction::Quicklist,
index,
modifiers,
};
let _ = write_output_json(&mut out, &output);
}
} else {
for (value, _) in &items {
let _ = write_plain_value(&mut out, value);
}
}
}
Ok(MenuResult::Cancelled { modifiers }) => {
if cli.structured { if cli.structured {
let output = OutputItem { let output = OutputItem {
value: Value::Null, value: Value::Null,
action: OutputAction::Cancel, action: OutputAction::Cancel,
index: 0, index: 0,
modifiers,
}; };
let _ = write_output_json(&mut out, &output); let _ = write_output_json(&mut out, &output);
} }
@@ -457,7 +885,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 +895,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 +905,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 +943,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.

210
crates/pikl/src/session.rs Normal file
View File

@@ -0,0 +1,210 @@
//! Session filter history. Stores and recalls previous filter
//! strings per named session. History is append-only to a file
//! in `~/.local/state/pikl/sessions/`.
use std::fs;
use std::io::Write;
use std::path::PathBuf;
use pikl_core::error::PiklError;
/// Filter history for a named session. Each entry is a filter
/// string the user confirmed with. Ctrl+P/Ctrl+N cycle through
/// these in the TUI.
pub struct FilterHistory {
entries: Vec<String>,
file_path: PathBuf,
}
impl FilterHistory {
/// Load history for the given session name. Creates an empty
/// history if the file doesn't exist yet.
pub fn load(session_name: &str) -> Result<Self, PiklError> {
let file_path = history_path(session_name)?;
let entries = if file_path.exists() {
let content = fs::read_to_string(&file_path)?;
content
.lines()
.filter(|l| !l.is_empty())
.map(String::from)
.collect()
} else {
Vec::new()
};
Ok(Self { entries, file_path })
}
/// Append a filter string to the history. Skips empty strings
/// and duplicates of the last entry.
pub fn append(&mut self, filter: &str) -> Result<(), PiklError> {
if filter.is_empty() {
return Ok(());
}
if self.entries.last().is_some_and(|last| last == filter) {
return Ok(());
}
// Create parent dirs if needed
if let Some(parent) = self.file_path.parent() {
fs::create_dir_all(parent)?;
}
let mut file = fs::OpenOptions::new()
.create(true)
.append(true)
.open(&self.file_path)?;
writeln!(file, "{filter}")?;
self.entries.push(filter.to_string());
Ok(())
}
/// Read-only access to history entries.
pub fn entries(&self) -> &[String] {
&self.entries
}
}
#[cfg(test)]
impl FilterHistory {
/// Test-only constructor that uses an explicit file path
/// instead of deriving one from HOME.
fn with_path(file_path: PathBuf) -> Self {
Self {
entries: Vec::new(),
file_path,
}
}
}
/// Build the history file path for a session name.
/// Uses `$HOME/.local/state/pikl/sessions/{name}.history`.
fn history_path(session_name: &str) -> Result<PathBuf, PiklError> {
let home = std::env::var("HOME").map_err(|_| {
PiklError::Io(std::io::Error::new(
std::io::ErrorKind::NotFound,
"HOME environment variable not set",
))
})?;
Ok(PathBuf::from(home)
.join(".local/state/pikl/sessions")
.join(format!("{session_name}.history")))
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
/// Create a temp directory and return a history file path inside it.
fn temp_history_path(name: &str) -> PathBuf {
let dir = std::env::temp_dir().join(format!("pikl-test-session-{name}"));
let _ = fs::create_dir_all(&dir);
dir.join("test.history")
}
/// Clean up the temp directory after a test.
fn cleanup(path: &PathBuf) {
if let Some(parent) = path.parent() {
let _ = fs::remove_dir_all(parent);
}
}
#[test]
fn load_missing_file_returns_empty() {
let path = temp_history_path("missing");
// Make sure it doesn't exist
let _ = fs::remove_file(&path);
let hist = FilterHistory::with_path(path.clone());
assert!(hist.entries().is_empty());
cleanup(&path);
}
#[test]
fn append_and_load_roundtrip() -> Result<(), PiklError> {
let path = temp_history_path("roundtrip");
let _ = fs::remove_file(&path);
let mut hist = FilterHistory::with_path(path.clone());
hist.append("hello")?;
hist.append("world")?;
// Re-read from disk by constructing a new history that
// loads the file content.
let content = fs::read_to_string(&path)?;
let loaded: Vec<String> = content
.lines()
.filter(|l| !l.is_empty())
.map(String::from)
.collect();
assert_eq!(loaded, vec!["hello", "world"]);
cleanup(&path);
Ok(())
}
#[test]
fn append_skips_empty_string() -> Result<(), PiklError> {
let path = temp_history_path("skip-empty");
let _ = fs::remove_file(&path);
let mut hist = FilterHistory::with_path(path.clone());
hist.append("")?;
assert!(hist.entries().is_empty());
assert!(!path.exists());
cleanup(&path);
Ok(())
}
#[test]
fn append_deduplicates_consecutive() -> Result<(), PiklError> {
let path = temp_history_path("dedup-consecutive");
let _ = fs::remove_file(&path);
let mut hist = FilterHistory::with_path(path.clone());
hist.append("foo")?;
hist.append("foo")?;
assert_eq!(hist.entries(), &["foo"]);
cleanup(&path);
Ok(())
}
#[test]
fn append_allows_nonconsecutive_duplicates() -> Result<(), PiklError> {
let path = temp_history_path("nonconsecutive");
let _ = fs::remove_file(&path);
let mut hist = FilterHistory::with_path(path.clone());
hist.append("foo")?;
hist.append("bar")?;
hist.append("foo")?;
assert_eq!(hist.entries(), &["foo", "bar", "foo"]);
cleanup(&path);
Ok(())
}
#[test]
fn entries_returns_all() -> Result<(), PiklError> {
let path = temp_history_path("entries-all");
let _ = fs::remove_file(&path);
let mut hist = FilterHistory::with_path(path.clone());
hist.append("alpha")?;
hist.append("beta")?;
hist.append("gamma")?;
let entries = hist.entries();
assert_eq!(entries.len(), 3);
assert_eq!(entries[0], "alpha");
assert_eq!(entries[1], "beta");
assert_eq!(entries[2], "gamma");
cleanup(&path);
Ok(())
}
}

View File

@@ -67,3 +67,166 @@ fn headless_actions_after_show_ui_exits_2() {
"expected show-ui error, got: {stderr}" "expected show-ui error, got: {stderr}"
); );
} }
// -- Modifier prefix integration tests --
#[test]
fn modifier_prefix_in_structured_output() {
let (stdout, _stderr, code) = common::run_pikl(
"alpha\nbeta\n",
"+shift confirm\n",
&["--structured"],
);
assert_eq!(code, 0, "expected exit 0, stderr: {_stderr}");
assert!(
stdout.contains(r#""modifiers":["shift"]"#),
"expected modifiers array in structured output, got: {stdout}"
);
}
#[test]
fn no_modifier_omits_field_in_structured_output() {
let (stdout, _stderr, code) = common::run_pikl(
"alpha\nbeta\n",
"confirm\n",
&["--structured"],
);
assert_eq!(code, 0, "expected exit 0, stderr: {_stderr}");
assert!(
!stdout.contains("modifiers"),
"expected no modifiers field when none held, got: {stdout}"
);
}
#[test]
fn multiple_modifier_prefixes_in_structured() {
let (stdout, _stderr, code) = common::run_pikl(
"alpha\nbeta\n",
"+ctrl+alt confirm\n",
&["--structured"],
);
assert_eq!(code, 0, "expected exit 0, stderr: {_stderr}");
assert!(
stdout.contains(r#""modifiers":["ctrl","alt"]"#),
"expected ctrl+alt modifiers, got: {stdout}"
);
}
#[test]
fn modifier_prefix_cancel_structured() {
let (stdout, _stderr, code) = common::run_pikl(
"alpha\n",
"+shift cancel\n",
&["--structured"],
);
assert_eq!(code, 1, "expected exit 1 on cancel");
assert!(
stdout.contains(r#""modifiers":["shift"]"#),
"expected modifiers on cancel output, got: {stdout}"
);
}
#[test]
fn modifier_prefix_does_not_affect_plain_output() {
let (stdout, _stderr, code) = common::run_pikl(
"alpha\nbeta\n",
"+shift confirm\n",
&[],
);
assert_eq!(code, 0, "expected exit 0, stderr: {_stderr}");
assert!(
!stdout.contains("modifiers"),
"plain output should not contain modifiers, got: {stdout}"
);
assert!(
stdout.contains("alpha"),
"expected alpha in plain output, got: {stdout}"
);
}
#[test]
fn unknown_modifier_prefix_exits_2() {
let (_stdout, stderr, code) = common::run_pikl(
"alpha\n",
"+super confirm\n",
&[],
);
assert_eq!(code, 2, "expected exit 2 on unknown modifier");
assert!(
stderr.contains("unknown modifier"),
"expected unknown modifier error, got: {stderr}"
);
}
// -- CSV/TSV integration tests --
#[test]
fn csv_confirm_first() {
let (stdout, _stderr, code) = common::run_pikl(
"name,age\nalice,30\nbob,25\n",
"confirm\n",
&["--input-format", "csv"],
);
assert_eq!(code, 0, "expected exit 0, stderr: {_stderr}");
// Plain output should show the JSON object
assert!(
stdout.contains("alice"),
"expected alice in stdout, got: {stdout}"
);
}
#[test]
fn csv_with_columns_subset() {
let (stdout, _stderr, code) = common::run_pikl(
"name,age,city\nalice,30,toronto\nbob,25,vancouver\n",
"confirm\n",
&["--input-format", "csv", "--columns", "name,city"],
);
assert_eq!(code, 0, "expected exit 0, stderr: {_stderr}");
assert!(
stdout.contains("alice"),
"expected alice in stdout, got: {stdout}"
);
}
#[test]
fn csv_filter_then_confirm() {
let (stdout, _stderr, code) = common::run_pikl(
"name,age\nalice,30\nbob,25\ncharlie,35\n",
"filter bob\nconfirm\n",
&["--input-format", "csv"],
);
assert_eq!(code, 0, "expected exit 0, stderr: {_stderr}");
assert!(
stdout.contains("bob"),
"expected bob in stdout, got: {stdout}"
);
}
#[test]
fn csv_multi_select() {
let (stdout, _stderr, code) = common::run_pikl(
"name,age\nalice,30\nbob,25\n",
"toggle-select\nmove-down\ntoggle-select\nconfirm\n",
&["--input-format", "csv", "--multi"],
);
assert_eq!(code, 0, "expected exit 0, stderr: {_stderr}");
assert!(
stdout.contains("alice") && stdout.contains("bob"),
"expected both alice and bob in stdout, got: {stdout}"
);
}
#[test]
fn tsv_confirm_first() {
let (stdout, _stderr, code) = common::run_pikl(
"name\tage\nalice\t30\nbob\t25\n",
"confirm\n",
&["--input-format", "tsv"],
);
assert_eq!(code, 0, "expected exit 0, stderr: {_stderr}");
assert!(
stdout.contains("alice"),
"expected alice in stdout, got: {stdout}"
);
}

View File

@@ -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"];
@@ -293,4 +331,29 @@ pikl_tests! {
exit: 0 exit: 0
} }
} }
headless mod modifier_prefix {
items: ["alpha", "beta", "gamma"];
test shift_confirm_plain_output_unchanged {
// Modifier prefixes don't affect plain text output.
actions: [raw "+shift confirm"]
stdout: "alpha"
exit: 0
}
test unknown_modifier_errors {
actions: [raw "+meta confirm"]
stderr contains: "unknown modifier"
exit: 2
}
test modifier_with_movement {
// Modifiers on navigation don't change behavior,
// but they should parse and not break anything.
actions: [raw "+shift move-down", confirm]
stdout: "beta"
exit: 0
}
}
} }

View File

@@ -48,6 +48,43 @@ metadata that came in, plus selection context:
{"label": "beach_sunset.jpg", "sublabel": "/home/maple/walls/nature/", "meta": {"size": "2.4MB"}, "action": "select", "index": 3} {"label": "beach_sunset.jpg", "sublabel": "/home/maple/walls/nature/", "meta": {"size": "2.4MB"}, "action": "select", "index": 3}
``` ```
When `--structured` is used, the output JSON includes a
`modifiers` field if any modifier keys (shift, ctrl, alt)
were held during confirmation. The value is an array of
strings:
```jsonl
{"label": "firefox", "action": "select", "index": 0, "modifiers": ["shift"]}
{"label": "alacritty", "action": "select", "index": 2, "modifiers": ["ctrl", "alt"]}
```
When no modifiers are held, the field is omitted entirely.
Plain text output is unchanged: modifiers only appear in
structured JSON.
### Working with Structured Output
Check for shift modifier with jq:
```sh
pikl --structured | jq 'if (.modifiers // [] | contains(["shift"])) then "alternate" else "default" end'
```
Extract a field:
```sh
pikl --structured | jq -r '.label'
```
Branch on modifiers in a script:
```sh
result=$(echo -e "a\nb\nc" | pikl --structured)
if echo "$result" | jq -e '.modifiers // [] | contains(["shift"])' > /dev/null 2>&1; then
echo "shift was held"
fi
```
### Streaming ### Streaming
Input can arrive over time. The list populates progressively Input can arrive over time. The list populates progressively
@@ -60,9 +97,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 +131,13 @@ 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) |
Hook event JSON includes a `modifiers` field on `select`,
`cancel`, `filter`, `hover`, and `quicklist` events when
modifier keys were held. Same format as structured output:
an array of strings like `["shift"]` or `["ctrl", "alt"]`.
Omitted when no modifiers are active.
### Exec Hooks (fire-and-forget) ### Exec Hooks (fire-and-forget)
@@ -383,16 +440,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 +455,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 +498,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
@@ -478,13 +562,19 @@ A few built-in themes ship with pikl. `--theme monokai` or
## Sessions ## Sessions
`--session name` persists the menu's state (filter text, `--session name` enables filter history across invocations.
scroll position, selected items, marks, registers) across
invocations. State lives in
`~/.local/state/pikl/sessions/`.
No `--session` = ephemeral, nothing saved. No `--session` = ephemeral, nothing saved.
When a session is active, every filter that leads to a
selection (single, multi, or quicklist) is appended to
`~/.local/state/pikl/sessions/{name}.history`. Empty
filters and consecutive duplicates are skipped.
On startup with a session name, the history file is loaded.
Ctrl+P / Ctrl+N in insert mode cycles through previous
filters. Selecting a history entry replaces the current
filter text.
Session names are just strings, use shell expansion for Session names are just strings, use shell expansion for
dynamic names: dynamic names:
@@ -493,8 +583,8 @@ pikl --session "walls-$(hostname)"
pikl --session "logs-$USER" pikl --session "logs-$USER"
``` ```
Session history is a log file alongside the state. Other Sessions are lightweight by design. No item persistence,
tools can tail it for observability. no scroll position, no selections. Just filter recall.
## Watched Sources ## Watched Sources
@@ -511,18 +601,96 @@ named pipe.
## IPC ## IPC
While running, pikl-menu listens on a Unix socket Opt-in external control via Unix socket. Enabled with
(`/run/user/$UID/pikl-$PID.sock`). External tools can: `--ipc`. Off by default: pikl is ephemeral and shouldn't
have side effects unless asked.
- Push new items Socket path:
- Remove items - Named session: `/run/user/$UID/pikl-{session}.sock`
- Update item fields - No session: `/run/user/$UID/pikl-{pid}.sock`
- Set the filter text
- Read current selection
- Close the menu
Protocol is newline-delimited JSON. Simple enough to use The socket path is logged via tracing on startup and
with `socat` or any language's Unix socket support. cleaned up on exit.
### Protocol
Newline-delimited JSON, one message per line. Simple
enough for `socat` or any language with Unix sockets.
### Client-to-pikl Commands
**Write commands** (fire-and-forget, no response):
| Action | Payload | Effect |
|---|---|---|
| `add_items` | `{"items": [...]}` | Append items |
| `replace_items` | `{"items": [...]}` | Replace all items |
| `remove_items` | `{"indices": [...]}` | Remove by index |
| `set_filter` | `{"text": "..."}` | Set filter text |
| `move_up` | (none) | Navigate up |
| `move_down` | (none) | Navigate down |
| `move_to_top` | (none) | Jump to top |
| `move_to_bottom` | (none) | Jump to bottom |
| `page_up` | (none) | Page up |
| `page_down` | (none) | Page down |
| `toggle_select` | (none) | Toggle current item |
| `select_all` | (none) | Select all items |
| `clear_selections` | (none) | Clear selections |
| `confirm` | (none) | Confirm selection |
| `cancel` | (none) | Cancel menu |
| `close` | (none) | Close menu |
**Read commands** (require `id` field, response echoes it):
| Action | Response |
|---|---|
| `get_state` | `{"id": "...", "state": {"filter": "...", "cursor": 3, "total_items": 150, "total_filtered": 12, "selection_count": 0, "mode": "insert"}}` |
| `get_selection` | `{"id": "...", "selection": [{"label": "...", "index": 3}, ...]}` |
**Event subscription:**
| Action | Payload | Effect |
|---|---|---|
| `subscribe` | `{"events": ["hover", "filter", ...]}` | Start receiving events |
| `unsubscribe` | (none) | Stop receiving events |
Subscribed events are pushed as
`{"event": "hover", "item": {...}, "index": 3}` lines.
### Multiple Connections
Multiple clients can connect simultaneously. Each gets
independent subscription state. All writes are serialized
through the same action channel.
### Auth
Unix socket permissions (user-only access). No additional
authentication.
### Architecture
IPC is just another frontend. It deserializes JSON
messages into `Action` variants and sends them through the
same `mpsc` channel as the TUI or action-fd. Event
subscriptions tap into the `broadcast` channel. No special
core changes needed.
### Examples
```sh
# Push items into a running pikl
echo '{"action": "add_items", "items": [{"label": "new"}]}' \
| socat - UNIX-CONNECT:/run/user/1000/pikl-mypicker.sock
# Read current state
echo '{"action": "get_state", "id": "1"}' \
| socat - UNIX-CONNECT:/run/user/1000/pikl-mypicker.sock
# Subscribe to hover events
echo '{"action": "subscribe", "events": ["hover"]}' \
| socat - UNIX-CONNECT:/run/user/1000/pikl-mypicker.sock
```
## Exit Codes ## Exit Codes
@@ -603,8 +771,8 @@ sequentially regardless of where they came from.
| TUI | Terminal keypresses (crossterm) | Yes | 1 | | TUI | Terminal keypresses (crossterm) | Yes | 1 |
| GUI | GUI events (iced) | Yes | 8 | | GUI | GUI events (iced) | Yes | 8 |
| Action-fd | Pre-validated script from a file descriptor | No (unless `show-ui`) | 1.5 | | Action-fd | Pre-validated script from a file descriptor | No (unless `show-ui`) | 1.5 |
| IPC | Unix socket, JSON protocol | Yes (bidirectional) | 6 | | IPC | Unix socket, newline-delimited JSON | Yes (bidirectional) | 6 |
| Lua | LuaJIT script via mlua | Yes (stateful, conditional) | Post-6 | | Lua | Embedded LuaJIT via mlua (in-process) | Yes (stateful, conditional) | Post-6 |
This framing means new interaction modes don't require core This framing means new interaction modes don't require core
changes: they're just new frontends that push actions. changes: they're just new frontends that push actions.
@@ -645,6 +813,18 @@ Available actions:
| `show-tui` | (none) | Hand off to TUI specifically | | `show-tui` | (none) | Hand off to TUI specifically |
| `show-gui` | (none) | Hand off to GUI specifically | | `show-gui` | (none) | Hand off to GUI specifically |
Actions can include modifier key prefixes with `+`:
```
+shift confirm
+ctrl+alt move-down 3
```
Valid modifiers: `shift`, `ctrl`, `alt`. Multiple modifiers
chain with `+`. Lines without a `+` prefix work exactly as
before. This lets scripts signal intent to hooks and
structured output the same way a user holding Shift would.
`show-ui` auto-detects the appropriate interactive frontend `show-ui` auto-detects the appropriate interactive frontend
(Wayland: GUI, X11: GUI, otherwise: TUI). `show-tui` and (Wayland: GUI, X11: GUI, otherwise: TUI). `show-tui` and
`show-gui` are explicit overrides. All three must be the `show-gui` are explicit overrides. All three must be the
@@ -745,8 +925,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
@@ -774,12 +960,15 @@ complexity:
extend pikl without touching Rust. extend pikl without touching Rust.
4. **IPC** (phase 6): bidirectional JSON over Unix socket. 4. **IPC** (phase 6): bidirectional JSON over Unix socket.
External tools can read state and send actions while pikl External tools can read state and send actions while pikl
runs interactively. Good for tool integration. runs interactively. Language-agnostic: anything that can
5. **Lua** (post phase 6): embedded LuaJIT via mlua. Full write JSON to a socket works.
stateful scripting: subscribe to events, branch on state, 5. **Lua** (post phase 6): embedded LuaJIT via mlua.
loops, the works. The Lua runtime is just another In-process scripting for complex hooks and behaviour.
frontend pushing Actions and subscribing to MenuEvents. Direct access to the action/event system, no socket
For anything complex enough to need a real language. overhead. Think Neovim's Lua layer: you're extending the
tool, not talking to it from outside. IPC and Lua are
separate integration points. IPC is for external
processes. Lua is for embedded logic.
No custom DSL. Action-fd stays simple forever. The jump No custom DSL. Action-fd stays simple forever. The jump
from "I need conditionals" to "use Lua" is intentional: from "I need conditionals" to "use Lua" is intentional:
@@ -801,16 +990,10 @@ with the full writeup.
## Open Questions ## Open Questions
- Should marks/registers persist across sessions or only
within a session?
- Accessibility: screen reader support for TUI mode? - Accessibility: screen reader support for TUI mode?
- Should `--watch` support inotify on Linux and FSEvents on - Should `--watch` support inotify on Linux and FSEvents on
macOS, or use `notify` crate to abstract? macOS, or use `notify` crate to abstract?
- Maximum practical item count before we need virtual - Maximum practical item count before we need virtual
scrolling? (Probably around 100k) scrolling? (Probably around 100k)
- Should hooks run in a pool or strictly sequential?
Resolved: exec hooks are one subprocess per event.
Handler hooks are persistent processes. Debounce and
cancel-stale manage concurrency.
- Plugin system via WASM for custom filter strategies? - Plugin system via WASM for custom filter strategies?
(Probably way later if ever) (Probably way later if ever)

View File

@@ -170,67 +170,135 @@ 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: IPC & Session History
Persistence and external control. Two independent features bundled together. IPC adds live
external control over a Unix socket. Session history gives
lightweight filter recall for repeated workflows.
### IPC (External Control)
Opt-in Unix socket server for live control of a running
pikl instance by external processes.
**Deliverables:** **Deliverables:**
- `--session name` for state persistence - `--ipc` flag to enable the socket listener (off by
- Session state: filter, scroll position, selections, default, pikl is ephemeral and shouldn't have side
marks, registers effects unless asked)
- Session history log file - Socket path: `/run/user/$UID/pikl-{session}.sock`
- Unix socket IPC while running (named session) or `/run/user/$UID/pikl-{pid}.sock`
- IPC commands: push/remove/update items, set filter, (no session name)
read selection, close - Socket path logged via tracing on startup
- Protocol: newline-delimited JSON - Cleanup on exit (normal, cancel, SIGTERM)
- Protocol: newline-delimited JSON, one message per line
- Write commands: `add_items`, `replace_items`,
`remove_items`, `set_filter`, `confirm`, `cancel`,
`close`, plus navigation actions (`move_up`,
`move_down`, `move_to_top`, `move_to_bottom`,
`page_up`, `page_down`, `toggle_select`, `select_all`,
`clear_selections`)
- Read commands: `get_state` (filter, cursor, counts,
mode), `get_selection` (selected items). Both require
an `id` field, response echoes it back.
- Event subscription: `subscribe` with event type list,
`unsubscribe` to stop. Subscribed events pushed as
`{"event": "...", ...}` lines.
- Multiple simultaneous client connections
- Auth: Unix socket permissions (user-only)
- IPC is just another frontend: deserializes JSON into
Actions, optionally subscribes to MenuEvent broadcast
**Done when:** You can close and reopen a session and find **Not in scope:**
your state intact. External scripts can push items into a - `get_items` (full item list read, potentially large)
running pikl instance. - Auto-enable with `--session`
- Auth beyond Unix socket permissions
## Phase 7: Streaming & Watched Sources ### Session Filter History
Live, dynamic menus.
**Deliverables:** **Deliverables:**
- Async/streaming stdin (items arrive over time, list - `--session name` flag
updates progressively) - On any confirm (single, multi, quicklist), append
- Streaming output (on-hover events emitted to stdout) current filter text to
- `--watch path` for file/directory watching `~/.local/state/pikl/sessions/{name}.history`
- `--watch-extensions` filter - Skip empty filters and consecutive duplicates
- notify crate integration (inotify on Linux, FSEvents - Load history on startup if session file exists
on macOS) - Ctrl+P / Ctrl+N in insert mode to cycle through
filter history
- Selecting a history entry replaces the current filter
text
- Per-session only, no global history
**Done when:** `find / -name '*.log' | pikl` populates **Done when:** An external script can connect to a running
progressively. A watched directory updates the list live. pikl instance over the socket, push items, read state, and
subscribe to events. A named session remembers filter
history across invocations.
## Phase 7: Streaming Stdin ✓
Auto-detected streaming when stdin is a pipe (not action-fd,
not CSV/TSV). The menu opens immediately with zero items and
streams them in as they arrive.
`--watch` was dropped from this phase. External tools like
`inotifywait -m ~/walls | pikl` compose naturally with
streaming stdin. No built-in file watcher needed.
**Deliverables:**
- Auto-detected streaming stdin (items arrive over time,
list updates progressively)
- `StreamingDone` action signals end of input
- `streaming` flag on ViewState for frontend indicators
- TUI shows `...` suffix on the count while streaming
- Background reader with batched `AddItems` (up to 100
items per batch, flushes immediately for slow sources)
- `streaming-done` action-fd command for test scripts
- No new CLI flags: streaming is auto-detected
**Done when:** `seq 1 10000 | pikl` opens immediately and
items stream in. Slow sources show items appearing one at
a time with a `...` indicator.
## Phase 8: GUI Frontend (Wayland + X11) ## Phase 8: GUI Frontend (Wayland + X11)
@@ -281,7 +349,14 @@ navigate directories without spawning new processes.
pass `-la` to it. The output would include both the pass `-la` to it. The output would include both the
selected item and the user-supplied arguments. selected item and the user-supplied arguments.
Open questions: **Partially resolved:** modifier key support has landed.
Structured output and hook events now carry a `modifiers`
field when shift/ctrl/alt are held during confirmation.
This covers the signaling side: scripts can detect
Shift+Enter and branch on it. The free-text argument
input (where does the user type `-la`?) is still open.
Remaining open questions:
- UX flow: does the filter text become the args on - UX flow: does the filter text become the args on
Shift+Enter? Or does Shift+Enter open a second input Shift+Enter? Or does Shift+Enter open a second input
field for args after selection? The filter-as-args field for args after selection? The filter-as-args
@@ -295,9 +370,6 @@ navigate directories without spawning new processes.
matches exactly one item just confirm that item (current matches exactly one item just confirm that item (current
behaviour), or should it also treat any "extra" text behaviour), or should it also treat any "extra" text
as args? Probably not, too implicit. as args? Probably not, too implicit.
- Keybind: Shift+Enter is natural, but some terminals
don't distinguish it from Enter. May need a fallback
like Ctrl+Enter or a normal-mode keybind.
This is a core feature (new keybind, new output field), This is a core feature (new keybind, new output field),
not just a launcher script concern. Fits naturally after not just a launcher script concern. Fits naturally after
@@ -344,3 +416,4 @@ commitment, no order.
to run per-keystroke" problem for the app launcher. to run per-keystroke" problem for the app launcher.
Could be a `pikl index` subcommand or a standalone Could be a `pikl index` subcommand or a standalone
helper script. helper script.
- Need vars in hooks like `--on-hover 'echo $out'`.

View File

@@ -126,7 +126,7 @@ bind = $mod, B, exec, wallpicker copy
| Hook debouncing | 3 | Don't flood hyprctl | | Hook debouncing | 3 | Don't flood hyprctl |
| Fuzzy filtering | 2 | Filter by filename | | Fuzzy filtering | 2 | Filter by filename |
| Manifest files | 3 | Reusable config | | Manifest files | 3 | Reusable config |
| Watched sources | 7 | Live update on new files | | Streaming stdin | 7 | Progressive load from find |
| Sessions | 6 | Remember last position | | Sessions | 6 | Remember last position |
## Open Questions ## Open Questions

View File

@@ -275,6 +275,152 @@ 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
}
# ── Phase 6 demos ────────────────────────────────────────
ipc_demo() {
echo "IPC demo: pikl runs here with --ipc enabled." >&2
echo "Open a second terminal and run:" >&2
echo " ./examples/ipc-remote.sh demo" >&2
echo "" >&2
echo "Use the remote to navigate, filter, add items, and query state." >&2
echo "Press Enter or q in the remote to exit." >&2
echo "" >&2
cat <<'ITEMS' | pikl --ipc --session demo --multi
{"label": "Arch Linux", "category": "rolling", "init": "systemd"}
{"label": "NixOS", "category": "rolling", "init": "systemd"}
{"label": "Void Linux", "category": "rolling", "init": "runit"}
{"label": "Debian", "category": "stable", "init": "systemd"}
{"label": "Alpine", "category": "stable", "init": "openrc"}
{"label": "Fedora", "category": "semi-rolling", "init": "systemd"}
{"label": "Gentoo", "category": "rolling", "init": "openrc"}
ITEMS
}
modifier_keys_demo() {
echo "Modifier key demo. Use --structured to see modifiers in output." >&2
echo "Press Enter normally, then try Shift+Enter or Ctrl+Enter." >&2
echo "Compare the JSON output: modifiers only appear when held." >&2
echo "" >&2
cat <<'ITEMS' | pikl --structured
{"label": "Firefox", "url": "https://firefox.com"}
{"label": "Neovim", "url": "https://neovim.io"}
{"label": "Alacritty", "url": "https://alacritty.org"}
{"label": "mpv", "url": "https://mpv.io"}
ITEMS
}
session_demo() {
echo "Session filter history: select with a filter, exit, relaunch." >&2
echo "Press Ctrl+P to recall your previous filter." >&2
echo "" >&2
printf "apple\nbanana\ncherry\ndate\nelderberry\nfig\ngrape\nhoneydew\n" \
| pikl --session fruit
}
# ── Scenario menu ───────────────────────────────────────── # ── Scenario menu ─────────────────────────────────────────
scenarios=( scenarios=(
@@ -297,6 +443,20 @@ 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"
"---"
"IPC remote control"
"Session filter history"
"Modifier keys (structured output)"
"---"
"on-select-exec hook (legacy)" "on-select-exec hook (legacy)"
) )
@@ -319,6 +479,17 @@ 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 ;;
*"IPC remote"*) ipc_demo ;;
*"Session filter"*) session_demo ;;
*"Modifier keys"*) modifier_keys_demo ;;
*"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

78
examples/ipc-remote.md Normal file
View File

@@ -0,0 +1,78 @@
# IPC Remote Control
Interactive demo of pikl's IPC socket. Run pikl in one terminal, control it
from another. Watch the menu respond in real time.
## Prerequisites
- `socat` for socket I/O (`pacman -S socat` / `brew install socat`)
- `python3` for pretty-printing JSON responses (optional, falls back to raw output)
## Quick start
**Terminal 1:** start pikl with IPC enabled.
```sh
echo -e "alpha\nbeta\ngamma\ndelta\nepsilon" | pikl --ipc --session demo
```
**Terminal 2:** connect the remote control.
```sh
./examples/ipc-remote.sh demo
```
## What you can do
The remote gives you single-key commands:
| Key | Action |
|-----|--------|
| `j` / `k` | Move cursor down / up |
| `g` / `G` | Jump to top / bottom |
| `f` | Set filter text (then type the query) |
| `F` | Clear filter |
| `Space` | Toggle select on current item |
| `a` | Select all |
| `c` | Clear selections |
| `s` | Print current menu state (JSON) |
| `S` | Print current selection (JSON) |
| `+` | Add new items interactively |
| `r` | Replace all items |
| `Enter` | Confirm selection (exits pikl) |
| `q` | Cancel (exits pikl) |
| `x` | Exit remote without affecting pikl |
## Things to try
1. Press `j` a few times and watch the cursor move in the pikl terminal.
2. Press `s` to see the state, including cursor position and item count.
3. Press `f`, type `al`, and watch the filter narrow the list.
4. Press `F` to clear the filter.
5. Press `+` and add a few items. They appear in the pikl terminal immediately.
6. Press `Space` a couple times to toggle selections, then `S` to see what's selected.
7. Press `Enter` to confirm. pikl exits and prints the selection.
## How it works
The remote sends newline-delimited JSON commands to pikl's Unix socket using
`socat`. Write commands (move, filter, add items) are fire-and-forget. Read
commands (get_state, get_selection) wait for a JSON response.
The socket path for a named session is `/run/user/$UID/pikl-{session}.sock`.
Without `--session`, pikl uses the PID: `/run/user/$UID/pikl-{pid}.sock`.
## Using socat directly
You don't need the remote script. Any tool that speaks Unix sockets works:
```sh
# Query state
echo '{"action":"get_state","id":"1"}' | socat - UNIX-CONNECT:/run/user/$(id -u)/pikl-demo.sock
# Move cursor down
echo '{"action":"move_down"}' | socat -t 0.1 - UNIX-CONNECT:/run/user/$(id -u)/pikl-demo.sock
# Add items
echo '{"action":"add_items","items":["foo","bar"]}' | socat -t 0.1 - UNIX-CONNECT:/run/user/$(id -u)/pikl-demo.sock
```

178
examples/ipc-remote.sh Executable file
View File

@@ -0,0 +1,178 @@
#!/usr/bin/env bash
# IPC remote control for pikl-menu.
#
# Connects to a running pikl instance over its Unix socket and
# lets you send commands interactively. You see the effects in
# the pikl terminal in real time.
#
# Usage:
# Terminal 1: echo -e "alpha\nbeta\ngamma\ndelta" | pikl --ipc --session demo
# Terminal 2: ./examples/ipc-remote.sh demo
#
# Requires: socat (for socket I/O)
#
# The session name is optional. Without it, you'll need to pass
# the full socket path as the first argument.
set -euo pipefail
# ── Socket path ──────────────────────────────────────────
resolve_socket() {
local arg="${1:-}"
if [[ -z "$arg" ]]; then
echo "usage: ipc-remote.sh <session-name | socket-path>" >&2
exit 2
fi
# If it looks like a path, use it directly
if [[ "$arg" == */* || "$arg" == *.sock ]]; then
echo "$arg"
return
fi
# Otherwise treat it as a session name
local uid
uid=$(id -u)
echo "/run/user/${uid}/pikl-${arg}.sock"
}
SOCK=$(resolve_socket "${1:-}")
if [[ ! -S "$SOCK" ]]; then
echo "socket not found: $SOCK" >&2
echo "" >&2
echo "make sure pikl is running with --ipc:" >&2
echo " echo -e 'alpha\nbeta\ngamma' | pikl --ipc --session demo" >&2
exit 1
fi
if ! command -v socat >/dev/null 2>&1; then
echo "socat is required but not installed." >&2
echo "install it with your package manager (pacman -S socat, brew install socat, etc.)" >&2
exit 1
fi
# ── IPC helpers ──────────────────────────────────────────
ipc_send() {
echo "$1" | socat - "UNIX-CONNECT:${SOCK}" 2>/dev/null
}
ipc_fire() {
# Fire-and-forget: send command, don't wait for response
echo "$1" | socat -t 0.1 - "UNIX-CONNECT:${SOCK}" 2>/dev/null || true
}
# ── Commands ─────────────────────────────────────────────
cmd_state() {
local resp
resp=$(ipc_send '{"action":"get_state","id":"q"}')
echo "$resp" | python3 -m json.tool 2>/dev/null || echo "$resp"
}
cmd_selection() {
local resp
resp=$(ipc_send '{"action":"get_selection","id":"q"}')
echo "$resp" | python3 -m json.tool 2>/dev/null || echo "$resp"
}
cmd_move_down() { ipc_fire '{"action":"move_down"}'; }
cmd_move_up() { ipc_fire '{"action":"move_up"}'; }
cmd_top() { ipc_fire '{"action":"move_to_top"}'; }
cmd_bottom() { ipc_fire '{"action":"move_to_bottom"}'; }
cmd_page_down() { ipc_fire '{"action":"page_down"}'; }
cmd_page_up() { ipc_fire '{"action":"page_up"}'; }
cmd_toggle() { ipc_fire '{"action":"toggle_select"}'; }
cmd_select_all() { ipc_fire '{"action":"select_all"}'; }
cmd_clear() { ipc_fire '{"action":"clear_selections"}'; }
cmd_confirm() { ipc_fire '{"action":"confirm"}'; }
cmd_cancel() { ipc_fire '{"action":"cancel"}'; }
cmd_filter() {
local text="${1:-}"
if [[ -z "$text" ]]; then
read -rp "filter text: " text
fi
ipc_fire "{\"action\":\"set_filter\",\"text\":\"${text}\"}"
}
cmd_add() {
local items="${1:-}"
if [[ -z "$items" ]]; then
echo "enter items to add (one per line, empty line to finish):"
local lines=()
while IFS= read -rp "> " line; do
[[ -z "$line" ]] && break
lines+=("\"${line}\"")
done
items=$(IFS=,; echo "${lines[*]}")
fi
ipc_fire "{\"action\":\"add_items\",\"items\":[${items}]}"
}
cmd_replace() {
echo "enter new items (one per line, empty line to finish):"
local lines=()
while IFS= read -rp "> " line; do
[[ -z "$line" ]] && break
lines+=("\"${line}\"")
done
local items
items=$(IFS=,; echo "${lines[*]}")
ipc_fire "{\"action\":\"replace_items\",\"items\":[${items}]}"
}
# ── Interactive loop ─────────────────────────────────────
show_help() {
cat <<'HELP'
pikl IPC remote control
───────────────────────
j / down move cursor down k / up move cursor up
g jump to top G jump to bottom
f <text> set filter F clear filter
space toggle select a select all
c clear selections s get state
S get selection
+ add items r replace all items
enter confirm selection q cancel menu
? show this help x exit remote
HELP
}
main() {
echo "connected to: $SOCK"
show_help
while true; do
read -rp "ipc> " -n1 key || break
echo ""
case "$key" in
j) cmd_move_down ;;
k) cmd_move_up ;;
g) cmd_top ;;
G) cmd_bottom ;;
f) read -rp "filter: " text; cmd_filter "$text" ;;
F) cmd_filter "" ;;
" ") cmd_toggle ;;
a) cmd_select_all ;;
c) cmd_clear ;;
s) cmd_state ;;
S) cmd_selection ;;
+) cmd_add ;;
r) cmd_replace ;;
"") cmd_confirm; echo "confirmed."; break ;;
q) cmd_cancel; echo "cancelled."; break ;;
x) echo "bye."; break ;;
"?") show_help ;;
*) echo "unknown key '$key'. press ? for help." ;;
esac
done
}
main