mirror of
https://github.com/herdrdev/herdr.git
synced 2026-11-06 16:06:22 +00:00
1134 lines
39 KiB
Rust
1134 lines
39 KiB
Rust
//! Keyboard transparency conformance.
|
|
//!
|
|
//! Herdr should be invisible to the application in a pane: for every keystroke,
|
|
//! the pane must receive the bytes it would receive running directly in the
|
|
//! host terminal. The oracle is libghostty's encoder fed the same key event the
|
|
//! Ghostty app builds from an OS key event. The Herdr path is the real one:
|
|
//! host bytes -> host framer -> client shell routing -> server pane input ->
|
|
//! pane encoder, with the pane's modes set by application output.
|
|
//!
|
|
//! Keystrokes whose host bytes cannot distinguish keys the pane would see
|
|
//! differently are counted as host-lossy, not as Herdr failures.
|
|
|
|
use std::collections::{BTreeMap, HashMap};
|
|
|
|
use super::*;
|
|
|
|
use crate::ghostty::{self, ffi};
|
|
|
|
#[derive(Clone, Copy)]
|
|
struct KeyDef {
|
|
name: &'static str,
|
|
key: ffi::GhosttyKey,
|
|
/// US layout text: (unshifted, shifted).
|
|
text: Option<(char, char)>,
|
|
}
|
|
|
|
const fn text(name: &'static str, key: ffi::GhosttyKey, base: char, shifted: char) -> KeyDef {
|
|
KeyDef {
|
|
name,
|
|
key,
|
|
text: Some((base, shifted)),
|
|
}
|
|
}
|
|
|
|
const fn func(name: &'static str, key: ffi::GhosttyKey) -> KeyDef {
|
|
KeyDef {
|
|
name,
|
|
key,
|
|
text: None,
|
|
}
|
|
}
|
|
|
|
const KEYS: &[KeyDef] = &[
|
|
text("a", ffi::GhosttyKey_GHOSTTY_KEY_A, 'a', 'A'),
|
|
text("b", ffi::GhosttyKey_GHOSTTY_KEY_B, 'b', 'B'),
|
|
text("c", ffi::GhosttyKey_GHOSTTY_KEY_C, 'c', 'C'),
|
|
text("d", ffi::GhosttyKey_GHOSTTY_KEY_D, 'd', 'D'),
|
|
text("e", ffi::GhosttyKey_GHOSTTY_KEY_E, 'e', 'E'),
|
|
text("f", ffi::GhosttyKey_GHOSTTY_KEY_F, 'f', 'F'),
|
|
text("g", ffi::GhosttyKey_GHOSTTY_KEY_G, 'g', 'G'),
|
|
text("h", ffi::GhosttyKey_GHOSTTY_KEY_H, 'h', 'H'),
|
|
text("i", ffi::GhosttyKey_GHOSTTY_KEY_I, 'i', 'I'),
|
|
text("j", ffi::GhosttyKey_GHOSTTY_KEY_J, 'j', 'J'),
|
|
text("k", ffi::GhosttyKey_GHOSTTY_KEY_K, 'k', 'K'),
|
|
text("l", ffi::GhosttyKey_GHOSTTY_KEY_L, 'l', 'L'),
|
|
text("m", ffi::GhosttyKey_GHOSTTY_KEY_M, 'm', 'M'),
|
|
text("n", ffi::GhosttyKey_GHOSTTY_KEY_N, 'n', 'N'),
|
|
text("o", ffi::GhosttyKey_GHOSTTY_KEY_O, 'o', 'O'),
|
|
text("p", ffi::GhosttyKey_GHOSTTY_KEY_P, 'p', 'P'),
|
|
text("q", ffi::GhosttyKey_GHOSTTY_KEY_Q, 'q', 'Q'),
|
|
text("r", ffi::GhosttyKey_GHOSTTY_KEY_R, 'r', 'R'),
|
|
text("s", ffi::GhosttyKey_GHOSTTY_KEY_S, 's', 'S'),
|
|
text("t", ffi::GhosttyKey_GHOSTTY_KEY_T, 't', 'T'),
|
|
text("u", ffi::GhosttyKey_GHOSTTY_KEY_U, 'u', 'U'),
|
|
text("v", ffi::GhosttyKey_GHOSTTY_KEY_V, 'v', 'V'),
|
|
text("w", ffi::GhosttyKey_GHOSTTY_KEY_W, 'w', 'W'),
|
|
text("x", ffi::GhosttyKey_GHOSTTY_KEY_X, 'x', 'X'),
|
|
text("y", ffi::GhosttyKey_GHOSTTY_KEY_Y, 'y', 'Y'),
|
|
text("z", ffi::GhosttyKey_GHOSTTY_KEY_Z, 'z', 'Z'),
|
|
text("0", ffi::GhosttyKey_GHOSTTY_KEY_DIGIT_0, '0', ')'),
|
|
text("1", ffi::GhosttyKey_GHOSTTY_KEY_DIGIT_1, '1', '!'),
|
|
text("2", ffi::GhosttyKey_GHOSTTY_KEY_DIGIT_2, '2', '@'),
|
|
text("3", ffi::GhosttyKey_GHOSTTY_KEY_DIGIT_3, '3', '#'),
|
|
text("4", ffi::GhosttyKey_GHOSTTY_KEY_DIGIT_4, '4', '$'),
|
|
text("5", ffi::GhosttyKey_GHOSTTY_KEY_DIGIT_5, '5', '%'),
|
|
text("6", ffi::GhosttyKey_GHOSTTY_KEY_DIGIT_6, '6', '^'),
|
|
text("7", ffi::GhosttyKey_GHOSTTY_KEY_DIGIT_7, '7', '&'),
|
|
text("8", ffi::GhosttyKey_GHOSTTY_KEY_DIGIT_8, '8', '*'),
|
|
text("9", ffi::GhosttyKey_GHOSTTY_KEY_DIGIT_9, '9', '('),
|
|
text("`", ffi::GhosttyKey_GHOSTTY_KEY_BACKQUOTE, '`', '~'),
|
|
text("-", ffi::GhosttyKey_GHOSTTY_KEY_MINUS, '-', '_'),
|
|
text("=", ffi::GhosttyKey_GHOSTTY_KEY_EQUAL, '=', '+'),
|
|
text("[", ffi::GhosttyKey_GHOSTTY_KEY_BRACKET_LEFT, '[', '{'),
|
|
text("]", ffi::GhosttyKey_GHOSTTY_KEY_BRACKET_RIGHT, ']', '}'),
|
|
text("\\", ffi::GhosttyKey_GHOSTTY_KEY_BACKSLASH, '\\', '|'),
|
|
text(";", ffi::GhosttyKey_GHOSTTY_KEY_SEMICOLON, ';', ':'),
|
|
text("'", ffi::GhosttyKey_GHOSTTY_KEY_QUOTE, '\'', '"'),
|
|
text(",", ffi::GhosttyKey_GHOSTTY_KEY_COMMA, ',', '<'),
|
|
text(".", ffi::GhosttyKey_GHOSTTY_KEY_PERIOD, '.', '>'),
|
|
text("/", ffi::GhosttyKey_GHOSTTY_KEY_SLASH, '/', '?'),
|
|
text("space", ffi::GhosttyKey_GHOSTTY_KEY_SPACE, ' ', ' '),
|
|
func("enter", ffi::GhosttyKey_GHOSTTY_KEY_ENTER),
|
|
func("tab", ffi::GhosttyKey_GHOSTTY_KEY_TAB),
|
|
func("backspace", ffi::GhosttyKey_GHOSTTY_KEY_BACKSPACE),
|
|
func("escape", ffi::GhosttyKey_GHOSTTY_KEY_ESCAPE),
|
|
func("up", ffi::GhosttyKey_GHOSTTY_KEY_ARROW_UP),
|
|
func("down", ffi::GhosttyKey_GHOSTTY_KEY_ARROW_DOWN),
|
|
func("left", ffi::GhosttyKey_GHOSTTY_KEY_ARROW_LEFT),
|
|
func("right", ffi::GhosttyKey_GHOSTTY_KEY_ARROW_RIGHT),
|
|
func("home", ffi::GhosttyKey_GHOSTTY_KEY_HOME),
|
|
func("end", ffi::GhosttyKey_GHOSTTY_KEY_END),
|
|
func("pageup", ffi::GhosttyKey_GHOSTTY_KEY_PAGE_UP),
|
|
func("pagedown", ffi::GhosttyKey_GHOSTTY_KEY_PAGE_DOWN),
|
|
func("insert", ffi::GhosttyKey_GHOSTTY_KEY_INSERT),
|
|
func("delete", ffi::GhosttyKey_GHOSTTY_KEY_DELETE),
|
|
func("f1", ffi::GhosttyKey_GHOSTTY_KEY_F1),
|
|
func("f2", ffi::GhosttyKey_GHOSTTY_KEY_F2),
|
|
func("f3", ffi::GhosttyKey_GHOSTTY_KEY_F3),
|
|
func("f4", ffi::GhosttyKey_GHOSTTY_KEY_F4),
|
|
func("f5", ffi::GhosttyKey_GHOSTTY_KEY_F5),
|
|
func("f6", ffi::GhosttyKey_GHOSTTY_KEY_F6),
|
|
func("f7", ffi::GhosttyKey_GHOSTTY_KEY_F7),
|
|
func("f8", ffi::GhosttyKey_GHOSTTY_KEY_F8),
|
|
func("f9", ffi::GhosttyKey_GHOSTTY_KEY_F9),
|
|
func("f10", ffi::GhosttyKey_GHOSTTY_KEY_F10),
|
|
func("f11", ffi::GhosttyKey_GHOSTTY_KEY_F11),
|
|
func("f12", ffi::GhosttyKey_GHOSTTY_KEY_F12),
|
|
];
|
|
|
|
/// Modes an application can request by writing to its terminal.
|
|
const PANE_MODES: &[(&str, &[u8])] = &[
|
|
("legacy", b""),
|
|
("legacy+decckm", b"\x1b[?1h"),
|
|
("modifyOtherKeys1", b"\x1b[>4;1m"),
|
|
("modifyOtherKeys2", b"\x1b[>4;2m"),
|
|
("kitty1", b"\x1b[>1u"),
|
|
("kitty3", b"\x1b[>3u"),
|
|
("kitty7", b"\x1b[>7u"),
|
|
("kitty11", b"\x1b[>11u"),
|
|
("kitty15", b"\x1b[>15u"),
|
|
("kitty31", b"\x1b[>31u"),
|
|
];
|
|
|
|
#[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
|
enum HostProfile {
|
|
/// Kitty keyboard host (Ghostty, kitty, WezTerm, foot...). Herdr pushes 7,
|
|
/// or 31 while the pane wants all keys reported.
|
|
Kitty,
|
|
/// Host without an enhanced keyboard protocol.
|
|
Legacy,
|
|
}
|
|
|
|
impl HostProfile {
|
|
fn name(self) -> &'static str {
|
|
match self {
|
|
Self::Kitty => "kitty-host",
|
|
Self::Legacy => "legacy-host",
|
|
}
|
|
}
|
|
|
|
fn setup(self, pane_mode: &[u8]) -> &'static [u8] {
|
|
match self {
|
|
Self::Kitty if pane_mode == b"\x1b[>11u" || pane_mode == b"\x1b[>15u" => b"\x1b[>31u",
|
|
Self::Kitty if pane_mode == b"\x1b[>31u" => b"\x1b[>31u",
|
|
Self::Kitty => b"\x1b[>7u",
|
|
Self::Legacy => b"",
|
|
}
|
|
}
|
|
}
|
|
|
|
const MODS: [(u16, &str); 4] = [
|
|
(ghostty::MOD_CTRL, "ctrl"),
|
|
(ghostty::MOD_ALT, "alt"),
|
|
(ghostty::MOD_SHIFT, "shift"),
|
|
(ghostty::MOD_SUPER, "super"),
|
|
];
|
|
|
|
fn mods_name(mods: u16) -> String {
|
|
let mut parts: Vec<&str> = MODS
|
|
.iter()
|
|
.filter(|(bit, _)| mods & bit != 0)
|
|
.map(|(_, name)| *name)
|
|
.collect();
|
|
if parts.is_empty() {
|
|
parts.push("plain");
|
|
}
|
|
parts.join("+")
|
|
}
|
|
|
|
/// Host bytes for one keystroke: (press, release).
|
|
type HostKeystroke = (Vec<u8>, Vec<u8>);
|
|
|
|
struct Oracle {
|
|
_terminal: ghostty::Terminal,
|
|
encoder: ghostty::KeyEncoder,
|
|
}
|
|
|
|
impl Oracle {
|
|
fn new(app_output: &[u8]) -> Self {
|
|
let mut terminal = ghostty::Terminal::new(80, 24, 0).expect("terminal");
|
|
terminal.write(app_output);
|
|
let mut encoder = ghostty::KeyEncoder::new().expect("encoder");
|
|
encoder.set_from_terminal(&terminal);
|
|
Self {
|
|
_terminal: terminal,
|
|
encoder,
|
|
}
|
|
}
|
|
|
|
/// Bytes for one keystroke (press then release), built like the Ghostty app
|
|
/// builds key events from the OS: physical key, modifiers, layout text, the
|
|
/// unshifted codepoint, and Shift consumed when it produced the text.
|
|
fn keystroke(&mut self, def: KeyDef, mods: u16) -> HostKeystroke {
|
|
let press = self.encode(def, mods, ffi::GhosttyKeyAction_GHOSTTY_KEY_ACTION_PRESS);
|
|
let release = self.encode(def, mods, ffi::GhosttyKeyAction_GHOSTTY_KEY_ACTION_RELEASE);
|
|
(press, release)
|
|
}
|
|
|
|
fn encode(&mut self, def: KeyDef, mods: u16, action: ffi::GhosttyKeyAction) -> Vec<u8> {
|
|
let mut event = ghostty::KeyEvent::new().expect("key event");
|
|
event.set_key(def.key);
|
|
event.set_mods(mods);
|
|
event.set_action(action);
|
|
let utf8;
|
|
if let Some((base, shifted)) = def.text {
|
|
let shift = mods & ghostty::MOD_SHIFT != 0;
|
|
utf8 = if shift { shifted } else { base }.to_string();
|
|
event.set_utf8(&utf8);
|
|
event.set_unshifted_codepoint(base as u32);
|
|
if shift && shifted != base {
|
|
event.set_consumed_mods(ghostty::MOD_SHIFT);
|
|
}
|
|
}
|
|
self.encoder.encode(&event).expect("encode")
|
|
}
|
|
}
|
|
|
|
/// Panes that negotiated nothing. Like Ghostty, modifyOtherKeys level 1 counts
|
|
/// as nothing.
|
|
fn is_legacy_pane(pane_mode: &[u8]) -> bool {
|
|
matches!(pane_mode, b"" | b"\x1b[?1h" | b"\x1b[>4;1m")
|
|
}
|
|
|
|
/// Agreed exception table: panes that negotiated no keyboard protocol keep the
|
|
/// classic bytes for keys where Ghostty's escape sequences break shells, and
|
|
/// get Super chords as basic Kitty reports (see `is_legacy_pane` use above).
|
|
fn legacy_shell_exception(pane_mode: &[u8], key: &str, mods: u16) -> Option<Vec<u8>> {
|
|
if !is_legacy_pane(pane_mode) {
|
|
return None;
|
|
}
|
|
let alt = mods & ghostty::MOD_ALT != 0;
|
|
let base = mods & !ghostty::MOD_ALT;
|
|
let classic: &[u8] = match (key, base) {
|
|
("enter", m) if m != 0 => b"\r",
|
|
("m", ghostty::MOD_CTRL) => b"\r",
|
|
("i", ghostty::MOD_CTRL) | ("tab", ghostty::MOD_CTRL) => b"\t",
|
|
("[", ghostty::MOD_CTRL) => b"\x1b",
|
|
_ => return None,
|
|
};
|
|
Some(if alt {
|
|
[b"\x1b", classic].concat()
|
|
} else {
|
|
classic.to_vec()
|
|
})
|
|
}
|
|
|
|
struct HerdrPath {
|
|
state: ClientShellState,
|
|
framer: crate::raw_input::RawInputByteFramer,
|
|
runtime: crate::terminal::TerminalRuntime,
|
|
rx: tokio::sync::mpsc::Receiver<bytes::Bytes>,
|
|
host: HostProfile,
|
|
}
|
|
|
|
impl HerdrPath {
|
|
fn new(host: HostProfile, app_output: &[u8]) -> Self {
|
|
let (runtime, mut rx) =
|
|
crate::terminal::TerminalRuntime::test_with_channel_and_scrollback_bytes(
|
|
80, 24, 0, b"", 4096,
|
|
);
|
|
runtime.test_process_pty_bytes(app_output);
|
|
while rx.try_recv().is_ok() {}
|
|
Self {
|
|
state: Self::fresh_state(host),
|
|
framer: Self::fresh_framer(host),
|
|
runtime,
|
|
rx,
|
|
host,
|
|
}
|
|
}
|
|
|
|
fn fresh_state(host: HostProfile) -> ClientShellState {
|
|
let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default()));
|
|
// Mirrors the client: Herdr pushes Kitty event types on Kitty hosts.
|
|
state.set_host_reports_key_releases(host == HostProfile::Kitty);
|
|
state.set_snapshot(Box::new(snapshot()));
|
|
state.set_pane_surface(surface());
|
|
state
|
|
}
|
|
|
|
fn fresh_framer(host: HostProfile) -> crate::raw_input::RawInputByteFramer {
|
|
#[allow(unused_mut)] // only mutated on unix
|
|
let mut framer = crate::raw_input::RawInputByteFramer::for_host_input();
|
|
#[cfg(unix)]
|
|
framer.set_host_escape_disambiguation_active(host == HostProfile::Kitty);
|
|
#[cfg(not(unix))]
|
|
let _ = host;
|
|
framer
|
|
}
|
|
|
|
/// Collect pane input from one client outcome, the way the client loop does:
|
|
/// side requests (pane focus) are ignored, link lookups get a no-link answer
|
|
/// and the held mouse events are replayed. Returns true when Herdr kept the
|
|
/// input for itself.
|
|
fn absorb(
|
|
&mut self,
|
|
outcome: ClientShellInput,
|
|
events: &mut Vec<ClientPaneInputEvent>,
|
|
) -> bool {
|
|
let mut owned = outcome.detach;
|
|
for request in outcome.requests {
|
|
if let ClientMessage::ClientShellPaneInput { events: batch, .. } = request {
|
|
events.extend(batch);
|
|
}
|
|
}
|
|
let mut replay = Vec::new();
|
|
for action in outcome.actions {
|
|
match action {
|
|
ClientShellAction::Endpoint {
|
|
boot_id, request, ..
|
|
} if matches!(
|
|
request.method,
|
|
crate::api::schema::Method::PaneLinkActivate(_)
|
|
) =>
|
|
{
|
|
let (_, actions) = self.state.handle_endpoint_result(
|
|
&boot_id,
|
|
&request.id,
|
|
Ok(crate::api::schema::ResponseResult::PaneLinkActivated {
|
|
url: None,
|
|
handled: false,
|
|
}),
|
|
);
|
|
for action in actions {
|
|
match action {
|
|
ClientShellAction::ReplayMouse(mouse) => replay.extend(mouse),
|
|
_ => owned = true,
|
|
}
|
|
}
|
|
}
|
|
ClientShellAction::Endpoint { .. } => {}
|
|
ClientShellAction::ReplayMouse(mouse) => replay.extend(mouse),
|
|
_ => owned = true,
|
|
}
|
|
}
|
|
if !replay.is_empty() {
|
|
let outcome = self.state.replay_mouse_events(replay);
|
|
owned |= self.absorb(outcome, events);
|
|
}
|
|
owned
|
|
}
|
|
|
|
fn reset_client(&mut self) {
|
|
self.state = Self::fresh_state(self.host);
|
|
self.framer = Self::fresh_framer(self.host);
|
|
}
|
|
|
|
/// Feed one host write, then let idle flushes run. Returns None when Herdr
|
|
/// itself consumed the key (prefix, binding, mode change).
|
|
fn feed(&mut self, host_bytes: &[u8]) -> Option<Vec<u8>> {
|
|
let mut chunks = self.framer.push(host_bytes);
|
|
for _ in 0..3 {
|
|
if !self.framer.has_pending_input() {
|
|
break;
|
|
}
|
|
chunks.extend(self.framer.flush_timeout());
|
|
}
|
|
let mut events = Vec::new();
|
|
let mut herdr_owned = false;
|
|
for chunk in chunks {
|
|
let outcome = self.state.handle_input_bytes(&chunk);
|
|
herdr_owned |= self.absorb(outcome, &mut events);
|
|
}
|
|
herdr_owned |= self.state.mode != ClientShellMode::Terminal || self.state.overlay.is_some();
|
|
if herdr_owned {
|
|
return None;
|
|
}
|
|
let mut out = Vec::new();
|
|
if let Err(err) =
|
|
crate::server::pane_input::test_apply_client_pane_input_events(&self.runtime, &events)
|
|
{
|
|
out.extend_from_slice(format!("<server error: {err}>").as_bytes());
|
|
}
|
|
while let Ok(bytes) = self.rx.try_recv() {
|
|
out.extend_from_slice(&bytes);
|
|
}
|
|
Some(out)
|
|
}
|
|
}
|
|
|
|
/// Kitty's default event type is press, so `;mods:1` and `;mods` are the same
|
|
/// report. Strip the explicit form so equivalent encodings compare equal.
|
|
fn normalize_kitty_press(bytes: &[u8]) -> Vec<u8> {
|
|
let mut out = Vec::with_capacity(bytes.len());
|
|
let mut i = 0;
|
|
while i < bytes.len() {
|
|
if bytes[i..].starts_with(b":1")
|
|
&& bytes
|
|
.get(i + 2)
|
|
.is_some_and(|b| matches!(b, b'u' | b'~' | b';') || b.is_ascii_uppercase())
|
|
{
|
|
i += 2;
|
|
continue;
|
|
}
|
|
out.push(bytes[i]);
|
|
i += 1;
|
|
}
|
|
out
|
|
}
|
|
|
|
#[derive(Default)]
|
|
struct Tally {
|
|
scored: usize,
|
|
passed: usize,
|
|
equivalent: usize,
|
|
host_lossy: usize,
|
|
herdr_owned: usize,
|
|
}
|
|
|
|
struct Failure {
|
|
host: &'static str,
|
|
pane: &'static str,
|
|
keystroke: String,
|
|
expected: Vec<u8>,
|
|
actual: Vec<u8>,
|
|
}
|
|
|
|
fn show(bytes: &[u8]) -> String {
|
|
bytes.escape_ascii().to_string()
|
|
}
|
|
|
|
struct Report {
|
|
by_cell: BTreeMap<(&'static str, &'static str, &'static str), Tally>,
|
|
failures: Vec<Failure>,
|
|
owned: Vec<String>,
|
|
}
|
|
|
|
fn run_keyboard_conformance() -> Report {
|
|
let mut report = Report {
|
|
by_cell: BTreeMap::new(),
|
|
failures: Vec::new(),
|
|
owned: Vec::new(),
|
|
};
|
|
for host in [HostProfile::Kitty, HostProfile::Legacy] {
|
|
for &(pane_name, pane_mode) in PANE_MODES {
|
|
let mut host_oracle = Oracle::new(host.setup(pane_mode));
|
|
let mut direct_oracle = Oracle::new(pane_mode);
|
|
let mut disambiguate_oracle = Oracle::new(b"\x1b[>1u");
|
|
|
|
let mut cases = Vec::new();
|
|
for def in KEYS {
|
|
for mods in 0u16..16 {
|
|
let mods = MODS
|
|
.iter()
|
|
.enumerate()
|
|
.filter(|(i, _)| mods & (1 << i) != 0)
|
|
.fold(0, |acc, (_, (bit, _))| acc | bit);
|
|
let host_bytes = host_oracle.keystroke(*def, mods);
|
|
let (mut press, release) = direct_oracle.keystroke(*def, mods);
|
|
if let Some(classic) = legacy_shell_exception(pane_mode, def.name, mods) {
|
|
press = classic;
|
|
} else if is_legacy_pane(pane_mode) && mods & ghostty::MOD_SUPER != 0 {
|
|
press = disambiguate_oracle.keystroke(*def, mods).0;
|
|
}
|
|
// A host that never reports releases gives nobody a release to forward.
|
|
let expected = if host_bytes.1.is_empty() {
|
|
press
|
|
} else {
|
|
[press, release].concat()
|
|
};
|
|
cases.push((*def, mods, host_bytes, expected));
|
|
}
|
|
}
|
|
|
|
let mut expected_by_host_bytes: HashMap<&HostKeystroke, Vec<&Vec<u8>>> = HashMap::new();
|
|
for (_, mods, host_bytes, expected) in &cases {
|
|
if host == HostProfile::Legacy && mods & ghostty::MOD_SUPER != 0 {
|
|
continue;
|
|
}
|
|
expected_by_host_bytes
|
|
.entry(host_bytes)
|
|
.or_default()
|
|
.push(expected);
|
|
}
|
|
|
|
let mut herdr = HerdrPath::new(host, pane_mode);
|
|
for (def, mods, host_bytes, expected) in &cases {
|
|
let group = if mods & ghostty::MOD_SUPER != 0 {
|
|
"super"
|
|
} else {
|
|
"core"
|
|
};
|
|
let tally = report
|
|
.by_cell
|
|
.entry((host.name(), pane_name, group))
|
|
.or_default();
|
|
// Legacy hosts cannot report Super at all (they drop it or send
|
|
// the chord without it), so nothing downstream can recover it.
|
|
let super_unreportable =
|
|
host == HostProfile::Legacy && mods & ghostty::MOD_SUPER != 0;
|
|
let ambiguous = expected_by_host_bytes
|
|
.get(host_bytes)
|
|
.is_some_and(|distinct| distinct.iter().any(|other| *other != expected));
|
|
if super_unreportable || ambiguous {
|
|
tally.host_lossy += 1;
|
|
continue;
|
|
}
|
|
let press = herdr.feed(&host_bytes.0);
|
|
let release = herdr.feed(&host_bytes.1);
|
|
let page_key_scrolls_herdr = *mods == 0
|
|
&& matches!(def.name, "pageup" | "pagedown")
|
|
&& herdr.runtime.plain_page_keys_use_host_scrollback() == Some(true);
|
|
let (Some(press), Some(release), false) = (press, release, page_key_scrolls_herdr)
|
|
else {
|
|
tally.herdr_owned += 1;
|
|
herdr.reset_client();
|
|
continue;
|
|
};
|
|
let actual = [press, release].concat();
|
|
tally.scored += 1;
|
|
if &actual == expected {
|
|
tally.passed += 1;
|
|
} else if normalize_kitty_press(&actual) == normalize_kitty_press(expected) {
|
|
tally.passed += 1;
|
|
tally.equivalent += 1;
|
|
} else {
|
|
report.failures.push(Failure {
|
|
host: host.name(),
|
|
pane: pane_name,
|
|
keystroke: format!("{}+{}", mods_name(*mods), def.name),
|
|
expected: expected.clone(),
|
|
actual,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
report
|
|
}
|
|
|
|
fn print_report(report: &Report) {
|
|
let mut total = Tally::default();
|
|
println!(
|
|
"\n{:<12} {:<17} {:<6} {:>7} {:>7} {:>7} {:>7} {:>7} {:>7}",
|
|
"host", "pane", "keys", "scored", "passed", "score", "equiv", "lossy", "owned"
|
|
);
|
|
for ((host, pane, group), tally) in &report.by_cell {
|
|
println!(
|
|
"{:<12} {:<17} {:<6} {:>7} {:>7} {:>6.1}% {:>7} {:>7} {:>7}",
|
|
host,
|
|
pane,
|
|
group,
|
|
tally.scored,
|
|
tally.passed,
|
|
100.0 * tally.passed as f64 / tally.scored.max(1) as f64,
|
|
tally.equivalent,
|
|
tally.host_lossy,
|
|
tally.herdr_owned
|
|
);
|
|
total.scored += tally.scored;
|
|
total.passed += tally.passed;
|
|
total.equivalent += tally.equivalent;
|
|
total.host_lossy += tally.host_lossy;
|
|
total.herdr_owned += tally.herdr_owned;
|
|
}
|
|
println!(
|
|
"{:<37} {:>7} {:>7} {:>6.1}% {:>7} {:>7} {:>7}",
|
|
"TOTAL",
|
|
total.scored,
|
|
total.passed,
|
|
100.0 * total.passed as f64 / total.scored.max(1) as f64,
|
|
total.equivalent,
|
|
total.host_lossy,
|
|
total.herdr_owned
|
|
);
|
|
|
|
if let Ok(path) = std::env::var("HERDR_INPUT_CONFORMANCE_FAILURES") {
|
|
let lines: Vec<String> = report
|
|
.failures
|
|
.iter()
|
|
.map(|failure| {
|
|
format!(
|
|
"{}\t{}\t{}\texpected={}\tactual={}",
|
|
failure.host,
|
|
failure.pane,
|
|
failure.keystroke,
|
|
show(&failure.expected),
|
|
show(&failure.actual)
|
|
)
|
|
})
|
|
.chain(report.owned.iter().map(|owned| format!("owned\t{owned}")))
|
|
.collect();
|
|
std::fs::write(path, lines.join("\n")).expect("write failures");
|
|
}
|
|
}
|
|
|
|
// Ratchet: failure counts may only go down. Lower them when a change fixes
|
|
// cases; a rise means a regression in Herdr's input transparency.
|
|
// Remaining: Alt+], Alt+Shift+P/X, Alt+^, Alt+_ behind legacy hosts. Their bytes
|
|
// also start host replies, so they are never forwarded (#344).
|
|
const KEYBOARD_FAILURES_BASELINE: usize = 50;
|
|
const MOUSE_FAILURES_BASELINE: usize = 96;
|
|
// Remaining: legacy hosts split right after ESC[, which is also Alt+[.
|
|
const SPLIT_IDLE_MISMATCH_BASELINE: usize = 207;
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Mouse
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const MOUSE_TRACKING: &[(&str, &[u8])] = &[
|
|
("off", b""),
|
|
("x10", b"\x1b[?9h"),
|
|
("normal", b"\x1b[?1000h"),
|
|
("button", b"\x1b[?1002h"),
|
|
("any", b"\x1b[?1003h"),
|
|
];
|
|
|
|
const MOUSE_FORMATS: &[(&str, &[u8])] = &[
|
|
("default", b""),
|
|
("utf8", b"\x1b[?1005h"),
|
|
("sgr", b"\x1b[?1006h"),
|
|
("urxvt", b"\x1b[?1015h"),
|
|
];
|
|
|
|
/// What Herdr asks the host for while it captures the mouse.
|
|
const HOST_MOUSE_SETUP: &[u8] = b"\x1b[?1003h\x1b[?1006h";
|
|
|
|
#[derive(Clone, Copy)]
|
|
struct MouseStep {
|
|
action: ffi::GhosttyMouseAction,
|
|
button: Option<ffi::GhosttyMouseButton>,
|
|
/// Offset added to the gesture's start cell.
|
|
dx: u16,
|
|
dy: u16,
|
|
}
|
|
|
|
const fn step(
|
|
action: ffi::GhosttyMouseAction,
|
|
button: Option<ffi::GhosttyMouseButton>,
|
|
dx: u16,
|
|
dy: u16,
|
|
) -> MouseStep {
|
|
MouseStep {
|
|
action,
|
|
button,
|
|
dx,
|
|
dy,
|
|
}
|
|
}
|
|
|
|
const GESTURES: &[(&str, &[MouseStep])] = &[
|
|
(
|
|
"click-left",
|
|
&[
|
|
step(
|
|
ghostty::MOUSE_ACTION_PRESS,
|
|
Some(ghostty::MOUSE_BUTTON_LEFT),
|
|
0,
|
|
0,
|
|
),
|
|
step(
|
|
ghostty::MOUSE_ACTION_RELEASE,
|
|
Some(ghostty::MOUSE_BUTTON_LEFT),
|
|
0,
|
|
0,
|
|
),
|
|
],
|
|
),
|
|
(
|
|
"click-middle",
|
|
&[
|
|
step(
|
|
ghostty::MOUSE_ACTION_PRESS,
|
|
Some(ghostty::MOUSE_BUTTON_MIDDLE),
|
|
0,
|
|
0,
|
|
),
|
|
step(
|
|
ghostty::MOUSE_ACTION_RELEASE,
|
|
Some(ghostty::MOUSE_BUTTON_MIDDLE),
|
|
0,
|
|
0,
|
|
),
|
|
],
|
|
),
|
|
(
|
|
"click-right",
|
|
&[
|
|
step(
|
|
ghostty::MOUSE_ACTION_PRESS,
|
|
Some(ghostty::MOUSE_BUTTON_RIGHT),
|
|
0,
|
|
0,
|
|
),
|
|
step(
|
|
ghostty::MOUSE_ACTION_RELEASE,
|
|
Some(ghostty::MOUSE_BUTTON_RIGHT),
|
|
0,
|
|
0,
|
|
),
|
|
],
|
|
),
|
|
(
|
|
"drag-left",
|
|
&[
|
|
step(
|
|
ghostty::MOUSE_ACTION_PRESS,
|
|
Some(ghostty::MOUSE_BUTTON_LEFT),
|
|
0,
|
|
0,
|
|
),
|
|
step(
|
|
ghostty::MOUSE_ACTION_MOTION,
|
|
Some(ghostty::MOUSE_BUTTON_LEFT),
|
|
1,
|
|
0,
|
|
),
|
|
step(
|
|
ghostty::MOUSE_ACTION_MOTION,
|
|
Some(ghostty::MOUSE_BUTTON_LEFT),
|
|
2,
|
|
1,
|
|
),
|
|
step(
|
|
ghostty::MOUSE_ACTION_RELEASE,
|
|
Some(ghostty::MOUSE_BUTTON_LEFT),
|
|
2,
|
|
1,
|
|
),
|
|
],
|
|
),
|
|
(
|
|
"move",
|
|
&[
|
|
step(ghostty::MOUSE_ACTION_MOTION, None, 0, 0),
|
|
step(ghostty::MOUSE_ACTION_MOTION, None, 1, 1),
|
|
],
|
|
),
|
|
(
|
|
"wheel-up",
|
|
&[step(
|
|
ghostty::MOUSE_ACTION_PRESS,
|
|
Some(ghostty::MOUSE_BUTTON_WHEEL_UP),
|
|
0,
|
|
0,
|
|
)],
|
|
),
|
|
(
|
|
"wheel-down",
|
|
&[step(
|
|
ghostty::MOUSE_ACTION_PRESS,
|
|
Some(ghostty::MOUSE_BUTTON_WHEEL_DOWN),
|
|
0,
|
|
0,
|
|
)],
|
|
),
|
|
(
|
|
"wheel-left",
|
|
&[step(
|
|
ghostty::MOUSE_ACTION_PRESS,
|
|
Some(ghostty::MOUSE_BUTTON_WHEEL_LEFT),
|
|
0,
|
|
0,
|
|
)],
|
|
),
|
|
(
|
|
"wheel-right",
|
|
&[step(
|
|
ghostty::MOUSE_ACTION_PRESS,
|
|
Some(ghostty::MOUSE_BUTTON_WHEEL_RIGHT),
|
|
0,
|
|
0,
|
|
)],
|
|
),
|
|
];
|
|
|
|
const MOUSE_MODS: [(u16, &str); 4] = [
|
|
(0, "plain"),
|
|
(ghostty::MOD_SHIFT, "shift"),
|
|
(ghostty::MOD_ALT, "alt"),
|
|
(ghostty::MOD_CTRL, "ctrl"),
|
|
];
|
|
|
|
struct MouseOracle {
|
|
_terminal: ghostty::Terminal,
|
|
encoder: ghostty::MouseEncoder,
|
|
}
|
|
|
|
impl MouseOracle {
|
|
fn new(app_output: &[u8], cols: u16, rows: u16) -> Self {
|
|
let mut terminal = ghostty::Terminal::new(cols, rows, 0).expect("terminal");
|
|
terminal.write(app_output);
|
|
let mut encoder = ghostty::MouseEncoder::new().expect("encoder");
|
|
encoder.set_from_terminal(&terminal);
|
|
encoder.set_size(u32::from(cols), u32::from(rows), 1, 1);
|
|
Self {
|
|
_terminal: terminal,
|
|
encoder,
|
|
}
|
|
}
|
|
|
|
fn encode(&mut self, step: MouseStep, mods: u16, column: u16, row: u16) -> Vec<u8> {
|
|
let mut event = ghostty::MouseEvent::new().expect("mouse event");
|
|
event.set_action(step.action);
|
|
match step.button {
|
|
Some(button) => event.set_button(button),
|
|
None => event.clear_button(),
|
|
}
|
|
event.set_mods(mods);
|
|
event.set_position(f32::from(column), f32::from(row));
|
|
self.encoder.encode(&event).expect("encode mouse")
|
|
}
|
|
}
|
|
|
|
const MOUSE_PANE_COLS: u16 = 40;
|
|
const MOUSE_PANE_ROWS: u16 = 10;
|
|
|
|
fn mouse_surface(mouse_reporting: bool) -> PaneSurfaceFrame {
|
|
let mut surface = surface();
|
|
let pane = &mut surface.panes[0];
|
|
for rect in [&mut pane.rect, &mut pane.inner_rect] {
|
|
rect.width = MOUSE_PANE_COLS;
|
|
rect.height = MOUSE_PANE_ROWS;
|
|
}
|
|
pane.mouse_reporting = mouse_reporting;
|
|
surface
|
|
}
|
|
|
|
fn run_mouse_conformance() -> Report {
|
|
let mut report = Report {
|
|
by_cell: BTreeMap::new(),
|
|
failures: Vec::new(),
|
|
owned: Vec::new(),
|
|
};
|
|
for &(tracking_name, tracking) in MOUSE_TRACKING {
|
|
for &(format_name, format) in MOUSE_FORMATS {
|
|
let pane_mode = [tracking, format].concat();
|
|
let pane_name: &'static str =
|
|
Box::leak(format!("{tracking_name}/{format_name}").into_boxed_str());
|
|
let tally = report
|
|
.by_cell
|
|
.entry(("kitty-host", pane_name, "mouse"))
|
|
.or_default();
|
|
|
|
let mut herdr = HerdrPath::new(HostProfile::Kitty, &pane_mode);
|
|
herdr
|
|
.state
|
|
.set_pane_surface(mouse_surface(!tracking.is_empty()));
|
|
herdr.state.compose(106, 20).expect("composed frame");
|
|
let inner = herdr.state.hits.panes[0].inner_rect;
|
|
let mut direct = MouseOracle::new(&pane_mode, 80, 24);
|
|
let mut host = MouseOracle::new(HOST_MOUSE_SETUP, 106, 20);
|
|
|
|
let starts = [
|
|
(0u16, 0u16),
|
|
(inner.width / 2, inner.height / 2),
|
|
(
|
|
inner.width.saturating_sub(3),
|
|
inner.height.saturating_sub(2),
|
|
),
|
|
];
|
|
for &(gesture_name, steps) in GESTURES {
|
|
for &(mods, mods_label) in &MOUSE_MODS {
|
|
for &(column, row) in &starts {
|
|
let mut expected = Vec::new();
|
|
let mut actual = Vec::new();
|
|
let mut owned = false;
|
|
for step in steps {
|
|
let (c, r) = (column + step.dx, row + step.dy);
|
|
expected.extend(direct.encode(*step, mods, c, r));
|
|
let host_bytes = host.encode(*step, mods, inner.x + c, inner.y + r);
|
|
match herdr.feed(&host_bytes) {
|
|
Some(bytes) => actual.extend(bytes),
|
|
None => owned = true,
|
|
}
|
|
}
|
|
if owned {
|
|
tally.herdr_owned += 1;
|
|
report.owned.push(format!(
|
|
"{pane_name}\t{mods_label}+{gesture_name}@{column},{row}"
|
|
));
|
|
herdr.reset_client();
|
|
herdr
|
|
.state
|
|
.set_pane_surface(mouse_surface(!tracking.is_empty()));
|
|
herdr.state.compose(106, 20).expect("composed frame");
|
|
continue;
|
|
}
|
|
tally.scored += 1;
|
|
if actual == expected {
|
|
tally.passed += 1;
|
|
} else {
|
|
report.failures.push(Failure {
|
|
host: "kitty-host",
|
|
pane: pane_name,
|
|
keystroke: format!("{mods_label}+{gesture_name}@{column},{row}"),
|
|
expected,
|
|
actual,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
report
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Split reads
|
|
// ---------------------------------------------------------------------------
|
|
|
|
fn host_input_corpus() -> Vec<(HostProfile, Vec<u8>)> {
|
|
let mut corpus = Vec::new();
|
|
for (host, setup) in [
|
|
(HostProfile::Kitty, &b"\x1b[>7u"[..]),
|
|
(HostProfile::Kitty, &b"\x1b[>31u"[..]),
|
|
(HostProfile::Legacy, &b""[..]),
|
|
] {
|
|
let mut oracle = Oracle::new(setup);
|
|
for def in KEYS {
|
|
for mods in [
|
|
0,
|
|
ghostty::MOD_SHIFT,
|
|
ghostty::MOD_CTRL,
|
|
ghostty::MOD_ALT,
|
|
ghostty::MOD_CTRL | ghostty::MOD_SHIFT,
|
|
ghostty::MOD_CTRL | ghostty::MOD_ALT,
|
|
] {
|
|
let (press, release) = oracle.keystroke(*def, mods);
|
|
corpus.push((host, press));
|
|
if !release.is_empty() {
|
|
corpus.push((host, release));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
let mut mouse = MouseOracle::new(HOST_MOUSE_SETUP, 300, 100);
|
|
for &(_, steps) in GESTURES {
|
|
for &(mods, _) in &MOUSE_MODS {
|
|
for step in steps {
|
|
corpus.push((HostProfile::Kitty, mouse.encode(*step, mods, 7, 3)));
|
|
corpus.push((HostProfile::Kitty, mouse.encode(*step, mods, 150, 42)));
|
|
}
|
|
}
|
|
}
|
|
corpus.sort();
|
|
corpus.dedup();
|
|
corpus.retain(|(_, bytes)| bytes.len() > 1);
|
|
corpus
|
|
}
|
|
|
|
/// Replies Herdr asks the host for at startup and on focus (colors, palette,
|
|
/// cell size, appearance).
|
|
const HOST_REPLIES: &[&[u8]] = &[
|
|
b"\x1b]10;rgb:ffff/ffff/ffff\x1b\\",
|
|
b"\x1b]11;rgb:1e1e/1e1e/2e2e\x07",
|
|
b"\x1b]11;rgb:1e1e/1e1e/2e2e\x1b\\",
|
|
b"\x1b]4;1;rgb:cdcd/0000/0000\x1b\\",
|
|
b"\x1b]4;255;rgb:eeee/eeee/eeee\x07",
|
|
b"\x1b[6;20;10t",
|
|
b"\x1b[?997;1n",
|
|
b"\x1b[?997;2n",
|
|
];
|
|
|
|
/// A network hiccup between two reads of one sequence (SSH, slow links).
|
|
const SPLIT_PAUSE_MS: i32 = 50;
|
|
|
|
fn framed_events(host: HostProfile, pieces: &[&[u8]], pause_ms: Option<i32>) -> String {
|
|
framed_events_with(host, pieces, pause_ms, false)
|
|
}
|
|
|
|
/// Feed `pieces` as separate reads. With `pause_ms`, the reads are that far
|
|
/// apart: like the client loop, the framer is flushed only when the pause
|
|
/// outlasts the wait it asked for.
|
|
fn framed_events_with(
|
|
host: HostProfile,
|
|
pieces: &[&[u8]],
|
|
pause_ms: Option<i32>,
|
|
awaiting_replies: bool,
|
|
) -> String {
|
|
let mut framer = HerdrPath::fresh_framer(host);
|
|
if awaiting_replies {
|
|
// Mirrors the Unix client right after it sent its host queries.
|
|
framer.host_color_query_sent();
|
|
framer.enable_host_color_scheme_change_tracking();
|
|
framer.enable_host_appearance_query_on_focus();
|
|
framer.host_cell_size_query_sent();
|
|
}
|
|
let mut chunks = Vec::new();
|
|
for (index, piece) in pieces.iter().enumerate() {
|
|
chunks.extend(framer.push(piece));
|
|
let Some(pause_ms) = pause_ms.filter(|_| index + 1 < pieces.len()) else {
|
|
continue;
|
|
};
|
|
let mut waited = 0;
|
|
for _ in 0..3 {
|
|
let wait = framer.idle_flush_timeout_ms(true);
|
|
if !framer.has_pending_input() || waited + wait > pause_ms {
|
|
break;
|
|
}
|
|
waited += wait;
|
|
chunks.extend(framer.flush_timeout());
|
|
}
|
|
}
|
|
for _ in 0..3 {
|
|
if !framer.has_pending_input() {
|
|
break;
|
|
}
|
|
chunks.extend(framer.flush_timeout());
|
|
}
|
|
let events: Vec<_> = chunks
|
|
.iter()
|
|
.flat_map(|chunk| crate::raw_input::parse_raw_input_bytes_sync(chunk))
|
|
.collect();
|
|
format!("{events:?}")
|
|
}
|
|
|
|
#[test]
|
|
fn split_read_robustness() {
|
|
let corpus = host_input_corpus();
|
|
let mut splits = 0usize;
|
|
let mut burst_mismatch = Vec::new();
|
|
let mut idle_mismatch = Vec::new();
|
|
for (host, bytes) in &corpus {
|
|
let whole = framed_events(*host, &[bytes], None);
|
|
for cut in 1..bytes.len() {
|
|
splits += 1;
|
|
let pieces = [&bytes[..cut], &bytes[cut..]];
|
|
if framed_events(*host, &pieces, None) != whole {
|
|
burst_mismatch.push(format!(
|
|
"{}\t{}|{}",
|
|
host.name(),
|
|
show(pieces[0]),
|
|
show(pieces[1])
|
|
));
|
|
}
|
|
if framed_events(*host, &pieces, Some(SPLIT_PAUSE_MS)) != whole {
|
|
idle_mismatch.push(format!(
|
|
"{}\t{}|{}",
|
|
host.name(),
|
|
show(pieces[0]),
|
|
show(pieces[1])
|
|
));
|
|
}
|
|
}
|
|
}
|
|
let mut reply_splits = 0usize;
|
|
let mut reply_burst_mismatch = Vec::new();
|
|
let mut reply_idle_mismatch = Vec::new();
|
|
for reply in HOST_REPLIES {
|
|
let whole = framed_events_with(HostProfile::Kitty, &[reply], None, true);
|
|
for cut in 1..reply.len() {
|
|
reply_splits += 1;
|
|
let pieces = [&reply[..cut], &reply[cut..]];
|
|
let line = format!("reply\t{}|{}", show(pieces[0]), show(pieces[1]));
|
|
if framed_events_with(HostProfile::Kitty, &pieces, None, true) != whole {
|
|
reply_burst_mismatch.push(line.clone());
|
|
}
|
|
if framed_events_with(HostProfile::Kitty, &pieces, Some(SPLIT_PAUSE_MS), true) != whole
|
|
{
|
|
reply_idle_mismatch.push(line);
|
|
}
|
|
}
|
|
}
|
|
println!(
|
|
"\nhost replies: {} replies, {} splits\n burst (no pause): {} differ\n {SPLIT_PAUSE_MS}ms pause: {} differ",
|
|
HOST_REPLIES.len(),
|
|
reply_splits,
|
|
reply_burst_mismatch.len(),
|
|
reply_idle_mismatch.len()
|
|
);
|
|
|
|
let pct = |bad: usize| 100.0 * (splits - bad) as f64 / splits.max(1) as f64;
|
|
println!(
|
|
"\nsplit reads: {} sequences, {} splits\n burst (no pause): {:.1}% identical ({} differ)\n {SPLIT_PAUSE_MS}ms pause: {:.1}% identical ({} differ)",
|
|
corpus.len(),
|
|
splits,
|
|
pct(burst_mismatch.len()),
|
|
burst_mismatch.len(),
|
|
pct(idle_mismatch.len()),
|
|
idle_mismatch.len()
|
|
);
|
|
if let Ok(path) = std::env::var("HERDR_INPUT_CONFORMANCE_FAILURES") {
|
|
let lines: Vec<String> = burst_mismatch
|
|
.iter()
|
|
.chain(&reply_burst_mismatch)
|
|
.map(|line| format!("burst\t{line}"))
|
|
.chain(
|
|
idle_mismatch
|
|
.iter()
|
|
.chain(&reply_idle_mismatch)
|
|
.map(|line| format!("idle\t{line}")),
|
|
)
|
|
.collect();
|
|
std::fs::write(path, lines.join("\n")).expect("write failures");
|
|
}
|
|
assert!(
|
|
burst_mismatch.is_empty() && reply_burst_mismatch.is_empty(),
|
|
"split reads without a pause changed the decoded input"
|
|
);
|
|
assert!(
|
|
idle_mismatch.len() <= SPLIT_IDLE_MISMATCH_BASELINE,
|
|
"split-read robustness regressed: {} > {SPLIT_IDLE_MISMATCH_BASELINE}",
|
|
idle_mismatch.len()
|
|
);
|
|
assert!(
|
|
reply_idle_mismatch.is_empty(),
|
|
"split host replies changed after a {SPLIT_PAUSE_MS}ms pause: {reply_idle_mismatch:?}"
|
|
);
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn mouse_transparency_conformance() {
|
|
let report = run_mouse_conformance();
|
|
print_report(&report);
|
|
assert!(
|
|
report.failures.len() <= MOUSE_FAILURES_BASELINE,
|
|
"mouse transparency regressed: {} failures > {MOUSE_FAILURES_BASELINE}",
|
|
report.failures.len()
|
|
);
|
|
}
|
|
|
|
#[tokio::test(flavor = "multi_thread")]
|
|
async fn keyboard_transparency_conformance() {
|
|
let report = run_keyboard_conformance();
|
|
print_report(&report);
|
|
assert!(
|
|
report.failures.len() <= KEYBOARD_FAILURES_BASELINE,
|
|
"keyboard transparency regressed: {} failures > {KEYBOARD_FAILURES_BASELINE}",
|
|
report.failures.len()
|
|
);
|
|
}
|