Compare commits
3 Commits
6dacfb5207
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
f81c8a20d2
|
|||
|
5e076b8682
|
|||
|
47437f536b
|
@@ -1466,6 +1466,142 @@ mod tests {
|
||||
assert!(events.contains(&HookEventKind::Filter));
|
||||
}
|
||||
|
||||
// -- Modifier flow-through tests --
|
||||
|
||||
#[tokio::test]
|
||||
async fn modifiers_flow_through_to_selected_result() {
|
||||
let (menu, tx) = test_menu();
|
||||
let mut rx = menu.subscribe();
|
||||
let handle = tokio::spawn(async move { menu.run().await });
|
||||
|
||||
let _ = rx.recv().await; // initial
|
||||
let _ = tx
|
||||
.send(ModifiedAction::new(Action::Resize { height: 10 }))
|
||||
.await;
|
||||
let _ = rx.recv().await;
|
||||
|
||||
// Confirm with shift held
|
||||
let shift = Modifiers {
|
||||
shift: true,
|
||||
ctrl: false,
|
||||
alt: false,
|
||||
};
|
||||
let _ = tx
|
||||
.send(ModifiedAction::with_modifiers(Action::Confirm, shift))
|
||||
.await;
|
||||
|
||||
let result = handle
|
||||
.await
|
||||
.unwrap_or(Ok(MenuResult::Cancelled {
|
||||
modifiers: Modifiers::default(),
|
||||
}));
|
||||
match result {
|
||||
Ok(MenuResult::Selected { modifiers, .. }) => {
|
||||
assert!(modifiers.shift, "shift should be set on result");
|
||||
assert!(!modifiers.ctrl);
|
||||
assert!(!modifiers.alt);
|
||||
}
|
||||
other => panic!("expected Selected with shift, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn modifiers_flow_through_to_cancelled_result() {
|
||||
let (menu, tx) = test_menu();
|
||||
let _rx = menu.subscribe();
|
||||
let handle = tokio::spawn(async move { menu.run().await });
|
||||
|
||||
let mods = Modifiers {
|
||||
shift: false,
|
||||
ctrl: true,
|
||||
alt: true,
|
||||
};
|
||||
let _ = tx
|
||||
.send(ModifiedAction::with_modifiers(Action::Cancel, mods))
|
||||
.await;
|
||||
|
||||
let result = handle
|
||||
.await
|
||||
.unwrap_or(Ok(MenuResult::Cancelled {
|
||||
modifiers: Modifiers::default(),
|
||||
}));
|
||||
match result {
|
||||
Ok(MenuResult::Cancelled { modifiers }) => {
|
||||
assert!(!modifiers.shift);
|
||||
assert!(modifiers.ctrl, "ctrl should be set");
|
||||
assert!(modifiers.alt, "alt should be set");
|
||||
}
|
||||
other => panic!("expected Cancelled with ctrl+alt, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn no_modifiers_result_has_empty_modifiers() {
|
||||
let (menu, tx) = test_menu();
|
||||
let mut rx = menu.subscribe();
|
||||
let handle = tokio::spawn(async move { menu.run().await });
|
||||
|
||||
let _ = rx.recv().await; // initial
|
||||
let _ = tx
|
||||
.send(ModifiedAction::new(Action::Resize { height: 10 }))
|
||||
.await;
|
||||
let _ = rx.recv().await;
|
||||
|
||||
// Confirm without modifiers
|
||||
let _ = tx.send(ModifiedAction::new(Action::Confirm)).await;
|
||||
|
||||
let result = handle
|
||||
.await
|
||||
.unwrap_or(Ok(MenuResult::Cancelled {
|
||||
modifiers: Modifiers::default(),
|
||||
}));
|
||||
match result {
|
||||
Ok(MenuResult::Selected { modifiers, .. }) => {
|
||||
assert!(
|
||||
modifiers.is_empty(),
|
||||
"modifiers should be empty when none held"
|
||||
);
|
||||
}
|
||||
other => panic!("expected Selected with empty mods, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn quicklist_carries_modifiers() {
|
||||
let (menu, tx) = test_menu();
|
||||
let mut rx = menu.subscribe();
|
||||
let handle = tokio::spawn(async move { menu.run().await });
|
||||
|
||||
let _ = rx.recv().await; // initial
|
||||
let _ = tx
|
||||
.send(ModifiedAction::new(Action::Resize { height: 10 }))
|
||||
.await;
|
||||
let _ = rx.recv().await;
|
||||
|
||||
let mods = Modifiers {
|
||||
shift: true,
|
||||
ctrl: false,
|
||||
alt: true,
|
||||
};
|
||||
let _ = tx
|
||||
.send(ModifiedAction::with_modifiers(Action::Quicklist, mods))
|
||||
.await;
|
||||
|
||||
let result = handle
|
||||
.await
|
||||
.unwrap_or(Ok(MenuResult::Cancelled {
|
||||
modifiers: Modifiers::default(),
|
||||
}));
|
||||
match result {
|
||||
Ok(MenuResult::Quicklist { modifiers, .. }) => {
|
||||
assert!(modifiers.shift);
|
||||
assert!(!modifiers.ctrl);
|
||||
assert!(modifiers.alt);
|
||||
}
|
||||
other => panic!("expected Quicklist with shift+alt, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
// -- SelectionState unit tests --
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "pikl-gui"
|
||||
description = "GUI frontend for pikl-menu (iced + Wayland layer-shell)."
|
||||
description = "GUI frontend for pikl-menu (iced-based, with platform-specific windowing)."
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
@@ -11,6 +11,8 @@ workspace = true
|
||||
[dependencies]
|
||||
pikl-core = { path = "../pikl-core" }
|
||||
iced = { version = "0.14", features = ["tokio"] }
|
||||
iced_layershell = "0.15"
|
||||
tokio = { version = "1", features = ["sync", "macros", "rt"] }
|
||||
tracing = "0.1"
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
iced_layershell = "0.15"
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
//! GUI frontend for pikl-menu. Renders a Wayland
|
||||
//! layer-shell overlay using iced. Same channel pattern as
|
||||
//! the TUI: receives [`ViewState`] snapshots, sends
|
||||
//! [`Action`]s into the core engine.
|
||||
//! GUI frontend for pikl-menu. Platform-aware: uses Wayland
|
||||
//! layer-shell on Linux, native window overlay on macOS.
|
||||
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
|
||||
@@ -9,12 +7,11 @@ 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 iced_layershell::build_pattern::application as layer_application;
|
||||
use iced_layershell::reexport::{Anchor, KeyboardInteractivity, Layer};
|
||||
use iced_layershell::settings::{LayerShellSettings, Settings};
|
||||
use iced_layershell::to_layer_message;
|
||||
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,
|
||||
};
|
||||
@@ -22,6 +19,19 @@ use pikl_core::event::{
|
||||
/// 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 {
|
||||
@@ -29,10 +39,10 @@ enum PendingKey {
|
||||
G,
|
||||
}
|
||||
|
||||
/// Messages for the iced application. The `#[to_layer_message]`
|
||||
/// macro generates the `TryInto<LayershellCustomActionWithId>`
|
||||
/// impl that iced_layershell requires.
|
||||
#[to_layer_message]
|
||||
/// 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.
|
||||
@@ -80,32 +90,9 @@ struct Pikl {
|
||||
filter_input_id: iced::widget::Id,
|
||||
}
|
||||
|
||||
/// Start the GUI. Creates a Wayland layer-shell overlay,
|
||||
/// runs the iced event loop, and exits when the user
|
||||
/// confirms or cancels.
|
||||
pub fn run(
|
||||
action_tx: mpsc::Sender<ModifiedAction>,
|
||||
event_rx: broadcast::Receiver<MenuEvent>,
|
||||
) -> Result<(), iced_layershell::Error> {
|
||||
// Send initial resize to core so it knows our viewport.
|
||||
let _ = action_tx.blocking_send(ModifiedAction::new(Action::Resize {
|
||||
height: VIEWPORT_HEIGHT,
|
||||
}));
|
||||
|
||||
// Bridge: spawn a thread that reads core events and
|
||||
// forwards them as Messages through an mpsc channel.
|
||||
let (bridge_tx, bridge_rx) = mpsc::channel::<Message>(64);
|
||||
std::thread::spawn(move || {
|
||||
bridge_core_events(event_rx, bridge_tx);
|
||||
});
|
||||
|
||||
// Stash channels in global slots so the boot fn pointer
|
||||
// and subscription fn pointer can access them.
|
||||
let _ = BRIDGE_RX.set(Mutex::new(Some(bridge_rx)));
|
||||
let _ = ACTION_TX.set(Mutex::new(Some(action_tx)));
|
||||
|
||||
layer_application(
|
||||
|| {
|
||||
/// 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()
|
||||
@@ -127,11 +114,22 @@ pub fn run(
|
||||
filter_input_id: input_id.clone(),
|
||||
};
|
||||
(pikl, iced::widget::operation::focus(input_id))
|
||||
},
|
||||
"pikl",
|
||||
update,
|
||||
view,
|
||||
)
|
||||
}
|
||||
|
||||
// ── 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,
|
||||
@@ -147,6 +145,54 @@ pub fn run(
|
||||
.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
|
||||
@@ -192,7 +238,10 @@ fn bridge_core_events(
|
||||
});
|
||||
}
|
||||
|
||||
// ── 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 {
|
||||
@@ -212,8 +261,8 @@ fn update(state: &mut Pikl, message: Message) -> Task<Message> {
|
||||
|
||||
// Re-focus the input on the first state update.
|
||||
// The boot focus task can fire before the widget
|
||||
// tree exists, so this catches the Hyprland case
|
||||
// where the input starts unfocused.
|
||||
// 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())
|
||||
@@ -400,14 +449,10 @@ fn subscription(_state: &Pikl) -> Subscription<Message> {
|
||||
/// bridge receiver from the global slot and yields Messages
|
||||
/// until the channel closes.
|
||||
fn core_event_stream() -> impl iced::futures::Stream<Item = Message> {
|
||||
// Take the receiver from the global slot. Only the first
|
||||
// subscription instantiation will get Some.
|
||||
let rx = BRIDGE_RX
|
||||
.get()
|
||||
.and_then(|m| m.lock().unwrap_or_else(|e| e.into_inner()).take());
|
||||
|
||||
// unfold with the receiver as state. Each step awaits one
|
||||
// message and yields it.
|
||||
iced::futures::stream::unfold(rx, |state| async move {
|
||||
let mut rx = state?;
|
||||
match rx.recv().await {
|
||||
@@ -417,6 +462,8 @@ fn core_event_stream() -> impl iced::futures::Stream<Item = Message> {
|
||||
})
|
||||
}
|
||||
|
||||
// ── Keyboard handling ──────────────────────────────────
|
||||
|
||||
/// Convert iced keyboard modifiers to pikl Modifiers.
|
||||
fn iced_modifiers(m: &iced::keyboard::Modifiers) -> PiklModifiers {
|
||||
PiklModifiers {
|
||||
|
||||
@@ -35,18 +35,27 @@ enum FrontendMode {
|
||||
}
|
||||
|
||||
/// Resolve `--mode` flag into a concrete frontend. "auto"
|
||||
/// picks GUI if `$WAYLAND_DISPLAY` is set, otherwise TUI.
|
||||
/// 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,
|
||||
}
|
||||
}
|
||||
@@ -356,14 +365,23 @@ fn main() {
|
||||
});
|
||||
|
||||
// STEP 4: Branch on headless vs interactive
|
||||
let result = if let Some(script) = script {
|
||||
rt.block_on(run_headless(items, &cli, script, start_mode, column_config))
|
||||
} else {
|
||||
let history_entries = history.as_ref().map(|h| h.entries().to_vec());
|
||||
#[cfg(unix)]
|
||||
let saved_fd = saved_stdin_fd;
|
||||
#[cfg(not(unix))]
|
||||
let saved_fd: Option<i32> = None;
|
||||
|
||||
let result = if let Some(script) = script {
|
||||
rt.block_on(run_headless(items, &cli, script, start_mode, column_config))
|
||||
} else if cfg!(target_os = "macos") && frontend_mode == FrontendMode::Gui {
|
||||
// macOS requires the GUI event loop on the main thread
|
||||
// (AppKit/winit constraint). We can't run it inside
|
||||
// block_on, so the GUI gets the main thread and tokio
|
||||
// work is spawned onto the runtime's worker threads.
|
||||
run_gui_main_thread(
|
||||
&rt, items, &cli, start_mode, column_config, streaming, saved_fd,
|
||||
)
|
||||
} else {
|
||||
let history_entries = history.as_ref().map(|h| h.entries().to_vec());
|
||||
rt.block_on(run_interactive(
|
||||
items,
|
||||
&cli,
|
||||
@@ -653,17 +671,14 @@ async fn run_interactive(
|
||||
result
|
||||
}
|
||||
FrontendMode::Gui => {
|
||||
// GUI runs on the main thread (Wayland requirement).
|
||||
// Spawn the core event loop on a background task.
|
||||
let menu_handle = tokio::spawn(menu.run());
|
||||
|
||||
// pikl_gui::run blocks until the user confirms/cancels.
|
||||
// Run it on a blocking thread so we don't stall the
|
||||
// tokio runtime.
|
||||
// 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(format!("GUI error: {e}")))
|
||||
})
|
||||
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 {
|
||||
@@ -679,6 +694,133 @@ async fn run_interactive(
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// exit with the appropriate code.
|
||||
fn handle_result(result: Result<MenuResult, PiklError>, cli: &Cli) {
|
||||
|
||||
@@ -68,6 +68,96 @@ fn headless_actions_after_show_ui_exits_2() {
|
||||
);
|
||||
}
|
||||
|
||||
// -- Modifier prefix integration tests --
|
||||
|
||||
#[test]
|
||||
fn modifier_prefix_in_structured_output() {
|
||||
let (stdout, _stderr, code) = common::run_pikl(
|
||||
"alpha\nbeta\n",
|
||||
"+shift confirm\n",
|
||||
&["--structured"],
|
||||
);
|
||||
assert_eq!(code, 0, "expected exit 0, stderr: {_stderr}");
|
||||
assert!(
|
||||
stdout.contains(r#""modifiers":["shift"]"#),
|
||||
"expected modifiers array in structured output, got: {stdout}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_modifier_omits_field_in_structured_output() {
|
||||
let (stdout, _stderr, code) = common::run_pikl(
|
||||
"alpha\nbeta\n",
|
||||
"confirm\n",
|
||||
&["--structured"],
|
||||
);
|
||||
assert_eq!(code, 0, "expected exit 0, stderr: {_stderr}");
|
||||
assert!(
|
||||
!stdout.contains("modifiers"),
|
||||
"expected no modifiers field when none held, got: {stdout}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn multiple_modifier_prefixes_in_structured() {
|
||||
let (stdout, _stderr, code) = common::run_pikl(
|
||||
"alpha\nbeta\n",
|
||||
"+ctrl+alt confirm\n",
|
||||
&["--structured"],
|
||||
);
|
||||
assert_eq!(code, 0, "expected exit 0, stderr: {_stderr}");
|
||||
assert!(
|
||||
stdout.contains(r#""modifiers":["ctrl","alt"]"#),
|
||||
"expected ctrl+alt modifiers, got: {stdout}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modifier_prefix_cancel_structured() {
|
||||
let (stdout, _stderr, code) = common::run_pikl(
|
||||
"alpha\n",
|
||||
"+shift cancel\n",
|
||||
&["--structured"],
|
||||
);
|
||||
assert_eq!(code, 1, "expected exit 1 on cancel");
|
||||
assert!(
|
||||
stdout.contains(r#""modifiers":["shift"]"#),
|
||||
"expected modifiers on cancel output, got: {stdout}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn modifier_prefix_does_not_affect_plain_output() {
|
||||
let (stdout, _stderr, code) = common::run_pikl(
|
||||
"alpha\nbeta\n",
|
||||
"+shift confirm\n",
|
||||
&[],
|
||||
);
|
||||
assert_eq!(code, 0, "expected exit 0, stderr: {_stderr}");
|
||||
assert!(
|
||||
!stdout.contains("modifiers"),
|
||||
"plain output should not contain modifiers, got: {stdout}"
|
||||
);
|
||||
assert!(
|
||||
stdout.contains("alpha"),
|
||||
"expected alpha in plain output, got: {stdout}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_modifier_prefix_exits_2() {
|
||||
let (_stdout, stderr, code) = common::run_pikl(
|
||||
"alpha\n",
|
||||
"+super confirm\n",
|
||||
&[],
|
||||
);
|
||||
assert_eq!(code, 2, "expected exit 2 on unknown modifier");
|
||||
assert!(
|
||||
stderr.contains("unknown modifier"),
|
||||
"expected unknown modifier error, got: {stderr}"
|
||||
);
|
||||
}
|
||||
|
||||
// -- CSV/TSV integration tests --
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -331,4 +331,29 @@ pikl_tests! {
|
||||
exit: 0
|
||||
}
|
||||
}
|
||||
|
||||
headless mod modifier_prefix {
|
||||
items: ["alpha", "beta", "gamma"];
|
||||
|
||||
test shift_confirm_plain_output_unchanged {
|
||||
// Modifier prefixes don't affect plain text output.
|
||||
actions: [raw "+shift confirm"]
|
||||
stdout: "alpha"
|
||||
exit: 0
|
||||
}
|
||||
|
||||
test unknown_modifier_errors {
|
||||
actions: [raw "+meta confirm"]
|
||||
stderr contains: "unknown modifier"
|
||||
exit: 2
|
||||
}
|
||||
|
||||
test modifier_with_movement {
|
||||
// Modifiers on navigation don't change behavior,
|
||||
// but they should parse and not break anything.
|
||||
actions: [raw "+shift move-down", confirm]
|
||||
stdout: "beta"
|
||||
exit: 0
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -400,6 +400,19 @@ ipc_demo() {
|
||||
ITEMS
|
||||
}
|
||||
|
||||
modifier_keys_demo() {
|
||||
echo "Modifier key demo. Use --structured to see modifiers in output." >&2
|
||||
echo "Press Enter normally, then try Shift+Enter or Ctrl+Enter." >&2
|
||||
echo "Compare the JSON output: modifiers only appear when held." >&2
|
||||
echo "" >&2
|
||||
cat <<'ITEMS' | pikl --structured
|
||||
{"label": "Firefox", "url": "https://firefox.com"}
|
||||
{"label": "Neovim", "url": "https://neovim.io"}
|
||||
{"label": "Alacritty", "url": "https://alacritty.org"}
|
||||
{"label": "mpv", "url": "https://mpv.io"}
|
||||
ITEMS
|
||||
}
|
||||
|
||||
session_demo() {
|
||||
echo "Session filter history: select with a filter, exit, relaunch." >&2
|
||||
echo "Press Ctrl+P to recall your previous filter." >&2
|
||||
@@ -442,6 +455,7 @@ scenarios=(
|
||||
"---"
|
||||
"IPC remote control"
|
||||
"Session filter history"
|
||||
"Modifier keys (structured output)"
|
||||
"---"
|
||||
"on-select-exec hook (legacy)"
|
||||
)
|
||||
@@ -475,6 +489,7 @@ run_scenario() {
|
||||
*"CSV + field"*) csv_filter ;;
|
||||
*"IPC remote"*) ipc_demo ;;
|
||||
*"Session filter"*) session_demo ;;
|
||||
*"Modifier keys"*) modifier_keys_demo ;;
|
||||
*"on-select-exec"*) on_select_hook ;;
|
||||
"---")
|
||||
echo "that's a separator, not a scenario" >&2
|
||||
|
||||
Reference in New Issue
Block a user