Compare commits
13 Commits
f6f1efdf8e
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
f81c8a20d2
|
|||
|
5e076b8682
|
|||
|
47437f536b
|
|||
|
0d241841bc
|
|||
|
e77ecb447e
|
|||
|
3b574e89ab
|
|||
|
aad4c16d6e
|
|||
|
cd1927fa9f
|
|||
|
8077469d19
|
|||
|
eab872d2ed
|
|||
|
a2e469456a
|
|||
|
18c66ca7a9
|
|||
|
2e29dfc103
|
3464
Cargo.lock
generated
3464
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@ resolver = "3"
|
|||||||
members = [
|
members = [
|
||||||
"crates/pikl-core",
|
"crates/pikl-core",
|
||||||
"crates/pikl-tui",
|
"crates/pikl-tui",
|
||||||
|
"crates/pikl-gui",
|
||||||
"crates/pikl",
|
"crates/pikl",
|
||||||
"crates/pikl-test-macros",
|
"crates/pikl-test-macros",
|
||||||
]
|
]
|
||||||
|
|||||||
27
README.md
27
README.md
@@ -131,7 +131,7 @@ pins the version and pulls in rust-analyzer + clippy.
|
|||||||
|
|
||||||
## Current Status
|
## Current Status
|
||||||
|
|
||||||
Phases 1 through 3 are complete. pikl works as a TUI menu with:
|
Phases 1 through 6 are complete. pikl works as a TUI menu with:
|
||||||
- Fuzzy, exact, regex, and inverse filtering with `|` pipeline chaining
|
- Fuzzy, exact, regex, and inverse filtering with `|` pipeline chaining
|
||||||
- Vim navigation (j/k, gg/G, Ctrl+D/U, Ctrl+F/B, modes)
|
- Vim navigation (j/k, gg/G, Ctrl+D/U, Ctrl+F/B, modes)
|
||||||
- Structured JSON I/O with sublabel, icon, group, and arbitrary metadata
|
- Structured JSON I/O with sublabel, icon, group, and arbitrary metadata
|
||||||
@@ -140,6 +140,31 @@ Phases 1 through 3 are complete. pikl works as a TUI menu with:
|
|||||||
- Format templates (`--format '{label} - {sublabel}'`)
|
- Format templates (`--format '{label} - {sublabel}'`)
|
||||||
- Field-scoped filtering (`--filter-fields`, `meta.res:3840` syntax)
|
- Field-scoped filtering (`--filter-fields`, `meta.res:3840` syntax)
|
||||||
- Headless scripting via `--action-fd`
|
- Headless scripting via `--action-fd`
|
||||||
|
- Multi-select with undo/redo and visual line mode
|
||||||
|
- Table mode for CSV/TSV input with auto-generated or custom columns
|
||||||
|
- IPC control via Unix socket (`--ipc`, `--session`)
|
||||||
|
- Session filter history with Ctrl+P/Ctrl+N recall
|
||||||
|
|
||||||
|
### IPC example
|
||||||
|
|
||||||
|
Run pikl with IPC in one terminal, control it from another:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# terminal 1: start pikl with IPC enabled
|
||||||
|
echo -e "alpha\nbeta\ngamma\ndelta" | pikl --ipc --session demo
|
||||||
|
|
||||||
|
# terminal 2: query state over the socket
|
||||||
|
echo '{"action":"get_state","id":"1"}' \
|
||||||
|
| socat - UNIX-CONNECT:/run/user/$(id -u)/pikl-demo.sock
|
||||||
|
|
||||||
|
# terminal 2: add items dynamically
|
||||||
|
echo '{"action":"add_items","items":["epsilon","zeta"]}' \
|
||||||
|
| socat -t 0.1 - UNIX-CONNECT:/run/user/$(id -u)/pikl-demo.sock
|
||||||
|
```
|
||||||
|
|
||||||
|
There's an interactive remote control demo at
|
||||||
|
[examples/ipc-remote.sh](examples/ipc-remote.sh)
|
||||||
|
(see [examples/ipc-remote.md](examples/ipc-remote.md) for the walkthrough).
|
||||||
|
|
||||||
## Platform Support
|
## Platform Support
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ tokio = { version = "1.50.0", features = ["sync", "io-util", "rt", "time"] }
|
|||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
nucleo-matcher = "0.3.1"
|
nucleo-matcher = "0.3.1"
|
||||||
fancy-regex = "0.14"
|
fancy-regex = "0.14"
|
||||||
|
indexmap = "2"
|
||||||
|
csv = "1"
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tokio = { version = "1.50.0", features = ["sync", "process", "io-util", "rt", "macros", "rt-multi-thread", "test-util"] }
|
tokio = { version = "1.50.0", features = ["sync", "process", "io-util", "rt", "macros", "rt-multi-thread", "test-util"] }
|
||||||
|
|||||||
@@ -20,6 +20,9 @@ pub enum PiklError {
|
|||||||
#[error("channel closed")]
|
#[error("channel closed")]
|
||||||
ChannelClosed,
|
ChannelClosed,
|
||||||
|
|
||||||
|
#[error("csv parse error: {0}")]
|
||||||
|
CsvParse(String),
|
||||||
|
|
||||||
#[error("{0}")]
|
#[error("{0}")]
|
||||||
Script(#[from] crate::script::action_fd::ScriptError),
|
Script(#[from] crate::script::action_fd::ScriptError),
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ pub mod script;
|
|||||||
pub mod error;
|
pub mod error;
|
||||||
|
|
||||||
// Re-export submodules at crate root so the public API stays flat.
|
// Re-export submodules at crate root so the public API stays flat.
|
||||||
|
pub use model::column;
|
||||||
pub use model::event;
|
pub use model::event;
|
||||||
pub use model::item;
|
pub use model::item;
|
||||||
pub use model::output;
|
pub use model::output;
|
||||||
@@ -22,6 +23,7 @@ pub use query::navigation;
|
|||||||
pub use query::pipeline;
|
pub use query::pipeline;
|
||||||
pub use query::regex_filter;
|
pub use query::regex_filter;
|
||||||
pub use query::strategy;
|
pub use query::strategy;
|
||||||
|
pub use runtime::csv_input;
|
||||||
pub use runtime::debounce;
|
pub use runtime::debounce;
|
||||||
pub use runtime::hook;
|
pub use runtime::hook;
|
||||||
pub use runtime::input;
|
pub use runtime::input;
|
||||||
|
|||||||
155
crates/pikl-core/src/model/column.rs
Normal file
155
crates/pikl-core/src/model/column.rs
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
//! Column configuration for table mode. Defines which fields
|
||||||
|
//! to display as columns and how to label them.
|
||||||
|
|
||||||
|
use crate::item::resolve_field_path;
|
||||||
|
use serde_json::Value;
|
||||||
|
|
||||||
|
/// A single column definition: which field to extract and
|
||||||
|
/// what to call it in the header.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ColumnDef {
|
||||||
|
/// Dotted path into the item's JSON value (e.g. "meta.res").
|
||||||
|
pub field: String,
|
||||||
|
/// Display name for the column header. Defaults to the
|
||||||
|
/// field path if no alias is given.
|
||||||
|
pub display_name: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Column layout for table mode. Controls which fields are
|
||||||
|
/// shown and in what order.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ColumnConfig {
|
||||||
|
pub columns: Vec<ColumnDef>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ColumnConfig {
|
||||||
|
/// Parse a column spec string like "label,meta.res:Resolution,meta.size".
|
||||||
|
/// Columns are comma-separated. Each column can have an alias
|
||||||
|
/// after the first colon: "field:Alias".
|
||||||
|
pub fn parse(spec: &str) -> Self {
|
||||||
|
let columns = spec
|
||||||
|
.split(',')
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.map(|part| {
|
||||||
|
let part = part.trim();
|
||||||
|
if let Some((field, alias)) = part.split_once(':') {
|
||||||
|
ColumnDef {
|
||||||
|
field: field.trim().to_string(),
|
||||||
|
display_name: alias.trim().to_string(),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ColumnDef {
|
||||||
|
field: part.to_string(),
|
||||||
|
display_name: part.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Self { columns }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a ColumnConfig from raw header names. Used when
|
||||||
|
/// auto-generating columns from CSV headers.
|
||||||
|
pub fn from_headers(headers: &[String]) -> Self {
|
||||||
|
let columns = headers
|
||||||
|
.iter()
|
||||||
|
.map(|h| ColumnDef {
|
||||||
|
field: h.clone(),
|
||||||
|
display_name: h.clone(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
Self { columns }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve each column's value from an item's JSON value.
|
||||||
|
/// Missing fields produce an empty string.
|
||||||
|
pub fn resolve_values(&self, value: &Value) -> Vec<String> {
|
||||||
|
self.columns
|
||||||
|
.iter()
|
||||||
|
.map(|col| {
|
||||||
|
resolve_field_path(value, &col.field)
|
||||||
|
.map(value_to_cell)
|
||||||
|
.unwrap_or_default()
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Convert a JSON value to a cell string for display.
|
||||||
|
fn value_to_cell(v: &Value) -> String {
|
||||||
|
match v {
|
||||||
|
Value::String(s) => s.clone(),
|
||||||
|
Value::Number(n) => n.to_string(),
|
||||||
|
Value::Bool(b) => b.to_string(),
|
||||||
|
Value::Null => String::new(),
|
||||||
|
other => other.to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_simple_columns() {
|
||||||
|
let config = ColumnConfig::parse("label,age,city");
|
||||||
|
assert_eq!(config.columns.len(), 3);
|
||||||
|
assert_eq!(config.columns[0].field, "label");
|
||||||
|
assert_eq!(config.columns[0].display_name, "label");
|
||||||
|
assert_eq!(config.columns[2].field, "city");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_with_alias() {
|
||||||
|
let config = ColumnConfig::parse("label,meta.res:Resolution");
|
||||||
|
assert_eq!(config.columns.len(), 2);
|
||||||
|
assert_eq!(config.columns[1].field, "meta.res");
|
||||||
|
assert_eq!(config.columns[1].display_name, "Resolution");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_empty_string() {
|
||||||
|
let config = ColumnConfig::parse("");
|
||||||
|
assert!(config.columns.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_trailing_comma() {
|
||||||
|
let config = ColumnConfig::parse("label,age,");
|
||||||
|
assert_eq!(config.columns.len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_flat_values() {
|
||||||
|
let config = ColumnConfig::parse("label,age");
|
||||||
|
let value = json!({"label": "alice", "age": 30});
|
||||||
|
let cells = config.resolve_values(&value);
|
||||||
|
assert_eq!(cells, vec!["alice", "30"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_nested_values() {
|
||||||
|
let config = ColumnConfig::parse("label,meta.res");
|
||||||
|
let value = json!({"label": "test", "meta": {"res": "1080p"}});
|
||||||
|
let cells = config.resolve_values(&value);
|
||||||
|
assert_eq!(cells, vec!["test", "1080p"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn resolve_missing_field() {
|
||||||
|
let config = ColumnConfig::parse("label,nope");
|
||||||
|
let value = json!({"label": "test"});
|
||||||
|
let cells = config.resolve_values(&value);
|
||||||
|
assert_eq!(cells, vec!["test", ""]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn from_headers_builds_identity() {
|
||||||
|
let headers = vec!["name".to_string(), "age".to_string()];
|
||||||
|
let config = ColumnConfig::from_headers(&headers);
|
||||||
|
assert_eq!(config.columns.len(), 2);
|
||||||
|
assert_eq!(config.columns[0].field, "name");
|
||||||
|
assert_eq!(config.columns[0].display_name, "name");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,6 +9,8 @@
|
|||||||
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use serde::ser::SerializeSeq;
|
||||||
|
use serde::Serialize;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
use crate::hook::HookResponse;
|
use crate::hook::HookResponse;
|
||||||
@@ -20,6 +22,7 @@ pub enum Mode {
|
|||||||
#[default]
|
#[default]
|
||||||
Insert,
|
Insert,
|
||||||
Normal,
|
Normal,
|
||||||
|
Visual,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A command the menu should process. Frontends and headless
|
/// A command the menu should process. Frontends and headless
|
||||||
@@ -37,6 +40,12 @@ pub enum Action {
|
|||||||
HalfPageUp(usize),
|
HalfPageUp(usize),
|
||||||
HalfPageDown(usize),
|
HalfPageDown(usize),
|
||||||
SetMode(Mode),
|
SetMode(Mode),
|
||||||
|
ToggleSelect,
|
||||||
|
SelectRange { start: usize, end: usize },
|
||||||
|
ClearSelections,
|
||||||
|
SelectAll,
|
||||||
|
UndoSelection,
|
||||||
|
RedoSelection,
|
||||||
Confirm,
|
Confirm,
|
||||||
Quicklist,
|
Quicklist,
|
||||||
Cancel,
|
Cancel,
|
||||||
@@ -46,6 +55,65 @@ pub enum Action {
|
|||||||
RemoveItems(Vec<usize>),
|
RemoveItems(Vec<usize>),
|
||||||
ProcessHookResponse(HookResponse),
|
ProcessHookResponse(HookResponse),
|
||||||
CloseMenu,
|
CloseMenu,
|
||||||
|
StreamingDone,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Modifier keys held during an action. Serializes as an
|
||||||
|
/// array of strings (e.g. `["shift", "ctrl"]`). Empty
|
||||||
|
/// modifiers serialize as `[]`.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
||||||
|
pub struct Modifiers {
|
||||||
|
pub shift: bool,
|
||||||
|
pub ctrl: bool,
|
||||||
|
pub alt: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Modifiers {
|
||||||
|
/// True when no modifiers are held.
|
||||||
|
pub fn is_empty(&self) -> bool {
|
||||||
|
!self.shift && !self.ctrl && !self.alt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Serialize for Modifiers {
|
||||||
|
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||||
|
let count = self.shift as usize + self.ctrl as usize + self.alt as usize;
|
||||||
|
let mut seq = serializer.serialize_seq(Some(count))?;
|
||||||
|
if self.shift {
|
||||||
|
seq.serialize_element("shift")?;
|
||||||
|
}
|
||||||
|
if self.ctrl {
|
||||||
|
seq.serialize_element("ctrl")?;
|
||||||
|
}
|
||||||
|
if self.alt {
|
||||||
|
seq.serialize_element("alt")?;
|
||||||
|
}
|
||||||
|
seq.end()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An [`Action`] paired with the modifier keys that were
|
||||||
|
/// held when it was triggered. Frontends produce these,
|
||||||
|
/// the menu engine consumes them.
|
||||||
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
|
pub struct ModifiedAction {
|
||||||
|
pub action: Action,
|
||||||
|
pub modifiers: Modifiers,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ModifiedAction {
|
||||||
|
/// Wrap an action with default (empty) modifiers.
|
||||||
|
pub fn new(action: Action) -> Self {
|
||||||
|
Self {
|
||||||
|
action,
|
||||||
|
modifiers: Modifiers::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Wrap an action with specific modifiers.
|
||||||
|
pub fn with_modifiers(action: Action, modifiers: Modifiers) -> Self {
|
||||||
|
Self { action, modifiers }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Broadcast from the menu loop to all subscribers
|
/// Broadcast from the menu loop to all subscribers
|
||||||
@@ -53,7 +121,7 @@ pub enum Action {
|
|||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum MenuEvent {
|
pub enum MenuEvent {
|
||||||
StateChanged(ViewState),
|
StateChanged(ViewState),
|
||||||
Selected(Value),
|
Selected(Vec<(Value, usize)>),
|
||||||
Quicklist(Vec<Value>),
|
Quicklist(Vec<Value>),
|
||||||
Cancelled,
|
Cancelled,
|
||||||
}
|
}
|
||||||
@@ -72,12 +140,28 @@ pub struct ViewState {
|
|||||||
pub total_items: usize,
|
pub total_items: usize,
|
||||||
pub total_filtered: usize,
|
pub total_filtered: usize,
|
||||||
pub mode: Mode,
|
pub mode: Mode,
|
||||||
|
pub selection_count: usize,
|
||||||
|
pub multi_enabled: bool,
|
||||||
|
/// True while stdin is still streaming items in.
|
||||||
|
pub streaming: bool,
|
||||||
|
/// Column headers for table mode. None when not in
|
||||||
|
/// table mode.
|
||||||
|
pub columns: Option<Vec<ColumnHeader>>,
|
||||||
/// Monotonically increasing counter. Each call to
|
/// Monotonically increasing counter. Each call to
|
||||||
/// `build_view_state()` bumps this, so frontends can
|
/// `build_view_state()` bumps this, so frontends can
|
||||||
/// detect duplicate broadcasts and skip redundant redraws.
|
/// detect duplicate broadcasts and skip redundant redraws.
|
||||||
pub generation: u64,
|
pub generation: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Column header with display name and computed width for
|
||||||
|
/// table mode rendering.
|
||||||
|
#[must_use]
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ColumnHeader {
|
||||||
|
pub display_name: String,
|
||||||
|
pub width: u16,
|
||||||
|
}
|
||||||
|
|
||||||
/// A single item in the current viewport window. Has the
|
/// A single item in the current viewport window. Has the
|
||||||
/// display label pre-resolved and the position in the
|
/// display label pre-resolved and the position in the
|
||||||
/// filtered list.
|
/// filtered list.
|
||||||
@@ -87,6 +171,10 @@ pub struct VisibleItem {
|
|||||||
pub label: String,
|
pub label: String,
|
||||||
pub formatted_text: Option<String>,
|
pub formatted_text: Option<String>,
|
||||||
pub index: usize,
|
pub index: usize,
|
||||||
|
pub selected: bool,
|
||||||
|
/// Column cell values for table mode. None when not in
|
||||||
|
/// table mode.
|
||||||
|
pub column_values: Option<Vec<String>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Final outcome of [`crate::menu::MenuRunner::run`]. The
|
/// Final outcome of [`crate::menu::MenuRunner::run`]. The
|
||||||
@@ -94,7 +182,92 @@ pub struct VisibleItem {
|
|||||||
#[must_use]
|
#[must_use]
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub enum MenuResult {
|
pub enum MenuResult {
|
||||||
Selected { value: Value, index: usize },
|
Selected {
|
||||||
Quicklist { items: Vec<(Value, usize)> },
|
items: Vec<(Value, usize)>,
|
||||||
Cancelled,
|
filter_text: String,
|
||||||
|
modifiers: Modifiers,
|
||||||
|
},
|
||||||
|
Quicklist {
|
||||||
|
items: Vec<(Value, usize)>,
|
||||||
|
filter_text: String,
|
||||||
|
modifiers: Modifiers,
|
||||||
|
},
|
||||||
|
Cancelled {
|
||||||
|
modifiers: Modifiers,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use serde_json::json;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn modifiers_empty_serializes_as_empty_array() {
|
||||||
|
let mods = Modifiers::default();
|
||||||
|
let json = serde_json::to_value(&mods).unwrap_or_default();
|
||||||
|
assert_eq!(json, json!([]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn modifiers_single_serializes() {
|
||||||
|
let mods = Modifiers {
|
||||||
|
shift: true,
|
||||||
|
ctrl: false,
|
||||||
|
alt: false,
|
||||||
|
};
|
||||||
|
let json = serde_json::to_value(&mods).unwrap_or_default();
|
||||||
|
assert_eq!(json, json!(["shift"]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn modifiers_multiple_serializes() {
|
||||||
|
let mods = Modifiers {
|
||||||
|
shift: true,
|
||||||
|
ctrl: true,
|
||||||
|
alt: false,
|
||||||
|
};
|
||||||
|
let json = serde_json::to_value(&mods).unwrap_or_default();
|
||||||
|
assert_eq!(json, json!(["shift", "ctrl"]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn modifiers_all_serializes() {
|
||||||
|
let mods = Modifiers {
|
||||||
|
shift: true,
|
||||||
|
ctrl: true,
|
||||||
|
alt: true,
|
||||||
|
};
|
||||||
|
let json = serde_json::to_value(&mods).unwrap_or_default();
|
||||||
|
assert_eq!(json, json!(["shift", "ctrl", "alt"]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn modifiers_is_empty() {
|
||||||
|
assert!(Modifiers::default().is_empty());
|
||||||
|
assert!(!Modifiers {
|
||||||
|
shift: true,
|
||||||
|
ctrl: false,
|
||||||
|
alt: false
|
||||||
|
}
|
||||||
|
.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn modified_action_new_has_empty_modifiers() {
|
||||||
|
let ma = ModifiedAction::new(Action::Confirm);
|
||||||
|
assert!(ma.modifiers.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn modified_action_with_modifiers() {
|
||||||
|
let mods = Modifiers {
|
||||||
|
shift: true,
|
||||||
|
ctrl: false,
|
||||||
|
alt: false,
|
||||||
|
};
|
||||||
|
let ma = ModifiedAction::with_modifiers(Action::Confirm, mods);
|
||||||
|
assert!(ma.modifiers.shift);
|
||||||
|
assert!(!ma.modifiers.ctrl);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -225,28 +225,19 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn icon_from_object() {
|
fn icon_from_object() {
|
||||||
let item = Item::new(
|
let item = Item::new(json!({"label": "Firefox", "icon": "firefox.png"}), "label");
|
||||||
json!({"label": "Firefox", "icon": "firefox.png"}),
|
|
||||||
"label",
|
|
||||||
);
|
|
||||||
assert_eq!(item.icon(), Some("firefox.png"));
|
assert_eq!(item.icon(), Some("firefox.png"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn group_from_object() {
|
fn group_from_object() {
|
||||||
let item = Item::new(
|
let item = Item::new(json!({"label": "Firefox", "group": "browsers"}), "label");
|
||||||
json!({"label": "Firefox", "group": "browsers"}),
|
|
||||||
"label",
|
|
||||||
);
|
|
||||||
assert_eq!(item.group(), Some("browsers"));
|
assert_eq!(item.group(), Some("browsers"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn meta_from_object() {
|
fn meta_from_object() {
|
||||||
let item = Item::new(
|
let item = Item::new(json!({"label": "test", "meta": {"res": 1080}}), "label");
|
||||||
json!({"label": "test", "meta": {"res": 1080}}),
|
|
||||||
"label",
|
|
||||||
);
|
|
||||||
let meta = item.meta();
|
let meta = item.meta();
|
||||||
assert!(meta.is_some());
|
assert!(meta.is_some());
|
||||||
assert_eq!(meta.and_then(|m| m.get("res")), Some(&json!(1080)));
|
assert_eq!(meta.and_then(|m| m.get("res")), Some(&json!(1080)));
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
pub mod column;
|
||||||
pub mod event;
|
pub mod event;
|
||||||
pub mod item;
|
pub mod item;
|
||||||
pub mod output;
|
pub mod output;
|
||||||
|
|||||||
@@ -2,10 +2,12 @@
|
|||||||
//! item value with action context and original index so
|
//! item value with action context and original index so
|
||||||
//! hooks and downstream tools know what happened.
|
//! hooks and downstream tools know what happened.
|
||||||
|
|
||||||
use serde::ser::SerializeMap;
|
|
||||||
use serde::Serialize;
|
use serde::Serialize;
|
||||||
|
use serde::ser::SerializeMap;
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
|
use crate::event::Modifiers;
|
||||||
|
|
||||||
/// What the user did to produce this output.
|
/// What the user did to produce this output.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
@@ -25,27 +27,35 @@ pub struct OutputItem {
|
|||||||
pub value: Value,
|
pub value: Value,
|
||||||
pub action: OutputAction,
|
pub action: OutputAction,
|
||||||
pub index: usize,
|
pub index: usize,
|
||||||
|
pub modifiers: Modifiers,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Serialize for OutputItem {
|
impl Serialize for OutputItem {
|
||||||
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
|
||||||
|
let has_mods = !self.modifiers.is_empty();
|
||||||
match &self.value {
|
match &self.value {
|
||||||
Value::Object(map) => {
|
Value::Object(map) => {
|
||||||
// Flatten: merge object fields with action/index
|
// Flatten: merge object fields with action/index
|
||||||
let mut s = serializer.serialize_map(Some(map.len() + 2))?;
|
let mut s = serializer.serialize_map(None)?;
|
||||||
for (k, v) in map {
|
for (k, v) in map {
|
||||||
s.serialize_entry(k, v)?;
|
s.serialize_entry(k, v)?;
|
||||||
}
|
}
|
||||||
s.serialize_entry("action", &self.action)?;
|
s.serialize_entry("action", &self.action)?;
|
||||||
s.serialize_entry("index", &self.index)?;
|
s.serialize_entry("index", &self.index)?;
|
||||||
|
if has_mods {
|
||||||
|
s.serialize_entry("modifiers", &self.modifiers)?;
|
||||||
|
}
|
||||||
s.end()
|
s.end()
|
||||||
}
|
}
|
||||||
_ => {
|
_ => {
|
||||||
// Non-object: put value in a "value" field
|
// Non-object: put value in a "value" field
|
||||||
let mut s = serializer.serialize_map(Some(3))?;
|
let mut s = serializer.serialize_map(None)?;
|
||||||
s.serialize_entry("value", &self.value)?;
|
s.serialize_entry("value", &self.value)?;
|
||||||
s.serialize_entry("action", &self.action)?;
|
s.serialize_entry("action", &self.action)?;
|
||||||
s.serialize_entry("index", &self.index)?;
|
s.serialize_entry("index", &self.index)?;
|
||||||
|
if has_mods {
|
||||||
|
s.serialize_entry("modifiers", &self.modifiers)?;
|
||||||
|
}
|
||||||
s.end()
|
s.end()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -63,6 +73,7 @@ mod tests {
|
|||||||
value: json!({"label": "Firefox", "url": "https://firefox.com"}),
|
value: json!({"label": "Firefox", "url": "https://firefox.com"}),
|
||||||
action: OutputAction::Select,
|
action: OutputAction::Select,
|
||||||
index: 3,
|
index: 3,
|
||||||
|
modifiers: Modifiers::default(),
|
||||||
};
|
};
|
||||||
let json = serde_json::to_value(&item).unwrap_or_default();
|
let json = serde_json::to_value(&item).unwrap_or_default();
|
||||||
assert_eq!(json["label"], "Firefox");
|
assert_eq!(json["label"], "Firefox");
|
||||||
@@ -77,6 +88,7 @@ mod tests {
|
|||||||
value: json!("hello"),
|
value: json!("hello"),
|
||||||
action: OutputAction::Select,
|
action: OutputAction::Select,
|
||||||
index: 0,
|
index: 0,
|
||||||
|
modifiers: Modifiers::default(),
|
||||||
};
|
};
|
||||||
let json = serde_json::to_value(&item).unwrap_or_default();
|
let json = serde_json::to_value(&item).unwrap_or_default();
|
||||||
assert_eq!(json["value"], "hello");
|
assert_eq!(json["value"], "hello");
|
||||||
@@ -90,6 +102,7 @@ mod tests {
|
|||||||
value: json!(null),
|
value: json!(null),
|
||||||
action: OutputAction::Cancel,
|
action: OutputAction::Cancel,
|
||||||
index: 0,
|
index: 0,
|
||||||
|
modifiers: Modifiers::default(),
|
||||||
};
|
};
|
||||||
let json = serde_json::to_value(&item).unwrap_or_default();
|
let json = serde_json::to_value(&item).unwrap_or_default();
|
||||||
assert_eq!(json["action"], "cancel");
|
assert_eq!(json["action"], "cancel");
|
||||||
@@ -101,6 +114,7 @@ mod tests {
|
|||||||
value: json!("test"),
|
value: json!("test"),
|
||||||
action: OutputAction::Quicklist,
|
action: OutputAction::Quicklist,
|
||||||
index: 2,
|
index: 2,
|
||||||
|
modifiers: Modifiers::default(),
|
||||||
};
|
};
|
||||||
let json = serde_json::to_value(&item).unwrap_or_default();
|
let json = serde_json::to_value(&item).unwrap_or_default();
|
||||||
assert_eq!(json["action"], "quicklist");
|
assert_eq!(json["action"], "quicklist");
|
||||||
@@ -113,6 +127,7 @@ mod tests {
|
|||||||
value: json!("alpha"),
|
value: json!("alpha"),
|
||||||
action: OutputAction::Select,
|
action: OutputAction::Select,
|
||||||
index: 0,
|
index: 0,
|
||||||
|
modifiers: Modifiers::default(),
|
||||||
};
|
};
|
||||||
let serialized = serde_json::to_string(&item).unwrap_or_default();
|
let serialized = serde_json::to_string(&item).unwrap_or_default();
|
||||||
assert!(
|
assert!(
|
||||||
@@ -120,4 +135,61 @@ mod tests {
|
|||||||
"output should contain the value text: {serialized}"
|
"output should contain the value text: {serialized}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn output_item_omits_empty_modifiers() {
|
||||||
|
let item = OutputItem {
|
||||||
|
value: json!("test"),
|
||||||
|
action: OutputAction::Select,
|
||||||
|
index: 0,
|
||||||
|
modifiers: Modifiers::default(),
|
||||||
|
};
|
||||||
|
let json = serde_json::to_value(&item).unwrap_or_default();
|
||||||
|
assert!(json.get("modifiers").is_none(), "empty modifiers should be omitted");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn output_item_includes_modifiers_when_present() {
|
||||||
|
let item = OutputItem {
|
||||||
|
value: json!("test"),
|
||||||
|
action: OutputAction::Select,
|
||||||
|
index: 0,
|
||||||
|
modifiers: Modifiers {
|
||||||
|
shift: true,
|
||||||
|
ctrl: false,
|
||||||
|
alt: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let json = serde_json::to_value(&item).unwrap_or_default();
|
||||||
|
assert_eq!(json["modifiers"], json!(["shift"]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn output_item_object_with_modifiers() {
|
||||||
|
let item = OutputItem {
|
||||||
|
value: json!({"label": "Firefox"}),
|
||||||
|
action: OutputAction::Select,
|
||||||
|
index: 0,
|
||||||
|
modifiers: Modifiers {
|
||||||
|
shift: true,
|
||||||
|
ctrl: true,
|
||||||
|
alt: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let json = serde_json::to_value(&item).unwrap_or_default();
|
||||||
|
assert_eq!(json["label"], "Firefox");
|
||||||
|
assert_eq!(json["modifiers"], json!(["shift", "ctrl"]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn output_item_object_omits_empty_modifiers() {
|
||||||
|
let item = OutputItem {
|
||||||
|
value: json!({"label": "Firefox"}),
|
||||||
|
action: OutputAction::Select,
|
||||||
|
index: 0,
|
||||||
|
modifiers: Modifiers::default(),
|
||||||
|
};
|
||||||
|
let json = serde_json::to_value(&item).unwrap_or_default();
|
||||||
|
assert!(json.get("modifiers").is_none());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,11 @@ pub trait Menu: Send + 'static {
|
|||||||
/// for output and hook events.
|
/// for output and hook events.
|
||||||
fn original_index(&self, filtered_index: usize) -> Option<usize>;
|
fn original_index(&self, filtered_index: usize) -> Option<usize>;
|
||||||
|
|
||||||
|
/// Get the JSON value of an item by its original (pre-filter)
|
||||||
|
/// index. Used by multi-select to retrieve selected items
|
||||||
|
/// that may not be in the current filtered set.
|
||||||
|
fn serialize_original(&self, original_index: usize) -> Option<&serde_json::Value>;
|
||||||
|
|
||||||
/// Get the formatted display text for a filtered item,
|
/// Get the formatted display text for a filtered item,
|
||||||
/// if a format template is configured. Returns None if
|
/// if a format template is configured. Returns None if
|
||||||
/// no template is set, in which case the raw label is
|
/// no template is set, in which case the raw label is
|
||||||
@@ -61,6 +66,17 @@ pub trait Menu: Send + 'static {
|
|||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get the column values for a filtered item, if table
|
||||||
|
/// mode is active. Returns None when no column config is set.
|
||||||
|
fn column_values(&self, _filtered_index: usize) -> Option<Vec<String>> {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Get the column config, if one is set.
|
||||||
|
fn column_config(&self) -> Option<&crate::model::column::ColumnConfig> {
|
||||||
|
None
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extension of [`Menu`] with mutation methods. Required by
|
/// Extension of [`Menu`] with mutation methods. Required by
|
||||||
|
|||||||
@@ -170,12 +170,7 @@ impl FilterPipeline {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
FilterKind::Field { path } => {
|
FilterKind::Field { path } => {
|
||||||
Self::eval_field(
|
Self::eval_field(stage, &input_indices, &self.item_values, path)
|
||||||
stage,
|
|
||||||
&input_indices,
|
|
||||||
&self.item_values,
|
|
||||||
path,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ pub enum FilterKind {
|
|||||||
/// Field-specific filter: `meta.res:3840` matches
|
/// Field-specific filter: `meta.res:3840` matches
|
||||||
/// items where the dotted path resolves to a value
|
/// items where the dotted path resolves to a value
|
||||||
/// containing the query text.
|
/// containing the query text.
|
||||||
Field { path: String },
|
Field {
|
||||||
|
path: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A parsed filter segment with its kind, inversion flag,
|
/// A parsed filter segment with its kind, inversion flag,
|
||||||
@@ -86,7 +88,9 @@ pub fn parse_segment(segment: &str) -> ParsedSegment<'_> {
|
|||||||
&& let Some((path, value)) = try_parse_field_filter(rest)
|
&& let Some((path, value)) = try_parse_field_filter(rest)
|
||||||
{
|
{
|
||||||
return ParsedSegment {
|
return ParsedSegment {
|
||||||
kind: FilterKind::Field { path: path.to_string() },
|
kind: FilterKind::Field {
|
||||||
|
path: path.to_string(),
|
||||||
|
},
|
||||||
inverse: true,
|
inverse: true,
|
||||||
query: value,
|
query: value,
|
||||||
};
|
};
|
||||||
@@ -95,7 +99,9 @@ pub fn parse_segment(segment: &str) -> ParsedSegment<'_> {
|
|||||||
// Check for field filter: field.path:value
|
// Check for field filter: field.path:value
|
||||||
if let Some((path, value)) = try_parse_field_filter(segment) {
|
if let Some((path, value)) = try_parse_field_filter(segment) {
|
||||||
return ParsedSegment {
|
return ParsedSegment {
|
||||||
kind: FilterKind::Field { path: path.to_string() },
|
kind: FilterKind::Field {
|
||||||
|
path: path.to_string(),
|
||||||
|
},
|
||||||
inverse: false,
|
inverse: false,
|
||||||
query: value,
|
query: value,
|
||||||
};
|
};
|
||||||
@@ -150,7 +156,10 @@ fn try_parse_field_filter(segment: &str) -> Option<(&str, &str)> {
|
|||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
// Path chars must be alphanumeric, underscore, or dot
|
// Path chars must be alphanumeric, underscore, or dot
|
||||||
if !path.chars().all(|c| c.is_alphanumeric() || c == '_' || c == '.') {
|
if !path
|
||||||
|
.chars()
|
||||||
|
.all(|c| c.is_alphanumeric() || c == '_' || c == '.')
|
||||||
|
{
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
208
crates/pikl-core/src/runtime/csv_input.rs
Normal file
208
crates/pikl-core/src/runtime/csv_input.rs
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
//! CSV/TSV input parser. Reads delimited data into [`Item`]s
|
||||||
|
//! with structured JSON values. First row is always headers.
|
||||||
|
|
||||||
|
use std::io::Read;
|
||||||
|
|
||||||
|
use serde_json::{Map, Value};
|
||||||
|
|
||||||
|
use crate::error::PiklError;
|
||||||
|
use crate::item::Item;
|
||||||
|
|
||||||
|
/// Input format for stdin parsing.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum InputFormat {
|
||||||
|
/// Auto-detect: try JSON lines first, fall back to plain text.
|
||||||
|
Auto,
|
||||||
|
/// Comma-separated values.
|
||||||
|
Csv,
|
||||||
|
/// Tab-separated values.
|
||||||
|
Tsv,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InputFormat {
|
||||||
|
/// Parse from a CLI string. Returns None for unknown formats.
|
||||||
|
pub fn from_str_opt(s: &str) -> Option<Self> {
|
||||||
|
match s {
|
||||||
|
"auto" => Some(Self::Auto),
|
||||||
|
"csv" => Some(Self::Csv),
|
||||||
|
"tsv" => Some(Self::Tsv),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn delimiter(self) -> u8 {
|
||||||
|
match self {
|
||||||
|
Self::Tsv => b'\t',
|
||||||
|
_ => b',',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result of parsing CSV/TSV input.
|
||||||
|
pub struct CsvParseResult {
|
||||||
|
pub items: Vec<Item>,
|
||||||
|
pub headers: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Known top-level fields that get promoted out of meta.
|
||||||
|
const KNOWN_FIELDS: &[&str] = &["label", "sublabel", "icon", "group"];
|
||||||
|
|
||||||
|
/// Read CSV or TSV from a reader, producing structured Items.
|
||||||
|
///
|
||||||
|
/// Each row becomes a JSON object. The `label_key` controls
|
||||||
|
/// which column becomes the display label. If no column matches
|
||||||
|
/// `label_key`, the first column is used as `label`.
|
||||||
|
///
|
||||||
|
/// Known fields (label, sublabel, icon, group) are promoted to
|
||||||
|
/// the top level. Everything else goes into a `meta` object.
|
||||||
|
pub fn read_csv_items_sync(
|
||||||
|
reader: impl Read,
|
||||||
|
format: InputFormat,
|
||||||
|
label_key: &str,
|
||||||
|
) -> Result<CsvParseResult, PiklError> {
|
||||||
|
let mut csv_reader = csv::ReaderBuilder::new()
|
||||||
|
.delimiter(format.delimiter())
|
||||||
|
.has_headers(true)
|
||||||
|
.from_reader(reader);
|
||||||
|
|
||||||
|
let raw_headers = csv_reader
|
||||||
|
.headers()
|
||||||
|
.map_err(|e| PiklError::CsvParse(e.to_string()))?
|
||||||
|
.clone();
|
||||||
|
|
||||||
|
if raw_headers.is_empty() {
|
||||||
|
return Err(PiklError::CsvParse("no headers found".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
let headers: Vec<String> = raw_headers.iter().map(|h| h.to_string()).collect();
|
||||||
|
|
||||||
|
// Figure out which column is the label. If label_key matches
|
||||||
|
// a header, use that. Otherwise first column becomes "label".
|
||||||
|
let has_label_column = headers.iter().any(|h| h == label_key);
|
||||||
|
|
||||||
|
let mut items = Vec::new();
|
||||||
|
for result in csv_reader.records() {
|
||||||
|
let record = result.map_err(|e| PiklError::CsvParse(e.to_string()))?;
|
||||||
|
let mut top = Map::new();
|
||||||
|
let mut meta = Map::new();
|
||||||
|
|
||||||
|
for (i, field_value) in record.iter().enumerate() {
|
||||||
|
let Some(header) = headers.get(i) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
|
||||||
|
if !has_label_column && i == 0 {
|
||||||
|
// First column becomes label when no explicit label column exists
|
||||||
|
top.insert("label".to_string(), Value::String(field_value.to_string()));
|
||||||
|
// Also keep it in meta under its original header name
|
||||||
|
meta.insert(header.clone(), Value::String(field_value.to_string()));
|
||||||
|
} else if KNOWN_FIELDS.contains(&header.as_str()) {
|
||||||
|
top.insert(header.clone(), Value::String(field_value.to_string()));
|
||||||
|
} else {
|
||||||
|
meta.insert(header.clone(), Value::String(field_value.to_string()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !meta.is_empty() {
|
||||||
|
top.insert("meta".to_string(), Value::Object(meta));
|
||||||
|
}
|
||||||
|
|
||||||
|
let value = Value::Object(top);
|
||||||
|
items.push(Item::new(value, label_key));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(CsvParseResult { items, headers })
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use serde_json::json;
|
||||||
|
use std::io::Cursor;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn basic_csv() {
|
||||||
|
let data = "name,age,city\nalice,30,toronto\nbob,25,vancouver\n";
|
||||||
|
let result = read_csv_items_sync(Cursor::new(data), InputFormat::Csv, "label").unwrap();
|
||||||
|
assert_eq!(result.items.len(), 2);
|
||||||
|
assert_eq!(result.headers, vec!["name", "age", "city"]);
|
||||||
|
// First column becomes label when no "label" header
|
||||||
|
assert_eq!(result.items[0].label(), "alice");
|
||||||
|
assert_eq!(result.items[1].label(), "bob");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn csv_with_label_header() {
|
||||||
|
let data = "label,age\nalice,30\nbob,25\n";
|
||||||
|
let result = read_csv_items_sync(Cursor::new(data), InputFormat::Csv, "label").unwrap();
|
||||||
|
assert_eq!(result.items[0].label(), "alice");
|
||||||
|
// "age" should be in meta
|
||||||
|
assert_eq!(result.items[0].field_value("meta.age"), Some(&json!("30")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tsv_input() {
|
||||||
|
let data = "name\tage\nalice\t30\nbob\t25\n";
|
||||||
|
let result = read_csv_items_sync(Cursor::new(data), InputFormat::Tsv, "label").unwrap();
|
||||||
|
assert_eq!(result.items.len(), 2);
|
||||||
|
assert_eq!(result.items[0].label(), "alice");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn known_fields_promoted() {
|
||||||
|
let data = "label,sublabel,icon,group,extra\nfoo,bar,ic.png,grp,val\n";
|
||||||
|
let result = read_csv_items_sync(Cursor::new(data), InputFormat::Csv, "label").unwrap();
|
||||||
|
let item = &result.items[0];
|
||||||
|
assert_eq!(item.label(), "foo");
|
||||||
|
assert_eq!(item.sublabel(), Some("bar"));
|
||||||
|
assert_eq!(item.icon(), Some("ic.png"));
|
||||||
|
assert_eq!(item.group(), Some("grp"));
|
||||||
|
assert_eq!(item.field_value("meta.extra"), Some(&json!("val")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn first_column_label_also_in_meta() {
|
||||||
|
let data = "name,age\nalice,30\n";
|
||||||
|
let result = read_csv_items_sync(Cursor::new(data), InputFormat::Csv, "label").unwrap();
|
||||||
|
// First column promoted to label
|
||||||
|
assert_eq!(result.items[0].label(), "alice");
|
||||||
|
// Original header name also in meta
|
||||||
|
assert_eq!(
|
||||||
|
result.items[0].field_value("meta.name"),
|
||||||
|
Some(&json!("alice"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn quoted_fields() {
|
||||||
|
let data = "name,bio\nalice,\"likes, commas\"\n";
|
||||||
|
let result = read_csv_items_sync(Cursor::new(data), InputFormat::Csv, "label").unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
result.items[0].field_value("meta.bio"),
|
||||||
|
Some(&json!("likes, commas"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_input_errors() {
|
||||||
|
let data = "";
|
||||||
|
let result = read_csv_items_sync(Cursor::new(data), InputFormat::Csv, "label");
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn headers_only_no_rows() {
|
||||||
|
let data = "name,age\n";
|
||||||
|
let result = read_csv_items_sync(Cursor::new(data), InputFormat::Csv, "label").unwrap();
|
||||||
|
assert!(result.items.is_empty());
|
||||||
|
assert_eq!(result.headers, vec!["name", "age"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn input_format_from_str() {
|
||||||
|
assert_eq!(InputFormat::from_str_opt("csv"), Some(InputFormat::Csv));
|
||||||
|
assert_eq!(InputFormat::from_str_opt("tsv"), Some(InputFormat::Tsv));
|
||||||
|
assert_eq!(InputFormat::from_str_opt("auto"), Some(InputFormat::Auto));
|
||||||
|
assert_eq!(InputFormat::from_str_opt("xml"), None);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ use tokio::sync::mpsc;
|
|||||||
use tokio::task::JoinHandle;
|
use tokio::task::JoinHandle;
|
||||||
use tracing::Instrument;
|
use tracing::Instrument;
|
||||||
|
|
||||||
use crate::event::Action;
|
use crate::event::{Action, ModifiedAction};
|
||||||
use crate::hook::{HookEvent, HookEventKind, HookHandler, HookResponse};
|
use crate::hook::{HookEvent, HookEventKind, HookHandler, HookResponse};
|
||||||
|
|
||||||
/// Debounce mode for a hook event kind.
|
/// Debounce mode for a hook event kind.
|
||||||
@@ -30,16 +30,13 @@ pub enum DebounceMode {
|
|||||||
/// behavior. Each event kind can have its own mode.
|
/// behavior. Each event kind can have its own mode.
|
||||||
pub struct DebouncedDispatcher {
|
pub struct DebouncedDispatcher {
|
||||||
handler: Arc<dyn HookHandler>,
|
handler: Arc<dyn HookHandler>,
|
||||||
_action_tx: mpsc::Sender<Action>,
|
_action_tx: mpsc::Sender<ModifiedAction>,
|
||||||
modes: HashMap<HookEventKind, DebounceMode>,
|
modes: HashMap<HookEventKind, DebounceMode>,
|
||||||
in_flight: HashMap<HookEventKind, JoinHandle<()>>,
|
in_flight: HashMap<HookEventKind, JoinHandle<()>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DebouncedDispatcher {
|
impl DebouncedDispatcher {
|
||||||
pub fn new(
|
pub fn new(handler: Arc<dyn HookHandler>, action_tx: mpsc::Sender<ModifiedAction>) -> Self {
|
||||||
handler: Arc<dyn HookHandler>,
|
|
||||||
action_tx: mpsc::Sender<Action>,
|
|
||||||
) -> Self {
|
|
||||||
Self {
|
Self {
|
||||||
handler,
|
handler,
|
||||||
_action_tx: action_tx,
|
_action_tx: action_tx,
|
||||||
@@ -71,11 +68,7 @@ impl DebouncedDispatcher {
|
|||||||
/// Dispatch a hook event through the debounce system.
|
/// Dispatch a hook event through the debounce system.
|
||||||
pub fn dispatch(&mut self, event: HookEvent) {
|
pub fn dispatch(&mut self, event: HookEvent) {
|
||||||
let kind = event.kind();
|
let kind = event.kind();
|
||||||
let mode = self
|
let mode = self.modes.get(&kind).cloned().unwrap_or(DebounceMode::None);
|
||||||
.modes
|
|
||||||
.get(&kind)
|
|
||||||
.cloned()
|
|
||||||
.unwrap_or(DebounceMode::None);
|
|
||||||
|
|
||||||
tracing::debug!(event_kind = ?kind, mode = ?mode, "dispatching hook event");
|
tracing::debug!(event_kind = ?kind, mode = ?mode, "dispatching hook event");
|
||||||
|
|
||||||
@@ -156,6 +149,7 @@ pub fn hook_response_to_action(resp: HookResponse) -> Action {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
use crate::error::PiklError;
|
use crate::error::PiklError;
|
||||||
|
use crate::event::Modifiers;
|
||||||
use serde_json::json;
|
use serde_json::json;
|
||||||
use std::sync::Mutex;
|
use std::sync::Mutex;
|
||||||
|
|
||||||
@@ -224,12 +218,15 @@ mod tests {
|
|||||||
// Rapid-fire filter events
|
// Rapid-fire filter events
|
||||||
dispatcher.dispatch(HookEvent::Filter {
|
dispatcher.dispatch(HookEvent::Filter {
|
||||||
text: "a".to_string(),
|
text: "a".to_string(),
|
||||||
|
modifiers: Modifiers::default(),
|
||||||
});
|
});
|
||||||
dispatcher.dispatch(HookEvent::Filter {
|
dispatcher.dispatch(HookEvent::Filter {
|
||||||
text: "ab".to_string(),
|
text: "ab".to_string(),
|
||||||
|
modifiers: Modifiers::default(),
|
||||||
});
|
});
|
||||||
dispatcher.dispatch(HookEvent::Filter {
|
dispatcher.dispatch(HookEvent::Filter {
|
||||||
text: "abc".to_string(),
|
text: "abc".to_string(),
|
||||||
|
modifiers: Modifiers::default(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Advance past debounce window. sleep(0) processes
|
// Advance past debounce window. sleep(0) processes
|
||||||
@@ -258,12 +255,14 @@ mod tests {
|
|||||||
dispatcher.dispatch(HookEvent::Hover {
|
dispatcher.dispatch(HookEvent::Hover {
|
||||||
item: json!("a"),
|
item: json!("a"),
|
||||||
index: 0,
|
index: 0,
|
||||||
|
modifiers: Modifiers::default(),
|
||||||
});
|
});
|
||||||
// Wait a bit, then send second hover which cancels first
|
// Wait a bit, then send second hover which cancels first
|
||||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||||
dispatcher.dispatch(HookEvent::Hover {
|
dispatcher.dispatch(HookEvent::Hover {
|
||||||
item: json!("b"),
|
item: json!("b"),
|
||||||
index: 1,
|
index: 1,
|
||||||
|
modifiers: Modifiers::default(),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Advance past debounce for the second event
|
// Advance past debounce for the second event
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use serde::{Deserialize, Serialize};
|
|||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
|
||||||
use crate::error::PiklError;
|
use crate::error::PiklError;
|
||||||
|
use crate::event::Modifiers;
|
||||||
|
|
||||||
/// A lifecycle event emitted by the menu engine. Handler
|
/// A lifecycle event emitted by the menu engine. Handler
|
||||||
/// hooks receive these as JSON lines on stdin. The `event`
|
/// hooks receive these as JSON lines on stdin. The `event`
|
||||||
@@ -16,11 +17,33 @@ use crate::error::PiklError;
|
|||||||
pub enum HookEvent {
|
pub enum HookEvent {
|
||||||
Open,
|
Open,
|
||||||
Close,
|
Close,
|
||||||
Hover { item: Value, index: usize },
|
Hover {
|
||||||
Select { item: Value, index: usize },
|
item: Value,
|
||||||
Cancel,
|
index: usize,
|
||||||
Filter { text: String },
|
#[serde(skip_serializing_if = "Modifiers::is_empty")]
|
||||||
Quicklist { items: Vec<Value>, count: usize },
|
modifiers: Modifiers,
|
||||||
|
},
|
||||||
|
Select {
|
||||||
|
item: Value,
|
||||||
|
index: usize,
|
||||||
|
#[serde(skip_serializing_if = "Modifiers::is_empty")]
|
||||||
|
modifiers: Modifiers,
|
||||||
|
},
|
||||||
|
Cancel {
|
||||||
|
#[serde(skip_serializing_if = "Modifiers::is_empty")]
|
||||||
|
modifiers: Modifiers,
|
||||||
|
},
|
||||||
|
Filter {
|
||||||
|
text: String,
|
||||||
|
#[serde(skip_serializing_if = "Modifiers::is_empty")]
|
||||||
|
modifiers: Modifiers,
|
||||||
|
},
|
||||||
|
Quicklist {
|
||||||
|
items: Vec<Value>,
|
||||||
|
count: usize,
|
||||||
|
#[serde(skip_serializing_if = "Modifiers::is_empty")]
|
||||||
|
modifiers: Modifiers,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Discriminant for [`HookEvent`], used as a key for
|
/// Discriminant for [`HookEvent`], used as a key for
|
||||||
@@ -44,7 +67,7 @@ impl HookEvent {
|
|||||||
HookEvent::Close => HookEventKind::Close,
|
HookEvent::Close => HookEventKind::Close,
|
||||||
HookEvent::Hover { .. } => HookEventKind::Hover,
|
HookEvent::Hover { .. } => HookEventKind::Hover,
|
||||||
HookEvent::Select { .. } => HookEventKind::Select,
|
HookEvent::Select { .. } => HookEventKind::Select,
|
||||||
HookEvent::Cancel => HookEventKind::Cancel,
|
HookEvent::Cancel { .. } => HookEventKind::Cancel,
|
||||||
HookEvent::Filter { .. } => HookEventKind::Filter,
|
HookEvent::Filter { .. } => HookEventKind::Filter,
|
||||||
HookEvent::Quicklist { .. } => HookEventKind::Quicklist,
|
HookEvent::Quicklist { .. } => HookEventKind::Quicklist,
|
||||||
}
|
}
|
||||||
@@ -116,11 +139,14 @@ mod tests {
|
|||||||
let event = HookEvent::Hover {
|
let event = HookEvent::Hover {
|
||||||
item: json!({"label": "test"}),
|
item: json!({"label": "test"}),
|
||||||
index: 5,
|
index: 5,
|
||||||
|
modifiers: Modifiers::default(),
|
||||||
};
|
};
|
||||||
let json = serde_json::to_value(&event).unwrap_or_default();
|
let json = serde_json::to_value(&event).unwrap_or_default();
|
||||||
assert_eq!(json["event"], "hover");
|
assert_eq!(json["event"], "hover");
|
||||||
assert_eq!(json["item"]["label"], "test");
|
assert_eq!(json["item"]["label"], "test");
|
||||||
assert_eq!(json["index"], 5);
|
assert_eq!(json["index"], 5);
|
||||||
|
// Empty modifiers should be omitted
|
||||||
|
assert!(json.get("modifiers").is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -128,6 +154,7 @@ mod tests {
|
|||||||
let event = HookEvent::Select {
|
let event = HookEvent::Select {
|
||||||
item: json!("hello"),
|
item: json!("hello"),
|
||||||
index: 0,
|
index: 0,
|
||||||
|
modifiers: Modifiers::default(),
|
||||||
};
|
};
|
||||||
let json = serde_json::to_value(&event).unwrap_or_default();
|
let json = serde_json::to_value(&event).unwrap_or_default();
|
||||||
assert_eq!(json["event"], "select");
|
assert_eq!(json["event"], "select");
|
||||||
@@ -137,14 +164,19 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn event_cancel_serializes() {
|
fn event_cancel_serializes() {
|
||||||
let json = serde_json::to_value(&HookEvent::Cancel).unwrap_or_default();
|
let json = serde_json::to_value(&HookEvent::Cancel {
|
||||||
|
modifiers: Modifiers::default(),
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
assert_eq!(json["event"], "cancel");
|
assert_eq!(json["event"], "cancel");
|
||||||
|
assert!(json.get("modifiers").is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn event_filter_serializes() {
|
fn event_filter_serializes() {
|
||||||
let event = HookEvent::Filter {
|
let event = HookEvent::Filter {
|
||||||
text: "foo".to_string(),
|
text: "foo".to_string(),
|
||||||
|
modifiers: Modifiers::default(),
|
||||||
};
|
};
|
||||||
let json = serde_json::to_value(&event).unwrap_or_default();
|
let json = serde_json::to_value(&event).unwrap_or_default();
|
||||||
assert_eq!(json["event"], "filter");
|
assert_eq!(json["event"], "filter");
|
||||||
@@ -160,7 +192,8 @@ mod tests {
|
|||||||
assert_eq!(
|
assert_eq!(
|
||||||
HookEvent::Hover {
|
HookEvent::Hover {
|
||||||
item: json!(null),
|
item: json!(null),
|
||||||
index: 0
|
index: 0,
|
||||||
|
modifiers: Modifiers::default(),
|
||||||
}
|
}
|
||||||
.kind(),
|
.kind(),
|
||||||
HookEventKind::Hover
|
HookEventKind::Hover
|
||||||
@@ -168,15 +201,23 @@ mod tests {
|
|||||||
assert_eq!(
|
assert_eq!(
|
||||||
HookEvent::Select {
|
HookEvent::Select {
|
||||||
item: json!(null),
|
item: json!(null),
|
||||||
index: 0
|
index: 0,
|
||||||
|
modifiers: Modifiers::default(),
|
||||||
}
|
}
|
||||||
.kind(),
|
.kind(),
|
||||||
HookEventKind::Select
|
HookEventKind::Select
|
||||||
);
|
);
|
||||||
assert_eq!(HookEvent::Cancel.kind(), HookEventKind::Cancel);
|
assert_eq!(
|
||||||
|
HookEvent::Cancel {
|
||||||
|
modifiers: Modifiers::default()
|
||||||
|
}
|
||||||
|
.kind(),
|
||||||
|
HookEventKind::Cancel
|
||||||
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
HookEvent::Filter {
|
HookEvent::Filter {
|
||||||
text: String::new()
|
text: String::new(),
|
||||||
|
modifiers: Modifiers::default(),
|
||||||
}
|
}
|
||||||
.kind(),
|
.kind(),
|
||||||
HookEventKind::Filter
|
HookEventKind::Filter
|
||||||
@@ -188,9 +229,8 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn response_add_items() {
|
fn response_add_items() {
|
||||||
let json = r#"{"action": "add_items", "items": [{"label": "new"}]}"#;
|
let json = r#"{"action": "add_items", "items": [{"label": "new"}]}"#;
|
||||||
let resp: HookResponse = serde_json::from_str(json).unwrap_or_else(|e| {
|
let resp: HookResponse =
|
||||||
std::unreachable!("parse failed: {e}")
|
serde_json::from_str(json).unwrap_or_else(|e| std::unreachable!("parse failed: {e}"));
|
||||||
});
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
resp,
|
resp,
|
||||||
HookResponse::AddItems {
|
HookResponse::AddItems {
|
||||||
@@ -202,9 +242,8 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn response_replace_items() {
|
fn response_replace_items() {
|
||||||
let json = r#"{"action": "replace_items", "items": ["a", "b"]}"#;
|
let json = r#"{"action": "replace_items", "items": ["a", "b"]}"#;
|
||||||
let resp: HookResponse = serde_json::from_str(json).unwrap_or_else(|e| {
|
let resp: HookResponse =
|
||||||
std::unreachable!("parse failed: {e}")
|
serde_json::from_str(json).unwrap_or_else(|e| std::unreachable!("parse failed: {e}"));
|
||||||
});
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
resp,
|
resp,
|
||||||
HookResponse::ReplaceItems {
|
HookResponse::ReplaceItems {
|
||||||
@@ -216,9 +255,8 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn response_remove_items() {
|
fn response_remove_items() {
|
||||||
let json = r#"{"action": "remove_items", "indices": [0, 3, 5]}"#;
|
let json = r#"{"action": "remove_items", "indices": [0, 3, 5]}"#;
|
||||||
let resp: HookResponse = serde_json::from_str(json).unwrap_or_else(|e| {
|
let resp: HookResponse =
|
||||||
std::unreachable!("parse failed: {e}")
|
serde_json::from_str(json).unwrap_or_else(|e| std::unreachable!("parse failed: {e}"));
|
||||||
});
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
resp,
|
resp,
|
||||||
HookResponse::RemoveItems {
|
HookResponse::RemoveItems {
|
||||||
@@ -230,9 +268,8 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn response_set_filter() {
|
fn response_set_filter() {
|
||||||
let json = r#"{"action": "set_filter", "text": "hello"}"#;
|
let json = r#"{"action": "set_filter", "text": "hello"}"#;
|
||||||
let resp: HookResponse = serde_json::from_str(json).unwrap_or_else(|e| {
|
let resp: HookResponse =
|
||||||
std::unreachable!("parse failed: {e}")
|
serde_json::from_str(json).unwrap_or_else(|e| std::unreachable!("parse failed: {e}"));
|
||||||
});
|
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
resp,
|
resp,
|
||||||
HookResponse::SetFilter {
|
HookResponse::SetFilter {
|
||||||
@@ -244,9 +281,8 @@ mod tests {
|
|||||||
#[test]
|
#[test]
|
||||||
fn response_close() {
|
fn response_close() {
|
||||||
let json = r#"{"action": "close"}"#;
|
let json = r#"{"action": "close"}"#;
|
||||||
let resp: HookResponse = serde_json::from_str(json).unwrap_or_else(|e| {
|
let resp: HookResponse =
|
||||||
std::unreachable!("parse failed: {e}")
|
serde_json::from_str(json).unwrap_or_else(|e| std::unreachable!("parse failed: {e}"));
|
||||||
});
|
|
||||||
assert_eq!(resp, HookResponse::Close);
|
assert_eq!(resp, HookResponse::Close);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -296,6 +332,7 @@ mod tests {
|
|||||||
let event = HookEvent::Quicklist {
|
let event = HookEvent::Quicklist {
|
||||||
items: vec![json!("alpha"), json!("beta")],
|
items: vec![json!("alpha"), json!("beta")],
|
||||||
count: 2,
|
count: 2,
|
||||||
|
modifiers: Modifiers::default(),
|
||||||
};
|
};
|
||||||
let json = serde_json::to_value(&event).unwrap_or_default();
|
let json = serde_json::to_value(&event).unwrap_or_default();
|
||||||
assert_eq!(json["event"], "quicklist");
|
assert_eq!(json["event"], "quicklist");
|
||||||
@@ -308,6 +345,7 @@ mod tests {
|
|||||||
let event = HookEvent::Quicklist {
|
let event = HookEvent::Quicklist {
|
||||||
items: vec![],
|
items: vec![],
|
||||||
count: 0,
|
count: 0,
|
||||||
|
modifiers: Modifiers::default(),
|
||||||
};
|
};
|
||||||
assert_eq!(event.kind(), HookEventKind::Quicklist);
|
assert_eq!(event.kind(), HookEventKind::Quicklist);
|
||||||
}
|
}
|
||||||
@@ -319,6 +357,7 @@ mod tests {
|
|||||||
let event = HookEvent::Hover {
|
let event = HookEvent::Hover {
|
||||||
item: json!({"label": "Firefox", "url": "https://firefox.com"}),
|
item: json!({"label": "Firefox", "url": "https://firefox.com"}),
|
||||||
index: 2,
|
index: 2,
|
||||||
|
modifiers: Modifiers::default(),
|
||||||
};
|
};
|
||||||
let serialized = serde_json::to_string(&event).unwrap_or_default();
|
let serialized = serde_json::to_string(&event).unwrap_or_default();
|
||||||
let parsed: Value = serde_json::from_str(&serialized).unwrap_or_default();
|
let parsed: Value = serde_json::from_str(&serialized).unwrap_or_default();
|
||||||
@@ -326,4 +365,35 @@ mod tests {
|
|||||||
assert_eq!(parsed["item"]["label"], "Firefox");
|
assert_eq!(parsed["item"]["label"], "Firefox");
|
||||||
assert_eq!(parsed["index"], 2);
|
assert_eq!(parsed["index"], 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -- Modifier serialization in hook events --
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn event_select_with_modifiers() {
|
||||||
|
let event = HookEvent::Select {
|
||||||
|
item: json!("test"),
|
||||||
|
index: 0,
|
||||||
|
modifiers: Modifiers {
|
||||||
|
shift: true,
|
||||||
|
ctrl: false,
|
||||||
|
alt: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let json = serde_json::to_value(&event).unwrap_or_default();
|
||||||
|
assert_eq!(json["modifiers"], json!(["shift"]));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn event_cancel_with_modifiers() {
|
||||||
|
let event = HookEvent::Cancel {
|
||||||
|
modifiers: Modifiers {
|
||||||
|
shift: true,
|
||||||
|
ctrl: true,
|
||||||
|
alt: false,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let json = serde_json::to_value(&event).unwrap_or_default();
|
||||||
|
assert_eq!(json["event"], "cancel");
|
||||||
|
assert_eq!(json["modifiers"], json!(["shift", "ctrl"]));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,6 +8,15 @@ use tokio::io::AsyncBufReadExt;
|
|||||||
use crate::error::PiklError;
|
use crate::error::PiklError;
|
||||||
use crate::item::Item;
|
use crate::item::Item;
|
||||||
|
|
||||||
|
/// Parse a single line into a JSON Value. Tries JSON first,
|
||||||
|
/// falls back to wrapping as a string. Used by the streaming
|
||||||
|
/// stdin reader in the CLI binary, which sends raw Values
|
||||||
|
/// via `Action::AddItems`.
|
||||||
|
pub fn parse_line_to_value(line: &str) -> serde_json::Value {
|
||||||
|
serde_json::from_str::<serde_json::Value>(line)
|
||||||
|
.unwrap_or_else(|_| serde_json::Value::String(line.to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
/// Try to parse a line as JSON. Falls back to wrapping
|
/// Try to parse a line as JSON. Falls back to wrapping
|
||||||
/// it as a plain-text string. The `label_key` controls
|
/// it as a plain-text string. The `label_key` controls
|
||||||
/// which JSON key is used as the display label for object
|
/// which JSON key is used as the display label for object
|
||||||
@@ -116,4 +125,39 @@ mod tests {
|
|||||||
assert_eq!(items.len(), 1);
|
assert_eq!(items.len(), 1);
|
||||||
assert_eq!(items[0].label(), "quoted string");
|
assert_eq!(items[0].label(), "quoted string");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_line_to_value_plain_text() {
|
||||||
|
let v = parse_line_to_value("hello world");
|
||||||
|
assert_eq!(v, serde_json::Value::String("hello world".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_line_to_value_json_object() {
|
||||||
|
let v = parse_line_to_value(r#"{"label": "foo", "count": 42}"#);
|
||||||
|
assert!(v.is_object());
|
||||||
|
assert_eq!(v["label"], "foo");
|
||||||
|
assert_eq!(v["count"], 42);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_line_to_value_invalid_json() {
|
||||||
|
let v = parse_line_to_value("{not valid json}");
|
||||||
|
assert_eq!(
|
||||||
|
v,
|
||||||
|
serde_json::Value::String("{not valid json}".to_string())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_line_to_value_json_string() {
|
||||||
|
let v = parse_line_to_value(r#""quoted""#);
|
||||||
|
assert_eq!(v, serde_json::Value::String("quoted".to_string()));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_line_to_value_json_number() {
|
||||||
|
let v = parse_line_to_value("42");
|
||||||
|
assert_eq!(v, serde_json::json!(42));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
use crate::filter::Filter;
|
use crate::filter::Filter;
|
||||||
use crate::format::FormatTemplate;
|
use crate::format::FormatTemplate;
|
||||||
use crate::item::Item;
|
use crate::item::Item;
|
||||||
|
use crate::model::column::ColumnConfig;
|
||||||
use crate::model::traits::{Menu, MutableMenu};
|
use crate::model::traits::{Menu, MutableMenu};
|
||||||
use crate::pipeline::FilterPipeline;
|
use crate::pipeline::FilterPipeline;
|
||||||
|
|
||||||
@@ -19,6 +20,7 @@ pub struct JsonMenu {
|
|||||||
filter: FilterPipeline,
|
filter: FilterPipeline,
|
||||||
filter_fields: Vec<String>,
|
filter_fields: Vec<String>,
|
||||||
format_template: Option<FormatTemplate>,
|
format_template: Option<FormatTemplate>,
|
||||||
|
column_config: Option<ColumnConfig>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl JsonMenu {
|
impl JsonMenu {
|
||||||
@@ -36,6 +38,7 @@ impl JsonMenu {
|
|||||||
filter,
|
filter,
|
||||||
filter_fields: vec!["label".to_string()],
|
filter_fields: vec!["label".to_string()],
|
||||||
format_template: None,
|
format_template: None,
|
||||||
|
column_config: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,6 +55,11 @@ impl JsonMenu {
|
|||||||
self.format_template = Some(template);
|
self.format_template = Some(template);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Set the column config for table mode rendering.
|
||||||
|
pub fn set_column_config(&mut self, config: ColumnConfig) {
|
||||||
|
self.column_config = Some(config);
|
||||||
|
}
|
||||||
|
|
||||||
/// Rebuild the filter pipeline from scratch. Called after
|
/// Rebuild the filter pipeline from scratch. Called after
|
||||||
/// filter_fields change or item mutations.
|
/// filter_fields change or item mutations.
|
||||||
fn rebuild_pipeline(&mut self) {
|
fn rebuild_pipeline(&mut self) {
|
||||||
@@ -132,11 +140,25 @@ impl Menu for JsonMenu {
|
|||||||
self.filter.matched_index(filtered_index)
|
self.filter.matched_index(filtered_index)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn serialize_original(&self, original_index: usize) -> Option<&serde_json::Value> {
|
||||||
|
self.items.get(original_index).map(|item| &item.value)
|
||||||
|
}
|
||||||
|
|
||||||
fn formatted_label(&self, filtered_index: usize) -> Option<String> {
|
fn formatted_label(&self, filtered_index: usize) -> Option<String> {
|
||||||
let template = self.format_template.as_ref()?;
|
let template = self.format_template.as_ref()?;
|
||||||
let orig_idx = self.filter.matched_index(filtered_index)?;
|
let orig_idx = self.filter.matched_index(filtered_index)?;
|
||||||
Some(template.render(&self.items[orig_idx].value))
|
Some(template.render(&self.items[orig_idx].value))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn column_values(&self, filtered_index: usize) -> Option<Vec<String>> {
|
||||||
|
let config = self.column_config.as_ref()?;
|
||||||
|
let orig_idx = self.filter.matched_index(filtered_index)?;
|
||||||
|
Some(config.resolve_values(&self.items[orig_idx].value))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn column_config(&self) -> Option<&ColumnConfig> {
|
||||||
|
self.column_config.as_ref()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl MutableMenu for JsonMenu {
|
impl MutableMenu for JsonMenu {
|
||||||
@@ -173,7 +195,11 @@ impl MutableMenu for JsonMenu {
|
|||||||
self.items.remove(idx);
|
self.items.remove(idx);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
tracing::debug!(count, remaining = self.items.len(), "items removed from menu");
|
tracing::debug!(
|
||||||
|
count,
|
||||||
|
remaining = self.items.len(),
|
||||||
|
"items removed from menu"
|
||||||
|
);
|
||||||
self.rebuild_pipeline();
|
self.rebuild_pipeline();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,3 +1,4 @@
|
|||||||
|
pub mod csv_input;
|
||||||
pub mod debounce;
|
pub mod debounce;
|
||||||
pub mod hook;
|
pub mod hook;
|
||||||
pub mod input;
|
pub mod input;
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ pub mod parse;
|
|||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
use crate::error::PiklError;
|
use crate::error::PiklError;
|
||||||
use crate::event::Action;
|
use crate::event::{Action, ModifiedAction, Modifiers};
|
||||||
|
|
||||||
pub use error::{ScriptError, ScriptErrorKind};
|
pub use error::{ScriptError, ScriptErrorKind};
|
||||||
pub use parse::{load_script, parse_action};
|
pub use parse::{load_script, parse_action};
|
||||||
@@ -22,7 +22,7 @@ pub use parse::{load_script, parse_action};
|
|||||||
/// A parsed action from an action-fd script.
|
/// A parsed action from an action-fd script.
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub enum ScriptAction {
|
pub enum ScriptAction {
|
||||||
Core(Action),
|
Core(Action, Modifiers),
|
||||||
ShowUi,
|
ShowUi,
|
||||||
ShowTui,
|
ShowTui,
|
||||||
ShowGui,
|
ShowGui,
|
||||||
@@ -41,14 +41,14 @@ pub enum ShowAction {
|
|||||||
/// Returns the Show* variant if the script ends with one, or None.
|
/// Returns the Show* variant if the script ends with one, or None.
|
||||||
pub async fn run_script(
|
pub async fn run_script(
|
||||||
script: Vec<ScriptAction>,
|
script: Vec<ScriptAction>,
|
||||||
tx: &mpsc::Sender<Action>,
|
tx: &mpsc::Sender<ModifiedAction>,
|
||||||
) -> Result<Option<ShowAction>, PiklError> {
|
) -> Result<Option<ShowAction>, PiklError> {
|
||||||
let mut show = None;
|
let mut show = None;
|
||||||
|
|
||||||
for action in script {
|
for action in script {
|
||||||
match action {
|
match action {
|
||||||
ScriptAction::Core(action) => {
|
ScriptAction::Core(action, modifiers) => {
|
||||||
tx.send(action)
|
tx.send(ModifiedAction { action, modifiers })
|
||||||
.await
|
.await
|
||||||
.map_err(|_| PiklError::ChannelClosed)?;
|
.map_err(|_| PiklError::ChannelClosed)?;
|
||||||
}
|
}
|
||||||
@@ -69,9 +69,9 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn run_script_sends_actions_in_order() {
|
async fn run_script_sends_actions_in_order() {
|
||||||
let script = vec![
|
let script = vec![
|
||||||
ScriptAction::Core(Action::UpdateFilter("hello".to_string())),
|
ScriptAction::Core(Action::UpdateFilter("hello".to_string()), Modifiers::default()),
|
||||||
ScriptAction::Core(Action::MoveDown(1)),
|
ScriptAction::Core(Action::MoveDown(1), Modifiers::default()),
|
||||||
ScriptAction::Core(Action::Confirm),
|
ScriptAction::Core(Action::Confirm, Modifiers::default()),
|
||||||
];
|
];
|
||||||
let (tx, mut rx) = mpsc::channel(16);
|
let (tx, mut rx) = mpsc::channel(16);
|
||||||
let result = run_script(script, &tx).await;
|
let result = run_script(script, &tx).await;
|
||||||
@@ -79,14 +79,14 @@ mod tests {
|
|||||||
assert_eq!(result.unwrap_or(Some(ShowAction::Ui)), None);
|
assert_eq!(result.unwrap_or(Some(ShowAction::Ui)), None);
|
||||||
|
|
||||||
// Verify order
|
// Verify order
|
||||||
assert!(matches!(rx.recv().await, Some(Action::UpdateFilter(s)) if s == "hello"));
|
assert!(matches!(rx.recv().await, Some(ModifiedAction { action: Action::UpdateFilter(s), .. }) if s == "hello"));
|
||||||
assert!(matches!(rx.recv().await, Some(Action::MoveDown(1))));
|
assert!(matches!(rx.recv().await, Some(ModifiedAction { action: Action::MoveDown(1), .. })));
|
||||||
assert!(matches!(rx.recv().await, Some(Action::Confirm)));
|
assert!(matches!(rx.recv().await, Some(ModifiedAction { action: Action::Confirm, .. })));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn run_script_returns_none_without_show() {
|
async fn run_script_returns_none_without_show() {
|
||||||
let script = vec![ScriptAction::Core(Action::Confirm)];
|
let script = vec![ScriptAction::Core(Action::Confirm, Modifiers::default())];
|
||||||
let (tx, _rx) = mpsc::channel(16);
|
let (tx, _rx) = mpsc::channel(16);
|
||||||
let result = run_script(script, &tx).await;
|
let result = run_script(script, &tx).await;
|
||||||
assert!(result.is_ok());
|
assert!(result.is_ok());
|
||||||
@@ -96,7 +96,7 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn run_script_returns_show_action() {
|
async fn run_script_returns_show_action() {
|
||||||
let script = vec![
|
let script = vec![
|
||||||
ScriptAction::Core(Action::UpdateFilter("test".to_string())),
|
ScriptAction::Core(Action::UpdateFilter("test".to_string()), Modifiers::default()),
|
||||||
ScriptAction::ShowUi,
|
ScriptAction::ShowUi,
|
||||||
];
|
];
|
||||||
let (tx, _rx) = mpsc::channel(16);
|
let (tx, _rx) = mpsc::channel(16);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
use std::io::BufRead;
|
use std::io::BufRead;
|
||||||
|
|
||||||
use crate::event::{Action, Mode};
|
use crate::event::{Action, Mode, Modifiers};
|
||||||
|
|
||||||
use super::ScriptAction;
|
use super::ScriptAction;
|
||||||
use super::error::{ScriptError, ScriptErrorKind};
|
use super::error::{ScriptError, ScriptErrorKind};
|
||||||
@@ -82,6 +82,30 @@ fn parse_positive_u16(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Parse modifier prefixes from the start of a trimmed line.
|
||||||
|
/// Returns (modifiers, remaining_line).
|
||||||
|
fn parse_modifier_prefix(line: &str) -> Result<(Modifiers, &str), Option<String>> {
|
||||||
|
let mut mods = Modifiers::default();
|
||||||
|
let mut rest = line;
|
||||||
|
|
||||||
|
while let Some(stripped) = rest.strip_prefix('+') {
|
||||||
|
// Find the end of this modifier token (next space or '+')
|
||||||
|
let end = stripped
|
||||||
|
.find([' ', '+'])
|
||||||
|
.unwrap_or(stripped.len());
|
||||||
|
let token = &stripped[..end];
|
||||||
|
match token {
|
||||||
|
"shift" => mods.shift = true,
|
||||||
|
"ctrl" => mods.ctrl = true,
|
||||||
|
"alt" => mods.alt = true,
|
||||||
|
other => return Err(Some(format!("unknown modifier '+{other}'"))),
|
||||||
|
}
|
||||||
|
rest = stripped[end..].trim_start();
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok((mods, rest))
|
||||||
|
}
|
||||||
|
|
||||||
/// Parse a single line into a ScriptAction.
|
/// Parse a single line into a ScriptAction.
|
||||||
pub fn parse_action(line_number: usize, line: &str) -> Result<ScriptAction, ScriptError> {
|
pub fn parse_action(line_number: usize, line: &str) -> Result<ScriptAction, ScriptError> {
|
||||||
let trimmed = line.trim();
|
let trimmed = line.trim();
|
||||||
@@ -90,41 +114,66 @@ pub fn parse_action(line_number: usize, line: &str) -> Result<ScriptAction, Scri
|
|||||||
return Ok(ScriptAction::Comment);
|
return Ok(ScriptAction::Comment);
|
||||||
}
|
}
|
||||||
|
|
||||||
let (cmd, arg) = match trimmed.split_once(' ') {
|
// Parse optional modifier prefixes (+shift, +ctrl, +alt)
|
||||||
|
let (modifiers, rest) = parse_modifier_prefix(trimmed).map_err(|msg| ScriptError {
|
||||||
|
line: line_number,
|
||||||
|
source_line: line.to_string(),
|
||||||
|
kind: ScriptErrorKind::InvalidArgument {
|
||||||
|
action: String::new(),
|
||||||
|
message: msg.unwrap_or_default(),
|
||||||
|
},
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if rest.is_empty() {
|
||||||
|
return Err(ScriptError {
|
||||||
|
line: line_number,
|
||||||
|
source_line: line.to_string(),
|
||||||
|
kind: ScriptErrorKind::InvalidArgument {
|
||||||
|
action: String::new(),
|
||||||
|
message: "modifier prefix without an action".to_string(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let (cmd, arg) = match rest.split_once(' ') {
|
||||||
Some((c, a)) => (c, Some(a)),
|
Some((c, a)) => (c, Some(a)),
|
||||||
None => (trimmed, None),
|
None => (rest, None),
|
||||||
|
};
|
||||||
|
|
||||||
|
let core = |action: Action| -> Result<ScriptAction, ScriptError> {
|
||||||
|
Ok(ScriptAction::Core(action, modifiers))
|
||||||
};
|
};
|
||||||
|
|
||||||
match cmd {
|
match cmd {
|
||||||
"filter" => {
|
"filter" => {
|
||||||
let text = arg.unwrap_or("");
|
let text = arg.unwrap_or("");
|
||||||
Ok(ScriptAction::Core(Action::UpdateFilter(text.to_string())))
|
core(Action::UpdateFilter(text.to_string()))
|
||||||
}
|
}
|
||||||
"move-up" => {
|
"move-up" => {
|
||||||
let n = parse_count(line_number, line, "move-up", arg)?;
|
let n = parse_count(line_number, line, "move-up", arg)?;
|
||||||
Ok(ScriptAction::Core(Action::MoveUp(n)))
|
core(Action::MoveUp(n))
|
||||||
}
|
}
|
||||||
"move-down" => {
|
"move-down" => {
|
||||||
let n = parse_count(line_number, line, "move-down", arg)?;
|
let n = parse_count(line_number, line, "move-down", arg)?;
|
||||||
Ok(ScriptAction::Core(Action::MoveDown(n)))
|
core(Action::MoveDown(n))
|
||||||
}
|
}
|
||||||
"move-to-top" => Ok(ScriptAction::Core(Action::MoveToTop)),
|
"move-to-top" => core(Action::MoveToTop),
|
||||||
"move-to-bottom" => Ok(ScriptAction::Core(Action::MoveToBottom)),
|
"move-to-bottom" => core(Action::MoveToBottom),
|
||||||
"page-up" => {
|
"page-up" => {
|
||||||
let n = parse_count(line_number, line, "page-up", arg)?;
|
let n = parse_count(line_number, line, "page-up", arg)?;
|
||||||
Ok(ScriptAction::Core(Action::PageUp(n)))
|
core(Action::PageUp(n))
|
||||||
}
|
}
|
||||||
"page-down" => {
|
"page-down" => {
|
||||||
let n = parse_count(line_number, line, "page-down", arg)?;
|
let n = parse_count(line_number, line, "page-down", arg)?;
|
||||||
Ok(ScriptAction::Core(Action::PageDown(n)))
|
core(Action::PageDown(n))
|
||||||
}
|
}
|
||||||
"half-page-up" => {
|
"half-page-up" => {
|
||||||
let n = parse_count(line_number, line, "half-page-up", arg)?;
|
let n = parse_count(line_number, line, "half-page-up", arg)?;
|
||||||
Ok(ScriptAction::Core(Action::HalfPageUp(n)))
|
core(Action::HalfPageUp(n))
|
||||||
}
|
}
|
||||||
"half-page-down" => {
|
"half-page-down" => {
|
||||||
let n = parse_count(line_number, line, "half-page-down", arg)?;
|
let n = parse_count(line_number, line, "half-page-down", arg)?;
|
||||||
Ok(ScriptAction::Core(Action::HalfPageDown(n)))
|
core(Action::HalfPageDown(n))
|
||||||
}
|
}
|
||||||
"set-mode" => {
|
"set-mode" => {
|
||||||
let Some(mode_str) = arg else {
|
let Some(mode_str) = arg else {
|
||||||
@@ -132,29 +181,36 @@ pub fn parse_action(line_number: usize, line: &str) -> Result<ScriptAction, Scri
|
|||||||
line_number,
|
line_number,
|
||||||
line,
|
line,
|
||||||
"set-mode",
|
"set-mode",
|
||||||
"missing mode value (insert or normal)".to_string(),
|
"missing mode value (insert, normal, or visual)".to_string(),
|
||||||
));
|
));
|
||||||
};
|
};
|
||||||
match mode_str.trim() {
|
match mode_str.trim() {
|
||||||
"insert" => Ok(ScriptAction::Core(Action::SetMode(Mode::Insert))),
|
"insert" => core(Action::SetMode(Mode::Insert)),
|
||||||
"normal" => Ok(ScriptAction::Core(Action::SetMode(Mode::Normal))),
|
"normal" => core(Action::SetMode(Mode::Normal)),
|
||||||
|
"visual" => core(Action::SetMode(Mode::Visual)),
|
||||||
other => Err(invalid_arg(
|
other => Err(invalid_arg(
|
||||||
line_number,
|
line_number,
|
||||||
line,
|
line,
|
||||||
"set-mode",
|
"set-mode",
|
||||||
format!("unknown mode '{other}', expected insert or normal"),
|
format!("unknown mode '{other}', expected insert, normal, or visual"),
|
||||||
)),
|
)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
"confirm" => Ok(ScriptAction::Core(Action::Confirm)),
|
"toggle-select" => core(Action::ToggleSelect),
|
||||||
"cancel" => Ok(ScriptAction::Core(Action::Cancel)),
|
"select-all" => core(Action::SelectAll),
|
||||||
|
"deselect-all" => core(Action::ClearSelections),
|
||||||
|
"undo-selection" => core(Action::UndoSelection),
|
||||||
|
"redo-selection" => core(Action::RedoSelection),
|
||||||
|
"confirm" => core(Action::Confirm),
|
||||||
|
"cancel" => core(Action::Cancel),
|
||||||
"resize" => {
|
"resize" => {
|
||||||
let height = parse_positive_u16(line_number, line, "resize", arg)?;
|
let height = parse_positive_u16(line_number, line, "resize", arg)?;
|
||||||
Ok(ScriptAction::Core(Action::Resize { height }))
|
core(Action::Resize { height })
|
||||||
}
|
}
|
||||||
"show-ui" => Ok(ScriptAction::ShowUi),
|
"show-ui" => Ok(ScriptAction::ShowUi),
|
||||||
"show-tui" => Ok(ScriptAction::ShowTui),
|
"show-tui" => Ok(ScriptAction::ShowTui),
|
||||||
"show-gui" => Ok(ScriptAction::ShowGui),
|
"show-gui" => Ok(ScriptAction::ShowGui),
|
||||||
|
"streaming-done" => core(Action::StreamingDone),
|
||||||
_ => Err(ScriptError {
|
_ => Err(ScriptError {
|
||||||
line: line_number,
|
line: line_number,
|
||||||
source_line: line.to_string(),
|
source_line: line.to_string(),
|
||||||
@@ -237,7 +293,7 @@ mod tests {
|
|||||||
assert!(result.is_ok());
|
assert!(result.is_ok());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
result.unwrap_or(ScriptAction::Comment),
|
result.unwrap_or(ScriptAction::Comment),
|
||||||
ScriptAction::Core(Action::UpdateFilter("hello world".to_string()))
|
ScriptAction::Core(Action::UpdateFilter("hello world".to_string()), Modifiers::default())
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -247,7 +303,7 @@ mod tests {
|
|||||||
assert!(result.is_ok());
|
assert!(result.is_ok());
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
result.unwrap_or(ScriptAction::Comment),
|
result.unwrap_or(ScriptAction::Comment),
|
||||||
ScriptAction::Core(Action::UpdateFilter(String::new()))
|
ScriptAction::Core(Action::UpdateFilter(String::new()), Modifiers::default())
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,27 +311,27 @@ mod tests {
|
|||||||
fn parse_movement_actions() {
|
fn parse_movement_actions() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
parse_action(1, "move-up").unwrap_or(ScriptAction::Comment),
|
parse_action(1, "move-up").unwrap_or(ScriptAction::Comment),
|
||||||
ScriptAction::Core(Action::MoveUp(1))
|
ScriptAction::Core(Action::MoveUp(1), Modifiers::default())
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
parse_action(1, "move-down").unwrap_or(ScriptAction::Comment),
|
parse_action(1, "move-down").unwrap_or(ScriptAction::Comment),
|
||||||
ScriptAction::Core(Action::MoveDown(1))
|
ScriptAction::Core(Action::MoveDown(1), Modifiers::default())
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
parse_action(1, "move-to-top").unwrap_or(ScriptAction::Comment),
|
parse_action(1, "move-to-top").unwrap_or(ScriptAction::Comment),
|
||||||
ScriptAction::Core(Action::MoveToTop)
|
ScriptAction::Core(Action::MoveToTop, Modifiers::default())
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
parse_action(1, "move-to-bottom").unwrap_or(ScriptAction::Comment),
|
parse_action(1, "move-to-bottom").unwrap_or(ScriptAction::Comment),
|
||||||
ScriptAction::Core(Action::MoveToBottom)
|
ScriptAction::Core(Action::MoveToBottom, Modifiers::default())
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
parse_action(1, "page-up").unwrap_or(ScriptAction::Comment),
|
parse_action(1, "page-up").unwrap_or(ScriptAction::Comment),
|
||||||
ScriptAction::Core(Action::PageUp(1))
|
ScriptAction::Core(Action::PageUp(1), Modifiers::default())
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
parse_action(1, "page-down").unwrap_or(ScriptAction::Comment),
|
parse_action(1, "page-down").unwrap_or(ScriptAction::Comment),
|
||||||
ScriptAction::Core(Action::PageDown(1))
|
ScriptAction::Core(Action::PageDown(1), Modifiers::default())
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,19 +339,19 @@ mod tests {
|
|||||||
fn parse_movement_with_count() {
|
fn parse_movement_with_count() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
parse_action(1, "move-up 5").unwrap_or(ScriptAction::Comment),
|
parse_action(1, "move-up 5").unwrap_or(ScriptAction::Comment),
|
||||||
ScriptAction::Core(Action::MoveUp(5))
|
ScriptAction::Core(Action::MoveUp(5), Modifiers::default())
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
parse_action(1, "move-down 3").unwrap_or(ScriptAction::Comment),
|
parse_action(1, "move-down 3").unwrap_or(ScriptAction::Comment),
|
||||||
ScriptAction::Core(Action::MoveDown(3))
|
ScriptAction::Core(Action::MoveDown(3), Modifiers::default())
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
parse_action(1, "page-up 2").unwrap_or(ScriptAction::Comment),
|
parse_action(1, "page-up 2").unwrap_or(ScriptAction::Comment),
|
||||||
ScriptAction::Core(Action::PageUp(2))
|
ScriptAction::Core(Action::PageUp(2), Modifiers::default())
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
parse_action(1, "page-down 10").unwrap_or(ScriptAction::Comment),
|
parse_action(1, "page-down 10").unwrap_or(ScriptAction::Comment),
|
||||||
ScriptAction::Core(Action::PageDown(10))
|
ScriptAction::Core(Action::PageDown(10), Modifiers::default())
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -317,11 +373,11 @@ mod tests {
|
|||||||
fn parse_confirm_cancel() {
|
fn parse_confirm_cancel() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
parse_action(1, "confirm").unwrap_or(ScriptAction::Comment),
|
parse_action(1, "confirm").unwrap_or(ScriptAction::Comment),
|
||||||
ScriptAction::Core(Action::Confirm)
|
ScriptAction::Core(Action::Confirm, Modifiers::default())
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
parse_action(1, "cancel").unwrap_or(ScriptAction::Comment),
|
parse_action(1, "cancel").unwrap_or(ScriptAction::Comment),
|
||||||
ScriptAction::Core(Action::Cancel)
|
ScriptAction::Core(Action::Cancel, Modifiers::default())
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -329,7 +385,7 @@ mod tests {
|
|||||||
fn parse_resize_valid() {
|
fn parse_resize_valid() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
parse_action(1, "resize 25").unwrap_or(ScriptAction::Comment),
|
parse_action(1, "resize 25").unwrap_or(ScriptAction::Comment),
|
||||||
ScriptAction::Core(Action::Resize { height: 25 })
|
ScriptAction::Core(Action::Resize { height: 25 }, Modifiers::default())
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -398,19 +454,19 @@ mod tests {
|
|||||||
fn parse_half_page_actions() {
|
fn parse_half_page_actions() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
parse_action(1, "half-page-up").unwrap_or(ScriptAction::Comment),
|
parse_action(1, "half-page-up").unwrap_or(ScriptAction::Comment),
|
||||||
ScriptAction::Core(Action::HalfPageUp(1))
|
ScriptAction::Core(Action::HalfPageUp(1), Modifiers::default())
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
parse_action(1, "half-page-down").unwrap_or(ScriptAction::Comment),
|
parse_action(1, "half-page-down").unwrap_or(ScriptAction::Comment),
|
||||||
ScriptAction::Core(Action::HalfPageDown(1))
|
ScriptAction::Core(Action::HalfPageDown(1), Modifiers::default())
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
parse_action(1, "half-page-up 3").unwrap_or(ScriptAction::Comment),
|
parse_action(1, "half-page-up 3").unwrap_or(ScriptAction::Comment),
|
||||||
ScriptAction::Core(Action::HalfPageUp(3))
|
ScriptAction::Core(Action::HalfPageUp(3), Modifiers::default())
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
parse_action(1, "half-page-down 2").unwrap_or(ScriptAction::Comment),
|
parse_action(1, "half-page-down 2").unwrap_or(ScriptAction::Comment),
|
||||||
ScriptAction::Core(Action::HalfPageDown(2))
|
ScriptAction::Core(Action::HalfPageDown(2), Modifiers::default())
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -424,11 +480,11 @@ mod tests {
|
|||||||
fn parse_set_mode() {
|
fn parse_set_mode() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
parse_action(1, "set-mode insert").unwrap_or(ScriptAction::Comment),
|
parse_action(1, "set-mode insert").unwrap_or(ScriptAction::Comment),
|
||||||
ScriptAction::Core(Action::SetMode(Mode::Insert))
|
ScriptAction::Core(Action::SetMode(Mode::Insert), Modifiers::default())
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
parse_action(1, "set-mode normal").unwrap_or(ScriptAction::Comment),
|
parse_action(1, "set-mode normal").unwrap_or(ScriptAction::Comment),
|
||||||
ScriptAction::Core(Action::SetMode(Mode::Normal))
|
ScriptAction::Core(Action::SetMode(Mode::Normal), Modifiers::default())
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -439,7 +495,10 @@ mod tests {
|
|||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_set_mode_invalid() {
|
fn parse_set_mode_invalid() {
|
||||||
assert!(parse_action(1, "set-mode visual").is_err());
|
assert_eq!(
|
||||||
|
parse_action(1, "set-mode visual").unwrap_or(ScriptAction::Comment),
|
||||||
|
ScriptAction::Core(Action::SetMode(Mode::Visual), Modifiers::default())
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -509,7 +568,7 @@ mod tests {
|
|||||||
ParsedLine {
|
ParsedLine {
|
||||||
line_number: 1,
|
line_number: 1,
|
||||||
source: "filter test".into(),
|
source: "filter test".into(),
|
||||||
action: ScriptAction::Core(Action::UpdateFilter("test".into())),
|
action: ScriptAction::Core(Action::UpdateFilter("test".into()), Modifiers::default()),
|
||||||
},
|
},
|
||||||
ParsedLine {
|
ParsedLine {
|
||||||
line_number: 2,
|
line_number: 2,
|
||||||
@@ -531,7 +590,7 @@ mod tests {
|
|||||||
ParsedLine {
|
ParsedLine {
|
||||||
line_number: 2,
|
line_number: 2,
|
||||||
source: "confirm".into(),
|
source: "confirm".into(),
|
||||||
action: ScriptAction::Core(Action::Confirm),
|
action: ScriptAction::Core(Action::Confirm, Modifiers::default()),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
let err = validate_show_last(&actions).unwrap_err();
|
let err = validate_show_last(&actions).unwrap_err();
|
||||||
@@ -545,7 +604,7 @@ mod tests {
|
|||||||
ParsedLine {
|
ParsedLine {
|
||||||
line_number: 1,
|
line_number: 1,
|
||||||
source: "filter x".into(),
|
source: "filter x".into(),
|
||||||
action: ScriptAction::Core(Action::UpdateFilter("x".into())),
|
action: ScriptAction::Core(Action::UpdateFilter("x".into()), Modifiers::default()),
|
||||||
},
|
},
|
||||||
ParsedLine {
|
ParsedLine {
|
||||||
line_number: 2,
|
line_number: 2,
|
||||||
@@ -555,7 +614,7 @@ mod tests {
|
|||||||
ParsedLine {
|
ParsedLine {
|
||||||
line_number: 3,
|
line_number: 3,
|
||||||
source: "confirm".into(),
|
source: "confirm".into(),
|
||||||
action: ScriptAction::Core(Action::Confirm),
|
action: ScriptAction::Core(Action::Confirm, Modifiers::default()),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
let err = validate_show_last(&actions).unwrap_err();
|
let err = validate_show_last(&actions).unwrap_err();
|
||||||
@@ -579,12 +638,12 @@ mod tests {
|
|||||||
ParsedLine {
|
ParsedLine {
|
||||||
line_number: 1,
|
line_number: 1,
|
||||||
source: "filter x".into(),
|
source: "filter x".into(),
|
||||||
action: ScriptAction::Core(Action::UpdateFilter("x".into())),
|
action: ScriptAction::Core(Action::UpdateFilter("x".into()), Modifiers::default()),
|
||||||
},
|
},
|
||||||
ParsedLine {
|
ParsedLine {
|
||||||
line_number: 2,
|
line_number: 2,
|
||||||
source: "confirm".into(),
|
source: "confirm".into(),
|
||||||
action: ScriptAction::Core(Action::Confirm),
|
action: ScriptAction::Core(Action::Confirm, Modifiers::default()),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
assert!(validate_show_last(&actions).is_ok());
|
assert!(validate_show_last(&actions).is_ok());
|
||||||
@@ -645,6 +704,38 @@ mod tests {
|
|||||||
assert!(parse_action(1, "half-page-up xyz").is_err());
|
assert!(parse_action(1, "half-page-up xyz").is_err());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_selection_actions() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_action(1, "toggle-select").unwrap_or(ScriptAction::Comment),
|
||||||
|
ScriptAction::Core(Action::ToggleSelect, Modifiers::default())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_action(1, "select-all").unwrap_or(ScriptAction::Comment),
|
||||||
|
ScriptAction::Core(Action::SelectAll, Modifiers::default())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_action(1, "deselect-all").unwrap_or(ScriptAction::Comment),
|
||||||
|
ScriptAction::Core(Action::ClearSelections, Modifiers::default())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_action(1, "undo-selection").unwrap_or(ScriptAction::Comment),
|
||||||
|
ScriptAction::Core(Action::UndoSelection, Modifiers::default())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
parse_action(1, "redo-selection").unwrap_or(ScriptAction::Comment),
|
||||||
|
ScriptAction::Core(Action::RedoSelection, Modifiers::default())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_streaming_done() {
|
||||||
|
assert_eq!(
|
||||||
|
parse_action(1, "streaming-done").unwrap_or(ScriptAction::Comment),
|
||||||
|
ScriptAction::Core(Action::StreamingDone, Modifiers::default())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn parse_set_mode_wrong_case() {
|
fn parse_set_mode_wrong_case() {
|
||||||
assert!(parse_action(1, "set-mode Insert").is_err());
|
assert!(parse_action(1, "set-mode Insert").is_err());
|
||||||
@@ -657,11 +748,11 @@ mod tests {
|
|||||||
// Extra whitespace around the mode value should be trimmed
|
// Extra whitespace around the mode value should be trimmed
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
parse_action(1, "set-mode insert ").unwrap_or(ScriptAction::Comment),
|
parse_action(1, "set-mode insert ").unwrap_or(ScriptAction::Comment),
|
||||||
ScriptAction::Core(Action::SetMode(Mode::Insert))
|
ScriptAction::Core(Action::SetMode(Mode::Insert), Modifiers::default())
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
parse_action(1, "set-mode normal ").unwrap_or(ScriptAction::Comment),
|
parse_action(1, "set-mode normal ").unwrap_or(ScriptAction::Comment),
|
||||||
ScriptAction::Core(Action::SetMode(Mode::Normal))
|
ScriptAction::Core(Action::SetMode(Mode::Normal), Modifiers::default())
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -672,16 +763,96 @@ mod tests {
|
|||||||
assert!(result.is_ok());
|
assert!(result.is_ok());
|
||||||
let actions = result.unwrap_or_default();
|
let actions = result.unwrap_or_default();
|
||||||
assert_eq!(actions.len(), 5);
|
assert_eq!(actions.len(), 5);
|
||||||
assert_eq!(actions[0], ScriptAction::Core(Action::HalfPageUp(2)));
|
assert_eq!(actions[0], ScriptAction::Core(Action::HalfPageUp(2), Modifiers::default()));
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
actions[1],
|
actions[1],
|
||||||
ScriptAction::Core(Action::SetMode(Mode::Normal))
|
ScriptAction::Core(Action::SetMode(Mode::Normal), Modifiers::default())
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
actions[2],
|
actions[2],
|
||||||
ScriptAction::Core(Action::SetMode(Mode::Insert))
|
ScriptAction::Core(Action::SetMode(Mode::Insert), Modifiers::default())
|
||||||
);
|
);
|
||||||
assert_eq!(actions[3], ScriptAction::Core(Action::HalfPageDown(1)));
|
assert_eq!(actions[3], ScriptAction::Core(Action::HalfPageDown(1), Modifiers::default()));
|
||||||
assert_eq!(actions[4], ScriptAction::Core(Action::Confirm));
|
assert_eq!(actions[4], ScriptAction::Core(Action::Confirm, Modifiers::default()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- Modifier prefix tests --
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_shift_prefix() {
|
||||||
|
let result = parse_action(1, "+shift confirm");
|
||||||
|
assert!(result.is_ok());
|
||||||
|
let expected_mods = Modifiers {
|
||||||
|
shift: true,
|
||||||
|
ctrl: false,
|
||||||
|
alt: false,
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
result.unwrap_or(ScriptAction::Comment),
|
||||||
|
ScriptAction::Core(Action::Confirm, expected_mods)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_multiple_modifier_prefixes() {
|
||||||
|
let result = parse_action(1, "+ctrl+alt move-down 3");
|
||||||
|
assert!(result.is_ok());
|
||||||
|
let expected_mods = Modifiers {
|
||||||
|
shift: false,
|
||||||
|
ctrl: true,
|
||||||
|
alt: true,
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
result.unwrap_or(ScriptAction::Comment),
|
||||||
|
ScriptAction::Core(Action::MoveDown(3), expected_mods)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_all_modifier_prefixes() {
|
||||||
|
let result = parse_action(1, "+shift+ctrl+alt cancel");
|
||||||
|
assert!(result.is_ok());
|
||||||
|
let expected_mods = Modifiers {
|
||||||
|
shift: true,
|
||||||
|
ctrl: true,
|
||||||
|
alt: true,
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
result.unwrap_or(ScriptAction::Comment),
|
||||||
|
ScriptAction::Core(Action::Cancel, expected_mods)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_unknown_modifier_is_error() {
|
||||||
|
let result = parse_action(1, "+super confirm");
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_modifier_without_action_is_error() {
|
||||||
|
let result = parse_action(1, "+shift");
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_no_modifier_prefix_has_empty_modifiers() {
|
||||||
|
let result = parse_action(1, "confirm");
|
||||||
|
assert!(result.is_ok());
|
||||||
|
assert_eq!(
|
||||||
|
result.unwrap_or(ScriptAction::Comment),
|
||||||
|
ScriptAction::Core(Action::Confirm, Modifiers::default())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_modifier_with_space_separated() {
|
||||||
|
// "+shift confirm" should work
|
||||||
|
let result = parse_action(1, "+shift confirm");
|
||||||
|
assert!(result.is_ok());
|
||||||
|
if let Ok(ScriptAction::Core(_, mods)) = result {
|
||||||
|
assert!(mods.shift);
|
||||||
|
assert!(!mods.ctrl);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -286,6 +286,48 @@ pikl_tests! {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
menu mod multi_select_basic {
|
||||||
|
items: ["alpha", "bravo", "charlie", "delta"];
|
||||||
|
multi: true;
|
||||||
|
|
||||||
|
test toggle_one_then_confirm {
|
||||||
|
actions: [toggle-select, confirm]
|
||||||
|
selected_items: ["alpha"]
|
||||||
|
}
|
||||||
|
|
||||||
|
test toggle_two_then_confirm {
|
||||||
|
actions: [toggle-select, move-down, move-down, toggle-select, confirm]
|
||||||
|
selected_items: ["alpha", "charlie"]
|
||||||
|
}
|
||||||
|
|
||||||
|
test no_selection_falls_back_to_cursor {
|
||||||
|
actions: [move-down, confirm]
|
||||||
|
selected: "bravo"
|
||||||
|
}
|
||||||
|
|
||||||
|
test select_all_then_confirm {
|
||||||
|
actions: [select-all, confirm]
|
||||||
|
selected_items: ["alpha", "bravo", "charlie", "delta"]
|
||||||
|
}
|
||||||
|
|
||||||
|
test clear_selections_resets {
|
||||||
|
actions: [toggle-select, move-down, toggle-select, clear-selections, confirm]
|
||||||
|
// Falls back to cursor item (bravo, since we moved down once)
|
||||||
|
selected: "bravo"
|
||||||
|
}
|
||||||
|
|
||||||
|
test undo_restores_previous {
|
||||||
|
actions: [toggle-select, move-down, toggle-select, undo-selection, confirm]
|
||||||
|
// Undo reverts the second toggle, only alpha remains
|
||||||
|
selected_items: ["alpha"]
|
||||||
|
}
|
||||||
|
|
||||||
|
test redo_after_undo {
|
||||||
|
actions: [toggle-select, move-down, toggle-select, undo-selection, redo-selection, confirm]
|
||||||
|
selected_items: ["alpha", "bravo"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
nav mod half_page_small_height {
|
nav mod half_page_small_height {
|
||||||
viewport: { height: 2, count: 10 };
|
viewport: { height: 2, count: 10 };
|
||||||
|
|
||||||
|
|||||||
18
crates/pikl-gui/Cargo.toml
Normal file
18
crates/pikl-gui/Cargo.toml
Normal 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
681
crates/pikl-gui/src/lib.rs
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,6 +24,7 @@ fn gen_module(module: &TestModule) -> syn::Result<TokenStream> {
|
|||||||
TestKind::Filter => gen_filter(case, &module.fixtures)?,
|
TestKind::Filter => gen_filter(case, &module.fixtures)?,
|
||||||
TestKind::Nav => gen_nav(case, &module.fixtures)?,
|
TestKind::Nav => gen_nav(case, &module.fixtures)?,
|
||||||
TestKind::Menu => gen_menu(case, &module.fixtures)?,
|
TestKind::Menu => gen_menu(case, &module.fixtures)?,
|
||||||
|
TestKind::Ipc => gen_ipc(case, &module.fixtures)?,
|
||||||
};
|
};
|
||||||
test_fns.push(tokens);
|
test_fns.push(tokens);
|
||||||
}
|
}
|
||||||
@@ -58,11 +59,20 @@ fn gen_imports(kind: TestKind) -> TokenStream {
|
|||||||
TestKind::Menu => {
|
TestKind::Menu => {
|
||||||
quote! {
|
quote! {
|
||||||
use pikl_core::item::Item;
|
use pikl_core::item::Item;
|
||||||
use pikl_core::event::{Action, MenuEvent, MenuResult};
|
use pikl_core::event::{Action, MenuEvent, MenuResult, ModifiedAction};
|
||||||
use pikl_core::menu::MenuRunner;
|
use pikl_core::menu::MenuRunner;
|
||||||
use pikl_core::json_menu::JsonMenu;
|
use pikl_core::json_menu::JsonMenu;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
TestKind::Ipc => {
|
||||||
|
quote! {
|
||||||
|
use pikl_core::item::Item;
|
||||||
|
use pikl_core::event::{Action, MenuEvent, MenuResult, ModifiedAction};
|
||||||
|
use pikl_core::menu::MenuRunner;
|
||||||
|
use pikl_core::json_menu::JsonMenu;
|
||||||
|
use crate::ipc::server::IpcServer;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,12 +102,15 @@ fn gen_headless(case: &TestCase, fixtures: &Fixtures) -> syn::Result<TokenStream
|
|||||||
// Build script string from actions
|
// Build script string from actions
|
||||||
let script = build_headless_script(&case.actions);
|
let script = build_headless_script(&case.actions);
|
||||||
|
|
||||||
// Build extra CLI args (e.g. --label-key)
|
// Build extra CLI args (e.g. --label-key, --multi)
|
||||||
let extra_args: Vec<TokenStream> = if let Some(ref key) = fixtures.label_key {
|
let mut extra_args: Vec<TokenStream> = Vec::new();
|
||||||
vec![quote! { "--label-key" }, quote! { #key }]
|
if let Some(ref key) = fixtures.label_key {
|
||||||
} else {
|
extra_args.push(quote! { "--label-key" });
|
||||||
Vec::new()
|
extra_args.push(quote! { #key });
|
||||||
};
|
}
|
||||||
|
if fixtures.multi == Some(true) {
|
||||||
|
extra_args.push(quote! { "--multi" });
|
||||||
|
}
|
||||||
|
|
||||||
// Build assertions
|
// Build assertions
|
||||||
let mut asserts = Vec::new();
|
let mut asserts = Vec::new();
|
||||||
@@ -138,6 +151,16 @@ fn gen_headless(case: &TestCase, fixtures: &Fixtures) -> syn::Result<TokenStream
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if asserts.is_empty() {
|
||||||
|
return Err(syn::Error::new(
|
||||||
|
case.name.span(),
|
||||||
|
format!(
|
||||||
|
"headless test '{}' has no assertions (expected stdout, stderr contains, or exit)",
|
||||||
|
case.name
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
Ok(quote! {
|
Ok(quote! {
|
||||||
#[test]
|
#[test]
|
||||||
fn #test_name() {
|
fn #test_name() {
|
||||||
@@ -166,8 +189,8 @@ fn build_headless_script(actions: &[ActionExpr]) -> String {
|
|||||||
script.push_str(line);
|
script.push_str(line);
|
||||||
script.push('\n');
|
script.push('\n');
|
||||||
}
|
}
|
||||||
ActionExpr::AddItems(_) => {
|
ActionExpr::AddItems(_) | ActionExpr::IpcSend(_) | ActionExpr::IpcExpect(_) => {
|
||||||
// Not applicable for headless. Items come from stdin.
|
// Not applicable for headless.
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -178,18 +201,12 @@ fn build_headless_script(actions: &[ActionExpr]) -> String {
|
|||||||
// Filter
|
// Filter
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// Generate a filter unit test: create items, push them
|
|
||||||
/// into a `FuzzyFilter`, set the query, and assert on
|
|
||||||
/// matched labels.
|
|
||||||
fn gen_filter(case: &TestCase, fixtures: &Fixtures) -> syn::Result<TokenStream> {
|
fn gen_filter(case: &TestCase, fixtures: &Fixtures) -> syn::Result<TokenStream> {
|
||||||
let test_name = &case.name;
|
let test_name = &case.name;
|
||||||
|
|
||||||
let item_exprs = gen_item_constructors(fixtures);
|
let item_exprs = gen_item_constructors(fixtures);
|
||||||
|
|
||||||
let query = case.query.as_deref().unwrap_or("");
|
let query = case.query.as_deref().unwrap_or("");
|
||||||
|
|
||||||
let mut asserts = Vec::new();
|
let mut asserts = Vec::new();
|
||||||
|
|
||||||
if let Some(ref expected) = case.match_labels {
|
if let Some(ref expected) = case.match_labels {
|
||||||
asserts.push(quote! {
|
asserts.push(quote! {
|
||||||
let labels: Vec<&str> = (0..f.matched_count())
|
let labels: Vec<&str> = (0..f.matched_count())
|
||||||
@@ -222,11 +239,8 @@ fn gen_filter(case: &TestCase, fixtures: &Fixtures) -> syn::Result<TokenStream>
|
|||||||
// Nav
|
// Nav
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// Generate a navigation unit test: create a viewport, run
|
|
||||||
/// movement actions, and assert on cursor/offset.
|
|
||||||
fn gen_nav(case: &TestCase, fixtures: &Fixtures) -> syn::Result<TokenStream> {
|
fn gen_nav(case: &TestCase, fixtures: &Fixtures) -> syn::Result<TokenStream> {
|
||||||
let test_name = &case.name;
|
let test_name = &case.name;
|
||||||
|
|
||||||
let (height, count) = fixtures.viewport.unwrap_or((10, 20));
|
let (height, count) = fixtures.viewport.unwrap_or((10, 20));
|
||||||
let height_lit = height;
|
let height_lit = height;
|
||||||
let count_lit = count;
|
let count_lit = count;
|
||||||
@@ -234,22 +248,14 @@ fn gen_nav(case: &TestCase, fixtures: &Fixtures) -> syn::Result<TokenStream> {
|
|||||||
let action_calls = gen_nav_actions(&case.actions)?;
|
let action_calls = gen_nav_actions(&case.actions)?;
|
||||||
|
|
||||||
let mut asserts = Vec::new();
|
let mut asserts = Vec::new();
|
||||||
|
|
||||||
if let Some(cursor) = case.cursor {
|
if let Some(cursor) = case.cursor {
|
||||||
asserts.push(quote! {
|
asserts.push(quote! {
|
||||||
assert_eq!(
|
assert_eq!(v.cursor(), #cursor, "expected cursor {}, got {}", #cursor, v.cursor());
|
||||||
v.cursor(), #cursor,
|
|
||||||
"expected cursor {}, got {}", #cursor, v.cursor()
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(offset) = case.offset {
|
if let Some(offset) = case.offset {
|
||||||
asserts.push(quote! {
|
asserts.push(quote! {
|
||||||
assert_eq!(
|
assert_eq!(v.scroll_offset(), #offset, "expected offset {}, got {}", #offset, v.scroll_offset());
|
||||||
v.scroll_offset(), #offset,
|
|
||||||
"expected offset {}, got {}", #offset, v.scroll_offset()
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -265,8 +271,6 @@ fn gen_nav(case: &TestCase, fixtures: &Fixtures) -> syn::Result<TokenStream> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convert DSL action names to `Viewport` method calls
|
|
||||||
/// (e.g. `move-down` becomes `v.move_down()`).
|
|
||||||
fn gen_nav_actions(actions: &[ActionExpr]) -> syn::Result<Vec<TokenStream>> {
|
fn gen_nav_actions(actions: &[ActionExpr]) -> syn::Result<Vec<TokenStream>> {
|
||||||
let mut calls = Vec::new();
|
let mut calls = Vec::new();
|
||||||
for action in actions {
|
for action in actions {
|
||||||
@@ -275,12 +279,8 @@ fn gen_nav_actions(actions: &[ActionExpr]) -> syn::Result<Vec<TokenStream>> {
|
|||||||
let method = Ident::new(&name.replace('-', "_"), Span::call_site());
|
let method = Ident::new(&name.replace('-', "_"), Span::call_site());
|
||||||
let needs_count = matches!(
|
let needs_count = matches!(
|
||||||
name.as_str(),
|
name.as_str(),
|
||||||
"move-up"
|
"move-up" | "move-down" | "page-up" | "page-down"
|
||||||
| "move-down"
|
| "half-page-up" | "half-page-down"
|
||||||
| "page-up"
|
|
||||||
| "page-down"
|
|
||||||
| "half-page-up"
|
|
||||||
| "half-page-down"
|
|
||||||
);
|
);
|
||||||
if needs_count {
|
if needs_count {
|
||||||
calls.push(quote! { v.#method(1); });
|
calls.push(quote! { v.#method(1); });
|
||||||
@@ -291,10 +291,7 @@ fn gen_nav_actions(actions: &[ActionExpr]) -> syn::Result<Vec<TokenStream>> {
|
|||||||
_ => {
|
_ => {
|
||||||
return Err(syn::Error::new(
|
return Err(syn::Error::new(
|
||||||
Span::call_site(),
|
Span::call_site(),
|
||||||
format!(
|
format!("nav tests only support simple actions, got: {:?}", action_debug(action)),
|
||||||
"nav tests only support simple actions, got: {:?}",
|
|
||||||
action_debug(action)
|
|
||||||
),
|
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -306,104 +303,65 @@ fn gen_nav_actions(actions: &[ActionExpr]) -> syn::Result<Vec<TokenStream>> {
|
|||||||
// Menu
|
// Menu
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// Generate an async menu state machine test: create a menu,
|
|
||||||
/// send actions, and assert on the final result (selected
|
|
||||||
/// item or cancellation).
|
|
||||||
fn gen_menu(case: &TestCase, fixtures: &Fixtures) -> syn::Result<TokenStream> {
|
fn gen_menu(case: &TestCase, fixtures: &Fixtures) -> syn::Result<TokenStream> {
|
||||||
let test_name = &case.name;
|
let test_name = &case.name;
|
||||||
let item_exprs = gen_item_constructors(fixtures);
|
let item_exprs = gen_item_constructors(fixtures);
|
||||||
|
|
||||||
let label_key = fixtures.label_key.as_deref().unwrap_or("label");
|
let label_key = fixtures.label_key.as_deref().unwrap_or("label");
|
||||||
|
|
||||||
let action_sends = gen_menu_actions(&case.actions)?;
|
let action_sends = gen_menu_actions(&case.actions)?;
|
||||||
|
let result_assert = gen_result_assert(case, label_key)?;
|
||||||
|
|
||||||
let result_assert = if case.cancelled {
|
let multi_setup = if fixtures.multi == Some(true) {
|
||||||
quote! {
|
quote! { menu.set_multi(true); }
|
||||||
assert!(
|
|
||||||
matches!(result, Ok(MenuResult::Cancelled)),
|
|
||||||
"expected Cancelled, got: {:?}", result.as_ref().map(|r| format!("{:?}", r))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} else if let Some(ref expected) = case.selected {
|
|
||||||
quote! {
|
|
||||||
match &result {
|
|
||||||
Ok(MenuResult::Selected { value, .. }) => {
|
|
||||||
let got = value.as_str()
|
|
||||||
.or_else(|| value.get(#label_key).and_then(|v| v.as_str()))
|
|
||||||
.unwrap_or("");
|
|
||||||
assert_eq!(
|
|
||||||
got, #expected,
|
|
||||||
"expected selected {:?}, got value: {:?}", #expected, value
|
|
||||||
);
|
|
||||||
}
|
|
||||||
other => panic!("expected Selected, got: {:?}", other),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
// No assertion on result. Probably an error, but let it compile.
|
|
||||||
quote! {}
|
quote! {}
|
||||||
};
|
};
|
||||||
|
|
||||||
// If test expects cancellation via sender drop (no cancel action, no confirm),
|
|
||||||
// we need to drop tx after sending actions.
|
|
||||||
let drop_sender = quote! { drop(tx); };
|
|
||||||
|
|
||||||
Ok(quote! {
|
Ok(quote! {
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn #test_name() {
|
async fn #test_name() {
|
||||||
let items = vec![#(#item_exprs),*];
|
let items = vec![#(#item_exprs),*];
|
||||||
let (menu, tx) = MenuRunner::new(JsonMenu::new(items, #label_key.to_string()));
|
let (mut menu, tx) = MenuRunner::new(JsonMenu::new(items, #label_key.to_string()));
|
||||||
|
#multi_setup
|
||||||
let mut rx = menu.subscribe();
|
let mut rx = menu.subscribe();
|
||||||
let handle = tokio::spawn(async move { menu.run().await });
|
let handle = tokio::spawn(async move { menu.run().await });
|
||||||
|
|
||||||
// Wait for initial state broadcast.
|
let _ = rx.recv().await;
|
||||||
|
let _ = tx.send(ModifiedAction::new(Action::Resize { height: 50 })).await;
|
||||||
let _ = rx.recv().await;
|
let _ = rx.recv().await;
|
||||||
|
|
||||||
// Give the viewport some height so confirms work.
|
|
||||||
let _ = tx.send(Action::Resize { height: 50 }).await;
|
|
||||||
let _ = rx.recv().await;
|
|
||||||
|
|
||||||
// Send all actions.
|
|
||||||
#(#action_sends)*
|
#(#action_sends)*
|
||||||
|
|
||||||
// Drop sender so menu loop can exit.
|
drop(tx);
|
||||||
#drop_sender
|
|
||||||
|
|
||||||
let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled));
|
let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled { modifiers: pikl_core::event::Modifiers::default() }));
|
||||||
#result_assert
|
#result_assert
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Convert DSL actions to `tx.send(Action::...)` calls for
|
|
||||||
/// menu tests.
|
|
||||||
fn gen_menu_actions(actions: &[ActionExpr]) -> syn::Result<Vec<TokenStream>> {
|
fn gen_menu_actions(actions: &[ActionExpr]) -> syn::Result<Vec<TokenStream>> {
|
||||||
let mut sends = Vec::new();
|
let mut sends = Vec::new();
|
||||||
for action in actions {
|
for action in actions {
|
||||||
let expr = match action {
|
let expr = match action {
|
||||||
ActionExpr::Simple(name) => {
|
ActionExpr::Simple(name) => {
|
||||||
let variant = menu_action_variant(name)?;
|
let variant = menu_action_variant(name)?;
|
||||||
quote! { let _ = tx.send(#variant).await; }
|
quote! { let _ = tx.send(ModifiedAction::new(#variant)).await; }
|
||||||
}
|
}
|
||||||
ActionExpr::Filter(query) => {
|
ActionExpr::Filter(query) => {
|
||||||
quote! {
|
quote! { let _ = tx.send(ModifiedAction::new(Action::UpdateFilter(#query.to_string()))).await; }
|
||||||
let _ = tx.send(Action::UpdateFilter(#query.to_string())).await;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
ActionExpr::AddItems(items) => {
|
ActionExpr::AddItems(items) => {
|
||||||
let item_exprs: Vec<TokenStream> = items
|
let item_exprs: Vec<TokenStream> = items
|
||||||
.iter()
|
.iter()
|
||||||
.map(|s| quote! { serde_json::Value::String(#s.to_string()) })
|
.map(|s| quote! { serde_json::Value::String(#s.to_string()) })
|
||||||
.collect();
|
.collect();
|
||||||
quote! {
|
quote! { let _ = tx.send(ModifiedAction::new(Action::AddItems(vec![#(#item_exprs),*]))).await; }
|
||||||
let _ = tx.send(Action::AddItems(vec![#(#item_exprs),*])).await;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
ActionExpr::Raw(_) => {
|
ActionExpr::Raw(_) => {
|
||||||
return Err(syn::Error::new(
|
return Err(syn::Error::new(Span::call_site(), "raw actions are only supported in headless tests"));
|
||||||
Span::call_site(),
|
}
|
||||||
"raw actions are only supported in headless tests",
|
ActionExpr::IpcSend(_) | ActionExpr::IpcExpect(_) => {
|
||||||
));
|
return Err(syn::Error::new(Span::call_site(), "ipc actions are only supported in ipc tests"));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
sends.push(expr);
|
sends.push(expr);
|
||||||
@@ -411,8 +369,6 @@ fn gen_menu_actions(actions: &[ActionExpr]) -> syn::Result<Vec<TokenStream>> {
|
|||||||
Ok(sends)
|
Ok(sends)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Map a DSL action name like `"move-down"` to the
|
|
||||||
/// corresponding `Action::MoveDown` token stream.
|
|
||||||
fn menu_action_variant(name: &str) -> syn::Result<TokenStream> {
|
fn menu_action_variant(name: &str) -> syn::Result<TokenStream> {
|
||||||
let tokens = match name {
|
let tokens = match name {
|
||||||
"confirm" => quote! { Action::Confirm },
|
"confirm" => quote! { Action::Confirm },
|
||||||
@@ -427,38 +383,220 @@ fn menu_action_variant(name: &str) -> syn::Result<TokenStream> {
|
|||||||
"half-page-down" => quote! { Action::HalfPageDown(1) },
|
"half-page-down" => quote! { Action::HalfPageDown(1) },
|
||||||
"set-mode-insert" => quote! { Action::SetMode(pikl_core::event::Mode::Insert) },
|
"set-mode-insert" => quote! { Action::SetMode(pikl_core::event::Mode::Insert) },
|
||||||
"set-mode-normal" => quote! { Action::SetMode(pikl_core::event::Mode::Normal) },
|
"set-mode-normal" => quote! { Action::SetMode(pikl_core::event::Mode::Normal) },
|
||||||
|
"set-mode-visual" => quote! { Action::SetMode(pikl_core::event::Mode::Visual) },
|
||||||
|
"toggle-select" => quote! { Action::ToggleSelect },
|
||||||
|
"select-all" => quote! { Action::SelectAll },
|
||||||
|
"clear-selections" => quote! { Action::ClearSelections },
|
||||||
|
"undo-selection" => quote! { Action::UndoSelection },
|
||||||
|
"redo-selection" => quote! { Action::RedoSelection },
|
||||||
_ => {
|
_ => {
|
||||||
return Err(syn::Error::new(
|
return Err(syn::Error::new(Span::call_site(), format!("unknown menu action: '{name}'")));
|
||||||
Span::call_site(),
|
|
||||||
format!("unknown menu action: '{name}'"),
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
Ok(tokens)
|
Ok(tokens)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// IPC
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Generate an async IPC integration test: create a menu,
|
||||||
|
/// start an IPC server, connect via Unix socket, and send/
|
||||||
|
/// receive JSON commands.
|
||||||
|
fn gen_ipc(case: &TestCase, fixtures: &Fixtures) -> syn::Result<TokenStream> {
|
||||||
|
let test_name = &case.name;
|
||||||
|
let test_name_str = test_name.to_string();
|
||||||
|
let item_exprs = gen_item_constructors(fixtures);
|
||||||
|
let label_key = fixtures.label_key.as_deref().unwrap_or("label");
|
||||||
|
|
||||||
|
let multi_setup = if fixtures.multi == Some(true) {
|
||||||
|
quote! { menu.set_multi(true); }
|
||||||
|
} else {
|
||||||
|
quote! {}
|
||||||
|
};
|
||||||
|
|
||||||
|
let has_expect = case.actions.iter().any(|a| matches!(a, ActionExpr::IpcExpect(_)));
|
||||||
|
let has_result = case.selected.is_some() || case.selected_items.is_some() || case.cancelled;
|
||||||
|
if !has_expect && !has_result {
|
||||||
|
return Err(syn::Error::new(
|
||||||
|
case.name.span(),
|
||||||
|
format!(
|
||||||
|
"ipc test '{}' has no assertions (expected ipc-expect, selected, selected_items, or cancelled)",
|
||||||
|
case.name
|
||||||
|
),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let ipc_actions = gen_ipc_actions(&case.actions)?;
|
||||||
|
|
||||||
|
let result_assert = if has_result {
|
||||||
|
gen_result_assert(case, label_key)?
|
||||||
|
} else {
|
||||||
|
quote! { let _ = result; }
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(quote! {
|
||||||
|
#[tokio::test]
|
||||||
|
async fn #test_name() {
|
||||||
|
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||||
|
use tokio::net::UnixStream;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
let items = vec![#(#item_exprs),*];
|
||||||
|
let (mut menu, tx) = MenuRunner::new(JsonMenu::new(items, #label_key.to_string()));
|
||||||
|
#multi_setup
|
||||||
|
let event_sender = menu.event_sender();
|
||||||
|
let ipc_event_rx = menu.subscribe();
|
||||||
|
|
||||||
|
let handle = tokio::spawn(async move { menu.run().await });
|
||||||
|
|
||||||
|
let _ = tx.send(ModifiedAction::new(Action::Resize { height: 50 })).await;
|
||||||
|
tokio::time::sleep(Duration::from_millis(30)).await;
|
||||||
|
|
||||||
|
// Start IPC server on a temp socket
|
||||||
|
let socket_path = std::env::temp_dir().join(
|
||||||
|
format!("pikl-ipc-{}-{}.sock", std::process::id(), #test_name_str)
|
||||||
|
);
|
||||||
|
let _ = std::fs::remove_file(&socket_path);
|
||||||
|
|
||||||
|
let ipc_server = IpcServer::new(
|
||||||
|
socket_path.clone(), tx.clone(), event_sender,
|
||||||
|
);
|
||||||
|
assert!(ipc_server.start(ipc_event_rx).is_ok(), "failed to start IPC server");
|
||||||
|
|
||||||
|
tokio::time::sleep(Duration::from_millis(10)).await;
|
||||||
|
let stream = match UnixStream::connect(&socket_path).await {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) => {
|
||||||
|
drop(tx);
|
||||||
|
drop(ipc_server);
|
||||||
|
let _ = handle.await;
|
||||||
|
assert!(false, "failed to connect to IPC socket: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let (read_half, write_half) = stream.into_split();
|
||||||
|
let mut reader = BufReader::new(read_half);
|
||||||
|
let mut writer = write_half;
|
||||||
|
|
||||||
|
#(#ipc_actions)*
|
||||||
|
|
||||||
|
drop(writer);
|
||||||
|
drop(tx);
|
||||||
|
drop(ipc_server);
|
||||||
|
let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled { modifiers: pikl_core::event::Modifiers::default() }));
|
||||||
|
#result_assert
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn gen_ipc_actions(actions: &[ActionExpr]) -> syn::Result<Vec<TokenStream>> {
|
||||||
|
let mut stmts = Vec::new();
|
||||||
|
for action in actions {
|
||||||
|
match action {
|
||||||
|
ActionExpr::IpcSend(line) => {
|
||||||
|
stmts.push(quote! {
|
||||||
|
assert!(writer.write_all(#line.as_bytes()).await.is_ok(), "ipc write failed");
|
||||||
|
assert!(writer.write_all(b"\n").await.is_ok(), "ipc write newline failed");
|
||||||
|
assert!(writer.flush().await.is_ok(), "ipc flush failed");
|
||||||
|
tokio::time::sleep(Duration::from_millis(30)).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
ActionExpr::IpcExpect(expected) => {
|
||||||
|
stmts.push(quote! {
|
||||||
|
{
|
||||||
|
let mut __resp = String::new();
|
||||||
|
let __read = tokio::time::timeout(
|
||||||
|
Duration::from_secs(5),
|
||||||
|
reader.read_line(&mut __resp)
|
||||||
|
).await;
|
||||||
|
assert!(__read.is_ok(), "IPC response timed out after 5s");
|
||||||
|
let __n = __read.unwrap_or(Ok(0)).unwrap_or(0);
|
||||||
|
assert!(__n > 0, "IPC connection closed before response");
|
||||||
|
assert!(
|
||||||
|
__resp.contains(#expected),
|
||||||
|
"expected IPC response to contain {:?}, got: {}",
|
||||||
|
#expected, __resp.trim()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
return Err(syn::Error::new(
|
||||||
|
Span::call_site(),
|
||||||
|
format!("ipc tests only support ipc-send and ipc-expect actions, got: {:?}", action_debug(action)),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(stmts)
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Shared helpers
|
// Shared helpers
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
/// Generate `Item::from_plain_text("...")` expressions for
|
|
||||||
/// each item in the fixtures.
|
|
||||||
fn gen_item_constructors(fixtures: &Fixtures) -> Vec<TokenStream> {
|
fn gen_item_constructors(fixtures: &Fixtures) -> Vec<TokenStream> {
|
||||||
match &fixtures.items {
|
match &fixtures.items {
|
||||||
Some(items) => items
|
Some(items) => items.iter().map(|s| quote! { Item::from_plain_text(#s) }).collect(),
|
||||||
.iter()
|
|
||||||
.map(|s| quote! { Item::from_plain_text(#s) })
|
|
||||||
.collect(),
|
|
||||||
None => Vec::new(),
|
None => Vec::new(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Format an action expression for use in error messages.
|
|
||||||
fn action_debug(action: &ActionExpr) -> String {
|
fn action_debug(action: &ActionExpr) -> String {
|
||||||
match action {
|
match action {
|
||||||
ActionExpr::Simple(name) => name.clone(),
|
ActionExpr::Simple(name) => name.clone(),
|
||||||
ActionExpr::Filter(q) => format!("filter \"{q}\""),
|
ActionExpr::Filter(q) => format!("filter \"{q}\""),
|
||||||
ActionExpr::Raw(r) => format!("raw \"{r}\""),
|
ActionExpr::Raw(r) => format!("raw \"{r}\""),
|
||||||
ActionExpr::AddItems(items) => format!("add-items {:?}", items),
|
ActionExpr::AddItems(items) => format!("add-items {:?}", items),
|
||||||
|
ActionExpr::IpcSend(s) => format!("ipc-send \"{s}\""),
|
||||||
|
ActionExpr::IpcExpect(s) => format!("ipc-expect \"{s}\""),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Generate the result assertion for menu/ipc tests.
|
||||||
|
fn gen_result_assert(case: &TestCase, label_key: &str) -> syn::Result<TokenStream> {
|
||||||
|
if case.cancelled {
|
||||||
|
Ok(quote! {
|
||||||
|
assert!(
|
||||||
|
matches!(result, Ok(MenuResult::Cancelled { .. })),
|
||||||
|
"expected Cancelled, got: {:?}", result.as_ref().map(|r| format!("{:?}", r))
|
||||||
|
);
|
||||||
|
})
|
||||||
|
} else if let Some(ref expected) = case.selected {
|
||||||
|
Ok(quote! {
|
||||||
|
match &result {
|
||||||
|
Ok(MenuResult::Selected { items, .. }) => {
|
||||||
|
assert!(!items.is_empty(), "expected Selected with items, got empty");
|
||||||
|
let value = &items[0].0;
|
||||||
|
let got = value.as_str()
|
||||||
|
.or_else(|| value.get(#label_key).and_then(|v| v.as_str()))
|
||||||
|
.unwrap_or("");
|
||||||
|
assert_eq!(got, #expected, "expected selected {:?}, got value: {:?}", #expected, value);
|
||||||
|
}
|
||||||
|
other => panic!("expected Selected, got: {:?}", other),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else if let Some(ref expected) = case.selected_items {
|
||||||
|
Ok(quote! {
|
||||||
|
match &result {
|
||||||
|
Ok(MenuResult::Selected { items, .. }) => {
|
||||||
|
let mut labels: Vec<&str> = items.iter()
|
||||||
|
.filter_map(|(v, _)| v.as_str()
|
||||||
|
.or_else(|| v.get(#label_key).and_then(|v| v.as_str())))
|
||||||
|
.collect();
|
||||||
|
labels.sort();
|
||||||
|
let mut expected = vec![#(#expected),*];
|
||||||
|
expected.sort();
|
||||||
|
assert_eq!(labels, expected, "expected selected_items {:?}, got {:?}", expected, labels);
|
||||||
|
}
|
||||||
|
other => panic!("expected Selected, got: {:?}", other),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
Err(syn::Error::new(
|
||||||
|
case.name.span(),
|
||||||
|
format!("test '{}' has no result assertion (expected selected, selected_items, or cancelled)", case.name),
|
||||||
|
))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -25,12 +25,14 @@ pub enum TestKind {
|
|||||||
Filter,
|
Filter,
|
||||||
Nav,
|
Nav,
|
||||||
Menu,
|
Menu,
|
||||||
|
Ipc,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct Fixtures {
|
pub struct Fixtures {
|
||||||
pub items: Option<Vec<String>>,
|
pub items: Option<Vec<String>>,
|
||||||
pub label_key: Option<String>,
|
pub label_key: Option<String>,
|
||||||
pub viewport: Option<(usize, usize)>, // (height, count)
|
pub viewport: Option<(usize, usize)>, // (height, count)
|
||||||
|
pub multi: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct TestCase {
|
pub struct TestCase {
|
||||||
@@ -44,6 +46,7 @@ pub struct TestCase {
|
|||||||
pub cursor: Option<usize>,
|
pub cursor: Option<usize>,
|
||||||
pub offset: Option<usize>,
|
pub offset: Option<usize>,
|
||||||
pub selected: Option<String>,
|
pub selected: Option<String>,
|
||||||
|
pub selected_items: Option<Vec<String>>,
|
||||||
pub cancelled: bool,
|
pub cancelled: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,6 +59,10 @@ pub enum ActionExpr {
|
|||||||
Raw(String),
|
Raw(String),
|
||||||
/// `add-items ["a", "b", "c"]`
|
/// `add-items ["a", "b", "c"]`
|
||||||
AddItems(Vec<String>),
|
AddItems(Vec<String>),
|
||||||
|
/// `ipc-send "json line"` — send a JSON command over the IPC socket
|
||||||
|
IpcSend(String),
|
||||||
|
/// `ipc-expect "text"` — read a response line and assert it contains text
|
||||||
|
IpcExpect(String),
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -89,6 +96,7 @@ impl Parse for TestModule {
|
|||||||
items: None,
|
items: None,
|
||||||
label_key: None,
|
label_key: None,
|
||||||
viewport: None,
|
viewport: None,
|
||||||
|
multi: None,
|
||||||
};
|
};
|
||||||
let mut tests = Vec::new();
|
let mut tests = Vec::new();
|
||||||
|
|
||||||
@@ -120,11 +128,18 @@ impl Parse for TestModule {
|
|||||||
fixtures.viewport = Some(parse_viewport_def(&content)?);
|
fixtures.viewport = Some(parse_viewport_def(&content)?);
|
||||||
eat_semi(&content);
|
eat_semi(&content);
|
||||||
}
|
}
|
||||||
|
"multi" => {
|
||||||
|
consume_ident_or_keyword(&content)?;
|
||||||
|
content.parse::<Token![:]>()?;
|
||||||
|
let val: syn::LitBool = content.parse()?;
|
||||||
|
fixtures.multi = Some(val.value);
|
||||||
|
eat_semi(&content);
|
||||||
|
}
|
||||||
_ => {
|
_ => {
|
||||||
return Err(syn::Error::new(
|
return Err(syn::Error::new(
|
||||||
content.span(),
|
content.span(),
|
||||||
format!(
|
format!(
|
||||||
"unexpected field '{ident_str}', expected test, items, label_key, or viewport"
|
"unexpected field '{ident_str}', expected test, items, label_key, viewport, or multi"
|
||||||
),
|
),
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@@ -163,6 +178,7 @@ fn parse_test_case(input: ParseStream) -> syn::Result<TestCase> {
|
|||||||
cursor: None,
|
cursor: None,
|
||||||
offset: None,
|
offset: None,
|
||||||
selected: None,
|
selected: None,
|
||||||
|
selected_items: None,
|
||||||
cancelled: false,
|
cancelled: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -224,6 +240,10 @@ fn parse_test_case(input: ParseStream) -> syn::Result<TestCase> {
|
|||||||
let val: LitStr = content.parse()?;
|
let val: LitStr = content.parse()?;
|
||||||
case.selected = Some(val.value());
|
case.selected = Some(val.value());
|
||||||
}
|
}
|
||||||
|
"selected_items" => {
|
||||||
|
content.parse::<Token![:]>()?;
|
||||||
|
case.selected_items = Some(parse_string_list(&content)?);
|
||||||
|
}
|
||||||
"cancelled" => {
|
"cancelled" => {
|
||||||
// Just the keyword presence means true. Optionally parse `: true`.
|
// Just the keyword presence means true. Optionally parse `: true`.
|
||||||
if content.peek(Token![:]) {
|
if content.peek(Token![:]) {
|
||||||
@@ -285,6 +305,14 @@ fn parse_action_expr(input: ParseStream) -> syn::Result<ActionExpr> {
|
|||||||
let items = parse_string_list(input)?;
|
let items = parse_string_list(input)?;
|
||||||
Ok(ActionExpr::AddItems(items))
|
Ok(ActionExpr::AddItems(items))
|
||||||
}
|
}
|
||||||
|
"ipc-send" => {
|
||||||
|
let val: LitStr = input.parse()?;
|
||||||
|
Ok(ActionExpr::IpcSend(val.value()))
|
||||||
|
}
|
||||||
|
"ipc-expect" => {
|
||||||
|
let val: LitStr = input.parse()?;
|
||||||
|
Ok(ActionExpr::IpcExpect(val.value()))
|
||||||
|
}
|
||||||
_ => Ok(ActionExpr::Simple(name)),
|
_ => Ok(ActionExpr::Simple(name)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -302,9 +330,10 @@ fn parse_kind(input: ParseStream) -> syn::Result<TestKind> {
|
|||||||
"filter" => Ok(TestKind::Filter),
|
"filter" => Ok(TestKind::Filter),
|
||||||
"nav" => Ok(TestKind::Nav),
|
"nav" => Ok(TestKind::Nav),
|
||||||
"menu" => Ok(TestKind::Menu),
|
"menu" => Ok(TestKind::Menu),
|
||||||
|
"ipc" => Ok(TestKind::Ipc),
|
||||||
other => Err(syn::Error::new(
|
other => Err(syn::Error::new(
|
||||||
ident.span(),
|
ident.span(),
|
||||||
format!("unknown test kind '{other}', expected headless, filter, nav, or menu"),
|
format!("unknown test kind '{other}', expected headless, filter, nav, menu, or ipc"),
|
||||||
)),
|
)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ use ratatui::text::{Line, Span};
|
|||||||
use ratatui::widgets::{List, ListItem, Paragraph};
|
use ratatui::widgets::{List, ListItem, Paragraph};
|
||||||
use tokio::sync::{broadcast, mpsc};
|
use tokio::sync::{broadcast, mpsc};
|
||||||
|
|
||||||
use pikl_core::event::{Action, MenuEvent, Mode, ViewState};
|
use pikl_core::event::{Action, MenuEvent, ModifiedAction, Modifiers, Mode, ViewState};
|
||||||
|
|
||||||
/// Pending key state for multi-key sequences (e.g. `gg`).
|
/// Pending key state for multi-key sequences (e.g. `gg`).
|
||||||
/// TUI-local, not part of core state.
|
/// TUI-local, not part of core state.
|
||||||
@@ -32,13 +32,23 @@ pub fn restore_terminal() {
|
|||||||
let _ = crossterm::execute!(std::io::stderr(), crossterm::terminal::LeaveAlternateScreen);
|
let _ = crossterm::execute!(std::io::stderr(), crossterm::terminal::LeaveAlternateScreen);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Convert crossterm key modifiers to pikl Modifiers.
|
||||||
|
fn crossterm_modifiers(km: KeyModifiers) -> Modifiers {
|
||||||
|
Modifiers {
|
||||||
|
shift: km.contains(KeyModifiers::SHIFT),
|
||||||
|
ctrl: km.contains(KeyModifiers::CONTROL),
|
||||||
|
alt: km.contains(KeyModifiers::ALT),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Start the TUI. Enters the alternate screen, runs the
|
/// Start the TUI. Enters the alternate screen, runs the
|
||||||
/// event loop, and restores the terminal on exit. Translates
|
/// event loop, and restores the terminal on exit. Translates
|
||||||
/// crossterm key events into [`Action`]s and renders
|
/// crossterm key events into [`Action`]s and renders
|
||||||
/// [`ViewState`] snapshots.
|
/// [`ViewState`] snapshots.
|
||||||
pub async fn run(
|
pub async fn run(
|
||||||
action_tx: mpsc::Sender<Action>,
|
action_tx: mpsc::Sender<ModifiedAction>,
|
||||||
mut event_rx: broadcast::Receiver<MenuEvent>,
|
mut event_rx: broadcast::Receiver<MenuEvent>,
|
||||||
|
filter_history: Option<Vec<String>>,
|
||||||
) -> std::io::Result<()> {
|
) -> std::io::Result<()> {
|
||||||
crossterm::terminal::enable_raw_mode()?;
|
crossterm::terminal::enable_raw_mode()?;
|
||||||
crossterm::execute!(std::io::stderr(), crossterm::terminal::EnterAlternateScreen,)?;
|
crossterm::execute!(std::io::stderr(), crossterm::terminal::EnterAlternateScreen,)?;
|
||||||
@@ -53,7 +63,7 @@ pub async fn run(
|
|||||||
let _ = crossterm::event::read()?;
|
let _ = crossterm::event::read()?;
|
||||||
}
|
}
|
||||||
|
|
||||||
let result = run_inner(&action_tx, &mut event_rx, &mut terminal).await;
|
let result = run_inner(&action_tx, &mut event_rx, &mut terminal, filter_history).await;
|
||||||
|
|
||||||
// Always clean up terminal, even on error
|
// Always clean up terminal, even on error
|
||||||
restore_terminal();
|
restore_terminal();
|
||||||
@@ -64,31 +74,49 @@ pub async fn run(
|
|||||||
/// Inner event loop. Separated from [`run`] so terminal
|
/// Inner event loop. Separated from [`run`] so terminal
|
||||||
/// cleanup always happens even if this returns an error.
|
/// cleanup always happens even if this returns an error.
|
||||||
async fn run_inner(
|
async fn run_inner(
|
||||||
action_tx: &mpsc::Sender<Action>,
|
action_tx: &mpsc::Sender<ModifiedAction>,
|
||||||
event_rx: &mut broadcast::Receiver<MenuEvent>,
|
event_rx: &mut broadcast::Receiver<MenuEvent>,
|
||||||
terminal: &mut Terminal<CrosstermBackend<std::io::Stderr>>,
|
terminal: &mut Terminal<CrosstermBackend<std::io::Stderr>>,
|
||||||
|
filter_history: Option<Vec<String>>,
|
||||||
) -> std::io::Result<()> {
|
) -> std::io::Result<()> {
|
||||||
// Send initial resize
|
// Send initial resize
|
||||||
let size = terminal.size()?;
|
let size = terminal.size()?;
|
||||||
let list_height = size.height.saturating_sub(1);
|
let list_height = size.height.saturating_sub(1);
|
||||||
let _ = action_tx
|
if action_tx
|
||||||
.send(Action::Resize {
|
.send(ModifiedAction::new(Action::Resize {
|
||||||
height: list_height,
|
height: list_height,
|
||||||
})
|
}))
|
||||||
.await;
|
.await
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
tracing::warn!("core channel closed before TUI started");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Local copy of filter text for immediate cursor positioning.
|
||||||
|
// Synced from core on every StateChanged. Core is the source
|
||||||
|
// of truth: if IPC or a hook changes the filter, the next
|
||||||
|
// broadcast overwrites this.
|
||||||
let mut filter_text = String::new();
|
let mut filter_text = String::new();
|
||||||
let mut view_state: Option<ViewState> = None;
|
let mut view_state: Option<ViewState> = None;
|
||||||
let mut event_stream = EventStream::new();
|
let mut event_stream = EventStream::new();
|
||||||
let mut mode = Mode::Insert;
|
let mut mode = Mode::Insert;
|
||||||
let mut pending = PendingKey::None;
|
let mut pending = PendingKey::None;
|
||||||
let mut last_generation: u64 = 0;
|
let mut last_generation: u64 = 0;
|
||||||
|
let mut multi_enabled = false;
|
||||||
|
let mut visual_anchor: Option<usize> = None;
|
||||||
|
|
||||||
|
// Session filter history cycling state
|
||||||
|
let history = filter_history.unwrap_or_default();
|
||||||
|
let mut history_cursor: Option<usize> = None;
|
||||||
|
let mut pre_history_text: Option<String> = None;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
if let Some(ref vs) = view_state {
|
if let Some(ref vs) = view_state {
|
||||||
let ft = filter_text.clone();
|
let ft = filter_text.clone();
|
||||||
|
let anchor = visual_anchor;
|
||||||
terminal.draw(|frame| {
|
terminal.draw(|frame| {
|
||||||
render_menu(frame, vs, &ft);
|
render_menu(frame, vs, &ft, anchor);
|
||||||
})?;
|
})?;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -100,18 +128,93 @@ async fn run_inner(
|
|||||||
let event = event_result?;
|
let event = event_result?;
|
||||||
match event {
|
match event {
|
||||||
Event::Key(key) => {
|
Event::Key(key) => {
|
||||||
if let Some(action) = map_key_event(key, &mut filter_text, mode, &mut pending) {
|
// History cycling: Ctrl+P/Ctrl+N in insert mode
|
||||||
|
// with a non-empty history list.
|
||||||
|
if mode == Mode::Insert && !history.is_empty() {
|
||||||
|
let history_handled = match (key.code, key.modifiers) {
|
||||||
|
(KeyCode::Char('p'), KeyModifiers::CONTROL) | (KeyCode::Up, _) => {
|
||||||
|
if pre_history_text.is_none() {
|
||||||
|
pre_history_text = Some(filter_text.clone());
|
||||||
|
}
|
||||||
|
let new_cursor = match history_cursor {
|
||||||
|
Some(c) if c > 0 => c - 1,
|
||||||
|
Some(_) => 0,
|
||||||
|
None => history.len() - 1,
|
||||||
|
};
|
||||||
|
history_cursor = Some(new_cursor);
|
||||||
|
filter_text.clone_from(&history[new_cursor]);
|
||||||
|
let _ = action_tx
|
||||||
|
.send(ModifiedAction::new(Action::UpdateFilter(filter_text.clone())))
|
||||||
|
.await;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
(KeyCode::Char('n'), KeyModifiers::CONTROL) => {
|
||||||
|
if let Some(c) = history_cursor {
|
||||||
|
if c + 1 < history.len() {
|
||||||
|
history_cursor = Some(c + 1);
|
||||||
|
filter_text.clone_from(&history[c + 1]);
|
||||||
|
} else {
|
||||||
|
// Past the end: restore original text
|
||||||
|
history_cursor = None;
|
||||||
|
if let Some(ref saved) = pre_history_text {
|
||||||
|
filter_text.clone_from(saved);
|
||||||
|
}
|
||||||
|
pre_history_text = None;
|
||||||
|
}
|
||||||
|
let _ = action_tx
|
||||||
|
.send(ModifiedAction::new(Action::UpdateFilter(filter_text.clone())))
|
||||||
|
.await;
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
// Not cycling, fall through to normal Ctrl+N
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
// Any other key resets history cycling
|
||||||
|
if history_cursor.is_some() {
|
||||||
|
history_cursor = None;
|
||||||
|
pre_history_text = None;
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if history_handled {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle multi-action keys (Tab, Space in normal/visual)
|
||||||
|
// before the single-action map_key_event path.
|
||||||
|
let handled = handle_multi_action_key(
|
||||||
|
key, mode, multi_enabled, &mut visual_anchor,
|
||||||
|
&view_state, action_tx,
|
||||||
|
).await;
|
||||||
|
if !handled
|
||||||
|
&& let Some(action) = map_key_event(key, &mut filter_text, mode, &mut pending)
|
||||||
|
{
|
||||||
// Track mode locally for key mapping
|
// Track mode locally for key mapping
|
||||||
if let Action::SetMode(m) = &action {
|
if let Action::SetMode(m) = &action {
|
||||||
|
if *m == Mode::Visual {
|
||||||
|
if let Some(ref vs) = view_state {
|
||||||
|
visual_anchor = Some(vs.cursor);
|
||||||
|
}
|
||||||
|
} else if mode == Mode::Visual {
|
||||||
|
visual_anchor = None;
|
||||||
|
}
|
||||||
mode = *m;
|
mode = *m;
|
||||||
pending = PendingKey::None;
|
pending = PendingKey::None;
|
||||||
}
|
}
|
||||||
let _ = action_tx.send(action).await;
|
if action_tx.send(ModifiedAction::with_modifiers(action, crossterm_modifiers(key.modifiers))).await.is_err() {
|
||||||
|
break; // core is gone
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Event::Resize(_, h) => {
|
Event::Resize(_, h) => {
|
||||||
let list_height = h.saturating_sub(1);
|
let list_height = h.saturating_sub(1);
|
||||||
let _ = action_tx.send(Action::Resize { height: list_height }).await;
|
if action_tx.send(ModifiedAction::new(Action::Resize { height: list_height })).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
}
|
}
|
||||||
@@ -136,6 +239,7 @@ async fn run_inner(
|
|||||||
mode = vs.mode;
|
mode = vs.mode;
|
||||||
pending = PendingKey::None;
|
pending = PendingKey::None;
|
||||||
}
|
}
|
||||||
|
multi_enabled = vs.multi_enabled;
|
||||||
view_state = Some(vs);
|
view_state = Some(vs);
|
||||||
}
|
}
|
||||||
Ok(MenuEvent::Selected(_) | MenuEvent::Quicklist(_) | MenuEvent::Cancelled) => {
|
Ok(MenuEvent::Selected(_) | MenuEvent::Quicklist(_) | MenuEvent::Cancelled) => {
|
||||||
@@ -155,12 +259,118 @@ async fn run_inner(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Handle keys that need to send multiple actions (Tab, Space
|
||||||
|
/// in normal mode, visual mode apply/cancel). Returns true
|
||||||
|
/// if the key was consumed.
|
||||||
|
async fn handle_multi_action_key(
|
||||||
|
key: KeyEvent,
|
||||||
|
mode: Mode,
|
||||||
|
multi_enabled: bool,
|
||||||
|
visual_anchor: &mut Option<usize>,
|
||||||
|
view_state: &Option<ViewState>,
|
||||||
|
action_tx: &mpsc::Sender<ModifiedAction>,
|
||||||
|
) -> bool {
|
||||||
|
match mode {
|
||||||
|
Mode::Insert => {
|
||||||
|
if !multi_enabled {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Tab => {
|
||||||
|
let mods = crossterm_modifiers(key.modifiers);
|
||||||
|
let _ = action_tx.send(ModifiedAction::with_modifiers(Action::ToggleSelect, mods)).await;
|
||||||
|
let _ = action_tx.send(ModifiedAction::with_modifiers(Action::MoveDown(1), mods)).await;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
KeyCode::BackTab => {
|
||||||
|
let mods = crossterm_modifiers(key.modifiers);
|
||||||
|
let _ = action_tx.send(ModifiedAction::with_modifiers(Action::ToggleSelect, mods)).await;
|
||||||
|
let _ = action_tx.send(ModifiedAction::with_modifiers(Action::MoveUp(1), mods)).await;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Mode::Normal => {
|
||||||
|
if !multi_enabled {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
match (key.code, key.modifiers) {
|
||||||
|
(KeyCode::Char(' '), m)
|
||||||
|
if !m.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) =>
|
||||||
|
{
|
||||||
|
let mods = crossterm_modifiers(key.modifiers);
|
||||||
|
let _ = action_tx.send(ModifiedAction::with_modifiers(Action::ToggleSelect, mods)).await;
|
||||||
|
let _ = action_tx.send(ModifiedAction::with_modifiers(Action::MoveDown(1), mods)).await;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
(KeyCode::Tab, _) => {
|
||||||
|
let mods = crossterm_modifiers(key.modifiers);
|
||||||
|
let _ = action_tx.send(ModifiedAction::with_modifiers(Action::ToggleSelect, mods)).await;
|
||||||
|
let _ = action_tx.send(ModifiedAction::with_modifiers(Action::MoveDown(1), mods)).await;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
(KeyCode::BackTab, _) => {
|
||||||
|
let mods = crossterm_modifiers(key.modifiers);
|
||||||
|
let _ = action_tx.send(ModifiedAction::with_modifiers(Action::ToggleSelect, mods)).await;
|
||||||
|
let _ = action_tx.send(ModifiedAction::with_modifiers(Action::MoveUp(1), mods)).await;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Mode::Visual => {
|
||||||
|
match (key.code, key.modifiers) {
|
||||||
|
(KeyCode::Char(' '), m) | (KeyCode::Enter, m)
|
||||||
|
if !m.intersects(KeyModifiers::ALT) =>
|
||||||
|
{
|
||||||
|
let mods = crossterm_modifiers(key.modifiers);
|
||||||
|
// Apply visual selection
|
||||||
|
if let (Some(anchor), Some(vs)) = (*visual_anchor, view_state) {
|
||||||
|
let cursor = vs.cursor;
|
||||||
|
let _ = action_tx
|
||||||
|
.send(ModifiedAction::with_modifiers(Action::SelectRange {
|
||||||
|
start: anchor,
|
||||||
|
end: cursor,
|
||||||
|
}, mods))
|
||||||
|
.await;
|
||||||
|
}
|
||||||
|
*visual_anchor = None;
|
||||||
|
let _ = action_tx.send(ModifiedAction::with_modifiers(Action::SetMode(Mode::Normal), mods)).await;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Render the menu into the given frame. Extracted from the
|
/// Render the menu into the given frame. Extracted from the
|
||||||
/// event loop so it can be tested with a [`TestBackend`].
|
/// event loop so it can be tested with a [`TestBackend`].
|
||||||
fn render_menu(frame: &mut ratatui::Frame, vs: &ViewState, filter_text: &str) {
|
fn render_menu(
|
||||||
|
frame: &mut ratatui::Frame,
|
||||||
|
vs: &ViewState,
|
||||||
|
filter_text: &str,
|
||||||
|
visual_anchor: Option<usize>,
|
||||||
|
) {
|
||||||
|
let is_table = vs.columns.is_some();
|
||||||
|
|
||||||
|
let constraints = if is_table {
|
||||||
|
vec![
|
||||||
|
Constraint::Length(1), // prompt
|
||||||
|
Constraint::Length(1), // header row
|
||||||
|
Constraint::Min(1), // items
|
||||||
|
]
|
||||||
|
} else {
|
||||||
|
vec![
|
||||||
|
Constraint::Length(1), // prompt
|
||||||
|
Constraint::Min(1), // items
|
||||||
|
]
|
||||||
|
};
|
||||||
|
|
||||||
let chunks = Layout::default()
|
let chunks = Layout::default()
|
||||||
.direction(Direction::Vertical)
|
.direction(Direction::Vertical)
|
||||||
.constraints([Constraint::Length(1), Constraint::Min(1)])
|
.constraints(constraints)
|
||||||
.split(frame.area());
|
.split(frame.area());
|
||||||
|
|
||||||
let filtered_count = vs.total_filtered;
|
let filtered_count = vs.total_filtered;
|
||||||
@@ -169,19 +379,32 @@ fn render_menu(frame: &mut ratatui::Frame, vs: &ViewState, filter_text: &str) {
|
|||||||
let mode_indicator = match vs.mode {
|
let mode_indicator = match vs.mode {
|
||||||
Mode::Insert => "[I]",
|
Mode::Insert => "[I]",
|
||||||
Mode::Normal => "[N]",
|
Mode::Normal => "[N]",
|
||||||
|
Mode::Visual => "[V]",
|
||||||
};
|
};
|
||||||
|
|
||||||
let prompt = Paragraph::new(Line::from(vec![
|
let mut prompt_spans = vec![
|
||||||
Span::styled(
|
Span::styled(
|
||||||
format!("{mode_indicator}> "),
|
format!("{mode_indicator}> "),
|
||||||
Style::default().fg(Color::Cyan),
|
Style::default().fg(Color::Cyan),
|
||||||
),
|
),
|
||||||
Span::raw(filter_text),
|
Span::raw(filter_text),
|
||||||
Span::styled(
|
Span::styled(
|
||||||
format!(" {filtered_count}/{total_count}"),
|
format!(
|
||||||
|
" {filtered_count}/{total_count}{}",
|
||||||
|
if vs.streaming { "..." } else { "" }
|
||||||
|
),
|
||||||
Style::default().fg(Color::DarkGray),
|
Style::default().fg(Color::DarkGray),
|
||||||
),
|
),
|
||||||
]));
|
];
|
||||||
|
|
||||||
|
if vs.selection_count > 0 {
|
||||||
|
prompt_spans.push(Span::styled(
|
||||||
|
format!(" [{}]", vs.selection_count),
|
||||||
|
Style::default().fg(Color::Yellow),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let prompt = Paragraph::new(Line::from(prompt_spans));
|
||||||
frame.render_widget(prompt, chunks[0]);
|
frame.render_widget(prompt, chunks[0]);
|
||||||
|
|
||||||
// mode_indicator len + "> " = mode_indicator.len() + 2
|
// mode_indicator len + "> " = mode_indicator.len() + 2
|
||||||
@@ -192,23 +415,111 @@ fn render_menu(frame: &mut ratatui::Frame, vs: &ViewState, filter_text: &str) {
|
|||||||
frame.set_cursor_position(((prompt_prefix_len + filter_text.len()) as u16, 0));
|
frame.set_cursor_position(((prompt_prefix_len + filter_text.len()) as u16, 0));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Render table header if in table mode
|
||||||
|
if let Some(ref columns) = vs.columns {
|
||||||
|
let header_chunk = chunks[1];
|
||||||
|
let header_spans: Vec<Span> = columns
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.flat_map(|(i, col)| {
|
||||||
|
let mut spans = Vec::new();
|
||||||
|
if i > 0 {
|
||||||
|
spans.push(Span::styled(
|
||||||
|
" \u{2502} ",
|
||||||
|
Style::default().fg(Color::DarkGray),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
spans.push(Span::styled(
|
||||||
|
format!("{:<width$}", col.display_name, width = col.width as usize),
|
||||||
|
Style::default()
|
||||||
|
.add_modifier(Modifier::BOLD)
|
||||||
|
.add_modifier(Modifier::UNDERLINED),
|
||||||
|
));
|
||||||
|
spans
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let header_line = Paragraph::new(Line::from(header_spans));
|
||||||
|
frame.render_widget(header_line, header_chunk);
|
||||||
|
}
|
||||||
|
|
||||||
|
let list_chunk = if is_table { chunks[2] } else { chunks[1] };
|
||||||
|
|
||||||
|
// Determine visual range for highlighting
|
||||||
|
let visual_range = if vs.mode == Mode::Visual {
|
||||||
|
visual_anchor.map(|anchor| {
|
||||||
|
let min = anchor.min(vs.cursor);
|
||||||
|
let max = anchor.max(vs.cursor);
|
||||||
|
min..=max
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
let items: Vec<ListItem> = vs
|
let items: Vec<ListItem> = vs
|
||||||
.visible_items
|
.visible_items
|
||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.map(|(i, vi)| {
|
.map(|(i, vi)| {
|
||||||
let style = if i == vs.cursor {
|
let in_visual = visual_range.as_ref().is_some_and(|r| r.contains(&i));
|
||||||
|
let mut style = if i == vs.cursor {
|
||||||
Style::default().add_modifier(Modifier::REVERSED)
|
Style::default().add_modifier(Modifier::REVERSED)
|
||||||
|
} else if in_visual {
|
||||||
|
Style::default().fg(Color::Black).bg(Color::Blue)
|
||||||
} else {
|
} else {
|
||||||
Style::default()
|
Style::default()
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if vi.selected {
|
||||||
|
style = style.fg(Color::Green);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Table mode: render cells padded to column widths
|
||||||
|
if let (Some(col_vals), Some(columns)) = (&vi.column_values, &vs.columns) {
|
||||||
|
let marker = if vs.multi_enabled {
|
||||||
|
if vi.selected { "* " } else { " " }
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
let cell_spans: Vec<Span> = columns
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.flat_map(|(ci, col)| {
|
||||||
|
let mut spans = Vec::new();
|
||||||
|
if ci > 0 {
|
||||||
|
spans.push(Span::styled(
|
||||||
|
" \u{2502} ",
|
||||||
|
Style::default().fg(Color::DarkGray),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let cell = col_vals.get(ci).map(|s| s.as_str()).unwrap_or("");
|
||||||
|
spans.push(Span::raw(format!(
|
||||||
|
"{:<width$}",
|
||||||
|
cell,
|
||||||
|
width = col.width as usize
|
||||||
|
)));
|
||||||
|
spans
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let mut all_spans = Vec::new();
|
||||||
|
if !marker.is_empty() {
|
||||||
|
all_spans.push(Span::raw(marker));
|
||||||
|
}
|
||||||
|
all_spans.extend(cell_spans);
|
||||||
|
ListItem::new(Line::from(all_spans)).style(style)
|
||||||
|
} else {
|
||||||
|
let marker = if vi.selected { "* " } else { " " };
|
||||||
let text = vi.formatted_text.as_deref().unwrap_or(vi.label.as_str());
|
let text = vi.formatted_text.as_deref().unwrap_or(vi.label.as_str());
|
||||||
|
if vs.multi_enabled {
|
||||||
|
ListItem::new(format!("{marker}{text}")).style(style)
|
||||||
|
} else {
|
||||||
ListItem::new(text).style(style)
|
ListItem::new(text).style(style)
|
||||||
|
}
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let list = List::new(items);
|
let list = List::new(items);
|
||||||
frame.render_widget(list, chunks[1]);
|
frame.render_widget(list, list_chunk);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Map a crossterm key event to an [`Action`], updating
|
/// Map a crossterm key event to an [`Action`], updating
|
||||||
@@ -223,6 +534,7 @@ fn map_key_event(
|
|||||||
match mode {
|
match mode {
|
||||||
Mode::Insert => map_insert_mode(key, filter_text),
|
Mode::Insert => map_insert_mode(key, filter_text),
|
||||||
Mode::Normal => map_normal_mode(key, pending),
|
Mode::Normal => map_normal_mode(key, pending),
|
||||||
|
Mode::Visual => map_visual_mode(key, pending),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -289,6 +601,12 @@ fn map_normal_mode(key: KeyEvent, pending: &mut PendingKey) -> Option<Action> {
|
|||||||
(KeyCode::Char('q'), m) if !m.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => {
|
(KeyCode::Char('q'), m) if !m.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => {
|
||||||
Some(Action::Cancel)
|
Some(Action::Cancel)
|
||||||
}
|
}
|
||||||
|
(KeyCode::Char('V'), _) => Some(Action::SetMode(Mode::Visual)),
|
||||||
|
(KeyCode::Char('U'), _) => Some(Action::ClearSelections),
|
||||||
|
(KeyCode::Char('u'), m) if !m.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => {
|
||||||
|
Some(Action::UndoSelection)
|
||||||
|
}
|
||||||
|
(KeyCode::Char('r'), KeyModifiers::CONTROL) => Some(Action::RedoSelection),
|
||||||
(KeyCode::Enter, _) => Some(Action::Confirm),
|
(KeyCode::Enter, _) => Some(Action::Confirm),
|
||||||
(KeyCode::Esc, _) => Some(Action::Cancel),
|
(KeyCode::Esc, _) => Some(Action::Cancel),
|
||||||
(KeyCode::Up, _) => Some(Action::MoveUp(1)),
|
(KeyCode::Up, _) => Some(Action::MoveUp(1)),
|
||||||
@@ -297,6 +615,41 @@ fn map_normal_mode(key: KeyEvent, pending: &mut PendingKey) -> Option<Action> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Visual mode: movement keys extend the range, V cancels,
|
||||||
|
/// Space/Enter apply (handled in handle_multi_action_key).
|
||||||
|
fn map_visual_mode(key: KeyEvent, pending: &mut PendingKey) -> Option<Action> {
|
||||||
|
// Handle pending `g` key for `gg` sequence
|
||||||
|
if *pending == PendingKey::G {
|
||||||
|
*pending = PendingKey::None;
|
||||||
|
if key.code == KeyCode::Char('g')
|
||||||
|
&& !key
|
||||||
|
.modifiers
|
||||||
|
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT)
|
||||||
|
{
|
||||||
|
return Some(Action::MoveToTop);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match (key.code, key.modifiers) {
|
||||||
|
(KeyCode::Char('j'), m) | (KeyCode::Down, m) if !m.intersects(KeyModifiers::ALT) => {
|
||||||
|
Some(Action::MoveDown(1))
|
||||||
|
}
|
||||||
|
(KeyCode::Char('k'), m) | (KeyCode::Up, m) if !m.intersects(KeyModifiers::ALT) => {
|
||||||
|
Some(Action::MoveUp(1))
|
||||||
|
}
|
||||||
|
(KeyCode::Char('G'), _) => Some(Action::MoveToBottom),
|
||||||
|
(KeyCode::Char('g'), m) if !m.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => {
|
||||||
|
*pending = PendingKey::G;
|
||||||
|
None
|
||||||
|
}
|
||||||
|
// V cancels visual mode, no selection applied
|
||||||
|
(KeyCode::Char('V'), _) => Some(Action::SetMode(Mode::Normal)),
|
||||||
|
// Escape always cancels the menu
|
||||||
|
(KeyCode::Esc, _) => Some(Action::Cancel),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
@@ -331,16 +684,22 @@ mod tests {
|
|||||||
label: "alpha".into(),
|
label: "alpha".into(),
|
||||||
formatted_text: None,
|
formatted_text: None,
|
||||||
index: 0,
|
index: 0,
|
||||||
|
selected: false,
|
||||||
|
column_values: None,
|
||||||
},
|
},
|
||||||
VisibleItem {
|
VisibleItem {
|
||||||
label: "bravo".into(),
|
label: "bravo".into(),
|
||||||
formatted_text: None,
|
formatted_text: None,
|
||||||
index: 1,
|
index: 1,
|
||||||
|
selected: false,
|
||||||
|
column_values: None,
|
||||||
},
|
},
|
||||||
VisibleItem {
|
VisibleItem {
|
||||||
label: "charlie".into(),
|
label: "charlie".into(),
|
||||||
formatted_text: None,
|
formatted_text: None,
|
||||||
index: 2,
|
index: 2,
|
||||||
|
selected: false,
|
||||||
|
column_values: None,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
cursor: 0,
|
cursor: 0,
|
||||||
@@ -348,6 +707,10 @@ mod tests {
|
|||||||
total_items: 5,
|
total_items: 5,
|
||||||
total_filtered: 3,
|
total_filtered: 3,
|
||||||
mode: Mode::Insert,
|
mode: Mode::Insert,
|
||||||
|
selection_count: 0,
|
||||||
|
multi_enabled: false,
|
||||||
|
streaming: false,
|
||||||
|
columns: None,
|
||||||
generation: 1,
|
generation: 1,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -703,7 +1066,7 @@ mod tests {
|
|||||||
let mut terminal = Terminal::new(backend).ok().expect("test terminal");
|
let mut terminal = Terminal::new(backend).ok().expect("test terminal");
|
||||||
terminal
|
terminal
|
||||||
.draw(|frame| {
|
.draw(|frame| {
|
||||||
render_menu(frame, vs, filter);
|
render_menu(frame, vs, filter, None);
|
||||||
})
|
})
|
||||||
.ok()
|
.ok()
|
||||||
.expect("draw");
|
.expect("draw");
|
||||||
@@ -820,6 +1183,10 @@ mod tests {
|
|||||||
total_items: 0,
|
total_items: 0,
|
||||||
total_filtered: 0,
|
total_filtered: 0,
|
||||||
mode: Mode::Insert,
|
mode: Mode::Insert,
|
||||||
|
selection_count: 0,
|
||||||
|
multi_enabled: false,
|
||||||
|
streaming: false,
|
||||||
|
columns: None,
|
||||||
generation: 1,
|
generation: 1,
|
||||||
};
|
};
|
||||||
let backend = render_to_backend(30, 4, &vs, "");
|
let backend = render_to_backend(30, 4, &vs, "");
|
||||||
@@ -828,6 +1195,29 @@ mod tests {
|
|||||||
assert!(prompt.contains("0/0"));
|
assert!(prompt.contains("0/0"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn streaming_indicator_shows_ellipsis() {
|
||||||
|
let mut vs = sample_view_state();
|
||||||
|
vs.streaming = true;
|
||||||
|
let backend = render_to_backend(40, 5, &vs, "");
|
||||||
|
let prompt = line_text(&backend, 0);
|
||||||
|
assert!(
|
||||||
|
prompt.contains("..."),
|
||||||
|
"streaming indicator should show '...': {prompt}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_streaming_indicator_when_done() {
|
||||||
|
let vs = sample_view_state();
|
||||||
|
let backend = render_to_backend(40, 5, &vs, "");
|
||||||
|
let prompt = line_text(&backend, 0);
|
||||||
|
assert!(
|
||||||
|
!prompt.contains("..."),
|
||||||
|
"should not show '...' when not streaming: {prompt}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn narrow_viewport_truncates() {
|
fn narrow_viewport_truncates() {
|
||||||
let vs = sample_view_state();
|
let vs = sample_view_state();
|
||||||
@@ -853,7 +1243,7 @@ mod tests {
|
|||||||
let mut terminal = Terminal::new(backend).ok().expect("test terminal");
|
let mut terminal = Terminal::new(backend).ok().expect("test terminal");
|
||||||
terminal
|
terminal
|
||||||
.draw(|frame| {
|
.draw(|frame| {
|
||||||
render_menu(frame, &vs, "hi");
|
render_menu(frame, &vs, "hi", None);
|
||||||
})
|
})
|
||||||
.ok()
|
.ok()
|
||||||
.expect("draw");
|
.expect("draw");
|
||||||
@@ -871,7 +1261,7 @@ mod tests {
|
|||||||
let mut terminal = Terminal::new(backend).ok().expect("test terminal");
|
let mut terminal = Terminal::new(backend).ok().expect("test terminal");
|
||||||
terminal
|
terminal
|
||||||
.draw(|frame| {
|
.draw(|frame| {
|
||||||
render_menu(frame, &vs, "");
|
render_menu(frame, &vs, "", None);
|
||||||
})
|
})
|
||||||
.ok()
|
.ok()
|
||||||
.expect("draw");
|
.expect("draw");
|
||||||
@@ -893,6 +1283,158 @@ mod tests {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -- Table mode rendering tests --
|
||||||
|
|
||||||
|
use pikl_core::event::ColumnHeader;
|
||||||
|
|
||||||
|
fn table_view_state() -> ViewState {
|
||||||
|
ViewState {
|
||||||
|
visible_items: vec![
|
||||||
|
VisibleItem {
|
||||||
|
label: "alice".into(),
|
||||||
|
formatted_text: None,
|
||||||
|
index: 0,
|
||||||
|
selected: false,
|
||||||
|
column_values: Some(vec!["alice".into(), "30".into(), "toronto".into()]),
|
||||||
|
},
|
||||||
|
VisibleItem {
|
||||||
|
label: "bob".into(),
|
||||||
|
formatted_text: None,
|
||||||
|
index: 1,
|
||||||
|
selected: false,
|
||||||
|
column_values: Some(vec!["bob".into(), "25".into(), "vancouver".into()]),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
cursor: 0,
|
||||||
|
filter_text: Arc::from(""),
|
||||||
|
total_items: 2,
|
||||||
|
total_filtered: 2,
|
||||||
|
mode: Mode::Insert,
|
||||||
|
selection_count: 0,
|
||||||
|
multi_enabled: false,
|
||||||
|
streaming: false,
|
||||||
|
columns: Some(vec![
|
||||||
|
ColumnHeader {
|
||||||
|
display_name: "name".into(),
|
||||||
|
width: 9,
|
||||||
|
},
|
||||||
|
ColumnHeader {
|
||||||
|
display_name: "age".into(),
|
||||||
|
width: 3,
|
||||||
|
},
|
||||||
|
ColumnHeader {
|
||||||
|
display_name: "city".into(),
|
||||||
|
width: 9,
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
generation: 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn table_renders_header_row() {
|
||||||
|
let vs = table_view_state();
|
||||||
|
let backend = render_to_backend(50, 6, &vs, "");
|
||||||
|
// Row 0 = prompt, Row 1 = header, Row 2+ = items
|
||||||
|
let header = line_text(&backend, 1);
|
||||||
|
assert!(
|
||||||
|
header.contains("name"),
|
||||||
|
"header should contain 'name': got '{header}'"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
header.contains("age"),
|
||||||
|
"header should contain 'age': got '{header}'"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
header.contains("city"),
|
||||||
|
"header should contain 'city': got '{header}'"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn table_header_has_bold_style() {
|
||||||
|
let vs = table_view_state();
|
||||||
|
let backend = render_to_backend(50, 6, &vs, "");
|
||||||
|
let buf = backend.buffer();
|
||||||
|
// Row 1 col 0 should be bold (header row)
|
||||||
|
let cell = &buf[(0, 1)];
|
||||||
|
assert!(
|
||||||
|
cell.modifier.contains(Modifier::BOLD),
|
||||||
|
"header should be bold"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn table_items_start_at_row_2() {
|
||||||
|
let vs = table_view_state();
|
||||||
|
let backend = render_to_backend(50, 6, &vs, "");
|
||||||
|
let row2 = line_text(&backend, 2);
|
||||||
|
let row3 = line_text(&backend, 3);
|
||||||
|
assert!(
|
||||||
|
row2.contains("alice"),
|
||||||
|
"row 2 should have alice: got '{row2}'"
|
||||||
|
);
|
||||||
|
assert!(row3.contains("bob"), "row 3 should have bob: got '{row3}'");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn table_cells_contain_values() {
|
||||||
|
let vs = table_view_state();
|
||||||
|
let backend = render_to_backend(50, 6, &vs, "");
|
||||||
|
let row2 = line_text(&backend, 2);
|
||||||
|
assert!(
|
||||||
|
row2.contains("toronto"),
|
||||||
|
"row should contain city value: got '{row2}'"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
row2.contains("30"),
|
||||||
|
"row should contain age value: got '{row2}'"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn table_cursor_row_has_reversed_style() {
|
||||||
|
let vs = table_view_state(); // cursor at 0
|
||||||
|
let backend = render_to_backend(50, 6, &vs, "");
|
||||||
|
let buf = backend.buffer();
|
||||||
|
// Row 2 (first item in table mode) should be REVERSED
|
||||||
|
let cell = &buf[(0, 2)];
|
||||||
|
assert!(
|
||||||
|
cell.modifier.contains(Modifier::REVERSED),
|
||||||
|
"cursor row should have REVERSED style in table mode"
|
||||||
|
);
|
||||||
|
// Row 3 should not
|
||||||
|
let cell2 = &buf[(0, 3)];
|
||||||
|
assert!(
|
||||||
|
!cell2.modifier.contains(Modifier::REVERSED),
|
||||||
|
"non-cursor row should not have REVERSED in table mode"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn table_header_has_separators() {
|
||||||
|
let vs = table_view_state();
|
||||||
|
let backend = render_to_backend(50, 6, &vs, "");
|
||||||
|
let header = line_text(&backend, 1);
|
||||||
|
assert!(
|
||||||
|
header.contains('\u{2502}'),
|
||||||
|
"header should contain column separator: got '{header}'"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn table_without_columns_renders_normally() {
|
||||||
|
// When columns is None, should render like a normal list (no header row)
|
||||||
|
let vs = sample_view_state();
|
||||||
|
let backend = render_to_backend(30, 6, &vs, "");
|
||||||
|
// Row 1 should be items, not a header
|
||||||
|
let row1 = line_text(&backend, 1);
|
||||||
|
assert!(
|
||||||
|
row1.contains("alpha"),
|
||||||
|
"without columns, row 1 should be first item: got '{row1}'"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn pending_cleared_on_mode_key() {
|
fn pending_cleared_on_mode_key() {
|
||||||
let mut ft = String::new();
|
let mut ft = String::new();
|
||||||
|
|||||||
@@ -15,8 +15,10 @@ path = "src/main.rs"
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
pikl-core = { path = "../pikl-core" }
|
pikl-core = { path = "../pikl-core" }
|
||||||
pikl-tui = { path = "../pikl-tui" }
|
pikl-tui = { path = "../pikl-tui" }
|
||||||
|
pikl-gui = { path = "../pikl-gui" }
|
||||||
clap = { version = "4", features = ["derive"] }
|
clap = { version = "4", features = ["derive"] }
|
||||||
tokio = { version = "1", features = ["rt-multi-thread", "process", "signal", "io-util"] }
|
tokio = { version = "1", features = ["rt-multi-thread", "process", "signal", "io-util", "net"] }
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = "0.3"
|
tracing-subscriber = "0.3"
|
||||||
|
|||||||
@@ -11,10 +11,8 @@ use tokio::process::Command;
|
|||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
use pikl_core::error::PiklError;
|
use pikl_core::error::PiklError;
|
||||||
use pikl_core::event::Action;
|
use pikl_core::event::{Action, ModifiedAction};
|
||||||
use pikl_core::hook::{
|
use pikl_core::hook::{HookEvent, HookEventKind, HookHandler, parse_hook_response};
|
||||||
parse_hook_response, HookEvent, HookEventKind, HookHandler,
|
|
||||||
};
|
|
||||||
|
|
||||||
/// A persistent handler hook process. Spawns a child process,
|
/// A persistent handler hook process. Spawns a child process,
|
||||||
/// writes events to its stdin as JSON lines, reads responses
|
/// writes events to its stdin as JSON lines, reads responses
|
||||||
@@ -27,10 +25,7 @@ pub struct ShellHandlerHook {
|
|||||||
|
|
||||||
impl ShellHandlerHook {
|
impl ShellHandlerHook {
|
||||||
/// Build from CLI flags. Returns None if no handler hooks are configured.
|
/// Build from CLI flags. Returns None if no handler hooks are configured.
|
||||||
pub fn from_cli(
|
pub fn from_cli(cli: &crate::Cli, action_tx: mpsc::Sender<ModifiedAction>) -> Option<Self> {
|
||||||
cli: &crate::Cli,
|
|
||||||
action_tx: mpsc::Sender<Action>,
|
|
||||||
) -> Option<Self> {
|
|
||||||
let mut handlers: Vec<(HookEventKind, &str)> = Vec::new();
|
let mut handlers: Vec<(HookEventKind, &str)> = Vec::new();
|
||||||
|
|
||||||
if let Some(ref cmd) = cli.on_open {
|
if let Some(ref cmd) = cli.on_open {
|
||||||
@@ -81,7 +76,9 @@ impl HookHandler for ShellHandlerHook {
|
|||||||
let kind = event.kind();
|
let kind = event.kind();
|
||||||
if let Some(tx) = self.event_txs.get(&kind) {
|
if let Some(tx) = self.event_txs.get(&kind) {
|
||||||
// Non-blocking send. If the channel is full, drop the event.
|
// Non-blocking send. If the channel is full, drop the event.
|
||||||
let _ = tx.try_send(event);
|
if let Err(e) = tx.try_send(event) {
|
||||||
|
tracing::debug!(error = %e, "handler event channel full, dropping event");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -94,7 +91,7 @@ impl HookHandler for ShellHandlerHook {
|
|||||||
async fn run_handler_process(
|
async fn run_handler_process(
|
||||||
command: &str,
|
command: &str,
|
||||||
mut event_rx: mpsc::Receiver<HookEvent>,
|
mut event_rx: mpsc::Receiver<HookEvent>,
|
||||||
action_tx: mpsc::Sender<Action>,
|
action_tx: mpsc::Sender<ModifiedAction>,
|
||||||
) -> Result<(), PiklError> {
|
) -> Result<(), PiklError> {
|
||||||
let mut child = Command::new("sh")
|
let mut child = Command::new("sh")
|
||||||
.arg("-c")
|
.arg("-c")
|
||||||
@@ -123,7 +120,7 @@ async fn run_handler_process(
|
|||||||
let mut lines = stdout_reader.lines();
|
let mut lines = stdout_reader.lines();
|
||||||
while let Ok(Some(line)) = lines.next_line().await {
|
while let Ok(Some(line)) = lines.next_line().await {
|
||||||
if let Some(resp) = parse_hook_response(&line) {
|
if let Some(resp) = parse_hook_response(&line) {
|
||||||
let action = Action::ProcessHookResponse(resp);
|
let action = ModifiedAction::new(Action::ProcessHookResponse(resp));
|
||||||
if reader_action_tx.send(action).await.is_err() {
|
if reader_action_tx.send(action).await.is_err() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
@@ -133,19 +130,17 @@ async fn run_handler_process(
|
|||||||
|
|
||||||
// Writer loop: reads events from channel, writes to stdin
|
// Writer loop: reads events from channel, writes to stdin
|
||||||
while let Some(event) = event_rx.recv().await {
|
while let Some(event) = event_rx.recv().await {
|
||||||
let json = serde_json::to_string(&event).unwrap_or_default();
|
let json = match serde_json::to_string(&event) {
|
||||||
if stdin_writer
|
Ok(j) => j,
|
||||||
.write_all(json.as_bytes())
|
Err(e) => {
|
||||||
.await
|
tracing::warn!(error = %e, "failed to serialize hook event, skipping");
|
||||||
.is_err()
|
continue;
|
||||||
{
|
}
|
||||||
|
};
|
||||||
|
if stdin_writer.write_all(json.as_bytes()).await.is_err() {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if stdin_writer
|
if stdin_writer.write_all(b"\n").await.is_err() {
|
||||||
.write_all(b"\n")
|
|
||||||
.await
|
|
||||||
.is_err()
|
|
||||||
{
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if stdin_writer.flush().await.is_err() {
|
if stdin_writer.flush().await.is_err() {
|
||||||
@@ -157,10 +152,7 @@ async fn run_handler_process(
|
|||||||
drop(stdin_writer);
|
drop(stdin_writer);
|
||||||
|
|
||||||
// Wait up to 1s for child to exit after EOF
|
// Wait up to 1s for child to exit after EOF
|
||||||
let exited = tokio::time::timeout(
|
let exited = tokio::time::timeout(std::time::Duration::from_secs(1), child.wait())
|
||||||
std::time::Duration::from_secs(1),
|
|
||||||
child.wait(),
|
|
||||||
)
|
|
||||||
.await
|
.await
|
||||||
.is_ok();
|
.is_ok();
|
||||||
|
|
||||||
@@ -177,10 +169,7 @@ async fn run_handler_process(
|
|||||||
libc::kill(pid as libc::pid_t, libc::SIGTERM);
|
libc::kill(pid as libc::pid_t, libc::SIGTERM);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let termed = tokio::time::timeout(
|
let termed = tokio::time::timeout(std::time::Duration::from_secs(1), child.wait())
|
||||||
std::time::Duration::from_secs(1),
|
|
||||||
child.wait(),
|
|
||||||
)
|
|
||||||
.await
|
.await
|
||||||
.is_ok();
|
.is_ok();
|
||||||
if !termed {
|
if !termed {
|
||||||
@@ -194,11 +183,7 @@ async fn run_handler_process(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Wait for the reader to finish
|
// Wait for the reader to finish
|
||||||
let _ = tokio::time::timeout(
|
let _ = tokio::time::timeout(std::time::Duration::from_secs(2), reader_handle).await;
|
||||||
std::time::Duration::from_secs(2),
|
|
||||||
reader_handle,
|
|
||||||
)
|
|
||||||
.await;
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -121,8 +121,7 @@ impl HookHandler for ShellExecHandler {
|
|||||||
if let Some(cmd) = self.commands.get(&kind) {
|
if let Some(cmd) = self.commands.get(&kind) {
|
||||||
let cmd = cmd.clone();
|
let cmd = cmd.clone();
|
||||||
// Serialize event as JSON for the hook's stdin
|
// Serialize event as JSON for the hook's stdin
|
||||||
let event_json =
|
let event_json = serde_json::to_value(&event).unwrap_or(serde_json::Value::Null);
|
||||||
serde_json::to_value(&event).unwrap_or(serde_json::Value::Null);
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
if let Err(e) = run_hook(&cmd, &event_json).await {
|
if let Err(e) = run_hook(&cmd, &event_json).await {
|
||||||
tracing::warn!(error = %e, command = %cmd, "exec hook failed");
|
tracing::warn!(error = %e, command = %cmd, "exec hook failed");
|
||||||
|
|||||||
120
crates/pikl/src/ipc/integration.rs
Normal file
120
crates/pikl/src/ipc/integration.rs
Normal 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":"#,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
55
crates/pikl/src/ipc/mod.rs
Normal file
55
crates/pikl/src/ipc/mod.rs
Normal 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")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
212
crates/pikl/src/ipc/protocol.rs
Normal file
212
crates/pikl/src/ipc/protocol.rs
Normal 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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
331
crates/pikl/src/ipc/server.rs
Normal file
331
crates/pikl/src/ipc/server.rs
Normal 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
|
||||||
|
}
|
||||||
@@ -1,5 +1,7 @@
|
|||||||
mod handler;
|
mod handler;
|
||||||
mod hook;
|
mod hook;
|
||||||
|
mod ipc;
|
||||||
|
mod session;
|
||||||
|
|
||||||
use std::io::{BufReader, IsTerminal, Write};
|
use std::io::{BufReader, IsTerminal, Write};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
@@ -7,12 +9,14 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
|
|
||||||
|
use pikl_core::column::ColumnConfig;
|
||||||
|
use pikl_core::csv_input::{self, InputFormat};
|
||||||
use pikl_core::debounce::{DebounceMode, DebouncedDispatcher};
|
use pikl_core::debounce::{DebounceMode, DebouncedDispatcher};
|
||||||
use pikl_core::error::PiklError;
|
use pikl_core::error::PiklError;
|
||||||
use pikl_core::event::{Action, MenuResult, Mode};
|
use pikl_core::event::{Action, MenuResult, ModifiedAction, Mode};
|
||||||
use pikl_core::format::FormatTemplate;
|
use pikl_core::format::FormatTemplate;
|
||||||
use pikl_core::hook::{HookEventKind, HookHandler};
|
use pikl_core::hook::{HookEventKind, HookHandler};
|
||||||
use pikl_core::input::read_items_sync;
|
use pikl_core::input::{parse_line_to_value, read_items_sync};
|
||||||
use pikl_core::item::Item;
|
use pikl_core::item::Item;
|
||||||
use pikl_core::json_menu::JsonMenu;
|
use pikl_core::json_menu::JsonMenu;
|
||||||
use pikl_core::menu::MenuRunner;
|
use pikl_core::menu::MenuRunner;
|
||||||
@@ -23,6 +27,39 @@ use serde_json::Value;
|
|||||||
use handler::ShellHandlerHook;
|
use handler::ShellHandlerHook;
|
||||||
use hook::ShellExecHandler;
|
use hook::ShellExecHandler;
|
||||||
|
|
||||||
|
/// Which frontend to launch.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum FrontendMode {
|
||||||
|
Tui,
|
||||||
|
Gui,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve `--mode` flag into a concrete frontend. "auto"
|
||||||
|
/// picks GUI when a graphical environment is detected:
|
||||||
|
/// `$WAYLAND_DISPLAY` on Linux, always on macOS. Falls
|
||||||
|
/// back to TUI otherwise.
|
||||||
|
fn resolve_frontend_mode(mode_str: &str) -> Option<FrontendMode> {
|
||||||
|
match mode_str {
|
||||||
|
"tui" => Some(FrontendMode::Tui),
|
||||||
|
"gui" => Some(FrontendMode::Gui),
|
||||||
|
"auto" => {
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
Some(FrontendMode::Gui)
|
||||||
|
}
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
{
|
||||||
|
if std::env::var_os("WAYLAND_DISPLAY").is_some() {
|
||||||
|
Some(FrontendMode::Gui)
|
||||||
|
} else {
|
||||||
|
Some(FrontendMode::Tui)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Parser)]
|
#[derive(Parser)]
|
||||||
#[command(
|
#[command(
|
||||||
name = "pikl",
|
name = "pikl",
|
||||||
@@ -119,6 +156,34 @@ struct Cli {
|
|||||||
/// Wrap output in structured JSON with action metadata
|
/// Wrap output in structured JSON with action metadata
|
||||||
#[arg(long)]
|
#[arg(long)]
|
||||||
structured: bool,
|
structured: bool,
|
||||||
|
|
||||||
|
/// Enable multi-select mode (Space/Tab to toggle, V for visual)
|
||||||
|
#[arg(long)]
|
||||||
|
multi: bool,
|
||||||
|
|
||||||
|
/// Output selected items in selection order instead of input order
|
||||||
|
#[arg(long)]
|
||||||
|
selection_order: bool,
|
||||||
|
|
||||||
|
/// Input format: auto, csv, or tsv
|
||||||
|
#[arg(long, value_name = "FORMAT", default_value = "auto")]
|
||||||
|
input_format: String,
|
||||||
|
|
||||||
|
/// Columns to display in table mode (e.g. "label,meta.age:Age")
|
||||||
|
#[arg(long, value_name = "COLS")]
|
||||||
|
columns: Option<String>,
|
||||||
|
|
||||||
|
/// Enable IPC control via Unix socket
|
||||||
|
#[arg(long)]
|
||||||
|
ipc: bool,
|
||||||
|
|
||||||
|
/// Session name (enables filter history, names the IPC socket)
|
||||||
|
#[arg(long)]
|
||||||
|
session: Option<String>,
|
||||||
|
|
||||||
|
/// Frontend mode: tui, gui, or auto (default: auto)
|
||||||
|
#[arg(long, value_name = "MODE", default_value = "auto")]
|
||||||
|
mode: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn main() {
|
fn main() {
|
||||||
@@ -173,12 +238,52 @@ fn main() {
|
|||||||
std::process::exit(2);
|
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
|
let timeout = cli
|
||||||
.stdin_timeout
|
.stdin_timeout
|
||||||
.unwrap_or(if script.is_some() { 30 } else { 0 });
|
.unwrap_or(if script.is_some() { 30 } else { 0 });
|
||||||
|
|
||||||
let items = match read_stdin_with_timeout(timeout, &cli.label_key) {
|
let (items, csv_headers) =
|
||||||
Ok(items) => items,
|
match read_stdin_with_timeout(timeout, &cli.label_key, input_format) {
|
||||||
|
Ok(r) => r,
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
let _ = writeln!(std::io::stderr().lock(), "pikl: {e}");
|
let _ = writeln!(std::io::stderr().lock(), "pikl: {e}");
|
||||||
std::process::exit(2);
|
std::process::exit(2);
|
||||||
@@ -192,6 +297,15 @@ fn main() {
|
|||||||
);
|
);
|
||||||
std::process::exit(2);
|
std::process::exit(2);
|
||||||
}
|
}
|
||||||
|
(items, csv_headers)
|
||||||
|
};
|
||||||
|
|
||||||
|
// Resolve column config: explicit --columns, or auto-generate from CSV headers
|
||||||
|
let column_config = if let Some(ref cols) = cli.columns {
|
||||||
|
Some(ColumnConfig::parse(cols))
|
||||||
|
} else {
|
||||||
|
csv_headers.map(|h| ColumnConfig::from_headers(&h))
|
||||||
|
};
|
||||||
|
|
||||||
// STEP 3: Build menu, start runtime
|
// STEP 3: Build menu, start runtime
|
||||||
let rt = tokio::runtime::Runtime::new().unwrap_or_else(|e| {
|
let rt = tokio::runtime::Runtime::new().unwrap_or_else(|e| {
|
||||||
@@ -202,8 +316,23 @@ fn main() {
|
|||||||
std::process::exit(2);
|
std::process::exit(2);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Resolve frontend mode
|
||||||
|
let frontend_mode = match resolve_frontend_mode(&cli.mode) {
|
||||||
|
Some(m) => m,
|
||||||
|
None => {
|
||||||
|
let _ = writeln!(
|
||||||
|
std::io::stderr().lock(),
|
||||||
|
"pikl: unknown --mode '{}', expected auto, tui, or gui",
|
||||||
|
cli.mode
|
||||||
|
);
|
||||||
|
std::process::exit(2);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Reopen stdin from /dev/tty before entering async context.
|
// Reopen stdin from /dev/tty before entering async context.
|
||||||
|
// Only needed for TUI mode (GUI doesn't read from the terminal).
|
||||||
if script.is_none()
|
if script.is_none()
|
||||||
|
&& frontend_mode == FrontendMode::Tui
|
||||||
&& let Err(e) = reopen_stdin_from_tty()
|
&& let Err(e) = reopen_stdin_from_tty()
|
||||||
{
|
{
|
||||||
let _ = writeln!(std::io::stderr().lock(), "pikl: {e}");
|
let _ = writeln!(std::io::stderr().lock(), "pikl: {e}");
|
||||||
@@ -214,28 +343,74 @@ fn main() {
|
|||||||
let start_mode = match cli.start_mode.as_str() {
|
let start_mode = match cli.start_mode.as_str() {
|
||||||
"insert" => Mode::Insert,
|
"insert" => Mode::Insert,
|
||||||
"normal" => Mode::Normal,
|
"normal" => Mode::Normal,
|
||||||
|
"visual" => Mode::Visual,
|
||||||
other => {
|
other => {
|
||||||
let _ = writeln!(
|
let _ = writeln!(
|
||||||
std::io::stderr().lock(),
|
std::io::stderr().lock(),
|
||||||
"pikl: unknown mode '{other}', expected insert or normal"
|
"pikl: unknown mode '{other}', expected insert, normal, or visual"
|
||||||
);
|
);
|
||||||
std::process::exit(2);
|
std::process::exit(2);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Load session filter history if --session is set
|
||||||
|
let mut history = cli.session.as_ref().and_then(|name| {
|
||||||
|
match session::FilterHistory::load(name) {
|
||||||
|
Ok(h) => Some(h),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(%e, "failed to load session history");
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// STEP 4: Branch on headless vs interactive
|
// STEP 4: Branch on headless vs interactive
|
||||||
|
#[cfg(unix)]
|
||||||
|
let saved_fd = saved_stdin_fd;
|
||||||
|
#[cfg(not(unix))]
|
||||||
|
let saved_fd: Option<i32> = None;
|
||||||
|
|
||||||
let result = if let Some(script) = script {
|
let result = if let Some(script) = script {
|
||||||
rt.block_on(run_headless(items, &cli, script, start_mode))
|
rt.block_on(run_headless(items, &cli, script, start_mode, column_config))
|
||||||
|
} else if cfg!(target_os = "macos") && frontend_mode == FrontendMode::Gui {
|
||||||
|
// macOS requires the GUI event loop on the main thread
|
||||||
|
// (AppKit/winit constraint). We can't run it inside
|
||||||
|
// block_on, so the GUI gets the main thread and tokio
|
||||||
|
// work is spawned onto the runtime's worker threads.
|
||||||
|
run_gui_main_thread(
|
||||||
|
&rt, items, &cli, start_mode, column_config, streaming, saved_fd,
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
rt.block_on(run_interactive(items, &cli, start_mode))
|
let history_entries = history.as_ref().map(|h| h.entries().to_vec());
|
||||||
|
rt.block_on(run_interactive(
|
||||||
|
items,
|
||||||
|
&cli,
|
||||||
|
start_mode,
|
||||||
|
column_config,
|
||||||
|
history_entries,
|
||||||
|
streaming,
|
||||||
|
saved_fd,
|
||||||
|
frontend_mode,
|
||||||
|
))
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Save filter text to session history on confirm
|
||||||
|
if let Some(ref mut h) = history
|
||||||
|
&& let Ok(MenuResult::Selected { ref filter_text, .. })
|
||||||
|
| Ok(MenuResult::Quicklist { ref filter_text, .. }) = result
|
||||||
|
{
|
||||||
|
let _ = h.append(filter_text).inspect_err(|e| {
|
||||||
|
tracing::warn!(%e, "failed to save session history");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// STEP 5: Handle result
|
// STEP 5: Handle result
|
||||||
handle_result(result, &cli);
|
handle_result(result, &cli);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build a JsonMenu with optional filter_fields and format template.
|
/// Build a JsonMenu with optional filter_fields, format template,
|
||||||
fn build_menu(items: Vec<Item>, cli: &Cli) -> JsonMenu {
|
/// and column config.
|
||||||
|
fn build_menu(items: Vec<Item>, cli: &Cli, column_config: Option<ColumnConfig>) -> JsonMenu {
|
||||||
let mut menu = JsonMenu::new(items, cli.label_key.clone());
|
let mut menu = JsonMenu::new(items, cli.label_key.clone());
|
||||||
if let Some(ref fields) = cli.filter_fields {
|
if let Some(ref fields) = cli.filter_fields {
|
||||||
menu.set_filter_fields(fields.clone());
|
menu.set_filter_fields(fields.clone());
|
||||||
@@ -243,13 +418,16 @@ fn build_menu(items: Vec<Item>, cli: &Cli) -> JsonMenu {
|
|||||||
if let Some(ref template) = cli.format {
|
if let Some(ref template) = cli.format {
|
||||||
menu.set_format_template(FormatTemplate::parse(template));
|
menu.set_format_template(FormatTemplate::parse(template));
|
||||||
}
|
}
|
||||||
|
if let Some(config) = column_config {
|
||||||
|
menu.set_column_config(config);
|
||||||
|
}
|
||||||
menu
|
menu
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build the composite hook handler from CLI flags, if any hooks are specified.
|
/// Build the composite hook handler from CLI flags, if any hooks are specified.
|
||||||
fn build_hook_handler(
|
fn build_hook_handler(
|
||||||
cli: &Cli,
|
cli: &Cli,
|
||||||
action_tx: &tokio::sync::mpsc::Sender<Action>,
|
action_tx: &tokio::sync::mpsc::Sender<ModifiedAction>,
|
||||||
) -> Option<(Arc<dyn HookHandler>, DebouncedDispatcher)> {
|
) -> Option<(Arc<dyn HookHandler>, DebouncedDispatcher)> {
|
||||||
let exec_handler = ShellExecHandler::from_cli(cli);
|
let exec_handler = ShellExecHandler::from_cli(cli);
|
||||||
let handler_hook = ShellHandlerHook::from_cli(cli, action_tx.clone());
|
let handler_hook = ShellHandlerHook::from_cli(cli, action_tx.clone());
|
||||||
@@ -309,12 +487,10 @@ struct CompositeHookHandler {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl HookHandler for CompositeHookHandler {
|
impl HookHandler for CompositeHookHandler {
|
||||||
fn handle(
|
fn handle(&self, event: pikl_core::hook::HookEvent) -> Result<(), PiklError> {
|
||||||
&self,
|
// Exec hooks are fire-and-forget: errors are logged
|
||||||
event: pikl_core::hook::HookEvent,
|
// internally by ShellExecHandler, not propagated here.
|
||||||
) -> Result<(), PiklError> {
|
// Handler hooks are bidirectional, so errors propagate.
|
||||||
// Both fire. Exec is fire-and-forget, handler may
|
|
||||||
// send responses through action_tx.
|
|
||||||
let _ = self.exec.handle(event.clone());
|
let _ = self.exec.handle(event.clone());
|
||||||
if let Some(ref h) = self.handler {
|
if let Some(ref h) = self.handler {
|
||||||
h.handle(event)?;
|
h.handle(event)?;
|
||||||
@@ -330,9 +506,12 @@ async fn run_headless(
|
|||||||
cli: &Cli,
|
cli: &Cli,
|
||||||
script: Vec<ScriptAction>,
|
script: Vec<ScriptAction>,
|
||||||
start_mode: Mode,
|
start_mode: Mode,
|
||||||
|
column_config: Option<ColumnConfig>,
|
||||||
) -> Result<MenuResult, PiklError> {
|
) -> Result<MenuResult, PiklError> {
|
||||||
let (mut menu, action_tx) = MenuRunner::new(build_menu(items, cli));
|
let (mut menu, action_tx) = MenuRunner::new(build_menu(items, cli, column_config));
|
||||||
menu.set_initial_mode(start_mode);
|
menu.set_initial_mode(start_mode);
|
||||||
|
menu.set_multi(cli.multi);
|
||||||
|
menu.set_selection_order(cli.selection_order);
|
||||||
|
|
||||||
if let Some((_handler, dispatcher)) = build_hook_handler(cli, &action_tx) {
|
if let Some((_handler, dispatcher)) = build_hook_handler(cli, &action_tx) {
|
||||||
menu.set_dispatcher(dispatcher);
|
menu.set_dispatcher(dispatcher);
|
||||||
@@ -341,7 +520,9 @@ async fn run_headless(
|
|||||||
let event_rx = menu.subscribe();
|
let event_rx = menu.subscribe();
|
||||||
|
|
||||||
// Default headless viewport
|
// Default headless viewport
|
||||||
let _ = action_tx.send(Action::Resize { height: 50 }).await;
|
let _ = action_tx
|
||||||
|
.send(ModifiedAction::new(Action::Resize { height: 50 }))
|
||||||
|
.await;
|
||||||
|
|
||||||
let menu_handle = tokio::spawn(menu.run());
|
let menu_handle = tokio::spawn(menu.run());
|
||||||
|
|
||||||
@@ -354,7 +535,7 @@ async fn run_headless(
|
|||||||
|
|
||||||
match show_action {
|
match show_action {
|
||||||
ShowAction::Ui | ShowAction::Tui | ShowAction::Gui => {
|
ShowAction::Ui | ShowAction::Tui | ShowAction::Gui => {
|
||||||
let tui_handle = tokio::spawn(pikl_tui::run(action_tx, event_rx));
|
let tui_handle = tokio::spawn(pikl_tui::run(action_tx, event_rx, None));
|
||||||
let result = menu_handle
|
let result = menu_handle
|
||||||
.await
|
.await
|
||||||
.map_err(|e| PiklError::Io(std::io::Error::other(e.to_string())))??;
|
.map_err(|e| PiklError::Io(std::io::Error::other(e.to_string())))??;
|
||||||
@@ -377,9 +558,17 @@ async fn run_interactive(
|
|||||||
items: Vec<Item>,
|
items: Vec<Item>,
|
||||||
cli: &Cli,
|
cli: &Cli,
|
||||||
start_mode: Mode,
|
start_mode: Mode,
|
||||||
|
column_config: Option<ColumnConfig>,
|
||||||
|
filter_history: Option<Vec<String>>,
|
||||||
|
streaming: bool,
|
||||||
|
saved_stdin_fd: Option<i32>,
|
||||||
|
frontend_mode: FrontendMode,
|
||||||
) -> Result<MenuResult, PiklError> {
|
) -> Result<MenuResult, PiklError> {
|
||||||
let (mut menu, action_tx) = MenuRunner::new(build_menu(items, cli));
|
let (mut menu, action_tx) = MenuRunner::new(build_menu(items, cli, column_config));
|
||||||
menu.set_initial_mode(start_mode);
|
menu.set_initial_mode(start_mode);
|
||||||
|
menu.set_multi(cli.multi);
|
||||||
|
menu.set_selection_order(cli.selection_order);
|
||||||
|
menu.set_streaming(streaming);
|
||||||
|
|
||||||
if let Some((_handler, dispatcher)) = build_hook_handler(cli, &action_tx) {
|
if let Some((_handler, dispatcher)) = build_hook_handler(cli, &action_tx) {
|
||||||
menu.set_dispatcher(dispatcher);
|
menu.set_dispatcher(dispatcher);
|
||||||
@@ -387,47 +576,266 @@ async fn run_interactive(
|
|||||||
|
|
||||||
let event_rx = menu.subscribe();
|
let event_rx = menu.subscribe();
|
||||||
|
|
||||||
|
// Start IPC server if requested
|
||||||
|
let _ipc_server = if cli.ipc {
|
||||||
|
let sock_path = ipc::socket_path(cli.session.as_deref())?;
|
||||||
|
let server = ipc::server::IpcServer::new(
|
||||||
|
sock_path,
|
||||||
|
action_tx.clone(),
|
||||||
|
menu.event_sender(),
|
||||||
|
);
|
||||||
|
let ipc_event_rx = menu.subscribe();
|
||||||
|
server
|
||||||
|
.start(ipc_event_rx)
|
||||||
|
.map_err(PiklError::Io)?;
|
||||||
|
Some(server)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
// Handle SIGINT/SIGTERM: restore terminal and exit cleanly.
|
// Handle SIGINT/SIGTERM: restore terminal and exit cleanly.
|
||||||
let signal_tx = action_tx.clone();
|
let signal_tx = action_tx.clone();
|
||||||
|
let signal_frontend = frontend_mode;
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
if let Ok(()) = tokio::signal::ctrl_c().await {
|
match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
|
||||||
pikl_tui::restore_terminal();
|
Ok(mut sigterm) => {
|
||||||
let _ = signal_tx.send(Action::Cancel).await;
|
tokio::select! {
|
||||||
|
_ = tokio::signal::ctrl_c() => {}
|
||||||
|
_ = sigterm.recv() => {}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(%e, "failed to register SIGTERM handler, falling back to SIGINT only");
|
||||||
|
let _ = tokio::signal::ctrl_c().await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if signal_frontend == FrontendMode::Tui {
|
||||||
|
pikl_tui::restore_terminal();
|
||||||
|
}
|
||||||
|
let _ = signal_tx.send(ModifiedAction::new(Action::Cancel)).await;
|
||||||
});
|
});
|
||||||
|
|
||||||
let tui_handle = tokio::spawn(async move { pikl_tui::run(action_tx, event_rx).await });
|
// Spawn background stdin reader for streaming mode.
|
||||||
|
if let Some(fd) = saved_stdin_fd {
|
||||||
|
let stream_tx = action_tx.clone();
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::io::BufRead;
|
||||||
|
use std::os::unix::io::FromRawFd;
|
||||||
|
|
||||||
|
let 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 result = menu.run().await;
|
||||||
|
|
||||||
let _ = tui_handle.await;
|
let _ = tui_handle.await;
|
||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
FrontendMode::Gui => {
|
||||||
|
// Spawn the core event loop on a background task.
|
||||||
|
let menu_handle = tokio::spawn(menu.run());
|
||||||
|
|
||||||
|
// On Linux, spawn_blocking is fine since Wayland doesn't
|
||||||
|
// have the main-thread restriction.
|
||||||
|
let gui_handle = tokio::task::spawn_blocking(move || {
|
||||||
|
pikl_gui::run(action_tx, event_rx)
|
||||||
|
.map_err(|e| PiklError::Io(std::io::Error::other(e.to_string())))
|
||||||
|
});
|
||||||
|
|
||||||
|
if let Err(e) = gui_handle.await {
|
||||||
|
return Err(PiklError::Io(std::io::Error::other(e.to_string())));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for core to finish.
|
||||||
|
match menu_handle.await {
|
||||||
|
Ok(result) => result,
|
||||||
|
Err(e) => Err(PiklError::Io(std::io::Error::other(e.to_string()))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Run GUI on the main thread for macOS. AppKit requires
|
||||||
|
/// the event loop on the main thread, so we can't use
|
||||||
|
/// block_on (which would nest the iced event loop inside
|
||||||
|
/// tokio's reactor). Instead, spawn all background work
|
||||||
|
/// onto the runtime's worker threads and run the GUI
|
||||||
|
/// directly.
|
||||||
|
fn run_gui_main_thread(
|
||||||
|
rt: &tokio::runtime::Runtime,
|
||||||
|
items: Vec<Item>,
|
||||||
|
cli: &Cli,
|
||||||
|
start_mode: Mode,
|
||||||
|
column_config: Option<ColumnConfig>,
|
||||||
|
streaming: bool,
|
||||||
|
saved_stdin_fd: Option<i32>,
|
||||||
|
) -> Result<MenuResult, PiklError> {
|
||||||
|
let (mut menu, action_tx) = MenuRunner::new(build_menu(items, cli, column_config));
|
||||||
|
menu.set_initial_mode(start_mode);
|
||||||
|
menu.set_multi(cli.multi);
|
||||||
|
menu.set_selection_order(cli.selection_order);
|
||||||
|
menu.set_streaming(streaming);
|
||||||
|
|
||||||
|
if let Some((_handler, dispatcher)) = build_hook_handler(cli, &action_tx) {
|
||||||
|
menu.set_dispatcher(dispatcher);
|
||||||
|
}
|
||||||
|
|
||||||
|
let event_rx = menu.subscribe();
|
||||||
|
|
||||||
|
// Start IPC server if requested
|
||||||
|
let _ipc_server = if cli.ipc {
|
||||||
|
let sock_path = ipc::socket_path(cli.session.as_deref())?;
|
||||||
|
let server = ipc::server::IpcServer::new(
|
||||||
|
sock_path,
|
||||||
|
action_tx.clone(),
|
||||||
|
menu.event_sender(),
|
||||||
|
);
|
||||||
|
let ipc_event_rx = menu.subscribe();
|
||||||
|
server.start(ipc_event_rx).map_err(PiklError::Io)?;
|
||||||
|
Some(server)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
// Enter the runtime context so tokio::spawn works
|
||||||
|
// without being inside block_on.
|
||||||
|
let _guard = rt.enter();
|
||||||
|
|
||||||
|
// Signal handler
|
||||||
|
let signal_tx = action_tx.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
|
||||||
|
Ok(mut sigterm) => {
|
||||||
|
tokio::select! {
|
||||||
|
_ = tokio::signal::ctrl_c() => {}
|
||||||
|
_ = sigterm.recv() => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(%e, "failed to register SIGTERM handler, falling back to SIGINT only");
|
||||||
|
let _ = tokio::signal::ctrl_c().await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let _ = signal_tx.send(ModifiedAction::new(Action::Cancel)).await;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Streaming stdin reader
|
||||||
|
if let Some(fd) = saved_stdin_fd {
|
||||||
|
let stream_tx = action_tx.clone();
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::io::BufRead;
|
||||||
|
use std::os::unix::io::FromRawFd;
|
||||||
|
|
||||||
|
let file = unsafe { std::fs::File::from_raw_fd(fd) };
|
||||||
|
let mut reader = std::io::BufReader::new(file);
|
||||||
|
let mut batch = Vec::with_capacity(100);
|
||||||
|
let mut line = String::new();
|
||||||
|
|
||||||
|
loop {
|
||||||
|
line.clear();
|
||||||
|
match reader.read_line(&mut line) {
|
||||||
|
Ok(0) => break,
|
||||||
|
Ok(_) => {
|
||||||
|
let trimmed = line.trim_end();
|
||||||
|
if !trimmed.is_empty() {
|
||||||
|
batch.push(parse_line_to_value(trimmed));
|
||||||
|
}
|
||||||
|
if (batch.len() >= 100 || reader.buffer().is_empty())
|
||||||
|
&& !batch.is_empty()
|
||||||
|
{
|
||||||
|
let _ = stream_tx.blocking_send(ModifiedAction::new(
|
||||||
|
Action::AddItems(std::mem::take(&mut batch)),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!(%e, "stdin read error");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !batch.is_empty() {
|
||||||
|
let _ =
|
||||||
|
stream_tx.blocking_send(ModifiedAction::new(Action::AddItems(batch)));
|
||||||
|
}
|
||||||
|
let _ =
|
||||||
|
stream_tx.blocking_send(ModifiedAction::new(Action::StreamingDone));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Core menu on a background task
|
||||||
|
let menu_handle = tokio::spawn(menu.run());
|
||||||
|
|
||||||
|
// GUI on the main thread (the whole point of this function)
|
||||||
|
pikl_gui::run(action_tx, event_rx)
|
||||||
|
.map_err(|e| PiklError::Io(std::io::Error::other(e.to_string())))?;
|
||||||
|
|
||||||
|
// GUI exited. Collect the menu result.
|
||||||
|
rt.block_on(async {
|
||||||
|
match menu_handle.await {
|
||||||
|
Ok(result) => result,
|
||||||
|
Err(e) => Err(PiklError::Io(std::io::Error::other(e.to_string()))),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Process the menu result: print output to stdout and
|
/// Process the menu result: print output to stdout and
|
||||||
/// exit with the appropriate code.
|
/// exit with the appropriate code.
|
||||||
fn handle_result(result: Result<MenuResult, PiklError>, cli: &Cli) {
|
fn handle_result(result: Result<MenuResult, PiklError>, cli: &Cli) {
|
||||||
let mut out = std::io::stdout().lock();
|
let mut out = std::io::stdout().lock();
|
||||||
match result {
|
match result {
|
||||||
Ok(MenuResult::Selected { value, index }) => {
|
Ok(MenuResult::Selected {
|
||||||
if cli.structured {
|
items, modifiers, ..
|
||||||
let output = OutputItem {
|
}) => {
|
||||||
value,
|
|
||||||
action: OutputAction::Select,
|
|
||||||
index,
|
|
||||||
};
|
|
||||||
let _ = write_output_json(&mut out, &output);
|
|
||||||
} else {
|
|
||||||
let _ = write_plain_value(&mut out, &value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(MenuResult::Quicklist { items }) => {
|
|
||||||
if cli.structured {
|
if cli.structured {
|
||||||
for (value, index) in items {
|
for (value, index) in items {
|
||||||
let output = OutputItem {
|
let output = OutputItem {
|
||||||
value,
|
value,
|
||||||
action: OutputAction::Quicklist,
|
action: OutputAction::Select,
|
||||||
index,
|
index,
|
||||||
|
modifiers,
|
||||||
};
|
};
|
||||||
let _ = write_output_json(&mut out, &output);
|
let _ = write_output_json(&mut out, &output);
|
||||||
}
|
}
|
||||||
@@ -437,12 +845,32 @@ fn handle_result(result: Result<MenuResult, PiklError>, cli: &Cli) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(MenuResult::Cancelled) => {
|
Ok(MenuResult::Quicklist {
|
||||||
|
items, modifiers, ..
|
||||||
|
}) => {
|
||||||
|
if cli.structured {
|
||||||
|
for (value, index) in items {
|
||||||
|
let output = OutputItem {
|
||||||
|
value,
|
||||||
|
action: OutputAction::Quicklist,
|
||||||
|
index,
|
||||||
|
modifiers,
|
||||||
|
};
|
||||||
|
let _ = write_output_json(&mut out, &output);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
for (value, _) in &items {
|
||||||
|
let _ = write_plain_value(&mut out, value);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(MenuResult::Cancelled { modifiers }) => {
|
||||||
if cli.structured {
|
if cli.structured {
|
||||||
let output = OutputItem {
|
let output = OutputItem {
|
||||||
value: Value::Null,
|
value: Value::Null,
|
||||||
action: OutputAction::Cancel,
|
action: OutputAction::Cancel,
|
||||||
index: 0,
|
index: 0,
|
||||||
|
modifiers,
|
||||||
};
|
};
|
||||||
let _ = write_output_json(&mut out, &output);
|
let _ = write_output_json(&mut out, &output);
|
||||||
}
|
}
|
||||||
@@ -457,7 +885,7 @@ fn handle_result(result: Result<MenuResult, PiklError>, cli: &Cli) {
|
|||||||
|
|
||||||
/// Serialize an OutputItem as JSON and write it to the given writer.
|
/// Serialize an OutputItem as JSON and write it to the given writer.
|
||||||
fn write_output_json(writer: &mut impl Write, output: &OutputItem) -> Result<(), std::io::Error> {
|
fn write_output_json(writer: &mut impl Write, output: &OutputItem) -> Result<(), std::io::Error> {
|
||||||
let json = serde_json::to_string(output).unwrap_or_default();
|
let json = serde_json::to_string(output).map_err(|e| std::io::Error::other(e.to_string()))?;
|
||||||
writeln!(writer, "{json}")
|
writeln!(writer, "{json}")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -467,7 +895,8 @@ fn write_plain_value(writer: &mut impl Write, value: &Value) -> Result<(), std::
|
|||||||
match value {
|
match value {
|
||||||
Value::String(s) => writeln!(writer, "{s}"),
|
Value::String(s) => writeln!(writer, "{s}"),
|
||||||
_ => {
|
_ => {
|
||||||
let json = serde_json::to_string(value).unwrap_or_default();
|
let json =
|
||||||
|
serde_json::to_string(value).map_err(|e| std::io::Error::other(e.to_string()))?;
|
||||||
writeln!(writer, "{json}")
|
writeln!(writer, "{json}")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -476,15 +905,26 @@ fn write_plain_value(writer: &mut impl Write, value: &Value) -> Result<(), std::
|
|||||||
/// Read items from stdin. If `timeout_secs` is non-zero,
|
/// Read items from stdin. If `timeout_secs` is non-zero,
|
||||||
/// spawn a thread and bail if it doesn't finish in time.
|
/// spawn a thread and bail if it doesn't finish in time.
|
||||||
/// A timeout of 0 means no timeout (blocking read).
|
/// A timeout of 0 means no timeout (blocking read).
|
||||||
fn read_stdin_with_timeout(timeout_secs: u64, label_key: &str) -> Result<Vec<Item>, PiklError> {
|
///
|
||||||
|
/// Returns (items, optional_csv_headers). CSV headers are
|
||||||
|
/// returned so column config can be auto-generated.
|
||||||
|
fn read_stdin_with_timeout(
|
||||||
|
timeout_secs: u64,
|
||||||
|
label_key: &str,
|
||||||
|
format: InputFormat,
|
||||||
|
) -> Result<(Vec<Item>, Option<Vec<String>>), PiklError> {
|
||||||
if timeout_secs == 0 {
|
if timeout_secs == 0 {
|
||||||
return read_items_sync(std::io::stdin().lock(), label_key);
|
return read_stdin_inner(std::io::stdin().lock(), label_key, format);
|
||||||
}
|
}
|
||||||
|
|
||||||
let label_key = label_key.to_string();
|
let label_key = label_key.to_string();
|
||||||
let (tx, rx) = std::sync::mpsc::channel();
|
let (tx, rx) = std::sync::mpsc::channel();
|
||||||
std::thread::spawn(move || {
|
std::thread::spawn(move || {
|
||||||
let _ = tx.send(read_items_sync(std::io::stdin().lock(), &label_key));
|
let _ = tx.send(read_stdin_inner(
|
||||||
|
std::io::stdin().lock(),
|
||||||
|
&label_key,
|
||||||
|
format,
|
||||||
|
));
|
||||||
});
|
});
|
||||||
|
|
||||||
match rx.recv_timeout(Duration::from_secs(timeout_secs)) {
|
match rx.recv_timeout(Duration::from_secs(timeout_secs)) {
|
||||||
@@ -503,6 +943,24 @@ fn read_stdin_with_timeout(timeout_secs: u64, label_key: &str) -> Result<Vec<Ite
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Read stdin as either JSON/plain or CSV/TSV depending on format.
|
||||||
|
fn read_stdin_inner(
|
||||||
|
reader: impl std::io::BufRead,
|
||||||
|
label_key: &str,
|
||||||
|
format: InputFormat,
|
||||||
|
) -> Result<(Vec<Item>, Option<Vec<String>>), PiklError> {
|
||||||
|
match format {
|
||||||
|
InputFormat::Auto => {
|
||||||
|
let items = read_items_sync(reader, label_key)?;
|
||||||
|
Ok((items, None))
|
||||||
|
}
|
||||||
|
InputFormat::Csv | InputFormat::Tsv => {
|
||||||
|
let result = csv_input::read_csv_items_sync(reader, format, label_key)?;
|
||||||
|
Ok((result.items, Some(result.headers)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Redirect stdin to `/dev/tty` so the TUI can read keyboard
|
/// Redirect stdin to `/dev/tty` so the TUI can read keyboard
|
||||||
/// input after stdin was consumed for piped items. Flushes
|
/// input after stdin was consumed for piped items. Flushes
|
||||||
/// stale input so crossterm starts clean.
|
/// stale input so crossterm starts clean.
|
||||||
|
|||||||
210
crates/pikl/src/session.rs
Normal file
210
crates/pikl/src/session.rs
Normal 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(())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -67,3 +67,166 @@ fn headless_actions_after_show_ui_exits_2() {
|
|||||||
"expected show-ui error, got: {stderr}"
|
"expected show-ui error, got: {stderr}"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// -- Modifier prefix integration tests --
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn modifier_prefix_in_structured_output() {
|
||||||
|
let (stdout, _stderr, code) = common::run_pikl(
|
||||||
|
"alpha\nbeta\n",
|
||||||
|
"+shift confirm\n",
|
||||||
|
&["--structured"],
|
||||||
|
);
|
||||||
|
assert_eq!(code, 0, "expected exit 0, stderr: {_stderr}");
|
||||||
|
assert!(
|
||||||
|
stdout.contains(r#""modifiers":["shift"]"#),
|
||||||
|
"expected modifiers array in structured output, got: {stdout}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_modifier_omits_field_in_structured_output() {
|
||||||
|
let (stdout, _stderr, code) = common::run_pikl(
|
||||||
|
"alpha\nbeta\n",
|
||||||
|
"confirm\n",
|
||||||
|
&["--structured"],
|
||||||
|
);
|
||||||
|
assert_eq!(code, 0, "expected exit 0, stderr: {_stderr}");
|
||||||
|
assert!(
|
||||||
|
!stdout.contains("modifiers"),
|
||||||
|
"expected no modifiers field when none held, got: {stdout}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn multiple_modifier_prefixes_in_structured() {
|
||||||
|
let (stdout, _stderr, code) = common::run_pikl(
|
||||||
|
"alpha\nbeta\n",
|
||||||
|
"+ctrl+alt confirm\n",
|
||||||
|
&["--structured"],
|
||||||
|
);
|
||||||
|
assert_eq!(code, 0, "expected exit 0, stderr: {_stderr}");
|
||||||
|
assert!(
|
||||||
|
stdout.contains(r#""modifiers":["ctrl","alt"]"#),
|
||||||
|
"expected ctrl+alt modifiers, got: {stdout}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn modifier_prefix_cancel_structured() {
|
||||||
|
let (stdout, _stderr, code) = common::run_pikl(
|
||||||
|
"alpha\n",
|
||||||
|
"+shift cancel\n",
|
||||||
|
&["--structured"],
|
||||||
|
);
|
||||||
|
assert_eq!(code, 1, "expected exit 1 on cancel");
|
||||||
|
assert!(
|
||||||
|
stdout.contains(r#""modifiers":["shift"]"#),
|
||||||
|
"expected modifiers on cancel output, got: {stdout}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn modifier_prefix_does_not_affect_plain_output() {
|
||||||
|
let (stdout, _stderr, code) = common::run_pikl(
|
||||||
|
"alpha\nbeta\n",
|
||||||
|
"+shift confirm\n",
|
||||||
|
&[],
|
||||||
|
);
|
||||||
|
assert_eq!(code, 0, "expected exit 0, stderr: {_stderr}");
|
||||||
|
assert!(
|
||||||
|
!stdout.contains("modifiers"),
|
||||||
|
"plain output should not contain modifiers, got: {stdout}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
stdout.contains("alpha"),
|
||||||
|
"expected alpha in plain output, got: {stdout}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_modifier_prefix_exits_2() {
|
||||||
|
let (_stdout, stderr, code) = common::run_pikl(
|
||||||
|
"alpha\n",
|
||||||
|
"+super confirm\n",
|
||||||
|
&[],
|
||||||
|
);
|
||||||
|
assert_eq!(code, 2, "expected exit 2 on unknown modifier");
|
||||||
|
assert!(
|
||||||
|
stderr.contains("unknown modifier"),
|
||||||
|
"expected unknown modifier error, got: {stderr}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- CSV/TSV integration tests --
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn csv_confirm_first() {
|
||||||
|
let (stdout, _stderr, code) = common::run_pikl(
|
||||||
|
"name,age\nalice,30\nbob,25\n",
|
||||||
|
"confirm\n",
|
||||||
|
&["--input-format", "csv"],
|
||||||
|
);
|
||||||
|
assert_eq!(code, 0, "expected exit 0, stderr: {_stderr}");
|
||||||
|
// Plain output should show the JSON object
|
||||||
|
assert!(
|
||||||
|
stdout.contains("alice"),
|
||||||
|
"expected alice in stdout, got: {stdout}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn csv_with_columns_subset() {
|
||||||
|
let (stdout, _stderr, code) = common::run_pikl(
|
||||||
|
"name,age,city\nalice,30,toronto\nbob,25,vancouver\n",
|
||||||
|
"confirm\n",
|
||||||
|
&["--input-format", "csv", "--columns", "name,city"],
|
||||||
|
);
|
||||||
|
assert_eq!(code, 0, "expected exit 0, stderr: {_stderr}");
|
||||||
|
assert!(
|
||||||
|
stdout.contains("alice"),
|
||||||
|
"expected alice in stdout, got: {stdout}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn csv_filter_then_confirm() {
|
||||||
|
let (stdout, _stderr, code) = common::run_pikl(
|
||||||
|
"name,age\nalice,30\nbob,25\ncharlie,35\n",
|
||||||
|
"filter bob\nconfirm\n",
|
||||||
|
&["--input-format", "csv"],
|
||||||
|
);
|
||||||
|
assert_eq!(code, 0, "expected exit 0, stderr: {_stderr}");
|
||||||
|
assert!(
|
||||||
|
stdout.contains("bob"),
|
||||||
|
"expected bob in stdout, got: {stdout}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn csv_multi_select() {
|
||||||
|
let (stdout, _stderr, code) = common::run_pikl(
|
||||||
|
"name,age\nalice,30\nbob,25\n",
|
||||||
|
"toggle-select\nmove-down\ntoggle-select\nconfirm\n",
|
||||||
|
&["--input-format", "csv", "--multi"],
|
||||||
|
);
|
||||||
|
assert_eq!(code, 0, "expected exit 0, stderr: {_stderr}");
|
||||||
|
assert!(
|
||||||
|
stdout.contains("alice") && stdout.contains("bob"),
|
||||||
|
"expected both alice and bob in stdout, got: {stdout}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tsv_confirm_first() {
|
||||||
|
let (stdout, _stderr, code) = common::run_pikl(
|
||||||
|
"name\tage\nalice\t30\nbob\t25\n",
|
||||||
|
"confirm\n",
|
||||||
|
&["--input-format", "tsv"],
|
||||||
|
);
|
||||||
|
assert_eq!(code, 0, "expected exit 0, stderr: {_stderr}");
|
||||||
|
assert!(
|
||||||
|
stdout.contains("alice"),
|
||||||
|
"expected alice in stdout, got: {stdout}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -284,6 +284,44 @@ pikl_tests! {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
headless mod multi_select_headless {
|
||||||
|
items: ["alpha", "bravo", "charlie"];
|
||||||
|
multi: true;
|
||||||
|
|
||||||
|
test toggle_and_confirm {
|
||||||
|
actions: [toggle-select, confirm]
|
||||||
|
stdout: "alpha"
|
||||||
|
exit: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
test toggle_two_and_confirm {
|
||||||
|
actions: [toggle-select, move-down, move-down, toggle-select, confirm]
|
||||||
|
stdout: "alpha"
|
||||||
|
exit: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
test select_all_and_confirm {
|
||||||
|
actions: [select-all, confirm]
|
||||||
|
stdout: "alpha"
|
||||||
|
exit: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
test deselect_all_clears {
|
||||||
|
actions: [toggle-select, deselect-all, confirm]
|
||||||
|
// Falls back to cursor item
|
||||||
|
stdout: "alpha"
|
||||||
|
exit: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
test without_multi_toggle_is_noop {
|
||||||
|
// This test does NOT have multi: true (uses the shared fixture)
|
||||||
|
// but toggle-select still just selects cursor on confirm
|
||||||
|
actions: [toggle-select, confirm]
|
||||||
|
stdout: "alpha"
|
||||||
|
exit: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
headless mod pipeline_headless {
|
headless mod pipeline_headless {
|
||||||
items: ["error_log", "warning_temp", "info_log"];
|
items: ["error_log", "warning_temp", "info_log"];
|
||||||
|
|
||||||
@@ -293,4 +331,29 @@ pikl_tests! {
|
|||||||
exit: 0
|
exit: 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
headless mod modifier_prefix {
|
||||||
|
items: ["alpha", "beta", "gamma"];
|
||||||
|
|
||||||
|
test shift_confirm_plain_output_unchanged {
|
||||||
|
// Modifier prefixes don't affect plain text output.
|
||||||
|
actions: [raw "+shift confirm"]
|
||||||
|
stdout: "alpha"
|
||||||
|
exit: 0
|
||||||
|
}
|
||||||
|
|
||||||
|
test unknown_modifier_errors {
|
||||||
|
actions: [raw "+meta confirm"]
|
||||||
|
stderr contains: "unknown modifier"
|
||||||
|
exit: 2
|
||||||
|
}
|
||||||
|
|
||||||
|
test modifier_with_movement {
|
||||||
|
// Modifiers on navigation don't change behavior,
|
||||||
|
// but they should parse and not break anything.
|
||||||
|
actions: [raw "+shift move-down", confirm]
|
||||||
|
stdout: "beta"
|
||||||
|
exit: 0
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
289
docs/DESIGN.md
289
docs/DESIGN.md
@@ -48,6 +48,43 @@ metadata that came in, plus selection context:
|
|||||||
{"label": "beach_sunset.jpg", "sublabel": "/home/maple/walls/nature/", "meta": {"size": "2.4MB"}, "action": "select", "index": 3}
|
{"label": "beach_sunset.jpg", "sublabel": "/home/maple/walls/nature/", "meta": {"size": "2.4MB"}, "action": "select", "index": 3}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
When `--structured` is used, the output JSON includes a
|
||||||
|
`modifiers` field if any modifier keys (shift, ctrl, alt)
|
||||||
|
were held during confirmation. The value is an array of
|
||||||
|
strings:
|
||||||
|
|
||||||
|
```jsonl
|
||||||
|
{"label": "firefox", "action": "select", "index": 0, "modifiers": ["shift"]}
|
||||||
|
{"label": "alacritty", "action": "select", "index": 2, "modifiers": ["ctrl", "alt"]}
|
||||||
|
```
|
||||||
|
|
||||||
|
When no modifiers are held, the field is omitted entirely.
|
||||||
|
Plain text output is unchanged: modifiers only appear in
|
||||||
|
structured JSON.
|
||||||
|
|
||||||
|
### Working with Structured Output
|
||||||
|
|
||||||
|
Check for shift modifier with jq:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pikl --structured | jq 'if (.modifiers // [] | contains(["shift"])) then "alternate" else "default" end'
|
||||||
|
```
|
||||||
|
|
||||||
|
Extract a field:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pikl --structured | jq -r '.label'
|
||||||
|
```
|
||||||
|
|
||||||
|
Branch on modifiers in a script:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
result=$(echo -e "a\nb\nc" | pikl --structured)
|
||||||
|
if echo "$result" | jq -e '.modifiers // [] | contains(["shift"])' > /dev/null 2>&1; then
|
||||||
|
echo "shift was held"
|
||||||
|
fi
|
||||||
|
```
|
||||||
|
|
||||||
### Streaming
|
### Streaming
|
||||||
|
|
||||||
Input can arrive over time. The list populates progressively
|
Input can arrive over time. The list populates progressively
|
||||||
@@ -60,9 +97,23 @@ selection. Useful for live preview pipelines.
|
|||||||
|
|
||||||
### CSV / TSV
|
### CSV / TSV
|
||||||
|
|
||||||
`--input-format csv` or `--input-format tsv` parses columnar
|
`--input-format csv` or `--input-format tsv` parses delimited
|
||||||
input. First row is headers, which become field names. Maps
|
input. First row is always headers, which become field names.
|
||||||
naturally to table/column display mode.
|
|
||||||
|
Each row becomes a JSON object. Known fields (`label`,
|
||||||
|
`sublabel`, `icon`, `group`) are promoted to the top level.
|
||||||
|
Everything else goes into a `meta` object. If no column
|
||||||
|
matches the label key, the first column is used as `label`.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# CSV rows become normal Items, so filtering, hooks, and
|
||||||
|
# multi-select all work unchanged.
|
||||||
|
echo "name,age,city\nalice,30,toronto" | pikl --input-format csv
|
||||||
|
```
|
||||||
|
|
||||||
|
When `--input-format csv` or `tsv` is used without
|
||||||
|
`--columns`, column config is auto-generated from the CSV
|
||||||
|
headers.
|
||||||
|
|
||||||
## Event Hooks
|
## Event Hooks
|
||||||
|
|
||||||
@@ -80,7 +131,13 @@ There are two ways to respond to them: **exec hooks** and
|
|||||||
| `on-select` | User confirms (Enter) | Apply the choice |
|
| `on-select` | User confirms (Enter) | Apply the choice |
|
||||||
| `on-cancel` | User cancels (Escape) | Revert preview |
|
| `on-cancel` | User cancels (Escape) | Revert preview |
|
||||||
| `on-filter` | Filter text changes | Dynamic item reloading |
|
| `on-filter` | Filter text changes | Dynamic item reloading |
|
||||||
| `on-mark` | User marks/unmarks an item | Visual feedback |
|
| `on-toggle` | User toggles an item's selection | Visual feedback (deferred) |
|
||||||
|
|
||||||
|
Hook event JSON includes a `modifiers` field on `select`,
|
||||||
|
`cancel`, `filter`, `hover`, and `quicklist` events when
|
||||||
|
modifier keys were held. Same format as structured output:
|
||||||
|
an array of strings like `["shift"]` or `["ctrl", "alt"]`.
|
||||||
|
Omitted when no modifiers are active.
|
||||||
|
|
||||||
### Exec Hooks (fire-and-forget)
|
### Exec Hooks (fire-and-forget)
|
||||||
|
|
||||||
@@ -383,16 +440,14 @@ designed.
|
|||||||
| `Ctrl+D` / `Ctrl+U` | Half-page down / up |
|
| `Ctrl+D` / `Ctrl+U` | Half-page down / up |
|
||||||
| `Ctrl+F` / `Ctrl+B` | Full page down / up |
|
| `Ctrl+F` / `Ctrl+B` | Full page down / up |
|
||||||
| `/` | Enter filter (insert) mode |
|
| `/` | Enter filter (insert) mode |
|
||||||
| `n` / `N` | Next / previous filter match |
|
| `Space` | Toggle select + move down (`--multi`) |
|
||||||
| `m{a-z}` | Set mark at current position |
|
| `V` | Enter visual line mode (`--multi`) |
|
||||||
| `'{a-z}` | Jump to mark |
|
| `u` | Undo last selection change (`--multi`) |
|
||||||
| `"a` | Select into register `a` |
|
| `U` | Clear all selections (`--multi`) |
|
||||||
| `v` | Toggle multi-select on current item |
|
| `Ctrl+R` | Redo selection change (`--multi`) |
|
||||||
| `V` | Visual line select mode |
|
|
||||||
| `Enter` | Confirm selection |
|
| `Enter` | Confirm selection |
|
||||||
| `Escape` | Cancel and exit |
|
| `Escape` | Cancel and exit |
|
||||||
| `q` | Cancel and exit |
|
| `q` | Cancel and exit |
|
||||||
| `Space` | Toggle select current item and move down |
|
|
||||||
|
|
||||||
`H`/`M`/`L` (viewport-relative jumps) are deferred. They're
|
`H`/`M`/`L` (viewport-relative jumps) are deferred. They're
|
||||||
nice-to-have but not essential for the first pass of vim
|
nice-to-have but not essential for the first pass of vim
|
||||||
@@ -400,12 +455,26 @@ navigation.
|
|||||||
|
|
||||||
### Multi-Select
|
### Multi-Select
|
||||||
|
|
||||||
- `Space` or `v` toggles selection on current item
|
Requires `--multi` flag. Without it, toggle/select actions
|
||||||
- `V` enters visual line mode, moving up/down selects ranges
|
are no-ops and confirm always returns a single item.
|
||||||
- All selected items are emitted on confirm
|
|
||||||
- `"a` through `"z`: select into named registers (like vim
|
- `Space` or `Tab` toggles selection on the cursor item and
|
||||||
yanking)
|
moves down. `Shift+Tab` toggles and moves up.
|
||||||
- Registers can be recalled or included in output
|
- `V` enters visual line mode: `j`/`k` extend the range,
|
||||||
|
`Space`/`Enter` force-selects the range and returns to
|
||||||
|
normal mode, `V` cancels back to normal with no change.
|
||||||
|
- `u` undoes the last selection change, `Ctrl+R` redoes.
|
||||||
|
Undo/redo stacks track snapshots of the full selection set.
|
||||||
|
- `U` clears all selections (undoable).
|
||||||
|
- Selections survive filter changes. Toggling item "foo",
|
||||||
|
then filtering to something else, then confirming still
|
||||||
|
includes "foo" in the output.
|
||||||
|
- `--selection-order` flag: output items in the order they
|
||||||
|
were selected (insertion order) instead of the default
|
||||||
|
input order.
|
||||||
|
- Confirm with selections: outputs all selected items.
|
||||||
|
Confirm with no selections in multi mode: falls back to
|
||||||
|
the cursor item as a single-element result.
|
||||||
|
|
||||||
### Drill-Down
|
### Drill-Down
|
||||||
|
|
||||||
@@ -429,11 +498,26 @@ pikl --format '{icon} {label} <dim>{sublabel}</dim>'
|
|||||||
### Table / Column Mode
|
### Table / Column Mode
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
pikl --columns label,meta.res,meta.size --column-separator ' │ '
|
pikl --columns label,meta.res:Resolution,meta.size
|
||||||
```
|
```
|
||||||
|
|
||||||
Auto-aligns columns. Sortable per-column with keybinds.
|
`--columns` takes a comma-separated list of field paths.
|
||||||
CSV/TSV input maps naturally here.
|
Each column can have a display alias after a colon
|
||||||
|
(`meta.res:Resolution`). Auto-aligns columns by computing
|
||||||
|
widths from header names and cell values (capped at 60
|
||||||
|
chars). Column widths are computed in core and included in
|
||||||
|
`ViewState`, so the GUI frontend gets them for free.
|
||||||
|
|
||||||
|
The header row renders bold/underlined with `│` separators.
|
||||||
|
Item rows pad cells to column widths within the same `List`
|
||||||
|
widget, so cursor/selection/visual-mode highlighting stays
|
||||||
|
identical to the non-table path.
|
||||||
|
|
||||||
|
When `--input-format csv` is used without `--columns`,
|
||||||
|
columns are auto-generated from the CSV headers.
|
||||||
|
|
||||||
|
**Future:** column sorting keybinds, cell/column selection,
|
||||||
|
horizontal scrolling for wide tables.
|
||||||
|
|
||||||
### Preview Pane
|
### Preview Pane
|
||||||
|
|
||||||
@@ -478,13 +562,19 @@ A few built-in themes ship with pikl. `--theme monokai` or
|
|||||||
|
|
||||||
## Sessions
|
## Sessions
|
||||||
|
|
||||||
`--session name` persists the menu's state (filter text,
|
`--session name` enables filter history across invocations.
|
||||||
scroll position, selected items, marks, registers) across
|
|
||||||
invocations. State lives in
|
|
||||||
`~/.local/state/pikl/sessions/`.
|
|
||||||
|
|
||||||
No `--session` = ephemeral, nothing saved.
|
No `--session` = ephemeral, nothing saved.
|
||||||
|
|
||||||
|
When a session is active, every filter that leads to a
|
||||||
|
selection (single, multi, or quicklist) is appended to
|
||||||
|
`~/.local/state/pikl/sessions/{name}.history`. Empty
|
||||||
|
filters and consecutive duplicates are skipped.
|
||||||
|
|
||||||
|
On startup with a session name, the history file is loaded.
|
||||||
|
Ctrl+P / Ctrl+N in insert mode cycles through previous
|
||||||
|
filters. Selecting a history entry replaces the current
|
||||||
|
filter text.
|
||||||
|
|
||||||
Session names are just strings, use shell expansion for
|
Session names are just strings, use shell expansion for
|
||||||
dynamic names:
|
dynamic names:
|
||||||
|
|
||||||
@@ -493,8 +583,8 @@ pikl --session "walls-$(hostname)"
|
|||||||
pikl --session "logs-$USER"
|
pikl --session "logs-$USER"
|
||||||
```
|
```
|
||||||
|
|
||||||
Session history is a log file alongside the state. Other
|
Sessions are lightweight by design. No item persistence,
|
||||||
tools can tail it for observability.
|
no scroll position, no selections. Just filter recall.
|
||||||
|
|
||||||
## Watched Sources
|
## Watched Sources
|
||||||
|
|
||||||
@@ -511,18 +601,96 @@ named pipe.
|
|||||||
|
|
||||||
## IPC
|
## IPC
|
||||||
|
|
||||||
While running, pikl-menu listens on a Unix socket
|
Opt-in external control via Unix socket. Enabled with
|
||||||
(`/run/user/$UID/pikl-$PID.sock`). External tools can:
|
`--ipc`. Off by default: pikl is ephemeral and shouldn't
|
||||||
|
have side effects unless asked.
|
||||||
|
|
||||||
- Push new items
|
Socket path:
|
||||||
- Remove items
|
- Named session: `/run/user/$UID/pikl-{session}.sock`
|
||||||
- Update item fields
|
- No session: `/run/user/$UID/pikl-{pid}.sock`
|
||||||
- Set the filter text
|
|
||||||
- Read current selection
|
|
||||||
- Close the menu
|
|
||||||
|
|
||||||
Protocol is newline-delimited JSON. Simple enough to use
|
The socket path is logged via tracing on startup and
|
||||||
with `socat` or any language's Unix socket support.
|
cleaned up on exit.
|
||||||
|
|
||||||
|
### Protocol
|
||||||
|
|
||||||
|
Newline-delimited JSON, one message per line. Simple
|
||||||
|
enough for `socat` or any language with Unix sockets.
|
||||||
|
|
||||||
|
### Client-to-pikl Commands
|
||||||
|
|
||||||
|
**Write commands** (fire-and-forget, no response):
|
||||||
|
|
||||||
|
| Action | Payload | Effect |
|
||||||
|
|---|---|---|
|
||||||
|
| `add_items` | `{"items": [...]}` | Append items |
|
||||||
|
| `replace_items` | `{"items": [...]}` | Replace all items |
|
||||||
|
| `remove_items` | `{"indices": [...]}` | Remove by index |
|
||||||
|
| `set_filter` | `{"text": "..."}` | Set filter text |
|
||||||
|
| `move_up` | (none) | Navigate up |
|
||||||
|
| `move_down` | (none) | Navigate down |
|
||||||
|
| `move_to_top` | (none) | Jump to top |
|
||||||
|
| `move_to_bottom` | (none) | Jump to bottom |
|
||||||
|
| `page_up` | (none) | Page up |
|
||||||
|
| `page_down` | (none) | Page down |
|
||||||
|
| `toggle_select` | (none) | Toggle current item |
|
||||||
|
| `select_all` | (none) | Select all items |
|
||||||
|
| `clear_selections` | (none) | Clear selections |
|
||||||
|
| `confirm` | (none) | Confirm selection |
|
||||||
|
| `cancel` | (none) | Cancel menu |
|
||||||
|
| `close` | (none) | Close menu |
|
||||||
|
|
||||||
|
**Read commands** (require `id` field, response echoes it):
|
||||||
|
|
||||||
|
| Action | Response |
|
||||||
|
|---|---|
|
||||||
|
| `get_state` | `{"id": "...", "state": {"filter": "...", "cursor": 3, "total_items": 150, "total_filtered": 12, "selection_count": 0, "mode": "insert"}}` |
|
||||||
|
| `get_selection` | `{"id": "...", "selection": [{"label": "...", "index": 3}, ...]}` |
|
||||||
|
|
||||||
|
**Event subscription:**
|
||||||
|
|
||||||
|
| Action | Payload | Effect |
|
||||||
|
|---|---|---|
|
||||||
|
| `subscribe` | `{"events": ["hover", "filter", ...]}` | Start receiving events |
|
||||||
|
| `unsubscribe` | (none) | Stop receiving events |
|
||||||
|
|
||||||
|
Subscribed events are pushed as
|
||||||
|
`{"event": "hover", "item": {...}, "index": 3}` lines.
|
||||||
|
|
||||||
|
### Multiple Connections
|
||||||
|
|
||||||
|
Multiple clients can connect simultaneously. Each gets
|
||||||
|
independent subscription state. All writes are serialized
|
||||||
|
through the same action channel.
|
||||||
|
|
||||||
|
### Auth
|
||||||
|
|
||||||
|
Unix socket permissions (user-only access). No additional
|
||||||
|
authentication.
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
|
||||||
|
IPC is just another frontend. It deserializes JSON
|
||||||
|
messages into `Action` variants and sends them through the
|
||||||
|
same `mpsc` channel as the TUI or action-fd. Event
|
||||||
|
subscriptions tap into the `broadcast` channel. No special
|
||||||
|
core changes needed.
|
||||||
|
|
||||||
|
### Examples
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Push items into a running pikl
|
||||||
|
echo '{"action": "add_items", "items": [{"label": "new"}]}' \
|
||||||
|
| socat - UNIX-CONNECT:/run/user/1000/pikl-mypicker.sock
|
||||||
|
|
||||||
|
# Read current state
|
||||||
|
echo '{"action": "get_state", "id": "1"}' \
|
||||||
|
| socat - UNIX-CONNECT:/run/user/1000/pikl-mypicker.sock
|
||||||
|
|
||||||
|
# Subscribe to hover events
|
||||||
|
echo '{"action": "subscribe", "events": ["hover"]}' \
|
||||||
|
| socat - UNIX-CONNECT:/run/user/1000/pikl-mypicker.sock
|
||||||
|
```
|
||||||
|
|
||||||
## Exit Codes
|
## Exit Codes
|
||||||
|
|
||||||
@@ -603,8 +771,8 @@ sequentially regardless of where they came from.
|
|||||||
| TUI | Terminal keypresses (crossterm) | Yes | 1 |
|
| TUI | Terminal keypresses (crossterm) | Yes | 1 |
|
||||||
| GUI | GUI events (iced) | Yes | 8 |
|
| GUI | GUI events (iced) | Yes | 8 |
|
||||||
| Action-fd | Pre-validated script from a file descriptor | No (unless `show-ui`) | 1.5 |
|
| Action-fd | Pre-validated script from a file descriptor | No (unless `show-ui`) | 1.5 |
|
||||||
| IPC | Unix socket, JSON protocol | Yes (bidirectional) | 6 |
|
| IPC | Unix socket, newline-delimited JSON | Yes (bidirectional) | 6 |
|
||||||
| Lua | LuaJIT script via mlua | Yes (stateful, conditional) | Post-6 |
|
| Lua | Embedded LuaJIT via mlua (in-process) | Yes (stateful, conditional) | Post-6 |
|
||||||
|
|
||||||
This framing means new interaction modes don't require core
|
This framing means new interaction modes don't require core
|
||||||
changes: they're just new frontends that push actions.
|
changes: they're just new frontends that push actions.
|
||||||
@@ -645,6 +813,18 @@ Available actions:
|
|||||||
| `show-tui` | (none) | Hand off to TUI specifically |
|
| `show-tui` | (none) | Hand off to TUI specifically |
|
||||||
| `show-gui` | (none) | Hand off to GUI specifically |
|
| `show-gui` | (none) | Hand off to GUI specifically |
|
||||||
|
|
||||||
|
Actions can include modifier key prefixes with `+`:
|
||||||
|
|
||||||
|
```
|
||||||
|
+shift confirm
|
||||||
|
+ctrl+alt move-down 3
|
||||||
|
```
|
||||||
|
|
||||||
|
Valid modifiers: `shift`, `ctrl`, `alt`. Multiple modifiers
|
||||||
|
chain with `+`. Lines without a `+` prefix work exactly as
|
||||||
|
before. This lets scripts signal intent to hooks and
|
||||||
|
structured output the same way a user holding Shift would.
|
||||||
|
|
||||||
`show-ui` auto-detects the appropriate interactive frontend
|
`show-ui` auto-detects the appropriate interactive frontend
|
||||||
(Wayland: GUI, X11: GUI, otherwise: TUI). `show-tui` and
|
(Wayland: GUI, X11: GUI, otherwise: TUI). `show-tui` and
|
||||||
`show-gui` are explicit overrides. All three must be the
|
`show-gui` are explicit overrides. All three must be the
|
||||||
@@ -745,8 +925,14 @@ find-walls --json | pikl \
|
|||||||
--on-cancel 'restore-wallpaper' \
|
--on-cancel 'restore-wallpaper' \
|
||||||
--on-hover-debounce 100
|
--on-hover-debounce 100
|
||||||
|
|
||||||
# Process killer with table view
|
# CSV table view
|
||||||
ps aux --no-headers | pikl --input-format tsv --columns 1,10,2 --multi
|
echo "name,age,city\nalice,30,toronto\nbob,25,vancouver" | pikl --input-format csv
|
||||||
|
|
||||||
|
# CSV with column subset and aliases
|
||||||
|
cat data.csv | pikl --input-format csv --columns "name,meta.age:Age"
|
||||||
|
|
||||||
|
# TSV table view
|
||||||
|
ps aux --no-headers | pikl --input-format tsv --multi
|
||||||
|
|
||||||
# Drill-down file browser
|
# Drill-down file browser
|
||||||
pikl --manifest ~/.config/pikl/filebrowser.toml --session filebrowser
|
pikl --manifest ~/.config/pikl/filebrowser.toml --session filebrowser
|
||||||
@@ -774,12 +960,15 @@ complexity:
|
|||||||
extend pikl without touching Rust.
|
extend pikl without touching Rust.
|
||||||
4. **IPC** (phase 6): bidirectional JSON over Unix socket.
|
4. **IPC** (phase 6): bidirectional JSON over Unix socket.
|
||||||
External tools can read state and send actions while pikl
|
External tools can read state and send actions while pikl
|
||||||
runs interactively. Good for tool integration.
|
runs interactively. Language-agnostic: anything that can
|
||||||
5. **Lua** (post phase 6): embedded LuaJIT via mlua. Full
|
write JSON to a socket works.
|
||||||
stateful scripting: subscribe to events, branch on state,
|
5. **Lua** (post phase 6): embedded LuaJIT via mlua.
|
||||||
loops, the works. The Lua runtime is just another
|
In-process scripting for complex hooks and behaviour.
|
||||||
frontend pushing Actions and subscribing to MenuEvents.
|
Direct access to the action/event system, no socket
|
||||||
For anything complex enough to need a real language.
|
overhead. Think Neovim's Lua layer: you're extending the
|
||||||
|
tool, not talking to it from outside. IPC and Lua are
|
||||||
|
separate integration points. IPC is for external
|
||||||
|
processes. Lua is for embedded logic.
|
||||||
|
|
||||||
No custom DSL. Action-fd stays simple forever. The jump
|
No custom DSL. Action-fd stays simple forever. The jump
|
||||||
from "I need conditionals" to "use Lua" is intentional:
|
from "I need conditionals" to "use Lua" is intentional:
|
||||||
@@ -801,16 +990,10 @@ with the full writeup.
|
|||||||
|
|
||||||
## Open Questions
|
## Open Questions
|
||||||
|
|
||||||
- Should marks/registers persist across sessions or only
|
|
||||||
within a session?
|
|
||||||
- Accessibility: screen reader support for TUI mode?
|
- Accessibility: screen reader support for TUI mode?
|
||||||
- Should `--watch` support inotify on Linux and FSEvents on
|
- Should `--watch` support inotify on Linux and FSEvents on
|
||||||
macOS, or use `notify` crate to abstract?
|
macOS, or use `notify` crate to abstract?
|
||||||
- Maximum practical item count before we need virtual
|
- Maximum practical item count before we need virtual
|
||||||
scrolling? (Probably around 100k)
|
scrolling? (Probably around 100k)
|
||||||
- Should hooks run in a pool or strictly sequential?
|
|
||||||
Resolved: exec hooks are one subprocess per event.
|
|
||||||
Handler hooks are persistent processes. Debounce and
|
|
||||||
cancel-stale manage concurrency.
|
|
||||||
- Plugin system via WASM for custom filter strategies?
|
- Plugin system via WASM for custom filter strategies?
|
||||||
(Probably way later if ever)
|
(Probably way later if ever)
|
||||||
|
|||||||
165
docs/DEVPLAN.md
165
docs/DEVPLAN.md
@@ -170,67 +170,135 @@ through hooks and structured I/O. A handler hook can
|
|||||||
receive hover events and emit commands to modify menu
|
receive hover events and emit commands to modify menu
|
||||||
state.
|
state.
|
||||||
|
|
||||||
## Phase 4: Multi-Select & Registers
|
## Phase 4: Multi-Select ✓
|
||||||
|
|
||||||
Power selection features.
|
Selection with undo/redo. No marks or registers: filtering
|
||||||
|
already handles navigation, and selections surviving filter
|
||||||
|
changes covers the register use case.
|
||||||
|
|
||||||
**Deliverables:**
|
**Deliverables:**
|
||||||
- Space to toggle select, V for visual line mode
|
- `--multi` flag to gate all multi-select behaviour
|
||||||
- Multi-select output (multiple JSON lines)
|
- `Space`/`Tab` toggle-select, `Shift+Tab` toggle-up
|
||||||
- Named registers (`"a` through `"z`)
|
- `V` visual line mode (force-select ranges)
|
||||||
- Marks (`m{a-z}`, `'{a-z}`)
|
- `u`/`Ctrl+R` undo/redo selection changes, `U` clear all
|
||||||
- `--multi` flag to enable multi-select mode
|
- Selections survive filter changes (tracked by original index)
|
||||||
|
- `--selection-order` flag for insertion-order output
|
||||||
|
- Multi-item output: one JSON line per selected item
|
||||||
|
- Action-fd commands: toggle-select, select-all, deselect-all,
|
||||||
|
undo-selection, redo-selection
|
||||||
|
- Test DSL: `multi` fixture, `selected_items` assertion
|
||||||
|
|
||||||
**Done when:** You can select multiple items, store them
|
**Done when:** `seq 1 20 | pikl --multi`, toggle a few items
|
||||||
in registers, and get them all in the output.
|
with Space, confirm, and all selected items appear on stdout.
|
||||||
|
|
||||||
## Phase 5: Table Mode & CSV
|
## Phase 5: Table Mode & CSV ✓
|
||||||
|
|
||||||
Columnar data display.
|
CSV/TSV input parsing and columnar table rendering.
|
||||||
|
|
||||||
**Deliverables:**
|
**Deliverables:**
|
||||||
- `--columns` flag for table layout
|
- `--input-format csv` and `--input-format tsv` for delimited input
|
||||||
- Auto-alignment
|
- `--columns` flag for table layout with optional aliases
|
||||||
|
- Auto-generated column config from CSV headers
|
||||||
|
- Column widths computed in core (GUI gets them for free)
|
||||||
|
- Table header row with bold/underline styling
|
||||||
|
- CSV rows become normal Items (JSON objects), so filtering,
|
||||||
|
field filters, hooks, multi-select, and output all work unchanged
|
||||||
|
|
||||||
|
**Deferred to future:**
|
||||||
|
- Cell/column selection in table mode
|
||||||
- Column sorting keybinds
|
- Column sorting keybinds
|
||||||
- `--input-format csv` and `--input-format tsv`
|
- Horizontal scrolling for wide tables
|
||||||
- Column-specific filtering
|
|
||||||
|
|
||||||
**Done when:** `ps aux | pikl --input-format tsv --columns 1,10,2`
|
**Done when:** `echo "name,age\nalice,30\nbob,25" | pikl --input-format csv`
|
||||||
renders a clean table.
|
renders a navigable table with headers.
|
||||||
|
|
||||||
## Phase 6: Sessions & IPC
|
## Phase 6: IPC & Session History
|
||||||
|
|
||||||
Persistence and external control.
|
Two independent features bundled together. IPC adds live
|
||||||
|
external control over a Unix socket. Session history gives
|
||||||
|
lightweight filter recall for repeated workflows.
|
||||||
|
|
||||||
|
### IPC (External Control)
|
||||||
|
|
||||||
|
Opt-in Unix socket server for live control of a running
|
||||||
|
pikl instance by external processes.
|
||||||
|
|
||||||
**Deliverables:**
|
**Deliverables:**
|
||||||
- `--session name` for state persistence
|
- `--ipc` flag to enable the socket listener (off by
|
||||||
- Session state: filter, scroll position, selections,
|
default, pikl is ephemeral and shouldn't have side
|
||||||
marks, registers
|
effects unless asked)
|
||||||
- Session history log file
|
- Socket path: `/run/user/$UID/pikl-{session}.sock`
|
||||||
- Unix socket IPC while running
|
(named session) or `/run/user/$UID/pikl-{pid}.sock`
|
||||||
- IPC commands: push/remove/update items, set filter,
|
(no session name)
|
||||||
read selection, close
|
- Socket path logged via tracing on startup
|
||||||
- Protocol: newline-delimited JSON
|
- Cleanup on exit (normal, cancel, SIGTERM)
|
||||||
|
- Protocol: newline-delimited JSON, one message per line
|
||||||
|
- Write commands: `add_items`, `replace_items`,
|
||||||
|
`remove_items`, `set_filter`, `confirm`, `cancel`,
|
||||||
|
`close`, plus navigation actions (`move_up`,
|
||||||
|
`move_down`, `move_to_top`, `move_to_bottom`,
|
||||||
|
`page_up`, `page_down`, `toggle_select`, `select_all`,
|
||||||
|
`clear_selections`)
|
||||||
|
- Read commands: `get_state` (filter, cursor, counts,
|
||||||
|
mode), `get_selection` (selected items). Both require
|
||||||
|
an `id` field, response echoes it back.
|
||||||
|
- Event subscription: `subscribe` with event type list,
|
||||||
|
`unsubscribe` to stop. Subscribed events pushed as
|
||||||
|
`{"event": "...", ...}` lines.
|
||||||
|
- Multiple simultaneous client connections
|
||||||
|
- Auth: Unix socket permissions (user-only)
|
||||||
|
- IPC is just another frontend: deserializes JSON into
|
||||||
|
Actions, optionally subscribes to MenuEvent broadcast
|
||||||
|
|
||||||
**Done when:** You can close and reopen a session and find
|
**Not in scope:**
|
||||||
your state intact. External scripts can push items into a
|
- `get_items` (full item list read, potentially large)
|
||||||
running pikl instance.
|
- Auto-enable with `--session`
|
||||||
|
- Auth beyond Unix socket permissions
|
||||||
|
|
||||||
## Phase 7: Streaming & Watched Sources
|
### Session Filter History
|
||||||
|
|
||||||
Live, dynamic menus.
|
|
||||||
|
|
||||||
**Deliverables:**
|
**Deliverables:**
|
||||||
- Async/streaming stdin (items arrive over time, list
|
- `--session name` flag
|
||||||
updates progressively)
|
- On any confirm (single, multi, quicklist), append
|
||||||
- Streaming output (on-hover events emitted to stdout)
|
current filter text to
|
||||||
- `--watch path` for file/directory watching
|
`~/.local/state/pikl/sessions/{name}.history`
|
||||||
- `--watch-extensions` filter
|
- Skip empty filters and consecutive duplicates
|
||||||
- notify crate integration (inotify on Linux, FSEvents
|
- Load history on startup if session file exists
|
||||||
on macOS)
|
- Ctrl+P / Ctrl+N in insert mode to cycle through
|
||||||
|
filter history
|
||||||
|
- Selecting a history entry replaces the current filter
|
||||||
|
text
|
||||||
|
- Per-session only, no global history
|
||||||
|
|
||||||
**Done when:** `find / -name '*.log' | pikl` populates
|
**Done when:** An external script can connect to a running
|
||||||
progressively. A watched directory updates the list live.
|
pikl instance over the socket, push items, read state, and
|
||||||
|
subscribe to events. A named session remembers filter
|
||||||
|
history across invocations.
|
||||||
|
|
||||||
|
## Phase 7: Streaming Stdin ✓
|
||||||
|
|
||||||
|
Auto-detected streaming when stdin is a pipe (not action-fd,
|
||||||
|
not CSV/TSV). The menu opens immediately with zero items and
|
||||||
|
streams them in as they arrive.
|
||||||
|
|
||||||
|
`--watch` was dropped from this phase. External tools like
|
||||||
|
`inotifywait -m ~/walls | pikl` compose naturally with
|
||||||
|
streaming stdin. No built-in file watcher needed.
|
||||||
|
|
||||||
|
**Deliverables:**
|
||||||
|
- Auto-detected streaming stdin (items arrive over time,
|
||||||
|
list updates progressively)
|
||||||
|
- `StreamingDone` action signals end of input
|
||||||
|
- `streaming` flag on ViewState for frontend indicators
|
||||||
|
- TUI shows `...` suffix on the count while streaming
|
||||||
|
- Background reader with batched `AddItems` (up to 100
|
||||||
|
items per batch, flushes immediately for slow sources)
|
||||||
|
- `streaming-done` action-fd command for test scripts
|
||||||
|
- No new CLI flags: streaming is auto-detected
|
||||||
|
|
||||||
|
**Done when:** `seq 1 10000 | pikl` opens immediately and
|
||||||
|
items stream in. Slow sources show items appearing one at
|
||||||
|
a time with a `...` indicator.
|
||||||
|
|
||||||
## Phase 8: GUI Frontend (Wayland + X11)
|
## Phase 8: GUI Frontend (Wayland + X11)
|
||||||
|
|
||||||
@@ -281,7 +349,14 @@ navigate directories without spawning new processes.
|
|||||||
pass `-la` to it. The output would include both the
|
pass `-la` to it. The output would include both the
|
||||||
selected item and the user-supplied arguments.
|
selected item and the user-supplied arguments.
|
||||||
|
|
||||||
Open questions:
|
**Partially resolved:** modifier key support has landed.
|
||||||
|
Structured output and hook events now carry a `modifiers`
|
||||||
|
field when shift/ctrl/alt are held during confirmation.
|
||||||
|
This covers the signaling side: scripts can detect
|
||||||
|
Shift+Enter and branch on it. The free-text argument
|
||||||
|
input (where does the user type `-la`?) is still open.
|
||||||
|
|
||||||
|
Remaining open questions:
|
||||||
- UX flow: does the filter text become the args on
|
- UX flow: does the filter text become the args on
|
||||||
Shift+Enter? Or does Shift+Enter open a second input
|
Shift+Enter? Or does Shift+Enter open a second input
|
||||||
field for args after selection? The filter-as-args
|
field for args after selection? The filter-as-args
|
||||||
@@ -295,9 +370,6 @@ navigate directories without spawning new processes.
|
|||||||
matches exactly one item just confirm that item (current
|
matches exactly one item just confirm that item (current
|
||||||
behaviour), or should it also treat any "extra" text
|
behaviour), or should it also treat any "extra" text
|
||||||
as args? Probably not, too implicit.
|
as args? Probably not, too implicit.
|
||||||
- Keybind: Shift+Enter is natural, but some terminals
|
|
||||||
don't distinguish it from Enter. May need a fallback
|
|
||||||
like Ctrl+Enter or a normal-mode keybind.
|
|
||||||
|
|
||||||
This is a core feature (new keybind, new output field),
|
This is a core feature (new keybind, new output field),
|
||||||
not just a launcher script concern. Fits naturally after
|
not just a launcher script concern. Fits naturally after
|
||||||
@@ -344,3 +416,4 @@ commitment, no order.
|
|||||||
to run per-keystroke" problem for the app launcher.
|
to run per-keystroke" problem for the app launcher.
|
||||||
Could be a `pikl index` subcommand or a standalone
|
Could be a `pikl index` subcommand or a standalone
|
||||||
helper script.
|
helper script.
|
||||||
|
- Need vars in hooks like `--on-hover 'echo $out'`.
|
||||||
|
|||||||
@@ -126,7 +126,7 @@ bind = $mod, B, exec, wallpicker copy
|
|||||||
| Hook debouncing | 3 | Don't flood hyprctl |
|
| Hook debouncing | 3 | Don't flood hyprctl |
|
||||||
| Fuzzy filtering | 2 | Filter by filename |
|
| Fuzzy filtering | 2 | Filter by filename |
|
||||||
| Manifest files | 3 | Reusable config |
|
| Manifest files | 3 | Reusable config |
|
||||||
| Watched sources | 7 | Live update on new files |
|
| Streaming stdin | 7 | Progressive load from find |
|
||||||
| Sessions | 6 | Remember last position |
|
| Sessions | 6 | Remember last position |
|
||||||
|
|
||||||
## Open Questions
|
## Open Questions
|
||||||
|
|||||||
171
examples/demo.sh
171
examples/demo.sh
@@ -275,6 +275,152 @@ pipeline_filter_demo() {
|
|||||||
ITEMS
|
ITEMS
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# ── Phase 4 demos ────────────────────────────────────────
|
||||||
|
|
||||||
|
multi_select_basic() {
|
||||||
|
echo "Multi-select: toggle items with Space or Tab, confirm with Enter." >&2
|
||||||
|
echo "Undo with u, redo with Ctrl+R, clear all with U." >&2
|
||||||
|
echo "" >&2
|
||||||
|
printf "apple\nbanana\ncherry\ndate\nelderberry\nfig\ngrape\nhoneydew\n" \
|
||||||
|
| pikl --multi
|
||||||
|
}
|
||||||
|
|
||||||
|
multi_select_packages() {
|
||||||
|
echo "Pick packages to install. Space to toggle, Enter to confirm." >&2
|
||||||
|
echo "Try V to enter visual mode, then j/k to extend, Space to apply." >&2
|
||||||
|
echo "" >&2
|
||||||
|
cat <<'ITEMS' | pikl --multi --format '{label} - {sublabel}'
|
||||||
|
{"label": "ripgrep", "sublabel": "fast recursive grep"}
|
||||||
|
{"label": "fd", "sublabel": "find alternative"}
|
||||||
|
{"label": "bat", "sublabel": "cat with syntax highlighting"}
|
||||||
|
{"label": "eza", "sublabel": "modern ls replacement"}
|
||||||
|
{"label": "delta", "sublabel": "better git diff"}
|
||||||
|
{"label": "zoxide", "sublabel": "smarter cd"}
|
||||||
|
{"label": "starship", "sublabel": "cross-shell prompt"}
|
||||||
|
{"label": "tokei", "sublabel": "code line counter"}
|
||||||
|
{"label": "hyperfine", "sublabel": "benchmarking tool"}
|
||||||
|
{"label": "procs", "sublabel": "modern ps replacement"}
|
||||||
|
ITEMS
|
||||||
|
}
|
||||||
|
|
||||||
|
multi_select_structured() {
|
||||||
|
echo "Multi-select with --structured output. Each selected item" >&2
|
||||||
|
echo "is a JSON line with action and index fields." >&2
|
||||||
|
echo "" >&2
|
||||||
|
printf "alpha\nbravo\ncharlie\ndelta\necho\nfoxtrot\n" \
|
||||||
|
| pikl --multi --structured
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Phase 5 demos ────────────────────────────────────────
|
||||||
|
|
||||||
|
csv_table() {
|
||||||
|
echo "CSV table mode. Columns auto-generated from headers." >&2
|
||||||
|
echo "Navigate with j/k, filter normally." >&2
|
||||||
|
echo "" >&2
|
||||||
|
cat <<'CSV' | pikl --input-format csv
|
||||||
|
name,role,language,editor
|
||||||
|
Alice,backend,Rust,Neovim
|
||||||
|
Bob,frontend,TypeScript,VS Code
|
||||||
|
Charlie,devops,Go,Helix
|
||||||
|
Diana,data,Python,Jupyter
|
||||||
|
Eve,security,Rust,Neovim
|
||||||
|
Frank,mobile,Kotlin,IntelliJ
|
||||||
|
CSV
|
||||||
|
}
|
||||||
|
|
||||||
|
tsv_table() {
|
||||||
|
echo "TSV table mode. Same as CSV but tab-delimited." >&2
|
||||||
|
echo "" >&2
|
||||||
|
printf "pid\tuser\tcommand\n101\troot\tinit\n202\tmaple\tnvim\n303\tmaple\tbash\n404\troot\tsshd\n" \
|
||||||
|
| pikl --input-format tsv
|
||||||
|
}
|
||||||
|
|
||||||
|
csv_columns_alias() {
|
||||||
|
echo "CSV with --columns to pick a subset and rename headers." >&2
|
||||||
|
echo "Only showing name and language (aliased to 'Lang')." >&2
|
||||||
|
echo "" >&2
|
||||||
|
cat <<'CSV' | pikl --input-format csv --columns "name,meta.language:Lang"
|
||||||
|
name,role,language,editor
|
||||||
|
Alice,backend,Rust,Neovim
|
||||||
|
Bob,frontend,TypeScript,VS Code
|
||||||
|
Charlie,devops,Go,Helix
|
||||||
|
Diana,data,Python,Jupyter
|
||||||
|
Eve,security,Rust,Neovim
|
||||||
|
CSV
|
||||||
|
}
|
||||||
|
|
||||||
|
csv_multi_select() {
|
||||||
|
echo "CSV + multi-select. Toggle rows with Space, confirm with Enter." >&2
|
||||||
|
echo "" >&2
|
||||||
|
cat <<'CSV' | pikl --input-format csv --multi
|
||||||
|
package,version,size
|
||||||
|
ripgrep,14.1,2.1M
|
||||||
|
fd,10.2,1.8M
|
||||||
|
bat,0.24,3.4M
|
||||||
|
eza,0.18,2.0M
|
||||||
|
delta,0.17,4.2M
|
||||||
|
tokei,12.1,1.5M
|
||||||
|
CSV
|
||||||
|
}
|
||||||
|
|
||||||
|
csv_filter() {
|
||||||
|
echo "CSV with filtering. Type to filter rows, field filters work too." >&2
|
||||||
|
echo "Try: meta.category:rolling" >&2
|
||||||
|
echo "" >&2
|
||||||
|
cat <<'CSV' | pikl --input-format csv
|
||||||
|
label,category,init
|
||||||
|
Arch Linux,rolling,systemd
|
||||||
|
NixOS,rolling,systemd
|
||||||
|
Void Linux,rolling,runit
|
||||||
|
Debian,stable,systemd
|
||||||
|
Alpine,stable,openrc
|
||||||
|
Fedora,semi-rolling,systemd
|
||||||
|
Gentoo,rolling,openrc
|
||||||
|
CSV
|
||||||
|
}
|
||||||
|
|
||||||
|
# ── Phase 6 demos ────────────────────────────────────────
|
||||||
|
|
||||||
|
ipc_demo() {
|
||||||
|
echo "IPC demo: pikl runs here with --ipc enabled." >&2
|
||||||
|
echo "Open a second terminal and run:" >&2
|
||||||
|
echo " ./examples/ipc-remote.sh demo" >&2
|
||||||
|
echo "" >&2
|
||||||
|
echo "Use the remote to navigate, filter, add items, and query state." >&2
|
||||||
|
echo "Press Enter or q in the remote to exit." >&2
|
||||||
|
echo "" >&2
|
||||||
|
cat <<'ITEMS' | pikl --ipc --session demo --multi
|
||||||
|
{"label": "Arch Linux", "category": "rolling", "init": "systemd"}
|
||||||
|
{"label": "NixOS", "category": "rolling", "init": "systemd"}
|
||||||
|
{"label": "Void Linux", "category": "rolling", "init": "runit"}
|
||||||
|
{"label": "Debian", "category": "stable", "init": "systemd"}
|
||||||
|
{"label": "Alpine", "category": "stable", "init": "openrc"}
|
||||||
|
{"label": "Fedora", "category": "semi-rolling", "init": "systemd"}
|
||||||
|
{"label": "Gentoo", "category": "rolling", "init": "openrc"}
|
||||||
|
ITEMS
|
||||||
|
}
|
||||||
|
|
||||||
|
modifier_keys_demo() {
|
||||||
|
echo "Modifier key demo. Use --structured to see modifiers in output." >&2
|
||||||
|
echo "Press Enter normally, then try Shift+Enter or Ctrl+Enter." >&2
|
||||||
|
echo "Compare the JSON output: modifiers only appear when held." >&2
|
||||||
|
echo "" >&2
|
||||||
|
cat <<'ITEMS' | pikl --structured
|
||||||
|
{"label": "Firefox", "url": "https://firefox.com"}
|
||||||
|
{"label": "Neovim", "url": "https://neovim.io"}
|
||||||
|
{"label": "Alacritty", "url": "https://alacritty.org"}
|
||||||
|
{"label": "mpv", "url": "https://mpv.io"}
|
||||||
|
ITEMS
|
||||||
|
}
|
||||||
|
|
||||||
|
session_demo() {
|
||||||
|
echo "Session filter history: select with a filter, exit, relaunch." >&2
|
||||||
|
echo "Press Ctrl+P to recall your previous filter." >&2
|
||||||
|
echo "" >&2
|
||||||
|
printf "apple\nbanana\ncherry\ndate\nelderberry\nfig\ngrape\nhoneydew\n" \
|
||||||
|
| pikl --session fruit
|
||||||
|
}
|
||||||
|
|
||||||
# ── Scenario menu ─────────────────────────────────────────
|
# ── Scenario menu ─────────────────────────────────────────
|
||||||
|
|
||||||
scenarios=(
|
scenarios=(
|
||||||
@@ -297,6 +443,20 @@ scenarios=(
|
|||||||
"Handler hook (event logging)"
|
"Handler hook (event logging)"
|
||||||
"Handler hook (add items on hover)"
|
"Handler hook (add items on hover)"
|
||||||
"---"
|
"---"
|
||||||
|
"Multi-select (basic)"
|
||||||
|
"Multi-select (packages)"
|
||||||
|
"Multi-select (structured output)"
|
||||||
|
"---"
|
||||||
|
"CSV table (auto columns)"
|
||||||
|
"TSV table (processes)"
|
||||||
|
"CSV with --columns aliases"
|
||||||
|
"CSV + multi-select"
|
||||||
|
"CSV + field filter"
|
||||||
|
"---"
|
||||||
|
"IPC remote control"
|
||||||
|
"Session filter history"
|
||||||
|
"Modifier keys (structured output)"
|
||||||
|
"---"
|
||||||
"on-select-exec hook (legacy)"
|
"on-select-exec hook (legacy)"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -319,6 +479,17 @@ run_scenario() {
|
|||||||
*"Exec hooks"*) exec_hooks_demo ;;
|
*"Exec hooks"*) exec_hooks_demo ;;
|
||||||
*"Handler hook (event"*) handler_hook_demo ;;
|
*"Handler hook (event"*) handler_hook_demo ;;
|
||||||
*"Handler hook (add"*) handler_add_items_demo ;;
|
*"Handler hook (add"*) handler_add_items_demo ;;
|
||||||
|
*"Multi-select (basic)"*) multi_select_basic ;;
|
||||||
|
*"Multi-select (pack"*) multi_select_packages ;;
|
||||||
|
*"Multi-select (struct"*) multi_select_structured ;;
|
||||||
|
*"CSV table"*) csv_table ;;
|
||||||
|
*"TSV table"*) tsv_table ;;
|
||||||
|
*"CSV with --columns"*) csv_columns_alias ;;
|
||||||
|
*"CSV + multi"*) csv_multi_select ;;
|
||||||
|
*"CSV + field"*) csv_filter ;;
|
||||||
|
*"IPC remote"*) ipc_demo ;;
|
||||||
|
*"Session filter"*) session_demo ;;
|
||||||
|
*"Modifier keys"*) modifier_keys_demo ;;
|
||||||
*"on-select-exec"*) on_select_hook ;;
|
*"on-select-exec"*) on_select_hook ;;
|
||||||
"---")
|
"---")
|
||||||
echo "that's a separator, not a scenario" >&2
|
echo "that's a separator, not a scenario" >&2
|
||||||
|
|||||||
78
examples/ipc-remote.md
Normal file
78
examples/ipc-remote.md
Normal 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
178
examples/ipc-remote.sh
Executable 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
|
||||||
Reference in New Issue
Block a user