Compare commits

..

5 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
24 changed files with 5626 additions and 271 deletions

3431
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

@@ -59,7 +59,7 @@ fn gen_imports(kind: TestKind) -> TokenStream {
TestKind::Menu => { TestKind::Menu => {
quote! { quote! {
use pikl_core::item::Item; use pikl_core::item::Item;
use pikl_core::event::{Action, MenuEvent, MenuResult}; use pikl_core::event::{Action, MenuEvent, MenuResult, ModifiedAction};
use pikl_core::menu::MenuRunner; use pikl_core::menu::MenuRunner;
use pikl_core::json_menu::JsonMenu; use pikl_core::json_menu::JsonMenu;
} }
@@ -67,7 +67,7 @@ fn gen_imports(kind: TestKind) -> TokenStream {
TestKind::Ipc => { TestKind::Ipc => {
quote! { quote! {
use pikl_core::item::Item; use pikl_core::item::Item;
use pikl_core::event::{Action, MenuEvent, MenuResult}; use pikl_core::event::{Action, MenuEvent, MenuResult, ModifiedAction};
use pikl_core::menu::MenuRunner; use pikl_core::menu::MenuRunner;
use pikl_core::json_menu::JsonMenu; use pikl_core::json_menu::JsonMenu;
use crate::ipc::server::IpcServer; use crate::ipc::server::IpcServer;
@@ -326,14 +326,14 @@ fn gen_menu(case: &TestCase, fixtures: &Fixtures) -> syn::Result<TokenStream> {
let handle = tokio::spawn(async move { menu.run().await }); let handle = tokio::spawn(async move { menu.run().await });
let _ = rx.recv().await; let _ = rx.recv().await;
let _ = tx.send(Action::Resize { height: 50 }).await; let _ = tx.send(ModifiedAction::new(Action::Resize { height: 50 })).await;
let _ = rx.recv().await; let _ = rx.recv().await;
#(#action_sends)* #(#action_sends)*
drop(tx); 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 #result_assert
} }
}) })
@@ -345,17 +345,17 @@ fn gen_menu_actions(actions: &[ActionExpr]) -> syn::Result<Vec<TokenStream>> {
let expr = match action { let expr = match action {
ActionExpr::Simple(name) => { ActionExpr::Simple(name) => {
let variant = menu_action_variant(name)?; let variant = menu_action_variant(name)?;
quote! { let _ = tx.send(#variant).await; } quote! { let _ = tx.send(ModifiedAction::new(#variant)).await; }
} }
ActionExpr::Filter(query) => { ActionExpr::Filter(query) => {
quote! { let _ = tx.send(Action::UpdateFilter(#query.to_string())).await; } quote! { let _ = tx.send(ModifiedAction::new(Action::UpdateFilter(#query.to_string()))).await; }
} }
ActionExpr::AddItems(items) => { ActionExpr::AddItems(items) => {
let item_exprs: Vec<TokenStream> = items let item_exprs: Vec<TokenStream> = items
.iter() .iter()
.map(|s| quote! { serde_json::Value::String(#s.to_string()) }) .map(|s| quote! { serde_json::Value::String(#s.to_string()) })
.collect(); .collect();
quote! { let _ = tx.send(Action::AddItems(vec![#(#item_exprs),*])).await; } quote! { let _ = tx.send(ModifiedAction::new(Action::AddItems(vec![#(#item_exprs),*]))).await; }
} }
ActionExpr::Raw(_) => { 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"));
@@ -450,7 +450,7 @@ fn gen_ipc(case: &TestCase, fixtures: &Fixtures) -> syn::Result<TokenStream> {
let handle = tokio::spawn(async move { menu.run().await }); let handle = tokio::spawn(async move { menu.run().await });
let _ = tx.send(Action::Resize { height: 50 }).await; let _ = tx.send(ModifiedAction::new(Action::Resize { height: 50 })).await;
tokio::time::sleep(Duration::from_millis(30)).await; tokio::time::sleep(Duration::from_millis(30)).await;
// Start IPC server on a temp socket // Start IPC server on a temp socket
@@ -484,7 +484,7 @@ fn gen_ipc(case: &TestCase, fixtures: &Fixtures) -> syn::Result<TokenStream> {
drop(writer); drop(writer);
drop(tx); drop(tx);
drop(ipc_server); drop(ipc_server);
let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled)); let result = handle.await.unwrap_or(Ok(MenuResult::Cancelled { modifiers: pikl_core::event::Modifiers::default() }));
#result_assert #result_assert
} }
}) })
@@ -559,7 +559,7 @@ fn gen_result_assert(case: &TestCase, label_key: &str) -> syn::Result<TokenStrea
if case.cancelled { if case.cancelled {
Ok(quote! { Ok(quote! {
assert!( assert!(
matches!(result, Ok(MenuResult::Cancelled)), matches!(result, Ok(MenuResult::Cancelled { .. })),
"expected Cancelled, got: {:?}", result.as_ref().map(|r| format!("{:?}", r)) "expected Cancelled, got: {:?}", result.as_ref().map(|r| format!("{:?}", r))
); );
}) })

View File

@@ -14,7 +14,7 @@ use ratatui::text::{Line, Span};
use ratatui::widgets::{List, ListItem, Paragraph}; use ratatui::widgets::{List, ListItem, Paragraph};
use tokio::sync::{broadcast, mpsc}; use tokio::sync::{broadcast, mpsc};
use pikl_core::event::{Action, MenuEvent, Mode, ViewState}; use pikl_core::event::{Action, MenuEvent, ModifiedAction, Modifiers, Mode, ViewState};
/// Pending key state for multi-key sequences (e.g. `gg`). /// Pending key state for multi-key sequences (e.g. `gg`).
/// TUI-local, not part of core state. /// TUI-local, not part of core state.
@@ -32,12 +32,21 @@ pub fn restore_terminal() {
let _ = crossterm::execute!(std::io::stderr(), crossterm::terminal::LeaveAlternateScreen); let _ = crossterm::execute!(std::io::stderr(), crossterm::terminal::LeaveAlternateScreen);
} }
/// Convert crossterm key modifiers to pikl Modifiers.
fn crossterm_modifiers(km: KeyModifiers) -> Modifiers {
Modifiers {
shift: km.contains(KeyModifiers::SHIFT),
ctrl: km.contains(KeyModifiers::CONTROL),
alt: km.contains(KeyModifiers::ALT),
}
}
/// Start the TUI. Enters the alternate screen, runs the /// Start the TUI. Enters the alternate screen, runs the
/// event loop, and restores the terminal on exit. Translates /// event loop, and restores the terminal on exit. Translates
/// crossterm key events into [`Action`]s and renders /// crossterm key events into [`Action`]s and renders
/// [`ViewState`] snapshots. /// [`ViewState`] snapshots.
pub async fn run( pub async fn run(
action_tx: mpsc::Sender<Action>, action_tx: mpsc::Sender<ModifiedAction>,
mut event_rx: broadcast::Receiver<MenuEvent>, mut event_rx: broadcast::Receiver<MenuEvent>,
filter_history: Option<Vec<String>>, filter_history: Option<Vec<String>>,
) -> std::io::Result<()> { ) -> std::io::Result<()> {
@@ -65,7 +74,7 @@ pub async fn run(
/// Inner event loop. Separated from [`run`] so terminal /// Inner event loop. Separated from [`run`] so terminal
/// cleanup always happens even if this returns an error. /// cleanup always happens even if this returns an error.
async fn run_inner( async fn run_inner(
action_tx: &mpsc::Sender<Action>, action_tx: &mpsc::Sender<ModifiedAction>,
event_rx: &mut broadcast::Receiver<MenuEvent>, event_rx: &mut broadcast::Receiver<MenuEvent>,
terminal: &mut Terminal<CrosstermBackend<std::io::Stderr>>, terminal: &mut Terminal<CrosstermBackend<std::io::Stderr>>,
filter_history: Option<Vec<String>>, filter_history: Option<Vec<String>>,
@@ -74,9 +83,9 @@ async fn run_inner(
let size = terminal.size()?; let size = terminal.size()?;
let list_height = size.height.saturating_sub(1); let list_height = size.height.saturating_sub(1);
if action_tx if action_tx
.send(Action::Resize { .send(ModifiedAction::new(Action::Resize {
height: list_height, height: list_height,
}) }))
.await .await
.is_err() .is_err()
{ {
@@ -135,7 +144,7 @@ async fn run_inner(
history_cursor = Some(new_cursor); history_cursor = Some(new_cursor);
filter_text.clone_from(&history[new_cursor]); filter_text.clone_from(&history[new_cursor]);
let _ = action_tx let _ = action_tx
.send(Action::UpdateFilter(filter_text.clone())) .send(ModifiedAction::new(Action::UpdateFilter(filter_text.clone())))
.await; .await;
true true
} }
@@ -153,7 +162,7 @@ async fn run_inner(
pre_history_text = None; pre_history_text = None;
} }
let _ = action_tx let _ = action_tx
.send(Action::UpdateFilter(filter_text.clone())) .send(ModifiedAction::new(Action::UpdateFilter(filter_text.clone())))
.await; .await;
true true
} else { } else {
@@ -196,14 +205,14 @@ async fn run_inner(
mode = *m; mode = *m;
pending = PendingKey::None; pending = PendingKey::None;
} }
if action_tx.send(action).await.is_err() { if action_tx.send(ModifiedAction::with_modifiers(action, crossterm_modifiers(key.modifiers))).await.is_err() {
break; // core is gone break; // core is gone
} }
} }
} }
Event::Resize(_, h) => { Event::Resize(_, h) => {
let list_height = h.saturating_sub(1); let list_height = h.saturating_sub(1);
if action_tx.send(Action::Resize { height: list_height }).await.is_err() { if action_tx.send(ModifiedAction::new(Action::Resize { height: list_height })).await.is_err() {
break; break;
} }
} }
@@ -259,7 +268,7 @@ async fn handle_multi_action_key(
multi_enabled: bool, multi_enabled: bool,
visual_anchor: &mut Option<usize>, visual_anchor: &mut Option<usize>,
view_state: &Option<ViewState>, view_state: &Option<ViewState>,
action_tx: &mpsc::Sender<Action>, action_tx: &mpsc::Sender<ModifiedAction>,
) -> bool { ) -> bool {
match mode { match mode {
Mode::Insert => { Mode::Insert => {
@@ -268,13 +277,15 @@ async fn handle_multi_action_key(
} }
match key.code { match key.code {
KeyCode::Tab => { KeyCode::Tab => {
let _ = action_tx.send(Action::ToggleSelect).await; let mods = crossterm_modifiers(key.modifiers);
let _ = action_tx.send(Action::MoveDown(1)).await; let _ = action_tx.send(ModifiedAction::with_modifiers(Action::ToggleSelect, mods)).await;
let _ = action_tx.send(ModifiedAction::with_modifiers(Action::MoveDown(1), mods)).await;
true true
} }
KeyCode::BackTab => { KeyCode::BackTab => {
let _ = action_tx.send(Action::ToggleSelect).await; let mods = crossterm_modifiers(key.modifiers);
let _ = action_tx.send(Action::MoveUp(1)).await; let _ = action_tx.send(ModifiedAction::with_modifiers(Action::ToggleSelect, mods)).await;
let _ = action_tx.send(ModifiedAction::with_modifiers(Action::MoveUp(1), mods)).await;
true true
} }
_ => false, _ => false,
@@ -288,18 +299,21 @@ async fn handle_multi_action_key(
(KeyCode::Char(' '), m) (KeyCode::Char(' '), m)
if !m.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) => if !m.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) =>
{ {
let _ = action_tx.send(Action::ToggleSelect).await; let mods = crossterm_modifiers(key.modifiers);
let _ = action_tx.send(Action::MoveDown(1)).await; let _ = action_tx.send(ModifiedAction::with_modifiers(Action::ToggleSelect, mods)).await;
let _ = action_tx.send(ModifiedAction::with_modifiers(Action::MoveDown(1), mods)).await;
true true
} }
(KeyCode::Tab, _) => { (KeyCode::Tab, _) => {
let _ = action_tx.send(Action::ToggleSelect).await; let mods = crossterm_modifiers(key.modifiers);
let _ = action_tx.send(Action::MoveDown(1)).await; let _ = action_tx.send(ModifiedAction::with_modifiers(Action::ToggleSelect, mods)).await;
let _ = action_tx.send(ModifiedAction::with_modifiers(Action::MoveDown(1), mods)).await;
true true
} }
(KeyCode::BackTab, _) => { (KeyCode::BackTab, _) => {
let _ = action_tx.send(Action::ToggleSelect).await; let mods = crossterm_modifiers(key.modifiers);
let _ = action_tx.send(Action::MoveUp(1)).await; let _ = action_tx.send(ModifiedAction::with_modifiers(Action::ToggleSelect, mods)).await;
let _ = action_tx.send(ModifiedAction::with_modifiers(Action::MoveUp(1), mods)).await;
true true
} }
_ => false, _ => false,
@@ -310,18 +324,19 @@ async fn handle_multi_action_key(
(KeyCode::Char(' '), m) | (KeyCode::Enter, m) (KeyCode::Char(' '), m) | (KeyCode::Enter, m)
if !m.intersects(KeyModifiers::ALT) => if !m.intersects(KeyModifiers::ALT) =>
{ {
let mods = crossterm_modifiers(key.modifiers);
// Apply visual selection // Apply visual selection
if let (Some(anchor), Some(vs)) = (*visual_anchor, view_state) { if let (Some(anchor), Some(vs)) = (*visual_anchor, view_state) {
let cursor = vs.cursor; let cursor = vs.cursor;
let _ = action_tx let _ = action_tx
.send(Action::SelectRange { .send(ModifiedAction::with_modifiers(Action::SelectRange {
start: anchor, start: anchor,
end: cursor, end: cursor,
}) }, mods))
.await; .await;
} }
*visual_anchor = None; *visual_anchor = None;
let _ = action_tx.send(Action::SetMode(Mode::Normal)).await; let _ = action_tx.send(ModifiedAction::with_modifiers(Action::SetMode(Mode::Normal), mods)).await;
true true
} }
_ => false, _ => false,
@@ -374,7 +389,10 @@ fn render_menu(
), ),
Span::raw(filter_text), Span::raw(filter_text),
Span::styled( Span::styled(
format!(" {filtered_count}/{total_count}"), format!(
" {filtered_count}/{total_count}{}",
if vs.streaming { "..." } else { "" }
),
Style::default().fg(Color::DarkGray), Style::default().fg(Color::DarkGray),
), ),
]; ];
@@ -691,6 +709,7 @@ mod tests {
mode: Mode::Insert, mode: Mode::Insert,
selection_count: 0, selection_count: 0,
multi_enabled: false, multi_enabled: false,
streaming: false,
columns: None, columns: None,
generation: 1, generation: 1,
} }
@@ -1166,6 +1185,7 @@ mod tests {
mode: Mode::Insert, mode: Mode::Insert,
selection_count: 0, selection_count: 0,
multi_enabled: false, multi_enabled: false,
streaming: false,
columns: None, columns: None,
generation: 1, generation: 1,
}; };
@@ -1175,6 +1195,29 @@ mod tests {
assert!(prompt.contains("0/0")); assert!(prompt.contains("0/0"));
} }
#[test]
fn streaming_indicator_shows_ellipsis() {
let mut vs = sample_view_state();
vs.streaming = true;
let backend = render_to_backend(40, 5, &vs, "");
let prompt = line_text(&backend, 0);
assert!(
prompt.contains("..."),
"streaming indicator should show '...': {prompt}"
);
}
#[test]
fn no_streaming_indicator_when_done() {
let vs = sample_view_state();
let backend = render_to_backend(40, 5, &vs, "");
let prompt = line_text(&backend, 0);
assert!(
!prompt.contains("..."),
"should not show '...' when not streaming: {prompt}"
);
}
#[test] #[test]
fn narrow_viewport_truncates() { fn narrow_viewport_truncates() {
let vs = sample_view_state(); let vs = sample_view_state();
@@ -1269,6 +1312,7 @@ mod tests {
mode: Mode::Insert, mode: Mode::Insert,
selection_count: 0, selection_count: 0,
multi_enabled: false, multi_enabled: false,
streaming: false,
columns: Some(vec![ columns: Some(vec![
ColumnHeader { ColumnHeader {
display_name: "name".into(), display_name: "name".into(),

View File

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

View File

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

View File

@@ -9,7 +9,7 @@ use tokio::net::UnixListener;
use tokio::sync::{broadcast, mpsc, watch, RwLock}; use tokio::sync::{broadcast, mpsc, watch, RwLock};
use tracing::{info, warn}; use tracing::{info, warn};
use pikl_core::event::{Action, MenuEvent, ViewState}; use pikl_core::event::{MenuEvent, ModifiedAction, ViewState};
use super::protocol::{ipc_command_to_action, view_state_to_response, IpcCommand, IpcResponse}; use super::protocol::{ipc_command_to_action, view_state_to_response, IpcCommand, IpcResponse};
@@ -19,7 +19,7 @@ use super::protocol::{ipc_command_to_action, view_state_to_response, IpcCommand,
/// removes the socket file. /// removes the socket file.
pub struct IpcServer { pub struct IpcServer {
socket_path: PathBuf, socket_path: PathBuf,
action_tx: mpsc::Sender<Action>, action_tx: mpsc::Sender<ModifiedAction>,
state: Arc<RwLock<Option<ViewState>>>, state: Arc<RwLock<Option<ViewState>>>,
event_tx: broadcast::Sender<MenuEvent>, event_tx: broadcast::Sender<MenuEvent>,
/// Dropping the sender signals all tasks to shut down. /// Dropping the sender signals all tasks to shut down.
@@ -29,7 +29,7 @@ pub struct IpcServer {
impl IpcServer { impl IpcServer {
pub fn new( pub fn new(
socket_path: PathBuf, socket_path: PathBuf,
action_tx: mpsc::Sender<Action>, action_tx: mpsc::Sender<ModifiedAction>,
event_tx: broadcast::Sender<MenuEvent>, event_tx: broadcast::Sender<MenuEvent>,
) -> Self { ) -> Self {
Self { Self {
@@ -143,7 +143,7 @@ impl Drop for IpcServer {
/// dispatches actions, sends responses. /// dispatches actions, sends responses.
async fn handle_connection( async fn handle_connection(
stream: tokio::net::UnixStream, stream: tokio::net::UnixStream,
action_tx: mpsc::Sender<Action>, action_tx: mpsc::Sender<ModifiedAction>,
state: Arc<RwLock<Option<ViewState>>>, state: Arc<RwLock<Option<ViewState>>>,
event_tx: broadcast::Sender<MenuEvent>, event_tx: broadcast::Sender<MenuEvent>,
) -> Result<(), std::io::Error> { ) -> Result<(), std::io::Error> {
@@ -180,7 +180,7 @@ async fn handle_connection(
// Write commands: convert to action, send, no response // Write commands: convert to action, send, no response
ref c if ipc_command_to_action(c).is_some() => { ref c if ipc_command_to_action(c).is_some() => {
if let Some(action) = ipc_command_to_action(&cmd) { if let Some(action) = ipc_command_to_action(&cmd) {
let _ = action_tx.send(action).await; let _ = action_tx.send(ModifiedAction::new(action)).await;
} }
} }

View File

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

View File

@@ -68,6 +68,96 @@ fn headless_actions_after_show_ui_exits_2() {
); );
} }
// -- Modifier prefix integration tests --
#[test]
fn modifier_prefix_in_structured_output() {
let (stdout, _stderr, code) = common::run_pikl(
"alpha\nbeta\n",
"+shift confirm\n",
&["--structured"],
);
assert_eq!(code, 0, "expected exit 0, stderr: {_stderr}");
assert!(
stdout.contains(r#""modifiers":["shift"]"#),
"expected modifiers array in structured output, got: {stdout}"
);
}
#[test]
fn no_modifier_omits_field_in_structured_output() {
let (stdout, _stderr, code) = common::run_pikl(
"alpha\nbeta\n",
"confirm\n",
&["--structured"],
);
assert_eq!(code, 0, "expected exit 0, stderr: {_stderr}");
assert!(
!stdout.contains("modifiers"),
"expected no modifiers field when none held, got: {stdout}"
);
}
#[test]
fn multiple_modifier_prefixes_in_structured() {
let (stdout, _stderr, code) = common::run_pikl(
"alpha\nbeta\n",
"+ctrl+alt confirm\n",
&["--structured"],
);
assert_eq!(code, 0, "expected exit 0, stderr: {_stderr}");
assert!(
stdout.contains(r#""modifiers":["ctrl","alt"]"#),
"expected ctrl+alt modifiers, got: {stdout}"
);
}
#[test]
fn modifier_prefix_cancel_structured() {
let (stdout, _stderr, code) = common::run_pikl(
"alpha\n",
"+shift cancel\n",
&["--structured"],
);
assert_eq!(code, 1, "expected exit 1 on cancel");
assert!(
stdout.contains(r#""modifiers":["shift"]"#),
"expected modifiers on cancel output, got: {stdout}"
);
}
#[test]
fn modifier_prefix_does_not_affect_plain_output() {
let (stdout, _stderr, code) = common::run_pikl(
"alpha\nbeta\n",
"+shift confirm\n",
&[],
);
assert_eq!(code, 0, "expected exit 0, stderr: {_stderr}");
assert!(
!stdout.contains("modifiers"),
"plain output should not contain modifiers, got: {stdout}"
);
assert!(
stdout.contains("alpha"),
"expected alpha in plain output, got: {stdout}"
);
}
#[test]
fn unknown_modifier_prefix_exits_2() {
let (_stdout, stderr, code) = common::run_pikl(
"alpha\n",
"+super confirm\n",
&[],
);
assert_eq!(code, 2, "expected exit 2 on unknown modifier");
assert!(
stderr.contains("unknown modifier"),
"expected unknown modifier error, got: {stderr}"
);
}
// -- CSV/TSV integration tests -- // -- CSV/TSV integration tests --
#[test] #[test]

View File

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

View File

@@ -48,6 +48,43 @@ metadata that came in, plus selection context:
{"label": "beach_sunset.jpg", "sublabel": "/home/maple/walls/nature/", "meta": {"size": "2.4MB"}, "action": "select", "index": 3} {"label": "beach_sunset.jpg", "sublabel": "/home/maple/walls/nature/", "meta": {"size": "2.4MB"}, "action": "select", "index": 3}
``` ```
When `--structured` is used, the output JSON includes a
`modifiers` field if any modifier keys (shift, ctrl, alt)
were held during confirmation. The value is an array of
strings:
```jsonl
{"label": "firefox", "action": "select", "index": 0, "modifiers": ["shift"]}
{"label": "alacritty", "action": "select", "index": 2, "modifiers": ["ctrl", "alt"]}
```
When no modifiers are held, the field is omitted entirely.
Plain text output is unchanged: modifiers only appear in
structured JSON.
### Working with Structured Output
Check for shift modifier with jq:
```sh
pikl --structured | jq 'if (.modifiers // [] | contains(["shift"])) then "alternate" else "default" end'
```
Extract a field:
```sh
pikl --structured | jq -r '.label'
```
Branch on modifiers in a script:
```sh
result=$(echo -e "a\nb\nc" | pikl --structured)
if echo "$result" | jq -e '.modifiers // [] | contains(["shift"])' > /dev/null 2>&1; then
echo "shift was held"
fi
```
### Streaming ### Streaming
Input can arrive over time. The list populates progressively Input can arrive over time. The list populates progressively
@@ -96,6 +133,12 @@ There are two ways to respond to them: **exec hooks** and
| `on-filter` | Filter text changes | Dynamic item reloading | | `on-filter` | Filter text changes | Dynamic item reloading |
| `on-toggle` | User toggles an item's selection | Visual feedback (deferred) | | `on-toggle` | User toggles an item's selection | Visual feedback (deferred) |
Hook event JSON includes a `modifiers` field on `select`,
`cancel`, `filter`, `hover`, and `quicklist` events when
modifier keys were held. Same format as structured output:
an array of strings like `["shift"]` or `["ctrl", "alt"]`.
Omitted when no modifiers are active.
### Exec Hooks (fire-and-forget) ### Exec Hooks (fire-and-forget)
`--on-<event>-exec` spawns a subprocess for each event. `--on-<event>-exec` spawns a subprocess for each event.
@@ -770,6 +813,18 @@ Available actions:
| `show-tui` | (none) | Hand off to TUI specifically | | `show-tui` | (none) | Hand off to TUI specifically |
| `show-gui` | (none) | Hand off to GUI specifically | | `show-gui` | (none) | Hand off to GUI specifically |
Actions can include modifier key prefixes with `+`:
```
+shift confirm
+ctrl+alt move-down 3
```
Valid modifiers: `shift`, `ctrl`, `alt`. Multiple modifiers
chain with `+`. Lines without a `+` prefix work exactly as
before. This lets scripts signal intent to hooks and
structured output the same way a user holding Shift would.
`show-ui` auto-detects the appropriate interactive frontend `show-ui` auto-detects the appropriate interactive frontend
(Wayland: GUI, X11: GUI, otherwise: TUI). `show-tui` and (Wayland: GUI, X11: GUI, otherwise: TUI). `show-tui` and
`show-gui` are explicit overrides. All three must be the `show-gui` are explicit overrides. All three must be the

View File

@@ -170,7 +170,7 @@ through hooks and structured I/O. A handler hook can
receive hover events and emit commands to modify menu receive hover events and emit commands to modify menu
state. state.
## Phase 4: Multi-Select ## Phase 4: Multi-Select
Selection with undo/redo. No marks or registers: filtering Selection with undo/redo. No marks or registers: filtering
already handles navigation, and selections surviving filter already handles navigation, and selections surviving filter
@@ -275,21 +275,30 @@ pikl instance over the socket, push items, read state, and
subscribe to events. A named session remembers filter subscribe to events. A named session remembers filter
history across invocations. history across invocations.
## Phase 7: Streaming & Watched Sources ## Phase 7: Streaming Stdin ✓
Live, dynamic menus. 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:** **Deliverables:**
- Async/streaming stdin (items arrive over time, list - Auto-detected streaming stdin (items arrive over time,
updates progressively) list updates progressively)
- Streaming output (on-hover events emitted to stdout) - `StreamingDone` action signals end of input
- `--watch path` for file/directory watching - `streaming` flag on ViewState for frontend indicators
- `--watch-extensions` filter - TUI shows `...` suffix on the count while streaming
- notify crate integration (inotify on Linux, FSEvents - Background reader with batched `AddItems` (up to 100
on macOS) 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:** `find / -name '*.log' | pikl` populates **Done when:** `seq 1 10000 | pikl` opens immediately and
progressively. A watched directory updates the list live. items stream in. Slow sources show items appearing one at
a time with a `...` indicator.
## Phase 8: GUI Frontend (Wayland + X11) ## Phase 8: GUI Frontend (Wayland + X11)
@@ -340,7 +349,14 @@ navigate directories without spawning new processes.
pass `-la` to it. The output would include both the pass `-la` to it. The output would include both the
selected item and the user-supplied arguments. selected item and the user-supplied arguments.
Open questions: **Partially resolved:** modifier key support has landed.
Structured output and hook events now carry a `modifiers`
field when shift/ctrl/alt are held during confirmation.
This covers the signaling side: scripts can detect
Shift+Enter and branch on it. The free-text argument
input (where does the user type `-la`?) is still open.
Remaining open questions:
- UX flow: does the filter text become the args on - UX flow: does the filter text become the args on
Shift+Enter? Or does Shift+Enter open a second input Shift+Enter? Or does Shift+Enter open a second input
field for args after selection? The filter-as-args field for args after selection? The filter-as-args
@@ -354,9 +370,6 @@ navigate directories without spawning new processes.
matches exactly one item just confirm that item (current matches exactly one item just confirm that item (current
behaviour), or should it also treat any "extra" text behaviour), or should it also treat any "extra" text
as args? Probably not, too implicit. as args? Probably not, too implicit.
- Keybind: Shift+Enter is natural, but some terminals
don't distinguish it from Enter. May need a fallback
like Ctrl+Enter or a normal-mode keybind.
This is a core feature (new keybind, new output field), This is a core feature (new keybind, new output field),
not just a launcher script concern. Fits naturally after not just a launcher script concern. Fits naturally after
@@ -403,3 +416,4 @@ commitment, no order.
to run per-keystroke" problem for the app launcher. to run per-keystroke" problem for the app launcher.
Could be a `pikl index` subcommand or a standalone Could be a `pikl index` subcommand or a standalone
helper script. helper script.
- Need vars in hooks like `--on-hover 'echo $out'`.

View File

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

View File

@@ -400,6 +400,19 @@ ipc_demo() {
ITEMS 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() { session_demo() {
echo "Session filter history: select with a filter, exit, relaunch." >&2 echo "Session filter history: select with a filter, exit, relaunch." >&2
echo "Press Ctrl+P to recall your previous filter." >&2 echo "Press Ctrl+P to recall your previous filter." >&2
@@ -442,6 +455,7 @@ scenarios=(
"---" "---"
"IPC remote control" "IPC remote control"
"Session filter history" "Session filter history"
"Modifier keys (structured output)"
"---" "---"
"on-select-exec hook (legacy)" "on-select-exec hook (legacy)"
) )
@@ -475,6 +489,7 @@ run_scenario() {
*"CSV + field"*) csv_filter ;; *"CSV + field"*) csv_filter ;;
*"IPC remote"*) ipc_demo ;; *"IPC remote"*) ipc_demo ;;
*"Session filter"*) session_demo ;; *"Session filter"*) session_demo ;;
*"Modifier keys"*) modifier_keys_demo ;;
*"on-select-exec"*) on_select_hook ;; *"on-select-exec"*) on_select_hook ;;
"---") "---")
echo "that's a separator, not a scenario" >&2 echo "that's a separator, not a scenario" >&2