feat: Add support for action modifier keys like ctrl,shift,alt.
Some checks failed
CI / Lint (push) Has been cancelled
CI / Test (push) Has been cancelled

This commit is contained in:
2026-03-15 11:51:17 -04:00
parent 0d241841bc
commit 47437f536b
18 changed files with 1055 additions and 244 deletions

View File

@@ -14,7 +14,7 @@ use ratatui::text::{Line, Span};
use ratatui::widgets::{List, ListItem, Paragraph};
use tokio::sync::{broadcast, mpsc};
use pikl_core::event::{Action, MenuEvent, Mode, ViewState};
use pikl_core::event::{Action, MenuEvent, ModifiedAction, Modifiers, Mode, ViewState};
/// Pending key state for multi-key sequences (e.g. `gg`).
/// TUI-local, not part of core state.
@@ -32,12 +32,21 @@ pub fn restore_terminal() {
let _ = crossterm::execute!(std::io::stderr(), crossterm::terminal::LeaveAlternateScreen);
}
/// Convert crossterm key modifiers to pikl Modifiers.
fn crossterm_modifiers(km: KeyModifiers) -> Modifiers {
Modifiers {
shift: km.contains(KeyModifiers::SHIFT),
ctrl: km.contains(KeyModifiers::CONTROL),
alt: km.contains(KeyModifiers::ALT),
}
}
/// Start the TUI. Enters the alternate screen, runs the
/// event loop, and restores the terminal on exit. Translates
/// crossterm key events into [`Action`]s and renders
/// [`ViewState`] snapshots.
pub async fn run(
action_tx: mpsc::Sender<Action>,
action_tx: mpsc::Sender<ModifiedAction>,
mut event_rx: broadcast::Receiver<MenuEvent>,
filter_history: Option<Vec<String>>,
) -> std::io::Result<()> {
@@ -65,7 +74,7 @@ pub async fn run(
/// Inner event loop. Separated from [`run`] so terminal
/// cleanup always happens even if this returns an error.
async fn run_inner(
action_tx: &mpsc::Sender<Action>,
action_tx: &mpsc::Sender<ModifiedAction>,
event_rx: &mut broadcast::Receiver<MenuEvent>,
terminal: &mut Terminal<CrosstermBackend<std::io::Stderr>>,
filter_history: Option<Vec<String>>,
@@ -74,9 +83,9 @@ async fn run_inner(
let size = terminal.size()?;
let list_height = size.height.saturating_sub(1);
if action_tx
.send(Action::Resize {
.send(ModifiedAction::new(Action::Resize {
height: list_height,
})
}))
.await
.is_err()
{
@@ -135,7 +144,7 @@ async fn run_inner(
history_cursor = Some(new_cursor);
filter_text.clone_from(&history[new_cursor]);
let _ = action_tx
.send(Action::UpdateFilter(filter_text.clone()))
.send(ModifiedAction::new(Action::UpdateFilter(filter_text.clone())))
.await;
true
}
@@ -153,7 +162,7 @@ async fn run_inner(
pre_history_text = None;
}
let _ = action_tx
.send(Action::UpdateFilter(filter_text.clone()))
.send(ModifiedAction::new(Action::UpdateFilter(filter_text.clone())))
.await;
true
} else {
@@ -196,14 +205,14 @@ async fn run_inner(
mode = *m;
pending = PendingKey::None;
}
if action_tx.send(action).await.is_err() {
if action_tx.send(ModifiedAction::with_modifiers(action, crossterm_modifiers(key.modifiers))).await.is_err() {
break; // core is gone
}
}
}
Event::Resize(_, h) => {
let list_height = h.saturating_sub(1);
if action_tx.send(Action::Resize { height: list_height }).await.is_err() {
if action_tx.send(ModifiedAction::new(Action::Resize { height: list_height })).await.is_err() {
break;
}
}
@@ -259,7 +268,7 @@ async fn handle_multi_action_key(
multi_enabled: bool,
visual_anchor: &mut Option<usize>,
view_state: &Option<ViewState>,
action_tx: &mpsc::Sender<Action>,
action_tx: &mpsc::Sender<ModifiedAction>,
) -> bool {
match mode {
Mode::Insert => {
@@ -268,13 +277,15 @@ async fn handle_multi_action_key(
}
match key.code {
KeyCode::Tab => {
let _ = action_tx.send(Action::ToggleSelect).await;
let _ = action_tx.send(Action::MoveDown(1)).await;
let mods = crossterm_modifiers(key.modifiers);
let _ = action_tx.send(ModifiedAction::with_modifiers(Action::ToggleSelect, mods)).await;
let _ = action_tx.send(ModifiedAction::with_modifiers(Action::MoveDown(1), mods)).await;
true
}
KeyCode::BackTab => {
let _ = action_tx.send(Action::ToggleSelect).await;
let _ = action_tx.send(Action::MoveUp(1)).await;
let mods = crossterm_modifiers(key.modifiers);
let _ = action_tx.send(ModifiedAction::with_modifiers(Action::ToggleSelect, mods)).await;
let _ = action_tx.send(ModifiedAction::with_modifiers(Action::MoveUp(1), mods)).await;
true
}
_ => false,
@@ -288,18 +299,21 @@ async fn handle_multi_action_key(
(KeyCode::Char(' '), m)
if !m.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) =>
{
let _ = action_tx.send(Action::ToggleSelect).await;
let _ = action_tx.send(Action::MoveDown(1)).await;
let mods = crossterm_modifiers(key.modifiers);
let _ = action_tx.send(ModifiedAction::with_modifiers(Action::ToggleSelect, mods)).await;
let _ = action_tx.send(ModifiedAction::with_modifiers(Action::MoveDown(1), mods)).await;
true
}
(KeyCode::Tab, _) => {
let _ = action_tx.send(Action::ToggleSelect).await;
let _ = action_tx.send(Action::MoveDown(1)).await;
let mods = crossterm_modifiers(key.modifiers);
let _ = action_tx.send(ModifiedAction::with_modifiers(Action::ToggleSelect, mods)).await;
let _ = action_tx.send(ModifiedAction::with_modifiers(Action::MoveDown(1), mods)).await;
true
}
(KeyCode::BackTab, _) => {
let _ = action_tx.send(Action::ToggleSelect).await;
let _ = action_tx.send(Action::MoveUp(1)).await;
let mods = crossterm_modifiers(key.modifiers);
let _ = action_tx.send(ModifiedAction::with_modifiers(Action::ToggleSelect, mods)).await;
let _ = action_tx.send(ModifiedAction::with_modifiers(Action::MoveUp(1), mods)).await;
true
}
_ => false,
@@ -310,18 +324,19 @@ async fn handle_multi_action_key(
(KeyCode::Char(' '), m) | (KeyCode::Enter, m)
if !m.intersects(KeyModifiers::ALT) =>
{
let mods = crossterm_modifiers(key.modifiers);
// Apply visual selection
if let (Some(anchor), Some(vs)) = (*visual_anchor, view_state) {
let cursor = vs.cursor;
let _ = action_tx
.send(Action::SelectRange {
.send(ModifiedAction::with_modifiers(Action::SelectRange {
start: anchor,
end: cursor,
})
}, mods))
.await;
}
*visual_anchor = None;
let _ = action_tx.send(Action::SetMode(Mode::Normal)).await;
let _ = action_tx.send(ModifiedAction::with_modifiers(Action::SetMode(Mode::Normal), mods)).await;
true
}
_ => false,