feat(gui): Add Wayland GUI frontend to menu system.

This commit is contained in:
2026-03-15 10:33:54 -04:00
parent e77ecb447e
commit 0d241841bc
7 changed files with 4134 additions and 21 deletions

3431
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -0,0 +1,16 @@
[package]
name = "pikl-gui"
description = "GUI frontend for pikl-menu (iced + Wayland layer-shell)."
version.workspace = true
edition.workspace = true
license.workspace = true
[lints]
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"

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

@@ -0,0 +1,617 @@
//! 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.
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 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};
use pikl_core::event::{Action, MenuEvent, Mode, ViewState, VisibleItem};
/// Number of visible items in the list viewport.
const VIEWPORT_HEIGHT: u16 = 20;
/// 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. The `#[to_layer_message]`
/// macro generates the `TryInto<LayershellCustomActionWithId>`
/// impl that iced_layershell requires.
#[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<Action>>>> = OnceLock::new();
/// Top-level GUI state.
struct Pikl {
/// Channel to send actions into the core engine.
action_tx: mpsc::Sender<Action>,
/// 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,
}
/// 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<Action>,
event_rx: broadcast::Receiver<MenuEvent>,
) -> Result<(), iced_layershell::Error> {
// Send initial resize to core so it knows our viewport.
let _ = action_tx.blocking_send(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(
|| {
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(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))
},
"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()
}
/// 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;
}
}
}
});
}
fn update(state: &mut Pikl, message: Message) -> Task<Message> {
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 Hyprland 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(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> {
// 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 {
Some(msg) => Some((msg, Some(rx))),
None => Some((Message::Done, None)),
}
})
}
/// 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 send_task = Task::perform(
async move {
let _ = tx.send(action).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();
tokio::spawn(async move {
let _ = tx.send(Action::ToggleSelect).await;
let _ = tx.send(Action::MoveDown(1)).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();
tokio::spawn(async move {
let _ = tx.send(Action::ToggleSelect).await;
let _ = tx.send(Action::MoveDown(1)).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();
tokio::spawn(async move {
let _ = tx.send(Action::ToggleSelect).await;
let _ = tx.send(Action::MoveDown(1)).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;
tokio::spawn(async move {
let _ = tx
.send(Action::SelectRange { start, end })
.await;
let _ = tx.send(Action::SetMode(Mode::Normal)).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;
tokio::spawn(async move {
let _ = tx
.send(Action::SelectRange { start, end })
.await;
let _ = tx.send(Action::SetMode(Mode::Normal)).await;
});
}
state.visual_anchor = None;
None
}
Key::Named(Named::ArrowDown) => Some(Action::MoveDown(1)),
Key::Named(Named::ArrowUp) => Some(Action::MoveUp(1)),
Key::Named(Named::Escape) => Some(Action::Cancel),
_ => None,
}
}

View File

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

View File

@@ -27,6 +27,30 @@ 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 if `$WAYLAND_DISPLAY` is set, otherwise TUI.
fn resolve_frontend_mode(mode_str: &str) -> Option<FrontendMode> {
match mode_str {
"tui" => Some(FrontendMode::Tui),
"gui" => Some(FrontendMode::Gui),
"auto" => {
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 +171,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() {
@@ -279,8 +307,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}");
@@ -329,6 +372,7 @@ fn main() {
history_entries, history_entries,
streaming, streaming,
saved_fd, saved_fd,
frontend_mode,
)) ))
}; };
@@ -498,6 +542,7 @@ async fn run_interactive(
filter_history: Option<Vec<String>>, filter_history: Option<Vec<String>>,
streaming: bool, streaming: bool,
saved_stdin_fd: Option<i32>, 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);
@@ -530,6 +575,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) => {
@@ -543,7 +589,9 @@ 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(Action::Cancel).await;
}); });
@@ -592,13 +640,41 @@ async fn run_interactive(
}); });
} }
let tui_handle = match frontend_mode {
tokio::spawn(async move { pikl_tui::run(action_tx, event_rx, filter_history).await }); 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 => {
// 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.
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}")))
})
});
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()))),
}
}
}
} }
/// Process the menu result: print output to stdout and /// Process the menu result: print output to stdout and

View File

@@ -412,3 +412,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'`.