1use std::{
4 error::Error,
5 fmt,
6 io::{self, IoSlice, Write},
7};
8
9use crate::{KeyboardEnhancementFlags, Position, TerminalSize, TerminalStyle};
10
11#[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#[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#[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#[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
141pub 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#[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#[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#[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#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
188pub struct HyperlinkParameter<'a> {
189 pub key: &'a str,
190 pub value: &'a str,
191}
192
193#[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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
209#[non_exhaustive]
210pub enum Command<'a> {
211 MoveCursor(CursorMove),
213 SaveCursorPosition,
215 RestoreCursorPosition,
217 SetCursorVisible(bool),
219 SetCursorBlinking(bool),
221 SetCursorAppearance(CursorAppearance),
223 SetAlternateScreen(bool),
225 SetBracketedPaste(bool),
227 SetFocusReporting(bool),
229 SetMouseCapture(bool),
231 PushKeyboardEnhancement(KeyboardEnhancementFlags),
233 PopKeyboardEnhancement,
235 Clear(ClearRegion),
237 Scroll(i32),
241 SetSize(TerminalSize),
243 SetTitle(TerminalText<'a>),
245 SetLineWrap(bool),
247 SetSynchronizedUpdate(bool),
249 SetStyle(TerminalStyle),
251 ResetStyle,
253 SetHyperlink(Option<TerminalHyperlink<'a>>),
255 ApplicationProgram(ControlString<'a>),
260 DeviceControl(ControlString<'a>),
265 Print(TerminalText<'a>),
267 LineFeed,
269 CarriageReturnLineFeed,
271}
272
273pub trait CommandWriter: TerminalOutput {
275 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}