feat: Add MacOS support.
Some checks failed
CI / Lint (push) Has been cancelled
CI / Test (push) Has been cancelled

This commit is contained in:
2026-03-15 12:17:39 -04:00
parent 47437f536b
commit 5e076b8682
3 changed files with 132 additions and 80 deletions

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "pikl-gui" 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 version.workspace = true
edition.workspace = true edition.workspace = true
license.workspace = true license.workspace = true
@@ -11,6 +11,8 @@ workspace = true
[dependencies] [dependencies]
pikl-core = { path = "../pikl-core" } pikl-core = { path = "../pikl-core" }
iced = { version = "0.14", features = ["tokio"] } iced = { version = "0.14", features = ["tokio"] }
iced_layershell = "0.15"
tokio = { version = "1", features = ["sync", "macros", "rt"] } tokio = { version = "1", features = ["sync", "macros", "rt"] }
tracing = "0.1" tracing = "0.1"
[target.'cfg(target_os = "linux")'.dependencies]
iced_layershell = "0.15"

View File

@@ -1,7 +1,5 @@
//! GUI frontend for pikl-menu. Renders a Wayland //! GUI frontend for pikl-menu. Platform-aware: uses Wayland
//! layer-shell overlay using iced. Same channel pattern as //! layer-shell on Linux, native window overlay on macOS.
//! the TUI: receives [`ViewState`] snapshots, sends
//! [`Action`]s into the core engine.
use std::sync::{Mutex, OnceLock}; use std::sync::{Mutex, OnceLock};
@@ -9,12 +7,11 @@ use iced::keyboard::key::Named;
use iced::keyboard::{Key, Modifiers}; use iced::keyboard::{Key, Modifiers};
use iced::widget::{column, container, row, scrollable, text, text_input, Column}; use iced::widget::{column, container, row, scrollable, text, text_input, Column};
use iced::{Color, Element, Font, Length, Subscription, Task, Theme}; 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 tokio::sync::{broadcast, mpsc};
#[cfg(target_os = "linux")]
use iced_layershell::to_layer_message;
use pikl_core::event::{ use pikl_core::event::{
Action, MenuEvent, ModifiedAction, Modifiers as PiklModifiers, Mode, ViewState, VisibleItem, 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. /// Number of visible items in the list viewport.
const VIEWPORT_HEIGHT: u16 = 20; 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`). /// Pending key state for multi-key sequences (e.g. `gg`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)] #[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum PendingKey { enum PendingKey {
@@ -29,10 +39,10 @@ enum PendingKey {
G, G,
} }
/// Messages for the iced application. The `#[to_layer_message]` /// Messages for the iced application. On Linux the
/// macro generates the `TryInto<LayershellCustomActionWithId>` /// `#[to_layer_message]` macro adds layer-shell variants
/// impl that iced_layershell requires. /// that the wildcard arm in `update` handles.
#[to_layer_message] #[cfg_attr(target_os = "linux", to_layer_message)]
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
enum Message { enum Message {
/// A state snapshot arrived from the core engine. /// A state snapshot arrived from the core engine.
@@ -80,73 +90,104 @@ struct Pikl {
filter_input_id: iced::widget::Id, filter_input_id: iced::widget::Id,
} }
/// Start the GUI. Creates a Wayland layer-shell overlay, /// Boot closure shared between platforms. Creates the initial
/// runs the iced event loop, and exits when the user /// Pikl state and returns a focus task for the filter input.
/// confirms or cancels. 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( pub fn run(
action_tx: mpsc::Sender<ModifiedAction>, action_tx: mpsc::Sender<ModifiedAction>,
event_rx: broadcast::Receiver<MenuEvent>, event_rx: broadcast::Receiver<MenuEvent>,
) -> Result<(), iced_layershell::Error> { ) -> Result<(), GuiError> {
// Send initial resize to core so it knows our viewport. 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>,
) {
let _ = action_tx.blocking_send(ModifiedAction::new(Action::Resize { let _ = action_tx.blocking_send(ModifiedAction::new(Action::Resize {
height: VIEWPORT_HEIGHT, 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); let (bridge_tx, bridge_rx) = mpsc::channel::<Message>(64);
std::thread::spawn(move || { std::thread::spawn(move || {
bridge_core_events(event_rx, bridge_tx); 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 _ = BRIDGE_RX.set(Mutex::new(Some(bridge_rx)));
let _ = ACTION_TX.set(Mutex::new(Some(action_tx))); 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::<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))
},
"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 /// 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<Message> { fn update(state: &mut Pikl, message: Message) -> Task<Message> {
#[allow(unreachable_patterns)]
match message { match message {
Message::StateChanged(vs) => { Message::StateChanged(vs) => {
if vs.generation == state.last_generation { if vs.generation == state.last_generation {
@@ -212,8 +256,8 @@ fn update(state: &mut Pikl, message: Message) -> Task<Message> {
// Re-focus the input on the first state update. // Re-focus the input on the first state update.
// The boot focus task can fire before the widget // The boot focus task can fire before the widget
// tree exists, so this catches the Hyprland case // tree exists, so this catches the case where the
// where the input starts unfocused. // input starts unfocused.
if !state.initial_focus_done { if !state.initial_focus_done {
state.initial_focus_done = true; state.initial_focus_done = true;
iced::widget::operation::focus(state.filter_input_id.clone()) iced::widget::operation::focus(state.filter_input_id.clone())
@@ -400,14 +444,10 @@ fn subscription(_state: &Pikl) -> Subscription<Message> {
/// bridge receiver from the global slot and yields Messages /// bridge receiver from the global slot and yields Messages
/// until the channel closes. /// until the channel closes.
fn core_event_stream() -> impl iced::futures::Stream<Item = Message> { 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 let rx = BRIDGE_RX
.get() .get()
.and_then(|m| m.lock().unwrap_or_else(|e| e.into_inner()).take()); .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 { iced::futures::stream::unfold(rx, |state| async move {
let mut rx = state?; let mut rx = state?;
match rx.recv().await { match rx.recv().await {
@@ -417,6 +457,8 @@ fn core_event_stream() -> impl iced::futures::Stream<Item = Message> {
}) })
} }
// ── Keyboard handling ──────────────────────────────────
/// Convert iced keyboard modifiers to pikl Modifiers. /// Convert iced keyboard modifiers to pikl Modifiers.
fn iced_modifiers(m: &iced::keyboard::Modifiers) -> PiklModifiers { fn iced_modifiers(m: &iced::keyboard::Modifiers) -> PiklModifiers {
PiklModifiers { PiklModifiers {

View File

@@ -35,16 +35,25 @@ enum FrontendMode {
} }
/// Resolve `--mode` flag into a concrete frontend. "auto" /// 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> { fn resolve_frontend_mode(mode_str: &str) -> Option<FrontendMode> {
match mode_str { match mode_str {
"tui" => Some(FrontendMode::Tui), "tui" => Some(FrontendMode::Tui),
"gui" => Some(FrontendMode::Gui), "gui" => Some(FrontendMode::Gui),
"auto" => { "auto" => {
if std::env::var_os("WAYLAND_DISPLAY").is_some() { #[cfg(target_os = "macos")]
{
Some(FrontendMode::Gui) 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, _ => None,
@@ -661,9 +670,8 @@ async fn run_interactive(
// Run it on a blocking thread so we don't stall the // Run it on a blocking thread so we don't stall the
// tokio runtime. // tokio runtime.
let gui_handle = tokio::task::spawn_blocking(move || { let gui_handle = tokio::task::spawn_blocking(move || {
pikl_gui::run(action_tx, event_rx).map_err(|e| { pikl_gui::run(action_tx, event_rx)
PiklError::Io(std::io::Error::other(format!("GUI error: {e}"))) .map_err(|e| PiklError::Io(std::io::Error::other(e.to_string())))
})
}); });
if let Err(e) = gui_handle.await { if let Err(e) = gui_handle.await {