forked from NotAShelf/beer
input: kitty keyboard protocol and hex codepoint entry
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: I0f58c82752b9d7a8df35fe78f034c0be6a6a6964
This commit is contained in:
parent
5cba919c78
commit
e04ffc6649
8 changed files with 544 additions and 15 deletions
|
|
@ -25,6 +25,7 @@ pub enum Action {
|
|||
JumpPromptDown,
|
||||
PipeCommandOutput,
|
||||
UrlMode,
|
||||
UnicodeInput,
|
||||
}
|
||||
|
||||
impl Action {
|
||||
|
|
@ -47,6 +48,7 @@ impl Action {
|
|||
"jump-prompt-down" => Self::JumpPromptDown,
|
||||
"pipe-command-output" => Self::PipeCommandOutput,
|
||||
"url-mode" => Self::UrlMode,
|
||||
"unicode-input" => Self::UnicodeInput,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
|
@ -185,6 +187,7 @@ const DEFAULT_BINDINGS: &[(&str, &str)] = &[
|
|||
("Ctrl+Shift+Up", "jump-prompt-up"),
|
||||
("Ctrl+Shift+Down", "jump-prompt-down"),
|
||||
("Ctrl+Shift+O", "url-mode"),
|
||||
("Ctrl+Shift+U", "unicode-input"),
|
||||
];
|
||||
|
||||
/// Map a key token to a keysym: a single character, or a named special key.
|
||||
|
|
|
|||
|
|
@ -249,6 +249,10 @@ pub struct Grid {
|
|||
last_base: Option<(usize, usize)>,
|
||||
/// OSC 8 hyperlink URIs; a cell's `link` is a 1-based index into this.
|
||||
links: Vec<Box<str>>,
|
||||
/// Active kitty-keyboard progressive-enhancement flags (0 = legacy mode).
|
||||
kitty_current: u8,
|
||||
/// Saved flag values for the kitty push/pop stack.
|
||||
kitty_stack: Vec<u8>,
|
||||
}
|
||||
|
||||
fn default_tabs(cols: usize) -> Vec<bool> {
|
||||
|
|
@ -261,6 +265,10 @@ fn default_tabs(cols: usize) -> Vec<bool> {
|
|||
/// one unit.
|
||||
const WORD_DELIMITERS: &str = " \t`!@#$%^&*()+=[]{}\\|;'\",<>?";
|
||||
|
||||
/// Mask of the kitty-keyboard flags we implement (disambiguate, report events,
|
||||
/// alternate keys, all-keys-as-escapes, associated text).
|
||||
const KITTY_ALL: u8 = 0b1_1111;
|
||||
|
||||
/// Whether `c` is part of a word (not whitespace, not in `delims`).
|
||||
fn is_word(c: char, delims: &str) -> bool {
|
||||
!c.is_whitespace() && !delims.contains(c)
|
||||
|
|
@ -304,6 +312,38 @@ impl Grid {
|
|||
scrollback_cap: SCROLLBACK_CAP,
|
||||
last_base: None,
|
||||
links: Vec::new(),
|
||||
kitty_current: 0,
|
||||
kitty_stack: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The active kitty-keyboard flags (0 means legacy encoding).
|
||||
pub fn kitty_flags(&self) -> u8 {
|
||||
self.kitty_current
|
||||
}
|
||||
|
||||
/// Apply `CSI = flags ; mode u`: mode 1 replaces, 2 sets bits, 3 clears bits.
|
||||
pub fn kitty_set(&mut self, flags: u8, mode: u8) {
|
||||
self.kitty_current = match mode {
|
||||
2 => self.kitty_current | flags,
|
||||
3 => self.kitty_current & !flags,
|
||||
_ => flags,
|
||||
} & KITTY_ALL;
|
||||
}
|
||||
|
||||
/// Apply `CSI > flags u`: push the current flags and switch to `flags`.
|
||||
pub fn kitty_push(&mut self, flags: u8) {
|
||||
if self.kitty_stack.len() >= 32 {
|
||||
self.kitty_stack.remove(0);
|
||||
}
|
||||
self.kitty_stack.push(self.kitty_current);
|
||||
self.kitty_current = flags & KITTY_ALL;
|
||||
}
|
||||
|
||||
/// Apply `CSI < n u`: pop `n` saved flag values, restoring the last one.
|
||||
pub fn kitty_pop(&mut self, n: usize) {
|
||||
for _ in 0..n.max(1) {
|
||||
self.kitty_current = self.kitty_stack.pop().unwrap_or(0);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1170,6 +1210,24 @@ impl Grid {
|
|||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn kitty_flag_stack() {
|
||||
let mut g = Grid::new(4, 2);
|
||||
assert_eq!(g.kitty_flags(), 0);
|
||||
g.kitty_set(0b1, 1); // replace
|
||||
assert_eq!(g.kitty_flags(), 0b1);
|
||||
g.kitty_set(0b100, 2); // set bits
|
||||
assert_eq!(g.kitty_flags(), 0b101);
|
||||
g.kitty_set(0b1, 3); // clear bits
|
||||
assert_eq!(g.kitty_flags(), 0b100);
|
||||
g.kitty_push(0b11); // push current, switch
|
||||
assert_eq!(g.kitty_flags(), 0b11);
|
||||
g.kitty_pop(1); // restore
|
||||
assert_eq!(g.kitty_flags(), 0b100);
|
||||
g.kitty_pop(5); // underflow is harmless
|
||||
assert_eq!(g.kitty_flags(), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn prints_and_wraps() {
|
||||
let mut g = Grid::new(4, 2);
|
||||
|
|
|
|||
356
src/input.rs
356
src/input.rs
|
|
@ -55,6 +55,233 @@ pub fn encode(event: &KeyEvent, mods: Modifiers, app_cursor: bool) -> Option<Vec
|
|||
Some(seq)
|
||||
}
|
||||
|
||||
/// A key event's kind, for the kitty keyboard protocol's event-type sub-field.
|
||||
#[derive(Clone, Copy, PartialEq, Eq)]
|
||||
pub enum KeyKind {
|
||||
Press = 1,
|
||||
Repeat = 2,
|
||||
Release = 3,
|
||||
}
|
||||
|
||||
/// How a functional (non-text) key is encoded in the kitty protocol.
|
||||
enum Func {
|
||||
/// `CSI number ... u`.
|
||||
Number(u32),
|
||||
/// `CSI number ... ~`.
|
||||
Tilde(u32),
|
||||
/// `CSI 1 ... LETTER`.
|
||||
Letter(u8),
|
||||
}
|
||||
|
||||
/// Map a keysym to its kitty functional encoding, or `None` for a text key
|
||||
/// (whose code is its Unicode codepoint).
|
||||
fn functional(keysym: Keysym) -> Option<Func> {
|
||||
Some(match keysym {
|
||||
Keysym::Escape => Func::Number(27),
|
||||
Keysym::Return | Keysym::KP_Enter => Func::Number(13),
|
||||
Keysym::Tab | Keysym::ISO_Left_Tab => Func::Number(9),
|
||||
Keysym::BackSpace => Func::Number(127),
|
||||
Keysym::Insert => Func::Tilde(2),
|
||||
Keysym::Delete => Func::Tilde(3),
|
||||
Keysym::Page_Up => Func::Tilde(5),
|
||||
Keysym::Page_Down => Func::Tilde(6),
|
||||
Keysym::Up => Func::Letter(b'A'),
|
||||
Keysym::Down => Func::Letter(b'B'),
|
||||
Keysym::Right => Func::Letter(b'C'),
|
||||
Keysym::Left => Func::Letter(b'D'),
|
||||
Keysym::Home => Func::Letter(b'H'),
|
||||
Keysym::End => Func::Letter(b'F'),
|
||||
Keysym::F1 => Func::Letter(b'P'),
|
||||
Keysym::F2 => Func::Letter(b'Q'),
|
||||
Keysym::F3 => Func::Tilde(13),
|
||||
Keysym::F4 => Func::Letter(b'S'),
|
||||
Keysym::F5 => Func::Tilde(15),
|
||||
Keysym::F6 => Func::Tilde(17),
|
||||
Keysym::F7 => Func::Tilde(18),
|
||||
Keysym::F8 => Func::Tilde(19),
|
||||
Keysym::F9 => Func::Tilde(20),
|
||||
Keysym::F10 => Func::Tilde(21),
|
||||
Keysym::F11 => Func::Tilde(23),
|
||||
Keysym::F12 => Func::Tilde(24),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The kitty key code for a lone modifier key, reported only in report-all mode.
|
||||
fn modifier_key(keysym: Keysym) -> Option<u32> {
|
||||
Some(match keysym {
|
||||
Keysym::Caps_Lock => 57358,
|
||||
Keysym::Num_Lock => 57360,
|
||||
Keysym::Shift_L => 57441,
|
||||
Keysym::Control_L => 57442,
|
||||
Keysym::Alt_L => 57443,
|
||||
Keysym::Super_L => 57444,
|
||||
Keysym::Shift_R => 57447,
|
||||
Keysym::Control_R => 57448,
|
||||
Keysym::Alt_R => 57449,
|
||||
Keysym::Super_R => 57450,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
|
||||
/// The kitty modifier bitfield (shift=1, alt=2, ctrl=4, super=8, caps=64,
|
||||
/// num=128); the wire parameter is `1 + this`.
|
||||
fn kitty_mod_bits(mods: Modifiers) -> u32 {
|
||||
u32::from(mods.shift)
|
||||
| (u32::from(mods.alt) << 1)
|
||||
| (u32::from(mods.ctrl) << 2)
|
||||
| (u32::from(mods.logo) << 3)
|
||||
| (u32::from(mods.caps_lock) << 6)
|
||||
| (u32::from(mods.num_lock) << 7)
|
||||
}
|
||||
|
||||
/// The un-shifted Unicode codepoint of a text key (always the lowercase form,
|
||||
/// per the protocol). `None` if the key produces no character.
|
||||
fn base_codepoint(keysym: Keysym) -> Option<u32> {
|
||||
let c = keysym.key_char()?;
|
||||
Some(u32::from(if c.is_ascii_uppercase() {
|
||||
c.to_ascii_lowercase()
|
||||
} else {
|
||||
c
|
||||
}))
|
||||
}
|
||||
|
||||
/// Build `CSI <field> [; mod[:event]] [; text] <terminator>`. The modifier
|
||||
/// section is emitted when modifiers/event/text require it; the event sub-field
|
||||
/// only when the event is not a plain press.
|
||||
fn csi(field: String, mod_param: u32, event: KeyKind, text: Option<&str>, term: u8) -> Vec<u8> {
|
||||
let mut s = String::from("\x1b[");
|
||||
s.push_str(&field);
|
||||
let needs_mods = mod_param != 1 || event != KeyKind::Press || text.is_some();
|
||||
if needs_mods {
|
||||
s.push(';');
|
||||
s.push_str(&mod_param.to_string());
|
||||
if event != KeyKind::Press {
|
||||
s.push(':');
|
||||
s.push_str(&(event as u8).to_string());
|
||||
}
|
||||
}
|
||||
if let Some(text) = text {
|
||||
s.push(';');
|
||||
let codes: Vec<String> = text.chars().map(|c| u32::from(c).to_string()).collect();
|
||||
s.push_str(&codes.join(":"));
|
||||
}
|
||||
s.push(char::from(term));
|
||||
s.into_bytes()
|
||||
}
|
||||
|
||||
/// Encode a key event under the kitty keyboard protocol with the given active
|
||||
/// `flags`. Returns `None` when the event produces nothing (e.g. a text key
|
||||
/// release without report-all mode). `app_cursor` only affects unmodified
|
||||
/// cursor keys, matching legacy behaviour.
|
||||
pub fn kitty_encode(
|
||||
event: &KeyEvent,
|
||||
mods: Modifiers,
|
||||
flags: u8,
|
||||
kind: KeyKind,
|
||||
app_cursor: bool,
|
||||
) -> Option<Vec<u8>> {
|
||||
let report_all = flags & 0b1000 != 0;
|
||||
let report_events = flags & 0b10 != 0;
|
||||
let report_alt = flags & 0b100 != 0;
|
||||
let report_text = flags & 0b1_0000 != 0;
|
||||
|
||||
let bits = kitty_mod_bits(mods);
|
||||
let mod_param = bits + 1;
|
||||
// Events other than a press are only reported when asked for.
|
||||
let events_ok = report_events || report_all;
|
||||
let kind = if events_ok { kind } else { KeyKind::Press };
|
||||
|
||||
// Lone modifier keys: only in report-all mode.
|
||||
if let Some(code) = modifier_key(event.keysym) {
|
||||
if !report_all {
|
||||
return None;
|
||||
}
|
||||
return Some(csi(code.to_string(), mod_param, kind, None, b'u'));
|
||||
}
|
||||
|
||||
match functional(event.keysym) {
|
||||
Some(func) => {
|
||||
// Enter/Tab/Backspace keep legacy bytes when unmodified and not in
|
||||
// report-all mode, so a shell stays usable.
|
||||
let legacy_special = matches!(
|
||||
event.keysym,
|
||||
Keysym::Return | Keysym::KP_Enter | Keysym::Tab | Keysym::BackSpace
|
||||
);
|
||||
if legacy_special && !report_all && bits == 0 {
|
||||
if kind != KeyKind::Press {
|
||||
return None;
|
||||
}
|
||||
return Some(match event.keysym {
|
||||
Keysym::BackSpace => b"\x7f".to_vec(),
|
||||
Keysym::Tab => b"\t".to_vec(),
|
||||
_ => b"\r".to_vec(),
|
||||
});
|
||||
}
|
||||
if kind != KeyKind::Press && !events_ok {
|
||||
return None;
|
||||
}
|
||||
Some(match func {
|
||||
Func::Number(n) => csi(n.to_string(), mod_param, kind, None, b'u'),
|
||||
Func::Tilde(n) => csi(n.to_string(), mod_param, kind, None, b'~'),
|
||||
Func::Letter(l) => {
|
||||
if mod_param == 1 && kind == KeyKind::Press {
|
||||
// Unmodified: legacy CSI/SS3 form, honouring app-cursor.
|
||||
vec![0x1b, if app_cursor { b'O' } else { b'[' }, l]
|
||||
} else {
|
||||
csi("1".to_string(), mod_param, kind, None, l)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
None => {
|
||||
let cp = base_codepoint(event.keysym)?;
|
||||
if report_all {
|
||||
if cp == 0 {
|
||||
return None;
|
||||
}
|
||||
let text = if report_text {
|
||||
event
|
||||
.utf8
|
||||
.as_deref()
|
||||
.filter(|t| !t.is_empty() && t.chars().all(|c| !c.is_control()))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let field = alt_field(cp, event.keysym, mods, report_alt);
|
||||
return Some(csi(field, mod_param, kind, text, b'u'));
|
||||
}
|
||||
// Without report-all, text keys only report presses.
|
||||
if kind != KeyKind::Press {
|
||||
return None;
|
||||
}
|
||||
// Plain or shift-only keys send their text; ctrl/alt/super → CSI u.
|
||||
if bits & !1 == 0 {
|
||||
let text = event.utf8.as_ref()?;
|
||||
if text.is_empty() {
|
||||
return None;
|
||||
}
|
||||
return Some(text.as_bytes().to_vec());
|
||||
}
|
||||
let field = alt_field(cp, event.keysym, mods, report_alt);
|
||||
Some(csi(field, mod_param, KeyKind::Press, None, b'u'))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The key-code field, with the shifted alternate appended (`code:shifted`) when
|
||||
/// alternate-key reporting is on and shift is held.
|
||||
fn alt_field(cp: u32, keysym: Keysym, mods: Modifiers, report_alt: bool) -> String {
|
||||
if report_alt
|
||||
&& mods.shift
|
||||
&& let Some(shifted) = keysym.key_char().map(u32::from)
|
||||
&& shifted != cp
|
||||
{
|
||||
return format!("{cp}:{shifted}");
|
||||
}
|
||||
cp.to_string()
|
||||
}
|
||||
|
||||
/// Encode a mouse event for the application. `button` is the base code (0/1/2
|
||||
/// for left/middle/right, 64/65 for wheel up/down); `col`/`row` are 0-based
|
||||
/// screen cells; `motion` marks a drag/move report. Returns the bytes to send.
|
||||
|
|
@ -252,6 +479,135 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
fn mods(ctrl: bool, shift: bool, alt: bool) -> Modifiers {
|
||||
Modifiers {
|
||||
ctrl,
|
||||
shift,
|
||||
alt,
|
||||
..NONE
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kitty_plain_text_key_stays_text() {
|
||||
// Disambiguate only: an unmodified letter is still sent as text.
|
||||
assert_eq!(
|
||||
kitty_encode(&key(Keysym::a, Some("a")), NONE, 0b1, KeyKind::Press, false),
|
||||
Some(b"a".to_vec())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kitty_ctrl_letter_is_csi_u() {
|
||||
// ctrl+a → CSI 97 ; 5 u (5 = 1 + ctrl).
|
||||
assert_eq!(
|
||||
kitty_encode(
|
||||
&key(Keysym::a, None),
|
||||
mods(true, false, false),
|
||||
0b1,
|
||||
KeyKind::Press,
|
||||
false
|
||||
),
|
||||
Some(b"\x1b[97;5u".to_vec())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kitty_escape_disambiguated() {
|
||||
assert_eq!(
|
||||
kitty_encode(&key(Keysym::Escape, None), NONE, 0b1, KeyKind::Press, false),
|
||||
Some(b"\x1b[27u".to_vec())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kitty_associated_text_and_alternate() {
|
||||
// shift+a with report-all + associated-text → CSI 97 ; 2 ; 65 u.
|
||||
let flags = 0b1_1001; // disambiguate | report-all | associated-text
|
||||
assert_eq!(
|
||||
kitty_encode(
|
||||
&key(Keysym::A, Some("A")),
|
||||
mods(false, true, false),
|
||||
flags,
|
||||
KeyKind::Press,
|
||||
false
|
||||
),
|
||||
Some(b"\x1b[97;2;65u".to_vec())
|
||||
);
|
||||
// ctrl+shift+a with alternate-key reporting → CSI 97:65 ; 6 u.
|
||||
assert_eq!(
|
||||
kitty_encode(
|
||||
&key(Keysym::A, None),
|
||||
mods(true, true, false),
|
||||
0b101,
|
||||
KeyKind::Press,
|
||||
false
|
||||
),
|
||||
Some(b"\x1b[97:65;6u".to_vec())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kitty_release_only_with_report_all() {
|
||||
let release = |flags| {
|
||||
kitty_encode(
|
||||
&key(Keysym::a, None),
|
||||
mods(true, false, false),
|
||||
flags,
|
||||
KeyKind::Release,
|
||||
false,
|
||||
)
|
||||
};
|
||||
// Event reporting alone does not report text-key release.
|
||||
assert_eq!(release(0b11), None);
|
||||
// Report-all does: CSI 97 ; 5 : 3 u.
|
||||
assert_eq!(release(0b1011), Some(b"\x1b[97;5:3u".to_vec()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn kitty_functional_keys() {
|
||||
// Unmodified arrow keeps the legacy form (app-cursor honoured).
|
||||
assert_eq!(
|
||||
kitty_encode(&key(Keysym::Up, None), NONE, 0b1, KeyKind::Press, false),
|
||||
Some(b"\x1b[A".to_vec())
|
||||
);
|
||||
assert_eq!(
|
||||
kitty_encode(&key(Keysym::Up, None), NONE, 0b1, KeyKind::Press, true),
|
||||
Some(b"\x1bOA".to_vec())
|
||||
);
|
||||
// Modified arrow → CSI 1 ; mods LETTER.
|
||||
assert_eq!(
|
||||
kitty_encode(
|
||||
&key(Keysym::Up, None),
|
||||
mods(true, false, false),
|
||||
0b1,
|
||||
KeyKind::Press,
|
||||
false
|
||||
),
|
||||
Some(b"\x1b[1;5A".to_vec())
|
||||
);
|
||||
// F5 keeps its tilde form.
|
||||
assert_eq!(
|
||||
kitty_encode(&key(Keysym::F5, None), NONE, 0b1, KeyKind::Press, false),
|
||||
Some(b"\x1b[15~".to_vec())
|
||||
);
|
||||
// Unmodified Enter is still a carriage return; report-all makes it CSI u.
|
||||
assert_eq!(
|
||||
kitty_encode(&key(Keysym::Return, None), NONE, 0b1, KeyKind::Press, false),
|
||||
Some(b"\r".to_vec())
|
||||
);
|
||||
assert_eq!(
|
||||
kitty_encode(
|
||||
&key(Keysym::Return, None),
|
||||
NONE,
|
||||
0b1001,
|
||||
KeyKind::Press,
|
||||
false
|
||||
),
|
||||
Some(b"\x1b[13u".to_vec())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn special_keys() {
|
||||
assert_eq!(
|
||||
|
|
|
|||
|
|
@ -340,6 +340,11 @@ impl Term {
|
|||
}
|
||||
}
|
||||
|
||||
/// Reply to a kitty-keyboard flags query (`CSI ? u`) with `CSI ? flags u`.
|
||||
fn report_kitty_flags(&mut self) {
|
||||
let _ = write!(self.response, "\x1b[?{}u", self.grid.kitty_flags());
|
||||
}
|
||||
|
||||
/// XTVERSION (`CSI > q`): report the terminal name and version.
|
||||
fn report_version(&mut self) {
|
||||
let _ = write!(
|
||||
|
|
|
|||
|
|
@ -82,7 +82,17 @@ impl Perform for Term {
|
|||
'p' if intermediates.contains(&b'$') => self.report_mode(params, private),
|
||||
'n' => self.device_status(params),
|
||||
's' => self.grid.save_cursor(),
|
||||
'u' => self.grid.restore_cursor(),
|
||||
// `CSI u` is SCORC, but the kitty keyboard protocol overloads it with
|
||||
// private prefixes: `?` query, `>` push, `<` pop, `=` set flags.
|
||||
'u' => match intermediates.first() {
|
||||
Some(b'?') => self.report_kitty_flags(),
|
||||
Some(b'>') => self.grid.kitty_push(n(params, 0, 0) as u8),
|
||||
Some(b'<') => self.grid.kitty_pop(n(params, 0, 1)),
|
||||
Some(b'=') => self
|
||||
.grid
|
||||
.kitty_set(n(params, 0, 0) as u8, n(params, 1, 1) as u8),
|
||||
_ => self.grid.restore_cursor(),
|
||||
},
|
||||
't' => self.title_stack_op(params),
|
||||
'g' => match raw(params, 0) {
|
||||
3 => self.grid.clear_all_tabs(),
|
||||
|
|
|
|||
|
|
@ -204,6 +204,9 @@ impl KeyboardHandler for App {
|
|||
_: u32,
|
||||
) {
|
||||
self.focused = false;
|
||||
// Drop held-key state so a key released while unfocused can't leak a
|
||||
// stale kitty release event later.
|
||||
self.keys_down.clear();
|
||||
self.report_focus(false);
|
||||
self.needs_draw = true;
|
||||
}
|
||||
|
|
@ -239,8 +242,9 @@ impl KeyboardHandler for App {
|
|||
_: &QueueHandle<Self>,
|
||||
_: &wl_keyboard::WlKeyboard,
|
||||
_: u32,
|
||||
_: KeyEvent,
|
||||
event: KeyEvent,
|
||||
) {
|
||||
self.handle_key_release(&event);
|
||||
}
|
||||
|
||||
fn update_modifiers(
|
||||
|
|
|
|||
|
|
@ -268,6 +268,8 @@ pub fn run(config: Config, config_path: Option<std::path::PathBuf>) -> anyhow::R
|
|||
url_hits: Vec::new(),
|
||||
url_labels: Vec::new(),
|
||||
url_input: String::new(),
|
||||
unicode_input: None,
|
||||
keys_down: std::collections::HashSet::new(),
|
||||
focused: true,
|
||||
exit: false,
|
||||
exit_code: ExitCode::SUCCESS,
|
||||
|
|
@ -477,6 +479,11 @@ struct App {
|
|||
url_labels: Vec<String>,
|
||||
/// Label characters typed so far in URL mode.
|
||||
url_input: String,
|
||||
/// Hex digits typed so far in Unicode codepoint-input mode; `None` when off.
|
||||
unicode_input: Option<String>,
|
||||
/// Raw key codes currently held, to tell press from repeat for the kitty
|
||||
/// keyboard protocol's event-type reporting.
|
||||
keys_down: std::collections::HashSet<u32>,
|
||||
/// Whether the toplevel currently has keyboard focus (drives the cursor).
|
||||
focused: bool,
|
||||
exit: bool,
|
||||
|
|
@ -553,7 +560,19 @@ impl App {
|
|||
/// text bindings, else the byte encoding sent to the shell (which snaps the
|
||||
/// viewport back to the live screen).
|
||||
fn handle_key(&mut self, event: &KeyEvent) {
|
||||
// URL hint mode and search both capture the keyboard while active.
|
||||
// A new arrival of a held key is a repeat; otherwise a fresh press.
|
||||
let kind = if self.keys_down.insert(event.raw_code) {
|
||||
crate::input::KeyKind::Press
|
||||
} else {
|
||||
crate::input::KeyKind::Repeat
|
||||
};
|
||||
|
||||
// The Unicode-input prompt, URL hint mode, and search each capture the
|
||||
// keyboard while active.
|
||||
if self.unicode_input.is_some() {
|
||||
self.unicode_key(event);
|
||||
return;
|
||||
}
|
||||
if self.url_mode {
|
||||
self.url_key(event);
|
||||
return;
|
||||
|
|
@ -572,11 +591,36 @@ impl App {
|
|||
return;
|
||||
}
|
||||
|
||||
let app_cursor = self
|
||||
.session
|
||||
.as_ref()
|
||||
.is_some_and(|s| s.term.grid().app_cursor());
|
||||
if let Some(bytes) = crate::input::encode(event, self.modifiers, app_cursor) {
|
||||
let (app_cursor, kitty) = self.session.as_ref().map_or((false, 0), |s| {
|
||||
(s.term.grid().app_cursor(), s.term.grid().kitty_flags())
|
||||
});
|
||||
let bytes = if kitty != 0 {
|
||||
crate::input::kitty_encode(event, self.modifiers, kitty, kind, app_cursor)
|
||||
} else {
|
||||
crate::input::encode(event, self.modifiers, app_cursor)
|
||||
};
|
||||
if let Some(bytes) = bytes {
|
||||
self.send_to_shell(&bytes);
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle a key release: only the kitty keyboard protocol cares, and only
|
||||
/// when it has asked for event reporting.
|
||||
fn handle_key_release(&mut self, event: &KeyEvent) {
|
||||
self.keys_down.remove(&event.raw_code);
|
||||
let (app_cursor, kitty) = self.session.as_ref().map_or((false, 0), |s| {
|
||||
(s.term.grid().app_cursor(), s.term.grid().kitty_flags())
|
||||
});
|
||||
if kitty == 0 {
|
||||
return;
|
||||
}
|
||||
if let Some(bytes) = crate::input::kitty_encode(
|
||||
event,
|
||||
self.modifiers,
|
||||
kitty,
|
||||
crate::input::KeyKind::Release,
|
||||
app_cursor,
|
||||
) {
|
||||
self.send_to_shell(&bytes);
|
||||
}
|
||||
}
|
||||
|
|
@ -628,6 +672,51 @@ impl App {
|
|||
Action::JumpPromptDown => self.jump_prompt(false),
|
||||
Action::PipeCommandOutput => self.pipe_command_output(),
|
||||
Action::UrlMode => self.enter_url_mode(),
|
||||
Action::UnicodeInput => {
|
||||
self.unicode_input = Some(String::new());
|
||||
self.needs_draw = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle a key while Unicode codepoint-input mode is active: accumulate hex
|
||||
/// digits, then commit the codepoint as UTF-8 on Enter/Space.
|
||||
fn unicode_key(&mut self, event: &KeyEvent) {
|
||||
match event.keysym {
|
||||
Keysym::Escape => {
|
||||
self.unicode_input = None;
|
||||
self.needs_draw = true;
|
||||
}
|
||||
Keysym::BackSpace => {
|
||||
if let Some(buf) = self.unicode_input.as_mut() {
|
||||
buf.pop();
|
||||
}
|
||||
self.needs_draw = true;
|
||||
}
|
||||
Keysym::Return | Keysym::KP_Enter | Keysym::space => {
|
||||
let buf = self.unicode_input.take().unwrap_or_default();
|
||||
if let Some(c) = u32::from_str_radix(buf.trim(), 16)
|
||||
.ok()
|
||||
.and_then(char::from_u32)
|
||||
{
|
||||
let mut bytes = [0u8; 4];
|
||||
let s = c.encode_utf8(&mut bytes).as_bytes().to_vec();
|
||||
self.send_to_shell(&s);
|
||||
}
|
||||
self.needs_draw = true;
|
||||
}
|
||||
_ => {
|
||||
if let Some(text) = event.utf8.as_ref() {
|
||||
let hex: String = text.chars().filter(char::is_ascii_hexdigit).collect();
|
||||
// Cap at 6 hex digits - the widest valid codepoint (U+10FFFF) fits.
|
||||
if let Some(buf) = self.unicode_input.as_mut()
|
||||
&& buf.len() + hex.len() <= 6
|
||||
{
|
||||
buf.push_str(&hex);
|
||||
}
|
||||
self.needs_draw = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -87,13 +87,17 @@ impl App {
|
|||
|
||||
// The search prompt occupies the bottom row while search mode is active.
|
||||
// Recording it in the snapshot keeps the row's damage/diff correct.
|
||||
let bar_text = self.searching.then(|| {
|
||||
let bar_text = if let Some(hex) = &self.unicode_input {
|
||||
Some(format!("unicode: U+{}", hex.to_uppercase()))
|
||||
} else {
|
||||
self.searching.then(|| {
|
||||
let (n, total) = grid.search_count();
|
||||
format!(
|
||||
"search: {} [{n}/{total}]",
|
||||
grid.search_query().unwrap_or("")
|
||||
)
|
||||
});
|
||||
})
|
||||
};
|
||||
if let Some(text) = &bar_text
|
||||
&& rows > 0
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue