diff --git a/crates/pikl-gui/Cargo.toml b/crates/pikl-gui/Cargo.toml index d6227dd..aae251d 100644 --- a/crates/pikl-gui/Cargo.toml +++ b/crates/pikl-gui/Cargo.toml @@ -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" diff --git a/crates/pikl-gui/src/lib.rs b/crates/pikl-gui/src/lib.rs index a235289..197e8b0 100644 --- a/crates/pikl-gui/src/lib.rs +++ b/crates/pikl-gui/src/lib.rs @@ -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` -/// 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,73 +90,104 @@ 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. +/// Boot closure shared between platforms. Creates the initial +/// Pikl state and returns a focus task for the filter input. +fn boot() -> (Pikl, Task) { + 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)) +} + +// ── Platform: Wayland layer-shell (Linux) ────────────── + +#[cfg(target_os = "linux")] pub fn run( action_tx: mpsc::Sender, event_rx: broadcast::Receiver, -) -> Result<(), iced_layershell::Error> { - // Send initial resize to core so it knows our viewport. +) -> 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, + event_rx: broadcast::Receiver, +) -> 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, + event_rx: broadcast::Receiver, +) { 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::(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 @@ -192,7 +233,10 @@ fn bridge_core_events( }); } +// ── Update / View / Subscription ─────────────────────── + fn update(state: &mut Pikl, message: Message) -> Task { + #[allow(unreachable_patterns)] match message { Message::StateChanged(vs) => { if vs.generation == state.last_generation { @@ -212,8 +256,8 @@ fn update(state: &mut Pikl, message: Message) -> Task { // 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 +444,10 @@ fn subscription(_state: &Pikl) -> Subscription { /// bridge receiver from the global slot and yields Messages /// until the channel closes. fn core_event_stream() -> impl iced::futures::Stream { - // 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 +457,8 @@ fn core_event_stream() -> impl iced::futures::Stream { }) } +// ── Keyboard handling ────────────────────────────────── + /// Convert iced keyboard modifiers to pikl Modifiers. fn iced_modifiers(m: &iced::keyboard::Modifiers) -> PiklModifiers { PiklModifiers { diff --git a/crates/pikl/src/main.rs b/crates/pikl/src/main.rs index ead7040..019b8bb 100644 --- a/crates/pikl/src/main.rs +++ b/crates/pikl/src/main.rs @@ -35,16 +35,25 @@ 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 { match mode_str { "tui" => Some(FrontendMode::Tui), "gui" => Some(FrontendMode::Gui), "auto" => { - if std::env::var_os("WAYLAND_DISPLAY").is_some() { + #[cfg(target_os = "macos")] + { Some(FrontendMode::Gui) - } else { - Some(FrontendMode::Tui) + } + #[cfg(not(target_os = "macos"))] + { + if std::env::var_os("WAYLAND_DISPLAY").is_some() { + Some(FrontendMode::Gui) + } else { + Some(FrontendMode::Tui) + } } } _ => None, @@ -661,9 +670,8 @@ async fn run_interactive( // 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}"))) - }) + 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 {