Compare commits

...

7 Commits

Author SHA1 Message Date
f81c8a20d2 fix
Some checks failed
CI / Lint (push) Has been cancelled
CI / Test (push) Has been cancelled
2026-03-16 11:07:51 -04:00
5e076b8682 feat: Add MacOS support.
Some checks failed
CI / Lint (push) Has been cancelled
CI / Test (push) Has been cancelled
2026-03-15 12:17:39 -04:00
47437f536b feat: Add support for action modifier keys like ctrl,shift,alt.
Some checks failed
CI / Lint (push) Has been cancelled
CI / Test (push) Has been cancelled
2026-03-15 11:56:26 -04:00
0d241841bc feat(gui): Add Wayland GUI frontend to menu system. 2026-03-15 10:33:54 -04:00
e77ecb447e feat: Add streaming pipe support for async loading in. 2026-03-15 01:33:38 -04:00
3b574e89ab example: Add IPC remote control example.
Some checks failed
CI / Test (push) Has been cancelled
CI / Lint (push) Has been cancelled
2026-03-14 17:05:18 -04:00
aad4c16d6e feat: Add support IPC socket control over menu. 2026-03-14 17:02:16 -04:00
32 changed files with 7425 additions and 439 deletions

3441
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@@ -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;
@@ -53,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
@@ -81,6 +142,8 @@ pub struct ViewState {
pub mode: Mode, pub mode: Mode,
pub selection_count: usize, pub selection_count: usize,
pub multi_enabled: bool, pub multi_enabled: bool,
/// True while stdin is still streaming items in.
pub streaming: bool,
/// Column headers for table mode. None when not in /// Column headers for table mode. None when not in
/// table mode. /// table mode.
pub columns: Option<Vec<ColumnHeader>>, pub columns: Option<Vec<ColumnHeader>>,
@@ -119,7 +182,92 @@ pub struct VisibleItem {
#[must_use] #[must_use]
#[derive(Debug)] #[derive(Debug)]
pub enum MenuResult { pub enum MenuResult {
Selected { items: Vec<(Value, usize)> }, Selected {
Quicklist { items: Vec<(Value, usize)> }, items: Vec<(Value, usize)>,
Cancelled, filter_text: String,
modifiers: Modifiers,
},
Quicklist {
items: Vec<(Value, usize)>,
filter_text: String,
modifiers: Modifiers,
},
Cancelled {
modifiers: Modifiers,
},
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn modifiers_empty_serializes_as_empty_array() {
let mods = Modifiers::default();
let json = serde_json::to_value(&mods).unwrap_or_default();
assert_eq!(json, json!([]));
}
#[test]
fn modifiers_single_serializes() {
let mods = Modifiers {
shift: true,
ctrl: false,
alt: false,
};
let json = serde_json::to_value(&mods).unwrap_or_default();
assert_eq!(json, json!(["shift"]));
}
#[test]
fn modifiers_multiple_serializes() {
let mods = Modifiers {
shift: true,
ctrl: true,
alt: false,
};
let json = serde_json::to_value(&mods).unwrap_or_default();
assert_eq!(json, json!(["shift", "ctrl"]));
}
#[test]
fn modifiers_all_serializes() {
let mods = Modifiers {
shift: true,
ctrl: true,
alt: true,
};
let json = serde_json::to_value(&mods).unwrap_or_default();
assert_eq!(json, json!(["shift", "ctrl", "alt"]));
}
#[test]
fn modifiers_is_empty() {
assert!(Modifiers::default().is_empty());
assert!(!Modifiers {
shift: true,
ctrl: false,
alt: false
}
.is_empty());
}
#[test]
fn modified_action_new_has_empty_modifiers() {
let ma = ModifiedAction::new(Action::Confirm);
assert!(ma.modifiers.is_empty());
}
#[test]
fn modified_action_with_modifiers() {
let mods = Modifiers {
shift: true,
ctrl: false,
alt: false,
};
let ma = ModifiedAction::with_modifiers(Action::Confirm, mods);
assert!(ma.modifiers.shift);
assert!(!ma.modifiers.ctrl);
}
} }

View File

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

View File

@@ -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,13 +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(handler: Arc<dyn HookHandler>, action_tx: mpsc::Sender<Action>) -> Self { pub fn new(handler: Arc<dyn HookHandler>, action_tx: mpsc::Sender<ModifiedAction>) -> Self {
Self { Self {
handler, handler,
_action_tx: action_tx, _action_tx: action_tx,
@@ -149,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;
@@ -217,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
@@ -251,12 +255,14 @@ mod tests {
dispatcher.dispatch(HookEvent::Hover { dispatcher.dispatch(HookEvent::Hover {
item: json!("a"), item: json!("a"),
index: 0, index: 0,
modifiers: Modifiers::default(),
}); });
// Wait a bit, then send second hover which cancels first // Wait a bit, then send second hover which cancels first
tokio::time::sleep(Duration::from_millis(50)).await; tokio::time::sleep(Duration::from_millis(50)).await;
dispatcher.dispatch(HookEvent::Hover { dispatcher.dispatch(HookEvent::Hover {
item: json!("b"), item: json!("b"),
index: 1, index: 1,
modifiers: Modifiers::default(),
}); });
// Advance past debounce for the second event // Advance past debounce for the second event

View File

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

View File

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

View File

@@ -11,7 +11,10 @@ use tracing::{debug, info, trace};
use crate::debounce::{DebouncedDispatcher, hook_response_to_action}; use crate::debounce::{DebouncedDispatcher, hook_response_to_action};
use crate::error::PiklError; use crate::error::PiklError;
use crate::event::{Action, ColumnHeader, MenuEvent, MenuResult, Mode, ViewState, VisibleItem}; use crate::event::{
Action, ColumnHeader, MenuEvent, MenuResult, ModifiedAction, Modifiers, Mode, ViewState,
VisibleItem,
};
use crate::hook::{HookEvent, HookHandler}; use crate::hook::{HookEvent, HookHandler};
use crate::model::traits::MutableMenu; use crate::model::traits::MutableMenu;
use crate::navigation::Viewport; use crate::navigation::Viewport;
@@ -147,13 +150,15 @@ pub struct MenuRunner<M: MutableMenu> {
viewport: Viewport, viewport: Viewport,
filter_text: Arc<str>, filter_text: Arc<str>,
mode: Mode, mode: Mode,
action_rx: mpsc::Receiver<Action>, action_rx: mpsc::Receiver<ModifiedAction>,
event_tx: broadcast::Sender<MenuEvent>, event_tx: broadcast::Sender<MenuEvent>,
dispatcher: Option<DebouncedDispatcher>, dispatcher: Option<DebouncedDispatcher>,
previous_cursor: Option<usize>, previous_cursor: Option<usize>,
generation: u64, generation: u64,
selection: SelectionState, selection: SelectionState,
visual_anchor: Option<usize>, visual_anchor: Option<usize>,
streaming: bool,
last_modifiers: Modifiers,
} }
impl<M: MutableMenu> MenuRunner<M> { impl<M: MutableMenu> MenuRunner<M> {
@@ -161,7 +166,7 @@ impl<M: MutableMenu> MenuRunner<M> {
/// Returns the runner and an action sender. Call /// Returns the runner and an action sender. Call
/// [`subscribe`](Self::subscribe) to get an event handle, /// [`subscribe`](Self::subscribe) to get an event handle,
/// then [`run`](Self::run) to start the event loop. /// then [`run`](Self::run) to start the event loop.
pub fn new(menu: M) -> (Self, mpsc::Sender<Action>) { pub fn new(menu: M) -> (Self, mpsc::Sender<ModifiedAction>) {
let (action_tx, action_rx) = mpsc::channel(256); let (action_tx, action_rx) = mpsc::channel(256);
// 1024 slots: large enough that a burst of rapid state changes // 1024 slots: large enough that a burst of rapid state changes
// (e.g. streaming AddItems + filter updates) won't cause lag for // (e.g. streaming AddItems + filter updates) won't cause lag for
@@ -180,6 +185,8 @@ impl<M: MutableMenu> MenuRunner<M> {
generation: 0, generation: 0,
selection: SelectionState::new(), selection: SelectionState::new(),
visual_anchor: None, visual_anchor: None,
streaming: false,
last_modifiers: Modifiers::default(),
}; };
(runner, action_tx) (runner, action_tx)
} }
@@ -190,13 +197,19 @@ impl<M: MutableMenu> MenuRunner<M> {
self.event_tx.subscribe() self.event_tx.subscribe()
} }
/// Get a clone of the event broadcast sender. Useful for
/// IPC servers that need to create their own subscribers.
pub fn event_sender(&self) -> broadcast::Sender<MenuEvent> {
self.event_tx.clone()
}
/// Set a hook handler. Wraps it in a DebouncedDispatcher /// Set a hook handler. Wraps it in a DebouncedDispatcher
/// with no debounce (all events fire immediately). Use /// with no debounce (all events fire immediately). Use
/// [`set_dispatcher`] for custom debounce settings. /// [`set_dispatcher`] for custom debounce settings.
pub fn set_hook_handler( pub fn set_hook_handler(
&mut self, &mut self,
handler: Arc<dyn HookHandler>, handler: Arc<dyn HookHandler>,
action_tx: mpsc::Sender<Action>, action_tx: mpsc::Sender<ModifiedAction>,
) { ) {
let dispatcher = DebouncedDispatcher::new(handler, action_tx); let dispatcher = DebouncedDispatcher::new(handler, action_tx);
self.dispatcher = Some(dispatcher); self.dispatcher = Some(dispatcher);
@@ -306,6 +319,7 @@ impl<M: MutableMenu> MenuRunner<M> {
mode: self.mode, mode: self.mode,
selection_count: self.selection.count(), selection_count: self.selection.count(),
multi_enabled: self.selection.multi_enabled, multi_enabled: self.selection.multi_enabled,
streaming: self.streaming,
columns, columns,
generation: self.generation, generation: self.generation,
} }
@@ -348,6 +362,7 @@ impl<M: MutableMenu> MenuRunner<M> {
self.emit_hook(HookEvent::Hover { self.emit_hook(HookEvent::Hover {
item: value, item: value,
index: orig_idx, index: orig_idx,
modifiers: self.last_modifiers,
}); });
} }
} }
@@ -551,6 +566,11 @@ impl<M: MutableMenu> MenuRunner<M> {
self.apply_action(action) self.apply_action(action)
} }
Action::CloseMenu => ActionOutcome::Closed, Action::CloseMenu => ActionOutcome::Closed,
Action::StreamingDone => {
debug!("streaming done");
self.streaming = false;
ActionOutcome::Broadcast
}
} }
} }
@@ -560,6 +580,11 @@ impl<M: MutableMenu> MenuRunner<M> {
self.mode = mode; self.mode = mode;
} }
/// Mark the menu as actively streaming items from stdin.
pub fn set_streaming(&mut self, v: bool) {
self.streaming = v;
}
/// Run the menu event loop. Consumes actions and /// Run the menu event loop. Consumes actions and
/// broadcasts events. /// broadcasts events.
/// ///
@@ -585,7 +610,9 @@ impl<M: MutableMenu> MenuRunner<M> {
// Emit Open event // Emit Open event
self.emit_hook(HookEvent::Open); self.emit_hook(HookEvent::Open);
while let Some(action) = self.action_rx.recv().await { while let Some(modified) = self.action_rx.recv().await {
let ModifiedAction { action, modifiers } = modified;
self.last_modifiers = modifiers;
let is_filter_update = matches!(&action, Action::UpdateFilter(_)); let is_filter_update = matches!(&action, Action::UpdateFilter(_));
match self.apply_action(action) { match self.apply_action(action) {
@@ -595,7 +622,10 @@ impl<M: MutableMenu> MenuRunner<M> {
// Emit Filter event if the filter changed // Emit Filter event if the filter changed
if is_filter_update { if is_filter_update {
let text = self.filter_text.to_string(); let text = self.filter_text.to_string();
self.emit_hook(HookEvent::Filter { text }); self.emit_hook(HookEvent::Filter {
text,
modifiers: self.last_modifiers,
});
} }
// Check for cursor movement -> Hover // Check for cursor movement -> Hover
@@ -609,13 +639,18 @@ impl<M: MutableMenu> MenuRunner<M> {
self.emit_hook(HookEvent::Select { self.emit_hook(HookEvent::Select {
item: value.clone(), item: value.clone(),
index: *index, index: *index,
modifiers: self.last_modifiers,
}); });
} }
// Emit Close event // Emit Close event
self.emit_hook(HookEvent::Close); self.emit_hook(HookEvent::Close);
let _ = self.event_tx.send(MenuEvent::Selected(items.clone())); let _ = self.event_tx.send(MenuEvent::Selected(items.clone()));
return Ok(MenuResult::Selected { items }); return Ok(MenuResult::Selected {
items,
filter_text: self.filter_text.to_string(),
modifiers: self.last_modifiers,
});
} }
ActionOutcome::Quicklist { items } => { ActionOutcome::Quicklist { items } => {
let values: Vec<Value> = items.iter().map(|(v, _)| v.clone()).collect(); let values: Vec<Value> = items.iter().map(|(v, _)| v.clone()).collect();
@@ -624,26 +659,37 @@ impl<M: MutableMenu> MenuRunner<M> {
self.emit_hook(HookEvent::Quicklist { self.emit_hook(HookEvent::Quicklist {
items: values.clone(), items: values.clone(),
count, count,
modifiers: self.last_modifiers,
}); });
self.emit_hook(HookEvent::Close); self.emit_hook(HookEvent::Close);
let _ = self.event_tx.send(MenuEvent::Quicklist(values)); let _ = self.event_tx.send(MenuEvent::Quicklist(values));
return Ok(MenuResult::Quicklist { items }); return Ok(MenuResult::Quicklist {
items,
filter_text: self.filter_text.to_string(),
modifiers: self.last_modifiers,
});
} }
ActionOutcome::Cancelled => { ActionOutcome::Cancelled => {
info!("menu cancelled"); info!("menu cancelled");
self.emit_hook(HookEvent::Cancel); self.emit_hook(HookEvent::Cancel {
modifiers: self.last_modifiers,
});
self.emit_hook(HookEvent::Close); self.emit_hook(HookEvent::Close);
let _ = self.event_tx.send(MenuEvent::Cancelled); let _ = self.event_tx.send(MenuEvent::Cancelled);
return Ok(MenuResult::Cancelled); return Ok(MenuResult::Cancelled {
modifiers: self.last_modifiers,
});
} }
ActionOutcome::Closed => { ActionOutcome::Closed => {
info!("menu closed by hook"); info!("menu closed by hook");
self.emit_hook(HookEvent::Close); self.emit_hook(HookEvent::Close);
let _ = self.event_tx.send(MenuEvent::Cancelled); let _ = self.event_tx.send(MenuEvent::Cancelled);
return Ok(MenuResult::Cancelled); return Ok(MenuResult::Cancelled {
modifiers: Modifiers::default(),
});
} }
ActionOutcome::NoOp => {} ActionOutcome::NoOp => {}
} }
@@ -651,7 +697,9 @@ impl<M: MutableMenu> MenuRunner<M> {
// Sender dropped // Sender dropped
self.emit_hook(HookEvent::Close); self.emit_hook(HookEvent::Close);
Ok(MenuResult::Cancelled) Ok(MenuResult::Cancelled {
modifiers: Modifiers::default(),
})
} }
} }
@@ -663,7 +711,7 @@ mod tests {
use crate::model::traits::Menu; use crate::model::traits::Menu;
use crate::runtime::json_menu::JsonMenu; use crate::runtime::json_menu::JsonMenu;
fn test_menu() -> (MenuRunner<JsonMenu>, mpsc::Sender<Action>) { fn test_menu() -> (MenuRunner<JsonMenu>, mpsc::Sender<ModifiedAction>) {
let items = vec![ let items = vec![
Item::from_plain_text("alpha"), Item::from_plain_text("alpha"),
Item::from_plain_text("beta"), Item::from_plain_text("beta"),
@@ -793,9 +841,9 @@ mod tests {
} }
// Cancel to exit // Cancel to exit
let _ = tx.send(Action::Cancel).await; let _ = tx.send(ModifiedAction::new(Action::Cancel)).await;
let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled)); let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled { modifiers: Modifiers::default() }));
assert!(matches!(result, Ok(MenuResult::Cancelled))); assert!(matches!(result, Ok(MenuResult::Cancelled { .. })));
} }
#[tokio::test] #[tokio::test]
@@ -809,7 +857,7 @@ mod tests {
let _ = rx.recv().await; let _ = rx.recv().await;
// Filter to "al" // Filter to "al"
let _ = tx.send(Action::UpdateFilter("al".to_string())).await; let _ = tx.send(ModifiedAction::new(Action::UpdateFilter("al".to_string()))).await;
if let Ok(MenuEvent::StateChanged(vs)) = rx.recv().await { if let Ok(MenuEvent::StateChanged(vs)) = rx.recv().await {
assert_eq!(vs.total_items, 4); assert_eq!(vs.total_items, 4);
// alpha should match "al" // alpha should match "al"
@@ -817,7 +865,7 @@ mod tests {
assert_eq!(&*vs.filter_text, "al"); assert_eq!(&*vs.filter_text, "al");
} }
let _ = tx.send(Action::Cancel).await; let _ = tx.send(ModifiedAction::new(Action::Cancel)).await;
let _ = handle.await; let _ = handle.await;
} }
@@ -832,20 +880,20 @@ mod tests {
let _ = rx.recv().await; let _ = rx.recv().await;
// Need to send resize first so viewport has height // Need to send resize first so viewport has height
let _ = tx.send(Action::Resize { height: 10 }).await; let _ = tx.send(ModifiedAction::new(Action::Resize { height: 10 })).await;
let _ = rx.recv().await; let _ = rx.recv().await;
// Move down and confirm // Move down and confirm
let _ = tx.send(Action::MoveDown(1)).await; let _ = tx.send(ModifiedAction::new(Action::MoveDown(1))).await;
let _ = rx.recv().await; let _ = rx.recv().await;
let _ = tx.send(Action::Confirm).await; let _ = tx.send(ModifiedAction::new(Action::Confirm)).await;
// Should get Selected event // Should get Selected event
if let Ok(MenuEvent::Selected(items)) = rx.recv().await { if let Ok(MenuEvent::Selected(items)) = rx.recv().await {
assert_eq!(items[0].0.as_str(), Some("beta")); assert_eq!(items[0].0.as_str(), Some("beta"));
} }
let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled)); let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled { modifiers: Modifiers::default() }));
assert!(matches!(result, Ok(MenuResult::Selected { .. }))); assert!(matches!(result, Ok(MenuResult::Selected { .. })));
} }
@@ -859,18 +907,18 @@ mod tests {
let _ = rx.recv().await; // initial let _ = rx.recv().await; // initial
// Filter to something that matches nothing // Filter to something that matches nothing
let _ = tx.send(Action::UpdateFilter("zzzzz".to_string())).await; let _ = tx.send(ModifiedAction::new(Action::UpdateFilter("zzzzz".to_string()))).await;
if let Ok(MenuEvent::StateChanged(vs)) = rx.recv().await { if let Ok(MenuEvent::StateChanged(vs)) = rx.recv().await {
assert_eq!(vs.total_filtered, 0); assert_eq!(vs.total_filtered, 0);
} }
// Confirm should be no-op // Confirm should be no-op
let _ = tx.send(Action::Confirm).await; let _ = tx.send(ModifiedAction::new(Action::Confirm)).await;
// Cancel to exit (should still work) // Cancel to exit (should still work)
let _ = tx.send(Action::Cancel).await; let _ = tx.send(ModifiedAction::new(Action::Cancel)).await;
let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled)); let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled { modifiers: Modifiers::default() }));
assert!(matches!(result, Ok(MenuResult::Cancelled))); assert!(matches!(result, Ok(MenuResult::Cancelled { .. })));
} }
#[tokio::test] #[tokio::test]
@@ -883,8 +931,8 @@ mod tests {
// Drop the only sender. // Drop the only sender.
drop(tx); drop(tx);
let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled)); let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled { modifiers: Modifiers::default() }));
assert!(matches!(result, Ok(MenuResult::Cancelled))); assert!(matches!(result, Ok(MenuResult::Cancelled { .. })));
} }
// -- End-to-end output correctness -- // -- End-to-end output correctness --
@@ -897,18 +945,18 @@ mod tests {
let handle = tokio::spawn(async move { menu.run().await }); let handle = tokio::spawn(async move { menu.run().await });
let _ = rx.recv().await; // initial state let _ = rx.recv().await; // initial state
let _ = tx.send(Action::Resize { height: 10 }).await; let _ = tx.send(ModifiedAction::new(Action::Resize { height: 10 })).await;
let _ = rx.recv().await; let _ = rx.recv().await;
// Confirm at cursor 0, should get "alpha" // Confirm at cursor 0, should get "alpha"
let _ = tx.send(Action::Confirm).await; let _ = tx.send(ModifiedAction::new(Action::Confirm)).await;
let event = rx.recv().await; let event = rx.recv().await;
assert!( assert!(
matches!(&event, Ok(MenuEvent::Selected(items)) if items[0].0.as_str() == Some("alpha")) matches!(&event, Ok(MenuEvent::Selected(items)) if items[0].0.as_str() == Some("alpha"))
); );
let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled)); let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled { modifiers: Modifiers::default() }));
assert!( assert!(
matches!(result, Ok(MenuResult::Selected { ref items, .. }) if items[0].0.as_str() == Some("alpha")) matches!(result, Ok(MenuResult::Selected { ref items, .. }) if items[0].0.as_str() == Some("alpha"))
); );
@@ -922,17 +970,17 @@ mod tests {
let handle = tokio::spawn(async move { menu.run().await }); let handle = tokio::spawn(async move { menu.run().await });
let _ = rx.recv().await; // initial let _ = rx.recv().await; // initial
let _ = tx.send(Action::Resize { height: 10 }).await; let _ = tx.send(ModifiedAction::new(Action::Resize { height: 10 })).await;
let _ = rx.recv().await; let _ = rx.recv().await;
// Move down twice -> cursor at index 2 -> "gamma" // Move down twice -> cursor at index 2 -> "gamma"
let _ = tx.send(Action::MoveDown(1)).await; let _ = tx.send(ModifiedAction::new(Action::MoveDown(1))).await;
let _ = rx.recv().await; let _ = rx.recv().await;
let _ = tx.send(Action::MoveDown(1)).await; let _ = tx.send(ModifiedAction::new(Action::MoveDown(1))).await;
let _ = rx.recv().await; let _ = rx.recv().await;
let _ = tx.send(Action::Confirm).await; let _ = tx.send(ModifiedAction::new(Action::Confirm)).await;
let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled)); let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled { modifiers: Modifiers::default() }));
assert!( assert!(
matches!(result, Ok(MenuResult::Selected { ref items, .. }) if items[0].0.as_str() == Some("gamma")) matches!(result, Ok(MenuResult::Selected { ref items, .. }) if items[0].0.as_str() == Some("gamma"))
); );
@@ -946,20 +994,20 @@ mod tests {
let handle = tokio::spawn(async move { menu.run().await }); let handle = tokio::spawn(async move { menu.run().await });
let _ = rx.recv().await; // initial let _ = rx.recv().await; // initial
let _ = tx.send(Action::Resize { height: 10 }).await; let _ = tx.send(ModifiedAction::new(Action::Resize { height: 10 })).await;
let _ = rx.recv().await; let _ = rx.recv().await;
// Filter to "del", should match "delta" // Filter to "del", should match "delta"
let _ = tx.send(Action::UpdateFilter("del".to_string())).await; let _ = tx.send(ModifiedAction::new(Action::UpdateFilter("del".to_string()))).await;
if let Ok(MenuEvent::StateChanged(vs)) = rx.recv().await { if let Ok(MenuEvent::StateChanged(vs)) = rx.recv().await {
assert!(vs.total_filtered >= 1); assert!(vs.total_filtered >= 1);
assert_eq!(vs.visible_items[0].label, "delta"); assert_eq!(vs.visible_items[0].label, "delta");
} }
// Confirm, should select "delta" // Confirm, should select "delta"
let _ = tx.send(Action::Confirm).await; let _ = tx.send(ModifiedAction::new(Action::Confirm)).await;
let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled)); let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled { modifiers: Modifiers::default() }));
assert!( assert!(
matches!(result, Ok(MenuResult::Selected { ref items, .. }) if items[0].0.as_str() == Some("delta")) matches!(result, Ok(MenuResult::Selected { ref items, .. }) if items[0].0.as_str() == Some("delta"))
); );
@@ -973,14 +1021,14 @@ mod tests {
let handle = tokio::spawn(async move { menu.run().await }); let handle = tokio::spawn(async move { menu.run().await });
let _ = rx.recv().await; // initial let _ = rx.recv().await; // initial
let _ = tx.send(Action::Resize { height: 10 }).await; let _ = tx.send(ModifiedAction::new(Action::Resize { height: 10 })).await;
let _ = rx.recv().await; let _ = rx.recv().await;
// Add a new item // Add a new item
let _ = tx let _ = tx
.send(Action::AddItems(vec![serde_json::Value::String( .send(ModifiedAction::new(Action::AddItems(vec![
"epsilon".to_string(), serde_json::Value::String("epsilon".to_string()),
)])) ])))
.await; .await;
if let Ok(MenuEvent::StateChanged(vs)) = rx.recv().await { if let Ok(MenuEvent::StateChanged(vs)) = rx.recv().await {
assert_eq!(vs.total_items, 5); assert_eq!(vs.total_items, 5);
@@ -988,15 +1036,15 @@ mod tests {
} }
// Filter to "eps", only epsilon should match // Filter to "eps", only epsilon should match
let _ = tx.send(Action::UpdateFilter("eps".to_string())).await; let _ = tx.send(ModifiedAction::new(Action::UpdateFilter("eps".to_string()))).await;
if let Ok(MenuEvent::StateChanged(vs)) = rx.recv().await { if let Ok(MenuEvent::StateChanged(vs)) = rx.recv().await {
assert!(vs.total_filtered >= 1); assert!(vs.total_filtered >= 1);
assert_eq!(vs.visible_items[0].label, "epsilon"); assert_eq!(vs.visible_items[0].label, "epsilon");
} }
let _ = tx.send(Action::Confirm).await; let _ = tx.send(ModifiedAction::new(Action::Confirm)).await;
let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled)); let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled { modifiers: Modifiers::default() }));
assert!( assert!(
matches!(result, Ok(MenuResult::Selected { ref items, .. }) if items[0].0.as_str() == Some("epsilon")) matches!(result, Ok(MenuResult::Selected { ref items, .. }) if items[0].0.as_str() == Some("epsilon"))
); );
@@ -1011,13 +1059,13 @@ mod tests {
let _ = rx.recv().await; // initial let _ = rx.recv().await; // initial
let _ = tx.send(Action::Cancel).await; let _ = tx.send(ModifiedAction::new(Action::Cancel)).await;
// Should get Cancelled event // Should get Cancelled event
assert!(matches!(rx.recv().await, Ok(MenuEvent::Cancelled))); assert!(matches!(rx.recv().await, Ok(MenuEvent::Cancelled)));
let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled)); let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled { modifiers: Modifiers::default() }));
assert!(matches!(result, Ok(MenuResult::Cancelled))); assert!(matches!(result, Ok(MenuResult::Cancelled { .. })));
} }
// -- Ordering invariant tests -- // -- Ordering invariant tests --
@@ -1040,14 +1088,14 @@ mod tests {
let handle = tokio::spawn(async move { menu.run().await }); let handle = tokio::spawn(async move { menu.run().await });
let _ = rx.recv().await; // initial let _ = rx.recv().await; // initial
let _ = tx.send(Action::Resize { height: 50 }).await; let _ = tx.send(ModifiedAction::new(Action::Resize { height: 50 })).await;
let _ = rx.recv().await; let _ = rx.recv().await;
// Back-to-back, no waiting between these // Back-to-back, no waiting between these
let _ = tx.send(Action::UpdateFilter("ban".to_string())).await; let _ = tx.send(ModifiedAction::new(Action::UpdateFilter("ban".to_string()))).await;
let _ = tx.send(Action::Confirm).await; let _ = tx.send(ModifiedAction::new(Action::Confirm)).await;
let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled)); let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled { modifiers: Modifiers::default() }));
// Must get "banana". Filter was applied before confirm ran. // Must get "banana". Filter was applied before confirm ran.
assert!(matches!( assert!(matches!(
result, result,
@@ -1064,16 +1112,16 @@ mod tests {
let handle = tokio::spawn(async move { menu.run().await }); let handle = tokio::spawn(async move { menu.run().await });
let _ = rx.recv().await; // initial let _ = rx.recv().await; // initial
let _ = tx.send(Action::Resize { height: 50 }).await; let _ = tx.send(ModifiedAction::new(Action::Resize { height: 50 })).await;
let _ = rx.recv().await; let _ = rx.recv().await;
// Three moves down back-to-back, then confirm // Three moves down back-to-back, then confirm
let _ = tx.send(Action::MoveDown(1)).await; let _ = tx.send(ModifiedAction::new(Action::MoveDown(1))).await;
let _ = tx.send(Action::MoveDown(1)).await; let _ = tx.send(ModifiedAction::new(Action::MoveDown(1))).await;
let _ = tx.send(Action::MoveDown(1)).await; let _ = tx.send(ModifiedAction::new(Action::MoveDown(1))).await;
let _ = tx.send(Action::Confirm).await; let _ = tx.send(ModifiedAction::new(Action::Confirm)).await;
let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled)); let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled { modifiers: Modifiers::default() }));
// Cursor at index 3 -> "delta" // Cursor at index 3 -> "delta"
assert!(matches!( assert!(matches!(
result, result,
@@ -1157,19 +1205,19 @@ mod tests {
let handle = tokio::spawn(async move { menu.run().await }); let handle = tokio::spawn(async move { menu.run().await });
let _ = rx.recv().await; // initial let _ = rx.recv().await; // initial
let _ = tx.send(Action::Resize { height: 50 }).await; let _ = tx.send(ModifiedAction::new(Action::Resize { height: 50 })).await;
let _ = rx.recv().await; let _ = rx.recv().await;
// All back-to-back // All back-to-back
let _ = tx let _ = tx
.send(Action::AddItems(vec![serde_json::Value::String( .send(ModifiedAction::new(Action::AddItems(vec![
"zephyr".to_string(), serde_json::Value::String("zephyr".to_string()),
)])) ])))
.await; .await;
let _ = tx.send(Action::UpdateFilter("zep".to_string())).await; let _ = tx.send(ModifiedAction::new(Action::UpdateFilter("zep".to_string()))).await;
let _ = tx.send(Action::Confirm).await; let _ = tx.send(ModifiedAction::new(Action::Confirm)).await;
let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled)); let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled { modifiers: Modifiers::default() }));
// Must find "zephyr". It was added before the filter ran. // Must find "zephyr". It was added before the filter ran.
assert!(matches!( assert!(matches!(
result, result,
@@ -1328,10 +1376,10 @@ mod tests {
// Skip initial state // Skip initial state
let _ = rx.recv().await; let _ = rx.recv().await;
let _ = tx.send(Action::Resize { height: 10 }).await; let _ = tx.send(ModifiedAction::new(Action::Resize { height: 10 })).await;
let _ = rx.recv().await; let _ = rx.recv().await;
let _ = tx.send(Action::Quicklist).await; let _ = tx.send(ModifiedAction::new(Action::Quicklist)).await;
if let Ok(MenuEvent::Quicklist(values)) = rx.recv().await { if let Ok(MenuEvent::Quicklist(values)) = rx.recv().await {
assert_eq!(values.len(), 4); assert_eq!(values.len(), 4);
@@ -1339,7 +1387,7 @@ mod tests {
panic!("expected Quicklist event"); panic!("expected Quicklist event");
} }
let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled)); let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled { modifiers: Modifiers::default() }));
assert!(matches!(result, Ok(MenuResult::Quicklist { .. }))); assert!(matches!(result, Ok(MenuResult::Quicklist { .. })));
} }
@@ -1351,16 +1399,16 @@ mod tests {
let handle = tokio::spawn(async move { menu.run().await }); let handle = tokio::spawn(async move { menu.run().await });
let _ = rx.recv().await; // initial let _ = rx.recv().await; // initial
let _ = tx.send(Action::Resize { height: 10 }).await; let _ = tx.send(ModifiedAction::new(Action::Resize { height: 10 })).await;
let _ = rx.recv().await; let _ = rx.recv().await;
// Filter then quicklist back-to-back // Filter then quicklist back-to-back
let _ = tx.send(Action::UpdateFilter("al".to_string())).await; let _ = tx.send(ModifiedAction::new(Action::UpdateFilter("al".to_string()))).await;
let _ = tx.send(Action::Quicklist).await; let _ = tx.send(ModifiedAction::new(Action::Quicklist)).await;
let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled)); let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled { modifiers: Modifiers::default() }));
match result { match result {
Ok(MenuResult::Quicklist { items }) => { Ok(MenuResult::Quicklist { items, .. }) => {
// "alpha" matches "al" // "alpha" matches "al"
assert!(!items.is_empty()); assert!(!items.is_empty());
for (v, _) in &items { for (v, _) in &items {
@@ -1404,6 +1452,7 @@ mod tests {
m.apply_action(Action::UpdateFilter("al".to_string())); m.apply_action(Action::UpdateFilter("al".to_string()));
m.emit_hook(HookEvent::Filter { m.emit_hook(HookEvent::Filter {
text: "al".to_string(), text: "al".to_string(),
modifiers: Modifiers::default(),
}); });
m.apply_action(Action::MoveDown(1)); m.apply_action(Action::MoveDown(1));
m.check_cursor_hover(); m.check_cursor_hover();
@@ -1417,6 +1466,142 @@ mod tests {
assert!(events.contains(&HookEventKind::Filter)); assert!(events.contains(&HookEventKind::Filter));
} }
// -- Modifier flow-through tests --
#[tokio::test]
async fn modifiers_flow_through_to_selected_result() {
let (menu, tx) = test_menu();
let mut rx = menu.subscribe();
let handle = tokio::spawn(async move { menu.run().await });
let _ = rx.recv().await; // initial
let _ = tx
.send(ModifiedAction::new(Action::Resize { height: 10 }))
.await;
let _ = rx.recv().await;
// Confirm with shift held
let shift = Modifiers {
shift: true,
ctrl: false,
alt: false,
};
let _ = tx
.send(ModifiedAction::with_modifiers(Action::Confirm, shift))
.await;
let result = handle
.await
.unwrap_or(Ok(MenuResult::Cancelled {
modifiers: Modifiers::default(),
}));
match result {
Ok(MenuResult::Selected { modifiers, .. }) => {
assert!(modifiers.shift, "shift should be set on result");
assert!(!modifiers.ctrl);
assert!(!modifiers.alt);
}
other => panic!("expected Selected with shift, got {other:?}"),
}
}
#[tokio::test]
async fn modifiers_flow_through_to_cancelled_result() {
let (menu, tx) = test_menu();
let _rx = menu.subscribe();
let handle = tokio::spawn(async move { menu.run().await });
let mods = Modifiers {
shift: false,
ctrl: true,
alt: true,
};
let _ = tx
.send(ModifiedAction::with_modifiers(Action::Cancel, mods))
.await;
let result = handle
.await
.unwrap_or(Ok(MenuResult::Cancelled {
modifiers: Modifiers::default(),
}));
match result {
Ok(MenuResult::Cancelled { modifiers }) => {
assert!(!modifiers.shift);
assert!(modifiers.ctrl, "ctrl should be set");
assert!(modifiers.alt, "alt should be set");
}
other => panic!("expected Cancelled with ctrl+alt, got {other:?}"),
}
}
#[tokio::test]
async fn no_modifiers_result_has_empty_modifiers() {
let (menu, tx) = test_menu();
let mut rx = menu.subscribe();
let handle = tokio::spawn(async move { menu.run().await });
let _ = rx.recv().await; // initial
let _ = tx
.send(ModifiedAction::new(Action::Resize { height: 10 }))
.await;
let _ = rx.recv().await;
// Confirm without modifiers
let _ = tx.send(ModifiedAction::new(Action::Confirm)).await;
let result = handle
.await
.unwrap_or(Ok(MenuResult::Cancelled {
modifiers: Modifiers::default(),
}));
match result {
Ok(MenuResult::Selected { modifiers, .. }) => {
assert!(
modifiers.is_empty(),
"modifiers should be empty when none held"
);
}
other => panic!("expected Selected with empty mods, got {other:?}"),
}
}
#[tokio::test]
async fn quicklist_carries_modifiers() {
let (menu, tx) = test_menu();
let mut rx = menu.subscribe();
let handle = tokio::spawn(async move { menu.run().await });
let _ = rx.recv().await; // initial
let _ = tx
.send(ModifiedAction::new(Action::Resize { height: 10 }))
.await;
let _ = rx.recv().await;
let mods = Modifiers {
shift: true,
ctrl: false,
alt: true,
};
let _ = tx
.send(ModifiedAction::with_modifiers(Action::Quicklist, mods))
.await;
let result = handle
.await
.unwrap_or(Ok(MenuResult::Cancelled {
modifiers: Modifiers::default(),
}));
match result {
Ok(MenuResult::Quicklist { modifiers, .. }) => {
assert!(modifiers.shift);
assert!(!modifiers.ctrl);
assert!(modifiers.alt);
}
other => panic!("expected Quicklist with shift+alt, got {other:?}"),
}
}
// -- SelectionState unit tests -- // -- SelectionState unit tests --
#[test] #[test]
@@ -1812,4 +1997,54 @@ mod tests {
// City "toronto" (7) vs header "City" (4): width = 7 // City "toronto" (7) vs header "City" (4): width = 7
assert_eq!(cols[2].width, 7); assert_eq!(cols[2].width, 7);
} }
// -- Streaming tests --
#[test]
fn streaming_done_sets_streaming_false() {
let (mut m, _tx) = test_menu();
m.set_streaming(true);
m.run_filter();
assert!(m.build_view_state().streaming);
let outcome = m.apply_action(Action::StreamingDone);
assert!(matches!(outcome, ActionOutcome::Broadcast));
assert!(!m.build_view_state().streaming);
}
#[test]
fn add_items_on_empty_streaming_menu() {
let (mut m, _tx) = MenuRunner::new(JsonMenu::new(vec![], "label".to_string()));
m.set_streaming(true);
m.run_filter();
m.apply_action(Action::Resize { height: 20 });
assert_eq!(m.menu.total(), 0);
assert!(m.build_view_state().streaming);
m.apply_action(Action::AddItems(vec![
Value::String("one".into()),
Value::String("two".into()),
]));
assert_eq!(m.menu.total(), 2);
assert_eq!(m.menu.filtered_count(), 2);
// Confirm on non-empty streaming menu works
let outcome = m.apply_action(Action::Confirm);
assert!(matches!(outcome, ActionOutcome::Selected { .. }));
}
#[test]
fn confirm_on_empty_streaming_menu_is_noop() {
let (mut m, _tx) = MenuRunner::new(JsonMenu::new(vec![], "label".to_string()));
m.set_streaming(true);
m.run_filter();
let outcome = m.apply_action(Action::Confirm);
assert!(matches!(outcome, ActionOutcome::NoOp));
}
#[test]
fn view_state_streaming_defaults_false() {
let mut m = ready_menu();
assert!(!m.build_view_state().streaming);
}
} }

View File

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

View File

@@ -4,7 +4,7 @@
use std::io::BufRead; use std::io::BufRead;
use crate::event::{Action, Mode}; use crate::event::{Action, Mode, Modifiers};
use super::ScriptAction; use super::ScriptAction;
use super::error::{ScriptError, ScriptErrorKind}; use super::error::{ScriptError, ScriptErrorKind};
@@ -82,6 +82,30 @@ fn parse_positive_u16(
} }
} }
/// Parse modifier prefixes from the start of a trimmed line.
/// Returns (modifiers, remaining_line).
fn parse_modifier_prefix(line: &str) -> Result<(Modifiers, &str), Option<String>> {
let mut mods = Modifiers::default();
let mut rest = line;
while let Some(stripped) = rest.strip_prefix('+') {
// Find the end of this modifier token (next space or '+')
let end = stripped
.find([' ', '+'])
.unwrap_or(stripped.len());
let token = &stripped[..end];
match token {
"shift" => mods.shift = true,
"ctrl" => mods.ctrl = true,
"alt" => mods.alt = true,
other => return Err(Some(format!("unknown modifier '+{other}'"))),
}
rest = stripped[end..].trim_start();
}
Ok((mods, rest))
}
/// Parse a single line into a ScriptAction. /// Parse a single line into a ScriptAction.
pub fn parse_action(line_number: usize, line: &str) -> Result<ScriptAction, ScriptError> { pub fn parse_action(line_number: usize, line: &str) -> Result<ScriptAction, ScriptError> {
let trimmed = line.trim(); let trimmed = line.trim();
@@ -90,41 +114,66 @@ pub fn parse_action(line_number: usize, line: &str) -> Result<ScriptAction, Scri
return Ok(ScriptAction::Comment); return Ok(ScriptAction::Comment);
} }
let (cmd, arg) = match trimmed.split_once(' ') { // Parse optional modifier prefixes (+shift, +ctrl, +alt)
let (modifiers, rest) = parse_modifier_prefix(trimmed).map_err(|msg| ScriptError {
line: line_number,
source_line: line.to_string(),
kind: ScriptErrorKind::InvalidArgument {
action: String::new(),
message: msg.unwrap_or_default(),
},
})?;
if rest.is_empty() {
return Err(ScriptError {
line: line_number,
source_line: line.to_string(),
kind: ScriptErrorKind::InvalidArgument {
action: String::new(),
message: "modifier prefix without an action".to_string(),
},
});
}
let (cmd, arg) = match rest.split_once(' ') {
Some((c, a)) => (c, Some(a)), Some((c, a)) => (c, Some(a)),
None => (trimmed, None), None => (rest, None),
};
let core = |action: Action| -> Result<ScriptAction, ScriptError> {
Ok(ScriptAction::Core(action, modifiers))
}; };
match cmd { match cmd {
"filter" => { "filter" => {
let text = arg.unwrap_or(""); let text = arg.unwrap_or("");
Ok(ScriptAction::Core(Action::UpdateFilter(text.to_string()))) core(Action::UpdateFilter(text.to_string()))
} }
"move-up" => { "move-up" => {
let n = parse_count(line_number, line, "move-up", arg)?; let n = parse_count(line_number, line, "move-up", arg)?;
Ok(ScriptAction::Core(Action::MoveUp(n))) core(Action::MoveUp(n))
} }
"move-down" => { "move-down" => {
let n = parse_count(line_number, line, "move-down", arg)?; let n = parse_count(line_number, line, "move-down", arg)?;
Ok(ScriptAction::Core(Action::MoveDown(n))) core(Action::MoveDown(n))
} }
"move-to-top" => Ok(ScriptAction::Core(Action::MoveToTop)), "move-to-top" => core(Action::MoveToTop),
"move-to-bottom" => Ok(ScriptAction::Core(Action::MoveToBottom)), "move-to-bottom" => core(Action::MoveToBottom),
"page-up" => { "page-up" => {
let n = parse_count(line_number, line, "page-up", arg)?; let n = parse_count(line_number, line, "page-up", arg)?;
Ok(ScriptAction::Core(Action::PageUp(n))) core(Action::PageUp(n))
} }
"page-down" => { "page-down" => {
let n = parse_count(line_number, line, "page-down", arg)?; let n = parse_count(line_number, line, "page-down", arg)?;
Ok(ScriptAction::Core(Action::PageDown(n))) core(Action::PageDown(n))
} }
"half-page-up" => { "half-page-up" => {
let n = parse_count(line_number, line, "half-page-up", arg)?; let n = parse_count(line_number, line, "half-page-up", arg)?;
Ok(ScriptAction::Core(Action::HalfPageUp(n))) core(Action::HalfPageUp(n))
} }
"half-page-down" => { "half-page-down" => {
let n = parse_count(line_number, line, "half-page-down", arg)?; let n = parse_count(line_number, line, "half-page-down", arg)?;
Ok(ScriptAction::Core(Action::HalfPageDown(n))) core(Action::HalfPageDown(n))
} }
"set-mode" => { "set-mode" => {
let Some(mode_str) = arg else { let Some(mode_str) = arg else {
@@ -136,9 +185,9 @@ pub fn parse_action(line_number: usize, line: &str) -> Result<ScriptAction, Scri
)); ));
}; };
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" => Ok(ScriptAction::Core(Action::SetMode(Mode::Visual))), "visual" => core(Action::SetMode(Mode::Visual)),
other => Err(invalid_arg( other => Err(invalid_arg(
line_number, line_number,
line, line,
@@ -147,20 +196,21 @@ pub fn parse_action(line_number: usize, line: &str) -> Result<ScriptAction, Scri
)), )),
} }
} }
"toggle-select" => Ok(ScriptAction::Core(Action::ToggleSelect)), "toggle-select" => core(Action::ToggleSelect),
"select-all" => Ok(ScriptAction::Core(Action::SelectAll)), "select-all" => core(Action::SelectAll),
"deselect-all" => Ok(ScriptAction::Core(Action::ClearSelections)), "deselect-all" => core(Action::ClearSelections),
"undo-selection" => Ok(ScriptAction::Core(Action::UndoSelection)), "undo-selection" => core(Action::UndoSelection),
"redo-selection" => Ok(ScriptAction::Core(Action::RedoSelection)), "redo-selection" => core(Action::RedoSelection),
"confirm" => Ok(ScriptAction::Core(Action::Confirm)), "confirm" => core(Action::Confirm),
"cancel" => Ok(ScriptAction::Core(Action::Cancel)), "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(),
@@ -243,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())
); );
} }
@@ -253,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())
); );
} }
@@ -261,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())
); );
} }
@@ -289,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())
); );
} }
@@ -323,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())
); );
} }
@@ -335,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())
); );
} }
@@ -404,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())
); );
} }
@@ -430,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())
); );
} }
@@ -447,7 +497,7 @@ mod tests {
fn parse_set_mode_invalid() { fn parse_set_mode_invalid() {
assert_eq!( assert_eq!(
parse_action(1, "set-mode visual").unwrap_or(ScriptAction::Comment), parse_action(1, "set-mode visual").unwrap_or(ScriptAction::Comment),
ScriptAction::Core(Action::SetMode(Mode::Visual)) ScriptAction::Core(Action::SetMode(Mode::Visual), Modifiers::default())
); );
} }
@@ -518,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,
@@ -540,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();
@@ -554,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,
@@ -564,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();
@@ -588,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());
@@ -658,23 +708,31 @@ mod tests {
fn parse_selection_actions() { fn parse_selection_actions() {
assert_eq!( assert_eq!(
parse_action(1, "toggle-select").unwrap_or(ScriptAction::Comment), parse_action(1, "toggle-select").unwrap_or(ScriptAction::Comment),
ScriptAction::Core(Action::ToggleSelect) ScriptAction::Core(Action::ToggleSelect, Modifiers::default())
); );
assert_eq!( assert_eq!(
parse_action(1, "select-all").unwrap_or(ScriptAction::Comment), parse_action(1, "select-all").unwrap_or(ScriptAction::Comment),
ScriptAction::Core(Action::SelectAll) ScriptAction::Core(Action::SelectAll, Modifiers::default())
); );
assert_eq!( assert_eq!(
parse_action(1, "deselect-all").unwrap_or(ScriptAction::Comment), parse_action(1, "deselect-all").unwrap_or(ScriptAction::Comment),
ScriptAction::Core(Action::ClearSelections) ScriptAction::Core(Action::ClearSelections, Modifiers::default())
); );
assert_eq!( assert_eq!(
parse_action(1, "undo-selection").unwrap_or(ScriptAction::Comment), parse_action(1, "undo-selection").unwrap_or(ScriptAction::Comment),
ScriptAction::Core(Action::UndoSelection) ScriptAction::Core(Action::UndoSelection, Modifiers::default())
); );
assert_eq!( assert_eq!(
parse_action(1, "redo-selection").unwrap_or(ScriptAction::Comment), parse_action(1, "redo-selection").unwrap_or(ScriptAction::Comment),
ScriptAction::Core(Action::RedoSelection) 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())
); );
} }
@@ -690,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())
); );
} }
@@ -705,16 +763,96 @@ mod tests {
assert!(result.is_ok()); assert!(result.is_ok());
let actions = result.unwrap_or_default(); let actions = result.unwrap_or_default();
assert_eq!(actions.len(), 5); assert_eq!(actions.len(), 5);
assert_eq!(actions[0], ScriptAction::Core(Action::HalfPageUp(2))); assert_eq!(actions[0], ScriptAction::Core(Action::HalfPageUp(2), Modifiers::default()));
assert_eq!( assert_eq!(
actions[1], actions[1],
ScriptAction::Core(Action::SetMode(Mode::Normal)) ScriptAction::Core(Action::SetMode(Mode::Normal), Modifiers::default())
); );
assert_eq!( assert_eq!(
actions[2], actions[2],
ScriptAction::Core(Action::SetMode(Mode::Insert)) ScriptAction::Core(Action::SetMode(Mode::Insert), Modifiers::default())
); );
assert_eq!(actions[3], ScriptAction::Core(Action::HalfPageDown(1))); assert_eq!(actions[3], ScriptAction::Core(Action::HalfPageDown(1), Modifiers::default()));
assert_eq!(actions[4], ScriptAction::Core(Action::Confirm)); assert_eq!(actions[4], ScriptAction::Core(Action::Confirm, Modifiers::default()));
}
// -- Modifier prefix tests --
#[test]
fn parse_shift_prefix() {
let result = parse_action(1, "+shift confirm");
assert!(result.is_ok());
let expected_mods = Modifiers {
shift: true,
ctrl: false,
alt: false,
};
assert_eq!(
result.unwrap_or(ScriptAction::Comment),
ScriptAction::Core(Action::Confirm, expected_mods)
);
}
#[test]
fn parse_multiple_modifier_prefixes() {
let result = parse_action(1, "+ctrl+alt move-down 3");
assert!(result.is_ok());
let expected_mods = Modifiers {
shift: false,
ctrl: true,
alt: true,
};
assert_eq!(
result.unwrap_or(ScriptAction::Comment),
ScriptAction::Core(Action::MoveDown(3), expected_mods)
);
}
#[test]
fn parse_all_modifier_prefixes() {
let result = parse_action(1, "+shift+ctrl+alt cancel");
assert!(result.is_ok());
let expected_mods = Modifiers {
shift: true,
ctrl: true,
alt: true,
};
assert_eq!(
result.unwrap_or(ScriptAction::Comment),
ScriptAction::Core(Action::Cancel, expected_mods)
);
}
#[test]
fn parse_unknown_modifier_is_error() {
let result = parse_action(1, "+super confirm");
assert!(result.is_err());
}
#[test]
fn parse_modifier_without_action_is_error() {
let result = parse_action(1, "+shift");
assert!(result.is_err());
}
#[test]
fn parse_no_modifier_prefix_has_empty_modifiers() {
let result = parse_action(1, "confirm");
assert!(result.is_ok());
assert_eq!(
result.unwrap_or(ScriptAction::Comment),
ScriptAction::Core(Action::Confirm, Modifiers::default())
);
}
#[test]
fn parse_modifier_with_space_separated() {
// "+shift confirm" should work
let result = parse_action(1, "+shift confirm");
assert!(result.is_ok());
if let Ok(ScriptAction::Core(_, mods)) = result {
assert!(mods.shift);
assert!(!mods.ctrl);
}
} }
} }

View File

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

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

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

View File

@@ -24,6 +24,7 @@ fn gen_module(module: &TestModule) -> syn::Result<TokenStream> {
TestKind::Filter => gen_filter(case, &module.fixtures)?, TestKind::Filter => gen_filter(case, &module.fixtures)?,
TestKind::Nav => gen_nav(case, &module.fixtures)?, TestKind::Nav => gen_nav(case, &module.fixtures)?,
TestKind::Menu => gen_menu(case, &module.fixtures)?, TestKind::Menu => gen_menu(case, &module.fixtures)?,
TestKind::Ipc => gen_ipc(case, &module.fixtures)?,
}; };
test_fns.push(tokens); test_fns.push(tokens);
} }
@@ -58,11 +59,20 @@ fn gen_imports(kind: TestKind) -> TokenStream {
TestKind::Menu => { TestKind::Menu => {
quote! { quote! {
use pikl_core::item::Item; use pikl_core::item::Item;
use pikl_core::event::{Action, MenuEvent, MenuResult}; use pikl_core::event::{Action, MenuEvent, MenuResult, ModifiedAction};
use pikl_core::menu::MenuRunner; use pikl_core::menu::MenuRunner;
use pikl_core::json_menu::JsonMenu; use pikl_core::json_menu::JsonMenu;
} }
} }
TestKind::Ipc => {
quote! {
use pikl_core::item::Item;
use pikl_core::event::{Action, MenuEvent, MenuResult, ModifiedAction};
use pikl_core::menu::MenuRunner;
use pikl_core::json_menu::JsonMenu;
use crate::ipc::server::IpcServer;
}
}
} }
} }
@@ -179,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.
} }
} }
} }
@@ -191,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())
@@ -235,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;
@@ -247,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()
);
}); });
} }
@@ -278,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 {
@@ -288,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); });
@@ -304,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)
),
)); ));
} }
} }
@@ -319,73 +303,12 @@ 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 {
quote! {
assert!(
matches!(result, Ok(MenuResult::Cancelled)),
"expected Cancelled, got: {:?}", result.as_ref().map(|r| format!("{:?}", r))
);
}
} else if let Some(ref expected) = case.selected {
quote! {
match &result {
Ok(MenuResult::Selected { 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 {
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 {
return Err(syn::Error::new(
case.name.span(),
format!(
"menu test '{}' has no result assertion (expected selected, selected_items, or cancelled)",
case.name
),
));
};
// If test expects cancellation via sender drop (no cancel action, no confirm),
// we need to drop tx after sending actions.
let drop_sender = quote! { drop(tx); };
let multi_setup = if fixtures.multi == Some(true) { let multi_setup = if fixtures.multi == Some(true) {
quote! { menu.set_multi(true); } quote! { menu.set_multi(true); }
@@ -402,54 +325,43 @@ fn gen_menu(case: &TestCase, fixtures: &Fixtures) -> syn::Result<TokenStream> {
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);
@@ -457,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 },
@@ -480,37 +390,213 @@ fn menu_action_variant(name: &str) -> syn::Result<TokenStream> {
"undo-selection" => quote! { Action::UndoSelection }, "undo-selection" => quote! { Action::UndoSelection },
"redo-selection" => quote! { Action::RedoSelection }, "redo-selection" => quote! { Action::RedoSelection },
_ => { _ => {
return Err(syn::Error::new( return Err(syn::Error::new(Span::call_site(), format!("unknown menu action: '{name}'")));
Span::call_site(),
format!("unknown menu action: '{name}'"),
));
} }
}; };
Ok(tokens) Ok(tokens)
} }
// ---------------------------------------------------------------------------
// IPC
// ---------------------------------------------------------------------------
/// Generate an async IPC integration test: create a menu,
/// start an IPC server, connect via Unix socket, and send/
/// receive JSON commands.
fn gen_ipc(case: &TestCase, fixtures: &Fixtures) -> syn::Result<TokenStream> {
let test_name = &case.name;
let test_name_str = test_name.to_string();
let item_exprs = gen_item_constructors(fixtures);
let label_key = fixtures.label_key.as_deref().unwrap_or("label");
let multi_setup = if fixtures.multi == Some(true) {
quote! { menu.set_multi(true); }
} else {
quote! {}
};
let has_expect = case.actions.iter().any(|a| matches!(a, ActionExpr::IpcExpect(_)));
let has_result = case.selected.is_some() || case.selected_items.is_some() || case.cancelled;
if !has_expect && !has_result {
return Err(syn::Error::new(
case.name.span(),
format!(
"ipc test '{}' has no assertions (expected ipc-expect, selected, selected_items, or cancelled)",
case.name
),
));
}
let ipc_actions = gen_ipc_actions(&case.actions)?;
let result_assert = if has_result {
gen_result_assert(case, label_key)?
} else {
quote! { let _ = result; }
};
Ok(quote! {
#[tokio::test]
async fn #test_name() {
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::net::UnixStream;
use std::time::Duration;
let items = vec![#(#item_exprs),*];
let (mut menu, tx) = MenuRunner::new(JsonMenu::new(items, #label_key.to_string()));
#multi_setup
let event_sender = menu.event_sender();
let ipc_event_rx = menu.subscribe();
let handle = tokio::spawn(async move { menu.run().await });
let _ = tx.send(ModifiedAction::new(Action::Resize { height: 50 })).await;
tokio::time::sleep(Duration::from_millis(30)).await;
// Start IPC server on a temp socket
let socket_path = std::env::temp_dir().join(
format!("pikl-ipc-{}-{}.sock", std::process::id(), #test_name_str)
);
let _ = std::fs::remove_file(&socket_path);
let ipc_server = IpcServer::new(
socket_path.clone(), tx.clone(), event_sender,
);
assert!(ipc_server.start(ipc_event_rx).is_ok(), "failed to start IPC server");
tokio::time::sleep(Duration::from_millis(10)).await;
let stream = match UnixStream::connect(&socket_path).await {
Ok(s) => s,
Err(e) => {
drop(tx);
drop(ipc_server);
let _ = handle.await;
assert!(false, "failed to connect to IPC socket: {e}");
return;
}
};
let (read_half, write_half) = stream.into_split();
let mut reader = BufReader::new(read_half);
let mut writer = write_half;
#(#ipc_actions)*
drop(writer);
drop(tx);
drop(ipc_server);
let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled { modifiers: pikl_core::event::Modifiers::default() }));
#result_assert
}
})
}
fn gen_ipc_actions(actions: &[ActionExpr]) -> syn::Result<Vec<TokenStream>> {
let mut stmts = Vec::new();
for action in actions {
match action {
ActionExpr::IpcSend(line) => {
stmts.push(quote! {
assert!(writer.write_all(#line.as_bytes()).await.is_ok(), "ipc write failed");
assert!(writer.write_all(b"\n").await.is_ok(), "ipc write newline failed");
assert!(writer.flush().await.is_ok(), "ipc flush failed");
tokio::time::sleep(Duration::from_millis(30)).await;
});
}
ActionExpr::IpcExpect(expected) => {
stmts.push(quote! {
{
let mut __resp = String::new();
let __read = tokio::time::timeout(
Duration::from_secs(5),
reader.read_line(&mut __resp)
).await;
assert!(__read.is_ok(), "IPC response timed out after 5s");
let __n = __read.unwrap_or(Ok(0)).unwrap_or(0);
assert!(__n > 0, "IPC connection closed before response");
assert!(
__resp.contains(#expected),
"expected IPC response to contain {:?}, got: {}",
#expected, __resp.trim()
);
}
});
}
_ => {
return Err(syn::Error::new(
Span::call_site(),
format!("ipc tests only support ipc-send and ipc-expect actions, got: {:?}", action_debug(action)),
));
}
}
}
Ok(stmts)
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Shared helpers // Shared helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
/// Generate `Item::from_plain_text("...")` expressions for
/// each item in the fixtures.
fn gen_item_constructors(fixtures: &Fixtures) -> Vec<TokenStream> { fn gen_item_constructors(fixtures: &Fixtures) -> Vec<TokenStream> {
match &fixtures.items { match &fixtures.items {
Some(items) => items Some(items) => items.iter().map(|s| quote! { Item::from_plain_text(#s) }).collect(),
.iter()
.map(|s| quote! { Item::from_plain_text(#s) })
.collect(),
None => Vec::new(), None => Vec::new(),
} }
} }
/// Format an action expression for use in error messages.
fn action_debug(action: &ActionExpr) -> String { fn action_debug(action: &ActionExpr) -> String {
match action { match action {
ActionExpr::Simple(name) => name.clone(), ActionExpr::Simple(name) => name.clone(),
ActionExpr::Filter(q) => format!("filter \"{q}\""), ActionExpr::Filter(q) => format!("filter \"{q}\""),
ActionExpr::Raw(r) => format!("raw \"{r}\""), ActionExpr::Raw(r) => format!("raw \"{r}\""),
ActionExpr::AddItems(items) => format!("add-items {:?}", items), ActionExpr::AddItems(items) => format!("add-items {:?}", items),
ActionExpr::IpcSend(s) => format!("ipc-send \"{s}\""),
ActionExpr::IpcExpect(s) => format!("ipc-expect \"{s}\""),
}
}
/// Generate the result assertion for menu/ipc tests.
fn gen_result_assert(case: &TestCase, label_key: &str) -> syn::Result<TokenStream> {
if case.cancelled {
Ok(quote! {
assert!(
matches!(result, Ok(MenuResult::Cancelled { .. })),
"expected Cancelled, got: {:?}", result.as_ref().map(|r| format!("{:?}", r))
);
})
} else if let Some(ref expected) = case.selected {
Ok(quote! {
match &result {
Ok(MenuResult::Selected { items, .. }) => {
assert!(!items.is_empty(), "expected Selected with items, got empty");
let value = &items[0].0;
let got = value.as_str()
.or_else(|| value.get(#label_key).and_then(|v| v.as_str()))
.unwrap_or("");
assert_eq!(got, #expected, "expected selected {:?}, got value: {:?}", #expected, value);
}
other => panic!("expected Selected, got: {:?}", other),
}
})
} else if let Some(ref expected) = case.selected_items {
Ok(quote! {
match &result {
Ok(MenuResult::Selected { items, .. }) => {
let mut labels: Vec<&str> = items.iter()
.filter_map(|(v, _)| v.as_str()
.or_else(|| v.get(#label_key).and_then(|v| v.as_str())))
.collect();
labels.sort();
let mut expected = vec![#(#expected),*];
expected.sort();
assert_eq!(labels, expected, "expected selected_items {:?}, got {:?}", expected, labels);
}
other => panic!("expected Selected, got: {:?}", other),
}
})
} else {
Err(syn::Error::new(
case.name.span(),
format!("test '{}' has no result assertion (expected selected, selected_items, or cancelled)", case.name),
))
} }
} }

View File

@@ -25,6 +25,7 @@ pub enum TestKind {
Filter, Filter,
Nav, Nav,
Menu, Menu,
Ipc,
} }
pub struct Fixtures { pub struct Fixtures {
@@ -58,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),
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -300,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)),
} }
} }
@@ -317,9 +330,10 @@ fn parse_kind(input: ParseStream) -> syn::Result<TestKind> {
"filter" => Ok(TestKind::Filter), "filter" => Ok(TestKind::Filter),
"nav" => Ok(TestKind::Nav), "nav" => Ok(TestKind::Nav),
"menu" => Ok(TestKind::Menu), "menu" => Ok(TestKind::Menu),
"ipc" => Ok(TestKind::Ipc),
other => Err(syn::Error::new( other => Err(syn::Error::new(
ident.span(), ident.span(),
format!("unknown test kind '{other}', expected headless, filter, nav, or menu"), format!("unknown test kind '{other}', expected headless, filter, nav, menu, or ipc"),
)), )),
} }
} }

View File

@@ -14,7 +14,7 @@ use ratatui::text::{Line, Span};
use ratatui::widgets::{List, ListItem, Paragraph}; use ratatui::widgets::{List, ListItem, Paragraph};
use tokio::sync::{broadcast, mpsc}; use tokio::sync::{broadcast, mpsc};
use pikl_core::event::{Action, MenuEvent, Mode, ViewState}; use pikl_core::event::{Action, MenuEvent, ModifiedAction, Modifiers, Mode, ViewState};
/// Pending key state for multi-key sequences (e.g. `gg`). /// Pending key state for multi-key sequences (e.g. `gg`).
/// TUI-local, not part of core state. /// TUI-local, not part of core state.
@@ -32,13 +32,23 @@ pub fn restore_terminal() {
let _ = crossterm::execute!(std::io::stderr(), crossterm::terminal::LeaveAlternateScreen); let _ = crossterm::execute!(std::io::stderr(), crossterm::terminal::LeaveAlternateScreen);
} }
/// Convert crossterm key modifiers to pikl Modifiers.
fn crossterm_modifiers(km: KeyModifiers) -> Modifiers {
Modifiers {
shift: km.contains(KeyModifiers::SHIFT),
ctrl: km.contains(KeyModifiers::CONTROL),
alt: km.contains(KeyModifiers::ALT),
}
}
/// Start the TUI. Enters the alternate screen, runs the /// Start the TUI. Enters the alternate screen, runs the
/// event loop, and restores the terminal on exit. Translates /// event loop, and restores the terminal on exit. Translates
/// crossterm key events into [`Action`]s and renders /// crossterm key events into [`Action`]s and renders
/// [`ViewState`] snapshots. /// [`ViewState`] snapshots.
pub async fn run( pub async fn run(
action_tx: mpsc::Sender<Action>, action_tx: mpsc::Sender<ModifiedAction>,
mut event_rx: broadcast::Receiver<MenuEvent>, mut event_rx: broadcast::Receiver<MenuEvent>,
filter_history: Option<Vec<String>>,
) -> std::io::Result<()> { ) -> std::io::Result<()> {
crossterm::terminal::enable_raw_mode()?; crossterm::terminal::enable_raw_mode()?;
crossterm::execute!(std::io::stderr(), crossterm::terminal::EnterAlternateScreen,)?; crossterm::execute!(std::io::stderr(), crossterm::terminal::EnterAlternateScreen,)?;
@@ -53,7 +63,7 @@ pub async fn run(
let _ = crossterm::event::read()?; let _ = crossterm::event::read()?;
} }
let result = run_inner(&action_tx, &mut event_rx, &mut terminal).await; let result = run_inner(&action_tx, &mut event_rx, &mut terminal, filter_history).await;
// Always clean up terminal, even on error // Always clean up terminal, even on error
restore_terminal(); restore_terminal();
@@ -64,17 +74,18 @@ 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);
if action_tx if action_tx
.send(Action::Resize { .send(ModifiedAction::new(Action::Resize {
height: list_height, height: list_height,
}) }))
.await .await
.is_err() .is_err()
{ {
@@ -95,6 +106,11 @@ async fn run_inner(
let mut multi_enabled = false; let mut multi_enabled = false;
let mut visual_anchor: Option<usize> = None; 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();
@@ -112,6 +128,62 @@ async fn run_inner(
let event = event_result?; let event = event_result?;
match event { match event {
Event::Key(key) => { Event::Key(key) => {
// 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) // Handle multi-action keys (Tab, Space in normal/visual)
// before the single-action map_key_event path. // before the single-action map_key_event path.
let handled = handle_multi_action_key( let handled = handle_multi_action_key(
@@ -133,14 +205,14 @@ async fn run_inner(
mode = *m; mode = *m;
pending = PendingKey::None; pending = PendingKey::None;
} }
if action_tx.send(action).await.is_err() { if action_tx.send(ModifiedAction::with_modifiers(action, crossterm_modifiers(key.modifiers))).await.is_err() {
break; // core is gone break; // core is gone
} }
} }
} }
Event::Resize(_, h) => { Event::Resize(_, h) => {
let list_height = h.saturating_sub(1); let list_height = h.saturating_sub(1);
if action_tx.send(Action::Resize { height: list_height }).await.is_err() { if action_tx.send(ModifiedAction::new(Action::Resize { height: list_height })).await.is_err() {
break; break;
} }
} }
@@ -196,7 +268,7 @@ async fn handle_multi_action_key(
multi_enabled: bool, multi_enabled: bool,
visual_anchor: &mut Option<usize>, visual_anchor: &mut Option<usize>,
view_state: &Option<ViewState>, view_state: &Option<ViewState>,
action_tx: &mpsc::Sender<Action>, action_tx: &mpsc::Sender<ModifiedAction>,
) -> bool { ) -> bool {
match mode { match mode {
Mode::Insert => { Mode::Insert => {
@@ -205,13 +277,15 @@ async fn handle_multi_action_key(
} }
match key.code { match key.code {
KeyCode::Tab => { KeyCode::Tab => {
let _ = action_tx.send(Action::ToggleSelect).await; let mods = crossterm_modifiers(key.modifiers);
let _ = action_tx.send(Action::MoveDown(1)).await; let _ = action_tx.send(ModifiedAction::with_modifiers(Action::ToggleSelect, mods)).await;
let _ = action_tx.send(ModifiedAction::with_modifiers(Action::MoveDown(1), mods)).await;
true true
} }
KeyCode::BackTab => { KeyCode::BackTab => {
let _ = action_tx.send(Action::ToggleSelect).await; let mods = crossterm_modifiers(key.modifiers);
let _ = action_tx.send(Action::MoveUp(1)).await; let _ = action_tx.send(ModifiedAction::with_modifiers(Action::ToggleSelect, mods)).await;
let _ = action_tx.send(ModifiedAction::with_modifiers(Action::MoveUp(1), mods)).await;
true true
} }
_ => false, _ => false,
@@ -225,18 +299,21 @@ async fn handle_multi_action_key(
(KeyCode::Char(' '), m) (KeyCode::Char(' '), m)
if !m.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => if !m.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) =>
{ {
let _ = action_tx.send(Action::ToggleSelect).await; let mods = crossterm_modifiers(key.modifiers);
let _ = action_tx.send(Action::MoveDown(1)).await; let _ = action_tx.send(ModifiedAction::with_modifiers(Action::ToggleSelect, mods)).await;
let _ = action_tx.send(ModifiedAction::with_modifiers(Action::MoveDown(1), mods)).await;
true true
} }
(KeyCode::Tab, _) => { (KeyCode::Tab, _) => {
let _ = action_tx.send(Action::ToggleSelect).await; let mods = crossterm_modifiers(key.modifiers);
let _ = action_tx.send(Action::MoveDown(1)).await; let _ = action_tx.send(ModifiedAction::with_modifiers(Action::ToggleSelect, mods)).await;
let _ = action_tx.send(ModifiedAction::with_modifiers(Action::MoveDown(1), mods)).await;
true true
} }
(KeyCode::BackTab, _) => { (KeyCode::BackTab, _) => {
let _ = action_tx.send(Action::ToggleSelect).await; let mods = crossterm_modifiers(key.modifiers);
let _ = action_tx.send(Action::MoveUp(1)).await; let _ = action_tx.send(ModifiedAction::with_modifiers(Action::ToggleSelect, mods)).await;
let _ = action_tx.send(ModifiedAction::with_modifiers(Action::MoveUp(1), mods)).await;
true true
} }
_ => false, _ => false,
@@ -247,18 +324,19 @@ async fn handle_multi_action_key(
(KeyCode::Char(' '), m) | (KeyCode::Enter, m) (KeyCode::Char(' '), m) | (KeyCode::Enter, m)
if !m.intersects(KeyModifiers::ALT) => if !m.intersects(KeyModifiers::ALT) =>
{ {
let mods = crossterm_modifiers(key.modifiers);
// Apply visual selection // Apply visual selection
if let (Some(anchor), Some(vs)) = (*visual_anchor, view_state) { if let (Some(anchor), Some(vs)) = (*visual_anchor, view_state) {
let cursor = vs.cursor; let cursor = vs.cursor;
let _ = action_tx let _ = action_tx
.send(Action::SelectRange { .send(ModifiedAction::with_modifiers(Action::SelectRange {
start: anchor, start: anchor,
end: cursor, end: cursor,
}) }, mods))
.await; .await;
} }
*visual_anchor = None; *visual_anchor = None;
let _ = action_tx.send(Action::SetMode(Mode::Normal)).await; let _ = action_tx.send(ModifiedAction::with_modifiers(Action::SetMode(Mode::Normal), mods)).await;
true true
} }
_ => false, _ => false,
@@ -311,7 +389,10 @@ fn render_menu(
), ),
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),
), ),
]; ];
@@ -628,6 +709,7 @@ mod tests {
mode: Mode::Insert, mode: Mode::Insert,
selection_count: 0, selection_count: 0,
multi_enabled: false, multi_enabled: false,
streaming: false,
columns: None, columns: None,
generation: 1, generation: 1,
} }
@@ -1103,6 +1185,7 @@ mod tests {
mode: Mode::Insert, mode: Mode::Insert,
selection_count: 0, selection_count: 0,
multi_enabled: false, multi_enabled: false,
streaming: false,
columns: None, columns: None,
generation: 1, generation: 1,
}; };
@@ -1112,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();
@@ -1206,6 +1312,7 @@ mod tests {
mode: Mode::Insert, mode: Mode::Insert,
selection_count: 0, selection_count: 0,
multi_enabled: false, multi_enabled: false,
streaming: false,
columns: Some(vec![ columns: Some(vec![
ColumnHeader { ColumnHeader {
display_name: "name".into(), display_name: "name".into(),

View File

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

View File

@@ -11,7 +11,7 @@ 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::{HookEvent, HookEventKind, HookHandler, parse_hook_response}; use pikl_core::hook::{HookEvent, HookEventKind, HookHandler, parse_hook_response};
/// A persistent handler hook process. Spawns a child process, /// A persistent handler hook process. Spawns a child process,
@@ -25,7 +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(cli: &crate::Cli, action_tx: mpsc::Sender<Action>) -> Option<Self> { pub fn from_cli(cli: &crate::Cli, action_tx: mpsc::Sender<ModifiedAction>) -> Option<Self> {
let mut handlers: Vec<(HookEventKind, &str)> = Vec::new(); let mut handlers: Vec<(HookEventKind, &str)> = Vec::new();
if let Some(ref cmd) = cli.on_open { if let Some(ref cmd) = cli.on_open {
@@ -91,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")
@@ -120,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;
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,7 @@
mod handler; mod handler;
mod hook; mod hook;
mod ipc;
mod session;
use std::io::{BufReader, IsTerminal, Write}; use std::io::{BufReader, IsTerminal, Write};
use std::sync::Arc; use std::sync::Arc;
@@ -11,10 +13,10 @@ use pikl_core::column::ColumnConfig;
use pikl_core::csv_input::{self, InputFormat}; 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;
@@ -25,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",
@@ -137,6 +172,18 @@ struct Cli {
/// Columns to display in table mode (e.g. "label,meta.age:Age") /// Columns to display in table mode (e.g. "label,meta.age:Age")
#[arg(long, value_name = "COLS")] #[arg(long, value_name = "COLS")]
columns: Option<String>, 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() {
@@ -191,10 +238,6 @@ fn main() {
std::process::exit(2); std::process::exit(2);
} }
let timeout = cli
.stdin_timeout
.unwrap_or(if script.is_some() { 30 } else { 0 });
let input_format = match InputFormat::from_str_opt(&cli.input_format) { let input_format = match InputFormat::from_str_opt(&cli.input_format) {
Some(f) => f, Some(f) => f,
None => { None => {
@@ -207,8 +250,39 @@ fn main() {
} }
}; };
let (items, csv_headers) = match read_stdin_with_timeout(timeout, &cli.label_key, input_format) // Detect streaming mode: piped stdin, no action-fd, not CSV/TSV.
{ let streaming = script.is_none()
&& !std::io::stdin().is_terminal()
&& !matches!(input_format, InputFormat::Csv | InputFormat::Tsv);
// Save the original stdin fd for the streaming reader before
// we dup2 /dev/tty onto fd 0.
#[cfg(unix)]
let saved_stdin_fd: Option<i32> = if streaming {
let fd = unsafe { libc::dup(libc::STDIN_FILENO) };
if fd < 0 {
let _ = writeln!(
std::io::stderr().lock(),
"pikl: failed to dup stdin: {}",
std::io::Error::last_os_error()
);
std::process::exit(2);
}
Some(fd)
} else {
None
};
let (items, csv_headers) = if streaming {
// Streaming mode: start with empty items, read in background later.
(Vec::new(), None)
} else {
let timeout = cli
.stdin_timeout
.unwrap_or(if script.is_some() { 30 } else { 0 });
let (items, csv_headers) =
match read_stdin_with_timeout(timeout, &cli.label_key, input_format) {
Ok(r) => r, Ok(r) => r,
Err(e) => { Err(e) => {
let _ = writeln!(std::io::stderr().lock(), "pikl: {e}"); let _ = writeln!(std::io::stderr().lock(), "pikl: {e}");
@@ -223,6 +297,8 @@ fn main() {
); );
std::process::exit(2); std::process::exit(2);
} }
(items, csv_headers)
};
// Resolve column config: explicit --columns, or auto-generate from CSV headers // Resolve column config: explicit --columns, or auto-generate from CSV headers
let column_config = if let Some(ref cols) = cli.columns { let column_config = if let Some(ref cols) = cli.columns {
@@ -240,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}");
@@ -262,13 +353,57 @@ fn main() {
} }
}; };
// 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, column_config)) 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, column_config)) 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);
} }
@@ -292,7 +427,7 @@ fn build_menu(items: Vec<Item>, cli: &Cli, column_config: Option<ColumnConfig>)
/// 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());
@@ -385,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());
@@ -398,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())))??;
@@ -422,11 +559,16 @@ async fn run_interactive(
cli: &Cli, cli: &Cli,
start_mode: Mode, start_mode: Mode,
column_config: Option<ColumnConfig>, 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, column_config)); let (mut menu, action_tx) = MenuRunner::new(build_menu(items, cli, column_config));
menu.set_initial_mode(start_mode); menu.set_initial_mode(start_mode);
menu.set_multi(cli.multi); menu.set_multi(cli.multi);
menu.set_selection_order(cli.selection_order); menu.set_selection_order(cli.selection_order);
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);
@@ -434,21 +576,249 @@ async fn run_interactive(
let event_rx = menu.subscribe(); let event_rx = menu.subscribe();
// Start IPC server if requested
let _ipc_server = if cli.ipc {
let sock_path = ipc::socket_path(cli.session.as_deref())?;
let server = ipc::server::IpcServer::new(
sock_path,
action_tx.clone(),
menu.event_sender(),
);
let ipc_event_rx = menu.subscribe();
server
.start(ipc_event_rx)
.map_err(PiklError::Io)?;
Some(server)
} else {
None
};
// Handle SIGINT/SIGTERM: restore terminal and exit cleanly. // Handle SIGINT/SIGTERM: restore terminal and exit cleanly.
let signal_tx = action_tx.clone(); let signal_tx = action_tx.clone();
let signal_frontend = frontend_mode;
tokio::spawn(async move { tokio::spawn(async move {
if let Ok(()) = tokio::signal::ctrl_c().await { match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
pikl_tui::restore_terminal(); Ok(mut sigterm) => {
let _ = signal_tx.send(Action::Cancel).await; tokio::select! {
_ = tokio::signal::ctrl_c() => {}
_ = sigterm.recv() => {}
} }
}
Err(e) => {
tracing::warn!(%e, "failed to register SIGTERM handler, falling back to SIGINT only");
let _ = tokio::signal::ctrl_c().await;
}
}
if signal_frontend == FrontendMode::Tui {
pikl_tui::restore_terminal();
}
let _ = signal_tx.send(ModifiedAction::new(Action::Cancel)).await;
}); });
let tui_handle = tokio::spawn(async move { pikl_tui::run(action_tx, event_rx).await }); // Spawn background stdin reader for streaming mode.
if let Some(fd) = saved_stdin_fd {
let stream_tx = action_tx.clone();
tokio::task::spawn_blocking(move || {
#[cfg(unix)]
{
use std::io::BufRead;
use std::os::unix::io::FromRawFd;
let 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
@@ -456,13 +826,16 @@ async fn run_interactive(
fn handle_result(result: Result<MenuResult, PiklError>, cli: &Cli) { fn handle_result(result: Result<MenuResult, PiklError>, cli: &Cli) {
let mut out = std::io::stdout().lock(); let mut out = std::io::stdout().lock();
match result { match result {
Ok(MenuResult::Selected { items }) => { Ok(MenuResult::Selected {
items, modifiers, ..
}) => {
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::Select, action: OutputAction::Select,
index, index,
modifiers,
}; };
let _ = write_output_json(&mut out, &output); let _ = write_output_json(&mut out, &output);
} }
@@ -472,13 +845,16 @@ fn handle_result(result: Result<MenuResult, PiklError>, cli: &Cli) {
} }
} }
} }
Ok(MenuResult::Quicklist { items }) => { Ok(MenuResult::Quicklist {
items, modifiers, ..
}) => {
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::Quicklist,
index, index,
modifiers,
}; };
let _ = write_output_json(&mut out, &output); let _ = write_output_json(&mut out, &output);
} }
@@ -488,12 +864,13 @@ fn handle_result(result: Result<MenuResult, PiklError>, cli: &Cli) {
} }
} }
} }
Ok(MenuResult::Cancelled) => { 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);
} }

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

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

View File

@@ -68,6 +68,96 @@ fn headless_actions_after_show_ui_exits_2() {
); );
} }
// -- 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 -- // -- CSV/TSV integration tests --
#[test] #[test]

View File

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

View File

@@ -48,6 +48,43 @@ metadata that came in, plus selection context:
{"label": "beach_sunset.jpg", "sublabel": "/home/maple/walls/nature/", "meta": {"size": "2.4MB"}, "action": "select", "index": 3} {"label": "beach_sunset.jpg", "sublabel": "/home/maple/walls/nature/", "meta": {"size": "2.4MB"}, "action": "select", "index": 3}
``` ```
When `--structured` is used, the output JSON includes a
`modifiers` field if any modifier keys (shift, ctrl, alt)
were held during confirmation. The value is an array of
strings:
```jsonl
{"label": "firefox", "action": "select", "index": 0, "modifiers": ["shift"]}
{"label": "alacritty", "action": "select", "index": 2, "modifiers": ["ctrl", "alt"]}
```
When no modifiers are held, the field is omitted entirely.
Plain text output is unchanged: modifiers only appear in
structured JSON.
### Working with Structured Output
Check for shift modifier with jq:
```sh
pikl --structured | jq 'if (.modifiers // [] | contains(["shift"])) then "alternate" else "default" end'
```
Extract a field:
```sh
pikl --structured | jq -r '.label'
```
Branch on modifiers in a script:
```sh
result=$(echo -e "a\nb\nc" | pikl --structured)
if echo "$result" | jq -e '.modifiers // [] | contains(["shift"])' > /dev/null 2>&1; then
echo "shift was held"
fi
```
### Streaming ### Streaming
Input can arrive over time. The list populates progressively Input can arrive over time. The list populates progressively
@@ -96,6 +133,12 @@ There are two ways to respond to them: **exec hooks** and
| `on-filter` | Filter text changes | Dynamic item reloading | | `on-filter` | Filter text changes | Dynamic item reloading |
| `on-toggle` | User toggles an item's selection | Visual feedback (deferred) | | `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)
`--on-<event>-exec` spawns a subprocess for each event. `--on-<event>-exec` spawns a subprocess for each event.
@@ -519,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, selections) 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:
@@ -534,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
@@ -552,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
@@ -644,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.
@@ -686,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
@@ -821,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:
@@ -848,16 +990,10 @@ with the full writeup.
## Open Questions ## Open Questions
- Should marks/registers persist across sessions or only
within a session?
- Accessibility: screen reader support for TUI mode? - Accessibility: screen reader support for TUI mode?
- Should `--watch` support inotify on Linux and FSEvents on - Should `--watch` support inotify on Linux and FSEvents on
macOS, or use `notify` crate to abstract? macOS, or use `notify` crate to abstract?
- Maximum practical item count before we need virtual - Maximum practical item count before we need virtual
scrolling? (Probably around 100k) scrolling? (Probably around 100k)
- Should hooks run in a pool or strictly sequential?
Resolved: exec hooks are one subprocess per event.
Handler hooks are persistent processes. Debounce and
cancel-stale manage concurrency.
- Plugin system via WASM for custom filter strategies? - Plugin system via WASM for custom filter strategies?
(Probably way later if ever) (Probably way later if ever)

View File

@@ -170,7 +170,7 @@ 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 ## Phase 4: Multi-Select
Selection with undo/redo. No marks or registers: filtering Selection with undo/redo. No marks or registers: filtering
already handles navigation, and selections surviving filter already handles navigation, and selections surviving filter
@@ -212,38 +212,93 @@ CSV/TSV input parsing and columnar table rendering.
**Done when:** `echo "name,age\nalice,30\nbob,25" | pikl --input-format csv` **Done when:** `echo "name,age\nalice,30\nbob,25" | pikl --input-format csv`
renders a navigable table with headers. 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
- Session history log file effects unless asked)
- Unix socket IPC while running - Socket path: `/run/user/$UID/pikl-{session}.sock`
- IPC commands: push/remove/update items, set filter, (named session) or `/run/user/$UID/pikl-{pid}.sock`
read selection, close (no session name)
- Protocol: newline-delimited JSON - Socket path logged via tracing on startup
- Cleanup on exit (normal, cancel, SIGTERM)
- Protocol: newline-delimited JSON, one message per line
- Write commands: `add_items`, `replace_items`,
`remove_items`, `set_filter`, `confirm`, `cancel`,
`close`, plus navigation actions (`move_up`,
`move_down`, `move_to_top`, `move_to_bottom`,
`page_up`, `page_down`, `toggle_select`, `select_all`,
`clear_selections`)
- Read commands: `get_state` (filter, cursor, counts,
mode), `get_selection` (selected items). Both require
an `id` field, response echoes it back.
- Event subscription: `subscribe` with event type list,
`unsubscribe` to stop. Subscribed events pushed as
`{"event": "...", ...}` lines.
- Multiple simultaneous client connections
- Auth: Unix socket permissions (user-only)
- IPC is just another frontend: deserializes JSON into
Actions, optionally subscribes to MenuEvent broadcast
**Done when:** You can close and reopen a session and find **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)
@@ -294,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
@@ -308,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
@@ -357,3 +416,4 @@ commitment, no order.
to run per-keystroke" problem for the app launcher. to run per-keystroke" problem for the app launcher.
Could be a `pikl index` subcommand or a standalone Could be a `pikl index` subcommand or a standalone
helper script. helper script.
- Need vars in hooks like `--on-hover 'echo $out'`.

View File

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

View File

@@ -379,6 +379,48 @@ Gentoo,rolling,openrc
CSV 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=(
@@ -411,6 +453,10 @@ scenarios=(
"CSV + multi-select" "CSV + multi-select"
"CSV + field filter" "CSV + field filter"
"---" "---"
"IPC remote control"
"Session filter history"
"Modifier keys (structured output)"
"---"
"on-select-exec hook (legacy)" "on-select-exec hook (legacy)"
) )
@@ -441,6 +487,9 @@ run_scenario() {
*"CSV with --columns"*) csv_columns_alias ;; *"CSV with --columns"*) csv_columns_alias ;;
*"CSV + multi"*) csv_multi_select ;; *"CSV + multi"*) csv_multi_select ;;
*"CSV + field"*) csv_filter ;; *"CSV + field"*) csv_filter ;;
*"IPC remote"*) ipc_demo ;;
*"Session filter"*) session_demo ;;
*"Modifier keys"*) modifier_keys_demo ;;
*"on-select-exec"*) on_select_hook ;; *"on-select-exec"*) on_select_hook ;;
"---") "---")
echo "that's a separator, not a scenario" >&2 echo "that's a separator, not a scenario" >&2

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

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

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

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