Compare commits
5 Commits
3b574e89ab
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
f81c8a20d2
|
|||
|
5e076b8682
|
|||
|
47437f536b
|
|||
|
0d241841bc
|
|||
|
e77ecb447e
|
3431
Cargo.lock
generated
3431
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@ resolver = "3"
|
|||||||
members = [
|
members = [
|
||||||
"crates/pikl-core",
|
"crates/pikl-core",
|
||||||
"crates/pikl-tui",
|
"crates/pikl-tui",
|
||||||
|
"crates/pikl-gui",
|
||||||
"crates/pikl",
|
"crates/pikl",
|
||||||
"crates/pikl-test-macros",
|
"crates/pikl-test-macros",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"]));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ pub mod parse;
|
|||||||
use tokio::sync::mpsc;
|
use tokio::sync::mpsc;
|
||||||
|
|
||||||
use crate::error::PiklError;
|
use crate::error::PiklError;
|
||||||
use crate::event::Action;
|
use crate::event::{Action, ModifiedAction, Modifiers};
|
||||||
|
|
||||||
pub use error::{ScriptError, ScriptErrorKind};
|
pub use error::{ScriptError, ScriptErrorKind};
|
||||||
pub use parse::{load_script, parse_action};
|
pub use parse::{load_script, parse_action};
|
||||||
@@ -22,7 +22,7 @@ pub use parse::{load_script, parse_action};
|
|||||||
/// A parsed action from an action-fd script.
|
/// A parsed action from an action-fd script.
|
||||||
#[derive(Debug, Clone, PartialEq)]
|
#[derive(Debug, Clone, PartialEq)]
|
||||||
pub enum ScriptAction {
|
pub enum ScriptAction {
|
||||||
Core(Action),
|
Core(Action, Modifiers),
|
||||||
ShowUi,
|
ShowUi,
|
||||||
ShowTui,
|
ShowTui,
|
||||||
ShowGui,
|
ShowGui,
|
||||||
@@ -41,14 +41,14 @@ pub enum ShowAction {
|
|||||||
/// Returns the Show* variant if the script ends with one, or None.
|
/// Returns the Show* variant if the script ends with one, or None.
|
||||||
pub async fn run_script(
|
pub async fn run_script(
|
||||||
script: Vec<ScriptAction>,
|
script: Vec<ScriptAction>,
|
||||||
tx: &mpsc::Sender<Action>,
|
tx: &mpsc::Sender<ModifiedAction>,
|
||||||
) -> Result<Option<ShowAction>, PiklError> {
|
) -> Result<Option<ShowAction>, PiklError> {
|
||||||
let mut show = None;
|
let mut show = None;
|
||||||
|
|
||||||
for action in script {
|
for action in script {
|
||||||
match action {
|
match action {
|
||||||
ScriptAction::Core(action) => {
|
ScriptAction::Core(action, modifiers) => {
|
||||||
tx.send(action)
|
tx.send(ModifiedAction { action, modifiers })
|
||||||
.await
|
.await
|
||||||
.map_err(|_| PiklError::ChannelClosed)?;
|
.map_err(|_| PiklError::ChannelClosed)?;
|
||||||
}
|
}
|
||||||
@@ -69,9 +69,9 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn run_script_sends_actions_in_order() {
|
async fn run_script_sends_actions_in_order() {
|
||||||
let script = vec![
|
let script = vec![
|
||||||
ScriptAction::Core(Action::UpdateFilter("hello".to_string())),
|
ScriptAction::Core(Action::UpdateFilter("hello".to_string()), Modifiers::default()),
|
||||||
ScriptAction::Core(Action::MoveDown(1)),
|
ScriptAction::Core(Action::MoveDown(1), Modifiers::default()),
|
||||||
ScriptAction::Core(Action::Confirm),
|
ScriptAction::Core(Action::Confirm, Modifiers::default()),
|
||||||
];
|
];
|
||||||
let (tx, mut rx) = mpsc::channel(16);
|
let (tx, mut rx) = mpsc::channel(16);
|
||||||
let result = run_script(script, &tx).await;
|
let result = run_script(script, &tx).await;
|
||||||
@@ -79,14 +79,14 @@ mod tests {
|
|||||||
assert_eq!(result.unwrap_or(Some(ShowAction::Ui)), None);
|
assert_eq!(result.unwrap_or(Some(ShowAction::Ui)), None);
|
||||||
|
|
||||||
// Verify order
|
// Verify order
|
||||||
assert!(matches!(rx.recv().await, Some(Action::UpdateFilter(s)) if s == "hello"));
|
assert!(matches!(rx.recv().await, Some(ModifiedAction { action: Action::UpdateFilter(s), .. }) if s == "hello"));
|
||||||
assert!(matches!(rx.recv().await, Some(Action::MoveDown(1))));
|
assert!(matches!(rx.recv().await, Some(ModifiedAction { action: Action::MoveDown(1), .. })));
|
||||||
assert!(matches!(rx.recv().await, Some(Action::Confirm)));
|
assert!(matches!(rx.recv().await, Some(ModifiedAction { action: Action::Confirm, .. })));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn run_script_returns_none_without_show() {
|
async fn run_script_returns_none_without_show() {
|
||||||
let script = vec![ScriptAction::Core(Action::Confirm)];
|
let script = vec![ScriptAction::Core(Action::Confirm, Modifiers::default())];
|
||||||
let (tx, _rx) = mpsc::channel(16);
|
let (tx, _rx) = mpsc::channel(16);
|
||||||
let result = run_script(script, &tx).await;
|
let result = run_script(script, &tx).await;
|
||||||
assert!(result.is_ok());
|
assert!(result.is_ok());
|
||||||
@@ -96,7 +96,7 @@ mod tests {
|
|||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn run_script_returns_show_action() {
|
async fn run_script_returns_show_action() {
|
||||||
let script = vec![
|
let script = vec![
|
||||||
ScriptAction::Core(Action::UpdateFilter("test".to_string())),
|
ScriptAction::Core(Action::UpdateFilter("test".to_string()), Modifiers::default()),
|
||||||
ScriptAction::ShowUi,
|
ScriptAction::ShowUi,
|
||||||
];
|
];
|
||||||
let (tx, _rx) = mpsc::channel(16);
|
let (tx, _rx) = mpsc::channel(16);
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
use std::io::BufRead;
|
use std::io::BufRead;
|
||||||
|
|
||||||
use crate::event::{Action, Mode};
|
use crate::event::{Action, Mode, Modifiers};
|
||||||
|
|
||||||
use super::ScriptAction;
|
use super::ScriptAction;
|
||||||
use super::error::{ScriptError, ScriptErrorKind};
|
use super::error::{ScriptError, ScriptErrorKind};
|
||||||
@@ -82,6 +82,30 @@ fn parse_positive_u16(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Parse modifier prefixes from the start of a trimmed line.
|
||||||
|
/// Returns (modifiers, remaining_line).
|
||||||
|
fn parse_modifier_prefix(line: &str) -> Result<(Modifiers, &str), Option<String>> {
|
||||||
|
let mut mods = Modifiers::default();
|
||||||
|
let mut rest = line;
|
||||||
|
|
||||||
|
while let Some(stripped) = rest.strip_prefix('+') {
|
||||||
|
// Find the end of this modifier token (next space or '+')
|
||||||
|
let end = stripped
|
||||||
|
.find([' ', '+'])
|
||||||
|
.unwrap_or(stripped.len());
|
||||||
|
let token = &stripped[..end];
|
||||||
|
match token {
|
||||||
|
"shift" => mods.shift = true,
|
||||||
|
"ctrl" => mods.ctrl = true,
|
||||||
|
"alt" => mods.alt = true,
|
||||||
|
other => return Err(Some(format!("unknown modifier '+{other}'"))),
|
||||||
|
}
|
||||||
|
rest = stripped[end..].trim_start();
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok((mods, rest))
|
||||||
|
}
|
||||||
|
|
||||||
/// Parse a single line into a ScriptAction.
|
/// Parse a single line into a ScriptAction.
|
||||||
pub fn parse_action(line_number: usize, line: &str) -> Result<ScriptAction, ScriptError> {
|
pub fn parse_action(line_number: usize, line: &str) -> Result<ScriptAction, ScriptError> {
|
||||||
let trimmed = line.trim();
|
let trimmed = line.trim();
|
||||||
@@ -90,41 +114,66 @@ pub fn parse_action(line_number: usize, line: &str) -> Result<ScriptAction, Scri
|
|||||||
return Ok(ScriptAction::Comment);
|
return Ok(ScriptAction::Comment);
|
||||||
}
|
}
|
||||||
|
|
||||||
let (cmd, arg) = match trimmed.split_once(' ') {
|
// Parse optional modifier prefixes (+shift, +ctrl, +alt)
|
||||||
|
let (modifiers, rest) = parse_modifier_prefix(trimmed).map_err(|msg| ScriptError {
|
||||||
|
line: line_number,
|
||||||
|
source_line: line.to_string(),
|
||||||
|
kind: ScriptErrorKind::InvalidArgument {
|
||||||
|
action: String::new(),
|
||||||
|
message: msg.unwrap_or_default(),
|
||||||
|
},
|
||||||
|
})?;
|
||||||
|
|
||||||
|
if rest.is_empty() {
|
||||||
|
return Err(ScriptError {
|
||||||
|
line: line_number,
|
||||||
|
source_line: line.to_string(),
|
||||||
|
kind: ScriptErrorKind::InvalidArgument {
|
||||||
|
action: String::new(),
|
||||||
|
message: "modifier prefix without an action".to_string(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let (cmd, arg) = match rest.split_once(' ') {
|
||||||
Some((c, a)) => (c, Some(a)),
|
Some((c, a)) => (c, Some(a)),
|
||||||
None => (trimmed, None),
|
None => (rest, None),
|
||||||
|
};
|
||||||
|
|
||||||
|
let core = |action: Action| -> Result<ScriptAction, ScriptError> {
|
||||||
|
Ok(ScriptAction::Core(action, modifiers))
|
||||||
};
|
};
|
||||||
|
|
||||||
match cmd {
|
match cmd {
|
||||||
"filter" => {
|
"filter" => {
|
||||||
let text = arg.unwrap_or("");
|
let text = arg.unwrap_or("");
|
||||||
Ok(ScriptAction::Core(Action::UpdateFilter(text.to_string())))
|
core(Action::UpdateFilter(text.to_string()))
|
||||||
}
|
}
|
||||||
"move-up" => {
|
"move-up" => {
|
||||||
let n = parse_count(line_number, line, "move-up", arg)?;
|
let n = parse_count(line_number, line, "move-up", arg)?;
|
||||||
Ok(ScriptAction::Core(Action::MoveUp(n)))
|
core(Action::MoveUp(n))
|
||||||
}
|
}
|
||||||
"move-down" => {
|
"move-down" => {
|
||||||
let n = parse_count(line_number, line, "move-down", arg)?;
|
let n = parse_count(line_number, line, "move-down", arg)?;
|
||||||
Ok(ScriptAction::Core(Action::MoveDown(n)))
|
core(Action::MoveDown(n))
|
||||||
}
|
}
|
||||||
"move-to-top" => Ok(ScriptAction::Core(Action::MoveToTop)),
|
"move-to-top" => core(Action::MoveToTop),
|
||||||
"move-to-bottom" => Ok(ScriptAction::Core(Action::MoveToBottom)),
|
"move-to-bottom" => core(Action::MoveToBottom),
|
||||||
"page-up" => {
|
"page-up" => {
|
||||||
let n = parse_count(line_number, line, "page-up", arg)?;
|
let n = parse_count(line_number, line, "page-up", arg)?;
|
||||||
Ok(ScriptAction::Core(Action::PageUp(n)))
|
core(Action::PageUp(n))
|
||||||
}
|
}
|
||||||
"page-down" => {
|
"page-down" => {
|
||||||
let n = parse_count(line_number, line, "page-down", arg)?;
|
let n = parse_count(line_number, line, "page-down", arg)?;
|
||||||
Ok(ScriptAction::Core(Action::PageDown(n)))
|
core(Action::PageDown(n))
|
||||||
}
|
}
|
||||||
"half-page-up" => {
|
"half-page-up" => {
|
||||||
let n = parse_count(line_number, line, "half-page-up", arg)?;
|
let n = parse_count(line_number, line, "half-page-up", arg)?;
|
||||||
Ok(ScriptAction::Core(Action::HalfPageUp(n)))
|
core(Action::HalfPageUp(n))
|
||||||
}
|
}
|
||||||
"half-page-down" => {
|
"half-page-down" => {
|
||||||
let n = parse_count(line_number, line, "half-page-down", arg)?;
|
let n = parse_count(line_number, line, "half-page-down", arg)?;
|
||||||
Ok(ScriptAction::Core(Action::HalfPageDown(n)))
|
core(Action::HalfPageDown(n))
|
||||||
}
|
}
|
||||||
"set-mode" => {
|
"set-mode" => {
|
||||||
let Some(mode_str) = arg else {
|
let Some(mode_str) = arg else {
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
18
crates/pikl-gui/Cargo.toml
Normal file
18
crates/pikl-gui/Cargo.toml
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
[package]
|
||||||
|
name = "pikl-gui"
|
||||||
|
description = "GUI frontend for pikl-menu (iced-based, with platform-specific windowing)."
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
|
||||||
|
[lints]
|
||||||
|
workspace = true
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
pikl-core = { path = "../pikl-core" }
|
||||||
|
iced = { version = "0.14", features = ["tokio"] }
|
||||||
|
tokio = { version = "1", features = ["sync", "macros", "rt"] }
|
||||||
|
tracing = "0.1"
|
||||||
|
|
||||||
|
[target.'cfg(target_os = "linux")'.dependencies]
|
||||||
|
iced_layershell = "0.15"
|
||||||
681
crates/pikl-gui/src/lib.rs
Normal file
681
crates/pikl-gui/src/lib.rs
Normal file
@@ -0,0 +1,681 @@
|
|||||||
|
//! GUI frontend for pikl-menu. Platform-aware: uses Wayland
|
||||||
|
//! layer-shell on Linux, native window overlay on macOS.
|
||||||
|
|
||||||
|
use std::sync::{Mutex, OnceLock};
|
||||||
|
|
||||||
|
use iced::keyboard::key::Named;
|
||||||
|
use iced::keyboard::{Key, Modifiers};
|
||||||
|
use iced::widget::{column, container, row, scrollable, text, text_input, Column};
|
||||||
|
use iced::{Color, Element, Font, Length, Subscription, Task, Theme};
|
||||||
|
use tokio::sync::{broadcast, mpsc};
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
use iced_layershell::to_layer_message;
|
||||||
|
|
||||||
|
use pikl_core::event::{
|
||||||
|
Action, MenuEvent, ModifiedAction, Modifiers as PiklModifiers, Mode, ViewState, VisibleItem,
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Number of visible items in the list viewport.
|
||||||
|
const VIEWPORT_HEIGHT: u16 = 20;
|
||||||
|
|
||||||
|
/// GUI error type. Wraps platform-specific errors from the
|
||||||
|
/// iced backends into a single Display-able type.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct GuiError(String);
|
||||||
|
|
||||||
|
impl std::fmt::Display for GuiError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.write_str(&self.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for GuiError {}
|
||||||
|
|
||||||
|
/// Pending key state for multi-key sequences (e.g. `gg`).
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
enum PendingKey {
|
||||||
|
None,
|
||||||
|
G,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Messages for the iced application. On Linux the
|
||||||
|
/// `#[to_layer_message]` macro adds layer-shell variants
|
||||||
|
/// that the wildcard arm in `update` handles.
|
||||||
|
#[cfg_attr(target_os = "linux", to_layer_message)]
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
enum Message {
|
||||||
|
/// A state snapshot arrived from the core engine.
|
||||||
|
StateChanged(ViewState),
|
||||||
|
/// The core signalled that the menu is done.
|
||||||
|
Done,
|
||||||
|
/// Text input changed (insert mode filter).
|
||||||
|
FilterChanged(String),
|
||||||
|
/// A keyboard event from iced.
|
||||||
|
KeyPressed(Key, Modifiers),
|
||||||
|
/// Fire-and-forget action sent, nothing to do.
|
||||||
|
Sent,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Global slot for the bridge receiver. Used by the
|
||||||
|
/// subscription fn pointer since `Subscription::run`
|
||||||
|
/// doesn't accept closures.
|
||||||
|
static BRIDGE_RX: OnceLock<Mutex<Option<mpsc::Receiver<Message>>>> = OnceLock::new();
|
||||||
|
|
||||||
|
/// Global slot for the action sender. Moved into the Pikl
|
||||||
|
/// struct during boot (called exactly once by iced).
|
||||||
|
static ACTION_TX: OnceLock<Mutex<Option<mpsc::Sender<ModifiedAction>>>> = OnceLock::new();
|
||||||
|
|
||||||
|
/// Top-level GUI state.
|
||||||
|
struct Pikl {
|
||||||
|
/// Channel to send actions into the core engine.
|
||||||
|
action_tx: mpsc::Sender<ModifiedAction>,
|
||||||
|
/// Current view snapshot from core.
|
||||||
|
view_state: Option<ViewState>,
|
||||||
|
/// Local copy of filter text for the text input widget.
|
||||||
|
filter_text: String,
|
||||||
|
/// Current input mode (synced from core).
|
||||||
|
mode: Mode,
|
||||||
|
/// Pending key for multi-key sequences.
|
||||||
|
pending: PendingKey,
|
||||||
|
/// Whether multi-select is enabled (synced from core).
|
||||||
|
multi_enabled: bool,
|
||||||
|
/// Visual mode anchor index.
|
||||||
|
visual_anchor: Option<usize>,
|
||||||
|
/// Last generation seen, to skip duplicate broadcasts.
|
||||||
|
last_generation: u64,
|
||||||
|
/// Whether we've focused the input after the first render.
|
||||||
|
initial_focus_done: bool,
|
||||||
|
/// ID for the filter text input widget.
|
||||||
|
filter_input_id: iced::widget::Id,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Boot closure shared between platforms. Creates the initial
|
||||||
|
/// Pikl state and returns a focus task for the filter input.
|
||||||
|
fn boot() -> (Pikl, Task<Message>) {
|
||||||
|
let input_id = iced::widget::Id::unique();
|
||||||
|
let atx = ACTION_TX
|
||||||
|
.get()
|
||||||
|
.and_then(|m| m.lock().unwrap_or_else(|e| e.into_inner()).take())
|
||||||
|
.unwrap_or_else(|| {
|
||||||
|
let (tx, _) = mpsc::channel::<ModifiedAction>(1);
|
||||||
|
tx
|
||||||
|
});
|
||||||
|
let pikl = Pikl {
|
||||||
|
action_tx: atx,
|
||||||
|
view_state: None,
|
||||||
|
filter_text: String::new(),
|
||||||
|
mode: Mode::Insert,
|
||||||
|
pending: PendingKey::None,
|
||||||
|
multi_enabled: false,
|
||||||
|
visual_anchor: None,
|
||||||
|
last_generation: 0,
|
||||||
|
initial_focus_done: false,
|
||||||
|
filter_input_id: input_id.clone(),
|
||||||
|
};
|
||||||
|
(pikl, iced::widget::operation::focus(input_id))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Platform: Wayland layer-shell (Linux) ──────────────
|
||||||
|
|
||||||
|
#[cfg(target_os = "linux")]
|
||||||
|
pub fn run(
|
||||||
|
action_tx: mpsc::Sender<ModifiedAction>,
|
||||||
|
event_rx: broadcast::Receiver<MenuEvent>,
|
||||||
|
) -> Result<(), GuiError> {
|
||||||
|
use iced_layershell::build_pattern::application as layer_application;
|
||||||
|
use iced_layershell::reexport::{Anchor, KeyboardInteractivity, Layer};
|
||||||
|
use iced_layershell::settings::{LayerShellSettings, Settings};
|
||||||
|
|
||||||
|
setup_bridge(action_tx, event_rx);
|
||||||
|
|
||||||
|
layer_application(boot, "pikl", update, view)
|
||||||
|
.settings(Settings {
|
||||||
|
layer_settings: LayerShellSettings {
|
||||||
|
layer: Layer::Overlay,
|
||||||
|
anchor: Anchor::Top | Anchor::Left | Anchor::Right,
|
||||||
|
size: Some((0, 600)),
|
||||||
|
keyboard_interactivity: KeyboardInteractivity::Exclusive,
|
||||||
|
exclusive_zone: -1,
|
||||||
|
margin: (200, 400, 0, 400),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.subscription(subscription)
|
||||||
|
.theme(theme)
|
||||||
|
.run()
|
||||||
|
.map_err(|e| GuiError(format!("{e}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Platform: native window overlay (macOS) ────────────
|
||||||
|
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
pub fn run(
|
||||||
|
action_tx: mpsc::Sender<ModifiedAction>,
|
||||||
|
event_rx: broadcast::Receiver<MenuEvent>,
|
||||||
|
) -> Result<(), GuiError> {
|
||||||
|
setup_bridge(action_tx, event_rx);
|
||||||
|
|
||||||
|
iced::application(boot, update, view)
|
||||||
|
.title("pikl")
|
||||||
|
.window_size((800, 600))
|
||||||
|
.centered()
|
||||||
|
.decorations(false)
|
||||||
|
.level(iced::window::Level::AlwaysOnTop)
|
||||||
|
.subscription(subscription)
|
||||||
|
.theme(theme)
|
||||||
|
.run()
|
||||||
|
.map_err(|e| GuiError(format!("{e}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Shared setup ───────────────────────────────────────
|
||||||
|
|
||||||
|
/// Send initial resize to core, spawn the bridge thread,
|
||||||
|
/// and stash channels in global slots.
|
||||||
|
fn setup_bridge(
|
||||||
|
action_tx: mpsc::Sender<ModifiedAction>,
|
||||||
|
event_rx: broadcast::Receiver<MenuEvent>,
|
||||||
|
) {
|
||||||
|
// try_send rather than blocking_send: this is the first
|
||||||
|
// message on a fresh channel so it always has capacity,
|
||||||
|
// and try_send works regardless of whether we're inside
|
||||||
|
// a tokio runtime context (macOS calls this from the
|
||||||
|
// main thread outside block_on).
|
||||||
|
let _ = action_tx.try_send(ModifiedAction::new(Action::Resize {
|
||||||
|
height: VIEWPORT_HEIGHT,
|
||||||
|
}));
|
||||||
|
|
||||||
|
let (bridge_tx, bridge_rx) = mpsc::channel::<Message>(64);
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
bridge_core_events(event_rx, bridge_tx);
|
||||||
|
});
|
||||||
|
|
||||||
|
let _ = BRIDGE_RX.set(Mutex::new(Some(bridge_rx)));
|
||||||
|
let _ = ACTION_TX.set(Mutex::new(Some(action_tx)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read core events from the broadcast channel and forward
|
||||||
|
/// them as Messages. Runs on a dedicated thread with its
|
||||||
|
/// own tokio runtime so it works regardless of whether the
|
||||||
|
/// caller's runtime is still alive.
|
||||||
|
fn bridge_core_events(
|
||||||
|
mut event_rx: broadcast::Receiver<MenuEvent>,
|
||||||
|
bridge_tx: mpsc::Sender<Message>,
|
||||||
|
) {
|
||||||
|
let rt = match tokio::runtime::Builder::new_current_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
{
|
||||||
|
Ok(rt) => rt,
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
rt.block_on(async {
|
||||||
|
loop {
|
||||||
|
match event_rx.recv().await {
|
||||||
|
Ok(MenuEvent::StateChanged(vs)) => {
|
||||||
|
if bridge_tx.send(Message::StateChanged(vs)).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(
|
||||||
|
MenuEvent::Selected(_)
|
||||||
|
| MenuEvent::Quicklist(_)
|
||||||
|
| MenuEvent::Cancelled,
|
||||||
|
) => {
|
||||||
|
let _ = bridge_tx.send(Message::Done).await;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
Err(broadcast::error::RecvError::Lagged(n)) => {
|
||||||
|
tracing::warn!(skipped = n, "GUI fell behind on state broadcasts");
|
||||||
|
}
|
||||||
|
Err(broadcast::error::RecvError::Closed) => {
|
||||||
|
let _ = bridge_tx.send(Message::Done).await;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Update / View / Subscription ───────────────────────
|
||||||
|
|
||||||
|
fn update(state: &mut Pikl, message: Message) -> Task<Message> {
|
||||||
|
#[allow(unreachable_patterns)]
|
||||||
|
match message {
|
||||||
|
Message::StateChanged(vs) => {
|
||||||
|
if vs.generation == state.last_generation {
|
||||||
|
return Task::none();
|
||||||
|
}
|
||||||
|
state.last_generation = vs.generation;
|
||||||
|
|
||||||
|
if &*vs.filter_text != state.filter_text.as_str() {
|
||||||
|
state.filter_text = vs.filter_text.to_string();
|
||||||
|
}
|
||||||
|
if vs.mode != state.mode {
|
||||||
|
state.mode = vs.mode;
|
||||||
|
state.pending = PendingKey::None;
|
||||||
|
}
|
||||||
|
state.multi_enabled = vs.multi_enabled;
|
||||||
|
state.view_state = Some(vs);
|
||||||
|
|
||||||
|
// Re-focus the input on the first state update.
|
||||||
|
// The boot focus task can fire before the widget
|
||||||
|
// tree exists, so this catches the case where the
|
||||||
|
// input starts unfocused.
|
||||||
|
if !state.initial_focus_done {
|
||||||
|
state.initial_focus_done = true;
|
||||||
|
iced::widget::operation::focus(state.filter_input_id.clone())
|
||||||
|
} else {
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Message::Done => iced::exit(),
|
||||||
|
Message::FilterChanged(new_text) => {
|
||||||
|
if state.mode != Mode::Insert {
|
||||||
|
return Task::none();
|
||||||
|
}
|
||||||
|
state.filter_text = new_text.clone();
|
||||||
|
let tx = state.action_tx.clone();
|
||||||
|
Task::perform(
|
||||||
|
async move {
|
||||||
|
let _ = tx.send(ModifiedAction::new(Action::UpdateFilter(new_text))).await;
|
||||||
|
},
|
||||||
|
|_| Message::Sent,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Message::KeyPressed(key, modifiers) => handle_key(state, key, modifiers),
|
||||||
|
Message::Sent => Task::none(),
|
||||||
|
_ => Task::none(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn view(state: &Pikl) -> Element<'_, Message> {
|
||||||
|
let filter_input = text_input("filter...", &state.filter_text)
|
||||||
|
.id(state.filter_input_id.clone())
|
||||||
|
.on_input(Message::FilterChanged)
|
||||||
|
.padding(8)
|
||||||
|
.size(16)
|
||||||
|
.font(Font::MONOSPACE);
|
||||||
|
|
||||||
|
let mode_indicator = match state.mode {
|
||||||
|
Mode::Insert => "[I]",
|
||||||
|
Mode::Normal => "[N]",
|
||||||
|
Mode::Visual => "[V]",
|
||||||
|
};
|
||||||
|
|
||||||
|
let counts = if let Some(vs) = &state.view_state {
|
||||||
|
let streaming = if vs.streaming { "..." } else { "" };
|
||||||
|
let sel = if vs.selection_count > 0 {
|
||||||
|
format!(" [{}]", vs.selection_count)
|
||||||
|
} else {
|
||||||
|
String::new()
|
||||||
|
};
|
||||||
|
format!(
|
||||||
|
"{}/{}{streaming}{sel}",
|
||||||
|
vs.total_filtered, vs.total_items
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
String::from("0/0")
|
||||||
|
};
|
||||||
|
|
||||||
|
let prompt_row = row![
|
||||||
|
text(mode_indicator)
|
||||||
|
.font(Font::MONOSPACE)
|
||||||
|
.size(16)
|
||||||
|
.color(Color::from_rgb(0.4, 0.8, 1.0)),
|
||||||
|
filter_input,
|
||||||
|
text(counts)
|
||||||
|
.font(Font::MONOSPACE)
|
||||||
|
.size(14)
|
||||||
|
.color(Color::from_rgb(0.5, 0.5, 0.5)),
|
||||||
|
]
|
||||||
|
.spacing(8)
|
||||||
|
.align_y(iced::Alignment::Center)
|
||||||
|
.padding(4);
|
||||||
|
|
||||||
|
let items_list: Element<Message> = if let Some(vs) = &state.view_state {
|
||||||
|
let items: Vec<Element<Message>> = vs
|
||||||
|
.visible_items
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.map(|(i, vi)| render_item(state, i, vi, vs))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
scrollable(
|
||||||
|
Column::with_children(items)
|
||||||
|
.spacing(0)
|
||||||
|
.width(Length::Fill),
|
||||||
|
)
|
||||||
|
.height(Length::Fill)
|
||||||
|
.into()
|
||||||
|
} else {
|
||||||
|
text("loading...").font(Font::MONOSPACE).size(14).into()
|
||||||
|
};
|
||||||
|
|
||||||
|
container(
|
||||||
|
column![prompt_row, items_list]
|
||||||
|
.spacing(0)
|
||||||
|
.width(Length::Fill),
|
||||||
|
)
|
||||||
|
.style(|_theme| container::Style {
|
||||||
|
background: Some(iced::Background::Color(Color::from_rgb(
|
||||||
|
0.12, 0.12, 0.15,
|
||||||
|
))),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.width(Length::Fill)
|
||||||
|
.height(Length::Fill)
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn theme(_state: &Pikl) -> Theme {
|
||||||
|
Theme::Dark
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_item<'a>(
|
||||||
|
state: &Pikl,
|
||||||
|
index: usize,
|
||||||
|
vi: &VisibleItem,
|
||||||
|
vs: &ViewState,
|
||||||
|
) -> Element<'a, Message> {
|
||||||
|
let is_cursor = index == vs.cursor;
|
||||||
|
let in_visual = if vs.mode == Mode::Visual {
|
||||||
|
state.visual_anchor.is_some_and(|anchor| {
|
||||||
|
let min = anchor.min(vs.cursor);
|
||||||
|
let max = anchor.max(vs.cursor);
|
||||||
|
(min..=max).contains(&index)
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
};
|
||||||
|
|
||||||
|
let display_text = vi.formatted_text.as_deref().unwrap_or(vi.label.as_str());
|
||||||
|
|
||||||
|
let label = if state.multi_enabled {
|
||||||
|
let marker = if vi.selected { "* " } else { " " };
|
||||||
|
format!("{marker}{display_text}")
|
||||||
|
} else {
|
||||||
|
display_text.to_string()
|
||||||
|
};
|
||||||
|
|
||||||
|
let text_color = if vi.selected {
|
||||||
|
Color::from_rgb(0.4, 0.9, 0.4)
|
||||||
|
} else if is_cursor || in_visual {
|
||||||
|
Color::from_rgb(0.95, 0.95, 0.95)
|
||||||
|
} else {
|
||||||
|
Color::from_rgb(0.8, 0.8, 0.8)
|
||||||
|
};
|
||||||
|
|
||||||
|
let bg_color = if is_cursor {
|
||||||
|
Color::from_rgb(0.25, 0.25, 0.35)
|
||||||
|
} else if in_visual {
|
||||||
|
Color::from_rgb(0.15, 0.2, 0.35)
|
||||||
|
} else {
|
||||||
|
Color::TRANSPARENT
|
||||||
|
};
|
||||||
|
|
||||||
|
container(
|
||||||
|
text(label)
|
||||||
|
.font(Font::MONOSPACE)
|
||||||
|
.size(15)
|
||||||
|
.color(text_color),
|
||||||
|
)
|
||||||
|
.style(move |_theme| container::Style {
|
||||||
|
background: Some(iced::Background::Color(bg_color)),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.padding([4, 10])
|
||||||
|
.width(Length::Fill)
|
||||||
|
.into()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn subscription(_state: &Pikl) -> Subscription<Message> {
|
||||||
|
let keyboard_sub = iced::event::listen_with(|event, _status, _id| match event {
|
||||||
|
iced::Event::Keyboard(iced::keyboard::Event::KeyPressed {
|
||||||
|
key,
|
||||||
|
modifiers,
|
||||||
|
..
|
||||||
|
}) => Some(Message::KeyPressed(key, modifiers)),
|
||||||
|
_ => None,
|
||||||
|
});
|
||||||
|
|
||||||
|
let core_sub = Subscription::run(core_event_stream);
|
||||||
|
|
||||||
|
Subscription::batch([keyboard_sub, core_sub])
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stream factory for the core event subscription. Takes the
|
||||||
|
/// bridge receiver from the global slot and yields Messages
|
||||||
|
/// until the channel closes.
|
||||||
|
fn core_event_stream() -> impl iced::futures::Stream<Item = Message> {
|
||||||
|
let rx = BRIDGE_RX
|
||||||
|
.get()
|
||||||
|
.and_then(|m| m.lock().unwrap_or_else(|e| e.into_inner()).take());
|
||||||
|
|
||||||
|
iced::futures::stream::unfold(rx, |state| async move {
|
||||||
|
let mut rx = state?;
|
||||||
|
match rx.recv().await {
|
||||||
|
Some(msg) => Some((msg, Some(rx))),
|
||||||
|
None => Some((Message::Done, None)),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Keyboard handling ──────────────────────────────────
|
||||||
|
|
||||||
|
/// Convert iced keyboard modifiers to pikl Modifiers.
|
||||||
|
fn iced_modifiers(m: &iced::keyboard::Modifiers) -> PiklModifiers {
|
||||||
|
PiklModifiers {
|
||||||
|
shift: m.shift(),
|
||||||
|
ctrl: m.control(),
|
||||||
|
alt: m.alt(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Handle a keyboard event and return the appropriate task.
|
||||||
|
fn handle_key(state: &mut Pikl, key: Key, modifiers: Modifiers) -> Task<Message> {
|
||||||
|
let action = match state.mode {
|
||||||
|
Mode::Insert => map_insert_key(state, &key, &modifiers),
|
||||||
|
Mode::Normal => map_normal_key(state, &key, &modifiers),
|
||||||
|
Mode::Visual => map_visual_key(state, &key, &modifiers),
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(action) = action {
|
||||||
|
if let Action::SetMode(m) = &action {
|
||||||
|
if *m == Mode::Visual {
|
||||||
|
if let Some(vs) = &state.view_state {
|
||||||
|
state.visual_anchor = Some(vs.cursor);
|
||||||
|
}
|
||||||
|
} else if state.mode == Mode::Visual {
|
||||||
|
state.visual_anchor = None;
|
||||||
|
}
|
||||||
|
state.mode = *m;
|
||||||
|
state.pending = PendingKey::None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let focus_task = if matches!(action, Action::SetMode(Mode::Insert)) {
|
||||||
|
iced::widget::operation::focus(state.filter_input_id.clone())
|
||||||
|
} else {
|
||||||
|
Task::none()
|
||||||
|
};
|
||||||
|
|
||||||
|
let tx = state.action_tx.clone();
|
||||||
|
let mods = iced_modifiers(&modifiers);
|
||||||
|
let send_task = Task::perform(
|
||||||
|
async move {
|
||||||
|
let _ = tx.send(ModifiedAction::with_modifiers(action, mods)).await;
|
||||||
|
},
|
||||||
|
|_| Message::Sent,
|
||||||
|
);
|
||||||
|
|
||||||
|
Task::batch([send_task, focus_task])
|
||||||
|
} else {
|
||||||
|
Task::none()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map keys in insert mode. Returns None for keys that the
|
||||||
|
/// text_input widget handles (regular characters, backspace).
|
||||||
|
fn map_insert_key(state: &mut Pikl, key: &Key, modifiers: &Modifiers) -> Option<Action> {
|
||||||
|
let ctrl = modifiers.control();
|
||||||
|
|
||||||
|
match key {
|
||||||
|
Key::Named(Named::Escape) => Some(Action::Cancel),
|
||||||
|
Key::Named(Named::Enter) => Some(Action::Confirm),
|
||||||
|
Key::Named(Named::ArrowUp) => Some(Action::MoveUp(1)),
|
||||||
|
Key::Named(Named::ArrowDown) => Some(Action::MoveDown(1)),
|
||||||
|
Key::Named(Named::PageUp) => Some(Action::PageUp(1)),
|
||||||
|
Key::Named(Named::PageDown) => Some(Action::PageDown(1)),
|
||||||
|
Key::Named(Named::Tab) if state.multi_enabled => {
|
||||||
|
let tx = state.action_tx.clone();
|
||||||
|
let mods = iced_modifiers(modifiers);
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let _ = tx.send(ModifiedAction::with_modifiers(Action::ToggleSelect, mods)).await;
|
||||||
|
let _ = tx.send(ModifiedAction::with_modifiers(Action::MoveDown(1), mods)).await;
|
||||||
|
});
|
||||||
|
None
|
||||||
|
}
|
||||||
|
Key::Character(c) if ctrl && c.as_str() == "p" => Some(Action::MoveUp(1)),
|
||||||
|
Key::Character(c) if ctrl && c.as_str() == "n" => {
|
||||||
|
Some(Action::SetMode(Mode::Normal))
|
||||||
|
}
|
||||||
|
Key::Character(c) if ctrl && c.as_str() == "q" => Some(Action::Quicklist),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map keys in normal mode.
|
||||||
|
fn map_normal_key(state: &mut Pikl, key: &Key, modifiers: &Modifiers) -> Option<Action> {
|
||||||
|
let ctrl = modifiers.control();
|
||||||
|
|
||||||
|
if state.pending == PendingKey::G {
|
||||||
|
state.pending = PendingKey::None;
|
||||||
|
if let Key::Character(c) = key
|
||||||
|
&& c.as_str() == "g"
|
||||||
|
&& !ctrl
|
||||||
|
{
|
||||||
|
return Some(Action::MoveToTop);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match key {
|
||||||
|
Key::Character(c) if !ctrl => match c.as_str() {
|
||||||
|
"j" => Some(Action::MoveDown(1)),
|
||||||
|
"k" => Some(Action::MoveUp(1)),
|
||||||
|
"G" => Some(Action::MoveToBottom),
|
||||||
|
"g" => {
|
||||||
|
state.pending = PendingKey::G;
|
||||||
|
None
|
||||||
|
}
|
||||||
|
"/" => Some(Action::SetMode(Mode::Insert)),
|
||||||
|
"q" => Some(Action::Cancel),
|
||||||
|
"V" => Some(Action::SetMode(Mode::Visual)),
|
||||||
|
"U" => Some(Action::ClearSelections),
|
||||||
|
"u" => Some(Action::UndoSelection),
|
||||||
|
" " if state.multi_enabled => {
|
||||||
|
let tx = state.action_tx.clone();
|
||||||
|
let mods = iced_modifiers(modifiers);
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let _ = tx.send(ModifiedAction::with_modifiers(Action::ToggleSelect, mods)).await;
|
||||||
|
let _ = tx.send(ModifiedAction::with_modifiers(Action::MoveDown(1), mods)).await;
|
||||||
|
});
|
||||||
|
None
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
},
|
||||||
|
Key::Character(c) if ctrl => match c.as_str() {
|
||||||
|
"d" => Some(Action::HalfPageDown(1)),
|
||||||
|
"u" => Some(Action::HalfPageUp(1)),
|
||||||
|
"f" => Some(Action::PageDown(1)),
|
||||||
|
"b" => Some(Action::PageUp(1)),
|
||||||
|
"e" => Some(Action::SetMode(Mode::Insert)),
|
||||||
|
"q" => Some(Action::Quicklist),
|
||||||
|
"r" => Some(Action::RedoSelection),
|
||||||
|
_ => None,
|
||||||
|
},
|
||||||
|
Key::Named(Named::Enter) => Some(Action::Confirm),
|
||||||
|
Key::Named(Named::Escape) => Some(Action::Cancel),
|
||||||
|
Key::Named(Named::ArrowUp) => Some(Action::MoveUp(1)),
|
||||||
|
Key::Named(Named::ArrowDown) => Some(Action::MoveDown(1)),
|
||||||
|
Key::Named(Named::Tab) if state.multi_enabled => {
|
||||||
|
let tx = state.action_tx.clone();
|
||||||
|
let mods = iced_modifiers(modifiers);
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let _ = tx.send(ModifiedAction::with_modifiers(Action::ToggleSelect, mods)).await;
|
||||||
|
let _ = tx.send(ModifiedAction::with_modifiers(Action::MoveDown(1), mods)).await;
|
||||||
|
});
|
||||||
|
None
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Map keys in visual mode.
|
||||||
|
fn map_visual_key(state: &mut Pikl, key: &Key, modifiers: &Modifiers) -> Option<Action> {
|
||||||
|
let ctrl = modifiers.control();
|
||||||
|
|
||||||
|
if state.pending == PendingKey::G {
|
||||||
|
state.pending = PendingKey::None;
|
||||||
|
if let Key::Character(c) = key
|
||||||
|
&& c.as_str() == "g"
|
||||||
|
&& !ctrl
|
||||||
|
{
|
||||||
|
return Some(Action::MoveToTop);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
match key {
|
||||||
|
Key::Character(c) if !ctrl => match c.as_str() {
|
||||||
|
"j" => Some(Action::MoveDown(1)),
|
||||||
|
"k" => Some(Action::MoveUp(1)),
|
||||||
|
"G" => Some(Action::MoveToBottom),
|
||||||
|
"g" => {
|
||||||
|
state.pending = PendingKey::G;
|
||||||
|
None
|
||||||
|
}
|
||||||
|
"V" => Some(Action::SetMode(Mode::Normal)),
|
||||||
|
" " => {
|
||||||
|
if let (Some(anchor), Some(vs)) =
|
||||||
|
(state.visual_anchor, &state.view_state)
|
||||||
|
{
|
||||||
|
let tx = state.action_tx.clone();
|
||||||
|
let start = anchor;
|
||||||
|
let end = vs.cursor;
|
||||||
|
let mods = iced_modifiers(modifiers);
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let _ = tx
|
||||||
|
.send(ModifiedAction::with_modifiers(Action::SelectRange { start, end }, mods))
|
||||||
|
.await;
|
||||||
|
let _ = tx.send(ModifiedAction::with_modifiers(Action::SetMode(Mode::Normal), mods)).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
state.visual_anchor = None;
|
||||||
|
None
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
},
|
||||||
|
Key::Named(Named::Enter) => {
|
||||||
|
if let (Some(anchor), Some(vs)) =
|
||||||
|
(state.visual_anchor, &state.view_state)
|
||||||
|
{
|
||||||
|
let tx = state.action_tx.clone();
|
||||||
|
let start = anchor;
|
||||||
|
let end = vs.cursor;
|
||||||
|
let mods = iced_modifiers(modifiers);
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let _ = tx
|
||||||
|
.send(ModifiedAction::with_modifiers(Action::SelectRange { start, end }, mods))
|
||||||
|
.await;
|
||||||
|
let _ = tx.send(ModifiedAction::with_modifiers(Action::SetMode(Mode::Normal), mods)).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
state.visual_anchor = None;
|
||||||
|
None
|
||||||
|
}
|
||||||
|
Key::Named(Named::ArrowDown) => Some(Action::MoveDown(1)),
|
||||||
|
Key::Named(Named::ArrowUp) => Some(Action::MoveUp(1)),
|
||||||
|
Key::Named(Named::Escape) => Some(Action::Cancel),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
);
|
);
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
|||||||
@@ -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"] }
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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'`.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user