Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
refactor: improve key resolution and prompt editing with byte-offset …
…carets and text editing shortcuts
  • Loading branch information
prem315 committed Oct 13, 2026
commit a205ac6534d1af65799586092d0fa6af618574ca
53 changes: 31 additions & 22 deletions packages/tui/src/action.rs
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@

use std::time::{Duration, Instant};

use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};

/// How long the leader stays armed while waiting for the key after it.
const LEADER_TIMEOUT: Duration = Duration::from_secs(2);
Expand All @@ -28,6 +28,17 @@ pub enum Action {
Exit,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Resolution {
/// The key triggered a named action.
Action(Action),
/// The key armed or spent the `<leader>` prefix without triggering an action, so it must
/// not fall through to the prompt.
Consumed,
/// The key is not bound here.
Unbound,
}

/// Resolves key presses to actions, including the `<leader>` prefix.
#[derive(Debug, Default)]
pub struct Keymap {
Expand All @@ -39,33 +50,31 @@ impl Keymap {
Self::default()
}

/// The action this key triggers.
/// What this key press means to the keymap.
///
/// Takes `&mut self` because a key can be consumed without triggering
/// anything: the leader key arms, and any other key clears the arming.
pub fn resolve(&mut self, key: KeyEvent) -> Option<Action> {
let leader = self.take_leader(key);
match (key.code, key.modifiers) {
// OpenCode's defaults for `app.exit`: ctrl+c, ctrl+d, <leader>q.
(KeyCode::Char('c'), KeyModifiers::CONTROL) => Some(Action::Exit),
(KeyCode::Char('d'), KeyModifiers::CONTROL) => Some(Action::Exit),
(KeyCode::Char('q'), _) if leader => Some(Action::Exit),
// Escape is deliberately not bound here, though it is what leaves the app: a menu
// claims it first now, so what it means depends on the screen and `App` decides.
_ => None,
/// anything: the leader key arms, and any key pressed while it is armed
/// spends the prefix rather than typing into the prompt.
pub fn resolve(&mut self, key: KeyEvent) -> Resolution {
if (key.code, key.modifiers) == LEADER {
if key.kind != KeyEventKind::Repeat {
self.leader_armed = Some(Instant::now());
}
return Resolution::Consumed;
}
}

/// Whether this key press completes a `<leader>` sequence, arming the leader
/// if the key is the leader itself.
fn take_leader(&mut self, key: KeyEvent) -> bool {
let armed = self
let leader = self
.leader_armed
.take()
.is_some_and(|at| at.elapsed() < LEADER_TIMEOUT);
if (key.code, key.modifiers) == LEADER {
self.leader_armed = Some(Instant::now());
match (key.code, key.modifiers) {
// OpenCode's defaults for `app.exit`: ctrl+c, ctrl+d, <leader>q.
(KeyCode::Char('c'), KeyModifiers::CONTROL) => Resolution::Action(Action::Exit),
(KeyCode::Char('d'), KeyModifiers::CONTROL) => Resolution::Action(Action::Exit),
(KeyCode::Char('q'), KeyModifiers::NONE) if leader => Resolution::Action(Action::Exit),
_ if leader => Resolution::Consumed,
// Escape is deliberately not bound here, though it is what leaves the app: a menu
// claims it first now, so what it means depends on the screen and `App` decides.
_ => Resolution::Unbound,
}
armed
}
}
14 changes: 9 additions & 5 deletions packages/tui/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

use crossterm::event::{KeyCode, KeyEvent};

use crate::action::{Action, Keymap};
use crate::action::{Action, Keymap, Resolution};
use crate::event::AppEvent;
use crate::menu::{Entry, Menu, Preview};
use crate::prompt::Prompt;
Expand Down Expand Up @@ -154,10 +154,14 @@ impl App {
fn handle_key(&mut self, key: KeyEvent) -> bool {
// Named actions first, so a key bound to the app is never typed into the prompt. The exits
// are the terminal's whether or not a menu is up.
if let Some(Action::Exit) = self.keymap.resolve(key) {
self.should_quit = true;
// Nothing to redraw: the loop is about to end.
return false;
match self.keymap.resolve(key) {
Resolution::Action(Action::Exit) => {
self.should_quit = true;
// Nothing to redraw: the loop is about to end.
return false;
}
Resolution::Consumed => return false,
Resolution::Unbound => {}
}
// Escape closes what is open and, with nothing open, leaves. It is not in the keymap
// because a keymap holds one answer per key and this one has two.
Expand Down
137 changes: 97 additions & 40 deletions packages/tui/src/prompt.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,14 @@
//! resolves named actions first, so an exit key never reaches here — every key that does either
//! moves the caret, changes the text, or is not ours.

use std::ops::Range;

use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};

/// The prompt's text and caret.
///
/// The caret counts characters rather than bytes: a byte index would have to be kept on a
/// boundary by hand every time the text changes, and a prompt line is short.
/// The caret is a UTF-8 byte offset into `text`, kept on a character boundary by every operation
/// that moves it so slicing before or after the caret is direct.
#[derive(Default)]
pub struct Prompt {
text: String,
Expand All @@ -23,7 +25,7 @@ impl Prompt {

/// The text before the caret, which is what the widget measures to scroll a long line.
pub fn before_caret(&self) -> &str {
&self.text[..self.byte(self.caret)]
&self.text[..self.caret]
}

/// Empty the line. Escape does this when it takes back the slash that asked for the command
Expand All @@ -35,61 +37,94 @@ impl Prompt {

/// Apply one key. Reports whether the prompt changed, which is the redraw.
pub fn edit(&mut self, key: KeyEvent) -> bool {
match key.code {
match (key.code, key.modifiers) {
(KeyCode::Char('a'), KeyModifiers::CONTROL) | (KeyCode::Home, _) => self.jump(0),
(KeyCode::Char('e'), KeyModifiers::CONTROL) | (KeyCode::End, _) => {
self.jump(self.text.len())
}
(KeyCode::Char('u'), KeyModifiers::CONTROL) => self.remove_range(0..self.caret),
(KeyCode::Char('k'), KeyModifiers::CONTROL) => {
self.remove_range(self.caret..self.text.len())
}
(KeyCode::Char('w'), KeyModifiers::CONTROL)
| (KeyCode::Backspace, KeyModifiers::ALT) => {
self.remove_range(self.prev_word()..self.caret)
}
(KeyCode::Left, m) if m.intersects(KeyModifiers::ALT | KeyModifiers::CONTROL) => {
self.jump(self.prev_word())
}
(KeyCode::Right, m) if m.intersects(KeyModifiers::ALT | KeyModifiers::CONTROL) => {
self.jump(self.next_word())
}
// Only a plain or shifted key types: ctrl belongs to the app (exit, the leader) and alt
// to the terminal.
KeyCode::Char(ch) if key.modifiers.difference(KeyModifiers::SHIFT).is_empty() => {
(KeyCode::Char(ch), m) if m.difference(KeyModifiers::SHIFT).is_empty() => {
// Some terminals report a control key as a character (`\x01` for ctrl+a). Those are
// never text, so they are dropped rather than drawn as a box.
if ch.is_control() {
return false;
}
self.insert_text(&ch.to_string())
let mut buf = [0u8; 4];
self.insert_text(ch.encode_utf8(&mut buf))
}
KeyCode::Backspace => self.remove(self.caret.checked_sub(1)),
KeyCode::Delete => self.remove(Some(self.caret)),
KeyCode::Left => self.move_caret(-1),
KeyCode::Right => self.move_caret(1),
KeyCode::Home => self.jump(0),
KeyCode::End => self.jump(self.len()),
(KeyCode::Backspace, _) => match self.prev_char() {
Some(prev) => self.remove_range(prev..self.caret),
None => false,
},
(KeyCode::Delete, _) => match self.next_char() {
Some(next) => self.remove_range(self.caret..next),
None => false,
},
(KeyCode::Left, _) => match self.prev_char() {
Some(prev) => self.jump(prev),
None => false,
},
(KeyCode::Right, _) => match self.next_char() {
Some(next) => self.jump(next),
None => false,
},
_ => false,
}
}

/// Insert text at the caret, as a paste arrives.
///
/// Control characters are dropped: the line holds one line, and a paste can carry newlines and
/// tabs that a terminal would otherwise act on.
/// Line breaks and tabs are turned into spaces so pasted lines do not run together, and any
/// other control characters are dropped.
pub fn insert_text(&mut self, text: &str) -> bool {
let text: String = text.chars().filter(|ch| !ch.is_control()).collect();
if text.is_empty() {
let mut clean = String::with_capacity(text.len());
let mut chars = text.chars().peekable();
while let Some(ch) = chars.next() {
match ch {
'\r' => {
if chars.peek() == Some(&'\n') {
chars.next();
}
clean.push(' ');
}
'\n' | '\t' => clean.push(' '),
ch if !ch.is_control() => clean.push(ch),
_ => {}
}
}
if clean.is_empty() {
return false;
}
let at = self.byte(self.caret);
self.text.insert_str(at, &text);
self.caret += text.chars().count();
self.text.insert_str(self.caret, &clean);
self.caret += clean.len();
true
}

/// Delete the character at `index`, when there is one.
fn remove(&mut self, index: Option<usize>) -> bool {
let Some(index) = index else {
return false;
};
let (start, end) = (self.byte(index), self.byte(index + 1));
if start == end {
fn remove_range(&mut self, range: Range<usize>) -> bool {
if range.is_empty() {
return false;
}
self.text.replace_range(start..end, "");
self.caret = index;
let start = range.start;
self.text.replace_range(range, "");
self.caret = start;
true
}

fn move_caret(&mut self, step: isize) -> bool {
let at = self.caret as isize + step;
self.jump(at.clamp(0, self.len() as isize) as usize)
}

fn jump(&mut self, caret: usize) -> bool {
if caret == self.caret {
return false;
Expand All @@ -98,15 +133,37 @@ impl Prompt {
true
}

fn len(&self) -> usize {
self.text.chars().count()
fn prev_char(&self) -> Option<usize> {
self.text[..self.caret]
.char_indices()
.next_back()
.map(|(idx, _)| idx)
}

fn next_char(&self) -> Option<usize> {
self.text[self.caret..]
.chars()
.next()
.map(|ch| self.caret + ch.len_utf8())
}

/// The byte offset of a char index, clamped to the end of the text.
fn byte(&self, index: usize) -> usize {
self.text
fn prev_word(&self) -> usize {
let before = &self.text[..self.caret];
let trimmed = before.trim_end_matches(char::is_whitespace);
trimmed
.char_indices()
.nth(index)
.map_or(self.text.len(), |(byte, _)| byte)
.rev()
.find(|(_, ch)| ch.is_whitespace())
.map_or(0, |(idx, ch)| idx + ch.len_utf8())
}

fn next_word(&self) -> usize {
let after = &self.text[self.caret..];
let mut chars = after.char_indices().peekable();
while chars.next_if(|(_, ch)| ch.is_whitespace()).is_some() {}
while chars.next_if(|(_, ch)| !ch.is_whitespace()).is_some() {}
chars
.peek()
.map_or(self.text.len(), |(idx, _)| self.caret + idx)
}
}
Loading