Skip to main content

urushi_terminal/
command.rs

1//! Backend-independent terminal output commands.
2
3use std::{
4    error::Error,
5    fmt,
6    io::{self, IoSlice, Write},
7};
8
9use crate::{KeyboardEnhancementFlags, Position, TerminalSize, TerminalStyle};
10
11/// Printable terminal text containing no control characters.
12#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
13pub struct TerminalText<'a>(&'a str);
14
15impl<'a> TerminalText<'a> {
16    pub const fn as_str(self) -> &'a str {
17        self.0
18    }
19}
20
21impl<'a> TryFrom<&'a str> for TerminalText<'a> {
22    type Error = InvalidTerminalText;
23
24    fn try_from(text: &'a str) -> Result<Self, Self::Error> {
25        match text
26            .char_indices()
27            .find(|(_, character)| character.is_control())
28        {
29            Some((byte_offset, _)) => Err(InvalidTerminalText { byte_offset }),
30            None => Ok(Self(text)),
31        }
32    }
33}
34
35/// The location of a control character rejected from terminal text.
36#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
37pub struct InvalidTerminalText {
38    byte_offset: usize,
39}
40
41impl InvalidTerminalText {
42    pub const fn byte_offset(self) -> usize {
43        self.byte_offset
44    }
45}
46
47impl fmt::Display for InvalidTerminalText {
48    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
49        write!(
50            formatter,
51            "terminal text contains a control character at byte {}",
52            self.byte_offset
53        )
54    }
55}
56
57impl Error for InvalidTerminalText {}
58
59/// Printable ASCII payload carried by an ECMA-48 control string.
60///
61/// The framing escape sequences are supplied by the terminal backend.
62/// Restricting the payload to printable ASCII prevents an extension protocol
63/// from terminating its string early or exposing C1-valued UTF-8 continuation
64/// bytes to a byte-oriented terminal parser.
65#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
66pub struct ControlString<'a>(&'a str);
67
68impl<'a> ControlString<'a> {
69    pub const fn as_str(self) -> &'a str {
70        self.0
71    }
72}
73
74impl<'a> TryFrom<&'a str> for ControlString<'a> {
75    type Error = InvalidControlString;
76
77    fn try_from(payload: &'a str) -> Result<Self, Self::Error> {
78        match payload
79            .bytes()
80            .enumerate()
81            .find(|(_, byte)| !(0x20..=0x7e).contains(byte))
82        {
83            Some((byte_offset, _)) => Err(InvalidControlString { byte_offset }),
84            None => Ok(Self(payload)),
85        }
86    }
87}
88
89/// The location of a non-printable-ASCII byte rejected from a control-string payload.
90#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
91pub struct InvalidControlString {
92    byte_offset: usize,
93}
94
95impl InvalidControlString {
96    pub const fn byte_offset(self) -> usize {
97        self.byte_offset
98    }
99}
100
101impl fmt::Display for InvalidControlString {
102    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
103        write!(
104            formatter,
105            "terminal control string contains a non-printable-ASCII byte at byte {}",
106            self.byte_offset
107        )
108    }
109}
110
111impl Error for InvalidControlString {}
112
113pub(crate) fn write_control_string(
114    writer: &mut impl Write,
115    introducer: &[u8],
116    payload: ControlString<'_>,
117) -> io::Result<()> {
118    let mut slices = [
119        IoSlice::new(introducer),
120        IoSlice::new(payload.as_str().as_bytes()),
121        IoSlice::new(b"\x1b\\"),
122    ];
123    let mut remaining = slices.as_mut_slice();
124    while !remaining.is_empty() {
125        let written = match writer.write_vectored(remaining) {
126            Ok(written) => written,
127            Err(error) if error.kind() == io::ErrorKind::Interrupted => continue,
128            Err(error) => return Err(error),
129        };
130        if written == 0 {
131            return Err(io::Error::new(
132                io::ErrorKind::WriteZero,
133                "failed to write terminal control string",
134            ));
135        }
136        IoSlice::advance_slices(&mut remaining, written);
137    }
138    Ok(())
139}
140
141/// A terminal output sink whose queued operations can be made visible.
142pub trait TerminalOutput {
143    fn flush(&mut self) -> io::Result<()>;
144}
145
146impl<T: TerminalOutput + ?Sized> TerminalOutput for &mut T {
147    fn flush(&mut self) -> io::Result<()> {
148        T::flush(self)
149    }
150}
151
152/// A cursor movement expressed independently of an escape-sequence API.
153#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
154pub enum CursorMove {
155    To(Position),
156    ToColumn(usize),
157    ToRow(usize),
158    By { columns: i32, rows: i32 },
159    ToNextLine(usize),
160    ToPreviousLine(usize),
161}
162
163/// The cursor appearance requested from the terminal.
164#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
165pub enum CursorAppearance {
166    UserDefault,
167    BlinkingBlock,
168    SteadyBlock,
169    BlinkingUnderline,
170    SteadyUnderline,
171    BlinkingBar,
172    SteadyBar,
173}
174
175/// A logical region of the terminal buffer to clear.
176#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
177pub enum ClearRegion {
178    Screen,
179    ScreenAndScrollback,
180    BeforeCursor,
181    AfterCursor,
182    Line,
183    AfterCursorInLine,
184}
185
186/// One OSC 8 hyperlink parameter.
187#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
188pub struct HyperlinkParameter<'a> {
189    pub key: &'a str,
190    pub value: &'a str,
191}
192
193/// A hyperlink attached to subsequently printed text.
194#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
195pub struct TerminalHyperlink<'a> {
196    pub uri: &'a str,
197    pub parameters: &'a [HyperlinkParameter<'a>],
198}
199
200/// One semantic terminal control or text-output operation.
201///
202/// Commands model terminal behavior rather than a backend library's command
203/// types. A surface owns when commands run; a backend owns how they reach the
204/// terminal. Their conventional wire forms belong to the ANSI family: CSI
205/// sequences control cursor, modes, erasure, and SGR style; OSC strings carry
206/// titles and hyperlinks; printable UTF-8 and C0 line endings are raw bytes.
207/// An adapter may use native platform operations instead of those encodings.
208#[derive(Clone, Copy, Debug, PartialEq, Eq)]
209#[non_exhaustive]
210pub enum Command<'a> {
211    /// Moves the cursor using a CSI cursor-positioning or movement sequence.
212    MoveCursor(CursorMove),
213    /// Saves the cursor position using the backend's ANSI/DEC save sequence.
214    SaveCursorPosition,
215    /// Restores the cursor position using the backend's ANSI/DEC restore sequence.
216    RestoreCursorPosition,
217    /// Shows or hides the cursor using a CSI private-mode sequence.
218    SetCursorVisible(bool),
219    /// Enables or disables cursor blinking using a CSI private-mode sequence.
220    SetCursorBlinking(bool),
221    /// Selects a cursor shape using a CSI cursor-style sequence.
222    SetCursorAppearance(CursorAppearance),
223    /// Selects the primary or alternate screen using a CSI private-mode sequence.
224    SetAlternateScreen(bool),
225    /// Enables or disables bracketed-paste reporting using a CSI private-mode sequence.
226    SetBracketedPaste(bool),
227    /// Enables or disables terminal-focus reporting using a CSI private-mode sequence.
228    SetFocusReporting(bool),
229    /// Enables or disables mouse reporting using CSI private-mode sequences.
230    SetMouseCapture(bool),
231    /// Pushes enhanced-keyboard flags using the terminal's CSI protocol.
232    PushKeyboardEnhancement(KeyboardEnhancementFlags),
233    /// Pops the most recently pushed enhanced-keyboard flags using CSI.
234    PopKeyboardEnhancement,
235    /// Clears terminal content using a CSI erase sequence.
236    Clear(ClearRegion),
237    /// Scrolls by signed rows. Positive values move content upward.
238    ///
239    /// Backends normally lower this to CSI scroll-up or scroll-down sequences.
240    Scroll(i32),
241    /// Requests a terminal size using a CSI window-manipulation sequence.
242    SetSize(TerminalSize),
243    /// Sets the terminal title using an OSC control string.
244    SetTitle(TerminalText<'a>),
245    /// Enables or disables automatic line wrapping using a CSI private-mode sequence.
246    SetLineWrap(bool),
247    /// Begins or ends a synchronized update using a CSI private-mode sequence.
248    SetSynchronizedUpdate(bool),
249    /// Applies a complete physical text style using CSI SGR sequences.
250    SetStyle(TerminalStyle),
251    /// Restores the terminal's default text style using CSI SGR sequences.
252    ResetStyle,
253    /// Starts or ends a hyperlink using an OSC 8 control string.
254    SetHyperlink(Option<TerminalHyperlink<'a>>),
255    /// Sends an ECMA-48 Application Program Command control string.
256    ///
257    /// Extension crates use this transport for protocols such as Kitty
258    /// graphics while retaining ownership of the protocol payload itself.
259    ApplicationProgram(ControlString<'a>),
260    /// Sends an ECMA-48 Device Control String.
261    ///
262    /// Extension crates use this transport for protocols such as Sixel while
263    /// retaining ownership of the protocol payload itself.
264    DeviceControl(ControlString<'a>),
265    /// Writes the UTF-8 text bytes without interpreting them as terminal control data.
266    Print(TerminalText<'a>),
267    /// Writes the raw C0 line-feed byte without a carriage return.
268    LineFeed,
269    /// Writes raw C0 carriage-return and line-feed bytes.
270    CarriageReturnLineFeed,
271}
272
273/// Writes backend-independent terminal commands in caller-selected order.
274pub trait CommandWriter: TerminalOutput {
275    /// Queues or writes one command.
276    fn write_command(&mut self, command: Command<'_>) -> io::Result<()>;
277}
278
279impl<T: CommandWriter + ?Sized> CommandWriter for &mut T {
280    fn write_command(&mut self, command: Command<'_>) -> io::Result<()> {
281        T::write_command(self, command)
282    }
283}
284
285#[cfg(test)]
286mod tests {
287    use super::*;
288
289    #[test]
290    fn terminal_text_keeps_commands_out_of_printable_payloads() {
291        assert_eq!(
292            TerminalText::try_from("漆")
293                .expect("text is printable")
294                .as_str(),
295            "漆"
296        );
297        assert_eq!(
298            TerminalText::try_from("safe\u{1b}[2J")
299                .expect_err("escape is rejected")
300                .byte_offset(),
301            4
302        );
303        assert!(TerminalText::try_from("line\nfeed").is_err());
304    }
305
306    #[test]
307    fn control_strings_reject_embedded_terminal_commands() {
308        assert_eq!(
309            ControlString::try_from("Gf=32;AAAA")
310                .expect("graphics payload is printable")
311                .as_str(),
312            "Gf=32;AAAA"
313        );
314        assert_eq!(
315            ControlString::try_from("Gf=32\u{1b}\\")
316                .expect_err("escape is rejected")
317                .byte_offset(),
318            5
319        );
320        assert_eq!(
321            ControlString::try_from("Ĝě2J")
322                .expect_err("non-ASCII UTF-8 is rejected")
323                .byte_offset(),
324            0
325        );
326        assert!(ControlString::try_from("Gf=32\u{7f}").is_err());
327    }
328}