feat: Add MacOS support.
This commit is contained in:
@@ -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,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<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(
|
||||
action_tx: mpsc::Sender<ModifiedAction>,
|
||||
event_rx: broadcast::Receiver<MenuEvent>,
|
||||
) -> 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<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 {
|
||||
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::<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
|
||||
@@ -192,7 +233,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 +256,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 +444,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 +457,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 {
|
||||
|
||||
Reference in New Issue
Block a user