Skip to content

Commit c2b9ec1

Browse files
committed
Isolate terminal probe parser
1 parent 737450c commit c2b9ec1

3 files changed

Lines changed: 275 additions & 20 deletions

File tree

crates/edit/src/bin/edit/main.rs

Lines changed: 192 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,12 @@ const SCRATCH_ARENA_CAPACITY: usize = 128 * MEBI;
4040
#[cfg(target_pointer_width = "64")]
4141
const SCRATCH_ARENA_CAPACITY: usize = 512 * MEBI;
4242

43+
// After DA, briefly drain follow-up probe responses so split OSC replies do not leak into
44+
// the main input parser. Keep the window short because real user input is still on stdin.
45+
const TERMINAL_PROBE_QUIET_TIMEOUT: Duration = Duration::from_millis(50);
46+
// Bound terminal probing for terminals that omit DA or leave a control sequence unfinished.
47+
const TERMINAL_PROBE_HARD_TIMEOUT: Duration = Duration::from_secs(3);
48+
4349
// NOTE: Before our main() gets called, Rust initializes its stdlib. This pulls in the entire
4450
// std::io::{stdin, stdout, stderr} machinery, and probably some more, which amounts to about 20KB.
4551
// It can technically be avoided nowadays with `#![no_main]`. Maybe a fun project for later? :)
@@ -87,11 +93,9 @@ fn run() -> apperr::Result<()> {
8793
// As such, we call this after `handle_args`.
8894
sys::switch_modes()?;
8995

90-
let mut vt_parser = vt::Parser::new();
91-
let mut input_parser = input::Parser::new();
9296
let mut tui = Tui::new()?;
9397

94-
let _restore = setup_terminal(&mut tui, &mut state, &mut vt_parser);
98+
let _restore = setup_terminal(&mut tui, &mut state);
9599

96100
state.menubar_color_bg = tui.indexed(IndexedColor::Background).oklab_blend(tui.indexed_alpha(
97101
IndexedColor::BrightBlue,
@@ -115,6 +119,11 @@ fn run() -> apperr::Result<()> {
115119

116120
sys::inject_window_size_into_stdin();
117121

122+
// Startup probing may leave partial terminal responses in the probe parser.
123+
// Start application input parsing with an independent parser lifecycle.
124+
let mut vt_parser = vt::Parser::new();
125+
let mut input_parser = input::Parser::new();
126+
118127
#[cfg(feature = "debug-latency")]
119128
let mut last_latency_width = 0;
120129

@@ -565,7 +574,91 @@ impl Drop for RestoreModes {
565574
}
566575
}
567576

568-
fn setup_terminal(tui: &mut Tui, state: &mut State, vt_parser: &mut vt::Parser) -> RestoreModes {
577+
struct TerminalProbe {
578+
seen_device_attributes: bool,
579+
seen_cursor_position: bool,
580+
seen_colors: [bool; framebuffer::INDEXED_COLORS_COUNT],
581+
color_responses: usize,
582+
quiet_deadline: Option<std::time::Instant>,
583+
}
584+
585+
impl TerminalProbe {
586+
fn new() -> Self {
587+
Self {
588+
seen_device_attributes: false,
589+
seen_cursor_position: false,
590+
seen_colors: [false; framebuffer::INDEXED_COLORS_COUNT],
591+
color_responses: 0,
592+
quiet_deadline: None,
593+
}
594+
}
595+
596+
fn record_device_attributes(&mut self, now: std::time::Instant) {
597+
self.seen_device_attributes = true;
598+
self.quiet_deadline = Some(now + TERMINAL_PROBE_QUIET_TIMEOUT);
599+
}
600+
601+
fn record_activity_after_device_attributes(&mut self, now: std::time::Instant) {
602+
if self.seen_device_attributes {
603+
self.quiet_deadline = Some(now + TERMINAL_PROBE_QUIET_TIMEOUT);
604+
}
605+
}
606+
607+
fn record_cursor_position(&mut self) {
608+
self.seen_cursor_position = true;
609+
}
610+
611+
fn record_color(&mut self, index: usize) {
612+
if !self.seen_colors[index] {
613+
self.seen_colors[index] = true;
614+
self.color_responses += 1;
615+
}
616+
}
617+
618+
fn color_responses(&self) -> usize {
619+
self.color_responses
620+
}
621+
622+
fn is_complete(&self) -> bool {
623+
self.seen_device_attributes
624+
&& self.seen_cursor_position
625+
&& self.color_responses == framebuffer::INDEXED_COLORS_COUNT
626+
}
627+
628+
fn should_finish(
629+
&self,
630+
parser_is_ground: bool,
631+
now: std::time::Instant,
632+
hard_deadline: std::time::Instant,
633+
) -> bool {
634+
if parser_is_ground && self.is_complete() {
635+
return true;
636+
}
637+
638+
if parser_is_ground
639+
&& self.seen_device_attributes
640+
&& self.quiet_deadline.is_some_and(|deadline| now >= deadline)
641+
{
642+
return true;
643+
}
644+
645+
now >= hard_deadline
646+
}
647+
648+
fn next_read_deadline(
649+
&self,
650+
parser_is_ground: bool,
651+
hard_deadline: std::time::Instant,
652+
) -> std::time::Instant {
653+
if parser_is_ground {
654+
self.quiet_deadline.unwrap_or(hard_deadline).min(hard_deadline)
655+
} else {
656+
hard_deadline
657+
}
658+
}
659+
}
660+
661+
fn setup_terminal(tui: &mut Tui, state: &mut State) -> RestoreModes {
569662
sys::write_stdout(concat!(
570663
// 1049: Alternative Screen Buffer
571664
// I put the ASB switch in the beginning, just in case the terminal performs
@@ -592,32 +685,49 @@ fn setup_terminal(tui: &mut Tui, state: &mut State, vt_parser: &mut vt::Parser)
592685
"\x1b[c",
593686
));
594687

595-
let mut done = false;
596688
let mut osc_buffer = String::new();
597689
let mut indexed_colors = framebuffer::DEFAULT_THEME;
598-
let mut color_responses = 0;
599690
let mut ambiguous_width = 1;
691+
let mut probe = TerminalProbe::new();
692+
let mut vt_parser = vt::Parser::new();
693+
let hard_deadline = std::time::Instant::now() + TERMINAL_PROBE_HARD_TIMEOUT;
694+
695+
loop {
696+
let now = std::time::Instant::now();
697+
if probe.should_finish(vt_parser.is_ground(), now, hard_deadline) {
698+
break;
699+
}
600700

601-
while !done {
602701
let scratch = scratch_arena(None);
702+
let mut timeout = probe
703+
.next_read_deadline(vt_parser.is_ground(), hard_deadline)
704+
.saturating_duration_since(now);
705+
timeout = timeout.min(vt_parser.read_timeout());
603706

604-
// We explicitly set a high read timeout, because we're not
605-
// waiting for user keyboard input. If we encounter a lone ESC,
606-
// it's unlikely to be from a ESC keypress, but rather from a VT sequence.
607-
let Some(input) = sys::read_stdin(&scratch, Duration::from_secs(3)) else {
707+
let Some(input) = sys::read_stdin(&scratch, timeout) else {
608708
break;
609709
};
610710

711+
let got_input = !input.is_empty();
712+
let mut probe_activity = false;
611713
let mut vt_stream = vt_parser.parse(&input);
612714
while let Some(token) = vt_stream.next() {
613715
match token {
614716
Token::Csi(csi) => match csi.final_byte {
615-
'c' => done = true,
717+
'c' => {
718+
probe.record_device_attributes(std::time::Instant::now());
719+
probe_activity = true;
720+
}
616721
// CPR (Cursor Position Report) response.
617-
'R' => ambiguous_width = csi.params[1] as CoordType - 1,
722+
'R' => {
723+
probe.record_cursor_position();
724+
ambiguous_width = csi.params[1] as CoordType - 1;
725+
probe_activity = true;
726+
}
618727
_ => {}
619728
},
620729
Token::Osc { mut data, partial } => {
730+
probe_activity = true;
621731
if partial {
622732
osc_buffer.push_str(data);
623733
continue;
@@ -629,16 +739,16 @@ fn setup_terminal(tui: &mut Tui, state: &mut State, vt_parser: &mut vt::Parser)
629739

630740
let mut splits = data.split_terminator(';');
631741

632-
let color = match splits.next().unwrap_or("") {
742+
let color_index = match splits.next().unwrap_or("") {
633743
// The response is `4;<color>;rgb:<r>/<g>/<b>`.
634744
"4" => match splits.next().unwrap_or("").parse::<usize>() {
635-
Ok(val) if val < 16 => &mut indexed_colors[val],
745+
Ok(val) if val < 16 => val,
636746
_ => continue,
637747
},
638748
// The response is `10;rgb:<r>/<g>/<b>`.
639-
"10" => &mut indexed_colors[IndexedColor::Foreground as usize],
749+
"10" => IndexedColor::Foreground as usize,
640750
// The response is `11;rgb:<r>/<g>/<b>`.
641-
"11" => &mut indexed_colors[IndexedColor::Background as usize],
751+
"11" => IndexedColor::Background as usize,
642752
_ => continue,
643753
};
644754

@@ -664,21 +774,26 @@ fn setup_terminal(tui: &mut Tui, state: &mut State, vt_parser: &mut vt::Parser)
664774
}
665775
}
666776

667-
*color = StraightRgba::from_le(rgb | 0xff000000);
668-
color_responses += 1;
777+
indexed_colors[color_index] = StraightRgba::from_le(rgb | 0xff000000);
778+
probe.record_color(color_index);
669779
osc_buffer.clear();
670780
}
781+
Token::Dcs { .. } => probe_activity = true,
671782
_ => {}
672783
}
673784
}
785+
786+
if probe_activity || got_input && !vt_parser.is_ground() {
787+
probe.record_activity_after_device_attributes(std::time::Instant::now());
788+
}
674789
}
675790

676791
if ambiguous_width == 2 {
677792
unicode::setup_ambiguous_width(2);
678793
state.documents.reflow_all();
679794
}
680795

681-
if color_responses == indexed_colors.len() {
796+
if probe.color_responses() == indexed_colors.len() {
682797
tui.setup_indexed_colors(indexed_colors);
683798
}
684799

@@ -704,3 +819,60 @@ fn sanitize_control_chars(text: &str) -> Cow<'_, str> {
704819
Cow::Borrowed(text)
705820
}
706821
}
822+
823+
#[cfg(test)]
824+
mod terminal_probe_tests {
825+
use super::*;
826+
827+
#[test]
828+
fn probe_waits_past_quiet_window_when_parser_has_pending_sequence() {
829+
let start = std::time::Instant::now();
830+
let hard_deadline = start + TERMINAL_PROBE_HARD_TIMEOUT;
831+
let mut probe = TerminalProbe::new();
832+
833+
probe.record_device_attributes(start);
834+
let quiet_deadline = start + TERMINAL_PROBE_QUIET_TIMEOUT;
835+
836+
assert!(!probe.should_finish(false, quiet_deadline, hard_deadline));
837+
assert!(probe.should_finish(false, hard_deadline, hard_deadline));
838+
}
839+
840+
#[test]
841+
fn probe_finishes_after_quiet_window_when_parser_is_ground() {
842+
let start = std::time::Instant::now();
843+
let hard_deadline = start + TERMINAL_PROBE_HARD_TIMEOUT;
844+
let mut probe = TerminalProbe::new();
845+
846+
probe.record_device_attributes(start);
847+
let quiet_deadline = start + TERMINAL_PROBE_QUIET_TIMEOUT;
848+
849+
assert!(probe.should_finish(true, quiet_deadline, hard_deadline));
850+
}
851+
852+
#[test]
853+
fn probe_complete_fast_path_requires_ground_parser() {
854+
let start = std::time::Instant::now();
855+
let hard_deadline = start + TERMINAL_PROBE_HARD_TIMEOUT;
856+
let mut probe = TerminalProbe::new();
857+
858+
probe.record_device_attributes(start);
859+
probe.record_cursor_position();
860+
for index in 0..framebuffer::INDEXED_COLORS_COUNT {
861+
probe.record_color(index);
862+
}
863+
864+
assert!(!probe.should_finish(false, start, hard_deadline));
865+
assert!(probe.should_finish(true, start, hard_deadline));
866+
}
867+
868+
#[test]
869+
fn probe_counts_unique_color_responses() {
870+
let mut probe = TerminalProbe::new();
871+
872+
probe.record_color(IndexedColor::Foreground as usize);
873+
probe.record_color(IndexedColor::Foreground as usize);
874+
probe.record_color(IndexedColor::Background as usize);
875+
876+
assert_eq!(probe.color_responses(), 2);
877+
}
878+
}

crates/edit/src/input.rs

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -592,3 +592,42 @@ impl<'input> Stream<'_, '_, 'input> {
592592
Some(Input::Mouse(mouse))
593593
}
594594
}
595+
596+
#[cfg(test)]
597+
mod tests {
598+
use super::*;
599+
600+
fn parse_resize<'a>(
601+
vt_parser: &mut vt::Parser,
602+
input_parser: &mut Parser,
603+
input: &'a str,
604+
) -> Option<Size> {
605+
input_parser.parse(vt_parser.parse(input)).find_map(|input| match input {
606+
Input::Resize(size) => Some(size),
607+
_ => None,
608+
})
609+
}
610+
611+
#[test]
612+
fn partial_osc_parser_state_does_not_emit_resize() {
613+
let mut vt_parser = vt::Parser::new();
614+
let mut stream = vt_parser.parse("\x1b]4;0;rgb:0c0c/0c0c/0c0c\x1b");
615+
while stream.next().is_some() {}
616+
drop(stream);
617+
618+
let mut input_parser = Parser::new();
619+
let resize = parse_resize(&mut vt_parser, &mut input_parser, "\x1b[8;40;127t");
620+
621+
assert_eq!(resize, None);
622+
}
623+
624+
#[test]
625+
fn fresh_parser_parses_synthetic_resize() {
626+
let mut vt_parser = vt::Parser::new();
627+
let mut input_parser = Parser::new();
628+
629+
let resize = parse_resize(&mut vt_parser, &mut input_parser, "\x1b[8;40;127t");
630+
631+
assert_eq!(resize, Some(Size { width: 127, height: 40 }));
632+
}
633+
}

0 commit comments

Comments
 (0)