Merge pull request #111 from Hmbown/feat/issue-89-key-hint-rendering
feat(tui): terminal-aware keybinding rendering (#89)
This commit is contained in:
@@ -1204,8 +1204,11 @@ async fn run_event_loop(
|
||||
let now = Instant::now();
|
||||
app.flush_paste_burst_if_due(now);
|
||||
|
||||
let has_ctrl_alt_or_super = key.modifiers.contains(KeyModifiers::CONTROL)
|
||||
|| key.modifiers.contains(KeyModifiers::ALT)
|
||||
// On Windows, AltGr is delivered as `Ctrl+Alt`; treat
|
||||
// AltGr-typed chars (e.g. European layouts producing `@`, `\`,
|
||||
// `|`) as plain text rather than swallowing them as a modified
|
||||
// shortcut. `key_hint::has_ctrl_or_alt` filters AltGr out.
|
||||
let has_ctrl_alt_or_super = super::widgets::key_hint::has_ctrl_or_alt(key.modifiers)
|
||||
|| key.modifiers.contains(KeyModifiers::SUPER);
|
||||
let is_plain_char = matches!(key.code, KeyCode::Char(_)) && !has_ctrl_alt_or_super;
|
||||
let is_enter = matches!(key.code, KeyCode::Enter);
|
||||
|
||||
+143
-33
@@ -978,6 +978,16 @@ impl ModalView for HelpView {
|
||||
|
||||
Clear.render(popup_area, buf);
|
||||
|
||||
// Render keybinding hints through `key_hint::KeyBinding` so they pick
|
||||
// up the host-platform notation (`⌥` on macOS, `alt+X` on Linux /
|
||||
// Windows). See `crates/tui/src/tui/widgets/key_hint.rs`.
|
||||
use crate::tui::widgets::key_hint::{alt, ctrl, plain, shift};
|
||||
let kb = |b: crate::tui::widgets::key_hint::KeyBinding| b.to_string();
|
||||
|
||||
let row = |label: String, desc: &str| -> Line<'static> {
|
||||
Line::from(format!(" {:<22} - {}", label, desc))
|
||||
};
|
||||
|
||||
let mut help_lines: Vec<Line> = vec![
|
||||
Line::from(vec![Span::styled(
|
||||
"DeepSeek TUI Help",
|
||||
@@ -988,75 +998,175 @@ impl ModalView for HelpView {
|
||||
"=== Navigation ===",
|
||||
Style::default().fg(palette::DEEPSEEK_SKY).bold(),
|
||||
)]),
|
||||
Line::from(" Up / Down - Scroll transcript (or navigate history)"),
|
||||
Line::from(" Ctrl+Up / Ctrl+Down - Navigate input history"),
|
||||
Line::from(" Alt+Up / Alt+Down - Scroll transcript"),
|
||||
Line::from(" PageUp / PageDown - Scroll transcript by page"),
|
||||
Line::from(" Home / End - Jump to top / bottom of transcript"),
|
||||
Line::from(" g / G - Jump to top / bottom (when input empty)"),
|
||||
Line::from(" [ / ] - Jump between tool output blocks"),
|
||||
row(
|
||||
format!("{} / {}", kb(plain(KeyCode::Up)), kb(plain(KeyCode::Down))),
|
||||
"Scroll transcript (or navigate history)",
|
||||
),
|
||||
row(
|
||||
format!("{} / {}", kb(ctrl(KeyCode::Up)), kb(ctrl(KeyCode::Down))),
|
||||
"Navigate input history",
|
||||
),
|
||||
row(
|
||||
format!("{} / {}", kb(alt(KeyCode::Up)), kb(alt(KeyCode::Down))),
|
||||
"Scroll transcript",
|
||||
),
|
||||
row(
|
||||
format!(
|
||||
"{} / {}",
|
||||
kb(plain(KeyCode::PageUp)),
|
||||
kb(plain(KeyCode::PageDown))
|
||||
),
|
||||
"Scroll transcript by page",
|
||||
),
|
||||
row(
|
||||
format!("{} / {}", kb(plain(KeyCode::Home)), kb(plain(KeyCode::End))),
|
||||
"Jump to top / bottom of transcript",
|
||||
),
|
||||
row(
|
||||
format!("{} / {}", kb(plain(KeyCode::Char('g'))), "G"),
|
||||
"Jump to top / bottom (when input empty)",
|
||||
),
|
||||
row("[ / ]".to_string(), "Jump between tool output blocks"),
|
||||
Line::from(""),
|
||||
Line::from(vec![Span::styled(
|
||||
"=== Input Editing ===",
|
||||
Style::default().fg(palette::DEEPSEEK_SKY).bold(),
|
||||
)]),
|
||||
Line::from(" Left / Right - Move cursor"),
|
||||
Line::from(" Ctrl+A / Ctrl+E - Jump to start / end of line"),
|
||||
Line::from(" Backspace / Delete - Delete character before / after cursor"),
|
||||
Line::from(" Ctrl+U - Clear the current draft"),
|
||||
row(
|
||||
format!(
|
||||
"{} / {}",
|
||||
kb(plain(KeyCode::Left)),
|
||||
kb(plain(KeyCode::Right))
|
||||
),
|
||||
"Move cursor",
|
||||
),
|
||||
row(
|
||||
format!(
|
||||
"{} / {}",
|
||||
kb(ctrl(KeyCode::Char('a'))),
|
||||
kb(ctrl(KeyCode::Char('e')))
|
||||
),
|
||||
"Jump to start / end of line",
|
||||
),
|
||||
row(
|
||||
format!(
|
||||
"{} / {}",
|
||||
kb(plain(KeyCode::Backspace)),
|
||||
kb(plain(KeyCode::Delete))
|
||||
),
|
||||
"Delete character before / after cursor",
|
||||
),
|
||||
row(kb(ctrl(KeyCode::Char('u'))), "Clear the current draft"),
|
||||
Line::from(""),
|
||||
Line::from(vec![Span::styled(
|
||||
"=== Multi-line Input ===",
|
||||
Style::default().fg(palette::DEEPSEEK_SKY).bold(),
|
||||
)]),
|
||||
Line::from(" Ctrl+J / Alt+Enter - Insert a new line in the composer"),
|
||||
row(
|
||||
format!(
|
||||
"{} / {}",
|
||||
kb(ctrl(KeyCode::Char('j'))),
|
||||
kb(alt(KeyCode::Enter))
|
||||
),
|
||||
"Insert a new line in the composer",
|
||||
),
|
||||
Line::from(""),
|
||||
Line::from(vec![Span::styled(
|
||||
"=== Actions ===",
|
||||
Style::default().fg(palette::DEEPSEEK_SKY).bold(),
|
||||
)]),
|
||||
Line::from(" Enter - Send the current draft"),
|
||||
Line::from(
|
||||
" Esc - Close menu, cancel request, discard draft, or clear input",
|
||||
row(kb(plain(KeyCode::Enter)), "Send the current draft"),
|
||||
row(
|
||||
kb(plain(KeyCode::Esc)),
|
||||
"Close menu, cancel request, discard draft, or clear input",
|
||||
),
|
||||
row(
|
||||
kb(ctrl(KeyCode::Char('c'))),
|
||||
"Cancel request or exit application",
|
||||
),
|
||||
row(kb(ctrl(KeyCode::Char('d'))), "Exit when input is empty"),
|
||||
row(kb(ctrl(KeyCode::Char('k'))), "Open command palette"),
|
||||
row(
|
||||
kb(plain(KeyCode::Char('l'))),
|
||||
"Open pager for last message (when input empty)",
|
||||
),
|
||||
row(
|
||||
kb(plain(KeyCode::Char('v'))),
|
||||
"Open details for the selected tool or message",
|
||||
),
|
||||
row(
|
||||
format!("{} (selection)", kb(plain(KeyCode::Enter))),
|
||||
"Open pager for selected text",
|
||||
),
|
||||
Line::from(" Ctrl+C - Cancel request or exit application"),
|
||||
Line::from(" Ctrl+D - Exit when input is empty"),
|
||||
Line::from(" Ctrl+K - Open command palette"),
|
||||
Line::from(" l - Open pager for last message (when input empty)"),
|
||||
Line::from(" v - Open details for the selected tool or message"),
|
||||
Line::from(" Enter (selection) - Open pager for selected text"),
|
||||
Line::from(""),
|
||||
Line::from(vec![Span::styled(
|
||||
"=== Modes ===",
|
||||
Style::default().fg(palette::DEEPSEEK_SKY).bold(),
|
||||
)]),
|
||||
Line::from(" Tab / Shift+Tab - Complete /command or cycle modes"),
|
||||
Line::from(" Alt+1/2/3 - Directly jump to Plan/Agent/YOLO"),
|
||||
Line::from(" Alt+P/A/Y - Alternative jump to Plan/Agent/YOLO"),
|
||||
Line::from(" Alt+!/@/#/$/+) - Focus Plan/Todos/Tasks/Agents/Auto sidebar"),
|
||||
Line::from(" /agent /yolo /plan - Set mode directly"),
|
||||
Line::from(" Ctrl+X - Toggle between Plan and Agent modes"),
|
||||
row(
|
||||
format!("{} / {}", kb(plain(KeyCode::Tab)), kb(shift(KeyCode::Tab))),
|
||||
"Complete /command or cycle modes",
|
||||
),
|
||||
row(
|
||||
format!("{}/2/3", kb(alt(KeyCode::Char('1')))),
|
||||
"Directly jump to Plan/Agent/YOLO",
|
||||
),
|
||||
row(
|
||||
format!("{}/A/Y", kb(alt(KeyCode::Char('p')))),
|
||||
"Alternative jump to Plan/Agent/YOLO",
|
||||
),
|
||||
row(
|
||||
format!("{}/@/#/$/)", kb(alt(KeyCode::Char('!')))),
|
||||
"Focus Plan/Todos/Tasks/Agents/Auto sidebar",
|
||||
),
|
||||
row("/agent /yolo /plan".to_string(), "Set mode directly"),
|
||||
row(
|
||||
kb(ctrl(KeyCode::Char('x'))),
|
||||
"Toggle between Plan and Agent modes",
|
||||
),
|
||||
Line::from(""),
|
||||
Line::from(vec![Span::styled(
|
||||
"=== Sessions ===",
|
||||
Style::default().fg(palette::DEEPSEEK_SKY).bold(),
|
||||
)]),
|
||||
Line::from(" Ctrl+R - Open session picker"),
|
||||
row(kb(ctrl(KeyCode::Char('r'))), "Open session picker"),
|
||||
Line::from(""),
|
||||
Line::from(vec![Span::styled(
|
||||
"=== Clipboard ===",
|
||||
Style::default().fg(palette::DEEPSEEK_SKY).bold(),
|
||||
)]),
|
||||
Line::from(" Ctrl+V - Paste text or attach clipboard image"),
|
||||
Line::from(" Ctrl+Shift+C - Copy selection (Cmd+C on macOS)"),
|
||||
Line::from(" @path - Add local text file or directory context"),
|
||||
Line::from(" /attach <path> - Attach local image/video media path"),
|
||||
row(
|
||||
kb(ctrl(KeyCode::Char('v'))),
|
||||
"Paste text or attach clipboard image",
|
||||
),
|
||||
row(
|
||||
kb(crate::tui::widgets::key_hint::KeyBinding::new(
|
||||
KeyCode::Char('c'),
|
||||
crossterm::event::KeyModifiers::CONTROL | crossterm::event::KeyModifiers::SHIFT,
|
||||
)),
|
||||
"Copy selection (Cmd+C on macOS)",
|
||||
),
|
||||
row(
|
||||
"@path".to_string(),
|
||||
"Add local text file or directory context",
|
||||
),
|
||||
row(
|
||||
"/attach <path>".to_string(),
|
||||
"Attach local image/video media path",
|
||||
),
|
||||
Line::from(""),
|
||||
Line::from(vec![Span::styled(
|
||||
"=== Help ===",
|
||||
Style::default().fg(palette::DEEPSEEK_SKY).bold(),
|
||||
)]),
|
||||
Line::from(" F1 / Ctrl+/ - Toggle this help view"),
|
||||
row(
|
||||
format!(
|
||||
"{} / {}",
|
||||
kb(plain(KeyCode::F(1))),
|
||||
kb(ctrl(KeyCode::Char('/')))
|
||||
),
|
||||
"Toggle this help view",
|
||||
),
|
||||
Line::from(""),
|
||||
Line::from(vec![Span::styled(
|
||||
"=== Mouse ===",
|
||||
|
||||
@@ -0,0 +1,313 @@
|
||||
//! Terminal-aware keybinding rendering.
|
||||
//!
|
||||
//! `KeyBinding` is a typed representation of a chord (a [`KeyCode`] plus a
|
||||
//! [`KeyModifiers`] set) that knows how to render itself in a way that matches
|
||||
//! the host platform's conventions. On macOS the Option key renders as `⌥`
|
||||
//! (matching how every other Mac app — including Terminal, iTerm2, and the
|
||||
//! system menu bar — labels Option chords). On Linux and Windows we keep the
|
||||
//! plain-text `alt + X` notation that users coming from other CLIs already
|
||||
//! recognise.
|
||||
//!
|
||||
//! See `codex-rs/tui/src/key_hint.rs` for the original design; this is a
|
||||
//! ratatui-compatible port that exposes a [`Display`] impl plus a
|
||||
//! `KeyBinding -> Span` conversion so call sites can use it equally well in
|
||||
//! plain `format!` calls and inside ratatui [`Line`] / [`Span`] builders.
|
||||
//!
|
||||
//! Windows AltGr disambiguation: many European keyboard layouts produce
|
||||
//! `Ctrl+Alt` events when AltGr is pressed alone (to type `@`, `\`, etc.).
|
||||
//! [`is_altgr`] returns `true` for that combination on Windows so callers can
|
||||
//! suppress alt-bound shortcut matching when the user is genuinely just
|
||||
//! reaching for a glyph. On non-Windows targets the function always returns
|
||||
//! `false`. See [`has_ctrl_or_alt`] for the convenience predicate that
|
||||
//! shortcut handlers should prefer over a raw `mods.contains(...)` check.
|
||||
|
||||
use std::fmt;
|
||||
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
|
||||
use ratatui::{
|
||||
style::{Style, Stylize},
|
||||
text::Span,
|
||||
};
|
||||
|
||||
// Compile-time platform detection. The `#[cfg(test)]` arm forces the macOS
|
||||
// rendering during `cargo test` so unit tests are deterministic regardless of
|
||||
// the host they run on (CI hits Ubuntu, macOS, and Windows).
|
||||
#[cfg(test)]
|
||||
const ALT_PREFIX: &str = "⌥+";
|
||||
#[cfg(all(not(test), target_os = "macos"))]
|
||||
const ALT_PREFIX: &str = "⌥+";
|
||||
#[cfg(all(not(test), not(target_os = "macos")))]
|
||||
const ALT_PREFIX: &str = "alt+";
|
||||
|
||||
const CTRL_PREFIX: &str = "ctrl+";
|
||||
const SHIFT_PREFIX: &str = "shift+";
|
||||
|
||||
/// A typed representation of a single chord (key + modifiers).
|
||||
///
|
||||
/// Construct via [`plain`], [`alt`], [`shift`], [`ctrl`], or [`ctrl_alt`] for
|
||||
/// the common cases, or [`KeyBinding::new`] for arbitrary modifier sets.
|
||||
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
|
||||
pub struct KeyBinding {
|
||||
key: KeyCode,
|
||||
modifiers: KeyModifiers,
|
||||
}
|
||||
|
||||
impl KeyBinding {
|
||||
/// Build a binding from a key code and modifier set.
|
||||
pub const fn new(key: KeyCode, modifiers: KeyModifiers) -> Self {
|
||||
Self { key, modifiers }
|
||||
}
|
||||
|
||||
/// `true` if the supplied [`KeyEvent`] matches this binding (key + mods),
|
||||
/// considering only `Press` / `Repeat` events (release events are ignored
|
||||
/// — crossterm only emits them when key-release reporting is on, and we
|
||||
/// never want to fire a shortcut on key-up regardless).
|
||||
pub fn is_press(&self, event: KeyEvent) -> bool {
|
||||
self.key == event.code
|
||||
&& self.modifiers == event.modifiers
|
||||
&& (event.kind == KeyEventKind::Press || event.kind == KeyEventKind::Repeat)
|
||||
}
|
||||
}
|
||||
|
||||
/// A binding with no modifiers.
|
||||
pub const fn plain(key: KeyCode) -> KeyBinding {
|
||||
KeyBinding::new(key, KeyModifiers::NONE)
|
||||
}
|
||||
|
||||
/// `Alt`-modified binding (renders as `⌥` on macOS, `alt+` elsewhere).
|
||||
pub const fn alt(key: KeyCode) -> KeyBinding {
|
||||
KeyBinding::new(key, KeyModifiers::ALT)
|
||||
}
|
||||
|
||||
/// `Shift`-modified binding.
|
||||
pub const fn shift(key: KeyCode) -> KeyBinding {
|
||||
KeyBinding::new(key, KeyModifiers::SHIFT)
|
||||
}
|
||||
|
||||
/// `Ctrl`-modified binding.
|
||||
pub const fn ctrl(key: KeyCode) -> KeyBinding {
|
||||
KeyBinding::new(key, KeyModifiers::CONTROL)
|
||||
}
|
||||
|
||||
/// `Ctrl+Alt`-modified binding.
|
||||
pub const fn ctrl_alt(key: KeyCode) -> KeyBinding {
|
||||
KeyBinding::new(key, KeyModifiers::CONTROL.union(KeyModifiers::ALT))
|
||||
}
|
||||
|
||||
fn modifiers_to_string(modifiers: KeyModifiers) -> String {
|
||||
let mut result = String::new();
|
||||
if modifiers.contains(KeyModifiers::CONTROL) {
|
||||
result.push_str(CTRL_PREFIX);
|
||||
}
|
||||
if modifiers.contains(KeyModifiers::SHIFT) {
|
||||
result.push_str(SHIFT_PREFIX);
|
||||
}
|
||||
if modifiers.contains(KeyModifiers::ALT) {
|
||||
result.push_str(ALT_PREFIX);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
fn keycode_to_string(key: &KeyCode) -> String {
|
||||
match key {
|
||||
KeyCode::Enter => "enter".to_string(),
|
||||
KeyCode::Tab => "tab".to_string(),
|
||||
KeyCode::BackTab => "shift+tab".to_string(),
|
||||
KeyCode::Backspace => "backspace".to_string(),
|
||||
KeyCode::Delete => "del".to_string(),
|
||||
KeyCode::Esc => "esc".to_string(),
|
||||
KeyCode::Char(' ') => "space".to_string(),
|
||||
KeyCode::Char(c) => c.to_string().to_ascii_lowercase(),
|
||||
KeyCode::Up => "↑".to_string(),
|
||||
KeyCode::Down => "↓".to_string(),
|
||||
KeyCode::Left => "←".to_string(),
|
||||
KeyCode::Right => "→".to_string(),
|
||||
KeyCode::PageUp => "pgup".to_string(),
|
||||
KeyCode::PageDown => "pgdn".to_string(),
|
||||
KeyCode::Home => "home".to_string(),
|
||||
KeyCode::End => "end".to_string(),
|
||||
KeyCode::F(n) => format!("f{n}"),
|
||||
_ => format!("{key}").to_ascii_lowercase(),
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for KeyBinding {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(
|
||||
f,
|
||||
"{}{}",
|
||||
modifiers_to_string(self.modifiers),
|
||||
keycode_to_string(&self.key)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<KeyBinding> for Span<'static> {
|
||||
fn from(binding: KeyBinding) -> Self {
|
||||
(&binding).into()
|
||||
}
|
||||
}
|
||||
|
||||
impl From<&KeyBinding> for Span<'static> {
|
||||
fn from(binding: &KeyBinding) -> Self {
|
||||
Span::styled(binding.to_string(), key_hint_style())
|
||||
}
|
||||
}
|
||||
|
||||
fn key_hint_style() -> Style {
|
||||
Style::default().dim()
|
||||
}
|
||||
|
||||
/// `true` if `mods` carries Ctrl or Alt — but not the AltGr Ctrl+Alt
|
||||
/// combination on Windows. Shortcut handlers should prefer this predicate
|
||||
/// over `mods.contains(CONTROL) || mods.contains(ALT)` so they don't fire on
|
||||
/// AltGr keypresses (which on European keyboard layouts are how users type
|
||||
/// `@`, `\`, `|`, etc.).
|
||||
pub fn has_ctrl_or_alt(mods: KeyModifiers) -> bool {
|
||||
(mods.contains(KeyModifiers::CONTROL) || mods.contains(KeyModifiers::ALT)) && !is_altgr(mods)
|
||||
}
|
||||
|
||||
/// On Windows, AltGr is delivered as `Ctrl+Alt`. There's no terminal-portable
|
||||
/// way to tell a real `Ctrl+Alt` chord apart from a layout-emitted AltGr glyph
|
||||
/// — crossterm doesn't expose left-vs-right modifier distinction across all
|
||||
/// backends — so we treat any `Ctrl+Alt` (with no other modifiers) as AltGr.
|
||||
/// This trades the (rare) ability to bind `Ctrl+Alt+<char>` for not
|
||||
/// swallowing accented characters European users type. On non-Windows
|
||||
/// platforms this always returns `false`.
|
||||
#[cfg(windows)]
|
||||
#[inline]
|
||||
pub fn is_altgr(mods: KeyModifiers) -> bool {
|
||||
mods.contains(KeyModifiers::ALT) && mods.contains(KeyModifiers::CONTROL)
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
#[inline]
|
||||
pub fn is_altgr(_mods: KeyModifiers) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
// Tests force ALT_PREFIX = "⌥+" via `cfg(test)`. We verify both
|
||||
// platform-specific renderings explicitly by invoking the helper code
|
||||
// paths the host-OS cfg arms would select.
|
||||
|
||||
#[test]
|
||||
fn plain_renders_just_the_key() {
|
||||
assert_eq!(plain(KeyCode::Enter).to_string(), "enter");
|
||||
assert_eq!(plain(KeyCode::Char(' ')).to_string(), "space");
|
||||
assert_eq!(plain(KeyCode::Up).to_string(), "↑");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alt_renders_with_macos_glyph_in_tests() {
|
||||
// Under cfg(test) we force the macOS prefix so test output is
|
||||
// deterministic. The non-macOS rendering is exercised in
|
||||
// `non_macos_alt_prefix` below.
|
||||
assert_eq!(alt(KeyCode::Up).to_string(), "⌥+↑");
|
||||
assert_eq!(alt(KeyCode::Char('p')).to_string(), "⌥+p");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn shift_and_ctrl_render_in_canonical_order() {
|
||||
// Order is: ctrl, shift, alt — matching codex-rs and what users
|
||||
// expect from cross-tool muscle memory.
|
||||
assert_eq!(ctrl(KeyCode::Char('c')).to_string(), "ctrl+c");
|
||||
assert_eq!(shift(KeyCode::Tab).to_string(), "shift+tab");
|
||||
assert_eq!(
|
||||
KeyBinding::new(
|
||||
KeyCode::Char('x'),
|
||||
KeyModifiers::CONTROL | KeyModifiers::SHIFT
|
||||
)
|
||||
.to_string(),
|
||||
"ctrl+shift+x"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ctrl_alt_combo_renders_both_modifiers() {
|
||||
assert_eq!(ctrl_alt(KeyCode::Char('a')).to_string(), "ctrl+⌥+a");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keycode_lowercases_letters() {
|
||||
assert_eq!(plain(KeyCode::Char('A')).to_string(), "a");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn function_keys_render_as_f_n() {
|
||||
assert_eq!(plain(KeyCode::F(1)).to_string(), "f1");
|
||||
assert_eq!(plain(KeyCode::F(12)).to_string(), "f12");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn span_conversion_carries_dim_style() {
|
||||
let span: Span<'static> = alt(KeyCode::Up).into();
|
||||
assert_eq!(span.content, "⌥+↑");
|
||||
// The exact `Style` representation in ratatui isn't trivially
|
||||
// comparable, so we just verify the style was set (not default).
|
||||
assert_ne!(span.style, Style::default());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn is_press_matches_press_and_repeat() {
|
||||
let binding = ctrl(KeyCode::Char('c'));
|
||||
let press = KeyEvent {
|
||||
code: KeyCode::Char('c'),
|
||||
modifiers: KeyModifiers::CONTROL,
|
||||
kind: KeyEventKind::Press,
|
||||
state: crossterm::event::KeyEventState::NONE,
|
||||
};
|
||||
let repeat = KeyEvent {
|
||||
kind: KeyEventKind::Repeat,
|
||||
..press
|
||||
};
|
||||
let release = KeyEvent {
|
||||
kind: KeyEventKind::Release,
|
||||
..press
|
||||
};
|
||||
let wrong_mods = KeyEvent {
|
||||
modifiers: KeyModifiers::NONE,
|
||||
..press
|
||||
};
|
||||
assert!(binding.is_press(press));
|
||||
assert!(binding.is_press(repeat));
|
||||
assert!(!binding.is_press(release));
|
||||
assert!(!binding.is_press(wrong_mods));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn altgr_only_fires_on_windows() {
|
||||
let altgr_mods = KeyModifiers::ALT | KeyModifiers::CONTROL;
|
||||
if cfg!(windows) {
|
||||
assert!(is_altgr(altgr_mods));
|
||||
assert!(!has_ctrl_or_alt(altgr_mods));
|
||||
} else {
|
||||
assert!(!is_altgr(altgr_mods));
|
||||
assert!(has_ctrl_or_alt(altgr_mods));
|
||||
}
|
||||
// Plain Alt is never AltGr.
|
||||
assert!(!is_altgr(KeyModifiers::ALT));
|
||||
assert!(has_ctrl_or_alt(KeyModifiers::ALT));
|
||||
// No modifiers: never Ctrl/Alt.
|
||||
assert!(!has_ctrl_or_alt(KeyModifiers::NONE));
|
||||
}
|
||||
|
||||
/// Render an alt-prefixed binding the way the Linux/Windows non-test arm
|
||||
/// would. We can't toggle the cfg at runtime, so we rebuild the rendering
|
||||
/// with the alternate prefix to lock in the expected string shape.
|
||||
#[test]
|
||||
fn non_macos_alt_prefix_shape() {
|
||||
let mods = modifiers_to_string(KeyModifiers::ALT);
|
||||
// Under cfg(test), this is "⌥+". Strip and re-render with "alt+" to
|
||||
// demonstrate the shape that ships on Linux/Windows release builds.
|
||||
let linux_shape = mods.replace("⌥+", "alt+");
|
||||
assert_eq!(linux_shape, "alt+");
|
||||
|
||||
let mods_mixed = modifiers_to_string(KeyModifiers::CONTROL | KeyModifiers::ALT);
|
||||
let linux_shape_mixed = mods_mixed.replace("⌥+", "alt+");
|
||||
assert_eq!(linux_shape_mixed, "ctrl+alt+");
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,10 @@
|
||||
mod footer;
|
||||
mod header;
|
||||
// Some helpers (`shift`, `ctrl_alt`, `is_press`, etc.) are part of the
|
||||
// public surface for issue #93's help overlay and future call sites; allow
|
||||
// dead code rather than scattering `#[allow]` across every constructor.
|
||||
#[allow(dead_code)]
|
||||
pub mod key_hint;
|
||||
mod renderable;
|
||||
|
||||
pub use footer::{
|
||||
|
||||
Reference in New Issue
Block a user