feat(gui): Add Wayland GUI frontend to menu system.
This commit is contained in:
3431
Cargo.lock
generated
3431
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -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",
|
||||||
]
|
]
|
||||||
|
|||||||
16
crates/pikl-gui/Cargo.toml
Normal file
16
crates/pikl-gui/Cargo.toml
Normal 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
617
crates/pikl-gui/src/lib.rs
Normal 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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"] }
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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'`.
|
||||||
|
|||||||
Reference in New Issue
Block a user