use aen_window::{InputEvent, MOD_ALT, MOD_CTRL, MOD_SHIFT, MOD_SUPER, TouchPhase};
use std::io;
const MAX_REPLY_BYTES: usize = 64 * 1024;
const MAX_COPY_BYTES: usize = 64 * 1024;
pub const MAX_COLS: u16 = 512;
pub const MAX_ROWS: u16 = 256;
#[derive(Default)]
pub struct Replies {
bytes: Vec<u8>,
overflow: bool,
pub focus_reporting: bool,
}
impl Replies {
fn push(&mut self, bytes: &[u8]) {
if bytes.len() > MAX_REPLY_BYTES.saturating_sub(self.bytes.len()) {
self.overflow = true;
} else {
self.bytes.extend_from_slice(bytes);
}
}
}
impl vt100::Callbacks for Replies {
fn unhandled_csi(
&mut self,
screen: &mut vt100::Screen,
i1: Option<u8>,
i2: Option<u8>,
params: &[&[u16]],
action: char,
) {
let first = params.first().and_then(|p| p.first()).copied().unwrap_or(0);
if i2.is_some() {
return;
}
match (i1, action, first) {
(None, 'n', 5) => self.push(b"\x1b[0n"),
(None, 'n', 6) | (Some(b'?'), 'n', 6) => {
let (row, col) = screen.cursor_position();
let private = if i1.is_some() { "?" } else { "" };
self.push(format!("\x1b[{private}{};{}R", row + 1, col + 1).as_bytes());
}
(None, 'c', 0) => self.push(b"\x1b[?1;2c"),
(Some(b'>'), 'c', 0) => self.push(b"\x1b[>0;1;0c"),
(None, 't', 18) => {
let (rows, cols) = screen.size();
self.push(format!("\x1b[8;{rows};{cols}t").as_bytes());
}
(Some(b'?'), 'h' | 'l', _) if params.iter().any(|p| p.first() == Some(&1004)) => {
self.focus_reporting = action == 'h';
}
_ => {}
}
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Grid {
pub rows: u16,
pub cols: u16,
pub cell_width: u32,
pub cell_height: u32,
pub padding: u32,
}
impl Grid {
pub fn position(self, x: i32, y: i32) -> (u16, u16) {
let col = (x.max(0) as u32).saturating_sub(self.padding) / self.cell_width;
let row = (y.max(0) as u32).saturating_sub(self.padding) / self.cell_height;
(
row.min(u32::from(self.rows - 1)) as u16,
col.min(u32::from(self.cols - 1)) as u16,
)
}
}
pub struct Terminal {
pub parser: vt100::Parser<Replies>,
pub focused: bool,
pub selection: Option<((u16, u16), (u16, u16))>,
selecting: bool,
copy: String,
button: Option<u8>,
active_touch: Option<i32>,
pointer_cell: Option<(u16, u16)>,
strings: StringGuard,
}
#[derive(Debug, PartialEq, Eq)]
pub enum ClipboardAction {
Copy(String),
Paste,
}
pub struct Effect {
pub clipboard: Option<ClipboardAction>,
pub redraw: bool,
pub bytes: Vec<u8>,
}
impl Terminal {
pub fn new(grid: Grid, scrollback: usize) -> Self {
let scrollback = scrollback.min(1_000_000 / usize::from(MAX_COLS));
Self {
parser: vt100::Parser::new_with_callbacks(
grid.rows,
grid.cols,
scrollback,
Replies::default(),
),
focused: false,
selection: None,
selecting: false,
copy: String::new(),
button: None,
active_touch: None,
pointer_cell: None,
strings: StringGuard::default(),
}
}
pub fn output(&mut self, bytes: &[u8]) {
self.parser.process(&self.strings.filter(bytes));
}
pub fn replies(&mut self) -> io::Result<Vec<u8>> {
let callbacks = self.parser.callbacks_mut();
if callbacks.overflow {
return Err(io::Error::other(
"Lios terminal reply queue exceeded its limit",
));
}
Ok(std::mem::take(&mut callbacks.bytes))
}
pub fn resize(&mut self, grid: Grid) {
self.cancel_pointer();
self.selection = None;
self.parser.screen_mut().set_size(grid.rows, grid.cols);
}
pub fn cancel_pointer(&mut self) {
self.selecting = false;
self.button = None;
self.active_touch = None;
self.pointer_cell = None;
}
pub fn selected(&self, row: u16, col: u16) -> bool {
self.selection.is_some_and(|(a, b)| {
let (start, end) = if a <= b { (a, b) } else { (b, a) };
(row, col) >= start && (row, col) <= end
})
}
fn scroll(&mut self, delta: i32) {
let current = self.parser.screen().scrollback();
let next = if delta < 0 {
current.saturating_sub(delta.unsigned_abs() as usize)
} else {
current.saturating_add(delta as usize)
};
self.parser.screen_mut().set_scrollback(next);
self.selection = None;
self.cancel_pointer();
}
fn copy_selection(&mut self) {
let Some((a, b)) = self.selection else {
return;
};
let (start, end) = if a <= b { (a, b) } else { (b, a) };
let (_, cols) = self.parser.screen().size();
self.copy.clear();
for row in start.0..=end.0 {
let first = if row == start.0 { start.1 } else { 0 };
let last = if row == end.0 { end.1 } else { cols - 1 };
for col in first..=last {
if let Some(cell) = self.parser.screen().cell(row, col) {
if cell.is_wide_continuation() {
continue;
}
let text = if cell.has_contents() {
cell.contents()
} else {
" "
};
if self.copy.len() + text.len() > MAX_COPY_BYTES {
return;
}
self.copy.push_str(text);
}
}
while self.copy.ends_with(' ') {
self.copy.pop();
}
if row != end.0 && self.copy.len() < MAX_COPY_BYTES {
self.copy.push('\n');
}
}
}
pub fn paste(&mut self, text: &str) -> io::Result<Vec<u8>> {
if !self.focused || text.len() > aen_window::clipboard::MAX_TEXT_BYTES {
return Err(io::Error::other("native paste is unavailable"));
}
let bracketed = self.parser.screen().bracketed_paste();
let mut bytes = Vec::new();
if bracketed {
bytes.extend_from_slice(b"\x1b[200~");
}
let text = text.replace("\r\n", "\n").replace('\r', "\n");
for c in text
.chars()
.filter(|c| !c.is_control() || matches!(c, '\n' | '\t'))
{
if c == '\n' && !bracketed {
bytes.push(b'\r');
} else {
let mut buffer = [0; 4];
bytes.extend_from_slice(c.encode_utf8(&mut buffer).as_bytes());
}
}
if bracketed {
bytes.extend_from_slice(b"\x1b[201~");
}
self.parser.screen_mut().set_scrollback(0);
self.selection = None;
Ok(bytes)
}
pub fn input(&mut self, event: InputEvent, grid: Grid) -> Effect {
let mut effect = Effect {
clipboard: None,
redraw: false,
bytes: Vec::new(),
};
match event {
InputEvent::Focus { focused } => {
self.focused = focused;
self.cancel_pointer();
if self.parser.callbacks().focus_reporting {
effect
.bytes
.extend_from_slice(if focused { b"\x1b[I" } else { b"\x1b[O" });
}
effect.redraw = true;
}
InputEvent::Leave => self.cancel_pointer(),
InputEvent::TextCommit { text } if self.focused => {
if !text.is_empty() && !text.chars().any(char::is_control) {
effect.bytes.extend_from_slice(text.as_bytes());
self.parser.screen_mut().set_scrollback(0);
self.selection = None;
effect.redraw = true;
}
}
InputEvent::Touch {
id,
phase: TouchPhase::Down,
x,
y,
} if self.focused && self.active_touch.is_none() => {
self.active_touch = Some(id);
return self.input(
InputEvent::Button {
button: 0x110,
pressed: true,
x,
y,
},
grid,
);
}
InputEvent::Touch {
id,
phase: TouchPhase::Motion,
x,
y,
} if self.active_touch == Some(id) && self.focused => {
return self.input(InputEvent::Motion { x, y }, grid);
}
InputEvent::Touch {
id,
phase: TouchPhase::Up,
..
} if self.active_touch == Some(id) => {
self.active_touch = None;
return self.input(
InputEvent::Button {
button: 0x110,
pressed: false,
x: 0,
y: 0,
},
grid,
);
}
InputEvent::TouchCancel => self.cancel_pointer(),
InputEvent::Key {
pressed: true,
keysym,
modifiers,
text,
..
} if self.focused => {
if modifiers & MOD_SUPER != 0 {
return effect;
}
let key = keysym_character(keysym).map(|c| c.to_ascii_lowercase());
if modifiers & (MOD_CTRL | MOD_SHIFT) == MOD_CTRL | MOD_SHIFT && key == Some('c') {
if self.selection.is_some() {
self.copy_selection();
effect.clipboard = Some(ClipboardAction::Copy(self.copy.clone()));
}
} else if modifiers & (MOD_CTRL | MOD_SHIFT) == MOD_CTRL | MOD_SHIFT
&& key == Some('v')
{
effect.clipboard = Some(ClipboardAction::Paste);
} else if modifiers == MOD_SHIFT
&& matches!(keysym, 0xff55 | 0xff56 | 0xff9a | 0xff9b)
{
self.scroll(if matches!(keysym, 0xff55 | 0xff9a) {
i32::from(grid.rows)
} else {
-i32::from(grid.rows)
});
effect.redraw = true;
} else {
effect.bytes = encode_key(
keysym,
modifiers,
text,
self.parser.screen().application_cursor(),
);
}
if !effect.bytes.is_empty() {
self.parser.screen_mut().set_scrollback(0);
self.selection = None;
effect.redraw = true;
}
}
InputEvent::Button {
button,
pressed,
x,
y,
} if self.focused => {
let cell = grid.position(x, y);
let mouse_mode = self.parser.screen().mouse_protocol_mode();
if mouse_mode == vt100::MouseProtocolMode::None {
if button == 0x110 {
if pressed {
self.selection = Some((cell, cell));
self.selecting = true;
} else {
self.selecting = false;
}
effect.redraw = true;
}
} else if let Some(code) = match button {
0x110 => Some(0),
0x112 => Some(1),
0x111 => Some(2),
_ => None,
} {
self.button = if pressed { Some(code) } else { None };
if pressed || mouse_mode != vt100::MouseProtocolMode::Press {
effect.bytes = mouse_bytes(
self.parser.screen().mouse_protocol_encoding(),
code,
cell,
!pressed,
);
}
}
self.pointer_cell = Some(cell);
}
InputEvent::Motion { x, y } if self.focused => {
let cell = grid.position(x, y);
if self.selecting {
if let Some((_, end)) = &mut self.selection {
*end = cell;
}
effect.redraw = true;
} else if self.pointer_cell != Some(cell) {
let mode = self.parser.screen().mouse_protocol_mode();
if mode == vt100::MouseProtocolMode::AnyMotion
|| (mode == vt100::MouseProtocolMode::ButtonMotion && self.button.is_some())
{
effect.bytes = mouse_bytes(
self.parser.screen().mouse_protocol_encoding(),
32 + self.button.unwrap_or(3),
cell,
false,
);
}
}
self.pointer_cell = Some(cell);
}
InputEvent::Axis { vertical, x, y, .. } if self.focused && vertical != 0 => {
if self.parser.screen().mouse_protocol_mode() == vt100::MouseProtocolMode::None {
self.scroll(if vertical < 0 { 3 } else { -3 });
effect.redraw = true;
} else {
effect.bytes = mouse_bytes(
self.parser.screen().mouse_protocol_encoding(),
if vertical < 0 { 64 } else { 65 },
grid.position(x, y),
false,
);
}
}
_ => {}
}
effect
}
}
fn mouse_bytes(
encoding: vt100::MouseProtocolEncoding,
code: u8,
(row, col): (u16, u16),
release: bool,
) -> Vec<u8> {
if encoding == vt100::MouseProtocolEncoding::Sgr {
return format!(
"\x1b[<{code};{};{}{}",
col + 1,
row + 1,
if release { 'm' } else { 'M' }
)
.into_bytes();
}
let mut bytes = b"\x1b[M".to_vec();
bytes.push(32 + if release { 3 } else { code });
for coordinate in [col, row] {
if encoding == vt100::MouseProtocolEncoding::Utf8 {
let character = char::from_u32(u32::from(coordinate) + 33).unwrap();
bytes.extend_from_slice(character.encode_utf8(&mut [0; 4]).as_bytes());
} else {
if coordinate > 222 {
return Vec::new();
}
bytes.push(coordinate as u8 + 33);
}
}
bytes
}
fn encode_key(
keysym: u32,
modifiers: u32,
text: Option<char>,
application_cursor: bool,
) -> Vec<u8> {
let parameter = 1
+ u32::from(modifiers & MOD_SHIFT != 0)
+ 2 * u32::from(modifiers & MOD_ALT != 0)
+ 4 * u32::from(modifiers & MOD_CTRL != 0);
let final_byte = match keysym {
0xff52 | 0xff97 => Some('A'), 0xff54 | 0xff99 => Some('B'), 0xff53 | 0xff98 => Some('C'), 0xff51 | 0xff96 => Some('D'), 0xff58 | 0xff9d => Some('E'), 0xff50 | 0xff95 => Some('H'), 0xff57 | 0xff9c => Some('F'), _ => None,
};
if let Some(final_byte) = final_byte {
return if parameter != 1 {
format!("\x1b[1;{parameter}{final_byte}")
} else if application_cursor {
format!("\x1bO{final_byte}")
} else {
format!("\x1b[{final_byte}")
}
.into_bytes();
}
let function = match keysym {
0xff91..=0xff94 => Some(keysym - 0xff91), 0xffbe..=0xffe0 => Some(keysym - 0xffbe), _ => None,
};
if let Some(function) = function {
let implied_modifier = match function / 12 {
1 => 1,
2 => 4,
_ => 0,
};
let parameter = 1 + ((parameter - 1) | implied_modifier);
let base = function % 12;
return if base < 4 {
let final_byte = char::from(b'P' + base as u8);
if parameter == 1 {
format!("\x1bO{final_byte}")
} else {
format!("\x1b[1;{parameter}{final_byte}")
}
} else {
let number = [15, 17, 18, 19, 20, 21, 23, 24][base as usize - 4];
if parameter == 1 {
format!("\x1b[{number}~")
} else {
format!("\x1b[{number};{parameter}~")
}
}
.into_bytes();
}
let tilde = match keysym {
0xff63 | 0xff9e => Some(2), 0xffff | 0xff9f => Some(3), 0xff55 | 0xff9a => Some(5), 0xff56 | 0xff9b => Some(6), _ => None,
};
if let Some(number) = tilde {
return if parameter == 1 {
format!("\x1b[{number}~")
} else {
format!("\x1b[{number};{parameter}~")
}
.into_bytes();
}
let mut bytes = if modifiers & MOD_CTRL != 0 {
let character = text
.or_else(|| keysym_character(keysym))
.map(|c| c.to_ascii_uppercase());
match character {
Some('@'..='_') => vec![character.unwrap() as u8 & 31],
Some(' ' | '2') => vec![0],
Some('3'..='7') => vec![character.unwrap() as u8 - b'3' + 27],
Some('?' | '8') => vec![127],
_ => Vec::new(),
}
} else {
Vec::new()
};
if bytes.is_empty() {
bytes = match keysym {
0xff0d | 0xff8d | 0xfe34 => vec![b'\r'], 0xff08 => vec![if modifiers & MOD_CTRL != 0 { 8 } else { 127 }],
0xfe20 => b"\x1b[Z".to_vec(), 0xff09 | 0xff89 if modifiers & MOD_SHIFT != 0 => b"\x1b[Z".to_vec(),
0xff09 | 0xff89 => vec![b'\t'], 0xff1b => vec![27], _ if modifiers & MOD_CTRL == 0 && modifiers & MOD_ALT != 0 => text
.or_else(|| keysym_character(keysym))
.map(|c| c.to_string().into_bytes())
.unwrap_or_default(),
_ if modifiers & MOD_CTRL == 0 => {
text.map(|c| c.to_string().into_bytes()).unwrap_or_default()
}
_ => Vec::new(),
};
}
if modifiers & MOD_ALT != 0 && !bytes.is_empty() {
bytes.insert(0, 27);
}
bytes
}
fn keysym_character(keysym: u32) -> Option<char> {
let codepoint = if keysym & 0xff00_0000 == 0x0100_0000 {
keysym & 0x00ff_ffff
} else {
keysym
};
char::from_u32(codepoint)
}
#[derive(Default)]
struct StringGuard {
escaped: bool,
string: bool,
length: usize,
truncated: bool,
}
impl StringGuard {
fn filter(&mut self, bytes: &[u8]) -> Vec<u8> {
let mut output = Vec::with_capacity(bytes.len() + 2);
for &byte in bytes {
if self.string {
if byte == 7 || (self.escaped && byte == b'\\') {
if self.truncated {
output.extend_from_slice(b"\x1b\\");
} else {
output.push(byte);
}
*self = Self::default();
continue;
}
self.escaped = byte == 27;
if self.length < 4096 {
output.push(byte);
self.length += 1;
} else {
self.truncated = true;
}
} else {
output.push(byte);
if self.escaped && matches!(byte, b']' | b'P' | b'_' | b'^') {
self.string = true;
self.length = 0;
self.truncated = false;
}
self.escaped = byte == 27;
}
}
output
}
}
#[cfg(test)]
mod tests {
use super::*;
pub fn grid() -> Grid {
Grid {
rows: 6,
cols: 20,
cell_width: 10,
cell_height: 20,
padding: 8,
}
}
#[test]
fn terminal_colors_unicode_and_alternate_screen_restore() {
let mut terminal = Terminal::new(grid(), 20);
terminal.output("hello \x1b[38;2;1;2;3m界é\x1b[0m".as_bytes());
assert_eq!(
terminal.parser.screen().cell(0, 6).unwrap().fgcolor(),
vt100::Color::Rgb(1, 2, 3)
);
assert!(terminal.parser.screen().cell(0, 6).unwrap().is_wide());
terminal.output(b"\x1b[?1049hOTHER\x1b[?1049l");
assert!(terminal.parser.screen().contents().contains("hello 界é"));
}
#[test]
fn device_queries_reply_and_clipboard_requests_are_ignored() {
let mut terminal = Terminal::new(grid(), 20);
terminal.output(b"abc\x1b[6n\x1b[5n\x1b[c\x1b]52;c;dGVzdA==\x07\x1b[8;900;900t");
assert_eq!(terminal.replies().unwrap(), b"\x1b[1;4R\x1b[0n\x1b[?1;2c");
assert_eq!(terminal.parser.screen().size(), (6, 20));
assert!(terminal.copy.is_empty());
}
#[test]
fn replies_are_bounded_under_query_flood() {
let mut terminal = Terminal::new(grid(), 0);
terminal.output(&b"\x1b[6n".repeat(MAX_REPLY_BYTES));
assert!(terminal.replies().is_err());
assert!(terminal.parser.callbacks().bytes.len() <= MAX_REPLY_BYTES);
}
#[test]
fn long_split_terminal_strings_are_bounded_and_parser_recovers() {
let mut terminal = Terminal::new(grid(), 0);
terminal.output(b"\x1b]52;c;");
for _ in 0..100 {
terminal.output(&[b'x'; 8192]);
}
assert!(terminal.strings.truncated);
assert_eq!(terminal.strings.length, 4096);
terminal.output(b"\x1b");
terminal.output(b"\\safe");
assert!(terminal.parser.screen().contents().contains("safe"));
assert!(!terminal.strings.string);
}
#[test]
fn terminal_key_sequences_cover_control_alt_repeat_and_modifiers() {
assert_eq!(encode_key('c' as u32, MOD_CTRL, None, false), [3]);
assert_eq!(encode_key('a' as u32, MOD_ALT, Some('a'), false), b"\x1ba");
assert_eq!(encode_key(0xff52, 0, None, true), b"\x1bOA");
assert_eq!(encode_key(0xff52, MOD_CTRL, None, true), b"\x1b[1;5A");
assert_eq!(encode_key(0xfe20, MOD_SHIFT, None, false), b"\x1b[Z");
assert_eq!(encode_key(0xffc8, 0, None, false), b"\x1b[23~");
}
fn key_event(keycode: u32, keysym: u32, modifiers: u32, text: Option<char>) -> InputEvent {
InputEvent::Key {
keycode,
keysym,
modifiers,
text,
pressed: true,
repeat: false,
}
}
#[test]
fn terminal_controls_follow_compositor_remaps_instead_of_physical_keys() {
let mut terminal = Terminal::new(grid(), 20);
terminal.input(InputEvent::Focus { focused: true }, grid());
for (physical, symbol, expected) in [
(58, 0xff1b, b"\x1b".as_slice()), (58, 0xff08, b"\x7f".as_slice()), (58, 0xff0d, b"\r".as_slice()), (30, 0xff53, b"\x1b[C".as_slice()),
(30, 0xffbe, b"\x1bOP".as_slice()),
] {
assert_eq!(
terminal
.input(key_event(physical, symbol, 0, None), grid())
.bytes,
expected
);
}
assert!(
terminal
.input(key_event(1, 0xffe5, 0, None), grid())
.bytes
.is_empty()
);
assert_eq!(
terminal
.input(key_event(103, 'x' as u32, 0, Some('x')), grid())
.bytes,
b"x"
);
assert_eq!(
terminal
.input(key_event(59, 'ü' as u32, 0, Some('ü')), grid())
.bytes,
"ü".as_bytes()
);
assert_eq!(
terminal
.input(key_event(16, '@' as u32, 0, Some('@')), grid())
.bytes,
b"@"
);
}
#[test]
fn terminal_appends_each_utf8_text_commit_once_after_its_physical_key() {
let mut terminal = Terminal::new(grid(), 20);
terminal.input(InputEvent::Focus { focused: true }, grid());
let physical = InputEvent::Key {
keycode: 36,
keysym: 'J' as u32,
modifiers: 0,
text: None,
pressed: true,
repeat: false,
};
assert!(terminal.input(physical, grid()).bytes.is_empty());
let text = "J\u{301}";
assert_eq!(
terminal
.input(
InputEvent::TextCommit {
text: text.to_owned(),
},
grid(),
)
.bytes,
text.as_bytes()
);
}
#[test]
fn terminal_keypad_navigation_and_numlock_text_are_distinct() {
for (symbol, normal, application) in [
(0xff95, b"\x1b[H", b"\x1bOH"),
(0xff96, b"\x1b[D", b"\x1bOD"),
(0xff97, b"\x1b[A", b"\x1bOA"),
(0xff98, b"\x1b[C", b"\x1bOC"),
(0xff99, b"\x1b[B", b"\x1bOB"),
(0xff9c, b"\x1b[F", b"\x1bOF"),
(0xff9d, b"\x1b[E", b"\x1bOE"),
] {
assert_eq!(encode_key(symbol, 0, None, false), normal);
assert_eq!(encode_key(symbol, 0, None, true), application);
}
for (symbol, sequence) in [
(0xff9a, b"\x1b[5~"),
(0xff9b, b"\x1b[6~"),
(0xff9e, b"\x1b[2~"),
(0xff9f, b"\x1b[3~"),
] {
assert_eq!(encode_key(symbol, 0, None, false), sequence);
}
assert_eq!(
encode_key(0xff9f, MOD_CTRL | MOD_ALT, None, false),
b"\x1b[3;7~"
);
assert_eq!(encode_key(0xff8d, 0, None, false), b"\r");
assert_eq!(encode_key(0xff89, 0, None, false), b"\t");
assert_eq!(encode_key(0xff91, 0, None, false), b"\x1bOP");
assert_eq!(encode_key(0xff94, 0, None, false), b"\x1bOS");
for (symbol, character) in [
(0xffb0, '0'),
(0xffb1, '1'),
(0xffb9, '9'),
(0xffaa, '*'),
(0xffab, '+'),
(0xffac, ','),
(0xffad, '-'),
(0xffae, '.'),
(0xffaf, '/'),
(0xffbd, '='),
] {
assert_eq!(
encode_key(symbol, 0, Some(character), false),
character.to_string().as_bytes()
);
}
}
#[test]
fn terminal_function_symbols_match_xterm_256color_sequences() {
for (symbol, expected) in [
(0xffbe, "\x1bOP"),
(0xffc1, "\x1bOS"),
(0xffc2, "\x1b[15~"),
(0xffc7, "\x1b[21~"),
(0xffc8, "\x1b[23~"),
(0xffc9, "\x1b[24~"),
(0xffca, "\x1b[1;2P"),
(0xffcd, "\x1b[1;2S"),
(0xffce, "\x1b[15;2~"),
(0xffd5, "\x1b[24;2~"),
(0xffd6, "\x1b[1;5P"),
(0xffe0, "\x1b[23;5~"),
] {
assert_eq!(
encode_key(symbol, 0, None, false),
expected.as_bytes(),
"keysym {symbol:x}"
);
}
assert_eq!(encode_key(0xffca, MOD_SHIFT, None, false), b"\x1b[1;2P");
assert_eq!(encode_key(0xffca, MOD_CTRL, None, false), b"\x1b[1;6P");
assert_eq!(encode_key(0xffd6, MOD_CTRL, None, false), b"\x1b[1;5P");
assert_eq!(
encode_key(0xffc2, MOD_CTRL | MOD_SHIFT, None, false),
b"\x1b[15;6~"
);
assert!(encode_key(0xffe1, MOD_SHIFT, None, false).is_empty());
}
#[test]
fn terminal_scrollback_shortcuts_follow_translated_page_symbols() {
let mut terminal = Terminal::new(grid(), 30);
terminal.output(&b"line\r\n".repeat(40));
terminal.input(InputEvent::Focus { focused: true }, grid());
let effect = terminal.input(key_event(30, 0xff9a, MOD_SHIFT, None), grid());
assert!(effect.bytes.is_empty());
assert!(effect.redraw);
assert_eq!(
terminal.parser.screen().scrollback(),
usize::from(grid().rows)
);
let effect = terminal.input(key_event(30, 0xff56, MOD_SHIFT, None), grid());
assert!(effect.bytes.is_empty());
assert_eq!(terminal.parser.screen().scrollback(), 0);
let effect = terminal.input(key_event(104, 'A' as u32, MOD_SHIFT, Some('A')), grid());
assert_eq!(effect.bytes, b"A");
assert_eq!(terminal.parser.screen().scrollback(), 0);
}
#[test]
fn touch_uses_one_captured_primary_contact_and_cancel_releases_selection() {
let mut terminal = Terminal::new(grid(), 20);
terminal.output(b"hello world");
terminal.input(InputEvent::Focus { focused: true }, grid());
let touch = |id, phase, x, y| InputEvent::Touch { id, phase, x, y };
terminal.input(touch(4, TouchPhase::Down, 8, 8), grid());
assert!(terminal.selecting);
assert_eq!(terminal.active_touch, Some(4));
terminal.input(touch(5, TouchPhase::Motion, 48, 8), grid());
assert_eq!(terminal.selection, Some(((0, 0), (0, 0))));
terminal.input(touch(4, TouchPhase::Motion, 48, 8), grid());
assert_eq!(terminal.selection, Some(((0, 0), (0, 4))));
terminal.input(touch(4, TouchPhase::Up, 0, 0), grid());
assert!(!terminal.selecting);
assert_eq!(terminal.active_touch, None);
terminal.input(touch(8, TouchPhase::Down, 8, 8), grid());
terminal.input(InputEvent::TouchCancel, grid());
assert!(!terminal.selecting);
assert_eq!(terminal.active_touch, None);
}
#[test]
fn shared_selection_requests_paste_and_focus_loss_are_consistent() {
let mut terminal = Terminal::new(grid(), 20);
terminal.output(b"hello world\x1b[?2004h");
terminal.input(InputEvent::Focus { focused: true }, grid());
terminal.input(
InputEvent::Button {
button: 0x110,
pressed: true,
x: 8,
y: 8,
},
grid(),
);
terminal.input(InputEvent::Motion { x: 48, y: 8 }, grid());
let key = |c, pressed| InputEvent::Key {
keycode: 46,
keysym: c as u32,
pressed,
repeat: false,
modifiers: MOD_CTRL | MOD_SHIFT,
text: Some(c),
};
assert_eq!(
terminal.input(key('c', true), grid()).clipboard,
Some(ClipboardAction::Copy("hello".into()))
);
let paste = terminal.input(key('v', true), grid());
assert!(paste.bytes.is_empty());
assert_eq!(paste.clipboard, Some(ClipboardAction::Paste));
assert_eq!(terminal.paste("hello").unwrap(), b"\x1b[200~hello\x1b[201~");
terminal.input(InputEvent::Focus { focused: false }, grid());
assert!(!terminal.selecting);
assert!(terminal.input(key('v', true), grid()).clipboard.is_none());
assert!(terminal.paste("hello").is_err());
}
#[test]
fn shared_paste_is_bounded_and_removes_control_injection() {
let mut terminal = Terminal::new(grid(), 0);
terminal.focused = true;
assert_eq!(
terminal.paste("one\r\ntwo\x1b[201~\0\x03").unwrap(),
b"one\rtwo[201~"
);
assert!(
terminal
.paste(&"a".repeat(aen_window::clipboard::MAX_TEXT_BYTES + 1))
.is_err()
);
terminal.output(b"\x1b[?2004h");
assert_eq!(
terminal.paste("one\ntwo").unwrap(),
b"\x1b[200~one\ntwo\x1b[201~"
);
}
#[test]
fn scrollback_and_mouse_tracking_use_native_cell_coordinates() {
let mut terminal = Terminal::new(grid(), 20);
terminal.output(&b"line\r\n".repeat(30));
terminal.input(InputEvent::Focus { focused: true }, grid());
terminal.input(
InputEvent::Axis {
horizontal: 0,
vertical: -10,
x: 8,
y: 8,
},
grid(),
);
assert_eq!(terminal.parser.screen().scrollback(), 3);
terminal.parser.screen_mut().set_scrollback(0);
terminal.output(b"\x1b[?1000h\x1b[?1006h");
let event = InputEvent::Button {
button: 0x110,
pressed: true,
x: 28,
y: 28,
};
assert_eq!(terminal.input(event, grid()).bytes, b"\x1b[<0;3;2M");
}
}