diff --git a/crates/edit/src/bin/edit/main.rs b/crates/edit/src/bin/edit/main.rs index 18f70eeacbc..daf7dd302f9 100644 --- a/crates/edit/src/bin/edit/main.rs +++ b/crates/edit/src/bin/edit/main.rs @@ -40,6 +40,12 @@ const SCRATCH_ARENA_CAPACITY: usize = 128 * MEBI; #[cfg(target_pointer_width = "64")] const SCRATCH_ARENA_CAPACITY: usize = 512 * MEBI; +// After DA, briefly drain follow-up probe responses so split OSC replies do not leak into +// the main input parser. Keep the window short because real user input is still on stdin. +const TERMINAL_PROBE_QUIET_TIMEOUT: Duration = Duration::from_millis(50); +// Bound terminal probing for terminals that omit DA or leave a control sequence unfinished. +const TERMINAL_PROBE_HARD_TIMEOUT: Duration = Duration::from_secs(3); + // NOTE: Before our main() gets called, Rust initializes its stdlib. This pulls in the entire // std::io::{stdin, stdout, stderr} machinery, and probably some more, which amounts to about 20KB. // It can technically be avoided nowadays with `#![no_main]`. Maybe a fun project for later? :) @@ -87,11 +93,9 @@ fn run() -> apperr::Result<()> { // As such, we call this after `handle_args`. sys::switch_modes()?; - let mut vt_parser = vt::Parser::new(); - let mut input_parser = input::Parser::new(); let mut tui = Tui::new()?; - let _restore = setup_terminal(&mut tui, &mut state, &mut vt_parser); + let _restore = setup_terminal(&mut tui, &mut state); state.menubar_color_bg = tui.indexed(IndexedColor::Background).oklab_blend(tui.indexed_alpha( IndexedColor::BrightBlue, @@ -115,6 +119,11 @@ fn run() -> apperr::Result<()> { sys::inject_window_size_into_stdin(); + // Startup probing may leave partial terminal responses in the probe parser. + // Start application input parsing with an independent parser lifecycle. + let mut vt_parser = vt::Parser::new(); + let mut input_parser = input::Parser::new(); + #[cfg(feature = "debug-latency")] let mut last_latency_width = 0; @@ -565,7 +574,159 @@ impl Drop for RestoreModes { } } -fn setup_terminal(tui: &mut Tui, state: &mut State, vt_parser: &mut vt::Parser) -> RestoreModes { +struct TerminalProbe { + seen_device_attributes: bool, + seen_cursor_position: bool, + seen_colors: [bool; framebuffer::INDEXED_COLORS_COUNT], + color_responses: usize, + quiet_deadline: Option, +} + +impl TerminalProbe { + fn new() -> Self { + Self { + seen_device_attributes: false, + seen_cursor_position: false, + seen_colors: [false; framebuffer::INDEXED_COLORS_COUNT], + color_responses: 0, + quiet_deadline: None, + } + } + + fn record_device_attributes(&mut self, now: std::time::Instant) { + self.seen_device_attributes = true; + self.quiet_deadline = Some(now + TERMINAL_PROBE_QUIET_TIMEOUT); + } + + fn record_activity_after_device_attributes(&mut self, now: std::time::Instant) { + if self.seen_device_attributes { + self.quiet_deadline = Some(now + TERMINAL_PROBE_QUIET_TIMEOUT); + } + } + + fn record_cursor_position(&mut self) { + self.seen_cursor_position = true; + } + + fn record_color(&mut self, index: usize) { + if !self.seen_colors[index] { + self.seen_colors[index] = true; + self.color_responses += 1; + } + } + + fn color_responses(&self) -> usize { + self.color_responses + } + + fn is_complete(&self) -> bool { + self.seen_device_attributes + && self.seen_cursor_position + && self.color_responses == framebuffer::INDEXED_COLORS_COUNT + } + + fn should_finish( + &self, + parser_is_ground: bool, + now: std::time::Instant, + hard_deadline: std::time::Instant, + ) -> bool { + if parser_is_ground && self.is_complete() { + return true; + } + + if parser_is_ground + && self.seen_device_attributes + && self.quiet_deadline.is_some_and(|deadline| now >= deadline) + { + return true; + } + + now >= hard_deadline + } + + fn next_read_deadline( + &self, + parser_is_ground: bool, + hard_deadline: std::time::Instant, + ) -> std::time::Instant { + if parser_is_ground { + self.quiet_deadline.unwrap_or(hard_deadline).min(hard_deadline) + } else { + hard_deadline + } + } +} + +fn record_probe_osc_response( + osc_buffer: &mut String, + indexed_colors: &mut [StraightRgba; framebuffer::INDEXED_COLORS_COUNT], + probe: &mut TerminalProbe, + data: &str, + partial: bool, +) { + if partial { + osc_buffer.push_str(data); + return; + } + + let parsed = if osc_buffer.is_empty() { + parse_probe_color_response(data) + } else { + osc_buffer.push_str(data); + parse_probe_color_response(osc_buffer) + }; + + if let Some((color_index, color)) = parsed { + indexed_colors[color_index] = color; + probe.record_color(color_index); + } + + osc_buffer.clear(); +} + +fn parse_probe_color_response(data: &str) -> Option<(usize, StraightRgba)> { + let mut splits = data.split_terminator(';'); + + let color_index = match splits.next().unwrap_or("") { + // The response is `4;;rgb://`. + "4" => match splits.next().unwrap_or("").parse::() { + Ok(val) if val < 16 => val, + _ => return None, + }, + // The response is `10;rgb://`. + "10" => IndexedColor::Foreground as usize, + // The response is `11;rgb://`. + "11" => IndexedColor::Background as usize, + _ => return None, + }; + + let color_param = splits.next().unwrap_or(""); + if !color_param.starts_with("rgb:") { + return None; + } + + let mut iter = color_param[4..].split_terminator('/'); + let rgb_parts = [(); 3].map(|_| iter.next().unwrap_or("0")); + let mut rgb = 0; + + for part in rgb_parts { + if part.len() == 2 || part.len() == 4 { + let Ok(mut val) = usize::from_str_radix(part, 16) else { + continue; + }; + if part.len() == 4 { + // Round from 16 bits to 8 bits. + val = (val * 0xff + 0x7fff) / 0xffff; + } + rgb = (rgb >> 8) | ((val as u32) << 16); + } + } + + Some((color_index, StraightRgba::from_le(rgb | 0xff000000))) +} + +fn setup_terminal(tui: &mut Tui, state: &mut State) -> RestoreModes { sys::write_stdout(concat!( // 1049: Alternative Screen Buffer // I put the ASB switch in the beginning, just in case the terminal performs @@ -592,85 +753,65 @@ fn setup_terminal(tui: &mut Tui, state: &mut State, vt_parser: &mut vt::Parser) "\x1b[c", )); - let mut done = false; let mut osc_buffer = String::new(); let mut indexed_colors = framebuffer::DEFAULT_THEME; - let mut color_responses = 0; let mut ambiguous_width = 1; + let mut probe = TerminalProbe::new(); + let mut vt_parser = vt::Parser::new(); + let hard_deadline = std::time::Instant::now() + TERMINAL_PROBE_HARD_TIMEOUT; + + loop { + let now = std::time::Instant::now(); + if probe.should_finish(vt_parser.is_ground(), now, hard_deadline) { + break; + } - while !done { let scratch = scratch_arena(None); + let mut timeout = probe + .next_read_deadline(vt_parser.is_ground(), hard_deadline) + .saturating_duration_since(now); + timeout = timeout.min(vt_parser.read_timeout()); - // We explicitly set a high read timeout, because we're not - // waiting for user keyboard input. If we encounter a lone ESC, - // it's unlikely to be from a ESC keypress, but rather from a VT sequence. - let Some(input) = sys::read_stdin(&scratch, Duration::from_secs(3)) else { + let Some(input) = sys::read_stdin(&scratch, timeout) else { break; }; + let got_input = !input.is_empty(); + let mut probe_activity = false; let mut vt_stream = vt_parser.parse(&input); while let Some(token) = vt_stream.next() { match token { Token::Csi(csi) => match csi.final_byte { - 'c' => done = true, + 'c' => { + probe.record_device_attributes(std::time::Instant::now()); + probe_activity = true; + } // CPR (Cursor Position Report) response. - 'R' => ambiguous_width = csi.params[1] as CoordType - 1, + 'R' => { + probe.record_cursor_position(); + ambiguous_width = csi.params[1] as CoordType - 1; + probe_activity = true; + } _ => {} }, - Token::Osc { mut data, partial } => { - if partial { - osc_buffer.push_str(data); - continue; - } - if !osc_buffer.is_empty() { - osc_buffer.push_str(data); - data = &osc_buffer; - } - - let mut splits = data.split_terminator(';'); - - let color = match splits.next().unwrap_or("") { - // The response is `4;;rgb://`. - "4" => match splits.next().unwrap_or("").parse::() { - Ok(val) if val < 16 => &mut indexed_colors[val], - _ => continue, - }, - // The response is `10;rgb://`. - "10" => &mut indexed_colors[IndexedColor::Foreground as usize], - // The response is `11;rgb://`. - "11" => &mut indexed_colors[IndexedColor::Background as usize], - _ => continue, - }; - - let color_param = splits.next().unwrap_or(""); - if !color_param.starts_with("rgb:") { - continue; - } - - let mut iter = color_param[4..].split_terminator('/'); - let rgb_parts = [(); 3].map(|_| iter.next().unwrap_or("0")); - let mut rgb = 0; - - for part in rgb_parts { - if part.len() == 2 || part.len() == 4 { - let Ok(mut val) = usize::from_str_radix(part, 16) else { - continue; - }; - if part.len() == 4 { - // Round from 16 bits to 8 bits. - val = (val * 0xff + 0x7fff) / 0xffff; - } - rgb = (rgb >> 8) | ((val as u32) << 16); - } - } - - *color = StraightRgba::from_le(rgb | 0xff000000); - color_responses += 1; - osc_buffer.clear(); + Token::Osc { data, partial } => { + probe_activity = true; + record_probe_osc_response( + &mut osc_buffer, + &mut indexed_colors, + &mut probe, + data, + partial, + ); } + Token::Dcs { .. } => probe_activity = true, _ => {} } } + + if probe_activity || got_input && !vt_parser.is_ground() { + probe.record_activity_after_device_attributes(std::time::Instant::now()); + } } if ambiguous_width == 2 { @@ -678,7 +819,7 @@ fn setup_terminal(tui: &mut Tui, state: &mut State, vt_parser: &mut vt::Parser) state.documents.reflow_all(); } - if color_responses == indexed_colors.len() { + if probe.color_responses() == indexed_colors.len() { tui.setup_indexed_colors(indexed_colors); } @@ -704,3 +845,93 @@ fn sanitize_control_chars(text: &str) -> Cow<'_, str> { Cow::Borrowed(text) } } + +#[cfg(test)] +mod terminal_probe_tests { + use super::*; + + #[test] + fn probe_waits_past_quiet_window_when_parser_has_pending_sequence() { + let start = std::time::Instant::now(); + let hard_deadline = start + TERMINAL_PROBE_HARD_TIMEOUT; + let mut probe = TerminalProbe::new(); + + probe.record_device_attributes(start); + let quiet_deadline = start + TERMINAL_PROBE_QUIET_TIMEOUT; + + assert!(!probe.should_finish(false, quiet_deadline, hard_deadline)); + assert!(probe.should_finish(false, hard_deadline, hard_deadline)); + } + + #[test] + fn probe_finishes_after_quiet_window_when_parser_is_ground() { + let start = std::time::Instant::now(); + let hard_deadline = start + TERMINAL_PROBE_HARD_TIMEOUT; + let mut probe = TerminalProbe::new(); + + probe.record_device_attributes(start); + let quiet_deadline = start + TERMINAL_PROBE_QUIET_TIMEOUT; + + assert!(probe.should_finish(true, quiet_deadline, hard_deadline)); + } + + #[test] + fn probe_complete_fast_path_requires_ground_parser() { + let start = std::time::Instant::now(); + let hard_deadline = start + TERMINAL_PROBE_HARD_TIMEOUT; + let mut probe = TerminalProbe::new(); + + probe.record_device_attributes(start); + probe.record_cursor_position(); + for index in 0..framebuffer::INDEXED_COLORS_COUNT { + probe.record_color(index); + } + + assert!(!probe.should_finish(false, start, hard_deadline)); + assert!(probe.should_finish(true, start, hard_deadline)); + } + + #[test] + fn probe_counts_unique_color_responses() { + let mut probe = TerminalProbe::new(); + + probe.record_color(IndexedColor::Foreground as usize); + probe.record_color(IndexedColor::Foreground as usize); + probe.record_color(IndexedColor::Background as usize); + + assert_eq!(probe.color_responses(), 2); + } + + #[test] + fn probe_osc_buffer_clears_after_invalid_complete_response() { + let mut osc_buffer = String::new(); + let mut indexed_colors = framebuffer::DEFAULT_THEME; + let mut probe = TerminalProbe::new(); + + record_probe_osc_response( + &mut osc_buffer, + &mut indexed_colors, + &mut probe, + "99;ignored", + true, + ); + assert!(!osc_buffer.is_empty()); + + record_probe_osc_response(&mut osc_buffer, &mut indexed_colors, &mut probe, "", false); + assert!(osc_buffer.is_empty()); + + record_probe_osc_response( + &mut osc_buffer, + &mut indexed_colors, + &mut probe, + "10;rgb:0101/0202/0303", + false, + ); + + assert_eq!(probe.color_responses(), 1); + assert_ne!( + indexed_colors[IndexedColor::Foreground as usize], + framebuffer::DEFAULT_THEME[IndexedColor::Foreground as usize] + ); + } +} diff --git a/crates/edit/src/input.rs b/crates/edit/src/input.rs index a0dfd840844..8884e76222d 100644 --- a/crates/edit/src/input.rs +++ b/crates/edit/src/input.rs @@ -592,3 +592,42 @@ impl<'input> Stream<'_, '_, 'input> { Some(Input::Mouse(mouse)) } } + +#[cfg(test)] +mod tests { + use super::*; + + fn parse_resize<'a>( + vt_parser: &mut vt::Parser, + input_parser: &mut Parser, + input: &'a str, + ) -> Option { + input_parser.parse(vt_parser.parse(input)).find_map(|input| match input { + Input::Resize(size) => Some(size), + _ => None, + }) + } + + #[test] + fn partial_osc_parser_state_does_not_emit_resize() { + let mut vt_parser = vt::Parser::new(); + let mut stream = vt_parser.parse("\x1b]4;0;rgb:0c0c/0c0c/0c0c\x1b"); + while stream.next().is_some() {} + drop(stream); + + let mut input_parser = Parser::new(); + let resize = parse_resize(&mut vt_parser, &mut input_parser, "\x1b[8;40;127t"); + + assert_eq!(resize, None); + } + + #[test] + fn fresh_parser_parses_synthetic_resize() { + let mut vt_parser = vt::Parser::new(); + let mut input_parser = Parser::new(); + + let resize = parse_resize(&mut vt_parser, &mut input_parser, "\x1b[8;40;127t"); + + assert_eq!(resize, Some(Size { width: 127, height: 40 })); + } +} diff --git a/crates/edit/src/vt.rs b/crates/edit/src/vt.rs index 451d1a213ea..9e51946d610 100644 --- a/crates/edit/src/vt.rs +++ b/crates/edit/src/vt.rs @@ -80,6 +80,11 @@ impl Parser { } } + /// Returns whether the parser has no unfinished VT sequence pending. + pub fn is_ground(&self) -> bool { + matches!(self.state, State::Ground) + } + /// Suggests a timeout for the next call to `read()`. /// /// We need this because of the ambiguity of whether a trailing @@ -118,6 +123,45 @@ pub struct Stream<'parser, 'input> { off: usize, } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parser_reports_pending_osc_until_st_is_complete() { + let mut parser = Parser::new(); + assert!(parser.is_ground()); + + let mut stream = parser.parse("\x1b]11;rgb:0c0c/0c0c/0c0c\x1b"); + assert!(stream.next().is_some()); + assert!(stream.next().is_none()); + drop(stream); + assert!(!parser.is_ground()); + + let mut stream = parser.parse("\\"); + assert!(matches!(stream.next(), Some(Token::Osc { partial: false, .. }))); + assert!(stream.next().is_none()); + drop(stream); + assert!(parser.is_ground()); + } + + #[test] + fn parser_reports_ground_after_escape_timeout() { + let mut parser = Parser::new(); + + let mut stream = parser.parse("\x1b"); + assert!(stream.next().is_none()); + drop(stream); + assert!(!parser.is_ground()); + + let mut stream = parser.parse(""); + assert!(matches!(stream.next(), Some(Token::Esc('\0')))); + assert!(stream.next().is_none()); + drop(stream); + assert!(parser.is_ground()); + } +} + impl<'input> Stream<'_, 'input> { /// Returns the input that is being parsed. pub fn input(&self) -> &'input str {