Skip to main content

urushi_terminal/backend/
crossterm.rs

1//! Crossterm implementation of Urushi's terminal backend capabilities.
2
3use std::{
4    io::{self, IsTerminal, Write},
5    time::Duration,
6};
7
8use crate::{
9    ClearRegion, Color, Command, CommandWriter, CursorAppearance, CursorMove, Event, EventSource,
10    FocusChange, KeyCode, KeyEvent, KeyEventState, KeyKind, KeyboardEnhancementFlags,
11    KeyboardEnhancementQuery, MediaKeyCode, ModifierKeyCode, Modifiers, MouseButton, MouseEvent,
12    MouseKind, PixelSize, Position, RawModeControl, TerminalOutput, TerminalQuery, TerminalSize,
13    TerminalStyle, TextAttribute, TextAttributes, Underline, UnderlineStyle, WindowSize,
14    command::write_control_string,
15};
16use crossterm::{
17    cursor,
18    event::{
19        self, DisableBracketedPaste, DisableFocusChange, DisableMouseCapture, EnableBracketedPaste,
20        EnableFocusChange, EnableMouseCapture, Event as CrosstermEvent,
21        KeyCode as CrosstermKeyCode, KeyEventKind, KeyEventState as CrosstermKeyEventState,
22        KeyModifiers, KeyboardEnhancementFlags as CrosstermKeyboardEnhancementFlags,
23        MediaKeyCode as CrosstermMediaKeyCode, ModifierKeyCode as CrosstermModifierKeyCode,
24        MouseButton as CrosstermMouseButton, MouseEventKind, PopKeyboardEnhancementFlags,
25        PushKeyboardEnhancementFlags,
26    },
27    queue,
28    style::{
29        Attribute, Color as CrosstermColor, Print, ResetColor, SetAttribute, SetBackgroundColor,
30        SetForegroundColor, SetUnderlineColor,
31    },
32    terminal::{
33        self, BeginSynchronizedUpdate, Clear, ClearType, DisableLineWrap, EnableLineWrap,
34        EndSynchronizedUpdate, EnterAlternateScreen, LeaveAlternateScreen, ScrollDown, ScrollUp,
35        SetSize, SetTitle,
36    },
37};
38
39/// The initial terminal backend, implemented with crossterm behind this module.
40pub struct CrosstermBackend<W> {
41    writer: W,
42}
43
44impl<W> CrosstermBackend<W> {
45    pub const fn new(writer: W) -> Self {
46        Self { writer }
47    }
48
49    pub const fn writer(&self) -> &W {
50        &self.writer
51    }
52
53    pub fn writer_mut(&mut self) -> &mut W {
54        &mut self.writer
55    }
56
57    pub fn into_inner(self) -> W {
58        self.writer
59    }
60}
61
62impl<W: Write> CommandWriter for CrosstermBackend<W> {
63    fn write_command(&mut self, command: Command<'_>) -> io::Result<()> {
64        match command {
65            Command::MoveCursor(movement) => write_cursor_move(&mut self.writer, movement),
66            Command::SaveCursorPosition => queue!(self.writer, cursor::SavePosition),
67            Command::RestoreCursorPosition => queue!(self.writer, cursor::RestorePosition),
68            Command::SetCursorVisible(true) => queue!(self.writer, cursor::Show),
69            Command::SetCursorVisible(false) => queue!(self.writer, cursor::Hide),
70            Command::SetCursorBlinking(true) => queue!(self.writer, cursor::EnableBlinking),
71            Command::SetCursorBlinking(false) => queue!(self.writer, cursor::DisableBlinking),
72            Command::SetCursorAppearance(appearance) => {
73                queue!(self.writer, convert_cursor_appearance(appearance))
74            }
75            Command::SetAlternateScreen(true) => queue!(self.writer, EnterAlternateScreen),
76            Command::SetAlternateScreen(false) => queue!(self.writer, LeaveAlternateScreen),
77            Command::SetBracketedPaste(true) => queue!(self.writer, EnableBracketedPaste),
78            Command::SetBracketedPaste(false) => queue!(self.writer, DisableBracketedPaste),
79            Command::SetFocusReporting(true) => queue!(self.writer, EnableFocusChange),
80            Command::SetFocusReporting(false) => queue!(self.writer, DisableFocusChange),
81            Command::SetMouseCapture(true) => queue!(self.writer, EnableMouseCapture),
82            Command::SetMouseCapture(false) => queue!(self.writer, DisableMouseCapture),
83            Command::PushKeyboardEnhancement(flags) => queue!(
84                self.writer,
85                PushKeyboardEnhancementFlags(convert_keyboard_enhancement_flags(flags))
86            ),
87            Command::PopKeyboardEnhancement => {
88                queue!(self.writer, PopKeyboardEnhancementFlags)
89            }
90            Command::Clear(region) => queue!(self.writer, Clear(convert_clear_region(region))),
91            Command::Scroll(rows) if rows > 0 => {
92                queue!(self.writer, ScrollUp(offset(rows.unsigned_abs())?))
93            }
94            Command::Scroll(rows) if rows < 0 => {
95                queue!(self.writer, ScrollDown(offset(rows.unsigned_abs())?))
96            }
97            Command::Scroll(_) => Ok(()),
98            Command::SetSize(size) => queue!(
99                self.writer,
100                SetSize(coordinate(size.columns())?, coordinate(size.rows())?)
101            ),
102            Command::SetTitle(title) => queue!(self.writer, SetTitle(title.as_str())),
103            Command::SetLineWrap(true) => queue!(self.writer, EnableLineWrap),
104            Command::SetLineWrap(false) => queue!(self.writer, DisableLineWrap),
105            Command::SetSynchronizedUpdate(true) => queue!(self.writer, BeginSynchronizedUpdate),
106            Command::SetSynchronizedUpdate(false) => queue!(self.writer, EndSynchronizedUpdate),
107            Command::SetStyle(style) => write_style(&mut self.writer, style),
108            Command::ResetStyle => queue!(self.writer, SetAttribute(Attribute::Reset), ResetColor),
109            Command::SetHyperlink(Some(link)) => write_hyperlink_start(&mut self.writer, link),
110            Command::SetHyperlink(None) => super::ansi::write_hyperlink_end(&mut self.writer),
111            Command::ApplicationProgram(payload) => {
112                write_control_string(&mut self.writer, b"\x1b_", payload)
113            }
114            Command::DeviceControl(payload) => {
115                write_control_string(&mut self.writer, b"\x1bP", payload)
116            }
117            Command::LineFeed => self.writer.write_all(b"\n"),
118            Command::CarriageReturnLineFeed => self.writer.write_all(b"\r\n"),
119            Command::Print(text) => queue!(self.writer, Print(text.as_str())),
120        }
121    }
122}
123
124impl<W: Write> TerminalOutput for CrosstermBackend<W> {
125    fn flush(&mut self) -> io::Result<()> {
126        self.writer.flush()
127    }
128}
129
130impl<W: Write + IsTerminal> RawModeControl for CrosstermBackend<W> {
131    fn is_interactive(&self) -> bool {
132        io::stdin().is_terminal() && self.writer.is_terminal()
133    }
134
135    fn enable_raw_mode(&mut self) -> io::Result<()> {
136        terminal::enable_raw_mode()
137    }
138
139    fn disable_raw_mode(&mut self) -> io::Result<()> {
140        terminal::disable_raw_mode()
141    }
142}
143
144impl<W> TerminalQuery for CrosstermBackend<W> {
145    fn terminal_size(&mut self) -> io::Result<TerminalSize> {
146        let (columns, rows) = terminal::size()?;
147        Ok(TerminalSize::new(usize::from(columns), usize::from(rows)))
148    }
149
150    fn cursor_position(&mut self) -> io::Result<Position> {
151        let (column, row) = cursor::position()?;
152        Ok(Position::new(usize::from(column), usize::from(row)))
153    }
154
155    fn window_size(&mut self) -> io::Result<WindowSize> {
156        match terminal::window_size() {
157            Ok(size) => Ok(convert_window_size(size)),
158            Err(error) if error.kind() == io::ErrorKind::Unsupported => self
159                .terminal_size()
160                .map(|cells| WindowSize::new(cells, None)),
161            Err(error) => Err(error),
162        }
163    }
164
165    fn raw_mode_enabled(&mut self) -> io::Result<bool> {
166        terminal::is_raw_mode_enabled()
167    }
168}
169
170impl<W> KeyboardEnhancementQuery for CrosstermBackend<W> {
171    fn supports_keyboard_enhancement(&mut self) -> io::Result<bool> {
172        terminal::supports_keyboard_enhancement()
173    }
174}
175
176fn convert_window_size(size: terminal::WindowSize) -> WindowSize {
177    let cells = TerminalSize::new(usize::from(size.columns), usize::from(size.rows));
178    let pixels = (size.width != 0 && size.height != 0)
179        .then(|| PixelSize::new(usize::from(size.width), usize::from(size.height)));
180    WindowSize::new(cells, pixels)
181}
182
183impl<W> EventSource for CrosstermBackend<W> {
184    fn read_event(&mut self) -> io::Result<Event> {
185        Ok(convert_event(event::read()?))
186    }
187
188    fn poll_event(&mut self) -> io::Result<Option<Event>> {
189        self.poll_event_timeout(Duration::ZERO)
190    }
191
192    fn poll_event_timeout(&mut self, timeout: Duration) -> io::Result<Option<Event>> {
193        if event::poll(timeout)? {
194            Ok(Some(convert_event(event::read()?)))
195        } else {
196            Ok(None)
197        }
198    }
199}
200
201fn convert_keyboard_enhancement_flags(
202    requested: KeyboardEnhancementFlags,
203) -> CrosstermKeyboardEnhancementFlags {
204    let mut flags = CrosstermKeyboardEnhancementFlags::empty();
205    if requested.contains(KeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES) {
206        flags |= CrosstermKeyboardEnhancementFlags::DISAMBIGUATE_ESCAPE_CODES;
207    }
208    if requested.contains(KeyboardEnhancementFlags::REPORT_EVENT_TYPES) {
209        flags |= CrosstermKeyboardEnhancementFlags::REPORT_EVENT_TYPES;
210    }
211    if requested.contains(KeyboardEnhancementFlags::REPORT_ALTERNATE_KEYS) {
212        flags |= CrosstermKeyboardEnhancementFlags::REPORT_ALTERNATE_KEYS;
213    }
214    if requested.contains(KeyboardEnhancementFlags::REPORT_ALL_KEYS_AS_ESCAPE_CODES) {
215        flags |= CrosstermKeyboardEnhancementFlags::REPORT_ALL_KEYS_AS_ESCAPE_CODES;
216    }
217    flags
218}
219
220fn coordinate(value: usize) -> io::Result<u16> {
221    u16::try_from(value).map_err(|_| {
222        io::Error::new(
223            io::ErrorKind::InvalidInput,
224            "terminal coordinate exceeds u16",
225        )
226    })
227}
228
229fn offset(value: u32) -> io::Result<u16> {
230    u16::try_from(value)
231        .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "terminal offset exceeds u16"))
232}
233
234fn write_cursor_move(writer: &mut impl Write, movement: CursorMove) -> io::Result<()> {
235    match movement {
236        CursorMove::To(position) => queue!(
237            writer,
238            cursor::MoveTo(coordinate(position.column())?, coordinate(position.row())?)
239        ),
240        CursorMove::ToColumn(column) => queue!(writer, cursor::MoveToColumn(coordinate(column)?)),
241        CursorMove::ToRow(row) => queue!(writer, cursor::MoveToRow(coordinate(row)?)),
242        CursorMove::By { columns, rows } => {
243            if rows < 0 {
244                queue!(writer, cursor::MoveUp(offset(rows.unsigned_abs())?))?;
245            } else if rows > 0 {
246                queue!(writer, cursor::MoveDown(offset(rows.unsigned_abs())?))?;
247            }
248            if columns < 0 {
249                queue!(writer, cursor::MoveLeft(offset(columns.unsigned_abs())?))
250            } else if columns > 0 {
251                queue!(writer, cursor::MoveRight(offset(columns.unsigned_abs())?))
252            } else {
253                Ok(())
254            }
255        }
256        CursorMove::ToNextLine(lines) => {
257            queue!(writer, cursor::MoveToNextLine(coordinate(lines)?))
258        }
259        CursorMove::ToPreviousLine(lines) => {
260            queue!(writer, cursor::MoveToPreviousLine(coordinate(lines)?))
261        }
262    }
263}
264
265fn convert_cursor_appearance(appearance: CursorAppearance) -> cursor::SetCursorStyle {
266    match appearance {
267        CursorAppearance::UserDefault => cursor::SetCursorStyle::DefaultUserShape,
268        CursorAppearance::BlinkingBlock => cursor::SetCursorStyle::BlinkingBlock,
269        CursorAppearance::SteadyBlock => cursor::SetCursorStyle::SteadyBlock,
270        CursorAppearance::BlinkingUnderline => cursor::SetCursorStyle::BlinkingUnderScore,
271        CursorAppearance::SteadyUnderline => cursor::SetCursorStyle::SteadyUnderScore,
272        CursorAppearance::BlinkingBar => cursor::SetCursorStyle::BlinkingBar,
273        CursorAppearance::SteadyBar => cursor::SetCursorStyle::SteadyBar,
274    }
275}
276
277fn convert_clear_region(region: ClearRegion) -> ClearType {
278    match region {
279        ClearRegion::Screen => ClearType::All,
280        ClearRegion::ScreenAndScrollback => ClearType::Purge,
281        ClearRegion::BeforeCursor => ClearType::FromCursorUp,
282        ClearRegion::AfterCursor => ClearType::FromCursorDown,
283        ClearRegion::Line => ClearType::CurrentLine,
284        ClearRegion::AfterCursorInLine => ClearType::UntilNewLine,
285    }
286}
287
288fn write_style(writer: &mut impl Write, style: TerminalStyle) -> io::Result<()> {
289    queue!(writer, SetAttribute(Attribute::Reset), ResetColor)?;
290    if let Some(color) = style.foreground {
291        queue!(writer, SetForegroundColor(convert_color(color)))?;
292    }
293    if let Some(color) = style.background {
294        queue!(writer, SetBackgroundColor(convert_color(color)))?;
295    }
296    write_attributes(writer, style.attributes)?;
297    write_underline(writer, style.underline)
298}
299
300fn write_underline(writer: &mut impl Write, underline: Option<Underline>) -> io::Result<()> {
301    let Some(underline) = underline else {
302        return Ok(());
303    };
304    let attribute = match underline.get_style() {
305        UnderlineStyle::Single => Attribute::Underlined,
306        UnderlineStyle::Double => Attribute::DoubleUnderlined,
307        UnderlineStyle::Curly => Attribute::Undercurled,
308        UnderlineStyle::Dotted => Attribute::Underdotted,
309        UnderlineStyle::Dashed => Attribute::Underdashed,
310    };
311    queue!(writer, SetAttribute(attribute))?;
312    if let Some(color) = underline.get_color() {
313        queue!(writer, SetUnderlineColor(convert_color(color)))?;
314    }
315    Ok(())
316}
317
318fn write_hyperlink_start(
319    writer: &mut impl Write,
320    link: crate::TerminalHyperlink<'_>,
321) -> io::Result<()> {
322    super::ansi::write_hyperlink_start(writer, link)
323}
324
325fn convert_color(color: Color) -> CrosstermColor {
326    match color {
327        Color::Ansi(0) => CrosstermColor::Black,
328        Color::Ansi(1) => CrosstermColor::DarkRed,
329        Color::Ansi(2) => CrosstermColor::DarkGreen,
330        Color::Ansi(3) => CrosstermColor::DarkYellow,
331        Color::Ansi(4) => CrosstermColor::DarkBlue,
332        Color::Ansi(5) => CrosstermColor::DarkMagenta,
333        Color::Ansi(6) => CrosstermColor::DarkCyan,
334        Color::Ansi(7) => CrosstermColor::Grey,
335        Color::Ansi(8) => CrosstermColor::DarkGrey,
336        Color::Ansi(9) => CrosstermColor::Red,
337        Color::Ansi(10) => CrosstermColor::Green,
338        Color::Ansi(11) => CrosstermColor::Yellow,
339        Color::Ansi(12) => CrosstermColor::Blue,
340        Color::Ansi(13) => CrosstermColor::Magenta,
341        Color::Ansi(14) => CrosstermColor::Cyan,
342        Color::Ansi(15) => CrosstermColor::White,
343        Color::Ansi(index) | Color::Ansi256(index) => CrosstermColor::AnsiValue(index),
344        Color::Rgb(red, green, blue) => CrosstermColor::Rgb {
345            r: red,
346            g: green,
347            b: blue,
348        },
349    }
350}
351
352fn write_attributes(writer: &mut impl Write, attributes: TextAttributes) -> io::Result<()> {
353    for attribute in attributes {
354        let attribute = match attribute {
355            TextAttribute::Bold => Attribute::Bold,
356            TextAttribute::Dim => Attribute::Dim,
357            TextAttribute::Italic => Attribute::Italic,
358            TextAttribute::SlowBlink => Attribute::SlowBlink,
359            TextAttribute::RapidBlink => Attribute::RapidBlink,
360            TextAttribute::Reversed => Attribute::Reverse,
361            TextAttribute::Hidden => Attribute::Hidden,
362            TextAttribute::CrossedOut => Attribute::CrossedOut,
363            TextAttribute::Fraktur => Attribute::Fraktur,
364            TextAttribute::Framed => Attribute::Framed,
365            TextAttribute::Encircled => Attribute::Encircled,
366            TextAttribute::Overlined => Attribute::OverLined,
367        };
368        queue!(writer, SetAttribute(attribute))?;
369    }
370    Ok(())
371}
372
373fn convert_event(event: CrosstermEvent) -> Event {
374    match event {
375        CrosstermEvent::Key(key) => Event::Key(KeyEvent {
376            code: convert_key_code(key.code),
377            modifiers: convert_modifiers(key.modifiers),
378            kind: match key.kind {
379                KeyEventKind::Press => KeyKind::Press,
380                KeyEventKind::Repeat => KeyKind::Repeat,
381                KeyEventKind::Release => KeyKind::Release,
382            },
383            state: convert_key_state(key.state),
384        }),
385        CrosstermEvent::Mouse(mouse) => Event::Mouse(MouseEvent {
386            kind: match mouse.kind {
387                MouseEventKind::Down(button) => MouseKind::Down(convert_mouse_button(button)),
388                MouseEventKind::Up(button) => MouseKind::Up(convert_mouse_button(button)),
389                MouseEventKind::Drag(button) => MouseKind::Drag(convert_mouse_button(button)),
390                MouseEventKind::Moved => MouseKind::Moved,
391                MouseEventKind::ScrollDown => MouseKind::ScrollDown,
392                MouseEventKind::ScrollUp => MouseKind::ScrollUp,
393                MouseEventKind::ScrollLeft => MouseKind::ScrollLeft,
394                MouseEventKind::ScrollRight => MouseKind::ScrollRight,
395            },
396            position: Position::new(usize::from(mouse.column), usize::from(mouse.row)),
397            modifiers: convert_modifiers(mouse.modifiers),
398        }),
399        CrosstermEvent::Paste(text) => Event::Paste(text),
400        CrosstermEvent::FocusGained => Event::Focus(FocusChange::Gained),
401        CrosstermEvent::FocusLost => Event::Focus(FocusChange::Lost),
402        CrosstermEvent::Resize(columns, rows) => {
403            Event::Resize(TerminalSize::new(usize::from(columns), usize::from(rows)))
404        }
405    }
406}
407
408fn convert_key_code(code: CrosstermKeyCode) -> KeyCode {
409    match code {
410        CrosstermKeyCode::Char(character) => KeyCode::Char(character),
411        CrosstermKeyCode::Enter => KeyCode::Enter,
412        CrosstermKeyCode::Tab => KeyCode::Tab,
413        CrosstermKeyCode::BackTab => KeyCode::BackTab,
414        CrosstermKeyCode::Backspace => KeyCode::Backspace,
415        CrosstermKeyCode::Delete => KeyCode::Delete,
416        CrosstermKeyCode::Esc => KeyCode::Escape,
417        CrosstermKeyCode::Insert => KeyCode::Insert,
418        CrosstermKeyCode::Home => KeyCode::Home,
419        CrosstermKeyCode::End => KeyCode::End,
420        CrosstermKeyCode::PageUp => KeyCode::PageUp,
421        CrosstermKeyCode::PageDown => KeyCode::PageDown,
422        CrosstermKeyCode::Up => KeyCode::Up,
423        CrosstermKeyCode::Down => KeyCode::Down,
424        CrosstermKeyCode::Left => KeyCode::Left,
425        CrosstermKeyCode::Right => KeyCode::Right,
426        CrosstermKeyCode::F(number) => KeyCode::Function(number),
427        CrosstermKeyCode::Null => KeyCode::Null,
428        CrosstermKeyCode::CapsLock => KeyCode::CapsLock,
429        CrosstermKeyCode::ScrollLock => KeyCode::ScrollLock,
430        CrosstermKeyCode::NumLock => KeyCode::NumLock,
431        CrosstermKeyCode::PrintScreen => KeyCode::PrintScreen,
432        CrosstermKeyCode::Pause => KeyCode::Pause,
433        CrosstermKeyCode::Menu => KeyCode::Menu,
434        CrosstermKeyCode::KeypadBegin => KeyCode::KeypadBegin,
435        CrosstermKeyCode::Media(code) => KeyCode::Media(convert_media_key(code)),
436        CrosstermKeyCode::Modifier(code) => KeyCode::Modifier(convert_modifier_key(code)),
437    }
438}
439
440fn convert_modifiers(modifiers: KeyModifiers) -> Modifiers {
441    let mut converted = Modifiers::NONE;
442    for (source, target) in [
443        (KeyModifiers::SHIFT, Modifiers::SHIFT),
444        (KeyModifiers::CONTROL, Modifiers::CONTROL),
445        (KeyModifiers::ALT, Modifiers::ALT),
446        (KeyModifiers::SUPER, Modifiers::SUPER),
447        (KeyModifiers::HYPER, Modifiers::HYPER),
448        (KeyModifiers::META, Modifiers::META),
449    ] {
450        if modifiers.contains(source) {
451            converted = converted | target;
452        }
453    }
454    converted
455}
456
457fn convert_key_state(state: CrosstermKeyEventState) -> KeyEventState {
458    let mut converted = KeyEventState::NONE;
459    for (source, target) in [
460        (CrosstermKeyEventState::KEYPAD, KeyEventState::KEYPAD),
461        (CrosstermKeyEventState::CAPS_LOCK, KeyEventState::CAPS_LOCK),
462        (CrosstermKeyEventState::NUM_LOCK, KeyEventState::NUM_LOCK),
463    ] {
464        if state.contains(source) {
465            converted = converted | target;
466        }
467    }
468    converted
469}
470
471fn convert_media_key(code: CrosstermMediaKeyCode) -> MediaKeyCode {
472    match code {
473        CrosstermMediaKeyCode::Play => MediaKeyCode::Play,
474        CrosstermMediaKeyCode::Pause => MediaKeyCode::Pause,
475        CrosstermMediaKeyCode::PlayPause => MediaKeyCode::PlayPause,
476        CrosstermMediaKeyCode::Reverse => MediaKeyCode::Reverse,
477        CrosstermMediaKeyCode::Stop => MediaKeyCode::Stop,
478        CrosstermMediaKeyCode::FastForward => MediaKeyCode::FastForward,
479        CrosstermMediaKeyCode::Rewind => MediaKeyCode::Rewind,
480        CrosstermMediaKeyCode::TrackNext => MediaKeyCode::TrackNext,
481        CrosstermMediaKeyCode::TrackPrevious => MediaKeyCode::TrackPrevious,
482        CrosstermMediaKeyCode::Record => MediaKeyCode::Record,
483        CrosstermMediaKeyCode::LowerVolume => MediaKeyCode::LowerVolume,
484        CrosstermMediaKeyCode::RaiseVolume => MediaKeyCode::RaiseVolume,
485        CrosstermMediaKeyCode::MuteVolume => MediaKeyCode::MuteVolume,
486    }
487}
488
489fn convert_modifier_key(code: CrosstermModifierKeyCode) -> ModifierKeyCode {
490    match code {
491        CrosstermModifierKeyCode::LeftShift => ModifierKeyCode::LeftShift,
492        CrosstermModifierKeyCode::LeftControl => ModifierKeyCode::LeftControl,
493        CrosstermModifierKeyCode::LeftAlt => ModifierKeyCode::LeftAlt,
494        CrosstermModifierKeyCode::LeftSuper => ModifierKeyCode::LeftSuper,
495        CrosstermModifierKeyCode::LeftHyper => ModifierKeyCode::LeftHyper,
496        CrosstermModifierKeyCode::LeftMeta => ModifierKeyCode::LeftMeta,
497        CrosstermModifierKeyCode::RightShift => ModifierKeyCode::RightShift,
498        CrosstermModifierKeyCode::RightControl => ModifierKeyCode::RightControl,
499        CrosstermModifierKeyCode::RightAlt => ModifierKeyCode::RightAlt,
500        CrosstermModifierKeyCode::RightSuper => ModifierKeyCode::RightSuper,
501        CrosstermModifierKeyCode::RightHyper => ModifierKeyCode::RightHyper,
502        CrosstermModifierKeyCode::RightMeta => ModifierKeyCode::RightMeta,
503        CrosstermModifierKeyCode::IsoLevel3Shift => ModifierKeyCode::IsoLevel3Shift,
504        CrosstermModifierKeyCode::IsoLevel5Shift => ModifierKeyCode::IsoLevel5Shift,
505    }
506}
507
508fn convert_mouse_button(button: CrosstermMouseButton) -> MouseButton {
509    match button {
510        CrosstermMouseButton::Left => MouseButton::Left,
511        CrosstermMouseButton::Right => MouseButton::Right,
512        CrosstermMouseButton::Middle => MouseButton::Middle,
513    }
514}
515
516#[cfg(test)]
517mod tests {
518    use super::*;
519    use crossterm::event::{KeyEvent as CrosstermKeyEvent, MouseEvent as CrosstermMouseEvent};
520
521    fn contains(haystack: &[u8], needle: &[u8]) -> bool {
522        haystack
523            .windows(needle.len())
524            .any(|window| window == needle)
525    }
526
527    #[test]
528    fn key_kind_code_and_modifiers_are_normalized() {
529        let event = convert_event(CrosstermEvent::Key(CrosstermKeyEvent::new_with_kind(
530            CrosstermKeyCode::Char('x'),
531            KeyModifiers::CONTROL | KeyModifiers::ALT,
532            KeyEventKind::Release,
533        )));
534
535        assert_eq!(
536            event,
537            Event::Key(KeyEvent {
538                code: KeyCode::Char('x'),
539                modifiers: Modifiers::CONTROL.union(Modifiers::ALT),
540                kind: KeyKind::Release,
541                state: KeyEventState::NONE,
542            })
543        );
544    }
545
546    #[test]
547    fn enhanced_keyboard_information_is_preserved() {
548        let event = convert_event(CrosstermEvent::Key(
549            CrosstermKeyEvent::new_with_kind_and_state(
550                CrosstermKeyCode::Modifier(CrosstermModifierKeyCode::RightSuper),
551                KeyModifiers::SHIFT
552                    | KeyModifiers::SUPER
553                    | KeyModifiers::HYPER
554                    | KeyModifiers::META,
555                KeyEventKind::Repeat,
556                CrosstermKeyEventState::KEYPAD
557                    | CrosstermKeyEventState::CAPS_LOCK
558                    | CrosstermKeyEventState::NUM_LOCK,
559            ),
560        ));
561
562        assert_eq!(
563            event,
564            Event::Key(KeyEvent {
565                code: KeyCode::Modifier(ModifierKeyCode::RightSuper),
566                modifiers: Modifiers::SHIFT
567                    .union(Modifiers::SUPER)
568                    .union(Modifiers::HYPER)
569                    .union(Modifiers::META),
570                kind: KeyKind::Repeat,
571                state: KeyEventState::KEYPAD | KeyEventState::CAPS_LOCK | KeyEventState::NUM_LOCK,
572            })
573        );
574        assert_eq!(
575            convert_key_code(CrosstermKeyCode::Media(CrosstermMediaKeyCode::TrackNext)),
576            KeyCode::Media(MediaKeyCode::TrackNext)
577        );
578    }
579
580    #[test]
581    fn semantic_commands_cover_terminal_control_without_exposing_crossterm() {
582        let mut backend = CrosstermBackend::new(Vec::new());
583        backend
584            .write_command(Command::MoveCursor(CursorMove::By {
585                columns: -2,
586                rows: 3,
587            }))
588            .expect("relative cursor move encodes");
589        backend
590            .write_command(Command::Clear(ClearRegion::ScreenAndScrollback))
591            .expect("scrollback clear encodes");
592        backend
593            .write_command(Command::SetSynchronizedUpdate(true))
594            .expect("synchronized update encodes");
595        backend
596            .write_command(Command::SetSynchronizedUpdate(false))
597            .expect("synchronized update end encodes");
598        backend
599            .write_command(Command::SetHyperlink(Some(crate::TerminalHyperlink {
600                uri: "https://example.invalid/\u{1b}",
601                parameters: &[crate::HyperlinkParameter {
602                    key: "id:unsafe",
603                    value: "value",
604                }],
605            })))
606            .expect("hyperlink encodes");
607        backend
608            .write_command(Command::ApplicationProgram(
609                crate::ControlString::try_from("Ga=T;AAAA").expect("valid APC payload"),
610            ))
611            .expect("application command encodes");
612
613        let output = backend.into_inner();
614        assert!(contains(&output, b"\x1b[3B\x1b[2D"));
615        assert!(contains(&output, b"\x1b[3J"));
616        assert!(contains(&output, b"id%3Aunsafe=value"));
617        assert!(contains(&output, b"https://example.invalid/%1B"));
618        assert!(contains(&output, b"\x1b_Ga=T;AAAA\x1b\\"));
619    }
620
621    #[test]
622    fn paste_focus_resize_and_mouse_do_not_leak_crossterm_types() {
623        assert_eq!(
624            convert_event(CrosstermEvent::Paste("text".to_owned())),
625            Event::Paste("text".to_owned())
626        );
627        assert_eq!(
628            convert_event(CrosstermEvent::FocusLost),
629            Event::Focus(FocusChange::Lost)
630        );
631        assert_eq!(
632            convert_event(CrosstermEvent::Resize(120, 40)),
633            Event::Resize(TerminalSize::new(120, 40))
634        );
635        assert_eq!(
636            convert_event(CrosstermEvent::Mouse(CrosstermMouseEvent {
637                kind: MouseEventKind::Drag(CrosstermMouseButton::Left),
638                column: 7,
639                row: 9,
640                modifiers: KeyModifiers::SHIFT,
641            })),
642            Event::Mouse(MouseEvent {
643                kind: MouseKind::Drag(MouseButton::Left),
644                position: Position::new(7, 9),
645                modifiers: Modifiers::SHIFT,
646            })
647        );
648    }
649
650    #[test]
651    fn absent_pixel_geometry_does_not_erase_cell_geometry() {
652        let size = convert_window_size(terminal::WindowSize {
653            columns: 80,
654            rows: 24,
655            width: 0,
656            height: 0,
657        });
658        assert_eq!(size.cells(), TerminalSize::new(80, 24));
659        assert_eq!(size.pixels(), None);
660
661        let size = convert_window_size(terminal::WindowSize {
662            columns: 80,
663            rows: 24,
664            width: 1600,
665            height: 960,
666        });
667        assert_eq!(size.pixels(), Some(PixelSize::new(1600, 960)));
668    }
669}