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 = [
"crates/pikl-core",
"crates/pikl-tui",
"crates/pikl-gui",
"crates/pikl",
"crates/pikl-test-macros",
]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,7 +11,10 @@ use tracing::{debug, info, trace};
use crate::debounce::{DebouncedDispatcher, hook_response_to_action};
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::model::traits::MutableMenu;
use crate::navigation::Viewport;
@@ -147,13 +150,15 @@ pub struct MenuRunner<M: MutableMenu> {
viewport: Viewport,
filter_text: Arc<str>,
mode: Mode,
action_rx: mpsc::Receiver<Action>,
action_rx: mpsc::Receiver<ModifiedAction>,
event_tx: broadcast::Sender<MenuEvent>,
dispatcher: Option<DebouncedDispatcher>,
previous_cursor: Option<usize>,
generation: u64,
selection: SelectionState,
visual_anchor: Option<usize>,
streaming: bool,
last_modifiers: Modifiers,
}
impl<M: MutableMenu> MenuRunner<M> {
@@ -161,7 +166,7 @@ impl<M: MutableMenu> MenuRunner<M> {
/// Returns the runner and an action sender. Call
/// [`subscribe`](Self::subscribe) to get an event handle,
/// 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);
// 1024 slots: large enough that a burst of rapid state changes
// (e.g. streaming AddItems + filter updates) won't cause lag for
@@ -180,6 +185,8 @@ impl<M: MutableMenu> MenuRunner<M> {
generation: 0,
selection: SelectionState::new(),
visual_anchor: None,
streaming: false,
last_modifiers: Modifiers::default(),
};
(runner, action_tx)
}
@@ -190,13 +197,19 @@ impl<M: MutableMenu> MenuRunner<M> {
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
/// with no debounce (all events fire immediately). Use
/// [`set_dispatcher`] for custom debounce settings.
pub fn set_hook_handler(
&mut self,
handler: Arc<dyn HookHandler>,
action_tx: mpsc::Sender<Action>,
action_tx: mpsc::Sender<ModifiedAction>,
) {
let dispatcher = DebouncedDispatcher::new(handler, action_tx);
self.dispatcher = Some(dispatcher);
@@ -306,6 +319,7 @@ impl<M: MutableMenu> MenuRunner<M> {
mode: self.mode,
selection_count: self.selection.count(),
multi_enabled: self.selection.multi_enabled,
streaming: self.streaming,
columns,
generation: self.generation,
}
@@ -348,6 +362,7 @@ impl<M: MutableMenu> MenuRunner<M> {
self.emit_hook(HookEvent::Hover {
item: value,
index: orig_idx,
modifiers: self.last_modifiers,
});
}
}
@@ -551,6 +566,11 @@ impl<M: MutableMenu> MenuRunner<M> {
self.apply_action(action)
}
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;
}
/// 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
/// broadcasts events.
///
@@ -585,7 +610,9 @@ impl<M: MutableMenu> MenuRunner<M> {
// Emit Open event
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(_));
match self.apply_action(action) {
@@ -595,7 +622,10 @@ impl<M: MutableMenu> MenuRunner<M> {
// Emit Filter event if the filter changed
if is_filter_update {
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
@@ -609,13 +639,18 @@ impl<M: MutableMenu> MenuRunner<M> {
self.emit_hook(HookEvent::Select {
item: value.clone(),
index: *index,
modifiers: self.last_modifiers,
});
}
// Emit Close event
self.emit_hook(HookEvent::Close);
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 } => {
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 {
items: values.clone(),
count,
modifiers: self.last_modifiers,
});
self.emit_hook(HookEvent::Close);
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 => {
info!("menu cancelled");
self.emit_hook(HookEvent::Cancel);
self.emit_hook(HookEvent::Cancel {
modifiers: self.last_modifiers,
});
self.emit_hook(HookEvent::Close);
let _ = self.event_tx.send(MenuEvent::Cancelled);
return Ok(MenuResult::Cancelled);
return Ok(MenuResult::Cancelled {
modifiers: self.last_modifiers,
});
}
ActionOutcome::Closed => {
info!("menu closed by hook");
self.emit_hook(HookEvent::Close);
let _ = self.event_tx.send(MenuEvent::Cancelled);
return Ok(MenuResult::Cancelled);
return Ok(MenuResult::Cancelled {
modifiers: Modifiers::default(),
});
}
ActionOutcome::NoOp => {}
}
@@ -651,7 +697,9 @@ impl<M: MutableMenu> MenuRunner<M> {
// Sender dropped
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::runtime::json_menu::JsonMenu;
fn test_menu() -> (MenuRunner<JsonMenu>, mpsc::Sender<Action>) {
fn test_menu() -> (MenuRunner<JsonMenu>, mpsc::Sender<ModifiedAction>) {
let items = vec![
Item::from_plain_text("alpha"),
Item::from_plain_text("beta"),
@@ -793,9 +841,9 @@ mod tests {
}
// Cancel to exit
let _ = tx.send(Action::Cancel).await;
let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled));
assert!(matches!(result, Ok(MenuResult::Cancelled)));
let _ = tx.send(ModifiedAction::new(Action::Cancel)).await;
let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled { modifiers: Modifiers::default() }));
assert!(matches!(result, Ok(MenuResult::Cancelled { .. })));
}
#[tokio::test]
@@ -809,7 +857,7 @@ mod tests {
let _ = rx.recv().await;
// 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 {
assert_eq!(vs.total_items, 4);
// alpha should match "al"
@@ -817,7 +865,7 @@ mod tests {
assert_eq!(&*vs.filter_text, "al");
}
let _ = tx.send(Action::Cancel).await;
let _ = tx.send(ModifiedAction::new(Action::Cancel)).await;
let _ = handle.await;
}
@@ -832,20 +880,20 @@ mod tests {
let _ = rx.recv().await;
// 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;
// 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 _ = tx.send(Action::Confirm).await;
let _ = tx.send(ModifiedAction::new(Action::Confirm)).await;
// Should get Selected event
if let Ok(MenuEvent::Selected(items)) = rx.recv().await {
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 { .. })));
}
@@ -859,18 +907,18 @@ mod tests {
let _ = rx.recv().await; // initial
// 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 {
assert_eq!(vs.total_filtered, 0);
}
// 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)
let _ = tx.send(Action::Cancel).await;
let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled));
assert!(matches!(result, Ok(MenuResult::Cancelled)));
let _ = tx.send(ModifiedAction::new(Action::Cancel)).await;
let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled { modifiers: Modifiers::default() }));
assert!(matches!(result, Ok(MenuResult::Cancelled { .. })));
}
#[tokio::test]
@@ -883,8 +931,8 @@ mod tests {
// Drop the only sender.
drop(tx);
let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled));
assert!(matches!(result, Ok(MenuResult::Cancelled)));
let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled { modifiers: Modifiers::default() }));
assert!(matches!(result, Ok(MenuResult::Cancelled { .. })));
}
// -- End-to-end output correctness --
@@ -897,18 +945,18 @@ mod tests {
let handle = tokio::spawn(async move { menu.run().await });
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;
// 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;
assert!(
matches!(&event, Ok(MenuEvent::Selected(items)) if items[0].0.as_str() == Some("alpha"))
);
let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled));
let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled { modifiers: Modifiers::default() }));
assert!(
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 _ = 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;
// 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 _ = tx.send(Action::MoveDown(1)).await;
let _ = tx.send(ModifiedAction::new(Action::MoveDown(1))).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!(
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 _ = 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;
// 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 {
assert!(vs.total_filtered >= 1);
assert_eq!(vs.visible_items[0].label, "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!(
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 _ = 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;
// Add a new item
let _ = tx
.send(Action::AddItems(vec![serde_json::Value::String(
"epsilon".to_string(),
)]))
.send(ModifiedAction::new(Action::AddItems(vec![
serde_json::Value::String("epsilon".to_string()),
])))
.await;
if let Ok(MenuEvent::StateChanged(vs)) = rx.recv().await {
assert_eq!(vs.total_items, 5);
@@ -988,15 +1036,15 @@ mod tests {
}
// 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 {
assert!(vs.total_filtered >= 1);
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!(
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 _ = tx.send(Action::Cancel).await;
let _ = tx.send(ModifiedAction::new(Action::Cancel)).await;
// Should get Cancelled event
assert!(matches!(rx.recv().await, Ok(MenuEvent::Cancelled)));
let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled));
assert!(matches!(result, Ok(MenuResult::Cancelled)));
let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled { modifiers: Modifiers::default() }));
assert!(matches!(result, Ok(MenuResult::Cancelled { .. })));
}
// -- Ordering invariant tests --
@@ -1040,14 +1088,14 @@ mod tests {
let handle = tokio::spawn(async move { menu.run().await });
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;
// Back-to-back, no waiting between these
let _ = tx.send(Action::UpdateFilter("ban".to_string())).await;
let _ = tx.send(Action::Confirm).await;
let _ = tx.send(ModifiedAction::new(Action::UpdateFilter("ban".to_string()))).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.
assert!(matches!(
result,
@@ -1064,16 +1112,16 @@ mod tests {
let handle = tokio::spawn(async move { menu.run().await });
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;
// Three moves down back-to-back, then confirm
let _ = tx.send(Action::MoveDown(1)).await;
let _ = tx.send(Action::MoveDown(1)).await;
let _ = tx.send(Action::MoveDown(1)).await;
let _ = tx.send(Action::Confirm).await;
let _ = tx.send(ModifiedAction::new(Action::MoveDown(1))).await;
let _ = tx.send(ModifiedAction::new(Action::MoveDown(1))).await;
let _ = tx.send(ModifiedAction::new(Action::MoveDown(1))).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"
assert!(matches!(
result,
@@ -1157,19 +1205,19 @@ mod tests {
let handle = tokio::spawn(async move { menu.run().await });
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;
// All back-to-back
let _ = tx
.send(Action::AddItems(vec![serde_json::Value::String(
"zephyr".to_string(),
)]))
.send(ModifiedAction::new(Action::AddItems(vec![
serde_json::Value::String("zephyr".to_string()),
])))
.await;
let _ = tx.send(Action::UpdateFilter("zep".to_string())).await;
let _ = tx.send(Action::Confirm).await;
let _ = tx.send(ModifiedAction::new(Action::UpdateFilter("zep".to_string()))).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.
assert!(matches!(
result,
@@ -1328,10 +1376,10 @@ mod tests {
// Skip initial state
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 _ = tx.send(Action::Quicklist).await;
let _ = tx.send(ModifiedAction::new(Action::Quicklist)).await;
if let Ok(MenuEvent::Quicklist(values)) = rx.recv().await {
assert_eq!(values.len(), 4);
@@ -1339,7 +1387,7 @@ mod tests {
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 { .. })));
}
@@ -1351,16 +1399,16 @@ mod tests {
let handle = tokio::spawn(async move { menu.run().await });
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;
// Filter then quicklist back-to-back
let _ = tx.send(Action::UpdateFilter("al".to_string())).await;
let _ = tx.send(Action::Quicklist).await;
let _ = tx.send(ModifiedAction::new(Action::UpdateFilter("al".to_string()))).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 {
Ok(MenuResult::Quicklist { items }) => {
Ok(MenuResult::Quicklist { items, .. }) => {
// "alpha" matches "al"
assert!(!items.is_empty());
for (v, _) in &items {
@@ -1404,6 +1452,7 @@ mod tests {
m.apply_action(Action::UpdateFilter("al".to_string()));
m.emit_hook(HookEvent::Filter {
text: "al".to_string(),
modifiers: Modifiers::default(),
});
m.apply_action(Action::MoveDown(1));
m.check_cursor_hover();
@@ -1417,6 +1466,142 @@ mod tests {
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 --
#[test]
@@ -1812,4 +1997,54 @@ mod tests {
// City "toronto" (7) vs header "City" (4): 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 crate::error::PiklError;
use crate::event::Action;
use crate::event::{Action, ModifiedAction, Modifiers};
pub use error::{ScriptError, ScriptErrorKind};
pub use parse::{load_script, parse_action};
@@ -22,7 +22,7 @@ pub use parse::{load_script, parse_action};
/// A parsed action from an action-fd script.
#[derive(Debug, Clone, PartialEq)]
pub enum ScriptAction {
Core(Action),
Core(Action, Modifiers),
ShowUi,
ShowTui,
ShowGui,
@@ -41,14 +41,14 @@ pub enum ShowAction {
/// Returns the Show* variant if the script ends with one, or None.
pub async fn run_script(
script: Vec<ScriptAction>,
tx: &mpsc::Sender<Action>,
tx: &mpsc::Sender<ModifiedAction>,
) -> Result<Option<ShowAction>, PiklError> {
let mut show = None;
for action in script {
match action {
ScriptAction::Core(action) => {
tx.send(action)
ScriptAction::Core(action, modifiers) => {
tx.send(ModifiedAction { action, modifiers })
.await
.map_err(|_| PiklError::ChannelClosed)?;
}
@@ -69,9 +69,9 @@ mod tests {
#[tokio::test]
async fn run_script_sends_actions_in_order() {
let script = vec![
ScriptAction::Core(Action::UpdateFilter("hello".to_string())),
ScriptAction::Core(Action::MoveDown(1)),
ScriptAction::Core(Action::Confirm),
ScriptAction::Core(Action::UpdateFilter("hello".to_string()), Modifiers::default()),
ScriptAction::Core(Action::MoveDown(1), Modifiers::default()),
ScriptAction::Core(Action::Confirm, Modifiers::default()),
];
let (tx, mut rx) = mpsc::channel(16);
let result = run_script(script, &tx).await;
@@ -79,14 +79,14 @@ mod tests {
assert_eq!(result.unwrap_or(Some(ShowAction::Ui)), None);
// Verify order
assert!(matches!(rx.recv().await, Some(Action::UpdateFilter(s)) if s == "hello"));
assert!(matches!(rx.recv().await, Some(Action::MoveDown(1))));
assert!(matches!(rx.recv().await, Some(Action::Confirm)));
assert!(matches!(rx.recv().await, Some(ModifiedAction { action: Action::UpdateFilter(s), .. }) if s == "hello"));
assert!(matches!(rx.recv().await, Some(ModifiedAction { action: Action::MoveDown(1), .. })));
assert!(matches!(rx.recv().await, Some(ModifiedAction { action: Action::Confirm, .. })));
}
#[tokio::test]
async fn run_script_returns_none_without_show() {
let script = vec![ScriptAction::Core(Action::Confirm)];
let script = vec![ScriptAction::Core(Action::Confirm, Modifiers::default())];
let (tx, _rx) = mpsc::channel(16);
let result = run_script(script, &tx).await;
assert!(result.is_ok());
@@ -96,7 +96,7 @@ mod tests {
#[tokio::test]
async fn run_script_returns_show_action() {
let script = vec![
ScriptAction::Core(Action::UpdateFilter("test".to_string())),
ScriptAction::Core(Action::UpdateFilter("test".to_string()), Modifiers::default()),
ScriptAction::ShowUi,
];
let (tx, _rx) = mpsc::channel(16);

View File

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

View File

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

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

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

View File

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

View File

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

View File

@@ -14,7 +14,7 @@ use ratatui::text::{Line, Span};
use ratatui::widgets::{List, ListItem, Paragraph};
use tokio::sync::{broadcast, mpsc};
use pikl_core::event::{Action, MenuEvent, Mode, ViewState};
use pikl_core::event::{Action, MenuEvent, ModifiedAction, Modifiers, Mode, ViewState};
/// Pending key state for multi-key sequences (e.g. `gg`).
/// TUI-local, not part of core state.
@@ -32,13 +32,23 @@ pub fn restore_terminal() {
let _ = crossterm::execute!(std::io::stderr(), crossterm::terminal::LeaveAlternateScreen);
}
/// Convert crossterm key modifiers to pikl Modifiers.
fn crossterm_modifiers(km: KeyModifiers) -> Modifiers {
Modifiers {
shift: km.contains(KeyModifiers::SHIFT),
ctrl: km.contains(KeyModifiers::CONTROL),
alt: km.contains(KeyModifiers::ALT),
}
}
/// Start the TUI. Enters the alternate screen, runs the
/// event loop, and restores the terminal on exit. Translates
/// crossterm key events into [`Action`]s and renders
/// [`ViewState`] snapshots.
pub async fn run(
action_tx: mpsc::Sender<Action>,
action_tx: mpsc::Sender<ModifiedAction>,
mut event_rx: broadcast::Receiver<MenuEvent>,
filter_history: Option<Vec<String>>,
) -> std::io::Result<()> {
crossterm::terminal::enable_raw_mode()?;
crossterm::execute!(std::io::stderr(), crossterm::terminal::EnterAlternateScreen,)?;
@@ -53,7 +63,7 @@ pub async fn run(
let _ = crossterm::event::read()?;
}
let result = run_inner(&action_tx, &mut event_rx, &mut terminal).await;
let result = run_inner(&action_tx, &mut event_rx, &mut terminal, filter_history).await;
// Always clean up terminal, even on error
restore_terminal();
@@ -64,17 +74,18 @@ pub async fn run(
/// Inner event loop. Separated from [`run`] so terminal
/// cleanup always happens even if this returns an error.
async fn run_inner(
action_tx: &mpsc::Sender<Action>,
action_tx: &mpsc::Sender<ModifiedAction>,
event_rx: &mut broadcast::Receiver<MenuEvent>,
terminal: &mut Terminal<CrosstermBackend<std::io::Stderr>>,
filter_history: Option<Vec<String>>,
) -> std::io::Result<()> {
// Send initial resize
let size = terminal.size()?;
let list_height = size.height.saturating_sub(1);
if action_tx
.send(Action::Resize {
.send(ModifiedAction::new(Action::Resize {
height: list_height,
})
}))
.await
.is_err()
{
@@ -95,6 +106,11 @@ async fn run_inner(
let mut multi_enabled = false;
let mut visual_anchor: Option<usize> = None;
// Session filter history cycling state
let history = filter_history.unwrap_or_default();
let mut history_cursor: Option<usize> = None;
let mut pre_history_text: Option<String> = None;
loop {
if let Some(ref vs) = view_state {
let ft = filter_text.clone();
@@ -112,6 +128,62 @@ async fn run_inner(
let event = event_result?;
match event {
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)
// before the single-action map_key_event path.
let handled = handle_multi_action_key(
@@ -133,14 +205,14 @@ async fn run_inner(
mode = *m;
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
}
}
}
Event::Resize(_, h) => {
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;
}
}
@@ -196,7 +268,7 @@ async fn handle_multi_action_key(
multi_enabled: bool,
visual_anchor: &mut Option<usize>,
view_state: &Option<ViewState>,
action_tx: &mpsc::Sender<Action>,
action_tx: &mpsc::Sender<ModifiedAction>,
) -> bool {
match mode {
Mode::Insert => {
@@ -205,13 +277,15 @@ async fn handle_multi_action_key(
}
match key.code {
KeyCode::Tab => {
let _ = action_tx.send(Action::ToggleSelect).await;
let _ = action_tx.send(Action::MoveDown(1)).await;
let mods = crossterm_modifiers(key.modifiers);
let _ = action_tx.send(ModifiedAction::with_modifiers(Action::ToggleSelect, mods)).await;
let _ = action_tx.send(ModifiedAction::with_modifiers(Action::MoveDown(1), mods)).await;
true
}
KeyCode::BackTab => {
let _ = action_tx.send(Action::ToggleSelect).await;
let _ = action_tx.send(Action::MoveUp(1)).await;
let mods = crossterm_modifiers(key.modifiers);
let _ = action_tx.send(ModifiedAction::with_modifiers(Action::ToggleSelect, mods)).await;
let _ = action_tx.send(ModifiedAction::with_modifiers(Action::MoveUp(1), mods)).await;
true
}
_ => false,
@@ -225,18 +299,21 @@ async fn handle_multi_action_key(
(KeyCode::Char(' '), m)
if !m.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) =>
{
let _ = action_tx.send(Action::ToggleSelect).await;
let _ = action_tx.send(Action::MoveDown(1)).await;
let mods = crossterm_modifiers(key.modifiers);
let _ = action_tx.send(ModifiedAction::with_modifiers(Action::ToggleSelect, mods)).await;
let _ = action_tx.send(ModifiedAction::with_modifiers(Action::MoveDown(1), mods)).await;
true
}
(KeyCode::Tab, _) => {
let _ = action_tx.send(Action::ToggleSelect).await;
let _ = action_tx.send(Action::MoveDown(1)).await;
let mods = crossterm_modifiers(key.modifiers);
let _ = action_tx.send(ModifiedAction::with_modifiers(Action::ToggleSelect, mods)).await;
let _ = action_tx.send(ModifiedAction::with_modifiers(Action::MoveDown(1), mods)).await;
true
}
(KeyCode::BackTab, _) => {
let _ = action_tx.send(Action::ToggleSelect).await;
let _ = action_tx.send(Action::MoveUp(1)).await;
let mods = crossterm_modifiers(key.modifiers);
let _ = action_tx.send(ModifiedAction::with_modifiers(Action::ToggleSelect, mods)).await;
let _ = action_tx.send(ModifiedAction::with_modifiers(Action::MoveUp(1), mods)).await;
true
}
_ => false,
@@ -247,18 +324,19 @@ async fn handle_multi_action_key(
(KeyCode::Char(' '), m) | (KeyCode::Enter, m)
if !m.intersects(KeyModifiers::ALT) =>
{
let mods = crossterm_modifiers(key.modifiers);
// Apply visual selection
if let (Some(anchor), Some(vs)) = (*visual_anchor, view_state) {
let cursor = vs.cursor;
let _ = action_tx
.send(Action::SelectRange {
.send(ModifiedAction::with_modifiers(Action::SelectRange {
start: anchor,
end: cursor,
})
}, mods))
.await;
}
*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
}
_ => false,
@@ -311,7 +389,10 @@ fn render_menu(
),
Span::raw(filter_text),
Span::styled(
format!(" {filtered_count}/{total_count}"),
format!(
" {filtered_count}/{total_count}{}",
if vs.streaming { "..." } else { "" }
),
Style::default().fg(Color::DarkGray),
),
];
@@ -628,6 +709,7 @@ mod tests {
mode: Mode::Insert,
selection_count: 0,
multi_enabled: false,
streaming: false,
columns: None,
generation: 1,
}
@@ -1103,6 +1185,7 @@ mod tests {
mode: Mode::Insert,
selection_count: 0,
multi_enabled: false,
streaming: false,
columns: None,
generation: 1,
};
@@ -1112,6 +1195,29 @@ mod tests {
assert!(prompt.contains("0/0"));
}
#[test]
fn streaming_indicator_shows_ellipsis() {
let mut vs = sample_view_state();
vs.streaming = true;
let backend = render_to_backend(40, 5, &vs, "");
let prompt = line_text(&backend, 0);
assert!(
prompt.contains("..."),
"streaming indicator should show '...': {prompt}"
);
}
#[test]
fn no_streaming_indicator_when_done() {
let vs = sample_view_state();
let backend = render_to_backend(40, 5, &vs, "");
let prompt = line_text(&backend, 0);
assert!(
!prompt.contains("..."),
"should not show '...' when not streaming: {prompt}"
);
}
#[test]
fn narrow_viewport_truncates() {
let vs = sample_view_state();
@@ -1206,6 +1312,7 @@ mod tests {
mode: Mode::Insert,
selection_count: 0,
multi_enabled: false,
streaming: false,
columns: Some(vec![
ColumnHeader {
display_name: "name".into(),

View File

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

View File

@@ -11,7 +11,7 @@ use tokio::process::Command;
use tokio::sync::mpsc;
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};
/// A persistent handler hook process. Spawns a child process,
@@ -25,7 +25,7 @@ pub struct ShellHandlerHook {
impl ShellHandlerHook {
/// Build from CLI flags. Returns None if no handler hooks are configured.
pub fn from_cli(cli: &crate::Cli, action_tx: mpsc::Sender<Action>) -> Option<Self> {
pub fn from_cli(cli: &crate::Cli, action_tx: mpsc::Sender<ModifiedAction>) -> Option<Self> {
let mut handlers: Vec<(HookEventKind, &str)> = Vec::new();
if let Some(ref cmd) = cli.on_open {
@@ -91,7 +91,7 @@ impl HookHandler for ShellHandlerHook {
async fn run_handler_process(
command: &str,
mut event_rx: mpsc::Receiver<HookEvent>,
action_tx: mpsc::Sender<Action>,
action_tx: mpsc::Sender<ModifiedAction>,
) -> Result<(), PiklError> {
let mut child = Command::new("sh")
.arg("-c")
@@ -120,7 +120,7 @@ async fn run_handler_process(
let mut lines = stdout_reader.lines();
while let Ok(Some(line)) = lines.next_line().await {
if let Some(resp) = parse_hook_response(&line) {
let action = Action::ProcessHookResponse(resp);
let action = ModifiedAction::new(Action::ProcessHookResponse(resp));
if reader_action_tx.send(action).await.is_err() {
break;
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,5 +1,7 @@
mod handler;
mod hook;
mod ipc;
mod session;
use std::io::{BufReader, IsTerminal, Write};
use std::sync::Arc;
@@ -11,10 +13,10 @@ use pikl_core::column::ColumnConfig;
use pikl_core::csv_input::{self, InputFormat};
use pikl_core::debounce::{DebounceMode, DebouncedDispatcher};
use pikl_core::error::PiklError;
use pikl_core::event::{Action, MenuResult, Mode};
use pikl_core::event::{Action, MenuResult, ModifiedAction, Mode};
use pikl_core::format::FormatTemplate;
use pikl_core::hook::{HookEventKind, HookHandler};
use pikl_core::input::read_items_sync;
use pikl_core::input::{parse_line_to_value, read_items_sync};
use pikl_core::item::Item;
use pikl_core::json_menu::JsonMenu;
use pikl_core::menu::MenuRunner;
@@ -25,6 +27,39 @@ use serde_json::Value;
use handler::ShellHandlerHook;
use hook::ShellExecHandler;
/// Which frontend to launch.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum FrontendMode {
Tui,
Gui,
}
/// Resolve `--mode` flag into a concrete frontend. "auto"
/// picks GUI when a graphical environment is detected:
/// `$WAYLAND_DISPLAY` on Linux, always on macOS. Falls
/// back to TUI otherwise.
fn resolve_frontend_mode(mode_str: &str) -> Option<FrontendMode> {
match mode_str {
"tui" => Some(FrontendMode::Tui),
"gui" => Some(FrontendMode::Gui),
"auto" => {
#[cfg(target_os = "macos")]
{
Some(FrontendMode::Gui)
}
#[cfg(not(target_os = "macos"))]
{
if std::env::var_os("WAYLAND_DISPLAY").is_some() {
Some(FrontendMode::Gui)
} else {
Some(FrontendMode::Tui)
}
}
}
_ => None,
}
}
#[derive(Parser)]
#[command(
name = "pikl",
@@ -137,6 +172,18 @@ struct Cli {
/// Columns to display in table mode (e.g. "label,meta.age:Age")
#[arg(long, value_name = "COLS")]
columns: Option<String>,
/// Enable IPC control via Unix socket
#[arg(long)]
ipc: bool,
/// Session name (enables filter history, names the IPC socket)
#[arg(long)]
session: Option<String>,
/// Frontend mode: tui, gui, or auto (default: auto)
#[arg(long, value_name = "MODE", default_value = "auto")]
mode: String,
}
fn main() {
@@ -191,10 +238,6 @@ fn main() {
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) {
Some(f) => f,
None => {
@@ -207,22 +250,55 @@ fn main() {
}
};
let (items, csv_headers) = match read_stdin_with_timeout(timeout, &cli.label_key, input_format)
{
Ok(r) => r,
Err(e) => {
let _ = writeln!(std::io::stderr().lock(), "pikl: {e}");
// 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
};
if items.is_empty() {
let _ = writeln!(
std::io::stderr().lock(),
"pikl: empty input. nothing to pick from"
);
std::process::exit(2);
}
let (items, csv_headers) = if streaming {
// Streaming mode: start with empty items, read in background later.
(Vec::new(), None)
} else {
let timeout = cli
.stdin_timeout
.unwrap_or(if script.is_some() { 30 } else { 0 });
let (items, csv_headers) =
match read_stdin_with_timeout(timeout, &cli.label_key, input_format) {
Ok(r) => r,
Err(e) => {
let _ = writeln!(std::io::stderr().lock(), "pikl: {e}");
std::process::exit(2);
}
};
if items.is_empty() {
let _ = writeln!(
std::io::stderr().lock(),
"pikl: empty input. nothing to pick from"
);
std::process::exit(2);
}
(items, csv_headers)
};
// Resolve column config: explicit --columns, or auto-generate from CSV headers
let column_config = if let Some(ref cols) = cli.columns {
@@ -240,8 +316,23 @@ fn main() {
std::process::exit(2);
});
// Resolve frontend mode
let frontend_mode = match resolve_frontend_mode(&cli.mode) {
Some(m) => m,
None => {
let _ = writeln!(
std::io::stderr().lock(),
"pikl: unknown --mode '{}', expected auto, tui, or gui",
cli.mode
);
std::process::exit(2);
}
};
// Reopen stdin from /dev/tty before entering async context.
// Only needed for TUI mode (GUI doesn't read from the terminal).
if script.is_none()
&& frontend_mode == FrontendMode::Tui
&& let Err(e) = reopen_stdin_from_tty()
{
let _ = writeln!(std::io::stderr().lock(), "pikl: {e}");
@@ -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
#[cfg(unix)]
let saved_fd = saved_stdin_fd;
#[cfg(not(unix))]
let saved_fd: Option<i32> = None;
let result = if let Some(script) = script {
rt.block_on(run_headless(items, &cli, script, start_mode, column_config))
} else if cfg!(target_os = "macos") && frontend_mode == FrontendMode::Gui {
// macOS requires the GUI event loop on the main thread
// (AppKit/winit constraint). We can't run it inside
// block_on, so the GUI gets the main thread and tokio
// work is spawned onto the runtime's worker threads.
run_gui_main_thread(
&rt, items, &cli, start_mode, column_config, streaming, saved_fd,
)
} else {
rt.block_on(run_interactive(items, &cli, start_mode, 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
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.
fn build_hook_handler(
cli: &Cli,
action_tx: &tokio::sync::mpsc::Sender<Action>,
action_tx: &tokio::sync::mpsc::Sender<ModifiedAction>,
) -> Option<(Arc<dyn HookHandler>, DebouncedDispatcher)> {
let exec_handler = ShellExecHandler::from_cli(cli);
let handler_hook = ShellHandlerHook::from_cli(cli, action_tx.clone());
@@ -385,7 +520,9 @@ async fn run_headless(
let event_rx = menu.subscribe();
// Default headless viewport
let _ = action_tx.send(Action::Resize { height: 50 }).await;
let _ = action_tx
.send(ModifiedAction::new(Action::Resize { height: 50 }))
.await;
let menu_handle = tokio::spawn(menu.run());
@@ -398,7 +535,7 @@ async fn run_headless(
match show_action {
ShowAction::Ui | ShowAction::Tui | ShowAction::Gui => {
let tui_handle = tokio::spawn(pikl_tui::run(action_tx, event_rx));
let tui_handle = tokio::spawn(pikl_tui::run(action_tx, event_rx, None));
let result = menu_handle
.await
.map_err(|e| PiklError::Io(std::io::Error::other(e.to_string())))??;
@@ -422,11 +559,16 @@ async fn run_interactive(
cli: &Cli,
start_mode: Mode,
column_config: Option<ColumnConfig>,
filter_history: Option<Vec<String>>,
streaming: bool,
saved_stdin_fd: Option<i32>,
frontend_mode: FrontendMode,
) -> Result<MenuResult, PiklError> {
let (mut menu, action_tx) = MenuRunner::new(build_menu(items, cli, 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);
@@ -434,21 +576,249 @@ async fn run_interactive(
let event_rx = menu.subscribe();
// Start IPC server if requested
let _ipc_server = if cli.ipc {
let sock_path = ipc::socket_path(cli.session.as_deref())?;
let server = ipc::server::IpcServer::new(
sock_path,
action_tx.clone(),
menu.event_sender(),
);
let ipc_event_rx = menu.subscribe();
server
.start(ipc_event_rx)
.map_err(PiklError::Io)?;
Some(server)
} else {
None
};
// Handle SIGINT/SIGTERM: restore terminal and exit cleanly.
let signal_tx = action_tx.clone();
let signal_frontend = frontend_mode;
tokio::spawn(async move {
if let Ok(()) = tokio::signal::ctrl_c().await {
pikl_tui::restore_terminal();
let _ = signal_tx.send(Action::Cancel).await;
match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
Ok(mut sigterm) => {
tokio::select! {
_ = tokio::signal::ctrl_c() => {}
_ = sigterm.recv() => {}
}
}
Err(e) => {
tracing::warn!(%e, "failed to register SIGTERM handler, falling back to SIGINT only");
let _ = tokio::signal::ctrl_c().await;
}
}
if signal_frontend == FrontendMode::Tui {
pikl_tui::restore_terminal();
}
let _ = signal_tx.send(ModifiedAction::new(Action::Cancel)).await;
});
let tui_handle = tokio::spawn(async move { pikl_tui::run(action_tx, event_rx).await });
// Spawn background stdin reader for streaming mode.
if let Some(fd) = saved_stdin_fd {
let stream_tx = action_tx.clone();
tokio::task::spawn_blocking(move || {
#[cfg(unix)]
{
use std::io::BufRead;
use std::os::unix::io::FromRawFd;
let result = menu.run().await;
let file = unsafe { std::fs::File::from_raw_fd(fd) };
let mut reader = std::io::BufReader::new(file);
let mut batch = Vec::with_capacity(100);
let mut line = String::new();
let _ = tui_handle.await;
result
loop {
line.clear();
match reader.read_line(&mut line) {
Ok(0) => break,
Ok(_) => {
let trimmed = line.trim_end();
if !trimmed.is_empty() {
batch.push(parse_line_to_value(trimmed));
}
if (batch.len() >= 100 || reader.buffer().is_empty())
&& !batch.is_empty()
{
let _ = stream_tx.blocking_send(ModifiedAction::new(Action::AddItems(
std::mem::take(&mut batch),
)));
}
}
Err(e) => {
tracing::warn!(%e, "stdin read error");
break;
}
}
}
if !batch.is_empty() {
let _ = stream_tx.blocking_send(ModifiedAction::new(Action::AddItems(batch)));
}
let _ = stream_tx.blocking_send(ModifiedAction::new(Action::StreamingDone));
}
});
}
match frontend_mode {
FrontendMode::Tui => {
let tui_handle = tokio::spawn(async move {
pikl_tui::run(action_tx, event_rx, filter_history).await
});
let result = menu.run().await;
let _ = tui_handle.await;
result
}
FrontendMode::Gui => {
// Spawn the core event loop on a background task.
let menu_handle = tokio::spawn(menu.run());
// On Linux, spawn_blocking is fine since Wayland doesn't
// have the main-thread restriction.
let gui_handle = tokio::task::spawn_blocking(move || {
pikl_gui::run(action_tx, event_rx)
.map_err(|e| PiklError::Io(std::io::Error::other(e.to_string())))
});
if let Err(e) = gui_handle.await {
return Err(PiklError::Io(std::io::Error::other(e.to_string())));
}
// Wait for core to finish.
match menu_handle.await {
Ok(result) => result,
Err(e) => Err(PiklError::Io(std::io::Error::other(e.to_string()))),
}
}
}
}
/// Run GUI on the main thread for macOS. AppKit requires
/// the event loop on the main thread, so we can't use
/// block_on (which would nest the iced event loop inside
/// tokio's reactor). Instead, spawn all background work
/// onto the runtime's worker threads and run the GUI
/// directly.
fn run_gui_main_thread(
rt: &tokio::runtime::Runtime,
items: Vec<Item>,
cli: &Cli,
start_mode: Mode,
column_config: Option<ColumnConfig>,
streaming: bool,
saved_stdin_fd: Option<i32>,
) -> Result<MenuResult, PiklError> {
let (mut menu, action_tx) = MenuRunner::new(build_menu(items, cli, column_config));
menu.set_initial_mode(start_mode);
menu.set_multi(cli.multi);
menu.set_selection_order(cli.selection_order);
menu.set_streaming(streaming);
if let Some((_handler, dispatcher)) = build_hook_handler(cli, &action_tx) {
menu.set_dispatcher(dispatcher);
}
let event_rx = menu.subscribe();
// Start IPC server if requested
let _ipc_server = if cli.ipc {
let sock_path = ipc::socket_path(cli.session.as_deref())?;
let server = ipc::server::IpcServer::new(
sock_path,
action_tx.clone(),
menu.event_sender(),
);
let ipc_event_rx = menu.subscribe();
server.start(ipc_event_rx).map_err(PiklError::Io)?;
Some(server)
} else {
None
};
// Enter the runtime context so tokio::spawn works
// without being inside block_on.
let _guard = rt.enter();
// Signal handler
let signal_tx = action_tx.clone();
tokio::spawn(async move {
match tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate()) {
Ok(mut sigterm) => {
tokio::select! {
_ = tokio::signal::ctrl_c() => {}
_ = sigterm.recv() => {}
}
}
Err(e) => {
tracing::warn!(%e, "failed to register SIGTERM handler, falling back to SIGINT only");
let _ = tokio::signal::ctrl_c().await;
}
}
let _ = signal_tx.send(ModifiedAction::new(Action::Cancel)).await;
});
// Streaming stdin reader
if let Some(fd) = saved_stdin_fd {
let stream_tx = action_tx.clone();
tokio::task::spawn_blocking(move || {
#[cfg(unix)]
{
use std::io::BufRead;
use std::os::unix::io::FromRawFd;
let file = unsafe { std::fs::File::from_raw_fd(fd) };
let mut reader = std::io::BufReader::new(file);
let mut batch = Vec::with_capacity(100);
let mut line = String::new();
loop {
line.clear();
match reader.read_line(&mut line) {
Ok(0) => break,
Ok(_) => {
let trimmed = line.trim_end();
if !trimmed.is_empty() {
batch.push(parse_line_to_value(trimmed));
}
if (batch.len() >= 100 || reader.buffer().is_empty())
&& !batch.is_empty()
{
let _ = stream_tx.blocking_send(ModifiedAction::new(
Action::AddItems(std::mem::take(&mut batch)),
));
}
}
Err(e) => {
tracing::warn!(%e, "stdin read error");
break;
}
}
}
if !batch.is_empty() {
let _ =
stream_tx.blocking_send(ModifiedAction::new(Action::AddItems(batch)));
}
let _ =
stream_tx.blocking_send(ModifiedAction::new(Action::StreamingDone));
}
});
}
// Core menu on a background task
let menu_handle = tokio::spawn(menu.run());
// GUI on the main thread (the whole point of this function)
pikl_gui::run(action_tx, event_rx)
.map_err(|e| PiklError::Io(std::io::Error::other(e.to_string())))?;
// GUI exited. Collect the menu result.
rt.block_on(async {
match menu_handle.await {
Ok(result) => result,
Err(e) => Err(PiklError::Io(std::io::Error::other(e.to_string()))),
}
})
}
/// Process the menu result: print output to stdout and
@@ -456,13 +826,16 @@ async fn run_interactive(
fn handle_result(result: Result<MenuResult, PiklError>, cli: &Cli) {
let mut out = std::io::stdout().lock();
match result {
Ok(MenuResult::Selected { items }) => {
Ok(MenuResult::Selected {
items, modifiers, ..
}) => {
if cli.structured {
for (value, index) in items {
let output = OutputItem {
value,
action: OutputAction::Select,
index,
modifiers,
};
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 {
for (value, index) in items {
let output = OutputItem {
value,
action: OutputAction::Quicklist,
index,
modifiers,
};
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 {
let output = OutputItem {
value: Value::Null,
action: OutputAction::Cancel,
index: 0,
modifiers,
};
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 --
#[test]

View File

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

View File

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

View File

@@ -170,7 +170,7 @@ through hooks and structured I/O. A handler hook can
receive hover events and emit commands to modify menu
state.
## Phase 4: Multi-Select
## Phase 4: Multi-Select
Selection with undo/redo. No marks or registers: filtering
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`
renders a navigable table with headers.
## Phase 6: Sessions & IPC
## Phase 6: IPC & Session History
Persistence and external control.
Two independent features bundled together. IPC adds live
external control over a Unix socket. Session history gives
lightweight filter recall for repeated workflows.
### IPC (External Control)
Opt-in Unix socket server for live control of a running
pikl instance by external processes.
**Deliverables:**
- `--session name` for state persistence
- Session state: filter, scroll position, selections
- Session history log file
- Unix socket IPC while running
- IPC commands: push/remove/update items, set filter,
read selection, close
- Protocol: newline-delimited JSON
- `--ipc` flag to enable the socket listener (off by
default, pikl is ephemeral and shouldn't have side
effects unless asked)
- Socket path: `/run/user/$UID/pikl-{session}.sock`
(named session) or `/run/user/$UID/pikl-{pid}.sock`
(no session name)
- Socket path logged via tracing on startup
- Cleanup on exit (normal, cancel, SIGTERM)
- Protocol: newline-delimited JSON, one message per line
- Write commands: `add_items`, `replace_items`,
`remove_items`, `set_filter`, `confirm`, `cancel`,
`close`, plus navigation actions (`move_up`,
`move_down`, `move_to_top`, `move_to_bottom`,
`page_up`, `page_down`, `toggle_select`, `select_all`,
`clear_selections`)
- Read commands: `get_state` (filter, cursor, counts,
mode), `get_selection` (selected items). Both require
an `id` field, response echoes it back.
- Event subscription: `subscribe` with event type list,
`unsubscribe` to stop. Subscribed events pushed as
`{"event": "...", ...}` lines.
- Multiple simultaneous client connections
- Auth: Unix socket permissions (user-only)
- IPC is just another frontend: deserializes JSON into
Actions, optionally subscribes to MenuEvent broadcast
**Done when:** You can close and reopen a session and find
your state intact. External scripts can push items into a
running pikl instance.
**Not in scope:**
- `get_items` (full item list read, potentially large)
- Auto-enable with `--session`
- Auth beyond Unix socket permissions
## Phase 7: Streaming & Watched Sources
Live, dynamic menus.
### Session Filter History
**Deliverables:**
- Async/streaming stdin (items arrive over time, list
updates progressively)
- Streaming output (on-hover events emitted to stdout)
- `--watch path` for file/directory watching
- `--watch-extensions` filter
- notify crate integration (inotify on Linux, FSEvents
on macOS)
- `--session name` flag
- On any confirm (single, multi, quicklist), append
current filter text to
`~/.local/state/pikl/sessions/{name}.history`
- Skip empty filters and consecutive duplicates
- Load history on startup if session file exists
- Ctrl+P / Ctrl+N in insert mode to cycle through
filter history
- Selecting a history entry replaces the current filter
text
- Per-session only, no global history
**Done when:** `find / -name '*.log' | pikl` populates
progressively. A watched directory updates the list live.
**Done when:** An external script can connect to a running
pikl instance over the socket, push items, read state, and
subscribe to events. A named session remembers filter
history across invocations.
## Phase 7: Streaming Stdin ✓
Auto-detected streaming when stdin is a pipe (not action-fd,
not CSV/TSV). The menu opens immediately with zero items and
streams them in as they arrive.
`--watch` was dropped from this phase. External tools like
`inotifywait -m ~/walls | pikl` compose naturally with
streaming stdin. No built-in file watcher needed.
**Deliverables:**
- Auto-detected streaming stdin (items arrive over time,
list updates progressively)
- `StreamingDone` action signals end of input
- `streaming` flag on ViewState for frontend indicators
- TUI shows `...` suffix on the count while streaming
- Background reader with batched `AddItems` (up to 100
items per batch, flushes immediately for slow sources)
- `streaming-done` action-fd command for test scripts
- No new CLI flags: streaming is auto-detected
**Done when:** `seq 1 10000 | pikl` opens immediately and
items stream in. Slow sources show items appearing one at
a time with a `...` indicator.
## Phase 8: GUI Frontend (Wayland + X11)
@@ -294,7 +349,14 @@ navigate directories without spawning new processes.
pass `-la` to it. The output would include both the
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
Shift+Enter? Or does Shift+Enter open a second input
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
behaviour), or should it also treat any "extra" text
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),
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.
Could be a `pikl index` subcommand or a standalone
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 |
| Fuzzy filtering | 2 | Filter by filename |
| Manifest files | 3 | Reusable config |
| Watched sources | 7 | Live update on new files |
| Streaming stdin | 7 | Progressive load from find |
| Sessions | 6 | Remember last position |
## Open Questions

View File

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

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

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

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

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