1use std::io::Read;
18use std::sync::Arc;
19use std::sync::atomic::{AtomicU64, Ordering};
20use std::time::Duration;
21
22use alacritty_terminal::event::{Event as AlacrittyEvent, EventListener};
23use alacritty_terminal::grid::{Dimensions, Scroll};
24use alacritty_terminal::index::{Column, Line, Point};
25use alacritty_terminal::term::TermMode;
26use alacritty_terminal::term::cell::Flags as AlacrittyFlags;
27use alacritty_terminal::term::{Config as TermConfig, Term};
28use alacritty_terminal::vte::ansi::{
29 Color as AnsiColor, CursorShape as AnsiCursorShape, NamedColor as AnsiNamedColor, Processor,
30};
31use arc_swap::ArcSwap;
32use parking_lot::Mutex;
33
34use crate::cell::{Cell, CellAttrs, CursorShape, NamedColor, TerminalColor};
35use crate::snapshot::TerminalSnapshot;
36
37const REFRESH_WINDOW: Duration = Duration::from_millis(16);
44
45#[derive(Clone, Default)]
59pub(crate) struct PtyResponder {
60 writer: Option<crate::handle::SharedPtyWriter>,
61}
62
63impl PtyResponder {
64 pub(crate) fn noop() -> Self {
66 Self { writer: None }
67 }
68
69 pub(crate) fn responding(writer: crate::handle::SharedPtyWriter) -> Self {
72 Self {
73 writer: Some(writer),
74 }
75 }
76
77 fn respond(&self, bytes: &[u8]) {
78 if let Some(w) = &self.writer {
79 let mut w = w.lock();
80 let _ = w.write_all(bytes);
81 let _ = w.flush();
82 }
83 }
84}
85
86impl EventListener for PtyResponder {
87 fn send_event(&self, event: AlacrittyEvent) {
88 match event {
89 AlacrittyEvent::PtyWrite(text) => self.respond(text.as_bytes()),
92 AlacrittyEvent::ColorRequest(_index, formatter) => {
96 let rgb = alacritty_terminal::vte::ansi::Rgb {
97 r: 0x1e,
98 g: 0x1e,
99 b: 0x1e,
100 };
101 self.respond(formatter(rgb).as_bytes());
102 }
103 _ => {}
105 }
106 }
107}
108
109struct PtyDimensions {
115 rows: u16,
116 cols: u16,
117}
118
119impl Dimensions for PtyDimensions {
120 fn total_lines(&self) -> usize {
121 self.rows as usize
122 }
123
124 fn screen_lines(&self) -> usize {
125 self.rows as usize
126 }
127
128 fn columns(&self) -> usize {
129 self.cols as usize
130 }
131}
132
133pub(crate) fn build_term(rows: u16, cols: u16, scrollback_lines: u32) -> Term<PtyResponder> {
138 build_term_with(rows, cols, scrollback_lines, PtyResponder::noop())
139}
140
141pub(crate) fn build_term_with(
145 rows: u16,
146 cols: u16,
147 scrollback_lines: u32,
148 responder: PtyResponder,
149) -> Term<PtyResponder> {
150 let dims = PtyDimensions { rows, cols };
151 let config = TermConfig {
152 scrolling_history: scrollback_lines as usize,
153 ..TermConfig::default()
154 };
155 Term::new(config, &dims, responder)
156}
157
158fn map_named_color(named: AnsiNamedColor) -> TerminalColor {
165 match named {
166 AnsiNamedColor::Black => TerminalColor::Named(NamedColor::Black),
167 AnsiNamedColor::Red => TerminalColor::Named(NamedColor::Red),
168 AnsiNamedColor::Green => TerminalColor::Named(NamedColor::Green),
169 AnsiNamedColor::Yellow => TerminalColor::Named(NamedColor::Yellow),
170 AnsiNamedColor::Blue => TerminalColor::Named(NamedColor::Blue),
171 AnsiNamedColor::Magenta => TerminalColor::Named(NamedColor::Magenta),
172 AnsiNamedColor::Cyan => TerminalColor::Named(NamedColor::Cyan),
173 AnsiNamedColor::White => TerminalColor::Named(NamedColor::White),
174 AnsiNamedColor::BrightBlack => TerminalColor::Named(NamedColor::BrightBlack),
175 AnsiNamedColor::BrightRed => TerminalColor::Named(NamedColor::BrightRed),
176 AnsiNamedColor::BrightGreen => TerminalColor::Named(NamedColor::BrightGreen),
177 AnsiNamedColor::BrightYellow => TerminalColor::Named(NamedColor::BrightYellow),
178 AnsiNamedColor::BrightBlue => TerminalColor::Named(NamedColor::BrightBlue),
179 AnsiNamedColor::BrightMagenta => TerminalColor::Named(NamedColor::BrightMagenta),
180 AnsiNamedColor::BrightCyan => TerminalColor::Named(NamedColor::BrightCyan),
181 AnsiNamedColor::BrightWhite => TerminalColor::Named(NamedColor::BrightWhite),
182 _ => TerminalColor::Default,
189 }
190}
191
192fn map_color(c: AnsiColor) -> TerminalColor {
193 match c {
194 AnsiColor::Named(n) => map_named_color(n),
195 AnsiColor::Indexed(idx) => TerminalColor::Indexed(idx),
196 AnsiColor::Spec(rgb) => TerminalColor::Rgb(rgb.r, rgb.g, rgb.b),
197 }
198}
199
200fn map_flags(flags: AlacrittyFlags) -> CellAttrs {
201 CellAttrs {
207 bold: flags.contains(AlacrittyFlags::BOLD),
208 italic: flags.contains(AlacrittyFlags::ITALIC),
209 underline: flags.contains(AlacrittyFlags::UNDERLINE),
210 reverse: flags.contains(AlacrittyFlags::INVERSE),
211 dim: flags.contains(AlacrittyFlags::DIM),
212 strikethrough: flags.contains(AlacrittyFlags::STRIKEOUT),
213 blink: false,
214 }
215}
216
217fn map_cell(a: &alacritty_terminal::term::cell::Cell) -> Cell {
218 Cell {
219 ch: a.c,
220 fg: map_color(a.fg),
221 bg: map_color(a.bg),
222 attrs: map_flags(a.flags),
223 wide_spacer: a.flags.contains(AlacrittyFlags::WIDE_CHAR_SPACER)
230 || a.flags.contains(AlacrittyFlags::LEADING_WIDE_CHAR_SPACER),
231 }
232}
233
234fn map_cursor_shape(s: AnsiCursorShape) -> CursorShape {
235 match s {
236 AnsiCursorShape::Block | AnsiCursorShape::HollowBlock => CursorShape::Block,
237 AnsiCursorShape::Underline => CursorShape::Underline,
238 AnsiCursorShape::Beam => CursorShape::Bar,
239 AnsiCursorShape::Hidden => CursorShape::Hidden,
240 }
241}
242
243pub(crate) fn term_to_snapshot<T: EventListener>(term: &Term<T>, seq: u64) -> TerminalSnapshot {
250 let grid = term.grid();
251 let rows = grid.screen_lines();
252 let cols = grid.columns();
253 let display_offset = grid.display_offset();
254 let scrollback_max = grid.history_size();
255 let mut cells = Vec::with_capacity(rows * cols);
256 let top_line = -(display_offset as i32);
263 for r in 0..rows {
264 for c in 0..cols {
265 let point = Point::new(Line(top_line + r as i32), Column(c));
266 cells.push(map_cell(&grid[point]));
267 }
268 }
269 let cursor_point = grid.cursor.point;
274 let live_cursor_row = cursor_point.line.0;
275 let shifted_cursor_row = live_cursor_row + display_offset as i32;
276 let cursor_visible_in_view = (0..rows as i32).contains(&shifted_cursor_row);
277 let cursor_row = shifted_cursor_row.max(0) as u16;
278 let cursor_col = cursor_point.column.0 as u16;
279 let mode = term.mode();
280 TerminalSnapshot {
281 cells: cells.into(),
282 rows: rows.min(u16::MAX as usize) as u16,
283 cols: cols.min(u16::MAX as usize) as u16,
284 cursor_row,
285 cursor_col,
286 cursor_visible: mode.contains(TermMode::SHOW_CURSOR) && cursor_visible_in_view,
292 cursor_shape: map_cursor_shape(term.cursor_style().shape),
293 title: None,
300 alt_screen: mode.contains(TermMode::ALT_SCREEN),
301 scroll_offset: display_offset.min(u32::MAX as usize) as u32,
302 scrollback_rows: scrollback_max.min(u32::MAX as usize) as u32,
303 seq,
304 }
305}
306
307#[derive(Debug, Clone, Copy)]
312pub enum TerminalScrollKind {
313 Delta(i32),
316 PageUp,
317 PageDown,
318 Top,
319 Bottom,
320}
321
322fn map_scroll(kind: TerminalScrollKind) -> Scroll {
323 match kind {
324 TerminalScrollKind::Delta(n) => Scroll::Delta(n),
325 TerminalScrollKind::PageUp => Scroll::PageUp,
326 TerminalScrollKind::PageDown => Scroll::PageDown,
327 TerminalScrollKind::Top => Scroll::Top,
328 TerminalScrollKind::Bottom => Scroll::Bottom,
329 }
330}
331
332#[derive(Clone)]
345pub struct SharedTerm {
346 pub(crate) inner: Arc<Mutex<Term<PtyResponder>>>,
347 snapshot: Arc<ArcSwap<TerminalSnapshot>>,
348 pub(crate) seq: Arc<AtomicU64>,
349 paint_request: Option<Arc<tokio::sync::Notify>>,
350}
351
352impl std::fmt::Debug for SharedTerm {
353 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
354 f.debug_struct("SharedTerm").finish_non_exhaustive()
355 }
356}
357
358#[derive(Debug, Clone, Copy, PartialEq, Eq)]
362pub struct GridSearchHit {
363 pub line: i32,
366 pub column: u16,
368 pub len: u32,
371}
372
373#[derive(Debug, Clone, Copy, PartialEq, Eq)]
377pub enum SearchDir {
378 Forward,
379 Backward,
380}
381
382impl SharedTerm {
383 pub(crate) fn from_state(
391 inner: Arc<Mutex<Term<PtyResponder>>>,
392 snapshot: Arc<ArcSwap<TerminalSnapshot>>,
393 seq: Arc<AtomicU64>,
394 ) -> Self {
395 Self {
396 inner,
397 snapshot,
398 seq,
399 paint_request: None,
400 }
401 }
402
403 pub fn fixture(rows: u16, cols: u16, scrollback: u32) -> Self {
408 let term = Arc::new(Mutex::new(build_term(rows, cols, scrollback)));
409 let snapshot = Arc::new(ArcSwap::from_pointee(TerminalSnapshot::empty()));
410 let seq = Arc::new(AtomicU64::new(0));
411 Self::from_state(term, snapshot, seq)
412 }
413
414 pub fn feed_for_fixture(&self, bytes: &[u8]) {
421 let mut processor: alacritty_terminal::vte::ansi::Processor =
422 alacritty_terminal::vte::ansi::Processor::new();
423 let mut t = self.inner.lock();
424 processor.advance(&mut *t, bytes);
425 }
426
427 pub fn scroll(&self, kind: TerminalScrollKind) {
431 let mut term = self.inner.lock();
432 term.scroll_display(map_scroll(kind));
433 let seq = self.seq.fetch_add(1, Ordering::Relaxed) + 1;
434 let snap = term_to_snapshot(&*term, seq);
435 self.snapshot.store(Arc::new(snap));
436 if let Some(n) = &self.paint_request {
437 n.notify_one();
438 }
439 }
440
441 pub fn bracketed_paste(&self) -> bool {
451 self.inner.lock().mode().contains(TermMode::BRACKETED_PASTE)
452 }
453
454 pub fn find_match(
464 &self,
465 regex: &fancy_regex::Regex,
466 direction: SearchDir,
467 ) -> Option<GridSearchHit> {
468 let term = self.inner.lock();
469 let grid = term.grid();
470 let topmost = grid.topmost_line().0;
471 let bottommost = grid.bottommost_line().0;
472 let cols = grid.columns();
473 let mut lines: Vec<i32> = (topmost..=bottommost).collect();
474 if matches!(direction, SearchDir::Backward) {
475 lines.reverse();
476 }
477 for line_idx in lines {
478 let mut row_text = String::with_capacity(cols);
479 for c in 0..cols {
480 let p = Point::new(Line(line_idx), Column(c));
481 row_text.push(grid[p].c);
482 }
483 let trimmed = row_text.trim_end_matches(' ');
487 if trimmed.is_empty() {
488 continue;
489 }
490 if let Ok(Some(m)) = regex.find(trimmed) {
491 let column = trimmed[..m.start()].chars().count() as u16;
492 let len = m.as_str().chars().count() as u32;
493 return Some(GridSearchHit {
494 line: line_idx,
495 column,
496 len,
497 });
498 }
499 }
500 None
501 }
502
503 pub fn line_range_text(&self, start_line: i32, end_line: i32) -> String {
515 let term = self.inner.lock();
516 let grid = term.grid();
517 let topmost = grid.topmost_line().0;
518 let bottommost = grid.bottommost_line().0;
519 let lo = start_line.max(topmost);
520 let hi = end_line.min(bottommost);
521 if lo > hi {
522 return String::new();
523 }
524 let cols = grid.columns();
525 let mut out = String::with_capacity(((hi - lo + 1) as usize) * (cols + 1));
526 for line_idx in lo..=hi {
527 let mut row_text = String::with_capacity(cols);
528 for c in 0..cols {
529 let p = Point::new(Line(line_idx), Column(c));
530 row_text.push(grid[p].c);
531 }
532 out.push_str(row_text.trim_end_matches(' '));
535 out.push('\n');
536 }
537 out
538 }
539
540 pub fn block_range_text(
549 &self,
550 start_line: i32,
551 end_line: i32,
552 start_col: u16,
553 end_col: u16,
554 ) -> String {
555 let term = self.inner.lock();
556 let grid = term.grid();
557 let topmost = grid.topmost_line().0;
558 let bottommost = grid.bottommost_line().0;
559 let lo = start_line.max(topmost);
560 let hi = end_line.min(bottommost);
561 if lo > hi {
562 return String::new();
563 }
564 let cols = grid.columns();
565 let lo_col = (start_col as usize).min(cols);
566 let hi_col = (end_col as usize + 1).min(cols);
567 if lo_col >= hi_col {
568 return String::new();
569 }
570 let mut out = String::with_capacity(((hi - lo + 1) as usize) * (hi_col - lo_col + 1));
571 for line_idx in lo..=hi {
572 for c in lo_col..hi_col {
573 let p = Point::new(Line(line_idx), Column(c));
574 out.push(grid[p].c);
575 }
576 out.push('\n');
577 }
578 out
579 }
580
581 pub fn char_range_text(
590 &self,
591 start_line: i32,
592 start_col: u16,
593 end_line: i32,
594 end_col: u16,
595 ) -> String {
596 let term = self.inner.lock();
597 let grid = term.grid();
598 let topmost = grid.topmost_line().0;
599 let bottommost = grid.bottommost_line().0;
600 let s_line = start_line.max(topmost);
601 let e_line = end_line.min(bottommost);
602 if s_line > e_line {
603 return String::new();
604 }
605 let cols = grid.columns();
606 let mut out = String::new();
607 for line_idx in s_line..=e_line {
608 let row_lo = if line_idx == s_line {
609 start_col as usize
610 } else {
611 0
612 };
613 let row_hi = if line_idx == e_line {
614 (end_col as usize + 1).min(cols)
615 } else {
616 cols
617 };
618 let mut row_text = String::with_capacity(row_hi.saturating_sub(row_lo));
619 for c in row_lo..row_hi {
620 let p = Point::new(Line(line_idx), Column(c));
621 row_text.push(grid[p].c);
622 }
623 if line_idx == e_line {
624 out.push_str(&row_text);
627 } else {
628 out.push_str(row_text.trim_end_matches(' '));
632 out.push('\n');
633 }
634 }
635 out
636 }
637
638 pub fn cursor_line(&self) -> i32 {
642 let term = self.inner.lock();
643 term.grid().cursor.point.line.0
644 }
645
646 pub fn line_bounds(&self) -> (i32, i32) {
650 let term = self.inner.lock();
651 let grid = term.grid();
652 (grid.topmost_line().0, grid.bottommost_line().0)
653 }
654
655 pub fn cursor_keys_application_mode(&self) -> bool {
663 let term = self.inner.lock();
664 term.mode().contains(TermMode::APP_CURSOR)
665 }
666
667 pub fn resize(&self, rows: u16, cols: u16) {
673 if rows == 0 || cols == 0 {
674 return;
675 }
676 let mut term = self.inner.lock();
677 let cur_rows = term.grid().screen_lines();
678 let cur_cols = term.grid().columns();
679 if cur_rows == rows as usize && cur_cols == cols as usize {
680 return;
681 }
682 struct Dims(u16, u16);
683 impl Dimensions for Dims {
684 fn total_lines(&self) -> usize {
685 self.0 as usize
686 }
687 fn screen_lines(&self) -> usize {
688 self.0 as usize
689 }
690 fn columns(&self) -> usize {
691 self.1 as usize
692 }
693 }
694 term.resize(Dims(rows, cols));
695 let seq = self.seq.fetch_add(1, Ordering::Relaxed) + 1;
696 let snap = term_to_snapshot(&*term, seq);
697 self.snapshot.store(Arc::new(snap));
698 if let Some(n) = &self.paint_request {
699 n.notify_one();
700 }
701 }
702
703 pub fn find_all_matches(&self, regex: &fancy_regex::Regex) -> Vec<GridSearchHit> {
710 const CAP: usize = 1024;
711 let term = self.inner.lock();
712 let grid = term.grid();
713 let topmost = grid.topmost_line().0;
714 let bottommost = grid.bottommost_line().0;
715 let cols = grid.columns();
716 let mut out: Vec<GridSearchHit> = Vec::new();
717 for line_idx in topmost..=bottommost {
718 let mut row_text = String::with_capacity(cols);
719 for c in 0..cols {
720 let p = Point::new(Line(line_idx), Column(c));
721 row_text.push(grid[p].c);
722 }
723 let trimmed = row_text.trim_end_matches(' ');
724 if trimmed.is_empty() {
725 continue;
726 }
727 let mut from = 0usize;
729 while from <= trimmed.len() {
730 let slice = &trimmed[from..];
731 match regex.find(slice) {
732 Ok(Some(m)) => {
733 let abs_start = from + m.start();
734 let column = trimmed[..abs_start].chars().count() as u16;
736 let len = m.as_str().chars().count() as u32;
737 out.push(GridSearchHit {
738 line: line_idx,
739 column,
740 len,
741 });
742 if out.len() >= CAP {
743 return out;
744 }
745 let consumed = m.range().len().max(1);
748 from = from + m.start() + consumed;
749 }
750 _ => break,
751 }
752 }
753 }
754 out
755 }
756
757 pub fn scroll_to_line(&self, target: i32) {
763 let mut term = self.inner.lock();
764 let current_offset = term.grid().display_offset() as i32;
765 let desired_offset = (-target).max(0);
766 let delta = desired_offset - current_offset;
767 if delta != 0 {
768 term.scroll_display(Scroll::Delta(delta));
769 }
770 let seq = self.seq.fetch_add(1, Ordering::Relaxed) + 1;
771 let snap = term_to_snapshot(&*term, seq);
772 self.snapshot.store(Arc::new(snap));
773 if let Some(n) = &self.paint_request {
774 n.notify_one();
775 }
776 }
777}
778
779pub fn spawn_reader(
798 mut reader: Box<dyn Read + Send>,
799 snapshot: Arc<ArcSwap<TerminalSnapshot>>,
800 rows: u16,
801 cols: u16,
802 scrollback_lines: u32,
803 paint_request: Option<Arc<tokio::sync::Notify>>,
804 writer: crate::handle::SharedPtyWriter,
805) -> SharedTerm {
806 tracing::info!(
807 target: "lattice_terminal::reader",
808 rows, cols, scrollback_lines,
809 "spawn_reader: spawning detached OS thread",
810 );
811 let term = Arc::new(Mutex::new(build_term_with(
815 rows,
816 cols,
817 scrollback_lines,
818 PtyResponder::responding(writer),
819 )));
820 let seq = Arc::new(AtomicU64::new(0));
821 let shared = SharedTerm {
822 inner: Arc::clone(&term),
823 snapshot: Arc::clone(&snapshot),
824 seq: Arc::clone(&seq),
825 paint_request: paint_request.clone(),
826 };
827 let _ = std::thread::Builder::new()
828 .name("lattice-pty-reader".to_string())
829 .spawn(move || {
830 tracing::info!(
831 target: "lattice_terminal::reader",
832 "reader task entered; waiting for first read",
833 );
834 let mut processor: Processor = Processor::new();
835 let mut buf = [0u8; 32 * 1024];
836 let mut total_bytes: u64 = 0;
837 loop {
838 let n = match reader.read(&mut buf) {
839 Ok(0) => {
840 tracing::info!(
841 target: "lattice_terminal::reader",
842 total_bytes,
843 seq = seq.load(Ordering::Relaxed),
844 "reader: EOF (child exited)",
845 );
846 break;
847 }
848 Ok(n) => n,
849 Err(e) => {
850 tracing::warn!(
851 target: "lattice_terminal::reader",
852 error = %e, total_bytes,
853 "pty read error",
854 );
855 break;
856 }
857 };
858 total_bytes += n as u64;
859 if total_bytes <= 256 {
860 tracing::info!(
861 target: "lattice_terminal::reader",
862 n, total_bytes,
863 "reader: read bytes",
864 );
865 }
866 let snap = {
872 let mut term = term.lock();
873 processor.advance(&mut *term, &buf[..n]);
874 let s = seq.fetch_add(1, Ordering::Relaxed) + 1;
875 term_to_snapshot(&*term, s)
876 };
877 snapshot.store(Arc::new(snap));
878 if let Some(n) = paint_request.as_ref() {
879 n.notify_one();
883 }
884 std::thread::sleep(REFRESH_WINDOW);
886 }
887 let snap = {
890 let term = term.lock();
891 let s = seq.fetch_add(1, Ordering::Relaxed) + 1;
892 term_to_snapshot(&*term, s)
893 };
894 snapshot.store(Arc::new(snap));
895 if let Some(n) = paint_request.as_ref() {
896 n.notify_one();
897 }
898 });
899 shared
900}
901
902#[cfg(test)]
903mod tests {
904 use super::*;
905
906 struct SharedVecWriter(Arc<Mutex<Vec<u8>>>);
909 impl std::io::Write for SharedVecWriter {
910 fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
911 self.0.lock().extend_from_slice(buf);
912 Ok(buf.len())
913 }
914 fn flush(&mut self) -> std::io::Result<()> {
915 Ok(())
916 }
917 }
918
919 #[test]
924 fn responder_answers_dsr_cursor_position_query() {
925 let captured = Arc::new(Mutex::new(Vec::<u8>::new()));
926 let writer: crate::handle::SharedPtyWriter =
927 Arc::new(Mutex::new(Box::new(SharedVecWriter(captured.clone()))));
928 let mut term = build_term_with(24, 80, 0, PtyResponder::responding(writer));
929 let mut processor: Processor = Processor::new();
930
931 processor.advance(&mut term, b"\x1b[6n");
933
934 let out = captured.lock().clone();
935 assert!(!out.is_empty(), "responder wrote no DSR reply");
937 assert!(out.starts_with(b"\x1b["), "not a CSI reply: {out:?}");
938 assert_eq!(
939 out.last(),
940 Some(&b'R'),
941 "DSR reply must end in 'R': {out:?}"
942 );
943 }
944
945 #[test]
948 fn noop_responder_writes_nothing() {
949 let mut term = build_term(24, 80, 0); let mut processor: Processor = Processor::new();
951 processor.advance(&mut term, b"\x1b[6n");
953 }
954
955 fn run(bytes: &[u8], rows: u16, cols: u16) -> TerminalSnapshot {
959 run_with_scrollback(bytes, rows, cols, 0)
960 }
961
962 fn run_with_scrollback(
963 bytes: &[u8],
964 rows: u16,
965 cols: u16,
966 scrollback: u32,
967 ) -> TerminalSnapshot {
968 let mut term = build_term(rows, cols, scrollback);
969 let mut processor: Processor = Processor::new();
970 processor.advance(&mut term, bytes);
971 term_to_snapshot(&term, 1)
972 }
973
974 #[test]
979 fn bracketed_paste_defaults_to_disabled() {
980 let term = SharedTerm::fixture(3, 10, 0);
981 assert!(!term.bracketed_paste());
982 }
983
984 #[test]
985 fn bracketed_paste_tracks_dec_private_mode_2004() {
986 let term = SharedTerm::fixture(3, 10, 0);
987 term.feed_for_fixture(b"\x1b[?2004h");
989 assert!(term.bracketed_paste());
990 term.feed_for_fixture(b"\x1b[?2004l");
992 assert!(!term.bracketed_paste());
993 }
994
995 #[test]
996 fn plain_ascii_lands_in_cells() {
997 let s = run(b"hi", 3, 10);
998 assert_eq!(s.cell_at(0, 0).ch, 'h');
999 assert_eq!(s.cell_at(0, 1).ch, 'i');
1000 assert_eq!(s.cursor_col, 2);
1001 assert_eq!(s.cursor_row, 0);
1002 }
1003
1004 #[test]
1005 fn newline_advances_row() {
1006 let s = run(b"a\r\nb", 3, 10);
1007 assert_eq!(s.cell_at(0, 0).ch, 'a');
1008 assert_eq!(s.cell_at(1, 0).ch, 'b');
1009 assert_eq!(s.cursor_row, 1);
1010 assert_eq!(s.cursor_col, 1);
1011 }
1012
1013 #[test]
1019 fn erase_in_line_clears_to_end_of_line() {
1020 let s = run(b"cargo test\r\x1b[K", 2, 20);
1021 for c in 0..20 {
1022 assert_eq!(
1023 s.cell_at(0, c).ch,
1024 ' ',
1025 "col {c} should be cleared after \\r ESC[K",
1026 );
1027 }
1028 assert_eq!(s.cursor_col, 0);
1029 assert_eq!(s.cursor_row, 0);
1030 }
1031
1032 #[test]
1033 fn sgr_colors_apply_to_following_cells() {
1034 let s = run(b"\x1b[31mhello\x1b[0m world", 2, 20);
1038 for c in 0..5 {
1039 let cell = s.cell_at(0, c);
1040 assert_eq!(
1041 cell.fg,
1042 TerminalColor::Named(NamedColor::Red),
1043 "cell `{}` at col {c} should be red",
1044 cell.ch,
1045 );
1046 }
1047 assert_eq!(s.cell_at(0, 5).fg, TerminalColor::Default);
1049 assert_eq!(s.cell_at(0, 6).fg, TerminalColor::Default);
1050 assert_eq!(s.cell_at(0, 6).ch, 'w');
1051 }
1052
1053 #[test]
1054 fn truecolor_sgr_lands_as_rgb_cell() {
1055 let s = run(b"\x1b[38;2;100;150;200mhi", 2, 5);
1057 assert_eq!(s.cell_at(0, 0).fg, TerminalColor::Rgb(100, 150, 200));
1058 assert_eq!(s.cell_at(0, 1).fg, TerminalColor::Rgb(100, 150, 200));
1059 }
1060
1061 #[test]
1062 fn bold_attribute_survives_the_swap() {
1063 let s = run(b"\x1b[1mbold\x1b[22m", 2, 10);
1064 for c in 0..4 {
1065 assert!(s.cell_at(0, c).attrs.bold, "col {c} should be bold");
1066 }
1067 }
1068
1069 #[test]
1070 fn wide_glyph_marks_trailing_spacer_cell() {
1071 let s = run("🚀x".as_bytes(), 2, 10);
1078 assert_eq!(s.cell_at(0, 0).ch, '🚀');
1079 assert!(
1080 !s.cell_at(0, 0).wide_spacer,
1081 "the glyph cell itself is not a spacer",
1082 );
1083 assert!(
1084 s.cell_at(0, 1).wide_spacer,
1085 "the cell after a wide glyph must be flagged as a spacer",
1086 );
1087 assert_eq!(
1088 s.cell_at(0, 2).ch,
1089 'x',
1090 "content resumes in the third column, past the spacer",
1091 );
1092 assert!(!s.cell_at(0, 2).wide_spacer);
1093 }
1094
1095 #[test]
1096 fn cursor_visible_defaults_true_and_hides_on_civis() {
1097 let visible = run(b"hi", 2, 5);
1098 assert!(visible.cursor_visible);
1099 let hidden = run(b"\x1b[?25lhi", 2, 5);
1101 assert!(!hidden.cursor_visible);
1102 }
1103
1104 #[test]
1105 fn alt_screen_flag_flips_on_smcup() {
1106 let s = run(b"\x1b[?1049h", 2, 5);
1108 assert!(s.alt_screen);
1109 }
1110
1111 #[test]
1114 fn scrollback_disabled_when_lines_zero() {
1115 let s = run_with_scrollback(b"", 4, 10, 0);
1116 assert_eq!(s.scrollback_rows, 0);
1117 assert_eq!(s.scroll_offset, 0);
1118 }
1119
1120 #[test]
1121 fn scrollback_rows_grow_as_history_accumulates() {
1122 let s = run_with_scrollback(b"r0\r\nr1\r\nr2\r\nr3\r\nr4\r\nr5", 3, 10, 32);
1126 assert_eq!(s.scroll_offset, 0);
1127 assert!(
1131 s.scrollback_rows >= 3,
1132 "expected ≥3 scrollback rows, got {}",
1133 s.scrollback_rows,
1134 );
1135 }
1136
1137 #[test]
1138 fn snapshot_window_shifts_when_term_scrolled_back() {
1139 let mut term = build_term(3, 10, 16);
1143 let mut processor: Processor = Processor::new();
1144 let burst = b"r0\r\nr1\r\nr2\r\nr3\r\nr4\r\nr5\r\nr6\r\nr7";
1145 processor.advance(&mut term, burst);
1146 let live = term_to_snapshot(&term, 1);
1148 assert_eq!(live.scroll_offset, 0);
1149 assert_eq!(live.cell_at(0, 0).ch, 'r');
1150 assert_eq!(live.cell_at(0, 1).ch, '5');
1151 assert_eq!(live.cell_at(2, 1).ch, '7');
1152 term.scroll_display(Scroll::Delta(2));
1154 let scrolled = term_to_snapshot(&term, 2);
1155 assert_eq!(scrolled.scroll_offset, 2);
1156 assert_eq!(scrolled.cell_at(0, 0).ch, 'r');
1158 assert_eq!(scrolled.cell_at(0, 1).ch, '3');
1159 assert_eq!(scrolled.cell_at(2, 1).ch, '5');
1160 }
1161
1162 #[test]
1163 fn cursor_hidden_when_scrolled_past_live_cursor() {
1164 let mut term = build_term(3, 10, 16);
1167 let mut processor: Processor = Processor::new();
1168 processor.advance(&mut term, b"a\r\nb\r\nc\r\nd\r\ne\r\nf\r\ng");
1169 let live = term_to_snapshot(&term, 1);
1170 assert!(live.cursor_visible);
1171 term.scroll_display(Scroll::Top);
1172 let top = term_to_snapshot(&term, 2);
1173 assert!(top.scroll_offset > 0);
1174 assert!(
1175 !top.cursor_visible,
1176 "cursor should be hidden when scrolled past it",
1177 );
1178 }
1179
1180 #[test]
1186 fn scroll_bottom_snaps_back_to_live_edge() {
1187 let term = Arc::new(Mutex::new(build_term(3, 10, 16)));
1188 let snapshot = Arc::new(ArcSwap::from_pointee(TerminalSnapshot::empty()));
1189 let seq = Arc::new(AtomicU64::new(0));
1190 let shared = SharedTerm {
1191 inner: Arc::clone(&term),
1192 snapshot: Arc::clone(&snapshot),
1193 seq: Arc::clone(&seq),
1194 paint_request: None,
1195 };
1196 {
1197 let mut t = term.lock();
1198 let mut p: Processor = Processor::new();
1199 p.advance(&mut *t, b"r0\r\nr1\r\nr2\r\nr3\r\nr4\r\nr5\r\nr6");
1200 }
1201 shared.scroll(TerminalScrollKind::Top);
1203 assert!(
1204 snapshot.load().scroll_offset > 0,
1205 "expected non-zero scroll_offset after Top",
1206 );
1207 shared.scroll(TerminalScrollKind::Bottom);
1210 assert_eq!(
1211 snapshot.load().scroll_offset,
1212 0,
1213 "Bottom should reset the viewport to the live edge",
1214 );
1215 }
1216
1217 #[test]
1222 fn find_match_forward_returns_oldest_hit() {
1223 let mut term = build_term(3, 20, 16);
1224 let mut processor: Processor = Processor::new();
1225 processor.advance(
1228 &mut term,
1229 b"needle one\r\nrow1\r\nrow2\r\nrow3\r\nneedle two\r\nrow5",
1230 );
1231 let snapshot = Arc::new(ArcSwap::from_pointee(TerminalSnapshot::empty()));
1232 let seq = Arc::new(AtomicU64::new(0));
1233 let shared = SharedTerm {
1234 inner: Arc::new(Mutex::new(term)),
1235 snapshot,
1236 seq,
1237 paint_request: None,
1238 };
1239 let regex = fancy_regex::Regex::new("needle").unwrap();
1240 let hit = shared.find_match(®ex, SearchDir::Forward).unwrap();
1241 assert!(
1246 hit.line < 0,
1247 "forward search should find scrollback first, got line {}",
1248 hit.line,
1249 );
1250 assert_eq!(hit.column, 0);
1251 assert_eq!(hit.len, 6);
1252 }
1253
1254 #[test]
1257 fn find_match_backward_returns_newest_hit() {
1258 let mut term = build_term(3, 20, 16);
1259 let mut processor: Processor = Processor::new();
1260 processor.advance(
1261 &mut term,
1262 b"needle one\r\nrow1\r\nrow2\r\nrow3\r\nneedle two\r\nrow5",
1263 );
1264 let snapshot = Arc::new(ArcSwap::from_pointee(TerminalSnapshot::empty()));
1265 let seq = Arc::new(AtomicU64::new(0));
1266 let shared = SharedTerm {
1267 inner: Arc::new(Mutex::new(term)),
1268 snapshot,
1269 seq,
1270 paint_request: None,
1271 };
1272 let regex = fancy_regex::Regex::new("needle").unwrap();
1273 let hit = shared.find_match(®ex, SearchDir::Backward).unwrap();
1274 assert!(
1278 hit.line >= 0,
1279 "backward search should find live-edge match first, got line {}",
1280 hit.line,
1281 );
1282 }
1283
1284 #[test]
1288 fn scroll_to_line_moves_viewport_to_target() {
1289 let mut term = build_term(3, 10, 16);
1290 let mut processor: Processor = Processor::new();
1291 processor.advance(&mut term, b"r0\r\nr1\r\nr2\r\nr3\r\nr4\r\nr5");
1292 let snapshot = Arc::new(ArcSwap::from_pointee(TerminalSnapshot::empty()));
1293 let seq = Arc::new(AtomicU64::new(0));
1294 let shared = SharedTerm {
1295 inner: Arc::new(Mutex::new(term)),
1296 snapshot: Arc::clone(&snapshot),
1297 seq,
1298 paint_request: None,
1299 };
1300 shared.scroll_to_line(-3);
1302 let snap = snapshot.load();
1303 assert_eq!(snap.scroll_offset, 3);
1304 }
1305
1306 #[test]
1312 fn line_range_text_extracts_trimmed_rows() {
1313 let mut term = build_term(3, 20, 16);
1314 let mut processor: Processor = Processor::new();
1315 processor.advance(&mut term, b"first line\r\nsecond line\r\nthird line");
1316 let snapshot = Arc::new(ArcSwap::from_pointee(TerminalSnapshot::empty()));
1317 let seq = Arc::new(AtomicU64::new(0));
1318 let shared = SharedTerm {
1319 inner: Arc::new(Mutex::new(term)),
1320 snapshot,
1321 seq,
1322 paint_request: None,
1323 };
1324 let text = shared.line_range_text(0, 2);
1326 assert_eq!(text, "first line\nsecond line\nthird line\n");
1327 }
1328
1329 #[test]
1334 fn char_range_text_spans_two_rows() {
1335 let mut term = build_term(3, 20, 16);
1336 let mut processor: Processor = Processor::new();
1337 processor.advance(&mut term, b"hello world\r\nfoo bar baz");
1338 let snapshot = Arc::new(ArcSwap::from_pointee(TerminalSnapshot::empty()));
1339 let seq = Arc::new(AtomicU64::new(0));
1340 let shared = SharedTerm {
1341 inner: Arc::new(Mutex::new(term)),
1342 snapshot,
1343 seq,
1344 paint_request: None,
1345 };
1346 let text = shared.char_range_text(0, 6, 1, 2);
1348 assert_eq!(text, "world\nfoo");
1349 }
1350
1351 #[test]
1355 fn char_range_text_single_row_inclusive() {
1356 let mut term = build_term(2, 20, 4);
1357 let mut processor: Processor = Processor::new();
1358 processor.advance(&mut term, b"hello world");
1359 let snapshot = Arc::new(ArcSwap::from_pointee(TerminalSnapshot::empty()));
1360 let seq = Arc::new(AtomicU64::new(0));
1361 let shared = SharedTerm {
1362 inner: Arc::new(Mutex::new(term)),
1363 snapshot,
1364 seq,
1365 paint_request: None,
1366 };
1367 let text = shared.char_range_text(0, 6, 0, 10);
1369 assert_eq!(text, "world");
1370 }
1371
1372 #[test]
1377 fn block_range_text_extracts_rectangle() {
1378 let mut term = build_term(3, 20, 4);
1379 let mut processor: Processor = Processor::new();
1380 processor.advance(&mut term, b"abc def ghi\r\njkl mno pqr\r\nstu vwx yz1");
1381 let snapshot = Arc::new(ArcSwap::from_pointee(TerminalSnapshot::empty()));
1382 let seq = Arc::new(AtomicU64::new(0));
1383 let shared = SharedTerm {
1384 inner: Arc::new(Mutex::new(term)),
1385 snapshot,
1386 seq,
1387 paint_request: None,
1388 };
1389 let text = shared.block_range_text(0, 2, 4, 6);
1391 assert_eq!(text, "def\nmno\nvwx\n");
1392 }
1393
1394 #[test]
1399 fn find_all_matches_collects_every_occurrence() {
1400 let mut term = build_term(3, 30, 16);
1401 let mut processor: Processor = Processor::new();
1402 processor.advance(
1403 &mut term,
1404 b"error: one\r\nok\r\nerror: two\r\nerror: three\r\nok",
1405 );
1406 let snapshot = Arc::new(ArcSwap::from_pointee(TerminalSnapshot::empty()));
1407 let seq = Arc::new(AtomicU64::new(0));
1408 let shared = SharedTerm {
1409 inner: Arc::new(Mutex::new(term)),
1410 snapshot,
1411 seq,
1412 paint_request: None,
1413 };
1414 let regex = fancy_regex::Regex::new("error").unwrap();
1415 let hits = shared.find_all_matches(®ex);
1416 assert_eq!(hits.len(), 3, "expected 3 `error` hits, got {hits:?}");
1417 for h in &hits {
1418 assert_eq!(h.len, 5);
1419 assert_eq!(h.column, 0);
1420 }
1421 }
1422
1423 #[test]
1427 fn line_range_text_clamps_out_of_bounds() {
1428 let term = build_term(3, 10, 16);
1429 let snapshot = Arc::new(ArcSwap::from_pointee(TerminalSnapshot::empty()));
1430 let seq = Arc::new(AtomicU64::new(0));
1431 let shared = SharedTerm {
1432 inner: Arc::new(Mutex::new(term)),
1433 snapshot,
1434 seq,
1435 paint_request: None,
1436 };
1437 assert_eq!(shared.line_range_text(9999, 10000), "");
1439 assert_eq!(shared.line_range_text(2, 1), "");
1441 }
1442
1443 #[test]
1446 fn find_match_returns_none_for_unmatched_pattern() {
1447 let term = build_term(3, 10, 16);
1448 let snapshot = Arc::new(ArcSwap::from_pointee(TerminalSnapshot::empty()));
1449 let seq = Arc::new(AtomicU64::new(0));
1450 let shared = SharedTerm {
1451 inner: Arc::new(Mutex::new(term)),
1452 snapshot,
1453 seq,
1454 paint_request: None,
1455 };
1456 let regex = fancy_regex::Regex::new("nothing-here").unwrap();
1457 assert!(shared.find_match(®ex, SearchDir::Forward).is_none());
1458 }
1459
1460 #[test]
1461 fn shared_term_scroll_publishes_fresh_snapshot() {
1462 let term = Arc::new(Mutex::new(build_term(3, 10, 16)));
1467 let snapshot = Arc::new(ArcSwap::from_pointee(TerminalSnapshot::empty()));
1468 let seq = Arc::new(AtomicU64::new(0));
1469 let shared = SharedTerm {
1470 inner: Arc::clone(&term),
1471 snapshot: Arc::clone(&snapshot),
1472 seq: Arc::clone(&seq),
1473 paint_request: None,
1474 };
1475 {
1476 let mut t = term.lock();
1477 let mut p: Processor = Processor::new();
1478 p.advance(&mut *t, b"r0\r\nr1\r\nr2\r\nr3\r\nr4\r\nr5");
1479 let s = term_to_snapshot(&*t, 1);
1481 snapshot.store(Arc::new(s));
1482 seq.store(1, Ordering::Relaxed);
1483 }
1484 assert_eq!(snapshot.load().scroll_offset, 0);
1485 shared.scroll(TerminalScrollKind::PageUp);
1486 let scrolled = snapshot.load();
1487 assert!(scrolled.scroll_offset > 0);
1488 assert!(scrolled.seq > 1);
1489 }
1490}