Compare commits

...

19 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
f6f1efdf8e doc: Add install guide instructions.
Some checks failed
CI / Lint (push) Has been cancelled
CI / Test (push) Has been cancelled
2026-03-14 12:50:27 -04:00
c6d80a9650 log: Add tracing events through core for future debugability. 2026-03-14 12:31:20 -04:00
bb3f200141 refactor(core): Split up the large menu trait. 2026-03-14 11:48:27 -04:00
a27d529fa7 feat(frontend): Add generations counter to inform frontends when a list should be re-rendered. 2026-03-14 11:47:27 -04:00
e5c875389c feat: Add graceful shutdown of active hooks. 2026-03-14 11:46:04 -04:00
e89c6cb16f refactor: Eliminate unnecessary empty Vec constructions. 2026-03-14 11:46:04 -04:00
49 changed files with 10196 additions and 663 deletions

3465
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -1,4 +1,4 @@
# pikl-menu
# pikl
Pipe stuff in, pick stuff out.
@@ -95,6 +95,30 @@ or at all:
overlay on Wayland (layer-shell) and X11. Auto-detects your
environment.
## Install
See [docs/guides/install.md](docs/guides/install.md) for full details.
The short version:
```sh
cargo install pikl
```
Or from source:
```sh
git clone https://github.com/maplecool/pikl-menu.git
cd pikl-menu
cargo install --path .
```
## Guides
- **[Install](docs/guides/install.md):** cargo install, building from source
- **[App Launcher](docs/guides/app-launcher.md):** set up pikl as a
keyboard-driven app launcher on Hyprland, i3, macOS (Raycast), or
in a terminal
## Building
```sh
@@ -107,7 +131,7 @@ pins the version and pulls in rust-analyzer + clippy.
## 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
- Vim navigation (j/k, gg/G, Ctrl+D/U, Ctrl+F/B, modes)
- Structured JSON I/O with sublabel, icon, group, and arbitrary metadata
@@ -116,6 +140,31 @@ Phases 1 through 3 are complete. pikl works as a TUI menu with:
- Format templates (`--format '{label} - {sublabel}'`)
- Field-scoped filtering (`--filter-fields`, `meta.res:3840` syntax)
- 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

View File

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

View File

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

View File

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

View File

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

View File

@@ -9,6 +9,8 @@
use std::sync::Arc;
use serde::ser::SerializeSeq;
use serde::Serialize;
use serde_json::Value;
use crate::hook::HookResponse;
@@ -20,6 +22,7 @@ pub enum Mode {
#[default]
Insert,
Normal,
Visual,
}
/// A command the menu should process. Frontends and headless
@@ -37,6 +40,12 @@ pub enum Action {
HalfPageUp(usize),
HalfPageDown(usize),
SetMode(Mode),
ToggleSelect,
SelectRange { start: usize, end: usize },
ClearSelections,
SelectAll,
UndoSelection,
RedoSelection,
Confirm,
Quicklist,
Cancel,
@@ -46,6 +55,65 @@ pub enum Action {
RemoveItems(Vec<usize>),
ProcessHookResponse(HookResponse),
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
@@ -53,7 +121,7 @@ pub enum Action {
#[derive(Debug, Clone)]
pub enum MenuEvent {
StateChanged(ViewState),
Selected(Value),
Selected(Vec<(Value, usize)>),
Quicklist(Vec<Value>),
Cancelled,
}
@@ -72,6 +140,26 @@ pub struct ViewState {
pub total_items: usize,
pub total_filtered: usize,
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
/// `build_view_state()` bumps this, so frontends can
/// detect duplicate broadcasts and skip redundant redraws.
pub generation: u64,
}
/// Column header with display name and computed width for
/// table mode rendering.
#[must_use]
#[derive(Debug, Clone)]
pub struct ColumnHeader {
pub display_name: String,
pub width: u16,
}
/// A single item in the current viewport window. Has the
@@ -83,6 +171,10 @@ pub struct VisibleItem {
pub label: String,
pub formatted_text: Option<String>,
pub index: usize,
pub selected: bool,
/// Column cell values for table mode. None when not in
/// table mode.
pub column_values: Option<Vec<String>>,
}
/// Final outcome of [`crate::menu::MenuRunner::run`]. The
@@ -90,7 +182,92 @@ pub struct VisibleItem {
#[must_use]
#[derive(Debug)]
pub enum MenuResult {
Selected { value: Value, index: usize },
Quicklist { items: Vec<(Value, usize)> },
Cancelled,
Selected {
items: Vec<(Value, usize)>,
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]
fn icon_from_object() {
let item = Item::new(
json!({"label": "Firefox", "icon": "firefox.png"}),
"label",
);
let item = Item::new(json!({"label": "Firefox", "icon": "firefox.png"}), "label");
assert_eq!(item.icon(), Some("firefox.png"));
}
#[test]
fn group_from_object() {
let item = Item::new(
json!({"label": "Firefox", "group": "browsers"}),
"label",
);
let item = Item::new(json!({"label": "Firefox", "group": "browsers"}), "label");
assert_eq!(item.group(), Some("browsers"));
}
#[test]
fn meta_from_object() {
let item = Item::new(
json!({"label": "test", "meta": {"res": 1080}}),
"label",
);
let item = Item::new(json!({"label": "test", "meta": {"res": 1080}}), "label");
let meta = item.meta();
assert!(meta.is_some());
assert_eq!(meta.and_then(|m| m.get("res")), Some(&json!(1080)));

View File

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

View File

@@ -2,10 +2,12 @@
//! item value with action context and original index so
//! hooks and downstream tools know what happened.
use serde::ser::SerializeMap;
use serde::Serialize;
use serde::ser::SerializeMap;
use serde_json::Value;
use crate::event::Modifiers;
/// What the user did to produce this output.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
#[serde(rename_all = "snake_case")]
@@ -25,27 +27,35 @@ pub struct OutputItem {
pub value: Value,
pub action: OutputAction,
pub index: usize,
pub modifiers: Modifiers,
}
impl Serialize for OutputItem {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
let has_mods = !self.modifiers.is_empty();
match &self.value {
Value::Object(map) => {
// 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 {
s.serialize_entry(k, v)?;
}
s.serialize_entry("action", &self.action)?;
s.serialize_entry("index", &self.index)?;
if has_mods {
s.serialize_entry("modifiers", &self.modifiers)?;
}
s.end()
}
_ => {
// 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("action", &self.action)?;
s.serialize_entry("index", &self.index)?;
if has_mods {
s.serialize_entry("modifiers", &self.modifiers)?;
}
s.end()
}
}
@@ -63,6 +73,7 @@ mod tests {
value: json!({"label": "Firefox", "url": "https://firefox.com"}),
action: OutputAction::Select,
index: 3,
modifiers: Modifiers::default(),
};
let json = serde_json::to_value(&item).unwrap_or_default();
assert_eq!(json["label"], "Firefox");
@@ -77,6 +88,7 @@ mod tests {
value: json!("hello"),
action: OutputAction::Select,
index: 0,
modifiers: Modifiers::default(),
};
let json = serde_json::to_value(&item).unwrap_or_default();
assert_eq!(json["value"], "hello");
@@ -90,6 +102,7 @@ mod tests {
value: json!(null),
action: OutputAction::Cancel,
index: 0,
modifiers: Modifiers::default(),
};
let json = serde_json::to_value(&item).unwrap_or_default();
assert_eq!(json["action"], "cancel");
@@ -101,6 +114,7 @@ mod tests {
value: json!("test"),
action: OutputAction::Quicklist,
index: 2,
modifiers: Modifiers::default(),
};
let json = serde_json::to_value(&item).unwrap_or_default();
assert_eq!(json["action"], "quicklist");
@@ -113,6 +127,7 @@ mod tests {
value: json!("alpha"),
action: OutputAction::Select,
index: 0,
modifiers: Modifiers::default(),
};
let serialized = serde_json::to_string(&item).unwrap_or_default();
assert!(
@@ -120,4 +135,61 @@ mod tests {
"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

@@ -32,9 +32,6 @@ pub trait Menu: Send + 'static {
/// position in the filtered results.
fn filtered_label(&self, filtered_index: usize) -> Option<&str>;
/// Add raw values from streaming input or AddItems actions.
fn add_raw(&mut self, values: Vec<serde_json::Value>);
/// Get the JSON value of a filtered item for output.
/// Returns a reference to the stored value.
fn serialize_filtered(&self, filtered_index: usize) -> Option<&serde_json::Value>;
@@ -44,13 +41,10 @@ pub trait Menu: Send + 'static {
/// for output and hook events.
fn original_index(&self, filtered_index: usize) -> Option<usize>;
/// Replace all items with a new set of values. Used by
/// handler hook `replace_items` responses.
fn replace_all(&mut self, values: Vec<serde_json::Value>);
/// Remove items at the given original indices. Used by
/// handler hook `remove_items` responses.
fn remove_by_indices(&mut self, indices: Vec<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,
/// if a format template is configured. Returns None if
@@ -72,4 +66,32 @@ pub trait Menu: Send + 'static {
})
.collect()
}
/// Get the column values for a filtered item, if table
/// mode is active. Returns None when no column config is set.
fn column_values(&self, _filtered_index: usize) -> Option<Vec<String>> {
None
}
/// Get the column config, if one is set.
fn column_config(&self) -> Option<&crate::model::column::ColumnConfig> {
None
}
}
/// Extension of [`Menu`] with mutation methods. Required by
/// [`MenuRunner`] which needs to add, replace, and remove
/// items. Embedders that only need read-only access can
/// depend on `Menu` alone.
pub trait MutableMenu: Menu {
/// Add raw values from streaming input or AddItems actions.
fn add_raw(&mut self, values: Vec<serde_json::Value>);
/// Replace all items with a new set of values. Used by
/// handler hook `replace_items` responses.
fn replace_all(&mut self, values: Vec<serde_json::Value>);
/// Remove items at the given original indices. Used by
/// handler hook `remove_items` responses.
fn remove_by_indices(&mut self, indices: Vec<usize>);
}

View File

@@ -4,6 +4,7 @@
//! caching: unchanged stages keep their results.
use serde_json::Value;
use tracing::{debug, trace};
use super::filter::{Filter, FuzzyFilter};
use super::strategy::{self, FilterKind};
@@ -135,11 +136,15 @@ impl FilterPipeline {
/// Evaluate all dirty stages in order. Each stage filters
/// against the previous stage's cached_indices.
fn evaluate(&mut self) {
let _span = tracing::trace_span!("pipeline_evaluate").entered();
for stage_idx in 0..self.stages.len() {
if !self.stages[stage_idx].dirty {
trace!(stage_idx, dirty = false, "skipping clean stage");
continue;
}
trace!(stage_idx, dirty = true, kind = ?self.stages[stage_idx].kind, "evaluating stage");
let input_indices: Vec<usize> = if stage_idx == 0 {
self.items.iter().map(|(idx, _)| *idx).collect()
} else {
@@ -165,15 +170,11 @@ impl FilterPipeline {
})
}
FilterKind::Field { path } => {
Self::eval_field(
stage,
&input_indices,
&self.item_values,
path,
)
Self::eval_field(stage, &input_indices, &self.item_values, path)
}
};
trace!(stage_idx, matched = result.len(), "stage complete");
self.stages[stage_idx].cached_indices = result;
self.stages[stage_idx].dirty = false;
}
@@ -275,6 +276,7 @@ impl FilterPipeline {
/// items. Used after replace_all or remove_by_indices
/// to rebuild the pipeline from scratch.
pub fn rebuild(&mut self, items: &[(usize, &str)]) {
debug!(item_count = items.len(), "pipeline rebuilt");
self.items.clear();
self.item_values.clear();
self.stages.clear();
@@ -288,6 +290,7 @@ impl FilterPipeline {
/// Clear all items and stages, then re-push with values.
/// Used when field filters need access to item JSON.
pub fn rebuild_with_values(&mut self, items: &[(usize, &str, &Value)]) {
debug!(item_count = items.len(), "pipeline rebuilt");
self.items.clear();
self.item_values.clear();
self.stages.clear();
@@ -325,6 +328,7 @@ impl Filter for FilterPipeline {
fn set_query(&mut self, query: &str) {
self.last_raw_query = query.to_string();
let segments = split_pipeline(query);
debug!(query = %query, stage_count = segments.len(), "pipeline query set");
// Reconcile stages with new segments
let mut new_len = segments.len();

View File

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

View File

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

View File

@@ -8,8 +8,9 @@ use std::time::Duration;
use tokio::sync::mpsc;
use tokio::task::JoinHandle;
use tracing::Instrument;
use crate::event::Action;
use crate::event::{Action, ModifiedAction};
use crate::hook::{HookEvent, HookEventKind, HookHandler, HookResponse};
/// Debounce mode for a hook event kind.
@@ -29,19 +30,16 @@ pub enum DebounceMode {
/// behavior. Each event kind can have its own mode.
pub struct DebouncedDispatcher {
handler: Arc<dyn HookHandler>,
action_tx: mpsc::Sender<Action>,
_action_tx: mpsc::Sender<ModifiedAction>,
modes: HashMap<HookEventKind, DebounceMode>,
in_flight: HashMap<HookEventKind, JoinHandle<()>>,
}
impl DebouncedDispatcher {
pub fn new(
handler: Arc<dyn HookHandler>,
action_tx: mpsc::Sender<Action>,
) -> Self {
pub fn new(handler: Arc<dyn HookHandler>, action_tx: mpsc::Sender<ModifiedAction>) -> Self {
Self {
handler,
action_tx,
_action_tx: action_tx,
modes: HashMap::new(),
in_flight: HashMap::new(),
}
@@ -70,11 +68,9 @@ impl DebouncedDispatcher {
/// Dispatch a hook event through the debounce system.
pub fn dispatch(&mut self, event: HookEvent) {
let kind = event.kind();
let mode = self
.modes
.get(&kind)
.cloned()
.unwrap_or(DebounceMode::None);
let mode = self.modes.get(&kind).cloned().unwrap_or(DebounceMode::None);
tracing::debug!(event_kind = ?kind, mode = ?mode, "dispatching hook event");
match mode {
DebounceMode::None => {
@@ -95,19 +91,15 @@ impl DebouncedDispatcher {
fn fire_now(&self, event: HookEvent) {
let handler = Arc::clone(&self.handler);
let action_tx = self.action_tx.clone();
tokio::spawn(async move {
match handler.handle(event) {
Ok(responses) => {
for resp in responses {
let _ = action_tx.send(Action::ProcessHookResponse(resp)).await;
}
}
Err(e) => {
let span = tracing::debug_span!("hook_fire", event_kind = ?event.kind());
tokio::spawn(
async move {
if let Err(e) = handler.handle(event) {
tracing::warn!(error = %e, "hook handler error");
}
}
});
.instrument(span),
);
}
fn fire_debounced(&mut self, event: HookEvent, delay: Duration, cancel: bool) {
@@ -116,27 +108,27 @@ impl DebouncedDispatcher {
self.cancel_in_flight(kind);
}
let delay_ms = delay.as_millis() as u64;
tracing::debug!(delay_ms, cancel_stale = cancel, "debounced hook scheduled");
let handler = Arc::clone(&self.handler);
let action_tx = self.action_tx.clone();
let handle = tokio::spawn(async move {
let span = tracing::debug_span!("hook_fire", event_kind = ?kind);
let handle = tokio::spawn(
async move {
tokio::time::sleep(delay).await;
match handler.handle(event) {
Ok(responses) => {
for resp in responses {
let _ = action_tx.send(Action::ProcessHookResponse(resp)).await;
}
}
Err(e) => {
if let Err(e) = handler.handle(event) {
tracing::warn!(error = %e, "hook handler error");
}
}
});
.instrument(span),
);
self.in_flight.insert(kind, handle);
}
fn cancel_in_flight(&mut self, kind: HookEventKind) {
if let Some(handle) = self.in_flight.remove(&kind) {
tracing::debug!(event_kind = ?kind, "cancelled in-flight hook");
handle.abort();
}
}
@@ -157,6 +149,7 @@ pub fn hook_response_to_action(resp: HookResponse) -> Action {
mod tests {
use super::*;
use crate::error::PiklError;
use crate::event::Modifiers;
use serde_json::json;
use std::sync::Mutex;
@@ -165,11 +158,11 @@ mod tests {
}
impl HookHandler for RecordingHandler {
fn handle(&self, event: HookEvent) -> Result<Vec<HookResponse>, PiklError> {
fn handle(&self, event: HookEvent) -> Result<(), PiklError> {
if let Ok(mut events) = self.events.lock() {
events.push(event.kind());
}
Ok(vec![])
Ok(())
}
}
@@ -225,12 +218,15 @@ mod tests {
// Rapid-fire filter events
dispatcher.dispatch(HookEvent::Filter {
text: "a".to_string(),
modifiers: Modifiers::default(),
});
dispatcher.dispatch(HookEvent::Filter {
text: "ab".to_string(),
modifiers: Modifiers::default(),
});
dispatcher.dispatch(HookEvent::Filter {
text: "abc".to_string(),
modifiers: Modifiers::default(),
});
// Advance past debounce window. sleep(0) processes
@@ -259,12 +255,14 @@ mod tests {
dispatcher.dispatch(HookEvent::Hover {
item: json!("a"),
index: 0,
modifiers: Modifiers::default(),
});
// Wait a bit, then send second hover which cancels first
tokio::time::sleep(Duration::from_millis(50)).await;
dispatcher.dispatch(HookEvent::Hover {
item: json!("b"),
index: 1,
modifiers: Modifiers::default(),
});
// Advance past debounce for the second event

View File

@@ -7,6 +7,7 @@ use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::error::PiklError;
use crate::event::Modifiers;
/// A lifecycle event emitted by the menu engine. Handler
/// hooks receive these as JSON lines on stdin. The `event`
@@ -16,11 +17,33 @@ use crate::error::PiklError;
pub enum HookEvent {
Open,
Close,
Hover { item: Value, index: usize },
Select { item: Value, index: usize },
Cancel,
Filter { text: String },
Quicklist { items: Vec<Value>, count: usize },
Hover {
item: Value,
index: usize,
#[serde(skip_serializing_if = "Modifiers::is_empty")]
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
@@ -44,7 +67,7 @@ impl HookEvent {
HookEvent::Close => HookEventKind::Close,
HookEvent::Hover { .. } => HookEventKind::Hover,
HookEvent::Select { .. } => HookEventKind::Select,
HookEvent::Cancel => HookEventKind::Cancel,
HookEvent::Cancel { .. } => HookEventKind::Cancel,
HookEvent::Filter { .. } => HookEventKind::Filter,
HookEvent::Quicklist { .. } => HookEventKind::Quicklist,
}
@@ -65,16 +88,15 @@ pub enum HookResponse {
}
/// Handler trait for lifecycle hooks. Implementations
/// receive events and optionally return responses.
/// Exec hooks return empty vecs. Handler hooks send
/// responses back through the action channel asynchronously
/// and also return empty vecs.
/// receive events and may produce side effects (spawning
/// processes, sending to channels). Responses flow through
/// the action channel, not the return value.
///
/// This is deliberately synchronous for dyn-compatibility.
/// Implementations that need async work (spawning processes,
/// writing to channels) should use `tokio::spawn` internally.
pub trait HookHandler: Send + Sync {
fn handle(&self, event: HookEvent) -> Result<Vec<HookResponse>, PiklError>;
fn handle(&self, event: HookEvent) -> Result<(), PiklError>;
}
/// Parse a single line of JSON as a [`HookResponse`].
@@ -82,7 +104,10 @@ pub trait HookHandler: Send + Sync {
/// tracing.
pub fn parse_hook_response(line: &str) -> Option<HookResponse> {
match serde_json::from_str::<HookResponse>(line) {
Ok(resp) => Some(resp),
Ok(resp) => {
tracing::debug!(response = ?resp, "parsed hook response");
Some(resp)
}
Err(e) => {
tracing::warn!(line, error = %e, "failed to parse hook response");
None
@@ -114,11 +139,14 @@ mod tests {
let event = HookEvent::Hover {
item: json!({"label": "test"}),
index: 5,
modifiers: Modifiers::default(),
};
let json = serde_json::to_value(&event).unwrap_or_default();
assert_eq!(json["event"], "hover");
assert_eq!(json["item"]["label"], "test");
assert_eq!(json["index"], 5);
// Empty modifiers should be omitted
assert!(json.get("modifiers").is_none());
}
#[test]
@@ -126,6 +154,7 @@ mod tests {
let event = HookEvent::Select {
item: json!("hello"),
index: 0,
modifiers: Modifiers::default(),
};
let json = serde_json::to_value(&event).unwrap_or_default();
assert_eq!(json["event"], "select");
@@ -135,14 +164,19 @@ mod tests {
#[test]
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!(json.get("modifiers").is_none());
}
#[test]
fn event_filter_serializes() {
let event = HookEvent::Filter {
text: "foo".to_string(),
modifiers: Modifiers::default(),
};
let json = serde_json::to_value(&event).unwrap_or_default();
assert_eq!(json["event"], "filter");
@@ -158,7 +192,8 @@ mod tests {
assert_eq!(
HookEvent::Hover {
item: json!(null),
index: 0
index: 0,
modifiers: Modifiers::default(),
}
.kind(),
HookEventKind::Hover
@@ -166,15 +201,23 @@ mod tests {
assert_eq!(
HookEvent::Select {
item: json!(null),
index: 0
index: 0,
modifiers: Modifiers::default(),
}
.kind(),
HookEventKind::Select
);
assert_eq!(HookEvent::Cancel.kind(), HookEventKind::Cancel);
assert_eq!(
HookEvent::Cancel {
modifiers: Modifiers::default()
}
.kind(),
HookEventKind::Cancel
);
assert_eq!(
HookEvent::Filter {
text: String::new()
text: String::new(),
modifiers: Modifiers::default(),
}
.kind(),
HookEventKind::Filter
@@ -186,9 +229,8 @@ mod tests {
#[test]
fn response_add_items() {
let json = r#"{"action": "add_items", "items": [{"label": "new"}]}"#;
let resp: HookResponse = serde_json::from_str(json).unwrap_or_else(|e| {
std::unreachable!("parse failed: {e}")
});
let resp: HookResponse =
serde_json::from_str(json).unwrap_or_else(|e| std::unreachable!("parse failed: {e}"));
assert_eq!(
resp,
HookResponse::AddItems {
@@ -200,9 +242,8 @@ mod tests {
#[test]
fn response_replace_items() {
let json = r#"{"action": "replace_items", "items": ["a", "b"]}"#;
let resp: HookResponse = serde_json::from_str(json).unwrap_or_else(|e| {
std::unreachable!("parse failed: {e}")
});
let resp: HookResponse =
serde_json::from_str(json).unwrap_or_else(|e| std::unreachable!("parse failed: {e}"));
assert_eq!(
resp,
HookResponse::ReplaceItems {
@@ -214,9 +255,8 @@ mod tests {
#[test]
fn response_remove_items() {
let json = r#"{"action": "remove_items", "indices": [0, 3, 5]}"#;
let resp: HookResponse = serde_json::from_str(json).unwrap_or_else(|e| {
std::unreachable!("parse failed: {e}")
});
let resp: HookResponse =
serde_json::from_str(json).unwrap_or_else(|e| std::unreachable!("parse failed: {e}"));
assert_eq!(
resp,
HookResponse::RemoveItems {
@@ -228,9 +268,8 @@ mod tests {
#[test]
fn response_set_filter() {
let json = r#"{"action": "set_filter", "text": "hello"}"#;
let resp: HookResponse = serde_json::from_str(json).unwrap_or_else(|e| {
std::unreachable!("parse failed: {e}")
});
let resp: HookResponse =
serde_json::from_str(json).unwrap_or_else(|e| std::unreachable!("parse failed: {e}"));
assert_eq!(
resp,
HookResponse::SetFilter {
@@ -242,9 +281,8 @@ mod tests {
#[test]
fn response_close() {
let json = r#"{"action": "close"}"#;
let resp: HookResponse = serde_json::from_str(json).unwrap_or_else(|e| {
std::unreachable!("parse failed: {e}")
});
let resp: HookResponse =
serde_json::from_str(json).unwrap_or_else(|e| std::unreachable!("parse failed: {e}"));
assert_eq!(resp, HookResponse::Close);
}
@@ -294,6 +332,7 @@ mod tests {
let event = HookEvent::Quicklist {
items: vec![json!("alpha"), json!("beta")],
count: 2,
modifiers: Modifiers::default(),
};
let json = serde_json::to_value(&event).unwrap_or_default();
assert_eq!(json["event"], "quicklist");
@@ -306,6 +345,7 @@ mod tests {
let event = HookEvent::Quicklist {
items: vec![],
count: 0,
modifiers: Modifiers::default(),
};
assert_eq!(event.kind(), HookEventKind::Quicklist);
}
@@ -317,6 +357,7 @@ mod tests {
let event = HookEvent::Hover {
item: json!({"label": "Firefox", "url": "https://firefox.com"}),
index: 2,
modifiers: Modifiers::default(),
};
let serialized = serde_json::to_string(&event).unwrap_or_default();
let parsed: Value = serde_json::from_str(&serialized).unwrap_or_default();
@@ -324,4 +365,35 @@ mod tests {
assert_eq!(parsed["item"]["label"], "Firefox");
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::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
/// it as a plain-text string. The `label_key` controls
/// which JSON key is used as the display label for object
@@ -34,6 +43,7 @@ pub fn read_items_sync(
}
items.push(parse_line(&line, label_key));
}
tracing::debug!(count = items.len(), "items read");
Ok(items)
}
@@ -51,6 +61,7 @@ pub async fn read_items(
}
items.push(parse_line(&line, label_key));
}
tracing::debug!(count = items.len(), "items read");
Ok(items)
}
@@ -114,4 +125,39 @@ mod tests {
assert_eq!(items.len(), 1);
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,7 +5,8 @@
use crate::filter::Filter;
use crate::format::FormatTemplate;
use crate::item::Item;
use crate::model::traits::Menu;
use crate::model::column::ColumnConfig;
use crate::model::traits::{Menu, MutableMenu};
use crate::pipeline::FilterPipeline;
/// A menu backed by a flat list of JSON items. Handles
@@ -19,11 +20,14 @@ pub struct JsonMenu {
filter: FilterPipeline,
filter_fields: Vec<String>,
format_template: Option<FormatTemplate>,
column_config: Option<ColumnConfig>,
}
impl JsonMenu {
/// Create a new JSON menu with the given items and label key.
pub fn new(items: Vec<Item>, label_key: String) -> Self {
let item_count = items.len();
tracing::debug!(item_count, label_key = %label_key, "json menu created");
let mut filter = FilterPipeline::new();
for (i, item) in items.iter().enumerate() {
filter.push_with_value(i, item.label(), &item.value);
@@ -34,6 +38,7 @@ impl JsonMenu {
filter,
filter_fields: vec!["label".to_string()],
format_template: None,
column_config: None,
}
}
@@ -50,6 +55,11 @@ impl JsonMenu {
self.format_template = Some(template);
}
/// Set the column config for table mode rendering.
pub fn set_column_config(&mut self, config: ColumnConfig) {
self.column_config = Some(config);
}
/// Rebuild the filter pipeline from scratch. Called after
/// filter_fields change or item mutations.
fn rebuild_pipeline(&mut self) {
@@ -120,16 +130,6 @@ impl Menu for JsonMenu {
.map(|idx| self.items[idx].label())
}
fn add_raw(&mut self, values: Vec<serde_json::Value>) {
for value in values {
let idx = self.items.len();
let item = Item::new(value, &self.label_key);
let text = self.extract_filter_text(&item);
self.filter.push_with_value(idx, &text, &item.value);
self.items.push(item);
}
}
fn serialize_filtered(&self, filtered_index: usize) -> Option<&serde_json::Value> {
self.filter
.matched_index(filtered_index)
@@ -140,7 +140,42 @@ impl Menu for JsonMenu {
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> {
let template = self.format_template.as_ref()?;
let orig_idx = self.filter.matched_index(filtered_index)?;
Some(template.render(&self.items[orig_idx].value))
}
fn column_values(&self, filtered_index: usize) -> Option<Vec<String>> {
let config = self.column_config.as_ref()?;
let orig_idx = self.filter.matched_index(filtered_index)?;
Some(config.resolve_values(&self.items[orig_idx].value))
}
fn column_config(&self) -> Option<&ColumnConfig> {
self.column_config.as_ref()
}
}
impl MutableMenu for JsonMenu {
fn add_raw(&mut self, values: Vec<serde_json::Value>) {
let count = values.len();
for value in values {
let idx = self.items.len();
let item = Item::new(value, &self.label_key);
let text = self.extract_filter_text(&item);
self.filter.push_with_value(idx, &text, &item.value);
self.items.push(item);
}
tracing::debug!(count, new_total = self.items.len(), "adding items to menu");
}
fn replace_all(&mut self, values: Vec<serde_json::Value>) {
tracing::debug!(count = values.len(), "replacing all menu items");
self.items = values
.into_iter()
.map(|v| Item::new(v, &self.label_key))
@@ -149,6 +184,7 @@ impl Menu for JsonMenu {
}
fn remove_by_indices(&mut self, indices: Vec<usize>) {
let count = indices.len();
// Sort descending to remove from the end first,
// preserving earlier indices.
let mut sorted = indices;
@@ -159,12 +195,11 @@ impl Menu for JsonMenu {
self.items.remove(idx);
}
}
tracing::debug!(
count,
remaining = self.items.len(),
"items removed from menu"
);
self.rebuild_pipeline();
}
fn formatted_label(&self, filtered_index: usize) -> Option<String> {
let template = self.format_template.as_ref()?;
let orig_idx = self.filter.matched_index(filtered_index)?;
Some(template.render(&self.items[orig_idx].value))
}
}

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 hook;
pub mod input;

View File

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

View File

@@ -4,7 +4,7 @@
use std::io::BufRead;
use crate::event::{Action, Mode};
use crate::event::{Action, Mode, Modifiers};
use super::ScriptAction;
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.
pub fn parse_action(line_number: usize, line: &str) -> Result<ScriptAction, ScriptError> {
let trimmed = line.trim();
@@ -90,41 +114,66 @@ pub fn parse_action(line_number: usize, line: &str) -> Result<ScriptAction, Scri
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)),
None => (trimmed, None),
None => (rest, None),
};
let core = |action: Action| -> Result<ScriptAction, ScriptError> {
Ok(ScriptAction::Core(action, modifiers))
};
match cmd {
"filter" => {
let text = arg.unwrap_or("");
Ok(ScriptAction::Core(Action::UpdateFilter(text.to_string())))
core(Action::UpdateFilter(text.to_string()))
}
"move-up" => {
let n = parse_count(line_number, line, "move-up", arg)?;
Ok(ScriptAction::Core(Action::MoveUp(n)))
core(Action::MoveUp(n))
}
"move-down" => {
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-bottom" => Ok(ScriptAction::Core(Action::MoveToBottom)),
"move-to-top" => core(Action::MoveToTop),
"move-to-bottom" => core(Action::MoveToBottom),
"page-up" => {
let n = parse_count(line_number, line, "page-up", arg)?;
Ok(ScriptAction::Core(Action::PageUp(n)))
core(Action::PageUp(n))
}
"page-down" => {
let n = parse_count(line_number, line, "page-down", arg)?;
Ok(ScriptAction::Core(Action::PageDown(n)))
core(Action::PageDown(n))
}
"half-page-up" => {
let n = parse_count(line_number, line, "half-page-up", arg)?;
Ok(ScriptAction::Core(Action::HalfPageUp(n)))
core(Action::HalfPageUp(n))
}
"half-page-down" => {
let n = parse_count(line_number, line, "half-page-down", arg)?;
Ok(ScriptAction::Core(Action::HalfPageDown(n)))
core(Action::HalfPageDown(n))
}
"set-mode" => {
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,
"set-mode",
"missing mode value (insert or normal)".to_string(),
"missing mode value (insert, normal, or visual)".to_string(),
));
};
match mode_str.trim() {
"insert" => Ok(ScriptAction::Core(Action::SetMode(Mode::Insert))),
"normal" => Ok(ScriptAction::Core(Action::SetMode(Mode::Normal))),
"insert" => core(Action::SetMode(Mode::Insert)),
"normal" => core(Action::SetMode(Mode::Normal)),
"visual" => core(Action::SetMode(Mode::Visual)),
other => Err(invalid_arg(
line_number,
line,
"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)),
"cancel" => Ok(ScriptAction::Core(Action::Cancel)),
"toggle-select" => core(Action::ToggleSelect),
"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" => {
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-tui" => Ok(ScriptAction::ShowTui),
"show-gui" => Ok(ScriptAction::ShowGui),
"streaming-done" => core(Action::StreamingDone),
_ => Err(ScriptError {
line: line_number,
source_line: line.to_string(),
@@ -237,7 +293,7 @@ mod tests {
assert!(result.is_ok());
assert_eq!(
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_eq!(
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() {
assert_eq!(
parse_action(1, "move-up").unwrap_or(ScriptAction::Comment),
ScriptAction::Core(Action::MoveUp(1))
ScriptAction::Core(Action::MoveUp(1), Modifiers::default())
);
assert_eq!(
parse_action(1, "move-down").unwrap_or(ScriptAction::Comment),
ScriptAction::Core(Action::MoveDown(1))
ScriptAction::Core(Action::MoveDown(1), Modifiers::default())
);
assert_eq!(
parse_action(1, "move-to-top").unwrap_or(ScriptAction::Comment),
ScriptAction::Core(Action::MoveToTop)
ScriptAction::Core(Action::MoveToTop, Modifiers::default())
);
assert_eq!(
parse_action(1, "move-to-bottom").unwrap_or(ScriptAction::Comment),
ScriptAction::Core(Action::MoveToBottom)
ScriptAction::Core(Action::MoveToBottom, Modifiers::default())
);
assert_eq!(
parse_action(1, "page-up").unwrap_or(ScriptAction::Comment),
ScriptAction::Core(Action::PageUp(1))
ScriptAction::Core(Action::PageUp(1), Modifiers::default())
);
assert_eq!(
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() {
assert_eq!(
parse_action(1, "move-up 5").unwrap_or(ScriptAction::Comment),
ScriptAction::Core(Action::MoveUp(5))
ScriptAction::Core(Action::MoveUp(5), Modifiers::default())
);
assert_eq!(
parse_action(1, "move-down 3").unwrap_or(ScriptAction::Comment),
ScriptAction::Core(Action::MoveDown(3))
ScriptAction::Core(Action::MoveDown(3), Modifiers::default())
);
assert_eq!(
parse_action(1, "page-up 2").unwrap_or(ScriptAction::Comment),
ScriptAction::Core(Action::PageUp(2))
ScriptAction::Core(Action::PageUp(2), Modifiers::default())
);
assert_eq!(
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() {
assert_eq!(
parse_action(1, "confirm").unwrap_or(ScriptAction::Comment),
ScriptAction::Core(Action::Confirm)
ScriptAction::Core(Action::Confirm, Modifiers::default())
);
assert_eq!(
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() {
assert_eq!(
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() {
assert_eq!(
parse_action(1, "half-page-up").unwrap_or(ScriptAction::Comment),
ScriptAction::Core(Action::HalfPageUp(1))
ScriptAction::Core(Action::HalfPageUp(1), Modifiers::default())
);
assert_eq!(
parse_action(1, "half-page-down").unwrap_or(ScriptAction::Comment),
ScriptAction::Core(Action::HalfPageDown(1))
ScriptAction::Core(Action::HalfPageDown(1), Modifiers::default())
);
assert_eq!(
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!(
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() {
assert_eq!(
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!(
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]
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]
@@ -509,7 +568,7 @@ mod tests {
ParsedLine {
line_number: 1,
source: "filter test".into(),
action: ScriptAction::Core(Action::UpdateFilter("test".into())),
action: ScriptAction::Core(Action::UpdateFilter("test".into()), Modifiers::default()),
},
ParsedLine {
line_number: 2,
@@ -531,7 +590,7 @@ mod tests {
ParsedLine {
line_number: 2,
source: "confirm".into(),
action: ScriptAction::Core(Action::Confirm),
action: ScriptAction::Core(Action::Confirm, Modifiers::default()),
},
];
let err = validate_show_last(&actions).unwrap_err();
@@ -545,7 +604,7 @@ mod tests {
ParsedLine {
line_number: 1,
source: "filter x".into(),
action: ScriptAction::Core(Action::UpdateFilter("x".into())),
action: ScriptAction::Core(Action::UpdateFilter("x".into()), Modifiers::default()),
},
ParsedLine {
line_number: 2,
@@ -555,7 +614,7 @@ mod tests {
ParsedLine {
line_number: 3,
source: "confirm".into(),
action: ScriptAction::Core(Action::Confirm),
action: ScriptAction::Core(Action::Confirm, Modifiers::default()),
},
];
let err = validate_show_last(&actions).unwrap_err();
@@ -579,12 +638,12 @@ mod tests {
ParsedLine {
line_number: 1,
source: "filter x".into(),
action: ScriptAction::Core(Action::UpdateFilter("x".into())),
action: ScriptAction::Core(Action::UpdateFilter("x".into()), Modifiers::default()),
},
ParsedLine {
line_number: 2,
source: "confirm".into(),
action: ScriptAction::Core(Action::Confirm),
action: ScriptAction::Core(Action::Confirm, Modifiers::default()),
},
];
assert!(validate_show_last(&actions).is_ok());
@@ -645,6 +704,38 @@ mod tests {
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]
fn parse_set_mode_wrong_case() {
assert!(parse_action(1, "set-mode Insert").is_err());
@@ -657,11 +748,11 @@ mod tests {
// Extra whitespace around the mode value should be trimmed
assert_eq!(
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!(
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());
let actions = result.unwrap_or_default();
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!(
actions[1],
ScriptAction::Core(Action::SetMode(Mode::Normal))
ScriptAction::Core(Action::SetMode(Mode::Normal), Modifiers::default())
);
assert_eq!(
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[4], ScriptAction::Core(Action::Confirm));
assert_eq!(actions[3], ScriptAction::Core(Action::HalfPageDown(1), Modifiers::default()));
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 {
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::Nav => gen_nav(case, &module.fixtures)?,
TestKind::Menu => gen_menu(case, &module.fixtures)?,
TestKind::Ipc => gen_ipc(case, &module.fixtures)?,
};
test_fns.push(tokens);
}
@@ -58,11 +59,20 @@ fn gen_imports(kind: TestKind) -> TokenStream {
TestKind::Menu => {
quote! {
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::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
let script = build_headless_script(&case.actions);
// Build extra CLI args (e.g. --label-key)
let extra_args: Vec<TokenStream> = if let Some(ref key) = fixtures.label_key {
vec![quote! { "--label-key" }, quote! { #key }]
} else {
Vec::new()
};
// Build extra CLI args (e.g. --label-key, --multi)
let mut extra_args: Vec<TokenStream> = Vec::new();
if let Some(ref key) = fixtures.label_key {
extra_args.push(quote! { "--label-key" });
extra_args.push(quote! { #key });
}
if fixtures.multi == Some(true) {
extra_args.push(quote! { "--multi" });
}
// Build assertions
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! {
#[test]
fn #test_name() {
@@ -166,8 +189,8 @@ fn build_headless_script(actions: &[ActionExpr]) -> String {
script.push_str(line);
script.push('\n');
}
ActionExpr::AddItems(_) => {
// Not applicable for headless. Items come from stdin.
ActionExpr::AddItems(_) | ActionExpr::IpcSend(_) | ActionExpr::IpcExpect(_) => {
// Not applicable for headless.
}
}
}
@@ -178,18 +201,12 @@ fn build_headless_script(actions: &[ActionExpr]) -> String {
// 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> {
let test_name = &case.name;
let item_exprs = gen_item_constructors(fixtures);
let query = case.query.as_deref().unwrap_or("");
let mut asserts = Vec::new();
if let Some(ref expected) = case.match_labels {
asserts.push(quote! {
let labels: Vec<&str> = (0..f.matched_count())
@@ -222,11 +239,8 @@ fn gen_filter(case: &TestCase, fixtures: &Fixtures) -> syn::Result<TokenStream>
// 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> {
let test_name = &case.name;
let (height, count) = fixtures.viewport.unwrap_or((10, 20));
let height_lit = height;
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 mut asserts = Vec::new();
if let Some(cursor) = case.cursor {
asserts.push(quote! {
assert_eq!(
v.cursor(), #cursor,
"expected cursor {}, got {}", #cursor, v.cursor()
);
assert_eq!(v.cursor(), #cursor, "expected cursor {}, got {}", #cursor, v.cursor());
});
}
if let Some(offset) = case.offset {
asserts.push(quote! {
assert_eq!(
v.scroll_offset(), #offset,
"expected offset {}, got {}", #offset, v.scroll_offset()
);
assert_eq!(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>> {
let mut calls = Vec::new();
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 needs_count = matches!(
name.as_str(),
"move-up"
| "move-down"
| "page-up"
| "page-down"
| "half-page-up"
| "half-page-down"
"move-up" | "move-down" | "page-up" | "page-down"
| "half-page-up" | "half-page-down"
);
if needs_count {
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(
Span::call_site(),
format!(
"nav tests only support simple actions, got: {:?}",
action_debug(action)
),
format!("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
// ---------------------------------------------------------------------------
/// 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> {
let test_name = &case.name;
let item_exprs = gen_item_constructors(fixtures);
let label_key = fixtures.label_key.as_deref().unwrap_or("label");
let action_sends = gen_menu_actions(&case.actions)?;
let result_assert = gen_result_assert(case, label_key)?;
let result_assert = if case.cancelled {
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 {
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),
}
}
let multi_setup = if fixtures.multi == Some(true) {
quote! { menu.set_multi(true); }
} else {
// No assertion on result. Probably an error, but let it compile.
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! {
#[tokio::test]
async fn #test_name() {
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 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;
// 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)*
// Drop sender so menu loop can exit.
#drop_sender
drop(tx);
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
}
})
}
/// Convert DSL actions to `tx.send(Action::...)` calls for
/// menu tests.
fn gen_menu_actions(actions: &[ActionExpr]) -> syn::Result<Vec<TokenStream>> {
let mut sends = Vec::new();
for action in actions {
let expr = match action {
ActionExpr::Simple(name) => {
let variant = menu_action_variant(name)?;
quote! { let _ = tx.send(#variant).await; }
quote! { let _ = tx.send(ModifiedAction::new(#variant)).await; }
}
ActionExpr::Filter(query) => {
quote! {
let _ = tx.send(Action::UpdateFilter(#query.to_string())).await;
}
quote! { let _ = tx.send(ModifiedAction::new(Action::UpdateFilter(#query.to_string()))).await; }
}
ActionExpr::AddItems(items) => {
let item_exprs: Vec<TokenStream> = items
.iter()
.map(|s| quote! { serde_json::Value::String(#s.to_string()) })
.collect();
quote! {
let _ = tx.send(Action::AddItems(vec![#(#item_exprs),*])).await;
}
quote! { let _ = tx.send(ModifiedAction::new(Action::AddItems(vec![#(#item_exprs),*]))).await; }
}
ActionExpr::Raw(_) => {
return Err(syn::Error::new(
Span::call_site(),
"raw actions are only supported in headless tests",
));
return Err(syn::Error::new(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);
@@ -411,8 +369,6 @@ fn gen_menu_actions(actions: &[ActionExpr]) -> syn::Result<Vec<TokenStream>> {
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> {
let tokens = match name {
"confirm" => quote! { Action::Confirm },
@@ -427,38 +383,220 @@ fn menu_action_variant(name: &str) -> syn::Result<TokenStream> {
"half-page-down" => quote! { Action::HalfPageDown(1) },
"set-mode-insert" => quote! { Action::SetMode(pikl_core::event::Mode::Insert) },
"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(
Span::call_site(),
format!("unknown menu action: '{name}'"),
));
return Err(syn::Error::new(Span::call_site(), format!("unknown menu action: '{name}'")));
}
};
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
// ---------------------------------------------------------------------------
/// Generate `Item::from_plain_text("...")` expressions for
/// each item in the fixtures.
fn gen_item_constructors(fixtures: &Fixtures) -> Vec<TokenStream> {
match &fixtures.items {
Some(items) => items
.iter()
.map(|s| quote! { Item::from_plain_text(#s) })
.collect(),
Some(items) => items.iter().map(|s| quote! { Item::from_plain_text(#s) }).collect(),
None => Vec::new(),
}
}
/// Format an action expression for use in error messages.
fn action_debug(action: &ActionExpr) -> String {
match action {
ActionExpr::Simple(name) => name.clone(),
ActionExpr::Filter(q) => format!("filter \"{q}\""),
ActionExpr::Raw(r) => format!("raw \"{r}\""),
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,
Nav,
Menu,
Ipc,
}
pub struct Fixtures {
pub items: Option<Vec<String>>,
pub label_key: Option<String>,
pub viewport: Option<(usize, usize)>, // (height, count)
pub multi: Option<bool>,
}
pub struct TestCase {
@@ -44,6 +46,7 @@ pub struct TestCase {
pub cursor: Option<usize>,
pub offset: Option<usize>,
pub selected: Option<String>,
pub selected_items: Option<Vec<String>>,
pub cancelled: bool,
}
@@ -56,6 +59,10 @@ pub enum ActionExpr {
Raw(String),
/// `add-items ["a", "b", "c"]`
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,
label_key: None,
viewport: None,
multi: None,
};
let mut tests = Vec::new();
@@ -120,11 +128,18 @@ impl Parse for TestModule {
fixtures.viewport = Some(parse_viewport_def(&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(
content.span(),
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,
offset: None,
selected: None,
selected_items: None,
cancelled: false,
};
@@ -224,6 +240,10 @@ fn parse_test_case(input: ParseStream) -> syn::Result<TestCase> {
let val: LitStr = content.parse()?;
case.selected = Some(val.value());
}
"selected_items" => {
content.parse::<Token![:]>()?;
case.selected_items = Some(parse_string_list(&content)?);
}
"cancelled" => {
// Just the keyword presence means true. Optionally parse `: true`.
if content.peek(Token![:]) {
@@ -285,6 +305,14 @@ fn parse_action_expr(input: ParseStream) -> syn::Result<ActionExpr> {
let items = parse_string_list(input)?;
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)),
}
}
@@ -302,9 +330,10 @@ fn parse_kind(input: ParseStream) -> syn::Result<TestKind> {
"filter" => Ok(TestKind::Filter),
"nav" => Ok(TestKind::Nav),
"menu" => Ok(TestKind::Menu),
"ipc" => Ok(TestKind::Ipc),
other => Err(syn::Error::new(
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,3 +14,4 @@ ratatui = "0.30"
crossterm = { version = "0.29", features = ["event-stream"] }
tokio = { version = "1", features = ["sync", "macros", "rt"] }
futures = "0.3"
tracing = "0.1"

View File

@@ -14,7 +14,7 @@ use ratatui::text::{Line, Span};
use ratatui::widgets::{List, ListItem, Paragraph};
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`).
/// TUI-local, not part of core state.
@@ -32,13 +32,23 @@ pub fn restore_terminal() {
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
/// event loop, and restores the terminal on exit. Translates
/// crossterm key events into [`Action`]s and renders
/// [`ViewState`] snapshots.
pub async fn run(
action_tx: mpsc::Sender<Action>,
action_tx: mpsc::Sender<ModifiedAction>,
mut event_rx: broadcast::Receiver<MenuEvent>,
filter_history: Option<Vec<String>>,
) -> std::io::Result<()> {
crossterm::terminal::enable_raw_mode()?;
crossterm::execute!(std::io::stderr(), crossterm::terminal::EnterAlternateScreen,)?;
@@ -53,7 +63,7 @@ pub async fn run(
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
restore_terminal();
@@ -64,30 +74,49 @@ pub async fn run(
/// Inner event loop. Separated from [`run`] so terminal
/// cleanup always happens even if this returns an error.
async fn run_inner(
action_tx: &mpsc::Sender<Action>,
action_tx: &mpsc::Sender<ModifiedAction>,
event_rx: &mut broadcast::Receiver<MenuEvent>,
terminal: &mut Terminal<CrosstermBackend<std::io::Stderr>>,
filter_history: Option<Vec<String>>,
) -> std::io::Result<()> {
// Send initial resize
let size = terminal.size()?;
let list_height = size.height.saturating_sub(1);
let _ = action_tx
.send(Action::Resize {
if action_tx
.send(ModifiedAction::new(Action::Resize {
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 view_state: Option<ViewState> = None;
let mut event_stream = EventStream::new();
let mut mode = Mode::Insert;
let mut pending = PendingKey::None;
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 {
if let Some(ref vs) = view_state {
let ft = filter_text.clone();
let anchor = visual_anchor;
terminal.draw(|frame| {
render_menu(frame, vs, &ft);
render_menu(frame, vs, &ft, anchor);
})?;
}
@@ -99,18 +128,93 @@ async fn run_inner(
let event = event_result?;
match event {
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
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;
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) => {
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;
}
}
_ => {}
}
@@ -118,6 +222,11 @@ async fn run_inner(
menu_event = event_rx.recv() => {
match menu_event {
Ok(MenuEvent::StateChanged(vs)) => {
// Skip duplicate broadcasts
if vs.generation == last_generation {
continue;
}
last_generation = vs.generation;
// Sync filter text from core. Local keystrokes
// update filter_text immediately for responsiveness,
// but if core pushes a different value (e.g. IPC
@@ -130,12 +239,15 @@ async fn run_inner(
mode = vs.mode;
pending = PendingKey::None;
}
multi_enabled = vs.multi_enabled;
view_state = Some(vs);
}
Ok(MenuEvent::Selected(_) | MenuEvent::Quicklist(_) | MenuEvent::Cancelled) => {
break;
}
Err(broadcast::error::RecvError::Lagged(_)) => {}
Err(broadcast::error::RecvError::Lagged(n)) => {
tracing::warn!(skipped = n, "TUI fell behind on state broadcasts");
}
Err(broadcast::error::RecvError::Closed) => {
break;
}
@@ -147,12 +259,118 @@ async fn run_inner(
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
/// 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()
.direction(Direction::Vertical)
.constraints([Constraint::Length(1), Constraint::Min(1)])
.constraints(constraints)
.split(frame.area());
let filtered_count = vs.total_filtered;
@@ -161,19 +379,32 @@ fn render_menu(frame: &mut ratatui::Frame, vs: &ViewState, filter_text: &str) {
let mode_indicator = match vs.mode {
Mode::Insert => "[I]",
Mode::Normal => "[N]",
Mode::Visual => "[V]",
};
let prompt = Paragraph::new(Line::from(vec![
let mut prompt_spans = vec![
Span::styled(
format!("{mode_indicator}> "),
Style::default().fg(Color::Cyan),
),
Span::raw(filter_text),
Span::styled(
format!(" {filtered_count}/{total_count}"),
format!(
" {filtered_count}/{total_count}{}",
if vs.streaming { "..." } else { "" }
),
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]);
// mode_indicator len + "> " = mode_indicator.len() + 2
@@ -184,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));
}
// 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
.visible_items
.iter()
.enumerate()
.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)
} else if in_visual {
Style::default().fg(Color::Black).bg(Color::Blue)
} else {
Style::default()
};
if vi.selected {
style = style.fg(Color::Green);
}
// Table mode: render cells padded to column widths
if let (Some(col_vals), Some(columns)) = (&vi.column_values, &vs.columns) {
let marker = if vs.multi_enabled {
if vi.selected { "* " } else { " " }
} else {
""
};
let cell_spans: Vec<Span> = columns
.iter()
.enumerate()
.flat_map(|(ci, col)| {
let mut spans = Vec::new();
if ci > 0 {
spans.push(Span::styled(
" \u{2502} ",
Style::default().fg(Color::DarkGray),
));
}
let cell = col_vals.get(ci).map(|s| s.as_str()).unwrap_or("");
spans.push(Span::raw(format!(
"{:<width$}",
cell,
width = col.width as usize
)));
spans
})
.collect();
let mut all_spans = Vec::new();
if !marker.is_empty() {
all_spans.push(Span::raw(marker));
}
all_spans.extend(cell_spans);
ListItem::new(Line::from(all_spans)).style(style)
} else {
let marker = if vi.selected { "* " } else { " " };
let text = vi.formatted_text.as_deref().unwrap_or(vi.label.as_str());
if vs.multi_enabled {
ListItem::new(format!("{marker}{text}")).style(style)
} else {
ListItem::new(text).style(style)
}
}
})
.collect();
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
@@ -215,6 +534,7 @@ fn map_key_event(
match mode {
Mode::Insert => map_insert_mode(key, filter_text),
Mode::Normal => map_normal_mode(key, pending),
Mode::Visual => map_visual_mode(key, pending),
}
}
@@ -281,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) => {
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::Esc, _) => Some(Action::Cancel),
(KeyCode::Up, _) => Some(Action::MoveUp(1)),
@@ -289,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)]
mod tests {
use super::*;
@@ -323,16 +684,22 @@ mod tests {
label: "alpha".into(),
formatted_text: None,
index: 0,
selected: false,
column_values: None,
},
VisibleItem {
label: "bravo".into(),
formatted_text: None,
index: 1,
selected: false,
column_values: None,
},
VisibleItem {
label: "charlie".into(),
formatted_text: None,
index: 2,
selected: false,
column_values: None,
},
],
cursor: 0,
@@ -340,6 +707,11 @@ mod tests {
total_items: 5,
total_filtered: 3,
mode: Mode::Insert,
selection_count: 0,
multi_enabled: false,
streaming: false,
columns: None,
generation: 1,
}
}
@@ -694,7 +1066,7 @@ mod tests {
let mut terminal = Terminal::new(backend).ok().expect("test terminal");
terminal
.draw(|frame| {
render_menu(frame, vs, filter);
render_menu(frame, vs, filter, None);
})
.ok()
.expect("draw");
@@ -811,6 +1183,11 @@ mod tests {
total_items: 0,
total_filtered: 0,
mode: Mode::Insert,
selection_count: 0,
multi_enabled: false,
streaming: false,
columns: None,
generation: 1,
};
let backend = render_to_backend(30, 4, &vs, "");
let prompt = line_text(&backend, 0);
@@ -818,6 +1195,29 @@ mod tests {
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]
fn narrow_viewport_truncates() {
let vs = sample_view_state();
@@ -843,7 +1243,7 @@ mod tests {
let mut terminal = Terminal::new(backend).ok().expect("test terminal");
terminal
.draw(|frame| {
render_menu(frame, &vs, "hi");
render_menu(frame, &vs, "hi", None);
})
.ok()
.expect("draw");
@@ -861,7 +1261,7 @@ mod tests {
let mut terminal = Terminal::new(backend).ok().expect("test terminal");
terminal
.draw(|frame| {
render_menu(frame, &vs, "");
render_menu(frame, &vs, "", None);
})
.ok()
.expect("draw");
@@ -883,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]
fn pending_cleared_on_mode_key() {
let mut ft = String::new();

View File

@@ -15,8 +15,10 @@ path = "src/main.rs"
[dependencies]
pikl-core = { path = "../pikl-core" }
pikl-tui = { path = "../pikl-tui" }
pikl-gui = { path = "../pikl-gui" }
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"
tracing = "0.1"
tracing-subscriber = "0.3"

View File

@@ -11,10 +11,8 @@ use tokio::process::Command;
use tokio::sync::mpsc;
use pikl_core::error::PiklError;
use pikl_core::event::Action;
use pikl_core::hook::{
parse_hook_response, HookEvent, HookEventKind, HookHandler, HookResponse,
};
use pikl_core::event::{Action, ModifiedAction};
use pikl_core::hook::{HookEvent, HookEventKind, HookHandler, parse_hook_response};
/// A persistent handler hook process. Spawns a child process,
/// writes events to its stdin as JSON lines, reads responses
@@ -27,10 +25,7 @@ pub struct ShellHandlerHook {
impl ShellHandlerHook {
/// Build from CLI flags. Returns None if no handler hooks are configured.
pub fn from_cli(
cli: &crate::Cli,
action_tx: mpsc::Sender<Action>,
) -> Option<Self> {
pub fn from_cli(cli: &crate::Cli, action_tx: mpsc::Sender<ModifiedAction>) -> Option<Self> {
let mut handlers: Vec<(HookEventKind, &str)> = Vec::new();
if let Some(ref cmd) = cli.on_open {
@@ -77,13 +72,15 @@ impl ShellHandlerHook {
}
impl HookHandler for ShellHandlerHook {
fn handle(&self, event: HookEvent) -> Result<Vec<HookResponse>, PiklError> {
fn handle(&self, event: HookEvent) -> Result<(), PiklError> {
let kind = event.kind();
if let Some(tx) = self.event_txs.get(&kind) {
// 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(vec![])
}
Ok(())
}
}
@@ -94,7 +91,7 @@ impl HookHandler for ShellHandlerHook {
async fn run_handler_process(
command: &str,
mut event_rx: mpsc::Receiver<HookEvent>,
action_tx: mpsc::Sender<Action>,
action_tx: mpsc::Sender<ModifiedAction>,
) -> Result<(), PiklError> {
let mut child = Command::new("sh")
.arg("-c")
@@ -123,7 +120,7 @@ async fn run_handler_process(
let mut lines = stdout_reader.lines();
while let Ok(Some(line)) = lines.next_line().await {
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() {
break;
}
@@ -133,19 +130,17 @@ async fn run_handler_process(
// Writer loop: reads events from channel, writes to stdin
while let Some(event) = event_rx.recv().await {
let json = serde_json::to_string(&event).unwrap_or_default();
if stdin_writer
.write_all(json.as_bytes())
.await
.is_err()
{
let json = match serde_json::to_string(&event) {
Ok(j) => j,
Err(e) => {
tracing::warn!(error = %e, "failed to serialize hook event, skipping");
continue;
}
};
if stdin_writer.write_all(json.as_bytes()).await.is_err() {
break;
}
if stdin_writer
.write_all(b"\n")
.await
.is_err()
{
if stdin_writer.write_all(b"\n").await.is_err() {
break;
}
if stdin_writer.flush().await.is_err() {
@@ -156,15 +151,39 @@ async fn run_handler_process(
// Close stdin to signal the child
drop(stdin_writer);
// Wait briefly for the reader to finish
let _ = tokio::time::timeout(
std::time::Duration::from_secs(2),
reader_handle,
)
.await;
// Wait up to 1s for child to exit after EOF
let exited = tokio::time::timeout(std::time::Duration::from_secs(1), child.wait())
.await
.is_ok();
// Kill child if still running
if !exited {
// Send SIGTERM on Unix, then wait another second
#[cfg(unix)]
{
if let Some(pid) = child.id() {
// SAFETY: pid is valid (child is still running), SIGTERM is
// a standard signal. kill() returns 0 on success or -1 on
// error, which we ignore (child may have exited between the
// check and the signal).
unsafe {
libc::kill(pid as libc::pid_t, libc::SIGTERM);
}
}
let termed = tokio::time::timeout(std::time::Duration::from_secs(1), child.wait())
.await
.is_ok();
if !termed {
let _ = child.kill().await;
}
}
#[cfg(not(unix))]
{
let _ = child.kill().await;
}
}
// Wait for the reader to finish
let _ = tokio::time::timeout(std::time::Duration::from_secs(2), reader_handle).await;
Ok(())
}

View File

@@ -9,7 +9,7 @@ use tokio::io::AsyncWriteExt;
use tokio::process::Command;
use pikl_core::error::PiklError;
use pikl_core::hook::{HookEvent, HookEventKind, HookHandler, HookResponse};
use pikl_core::hook::{HookEvent, HookEventKind, HookHandler};
/// Duplicate stderr as a [`Stdio`] handle for use as a
/// child process's stdout. Keeps hook output on stderr
@@ -18,8 +18,12 @@ fn stderr_as_stdio() -> std::process::Stdio {
#[cfg(unix)]
{
use std::os::unix::io::FromRawFd;
// SAFETY: STDERR_FILENO is a valid open fd in any running process.
// dup() returns a new fd or -1, which we check below.
let fd = unsafe { libc::dup(libc::STDERR_FILENO) };
if fd >= 0 {
// SAFETY: fd is valid (checked >= 0 above) and we take exclusive
// ownership here (no aliasing). The File will close it on drop.
return unsafe { std::process::Stdio::from(std::fs::File::from_raw_fd(fd)) };
}
}
@@ -112,20 +116,19 @@ impl ShellExecHandler {
}
impl HookHandler for ShellExecHandler {
fn handle(&self, event: HookEvent) -> Result<Vec<HookResponse>, PiklError> {
fn handle(&self, event: HookEvent) -> Result<(), PiklError> {
let kind = event.kind();
if let Some(cmd) = self.commands.get(&kind) {
let cmd = cmd.clone();
// Serialize event as JSON for the hook's stdin
let event_json =
serde_json::to_value(&event).unwrap_or(serde_json::Value::Null);
let event_json = serde_json::to_value(&event).unwrap_or(serde_json::Value::Null);
tokio::spawn(async move {
if let Err(e) = run_hook(&cmd, &event_json).await {
tracing::warn!(error = %e, command = %cmd, "exec hook failed");
}
});
}
Ok(vec![])
Ok(())
}
}

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 hook;
mod ipc;
mod session;
use std::io::{BufReader, IsTerminal, Write};
use std::sync::Arc;
@@ -7,12 +9,14 @@ use std::time::Duration;
use clap::Parser;
use pikl_core::column::ColumnConfig;
use pikl_core::csv_input::{self, InputFormat};
use pikl_core::debounce::{DebounceMode, DebouncedDispatcher};
use pikl_core::error::PiklError;
use pikl_core::event::{Action, MenuResult, Mode};
use pikl_core::event::{Action, MenuResult, ModifiedAction, Mode};
use pikl_core::format::FormatTemplate;
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::json_menu::JsonMenu;
use pikl_core::menu::MenuRunner;
@@ -23,6 +27,39 @@ use serde_json::Value;
use handler::ShellHandlerHook;
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)]
#[command(
name = "pikl",
@@ -119,11 +156,41 @@ struct Cli {
/// Wrap output in structured JSON with action metadata
#[arg(long)]
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() {
// Initialize tracing from RUST_LOG env var
tracing_subscriber::fmt::init();
tracing_subscriber::fmt()
.with_writer(std::io::stderr)
.init();
let cli = Cli::parse();
@@ -171,12 +238,52 @@ fn main() {
std::process::exit(2);
}
let input_format = match InputFormat::from_str_opt(&cli.input_format) {
Some(f) => f,
None => {
let _ = writeln!(
std::io::stderr().lock(),
"pikl: unknown input format '{}', expected auto, csv, or tsv",
cli.input_format
);
std::process::exit(2);
}
};
// Detect streaming mode: piped stdin, no action-fd, not CSV/TSV.
let streaming = script.is_none()
&& !std::io::stdin().is_terminal()
&& !matches!(input_format, InputFormat::Csv | InputFormat::Tsv);
// 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 = match read_stdin_with_timeout(timeout, &cli.label_key) {
Ok(items) => items,
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);
@@ -190,6 +297,15 @@ fn main() {
);
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
let rt = tokio::runtime::Runtime::new().unwrap_or_else(|e| {
@@ -200,8 +316,23 @@ fn main() {
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.
// Only needed for TUI mode (GUI doesn't read from the terminal).
if script.is_none()
&& frontend_mode == FrontendMode::Tui
&& let Err(e) = reopen_stdin_from_tty()
{
let _ = writeln!(std::io::stderr().lock(), "pikl: {e}");
@@ -212,28 +343,74 @@ fn main() {
let start_mode = match cli.start_mode.as_str() {
"insert" => Mode::Insert,
"normal" => Mode::Normal,
"visual" => Mode::Visual,
other => {
let _ = writeln!(
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);
}
};
// 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
#[cfg(unix)]
let saved_fd = saved_stdin_fd;
#[cfg(not(unix))]
let saved_fd: Option<i32> = None;
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 {
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
handle_result(result, &cli);
}
/// Build a JsonMenu with optional filter_fields and format template.
fn build_menu(items: Vec<Item>, cli: &Cli) -> JsonMenu {
/// Build a JsonMenu with optional filter_fields, format template,
/// 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());
if let Some(ref fields) = cli.filter_fields {
menu.set_filter_fields(fields.clone());
@@ -241,13 +418,16 @@ fn build_menu(items: Vec<Item>, cli: &Cli) -> JsonMenu {
if let Some(ref template) = cli.format {
menu.set_format_template(FormatTemplate::parse(template));
}
if let Some(config) = column_config {
menu.set_column_config(config);
}
menu
}
/// Build the composite hook handler from CLI flags, if any hooks are specified.
fn build_hook_handler(
cli: &Cli,
action_tx: &tokio::sync::mpsc::Sender<Action>,
action_tx: &tokio::sync::mpsc::Sender<ModifiedAction>,
) -> Option<(Arc<dyn HookHandler>, DebouncedDispatcher)> {
let exec_handler = ShellExecHandler::from_cli(cli);
let handler_hook = ShellHandlerHook::from_cli(cli, action_tx.clone());
@@ -307,17 +487,15 @@ struct CompositeHookHandler {
}
impl HookHandler for CompositeHookHandler {
fn handle(
&self,
event: pikl_core::hook::HookEvent,
) -> Result<Vec<pikl_core::hook::HookResponse>, PiklError> {
// Both fire. Exec is fire-and-forget, handler may
// send responses through action_tx.
fn handle(&self, event: pikl_core::hook::HookEvent) -> Result<(), PiklError> {
// Exec hooks are fire-and-forget: errors are logged
// internally by ShellExecHandler, not propagated here.
// Handler hooks are bidirectional, so errors propagate.
let _ = self.exec.handle(event.clone());
if let Some(ref h) = self.handler {
h.handle(event)?;
}
Ok(vec![])
Ok(())
}
}
@@ -328,9 +506,12 @@ async fn run_headless(
cli: &Cli,
script: Vec<ScriptAction>,
start_mode: Mode,
column_config: Option<ColumnConfig>,
) -> 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_multi(cli.multi);
menu.set_selection_order(cli.selection_order);
if let Some((_handler, dispatcher)) = build_hook_handler(cli, &action_tx) {
menu.set_dispatcher(dispatcher);
@@ -339,7 +520,9 @@ async fn run_headless(
let event_rx = menu.subscribe();
// 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());
@@ -352,7 +535,7 @@ async fn run_headless(
match show_action {
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
.await
.map_err(|e| PiklError::Io(std::io::Error::other(e.to_string())))??;
@@ -375,9 +558,17 @@ async fn run_interactive(
items: Vec<Item>,
cli: &Cli,
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> {
let (mut menu, action_tx) = MenuRunner::new(build_menu(items, cli));
let (mut menu, action_tx) = MenuRunner::new(build_menu(items, cli, column_config));
menu.set_initial_mode(start_mode);
menu.set_multi(cli.multi);
menu.set_selection_order(cli.selection_order);
menu.set_streaming(streaming);
if let Some((_handler, dispatcher)) = build_hook_handler(cli, &action_tx) {
menu.set_dispatcher(dispatcher);
@@ -385,21 +576,249 @@ async fn run_interactive(
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.
let signal_tx = action_tx.clone();
let signal_frontend = frontend_mode;
tokio::spawn(async move {
if let Ok(()) = tokio::signal::ctrl_c().await {
pikl_tui::restore_terminal();
let _ = signal_tx.send(Action::Cancel).await;
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;
}
}
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 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));
}
});
}
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
@@ -407,25 +826,16 @@ async fn run_interactive(
fn handle_result(result: Result<MenuResult, PiklError>, cli: &Cli) {
let mut out = std::io::stdout().lock();
match result {
Ok(MenuResult::Selected { value, index }) => {
if cli.structured {
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 }) => {
Ok(MenuResult::Selected {
items, modifiers, ..
}) => {
if cli.structured {
for (value, index) in items {
let output = OutputItem {
value,
action: OutputAction::Quicklist,
action: OutputAction::Select,
index,
modifiers,
};
let _ = write_output_json(&mut out, &output);
}
@@ -435,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 {
let output = OutputItem {
value: Value::Null,
action: OutputAction::Cancel,
index: 0,
modifiers,
};
let _ = write_output_json(&mut out, &output);
}
@@ -455,7 +885,7 @@ fn handle_result(result: Result<MenuResult, PiklError>, cli: &Cli) {
/// 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> {
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}")
}
@@ -465,7 +895,8 @@ fn write_plain_value(writer: &mut impl Write, value: &Value) -> Result<(), std::
match value {
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}")
}
}
@@ -474,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,
/// spawn a thread and bail if it doesn't finish in time.
/// A timeout of 0 means no timeout (blocking read).
fn read_stdin_with_timeout(timeout_secs: u64, label_key: &str) -> Result<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 {
return read_items_sync(std::io::stdin().lock(), label_key);
return read_stdin_inner(std::io::stdin().lock(), label_key, format);
}
let label_key = label_key.to_string();
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let _ = tx.send(read_items_sync(std::io::stdin().lock(), &label_key));
let _ = tx.send(read_stdin_inner(
std::io::stdin().lock(),
&label_key,
format,
));
});
match rx.recv_timeout(Duration::from_secs(timeout_secs)) {
@@ -501,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
/// input after stdin was consumed for piped items. Flushes
/// 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}"
);
}
// -- 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 {
items: ["error_log", "warning_temp", "info_log"];
@@ -293,4 +331,29 @@ pikl_tests! {
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}
```
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
Input can arrive over time. The list populates progressively
@@ -60,9 +97,23 @@ selection. Useful for live preview pipelines.
### CSV / TSV
`--input-format csv` or `--input-format tsv` parses columnar
input. First row is headers, which become field names. Maps
naturally to table/column display mode.
`--input-format csv` or `--input-format tsv` parses delimited
input. First row is always headers, which become field names.
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
@@ -80,7 +131,13 @@ There are two ways to respond to them: **exec hooks** and
| `on-select` | User confirms (Enter) | Apply the choice |
| `on-cancel` | User cancels (Escape) | Revert preview |
| `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)
@@ -383,16 +440,14 @@ designed.
| `Ctrl+D` / `Ctrl+U` | Half-page down / up |
| `Ctrl+F` / `Ctrl+B` | Full page down / up |
| `/` | Enter filter (insert) mode |
| `n` / `N` | Next / previous filter match |
| `m{a-z}` | Set mark at current position |
| `'{a-z}` | Jump to mark |
| `"a` | Select into register `a` |
| `v` | Toggle multi-select on current item |
| `V` | Visual line select mode |
| `Space` | Toggle select + move down (`--multi`) |
| `V` | Enter visual line mode (`--multi`) |
| `u` | Undo last selection change (`--multi`) |
| `U` | Clear all selections (`--multi`) |
| `Ctrl+R` | Redo selection change (`--multi`) |
| `Enter` | Confirm selection |
| `Escape` | 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
nice-to-have but not essential for the first pass of vim
@@ -400,12 +455,26 @@ navigation.
### Multi-Select
- `Space` or `v` toggles selection on current item
- `V` enters visual line mode, moving up/down selects ranges
- All selected items are emitted on confirm
- `"a` through `"z`: select into named registers (like vim
yanking)
- Registers can be recalled or included in output
Requires `--multi` flag. Without it, toggle/select actions
are no-ops and confirm always returns a single item.
- `Space` or `Tab` toggles selection on the cursor item and
moves down. `Shift+Tab` toggles and moves up.
- `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
@@ -429,11 +498,26 @@ pikl --format '{icon} {label} <dim>{sublabel}</dim>'
### Table / Column Mode
```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.
CSV/TSV input maps naturally here.
`--columns` takes a comma-separated list of field paths.
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
@@ -478,13 +562,19 @@ A few built-in themes ship with pikl. `--theme monokai` or
## Sessions
`--session name` persists the menu's state (filter text,
scroll position, selected items, marks, registers) across
invocations. State lives in
`~/.local/state/pikl/sessions/`.
`--session name` enables filter history across invocations.
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
dynamic names:
@@ -493,8 +583,8 @@ pikl --session "walls-$(hostname)"
pikl --session "logs-$USER"
```
Session history is a log file alongside the state. Other
tools can tail it for observability.
Sessions are lightweight by design. No item persistence,
no scroll position, no selections. Just filter recall.
## Watched Sources
@@ -511,18 +601,96 @@ named pipe.
## IPC
While running, pikl-menu listens on a Unix socket
(`/run/user/$UID/pikl-$PID.sock`). External tools can:
Opt-in external control via Unix socket. Enabled with
`--ipc`. Off by default: pikl is ephemeral and shouldn't
have side effects unless asked.
- Push new items
- Remove items
- Update item fields
- Set the filter text
- Read current selection
- Close the menu
Socket path:
- Named session: `/run/user/$UID/pikl-{session}.sock`
- No session: `/run/user/$UID/pikl-{pid}.sock`
Protocol is newline-delimited JSON. Simple enough to use
with `socat` or any language's Unix socket support.
The socket path is logged via tracing on startup and
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
@@ -603,8 +771,8 @@ sequentially regardless of where they came from.
| TUI | Terminal keypresses (crossterm) | Yes | 1 |
| GUI | GUI events (iced) | Yes | 8 |
| Action-fd | Pre-validated script from a file descriptor | No (unless `show-ui`) | 1.5 |
| IPC | Unix socket, JSON protocol | Yes (bidirectional) | 6 |
| Lua | LuaJIT script via mlua | Yes (stateful, conditional) | Post-6 |
| IPC | Unix socket, newline-delimited JSON | Yes (bidirectional) | 6 |
| Lua | Embedded LuaJIT via mlua (in-process) | Yes (stateful, conditional) | Post-6 |
This framing means new interaction modes don't require core
changes: they're just new frontends that push actions.
@@ -645,6 +813,18 @@ Available actions:
| `show-tui` | (none) | Hand off to TUI 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
(Wayland: GUI, X11: GUI, otherwise: TUI). `show-tui` and
`show-gui` are explicit overrides. All three must be the
@@ -745,8 +925,14 @@ find-walls --json | pikl \
--on-cancel 'restore-wallpaper' \
--on-hover-debounce 100
# Process killer with table view
ps aux --no-headers | pikl --input-format tsv --columns 1,10,2 --multi
# CSV table view
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
pikl --manifest ~/.config/pikl/filebrowser.toml --session filebrowser
@@ -774,12 +960,15 @@ complexity:
extend pikl without touching Rust.
4. **IPC** (phase 6): bidirectional JSON over Unix socket.
External tools can read state and send actions while pikl
runs interactively. Good for tool integration.
5. **Lua** (post phase 6): embedded LuaJIT via mlua. Full
stateful scripting: subscribe to events, branch on state,
loops, the works. The Lua runtime is just another
frontend pushing Actions and subscribing to MenuEvents.
For anything complex enough to need a real language.
runs interactively. Language-agnostic: anything that can
write JSON to a socket works.
5. **Lua** (post phase 6): embedded LuaJIT via mlua.
In-process scripting for complex hooks and behaviour.
Direct access to the action/event system, no socket
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
from "I need conditionals" to "use Lua" is intentional:
@@ -801,16 +990,10 @@ with the full writeup.
## Open Questions
- Should marks/registers persist across sessions or only
within a session?
- Accessibility: screen reader support for TUI mode?
- Should `--watch` support inotify on Linux and FSEvents on
macOS, or use `notify` crate to abstract?
- Maximum practical item count before we need virtual
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?
(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
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:**
- Space to toggle select, V for visual line mode
- Multi-select output (multiple JSON lines)
- Named registers (`"a` through `"z`)
- Marks (`m{a-z}`, `'{a-z}`)
- `--multi` flag to enable multi-select mode
- `--multi` flag to gate all multi-select behaviour
- `Space`/`Tab` toggle-select, `Shift+Tab` toggle-up
- `V` visual line mode (force-select ranges)
- `u`/`Ctrl+R` undo/redo selection changes, `U` clear all
- 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
in registers, and get them all in the output.
**Done when:** `seq 1 20 | pikl --multi`, toggle a few items
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:**
- `--columns` flag for table layout
- Auto-alignment
- `--input-format csv` and `--input-format tsv` for delimited input
- `--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
- `--input-format csv` and `--input-format tsv`
- Column-specific filtering
- Horizontal scrolling for wide tables
**Done when:** `ps aux | pikl --input-format tsv --columns 1,10,2`
renders a clean table.
**Done when:** `echo "name,age\nalice,30\nbob,25" | pikl --input-format csv`
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:**
- `--session name` for state persistence
- Session state: filter, scroll position, selections,
marks, registers
- Session history log file
- Unix socket IPC while running
- IPC commands: push/remove/update items, set filter,
read selection, close
- Protocol: newline-delimited JSON
- `--ipc` flag to enable the socket listener (off by
default, pikl is ephemeral and shouldn't have side
effects unless asked)
- Socket path: `/run/user/$UID/pikl-{session}.sock`
(named session) or `/run/user/$UID/pikl-{pid}.sock`
(no session name)
- Socket path logged via tracing on startup
- 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
your state intact. External scripts can push items into a
running pikl instance.
**Not in scope:**
- `get_items` (full item list read, potentially large)
- Auto-enable with `--session`
- Auth beyond Unix socket permissions
## Phase 7: Streaming & Watched Sources
Live, dynamic menus.
### Session Filter History
**Deliverables:**
- Async/streaming stdin (items arrive over time, list
updates progressively)
- Streaming output (on-hover events emitted to stdout)
- `--watch path` for file/directory watching
- `--watch-extensions` filter
- notify crate integration (inotify on Linux, FSEvents
on macOS)
- `--session name` flag
- On any confirm (single, multi, quicklist), append
current filter text to
`~/.local/state/pikl/sessions/{name}.history`
- Skip empty filters and consecutive duplicates
- Load history on startup if session file exists
- 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
progressively. A watched directory updates the list live.
**Done when:** An external script can connect to a running
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)
@@ -275,6 +343,40 @@ navigate directories without spawning new processes.
query, or the viewport to do a lookup after each filter
pass.
- **Confirm-with-arguments (Shift+Enter).** Select an item
and also pass free-text arguments alongside it. Primary
use case: app launcher where you select `ls` and want to
pass `-la` to it. The output would include both the
selected item and the user-supplied arguments.
**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
Shift+Enter? Or does Shift+Enter open a second input
field for args after selection? The filter-as-args
approach is simpler but conflates filtering and
argument input. A two-step flow (select, then type
args) is cleaner but adds a mode.
- Output format: separate field in the JSON output
(`"args": "-la"`)? Second line on stdout? Appended to
the label? Needs to be unambiguous for scripts.
- Should regular Enter with a non-empty filter that
matches exactly one item just confirm that item (current
behaviour), or should it also treat any "extra" text
as args? Probably not, too implicit.
This is a core feature (new keybind, new output field),
not just a launcher script concern. Fits naturally after
phase 4 (multi-select) since it's another selection
mode variant. The launcher script would assemble
`{selected} {args}` for execution.
## Future Ideas (Unscheduled)
These are things we've talked about or thought of. No
@@ -306,3 +408,12 @@ commitment, no order.
(optionally into a tmux session). Needs GUI frontend
(phase 8) and frecency sorting.
See `docs/use-cases/app-launcher.md`.
Setup guides: `docs/guides/app-launcher.md`.
- App description indexing: a tool or subcommand that
builds a local cache of binary descriptions from man
pages (`whatis`), .desktop file Comment fields, and
macOS Info.plist data. Solves the "whatis is too slow
to run per-keystroke" problem for the app launcher.
Could be a `pikl index` subcommand or a standalone
helper script.
- Need vars in hooks like `--on-hover 'echo $out'`.

286
docs/guides/app-launcher.md Normal file
View File

@@ -0,0 +1,286 @@
# App Launcher Setup
Use pikl as a keyboard-driven application launcher. Bind
a hotkey, type a few characters, hit Enter, and your app
launches. This guide covers terminal, Hyprland, i3, and
macOS setups.
For the design rationale and feature roadmap, see
[the app launcher use case](../use-cases/app-launcher.md).
## Quick start: terminal
The simplest version. No GUI, no hotkeys, just an alias:
```sh
# ~/.bashrc or ~/.zshrc
alias launch='compgen -c | sort -u | pikl | xargs -I{} sh -c "{} &"'
```
Run `launch`, type to filter, Enter to run the selection
in the background. That's it.
### With descriptions (experimental)
You can pull one-line descriptions from man pages using
`whatis`. This is noticeably slow on systems with
thousands of binaries (it shells out per binary), so treat
it as a nice-to-have rather than the default:
```sh
launch-rich() {
compgen -c | sort -u | while IFS= read -r cmd; do
desc=$(whatis "$cmd" 2>/dev/null | head -1 | sed 's/.*- //')
printf '{"label":"%s","sublabel":"%s"}\n' "$cmd" "$desc"
done | pikl --format '{label} <dim>{sublabel}</dim>' \
| jq -r '.label' \
| xargs -I{} sh -c '{} &'
}
```
This works, but it's slow. Caching the output of the
`whatis` loop to a file and refreshing it periodically
would make it usable day-to-day. A built-in indexing
solution is on the roadmap but not built yet.
## Hyprland
Hyprland is a first-class target. pikl uses
`iced_layershell` to render as a Wayland layer-shell
overlay, so it floats above your desktop like rofi does.
### The launcher script
Save this somewhere in your PATH (e.g.
`~/.local/bin/pikl-launch`):
```sh
#!/bin/sh
# pikl-launch: open pikl as a GUI app launcher
compgen -c | sort -u \
| pikl --mode gui \
| xargs -I{} sh -c '{} &'
```
```sh
chmod +x ~/.local/bin/pikl-launch
```
### Keybinding
Add to `~/.config/hypr/hyprland.conf`:
```
bind = SUPER, SPACE, exec, pikl-launch
```
Reload with `hyprctl reload` or restart Hyprland.
### With .desktop files (future)
When pikl gains .desktop file parsing (or a helper
script emits structured JSON from XDG desktop entries),
you'll get proper app names, descriptions, and categories
instead of raw binary names. The keybinding and workflow
stay the same, only the input to pikl changes.
## i3
i3 runs on X11, so pikl opens as an override-redirect
window rather than a layer-shell overlay. Same launcher
script, different keybinding syntax.
### The launcher script
Same `pikl-launch` script as Hyprland above. pikl
auto-detects X11 vs Wayland, so no changes needed.
### Keybinding
Add to `~/.config/i3/config`:
```
bindsym $mod+space exec --no-startup-id pikl-launch
```
Reload with `$mod+Shift+r`.
### Notes
- `--no-startup-id` prevents the i3 startup notification
cursor from spinning while pikl is open.
- If pikl doesn't grab focus automatically, you may need
to add an i3 rule:
```
for_window [class="pikl"] focus
```
## macOS with Raycast
Raycast is the best way to bind a global hotkey to pikl
on macOS. You create a script command that Raycast can
trigger from its search bar or a direct hotkey.
### Prerequisites
- [Raycast](https://raycast.com) installed
- pikl installed (`cargo install pikl`)
- pikl in your PATH (cargo's bin directory is usually
`~/.cargo/bin`, make sure it's in your shell PATH)
### Create the script command
Raycast script commands are shell scripts with a special
header. Create this file in your Raycast script commands
directory (usually `~/.config/raycast/scripts/` or
wherever you've configured it):
**`pikl-launch.sh`:**
```bash
#!/bin/bash
# Required parameters:
# @raycast.schemaVersion 1
# @raycast.title App Launcher (pikl)
# @raycast.mode silent
# @raycast.packageName pikl
# Optional parameters:
# @raycast.icon :rocket:
# Scan /Applications for .app bundles, pipe to pikl, open the result
find /Applications ~/Applications -maxdepth 2 -name '*.app' 2>/dev/null \
| sed 's|.*/||; s|\.app$||' \
| sort -u \
| pikl --mode gui \
| xargs -I{} open -a "{}"
```
```sh
chmod +x pikl-launch.sh
```
The `silent` mode tells Raycast not to show any output
window. pikl handles its own GUI.
### Assign a hotkey
1. Open Raycast preferences (Cmd+,)
2. Go to Extensions
3. Find "App Launcher (pikl)" under Script Commands
4. Click the hotkey field and press your preferred combo
(e.g. Ctrl+Space, Opt+Space, etc.)
### With structured JSON input
For a richer experience with app metadata, you can parse
the `Info.plist` files inside .app bundles:
```bash
#!/bin/bash
# Required parameters:
# @raycast.schemaVersion 1
# @raycast.title App Launcher (pikl)
# @raycast.mode silent
# @raycast.packageName pikl
for app in /Applications/*.app ~/Applications/*.app; do
[ -d "$app" ] || continue
name=$(basename "$app" .app)
# Pull the bundle identifier for metadata
bundle_id=$(/usr/libexec/PlistBuddy -c "Print :CFBundleIdentifier" \
"$app/Contents/Info.plist" 2>/dev/null)
printf '{"label":"%s","meta":{"bundle":"%s","path":"%s"}}\n' \
"$name" "$bundle_id" "$app"
done \
| pikl --mode gui \
| jq -r '.meta.path' \
| xargs -I{} open "{}"
```
This version gives pikl structured data to work with.
When pikl gains frecency sorting, your most-launched apps
will float to the top automatically.
### Alternative: skhd
If you don't use Raycast, [skhd](https://github.com/koekeishiya/skhd)
is a standalone hotkey daemon:
```
# ~/.config/skhd/skhdrc
ctrl + space : pikl-launch-mac.sh
```
Where `pikl-launch-mac.sh` is the same script as above,
minus the Raycast header comments.
## Customizing the launcher
These tips apply to all platforms.
### Filter fields
If your input includes structured JSON with metadata,
tell pikl which fields to search:
```sh
# Search both label and sublabel when filtering
pikl --filter-fields label,sublabel
```
### Hooks
Run something when the launcher opens or when an item
is selected:
```sh
pikl --mode gui \
--on-select-exec 'jq -r .label >> ~/.local/state/pikl-launch-history'
```
This logs every launch to a history file, which could
feed into frecency sorting later.
### Starting in normal mode
If you prefer to land in vim normal mode (navigate first,
then `/` to filter):
```sh
pikl --mode gui --start-mode normal
```
## What's not built yet
A few things mentioned in this guide depend on features
that are still in development:
- **GUI frontend** (phase 8): the `--mode gui` flag and
layer-shell/X11 rendering. Until this ships, you can use
the TUI versions in a drop-down terminal (like tdrop,
kitty's `--single-instance`, or a quake-mode terminal).
- **Frecency sorting:** tracking launch frequency and
boosting common picks. On the roadmap.
- **.desktop file parsing:** structured input from XDG
desktop entries with proper names, icons, and categories.
On the roadmap.
- **Description caching/indexing:** a way to build and
maintain a local index of binary descriptions so the
launcher can show what each app does without the slow
`whatis` loop. On the roadmap.
## GNOME, KDE, and other desktops
These aren't supported as app launcher environments right
now. Hyprland, i3, and macOS are the environments the dev
team uses daily, so that's where the effort goes. The
main blocker for GNOME on Wayland is the lack of
layer-shell support, which means pikl can't render as an
overlay the same way it does on wlroots-based compositors.
If you use one of these environments and want to help,
PRs and discussion are welcome.

60
docs/guides/install.md Normal file
View File

@@ -0,0 +1,60 @@
# Installing pikl
## From crates.io (recommended)
```sh
cargo install pikl
```
This builds the unified `pikl` binary with both TUI and
GUI frontends. You'll need a working Rust toolchain. If
you don't have one, [rustup](https://rustup.rs) is the
way to go.
### TUI only
If you only want the terminal interface and don't want to
pull in GUI dependencies:
```sh
cargo install pikl --no-default-features --features tui
```
## From source
```sh
git clone https://github.com/maplecool/pikl-menu.git
cd pikl-menu
cargo install --path .
```
This builds and installs the `pikl` binary into your
cargo bin directory (usually `~/.cargo/bin/`).
## Package managers
We'd like pikl to be available in package managers like
the AUR and Homebrew, but honestly haven't set that up
before and aren't sure when we'll get to it. TBD.
If you package pikl for a distro or package manager, open
an issue and we'll link it here.
## Verify it works
```sh
echo -e "hello\nworld\ngoodbye" | pikl
```
You should see a filterable list in insert mode. Type to
filter, use arrow keys to navigate, Enter to select,
Escape to quit. The selected item prints to stdout.
pikl starts in insert mode by default (type to filter
immediately). Press Ctrl+N to switch to normal mode for
vim-style navigation (j/k, gg, G, Ctrl+D/U). Ctrl+E
switches back to insert mode. You can also start in
normal mode with `--start-mode normal`.
Note: Ctrl+I is not the same as Tab. pikl treats these
as distinct inputs.

View File

@@ -126,7 +126,7 @@ bind = $mod, B, exec, wallpicker copy
| Hook debouncing | 3 | Don't flood hyprctl |
| Fuzzy filtering | 2 | Filter by filename |
| 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 |
## Open Questions

View File

@@ -275,6 +275,152 @@ pipeline_filter_demo() {
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 ─────────────────────────────────────────
scenarios=(
@@ -297,6 +443,20 @@ scenarios=(
"Handler hook (event logging)"
"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)"
)
@@ -319,6 +479,17 @@ run_scenario() {
*"Exec hooks"*) exec_hooks_demo ;;
*"Handler hook (event"*) handler_hook_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 ;;
"---")
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