feat(gui): Add Wayland GUI frontend to menu system.
This commit is contained in:
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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user