diff --git a/crates/pikl-gui/src/lib.rs b/crates/pikl-gui/src/lib.rs index 197e8b0..303aaca 100644 --- a/crates/pikl-gui/src/lib.rs +++ b/crates/pikl-gui/src/lib.rs @@ -177,7 +177,12 @@ fn setup_bridge( action_tx: mpsc::Sender, event_rx: broadcast::Receiver, ) { - let _ = action_tx.blocking_send(ModifiedAction::new(Action::Resize { + // 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, })); diff --git a/crates/pikl/src/main.rs b/crates/pikl/src/main.rs index 019b8bb..3b55b08 100644 --- a/crates/pikl/src/main.rs +++ b/crates/pikl/src/main.rs @@ -365,14 +365,23 @@ fn main() { }); // STEP 4: Branch on headless vs interactive + #[cfg(unix)] + let saved_fd = saved_stdin_fd; + #[cfg(not(unix))] + let saved_fd: Option = 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()); - #[cfg(unix)] - let saved_fd = saved_stdin_fd; - #[cfg(not(unix))] - let saved_fd: Option = None; rt.block_on(run_interactive( items, &cli, @@ -662,13 +671,11 @@ 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(e.to_string()))) @@ -687,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, + cli: &Cli, + start_mode: Mode, + column_config: Option, + streaming: bool, + saved_stdin_fd: Option, +) -> Result { + 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, cli: &Cli) {