| /* | |
| * Licensed to the Apache Software Foundation (ASF) under one | |
| * or more contributor license agreements. See the NOTICE file | |
| * distributed with this work for additional information | |
| * regarding copyright ownership. The ASF licenses this file | |
| * to you under the Apache License, Version 2.0 (the | |
| * "License"); you may not use this file except in compliance | |
| * with the License. You may obtain a copy of the License at | |
| * | |
| * http://www.apache.org/licenses/LICENSE-2.0 | |
| * | |
| * Unless required by applicable law or agreed to in writing, | |
| * software distributed under the License is distributed on an | |
| * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY | |
| * KIND, either express or implied. See the License for the | |
| * specific language governing permissions and limitations | |
| * under the License. | |
| */ | |
| //! Lossless display capture and installation. This is not a parser checkpoint. | |
| use super::{Events, Screen}; | |
| use alacritty_terminal::{ | |
| Term, | |
| event::EventListener, | |
| grid::Dimensions, | |
| index::{Column, Line, Point}, | |
| term::{ | |
| TermMode, | |
| cell::{Cell, Flags}, | |
| color, | |
| }, | |
| vte::ansi::{self, Handler}, | |
| }; | |
| use maka_agent_facts::terminal::{ | |
| TerminalCursor, | |
| display::{self, Color, Display, Metadata, Row, Run, Style}, | |
| }; | |
| /// A physical viewport into the original styled grid. Capturing it visits only | |
| /// the requested rows, irrespective of the amount of retained scrollback. | |
| #[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] | |
| #[serde(rename_all = "camelCase")] | |
| pub struct Viewport { | |
| pub metadata: Metadata, | |
| pub rows: Vec<Row>, | |
| /// Rows above the bottom of the grid, clamped to the retained extent. | |
| pub offset: usize, | |
| pub total_rows: usize, | |
| pub scrollback_rows: usize, | |
| /// Original grid coordinate: current screen starts at zero, history is negative. | |
| pub first_line: i32, | |
| } | |
| impl Viewport { | |
| /// Read a validated, settled display (for example from `Assembly::finish`) | |
| /// without cloning its other rows or alternate buffer. This is not an input | |
| /// validation boundary: validating again would walk the entire history. | |
| pub fn from_display( | |
| display: &Display, | |
| offset: usize, | |
| count: usize, | |
| ) -> Result<Self, &'static str> { | |
| let history = display.scrollback.len(); | |
| let (offset, first_line) = viewport_range(display.metadata.size, history, offset, count)?; | |
| let first = (first_line + history as i32) as usize; | |
| let rows = (first..first + count) | |
| .map(|line| { | |
| if line < history { | |
| display.scrollback[line].clone() | |
| } else { | |
| display.screen[line - history].clone() | |
| } | |
| }) | |
| .collect(); | |
| Ok(Self { | |
| metadata: display.metadata.clone(), | |
| rows, | |
| offset, | |
| total_rows: history + usize::from(display.metadata.size.rows()), | |
| scrollback_rows: history, | |
| first_line, | |
| }) | |
| } | |
| } | |
| fn viewport_range( | |
| size: maka_agent_facts::terminal::TerminalSize, | |
| history: usize, | |
| offset: usize, | |
| count: usize, | |
| ) -> Result<(usize, i32), &'static str> { | |
| let screen_rows = usize::from(size.rows()); | |
| if count == 0 || count > screen_rows { | |
| return Err("terminal viewport must contain between one and the screen's rows"); | |
| } | |
| let offset = offset.min(history + screen_rows - count); | |
| Ok((offset, (screen_rows - count) as i32 - offset as i32)) | |
| } | |
| impl Screen { | |
| /// Capture at most one screen of original styled cells, without walking or | |
| /// encoding the retained history. Offset zero follows the live bottom. | |
| pub fn viewport(&self, offset: usize, count: usize) -> Result<Viewport, &'static str> { | |
| let term = &self.state.term; | |
| let history = term.history_size(); | |
| let (offset, first_line) = viewport_range(self.size(), history, offset, count)?; | |
| let mut metadata = metadata(term, self); | |
| metadata.truncated |= self.failed; | |
| Ok(Viewport { | |
| metadata, | |
| rows: rows(term, first_line..first_line + count as i32), | |
| offset, | |
| total_rows: history + term.screen_lines(), | |
| scrollback_rows: history, | |
| first_line, | |
| }) | |
| } | |
| /// Capture even a sticky-failed parser's structurally valid grid. The failed | |
| /// display includes only the applied prefix, and is explicitly incomplete. | |
| pub fn display(&self) -> Display { | |
| let mut metadata = metadata(&self.state.term, self); | |
| metadata.truncated |= self.failed; | |
| let mut display = Display { | |
| metadata, | |
| screen: rows(&self.state.term, 0..self.state.term.screen_lines() as i32), | |
| scrollback: Vec::new(), | |
| last_alternate_screen: self.state.last_alternate.clone(), | |
| }; | |
| let mut remaining = display::MAX_DISPLAY_BYTES | |
| .checked_sub(display.encoded_size()) | |
| .expect("bounded native viewports fit the display allowance"); | |
| for line in (-(self.state.term.history_size() as i32)..0).rev() { | |
| let row = row(&self.state.term, line); | |
| let bytes = serde_json::to_vec(&row).expect("serializable row").len() | |
| + usize::from(!display.scrollback.is_empty()); | |
| let Some(rest) = remaining.checked_sub(bytes) else { | |
| display.metadata.truncated = true; | |
| break; | |
| }; | |
| remaining = rest; | |
| display.scrollback.push(row); | |
| } | |
| display.scrollback.reverse(); | |
| display | |
| } | |
| } | |
| fn metadata(term: &Term<Events>, screen: &Screen) -> Metadata { | |
| let point = term.grid().cursor.point; | |
| let style = term.cursor_style(); | |
| Metadata { | |
| size: screen.size(), | |
| cursor: TerminalCursor { | |
| x: point.column.0 as u16, | |
| y: point.line.0.max(0) as u16, | |
| visible: term.mode().contains(TermMode::SHOW_CURSOR), | |
| }, | |
| cursor_shape: match style.shape { | |
| ansi::CursorShape::Block => display::CursorShape::Block, | |
| ansi::CursorShape::Underline => display::CursorShape::Underline, | |
| ansi::CursorShape::Beam => display::CursorShape::Beam, | |
| ansi::CursorShape::HollowBlock => display::CursorShape::HollowBlock, | |
| ansi::CursorShape::Hidden => display::CursorShape::Hidden, | |
| }, | |
| cursor_blinking: style.blinking, | |
| alternate_screen: term.mode().contains(TermMode::ALT_SCREEN), | |
| truncated: screen.state.truncated, | |
| input: screen.input_modes(), | |
| palette: (0..color::COUNT) | |
| .filter_map(|index| { | |
| term.colors()[index].map(|rgb| (index as u16, [rgb.r, rgb.g, rgb.b])) | |
| }) | |
| .collect(), | |
| } | |
| } | |
| pub(super) fn rows(term: &Term<Events>, lines: std::ops::Range<i32>) -> Vec<Row> { | |
| lines.map(|line| row(term, line)).collect() | |
| } | |
| fn row(term: &Term<Events>, line: i32) -> Row { | |
| let mut out: Row = Vec::new(); | |
| let mut columns = 0; | |
| for column in 0..term.columns() { | |
| let cell = &term.grid()[Line(line)][Column(column)]; | |
| let style = Style { | |
| foreground: color(cell.fg), | |
| background: color(cell.bg), | |
| flags: cell.flags.bits(), | |
| underline: cell.underline_color().map(color), | |
| }; | |
| if out.last().is_none_or(|run| run.style != style) { | |
| out.push(Run { | |
| style, | |
| text: String::new(), | |
| lengths: Vec::new(), | |
| }); | |
| columns = 0; | |
| } | |
| let run = out.last_mut().unwrap(); | |
| let extra = cell.zerowidth().unwrap_or_default(); | |
| if !extra.is_empty() && run.lengths.is_empty() { | |
| run.lengths.resize(columns, 1); | |
| } | |
| if !run.lengths.is_empty() || !extra.is_empty() { | |
| run.lengths.push((extra.len() + 1) as u8); | |
| } | |
| run.text.push(cell.c); | |
| run.text.extend(extra); | |
| columns += 1; | |
| } | |
| out | |
| } | |
| fn color(color: ansi::Color) -> Color { | |
| match color { | |
| ansi::Color::Named(color) => Color::Named(color as u16), | |
| ansi::Color::Indexed(index) => Color::Indexed(index), | |
| ansi::Color::Spec(rgb) => Color::Rgb([rgb.r, rgb.g, rgb.b]), | |
| } | |
| } | |
| fn native_color(color: Color) -> Result<ansi::Color, &'static str> { | |
| use ansi::NamedColor::*; | |
| Ok(match color { | |
| Color::Rgb([r, g, b]) => ansi::Color::Spec(ansi::Rgb { r, g, b }), | |
| Color::Indexed(index) => ansi::Color::Indexed(index), | |
| Color::Named(index) => ansi::Color::Named(match index { | |
| 0 => Black, | |
| 1 => Red, | |
| 2 => Green, | |
| 3 => Yellow, | |
| 4 => Blue, | |
| 5 => Magenta, | |
| 6 => Cyan, | |
| 7 => White, | |
| 8 => BrightBlack, | |
| 9 => BrightRed, | |
| 10 => BrightGreen, | |
| 11 => BrightYellow, | |
| 12 => BrightBlue, | |
| 13 => BrightMagenta, | |
| 14 => BrightCyan, | |
| 15 => BrightWhite, | |
| 256 => Foreground, | |
| 257 => Background, | |
| 258 => Cursor, | |
| 259 => DimBlack, | |
| 260 => DimRed, | |
| 261 => DimGreen, | |
| 262 => DimYellow, | |
| 263 => DimBlue, | |
| 264 => DimMagenta, | |
| 265 => DimCyan, | |
| 266 => DimWhite, | |
| 267 => BrightForeground, | |
| 268 => DimForeground, | |
| _ => return Err("invalid named terminal color"), | |
| }), | |
| }) | |
| } | |
| /// Install into a fresh display-only Term, retaining exact physical cells for | |
| /// scroll and selection. Callers must never continue a raw parser from this grid. | |
| pub fn install<T: EventListener>( | |
| term: &mut Term<T>, | |
| display: &Display, | |
| ) -> Result<(), &'static str> { | |
| display.validate()?; | |
| let meta = &display.metadata; | |
| if term.columns() != usize::from(meta.size.cols()) | |
| || term.screen_lines() != usize::from(meta.size.rows()) | |
| { | |
| return Err("terminal display geometry mismatch"); | |
| } | |
| if meta.alternate_screen != term.mode().contains(TermMode::ALT_SCREEN) { | |
| term.swap_alt(); | |
| } | |
| term.grid_mut().update_history(display.scrollback.len()); | |
| term.grid_mut().initialize_all(); | |
| let history = display.scrollback.len() as i32; | |
| for (line, row) in display.scrollback.iter().chain(&display.screen).enumerate() { | |
| install_row(term, Line(line as i32 - history), row)?; | |
| } | |
| for &(index, [r, g, b]) in &meta.palette { | |
| if usize::from(index) >= color::COUNT { | |
| return Err("invalid terminal palette index"); | |
| } | |
| term.set_color(usize::from(index), ansi::Rgb { r, g, b }); | |
| } | |
| term.grid_mut().cursor.point = Point::new( | |
| Line(i32::from(meta.cursor.y)), | |
| Column(usize::from(meta.cursor.x)), | |
| ); | |
| term.set_cursor_style(Some(ansi::CursorStyle { | |
| shape: match meta.cursor_shape { | |
| display::CursorShape::Block => ansi::CursorShape::Block, | |
| display::CursorShape::Underline => ansi::CursorShape::Underline, | |
| display::CursorShape::Beam => ansi::CursorShape::Beam, | |
| display::CursorShape::HollowBlock => ansi::CursorShape::HollowBlock, | |
| display::CursorShape::Hidden => ansi::CursorShape::Hidden, | |
| }, | |
| blinking: meta.cursor_blinking, | |
| })); | |
| let show = ansi::NamedPrivateMode::ShowCursor.into(); | |
| if meta.cursor.visible { | |
| term.set_private_mode(show); | |
| } else { | |
| term.unset_private_mode(show); | |
| } | |
| Ok(()) | |
| } | |
| fn install_row<T: EventListener>( | |
| term: &mut Term<T>, | |
| line: Line, | |
| row: &Row, | |
| ) -> Result<(), &'static str> { | |
| let mut column = 0; | |
| for run in row { | |
| let mut text = run.text.chars(); | |
| let mut lengths = run.lengths.iter(); | |
| while let Some(c) = text.next() { | |
| let length = if run.lengths.is_empty() { | |
| 1 | |
| } else { | |
| usize::from(*lengths.next().ok_or("terminal run lengths exhausted")?) | |
| }; | |
| if !(1..=33).contains(&length) || column >= term.columns() { | |
| return Err("invalid terminal cell count"); | |
| } | |
| let mut cell = Cell { | |
| c, | |
| fg: native_color(run.style.foreground)?, | |
| bg: native_color(run.style.background)?, | |
| flags: Flags::from_bits(run.style.flags).ok_or("invalid terminal cell flags")?, | |
| extra: None, | |
| }; | |
| cell.set_underline_color(run.style.underline.map(native_color).transpose()?); | |
| for _ in 1..length { | |
| cell.push_zerowidth(text.next().ok_or("terminal cell text exhausted")?); | |
| } | |
| term.grid_mut()[line][Column(column)] = cell; | |
| column += 1; | |
| } | |
| if lengths.next().is_some() { | |
| return Err("excess terminal run lengths"); | |
| } | |
| } | |
| if column != term.columns() { | |
| return Err("terminal row geometry mismatch"); | |
| } | |
| Ok(()) | |
| } | |
| #[cfg(test)] | |
| mod tests { | |
| use super::*; | |
| #[test] | |
| fn viewport_reads_only_the_requested_physical_extent_and_clamps_to_retained_history() { | |
| let mut source = Screen::new(maka_agent_facts::terminal::TerminalSize::new(8, 3).unwrap()); | |
| source.write("zero\r\none\r\ntwo\r\nthree").unwrap(); | |
| let text = |view: &Viewport| { | |
| view.rows | |
| .iter() | |
| .map(|row| row.iter().map(|run| run.text.as_str()).collect::<String>()) | |
| .collect::<Vec<_>>() | |
| }; | |
| let bottom = source.viewport(0, 2).unwrap(); | |
| assert_eq!(bottom.first_line, 1); | |
| assert_eq!(bottom.scrollback_rows, 1); | |
| assert_eq!(bottom.total_rows, 4); | |
| assert_eq!(text(&bottom), ["two ", "three "]); | |
| let top = source.viewport(usize::MAX, 2).unwrap(); | |
| assert_eq!(top.offset, 2); | |
| assert_eq!(top.first_line, -1); | |
| assert_eq!(text(&top), ["zero ", "one "]); | |
| let display = source.display(); | |
| assert_eq!(Viewport::from_display(&display, 2, 2).unwrap(), top); | |
| assert!(source.viewport(0, 0).is_err()); | |
| assert!(source.viewport(0, 4).is_err()); | |
| } | |
| use maka_agent_facts::terminal::TerminalSize; | |
| #[test] | |
| fn styled_display_round_trips_exact_cells_palette_cursor_and_resized_alternate() { | |
| let mut source = Screen::new(TerminalSize::new(12, 4).unwrap()); | |
| source | |
| .write("\x1b[31mred\x1b[0m\r\n\x1b[48;2;1;2;3m \x1b[0m\r\n中文e\u{301}\r\nlast\r\n") | |
| .unwrap(); | |
| source | |
| .write("\x1b[?1049h\x1b[38;2;7;8;9mALT\x1b[?1049l") | |
| .unwrap(); | |
| source.resize(TerminalSize::new(10, 4).unwrap()).unwrap(); | |
| source | |
| .write("\x1b]4;1;rgb:11/22/33\x07\x1b[2;3H\x1b[1;3;4;7;9mX\x1b[0m\x1b[5 q") | |
| .unwrap(); | |
| let display = source.display(); | |
| display.validate().unwrap(); | |
| assert_eq!( | |
| display.last_alternate_screen.as_ref().unwrap().size, | |
| TerminalSize::new(12, 4).unwrap() | |
| ); | |
| assert_eq!(display.metadata.palette, vec![(1, [0x11, 0x22, 0x33])]); | |
| assert_eq!(display.metadata.cursor_shape, display::CursorShape::Beam); | |
| assert!(display.metadata.cursor_blinking); | |
| let encoded = serde_json::to_string(&display).unwrap(); | |
| let display: Display = serde_json::from_str(&encoded).unwrap(); | |
| let mut target = Screen::new(display.metadata.size); | |
| install(&mut target.state.term, &display).unwrap(); | |
| assert_eq!( | |
| target.state.term.history_size(), | |
| source.state.term.history_size() | |
| ); | |
| for line in | |
| -(source.state.term.history_size() as i32)..source.state.term.screen_lines() as i32 | |
| { | |
| for column in 0..source.state.term.columns() { | |
| assert_eq!( | |
| target.state.term.grid()[Line(line)][Column(column)], | |
| source.state.term.grid()[Line(line)][Column(column)], | |
| "cell {line}:{column}" | |
| ); | |
| } | |
| } | |
| for index in 0..color::COUNT { | |
| assert_eq!( | |
| target.state.term.colors()[index], | |
| source.state.term.colors()[index] | |
| ); | |
| } | |
| assert_eq!( | |
| target.state.term.cursor_style(), | |
| source.state.term.cursor_style() | |
| ); | |
| assert_eq!( | |
| target.state.term.grid().cursor.point, | |
| source.state.term.grid().cursor.point | |
| ); | |
| assert!( | |
| display | |
| .scrollback | |
| .iter() | |
| .flatten() | |
| .any(|run| run.style.foreground == Color::Named(1)) | |
| ); | |
| assert!( | |
| display | |
| .scrollback | |
| .iter() | |
| .chain(&display.screen) | |
| .flatten() | |
| .any(|run| run.style.background == Color::Rgb([1, 2, 3]) && run.text.contains(' ')) | |
| ); | |
| } | |
| } |