Zig's KeyEvent.utf8 and the embedded API's key/preedit text are byte slices passed through to key encoding and the pty; the port converted them lossily to UTF-8. - KeyEvent.utf8 is &[u8] end to end (ghostty-input encoder, core, ghostty-lib C API, libghostty-vt key events, GTK callers). Where Zig validates with Utf8View (alternates, modify-other-keys, ctrl CSI-u, associated text, binding unicode lookup) invalid text takes the same "no UTF-8" fallback. - Invalid preedit UTF-8 behaves like Zig's `try Utf8View.init`: the prior preedit is cleared and marked dirty, the callback returns an error (logged by the C API) and no preedit is set. - The inspector key widget shows invalid key text lossily, display only. Test Plan: - cargo test --workspace --no-fail-fast (3407 passed) - cargo test -p ghostty-lib --features test-platform (C-API key text 0xff 0xfe reaches the pty unchanged) - just check-macos (both), just check-windows, just check-wasm
540 lines
15 KiB
Rust
540 lines
15 KiB
Rust
//! Keyboard event inspector (ZIG/src/inspector/widgets/key.zig).
|
|
|
|
use std::collections::VecDeque;
|
|
|
|
use dear_imgui_rs::{InputTextFlags, TableColumnFlags, TableColumnWidth, TableFlags, Ui};
|
|
use ghostty_input::{Action, Key, KeyEvent, Mods, binding::Action as BindingAction};
|
|
|
|
/// The maximum number of recorded events (Zig: the ring grows by doubling up
|
|
/// to this capacity, after which the oldest event is dropped).
|
|
pub const MAX_EVENTS: usize = 50;
|
|
|
|
/// The recorded copy of the input event (Zig keeps an `input.KeyEvent` whose
|
|
/// `utf8` is owned by the inspector).
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct InputEvent {
|
|
pub action: Action,
|
|
pub key: Key,
|
|
pub mods: Mods,
|
|
pub consumed_mods: Mods,
|
|
pub composing: bool,
|
|
pub utf8: String,
|
|
pub unshifted_codepoint: u32,
|
|
}
|
|
|
|
impl InputEvent {
|
|
pub fn from_key_event(ev: &KeyEvent<'_>) -> Self {
|
|
Self {
|
|
action: ev.action,
|
|
key: ev.key,
|
|
mods: ev.mods,
|
|
consumed_mods: ev.consumed_mods,
|
|
composing: ev.composing,
|
|
// Display only: invalid UTF-8 is shown as U+FFFD.
|
|
utf8: String::from_utf8_lossy(ev.utf8).into_owned(),
|
|
unshifted_codepoint: ev.unshifted_codepoint,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Represents a recorded keyboard event.
|
|
#[derive(Debug, Clone)]
|
|
pub struct Event {
|
|
/// The input event.
|
|
pub event: InputEvent,
|
|
|
|
/// The binding that was triggered as a result of this event.
|
|
/// Multiple bindings are possible if they are chained.
|
|
pub binding: Vec<BindingAction>,
|
|
|
|
/// The data sent to the pty as a result of this keyboard event.
|
|
pub pty: Vec<u8>,
|
|
|
|
/// State for the inspector GUI. Do not set this unless you're the inspector.
|
|
pub imgui_selected: bool,
|
|
|
|
/// Unique ID used for the ImGui ID stack (Zig used the event pointer).
|
|
id: u64,
|
|
}
|
|
|
|
fn next_event_id() -> u64 {
|
|
use std::sync::atomic::{AtomicU64, Ordering};
|
|
static NEXT: AtomicU64 = AtomicU64::new(1);
|
|
NEXT.fetch_add(1, Ordering::Relaxed)
|
|
}
|
|
|
|
fn action_name(a: Action) -> &'static str {
|
|
match a {
|
|
Action::Press => "press",
|
|
Action::Release => "release",
|
|
Action::Repeat => "repeat",
|
|
}
|
|
}
|
|
|
|
/// The name of the key, or its UTF-8 for unidentified keys.
|
|
fn key_display(ev: &InputEvent) -> String {
|
|
match ev.key {
|
|
Key::Unidentified if !ev.utf8.is_empty() => ev.utf8.clone(),
|
|
k => k.name().to_owned(),
|
|
}
|
|
}
|
|
|
|
/// Formats pty data like the inspector shows it.
|
|
fn format_pty(data: &[u8]) -> String {
|
|
use std::fmt::Write;
|
|
let mut out = String::new();
|
|
for &byte in data {
|
|
// Print ESC special because its so common
|
|
if byte == 0x1B {
|
|
out.push_str("ESC ");
|
|
continue;
|
|
}
|
|
|
|
// Print ASCII as-is
|
|
if byte > 0x20 && byte < 0x7F {
|
|
out.push(char::from(byte));
|
|
continue;
|
|
}
|
|
|
|
// Everything else as a hex byte
|
|
let _ = write!(out, "0x{byte:X} ");
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Formats a UTF-8 string as a sequence of codepoints.
|
|
fn format_utf8(utf8: &str) -> String {
|
|
use std::fmt::Write;
|
|
let mut out = String::new();
|
|
for ch in utf8.chars() {
|
|
let _ = write!(out, "U+{:X} ", u32::from(ch));
|
|
}
|
|
out
|
|
}
|
|
|
|
impl Event {
|
|
pub fn new(ev: &KeyEvent<'_>) -> Self {
|
|
Self {
|
|
event: InputEvent::from_key_event(ev),
|
|
binding: Vec::new(),
|
|
pty: Vec::new(),
|
|
imgui_selected: false,
|
|
id: next_event_id(),
|
|
}
|
|
}
|
|
|
|
/// Returns a label that can be used for this event.
|
|
pub fn label(&self) -> String {
|
|
let mut out = String::new();
|
|
match self.event.action {
|
|
Action::Press => out.push_str("Press: "),
|
|
Action::Release => out.push_str("Release: "),
|
|
Action::Repeat => out.push_str("Repeat: "),
|
|
}
|
|
|
|
if self.event.mods.shift {
|
|
out.push_str("Shift+");
|
|
}
|
|
if self.event.mods.ctrl {
|
|
out.push_str("Ctrl+");
|
|
}
|
|
if self.event.mods.alt {
|
|
out.push_str("Alt+");
|
|
}
|
|
if self.event.mods.super_ {
|
|
out.push_str("Super+");
|
|
}
|
|
|
|
// Write our key. If we have an invalid key we attempt to write
|
|
// the utf8 associated with it if we have it to handle non-ascii.
|
|
out.push_str(&key_display(&self.event));
|
|
|
|
// Deadkey
|
|
if self.event.composing {
|
|
out.push_str(" (composing)");
|
|
}
|
|
|
|
out
|
|
}
|
|
|
|
/// Render this event in the inspector GUI.
|
|
pub fn render(&self, ui: &Ui) {
|
|
let Some(_t) = ui.begin_table_with_flags("##event", 2, TableFlags::NONE) else {
|
|
return;
|
|
};
|
|
|
|
if !self.binding.is_empty() {
|
|
ui.table_next_row();
|
|
ui.table_set_column_index(0);
|
|
ui.text("Triggered Binding");
|
|
ui.table_set_column_index(1);
|
|
|
|
let height = {
|
|
let item_count = self.binding.len().min(5) as f32;
|
|
let padding = ui.clone_style().frame_padding()[1] * 2.0;
|
|
ui.text_line_height_with_spacing() * item_count + padding
|
|
};
|
|
if let Some(_list) = ui
|
|
.list_box_config("##bindings")
|
|
.size([0.0, height])
|
|
.begin(ui)
|
|
{
|
|
for action in &self.binding {
|
|
ui.selectable(action.key().name());
|
|
}
|
|
}
|
|
}
|
|
|
|
{
|
|
ui.table_next_row();
|
|
ui.table_set_column_index(0);
|
|
ui.text("Encoding to Pty");
|
|
ui.table_set_column_index(1);
|
|
if self.pty.is_empty() {
|
|
ui.text_disabled("(no data)");
|
|
} else {
|
|
let mut buf = format_pty(&self.pty);
|
|
ui.input_text("##pty", &mut buf)
|
|
.flags(InputTextFlags::READ_ONLY)
|
|
.build();
|
|
}
|
|
}
|
|
|
|
{
|
|
ui.table_next_row();
|
|
ui.table_set_column_index(0);
|
|
ui.text("Action");
|
|
ui.table_set_column_index(1);
|
|
ui.text(action_name(self.event.action));
|
|
}
|
|
{
|
|
ui.table_next_row();
|
|
ui.table_set_column_index(0);
|
|
ui.text("Key");
|
|
ui.table_set_column_index(1);
|
|
ui.text(self.event.key.name());
|
|
}
|
|
if !self.event.mods.empty() {
|
|
ui.table_next_row();
|
|
ui.table_set_column_index(0);
|
|
ui.text("Mods");
|
|
ui.table_set_column_index(1);
|
|
if self.event.mods.shift {
|
|
ui.text("shift ");
|
|
}
|
|
if self.event.mods.ctrl {
|
|
ui.text("ctrl ");
|
|
}
|
|
if self.event.mods.alt {
|
|
ui.text("alt ");
|
|
}
|
|
if self.event.mods.super_ {
|
|
ui.text("super ");
|
|
}
|
|
}
|
|
if self.event.composing {
|
|
ui.table_next_row();
|
|
ui.table_set_column_index(0);
|
|
ui.text("Composing");
|
|
ui.table_set_column_index(1);
|
|
ui.text("true");
|
|
}
|
|
{
|
|
ui.table_next_row();
|
|
ui.table_set_column_index(0);
|
|
ui.text("UTF-8");
|
|
ui.table_set_column_index(1);
|
|
if self.event.utf8.is_empty() {
|
|
ui.text_disabled("(empty)");
|
|
} else {
|
|
let mut buf = format_utf8(&self.event.utf8);
|
|
ui.input_text("##utf8", &mut buf)
|
|
.flags(InputTextFlags::READ_ONLY)
|
|
.build();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn mods_tooltip(mods: &Mods) -> String {
|
|
let mut parts = Vec::new();
|
|
if mods.shift {
|
|
parts.push("Shift");
|
|
}
|
|
if mods.ctrl {
|
|
parts.push("Ctrl");
|
|
}
|
|
if mods.alt {
|
|
parts.push("Alt");
|
|
}
|
|
if mods.super_ {
|
|
parts.push("Super");
|
|
}
|
|
parts.join("+")
|
|
}
|
|
|
|
/// Keyboard event stream inspector widget.
|
|
#[derive(Debug, Default)]
|
|
pub struct Stream {
|
|
pub events: VecDeque<Event>,
|
|
}
|
|
|
|
impl Stream {
|
|
pub fn new() -> Self {
|
|
Self {
|
|
events: VecDeque::with_capacity(MAX_EVENTS),
|
|
}
|
|
}
|
|
|
|
/// Append an event, dropping the oldest one when full.
|
|
pub fn record(&mut self, ev: Event) {
|
|
if self.events.len() >= MAX_EVENTS {
|
|
self.events.pop_front();
|
|
}
|
|
self.events.push_back(ev);
|
|
}
|
|
|
|
pub fn draw(&mut self, ui: &Ui, open: bool) {
|
|
if !open {
|
|
return;
|
|
}
|
|
|
|
if self.events.is_empty() {
|
|
ui.text("No recorded key events. Press a key with the terminal focused to record it.");
|
|
return;
|
|
}
|
|
|
|
if ui.button("Clear") {
|
|
self.events.clear();
|
|
}
|
|
|
|
ui.separator();
|
|
|
|
let table_flags = TableFlags::BORDERS | TableFlags::RESIZABLE | TableFlags::SCROLL_Y;
|
|
|
|
let Some(_t) = ui.begin_table_with_flags(
|
|
"table_key_events",
|
|
6,
|
|
dear_imgui_rs::TableOptions::default()
|
|
.flags(table_flags)
|
|
.sizing_policy(dear_imgui_rs::TableSizingPolicy::FixedFit),
|
|
) else {
|
|
return;
|
|
};
|
|
|
|
ui.table_setup_scroll_freeze(0, 1);
|
|
let fixed = |name: &str, w: f32| {
|
|
ui.table_setup_column(
|
|
name,
|
|
TableColumnFlags::NONE,
|
|
Some(TableColumnWidth::Fixed(w)),
|
|
);
|
|
};
|
|
fixed("Action", 80.0);
|
|
fixed("Key", 160.0);
|
|
fixed("Mods", 150.0);
|
|
fixed("UTF-8", 80.0);
|
|
ui.table_setup_column(
|
|
"PTY Encoding",
|
|
TableColumnFlags::NONE,
|
|
Some(TableColumnWidth::Stretch(0.0)),
|
|
);
|
|
ui.table_setup_column(
|
|
"Binding",
|
|
TableColumnFlags::NONE,
|
|
Some(TableColumnWidth::Stretch(0.0)),
|
|
);
|
|
ui.table_headers_row();
|
|
|
|
for ev in self.events.iter().rev() {
|
|
let _id = ui.push_id(ev.id as i32);
|
|
|
|
ui.table_next_row();
|
|
let row_min_y = ui.cursor_screen_pos()[1];
|
|
|
|
// Set row background color based on action
|
|
ui.table_set_row_bg0_color_u32(action_color(ev.event.action));
|
|
|
|
// Action column with colored text
|
|
ui.table_set_column_index(0);
|
|
let action_text_color = match ev.event.action {
|
|
Action::Press => [0.4, 1.0, 0.4, 1.0], // Green
|
|
Action::Release => [0.6, 0.6, 1.0, 1.0], // Blue
|
|
Action::Repeat => [1.0, 1.0, 0.4, 1.0], // Yellow
|
|
};
|
|
ui.text_colored(action_text_color, action_name(ev.event.action));
|
|
|
|
// Key column with consistent key coloring
|
|
ui.table_set_column_index(1);
|
|
let key_name = key_display(&ev.event);
|
|
let key_rgba = key_color(ev.event.key);
|
|
let key_color = [
|
|
(key_rgba & 0xFF) as f32 / 255.0,
|
|
((key_rgba >> 8) & 0xFF) as f32 / 255.0,
|
|
((key_rgba >> 16) & 0xFF) as f32 / 255.0,
|
|
1.0,
|
|
];
|
|
ui.text_colored(key_color, key_name);
|
|
|
|
// Composing indicator
|
|
if ev.event.composing {
|
|
ui.same_line();
|
|
ui.text_colored([1.0, 0.6, 0.0, 1.0], "*");
|
|
if ui.is_item_hovered() {
|
|
ui.set_tooltip("Composing (dead key)");
|
|
}
|
|
}
|
|
|
|
// Mods
|
|
ui.table_set_column_index(2);
|
|
if ev.event.mods.empty() {
|
|
ui.text_disabled("-");
|
|
} else {
|
|
let mut any_hovered = false;
|
|
for (set, label) in [
|
|
(ev.event.mods.shift, "S"),
|
|
(ev.event.mods.ctrl, "C"),
|
|
(ev.event.mods.alt, "A"),
|
|
(ev.event.mods.super_, "M"),
|
|
] {
|
|
if set {
|
|
ui.small_button(label);
|
|
any_hovered = any_hovered || ui.is_item_hovered();
|
|
ui.same_line();
|
|
}
|
|
}
|
|
ui.new_line();
|
|
|
|
if any_hovered {
|
|
ui.set_tooltip(mods_tooltip(&ev.event.mods));
|
|
}
|
|
}
|
|
|
|
// UTF-8
|
|
ui.table_set_column_index(3);
|
|
if ev.event.utf8.is_empty() {
|
|
ui.text_disabled("-");
|
|
} else {
|
|
ui.text(format_utf8(&ev.event.utf8));
|
|
}
|
|
|
|
// PTY
|
|
ui.table_set_column_index(4);
|
|
if ev.pty.is_empty() {
|
|
ui.text_disabled("-");
|
|
} else {
|
|
ui.text(format_pty(&ev.pty));
|
|
}
|
|
|
|
// Binding
|
|
ui.table_set_column_index(5);
|
|
if ev.binding.is_empty() {
|
|
ui.text_disabled("-");
|
|
} else {
|
|
let names: Vec<&str> = ev.binding.iter().map(|a| a.key().name()).collect();
|
|
ui.text(names.join(", "));
|
|
}
|
|
|
|
// Row hover highlight
|
|
let row_max_y = ui.cursor_screen_pos()[1];
|
|
let mouse_pos = ui.mouse_pos();
|
|
if mouse_pos[1] >= row_min_y && mouse_pos[1] < row_max_y {
|
|
ui.table_set_row_bg1_color_u32(0x1AFFFFFF);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Returns row background color for an action (ABGR format for ImGui)
|
|
fn action_color(action: Action) -> u32 {
|
|
match action {
|
|
Action::Press => 0x1A4A6F4A, // Muted sage green
|
|
Action::Release => 0x1A6A5A5A, // Muted slate gray
|
|
Action::Repeat => 0x1A4A5A6F, // Muted warm brown
|
|
}
|
|
}
|
|
|
|
/// Generate a consistent color for a key based on its enum value.
|
|
/// Uses HSV color space with fixed saturation and value for pleasing colors.
|
|
fn key_color(key: Key) -> u32 {
|
|
let key_int = key as u32;
|
|
let hue = key_int.wrapping_mul(47) as f32 / 256.0;
|
|
hsv_to_rgba(hue, 0.5, 0.9, 1.0)
|
|
}
|
|
|
|
/// Convert HSV (hue 0-1, saturation 0-1, value 0-1) to RGBA u32.
|
|
fn hsv_to_rgba(h: f32, s: f32, v: f32, a: f32) -> u32 {
|
|
let i = (h * 6.0) as u32;
|
|
let f = h * 6.0 - i as f32;
|
|
let p = v * (1.0 - s);
|
|
let q = v * (1.0 - f * s);
|
|
let t = v * (1.0 - (1.0 - f) * s);
|
|
|
|
let (r, g, b) = match i % 6 {
|
|
0 => (v, t, p),
|
|
1 => (q, v, p),
|
|
2 => (p, v, t),
|
|
3 => (p, q, v),
|
|
4 => (t, p, v),
|
|
_ => (v, p, q),
|
|
};
|
|
|
|
let ri = (r * 255.0) as u32;
|
|
let gi = (g * 255.0) as u32;
|
|
let bi = (b * 255.0) as u32;
|
|
let ai = (a * 255.0) as u32;
|
|
|
|
(ai << 24) | (bi << 16) | (gi << 8) | ri
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn press(key: Key, utf8: &str) -> Event {
|
|
Event::new(&KeyEvent {
|
|
action: Action::Press,
|
|
key,
|
|
utf8: utf8.as_bytes(),
|
|
..KeyEvent::default()
|
|
})
|
|
}
|
|
|
|
#[test]
|
|
fn event_label() {
|
|
let mut ev = press(Key::KeyA, "a");
|
|
ev.event.mods.ctrl = true;
|
|
ev.event.mods.shift = true;
|
|
assert_eq!(ev.label(), "Press: Shift+Ctrl+key_a");
|
|
|
|
let ev = press(Key::Unidentified, "é");
|
|
assert_eq!(ev.label(), "Press: é");
|
|
}
|
|
|
|
#[test]
|
|
fn pty_formatting() {
|
|
assert_eq!(format_pty(b"\x1b[A"), "ESC [A");
|
|
assert_eq!(format_pty(&[0x0d, b'a', 0x20]), "0xD a0x20 ");
|
|
}
|
|
|
|
#[test]
|
|
fn utf8_formatting() {
|
|
assert_eq!(format_utf8("aé"), "U+61 U+E9 ");
|
|
}
|
|
|
|
#[test]
|
|
fn ring_drops_oldest() {
|
|
let mut s = Stream::new();
|
|
for _ in 0..(MAX_EVENTS + 5) {
|
|
s.record(press(Key::KeyA, "a"));
|
|
}
|
|
assert_eq!(s.events.len(), MAX_EVENTS);
|
|
}
|
|
|
|
#[test]
|
|
fn hsv_matches_zig() {
|
|
// pure red, value 1
|
|
assert_eq!(hsv_to_rgba(0.0, 1.0, 1.0, 1.0), 0xFF00_00FF);
|
|
}
|
|
}
|