clone
ssh://git@git.notashelf.dev:33/notashelf/beer.gitCommit 5cba919c783e
tarballunverified16 files changed+6031-5855
@@ -1,20 +1,163 @@ # beer -A fast, software-rendered, **Wayland-native** terminal emulator written in Rust.-Lightweight in dependencies, on disk, and in memory.+`beer` is a small Wayland-native terminal emulator written in Rust. It renders+through `wl_shm` with the CPU, uses a real PTY for the child shell, and keeps+the architecture close to the model used by terminals like foot: no GPU+renderer, no tabs, no ligatures, and no async runtime.++The project is still pre-1.0, but it is already usable as a single-window+terminal on Wayland.++## Features++- Software-rendered Wayland window with server-side decoration negotiation.+- PTY-backed login shell with resize propagation through `TIOCSWINSZ`.+- VT parsing through `vte`, including cursor movement, erase/edit operations,+ scroll regions, alternate screen, SGR attributes, truecolor, 256-color, OSC+ palette/theme escapes, title updates, DA/DSR, XTGETTCAP, and synchronized+ output.+- Font discovery and rasterization through fontconfig and FreeType, including+ fallback faces, styled variants, bounded glyph caching, and color emoji.+- Scrollback with wheel and key scrolling, reflow on resize, and incremental+ search.+- Mouse selection with word, line, and rectangular selection; clipboard and+ primary-selection integration; bracketed paste; OSC 52 set/query.+- Mouse and focus reporting for terminal applications.+- TOML configuration with live reload on `SIGUSR1`.+- Shell integration through OSC 7 and OSC 133 for cwd-aware new windows, prompt+ jumping, and piping the last command's output.+- OSC 8 hyperlinks, visible URL hint mode, and OSC 9/777/99 notifications.++## Not Implemented Yet++- Daemon/server mode and client mode.+- Kitty keyboard protocol and Unicode codepoint input mode.+- Touchscreen input.+- Conformance and performance hardening passes beyond the current unit tests. ## Build -A Nix dev shell provides the toolchain and native libraries:+The repository provides a Nix dev shell with the Rust toolchain and native+Wayland/font dependencies: ```sh-# Enter a devshell for necessary deps+# Enter the development shell. $ nix develop -# Build in release mode-cargo build --release+# Build an optimized local binary.+$ cargo build --release+```++The Nix package builds the binary, terminfo entry, and man pages:++```sh+# Build the Nix package.+$ nix build+```++## Run++From a Wayland session:++```sh+# Start Beer from your terminal.+$ beer ``` +or after a release build:++```sh+# Run the release binary directly.+$ ./target/release/beer+```++Useful flags:++```sh+# Pass a config file to Beer.+$ beer --config /path/to/beer.toml++# Check the version with -V or --version.+$ beer --version++# See the help text.+$ beer --help+```++`--server` exists as a future mode switch and currently returns an error.++## Configuration++By default, `beer` reads:++```text+$XDG_CONFIG_HOME/beer/beer.toml+```++or, if `XDG_CONFIG_HOME` is unset:++```text+~/.config/beer/beer.toml+```++A missing file uses defaults. A malformed file logs a warning and falls back to+defaults. Unknown keys are ignored for forward compatibility.++Example:++```toml+[main]+font = "monospace"+font-size = 16+pad-x = 2+pad-y = 2++[colors]+background = "#181818"+foreground = "#c5c8c6"+alpha = 1.0++[key-bindings]+"Ctrl+Shift+C" = "copy"+"Ctrl+Shift+V" = "paste"+"Ctrl+Shift+F" = "search"++[url]+launch = ["xdg-open"]+```++See `doc/beer.toml.5.scd` for the full configuration reference.++## Development++The expected verification set is:++```sh+# Check Rust formatting.+$ cargo fmt --all -- --check++# Run Clippy with warnings denied.+$ cargo clippy --all-targets --all-features -- -D warnings++# Build a release-optimized binary.+$ cargo build --release++# Run the test suite.+$ cargo test++# Check dependency policy.+$ cargo deny check++# Verify the Nix package.+$ nix build+```++The code is organized as a single crate with internal modules for the PTY, VT+model, grid, font pipeline, renderer, input encoding, configuration, and Wayland+frontend. Keep feature logic in the module that owns the concept; avoid adding+more state directly to the Wayland event root or the core grid when a focused+submodule can own it.+ ## License EUPL-1.2.@@ -114,7 +114,7 @@ _jump-prompt-up_, _jump-prompt-down_, _pipe-command-output_, _url-mode_. ``` [key-bindings] "Ctrl+Shift+C" = "copy"-"Ctrl+grave" = "none"+"Ctrl+`" = "none" ``` # [shell-integration]@@ -10,66 +10,68 @@ libxkbcommon, freetype, fontconfig,-}:-rustPlatform.buildRustPackage (finalAttrs: {- pname = "beer";- version = "0.0.1";+}: let+ cargoTOML = (lib.importTOML ../Cargo.toml).package.version;+in+ rustPlatform.buildRustPackage (finalAttrs: {+ pname = "beer";+ version = cargoTOML.package.version; - src = let- fs = lib.fileset;- s = ../.;- in- fs.toSource {- root = s;- fileset = fs.unions [- (fs.fileFilter (file: builtins.any file.hasExt ["rs"]) (s + /src))- (s + /Cargo.lock)- (s + /Cargo.toml)- (s + /terminfo)- (s + /doc)- ];- };+ src = let+ fs = lib.fileset;+ s = ../.;+ in+ fs.toSource {+ root = s;+ fileset = fs.unions [+ (s + /src)+ (s + /Cargo.lock)+ (s + /Cargo.toml)+ (s + /terminfo)+ (s + /doc)+ ];+ }; - cargoLock.lockFile = "${finalAttrs.src}/Cargo.lock";- enableParallelBuilding = true;+ cargoLock.lockFile = "${finalAttrs.src}/Cargo.lock";+ enableParallelBuilding = true; - strictDeps = true;- nativeBuildInputs = [- pkg-config- makeBinaryWrapper- ncurses # tic- scdoc # man page generation- installShellFiles # installManPage- ];+ strictDeps = true;+ nativeBuildInputs = [+ pkg-config+ makeBinaryWrapper+ ncurses # tic+ scdoc # man page generation+ installShellFiles # installManPage+ ]; - buildInputs = [- wayland- libxkbcommon- freetype- fontconfig- ];+ buildInputs = [+ wayland+ libxkbcommon+ freetype+ fontconfig+ ]; - # Install the terminfo entry, and make the Wayland/xkb libraries (loaded via- # dlopen, so not captured by rpath) discoverable at runtime.- postInstall = ''- mkdir -p "$out/share/terminfo"- tic -x -o "$out/share/terminfo" terminfo/beer.info+ # Install the terminfo entry, and make the Wayland/xkb libraries (loaded via+ # dlopen, so not captured by rpath) discoverable at runtime.+ postInstall = ''+ mkdir -p "$out/share/terminfo"+ tic -x -o "$out/share/terminfo" terminfo/beer.info - # Generate the man pages from their scdoc sources.- scdoc < doc/beer.1.scd > beer.1- scdoc < doc/beer.toml.5.scd > beer.toml.5- installManPage beer.1 beer.toml.5+ # Generate the man pages from their scdoc sources.+ scdoc < doc/beer.1.scd > beer.1+ scdoc < doc/beer.toml.5.scd > beer.toml.5+ installManPage beer.1 beer.toml.5 - wrapProgram "$out/bin/beer" \- --prefix LD_LIBRARY_PATH : ${lib.makeLibraryPath [wayland libxkbcommon]}- '';+ wrapProgram "$out/bin/beer" \+ --prefix LD_LIBRARY_PATH : ${lib.makeLibraryPath [wayland libxkbcommon]}+ ''; - meta = {- description = "A fast, software-rendered, Wayland-native terminal emulator";- homepage = "https://github.com/NotAShelf/beer";- license = lib.licenses.eupl12;- maintainers = with lib.maintainers; [NotAShelf];- mainProgram = "beer";- platforms = lib.platforms.linux;- };-})+ meta = {+ description = "A fast, software-rendered, Wayland-native terminal emulator";+ homepage = "https://github.com/NotAShelf/beer";+ license = lib.licenses.eupl12;+ maintainers = with lib.maintainers; [NotAShelf];+ mainProgram = "beer";+ platforms = lib.platforms.linux;+ };+ })@@ -1,1914 +0,0 @@-//! The terminal screen: a grid of styled cells, a cursor, and the editing-//! operations the VT parser drives.--use std::collections::VecDeque;-use std::num::NonZeroU16;--use unicode_width::UnicodeWidthChar;--/// Maximum scrollback lines retained for the main screen.-const SCROLLBACK_CAP: usize = 10_000;--/// A cell colour: terminal default, a palette index, or direct RGB.-#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]-pub enum Color {- #[default]- Default,- Indexed(u8),- Rgb(u8, u8, u8),-}--/// Per-cell style flags, packed into a `u16`.-#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]-pub struct Flags(u16);--impl Flags {- pub const BOLD: Self = Self(1 << 0);- pub const DIM: Self = Self(1 << 1);- pub const ITALIC: Self = Self(1 << 2);- pub const BLINK: Self = Self(1 << 4);- pub const REVERSE: Self = Self(1 << 5);- pub const HIDDEN: Self = Self(1 << 6);- pub const STRIKE: Self = Self(1 << 7);- /// Trailing column of a double-width glyph; holds no character of its own.- pub const WIDE_CONT: Self = Self(1 << 8);- pub const OVERLINE: Self = Self(1 << 9);-- pub const fn empty() -> Self {- Self(0)- }-- pub const fn union(self, other: Self) -> Self {- Self(self.0 | other.0)- }-- pub fn contains(self, other: Self) -> bool {- self.0 & other.0 == other.0- }-- pub fn insert(&mut self, other: Self) {- self.0 |= other.0;- }-- pub fn remove(&mut self, other: Self) {- self.0 &= !other.0;- }-}--/// Underline style (SGR 4 / 4:x / 21).-#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]-pub enum Underline {- #[default]- None,- Single,- Double,- Curly,- Dotted,- Dashed,-}--/// Cursor shape (DECSCUSR).-#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]-pub enum CursorShape {- #[default]- Block,- Underline,- Beam,-}--/// Which mouse events the application has asked to receive (DECSET 9/1000-1003).-#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]-pub enum MouseProtocol {- /// No reporting; the pointer drives local selection/scroll.- #[default]- Off,- /// X10 (9): button presses only.- X10,- /// Normal (1000): button press and release.- Normal,- /// Button-event (1002): press, release, and motion while a button is held.- Button,- /// Any-event (1003): press, release, and all pointer motion.- Any,-}--/// How mouse events are framed on the wire (default byte form, UTF-8, or SGR).-#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]-pub enum MouseEncoding {- /// Legacy `CSI M Cb Cx Cy`, each value a byte offset by 32 (≤ 223).- #[default]- X10,- /// As X10 but coordinates above 95 are UTF-8 encoded (DECSET 1005).- Utf8,- /// `CSI < Cb ; Cx ; Cy M/m`, decimal and unbounded (DECSET 1006).- Sgr,-}--/// One grid cell: a character plus its rendering style.-#[derive(Clone, PartialEq, Eq, Debug)]-pub struct Cell {- pub c: char,- pub fg: Color,- pub bg: Color,- pub flags: Flags,- pub underline: Underline,- /// Underline colour; `Default` means "follow the foreground".- pub underline_color: Color,- /// Zero-width combining marks attached to `c`, in arrival order. `None` for- /// the common case; the renderer stacks each over the base glyph. This is- /// the grapheme cluster a future shaper (HarfBuzz) would consume.- pub combining: Option<Box<str>>,- /// OSC 8 hyperlink: a 1-based index into the grid's link table, or `None`.- pub link: Option<NonZeroU16>,-}--impl Default for Cell {- fn default() -> Self {- Self {- c: ' ',- fg: Color::Default,- bg: Color::Default,- flags: Flags::empty(),- underline: Underline::None,- underline_color: Color::Default,- combining: None,- link: None,- }- }-}--#[derive(Clone, Copy, Debug, Default)]-struct Cursor {- x: usize,- y: usize,-}--/// A URL detected in the visible viewport, with the `(row, col)` of its first-/// character in viewport coordinates.-#[derive(Clone, PartialEq, Eq, Debug)]-pub struct UrlHit {- pub url: String,- pub row: usize,- pub col: usize,-}--/// Whether `c` may appear inside a URL (excludes whitespace, controls, and the-/// delimiters that conventionally bound a URL in flowing text).-fn is_url_char(c: char) -> bool {- !c.is_whitespace()- && !c.is_control()- && !matches!(c, '<' | '>' | '"' | '`' | '{' | '}' | '|' | '\\' | '^')-}--/// Find `scheme://…` URLs in `chars`, returning `(start, end)` index ranges.-/// The scheme is a run of `[A-Za-z][A-Za-z0-9+.-]*` before `://`; the body runs-/// to the first non-URL character, with trailing sentence punctuation trimmed.-fn find_urls(chars: &[char]) -> Vec<(usize, usize)> {- let mut out = Vec::new();- let mut i = 0;- while i + 2 < chars.len() {- if chars[i] == ':' && chars[i + 1] == '/' && chars[i + 2] == '/' {- // Backtrack over the scheme.- let mut start = i;- while start > 0 {- let c = chars[start - 1];- if c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.') {- start -= 1;- } else {- break;- }- }- if start < i && chars[start].is_ascii_alphabetic() {- let mut end = i + 3;- while end < chars.len() && is_url_char(chars[end]) {- end += 1;- }- // Trim trailing punctuation that is usually sentence-level.- while end > i + 3- && matches!(- chars[end - 1],- '.' | ',' | ';' | ':' | '!' | '?' | ')' | ']' | '\'' | '"'- )- {- end -= 1;- }- if end > i + 3 {- out.push((start, end));- i = end;- continue;- }- }- }- i += 1;- }- out-}--/// Shell-integration prompt mark on a line (OSC 133): the start of a prompt,-/// the start of typed command input, the start of command output, or the line-/// where the command finished.-#[derive(Clone, Copy, PartialEq, Eq, Debug)]-pub enum PromptKind {- PromptStart,- CmdStart,- OutputStart,- CmdEnd,-}--/// One screen/scrollback row: its cells plus whether it soft-wrapped into the-/// next row (autowrap continuation, as opposed to a hard line break). The flag-/// is what lets resize rejoin and rewrap paragraphs.-#[derive(Clone, PartialEq, Eq, Debug)]-struct Line {- cells: Vec<Cell>,- wrapped: bool,- /// OSC 133 mark attached to this (logical) line, if any.- prompt: Option<PromptKind>,-}--impl Line {- fn blank(cols: usize) -> Self {- Self {- cells: vec![Cell::default(); cols],- wrapped: false,- prompt: None,- }- }-}--/// A point in the combined scrollback+live coordinate space: `row` indexes-/// scrollback lines first (oldest at 0), then the live screen.-#[derive(Clone, Copy, PartialEq, Eq, Debug)]-pub struct Point {- pub row: usize,- pub col: usize,-}--/// One scrollback-search hit: a run of `len` cells at absolute `(row, col)`.-#[derive(Clone, Copy, PartialEq, Eq, Debug)]-struct Match {- row: usize,- col: usize,- len: usize,-}--/// Incremental scrollback search: the query, every hit, and the focused one.-#[derive(Clone, Debug, Default)]-struct SearchState {- query: String,- matches: Vec<Match>,- current: usize,-}--/// The active screen plus cursor, scroll region, and current pen.-#[derive(Debug)]-pub struct Grid {- cols: usize,- rows: usize,- lines: Vec<Line>,- cursor: Cursor,- saved: Cursor,- /// Inclusive top/bottom rows of the scroll region.- top: usize,- bottom: usize,- /// Template cell carrying the current SGR colours/flags.- pen: Cell,- autowrap: bool,- origin: bool,- insert: bool,- /// Cursor parked past the last column, awaiting the next print to wrap.- wrap_pending: bool,- tabs: Vec<bool>,- /// Saved primary screen while the alternate screen is active.- alt_saved: Option<Vec<Line>>,- /// Lines that have scrolled off the top of the main screen, newest last.- scrollback: VecDeque<Line>,- /// How many lines the viewport is scrolled back from the live bottom.- view_offset: usize,- cursor_shape: CursorShape,- /// Whether the cursor shape is a blinking variant (DECSCUSR odd codes).- cursor_blink: bool,- cursor_visible: bool,- /// Application cursor-keys mode (DECCKM): arrows send SS3 instead of CSI.- app_cursor: bool,- /// Cursor colour from OSC 12; `None` follows the cell under the cursor.- cursor_color: Option<(u8, u8, u8)>,- /// Active mouse selection as (anchor, head) in absolute coordinates.- selection: Option<(Point, Point)>,- /// Whether the selection is a rectangular block rather than linear flow.- selection_block: bool,- /// Bracketed paste mode (DECSET 2004): wrap pasted text in `ESC[200~`/`201~`.- bracketed_paste: bool,- /// Synchronized output (DECSET 2026): hold presentation while a frame is- /// being assembled, so the screen never shows a half-drawn update.- sync: bool,- /// Which mouse events the application wants reported.- mouse_protocol: MouseProtocol,- /// Wire framing for those reports.- mouse_encoding: MouseEncoding,- /// Focus in/out reporting (DECSET 1004).- focus_events: bool,- /// Active incremental scrollback search, if any.- search: Option<SearchState>,- /// Characters that break a word for double-click selection.- word_delimiters: String,- /// History retention cap for the main screen.- scrollback_cap: usize,- /// Position of the last printed base cell, so a following zero-width- /// combining mark can attach to it.- last_base: Option<(usize, usize)>,- /// OSC 8 hyperlink URIs; a cell's `link` is a 1-based index into this.- links: Vec<Box<str>>,-}--fn default_tabs(cols: usize) -> Vec<bool> {- (0..cols).map(|i| i % 8 == 0 && i != 0).collect()-}--/// Default characters that terminate a word for double-click selection (the-/// `word-delimiters` config key overrides this). `_`, `-`, `.`, `/`, `:`, `~`-/// are deliberately *not* delimiters so paths, URLs, and option flags select as-/// one unit.-const WORD_DELIMITERS: &str = " \t`!@#$%^&*()+=[]{}\\|;'\",<>?";--/// Whether `c` is part of a word (not whitespace, not in `delims`).-fn is_word(c: char, delims: &str) -> bool {- !c.is_whitespace() && !delims.contains(c)-}--impl Grid {- pub fn new(cols: usize, rows: usize) -> Self {- let cols = cols.max(1);- let rows = rows.max(1);- Self {- cols,- rows,- lines: vec![Line::blank(cols); rows],- cursor: Cursor::default(),- saved: Cursor::default(),- top: 0,- bottom: rows - 1,- pen: Cell::default(),- autowrap: true,- origin: false,- insert: false,- wrap_pending: false,- tabs: default_tabs(cols),- alt_saved: None,- scrollback: VecDeque::new(),- view_offset: 0,- cursor_shape: CursorShape::default(),- cursor_blink: false,- cursor_visible: true,- cursor_color: None,- app_cursor: false,- selection: None,- selection_block: false,- bracketed_paste: false,- sync: false,- mouse_protocol: MouseProtocol::Off,- mouse_encoding: MouseEncoding::X10,- focus_events: false,- search: None,- word_delimiters: WORD_DELIMITERS.to_string(),- scrollback_cap: SCROLLBACK_CAP,- last_base: None,- links: Vec::new(),- }- }-- /// Set (or clear) the active OSC 8 hyperlink applied to printed cells. An- /// empty/`None` URI ends the current link.- pub fn set_link(&mut self, uri: Option<&str>) {- self.pen.link = match uri {- Some(uri) if !uri.is_empty() => Some(self.intern_link(uri)),- _ => None,- };- }-- /// Intern a hyperlink URI, returning its 1-based id (deduplicated; the table- /// is capped so a pathological stream cannot grow it without bound).- fn intern_link(&mut self, uri: &str) -> NonZeroU16 {- if let Some(i) = self.links.iter().position(|u| u.as_ref() == uri) {- return NonZeroU16::new(i as u16 + 1).expect("index + 1 is non-zero");- }- // u16::MAX distinct links is far past any real document; reuse the last- // slot once saturated rather than overflow the id space.- if self.links.len() < usize::from(u16::MAX) - 1 {- self.links.push(uri.into());- } else {- *self.links.last_mut().expect("table is non-empty when full") = uri.into();- }- NonZeroU16::new(self.links.len() as u16).expect("len after push is non-zero")- }-- /// The URI for a hyperlink id, if it is still in the table.- pub fn link_uri(&self, id: NonZeroU16) -> Option<&str> {- self.links- .get(usize::from(id.get()) - 1)- .map(|s| s.as_ref())- }-- /// The hyperlink id of the cell at an absolute `(row, col)`, if any.- pub fn link_at(&self, abs_row: usize, col: usize) -> Option<NonZeroU16> {- self.abs_row(abs_row).get(col).and_then(|c| c.link)- }-- /// Override the word-delimiter set; `None` keeps the built-in default.- pub fn set_word_delimiters(&mut self, delims: Option<String>) {- if let Some(d) = delims {- self.word_delimiters = d;- }- }-- /// Set the scrollback retention cap, trimming history if it shrank.- pub fn set_scrollback_cap(&mut self, cap: usize) {- self.scrollback_cap = cap;- while self.scrollback.len() > cap {- self.scrollback.pop_front();- }- self.view_offset = self.view_offset.min(self.scrollback.len());- }-- pub fn cols(&self) -> usize {- self.cols- }-- pub fn rows(&self) -> usize {- self.rows- }-- /// Resize the screen. On the main screen this reflows: soft-wrapped runs are- /// rejoined into logical lines and rewrapped to the new width, across both- /// scrollback and the live screen, keeping the cursor on its content. The- /// alternate screen just clips, since its apps repaint on resize.- pub fn resize(&mut self, cols: usize, rows: usize) {- let cols = cols.max(1);- let rows = rows.max(1);- if self.alt_saved.is_some() {- self.clip_resize(cols, rows);- } else {- self.reflow_resize(cols, rows);- }- self.cols = cols;- self.rows = rows;- self.top = 0;- self.bottom = rows - 1;- self.tabs = default_tabs(cols);- self.cursor.x = self.cursor.x.min(cols - 1);- self.cursor.y = self.cursor.y.min(rows - 1);- self.wrap_pending = false;- self.view_offset = 0;- }-- /// Clip-resize the live screen and the saved primary (alternate screen).- fn clip_resize(&mut self, cols: usize, rows: usize) {- for line in &mut self.lines {- line.cells.resize(cols, Cell::default());- }- self.lines.resize(rows, Line::blank(cols));- if let Some(saved) = self.alt_saved.as_mut() {- for line in saved.iter_mut() {- line.cells.resize(cols, Cell::default());- }- saved.resize(rows, Line::blank(cols));- }- self.clear_selection();- self.clear_search();- }-- /// Reflow scrollback + live content to a new width, rewrapping soft-wrapped- /// paragraphs and repositioning the cursor onto its character.- fn reflow_resize(&mut self, cols: usize, rows: usize) {- let cursor_abs = self.scrollback.len() + self.cursor.y;- let total = self.scrollback.len() + self.lines.len();-- // 1. Rejoin soft-wrapped rows into logical lines. Track which logical- // line the cursor falls in and its offset within that line.- let mut logicals: Vec<Vec<Cell>> = Vec::new();- let mut logical_marks: Vec<Option<PromptKind>> = Vec::new();- let mut acc: Vec<Cell> = Vec::new();- let mut acc_mark: Option<PromptKind> = None;- let mut cur_logical = 0usize;- let mut cur_off = 0usize;- for abs in 0..total {- if abs == cursor_abs {- cur_logical = logicals.len();- cur_off = acc.len() + self.cursor.x;- }- let line = if abs < self.scrollback.len() {- &self.scrollback[abs]- } else {- &self.lines[abs - self.scrollback.len()]- };- // The mark on the first physical row of a logical line carries over.- if acc.is_empty() {- acc_mark = line.prompt;- }- acc.extend_from_slice(&line.cells);- if !line.wrapped {- logicals.push(std::mem::take(&mut acc));- logical_marks.push(acc_mark.take());- }- }- if !acc.is_empty() {- logicals.push(acc);- logical_marks.push(acc_mark);- }- // Drop trailing all-blank lines (empty screen below the content), but- // never above the cursor's line, so the cursor keeps its row.- let last_content = logicals- .iter()- .rposition(|l| l.iter().any(|c| *c != Cell::default()))- .unwrap_or(0);- logicals.truncate(last_content.max(cur_logical) + 1);- logical_marks.truncate(last_content.max(cur_logical) + 1);-- // 2. Rewrap each logical line to the new width, recording where the- // cursor lands. Trailing blanks are dropped so a hard line does not- // rewrap its padding onto extra rows.- let mut new_lines: Vec<Line> = Vec::new();- let mut new_cursor_abs = 0usize;- let mut new_cursor_col = 0usize;- for (li, mut logical) in logicals.into_iter().enumerate() {- let trim = logical- .iter()- .rposition(|c| *c != Cell::default())- .map_or(0, |p| p + 1);- logical.truncate(trim);- let first = new_lines.len();- let chunks = logical.len().div_ceil(cols).max(1);- for ci in 0..chunks {- let start = ci * cols;- let end = (start + cols).min(logical.len());- let mut cells = logical.get(start..end).unwrap_or(&[]).to_vec();- cells.resize(cols, Cell::default());- new_lines.push(Line {- cells,- wrapped: ci + 1 < chunks,- // The mark belongs to the first physical row of the line.- prompt: if ci == 0 { logical_marks[li] } else { None },- });- }- if li == cur_logical {- let off = cur_off.min(logical.len());- let chunk = (off / cols).min(chunks - 1);- new_cursor_abs = first + chunk;- new_cursor_col = (off - chunk * cols).min(cols - 1);- }- }-- // 3. The last `rows` lines are the live screen; the rest is scrollback.- let live_start = new_lines.len().saturating_sub(rows);- let mut scrollback: VecDeque<Line> = new_lines.drain(0..live_start).collect();- let mut live = new_lines;- while live.len() < rows {- live.push(Line::blank(cols));- }- while scrollback.len() > self.scrollback_cap {- scrollback.pop_front();- }-- self.cursor.y = new_cursor_abs.saturating_sub(live_start).min(rows - 1);- self.cursor.x = new_cursor_col;- self.lines = live;- self.scrollback = scrollback;- self.clear_selection();- self.clear_search();- }-- pub fn cursor(&self) -> (usize, usize) {- (self.cursor.x, self.cursor.y)- }-- // --- pen / attributes ----- pub fn pen_mut(&mut self) -> &mut Cell {- &mut self.pen- }-- pub fn reset_pen(&mut self) {- self.pen = Cell::default();- }-- pub fn set_autowrap(&mut self, on: bool) {- self.autowrap = on;- }-- pub fn set_origin(&mut self, on: bool) {- self.origin = on;- self.move_to(0, 0);- }-- pub fn set_insert(&mut self, on: bool) {- self.insert = on;- }-- pub fn autowrap(&self) -> bool {- self.autowrap- }-- pub fn origin(&self) -> bool {- self.origin- }-- pub fn insert(&self) -> bool {- self.insert- }-- pub fn alt_active(&self) -> bool {- self.alt_saved.is_some()- }-- pub fn set_cursor_shape(&mut self, shape: CursorShape) {- self.cursor_shape = shape;- }-- pub fn cursor_shape(&self) -> CursorShape {- self.cursor_shape- }-- pub fn set_cursor_blink(&mut self, blink: bool) {- self.cursor_blink = blink;- }-- pub fn cursor_blink(&self) -> bool {- self.cursor_blink- }-- pub fn set_cursor_visible(&mut self, visible: bool) {- self.cursor_visible = visible;- }-- pub fn cursor_visible(&self) -> bool {- self.cursor_visible- }-- pub fn set_cursor_color(&mut self, color: Option<(u8, u8, u8)>) {- self.cursor_color = color;- }-- pub fn cursor_color(&self) -> Option<(u8, u8, u8)> {- self.cursor_color- }-- pub fn set_app_cursor(&mut self, on: bool) {- self.app_cursor = on;- }-- pub fn app_cursor(&self) -> bool {- self.app_cursor- }-- // --- printing ----- /// Place a printable character at the cursor, honouring width and autowrap.- pub fn print(&mut self, c: char) {- let width = c.width().unwrap_or(0);- if width == 0 {- // A zero-width combining mark attaches to the last base cell.- self.add_combining(c);- return;- }- if self.wrap_pending {- self.cursor.x = 0;- self.lines[self.cursor.y].wrapped = true;- self.line_feed();- self.wrap_pending = false;- }- if width == 2 && self.cursor.x + 1 >= self.cols {- // A double-width glyph cannot straddle the right edge: wrap first.- if self.autowrap {- self.cursor.x = 0;- self.lines[self.cursor.y].wrapped = true;- self.line_feed();- } else {- return;- }- }- if self.insert {- self.shift_right(width);- }-- let (x, y) = (self.cursor.x, self.cursor.y);- let mut cell = self.pen.clone();- cell.c = c;- cell.combining = None;- cell.flags.remove(Flags::WIDE_CONT);- self.lines[y].cells[x] = cell;- self.last_base = Some((x, y));- if width == 2 && x + 1 < self.cols {- let mut cont = self.pen.clone();- cont.c = ' ';- cont.combining = None;- cont.flags.insert(Flags::WIDE_CONT);- self.lines[y].cells[x + 1] = cont;- }-- let advance = width;- if self.cursor.x + advance >= self.cols {- if self.autowrap {- self.cursor.x = self.cols - 1;- self.wrap_pending = true;- } else {- self.cursor.x = self.cols - 1;- }- } else {- self.cursor.x += advance;- }- }-- /// Attach a zero-width combining mark to the most recently printed base- /// cell. Capped so a malicious stream of marks cannot grow a cell unbounded.- fn add_combining(&mut self, mark: char) {- const MAX_MARKS: usize = 8;- let Some((x, y)) = self.last_base else {- return;- };- let Some(cell) = self.lines.get_mut(y).and_then(|l| l.cells.get_mut(x)) else {- return;- };- let mut s: String = cell.combining.take().map(String::from).unwrap_or_default();- if s.chars().count() < MAX_MARKS {- s.push(mark);- }- cell.combining = Some(s.into_boxed_str());- }-- fn shift_right(&mut self, n: usize) {- let (x, y) = (self.cursor.x, self.cursor.y);- let end = self.cols;- let blank = self.pen_blank();- let row = &mut self.lines[y].cells;- for i in (x + n..end).rev() {- row[i] = row[i - n].clone();- }- for cell in &mut row[x..(x + n).min(end)] {- *cell = blank.clone();- }- }-- fn pen_blank(&self) -> Cell {- // A space carrying only the current background (back-colour erase).- Cell {- bg: self.pen.bg,- ..Cell::default()- }- }-- // --- cursor movement ----- fn region(&self) -> (usize, usize) {- if self.origin {- (self.top, self.bottom)- } else {- (0, self.rows - 1)- }- }-- pub fn move_to(&mut self, x: usize, y: usize) {- let (rt, rb) = self.region();- self.cursor.x = x.min(self.cols - 1);- self.cursor.y = (y + rt).min(rb).max(rt);- self.wrap_pending = false;- }-- pub fn move_to_col(&mut self, x: usize) {- self.cursor.x = x.min(self.cols - 1);- self.wrap_pending = false;- }-- pub fn move_to_row(&mut self, y: usize) {- let (rt, rb) = self.region();- self.cursor.y = (y + rt).min(rb).max(rt);- self.wrap_pending = false;- }-- pub fn cursor_up(&mut self, n: usize) {- let (rt, _) = self.region();- self.cursor.y = self.cursor.y.saturating_sub(n).max(rt);- self.wrap_pending = false;- }-- pub fn cursor_down(&mut self, n: usize) {- let (_, rb) = self.region();- self.cursor.y = (self.cursor.y + n).min(rb);- self.wrap_pending = false;- }-- pub fn cursor_fwd(&mut self, n: usize) {- self.cursor.x = (self.cursor.x + n).min(self.cols - 1);- self.wrap_pending = false;- }-- pub fn cursor_back(&mut self, n: usize) {- self.cursor.x = self.cursor.x.saturating_sub(n);- self.wrap_pending = false;- }-- pub fn save_cursor(&mut self) {- self.saved = self.cursor;- }-- pub fn restore_cursor(&mut self) {- self.cursor = self.saved;- self.cursor.x = self.cursor.x.min(self.cols - 1);- self.cursor.y = self.cursor.y.min(self.rows - 1);- self.wrap_pending = false;- }-- // --- line discipline ----- pub fn carriage_return(&mut self) {- self.cursor.x = 0;- self.wrap_pending = false;- }-- pub fn backspace(&mut self) {- self.cursor.x = self.cursor.x.saturating_sub(1);- self.wrap_pending = false;- }-- /// LF/VT/FF: move down one row, scrolling at the region bottom.- pub fn line_feed(&mut self) {- if self.cursor.y == self.bottom {- self.scroll_up(1);- } else if self.cursor.y < self.rows - 1 {- self.cursor.y += 1;- }- self.wrap_pending = false;- }-- /// RI: move up one row, scrolling down at the region top.- pub fn reverse_index(&mut self) {- if self.cursor.y == self.top {- self.scroll_down(1);- } else if self.cursor.y > 0 {- self.cursor.y -= 1;- }- self.wrap_pending = false;- }-- /// NEL: carriage return plus line feed.- pub fn next_line(&mut self) {- self.carriage_return();- self.line_feed();- }-- // --- tab stops ----- pub fn tab(&mut self) {- let mut x = self.cursor.x + 1;- while x < self.cols && !self.tabs[x] {- x += 1;- }- self.cursor.x = x.min(self.cols - 1);- self.wrap_pending = false;- }-- pub fn set_tab(&mut self) {- if self.cursor.x < self.cols {- self.tabs[self.cursor.x] = true;- }- }-- pub fn clear_tab(&mut self) {- if self.cursor.x < self.cols {- self.tabs[self.cursor.x] = false;- }- }-- pub fn clear_all_tabs(&mut self) {- self.tabs.iter_mut().for_each(|t| *t = false);- }-- // --- scrolling within the region ----- pub fn set_scroll_region(&mut self, top: usize, bottom: usize) {- if top < bottom && bottom < self.rows {- self.top = top;- self.bottom = bottom;- } else {- self.top = 0;- self.bottom = self.rows - 1;- }- self.move_to(0, 0);- }-- pub fn scroll_up(&mut self, n: usize) {- let n = n.min(self.bottom - self.top + 1);- // Lines leaving the top of the *whole* main screen become scrollback;- // a DECSTBM region scroll (top > 0) or the alt screen does not.- if self.top == 0 && self.alt_saved.is_none() {- for y in 0..n {- let line = std::mem::replace(&mut self.lines[y], Line::blank(self.cols));- self.scrollback.push_back(line);- }- let mut evicted = 0;- while self.scrollback.len() > self.scrollback_cap {- self.scrollback.pop_front();- evicted += 1;- }- if evicted > 0 {- self.shift_selection(evicted);- self.shift_search(evicted);- }- // Keep a scrolled-back viewport anchored to the same content.- if self.view_offset > 0 {- self.view_offset = (self.view_offset + n).min(self.scrollback.len());- }- }- for y in self.top..=self.bottom {- if y + n <= self.bottom {- self.lines.swap(y, y + n);- }- }- for y in (self.bottom + 1 - n)..=self.bottom {- self.blank_row(y);- }- }-- pub fn scroll_down(&mut self, n: usize) {- let n = n.min(self.bottom - self.top + 1);- for y in (self.top..=self.bottom).rev() {- if y >= self.top + n {- self.lines.swap(y, y - n);- }- }- for y in self.top..(self.top + n) {- self.blank_row(y);- }- }-- fn blank_row(&mut self, y: usize) {- let blank = self.pen_blank();- let line = &mut self.lines[y];- for cell in &mut line.cells {- *cell = blank.clone();- }- line.wrapped = false;- }-- // --- erase ----- /// ED: 0=below, 1=above, 2/3=all.- pub fn erase_display(&mut self, mode: u16) {- let (x, y) = (self.cursor.x, self.cursor.y);- match mode {- 0 => {- self.erase_in_row(y, x, self.cols);- for r in (y + 1)..self.rows {- self.blank_row(r);- }- }- 1 => {- for r in 0..y {- self.blank_row(r);- }- self.erase_in_row(y, 0, x + 1);- }- _ => {- for r in 0..self.rows {- self.blank_row(r);- }- }- }- self.wrap_pending = false;- }-- /// EL: 0=right, 1=left, 2=line.- pub fn erase_line(&mut self, mode: u16) {- let (x, y) = (self.cursor.x, self.cursor.y);- match mode {- 0 => self.erase_in_row(y, x, self.cols),- 1 => self.erase_in_row(y, 0, x + 1),- _ => self.erase_in_row(y, 0, self.cols),- }- self.wrap_pending = false;- }-- /// ECH: erase n characters from the cursor without moving it.- pub fn erase_chars(&mut self, n: usize) {- let (x, y) = (self.cursor.x, self.cursor.y);- self.erase_in_row(y, x, (x + n).min(self.cols));- }-- fn erase_in_row(&mut self, y: usize, from: usize, to: usize) {- let blank = self.pen_blank();- for cell in &mut self.lines[y].cells[from..to.min(self.cols)] {- *cell = blank.clone();- }- }-- // --- intra-line editing ----- /// ICH: insert n blanks at the cursor, shifting the rest right.- pub fn insert_chars(&mut self, n: usize) {- let saved = self.insert;- self.insert = true;- self.shift_right(n.min(self.cols));- self.insert = saved;- }-- /// DCH: delete n characters at the cursor, shifting the rest left.- pub fn delete_chars(&mut self, n: usize) {- let (x, y) = (self.cursor.x, self.cursor.y);- let n = n.min(self.cols - x);- let blank = self.pen_blank();- let row = &mut self.lines[y].cells;- for i in x..self.cols {- row[i] = if i + n < self.cols {- row[i + n].clone()- } else {- blank.clone()- };- }- }-- /// IL: insert n blank lines at the cursor row, within the scroll region.- pub fn insert_lines(&mut self, n: usize) {- if self.cursor.y < self.top || self.cursor.y > self.bottom {- return;- }- let n = n.min(self.bottom - self.cursor.y + 1);- for y in (self.cursor.y..=self.bottom).rev() {- if y >= self.cursor.y + n {- self.lines.swap(y, y - n);- }- }- for y in self.cursor.y..(self.cursor.y + n) {- self.blank_row(y);- }- }-- /// DL: delete n lines at the cursor row, within the scroll region.- pub fn delete_lines(&mut self, n: usize) {- if self.cursor.y < self.top || self.cursor.y > self.bottom {- return;- }- let n = n.min(self.bottom - self.cursor.y + 1);- for y in self.cursor.y..=self.bottom {- if y + n <= self.bottom {- self.lines.swap(y, y + n);- }- }- for y in (self.bottom + 1 - n)..=self.bottom {- self.blank_row(y);- }- }-- // --- alternate screen ----- pub fn enter_alt_screen(&mut self) {- if self.alt_saved.is_some() {- return;- }- self.view_offset = 0;- let blank = vec![Line::blank(self.cols); self.rows];- self.alt_saved = Some(std::mem::replace(&mut self.lines, blank));- }-- pub fn leave_alt_screen(&mut self) {- if let Some(main) = self.alt_saved.take() {- self.lines = main;- }- }-- // --- scrollback viewport ----- /// Scroll the viewport by `delta` lines: positive = back into history,- /// negative = toward the live screen. No-op on the alternate screen.- pub fn scroll_view(&mut self, delta: isize) {- if self.alt_saved.is_some() {- return;- }- let max = self.scrollback.len() as isize;- self.view_offset = (self.view_offset as isize + delta).clamp(0, max) as usize;- }-- pub fn scroll_to_bottom(&mut self) {- self.view_offset = 0;- }-- /// Whether the viewport is showing the live screen (not scrolled back).- pub fn view_at_bottom(&self) -> bool {- self.view_offset == 0- }-- /// One page (a screenful) of lines, for page-scroll bindings.- pub fn page(&self) -> usize {- self.rows.max(1)- }-- /// The cells shown at viewport row `y` (0 = top of the window), accounting- /// for the scrollback offset. May differ from `cols` in width if the line- /// predates a resize (no reflow yet), so callers must not assume length.- pub fn view_row(&self, y: usize) -> &[Cell] {- let start = self.scrollback.len() - self.view_offset;- let idx = start + y;- if idx < self.scrollback.len() {- &self.scrollback[idx].cells- } else {- &self.lines[idx - self.scrollback.len()].cells- }- }-- // --- selection ----- /// The absolute row currently shown at viewport row `y`.- pub fn view_to_abs(&self, y: usize) -> usize {- self.scrollback.len() - self.view_offset + y- }-- /// The line at an absolute row (scrollback first, then the live screen).- fn line_at_abs(&self, abs: usize) -> &Line {- if abs < self.scrollback.len() {- &self.scrollback[abs]- } else {- &self.lines[abs - self.scrollback.len()]- }- }-- /// Detect plain-text URLs across the visible viewport, returning each with- /// the viewport `(row, col)` of its first character. Soft-wrapped rows are- /// joined so a URL split across a wrap is found whole; hard line breaks end- /// a URL.- pub fn visible_urls(&self) -> Vec<UrlHit> {- let mut chars: Vec<char> = Vec::new();- let mut pos: Vec<(usize, usize)> = Vec::new();- for y in 0..self.rows {- let line = self.line_at_abs(self.view_to_abs(y));- for (x, cell) in line.cells.iter().enumerate() {- if cell.flags.contains(Flags::WIDE_CONT) {- continue;- }- chars.push(cell.c);- pos.push((y, x));- }- if !line.wrapped {- chars.push('\n'); // a hard break terminates any URL- pos.push((y, usize::MAX));- }- }- find_urls(&chars)- .into_iter()- .map(|(s, e)| {- let (row, col) = pos[s];- UrlHit {- url: chars[s..e].iter().collect(),- row,- col,- }- })- .collect()- }-- /// Cells of an absolute row (scrollback first, then the live screen).- fn abs_row(&self, row: usize) -> &[Cell] {- if row < self.scrollback.len() {- &self.scrollback[row].cells- } else {- &self.lines[row - self.scrollback.len()].cells- }- }-- /// Total rows across scrollback and the live screen.- fn total_lines(&self) -> usize {- self.scrollback.len() + self.lines.len()- }-- /// The OSC 133 mark on an absolute row, if any.- fn abs_prompt(&self, row: usize) -> Option<PromptKind> {- if row < self.scrollback.len() {- self.scrollback[row].prompt- } else {- self.lines- .get(row - self.scrollback.len())- .and_then(|l| l.prompt)- }- }-- // --- shell integration (OSC 133) ----- /// Attach an OSC 133 prompt mark to the live line under the cursor.- pub fn set_prompt_mark(&mut self, kind: PromptKind) {- let y = self.cursor.y;- if let Some(line) = self.lines.get_mut(y) {- line.prompt = Some(kind);- }- }-- /// Scroll the viewport to the previous (`up`) or next prompt, placing that- /// prompt line at the top of the window. No-op on the alternate screen or- /// when there is no prompt in that direction.- pub fn jump_prompt(&mut self, up: bool) {- if self.alt_saved.is_some() {- return;- }- let top = self.scrollback.len().saturating_sub(self.view_offset);- let total = self.total_lines();- let is_prompt = |k: Option<PromptKind>| k == Some(PromptKind::PromptStart);- let target = if up {- (0..top).rev().find(|&r| is_prompt(self.abs_prompt(r)))- } else {- ((top + 1)..total).find(|&r| is_prompt(self.abs_prompt(r)))- };- if let Some(t) = target {- let offset = self.scrollback.len() as isize - t as isize;- self.view_offset = offset.clamp(0, self.scrollback.len() as isize) as usize;- }- }-- /// Text of the most recent command's output: the rows from the last- /// output-start (OSC 133 C) up to the command-end (D) or next prompt.- pub fn last_command_output(&self) -> Option<String> {- let total = self.total_lines();- let start = (0..total)- .rev()- .find(|&r| self.abs_prompt(r) == Some(PromptKind::OutputStart))?;- let mut lines: Vec<String> = Vec::new();- for r in start..total {- if r > start- && matches!(- self.abs_prompt(r),- Some(PromptKind::CmdEnd | PromptKind::PromptStart)- )- {- break;- }- lines.push(self.row_slice_text(r, 0, usize::MAX).trim_end().to_string());- }- // Drop trailing blank rows (e.g. the empty live screen below the output).- while lines.last().is_some_and(|l| l.is_empty()) {- lines.pop();- }- let mut out = lines.join("\n");- out.push('\n');- Some(out)- }-- /// Slide an active selection up by `n` rows after scrollback eviction,- /// dropping it if either endpoint scrolled off the top.- fn shift_selection(&mut self, n: usize) {- if let Some((a, b)) = self.selection {- if a.row < n || b.row < n {- self.selection = None;- } else {- self.selection = Some((- Point {- row: a.row - n,- ..a- },- Point {- row: b.row - n,- ..b- },- ));- }- }- }-- pub fn clear_selection(&mut self) {- self.selection = None;- }-- /// Begin a linear selection at an absolute point (drag anchor).- pub fn start_selection(&mut self, row: usize, col: usize) {- let p = Point { row, col };- self.selection = Some((p, p));- self.selection_block = false;- }-- /// Begin a rectangular (block) selection at an absolute point.- pub fn start_block_selection(&mut self, row: usize, col: usize) {- let p = Point { row, col };- self.selection = Some((p, p));- self.selection_block = true;- }-- /// Move the selection head (drag), keeping the anchor fixed.- pub fn extend_selection(&mut self, row: usize, col: usize) {- if let Some((_, head)) = self.selection.as_mut() {- *head = Point { row, col };- }- }-- /// Select the word at an absolute point, breaking on whitespace and the- /// default delimiter set.- pub fn select_word(&mut self, row: usize, col: usize) {- let delims = &self.word_delimiters;- let line = self.abs_row(row);- if col >= line.len() || !is_word(line[col].c, delims) {- self.start_selection(row, col);- return;- }- let mut lo = col;- while lo > 0 && is_word(line[lo - 1].c, delims) {- lo -= 1;- }- let mut hi = col;- while hi + 1 < line.len() && is_word(line[hi + 1].c, delims) {- hi += 1;- }- self.selection = Some((Point { row, col: lo }, Point { row, col: hi }));- self.selection_block = false;- }-- /// Select the whole line at an absolute row.- pub fn select_line(&mut self, row: usize) {- let last = self.abs_row(row).len().saturating_sub(1);- self.selection = Some((Point { row, col: 0 }, Point { row, col: last }));- self.selection_block = false;- }-- /// The rectangle `(top_row, bottom_row, left_col, right_col)` of a block- /// selection.- fn block_rect(&self) -> Option<(usize, usize, usize, usize)> {- let (a, b) = self.selection?;- Some((- a.row.min(b.row),- a.row.max(b.row),- a.col.min(b.col),- a.col.max(b.col),- ))- }-- /// Normalized selection (start <= end in reading order), if any.- fn ordered_selection(&self) -> Option<(Point, Point)> {- self.selection.map(|(a, b)| {- if (a.row, a.col) <= (b.row, b.col) {- (a, b)- } else {- (b, a)- }- })- }-- /// Whether the cell at an absolute `(row, col)` falls inside the selection.- pub fn is_selected(&self, row: usize, col: usize) -> bool {- if self.selection_block {- let Some((r0, r1, c0, c1)) = self.block_rect() else {- return false;- };- return row >= r0 && row <= r1 && col >= c0 && col <= c1;- }- let Some((start, end)) = self.ordered_selection() else {- return false;- };- if row < start.row || row > end.row {- return false;- }- let lo = if row == start.row { start.col } else { 0 };- let hi = if row == end.row { end.col } else { usize::MAX };- col >= lo && col <= hi- }-- /// The inclusive `(lo, hi)` column span selected on absolute row `row`, if- /// any part of that row is selected.- pub fn selection_span_on(&self, row: usize) -> Option<(usize, usize)> {- if self.selection_block {- let (r0, r1, c0, c1) = self.block_rect()?;- return (row >= r0 && row <= r1).then_some((c0, c1));- }- let (start, end) = self.ordered_selection()?;- if row < start.row || row > end.row {- return None;- }- let lo = if row == start.row { start.col } else { 0 };- let hi = if row == end.row {- end.col- } else {- self.abs_row(row).len().saturating_sub(1)- };- Some((lo, hi))- }-- /// The selected text, with trailing blanks trimmed per line and rows joined- /// by newlines. `None` if there is no selection.- pub fn selection_text(&self) -> Option<String> {- if self.selection_block {- let (r0, r1, c0, c1) = self.block_rect()?;- let mut out = String::new();- for row in r0..=r1 {- out.push_str(self.row_slice_text(row, c0, c1 + 1).trim_end());- if row != r1 {- out.push('\n');- }- }- return Some(out);- }- let (start, end) = self.ordered_selection()?;- let mut out = String::new();- for row in start.row..=end.row {- let line = self.abs_row(row);- let lo = if row == start.row { start.col } else { 0 };- let hi = if row == end.row {- (end.col + 1).min(line.len())- } else {- line.len()- };- out.push_str(self.row_slice_text(row, lo, hi).trim_end());- if row != end.row {- out.push('\n');- }- }- Some(out)- }-- /// The characters of an absolute row in `[from, to)`, skipping wide- /// continuation cells.- fn row_slice_text(&self, row: usize, from: usize, to: usize) -> String {- let mut out = String::new();- for cell in self- .abs_row(row)- .get(from..to.min(self.abs_row(row).len()))- .unwrap_or(&[])- .iter()- .filter(|c| !c.flags.contains(Flags::WIDE_CONT))- {- out.push(cell.c);- if let Some(marks) = &cell.combining {- out.push_str(marks);- }- }- out- }-- // --- scrollback search ----- /// Set the search query and recompute matches over scrollback + the live- /// screen, focusing the most recent hit and scrolling it into view. An- /// empty query keeps search mode active but clears the hit list.- pub fn set_search(&mut self, query: &str) {- let matches = if query.is_empty() {- Vec::new()- } else {- self.compute_matches(query)- };- let current = matches.len().saturating_sub(1);- self.search = Some(SearchState {- query: query.to_string(),- matches,- current,- });- self.jump_to_current();- }-- /// Move the focused match `forward` (toward newer) or back (toward older),- /// wrapping, and scroll it into view.- pub fn search_step(&mut self, forward: bool) {- if let Some(s) = self.search.as_mut()- && !s.matches.is_empty()- {- let n = s.matches.len();- s.current = if forward {- (s.current + 1) % n- } else {- (s.current + n - 1) % n- };- }- self.jump_to_current();- }-- pub fn clear_search(&mut self) {- self.search = None;- }-- /// The current query, while search mode is active.- pub fn search_query(&self) -> Option<&str> {- self.search.as_ref().map(|s| s.query.as_str())- }-- /// `(focused match index 1-based, total matches)` for the search prompt.- pub fn search_count(&self) -> (usize, usize) {- self.search.as_ref().map_or((0, 0), |s| {- let total = s.matches.len();- (if total == 0 { 0 } else { s.current + 1 }, total)- })- }-- /// Match spans `(lo, hi, is_current)` on absolute row `row`, for highlight.- pub fn search_spans_on(&self, row: usize) -> Vec<(usize, usize, bool)> {- let Some(s) = self.search.as_ref() else {- return Vec::new();- };- s.matches- .iter()- .enumerate()- .filter(|(_, m)| m.row == row && m.len > 0)- .map(|(i, m)| (m.col, m.col + m.len - 1, i == s.current))- .collect()- }-- fn compute_matches(&self, query: &str) -> Vec<Match> {- // Smart case: an uppercase letter in the query forces case sensitivity.- let sensitive = query.chars().any(|c| c.is_ascii_uppercase());- let fold = |c: char| {- if sensitive { c } else { c.to_ascii_lowercase() }- };- let needle: Vec<char> = query.chars().map(fold).collect();- if needle.is_empty() {- return Vec::new();- }- let total = self.scrollback.len() + self.rows;- let mut matches = Vec::new();- for row in 0..total {- let hay: Vec<char> = self.abs_row(row).iter().map(|cell| fold(cell.c)).collect();- let mut i = 0;- while i + needle.len() <= hay.len() {- if hay[i..i + needle.len()] == needle[..] {- matches.push(Match {- row,- col: i,- len: needle.len(),- });- i += needle.len();- } else {- i += 1;- }- }- }- matches- }-- /// Scroll the viewport so the focused match is on screen, centering it only- /// when it would otherwise be off the visible range.- fn jump_to_current(&mut self) {- let Some(abs) = self- .search- .as_ref()- .and_then(|s| s.matches.get(s.current))- .map(|m| m.row)- else {- return;- };- let sb = self.scrollback.len();- let top = sb - self.view_offset;- let bottom = top + self.rows - 1;- if abs < top || abs > bottom {- let target = sb as isize + self.rows as isize / 2 - abs as isize;- self.view_offset = target.clamp(0, sb as isize) as usize;- }- }-- /// Slide search hits up by `n` rows after scrollback eviction, dropping any- /// that scrolled off the top.- fn shift_search(&mut self, n: usize) {- if let Some(s) = self.search.as_mut() {- s.matches.retain(|m| m.row >= n);- for m in &mut s.matches {- m.row -= n;- }- s.current = s.current.min(s.matches.len().saturating_sub(1));- }- }-- pub fn set_bracketed_paste(&mut self, on: bool) {- self.bracketed_paste = on;- }-- pub fn bracketed_paste(&self) -> bool {- self.bracketed_paste- }-- pub fn set_sync(&mut self, on: bool) {- self.sync = on;- }-- pub fn sync_active(&self) -> bool {- self.sync- }-- pub fn set_mouse_protocol(&mut self, protocol: MouseProtocol) {- self.mouse_protocol = protocol;- }-- pub fn mouse_protocol(&self) -> MouseProtocol {- self.mouse_protocol- }-- pub fn set_mouse_encoding(&mut self, encoding: MouseEncoding) {- self.mouse_encoding = encoding;- }-- pub fn mouse_encoding(&self) -> MouseEncoding {- self.mouse_encoding- }-- pub fn set_focus_events(&mut self, on: bool) {- self.focus_events = on;- }-- pub fn focus_events(&self) -> bool {- self.focus_events- }-- // --- inspection (logging + tests) ----- /// The visible text of one row, trailing blanks trimmed.- #[cfg(test)]- pub fn row_text(&self, y: usize) -> String {- self.lines[y]- .cells- .iter()- .filter(|c| !c.flags.contains(Flags::WIDE_CONT))- .map(|c| c.c)- .collect::<String>()- .trim_end()- .to_string()- }-- pub fn cell(&self, x: usize, y: usize) -> &Cell {- &self.lines[y].cells[x]- }-}--#[cfg(test)]-mod tests {- use super::*;-- #[test]- fn prints_and_wraps() {- let mut g = Grid::new(4, 2);- for c in "abcde".chars() {- g.print(c);- }- assert_eq!(g.row_text(0), "abcd");- assert_eq!(g.row_text(1), "e");- assert_eq!(g.cursor(), (1, 1));- }-- #[test]- fn combining_marks_attach_to_base_cell() {- let mut g = Grid::new(8, 1);- // "e" + COMBINING ACUTE ACCENT + "x": the mark joins the 'e' cell, the- // 'x' lands in the next cell (the mark advanced nothing).- for c in "e\u{0301}x".chars() {- g.print(c);- }- assert_eq!(g.cell(0, 0).c, 'e');- assert_eq!(g.cell(0, 0).combining.as_deref(), Some("\u{0301}"));- assert_eq!(g.cell(1, 0).c, 'x');- assert_eq!(g.cursor(), (2, 0));- // Copied text round-trips the full grapheme cluster.- g.start_selection(0, 0);- g.extend_selection(0, 1);- assert_eq!(g.selection_text().as_deref(), Some("e\u{0301}x"));- }-- #[test]- fn detects_urls_trimming_trailing_punctuation() {- let mut g = Grid::new(60, 2);- for c in "see https://example.com/p?q=1, ok".chars() {- g.print(c);- }- let hits = g.visible_urls();- assert_eq!(hits.len(), 1);- assert_eq!(hits[0].url, "https://example.com/p?q=1");- assert_eq!((hits[0].row, hits[0].col), (0, 4));- }-- #[test]- fn detects_url_across_a_soft_wrap() {- // 20 cols: the URL wraps, but autowrap sets the wrapped flag so it rejoins.- let mut g = Grid::new(20, 3);- for c in "x https://example.com/averylongpath".chars() {- g.print(c);- }- let hits = g.visible_urls();- assert_eq!(hits.len(), 1);- assert_eq!(hits[0].url, "https://example.com/averylongpath");- }-- #[test]- fn prompt_mark_survives_reflow() {- let mut g = Grid::new(10, 4);- g.set_prompt_mark(PromptKind::OutputStart); // marks line 0- for c in "hello".chars() {- g.print(c);- }- g.resize(6, 4); // rewrap to a narrower width- // The mark followed its logical line, so the output is still found.- assert_eq!(g.last_command_output().as_deref(), Some("hello\n"));- }-- #[test]- fn carriage_return_and_line_feed() {- let mut g = Grid::new(8, 4);- for c in "hi".chars() {- g.print(c);- }- g.carriage_return();- g.line_feed();- for c in "yo".chars() {- g.print(c);- }- assert_eq!(g.row_text(0), "hi");- assert_eq!(g.row_text(1), "yo");- }-- #[test]- fn line_feed_scrolls_at_bottom() {- let mut g = Grid::new(4, 2);- g.print('a');- g.next_line();- g.print('b');- g.next_line(); // scrolls: row0 <- "b", row1 blank- assert_eq!(g.row_text(0), "b");- assert_eq!(g.row_text(1), "");- }-- #[test]- fn erase_line_to_right() {- let mut g = Grid::new(6, 1);- for c in "abcdef".chars() {- g.print(c);- }- g.move_to(2, 0);- g.erase_line(0);- assert_eq!(g.row_text(0), "ab");- }-- #[test]- fn delete_and_insert_chars() {- let mut g = Grid::new(6, 1);- for c in "abcdef".chars() {- g.print(c);- }- g.move_to(1, 0);- g.delete_chars(2);- assert_eq!(g.row_text(0), "adef");- g.move_to(1, 0);- g.insert_chars(2);- assert_eq!(g.row_text(0), "a def");- }-- #[test]- fn scrollback_captures_scrolled_lines() {- let mut g = Grid::new(8, 2);- for c in ['1', '2', '3'] {- g.print(c);- g.next_line();- }- // Live screen: newest line on top, cleared line below.- assert_eq!(g.view_row(0)[0].c, '3');- assert!(g.view_at_bottom());- // Scroll back to reveal the two captured lines.- g.scroll_view(2);- assert!(!g.view_at_bottom());- assert_eq!(g.view_row(0)[0].c, '1');- assert_eq!(g.view_row(1)[0].c, '2');- g.scroll_to_bottom();- assert_eq!(g.view_row(0)[0].c, '3');- }-- #[test]- fn wide_char_occupies_two_columns() {- let mut g = Grid::new(6, 1);- g.print('世');- g.print('x');- assert_eq!(g.cell(0, 0).c, '世');- assert!(g.cell(1, 0).flags.contains(Flags::WIDE_CONT));- assert_eq!(g.cell(2, 0).c, 'x');- }-- #[test]- fn selection_extracts_text_across_rows() {- let mut g = Grid::new(8, 2);- for c in "abcd".chars() {- g.print(c);- }- g.carriage_return();- g.line_feed();- for c in "efgh".chars() {- g.print(c);- }- // Select "cd" on row 0 through "ef" on row 1 (rows are live: abs 0,1).- g.start_selection(0, 2);- g.extend_selection(1, 1);- assert!(g.is_selected(0, 3));- assert!(g.is_selected(1, 0));- assert!(!g.is_selected(1, 2));- assert_eq!(g.selection_text().as_deref(), Some("cd\nef"));- }-- #[test]- fn select_word_spans_one_word() {- let mut g = Grid::new(16, 1);- for c in "foo bar baz".chars() {- g.print(c);- }- g.select_word(0, 5); // inside "bar"- assert_eq!(g.selection_text().as_deref(), Some("bar"));- }-- #[test]- fn select_word_breaks_on_delimiters_but_keeps_paths() {- let mut g = Grid::new(32, 1);- for c in "run /usr/bin:next".chars() {- g.print(c);- }- // '/' and ':' are not delimiters, so the path selects whole.- g.select_word(0, 7); // inside "/usr/bin:next"- assert_eq!(g.selection_text().as_deref(), Some("/usr/bin:next"));- // '(' is a delimiter.- let mut g = Grid::new(16, 1);- for c in "f(arg)".chars() {- g.print(c);- }- g.select_word(0, 2); // inside "arg"- assert_eq!(g.selection_text().as_deref(), Some("arg"));- }-- #[test]- fn block_selection_is_rectangular() {- let mut g = Grid::new(8, 3);- for line in ["abcd", "efgh", "ijkl"] {- for c in line.chars() {- g.print(c);- }- g.carriage_return();- g.line_feed();- }- // A block from (row0,col1) to (row2,col2) takes columns 1..=2 each row.- g.start_block_selection(0, 1);- g.extend_selection(2, 2);- assert!(g.is_selected(0, 1));- assert!(g.is_selected(2, 2));- assert!(!g.is_selected(1, 3));- assert!(!g.is_selected(1, 0));- assert_eq!(g.selection_text().as_deref(), Some("bc\nfg\njk"));- }-- #[test]- fn search_finds_matches_across_history() {- let mut g = Grid::new(16, 2);- for line in ["alpha", "beta", "ALPHA", "gamma"] {- for c in line.chars() {- g.print(c);- }- g.carriage_return();- g.line_feed();- }- // Case-insensitive (no uppercase in query) matches both alphas.- g.set_search("alpha");- assert_eq!(g.search_count(), (2, 2)); // focus starts on the latest hit- let spans0 = g.search_spans_on(0);- assert_eq!(spans0, vec![(0, 4, false)]);- // Smart case: an uppercase letter restricts to the exact-case hit.- g.set_search("ALPHA");- assert_eq!(g.search_count(), (1, 1));- assert_eq!(g.search_spans_on(2), vec![(0, 4, true)]);- // Stepping wraps and the no-match query reports zero.- g.set_search("zzz");- assert_eq!(g.search_count(), (0, 0));- assert_eq!(g.search_query(), Some("zzz"));- g.clear_search();- assert_eq!(g.search_query(), None);- }-- #[test]- fn reflow_rewraps_a_wrapped_paragraph() {- let mut g = Grid::new(4, 3);- for c in "abcdefgh".chars() {- g.print(c); // wraps: "abcd" | "efgh" | (cursor parked)- }- // Widen: the two wrapped rows rejoin into one logical line.- g.resize(8, 3);- assert_eq!(g.row_text(0), "abcdefgh");- assert_eq!(g.cursor(), (7, 0)); // cursor follows the content end- // Narrow back: it rewraps to the smaller width without losing text.- g.resize(4, 3);- assert_eq!(g.row_text(0), "abcd");- assert_eq!(g.row_text(1), "efgh");- }-- #[test]- fn reflow_preserves_hard_breaks() {- let mut g = Grid::new(10, 3);- for c in "one".chars() {- g.print(c);- }- g.carriage_return();- g.line_feed();- for c in "two".chars() {- g.print(c);- }- // A hard newline must not be rejoined when the width changes.- g.resize(6, 3);- assert_eq!(g.row_text(0), "one");- assert_eq!(g.row_text(1), "two");- }-- #[test]- fn scroll_region_limits_line_feed() {- let mut g = Grid::new(4, 4);- g.set_scroll_region(1, 2);- g.move_to(0, 2); // bottom of region (origin off: absolute row 2)- g.print('x');- g.move_to(0, 2);- g.line_feed(); // at region bottom: scroll [1,2] up, x moves to row 1- assert_eq!(g.row_text(1), "x");- assert_eq!(g.row_text(2), "");- }-}@@ -0,0 +1,136 @@+use std::num::NonZeroU16;++use super::*;++/// A URL detected in the visible viewport, with the `(row, col)` of its first+/// character in viewport coordinates.+#[derive(Clone, PartialEq, Eq, Debug)]+pub struct UrlHit {+ pub url: String,+ pub row: usize,+ pub col: usize,+}++/// Whether `c` may appear inside a URL (excludes whitespace, controls, and the+/// delimiters that conventionally bound a URL in flowing text).+fn is_url_char(c: char) -> bool {+ !c.is_whitespace()+ && !c.is_control()+ && !matches!(c, '<' | '>' | '"' | '`' | '{' | '}' | '|' | '\\' | '^')+}++/// Find `scheme://…` URLs in `chars`, returning `(start, end)` index ranges.+/// The scheme is a run of `[A-Za-z][A-Za-z0-9+.-]*` before `://`; the body runs+/// to the first non-URL character, with trailing sentence punctuation trimmed.+fn find_urls(chars: &[char]) -> Vec<(usize, usize)> {+ let mut out = Vec::new();+ let mut i = 0;+ while i + 2 < chars.len() {+ if chars[i] == ':' && chars[i + 1] == '/' && chars[i + 2] == '/' {+ // Backtrack over the scheme.+ let mut start = i;+ while start > 0 {+ let c = chars[start - 1];+ if c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.') {+ start -= 1;+ } else {+ break;+ }+ }+ if start < i && chars[start].is_ascii_alphabetic() {+ let mut end = i + 3;+ while end < chars.len() && is_url_char(chars[end]) {+ end += 1;+ }+ // Trim trailing punctuation that is usually sentence-level.+ while end > i + 3+ && matches!(+ chars[end - 1],+ '.' | ',' | ';' | ':' | '!' | '?' | ')' | ']' | '\'' | '"'+ )+ {+ end -= 1;+ }+ if end > i + 3 {+ out.push((start, end));+ i = end;+ continue;+ }+ }+ }+ i += 1;+ }+ out+}++impl Grid {+ /// Set (or clear) the active OSC 8 hyperlink applied to printed cells. An+ /// empty/`None` URI ends the current link.+ pub fn set_link(&mut self, uri: Option<&str>) {+ self.pen.link = match uri {+ Some(uri) if !uri.is_empty() => Some(self.intern_link(uri)),+ _ => None,+ };+ }++ /// Intern a hyperlink URI, returning its 1-based id (deduplicated; the table+ /// is capped so a pathological stream cannot grow it without bound).+ fn intern_link(&mut self, uri: &str) -> NonZeroU16 {+ if let Some(i) = self.links.iter().position(|u| u.as_ref() == uri) {+ return NonZeroU16::new(i as u16 + 1).expect("index + 1 is non-zero");+ }+ // u16::MAX distinct links is far past any real document; reuse the last+ // slot once saturated rather than overflow the id space.+ if self.links.len() < usize::from(u16::MAX) - 1 {+ self.links.push(uri.into());+ } else {+ *self.links.last_mut().expect("table is non-empty when full") = uri.into();+ }+ NonZeroU16::new(self.links.len() as u16).expect("len after push is non-zero")+ }++ /// The URI for a hyperlink id, if it is still in the table.+ pub fn link_uri(&self, id: NonZeroU16) -> Option<&str> {+ self.links+ .get(usize::from(id.get()) - 1)+ .map(|s| s.as_ref())+ }++ /// The hyperlink id of the cell at an absolute `(row, col)`, if any.+ pub fn link_at(&self, abs_row: usize, col: usize) -> Option<NonZeroU16> {+ self.abs_row(abs_row).get(col).and_then(|c| c.link)+ }+ /// Detect plain-text URLs across the visible viewport, returning each with+ /// the viewport `(row, col)` of its first character. Soft-wrapped rows are+ /// joined so a URL split across a wrap is found whole; hard line breaks end+ /// a URL.+ pub fn visible_urls(&self) -> Vec<UrlHit> {+ let mut chars: Vec<char> = Vec::new();+ let mut pos: Vec<(usize, usize)> = Vec::new();+ for y in 0..self.rows {+ let line = self.line_at_abs(self.view_to_abs(y));+ for (x, cell) in line.cells.iter().enumerate() {+ if cell.flags.contains(Flags::WIDE_CONT) {+ continue;+ }+ chars.push(cell.c);+ pos.push((y, x));+ }+ if !line.wrapped {+ chars.push('\n'); // a hard break terminates any URL+ pos.push((y, usize::MAX));+ }+ }+ find_urls(&chars)+ .into_iter()+ .map(|(s, e)| {+ let (row, col) = pos[s];+ UrlHit {+ url: chars[s..e].iter().collect(),+ row,+ col,+ }+ })+ .collect()+ }+}@@ -0,0 +1,1457 @@+//! The terminal screen: a grid of styled cells, a cursor, and the editing+//! operations the VT parser drives.++mod links;+mod search;+mod selection;++use std::collections::VecDeque;+use std::num::NonZeroU16;++use unicode_width::UnicodeWidthChar;++pub use links::UrlHit;+use search::SearchState;++/// Maximum scrollback lines retained for the main screen.+const SCROLLBACK_CAP: usize = 10_000;++/// A cell colour: terminal default, a palette index, or direct RGB.+#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]+pub enum Color {+ #[default]+ Default,+ Indexed(u8),+ Rgb(u8, u8, u8),+}++/// Per-cell style flags, packed into a `u16`.+#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]+pub struct Flags(u16);++impl Flags {+ pub const BOLD: Self = Self(1 << 0);+ pub const DIM: Self = Self(1 << 1);+ pub const ITALIC: Self = Self(1 << 2);+ pub const BLINK: Self = Self(1 << 4);+ pub const REVERSE: Self = Self(1 << 5);+ pub const HIDDEN: Self = Self(1 << 6);+ pub const STRIKE: Self = Self(1 << 7);+ /// Trailing column of a double-width glyph; holds no character of its own.+ pub const WIDE_CONT: Self = Self(1 << 8);+ pub const OVERLINE: Self = Self(1 << 9);++ pub const fn empty() -> Self {+ Self(0)+ }++ pub const fn union(self, other: Self) -> Self {+ Self(self.0 | other.0)+ }++ pub fn contains(self, other: Self) -> bool {+ self.0 & other.0 == other.0+ }++ pub fn insert(&mut self, other: Self) {+ self.0 |= other.0;+ }++ pub fn remove(&mut self, other: Self) {+ self.0 &= !other.0;+ }+}++/// Underline style (SGR 4 / 4:x / 21).+#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]+pub enum Underline {+ #[default]+ None,+ Single,+ Double,+ Curly,+ Dotted,+ Dashed,+}++/// Cursor shape (DECSCUSR).+#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]+pub enum CursorShape {+ #[default]+ Block,+ Underline,+ Beam,+}++/// Which mouse events the application has asked to receive (DECSET 9/1000-1003).+#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]+pub enum MouseProtocol {+ /// No reporting; the pointer drives local selection/scroll.+ #[default]+ Off,+ /// X10 (9): button presses only.+ X10,+ /// Normal (1000): button press and release.+ Normal,+ /// Button-event (1002): press, release, and motion while a button is held.+ Button,+ /// Any-event (1003): press, release, and all pointer motion.+ Any,+}++/// How mouse events are framed on the wire (default byte form, UTF-8, or SGR).+#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]+pub enum MouseEncoding {+ /// Legacy `CSI M Cb Cx Cy`, each value a byte offset by 32 (≤ 223).+ #[default]+ X10,+ /// As X10 but coordinates above 95 are UTF-8 encoded (DECSET 1005).+ Utf8,+ /// `CSI < Cb ; Cx ; Cy M/m`, decimal and unbounded (DECSET 1006).+ Sgr,+}++/// One grid cell: a character plus its rendering style.+#[derive(Clone, PartialEq, Eq, Debug)]+pub struct Cell {+ pub c: char,+ pub fg: Color,+ pub bg: Color,+ pub flags: Flags,+ pub underline: Underline,+ /// Underline colour; `Default` means "follow the foreground".+ pub underline_color: Color,+ /// Zero-width combining marks attached to `c`, in arrival order. `None` for+ /// the common case; the renderer stacks each over the base glyph. This is+ /// the grapheme cluster a future shaper (HarfBuzz) would consume.+ pub combining: Option<Box<str>>,+ /// OSC 8 hyperlink: a 1-based index into the grid's link table, or `None`.+ pub link: Option<NonZeroU16>,+}++impl Default for Cell {+ fn default() -> Self {+ Self {+ c: ' ',+ fg: Color::Default,+ bg: Color::Default,+ flags: Flags::empty(),+ underline: Underline::None,+ underline_color: Color::Default,+ combining: None,+ link: None,+ }+ }+}++#[derive(Clone, Copy, Debug, Default)]+struct Cursor {+ x: usize,+ y: usize,+}++/// Shell-integration prompt mark on a line (OSC 133): the start of a prompt,+/// the start of typed command input, the start of command output, or the line+/// where the command finished.+#[derive(Clone, Copy, PartialEq, Eq, Debug)]+pub enum PromptKind {+ PromptStart,+ CmdStart,+ OutputStart,+ CmdEnd,+}++/// One screen/scrollback row: its cells plus whether it soft-wrapped into the+/// next row (autowrap continuation, as opposed to a hard line break). The flag+/// is what lets resize rejoin and rewrap paragraphs.+#[derive(Clone, PartialEq, Eq, Debug)]+struct Line {+ cells: Vec<Cell>,+ wrapped: bool,+ /// OSC 133 mark attached to this (logical) line, if any.+ prompt: Option<PromptKind>,+}++impl Line {+ fn blank(cols: usize) -> Self {+ Self {+ cells: vec![Cell::default(); cols],+ wrapped: false,+ prompt: None,+ }+ }+}++/// A point in the combined scrollback+live coordinate space: `row` indexes+/// scrollback lines first (oldest at 0), then the live screen.+#[derive(Clone, Copy, PartialEq, Eq, Debug)]+pub struct Point {+ pub row: usize,+ pub col: usize,+}++/// The active screen plus cursor, scroll region, and current pen.+#[derive(Debug)]+pub struct Grid {+ cols: usize,+ rows: usize,+ lines: Vec<Line>,+ cursor: Cursor,+ saved: Cursor,+ /// Inclusive top/bottom rows of the scroll region.+ top: usize,+ bottom: usize,+ /// Template cell carrying the current SGR colours/flags.+ pen: Cell,+ autowrap: bool,+ origin: bool,+ insert: bool,+ /// Cursor parked past the last column, awaiting the next print to wrap.+ wrap_pending: bool,+ tabs: Vec<bool>,+ /// Saved primary screen while the alternate screen is active.+ alt_saved: Option<Vec<Line>>,+ /// Lines that have scrolled off the top of the main screen, newest last.+ scrollback: VecDeque<Line>,+ /// How many lines the viewport is scrolled back from the live bottom.+ view_offset: usize,+ cursor_shape: CursorShape,+ /// Whether the cursor shape is a blinking variant (DECSCUSR odd codes).+ cursor_blink: bool,+ cursor_visible: bool,+ /// Application cursor-keys mode (DECCKM): arrows send SS3 instead of CSI.+ app_cursor: bool,+ /// Cursor colour from OSC 12; `None` follows the cell under the cursor.+ cursor_color: Option<(u8, u8, u8)>,+ /// Active mouse selection as (anchor, head) in absolute coordinates.+ selection: Option<(Point, Point)>,+ /// Whether the selection is a rectangular block rather than linear flow.+ selection_block: bool,+ /// Bracketed paste mode (DECSET 2004): wrap pasted text in `ESC[200~`/`201~`.+ bracketed_paste: bool,+ /// Synchronized output (DECSET 2026): hold presentation while a frame is+ /// being assembled, so the screen never shows a half-drawn update.+ sync: bool,+ /// Which mouse events the application wants reported.+ mouse_protocol: MouseProtocol,+ /// Wire framing for those reports.+ mouse_encoding: MouseEncoding,+ /// Focus in/out reporting (DECSET 1004).+ focus_events: bool,+ /// Active incremental scrollback search, if any.+ search: Option<SearchState>,+ /// Characters that break a word for double-click selection.+ word_delimiters: String,+ /// History retention cap for the main screen.+ scrollback_cap: usize,+ /// Position of the last printed base cell, so a following zero-width+ /// combining mark can attach to it.+ last_base: Option<(usize, usize)>,+ /// OSC 8 hyperlink URIs; a cell's `link` is a 1-based index into this.+ links: Vec<Box<str>>,+}++fn default_tabs(cols: usize) -> Vec<bool> {+ (0..cols).map(|i| i % 8 == 0 && i != 0).collect()+}++/// Default characters that terminate a word for double-click selection (the+/// `word-delimiters` config key overrides this). `_`, `-`, `.`, `/`, `:`, `~`+/// are deliberately *not* delimiters so paths, URLs, and option flags select as+/// one unit.+const WORD_DELIMITERS: &str = " \t`!@#$%^&*()+=[]{}\\|;'\",<>?";++/// Whether `c` is part of a word (not whitespace, not in `delims`).+fn is_word(c: char, delims: &str) -> bool {+ !c.is_whitespace() && !delims.contains(c)+}++impl Grid {+ pub fn new(cols: usize, rows: usize) -> Self {+ let cols = cols.max(1);+ let rows = rows.max(1);+ Self {+ cols,+ rows,+ lines: vec![Line::blank(cols); rows],+ cursor: Cursor::default(),+ saved: Cursor::default(),+ top: 0,+ bottom: rows - 1,+ pen: Cell::default(),+ autowrap: true,+ origin: false,+ insert: false,+ wrap_pending: false,+ tabs: default_tabs(cols),+ alt_saved: None,+ scrollback: VecDeque::new(),+ view_offset: 0,+ cursor_shape: CursorShape::default(),+ cursor_blink: false,+ cursor_visible: true,+ cursor_color: None,+ app_cursor: false,+ selection: None,+ selection_block: false,+ bracketed_paste: false,+ sync: false,+ mouse_protocol: MouseProtocol::Off,+ mouse_encoding: MouseEncoding::X10,+ focus_events: false,+ search: None,+ word_delimiters: WORD_DELIMITERS.to_string(),+ scrollback_cap: SCROLLBACK_CAP,+ last_base: None,+ links: Vec::new(),+ }+ }++ /// Override the word-delimiter set; `None` keeps the built-in default.+ pub fn set_word_delimiters(&mut self, delims: Option<String>) {+ if let Some(d) = delims {+ self.word_delimiters = d;+ }+ }++ /// Set the scrollback retention cap, trimming history if it shrank.+ pub fn set_scrollback_cap(&mut self, cap: usize) {+ self.scrollback_cap = cap;+ while self.scrollback.len() > cap {+ self.scrollback.pop_front();+ }+ self.view_offset = self.view_offset.min(self.scrollback.len());+ }++ pub fn cols(&self) -> usize {+ self.cols+ }++ pub fn rows(&self) -> usize {+ self.rows+ }++ /// Resize the screen. On the main screen this reflows: soft-wrapped runs are+ /// rejoined into logical lines and rewrapped to the new width, across both+ /// scrollback and the live screen, keeping the cursor on its content. The+ /// alternate screen just clips, since its apps repaint on resize.+ pub fn resize(&mut self, cols: usize, rows: usize) {+ let cols = cols.max(1);+ let rows = rows.max(1);+ if self.alt_saved.is_some() {+ self.clip_resize(cols, rows);+ } else {+ self.reflow_resize(cols, rows);+ }+ self.cols = cols;+ self.rows = rows;+ self.top = 0;+ self.bottom = rows - 1;+ self.tabs = default_tabs(cols);+ self.cursor.x = self.cursor.x.min(cols - 1);+ self.cursor.y = self.cursor.y.min(rows - 1);+ self.wrap_pending = false;+ self.view_offset = 0;+ }++ /// Clip-resize the live screen and the saved primary (alternate screen).+ fn clip_resize(&mut self, cols: usize, rows: usize) {+ for line in &mut self.lines {+ line.cells.resize(cols, Cell::default());+ }+ self.lines.resize(rows, Line::blank(cols));+ if let Some(saved) = self.alt_saved.as_mut() {+ for line in saved.iter_mut() {+ line.cells.resize(cols, Cell::default());+ }+ saved.resize(rows, Line::blank(cols));+ }+ self.clear_selection();+ self.clear_search();+ }++ /// Reflow scrollback + live content to a new width, rewrapping soft-wrapped+ /// paragraphs and repositioning the cursor onto its character.+ fn reflow_resize(&mut self, cols: usize, rows: usize) {+ let cursor_abs = self.scrollback.len() + self.cursor.y;+ let total = self.scrollback.len() + self.lines.len();++ // 1. Rejoin soft-wrapped rows into logical lines. Track which logical+ // line the cursor falls in and its offset within that line.+ let mut logicals: Vec<Vec<Cell>> = Vec::new();+ let mut logical_marks: Vec<Option<PromptKind>> = Vec::new();+ let mut acc: Vec<Cell> = Vec::new();+ let mut acc_mark: Option<PromptKind> = None;+ let mut cur_logical = 0usize;+ let mut cur_off = 0usize;+ for abs in 0..total {+ if abs == cursor_abs {+ cur_logical = logicals.len();+ cur_off = acc.len() + self.cursor.x;+ }+ let line = if abs < self.scrollback.len() {+ &self.scrollback[abs]+ } else {+ &self.lines[abs - self.scrollback.len()]+ };+ // The mark on the first physical row of a logical line carries over.+ if acc.is_empty() {+ acc_mark = line.prompt;+ }+ acc.extend_from_slice(&line.cells);+ if !line.wrapped {+ logicals.push(std::mem::take(&mut acc));+ logical_marks.push(acc_mark.take());+ }+ }+ if !acc.is_empty() {+ logicals.push(acc);+ logical_marks.push(acc_mark);+ }+ // Drop trailing all-blank lines (empty screen below the content), but+ // never above the cursor's line, so the cursor keeps its row.+ let last_content = logicals+ .iter()+ .rposition(|l| l.iter().any(|c| *c != Cell::default()))+ .unwrap_or(0);+ logicals.truncate(last_content.max(cur_logical) + 1);+ logical_marks.truncate(last_content.max(cur_logical) + 1);++ // 2. Rewrap each logical line to the new width, recording where the+ // cursor lands. Trailing blanks are dropped so a hard line does not+ // rewrap its padding onto extra rows.+ let mut new_lines: Vec<Line> = Vec::new();+ let mut new_cursor_abs = 0usize;+ let mut new_cursor_col = 0usize;+ for (li, mut logical) in logicals.into_iter().enumerate() {+ let trim = logical+ .iter()+ .rposition(|c| *c != Cell::default())+ .map_or(0, |p| p + 1);+ logical.truncate(trim);+ let first = new_lines.len();+ let chunks = logical.len().div_ceil(cols).max(1);+ for ci in 0..chunks {+ let start = ci * cols;+ let end = (start + cols).min(logical.len());+ let mut cells = logical.get(start..end).unwrap_or(&[]).to_vec();+ cells.resize(cols, Cell::default());+ new_lines.push(Line {+ cells,+ wrapped: ci + 1 < chunks,+ // The mark belongs to the first physical row of the line.+ prompt: if ci == 0 { logical_marks[li] } else { None },+ });+ }+ if li == cur_logical {+ let off = cur_off.min(logical.len());+ let chunk = (off / cols).min(chunks - 1);+ new_cursor_abs = first + chunk;+ new_cursor_col = (off - chunk * cols).min(cols - 1);+ }+ }++ // 3. The last `rows` lines are the live screen; the rest is scrollback.+ let live_start = new_lines.len().saturating_sub(rows);+ let mut scrollback: VecDeque<Line> = new_lines.drain(0..live_start).collect();+ let mut live = new_lines;+ while live.len() < rows {+ live.push(Line::blank(cols));+ }+ while scrollback.len() > self.scrollback_cap {+ scrollback.pop_front();+ }++ self.cursor.y = new_cursor_abs.saturating_sub(live_start).min(rows - 1);+ self.cursor.x = new_cursor_col;+ self.lines = live;+ self.scrollback = scrollback;+ self.clear_selection();+ self.clear_search();+ }++ pub fn cursor(&self) -> (usize, usize) {+ (self.cursor.x, self.cursor.y)+ }++ // --- pen / attributes ---++ pub fn pen_mut(&mut self) -> &mut Cell {+ &mut self.pen+ }++ pub fn reset_pen(&mut self) {+ self.pen = Cell::default();+ }++ pub fn set_autowrap(&mut self, on: bool) {+ self.autowrap = on;+ }++ pub fn set_origin(&mut self, on: bool) {+ self.origin = on;+ self.move_to(0, 0);+ }++ pub fn set_insert(&mut self, on: bool) {+ self.insert = on;+ }++ pub fn autowrap(&self) -> bool {+ self.autowrap+ }++ pub fn origin(&self) -> bool {+ self.origin+ }++ pub fn insert(&self) -> bool {+ self.insert+ }++ pub fn alt_active(&self) -> bool {+ self.alt_saved.is_some()+ }++ pub fn set_cursor_shape(&mut self, shape: CursorShape) {+ self.cursor_shape = shape;+ }++ pub fn cursor_shape(&self) -> CursorShape {+ self.cursor_shape+ }++ pub fn set_cursor_blink(&mut self, blink: bool) {+ self.cursor_blink = blink;+ }++ pub fn cursor_blink(&self) -> bool {+ self.cursor_blink+ }++ pub fn set_cursor_visible(&mut self, visible: bool) {+ self.cursor_visible = visible;+ }++ pub fn cursor_visible(&self) -> bool {+ self.cursor_visible+ }++ pub fn set_cursor_color(&mut self, color: Option<(u8, u8, u8)>) {+ self.cursor_color = color;+ }++ pub fn cursor_color(&self) -> Option<(u8, u8, u8)> {+ self.cursor_color+ }++ pub fn set_app_cursor(&mut self, on: bool) {+ self.app_cursor = on;+ }++ pub fn app_cursor(&self) -> bool {+ self.app_cursor+ }++ // --- printing ---++ /// Place a printable character at the cursor, honouring width and autowrap.+ pub fn print(&mut self, c: char) {+ let width = c.width().unwrap_or(0);+ if width == 0 {+ // A zero-width combining mark attaches to the last base cell.+ self.add_combining(c);+ return;+ }+ if self.wrap_pending {+ self.cursor.x = 0;+ self.lines[self.cursor.y].wrapped = true;+ self.line_feed();+ self.wrap_pending = false;+ }+ if width == 2 && self.cursor.x + 1 >= self.cols {+ // A double-width glyph cannot straddle the right edge: wrap first.+ if self.autowrap {+ self.cursor.x = 0;+ self.lines[self.cursor.y].wrapped = true;+ self.line_feed();+ } else {+ return;+ }+ }+ if self.insert {+ self.shift_right(width);+ }++ let (x, y) = (self.cursor.x, self.cursor.y);+ let mut cell = self.pen.clone();+ cell.c = c;+ cell.combining = None;+ cell.flags.remove(Flags::WIDE_CONT);+ self.lines[y].cells[x] = cell;+ self.last_base = Some((x, y));+ if width == 2 && x + 1 < self.cols {+ let mut cont = self.pen.clone();+ cont.c = ' ';+ cont.combining = None;+ cont.flags.insert(Flags::WIDE_CONT);+ self.lines[y].cells[x + 1] = cont;+ }++ let advance = width;+ if self.cursor.x + advance >= self.cols {+ if self.autowrap {+ self.cursor.x = self.cols - 1;+ self.wrap_pending = true;+ } else {+ self.cursor.x = self.cols - 1;+ }+ } else {+ self.cursor.x += advance;+ }+ }++ /// Attach a zero-width combining mark to the most recently printed base+ /// cell. Capped so a malicious stream of marks cannot grow a cell unbounded.+ fn add_combining(&mut self, mark: char) {+ const MAX_MARKS: usize = 8;+ let Some((x, y)) = self.last_base else {+ return;+ };+ let Some(cell) = self.lines.get_mut(y).and_then(|l| l.cells.get_mut(x)) else {+ return;+ };+ let mut s: String = cell.combining.take().map(String::from).unwrap_or_default();+ if s.chars().count() < MAX_MARKS {+ s.push(mark);+ }+ cell.combining = Some(s.into_boxed_str());+ }++ fn shift_right(&mut self, n: usize) {+ let (x, y) = (self.cursor.x, self.cursor.y);+ let end = self.cols;+ let blank = self.pen_blank();+ let row = &mut self.lines[y].cells;+ for i in (x + n..end).rev() {+ row[i] = row[i - n].clone();+ }+ for cell in &mut row[x..(x + n).min(end)] {+ *cell = blank.clone();+ }+ }++ fn pen_blank(&self) -> Cell {+ // A space carrying only the current background (back-colour erase).+ Cell {+ bg: self.pen.bg,+ ..Cell::default()+ }+ }++ // --- cursor movement ---++ fn region(&self) -> (usize, usize) {+ if self.origin {+ (self.top, self.bottom)+ } else {+ (0, self.rows - 1)+ }+ }++ pub fn move_to(&mut self, x: usize, y: usize) {+ let (rt, rb) = self.region();+ self.cursor.x = x.min(self.cols - 1);+ self.cursor.y = (y + rt).min(rb).max(rt);+ self.wrap_pending = false;+ }++ pub fn move_to_col(&mut self, x: usize) {+ self.cursor.x = x.min(self.cols - 1);+ self.wrap_pending = false;+ }++ pub fn move_to_row(&mut self, y: usize) {+ let (rt, rb) = self.region();+ self.cursor.y = (y + rt).min(rb).max(rt);+ self.wrap_pending = false;+ }++ pub fn cursor_up(&mut self, n: usize) {+ let (rt, _) = self.region();+ self.cursor.y = self.cursor.y.saturating_sub(n).max(rt);+ self.wrap_pending = false;+ }++ pub fn cursor_down(&mut self, n: usize) {+ let (_, rb) = self.region();+ self.cursor.y = (self.cursor.y + n).min(rb);+ self.wrap_pending = false;+ }++ pub fn cursor_fwd(&mut self, n: usize) {+ self.cursor.x = (self.cursor.x + n).min(self.cols - 1);+ self.wrap_pending = false;+ }++ pub fn cursor_back(&mut self, n: usize) {+ self.cursor.x = self.cursor.x.saturating_sub(n);+ self.wrap_pending = false;+ }++ pub fn save_cursor(&mut self) {+ self.saved = self.cursor;+ }++ pub fn restore_cursor(&mut self) {+ self.cursor = self.saved;+ self.cursor.x = self.cursor.x.min(self.cols - 1);+ self.cursor.y = self.cursor.y.min(self.rows - 1);+ self.wrap_pending = false;+ }++ // --- line discipline ---++ pub fn carriage_return(&mut self) {+ self.cursor.x = 0;+ self.wrap_pending = false;+ }++ pub fn backspace(&mut self) {+ self.cursor.x = self.cursor.x.saturating_sub(1);+ self.wrap_pending = false;+ }++ /// LF/VT/FF: move down one row, scrolling at the region bottom.+ pub fn line_feed(&mut self) {+ if self.cursor.y == self.bottom {+ self.scroll_up(1);+ } else if self.cursor.y < self.rows - 1 {+ self.cursor.y += 1;+ }+ self.wrap_pending = false;+ }++ /// RI: move up one row, scrolling down at the region top.+ pub fn reverse_index(&mut self) {+ if self.cursor.y == self.top {+ self.scroll_down(1);+ } else if self.cursor.y > 0 {+ self.cursor.y -= 1;+ }+ self.wrap_pending = false;+ }++ /// NEL: carriage return plus line feed.+ pub fn next_line(&mut self) {+ self.carriage_return();+ self.line_feed();+ }++ // --- tab stops ---++ pub fn tab(&mut self) {+ let mut x = self.cursor.x + 1;+ while x < self.cols && !self.tabs[x] {+ x += 1;+ }+ self.cursor.x = x.min(self.cols - 1);+ self.wrap_pending = false;+ }++ pub fn set_tab(&mut self) {+ if self.cursor.x < self.cols {+ self.tabs[self.cursor.x] = true;+ }+ }++ pub fn clear_tab(&mut self) {+ if self.cursor.x < self.cols {+ self.tabs[self.cursor.x] = false;+ }+ }++ pub fn clear_all_tabs(&mut self) {+ self.tabs.iter_mut().for_each(|t| *t = false);+ }++ // --- scrolling within the region ---++ pub fn set_scroll_region(&mut self, top: usize, bottom: usize) {+ if top < bottom && bottom < self.rows {+ self.top = top;+ self.bottom = bottom;+ } else {+ self.top = 0;+ self.bottom = self.rows - 1;+ }+ self.move_to(0, 0);+ }++ pub fn scroll_up(&mut self, n: usize) {+ let n = n.min(self.bottom - self.top + 1);+ // Lines leaving the top of the *whole* main screen become scrollback;+ // a DECSTBM region scroll (top > 0) or the alt screen does not.+ if self.top == 0 && self.alt_saved.is_none() {+ for y in 0..n {+ let line = std::mem::replace(&mut self.lines[y], Line::blank(self.cols));+ self.scrollback.push_back(line);+ }+ let mut evicted = 0;+ while self.scrollback.len() > self.scrollback_cap {+ self.scrollback.pop_front();+ evicted += 1;+ }+ if evicted > 0 {+ self.shift_selection(evicted);+ self.shift_search(evicted);+ }+ // Keep a scrolled-back viewport anchored to the same content.+ if self.view_offset > 0 {+ self.view_offset = (self.view_offset + n).min(self.scrollback.len());+ }+ }+ for y in self.top..=self.bottom {+ if y + n <= self.bottom {+ self.lines.swap(y, y + n);+ }+ }+ for y in (self.bottom + 1 - n)..=self.bottom {+ self.blank_row(y);+ }+ }++ pub fn scroll_down(&mut self, n: usize) {+ let n = n.min(self.bottom - self.top + 1);+ for y in (self.top..=self.bottom).rev() {+ if y >= self.top + n {+ self.lines.swap(y, y - n);+ }+ }+ for y in self.top..(self.top + n) {+ self.blank_row(y);+ }+ }++ fn blank_row(&mut self, y: usize) {+ let blank = self.pen_blank();+ let line = &mut self.lines[y];+ for cell in &mut line.cells {+ *cell = blank.clone();+ }+ line.wrapped = false;+ }++ // --- erase ---++ /// ED: 0=below, 1=above, 2/3=all.+ pub fn erase_display(&mut self, mode: u16) {+ let (x, y) = (self.cursor.x, self.cursor.y);+ match mode {+ 0 => {+ self.erase_in_row(y, x, self.cols);+ for r in (y + 1)..self.rows {+ self.blank_row(r);+ }+ }+ 1 => {+ for r in 0..y {+ self.blank_row(r);+ }+ self.erase_in_row(y, 0, x + 1);+ }+ _ => {+ for r in 0..self.rows {+ self.blank_row(r);+ }+ }+ }+ self.wrap_pending = false;+ }++ /// EL: 0=right, 1=left, 2=line.+ pub fn erase_line(&mut self, mode: u16) {+ let (x, y) = (self.cursor.x, self.cursor.y);+ match mode {+ 0 => self.erase_in_row(y, x, self.cols),+ 1 => self.erase_in_row(y, 0, x + 1),+ _ => self.erase_in_row(y, 0, self.cols),+ }+ self.wrap_pending = false;+ }++ /// ECH: erase n characters from the cursor without moving it.+ pub fn erase_chars(&mut self, n: usize) {+ let (x, y) = (self.cursor.x, self.cursor.y);+ self.erase_in_row(y, x, (x + n).min(self.cols));+ }++ fn erase_in_row(&mut self, y: usize, from: usize, to: usize) {+ let blank = self.pen_blank();+ for cell in &mut self.lines[y].cells[from..to.min(self.cols)] {+ *cell = blank.clone();+ }+ }++ // --- intra-line editing ---++ /// ICH: insert n blanks at the cursor, shifting the rest right.+ pub fn insert_chars(&mut self, n: usize) {+ let saved = self.insert;+ self.insert = true;+ self.shift_right(n.min(self.cols));+ self.insert = saved;+ }++ /// DCH: delete n characters at the cursor, shifting the rest left.+ pub fn delete_chars(&mut self, n: usize) {+ let (x, y) = (self.cursor.x, self.cursor.y);+ let n = n.min(self.cols - x);+ let blank = self.pen_blank();+ let row = &mut self.lines[y].cells;+ for i in x..self.cols {+ row[i] = if i + n < self.cols {+ row[i + n].clone()+ } else {+ blank.clone()+ };+ }+ }++ /// IL: insert n blank lines at the cursor row, within the scroll region.+ pub fn insert_lines(&mut self, n: usize) {+ if self.cursor.y < self.top || self.cursor.y > self.bottom {+ return;+ }+ let n = n.min(self.bottom - self.cursor.y + 1);+ for y in (self.cursor.y..=self.bottom).rev() {+ if y >= self.cursor.y + n {+ self.lines.swap(y, y - n);+ }+ }+ for y in self.cursor.y..(self.cursor.y + n) {+ self.blank_row(y);+ }+ }++ /// DL: delete n lines at the cursor row, within the scroll region.+ pub fn delete_lines(&mut self, n: usize) {+ if self.cursor.y < self.top || self.cursor.y > self.bottom {+ return;+ }+ let n = n.min(self.bottom - self.cursor.y + 1);+ for y in self.cursor.y..=self.bottom {+ if y + n <= self.bottom {+ self.lines.swap(y, y + n);+ }+ }+ for y in (self.bottom + 1 - n)..=self.bottom {+ self.blank_row(y);+ }+ }++ // --- alternate screen ---++ pub fn enter_alt_screen(&mut self) {+ if self.alt_saved.is_some() {+ return;+ }+ self.view_offset = 0;+ let blank = vec![Line::blank(self.cols); self.rows];+ self.alt_saved = Some(std::mem::replace(&mut self.lines, blank));+ }++ pub fn leave_alt_screen(&mut self) {+ if let Some(main) = self.alt_saved.take() {+ self.lines = main;+ }+ }++ // --- scrollback viewport ---++ /// Scroll the viewport by `delta` lines: positive = back into history,+ /// negative = toward the live screen. No-op on the alternate screen.+ pub fn scroll_view(&mut self, delta: isize) {+ if self.alt_saved.is_some() {+ return;+ }+ let max = self.scrollback.len() as isize;+ self.view_offset = (self.view_offset as isize + delta).clamp(0, max) as usize;+ }++ pub fn scroll_to_bottom(&mut self) {+ self.view_offset = 0;+ }++ /// Whether the viewport is showing the live screen (not scrolled back).+ pub fn view_at_bottom(&self) -> bool {+ self.view_offset == 0+ }++ /// One page (a screenful) of lines, for page-scroll bindings.+ pub fn page(&self) -> usize {+ self.rows.max(1)+ }++ /// The cells shown at viewport row `y` (0 = top of the window), accounting+ /// for the scrollback offset. May differ from `cols` in width if the line+ /// predates a resize (no reflow yet), so callers must not assume length.+ pub fn view_row(&self, y: usize) -> &[Cell] {+ let start = self.scrollback.len() - self.view_offset;+ let idx = start + y;+ if idx < self.scrollback.len() {+ &self.scrollback[idx].cells+ } else {+ &self.lines[idx - self.scrollback.len()].cells+ }+ }++ // --- selection ---++ /// The absolute row currently shown at viewport row `y`.+ pub fn view_to_abs(&self, y: usize) -> usize {+ self.scrollback.len() - self.view_offset + y+ }++ /// The line at an absolute row (scrollback first, then the live screen).+ fn line_at_abs(&self, abs: usize) -> &Line {+ if abs < self.scrollback.len() {+ &self.scrollback[abs]+ } else {+ &self.lines[abs - self.scrollback.len()]+ }+ }++ /// Cells of an absolute row (scrollback first, then the live screen).+ fn abs_row(&self, row: usize) -> &[Cell] {+ if row < self.scrollback.len() {+ &self.scrollback[row].cells+ } else {+ &self.lines[row - self.scrollback.len()].cells+ }+ }++ /// Total rows across scrollback and the live screen.+ fn total_lines(&self) -> usize {+ self.scrollback.len() + self.lines.len()+ }++ /// The OSC 133 mark on an absolute row, if any.+ fn abs_prompt(&self, row: usize) -> Option<PromptKind> {+ if row < self.scrollback.len() {+ self.scrollback[row].prompt+ } else {+ self.lines+ .get(row - self.scrollback.len())+ .and_then(|l| l.prompt)+ }+ }++ // --- shell integration (OSC 133) ---++ /// Attach an OSC 133 prompt mark to the live line under the cursor.+ pub fn set_prompt_mark(&mut self, kind: PromptKind) {+ let y = self.cursor.y;+ if let Some(line) = self.lines.get_mut(y) {+ line.prompt = Some(kind);+ }+ }++ /// Scroll the viewport to the previous (`up`) or next prompt, placing that+ /// prompt line at the top of the window. No-op on the alternate screen or+ /// when there is no prompt in that direction.+ pub fn jump_prompt(&mut self, up: bool) {+ if self.alt_saved.is_some() {+ return;+ }+ let top = self.scrollback.len().saturating_sub(self.view_offset);+ let total = self.total_lines();+ let is_prompt = |k: Option<PromptKind>| k == Some(PromptKind::PromptStart);+ let target = if up {+ (0..top).rev().find(|&r| is_prompt(self.abs_prompt(r)))+ } else {+ ((top + 1)..total).find(|&r| is_prompt(self.abs_prompt(r)))+ };+ if let Some(t) = target {+ let offset = self.scrollback.len() as isize - t as isize;+ self.view_offset = offset.clamp(0, self.scrollback.len() as isize) as usize;+ }+ }++ /// Text of the most recent command's output: the rows from the last+ /// output-start (OSC 133 C) up to the command-end (D) or next prompt.+ pub fn last_command_output(&self) -> Option<String> {+ let total = self.total_lines();+ let start = (0..total)+ .rev()+ .find(|&r| self.abs_prompt(r) == Some(PromptKind::OutputStart))?;+ let mut lines: Vec<String> = Vec::new();+ for r in start..total {+ if r > start+ && matches!(+ self.abs_prompt(r),+ Some(PromptKind::CmdEnd | PromptKind::PromptStart)+ )+ {+ break;+ }+ lines.push(self.row_slice_text(r, 0, usize::MAX).trim_end().to_string());+ }+ // Drop trailing blank rows (e.g. the empty live screen below the output).+ while lines.last().is_some_and(|l| l.is_empty()) {+ lines.pop();+ }+ let mut out = lines.join("\n");+ out.push('\n');+ Some(out)+ }++ pub fn set_bracketed_paste(&mut self, on: bool) {+ self.bracketed_paste = on;+ }++ pub fn bracketed_paste(&self) -> bool {+ self.bracketed_paste+ }++ pub fn set_sync(&mut self, on: bool) {+ self.sync = on;+ }++ pub fn sync_active(&self) -> bool {+ self.sync+ }++ pub fn set_mouse_protocol(&mut self, protocol: MouseProtocol) {+ self.mouse_protocol = protocol;+ }++ pub fn mouse_protocol(&self) -> MouseProtocol {+ self.mouse_protocol+ }++ pub fn set_mouse_encoding(&mut self, encoding: MouseEncoding) {+ self.mouse_encoding = encoding;+ }++ pub fn mouse_encoding(&self) -> MouseEncoding {+ self.mouse_encoding+ }++ pub fn set_focus_events(&mut self, on: bool) {+ self.focus_events = on;+ }++ pub fn focus_events(&self) -> bool {+ self.focus_events+ }++ // --- inspection (logging + tests) ---++ /// The visible text of one row, trailing blanks trimmed.+ #[cfg(test)]+ pub fn row_text(&self, y: usize) -> String {+ self.lines[y]+ .cells+ .iter()+ .filter(|c| !c.flags.contains(Flags::WIDE_CONT))+ .map(|c| c.c)+ .collect::<String>()+ .trim_end()+ .to_string()+ }++ pub fn cell(&self, x: usize, y: usize) -> &Cell {+ &self.lines[y].cells[x]+ }+}++#[cfg(test)]+mod tests {+ use super::*;++ #[test]+ fn prints_and_wraps() {+ let mut g = Grid::new(4, 2);+ for c in "abcde".chars() {+ g.print(c);+ }+ assert_eq!(g.row_text(0), "abcd");+ assert_eq!(g.row_text(1), "e");+ assert_eq!(g.cursor(), (1, 1));+ }++ #[test]+ fn combining_marks_attach_to_base_cell() {+ let mut g = Grid::new(8, 1);+ // "e" + COMBINING ACUTE ACCENT + "x": the mark joins the 'e' cell, the+ // 'x' lands in the next cell (the mark advanced nothing).+ for c in "e\u{0301}x".chars() {+ g.print(c);+ }+ assert_eq!(g.cell(0, 0).c, 'e');+ assert_eq!(g.cell(0, 0).combining.as_deref(), Some("\u{0301}"));+ assert_eq!(g.cell(1, 0).c, 'x');+ assert_eq!(g.cursor(), (2, 0));+ // Copied text round-trips the full grapheme cluster.+ g.start_selection(0, 0);+ g.extend_selection(0, 1);+ assert_eq!(g.selection_text().as_deref(), Some("e\u{0301}x"));+ }++ #[test]+ fn detects_urls_trimming_trailing_punctuation() {+ let mut g = Grid::new(60, 2);+ for c in "see https://example.com/p?q=1, ok".chars() {+ g.print(c);+ }+ let hits = g.visible_urls();+ assert_eq!(hits.len(), 1);+ assert_eq!(hits[0].url, "https://example.com/p?q=1");+ assert_eq!((hits[0].row, hits[0].col), (0, 4));+ }++ #[test]+ fn detects_url_across_a_soft_wrap() {+ // 20 cols: the URL wraps, but autowrap sets the wrapped flag so it rejoins.+ let mut g = Grid::new(20, 3);+ for c in "x https://example.com/averylongpath".chars() {+ g.print(c);+ }+ let hits = g.visible_urls();+ assert_eq!(hits.len(), 1);+ assert_eq!(hits[0].url, "https://example.com/averylongpath");+ }++ #[test]+ fn prompt_mark_survives_reflow() {+ let mut g = Grid::new(10, 4);+ g.set_prompt_mark(PromptKind::OutputStart); // marks line 0+ for c in "hello".chars() {+ g.print(c);+ }+ g.resize(6, 4); // rewrap to a narrower width+ // The mark followed its logical line, so the output is still found.+ assert_eq!(g.last_command_output().as_deref(), Some("hello\n"));+ }++ #[test]+ fn carriage_return_and_line_feed() {+ let mut g = Grid::new(8, 4);+ for c in "hi".chars() {+ g.print(c);+ }+ g.carriage_return();+ g.line_feed();+ for c in "yo".chars() {+ g.print(c);+ }+ assert_eq!(g.row_text(0), "hi");+ assert_eq!(g.row_text(1), "yo");+ }++ #[test]+ fn line_feed_scrolls_at_bottom() {+ let mut g = Grid::new(4, 2);+ g.print('a');+ g.next_line();+ g.print('b');+ g.next_line(); // scrolls: row0 <- "b", row1 blank+ assert_eq!(g.row_text(0), "b");+ assert_eq!(g.row_text(1), "");+ }++ #[test]+ fn erase_line_to_right() {+ let mut g = Grid::new(6, 1);+ for c in "abcdef".chars() {+ g.print(c);+ }+ g.move_to(2, 0);+ g.erase_line(0);+ assert_eq!(g.row_text(0), "ab");+ }++ #[test]+ fn delete_and_insert_chars() {+ let mut g = Grid::new(6, 1);+ for c in "abcdef".chars() {+ g.print(c);+ }+ g.move_to(1, 0);+ g.delete_chars(2);+ assert_eq!(g.row_text(0), "adef");+ g.move_to(1, 0);+ g.insert_chars(2);+ assert_eq!(g.row_text(0), "a def");+ }++ #[test]+ fn scrollback_captures_scrolled_lines() {+ let mut g = Grid::new(8, 2);+ for c in ['1', '2', '3'] {+ g.print(c);+ g.next_line();+ }+ // Live screen: newest line on top, cleared line below.+ assert_eq!(g.view_row(0)[0].c, '3');+ assert!(g.view_at_bottom());+ // Scroll back to reveal the two captured lines.+ g.scroll_view(2);+ assert!(!g.view_at_bottom());+ assert_eq!(g.view_row(0)[0].c, '1');+ assert_eq!(g.view_row(1)[0].c, '2');+ g.scroll_to_bottom();+ assert_eq!(g.view_row(0)[0].c, '3');+ }++ #[test]+ fn wide_char_occupies_two_columns() {+ let mut g = Grid::new(6, 1);+ g.print('世');+ g.print('x');+ assert_eq!(g.cell(0, 0).c, '世');+ assert!(g.cell(1, 0).flags.contains(Flags::WIDE_CONT));+ assert_eq!(g.cell(2, 0).c, 'x');+ }++ #[test]+ fn selection_extracts_text_across_rows() {+ let mut g = Grid::new(8, 2);+ for c in "abcd".chars() {+ g.print(c);+ }+ g.carriage_return();+ g.line_feed();+ for c in "efgh".chars() {+ g.print(c);+ }+ // Select "cd" on row 0 through "ef" on row 1 (rows are live: abs 0,1).+ g.start_selection(0, 2);+ g.extend_selection(1, 1);+ assert!(g.is_selected(0, 3));+ assert!(g.is_selected(1, 0));+ assert!(!g.is_selected(1, 2));+ assert_eq!(g.selection_text().as_deref(), Some("cd\nef"));+ }++ #[test]+ fn select_word_spans_one_word() {+ let mut g = Grid::new(16, 1);+ for c in "foo bar baz".chars() {+ g.print(c);+ }+ g.select_word(0, 5); // inside "bar"+ assert_eq!(g.selection_text().as_deref(), Some("bar"));+ }++ #[test]+ fn select_word_breaks_on_delimiters_but_keeps_paths() {+ let mut g = Grid::new(32, 1);+ for c in "run /usr/bin:next".chars() {+ g.print(c);+ }+ // '/' and ':' are not delimiters, so the path selects whole.+ g.select_word(0, 7); // inside "/usr/bin:next"+ assert_eq!(g.selection_text().as_deref(), Some("/usr/bin:next"));+ // '(' is a delimiter.+ let mut g = Grid::new(16, 1);+ for c in "f(arg)".chars() {+ g.print(c);+ }+ g.select_word(0, 2); // inside "arg"+ assert_eq!(g.selection_text().as_deref(), Some("arg"));+ }++ #[test]+ fn block_selection_is_rectangular() {+ let mut g = Grid::new(8, 3);+ for line in ["abcd", "efgh", "ijkl"] {+ for c in line.chars() {+ g.print(c);+ }+ g.carriage_return();+ g.line_feed();+ }+ // A block from (row0,col1) to (row2,col2) takes columns 1..=2 each row.+ g.start_block_selection(0, 1);+ g.extend_selection(2, 2);+ assert!(g.is_selected(0, 1));+ assert!(g.is_selected(2, 2));+ assert!(!g.is_selected(1, 3));+ assert!(!g.is_selected(1, 0));+ assert_eq!(g.selection_text().as_deref(), Some("bc\nfg\njk"));+ }++ #[test]+ fn search_finds_matches_across_history() {+ let mut g = Grid::new(16, 2);+ for line in ["alpha", "beta", "ALPHA", "gamma"] {+ for c in line.chars() {+ g.print(c);+ }+ g.carriage_return();+ g.line_feed();+ }+ // Case-insensitive (no uppercase in query) matches both alphas.+ g.set_search("alpha");+ assert_eq!(g.search_count(), (2, 2)); // focus starts on the latest hit+ let spans0 = g.search_spans_on(0);+ assert_eq!(spans0, vec![(0, 4, false)]);+ // Smart case: an uppercase letter restricts to the exact-case hit.+ g.set_search("ALPHA");+ assert_eq!(g.search_count(), (1, 1));+ assert_eq!(g.search_spans_on(2), vec![(0, 4, true)]);+ // Stepping wraps and the no-match query reports zero.+ g.set_search("zzz");+ assert_eq!(g.search_count(), (0, 0));+ assert_eq!(g.search_query(), Some("zzz"));+ g.clear_search();+ assert_eq!(g.search_query(), None);+ }++ #[test]+ fn reflow_rewraps_a_wrapped_paragraph() {+ let mut g = Grid::new(4, 3);+ for c in "abcdefgh".chars() {+ g.print(c); // wraps: "abcd" | "efgh" | (cursor parked)+ }+ // Widen: the two wrapped rows rejoin into one logical line.+ g.resize(8, 3);+ assert_eq!(g.row_text(0), "abcdefgh");+ assert_eq!(g.cursor(), (7, 0)); // cursor follows the content end+ // Narrow back: it rewraps to the smaller width without losing text.+ g.resize(4, 3);+ assert_eq!(g.row_text(0), "abcd");+ assert_eq!(g.row_text(1), "efgh");+ }++ #[test]+ fn reflow_preserves_hard_breaks() {+ let mut g = Grid::new(10, 3);+ for c in "one".chars() {+ g.print(c);+ }+ g.carriage_return();+ g.line_feed();+ for c in "two".chars() {+ g.print(c);+ }+ // A hard newline must not be rejoined when the width changes.+ g.resize(6, 3);+ assert_eq!(g.row_text(0), "one");+ assert_eq!(g.row_text(1), "two");+ }++ #[test]+ fn scroll_region_limits_line_feed() {+ let mut g = Grid::new(4, 4);+ g.set_scroll_region(1, 2);+ g.move_to(0, 2); // bottom of region (origin off: absolute row 2)+ g.print('x');+ g.move_to(0, 2);+ g.line_feed(); // at region bottom: scroll [1,2] up, x moves to row 1+ assert_eq!(g.row_text(1), "x");+ assert_eq!(g.row_text(2), "");+ }+}@@ -0,0 +1,148 @@+use super::*;++/// One scrollback-search hit: a run of `len` cells at absolute `(row, col)`.+#[derive(Clone, Copy, PartialEq, Eq, Debug)]+struct Match {+ row: usize,+ col: usize,+ len: usize,+}++/// Incremental scrollback search: the query, every hit, and the focused one.+#[derive(Clone, Debug, Default)]+pub(super) struct SearchState {+ query: String,+ matches: Vec<Match>,+ current: usize,+}++impl Grid {+ // --- scrollback search ---++ /// Set the search query and recompute matches over scrollback + the live+ /// screen, focusing the most recent hit and scrolling it into view. An+ /// empty query keeps search mode active but clears the hit list.+ pub fn set_search(&mut self, query: &str) {+ let matches = if query.is_empty() {+ Vec::new()+ } else {+ self.compute_matches(query)+ };+ let current = matches.len().saturating_sub(1);+ self.search = Some(SearchState {+ query: query.to_string(),+ matches,+ current,+ });+ self.jump_to_current();+ }++ /// Move the focused match `forward` (toward newer) or back (toward older),+ /// wrapping, and scroll it into view.+ pub fn search_step(&mut self, forward: bool) {+ if let Some(s) = self.search.as_mut()+ && !s.matches.is_empty()+ {+ let n = s.matches.len();+ s.current = if forward {+ (s.current + 1) % n+ } else {+ (s.current + n - 1) % n+ };+ }+ self.jump_to_current();+ }++ pub fn clear_search(&mut self) {+ self.search = None;+ }++ /// The current query, while search mode is active.+ pub fn search_query(&self) -> Option<&str> {+ self.search.as_ref().map(|s| s.query.as_str())+ }++ /// `(focused match index 1-based, total matches)` for the search prompt.+ pub fn search_count(&self) -> (usize, usize) {+ self.search.as_ref().map_or((0, 0), |s| {+ let total = s.matches.len();+ (if total == 0 { 0 } else { s.current + 1 }, total)+ })+ }++ /// Match spans `(lo, hi, is_current)` on absolute row `row`, for highlight.+ pub fn search_spans_on(&self, row: usize) -> Vec<(usize, usize, bool)> {+ let Some(s) = self.search.as_ref() else {+ return Vec::new();+ };+ s.matches+ .iter()+ .enumerate()+ .filter(|(_, m)| m.row == row && m.len > 0)+ .map(|(i, m)| (m.col, m.col + m.len - 1, i == s.current))+ .collect()+ }++ fn compute_matches(&self, query: &str) -> Vec<Match> {+ // Smart case: an uppercase letter in the query forces case sensitivity.+ let sensitive = query.chars().any(|c| c.is_ascii_uppercase());+ let fold = |c: char| {+ if sensitive { c } else { c.to_ascii_lowercase() }+ };+ let needle: Vec<char> = query.chars().map(fold).collect();+ if needle.is_empty() {+ return Vec::new();+ }+ let total = self.scrollback.len() + self.rows;+ let mut matches = Vec::new();+ for row in 0..total {+ let hay: Vec<char> = self.abs_row(row).iter().map(|cell| fold(cell.c)).collect();+ let mut i = 0;+ while i + needle.len() <= hay.len() {+ if hay[i..i + needle.len()] == needle[..] {+ matches.push(Match {+ row,+ col: i,+ len: needle.len(),+ });+ i += needle.len();+ } else {+ i += 1;+ }+ }+ }+ matches+ }++ /// Scroll the viewport so the focused match is on screen, centering it only+ /// when it would otherwise be off the visible range.+ fn jump_to_current(&mut self) {+ let Some(abs) = self+ .search+ .as_ref()+ .and_then(|s| s.matches.get(s.current))+ .map(|m| m.row)+ else {+ return;+ };+ let sb = self.scrollback.len();+ let top = sb - self.view_offset;+ let bottom = top + self.rows - 1;+ if abs < top || abs > bottom {+ let target = sb as isize + self.rows as isize / 2 - abs as isize;+ self.view_offset = target.clamp(0, sb as isize) as usize;+ }+ }++ /// Slide search hits up by `n` rows after scrollback eviction, dropping any+ /// that scrolled off the top.+ pub(super) fn shift_search(&mut self, n: usize) {+ if let Some(s) = self.search.as_mut() {+ s.matches.retain(|m| m.row >= n);+ for m in &mut s.matches {+ m.row -= n;+ }+ s.current = s.current.min(s.matches.len().saturating_sub(1));+ }+ }+}@@ -0,0 +1,190 @@+use super::*;++impl Grid {+ /// Slide an active selection up by `n` rows after scrollback eviction,+ /// dropping it if either endpoint scrolled off the top.+ pub(super) fn shift_selection(&mut self, n: usize) {+ if let Some((a, b)) = self.selection {+ if a.row < n || b.row < n {+ self.selection = None;+ } else {+ self.selection = Some((+ Point {+ row: a.row - n,+ ..a+ },+ Point {+ row: b.row - n,+ ..b+ },+ ));+ }+ }+ }++ pub fn clear_selection(&mut self) {+ self.selection = None;+ }++ /// Begin a linear selection at an absolute point (drag anchor).+ pub fn start_selection(&mut self, row: usize, col: usize) {+ let p = Point { row, col };+ self.selection = Some((p, p));+ self.selection_block = false;+ }++ /// Begin a rectangular (block) selection at an absolute point.+ pub fn start_block_selection(&mut self, row: usize, col: usize) {+ let p = Point { row, col };+ self.selection = Some((p, p));+ self.selection_block = true;+ }++ /// Move the selection head (drag), keeping the anchor fixed.+ pub fn extend_selection(&mut self, row: usize, col: usize) {+ if let Some((_, head)) = self.selection.as_mut() {+ *head = Point { row, col };+ }+ }++ /// Select the word at an absolute point, breaking on whitespace and the+ /// default delimiter set.+ pub fn select_word(&mut self, row: usize, col: usize) {+ let delims = &self.word_delimiters;+ let line = self.abs_row(row);+ if col >= line.len() || !is_word(line[col].c, delims) {+ self.start_selection(row, col);+ return;+ }+ let mut lo = col;+ while lo > 0 && is_word(line[lo - 1].c, delims) {+ lo -= 1;+ }+ let mut hi = col;+ while hi + 1 < line.len() && is_word(line[hi + 1].c, delims) {+ hi += 1;+ }+ self.selection = Some((Point { row, col: lo }, Point { row, col: hi }));+ self.selection_block = false;+ }++ /// Select the whole line at an absolute row.+ pub fn select_line(&mut self, row: usize) {+ let last = self.abs_row(row).len().saturating_sub(1);+ self.selection = Some((Point { row, col: 0 }, Point { row, col: last }));+ self.selection_block = false;+ }++ /// The rectangle `(top_row, bottom_row, left_col, right_col)` of a block+ /// selection.+ fn block_rect(&self) -> Option<(usize, usize, usize, usize)> {+ let (a, b) = self.selection?;+ Some((+ a.row.min(b.row),+ a.row.max(b.row),+ a.col.min(b.col),+ a.col.max(b.col),+ ))+ }++ /// Normalized selection (start <= end in reading order), if any.+ fn ordered_selection(&self) -> Option<(Point, Point)> {+ self.selection.map(|(a, b)| {+ if (a.row, a.col) <= (b.row, b.col) {+ (a, b)+ } else {+ (b, a)+ }+ })+ }++ /// Whether the cell at an absolute `(row, col)` falls inside the selection.+ pub fn is_selected(&self, row: usize, col: usize) -> bool {+ if self.selection_block {+ let Some((r0, r1, c0, c1)) = self.block_rect() else {+ return false;+ };+ return row >= r0 && row <= r1 && col >= c0 && col <= c1;+ }+ let Some((start, end)) = self.ordered_selection() else {+ return false;+ };+ if row < start.row || row > end.row {+ return false;+ }+ let lo = if row == start.row { start.col } else { 0 };+ let hi = if row == end.row { end.col } else { usize::MAX };+ col >= lo && col <= hi+ }++ /// The inclusive `(lo, hi)` column span selected on absolute row `row`, if+ /// any part of that row is selected.+ pub fn selection_span_on(&self, row: usize) -> Option<(usize, usize)> {+ if self.selection_block {+ let (r0, r1, c0, c1) = self.block_rect()?;+ return (row >= r0 && row <= r1).then_some((c0, c1));+ }+ let (start, end) = self.ordered_selection()?;+ if row < start.row || row > end.row {+ return None;+ }+ let lo = if row == start.row { start.col } else { 0 };+ let hi = if row == end.row {+ end.col+ } else {+ self.abs_row(row).len().saturating_sub(1)+ };+ Some((lo, hi))+ }++ /// The selected text, with trailing blanks trimmed per line and rows joined+ /// by newlines. `None` if there is no selection.+ pub fn selection_text(&self) -> Option<String> {+ if self.selection_block {+ let (r0, r1, c0, c1) = self.block_rect()?;+ let mut out = String::new();+ for row in r0..=r1 {+ out.push_str(self.row_slice_text(row, c0, c1 + 1).trim_end());+ if row != r1 {+ out.push('\n');+ }+ }+ return Some(out);+ }+ let (start, end) = self.ordered_selection()?;+ let mut out = String::new();+ for row in start.row..=end.row {+ let line = self.abs_row(row);+ let lo = if row == start.row { start.col } else { 0 };+ let hi = if row == end.row {+ (end.col + 1).min(line.len())+ } else {+ line.len()+ };+ out.push_str(self.row_slice_text(row, lo, hi).trim_end());+ if row != end.row {+ out.push('\n');+ }+ }+ Some(out)+ }++ /// The characters of an absolute row in `[from, to)`, skipping wide+ /// continuation cells.+ pub(super) fn row_slice_text(&self, row: usize, from: usize, to: usize) -> String {+ let mut out = String::new();+ for cell in self+ .abs_row(row)+ .get(from..to.min(self.abs_row(row).len()))+ .unwrap_or(&[])+ .iter()+ .filter(|c| !c.flags.contains(Flags::WIDE_CONT))+ {+ out.push(cell.c);+ if let Some(marks) = &cell.combining {+ out.push_str(marks);+ }+ }+ out+ }+}@@ -20,7 +20,7 @@ use crate::config::Config; /// A fast, software-rendered, Wayland-native terminal emulator. #[derive(Parse)]-#[pound(name = "beer", version = "0.0.0")]+#[pound(name = "beer", version = "0.2.0")] struct Cli { /// Run as a daemon hosting multiple windows. #[pound(long)]@@ -1,1253 +0,0 @@-//! VT emulation: feed bytes through `vte` and drive the [`Grid`].--use std::io::Write as _;--use vte::{Params, Perform};--use crate::grid::{- Color, CursorShape, Flags, Grid, MouseEncoding, MouseProtocol, PromptKind, Underline,-};-use crate::theme::{Rgb, Theme};--/// G0/G1 character set designation.-#[derive(Clone, Copy, PartialEq, Eq, Debug)]-enum Charset {- Ascii,- DecSpecial,-}--/// Which device-attributes query is being answered.-#[derive(Clone, Copy, Debug)]-enum DaLevel {- Primary,- Secondary,- Tertiary,-}--/// A clipboard request from the application (OSC 52), for the front-end to act-/// on since it owns the Wayland selections.-#[derive(Clone, Debug)]-pub enum ClipboardOp {- /// Set the clipboard (or primary) to `text`.- Set { primary: bool, text: String },- /// Report the current clipboard (or primary) contents back to the app.- Query { primary: bool },-}--/// A desktop notification an application requested (OSC 9 / 777 / 99), for the-/// front-end to deliver via the configured notifier.-#[derive(Clone, Debug, PartialEq, Eq)]-pub struct Notification {- pub title: Option<String>,- pub body: String,-}--/// Which dynamic colour an OSC 10/11/17/19 escape targets.-#[derive(Clone, Copy, Debug)]-enum Dynamic {- Fg,- Bg,- SelBg,- SelFg,-}--/// DECRQM mode-state code: 1 = set, 2 = reset.-fn set_reset(on: bool) -> u8 {- if on { 1 } else { 2 }-}--/// Parse an OSC colour spec into an [`Rgb`].-fn parse_spec(spec: &[u8]) -> Option<Rgb> {- std::str::from_utf8(spec)- .ok()- .and_then(crate::theme::parse_color)-}--/// Parse a decimal palette index (0-255).-fn parse_index(b: &[u8]) -> Option<u8> {- std::str::from_utf8(b).ok()?.parse().ok()-}--fn rgb_tuple(rgb: Rgb) -> (u8, u8, u8) {- (rgb.0, rgb.1, rgb.2)-}--/// Select `protocol` when a mouse mode is set, else turn reporting off.-fn proto(on: bool, protocol: MouseProtocol) -> MouseProtocol {- if on { protocol } else { MouseProtocol::Off }-}--/// Select `encoding` when its mode is set, else fall back to the default form.-fn enc(on: bool, encoding: MouseEncoding) -> MouseEncoding {- if on { encoding } else { MouseEncoding::X10 }-}--/// Map an SGR 4 param (`4` or `4:x`) to an underline style.-fn underline_from(param: &[u16]) -> Underline {- match param.get(1).copied().unwrap_or(1) {- 0 => Underline::None,- 2 => Underline::Double,- 3 => Underline::Curly,- 4 => Underline::Dotted,- 5 => Underline::Dashed,- _ => Underline::Single,- }-}--/// The terminal model: a grid plus the escape-sequence state around it.-#[derive(Debug)]-pub struct Term {- grid: Grid,- title: Option<String>,- title_stack: Vec<Option<String>>,- response: Vec<u8>,- g0: Charset,- g1: Charset,- shift_out: bool,- /// Accumulated payload of an in-progress `DCS + q` (XTGETTCAP) query.- xtgettcap: Option<Vec<u8>>,- /// Pending OSC 52 clipboard requests, drained by the front-end.- clipboard_ops: Vec<ClipboardOp>,- /// The active colour scheme (seeded from config, mutated by OSC escapes).- theme: Theme,- /// Set when the child rings the bell (`BEL`); cleared by the front-end.- bell: bool,- /// Working directory reported by the shell via OSC 7, for new windows.- cwd: Option<String>,- /// Desktop notifications requested via OSC 9/777/99, drained by the front-end.- notifications: Vec<Notification>,-}--impl Term {- pub fn new(cols: usize, rows: usize) -> Self {- Self {- grid: Grid::new(cols, rows),- title: None,- title_stack: Vec::new(),- response: Vec::new(),- g0: Charset::Ascii,- g1: Charset::Ascii,- shift_out: false,- xtgettcap: None,- clipboard_ops: Vec::new(),- theme: Theme::default(),- bell: false,- cwd: None,- notifications: Vec::new(),- }- }-- /// The working directory last reported by the shell (OSC 7), if any.- pub fn cwd(&self) -> Option<&str> {- self.cwd.as_deref()- }-- /// Drain the desktop notifications requested since the last call.- pub fn take_notifications(&mut self) -> Vec<Notification> {- std::mem::take(&mut self.notifications)- }-- /// Take and clear the pending bell flag.- pub fn take_bell(&mut self) -> bool {- std::mem::take(&mut self.bell)- }-- /// Drain the OSC 52 clipboard requests accumulated since the last call.- pub fn take_clipboard_ops(&mut self) -> Vec<ClipboardOp> {- std::mem::take(&mut self.clipboard_ops)- }-- pub fn theme(&self) -> &Theme {- &self.theme- }-- /// Replace the colour scheme (config load / reload).- pub fn set_theme(&mut self, theme: Theme) {- self.theme = theme;- }-- /// Answer an XTGETTCAP query: for each hex-encoded capability name, reply- /// with `DCS 1 + r name=value ST` if known, else `DCS 0 + r name ST`.- fn answer_xtgettcap(&mut self, payload: &[u8]) {- for name_hex in payload.split(|&b| b == b';') {- let value = decode_hex(name_hex).and_then(|name| cap_value(&name));- match value {- Some(value) => {- self.response.extend_from_slice(b"\x1bP1+r");- self.response.extend_from_slice(name_hex);- self.response.push(b'=');- for byte in value.bytes() {- let _ = write!(self.response, "{byte:02x}");- }- self.response.extend_from_slice(b"\x1b\\");- }- None => {- self.response.extend_from_slice(b"\x1bP0+r");- self.response.extend_from_slice(name_hex);- self.response.extend_from_slice(b"\x1b\\");- }- }- }- }-- pub fn grid(&self) -> &Grid {- &self.grid- }-- pub fn grid_mut(&mut self) -> &mut Grid {- &mut self.grid- }-- pub fn resize(&mut self, cols: usize, rows: usize) {- self.grid.resize(cols, rows);- }-- pub fn scroll_view(&mut self, delta: isize) {- self.grid.scroll_view(delta);- }-- pub fn scroll_to_bottom(&mut self) {- self.grid.scroll_to_bottom();- }-- /// Lines per page, for page-scroll bindings.- pub fn page(&self) -> usize {- self.grid.page()- }-- pub fn title(&self) -> Option<&str> {- self.title.as_deref()- }-- /// Bytes the terminal needs to send back to the application (DA, CPR, ...).- pub fn take_response(&mut self) -> Vec<u8> {- std::mem::take(&mut self.response)- }-- fn active_charset(&self) -> Charset {- if self.shift_out { self.g1 } else { self.g0 }- }-- fn set_mode(&mut self, params: &Params, private: bool, on: bool) {- for p in params.iter() {- let Some(&code) = p.first() else { continue };- match (private, code) {- (true, 6) => self.grid.set_origin(on),- (true, 7) => self.grid.set_autowrap(on),- (true, 1049) => {- if on {- self.grid.save_cursor();- self.grid.enter_alt_screen();- self.grid.erase_display(2);- } else {- self.grid.leave_alt_screen();- self.grid.restore_cursor();- }- }- (true, 47 | 1047) => {- if on {- self.grid.enter_alt_screen();- } else {- self.grid.leave_alt_screen();- }- }- (false, 4) => self.grid.set_insert(on),- (true, 1) => self.grid.set_app_cursor(on),- (true, 25) => self.grid.set_cursor_visible(on),- (true, 9) => self.grid.set_mouse_protocol(proto(on, MouseProtocol::X10)),- (true, 1000) => self- .grid- .set_mouse_protocol(proto(on, MouseProtocol::Normal)),- (true, 1002) => self- .grid- .set_mouse_protocol(proto(on, MouseProtocol::Button)),- (true, 1003) => self.grid.set_mouse_protocol(proto(on, MouseProtocol::Any)),- (true, 1004) => self.grid.set_focus_events(on),- (true, 1005) => self.grid.set_mouse_encoding(enc(on, MouseEncoding::Utf8)),- (true, 1006) => self.grid.set_mouse_encoding(enc(on, MouseEncoding::Sgr)),- (true, 2004) => self.grid.set_bracketed_paste(on),- (true, 2026) => self.grid.set_sync(on),- _ => tracing::trace!("unhandled mode {code} private={private} on={on}"),- }- }- }-- fn sgr(&mut self, params: &Params) {- let items: Vec<&[u16]> = params.iter().collect();- if items.is_empty() {- self.grid.reset_pen();- return;- }- let mut i = 0;- while i < items.len() {- let p = items[i];- let code = p.first().copied().unwrap_or(0);- let pen = self.grid.pen_mut();- let mut step = 1;- match code {- 0 => *pen = Default::default(),- 1 => pen.flags.insert(Flags::BOLD),- 2 => pen.flags.insert(Flags::DIM),- 3 => pen.flags.insert(Flags::ITALIC),- 4 => pen.underline = underline_from(p),- 5 | 6 => pen.flags.insert(Flags::BLINK),- 7 => pen.flags.insert(Flags::REVERSE),- 8 => pen.flags.insert(Flags::HIDDEN),- 9 => pen.flags.insert(Flags::STRIKE),- 21 => pen.underline = Underline::Double,- 22 => pen.flags.remove(Flags::BOLD.union(Flags::DIM)),- 23 => pen.flags.remove(Flags::ITALIC),- 24 => pen.underline = Underline::None,- 25 => pen.flags.remove(Flags::BLINK),- 27 => pen.flags.remove(Flags::REVERSE),- 28 => pen.flags.remove(Flags::HIDDEN),- 29 => pen.flags.remove(Flags::STRIKE),- 30..=37 => pen.fg = Color::Indexed((code - 30) as u8),- 39 => pen.fg = Color::Default,- 40..=47 => pen.bg = Color::Indexed((code - 40) as u8),- 49 => pen.bg = Color::Default,- 53 => pen.flags.insert(Flags::OVERLINE),- 55 => pen.flags.remove(Flags::OVERLINE),- 90..=97 => pen.fg = Color::Indexed((code - 90 + 8) as u8),- 100..=107 => pen.bg = Color::Indexed((code - 100 + 8) as u8),- 38 | 48 | 58 => {- let (color, consumed) = ext_color(&items, i);- if let Some(color) = color {- match code {- 38 => pen.fg = color,- 48 => pen.bg = color,- _ => pen.underline_color = color,- }- }- step = consumed;- }- 59 => pen.underline_color = Color::Default,- _ => {}- }- i += step;- }- }-- /// Device attributes. DA1 claims a VT220 with ANSI colour; DA2 a generic- /// firmware level; DA3 a (zero) unit ID.- fn device_attrs(&mut self, level: DaLevel) {- match level {- DaLevel::Primary => self.response.extend_from_slice(b"\x1b[?62;22c"),- DaLevel::Secondary => self.response.extend_from_slice(b"\x1b[>0;276;0c"),- DaLevel::Tertiary => self.response.extend_from_slice(b"\x1bP!|00000000\x1b\\"),- }- }-- /// XTVERSION (`CSI > q`): report the terminal name and version.- fn report_version(&mut self) {- let _ = write!(- self.response,- "\x1bP>|beer({})\x1b\\",- env!("CARGO_PKG_VERSION")- );- }-- /// Emit an OSC colour reply (`OSC code ; rgb:rrrr/gggg/bbbb` + terminator).- fn reply_color(&mut self, code: &str, rgb: Rgb, bell: bool) {- let Rgb(r, g, b) = rgb;- let _ = write!(- self.response,- "\x1b]{code};rgb:{:04x}/{:04x}/{:04x}",- u16::from(r) * 0x101,- u16::from(g) * 0x101,- u16::from(b) * 0x101,- );- self.response- .extend_from_slice(if bell { b"\x07" } else { b"\x1b\\" });- }-- /// OSC 4: set or query palette entries, given as `index;spec` pairs.- fn osc_palette(&mut self, params: &[&[u8]], bell: bool) {- let mut rest = params[1..].iter();- while let (Some(idx_raw), Some(spec)) = (rest.next(), rest.next()) {- let Some(idx) = parse_index(idx_raw) else {- continue;- };- if *spec == b"?" {- let rgb = self.theme.palette[idx as usize];- self.reply_color(&format!("4;{idx}"), rgb, bell);- } else if let Some(rgb) = parse_spec(spec) {- self.theme.set_palette(idx, rgb);- }- }- }-- /// OSC 10/11/17/19: set or query a dynamic colour.- fn osc_dynamic_color(&mut self, kind: Dynamic, spec: Option<&&[u8]>, bell: bool) {- let Some(spec) = spec else { return };- if **spec == b"?"[..] {- let rgb = match kind {- Dynamic::Fg => self.theme.fg,- Dynamic::Bg => self.theme.bg,- Dynamic::SelBg => self.theme.selection_bg,- Dynamic::SelFg => self.theme.selection_fg.unwrap_or(self.theme.fg),- };- let code = match kind {- Dynamic::Fg => "10",- Dynamic::Bg => "11",- Dynamic::SelBg => "17",- Dynamic::SelFg => "19",- };- self.reply_color(code, rgb, bell);- } else if let Some(rgb) = parse_spec(spec) {- match kind {- Dynamic::Fg => self.theme.fg = rgb,- Dynamic::Bg => self.theme.bg = rgb,- Dynamic::SelBg => self.theme.selection_bg = rgb,- Dynamic::SelFg => self.theme.selection_fg = Some(rgb),- }- }- }-- fn device_status(&mut self, params: &Params) {- match params.iter().next().and_then(|p| p.first().copied()) {- Some(5) => self.response.extend_from_slice(b"\x1b[0n"),- Some(6) => {- let (x, y) = self.grid.cursor();- let _ = write!(self.response, "\x1b[{};{}R", y + 1, x + 1);- }- _ => {}- }- }-- /// DECRQM (`CSI [?] Ps $ p`): report whether a mode is set (1), reset (2),- /// or unrecognized (0). Only the modes we actually track are reported.- fn report_mode(&mut self, params: &Params, private: bool) {- let code = raw(params, 0);- let state = match (private, code) {- (true, 6) => set_reset(self.grid.origin()),- (true, 7) => set_reset(self.grid.autowrap()),- (true, 47 | 1047 | 1049) => set_reset(self.grid.alt_active()),- (true, 9) => set_reset(self.grid.mouse_protocol() == MouseProtocol::X10),- (true, 1000) => set_reset(self.grid.mouse_protocol() == MouseProtocol::Normal),- (true, 1002) => set_reset(self.grid.mouse_protocol() == MouseProtocol::Button),- (true, 1003) => set_reset(self.grid.mouse_protocol() == MouseProtocol::Any),- (true, 1004) => set_reset(self.grid.focus_events()),- (true, 1005) => set_reset(self.grid.mouse_encoding() == MouseEncoding::Utf8),- (true, 1006) => set_reset(self.grid.mouse_encoding() == MouseEncoding::Sgr),- (true, 2004) => set_reset(self.grid.bracketed_paste()),- (true, 2026) => set_reset(self.grid.sync_active()),- (false, 4) => set_reset(self.grid.insert()),- _ => 0,- };- let prefix = if private { "?" } else { "" };- let _ = write!(self.response, "\x1b[{prefix}{code};{state}$y");- }-- /// Title stack (`CSI 22/23 ; Ps t`): push or pop the window title.- fn title_stack_op(&mut self, params: &Params) {- match raw(params, 0) {- 22 => self.title_stack.push(self.title.clone()),- 23 => {- if let Some(title) = self.title_stack.pop() {- self.title = title;- }- }- _ => {}- }- }-}--/// First param value, with 0/absent folded to `default` (xterm convention for-/// cursor movement and counts).-fn n(params: &Params, idx: usize, default: usize) -> usize {- match params.iter().nth(idx).and_then(|p| p.first().copied()) {- Some(0) | None => default,- Some(v) => v as usize,- }-}--/// Raw first param value (0 is meaningful), defaulting to 0 when absent.-fn raw(params: &Params, idx: usize) -> u16 {- params- .iter()- .nth(idx)- .and_then(|p| p.first().copied())- .unwrap_or(0)-}--/// Parse an SGR 38/48 extended colour, returning the colour and how many-/// top-level params it consumed (1 for colon form, more for semicolon form).-fn ext_color(items: &[&[u16]], i: usize) -> (Option<Color>, usize) {- let head = items[i];- if head.len() >= 2 {- return (color_from_subparams(&head[1..]), 1);- }- match items.get(i + 1).and_then(|s| s.first().copied()) {- Some(5) => {- let idx = items- .get(i + 2)- .and_then(|s| s.first().copied())- .unwrap_or(0);- (Some(Color::Indexed(idx as u8)), 3)- }- Some(2) => {- let get = |k: usize| {- items- .get(i + k)- .and_then(|s| s.first().copied())- .unwrap_or(0)- };- (- Some(Color::Rgb(get(2) as u8, get(3) as u8, get(4) as u8)),- 5,- )- }- _ => (None, 1),- }-}--fn color_from_subparams(sub: &[u16]) -> Option<Color> {- match sub.first().copied() {- Some(5) => sub.get(1).map(|&i| Color::Indexed(i as u8)),- Some(2) => {- // Either `2:r:g:b` or `2:colorspace:r:g:b`.- let rgb = if sub.len() >= 5 {- &sub[2..5]- } else {- &sub[1..]- };- match rgb {- [r, g, b, ..] => Some(Color::Rgb(*r as u8, *g as u8, *b as u8)),- _ => None,- }- }- _ => None,- }-}--fn charset(byte: u8) -> Charset {- match byte {- b'0' => Charset::DecSpecial,- _ => Charset::Ascii,- }-}--/// Translate a byte under the DEC special graphics set (line drawing).-fn dec_special(c: char) -> char {- match c {- '`' => '◆',- 'a' => '▒',- 'f' => '°',- 'g' => '±',- 'j' => '┘',- 'k' => '┐',- 'l' => '┌',- 'm' => '└',- 'n' => '┼',- 'o' => '⎺',- 'p' => '⎻',- 'q' => '─',- 'r' => '⎼',- 's' => '⎽',- 't' => '├',- 'u' => '┤',- 'v' => '┴',- 'w' => '┬',- 'x' => '│',- 'y' => '≤',- 'z' => '≥',- '~' => '·',- _ => c,- }-}--impl Perform for Term {- fn print(&mut self, c: char) {- let c = if self.active_charset() == Charset::DecSpecial {- dec_special(c)- } else {- c- };- self.grid.print(c);- }-- fn execute(&mut self, byte: u8) {- match byte {- 0x07 => self.bell = true,- 0x08 => self.grid.backspace(),- 0x09 => self.grid.tab(),- 0x0A..=0x0C => self.grid.line_feed(),- 0x0D => self.grid.carriage_return(),- 0x0E => self.shift_out = true,- 0x0F => self.shift_out = false,- _ => {}- }- }-- fn csi_dispatch(&mut self, params: &Params, intermediates: &[u8], _ignore: bool, action: char) {- let private = intermediates.first() == Some(&b'?');- match action {- 'A' => self.grid.cursor_up(n(params, 0, 1)),- 'B' | 'e' => self.grid.cursor_down(n(params, 0, 1)),- 'C' | 'a' => self.grid.cursor_fwd(n(params, 0, 1)),- 'D' => self.grid.cursor_back(n(params, 0, 1)),- 'E' => {- self.grid.cursor_down(n(params, 0, 1));- self.grid.carriage_return();- }- 'F' => {- self.grid.cursor_up(n(params, 0, 1));- self.grid.carriage_return();- }- 'G' | '`' => self.grid.move_to_col(n(params, 0, 1) - 1),- 'd' => self.grid.move_to_row(n(params, 0, 1) - 1),- 'H' | 'f' => self.grid.move_to(n(params, 1, 1) - 1, n(params, 0, 1) - 1),- 'J' => self.grid.erase_display(raw(params, 0)),- 'K' => self.grid.erase_line(raw(params, 0)),- '@' => self.grid.insert_chars(n(params, 0, 1)),- 'P' => self.grid.delete_chars(n(params, 0, 1)),- 'L' => self.grid.insert_lines(n(params, 0, 1)),- 'M' => self.grid.delete_lines(n(params, 0, 1)),- 'X' => self.grid.erase_chars(n(params, 0, 1)),- 'S' => self.grid.scroll_up(n(params, 0, 1)),- 'T' => self.grid.scroll_down(n(params, 0, 1)),- 'm' => self.sgr(params),- 'r' => {- let top = n(params, 0, 1) - 1;- let bottom = match params.iter().nth(1).and_then(|p| p.first().copied()) {- Some(0) | None => self.grid.rows() - 1,- Some(v) => (v as usize).saturating_sub(1),- };- self.grid.set_scroll_region(top, bottom);- }- 'h' => self.set_mode(params, private, true),- 'l' => self.set_mode(params, private, false),- 'c' => self.device_attrs(match intermediates.first() {- Some(b'>') => DaLevel::Secondary,- Some(b'=') => DaLevel::Tertiary,- _ => DaLevel::Primary,- }),- 'q' if intermediates.first() == Some(&b'>') => self.report_version(),- 'q' if intermediates.first() == Some(&b' ') => {- let code = raw(params, 0);- self.grid.set_cursor_shape(match code {- 3 | 4 => CursorShape::Underline,- 5 | 6 => CursorShape::Beam,- _ => CursorShape::Block,- });- // Even codes are steady; 0/1 and other odd codes blink.- self.grid.set_cursor_blink(code == 0 || code % 2 == 1);- }- 'p' if intermediates.contains(&b'$') => self.report_mode(params, private),- 'n' => self.device_status(params),- 's' => self.grid.save_cursor(),- 'u' => self.grid.restore_cursor(),- 't' => self.title_stack_op(params),- 'g' => match raw(params, 0) {- 3 => self.grid.clear_all_tabs(),- _ => self.grid.clear_tab(),- },- _ => tracing::trace!("unhandled CSI {action:?} {intermediates:?}"),- }- }-- fn esc_dispatch(&mut self, intermediates: &[u8], _ignore: bool, byte: u8) {- match (intermediates.first().copied(), byte) {- (None, b'D') => self.grid.line_feed(),- (None, b'M') => self.grid.reverse_index(),- (None, b'E') => self.grid.next_line(),- (None, b'7') => self.grid.save_cursor(),- (None, b'8') => self.grid.restore_cursor(),- (None, b'H') => self.grid.set_tab(),- (None, b'c') => {- self.grid.reset_pen();- self.grid.set_scroll_region(0, self.grid.rows() - 1);- self.grid.set_autowrap(true);- self.grid.set_origin(false);- self.grid.erase_display(2);- self.grid.move_to(0, 0);- self.g0 = Charset::Ascii;- self.g1 = Charset::Ascii;- self.shift_out = false;- }- (Some(b'('), c) => self.g0 = charset(c),- (Some(b')'), c) => self.g1 = charset(c),- _ => {}- }- }-- fn osc_dispatch(&mut self, params: &[&[u8]], bell: bool) {- match params.first() {- Some(&n) if n == b"0" || n == b"2" => {- if let Some(text) = params.get(1) {- self.title = Some(String::from_utf8_lossy(text).into_owned());- }- }- // OSC 7: the shell reports its cwd as a `file://host/path` URI.- Some(&n) if n == b"7" => {- if let Some(uri) = params.get(1) {- self.cwd = file_uri_path(uri);- }- }- // OSC 133: shell-integration prompt marks (A/B/C/D, with optional- // `;key=value` attributes we ignore).- Some(&n) if n == b"133" => {- if let Some(kind) = params- .get(1)- .and_then(|p| p.first())- .and_then(|&b| prompt_kind(b))- {- self.grid.set_prompt_mark(kind);- }- }- // OSC 8: hyperlink. `OSC 8 ; params ; URI ST`; an empty URI ends the- // link. The URI is everything after the second field, rejoined since- // a URI may itself contain ';'.- Some(&n) if n == b"8" => {- let uri_bytes = params- .get(2..)- .map(|parts| parts.join(&b';'))- .unwrap_or_default();- let uri = std::str::from_utf8(&uri_bytes).unwrap_or("");- self.grid.set_link((!uri.is_empty()).then_some(uri));- }- // OSC 9: iTerm2-style notification (`OSC 9 ; body`).- Some(&n) if n == b"9" => {- if let Some(body) = osc_text(params.get(1)) {- self.notifications.push(Notification { title: None, body });- }- }- // OSC 777: `OSC 777 ; notify ; title ; body`.- Some(&n) if n == b"777" && params.get(1) == Some(&&b"notify"[..]) => {- let title = osc_text(params.get(2));- if let Some(body) = osc_text(params.get(3)) {- self.notifications.push(Notification { title, body });- }- }- // OSC 99: kitty desktop-notification protocol. We honour the common- // single-chunk form, taking the payload as the body and ignoring the- // metadata key=value field.- Some(&n) if n == b"99" => {- if let Some(body) = osc_text(params.get(2)).filter(|b| !b.is_empty()) {- self.notifications.push(Notification { title: None, body });- }- }- // OSC 4: set/query palette entries (pairs of index;spec).- Some(&n) if n == b"4" => self.osc_palette(params, bell),- // OSC 104: reset palette (all, or the listed indices).- Some(&n) if n == b"104" => {- if params.len() <= 1 {- self.theme.reset_palette();- } else {- for p in ¶ms[1..] {- if let Some(i) = parse_index(p) {- self.theme.reset_palette_index(i);- }- }- }- }- // OSC 10/11: foreground / background; OSC 110/111 reset them.- Some(&n) if n == b"10" => self.osc_dynamic_color(Dynamic::Fg, params.get(1), bell),- Some(&n) if n == b"11" => self.osc_dynamic_color(Dynamic::Bg, params.get(1), bell),- Some(&n) if n == b"110" => self.theme.reset_fg(),- Some(&n) if n == b"111" => self.theme.reset_bg(),- // OSC 17/19: selection (highlight) background / foreground.- Some(&n) if n == b"17" => self.osc_dynamic_color(Dynamic::SelBg, params.get(1), bell),- Some(&n) if n == b"19" => self.osc_dynamic_color(Dynamic::SelFg, params.get(1), bell),- // OSC 12: set cursor colour; OSC 112: reset to default.- Some(&n) if n == b"12" => {- self.grid- .set_cursor_color(params.get(1).and_then(|s| parse_spec(s)).map(rgb_tuple));- }- Some(&n) if n == b"112" => self.grid.set_cursor_color(None),- // OSC 52: clipboard get/set. Pc selects the target, Pd is base64 or- // `?` to query. We only touch `c` (clipboard) and `p` (primary).- Some(&n) if n == b"52" => {- let target = params.get(1).copied().unwrap_or(b"");- let data = params.get(2).copied().unwrap_or(b"");- let primary = target.first() == Some(&b'p');- if data == b"?" {- self.clipboard_ops.push(ClipboardOp::Query { primary });- } else if let Some(text) =- base64_decode(data).and_then(|b| String::from_utf8(b).ok())- {- self.clipboard_ops.push(ClipboardOp::Set { primary, text });- }- }- _ => {}- }- }-- fn hook(&mut self, _: &Params, intermediates: &[u8], _: bool, action: char) {- // XTGETTCAP arrives as `DCS + q <names> ST`.- if action == 'q' && intermediates == [b'+'] {- self.xtgettcap = Some(Vec::new());- }- }-- fn put(&mut self, byte: u8) {- if let Some(buf) = self.xtgettcap.as_mut() {- buf.push(byte);- }- }-- fn unhook(&mut self) {- if let Some(payload) = self.xtgettcap.take() {- self.answer_xtgettcap(&payload);- }- }-}--/// Look up a terminfo capability beer reports via XTGETTCAP.-fn cap_value(name: &[u8]) -> Option<&'static str> {- match name {- b"TN" => Some("beer"),- b"Co" | b"colors" => Some("256"),- b"RGB" => Some("8/8/8"),- _ => None,- }-}--const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";--/// Standard base64 encode (used for OSC 52 query replies).-pub fn base64_encode(data: &[u8]) -> String {- let mut out = String::with_capacity(data.len().div_ceil(3) * 4);- for chunk in data.chunks(3) {- let b = [- chunk[0],- *chunk.get(1).unwrap_or(&0),- *chunk.get(2).unwrap_or(&0),- ];- let n = (u32::from(b[0]) << 16) | (u32::from(b[1]) << 8) | u32::from(b[2]);- out.push(B64[(n >> 18 & 63) as usize] as char);- out.push(B64[(n >> 12 & 63) as usize] as char);- out.push(if chunk.len() > 1 {- B64[(n >> 6 & 63) as usize] as char- } else {- '='- });- out.push(if chunk.len() > 2 {- B64[(n & 63) as usize] as char- } else {- '='- });- }- out-}--/// Standard base64 decode, ignoring padding and whitespace; `None` on a bad-/// character.-fn base64_decode(data: &[u8]) -> Option<Vec<u8>> {- let val = |c: u8| -> Option<u32> {- match c {- b'A'..=b'Z' => Some(u32::from(c - b'A')),- b'a'..=b'z' => Some(u32::from(c - b'a') + 26),- b'0'..=b'9' => Some(u32::from(c - b'0') + 52),- b'+' => Some(62),- b'/' => Some(63),- _ => None,- }- };- let filtered: Vec<u8> = data- .iter()- .copied()- .filter(|&c| c != b'=' && !c.is_ascii_whitespace())- .collect();- let mut out = Vec::with_capacity(filtered.len() / 4 * 3);- for chunk in filtered.chunks(4) {- if chunk.len() == 1 {- return None; // a lone sextet cannot form a byte- }- let mut n = 0u32;- for &c in chunk {- n = (n << 6) | val(c)?;- }- n <<= 6 * (4 - chunk.len() as u32);- out.push((n >> 16) as u8);- if chunk.len() >= 3 {- out.push((n >> 8) as u8);- }- if chunk.len() >= 4 {- out.push(n as u8);- }- }- Some(out)-}--/// Decode an OSC string field to UTF-8 (lossy), or `None` if absent.-fn osc_text(field: Option<&&[u8]>) -> Option<String> {- field.map(|b| String::from_utf8_lossy(b).into_owned())-}--/// Map an OSC 133 mark letter to a [`PromptKind`].-fn prompt_kind(b: u8) -> Option<PromptKind> {- match b {- b'A' => Some(PromptKind::PromptStart),- b'B' => Some(PromptKind::CmdStart),- b'C' => Some(PromptKind::OutputStart),- b'D' => Some(PromptKind::CmdEnd),- _ => None,- }-}--/// Extract the local path from an OSC 7 `file://host/path` URI, percent-decoding-/// `%XX` escapes. The host part is ignored (we only spawn locally). Returns-/// `None` if it is not a usable absolute path.-fn file_uri_path(uri: &[u8]) -> Option<String> {- let rest = uri.strip_prefix(b"file://").unwrap_or(uri);- // Skip the authority (host) up to the first '/', which begins the path.- let slash = rest.iter().position(|&b| b == b'/')?;- let path_bytes = percent_decode(&rest[slash..]);- let path = String::from_utf8(path_bytes).ok()?;- path.starts_with('/').then_some(path)-}--/// Percent-decode `%XX` byte escapes in a URI path, passing other bytes through.-fn percent_decode(s: &[u8]) -> Vec<u8> {- let mut out = Vec::with_capacity(s.len());- let mut i = 0;- while i < s.len() {- if s[i] == b'%' && i + 2 < s.len() {- let hi = (s[i + 1] as char).to_digit(16);- let lo = (s[i + 2] as char).to_digit(16);- if let (Some(hi), Some(lo)) = (hi, lo) {- out.push((hi * 16 + lo) as u8);- i += 3;- continue;- }- }- out.push(s[i]);- i += 1;- }- out-}--/// Decode an even-length lowercase/uppercase hex string into bytes.-fn decode_hex(s: &[u8]) -> Option<Vec<u8>> {- if s.is_empty() || !s.len().is_multiple_of(2) {- return None;- }- let nibble = |b: u8| (b as char).to_digit(16).map(|d| d as u8);- s.chunks_exact(2)- .map(|pair| Some((nibble(pair[0])? << 4) | nibble(pair[1])?))- .collect()-}--#[cfg(test)]-mod tests {- use super::*;- use crate::grid::{MouseEncoding, MouseProtocol};-- fn feed(term: &mut Term, bytes: &[u8]) {- let mut parser = vte::Parser::new();- parser.advance(term, bytes);- }-- #[test]- fn plain_text_lands_in_the_grid() {- let mut t = Term::new(20, 4);- feed(&mut t, b"hello");- assert_eq!(t.grid().row_text(0), "hello");- }-- #[test]- fn cursor_position_and_erase() {- let mut t = Term::new(20, 4);- feed(&mut t, b"abcde\x1b[Hxyz");- assert_eq!(t.grid().row_text(0), "xyzde");- }-- #[test]- fn newline_sequence() {- let mut t = Term::new(20, 4);- feed(&mut t, b"one\r\ntwo");- assert_eq!(t.grid().row_text(0), "one");- assert_eq!(t.grid().row_text(1), "two");- }-- #[test]- fn device_attributes_levels() {- let mut t = Term::new(20, 4);- feed(&mut t, b"\x1b[c");- assert_eq!(t.take_response(), b"\x1b[?62;22c");- feed(&mut t, b"\x1b[>c");- assert_eq!(t.take_response(), b"\x1b[>0;276;0c");- feed(&mut t, b"\x1b[=c");- assert_eq!(t.take_response(), b"\x1bP!|00000000\x1b\\");- }-- #[test]- fn xtversion_reports_name() {- let mut t = Term::new(20, 4);- feed(&mut t, b"\x1b[>q");- let resp = t.take_response();- assert!(resp.starts_with(b"\x1bP>|beer("));- assert!(resp.ends_with(b")\x1b\\"));- }-- #[test]- fn decrqm_reports_known_modes() {- let mut t = Term::new(20, 4);- feed(&mut t, b"\x1b[?7$p"); // autowrap, on by default- assert_eq!(t.take_response(), b"\x1b[?7;1$y");- feed(&mut t, b"\x1b[?7l\x1b[?7$p"); // turn it off, re-query- assert_eq!(t.take_response(), b"\x1b[?7;2$y");- feed(&mut t, b"\x1b[?9999$p"); // unknown mode- assert_eq!(t.take_response(), b"\x1b[?9999;0$y");- }-- #[test]- fn sgr_underline_styles_and_lines() {- let mut t = Term::new(20, 1);- feed(&mut t, b"\x1b[4:3;58;5;1;53mX");- let cell = t.grid().cell(0, 0);- assert_eq!(cell.underline, Underline::Curly);- assert_eq!(cell.underline_color, Color::Indexed(1));- assert!(cell.flags.contains(Flags::OVERLINE));- // 4:0 turns the underline back off.- feed(&mut t, b"\x1b[4:0mY");- assert_eq!(t.grid().cell(1, 0).underline, Underline::None);- }-- #[test]- fn decscusr_and_cursor_visibility() {- let mut t = Term::new(20, 1);- feed(&mut t, b"\x1b[4 q");- assert_eq!(t.grid().cursor_shape(), CursorShape::Underline);- feed(&mut t, b"\x1b[6 q");- assert_eq!(t.grid().cursor_shape(), CursorShape::Beam);- feed(&mut t, b"\x1b[0 q");- assert_eq!(t.grid().cursor_shape(), CursorShape::Block);-- feed(&mut t, b"\x1b[?25l");- assert!(!t.grid().cursor_visible());- feed(&mut t, b"\x1b[?25h");- assert!(t.grid().cursor_visible());- }-- #[test]- fn osc12_sets_and_resets_cursor_color() {- let mut t = Term::new(20, 1);- feed(&mut t, b"\x1b]12;#ff0000\x07");- assert_eq!(t.grid().cursor_color(), Some((255, 0, 0)));- feed(&mut t, b"\x1b]12;rgb:00/80/ff\x07");- assert_eq!(t.grid().cursor_color(), Some((0, 0x80, 0xff)));- feed(&mut t, b"\x1b]112\x07");- assert_eq!(t.grid().cursor_color(), None);- }-- #[test]- fn decscusr_and_cursor_color() {- use crate::grid::CursorShape;- let mut t = Term::new(20, 1);- feed(&mut t, b"\x1b[5 q"); // blinking bar- assert_eq!(t.grid().cursor_shape(), CursorShape::Beam);- feed(&mut t, b"\x1b[4 q"); // steady underline- assert_eq!(t.grid().cursor_shape(), CursorShape::Underline);- feed(&mut t, b"\x1b]12;#ff3030\x07");- assert_eq!(t.grid().cursor_color(), Some((0xff, 0x30, 0x30)));- feed(&mut t, b"\x1b]112\x07");- assert_eq!(t.grid().cursor_color(), None);- feed(&mut t, b"\x1b[?25l"); // hide cursor- assert!(!t.grid().cursor_visible());- }-- #[test]- fn osc_palette_and_dynamic_colors() {- use crate::theme::Rgb;- let mut t = Term::new(20, 2);- // Set palette index 1 and foreground via OSC, then query them back.- feed(&mut t, b"\x1b]4;1;#ff0000\x1b\\");- assert_eq!(t.theme().palette[1], Rgb(0xff, 0, 0));- feed(&mut t, b"\x1b]10;rgb:00/80/ff\x1b\\");- assert_eq!(t.theme().fg, Rgb(0, 0x80, 0xff));- feed(&mut t, b"\x1b]11;?\x07");- let resp = t.take_response();- assert!(resp.starts_with(b"\x1b]11;rgb:"));- // Reset returns the palette entry to its default.- feed(&mut t, b"\x1b]104;1\x1b\\");- assert_ne!(t.theme().palette[1], Rgb(0xff, 0, 0));- }-- #[test]- fn xtgettcap_known_and_unknown() {- let mut t = Term::new(20, 1);- feed(&mut t, b"\x1bP+q544e\x1b\\"); // "TN"- assert_eq!(t.take_response(), b"\x1bP1+r544e=62656572\x1b\\"); // = "beer"- feed(&mut t, b"\x1bP+q6162\x1b\\"); // "ab", unknown- assert_eq!(t.take_response(), b"\x1bP0+r6162\x1b\\");- }-- #[test]- fn bracketed_paste_and_sync_modes() {- let mut t = Term::new(20, 2);- feed(&mut t, b"\x1b[?2004h");- assert!(t.grid().bracketed_paste());- feed(&mut t, b"\x1b[?2004$p");- assert_eq!(t.take_response(), b"\x1b[?2004;1$y");- feed(&mut t, b"\x1b[?2026h");- assert!(t.grid().sync_active());- feed(&mut t, b"\x1b[?2026l\x1b[?2026$p");- assert!(!t.grid().sync_active());- assert_eq!(t.take_response(), b"\x1b[?2026;2$y");- }-- #[test]- fn base64_round_trips() {- for s in [- "",- "f",- "fo",- "foo",- "foob",- "fooba",- "foobar",- "hi there\n",- ] {- let enc = base64_encode(s.as_bytes());- assert_eq!(base64_decode(enc.as_bytes()).as_deref(), Some(s.as_bytes()));- }- assert_eq!(base64_encode(b"foobar"), "Zm9vYmFy");- assert_eq!(base64_decode(b"Zm9vYmFy").as_deref(), Some(&b"foobar"[..]));- }-- #[test]- fn osc52_set_and_query() {- let mut t = Term::new(20, 2);- // Set clipboard to "hi" (base64 "aGk=").- feed(&mut t, b"\x1b]52;c;aGk=\x07");- let ops = t.take_clipboard_ops();- match ops.as_slice() {- [- ClipboardOp::Set {- primary: false,- text,- },- ] => assert_eq!(text, "hi"),- other => panic!("unexpected ops: {other:?}"),- }- // Query the primary selection.- feed(&mut t, b"\x1b]52;p;?\x07");- let ops = t.take_clipboard_ops();- assert!(matches!(- ops.as_slice(),- [ClipboardOp::Query { primary: true }]- ));- }-- #[test]- fn mouse_modes_track_protocol_and_encoding() {- let mut t = Term::new(20, 4);- feed(&mut t, b"\x1b[?1002h\x1b[?1006h");- assert_eq!(t.grid().mouse_protocol(), MouseProtocol::Button);- assert_eq!(t.grid().mouse_encoding(), MouseEncoding::Sgr);- feed(&mut t, b"\x1b[?1002$p");- assert_eq!(t.take_response(), b"\x1b[?1002;1$y");- feed(&mut t, b"\x1b[?1003h"); // any-event supersedes button-event- assert_eq!(t.grid().mouse_protocol(), MouseProtocol::Any);- feed(&mut t, b"\x1b[?1000l"); // turning a mouse mode off clears reporting- assert_eq!(t.grid().mouse_protocol(), MouseProtocol::Off);- feed(&mut t, b"\x1b[?1004h");- assert!(t.grid().focus_events());- }-- #[test]- fn title_stack_push_pop() {- let mut t = Term::new(20, 4);- feed(&mut t, b"\x1b]0;first\x07");- feed(&mut t, b"\x1b[22t"); // push "first"- feed(&mut t, b"\x1b]0;second\x07");- assert_eq!(t.title(), Some("second"));- feed(&mut t, b"\x1b[23t"); // pop -> "first"- assert_eq!(t.title(), Some("first"));- }-- #[test]- fn sgr_sets_pen_colours() {- let mut t = Term::new(20, 1);- feed(&mut t, b"\x1b[31;1mX");- let cell = t.grid().cell(0, 0);- assert_eq!(cell.fg, Color::Indexed(1));- assert!(cell.flags.contains(Flags::BOLD));- }-- #[test]- fn truecolor_semicolon_and_colon() {- let mut t = Term::new(20, 1);- feed(&mut t, b"\x1b[38;2;10;20;30mA");- assert_eq!(t.grid().cell(0, 0).fg, Color::Rgb(10, 20, 30));- feed(&mut t, b"\x1b[38:2:40:50:60mB");- assert_eq!(t.grid().cell(1, 0).fg, Color::Rgb(40, 50, 60));- }-- #[test]- fn device_status_reports_cursor() {- let mut t = Term::new(20, 4);- feed(&mut t, b"\x1b[3;5H\x1b[6n");- assert_eq!(t.take_response(), b"\x1b[3;5R");- }-- #[test]- fn line_drawing_charset() {- let mut t = Term::new(20, 1);- feed(&mut t, b"\x1b(0qx\x1b(B");- assert_eq!(t.grid().row_text(0), "─│");- }-- #[test]- fn osc133_marks_capture_last_command_output() {- let mut t = Term::new(12, 6);- feed(&mut t, b"\x1b]133;A\x07$ echo hi\r\n"); // prompt + typed command- feed(&mut t, b"\x1b]133;C\x07hi\r\n"); // output start, then output- feed(&mut t, b"\x1b]133;D\x07"); // command finished- assert_eq!(t.grid().last_command_output().as_deref(), Some("hi\n"));- }-- #[test]- fn osc_notifications_collected() {- let mut t = Term::new(20, 2);- feed(&mut t, b"\x1b]9;hello\x07");- feed(&mut t, b"\x1b]777;notify;Title;Body\x07");- let n = t.take_notifications();- assert_eq!(- n,- vec![- Notification {- title: None,- body: "hello".into()- },- Notification {- title: Some("Title".into()),- body: "Body".into()- },- ]- );- assert!(t.take_notifications().is_empty());- }-- #[test]- fn osc7_tracks_cwd_and_decodes_percent() {- let mut t = Term::new(20, 1);- feed(&mut t, b"\x1b]7;file://hermes/home/user/my%20dir\x07");- assert_eq!(t.cwd(), Some("/home/user/my dir"));- // A non-file or relative URI leaves the previous value untouched? It- // simply does not match a path, so cwd stays None here.- let mut t2 = Term::new(20, 1);- feed(&mut t2, b"\x1b]7;file://host\x07");- assert_eq!(t2.cwd(), None);- }-- #[test]- fn title_via_osc() {- let mut t = Term::new(20, 1);- feed(&mut t, b"\x1b]0;hello\x07");- assert_eq!(t.title(), Some("hello"));- }-- #[test]- fn alt_screen_preserves_primary() {- let mut t = Term::new(20, 2);- feed(&mut t, b"main");- feed(&mut t, b"\x1b[?1049h");- assert_eq!(t.grid().row_text(0), "");- feed(&mut t, b"\x1b[?1049l");- assert_eq!(t.grid().row_text(0), "main");- }-}@@ -0,0 +1,1018 @@+//! VT emulation: feed bytes through `vte` and drive the [`Grid`].++mod perform;++use std::io::Write as _;++use vte::Params;++use crate::grid::{+ Color, CursorShape, Flags, Grid, MouseEncoding, MouseProtocol, PromptKind, Underline,+};+use crate::theme::{Rgb, Theme};++/// G0/G1 character set designation.+#[derive(Clone, Copy, PartialEq, Eq, Debug)]+enum Charset {+ Ascii,+ DecSpecial,+}++/// Which device-attributes query is being answered.+#[derive(Clone, Copy, Debug)]+enum DaLevel {+ Primary,+ Secondary,+ Tertiary,+}++/// A clipboard request from the application (OSC 52), for the front-end to act+/// on since it owns the Wayland selections.+#[derive(Clone, Debug)]+pub enum ClipboardOp {+ /// Set the clipboard (or primary) to `text`.+ Set { primary: bool, text: String },+ /// Report the current clipboard (or primary) contents back to the app.+ Query { primary: bool },+}++/// A desktop notification an application requested (OSC 9 / 777 / 99), for the+/// front-end to deliver via the configured notifier.+#[derive(Clone, Debug, PartialEq, Eq)]+pub struct Notification {+ pub title: Option<String>,+ pub body: String,+}++/// Which dynamic colour an OSC 10/11/17/19 escape targets.+#[derive(Clone, Copy, Debug)]+enum Dynamic {+ Fg,+ Bg,+ SelBg,+ SelFg,+}++/// DECRQM mode-state code: 1 = set, 2 = reset.+fn set_reset(on: bool) -> u8 {+ if on { 1 } else { 2 }+}++/// Parse an OSC colour spec into an [`Rgb`].+fn parse_spec(spec: &[u8]) -> Option<Rgb> {+ std::str::from_utf8(spec)+ .ok()+ .and_then(crate::theme::parse_color)+}++/// Parse a decimal palette index (0-255).+fn parse_index(b: &[u8]) -> Option<u8> {+ std::str::from_utf8(b).ok()?.parse().ok()+}++fn rgb_tuple(rgb: Rgb) -> (u8, u8, u8) {+ (rgb.0, rgb.1, rgb.2)+}++/// Select `protocol` when a mouse mode is set, else turn reporting off.+fn proto(on: bool, protocol: MouseProtocol) -> MouseProtocol {+ if on { protocol } else { MouseProtocol::Off }+}++/// Select `encoding` when its mode is set, else fall back to the default form.+fn enc(on: bool, encoding: MouseEncoding) -> MouseEncoding {+ if on { encoding } else { MouseEncoding::X10 }+}++/// Map an SGR 4 param (`4` or `4:x`) to an underline style.+fn underline_from(param: &[u16]) -> Underline {+ match param.get(1).copied().unwrap_or(1) {+ 0 => Underline::None,+ 2 => Underline::Double,+ 3 => Underline::Curly,+ 4 => Underline::Dotted,+ 5 => Underline::Dashed,+ _ => Underline::Single,+ }+}++/// The terminal model: a grid plus the escape-sequence state around it.+#[derive(Debug)]+pub struct Term {+ grid: Grid,+ title: Option<String>,+ title_stack: Vec<Option<String>>,+ response: Vec<u8>,+ g0: Charset,+ g1: Charset,+ shift_out: bool,+ /// Accumulated payload of an in-progress `DCS + q` (XTGETTCAP) query.+ xtgettcap: Option<Vec<u8>>,+ /// Pending OSC 52 clipboard requests, drained by the front-end.+ clipboard_ops: Vec<ClipboardOp>,+ /// The active colour scheme (seeded from config, mutated by OSC escapes).+ theme: Theme,+ /// Set when the child rings the bell (`BEL`); cleared by the front-end.+ bell: bool,+ /// Working directory reported by the shell via OSC 7, for new windows.+ cwd: Option<String>,+ /// Desktop notifications requested via OSC 9/777/99, drained by the front-end.+ notifications: Vec<Notification>,+}++impl Term {+ pub fn new(cols: usize, rows: usize) -> Self {+ Self {+ grid: Grid::new(cols, rows),+ title: None,+ title_stack: Vec::new(),+ response: Vec::new(),+ g0: Charset::Ascii,+ g1: Charset::Ascii,+ shift_out: false,+ xtgettcap: None,+ clipboard_ops: Vec::new(),+ theme: Theme::default(),+ bell: false,+ cwd: None,+ notifications: Vec::new(),+ }+ }++ /// The working directory last reported by the shell (OSC 7), if any.+ pub fn cwd(&self) -> Option<&str> {+ self.cwd.as_deref()+ }++ /// Drain the desktop notifications requested since the last call.+ pub fn take_notifications(&mut self) -> Vec<Notification> {+ std::mem::take(&mut self.notifications)+ }++ /// Take and clear the pending bell flag.+ pub fn take_bell(&mut self) -> bool {+ std::mem::take(&mut self.bell)+ }++ /// Drain the OSC 52 clipboard requests accumulated since the last call.+ pub fn take_clipboard_ops(&mut self) -> Vec<ClipboardOp> {+ std::mem::take(&mut self.clipboard_ops)+ }++ pub fn theme(&self) -> &Theme {+ &self.theme+ }++ /// Replace the colour scheme (config load / reload).+ pub fn set_theme(&mut self, theme: Theme) {+ self.theme = theme;+ }++ /// Answer an XTGETTCAP query: for each hex-encoded capability name, reply+ /// with `DCS 1 + r name=value ST` if known, else `DCS 0 + r name ST`.+ fn answer_xtgettcap(&mut self, payload: &[u8]) {+ for name_hex in payload.split(|&b| b == b';') {+ let value = decode_hex(name_hex).and_then(|name| cap_value(&name));+ match value {+ Some(value) => {+ self.response.extend_from_slice(b"\x1bP1+r");+ self.response.extend_from_slice(name_hex);+ self.response.push(b'=');+ for byte in value.bytes() {+ let _ = write!(self.response, "{byte:02x}");+ }+ self.response.extend_from_slice(b"\x1b\\");+ }+ None => {+ self.response.extend_from_slice(b"\x1bP0+r");+ self.response.extend_from_slice(name_hex);+ self.response.extend_from_slice(b"\x1b\\");+ }+ }+ }+ }++ pub fn grid(&self) -> &Grid {+ &self.grid+ }++ pub fn grid_mut(&mut self) -> &mut Grid {+ &mut self.grid+ }++ pub fn resize(&mut self, cols: usize, rows: usize) {+ self.grid.resize(cols, rows);+ }++ pub fn scroll_view(&mut self, delta: isize) {+ self.grid.scroll_view(delta);+ }++ pub fn scroll_to_bottom(&mut self) {+ self.grid.scroll_to_bottom();+ }++ /// Lines per page, for page-scroll bindings.+ pub fn page(&self) -> usize {+ self.grid.page()+ }++ pub fn title(&self) -> Option<&str> {+ self.title.as_deref()+ }++ /// Bytes the terminal needs to send back to the application (DA, CPR, ...).+ pub fn take_response(&mut self) -> Vec<u8> {+ std::mem::take(&mut self.response)+ }++ fn active_charset(&self) -> Charset {+ if self.shift_out { self.g1 } else { self.g0 }+ }++ fn set_mode(&mut self, params: &Params, private: bool, on: bool) {+ for p in params.iter() {+ let Some(&code) = p.first() else { continue };+ match (private, code) {+ (true, 6) => self.grid.set_origin(on),+ (true, 7) => self.grid.set_autowrap(on),+ (true, 1049) => {+ if on {+ self.grid.save_cursor();+ self.grid.enter_alt_screen();+ self.grid.erase_display(2);+ } else {+ self.grid.leave_alt_screen();+ self.grid.restore_cursor();+ }+ }+ (true, 47 | 1047) => {+ if on {+ self.grid.enter_alt_screen();+ } else {+ self.grid.leave_alt_screen();+ }+ }+ (false, 4) => self.grid.set_insert(on),+ (true, 1) => self.grid.set_app_cursor(on),+ (true, 25) => self.grid.set_cursor_visible(on),+ (true, 9) => self.grid.set_mouse_protocol(proto(on, MouseProtocol::X10)),+ (true, 1000) => self+ .grid+ .set_mouse_protocol(proto(on, MouseProtocol::Normal)),+ (true, 1002) => self+ .grid+ .set_mouse_protocol(proto(on, MouseProtocol::Button)),+ (true, 1003) => self.grid.set_mouse_protocol(proto(on, MouseProtocol::Any)),+ (true, 1004) => self.grid.set_focus_events(on),+ (true, 1005) => self.grid.set_mouse_encoding(enc(on, MouseEncoding::Utf8)),+ (true, 1006) => self.grid.set_mouse_encoding(enc(on, MouseEncoding::Sgr)),+ (true, 2004) => self.grid.set_bracketed_paste(on),+ (true, 2026) => self.grid.set_sync(on),+ _ => tracing::trace!("unhandled mode {code} private={private} on={on}"),+ }+ }+ }++ fn sgr(&mut self, params: &Params) {+ let items: Vec<&[u16]> = params.iter().collect();+ if items.is_empty() {+ self.grid.reset_pen();+ return;+ }+ let mut i = 0;+ while i < items.len() {+ let p = items[i];+ let code = p.first().copied().unwrap_or(0);+ let pen = self.grid.pen_mut();+ let mut step = 1;+ match code {+ 0 => *pen = Default::default(),+ 1 => pen.flags.insert(Flags::BOLD),+ 2 => pen.flags.insert(Flags::DIM),+ 3 => pen.flags.insert(Flags::ITALIC),+ 4 => pen.underline = underline_from(p),+ 5 | 6 => pen.flags.insert(Flags::BLINK),+ 7 => pen.flags.insert(Flags::REVERSE),+ 8 => pen.flags.insert(Flags::HIDDEN),+ 9 => pen.flags.insert(Flags::STRIKE),+ 21 => pen.underline = Underline::Double,+ 22 => pen.flags.remove(Flags::BOLD.union(Flags::DIM)),+ 23 => pen.flags.remove(Flags::ITALIC),+ 24 => pen.underline = Underline::None,+ 25 => pen.flags.remove(Flags::BLINK),+ 27 => pen.flags.remove(Flags::REVERSE),+ 28 => pen.flags.remove(Flags::HIDDEN),+ 29 => pen.flags.remove(Flags::STRIKE),+ 30..=37 => pen.fg = Color::Indexed((code - 30) as u8),+ 39 => pen.fg = Color::Default,+ 40..=47 => pen.bg = Color::Indexed((code - 40) as u8),+ 49 => pen.bg = Color::Default,+ 53 => pen.flags.insert(Flags::OVERLINE),+ 55 => pen.flags.remove(Flags::OVERLINE),+ 90..=97 => pen.fg = Color::Indexed((code - 90 + 8) as u8),+ 100..=107 => pen.bg = Color::Indexed((code - 100 + 8) as u8),+ 38 | 48 | 58 => {+ let (color, consumed) = ext_color(&items, i);+ if let Some(color) = color {+ match code {+ 38 => pen.fg = color,+ 48 => pen.bg = color,+ _ => pen.underline_color = color,+ }+ }+ step = consumed;+ }+ 59 => pen.underline_color = Color::Default,+ _ => {}+ }+ i += step;+ }+ }++ /// Device attributes. DA1 claims a VT220 with ANSI colour; DA2 a generic+ /// firmware level; DA3 a (zero) unit ID.+ fn device_attrs(&mut self, level: DaLevel) {+ match level {+ DaLevel::Primary => self.response.extend_from_slice(b"\x1b[?62;22c"),+ DaLevel::Secondary => self.response.extend_from_slice(b"\x1b[>0;276;0c"),+ DaLevel::Tertiary => self.response.extend_from_slice(b"\x1bP!|00000000\x1b\\"),+ }+ }++ /// XTVERSION (`CSI > q`): report the terminal name and version.+ fn report_version(&mut self) {+ let _ = write!(+ self.response,+ "\x1bP>|beer({})\x1b\\",+ env!("CARGO_PKG_VERSION")+ );+ }++ /// Emit an OSC colour reply (`OSC code ; rgb:rrrr/gggg/bbbb` + terminator).+ fn reply_color(&mut self, code: &str, rgb: Rgb, bell: bool) {+ let Rgb(r, g, b) = rgb;+ let _ = write!(+ self.response,+ "\x1b]{code};rgb:{:04x}/{:04x}/{:04x}",+ u16::from(r) * 0x101,+ u16::from(g) * 0x101,+ u16::from(b) * 0x101,+ );+ self.response+ .extend_from_slice(if bell { b"\x07" } else { b"\x1b\\" });+ }++ /// OSC 4: set or query palette entries, given as `index;spec` pairs.+ fn osc_palette(&mut self, params: &[&[u8]], bell: bool) {+ let mut rest = params[1..].iter();+ while let (Some(idx_raw), Some(spec)) = (rest.next(), rest.next()) {+ let Some(idx) = parse_index(idx_raw) else {+ continue;+ };+ if *spec == b"?" {+ let rgb = self.theme.palette[idx as usize];+ self.reply_color(&format!("4;{idx}"), rgb, bell);+ } else if let Some(rgb) = parse_spec(spec) {+ self.theme.set_palette(idx, rgb);+ }+ }+ }++ /// OSC 10/11/17/19: set or query a dynamic colour.+ fn osc_dynamic_color(&mut self, kind: Dynamic, spec: Option<&&[u8]>, bell: bool) {+ let Some(spec) = spec else { return };+ if **spec == b"?"[..] {+ let rgb = match kind {+ Dynamic::Fg => self.theme.fg,+ Dynamic::Bg => self.theme.bg,+ Dynamic::SelBg => self.theme.selection_bg,+ Dynamic::SelFg => self.theme.selection_fg.unwrap_or(self.theme.fg),+ };+ let code = match kind {+ Dynamic::Fg => "10",+ Dynamic::Bg => "11",+ Dynamic::SelBg => "17",+ Dynamic::SelFg => "19",+ };+ self.reply_color(code, rgb, bell);+ } else if let Some(rgb) = parse_spec(spec) {+ match kind {+ Dynamic::Fg => self.theme.fg = rgb,+ Dynamic::Bg => self.theme.bg = rgb,+ Dynamic::SelBg => self.theme.selection_bg = rgb,+ Dynamic::SelFg => self.theme.selection_fg = Some(rgb),+ }+ }+ }++ fn device_status(&mut self, params: &Params) {+ match params.iter().next().and_then(|p| p.first().copied()) {+ Some(5) => self.response.extend_from_slice(b"\x1b[0n"),+ Some(6) => {+ let (x, y) = self.grid.cursor();+ let _ = write!(self.response, "\x1b[{};{}R", y + 1, x + 1);+ }+ _ => {}+ }+ }++ /// DECRQM (`CSI [?] Ps $ p`): report whether a mode is set (1), reset (2),+ /// or unrecognized (0). Only the modes we actually track are reported.+ fn report_mode(&mut self, params: &Params, private: bool) {+ let code = raw(params, 0);+ let state = match (private, code) {+ (true, 6) => set_reset(self.grid.origin()),+ (true, 7) => set_reset(self.grid.autowrap()),+ (true, 47 | 1047 | 1049) => set_reset(self.grid.alt_active()),+ (true, 9) => set_reset(self.grid.mouse_protocol() == MouseProtocol::X10),+ (true, 1000) => set_reset(self.grid.mouse_protocol() == MouseProtocol::Normal),+ (true, 1002) => set_reset(self.grid.mouse_protocol() == MouseProtocol::Button),+ (true, 1003) => set_reset(self.grid.mouse_protocol() == MouseProtocol::Any),+ (true, 1004) => set_reset(self.grid.focus_events()),+ (true, 1005) => set_reset(self.grid.mouse_encoding() == MouseEncoding::Utf8),+ (true, 1006) => set_reset(self.grid.mouse_encoding() == MouseEncoding::Sgr),+ (true, 2004) => set_reset(self.grid.bracketed_paste()),+ (true, 2026) => set_reset(self.grid.sync_active()),+ (false, 4) => set_reset(self.grid.insert()),+ _ => 0,+ };+ let prefix = if private { "?" } else { "" };+ let _ = write!(self.response, "\x1b[{prefix}{code};{state}$y");+ }++ /// Title stack (`CSI 22/23 ; Ps t`): push or pop the window title.+ fn title_stack_op(&mut self, params: &Params) {+ match raw(params, 0) {+ 22 => self.title_stack.push(self.title.clone()),+ 23 => {+ if let Some(title) = self.title_stack.pop() {+ self.title = title;+ }+ }+ _ => {}+ }+ }+}++/// First param value, with 0/absent folded to `default` (xterm convention for+/// cursor movement and counts).+fn n(params: &Params, idx: usize, default: usize) -> usize {+ match params.iter().nth(idx).and_then(|p| p.first().copied()) {+ Some(0) | None => default,+ Some(v) => v as usize,+ }+}++/// Raw first param value (0 is meaningful), defaulting to 0 when absent.+fn raw(params: &Params, idx: usize) -> u16 {+ params+ .iter()+ .nth(idx)+ .and_then(|p| p.first().copied())+ .unwrap_or(0)+}++/// Parse an SGR 38/48 extended colour, returning the colour and how many+/// top-level params it consumed (1 for colon form, more for semicolon form).+fn ext_color(items: &[&[u16]], i: usize) -> (Option<Color>, usize) {+ let head = items[i];+ if head.len() >= 2 {+ return (color_from_subparams(&head[1..]), 1);+ }+ match items.get(i + 1).and_then(|s| s.first().copied()) {+ Some(5) => {+ let idx = items+ .get(i + 2)+ .and_then(|s| s.first().copied())+ .unwrap_or(0);+ (Some(Color::Indexed(idx as u8)), 3)+ }+ Some(2) => {+ let get = |k: usize| {+ items+ .get(i + k)+ .and_then(|s| s.first().copied())+ .unwrap_or(0)+ };+ (+ Some(Color::Rgb(get(2) as u8, get(3) as u8, get(4) as u8)),+ 5,+ )+ }+ _ => (None, 1),+ }+}++fn color_from_subparams(sub: &[u16]) -> Option<Color> {+ match sub.first().copied() {+ Some(5) => sub.get(1).map(|&i| Color::Indexed(i as u8)),+ Some(2) => {+ // Either `2:r:g:b` or `2:colorspace:r:g:b`.+ let rgb = if sub.len() >= 5 {+ &sub[2..5]+ } else {+ &sub[1..]+ };+ match rgb {+ [r, g, b, ..] => Some(Color::Rgb(*r as u8, *g as u8, *b as u8)),+ _ => None,+ }+ }+ _ => None,+ }+}++fn charset(byte: u8) -> Charset {+ match byte {+ b'0' => Charset::DecSpecial,+ _ => Charset::Ascii,+ }+}++/// Translate a byte under the DEC special graphics set (line drawing).+fn dec_special(c: char) -> char {+ match c {+ '`' => '◆',+ 'a' => '▒',+ 'f' => '°',+ 'g' => '±',+ 'j' => '┘',+ 'k' => '┐',+ 'l' => '┌',+ 'm' => '└',+ 'n' => '┼',+ 'o' => '⎺',+ 'p' => '⎻',+ 'q' => '─',+ 'r' => '⎼',+ 's' => '⎽',+ 't' => '├',+ 'u' => '┤',+ 'v' => '┴',+ 'w' => '┬',+ 'x' => '│',+ 'y' => '≤',+ 'z' => '≥',+ '~' => '·',+ _ => c,+ }+}++/// Look up a terminfo capability beer reports via XTGETTCAP.+fn cap_value(name: &[u8]) -> Option<&'static str> {+ match name {+ b"TN" => Some("beer"),+ b"Co" | b"colors" => Some("256"),+ b"RGB" => Some("8/8/8"),+ _ => None,+ }+}++const B64: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";++/// Standard base64 encode (used for OSC 52 query replies).+pub fn base64_encode(data: &[u8]) -> String {+ let mut out = String::with_capacity(data.len().div_ceil(3) * 4);+ for chunk in data.chunks(3) {+ let b = [+ chunk[0],+ *chunk.get(1).unwrap_or(&0),+ *chunk.get(2).unwrap_or(&0),+ ];+ let n = (u32::from(b[0]) << 16) | (u32::from(b[1]) << 8) | u32::from(b[2]);+ out.push(B64[(n >> 18 & 63) as usize] as char);+ out.push(B64[(n >> 12 & 63) as usize] as char);+ out.push(if chunk.len() > 1 {+ B64[(n >> 6 & 63) as usize] as char+ } else {+ '='+ });+ out.push(if chunk.len() > 2 {+ B64[(n & 63) as usize] as char+ } else {+ '='+ });+ }+ out+}++/// Standard base64 decode, ignoring padding and whitespace; `None` on a bad+/// character.+fn base64_decode(data: &[u8]) -> Option<Vec<u8>> {+ let val = |c: u8| -> Option<u32> {+ match c {+ b'A'..=b'Z' => Some(u32::from(c - b'A')),+ b'a'..=b'z' => Some(u32::from(c - b'a') + 26),+ b'0'..=b'9' => Some(u32::from(c - b'0') + 52),+ b'+' => Some(62),+ b'/' => Some(63),+ _ => None,+ }+ };+ let filtered: Vec<u8> = data+ .iter()+ .copied()+ .filter(|&c| c != b'=' && !c.is_ascii_whitespace())+ .collect();+ let mut out = Vec::with_capacity(filtered.len() / 4 * 3);+ for chunk in filtered.chunks(4) {+ if chunk.len() == 1 {+ return None; // a lone sextet cannot form a byte+ }+ let mut n = 0u32;+ for &c in chunk {+ n = (n << 6) | val(c)?;+ }+ n <<= 6 * (4 - chunk.len() as u32);+ out.push((n >> 16) as u8);+ if chunk.len() >= 3 {+ out.push((n >> 8) as u8);+ }+ if chunk.len() >= 4 {+ out.push(n as u8);+ }+ }+ Some(out)+}++/// Decode an OSC string field to UTF-8 (lossy), or `None` if absent.+fn osc_text(field: Option<&&[u8]>) -> Option<String> {+ field.map(|b| String::from_utf8_lossy(b).into_owned())+}++/// Map an OSC 133 mark letter to a [`PromptKind`].+fn prompt_kind(b: u8) -> Option<PromptKind> {+ match b {+ b'A' => Some(PromptKind::PromptStart),+ b'B' => Some(PromptKind::CmdStart),+ b'C' => Some(PromptKind::OutputStart),+ b'D' => Some(PromptKind::CmdEnd),+ _ => None,+ }+}++/// Extract the local path from an OSC 7 `file://host/path` URI, percent-decoding+/// `%XX` escapes. The host part is ignored (we only spawn locally). Returns+/// `None` if it is not a usable absolute path.+fn file_uri_path(uri: &[u8]) -> Option<String> {+ let rest = uri.strip_prefix(b"file://").unwrap_or(uri);+ // Skip the authority (host) up to the first '/', which begins the path.+ let slash = rest.iter().position(|&b| b == b'/')?;+ let path_bytes = percent_decode(&rest[slash..]);+ let path = String::from_utf8(path_bytes).ok()?;+ path.starts_with('/').then_some(path)+}++/// Percent-decode `%XX` byte escapes in a URI path, passing other bytes through.+fn percent_decode(s: &[u8]) -> Vec<u8> {+ let mut out = Vec::with_capacity(s.len());+ let mut i = 0;+ while i < s.len() {+ if s[i] == b'%' && i + 2 < s.len() {+ let hi = (s[i + 1] as char).to_digit(16);+ let lo = (s[i + 2] as char).to_digit(16);+ if let (Some(hi), Some(lo)) = (hi, lo) {+ out.push((hi * 16 + lo) as u8);+ i += 3;+ continue;+ }+ }+ out.push(s[i]);+ i += 1;+ }+ out+}++/// Decode an even-length lowercase/uppercase hex string into bytes.+fn decode_hex(s: &[u8]) -> Option<Vec<u8>> {+ if s.is_empty() || !s.len().is_multiple_of(2) {+ return None;+ }+ let nibble = |b: u8| (b as char).to_digit(16).map(|d| d as u8);+ s.chunks_exact(2)+ .map(|pair| Some((nibble(pair[0])? << 4) | nibble(pair[1])?))+ .collect()+}++#[cfg(test)]+mod tests {+ use super::*;+ use crate::grid::{MouseEncoding, MouseProtocol};++ fn feed(term: &mut Term, bytes: &[u8]) {+ let mut parser = vte::Parser::new();+ parser.advance(term, bytes);+ }++ #[test]+ fn plain_text_lands_in_the_grid() {+ let mut t = Term::new(20, 4);+ feed(&mut t, b"hello");+ assert_eq!(t.grid().row_text(0), "hello");+ }++ #[test]+ fn cursor_position_and_erase() {+ let mut t = Term::new(20, 4);+ feed(&mut t, b"abcde\x1b[Hxyz");+ assert_eq!(t.grid().row_text(0), "xyzde");+ }++ #[test]+ fn newline_sequence() {+ let mut t = Term::new(20, 4);+ feed(&mut t, b"one\r\ntwo");+ assert_eq!(t.grid().row_text(0), "one");+ assert_eq!(t.grid().row_text(1), "two");+ }++ #[test]+ fn device_attributes_levels() {+ let mut t = Term::new(20, 4);+ feed(&mut t, b"\x1b[c");+ assert_eq!(t.take_response(), b"\x1b[?62;22c");+ feed(&mut t, b"\x1b[>c");+ assert_eq!(t.take_response(), b"\x1b[>0;276;0c");+ feed(&mut t, b"\x1b[=c");+ assert_eq!(t.take_response(), b"\x1bP!|00000000\x1b\\");+ }++ #[test]+ fn xtversion_reports_name() {+ let mut t = Term::new(20, 4);+ feed(&mut t, b"\x1b[>q");+ let resp = t.take_response();+ assert!(resp.starts_with(b"\x1bP>|beer("));+ assert!(resp.ends_with(b")\x1b\\"));+ }++ #[test]+ fn decrqm_reports_known_modes() {+ let mut t = Term::new(20, 4);+ feed(&mut t, b"\x1b[?7$p"); // autowrap, on by default+ assert_eq!(t.take_response(), b"\x1b[?7;1$y");+ feed(&mut t, b"\x1b[?7l\x1b[?7$p"); // turn it off, re-query+ assert_eq!(t.take_response(), b"\x1b[?7;2$y");+ feed(&mut t, b"\x1b[?9999$p"); // unknown mode+ assert_eq!(t.take_response(), b"\x1b[?9999;0$y");+ }++ #[test]+ fn sgr_underline_styles_and_lines() {+ let mut t = Term::new(20, 1);+ feed(&mut t, b"\x1b[4:3;58;5;1;53mX");+ let cell = t.grid().cell(0, 0);+ assert_eq!(cell.underline, Underline::Curly);+ assert_eq!(cell.underline_color, Color::Indexed(1));+ assert!(cell.flags.contains(Flags::OVERLINE));+ // 4:0 turns the underline back off.+ feed(&mut t, b"\x1b[4:0mY");+ assert_eq!(t.grid().cell(1, 0).underline, Underline::None);+ }++ #[test]+ fn decscusr_and_cursor_visibility() {+ let mut t = Term::new(20, 1);+ feed(&mut t, b"\x1b[4 q");+ assert_eq!(t.grid().cursor_shape(), CursorShape::Underline);+ feed(&mut t, b"\x1b[6 q");+ assert_eq!(t.grid().cursor_shape(), CursorShape::Beam);+ feed(&mut t, b"\x1b[0 q");+ assert_eq!(t.grid().cursor_shape(), CursorShape::Block);++ feed(&mut t, b"\x1b[?25l");+ assert!(!t.grid().cursor_visible());+ feed(&mut t, b"\x1b[?25h");+ assert!(t.grid().cursor_visible());+ }++ #[test]+ fn osc12_sets_and_resets_cursor_color() {+ let mut t = Term::new(20, 1);+ feed(&mut t, b"\x1b]12;#ff0000\x07");+ assert_eq!(t.grid().cursor_color(), Some((255, 0, 0)));+ feed(&mut t, b"\x1b]12;rgb:00/80/ff\x07");+ assert_eq!(t.grid().cursor_color(), Some((0, 0x80, 0xff)));+ feed(&mut t, b"\x1b]112\x07");+ assert_eq!(t.grid().cursor_color(), None);+ }++ #[test]+ fn decscusr_and_cursor_color() {+ use crate::grid::CursorShape;+ let mut t = Term::new(20, 1);+ feed(&mut t, b"\x1b[5 q"); // blinking bar+ assert_eq!(t.grid().cursor_shape(), CursorShape::Beam);+ feed(&mut t, b"\x1b[4 q"); // steady underline+ assert_eq!(t.grid().cursor_shape(), CursorShape::Underline);+ feed(&mut t, b"\x1b]12;#ff3030\x07");+ assert_eq!(t.grid().cursor_color(), Some((0xff, 0x30, 0x30)));+ feed(&mut t, b"\x1b]112\x07");+ assert_eq!(t.grid().cursor_color(), None);+ feed(&mut t, b"\x1b[?25l"); // hide cursor+ assert!(!t.grid().cursor_visible());+ }++ #[test]+ fn osc_palette_and_dynamic_colors() {+ use crate::theme::Rgb;+ let mut t = Term::new(20, 2);+ // Set palette index 1 and foreground via OSC, then query them back.+ feed(&mut t, b"\x1b]4;1;#ff0000\x1b\\");+ assert_eq!(t.theme().palette[1], Rgb(0xff, 0, 0));+ feed(&mut t, b"\x1b]10;rgb:00/80/ff\x1b\\");+ assert_eq!(t.theme().fg, Rgb(0, 0x80, 0xff));+ feed(&mut t, b"\x1b]11;?\x07");+ let resp = t.take_response();+ assert!(resp.starts_with(b"\x1b]11;rgb:"));+ // Reset returns the palette entry to its default.+ feed(&mut t, b"\x1b]104;1\x1b\\");+ assert_ne!(t.theme().palette[1], Rgb(0xff, 0, 0));+ }++ #[test]+ fn xtgettcap_known_and_unknown() {+ let mut t = Term::new(20, 1);+ feed(&mut t, b"\x1bP+q544e\x1b\\"); // "TN"+ assert_eq!(t.take_response(), b"\x1bP1+r544e=62656572\x1b\\"); // = "beer"+ feed(&mut t, b"\x1bP+q6162\x1b\\"); // "ab", unknown+ assert_eq!(t.take_response(), b"\x1bP0+r6162\x1b\\");+ }++ #[test]+ fn bracketed_paste_and_sync_modes() {+ let mut t = Term::new(20, 2);+ feed(&mut t, b"\x1b[?2004h");+ assert!(t.grid().bracketed_paste());+ feed(&mut t, b"\x1b[?2004$p");+ assert_eq!(t.take_response(), b"\x1b[?2004;1$y");+ feed(&mut t, b"\x1b[?2026h");+ assert!(t.grid().sync_active());+ feed(&mut t, b"\x1b[?2026l\x1b[?2026$p");+ assert!(!t.grid().sync_active());+ assert_eq!(t.take_response(), b"\x1b[?2026;2$y");+ }++ #[test]+ fn base64_round_trips() {+ for s in [+ "",+ "f",+ "fo",+ "foo",+ "foob",+ "fooba",+ "foobar",+ "hi there\n",+ ] {+ let enc = base64_encode(s.as_bytes());+ assert_eq!(base64_decode(enc.as_bytes()).as_deref(), Some(s.as_bytes()));+ }+ assert_eq!(base64_encode(b"foobar"), "Zm9vYmFy");+ assert_eq!(base64_decode(b"Zm9vYmFy").as_deref(), Some(&b"foobar"[..]));+ }++ #[test]+ fn osc52_set_and_query() {+ let mut t = Term::new(20, 2);+ // Set clipboard to "hi" (base64 "aGk=").+ feed(&mut t, b"\x1b]52;c;aGk=\x07");+ let ops = t.take_clipboard_ops();+ match ops.as_slice() {+ [+ ClipboardOp::Set {+ primary: false,+ text,+ },+ ] => assert_eq!(text, "hi"),+ other => panic!("unexpected ops: {other:?}"),+ }+ // Query the primary selection.+ feed(&mut t, b"\x1b]52;p;?\x07");+ let ops = t.take_clipboard_ops();+ assert!(matches!(+ ops.as_slice(),+ [ClipboardOp::Query { primary: true }]+ ));+ }++ #[test]+ fn mouse_modes_track_protocol_and_encoding() {+ let mut t = Term::new(20, 4);+ feed(&mut t, b"\x1b[?1002h\x1b[?1006h");+ assert_eq!(t.grid().mouse_protocol(), MouseProtocol::Button);+ assert_eq!(t.grid().mouse_encoding(), MouseEncoding::Sgr);+ feed(&mut t, b"\x1b[?1002$p");+ assert_eq!(t.take_response(), b"\x1b[?1002;1$y");+ feed(&mut t, b"\x1b[?1003h"); // any-event supersedes button-event+ assert_eq!(t.grid().mouse_protocol(), MouseProtocol::Any);+ feed(&mut t, b"\x1b[?1000l"); // turning a mouse mode off clears reporting+ assert_eq!(t.grid().mouse_protocol(), MouseProtocol::Off);+ feed(&mut t, b"\x1b[?1004h");+ assert!(t.grid().focus_events());+ }++ #[test]+ fn title_stack_push_pop() {+ let mut t = Term::new(20, 4);+ feed(&mut t, b"\x1b]0;first\x07");+ feed(&mut t, b"\x1b[22t"); // push "first"+ feed(&mut t, b"\x1b]0;second\x07");+ assert_eq!(t.title(), Some("second"));+ feed(&mut t, b"\x1b[23t"); // pop -> "first"+ assert_eq!(t.title(), Some("first"));+ }++ #[test]+ fn sgr_sets_pen_colours() {+ let mut t = Term::new(20, 1);+ feed(&mut t, b"\x1b[31;1mX");+ let cell = t.grid().cell(0, 0);+ assert_eq!(cell.fg, Color::Indexed(1));+ assert!(cell.flags.contains(Flags::BOLD));+ }++ #[test]+ fn truecolor_semicolon_and_colon() {+ let mut t = Term::new(20, 1);+ feed(&mut t, b"\x1b[38;2;10;20;30mA");+ assert_eq!(t.grid().cell(0, 0).fg, Color::Rgb(10, 20, 30));+ feed(&mut t, b"\x1b[38:2:40:50:60mB");+ assert_eq!(t.grid().cell(1, 0).fg, Color::Rgb(40, 50, 60));+ }++ #[test]+ fn device_status_reports_cursor() {+ let mut t = Term::new(20, 4);+ feed(&mut t, b"\x1b[3;5H\x1b[6n");+ assert_eq!(t.take_response(), b"\x1b[3;5R");+ }++ #[test]+ fn line_drawing_charset() {+ let mut t = Term::new(20, 1);+ feed(&mut t, b"\x1b(0qx\x1b(B");+ assert_eq!(t.grid().row_text(0), "─│");+ }++ #[test]+ fn osc133_marks_capture_last_command_output() {+ let mut t = Term::new(12, 6);+ feed(&mut t, b"\x1b]133;A\x07$ echo hi\r\n"); // prompt + typed command+ feed(&mut t, b"\x1b]133;C\x07hi\r\n"); // output start, then output+ feed(&mut t, b"\x1b]133;D\x07"); // command finished+ assert_eq!(t.grid().last_command_output().as_deref(), Some("hi\n"));+ }++ #[test]+ fn osc_notifications_collected() {+ let mut t = Term::new(20, 2);+ feed(&mut t, b"\x1b]9;hello\x07");+ feed(&mut t, b"\x1b]777;notify;Title;Body\x07");+ let n = t.take_notifications();+ assert_eq!(+ n,+ vec![+ Notification {+ title: None,+ body: "hello".into()+ },+ Notification {+ title: Some("Title".into()),+ body: "Body".into()+ },+ ]+ );+ assert!(t.take_notifications().is_empty());+ }++ #[test]+ fn osc7_tracks_cwd_and_decodes_percent() {+ let mut t = Term::new(20, 1);+ feed(&mut t, b"\x1b]7;file://hermes/home/user/my%20dir\x07");+ assert_eq!(t.cwd(), Some("/home/user/my dir"));+ // A non-file or relative URI leaves the previous value untouched? It+ // simply does not match a path, so cwd stays None here.+ let mut t2 = Term::new(20, 1);+ feed(&mut t2, b"\x1b]7;file://host\x07");+ assert_eq!(t2.cwd(), None);+ }++ #[test]+ fn title_via_osc() {+ let mut t = Term::new(20, 1);+ feed(&mut t, b"\x1b]0;hello\x07");+ assert_eq!(t.title(), Some("hello"));+ }++ #[test]+ fn alt_screen_preserves_primary() {+ let mut t = Term::new(20, 2);+ feed(&mut t, b"main");+ feed(&mut t, b"\x1b[?1049h");+ assert_eq!(t.grid().row_text(0), "");+ feed(&mut t, b"\x1b[?1049l");+ assert_eq!(t.grid().row_text(0), "main");+ }+}@@ -0,0 +1,240 @@+use vte::Perform;++use super::*;++impl Perform for Term {+ fn print(&mut self, c: char) {+ let c = if self.active_charset() == Charset::DecSpecial {+ dec_special(c)+ } else {+ c+ };+ self.grid.print(c);+ }++ fn execute(&mut self, byte: u8) {+ match byte {+ 0x07 => self.bell = true,+ 0x08 => self.grid.backspace(),+ 0x09 => self.grid.tab(),+ 0x0A..=0x0C => self.grid.line_feed(),+ 0x0D => self.grid.carriage_return(),+ 0x0E => self.shift_out = true,+ 0x0F => self.shift_out = false,+ _ => {}+ }+ }++ fn csi_dispatch(&mut self, params: &Params, intermediates: &[u8], _ignore: bool, action: char) {+ let private = intermediates.first() == Some(&b'?');+ match action {+ 'A' => self.grid.cursor_up(n(params, 0, 1)),+ 'B' | 'e' => self.grid.cursor_down(n(params, 0, 1)),+ 'C' | 'a' => self.grid.cursor_fwd(n(params, 0, 1)),+ 'D' => self.grid.cursor_back(n(params, 0, 1)),+ 'E' => {+ self.grid.cursor_down(n(params, 0, 1));+ self.grid.carriage_return();+ }+ 'F' => {+ self.grid.cursor_up(n(params, 0, 1));+ self.grid.carriage_return();+ }+ 'G' | '`' => self.grid.move_to_col(n(params, 0, 1) - 1),+ 'd' => self.grid.move_to_row(n(params, 0, 1) - 1),+ 'H' | 'f' => self.grid.move_to(n(params, 1, 1) - 1, n(params, 0, 1) - 1),+ 'J' => self.grid.erase_display(raw(params, 0)),+ 'K' => self.grid.erase_line(raw(params, 0)),+ '@' => self.grid.insert_chars(n(params, 0, 1)),+ 'P' => self.grid.delete_chars(n(params, 0, 1)),+ 'L' => self.grid.insert_lines(n(params, 0, 1)),+ 'M' => self.grid.delete_lines(n(params, 0, 1)),+ 'X' => self.grid.erase_chars(n(params, 0, 1)),+ 'S' => self.grid.scroll_up(n(params, 0, 1)),+ 'T' => self.grid.scroll_down(n(params, 0, 1)),+ 'm' => self.sgr(params),+ 'r' => {+ let top = n(params, 0, 1) - 1;+ let bottom = match params.iter().nth(1).and_then(|p| p.first().copied()) {+ Some(0) | None => self.grid.rows() - 1,+ Some(v) => (v as usize).saturating_sub(1),+ };+ self.grid.set_scroll_region(top, bottom);+ }+ 'h' => self.set_mode(params, private, true),+ 'l' => self.set_mode(params, private, false),+ 'c' => self.device_attrs(match intermediates.first() {+ Some(b'>') => DaLevel::Secondary,+ Some(b'=') => DaLevel::Tertiary,+ _ => DaLevel::Primary,+ }),+ 'q' if intermediates.first() == Some(&b'>') => self.report_version(),+ 'q' if intermediates.first() == Some(&b' ') => {+ let code = raw(params, 0);+ self.grid.set_cursor_shape(match code {+ 3 | 4 => CursorShape::Underline,+ 5 | 6 => CursorShape::Beam,+ _ => CursorShape::Block,+ });+ // Even codes are steady; 0/1 and other odd codes blink.+ self.grid.set_cursor_blink(code == 0 || code % 2 == 1);+ }+ 'p' if intermediates.contains(&b'$') => self.report_mode(params, private),+ 'n' => self.device_status(params),+ 's' => self.grid.save_cursor(),+ 'u' => self.grid.restore_cursor(),+ 't' => self.title_stack_op(params),+ 'g' => match raw(params, 0) {+ 3 => self.grid.clear_all_tabs(),+ _ => self.grid.clear_tab(),+ },+ _ => tracing::trace!("unhandled CSI {action:?} {intermediates:?}"),+ }+ }++ fn esc_dispatch(&mut self, intermediates: &[u8], _ignore: bool, byte: u8) {+ match (intermediates.first().copied(), byte) {+ (None, b'D') => self.grid.line_feed(),+ (None, b'M') => self.grid.reverse_index(),+ (None, b'E') => self.grid.next_line(),+ (None, b'7') => self.grid.save_cursor(),+ (None, b'8') => self.grid.restore_cursor(),+ (None, b'H') => self.grid.set_tab(),+ (None, b'c') => {+ self.grid.reset_pen();+ self.grid.set_scroll_region(0, self.grid.rows() - 1);+ self.grid.set_autowrap(true);+ self.grid.set_origin(false);+ self.grid.erase_display(2);+ self.grid.move_to(0, 0);+ self.g0 = Charset::Ascii;+ self.g1 = Charset::Ascii;+ self.shift_out = false;+ }+ (Some(b'('), c) => self.g0 = charset(c),+ (Some(b')'), c) => self.g1 = charset(c),+ _ => {}+ }+ }++ fn osc_dispatch(&mut self, params: &[&[u8]], bell: bool) {+ match params.first() {+ Some(&n) if n == b"0" || n == b"2" => {+ if let Some(text) = params.get(1) {+ self.title = Some(String::from_utf8_lossy(text).into_owned());+ }+ }+ // OSC 7: the shell reports its cwd as a `file://host/path` URI.+ Some(&n) if n == b"7" => {+ if let Some(uri) = params.get(1) {+ self.cwd = file_uri_path(uri);+ }+ }+ // OSC 133: shell-integration prompt marks (A/B/C/D, with optional+ // `;key=value` attributes we ignore).+ Some(&n) if n == b"133" => {+ if let Some(kind) = params+ .get(1)+ .and_then(|p| p.first())+ .and_then(|&b| prompt_kind(b))+ {+ self.grid.set_prompt_mark(kind);+ }+ }+ // OSC 8: hyperlink. `OSC 8 ; params ; URI ST`; an empty URI ends the+ // link. The URI is everything after the second field, rejoined since+ // a URI may itself contain ';'.+ Some(&n) if n == b"8" => {+ let uri_bytes = params+ .get(2..)+ .map(|parts| parts.join(&b';'))+ .unwrap_or_default();+ let uri = std::str::from_utf8(&uri_bytes).unwrap_or("");+ self.grid.set_link((!uri.is_empty()).then_some(uri));+ }+ // OSC 9: iTerm2-style notification (`OSC 9 ; body`).+ Some(&n) if n == b"9" => {+ if let Some(body) = osc_text(params.get(1)) {+ self.notifications.push(Notification { title: None, body });+ }+ }+ // OSC 777: `OSC 777 ; notify ; title ; body`.+ Some(&n) if n == b"777" && params.get(1) == Some(&&b"notify"[..]) => {+ let title = osc_text(params.get(2));+ if let Some(body) = osc_text(params.get(3)) {+ self.notifications.push(Notification { title, body });+ }+ }+ // OSC 99: kitty desktop-notification protocol. We honour the common+ // single-chunk form, taking the payload as the body and ignoring the+ // metadata key=value field.+ Some(&n) if n == b"99" => {+ if let Some(body) = osc_text(params.get(2)).filter(|b| !b.is_empty()) {+ self.notifications.push(Notification { title: None, body });+ }+ }+ // OSC 4: set/query palette entries (pairs of index;spec).+ Some(&n) if n == b"4" => self.osc_palette(params, bell),+ // OSC 104: reset palette (all, or the listed indices).+ Some(&n) if n == b"104" => {+ if params.len() <= 1 {+ self.theme.reset_palette();+ } else {+ for p in ¶ms[1..] {+ if let Some(i) = parse_index(p) {+ self.theme.reset_palette_index(i);+ }+ }+ }+ }+ // OSC 10/11: foreground / background; OSC 110/111 reset them.+ Some(&n) if n == b"10" => self.osc_dynamic_color(Dynamic::Fg, params.get(1), bell),+ Some(&n) if n == b"11" => self.osc_dynamic_color(Dynamic::Bg, params.get(1), bell),+ Some(&n) if n == b"110" => self.theme.reset_fg(),+ Some(&n) if n == b"111" => self.theme.reset_bg(),+ // OSC 17/19: selection (highlight) background / foreground.+ Some(&n) if n == b"17" => self.osc_dynamic_color(Dynamic::SelBg, params.get(1), bell),+ Some(&n) if n == b"19" => self.osc_dynamic_color(Dynamic::SelFg, params.get(1), bell),+ // OSC 12: set cursor colour; OSC 112: reset to default.+ Some(&n) if n == b"12" => {+ self.grid+ .set_cursor_color(params.get(1).and_then(|s| parse_spec(s)).map(rgb_tuple));+ }+ Some(&n) if n == b"112" => self.grid.set_cursor_color(None),+ // OSC 52: clipboard get/set. Pc selects the target, Pd is base64 or+ // `?` to query. We only touch `c` (clipboard) and `p` (primary).+ Some(&n) if n == b"52" => {+ let target = params.get(1).copied().unwrap_or(b"");+ let data = params.get(2).copied().unwrap_or(b"");+ let primary = target.first() == Some(&b'p');+ if data == b"?" {+ self.clipboard_ops.push(ClipboardOp::Query { primary });+ } else if let Some(text) =+ base64_decode(data).and_then(|b| String::from_utf8(b).ok())+ {+ self.clipboard_ops.push(ClipboardOp::Set { primary, text });+ }+ }+ _ => {}+ }+ }++ fn hook(&mut self, _: &Params, intermediates: &[u8], _: bool, action: char) {+ // XTGETTCAP arrives as `DCS + q <names> ST`.+ if action == 'q' && intermediates == [b'+'] {+ self.xtgettcap = Some(Vec::new());+ }+ }++ fn put(&mut self, byte: u8) {+ if let Some(buf) = self.xtgettcap.as_mut() {+ buf.push(byte);+ }+ }++ fn unhook(&mut self) {+ if let Some(payload) = self.xtgettcap.take() {+ self.answer_xtgettcap(&payload);+ }+ }+}@@ -1,2625 +0,0 @@-//! Wayland front-end: connection, surface, and software-drawn window.-//!-//! Uses smithay-client-toolkit for protocol boilerplate and calloop for the-//! event loop, so the PTY master fd and timers share one loop.--use std::fs::File;-use std::io::{Read as _, Write as _};-use std::num::NonZeroU16;-use std::os::fd::OwnedFd;-use std::os::unix::process::ExitStatusExt;-use std::process::ExitCode;--use std::time::Duration;--use anyhow::Context;-use calloop::generic::Generic;-use calloop::timer::{TimeoutAction, Timer};-use calloop::{EventLoop, Interest, LoopHandle, Mode, PostAction, RegistrationToken};-use calloop_wayland_source::WaylandSource;--use crate::config::Config;-use crate::font::Fonts;-use crate::grid::{Cell, CursorShape, Grid, MouseProtocol, UrlHit};-use crate::pty::Pty;-use crate::render::Renderer;-use crate::vt::Term;-use smithay_client_toolkit::reexports::protocols::wp::cursor_shape::v1::client::wp_cursor_shape_device_v1::{- Shape, WpCursorShapeDeviceV1,-};-use smithay_client_toolkit::reexports::protocols::wp::primary_selection::zv1::client::{- zwp_primary_selection_device_v1::ZwpPrimarySelectionDeviceV1,- zwp_primary_selection_source_v1::ZwpPrimarySelectionSourceV1,-};-use smithay_client_toolkit::{- activation::{ActivationHandler, ActivationState, RequestData},- compositor::{CompositorHandler, CompositorState},- data_device_manager::{- DataDeviceManagerState, WritePipe,- data_device::{DataDevice, DataDeviceHandler},- data_offer::{DataOfferHandler, DragOffer},- data_source::{CopyPasteSource, DataSourceHandler},- },- delegate_activation, delegate_compositor, delegate_data_device, delegate_keyboard,- delegate_output,- delegate_pointer, delegate_primary_selection, delegate_registry, delegate_seat, delegate_shm,- delegate_xdg_shell, delegate_xdg_window,- output::{OutputHandler, OutputState},- primary_selection::{- PrimarySelectionManagerState,- device::{PrimarySelectionDevice, PrimarySelectionDeviceHandler},- selection::{PrimarySelectionSource, PrimarySelectionSourceHandler},- },- registry::{ProvidesRegistryState, RegistryState},- registry_handlers,- seat::{- Capability, SeatHandler, SeatState,- keyboard::{KeyEvent, KeyboardHandler, Keysym, Modifiers, RawModifiers, RepeatInfo},- pointer::{- BTN_LEFT, BTN_MIDDLE, BTN_RIGHT, PointerEvent, PointerEventKind, PointerHandler,- cursor_shape::CursorShapeManager,- },- },- shell::{- WaylandSurface,- xdg::{- XdgShell,- window::{Window, WindowConfigure, WindowDecorations, WindowHandler},- },- },- shm::{- Shm, ShmHandler,- slot::{Buffer, SlotPool},- },-};-use wayland_client::{- Connection, Dispatch, Proxy, QueueHandle,- globals::{GlobalList, registry_queue_init},- protocol::{- wl_data_device::WlDataDevice, wl_data_device_manager::DndAction,- wl_data_source::WlDataSource, wl_keyboard, wl_output, wl_pointer, wl_seat, wl_shm,- wl_surface,- },-};-use wayland_protocols::wp::fractional_scale::v1::client::{- wp_fractional_scale_manager_v1::WpFractionalScaleManagerV1,- wp_fractional_scale_v1::{self, WpFractionalScaleV1},-};-use wayland_protocols::wp::text_input::zv3::client::{- zwp_text_input_manager_v3::ZwpTextInputManagerV3,- zwp_text_input_v3::{self, ContentHint, ContentPurpose, ZwpTextInputV3},-};-use wayland_protocols::wp::viewporter::client::{- wp_viewport::WpViewport, wp_viewporter::WpViewporter,-};--/// MIME types beer offers and accepts for clipboard text.-const TEXT_MIMES: &[&str] = &[- "text/plain;charset=utf-8",- "text/plain;charset=UTF-8",- "UTF8_STRING",- "STRING",- "text/plain",- "TEXT",-];--/// Pick the first MIME type we understand from an offer's advertised set.-fn pick_mime(mimes: &[String]) -> Option<String> {- mimes- .iter()- .find(|m| TEXT_MIMES.contains(&m.as_str()))- .cloned()-}--/// Max gap between clicks counted as a multi-click (ms).-const MULTI_CLICK_MS: u32 = 400;--/// Blink half-period: cells/cursor toggle visibility this often.-const BLINK_MS: u64 = 500;--/// Buffers kept for double/triple buffering before we wait for a release.-const MAX_BUFFERS: usize = 3;--/// How long synchronized output (DECSET 2026) may hold the screen before we-/// present anyway, so a misbehaving app cannot freeze the window.-const SYNC_TIMEOUT_MS: u64 = 150;--/// Interval between autoscroll steps while a drag selection runs off an edge.-const AUTOSCROLL_MS: u64 = 40;--/// How long the visual bell inverts the screen.-const FLASH_MS: u64 = 80;--/// What determines one rendered row's pixels: its cells, the cursor on it, the-/// selection span over it, and the blink phase. Two equal `RowSnap`s render-/// identically, so a buffer holding an equal snapshot needs no repaint.-#[derive(Clone, PartialEq, Debug)]-struct RowSnap {- cells: Vec<Cell>,- /// `(col, shape, focused)` when the cursor is drawn on this row.- cursor: Option<(usize, CursorShape, bool)>,- /// Inclusive selected column span on this row.- sel: Option<(usize, usize)>,- /// Search-match spans `(lo, hi, is_current)` highlighted on this row.- search: Vec<(usize, usize, bool)>,- /// Search-prompt text drawn over this row (only the bottom row, when active).- overlay: Option<String>,- /// IME preedit `(start_col, text)` drawn inline over this row (cursor row).- preedit: Option<(usize, String)>,- /// Blink phase, but only varied when the row actually has blinking ink, so- /// non-blinking rows stay equal across phase toggles.- blink: bool,-}--/// One shm buffer plus the per-row snapshot of what it currently displays.-#[derive(Debug)]-struct FrameBuf {- buffer: Buffer,- rows: Vec<RowSnap>,-}--/// Snapshot the determinants of viewport row `y`'s pixels.-fn row_snap(grid: &Grid, y: usize, focused: bool, blink_on: bool) -> RowSnap {- let abs = grid.view_to_abs(y);- let cells = grid.view_row(y).to_vec();- let cursor = if grid.view_at_bottom() && grid.cursor().1 == y {- let visible = grid.cursor_visible() && (!grid.cursor_blink() || blink_on);- visible.then(|| (grid.cursor().0, grid.cursor_shape(), focused))- } else {- None- };- let has_blink = cells- .iter()- .any(|c| c.flags.contains(crate::grid::Flags::BLINK));- RowSnap {- cells,- cursor,- sel: grid.selection_span_on(abs),- search: grid.search_spans_on(abs),- overlay: None,- preedit: None,- blink: if has_blink { blink_on } else { true },- }-}--/// Fallback window size in pixels if the configured geometry yields nothing.-const DEFAULT_W: u32 = 800;-const DEFAULT_H: u32 = 600;--/// Run a single window until it is closed, returning the shell's exit code.-pub fn run(config: Config, config_path: Option<std::path::PathBuf>) -> anyhow::Result<ExitCode> {- let conn = Connection::connect_to_env().context("connect to Wayland compositor")?;- let (globals, event_queue) =- registry_queue_init(&conn).context("initialize Wayland registry")?;- let qh = event_queue.handle();-- let mut event_loop: EventLoop<App> =- EventLoop::try_new().context("create calloop event loop")?;- WaylandSource::new(conn, event_queue)- .insert(event_loop.handle())- .map_err(|e| anyhow::anyhow!("insert Wayland source into event loop: {e}"))?;-- let compositor = CompositorState::bind(&globals, &qh).context("compositor not available")?;- let xdg_shell = XdgShell::bind(&globals, &qh).context("xdg_wm_base not available")?;- let shm = Shm::bind(&globals, &qh).context("wl_shm not available")?;- let data_device_manager = DataDeviceManagerState::bind(&globals, &qh)- .context("wl_data_device_manager not available")?;- let primary_manager = PrimarySelectionManagerState::bind(&globals, &qh).ok();- let cursor_shape_manager = CursorShapeManager::bind(&globals, &qh).ok();-- let surface = compositor.create_surface(&qh);- let window = xdg_shell.create_window(surface, WindowDecorations::RequestServer, &qh);- window.set_title("beer");- window.set_app_id("dev.notashelf.beer");- window.set_min_size(Some((1, 1)));-- // Decorrelate buffer pixels from surface size so we can render at the- // compositor's preferred fractional scale (crisp glyphs on a 150% output)- // and present at the logical size. Both are optional; without them the- // window falls back to integer buffer scaling via `scale_factor_changed`.- let viewport = bind_global::<WpViewporter>(&globals, &qh)- .map(|vp| vp.get_viewport(window.wl_surface(), &qh, ()));- // Fractional scaling needs a viewport to present the scaled buffer back at- // the logical size; without one we can only do integer buffer scaling.- let fractional_scale = viewport.as_ref().and_then(|_| {- bind_global::<WpFractionalScaleManagerV1>(&globals, &qh)- .map(|mgr| mgr.get_fractional_scale(window.wl_surface(), &qh, ()))- });- let text_input_manager = bind_global::<ZwpTextInputManagerV3>(&globals, &qh);- let activation = ActivationState::bind(&globals, &qh).ok();-- // First commit with no buffer kicks off the initial configure.- window.commit();-- let fonts = Fonts::new(&config.main.font, config.main.font_size).context("load font")?;- let mut renderer = Renderer::new(fonts);- renderer.set_padding(config.main.pad_x, config.main.pad_y);-- // Start at the configured cell geometry plus padding; the compositor may- // override it on the first configure.- let m = renderer.metrics();- let width = (u32::from(config.main.initial_cols) * m.width + 2 * config.main.pad_x).max(1);- let height = (u32::from(config.main.initial_rows) * m.height + 2 * config.main.pad_y).max(1);- let pool = SlotPool::new(- (width * height * 4).max(DEFAULT_W * DEFAULT_H) as usize,- &shm,- )- .context("create shm slot pool")?;-- let bindings =- crate::bindings::Bindings::from_config(&config.key_bindings, &config.text_bindings);- let font_size = config.main.font_size;-- let mut app = App {- registry_state: RegistryState::new(&globals),- output_state: OutputState::new(&globals, &qh),- seat_state: SeatState::new(&globals, &qh),- shm,- pool,- window,- renderer,- loop_handle: event_loop.handle(),- qh: qh.clone(),- data_device_manager,- primary_manager,- cursor_shape_manager,- text_input_manager,- activation,- preedit: String::new(),- ime_preedit_pending: String::new(),- ime_commit_pending: String::new(),- viewport,- fractional_scale,- scale120: 120,- seats: Vec::new(),- active_seat: 0,- copy_source: None,- primary_source: None,- clipboard: String::new(),- primary_clip: String::new(),- selecting: false,- hovered_link: None,- pointer_enter_serial: 0,- press_cell: None,- pressed_button: None,- last_report_cell: None,- autoscroll: 0,- autoscroll_timer: None,- pointer_pos: (0.0, 0.0),- last_click: None,- serial: 0,- modifiers: Modifiers::default(),- // The PTY is spawned on the first configure, once the real window size- // is known, so the shell starts at the final size and is not hit by a- // startup SIGWINCH storm that makes it reprint its prompt.- session: None,- title: None,- config,- config_path,- bindings,- font_size,- fullscreen: false,- width,- height,- needs_draw: false,- frame_pending: false,- frames: Vec::new(),- buf_dims: (0, 0),- blink_on: true,- sync_timeout: None,- flashing: false,- flash_timer: None,- searching: false,- url_mode: false,- url_hits: Vec::new(),- url_labels: Vec::new(),- url_input: String::new(),- focused: true,- exit: false,- exit_code: ExitCode::SUCCESS,- };-- // Toggle the blink phase on a timer so blinking text and cursors animate.- let blink = Timer::from_duration(Duration::from_millis(BLINK_MS));- let blink_registered = event_loop- .handle()- .insert_source(blink, |_, _, app: &mut App| {- app.blink_on = !app.blink_on;- app.needs_draw = true;- TimeoutAction::ToDuration(Duration::from_millis(BLINK_MS))- });- if let Err(err) = blink_registered {- tracing::warn!("register blink timer: {err}");- }-- // SIGUSR1 reloads the config in place.- match calloop::signals::Signals::new(&[calloop::signals::Signal::SIGUSR1]) {- Ok(signals) => {- let registered = event_loop- .handle()- .insert_source(signals, |_, _, app: &mut App| app.reload_config());- if let Err(err) = registered {- tracing::warn!("register signal source: {err}");- }- }- Err(err) => tracing::warn!("install SIGUSR1 handler: {err}"),- }-- // Each iteration blocks until an event (PTY output, input, configure, frame- // callback, blink) arrives, then presents at most one frame; bursts of PTY- // output between frame callbacks coalesce into a single repaint.- while !app.exit {- event_loop- .dispatch(None, &mut app)- .context("dispatch event loop")?;- app.flush();- }- Ok(app.exit_code)-}--/// Bind a singleton global at version 1 with `()` user-data, or `None` if the-/// compositor does not advertise it.-fn bind_global<I>(globals: &GlobalList, qh: &QueueHandle<App>) -> Option<I>-where- I: Proxy + 'static,- App: Dispatch<I, ()>,-{- match globals.bind(qh, 1..=1, ()) {- Ok(global) => Some(global),- Err(err) => {- tracing::debug!("bind {}: {err}", I::interface().name);- None- }- }-}--/// Columns and rows that fit a `width`×`height` px window at `metrics`, after-/// reserving `2 * pad` pixels of inner padding on each axis.-fn grid_size(- metrics: crate::font::CellMetrics,- width: u32,- height: u32,- pad: (u32, u32),-) -> (u16, u16) {- let cols = (width.saturating_sub(2 * pad.0) / metrics.width).max(1);- let rows = (height.saturating_sub(2 * pad.1) / metrics.height).max(1);- (cols as u16, rows as u16)-}--/// Write every byte to `fd`, retrying short writes and interrupts.-fn write_all(fd: &OwnedFd, mut buf: &[u8]) -> rustix::io::Result<()> {- while !buf.is_empty() {- match rustix::io::write(fd, buf) {- Ok(0) => return Err(rustix::io::Errno::IO),- Ok(n) => buf = &buf[n..],- Err(rustix::io::Errno::INTR) => {}- Err(e) => return Err(e),- }- }- Ok(())-}--/// The per-window terminal: the PTY and the parsed screen behind it. Created on-/// the first configure, once the real size is known.-#[derive(Debug)]-struct Session {- pty: Pty,- term: Term,-}--/// Input devices for one seat. Several seats can drive the single window; the-/// most recently used one owns clipboard/primary claims.-#[derive(Debug)]-struct SeatData {- seat: wl_seat::WlSeat,- keyboard: Option<wl_keyboard::WlKeyboard>,- pointer: Option<wl_pointer::WlPointer>,- cursor_shape_device: Option<WpCursorShapeDeviceV1>,- data_device: Option<DataDevice>,- primary_device: Option<PrimarySelectionDevice>,- /// text-input-v3 handle for IME preedit/commit, if the compositor offers it.- text_input: Option<ZwpTextInputV3>,-}--/// Window + Wayland client state shared across all protocol handlers.-#[derive(Debug)]-struct App {- registry_state: RegistryState,- output_state: OutputState,- seat_state: SeatState,- shm: Shm,- pool: SlotPool,- window: Window,- renderer: Renderer,- loop_handle: LoopHandle<'static, App>,- qh: QueueHandle<App>,- data_device_manager: DataDeviceManagerState,- primary_manager: Option<PrimarySelectionManagerState>,- /// Sets the pointer to an I-beam over the window (cursor-shape-v1).- cursor_shape_manager: Option<CursorShapeManager>,- /// IME manager (text-input-v3); per-seat handles live in `seats`.- text_input_manager: Option<ZwpTextInputManagerV3>,- /// xdg-activation, used to request attention on an urgent bell.- activation: Option<ActivationState>,- /// Committed IME preedit string shown inline at the cursor while composing.- preedit: String,- /// Preedit/commit accumulated since the last text-input `done`.- ime_preedit_pending: String,- ime_commit_pending: String,- /// Presents a scaled buffer at the logical surface size (viewporter).- viewport: Option<WpViewport>,- /// Per-surface fractional-scale object; kept alive to receive scale events.- fractional_scale: Option<WpFractionalScaleV1>,- /// Compositor's preferred scale in 120ths (120 = 1.0, 180 = 1.5).- scale120: u32,- /// One entry per seat; `active_seat` indexes the most recently used.- seats: Vec<SeatData>,- active_seat: usize,- /// Held while we own the clipboard / primary selection, serving paste reads.- copy_source: Option<CopyPasteSource>,- primary_source: Option<PrimarySelectionSource>,- clipboard: String,- primary_clip: String,- /// A left-button drag is in progress.- selecting: bool,- /// OSC 8 hyperlink under the pointer, underlined and opened on click.- hovered_link: Option<NonZeroU16>,- /// Serial of the last pointer enter, reused to update the cursor shape.- pointer_enter_serial: u32,- /// Cell `(abs_row, col)` of the last left-press, for click-to-open links.- press_cell: Option<(usize, usize)>,- /// Button base code held down while mouse reporting, for drag reports.- pressed_button: Option<u8>,- /// Last cell a motion report was emitted for, to suppress duplicates.- last_report_cell: Option<(usize, usize)>,- /// Autoscroll direction while dragging past an edge: +1 back, -1 toward live.- autoscroll: isize,- /// Calloop token for the repeating autoscroll timer, when armed.- autoscroll_timer: Option<RegistrationToken>,- pointer_pos: (f64, f64),- /// Last click (time ms, abs row, col, count) for double/triple detection.- last_click: Option<(u32, usize, usize, u32)>,- /// Most recent input serial, used to claim selections.- serial: u32,- modifiers: Modifiers,- /// `None` until the first configure spawns the shell.- session: Option<Session>,- /// Last title applied to the toplevel, to avoid redundant requests.- title: Option<String>,- /// The active user configuration.- config: Config,- /// Path the config was loaded from, for SIGUSR1 live reload.- config_path: Option<std::path::PathBuf>,- /// Resolved key/text bindings.- bindings: crate::bindings::Bindings,- /// Current font size in pixels (changed by font-resize bindings).- font_size: u32,- /// Whether the toplevel is fullscreen.- fullscreen: bool,- width: u32,- height: u32,- /// The grid changed and the window wants repainting on the next frame.- needs_draw: bool,- /// A `wl_surface.frame` callback is in flight; defer drawing until it fires.- frame_pending: bool,- /// Double/triple-buffer ring, each tagged with the rows it currently shows.- frames: Vec<FrameBuf>,- /// Pixel size the `frames` buffers were allocated for.- buf_dims: (u32, u32),- /// Current blink phase, toggled by a timer; off hides blinking ink.- blink_on: bool,- /// Armed while synchronized output holds the screen, to force it open.- sync_timeout: Option<RegistrationToken>,- /// The visual bell is inverting the screen.- flashing: bool,- /// Timer that ends the visual-bell flash.- flash_timer: Option<RegistrationToken>,- /// Whether incremental search mode is active (the query lives in the grid).- searching: bool,- /// URL hint mode: detected URLs get keyboard labels to open them.- url_mode: bool,- /// Detected URLs and their hint labels (parallel), while `url_mode` is on.- url_hits: Vec<UrlHit>,- url_labels: Vec<String>,- /// Label characters typed so far in URL mode.- url_input: String,- /// Whether the toplevel currently has keyboard focus (drives the cursor).- focused: bool,- exit: bool,- /// Exit code to return, taken from the shell when it exits.- exit_code: ExitCode,-}--impl App {- /// Spawn the shell at the current window size and start reading its output.- fn spawn_session(&mut self) {- let (cols, rows) = self.grid_dims();- let pty = match Pty::spawn(cols, rows, &self.config.main.term) {- Ok(pty) => pty,- Err(err) => {- tracing::error!("spawn shell: {err:#}");- self.exit = true;- return;- }- };- let read_fd = match pty.master().try_clone() {- Ok(fd) => fd,- Err(err) => {- tracing::error!("clone pty master: {err}");- self.exit = true;- return;- }- };-- let mut parser = vte::Parser::new();- let source = Generic::new(read_fd, Interest::READ, Mode::Level);- let registered = self- .loop_handle- .insert_source(source, move |_, fd, app: &mut App| {- let mut buf = [0u8; 4096];- let n = match rustix::io::read(&*fd, &mut buf) {- Ok(0) => {- app.child_exited();- return Ok(PostAction::Remove);- }- Ok(n) => n,- Err(rustix::io::Errno::INTR | rustix::io::Errno::AGAIN) => {- return Ok(PostAction::Continue);- }- Err(_) => {- app.child_exited();- return Ok(PostAction::Remove);- }- };- if let Some(session) = app.session.as_mut() {- parser.advance(&mut session.term, &buf[..n]);- }- app.after_feed();- Ok(PostAction::Continue)- });- if let Err(err) = registered {- tracing::error!("register pty in event loop: {err}");- self.exit = true;- return;- }-- let mut term = Term::new(cols as usize, rows as usize);- term.set_theme(crate::theme::Theme::from_config(&self.config.colors));- let grid = term.grid_mut();- grid.set_word_delimiters(self.config.main.word_delimiters.clone());- grid.set_scrollback_cap(self.config.scrollback.lines);- if let Some(shape) = cursor_shape_from(self.config.cursor.style.as_deref()) {- grid.set_cursor_shape(shape);- }- grid.set_cursor_blink(self.config.cursor.blink);- self.session = Some(Session { pty, term });- }-- /// Handle a key (initial press or repeat): configured bindings first, then- /// text bindings, else the byte encoding sent to the shell (which snaps the- /// viewport back to the live screen).- fn handle_key(&mut self, event: &KeyEvent) {- // URL hint mode and search both capture the keyboard while active.- if self.url_mode {- self.url_key(event);- return;- }- if self.searching {- self.search_key(event);- return;- }- if let Some(action) = self.bindings.action(event, self.modifiers) {- self.dispatch_action(action);- return;- }- if let Some(text) = self.bindings.text(event, self.modifiers) {- let bytes = text.to_vec();- self.send_to_shell(&bytes);- return;- }-- let app_cursor = self- .session- .as_ref()- .is_some_and(|s| s.term.grid().app_cursor());- if let Some(bytes) = crate::input::encode(event, self.modifiers, app_cursor) {- self.send_to_shell(&bytes);- }- }-- /// Write key/text bytes to the shell, snapping the viewport to the live- /// screen and clearing any selection first.- fn send_to_shell(&mut self, bytes: &[u8]) {- if let Some(session) = self.session.as_mut() {- session.term.scroll_to_bottom();- session.term.grid_mut().clear_selection();- self.needs_draw = true;- if let Err(err) = write_all(session.pty.master(), bytes) {- tracing::warn!("write key to pty: {err}");- }- }- }-- /// Run a bound editor action.- fn dispatch_action(&mut self, action: crate::bindings::Action) {- use crate::bindings::Action;- match action {- Action::Copy => {- let qh = self.qh.clone();- self.set_clipboard(&qh);- }- Action::Paste => self.paste_clipboard(),- Action::PastePrimary => self.paste_primary(),- Action::ScrollPageUp => self.scroll_page(true),- Action::ScrollPageDown => self.scroll_page(false),- Action::ScrollTop => {- if let Some(session) = self.session.as_mut() {- session.term.scroll_view(isize::MAX);- self.needs_draw = true;- }- }- Action::ScrollBottom => {- if let Some(session) = self.session.as_mut() {- session.term.scroll_to_bottom();- self.needs_draw = true;- }- }- Action::SearchStart => self.toggle_search(),- Action::FontIncrease => self.change_font_size(self.font_size + 1),- Action::FontDecrease => self.change_font_size(self.font_size.saturating_sub(1)),- Action::FontReset => self.change_font_size(self.config.main.font_size),- Action::Fullscreen => self.toggle_fullscreen(),- Action::NewWindow => self.spawn_new_window(),- Action::JumpPromptUp => self.jump_prompt(true),- Action::JumpPromptDown => self.jump_prompt(false),- Action::PipeCommandOutput => self.pipe_command_output(),- Action::UrlMode => self.enter_url_mode(),- }- }-- /// Enter URL hint mode: detect the visible URLs and label them. No-op (with- /// a brief log) when there are none.- fn enter_url_mode(&mut self) {- let Some(session) = self.session.as_ref() else {- return;- };- let hits = session.term.grid().visible_urls();- if hits.is_empty() {- return;- }- self.url_labels = hint_labels(hits.len());- self.url_hits = hits;- self.url_input = String::new();- self.url_mode = true;- self.needs_draw = true;- }-- /// Leave URL hint mode, discarding any partial label input.- fn exit_url_mode(&mut self) {- self.url_mode = false;- self.url_hits.clear();- self.url_labels.clear();- self.url_input.clear();- // Drop the labelled buffers so the next present repaints without labels.- self.frames.clear();- self.needs_draw = true;- }-- /// Handle a key while URL hint mode is active: build up a label, open the- /// matching URL, or cancel.- fn url_key(&mut self, event: &KeyEvent) {- match event.keysym {- Keysym::Escape => self.exit_url_mode(),- Keysym::BackSpace => {- self.url_input.pop();- self.needs_draw = true;- }- _ => {- let Some(text) = event.utf8.as_ref() else {- return;- };- for c in text.chars().filter(|c| c.is_ascii_alphabetic()) {- self.url_input.push(c.to_ascii_lowercase());- }- // Exact match opens; if no label even has this prefix, cancel.- if let Some(i) = self.url_labels.iter().position(|l| *l == self.url_input) {- let url = self.url_hits[i].url.clone();- self.exit_url_mode();- self.open_url(&url);- } else if !self- .url_labels- .iter()- .any(|l| l.starts_with(&self.url_input))- {- self.exit_url_mode();- } else {- self.needs_draw = true;- }- }- }- }-- /// Scroll the viewport to the previous/next shell prompt (OSC 133).- fn jump_prompt(&mut self, up: bool) {- if let Some(session) = self.session.as_mut() {- session.term.grid_mut().jump_prompt(up);- self.needs_draw = true;- }- }-- /// Feed the last command's output (between OSC 133 C and D) to the configured- /// command on stdin.- fn pipe_command_output(&mut self) {- let argv = &self.config.shell_integration.pipe_command;- let Some((program, args)) = argv.split_first() else {- tracing::warn!("pipe-command-output: no [shell-integration] pipe-command configured");- return;- };- let Some(text) = self- .session- .as_ref()- .and_then(|s| s.term.grid().last_command_output())- else {- return;- };- let mut cmd = std::process::Command::new(program);- cmd.args(args)- .stdin(std::process::Stdio::piped())- .stdout(std::process::Stdio::null())- .stderr(std::process::Stdio::null());- if let Some(cwd) = self.session.as_ref().and_then(|s| s.term.cwd()) {- cmd.current_dir(cwd);- }- match cmd.spawn() {- Ok(mut child) => {- if let Some(mut stdin) = child.stdin.take() {- let _ = stdin.write_all(text.as_bytes());- }- }- Err(err) => tracing::warn!("pipe-command-output: spawn failed: {err}"),- }- }-- /// Launch another beer process in the shell's reported working directory- /// (OSC 7), inheriting the same config. The child is fully detached.- fn spawn_new_window(&mut self) {- let exe = match std::env::current_exe() {- Ok(exe) => exe,- Err(err) => {- tracing::warn!("locate beer executable: {err}");- return;- }- };- let mut cmd = std::process::Command::new(exe);- if let Some(path) = self.config_path.as_ref() {- cmd.arg("--config").arg(path);- }- if let Some(cwd) = self.session.as_ref().and_then(|s| s.term.cwd()) {- cmd.current_dir(cwd);- }- cmd.stdin(std::process::Stdio::null())- .stdout(std::process::Stdio::null())- .stderr(std::process::Stdio::null());- if let Err(err) = cmd.spawn() {- tracing::warn!("spawn new window: {err}");- }- }-- /// Send `count` cursor-up/down keys to the shell for alternate-scroll,- /// honouring the application cursor-key mode (DECCKM).- fn alternate_scroll(&mut self, up: bool, count: isize) {- let app_cursor = self- .session- .as_ref()- .is_some_and(|s| s.term.grid().app_cursor());- let seq: &[u8] = match (up, app_cursor) {- (true, false) => b"\x1b[A",- (true, true) => b"\x1bOA",- (false, false) => b"\x1b[B",- (false, true) => b"\x1bOB",- };- for _ in 0..count {- self.write_to_pty(seq);- }- }-- /// Scroll the viewport one page back (`up`) or toward the live screen.- fn scroll_page(&mut self, up: bool) {- if let Some(session) = self.session.as_mut() {- let page = session.term.page() as isize;- session.term.scroll_view(if up { page } else { -page });- self.needs_draw = true;- }- }-- /// Toggle the toplevel between fullscreen and windowed.- fn toggle_fullscreen(&mut self) {- self.fullscreen = !self.fullscreen;- if self.fullscreen {- self.window.set_fullscreen(None);- } else {- self.window.unset_fullscreen();- }- }-- /// Re-read the config file and apply it in place (SIGUSR1).- fn reload_config(&mut self) {- let new = Config::load(self.config_path.as_deref());- self.bindings =- crate::bindings::Bindings::from_config(&new.key_bindings, &new.text_bindings);- let font_changed = new.main.font != self.config.main.font- || new.main.font_size != self.config.main.font_size;- if font_changed {- self.font_size = new.main.font_size;- }- self.config.main.font = new.main.font.clone();- self.config.main.font_size = new.main.font_size;- self.config.main.pad_x = new.main.pad_x;- self.config.main.pad_y = new.main.pad_y;- // Re-rasterize at the active scale and update padding in one place.- self.rescale_render();- if let Some(session) = self.session.as_mut() {- session- .term- .set_theme(crate::theme::Theme::from_config(&new.colors));- let grid = session.term.grid_mut();- grid.set_word_delimiters(new.main.word_delimiters.clone());- grid.set_scrollback_cap(new.scrollback.lines);- if let Some(shape) = cursor_shape_from(new.cursor.style.as_deref()) {- grid.set_cursor_shape(shape);- }- grid.set_cursor_blink(new.cursor.blink);- }- self.config = new;- self.frames.clear();- self.resize_grid();- self.needs_draw = true;- tracing::info!("config reloaded");- }-- /// Re-rasterize the font at `new_size`, then re-derive the grid geometry.- fn change_font_size(&mut self, new_size: u32) {- let new_size = new_size.clamp(6, 200);- if new_size == self.font_size {- return;- }- self.font_size = new_size;- self.rescale_render();- self.frames.clear();- self.resize_grid();- self.needs_draw = true;- }-- /// Enter or leave incremental search mode.- fn toggle_search(&mut self) {- self.searching = !self.searching;- if let Some(session) = self.session.as_mut() {- if self.searching {- session.term.grid_mut().set_search("");- } else {- session.term.grid_mut().clear_search();- }- }- self.needs_draw = true;- }-- /// Handle a key while search mode is active: edit the query incrementally,- /// step between matches, or exit.- fn search_key(&mut self, event: &KeyEvent) {- let Some(session) = self.session.as_mut() else {- return;- };- let grid = session.term.grid_mut();- match event.keysym {- Keysym::Escape => {- grid.clear_search();- self.searching = false;- }- Keysym::Return | Keysym::KP_Enter | Keysym::Up | Keysym::Page_Up => {- grid.search_step(false);- }- Keysym::Down | Keysym::Page_Down => grid.search_step(true),- Keysym::BackSpace => {- let mut query = grid.search_query().unwrap_or("").to_string();- query.pop();- grid.set_search(&query);- }- _ => {- // Append typed text, ignoring control characters.- if let Some(text) = event.utf8.as_ref() {- let printable: String = text.chars().filter(|c| !c.is_control()).collect();- if !printable.is_empty() {- let mut query = grid.search_query().unwrap_or("").to_string();- query.push_str(&printable);- grid.set_search(&query);- }- }- }- }- self.needs_draw = true;- }-- /// The OSC 8 hyperlink id under the pointer, if any.- fn link_under_pointer(&self) -> Option<NonZeroU16> {- let (row, col) = self.cell_at(self.pointer_pos.0, self.pointer_pos.1)?;- self.session.as_ref()?.term.grid().link_at(row, col)- }-- /// Recompute the hyperlink under the pointer; when it changes, repaint to- /// move the hover underline and update the pointer to a hand over a link.- fn update_hover(&mut self, pointer: &wl_pointer::WlPointer) {- let link = self.link_under_pointer();- if link == self.hovered_link {- return;- }- self.hovered_link = link;- // The hover underline lives in every buffer's snapshot; drop the ring so- // the affected rows repaint with (or without) it.- self.frames.clear();- self.needs_draw = true;- let shape = if link.is_some() {- Shape::Pointer- } else {- Shape::Text- };- if let Some(device) = self- .seats- .iter()- .find(|s| s.pointer.as_ref() == Some(pointer))- .and_then(|s| s.cursor_shape_device.as_ref())- {- device.set_shape(self.pointer_enter_serial, shape);- }- }-- /// If the left button was pressed and released on the same hyperlinked cell- /// (a click, not a drag), open the link.- fn maybe_open_clicked_link(&mut self) {- let release = self.cell_at(self.pointer_pos.0, self.pointer_pos.1);- let Some((row, col)) = release else { return };- if self.press_cell != Some((row, col)) {- return;- }- let uri = self- .session- .as_ref()- .and_then(|s| s.term.grid().link_at(row, col).map(|id| (s, id)))- .and_then(|(s, id)| s.term.grid().link_uri(id))- .map(str::to_owned);- if let Some(uri) = uri {- self.open_url(&uri);- }- }-- /// Launch the configured opener (default `xdg-open`) on a URL.- fn open_url(&self, url: &str) {- let Some((program, args)) = self.config.url.launch.split_first() else {- tracing::warn!("open url: no [url] launch command configured");- return;- };- let mut cmd = std::process::Command::new(program);- cmd.args(args)- .arg(url)- .stdin(std::process::Stdio::null())- .stdout(std::process::Stdio::null())- .stderr(std::process::Stdio::null());- if let Err(err) = cmd.spawn() {- tracing::warn!("open url {url:?}: {err}");- }- }-- /// Scale a logical pixel length to physical (buffer) pixels at the current- /// fractional scale, rounding to nearest.- fn to_phys(&self, v: u32) -> u32 {- ((u64::from(v) * u64::from(self.scale120) + 60) / 120) as u32- }-- /// Physical (buffer) pixel size at the current scale.- fn phys_dims(&self) -> (u32, u32) {- (- self.to_phys(self.width).max(1),- self.to_phys(self.height).max(1),- )- }-- /// Columns and rows for the current physical size, metrics, and padding.- /// Scale-invariant: every term scales together so the cell count is stable.- fn grid_dims(&self) -> (u16, u16) {- let (pw, ph) = self.phys_dims();- grid_size(- self.renderer.metrics(),- pw,- ph,- (- self.to_phys(self.config.main.pad_x),- self.to_phys(self.config.main.pad_y),- ),- )- }-- /// Re-rasterize the font and padding at the current scale × the logical- /// font size, so glyphs are crisp at fractional scales.- fn rescale_render(&mut self) {- self.renderer.set_padding(- self.to_phys(self.config.main.pad_x),- self.to_phys(self.config.main.pad_y),- );- let px = self.to_phys(self.font_size).max(1);- if let Err(err) = self.renderer.set_font(&self.config.main.font, px) {- tracing::warn!("rasterize font at scale {}: {err:#}", self.scale120);- }- }-- /// Adopt a new preferred scale (in 120ths): re-rasterize, re-derive the grid- /// geometry, and update the viewport so the logical size stays put.- fn set_scale(&mut self, scale120: u32) {- let scale120 = scale120.max(1);- if scale120 == self.scale120 {- return;- }- self.scale120 = scale120;- self.rescale_render();- self.frames.clear();- self.buf_dims = (0, 0);- if let Some(vp) = &self.viewport {- vp.set_destination(self.width.max(1) as i32, self.height.max(1) as i32);- }- self.resize_grid();- self.needs_draw = true;- }-- /// Inner padding `(x, y)` in physical pixels (pointer coords are converted- /// to physical before use).- fn padding(&self) -> (f64, f64) {- (- f64::from(self.to_phys(self.config.main.pad_x)),- f64::from(self.to_phys(self.config.main.pad_y)),- )- }-- /// Convert a logical surface coordinate to a physical buffer coordinate.- fn to_phys_f(&self, v: f64) -> f64 {- v * f64::from(self.scale120) / 120.0- }-- /// The active seat (the one that most recently produced input).- fn active(&self) -> Option<&SeatData> {- self.seats.get(self.active_seat)- }-- /// The active seat's clipboard device, if any.- fn data_device(&self) -> Option<&DataDevice> {- self.active().and_then(|s| s.data_device.as_ref())- }-- /// The active seat's primary-selection device, if any.- fn primary_device(&self) -> Option<&PrimarySelectionDevice> {- self.active().and_then(|s| s.primary_device.as_ref())- }-- /// Find (or create) the per-seat entry for `seat`, returning its index.- fn seat_index(&mut self, seat: &wl_seat::WlSeat) -> usize {- if let Some(i) = self.seats.iter().position(|s| &s.seat == seat) {- return i;- }- self.seats.push(SeatData {- seat: seat.clone(),- keyboard: None,- pointer: None,- cursor_shape_device: None,- data_device: None,- primary_device: None,- text_input: None,- });- self.seats.len() - 1- }-- /// Mark the seat owning `keyboard` as active for clipboard ownership.- fn activate_keyboard(&mut self, keyboard: &wl_keyboard::WlKeyboard) {- if let Some(i) = self- .seats- .iter()- .position(|s| s.keyboard.as_ref() == Some(keyboard))- {- self.active_seat = i;- }- }-- /// Mark the seat owning `pointer` as active for clipboard ownership.- fn activate_pointer(&mut self, pointer: &wl_pointer::WlPointer) {- if let Some(i) = self- .seats- .iter()- .position(|s| s.pointer.as_ref() == Some(pointer))- {- self.active_seat = i;- }- }-- /// Map window pixel coordinates to an absolute `(row, col)` grid point.- fn cell_at(&self, px: f64, py: f64) -> Option<(usize, usize)> {- let session = self.session.as_ref()?;- let m = self.renderer.metrics();- let (pad_x, pad_y) = self.padding();- let (px, py) = (self.to_phys_f(px), self.to_phys_f(py));- let grid = session.term.grid();- let col =- ((px - pad_x).max(0.0) as usize / m.width as usize).min(grid.cols().saturating_sub(1));- let vrow =- ((py - pad_y).max(0.0) as usize / m.height as usize).min(grid.rows().saturating_sub(1));- Some((grid.view_to_abs(vrow), col))- }-- /// Left-button press: start (or word/line-extend) a selection.- fn pointer_press(&mut self, time: u32) {- let Some((row, col)) = self.cell_at(self.pointer_pos.0, self.pointer_pos.1) else {- return;- };- let count = match self.last_click {- Some((t, r, c, n))- if time.wrapping_sub(t) <= MULTI_CLICK_MS && r == row && c == col =>- {- n % 3 + 1- }- _ => 1,- };- self.last_click = Some((time, row, col, count));- let Some(session) = self.session.as_mut() else {- return;- };- let ctrl = self.modifiers.ctrl;- let grid = session.term.grid_mut();- match count {- 2 => grid.select_word(row, col),- 3 => grid.select_line(row),- // Holding Ctrl starts a rectangular (block) selection.- _ if ctrl => grid.start_block_selection(row, col),- _ => grid.start_selection(row, col),- }- self.selecting = true;- self.needs_draw = true;- }-- /// Pointer motion during a drag: extend the selection head and, if the- /// pointer has left the top/bottom edge, start autoscrolling.- fn pointer_drag(&mut self) {- if !self.selecting {- return;- }- let Some((row, col)) = self.cell_at(self.pointer_pos.0, self.pointer_pos.1) else {- return;- };- if let Some(session) = self.session.as_mut() {- session.term.grid_mut().extend_selection(row, col);- self.needs_draw = true;- }- self.update_autoscroll();- }-- /// Arm or disarm edge autoscroll based on the pointer's vertical position.- fn update_autoscroll(&mut self) {- let dir = if self.pointer_pos.1 < 0.0 {- 1 // above the top: reveal older lines- } else if self.pointer_pos.1 >= f64::from(self.height) {- -1 // below the bottom: advance toward the live screen- } else {- 0- };- self.autoscroll = dir;- if dir != 0 && self.autoscroll_timer.is_none() {- let timer = Timer::immediate();- self.autoscroll_timer = self- .loop_handle- .insert_source(timer, |_, _, app: &mut App| app.autoscroll_step())- .ok();- }- }-- /// One autoscroll step: scroll the viewport and drag the selection head to- /// the edge cell under the pointer. Reschedules until the drag ends or the- /// pointer returns inside the window.- fn autoscroll_step(&mut self) -> TimeoutAction {- if !self.selecting || self.autoscroll == 0 {- self.autoscroll_timer = None;- return TimeoutAction::Drop;- }- if let Some(session) = self.session.as_mut() {- session.term.scroll_view(self.autoscroll);- }- let edge_y = if self.autoscroll > 0 {- 0.0- } else {- f64::from(self.height) - 1.0- };- if let Some((row, col)) = self.cell_at(self.pointer_pos.0, edge_y)- && let Some(session) = self.session.as_mut()- {- session.term.grid_mut().extend_selection(row, col);- }- self.needs_draw = true;- TimeoutAction::ToDuration(Duration::from_millis(AUTOSCROLL_MS))- }-- /// Left-button release: stop autoscrolling and publish the primary selection.- fn pointer_release(&mut self, qh: &QueueHandle<App>) {- if !self.selecting {- return;- }- self.selecting = false;- self.autoscroll = 0;- if let Some(token) = self.autoscroll_timer.take() {- self.loop_handle.remove(token);- }- self.set_primary(qh);- }-- /// Whether the application wants mouse reports and the user is not holding- /// Shift (which forces local selection regardless of mode).- fn mouse_reporting(&self) -> bool {- self.session- .as_ref()- .is_some_and(|s| s.term.grid().mouse_protocol() != MouseProtocol::Off)- && !self.modifiers.shift- }-- /// The viewport cell `(col, row)` under the pointer, clamped to the screen.- fn report_screen_cell(&self) -> Option<(usize, usize)> {- let session = self.session.as_ref()?;- let m = self.renderer.metrics();- let (pad_x, pad_y) = self.padding();- let (ppx, ppy) = (- self.to_phys_f(self.pointer_pos.0),- self.to_phys_f(self.pointer_pos.1),- );- let grid = session.term.grid();- let col =- ((ppx - pad_x).max(0.0) as usize / m.width as usize).min(grid.cols().saturating_sub(1));- let row = ((ppy - pad_y).max(0.0) as usize / m.height as usize)- .min(grid.rows().saturating_sub(1));- Some((col, row))- }-- /// Report a button press/release to the application, if reporting is active.- /// Returns whether the event was consumed (so local handling is skipped).- fn try_report_button(&mut self, code: u8, pressed: bool) -> bool {- let Some(session) = self.session.as_ref() else {- return false;- };- let grid = session.term.grid();- let proto = grid.mouse_protocol();- if proto == MouseProtocol::Off || self.modifiers.shift {- return false;- }- let enc = grid.mouse_encoding();- // X10 (mode 9) reports presses only; a release is swallowed, not sent.- if (pressed || proto != MouseProtocol::X10)- && let Some((col, row)) = self.report_screen_cell()- {- let bytes =- crate::input::encode_mouse(enc, code, col, row, pressed, false, self.modifiers);- self.write_to_pty(&bytes);- self.last_report_cell = Some((col, row));- }- true- }-- /// Report pointer motion to the application when the active mode wants it.- /// Returns whether reporting consumed the motion (suppressing local drag).- fn try_report_motion(&mut self) -> bool {- let Some(session) = self.session.as_ref() else {- return false;- };- let grid = session.term.grid();- let proto = grid.mouse_protocol();- if proto == MouseProtocol::Off || self.modifiers.shift {- return false;- }- let enc = grid.mouse_encoding();- let wants = match proto {- MouseProtocol::Any => true,- MouseProtocol::Button => self.pressed_button.is_some(),- _ => false,- };- if wants- && let Some((col, row)) = self.report_screen_cell()- && self.last_report_cell != Some((col, row))- {- // Any-event motion with no button held uses the "no button" code 3.- let code = self.pressed_button.unwrap_or(3);- let bytes = crate::input::encode_mouse(enc, code, col, row, true, true, self.modifiers);- self.write_to_pty(&bytes);- self.last_report_cell = Some((col, row));- }- true- }-- /// Send focus in/out (DECSET 1004) to the application when it asked for it.- fn report_focus(&mut self, focused: bool) {- if self- .session- .as_ref()- .is_some_and(|s| s.term.grid().focus_events())- {- self.write_to_pty(if focused { b"\x1b[I" } else { b"\x1b[O" });- }- }-- /// Write bytes to the PTY master, logging on failure.- fn write_to_pty(&mut self, bytes: &[u8]) {- if let Some(session) = self.session.as_mut()- && let Err(err) = write_all(session.pty.master(), bytes)- {- tracing::warn!("write to pty: {err}");- }- }-- /// The current selection text, if any and non-empty.- fn selection_text(&self) -> Option<String> {- let text = self.session.as_ref()?.term.grid().selection_text()?;- (!text.is_empty()).then_some(text)- }-- /// Take ownership of the CLIPBOARD selection, serving `text` to pasters.- fn claim_clipboard(&mut self, text: String, qh: &QueueHandle<App>) {- let Some(device) = self.data_device() else {- return;- };- let source = self- .data_device_manager- .create_copy_paste_source(qh, TEXT_MIMES.iter().copied());- source.set_selection(device, self.serial);- self.clipboard = text;- self.copy_source = Some(source);- }-- /// Take ownership of the primary selection, serving `text` to pasters.- fn claim_primary(&mut self, text: String, qh: &QueueHandle<App>) {- let (Some(manager), Some(device)) = (self.primary_manager.as_ref(), self.primary_device())- else {- return;- };- let source = manager.create_selection_source(qh, TEXT_MIMES.iter().copied());- source.set_selection(device, self.serial);- self.primary_clip = text;- self.primary_source = Some(source);- }-- /// Claim the clipboard (CLIPBOARD) with the current selection (Ctrl+Shift+C).- fn set_clipboard(&mut self, qh: &QueueHandle<App>) {- if let Some(text) = self.selection_text() {- self.claim_clipboard(text, qh);- }- }-- /// Claim the primary selection with the current selection (select-to-copy).- fn set_primary(&mut self, qh: &QueueHandle<App>) {- if let Some(text) = self.selection_text() {- self.claim_primary(text, qh);- }- }-- /// Point the IME's candidate popup at the terminal cursor, in logical- /// surface coordinates (the renderer works in physical pixels, so divide- /// the physical cell rectangle back down by the scale).- fn ime_set_cursor_rect(&self, ti: &ZwpTextInputV3) {- let Some(session) = self.session.as_ref() else {- return;- };- let (cx, cy) = session.term.grid().cursor();- let m = self.renderer.metrics();- let s = f64::from(self.scale120) / 120.0;- let pad_x = f64::from(self.to_phys(self.config.main.pad_x));- let pad_y = f64::from(self.to_phys(self.config.main.pad_y));- let x = ((pad_x + cx as f64 * f64::from(m.width)) / s) as i32;- let y = ((pad_y + cy as f64 * f64::from(m.height)) / s) as i32;- let w = (f64::from(m.width) / s) as i32;- let h = (f64::from(m.height) / s) as i32;- ti.set_cursor_rectangle(x, y, w.max(1), h.max(1));- }-- /// Apply one IME transaction: commit any committed text to the shell, adopt- /// the new preedit, then re-commit our state (cursor rectangle) to the IME.- fn ime_done(&mut self, ti: &ZwpTextInputV3) {- let commit = std::mem::take(&mut self.ime_commit_pending);- // Preedit is replaced wholesale each cycle; an absent preedit clears it.- self.preedit = std::mem::take(&mut self.ime_preedit_pending);- if !commit.is_empty() {- self.send_to_shell(commit.as_bytes());- }- self.ime_set_cursor_rect(ti);- ti.commit();- self.needs_draw = true;- }-- /// Act on the OSC 52 clipboard requests an application made: take ownership- /// of the selection it set, or answer a query with what we currently hold.- fn handle_clipboard_ops(&mut self, ops: Vec<crate::vt::ClipboardOp>) {- use crate::vt::ClipboardOp;- let qh = self.qh.clone();- for op in ops {- match op {- ClipboardOp::Set {- primary: true,- text,- } => self.claim_primary(text, &qh),- ClipboardOp::Set {- primary: false,- text,- } => self.claim_clipboard(text, &qh),- ClipboardOp::Query { primary } => {- let text = if primary {- &self.primary_clip- } else {- &self.clipboard- };- let kind = if primary { 'p' } else { 'c' };- let reply = format!(- "\x1b]52;{kind};{}\x07",- crate::vt::base64_encode(text.as_bytes())- );- self.write_to_pty(reply.as_bytes());- }- }- }- }-- /// Paste the CLIPBOARD selection into the shell (Ctrl+Shift+V).- fn paste_clipboard(&mut self) {- let Some(offer) = self.data_device().and_then(|d| d.data().selection_offer()) else {- return;- };- let Some(mime) = offer.with_mime_types(pick_mime) else {- return;- };- if let Ok(pipe) = offer.receive(mime) {- self.read_paste(pipe);- }- }-- /// Paste the primary selection into the shell (middle click).- fn paste_primary(&mut self) {- let Some(offer) = self- .primary_device()- .and_then(|d| d.data().selection_offer())- else {- return;- };- let Some(mime) = offer.with_mime_types(pick_mime) else {- return;- };- if let Ok(pipe) = offer.receive(mime) {- self.read_paste(pipe);- }- }-- /// Drain a clipboard read-pipe on the event loop, writing the bytes to the- /// PTY once the source closes its end.- fn read_paste(&mut self, pipe: smithay_client_toolkit::data_device_manager::ReadPipe) {- let mut data: Vec<u8> = Vec::new();- let registered = self- .loop_handle- .insert_source(pipe, move |_, file, app: &mut App| {- // SAFETY: the file is owned by the source and not closed while read.- let f: &mut File = unsafe { file.get_mut() };- let mut tmp = [0u8; 4096];- match f.read(&mut tmp) {- Ok(0) => {- app.paste_bytes(&data);- PostAction::Remove- }- Ok(n) => {- data.extend_from_slice(&tmp[..n]);- PostAction::Continue- }- Err(e) if matches!(e.kind(), std::io::ErrorKind::Interrupted) => {- PostAction::Continue- }- Err(e) => {- tracing::warn!("read paste pipe: {e}");- PostAction::Remove- }- }- });- if let Err(err) = registered {- tracing::warn!("register paste pipe: {err}");- }- }-- /// Write pasted bytes to the PTY, framing them for bracketed-paste mode and- /// snapping the viewport to the live screen.- fn paste_bytes(&mut self, data: &[u8]) {- let Some(session) = self.session.as_mut() else {- return;- };- session.term.scroll_to_bottom();- self.needs_draw = true;- let bracketed = session.term.grid().bracketed_paste();- // Strip control bytes a terminal must never receive raw from a paste;- // keep tab and newlines (CR is what the shell expects for Enter).- let mut clean: Vec<u8> = Vec::with_capacity(data.len());- for &b in data {- match b {- b'\n' => clean.push(b'\r'),- b'\t' | b'\r' => clean.push(b),- 0x20..=0xff => clean.push(b),- _ => {}- }- }- let fd = session.pty.master();- if bracketed {- let _ = write_all(fd, b"\x1b[200~");- let _ = write_all(fd, &clean);- let _ = write_all(fd, b"\x1b[201~");- } else if let Err(err) = write_all(fd, &clean) {- tracing::warn!("write paste to pty: {err}");- }- }-- /// After parsing child output: send any replies, sync the title, repaint.- fn after_feed(&mut self) {- let Some(session) = self.session.as_mut() else {- return;- };- let reply = session.term.take_response();- if !reply.is_empty()- && let Err(err) = write_all(session.pty.master(), &reply)- {- tracing::warn!("write to pty: {err}");- }-- let new_title = session.term.title().map(str::to_owned);- if new_title.as_deref() != self.title.as_deref() {- self.title = new_title;- self.window- .set_title(self.title.clone().unwrap_or_default());- }- let rang = session.term.take_bell();- let ops = session.term.take_clipboard_ops();- let notifications = session.term.take_notifications();- if !ops.is_empty() {- self.handle_clipboard_ops(ops);- }- for note in notifications {- self.send_notification(¬e);- }- if rang {- self.ring_bell();- }- self.needs_draw = true;- }-- /// React to a `BEL`: optionally flash, run the configured bell command, and- /// request the compositor's attention when unfocused.- fn ring_bell(&mut self) {- if self.config.bell.visual {- self.start_flash();- }- if let Some((program, args)) = self.config.bell.command.split_first() {- let _ = std::process::Command::new(program)- .args(args)- .stdin(std::process::Stdio::null())- .stdout(std::process::Stdio::null())- .stderr(std::process::Stdio::null())- .spawn()- .inspect_err(|err| tracing::warn!("bell command: {err}"));- }- if self.config.bell.urgent && !self.focused {- self.request_attention();- }- }-- /// Deliver a desktop notification through the configured notifier (default- /// `notify-send`), appending the title and body as the final arguments.- fn send_notification(&self, note: &crate::vt::Notification) {- let Some((program, args)) = self.config.notify.command.split_first() else {- return;- };- let title = note- .title- .clone()- .or_else(|| self.title.clone())- .unwrap_or_else(|| "beer".to_string());- let _ = std::process::Command::new(program)- .args(args)- .arg(title)- .arg(¬e.body)- .stdin(std::process::Stdio::null())- .stdout(std::process::Stdio::null())- .stderr(std::process::Stdio::null())- .spawn()- .inspect_err(|err| tracing::warn!("notify command: {err}"));- }-- /// Ask the compositor to draw attention to the window (xdg-activation).- fn request_attention(&mut self) {- let Some(activation) = self.activation.as_ref() else {- return;- };- let seat_and_serial = self- .seats- .get(self.active_seat)- .map(|s| (s.seat.clone(), self.serial));- let data = smithay_client_toolkit::activation::RequestData {- app_id: Some("dev.notashelf.beer".to_string()),- seat_and_serial,- surface: Some(self.window.wl_surface().clone()),- };- activation.request_token::<App>(&self.qh, data);- }-- /// Begin a visual-bell flash: invert the screen for a moment. Clearing the- /// buffer ring forces a full repaint with the inverted theme.- fn start_flash(&mut self) {- self.flashing = true;- self.frames.clear();- self.needs_draw = true;- if self.flash_timer.is_none() {- let timer = Timer::from_duration(Duration::from_millis(FLASH_MS));- self.flash_timer = self- .loop_handle- .insert_source(timer, |_, _, app: &mut App| {- app.flashing = false;- app.frames.clear();- app.needs_draw = true;- app.flash_timer = None;- TimeoutAction::Drop- })- .ok();- }- }-- /// Recompute the grid size for the current window and tell the grid and the- /// PTY about it if it changed.- fn resize_grid(&mut self) {- let (cols, rows) = self.grid_dims();- let Some(session) = self.session.as_mut() else {- return;- };- if (cols as usize, rows as usize)- == (session.term.grid().cols(), session.term.grid().rows())- {- return;- }- session.term.resize(cols as usize, rows as usize);- if let Err(err) = session.pty.resize(cols, rows) {- tracing::warn!("resize pty: {err}");- }- }-- /// The child shell has gone away; reap it, capture its code, and tear the- /// window down.- fn child_exited(&mut self) {- if let Some(session) = self.session.as_mut() {- match session.pty.wait() {- Ok(status) => {- tracing::info!("shell exited: {status}");- // Mirror the shell's status: its code, or 128+signal if killed.- let code = status- .code()- .unwrap_or_else(|| 128 + status.signal().unwrap_or(0));- self.exit_code = ExitCode::from(code as u8);- }- Err(err) => tracing::warn!("reap shell: {err}"),- }- }- self.exit = true;- }-- /// Present a frame if one is wanted and the compositor is ready for it.- /// Called after every event-loop wake; the frame-callback gate keeps draws- /// paced to the display instead of one per PTY read. While the app holds- /// synchronized output (DECSET 2026) we withhold the frame, but arm a- /// timeout so a stuck `2026h` cannot freeze the window.- fn flush(&mut self) {- let sync = self- .session- .as_ref()- .is_some_and(|s| s.term.grid().sync_active());- if sync {- if self.sync_timeout.is_none() {- let timer = Timer::from_duration(Duration::from_millis(SYNC_TIMEOUT_MS));- self.sync_timeout = self- .loop_handle- .insert_source(timer, |_, _, app: &mut App| {- if let Some(session) = app.session.as_mut() {- session.term.grid_mut().set_sync(false);- }- app.sync_timeout = None;- app.needs_draw = true;- TimeoutAction::Drop- })- .ok();- }- return;- }- if let Some(token) = self.sync_timeout.take() {- self.loop_handle.remove(token);- }- if self.needs_draw && !self.frame_pending && self.session.is_some() {- self.present();- }- }-- /// Render only the rows that changed since the chosen buffer last displayed- /// them, damage just those rows, and commit with a frame-callback request.- fn present(&mut self) {- self.needs_draw = false;- // URL hint labels overlay the grid but are not part of the row snapshot,- // so force a full redraw while the labels are showing.- if self.url_mode {- self.frames.clear();- }- // Render into a buffer sized in physical pixels (logical × scale); the- // viewport presents it back at the logical surface size.- let (w, h) = self.phys_dims();- let m = self.renderer.metrics();- let (focused, blink_on) = (self.focused, self.blink_on);-- // A resize invalidates every buffer's contents and size.- if self.buf_dims != (w, h) {- self.frames.clear();- self.buf_dims = (w, h);- }-- let Some(session) = self.session.as_ref() else {- return;- };- let grid = session.term.grid();- // The visual bell inverts fg/bg for the duration of the flash.- let flashed = self.flashing.then(|| session.term.theme().inverted());- let theme = flashed.as_ref().unwrap_or(session.term.theme());- let rows = grid.rows();- let mut cur: Vec<RowSnap> = (0..rows)- .map(|y| row_snap(grid, y, focused, blink_on))- .collect();-- // The search prompt occupies the bottom row while search mode is active.- // Recording it in the snapshot keeps the row's damage/diff correct.- let bar_text = self.searching.then(|| {- let (n, total) = grid.search_count();- format!(- "search: {} [{n}/{total}]",- grid.search_query().unwrap_or("")- )- });- if let Some(text) = &bar_text- && rows > 0- {- cur[rows - 1].overlay = Some(text.clone());- }-- // The IME preedit is drawn inline at the cursor while composing.- if !self.preedit.is_empty() && grid.view_at_bottom() {- let (cx, cy) = grid.cursor();- if cy < rows {- cur[cy].preedit = Some((cx, self.preedit.clone()));- }- }-- // Reuse a buffer the compositor has released, else grow the ring.- let stride = w as i32 * 4;- let mut idx = None;- for i in 0..self.frames.len() {- if self.pool.canvas(&self.frames[i].buffer).is_some() {- idx = Some(i);- break;- }- }- let idx = match idx {- Some(i) => i,- None if self.frames.len() < MAX_BUFFERS => {- match self- .pool- .create_buffer(w as i32, h as i32, stride, wl_shm::Format::Argb8888)- {- Ok((buffer, _)) => {- self.frames.push(FrameBuf {- buffer,- rows: Vec::new(),- });- self.frames.len() - 1- }- Err(err) => {- tracing::error!("allocate shm buffer: {err}");- return;- }- }- }- // All buffers are still held by the compositor; a release event will- // wake us and `needs_draw` (re-set below) retries then.- None => {- self.needs_draw = true;- return;- }- };-- // Rows that differ from what this buffer last showed (all, if fresh).- let prev = &self.frames[idx].rows;- let dirty: Vec<usize> = (0..rows)- .filter(|&y| prev.get(y) != Some(&cur[y]))- .collect();- if dirty.is_empty() {- return;- }-- // A buffer used for the first time has uninitialized margins; paint the- // whole thing (background + padding) once, then damage it in full below.- let fresh = self.frames[idx].rows.is_empty();- let pad_y = self.to_phys(self.config.main.pad_y) as i32;- let Some(canvas) = self.pool.canvas(&self.frames[idx].buffer) else {- return;- };- let dims = (w as usize, h as usize);- let frame = crate::render::Frame {- theme,- focused,- blink_on,- hovered_link: self.hovered_link,- };- if fresh {- self.renderer.clear(canvas, dims, theme);- }- for &y in &dirty {- self.renderer.render_row(canvas, dims, grid, &frame, y);- }- // Draw the search prompt over the (now repainted) bottom row.- if let Some(text) = &bar_text- && dirty.contains(&(rows - 1))- {- self.renderer- .render_search_bar(canvas, dims, theme, rows - 1, text);- }- // Draw the IME preedit inline over its (repainted) cursor row.- for &y in &dirty {- if let Some((col, text)) = &cur[y].preedit {- self.renderer- .render_preedit(canvas, dims, theme, y, *col, text);- }- }- // Draw URL hint labels on top, narrowing to those matching the input.- if self.url_mode {- for (hit, label) in self.url_hits.iter().zip(&self.url_labels) {- if label.starts_with(&self.url_input) {- self.renderer- .render_label(canvas, dims, theme, hit.row, hit.col, label);- }- }- }- self.frames[idx].rows = cur;-- let surface = self.window.wl_surface();- if let Err(err) = self.frames[idx].buffer.attach_to(surface) {- tracing::error!("attach buffer: {err}");- return;- }- // With a viewport the buffer is presented at the logical destination, so- // its own scale stays 1; without one, fall back to integer buffer scale.- if let Some(vp) = &self.viewport {- surface.set_buffer_scale(1);- vp.set_destination(self.width.max(1) as i32, self.height.max(1) as i32);- } else {- surface.set_buffer_scale((self.scale120 / 120).max(1) as i32);- }- if fresh {- surface.damage_buffer(0, 0, w as i32, h as i32);- } else {- for &y in &dirty {- let top = pad_y + y as i32 * m.height as i32;- surface.damage_buffer(0, top, w as i32, m.height as i32);- }- }- surface.frame(&self.qh, surface.clone());- self.window.commit();- self.frame_pending = true;- }-}--impl CompositorHandler for App {- fn scale_factor_changed(- &mut self,- _: &Connection,- _: &QueueHandle<Self>,- _: &wl_surface::WlSurface,- factor: i32,- ) {- // Integer fallback for compositors without fractional-scale-v1; ignored- // when the fractional-scale object drives the scale instead.- if self.fractional_scale.is_none() {- self.set_scale((factor.max(1) as u32) * 120);- }- }-- fn transform_changed(- &mut self,- _: &Connection,- _: &QueueHandle<Self>,- _: &wl_surface::WlSurface,- _: wl_output::Transform,- ) {- }-- fn frame(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &wl_surface::WlSurface, _: u32) {- // The compositor is ready for another frame; `flush` will repaint if the- // grid has changed since the last present.- self.frame_pending = false;- }-- fn surface_enter(- &mut self,- _: &Connection,- _: &QueueHandle<Self>,- _: &wl_surface::WlSurface,- _: &wl_output::WlOutput,- ) {- }-- fn surface_leave(- &mut self,- _: &Connection,- _: &QueueHandle<Self>,- _: &wl_surface::WlSurface,- _: &wl_output::WlOutput,- ) {- }-}--impl WindowHandler for App {- fn request_close(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &Window) {- self.exit = true;- }-- fn configure(- &mut self,- _: &Connection,- _: &QueueHandle<Self>,- _: &Window,- configure: WindowConfigure,- _serial: u32,- ) {- if let (Some(w), Some(h)) = configure.new_size {- self.width = w.get();- self.height = h.get();- if let Some(vp) = &self.viewport {- vp.set_destination(self.width.max(1) as i32, self.height.max(1) as i32);- }- }- self.focused = configure.is_activated();- if self.session.is_none() {- self.spawn_session();- } else {- self.resize_grid();- }- self.needs_draw = true;- }-}--impl ShmHandler for App {- fn shm_state(&mut self) -> &mut Shm {- &mut self.shm- }-}--impl SeatHandler for App {- fn seat_state(&mut self) -> &mut SeatState {- &mut self.seat_state- }-- fn new_seat(&mut self, _: &Connection, qh: &QueueHandle<Self>, seat: wl_seat::WlSeat) {- // Clipboard/primary devices are seat-scoped, not capability-scoped.- let data_device = Some(self.data_device_manager.get_data_device(qh, &seat));- let primary_device = self- .primary_manager- .as_ref()- .map(|m| m.get_selection_device(qh, &seat));- let i = self.seat_index(&seat);- self.seats[i].data_device = data_device;- self.seats[i].primary_device = primary_device;- }-- fn new_capability(- &mut self,- _: &Connection,- qh: &QueueHandle<Self>,- seat: wl_seat::WlSeat,- capability: Capability,- ) {- let i = self.seat_index(&seat);- if capability == Capability::Keyboard && self.seats[i].keyboard.is_none() {- // get_keyboard_with_repeat drives key repeat off a calloop timer and- // delivers each repeat through the callback.- let loop_handle = self.loop_handle.clone();- let keyboard = self.seat_state.get_keyboard_with_repeat(- qh,- &seat,- None,- loop_handle,- Box::new(|app: &mut App, _kbd, event| app.handle_key(&event)),- );- match keyboard {- Ok(keyboard) => self.seats[i].keyboard = Some(keyboard),- Err(err) => tracing::warn!("get keyboard: {err}"),- }- if self.seats[i].text_input.is_none()- && let Some(mgr) = self.text_input_manager.as_ref()- {- self.seats[i].text_input = Some(mgr.get_text_input(&seat, qh, ()));- }- }- if capability == Capability::Pointer && self.seats[i].pointer.is_none() {- match self.seat_state.get_pointer(qh, &seat) {- Ok(pointer) => {- self.seats[i].cursor_shape_device = self- .cursor_shape_manager- .as_ref()- .map(|m| m.get_shape_device(&pointer, qh));- self.seats[i].pointer = Some(pointer);- }- Err(err) => tracing::warn!("get pointer: {err}"),- }- }- }-- fn remove_capability(- &mut self,- _: &Connection,- _: &QueueHandle<Self>,- seat: wl_seat::WlSeat,- capability: Capability,- ) {- let Some(s) = self.seats.iter_mut().find(|s| s.seat == seat) else {- return;- };- match capability {- Capability::Keyboard => {- if let Some(keyboard) = s.keyboard.take() {- keyboard.release();- }- }- Capability::Pointer => {- s.cursor_shape_device = None;- if let Some(pointer) = s.pointer.take() {- pointer.release();- }- }- _ => {}- }- }-- fn remove_seat(&mut self, _: &Connection, _: &QueueHandle<Self>, seat: wl_seat::WlSeat) {- self.seats.retain(|s| s.seat != seat);- self.active_seat = self.active_seat.min(self.seats.len().saturating_sub(1));- }-}--impl KeyboardHandler for App {- fn enter(- &mut self,- _: &Connection,- _: &QueueHandle<Self>,- keyboard: &wl_keyboard::WlKeyboard,- _: &wl_surface::WlSurface,- serial: u32,- _: &[u32],- _: &[Keysym],- ) {- self.activate_keyboard(keyboard);- self.serial = serial;- self.focused = true;- self.report_focus(true);- self.needs_draw = true;- }-- fn leave(- &mut self,- _: &Connection,- _: &QueueHandle<Self>,- _: &wl_keyboard::WlKeyboard,- _: &wl_surface::WlSurface,- _: u32,- ) {- self.focused = false;- self.report_focus(false);- self.needs_draw = true;- }-- fn press_key(- &mut self,- _: &Connection,- _: &QueueHandle<Self>,- keyboard: &wl_keyboard::WlKeyboard,- serial: u32,- event: KeyEvent,- ) {- self.activate_keyboard(keyboard);- self.serial = serial;- self.handle_key(&event);- }-- fn repeat_key(- &mut self,- _: &Connection,- _: &QueueHandle<Self>,- _: &wl_keyboard::WlKeyboard,- _: u32,- _: KeyEvent,- ) {- // Repeats are delivered through the get_keyboard_with_repeat callback;- // this non-calloop hook is unused.- }-- fn release_key(- &mut self,- _: &Connection,- _: &QueueHandle<Self>,- _: &wl_keyboard::WlKeyboard,- _: u32,- _: KeyEvent,- ) {- }-- fn update_modifiers(- &mut self,- _: &Connection,- _: &QueueHandle<Self>,- _: &wl_keyboard::WlKeyboard,- _: u32,- modifiers: Modifiers,- _: RawModifiers,- _: u32,- ) {- self.modifiers = modifiers;- }-- fn update_repeat_info(- &mut self,- _: &Connection,- _: &QueueHandle<Self>,- _: &wl_keyboard::WlKeyboard,- _: RepeatInfo,- ) {- }-}--/// Parse a configured cursor-style name into a [`CursorShape`].-fn cursor_shape_from(style: Option<&str>) -> Option<CursorShape> {- match style? {- "block" => Some(CursorShape::Block),- "beam" | "bar" => Some(CursorShape::Beam),- "underline" => Some(CursorShape::Underline),- other => {- tracing::warn!("unknown cursor style {other:?}");- None- }- }-}--/// Generate `n` distinct keyboard hint labels (a, b, …, z, aa, ab, …), all the-/// same length so prefix matching is unambiguous.-fn hint_labels(n: usize) -> Vec<String> {- const ALPHABET: &[u8] = b"abcdefghijklmnopqrstuvwxyz";- if n == 0 {- return Vec::new();- }- let (mut width, mut capacity) = (1usize, 26usize);- while capacity < n {- width += 1;- capacity *= 26;- }- (0..n)- .map(|i| {- let mut idx = i;- let mut chars = vec![b'a'; width];- for slot in chars.iter_mut().rev() {- *slot = ALPHABET[idx % 26];- idx /= 26;- }- String::from_utf8(chars).expect("ascii labels are valid utf-8")- })- .collect()-}--/// Map a Wayland button code to the terminal mouse base code, if reportable.-fn button_code(button: u32) -> Option<u8> {- match button {- BTN_LEFT => Some(0),- BTN_MIDDLE => Some(1),- BTN_RIGHT => Some(2),- _ => None,- }-}--impl PointerHandler for App {- fn pointer_frame(- &mut self,- _: &Connection,- qh: &QueueHandle<Self>,- pointer: &wl_pointer::WlPointer,- events: &[PointerEvent],- ) {- self.activate_pointer(pointer);- let cell_h = f64::from(self.renderer.metrics().height);- for event in events {- match &event.kind {- PointerEventKind::Enter { serial } => {- self.pointer_pos = event.position;- self.pointer_enter_serial = *serial;- self.update_hover(pointer);- self.pointer_drag();- }- PointerEventKind::Motion { .. } => {- self.pointer_pos = event.position;- if self.try_report_motion() {- continue;- }- if !self.selecting {- self.update_hover(pointer);- }- self.pointer_drag();- }- PointerEventKind::Press {- button,- serial,- time,- ..- } => {- self.serial = *serial;- self.pointer_pos = event.position;- if let Some(code) = button_code(*button)- && self.try_report_button(code, true)- {- self.pressed_button = Some(code);- continue;- }- match *button {- BTN_LEFT => {- self.press_cell = self.cell_at(self.pointer_pos.0, self.pointer_pos.1);- self.pointer_press(*time);- }- BTN_MIDDLE => self.paste_primary(),- _ => {}- }- }- PointerEventKind::Release { button, .. } => {- self.pointer_pos = event.position;- let code = button_code(*button);- if let Some(code) = code- && self.try_report_button(code, false)- {- if self.pressed_button == Some(code) {- self.pressed_button = None;- }- continue;- }- if *button == BTN_LEFT {- self.maybe_open_clicked_link();- self.pointer_release(qh);- }- }- PointerEventKind::Axis { vertical, .. } => {- // Wheel notches arrive as value120 (÷120) or legacy discrete- // steps, ~3 lines each; touchpads send pixels per cell height.- let (raw, scale) = if vertical.value120 != 0 {- (f64::from(vertical.value120) / 120.0, 3.0)- } else if vertical.discrete != 0 {- (f64::from(vertical.discrete), 3.0)- } else if cell_h > 0.0 {- (vertical.absolute / cell_h, 1.0)- } else {- continue;- };- if raw == 0.0 {- continue;- }- let mult = self.config.mouse.scroll_multiplier.max(0.0);- let lines = (raw.abs() * scale * mult).ceil().max(1.0) as isize;- let up = raw < 0.0;- // Reporting apps get wheel buttons (64 up / 65 down) as- // presses, one per line, capped so a flick cannot flood.- if self.mouse_reporting() {- let code = if up { 64 } else { 65 };- for _ in 0..lines.clamp(1, 8) {- self.try_report_button(code, true);- }- continue;- }- // On the alternate screen there is no scrollback to move, so- // (when enabled) translate the wheel into cursor-key presses- // for apps that did not request mouse reporting.- let alt = self- .session- .as_ref()- .is_some_and(|s| s.term.grid().alt_active());- if alt && self.config.mouse.alternate_scroll {- self.alternate_scroll(up, lines.clamp(1, 8));- continue;- }- // Positive axis = scroll down (toward live); the viewport- // scrolls the opposite way (negative offset delta).- let delta = if up { lines } else { -lines };- if let Some(session) = self.session.as_mut() {- session.term.scroll_view(delta);- self.needs_draw = true;- }- }- _ => {}- }- }- }-}--impl OutputHandler for App {- fn output_state(&mut self) -> &mut OutputState {- &mut self.output_state- }-- fn new_output(&mut self, _: &Connection, _: &QueueHandle<Self>, _: wl_output::WlOutput) {}-- fn update_output(&mut self, _: &Connection, _: &QueueHandle<Self>, _: wl_output::WlOutput) {}-- fn output_destroyed(&mut self, _: &Connection, _: &QueueHandle<Self>, _: wl_output::WlOutput) {}-}--impl ProvidesRegistryState for App {- fn registry(&mut self) -> &mut RegistryState {- &mut self.registry_state- }- registry_handlers![OutputState, SeatState];-}--/// Serve the held clipboard text when a paste target requests it.-fn serve(text: &str, fd: WritePipe) {- let mut file = File::from(OwnedFd::from(fd));- let _ = file.write_all(text.as_bytes());-}--impl DataDeviceHandler for App {- fn enter(- &mut self,- _: &Connection,- _: &QueueHandle<Self>,- _: &WlDataDevice,- _: f64,- _: f64,- _: &wl_surface::WlSurface,- ) {- }- fn leave(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &WlDataDevice) {}- fn motion(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &WlDataDevice, _: f64, _: f64) {}- fn selection(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &WlDataDevice) {}- fn drop_performed(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &WlDataDevice) {}-}--impl DataOfferHandler for App {- fn source_actions(- &mut self,- _: &Connection,- _: &QueueHandle<Self>,- _: &mut DragOffer,- _: DndAction,- ) {- }- fn selected_action(- &mut self,- _: &Connection,- _: &QueueHandle<Self>,- _: &mut DragOffer,- _: DndAction,- ) {- }-}--impl DataSourceHandler for App {- fn accept_mime(- &mut self,- _: &Connection,- _: &QueueHandle<Self>,- _: &WlDataSource,- _: Option<String>,- ) {- }-- fn send_request(- &mut self,- _: &Connection,- _: &QueueHandle<Self>,- source: &WlDataSource,- _mime: String,- fd: WritePipe,- ) {- if self- .copy_source- .as_ref()- .is_some_and(|s| s.inner() == source)- {- serve(&self.clipboard, fd);- }- }-- fn cancelled(&mut self, _: &Connection, _: &QueueHandle<Self>, source: &WlDataSource) {- if self- .copy_source- .as_ref()- .is_some_and(|s| s.inner() == source)- {- self.copy_source = None;- }- }-- fn dnd_dropped(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &WlDataSource) {}- fn dnd_finished(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &WlDataSource) {}- fn action(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &WlDataSource, _: DndAction) {}-}--impl PrimarySelectionDeviceHandler for App {- fn selection(- &mut self,- _: &Connection,- _: &QueueHandle<Self>,- _: &ZwpPrimarySelectionDeviceV1,- ) {- }-}--impl PrimarySelectionSourceHandler for App {- fn send_request(- &mut self,- _: &Connection,- _: &QueueHandle<Self>,- source: &ZwpPrimarySelectionSourceV1,- _mime: String,- fd: WritePipe,- ) {- if self- .primary_source- .as_ref()- .is_some_and(|s| s.inner() == source)- {- serve(&self.primary_clip, fd);- }- }-- fn cancelled(- &mut self,- _: &Connection,- _: &QueueHandle<Self>,- source: &ZwpPrimarySelectionSourceV1,- ) {- if self- .primary_source- .as_ref()- .is_some_and(|s| s.inner() == source)- {- self.primary_source = None;- }- }-}--// Fractional-scale and viewporter are not wrapped by sctk, so dispatch them by-// hand. Only the fractional-scale object carries an event we act on.-impl Dispatch<WpFractionalScaleV1, ()> for App {- fn event(- state: &mut Self,- _: &WpFractionalScaleV1,- event: wp_fractional_scale_v1::Event,- _: &(),- _: &Connection,- _: &QueueHandle<Self>,- ) {- if let wp_fractional_scale_v1::Event::PreferredScale { scale } = event {- state.set_scale(scale);- }- }-}--impl Dispatch<WpFractionalScaleManagerV1, ()> for App {- fn event(- _: &mut Self,- _: &WpFractionalScaleManagerV1,- _: <WpFractionalScaleManagerV1 as Proxy>::Event,- _: &(),- _: &Connection,- _: &QueueHandle<Self>,- ) {- }-}--impl Dispatch<WpViewporter, ()> for App {- fn event(- _: &mut Self,- _: &WpViewporter,- _: <WpViewporter as Proxy>::Event,- _: &(),- _: &Connection,- _: &QueueHandle<Self>,- ) {- }-}--impl Dispatch<WpViewport, ()> for App {- fn event(- _: &mut Self,- _: &WpViewport,- _: <WpViewport as Proxy>::Event,- _: &(),- _: &Connection,- _: &QueueHandle<Self>,- ) {- }-}--impl ActivationHandler for App {- type RequestData = RequestData;-- fn new_token(&mut self, token: String, _: &RequestData) {- // The compositor granted an activation token; use it to draw attention.- if let Some(activation) = self.activation.as_ref() {- activation.activate::<App>(self.window.wl_surface(), token);- }- }-}--impl Dispatch<ZwpTextInputManagerV3, ()> for App {- fn event(- _: &mut Self,- _: &ZwpTextInputManagerV3,- _: <ZwpTextInputManagerV3 as Proxy>::Event,- _: &(),- _: &Connection,- _: &QueueHandle<Self>,- ) {- }-}--// text-input-v3 batches preedit/commit between `enter` and `done`; we apply the-// accumulated transaction on `done` and re-enable on focus enter.-impl Dispatch<ZwpTextInputV3, ()> for App {- fn event(- state: &mut Self,- ti: &ZwpTextInputV3,- event: zwp_text_input_v3::Event,- _: &(),- _: &Connection,- _: &QueueHandle<Self>,- ) {- use zwp_text_input_v3::Event;- match event {- Event::Enter { .. } => {- ti.enable();- ti.set_content_type(ContentHint::None, ContentPurpose::Terminal);- state.ime_set_cursor_rect(ti);- ti.commit();- }- Event::Leave { .. } => {- ti.disable();- ti.commit();- state.preedit.clear();- state.ime_preedit_pending.clear();- state.ime_commit_pending.clear();- state.needs_draw = true;- }- Event::PreeditString { text, .. } => {- state.ime_preedit_pending = text.unwrap_or_default();- }- Event::CommitString { text } => {- state.ime_commit_pending.push_str(&text.unwrap_or_default());- }- Event::Done { .. } => state.ime_done(ti),- // We do not expose surrounding text, so nothing to delete.- Event::DeleteSurroundingText { .. } => {}- _ => {}- }- }-}--delegate_compositor!(App);-delegate_output!(App);-delegate_shm!(App);-delegate_seat!(App);-delegate_keyboard!(App);-delegate_pointer!(App);-delegate_xdg_shell!(App);-delegate_xdg_window!(App);-delegate_data_device!(App);-delegate_primary_selection!(App);-delegate_registry!(App);-delegate_activation!(App);@@ -0,0 +1,711 @@+use super::*;++impl CompositorHandler for App {+ fn scale_factor_changed(+ &mut self,+ _: &Connection,+ _: &QueueHandle<Self>,+ _: &wl_surface::WlSurface,+ factor: i32,+ ) {+ // Integer fallback for compositors without fractional-scale-v1; ignored+ // when the fractional-scale object drives the scale instead.+ if self.fractional_scale.is_none() {+ self.set_scale((factor.max(1) as u32) * 120);+ }+ }++ fn transform_changed(+ &mut self,+ _: &Connection,+ _: &QueueHandle<Self>,+ _: &wl_surface::WlSurface,+ _: wl_output::Transform,+ ) {+ }++ fn frame(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &wl_surface::WlSurface, _: u32) {+ // The compositor is ready for another frame; `flush` will repaint if the+ // grid has changed since the last present.+ self.frame_pending = false;+ }++ fn surface_enter(+ &mut self,+ _: &Connection,+ _: &QueueHandle<Self>,+ _: &wl_surface::WlSurface,+ _: &wl_output::WlOutput,+ ) {+ }++ fn surface_leave(+ &mut self,+ _: &Connection,+ _: &QueueHandle<Self>,+ _: &wl_surface::WlSurface,+ _: &wl_output::WlOutput,+ ) {+ }+}++impl WindowHandler for App {+ fn request_close(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &Window) {+ self.exit = true;+ }++ fn configure(+ &mut self,+ _: &Connection,+ _: &QueueHandle<Self>,+ _: &Window,+ configure: WindowConfigure,+ _serial: u32,+ ) {+ if let (Some(w), Some(h)) = configure.new_size {+ self.width = w.get();+ self.height = h.get();+ if let Some(vp) = &self.viewport {+ vp.set_destination(self.width.max(1) as i32, self.height.max(1) as i32);+ }+ }+ self.focused = configure.is_activated();+ if self.session.is_none() {+ self.spawn_session();+ } else {+ self.resize_grid();+ }+ self.needs_draw = true;+ }+}++impl ShmHandler for App {+ fn shm_state(&mut self) -> &mut Shm {+ &mut self.shm+ }+}++impl SeatHandler for App {+ fn seat_state(&mut self) -> &mut SeatState {+ &mut self.seat_state+ }++ fn new_seat(&mut self, _: &Connection, qh: &QueueHandle<Self>, seat: wl_seat::WlSeat) {+ // Clipboard/primary devices are seat-scoped, not capability-scoped.+ let data_device = Some(self.data_device_manager.get_data_device(qh, &seat));+ let primary_device = self+ .primary_manager+ .as_ref()+ .map(|m| m.get_selection_device(qh, &seat));+ let i = self.seat_index(&seat);+ self.seats[i].data_device = data_device;+ self.seats[i].primary_device = primary_device;+ }++ fn new_capability(+ &mut self,+ _: &Connection,+ qh: &QueueHandle<Self>,+ seat: wl_seat::WlSeat,+ capability: Capability,+ ) {+ let i = self.seat_index(&seat);+ if capability == Capability::Keyboard && self.seats[i].keyboard.is_none() {+ // get_keyboard_with_repeat drives key repeat off a calloop timer and+ // delivers each repeat through the callback.+ let loop_handle = self.loop_handle.clone();+ let keyboard = self.seat_state.get_keyboard_with_repeat(+ qh,+ &seat,+ None,+ loop_handle,+ Box::new(|app: &mut App, _kbd, event| app.handle_key(&event)),+ );+ match keyboard {+ Ok(keyboard) => self.seats[i].keyboard = Some(keyboard),+ Err(err) => tracing::warn!("get keyboard: {err}"),+ }+ if self.seats[i].text_input.is_none()+ && let Some(mgr) = self.text_input_manager.as_ref()+ {+ self.seats[i].text_input = Some(mgr.get_text_input(&seat, qh, ()));+ }+ }+ if capability == Capability::Pointer && self.seats[i].pointer.is_none() {+ match self.seat_state.get_pointer(qh, &seat) {+ Ok(pointer) => {+ self.seats[i].cursor_shape_device = self+ .cursor_shape_manager+ .as_ref()+ .map(|m| m.get_shape_device(&pointer, qh));+ self.seats[i].pointer = Some(pointer);+ }+ Err(err) => tracing::warn!("get pointer: {err}"),+ }+ }+ }++ fn remove_capability(+ &mut self,+ _: &Connection,+ _: &QueueHandle<Self>,+ seat: wl_seat::WlSeat,+ capability: Capability,+ ) {+ let Some(s) = self.seats.iter_mut().find(|s| s.seat == seat) else {+ return;+ };+ match capability {+ Capability::Keyboard => {+ if let Some(keyboard) = s.keyboard.take() {+ keyboard.release();+ }+ }+ Capability::Pointer => {+ s.cursor_shape_device = None;+ if let Some(pointer) = s.pointer.take() {+ pointer.release();+ }+ }+ _ => {}+ }+ }++ fn remove_seat(&mut self, _: &Connection, _: &QueueHandle<Self>, seat: wl_seat::WlSeat) {+ self.seats.retain(|s| s.seat != seat);+ self.active_seat = self.active_seat.min(self.seats.len().saturating_sub(1));+ }+}++impl KeyboardHandler for App {+ fn enter(+ &mut self,+ _: &Connection,+ _: &QueueHandle<Self>,+ keyboard: &wl_keyboard::WlKeyboard,+ _: &wl_surface::WlSurface,+ serial: u32,+ _: &[u32],+ _: &[Keysym],+ ) {+ self.activate_keyboard(keyboard);+ self.serial = serial;+ self.focused = true;+ self.report_focus(true);+ self.needs_draw = true;+ }++ fn leave(+ &mut self,+ _: &Connection,+ _: &QueueHandle<Self>,+ _: &wl_keyboard::WlKeyboard,+ _: &wl_surface::WlSurface,+ _: u32,+ ) {+ self.focused = false;+ self.report_focus(false);+ self.needs_draw = true;+ }++ fn press_key(+ &mut self,+ _: &Connection,+ _: &QueueHandle<Self>,+ keyboard: &wl_keyboard::WlKeyboard,+ serial: u32,+ event: KeyEvent,+ ) {+ self.activate_keyboard(keyboard);+ self.serial = serial;+ self.handle_key(&event);+ }++ fn repeat_key(+ &mut self,+ _: &Connection,+ _: &QueueHandle<Self>,+ _: &wl_keyboard::WlKeyboard,+ _: u32,+ _: KeyEvent,+ ) {+ // Repeats are delivered through the get_keyboard_with_repeat callback;+ // this non-calloop hook is unused.+ }++ fn release_key(+ &mut self,+ _: &Connection,+ _: &QueueHandle<Self>,+ _: &wl_keyboard::WlKeyboard,+ _: u32,+ _: KeyEvent,+ ) {+ }++ fn update_modifiers(+ &mut self,+ _: &Connection,+ _: &QueueHandle<Self>,+ _: &wl_keyboard::WlKeyboard,+ _: u32,+ modifiers: Modifiers,+ _: RawModifiers,+ _: u32,+ ) {+ self.modifiers = modifiers;+ }++ fn update_repeat_info(+ &mut self,+ _: &Connection,+ _: &QueueHandle<Self>,+ _: &wl_keyboard::WlKeyboard,+ _: RepeatInfo,+ ) {+ }+}++/// Parse a configured cursor-style name into a [`CursorShape`].+pub(super) fn cursor_shape_from(style: Option<&str>) -> Option<CursorShape> {+ match style? {+ "block" => Some(CursorShape::Block),+ "beam" | "bar" => Some(CursorShape::Beam),+ "underline" => Some(CursorShape::Underline),+ other => {+ tracing::warn!("unknown cursor style {other:?}");+ None+ }+ }+}++/// Generate `n` distinct keyboard hint labels (a, b, …, z, aa, ab, …), all the+/// same length so prefix matching is unambiguous.+pub(super) fn hint_labels(n: usize) -> Vec<String> {+ const ALPHABET: &[u8] = b"abcdefghijklmnopqrstuvwxyz";+ if n == 0 {+ return Vec::new();+ }+ let (mut width, mut capacity) = (1usize, 26usize);+ while capacity < n {+ width += 1;+ capacity *= 26;+ }+ (0..n)+ .map(|i| {+ let mut idx = i;+ let mut chars = vec![b'a'; width];+ for slot in chars.iter_mut().rev() {+ *slot = ALPHABET[idx % 26];+ idx /= 26;+ }+ String::from_utf8(chars).expect("ascii labels are valid utf-8")+ })+ .collect()+}++/// Map a Wayland button code to the terminal mouse base code, if reportable.+fn button_code(button: u32) -> Option<u8> {+ match button {+ BTN_LEFT => Some(0),+ BTN_MIDDLE => Some(1),+ BTN_RIGHT => Some(2),+ _ => None,+ }+}++impl PointerHandler for App {+ fn pointer_frame(+ &mut self,+ _: &Connection,+ qh: &QueueHandle<Self>,+ pointer: &wl_pointer::WlPointer,+ events: &[PointerEvent],+ ) {+ self.activate_pointer(pointer);+ let cell_h = f64::from(self.renderer.metrics().height);+ for event in events {+ match &event.kind {+ PointerEventKind::Enter { serial } => {+ self.pointer_pos = event.position;+ self.pointer_enter_serial = *serial;+ self.update_hover(pointer);+ self.pointer_drag();+ }+ PointerEventKind::Motion { .. } => {+ self.pointer_pos = event.position;+ if self.try_report_motion() {+ continue;+ }+ if !self.selecting {+ self.update_hover(pointer);+ }+ self.pointer_drag();+ }+ PointerEventKind::Press {+ button,+ serial,+ time,+ ..+ } => {+ self.serial = *serial;+ self.pointer_pos = event.position;+ if let Some(code) = button_code(*button)+ && self.try_report_button(code, true)+ {+ self.pressed_button = Some(code);+ continue;+ }+ match *button {+ BTN_LEFT => {+ self.press_cell = self.cell_at(self.pointer_pos.0, self.pointer_pos.1);+ self.pointer_press(*time);+ }+ BTN_MIDDLE => self.paste_primary(),+ _ => {}+ }+ }+ PointerEventKind::Release { button, .. } => {+ self.pointer_pos = event.position;+ let code = button_code(*button);+ if let Some(code) = code+ && self.try_report_button(code, false)+ {+ if self.pressed_button == Some(code) {+ self.pressed_button = None;+ }+ continue;+ }+ if *button == BTN_LEFT {+ self.maybe_open_clicked_link();+ self.pointer_release(qh);+ }+ }+ PointerEventKind::Axis { vertical, .. } => {+ // Wheel notches arrive as value120 (÷120) or legacy discrete+ // steps, ~3 lines each; touchpads send pixels per cell height.+ let (raw, scale) = if vertical.value120 != 0 {+ (f64::from(vertical.value120) / 120.0, 3.0)+ } else if vertical.discrete != 0 {+ (f64::from(vertical.discrete), 3.0)+ } else if cell_h > 0.0 {+ (vertical.absolute / cell_h, 1.0)+ } else {+ continue;+ };+ if raw == 0.0 {+ continue;+ }+ let mult = self.config.mouse.scroll_multiplier.max(0.0);+ let lines = (raw.abs() * scale * mult).ceil().max(1.0) as isize;+ let up = raw < 0.0;+ // Reporting apps get wheel buttons (64 up / 65 down) as+ // presses, one per line, capped so a flick cannot flood.+ if self.mouse_reporting() {+ let code = if up { 64 } else { 65 };+ for _ in 0..lines.clamp(1, 8) {+ self.try_report_button(code, true);+ }+ continue;+ }+ // On the alternate screen there is no scrollback to move, so+ // (when enabled) translate the wheel into cursor-key presses+ // for apps that did not request mouse reporting.+ let alt = self+ .session+ .as_ref()+ .is_some_and(|s| s.term.grid().alt_active());+ if alt && self.config.mouse.alternate_scroll {+ self.alternate_scroll(up, lines.clamp(1, 8));+ continue;+ }+ // Positive axis = scroll down (toward live); the viewport+ // scrolls the opposite way (negative offset delta).+ let delta = if up { lines } else { -lines };+ if let Some(session) = self.session.as_mut() {+ session.term.scroll_view(delta);+ self.needs_draw = true;+ }+ }+ _ => {}+ }+ }+ }+}++impl OutputHandler for App {+ fn output_state(&mut self) -> &mut OutputState {+ &mut self.output_state+ }++ fn new_output(&mut self, _: &Connection, _: &QueueHandle<Self>, _: wl_output::WlOutput) {}++ fn update_output(&mut self, _: &Connection, _: &QueueHandle<Self>, _: wl_output::WlOutput) {}++ fn output_destroyed(&mut self, _: &Connection, _: &QueueHandle<Self>, _: wl_output::WlOutput) {}+}++impl ProvidesRegistryState for App {+ fn registry(&mut self) -> &mut RegistryState {+ &mut self.registry_state+ }+ registry_handlers![OutputState, SeatState];+}++/// Serve the held clipboard text when a paste target requests it.+fn serve(text: &str, fd: WritePipe) {+ let mut file = File::from(OwnedFd::from(fd));+ let _ = file.write_all(text.as_bytes());+}++impl DataDeviceHandler for App {+ fn enter(+ &mut self,+ _: &Connection,+ _: &QueueHandle<Self>,+ _: &WlDataDevice,+ _: f64,+ _: f64,+ _: &wl_surface::WlSurface,+ ) {+ }+ fn leave(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &WlDataDevice) {}+ fn motion(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &WlDataDevice, _: f64, _: f64) {}+ fn selection(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &WlDataDevice) {}+ fn drop_performed(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &WlDataDevice) {}+}++impl DataOfferHandler for App {+ fn source_actions(+ &mut self,+ _: &Connection,+ _: &QueueHandle<Self>,+ _: &mut DragOffer,+ _: DndAction,+ ) {+ }+ fn selected_action(+ &mut self,+ _: &Connection,+ _: &QueueHandle<Self>,+ _: &mut DragOffer,+ _: DndAction,+ ) {+ }+}++impl DataSourceHandler for App {+ fn accept_mime(+ &mut self,+ _: &Connection,+ _: &QueueHandle<Self>,+ _: &WlDataSource,+ _: Option<String>,+ ) {+ }++ fn send_request(+ &mut self,+ _: &Connection,+ _: &QueueHandle<Self>,+ source: &WlDataSource,+ _mime: String,+ fd: WritePipe,+ ) {+ if self+ .copy_source+ .as_ref()+ .is_some_and(|s| s.inner() == source)+ {+ serve(&self.clipboard, fd);+ }+ }++ fn cancelled(&mut self, _: &Connection, _: &QueueHandle<Self>, source: &WlDataSource) {+ if self+ .copy_source+ .as_ref()+ .is_some_and(|s| s.inner() == source)+ {+ self.copy_source = None;+ }+ }++ fn dnd_dropped(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &WlDataSource) {}+ fn dnd_finished(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &WlDataSource) {}+ fn action(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &WlDataSource, _: DndAction) {}+}++impl PrimarySelectionDeviceHandler for App {+ fn selection(+ &mut self,+ _: &Connection,+ _: &QueueHandle<Self>,+ _: &ZwpPrimarySelectionDeviceV1,+ ) {+ }+}++impl PrimarySelectionSourceHandler for App {+ fn send_request(+ &mut self,+ _: &Connection,+ _: &QueueHandle<Self>,+ source: &ZwpPrimarySelectionSourceV1,+ _mime: String,+ fd: WritePipe,+ ) {+ if self+ .primary_source+ .as_ref()+ .is_some_and(|s| s.inner() == source)+ {+ serve(&self.primary_clip, fd);+ }+ }++ fn cancelled(+ &mut self,+ _: &Connection,+ _: &QueueHandle<Self>,+ source: &ZwpPrimarySelectionSourceV1,+ ) {+ if self+ .primary_source+ .as_ref()+ .is_some_and(|s| s.inner() == source)+ {+ self.primary_source = None;+ }+ }+}++// Fractional-scale and viewporter are not wrapped by sctk, so dispatch them by+// hand. Only the fractional-scale object carries an event we act on.+impl Dispatch<WpFractionalScaleV1, ()> for App {+ fn event(+ state: &mut Self,+ _: &WpFractionalScaleV1,+ event: wp_fractional_scale_v1::Event,+ _: &(),+ _: &Connection,+ _: &QueueHandle<Self>,+ ) {+ if let wp_fractional_scale_v1::Event::PreferredScale { scale } = event {+ state.set_scale(scale);+ }+ }+}++impl Dispatch<WpFractionalScaleManagerV1, ()> for App {+ fn event(+ _: &mut Self,+ _: &WpFractionalScaleManagerV1,+ _: <WpFractionalScaleManagerV1 as Proxy>::Event,+ _: &(),+ _: &Connection,+ _: &QueueHandle<Self>,+ ) {+ }+}++impl Dispatch<WpViewporter, ()> for App {+ fn event(+ _: &mut Self,+ _: &WpViewporter,+ _: <WpViewporter as Proxy>::Event,+ _: &(),+ _: &Connection,+ _: &QueueHandle<Self>,+ ) {+ }+}++impl Dispatch<WpViewport, ()> for App {+ fn event(+ _: &mut Self,+ _: &WpViewport,+ _: <WpViewport as Proxy>::Event,+ _: &(),+ _: &Connection,+ _: &QueueHandle<Self>,+ ) {+ }+}++impl ActivationHandler for App {+ type RequestData = RequestData;++ fn new_token(&mut self, token: String, _: &RequestData) {+ // The compositor granted an activation token; use it to draw attention.+ if let Some(activation) = self.activation.as_ref() {+ activation.activate::<App>(self.window.wl_surface(), token);+ }+ }+}++impl Dispatch<ZwpTextInputManagerV3, ()> for App {+ fn event(+ _: &mut Self,+ _: &ZwpTextInputManagerV3,+ _: <ZwpTextInputManagerV3 as Proxy>::Event,+ _: &(),+ _: &Connection,+ _: &QueueHandle<Self>,+ ) {+ }+}++// text-input-v3 batches preedit/commit between `enter` and `done`; we apply the+// accumulated transaction on `done` and re-enable on focus enter.+impl Dispatch<ZwpTextInputV3, ()> for App {+ fn event(+ state: &mut Self,+ ti: &ZwpTextInputV3,+ event: zwp_text_input_v3::Event,+ _: &(),+ _: &Connection,+ _: &QueueHandle<Self>,+ ) {+ use zwp_text_input_v3::Event;+ match event {+ Event::Enter { .. } => {+ ti.enable();+ ti.set_content_type(ContentHint::None, ContentPurpose::Terminal);+ state.ime_set_cursor_rect(ti);+ ti.commit();+ }+ Event::Leave { .. } => {+ ti.disable();+ ti.commit();+ state.preedit.clear();+ state.ime_preedit_pending.clear();+ state.ime_commit_pending.clear();+ state.needs_draw = true;+ }+ Event::PreeditString { text, .. } => {+ state.ime_preedit_pending = text.unwrap_or_default();+ }+ Event::CommitString { text } => {+ state.ime_commit_pending.push_str(&text.unwrap_or_default());+ }+ Event::Done { .. } => state.ime_done(ti),+ // We do not expose surrounding text, so nothing to delete.+ Event::DeleteSurroundingText { .. } => {}+ _ => {}+ }+ }+}++delegate_compositor!(App);+delegate_output!(App);+delegate_shm!(App);+delegate_seat!(App);+delegate_keyboard!(App);+delegate_pointer!(App);+delegate_xdg_shell!(App);+delegate_xdg_window!(App);+delegate_data_device!(App);+delegate_primary_selection!(App);+delegate_registry!(App);+delegate_activation!(App);@@ -0,0 +1,1696 @@+//! Wayland front-end: connection, surface, and software-drawn window.+//!+//! Uses smithay-client-toolkit for protocol boilerplate and calloop for the+//! event loop, so the PTY master fd and timers share one loop.++mod handlers;+mod rendering;++use std::fs::File;+use std::io::{Read as _, Write as _};+use std::num::NonZeroU16;+use std::os::fd::OwnedFd;+use std::os::unix::process::ExitStatusExt;+use std::process::ExitCode;++use std::time::Duration;++use anyhow::Context;+use calloop::generic::Generic;+use calloop::timer::{TimeoutAction, Timer};+use calloop::{EventLoop, Interest, LoopHandle, Mode, PostAction, RegistrationToken};+use calloop_wayland_source::WaylandSource;++use crate::config::Config;+use crate::font::Fonts;+use crate::grid::{Cell, CursorShape, Grid, MouseProtocol, UrlHit};+use crate::pty::Pty;+use crate::render::Renderer;+use crate::vt::Term;+use smithay_client_toolkit::reexports::protocols::wp::cursor_shape::v1::client::wp_cursor_shape_device_v1::{+ Shape, WpCursorShapeDeviceV1,+};+use smithay_client_toolkit::reexports::protocols::wp::primary_selection::zv1::client::{+ zwp_primary_selection_device_v1::ZwpPrimarySelectionDeviceV1,+ zwp_primary_selection_source_v1::ZwpPrimarySelectionSourceV1,+};+use smithay_client_toolkit::{+ activation::{ActivationHandler, ActivationState, RequestData},+ compositor::{CompositorHandler, CompositorState},+ data_device_manager::{+ DataDeviceManagerState, WritePipe,+ data_device::{DataDevice, DataDeviceHandler},+ data_offer::{DataOfferHandler, DragOffer},+ data_source::{CopyPasteSource, DataSourceHandler},+ },+ delegate_activation, delegate_compositor, delegate_data_device, delegate_keyboard,+ delegate_output,+ delegate_pointer, delegate_primary_selection, delegate_registry, delegate_seat, delegate_shm,+ delegate_xdg_shell, delegate_xdg_window,+ output::{OutputHandler, OutputState},+ primary_selection::{+ PrimarySelectionManagerState,+ device::{PrimarySelectionDevice, PrimarySelectionDeviceHandler},+ selection::{PrimarySelectionSource, PrimarySelectionSourceHandler},+ },+ registry::{ProvidesRegistryState, RegistryState},+ registry_handlers,+ seat::{+ Capability, SeatHandler, SeatState,+ keyboard::{KeyEvent, KeyboardHandler, Keysym, Modifiers, RawModifiers, RepeatInfo},+ pointer::{+ BTN_LEFT, BTN_MIDDLE, BTN_RIGHT, PointerEvent, PointerEventKind, PointerHandler,+ cursor_shape::CursorShapeManager,+ },+ },+ shell::{+ WaylandSurface,+ xdg::{+ XdgShell,+ window::{Window, WindowConfigure, WindowDecorations, WindowHandler},+ },+ },+ shm::{+ Shm, ShmHandler,+ slot::{Buffer, SlotPool},+ },+};+use wayland_client::{+ Connection, Dispatch, Proxy, QueueHandle,+ globals::{GlobalList, registry_queue_init},+ protocol::{+ wl_data_device::WlDataDevice, wl_data_device_manager::DndAction,+ wl_data_source::WlDataSource, wl_keyboard, wl_output, wl_pointer, wl_seat, wl_shm,+ wl_surface,+ },+};+use wayland_protocols::wp::fractional_scale::v1::client::{+ wp_fractional_scale_manager_v1::WpFractionalScaleManagerV1,+ wp_fractional_scale_v1::{self, WpFractionalScaleV1},+};+use wayland_protocols::wp::text_input::zv3::client::{+ zwp_text_input_manager_v3::ZwpTextInputManagerV3,+ zwp_text_input_v3::{self, ContentHint, ContentPurpose, ZwpTextInputV3},+};+use wayland_protocols::wp::viewporter::client::{+ wp_viewport::WpViewport, wp_viewporter::WpViewporter,+};++use handlers::{cursor_shape_from, hint_labels};+use rendering::FrameBuf;++/// MIME types beer offers and accepts for clipboard text.+const TEXT_MIMES: &[&str] = &[+ "text/plain;charset=utf-8",+ "text/plain;charset=UTF-8",+ "UTF8_STRING",+ "STRING",+ "text/plain",+ "TEXT",+];++/// Pick the first MIME type we understand from an offer's advertised set.+fn pick_mime(mimes: &[String]) -> Option<String> {+ mimes+ .iter()+ .find(|m| TEXT_MIMES.contains(&m.as_str()))+ .cloned()+}++/// Max gap between clicks counted as a multi-click (ms).+const MULTI_CLICK_MS: u32 = 400;++/// Blink half-period: cells/cursor toggle visibility this often.+const BLINK_MS: u64 = 500;++/// Buffers kept for double/triple buffering before we wait for a release.+const MAX_BUFFERS: usize = 3;++/// How long synchronized output (DECSET 2026) may hold the screen before we+/// present anyway, so a misbehaving app cannot freeze the window.+const SYNC_TIMEOUT_MS: u64 = 150;++/// Interval between autoscroll steps while a drag selection runs off an edge.+const AUTOSCROLL_MS: u64 = 40;++/// How long the visual bell inverts the screen.+const FLASH_MS: u64 = 80;++/// Fallback window size in pixels if the configured geometry yields nothing.+const DEFAULT_W: u32 = 800;+const DEFAULT_H: u32 = 600;++/// Run a single window until it is closed, returning the shell's exit code.+pub fn run(config: Config, config_path: Option<std::path::PathBuf>) -> anyhow::Result<ExitCode> {+ let conn = Connection::connect_to_env().context("connect to Wayland compositor")?;+ let (globals, event_queue) =+ registry_queue_init(&conn).context("initialize Wayland registry")?;+ let qh = event_queue.handle();++ let mut event_loop: EventLoop<App> =+ EventLoop::try_new().context("create calloop event loop")?;+ WaylandSource::new(conn, event_queue)+ .insert(event_loop.handle())+ .map_err(|e| anyhow::anyhow!("insert Wayland source into event loop: {e}"))?;++ let compositor = CompositorState::bind(&globals, &qh).context("compositor not available")?;+ let xdg_shell = XdgShell::bind(&globals, &qh).context("xdg_wm_base not available")?;+ let shm = Shm::bind(&globals, &qh).context("wl_shm not available")?;+ let data_device_manager = DataDeviceManagerState::bind(&globals, &qh)+ .context("wl_data_device_manager not available")?;+ let primary_manager = PrimarySelectionManagerState::bind(&globals, &qh).ok();+ let cursor_shape_manager = CursorShapeManager::bind(&globals, &qh).ok();++ let surface = compositor.create_surface(&qh);+ let window = xdg_shell.create_window(surface, WindowDecorations::RequestServer, &qh);+ window.set_title("beer");+ window.set_app_id("dev.notashelf.beer");+ window.set_min_size(Some((1, 1)));++ // Decorrelate buffer pixels from surface size so we can render at the+ // compositor's preferred fractional scale (crisp glyphs on a 150% output)+ // and present at the logical size. Both are optional; without them the+ // window falls back to integer buffer scaling via `scale_factor_changed`.+ let viewport = bind_global::<WpViewporter>(&globals, &qh)+ .map(|vp| vp.get_viewport(window.wl_surface(), &qh, ()));+ // Fractional scaling needs a viewport to present the scaled buffer back at+ // the logical size; without one we can only do integer buffer scaling.+ let fractional_scale = viewport.as_ref().and_then(|_| {+ bind_global::<WpFractionalScaleManagerV1>(&globals, &qh)+ .map(|mgr| mgr.get_fractional_scale(window.wl_surface(), &qh, ()))+ });+ let text_input_manager = bind_global::<ZwpTextInputManagerV3>(&globals, &qh);+ let activation = ActivationState::bind(&globals, &qh).ok();++ // First commit with no buffer kicks off the initial configure.+ window.commit();++ let fonts = Fonts::new(&config.main.font, config.main.font_size).context("load font")?;+ let mut renderer = Renderer::new(fonts);+ renderer.set_padding(config.main.pad_x, config.main.pad_y);++ // Start at the configured cell geometry plus padding; the compositor may+ // override it on the first configure.+ let m = renderer.metrics();+ let width = (u32::from(config.main.initial_cols) * m.width + 2 * config.main.pad_x).max(1);+ let height = (u32::from(config.main.initial_rows) * m.height + 2 * config.main.pad_y).max(1);+ let pool = SlotPool::new(+ (width * height * 4).max(DEFAULT_W * DEFAULT_H) as usize,+ &shm,+ )+ .context("create shm slot pool")?;++ let bindings =+ crate::bindings::Bindings::from_config(&config.key_bindings, &config.text_bindings);+ let font_size = config.main.font_size;++ let mut app = App {+ registry_state: RegistryState::new(&globals),+ output_state: OutputState::new(&globals, &qh),+ seat_state: SeatState::new(&globals, &qh),+ shm,+ pool,+ window,+ renderer,+ loop_handle: event_loop.handle(),+ qh: qh.clone(),+ data_device_manager,+ primary_manager,+ cursor_shape_manager,+ text_input_manager,+ activation,+ preedit: String::new(),+ ime_preedit_pending: String::new(),+ ime_commit_pending: String::new(),+ viewport,+ fractional_scale,+ scale120: 120,+ seats: Vec::new(),+ active_seat: 0,+ copy_source: None,+ primary_source: None,+ clipboard: String::new(),+ primary_clip: String::new(),+ selecting: false,+ hovered_link: None,+ pointer_enter_serial: 0,+ press_cell: None,+ pressed_button: None,+ last_report_cell: None,+ autoscroll: 0,+ autoscroll_timer: None,+ pointer_pos: (0.0, 0.0),+ last_click: None,+ serial: 0,+ modifiers: Modifiers::default(),+ // The PTY is spawned on the first configure, once the real window size+ // is known, so the shell starts at the final size and is not hit by a+ // startup SIGWINCH storm that makes it reprint its prompt.+ session: None,+ title: None,+ config,+ config_path,+ bindings,+ font_size,+ fullscreen: false,+ width,+ height,+ needs_draw: false,+ frame_pending: false,+ frames: Vec::new(),+ buf_dims: (0, 0),+ blink_on: true,+ sync_timeout: None,+ flashing: false,+ flash_timer: None,+ searching: false,+ url_mode: false,+ url_hits: Vec::new(),+ url_labels: Vec::new(),+ url_input: String::new(),+ focused: true,+ exit: false,+ exit_code: ExitCode::SUCCESS,+ };++ // Toggle the blink phase on a timer so blinking text and cursors animate.+ let blink = Timer::from_duration(Duration::from_millis(BLINK_MS));+ let blink_registered = event_loop+ .handle()+ .insert_source(blink, |_, _, app: &mut App| {+ app.blink_on = !app.blink_on;+ app.needs_draw = true;+ TimeoutAction::ToDuration(Duration::from_millis(BLINK_MS))+ });+ if let Err(err) = blink_registered {+ tracing::warn!("register blink timer: {err}");+ }++ // SIGUSR1 reloads the config in place.+ match calloop::signals::Signals::new(&[calloop::signals::Signal::SIGUSR1]) {+ Ok(signals) => {+ let registered = event_loop+ .handle()+ .insert_source(signals, |_, _, app: &mut App| app.reload_config());+ if let Err(err) = registered {+ tracing::warn!("register signal source: {err}");+ }+ }+ Err(err) => tracing::warn!("install SIGUSR1 handler: {err}"),+ }++ // Each iteration blocks until an event (PTY output, input, configure, frame+ // callback, blink) arrives, then presents at most one frame; bursts of PTY+ // output between frame callbacks coalesce into a single repaint.+ while !app.exit {+ event_loop+ .dispatch(None, &mut app)+ .context("dispatch event loop")?;+ app.flush();+ }+ Ok(app.exit_code)+}++/// Bind a singleton global at version 1 with `()` user-data, or `None` if the+/// compositor does not advertise it.+fn bind_global<I>(globals: &GlobalList, qh: &QueueHandle<App>) -> Option<I>+where+ I: Proxy + 'static,+ App: Dispatch<I, ()>,+{+ match globals.bind(qh, 1..=1, ()) {+ Ok(global) => Some(global),+ Err(err) => {+ tracing::debug!("bind {}: {err}", I::interface().name);+ None+ }+ }+}++/// Columns and rows that fit a `width`×`height` px window at `metrics`, after+/// reserving `2 * pad` pixels of inner padding on each axis.+fn grid_size(+ metrics: crate::font::CellMetrics,+ width: u32,+ height: u32,+ pad: (u32, u32),+) -> (u16, u16) {+ let cols = (width.saturating_sub(2 * pad.0) / metrics.width).max(1);+ let rows = (height.saturating_sub(2 * pad.1) / metrics.height).max(1);+ (cols as u16, rows as u16)+}++/// Write every byte to `fd`, retrying short writes and interrupts.+fn write_all(fd: &OwnedFd, mut buf: &[u8]) -> rustix::io::Result<()> {+ while !buf.is_empty() {+ match rustix::io::write(fd, buf) {+ Ok(0) => return Err(rustix::io::Errno::IO),+ Ok(n) => buf = &buf[n..],+ Err(rustix::io::Errno::INTR) => {}+ Err(e) => return Err(e),+ }+ }+ Ok(())+}++/// The per-window terminal: the PTY and the parsed screen behind it. Created on+/// the first configure, once the real size is known.+#[derive(Debug)]+struct Session {+ pty: Pty,+ term: Term,+}++/// Input devices for one seat. Several seats can drive the single window; the+/// most recently used one owns clipboard/primary claims.+#[derive(Debug)]+struct SeatData {+ seat: wl_seat::WlSeat,+ keyboard: Option<wl_keyboard::WlKeyboard>,+ pointer: Option<wl_pointer::WlPointer>,+ cursor_shape_device: Option<WpCursorShapeDeviceV1>,+ data_device: Option<DataDevice>,+ primary_device: Option<PrimarySelectionDevice>,+ /// text-input-v3 handle for IME preedit/commit, if the compositor offers it.+ text_input: Option<ZwpTextInputV3>,+}++/// Window + Wayland client state shared across all protocol handlers.+#[derive(Debug)]+struct App {+ registry_state: RegistryState,+ output_state: OutputState,+ seat_state: SeatState,+ shm: Shm,+ pool: SlotPool,+ window: Window,+ renderer: Renderer,+ loop_handle: LoopHandle<'static, App>,+ qh: QueueHandle<App>,+ data_device_manager: DataDeviceManagerState,+ primary_manager: Option<PrimarySelectionManagerState>,+ /// Sets the pointer to an I-beam over the window (cursor-shape-v1).+ cursor_shape_manager: Option<CursorShapeManager>,+ /// IME manager (text-input-v3); per-seat handles live in `seats`.+ text_input_manager: Option<ZwpTextInputManagerV3>,+ /// xdg-activation, used to request attention on an urgent bell.+ activation: Option<ActivationState>,+ /// Committed IME preedit string shown inline at the cursor while composing.+ preedit: String,+ /// Preedit/commit accumulated since the last text-input `done`.+ ime_preedit_pending: String,+ ime_commit_pending: String,+ /// Presents a scaled buffer at the logical surface size (viewporter).+ viewport: Option<WpViewport>,+ /// Per-surface fractional-scale object; kept alive to receive scale events.+ fractional_scale: Option<WpFractionalScaleV1>,+ /// Compositor's preferred scale in 120ths (120 = 1.0, 180 = 1.5).+ scale120: u32,+ /// One entry per seat; `active_seat` indexes the most recently used.+ seats: Vec<SeatData>,+ active_seat: usize,+ /// Held while we own the clipboard / primary selection, serving paste reads.+ copy_source: Option<CopyPasteSource>,+ primary_source: Option<PrimarySelectionSource>,+ clipboard: String,+ primary_clip: String,+ /// A left-button drag is in progress.+ selecting: bool,+ /// OSC 8 hyperlink under the pointer, underlined and opened on click.+ hovered_link: Option<NonZeroU16>,+ /// Serial of the last pointer enter, reused to update the cursor shape.+ pointer_enter_serial: u32,+ /// Cell `(abs_row, col)` of the last left-press, for click-to-open links.+ press_cell: Option<(usize, usize)>,+ /// Button base code held down while mouse reporting, for drag reports.+ pressed_button: Option<u8>,+ /// Last cell a motion report was emitted for, to suppress duplicates.+ last_report_cell: Option<(usize, usize)>,+ /// Autoscroll direction while dragging past an edge: +1 back, -1 toward live.+ autoscroll: isize,+ /// Calloop token for the repeating autoscroll timer, when armed.+ autoscroll_timer: Option<RegistrationToken>,+ pointer_pos: (f64, f64),+ /// Last click (time ms, abs row, col, count) for double/triple detection.+ last_click: Option<(u32, usize, usize, u32)>,+ /// Most recent input serial, used to claim selections.+ serial: u32,+ modifiers: Modifiers,+ /// `None` until the first configure spawns the shell.+ session: Option<Session>,+ /// Last title applied to the toplevel, to avoid redundant requests.+ title: Option<String>,+ /// The active user configuration.+ config: Config,+ /// Path the config was loaded from, for SIGUSR1 live reload.+ config_path: Option<std::path::PathBuf>,+ /// Resolved key/text bindings.+ bindings: crate::bindings::Bindings,+ /// Current font size in pixels (changed by font-resize bindings).+ font_size: u32,+ /// Whether the toplevel is fullscreen.+ fullscreen: bool,+ width: u32,+ height: u32,+ /// The grid changed and the window wants repainting on the next frame.+ needs_draw: bool,+ /// A `wl_surface.frame` callback is in flight; defer drawing until it fires.+ frame_pending: bool,+ /// Double/triple-buffer ring, each tagged with the rows it currently shows.+ frames: Vec<FrameBuf>,+ /// Pixel size the `frames` buffers were allocated for.+ buf_dims: (u32, u32),+ /// Current blink phase, toggled by a timer; off hides blinking ink.+ blink_on: bool,+ /// Armed while synchronized output holds the screen, to force it open.+ sync_timeout: Option<RegistrationToken>,+ /// The visual bell is inverting the screen.+ flashing: bool,+ /// Timer that ends the visual-bell flash.+ flash_timer: Option<RegistrationToken>,+ /// Whether incremental search mode is active (the query lives in the grid).+ searching: bool,+ /// URL hint mode: detected URLs get keyboard labels to open them.+ url_mode: bool,+ /// Detected URLs and their hint labels (parallel), while `url_mode` is on.+ url_hits: Vec<UrlHit>,+ url_labels: Vec<String>,+ /// Label characters typed so far in URL mode.+ url_input: String,+ /// Whether the toplevel currently has keyboard focus (drives the cursor).+ focused: bool,+ exit: bool,+ /// Exit code to return, taken from the shell when it exits.+ exit_code: ExitCode,+}++impl App {+ /// Spawn the shell at the current window size and start reading its output.+ fn spawn_session(&mut self) {+ let (cols, rows) = self.grid_dims();+ let pty = match Pty::spawn(cols, rows, &self.config.main.term) {+ Ok(pty) => pty,+ Err(err) => {+ tracing::error!("spawn shell: {err:#}");+ self.exit = true;+ return;+ }+ };+ let read_fd = match pty.master().try_clone() {+ Ok(fd) => fd,+ Err(err) => {+ tracing::error!("clone pty master: {err}");+ self.exit = true;+ return;+ }+ };++ let mut parser = vte::Parser::new();+ let source = Generic::new(read_fd, Interest::READ, Mode::Level);+ let registered = self+ .loop_handle+ .insert_source(source, move |_, fd, app: &mut App| {+ let mut buf = [0u8; 4096];+ let n = match rustix::io::read(&*fd, &mut buf) {+ Ok(0) => {+ app.child_exited();+ return Ok(PostAction::Remove);+ }+ Ok(n) => n,+ Err(rustix::io::Errno::INTR | rustix::io::Errno::AGAIN) => {+ return Ok(PostAction::Continue);+ }+ Err(_) => {+ app.child_exited();+ return Ok(PostAction::Remove);+ }+ };+ if let Some(session) = app.session.as_mut() {+ parser.advance(&mut session.term, &buf[..n]);+ }+ app.after_feed();+ Ok(PostAction::Continue)+ });+ if let Err(err) = registered {+ tracing::error!("register pty in event loop: {err}");+ self.exit = true;+ return;+ }++ let mut term = Term::new(cols as usize, rows as usize);+ term.set_theme(crate::theme::Theme::from_config(&self.config.colors));+ let grid = term.grid_mut();+ grid.set_word_delimiters(self.config.main.word_delimiters.clone());+ grid.set_scrollback_cap(self.config.scrollback.lines);+ if let Some(shape) = cursor_shape_from(self.config.cursor.style.as_deref()) {+ grid.set_cursor_shape(shape);+ }+ grid.set_cursor_blink(self.config.cursor.blink);+ self.session = Some(Session { pty, term });+ }++ /// Handle a key (initial press or repeat): configured bindings first, then+ /// text bindings, else the byte encoding sent to the shell (which snaps the+ /// viewport back to the live screen).+ fn handle_key(&mut self, event: &KeyEvent) {+ // URL hint mode and search both capture the keyboard while active.+ if self.url_mode {+ self.url_key(event);+ return;+ }+ if self.searching {+ self.search_key(event);+ return;+ }+ if let Some(action) = self.bindings.action(event, self.modifiers) {+ self.dispatch_action(action);+ return;+ }+ if let Some(text) = self.bindings.text(event, self.modifiers) {+ let bytes = text.to_vec();+ self.send_to_shell(&bytes);+ return;+ }++ let app_cursor = self+ .session+ .as_ref()+ .is_some_and(|s| s.term.grid().app_cursor());+ if let Some(bytes) = crate::input::encode(event, self.modifiers, app_cursor) {+ self.send_to_shell(&bytes);+ }+ }++ /// Write key/text bytes to the shell, snapping the viewport to the live+ /// screen and clearing any selection first.+ fn send_to_shell(&mut self, bytes: &[u8]) {+ if let Some(session) = self.session.as_mut() {+ session.term.scroll_to_bottom();+ session.term.grid_mut().clear_selection();+ self.needs_draw = true;+ if let Err(err) = write_all(session.pty.master(), bytes) {+ tracing::warn!("write key to pty: {err}");+ }+ }+ }++ /// Run a bound editor action.+ fn dispatch_action(&mut self, action: crate::bindings::Action) {+ use crate::bindings::Action;+ match action {+ Action::Copy => {+ let qh = self.qh.clone();+ self.set_clipboard(&qh);+ }+ Action::Paste => self.paste_clipboard(),+ Action::PastePrimary => self.paste_primary(),+ Action::ScrollPageUp => self.scroll_page(true),+ Action::ScrollPageDown => self.scroll_page(false),+ Action::ScrollTop => {+ if let Some(session) = self.session.as_mut() {+ session.term.scroll_view(isize::MAX);+ self.needs_draw = true;+ }+ }+ Action::ScrollBottom => {+ if let Some(session) = self.session.as_mut() {+ session.term.scroll_to_bottom();+ self.needs_draw = true;+ }+ }+ Action::SearchStart => self.toggle_search(),+ Action::FontIncrease => self.change_font_size(self.font_size + 1),+ Action::FontDecrease => self.change_font_size(self.font_size.saturating_sub(1)),+ Action::FontReset => self.change_font_size(self.config.main.font_size),+ Action::Fullscreen => self.toggle_fullscreen(),+ Action::NewWindow => self.spawn_new_window(),+ Action::JumpPromptUp => self.jump_prompt(true),+ Action::JumpPromptDown => self.jump_prompt(false),+ Action::PipeCommandOutput => self.pipe_command_output(),+ Action::UrlMode => self.enter_url_mode(),+ }+ }++ /// Enter URL hint mode: detect the visible URLs and label them. No-op (with+ /// a brief log) when there are none.+ fn enter_url_mode(&mut self) {+ let Some(session) = self.session.as_ref() else {+ return;+ };+ let hits = session.term.grid().visible_urls();+ if hits.is_empty() {+ return;+ }+ self.url_labels = hint_labels(hits.len());+ self.url_hits = hits;+ self.url_input = String::new();+ self.url_mode = true;+ self.needs_draw = true;+ }++ /// Leave URL hint mode, discarding any partial label input.+ fn exit_url_mode(&mut self) {+ self.url_mode = false;+ self.url_hits.clear();+ self.url_labels.clear();+ self.url_input.clear();+ // Drop the labelled buffers so the next present repaints without labels.+ self.frames.clear();+ self.needs_draw = true;+ }++ /// Handle a key while URL hint mode is active: build up a label, open the+ /// matching URL, or cancel.+ fn url_key(&mut self, event: &KeyEvent) {+ match event.keysym {+ Keysym::Escape => self.exit_url_mode(),+ Keysym::BackSpace => {+ self.url_input.pop();+ self.needs_draw = true;+ }+ _ => {+ let Some(text) = event.utf8.as_ref() else {+ return;+ };+ for c in text.chars().filter(|c| c.is_ascii_alphabetic()) {+ self.url_input.push(c.to_ascii_lowercase());+ }+ // Exact match opens; if no label even has this prefix, cancel.+ if let Some(i) = self.url_labels.iter().position(|l| *l == self.url_input) {+ let url = self.url_hits[i].url.clone();+ self.exit_url_mode();+ self.open_url(&url);+ } else if !self+ .url_labels+ .iter()+ .any(|l| l.starts_with(&self.url_input))+ {+ self.exit_url_mode();+ } else {+ self.needs_draw = true;+ }+ }+ }+ }++ /// Scroll the viewport to the previous/next shell prompt (OSC 133).+ fn jump_prompt(&mut self, up: bool) {+ if let Some(session) = self.session.as_mut() {+ session.term.grid_mut().jump_prompt(up);+ self.needs_draw = true;+ }+ }++ /// Feed the last command's output (between OSC 133 C and D) to the configured+ /// command on stdin.+ fn pipe_command_output(&mut self) {+ let argv = &self.config.shell_integration.pipe_command;+ let Some((program, args)) = argv.split_first() else {+ tracing::warn!("pipe-command-output: no [shell-integration] pipe-command configured");+ return;+ };+ let Some(text) = self+ .session+ .as_ref()+ .and_then(|s| s.term.grid().last_command_output())+ else {+ return;+ };+ let mut cmd = std::process::Command::new(program);+ cmd.args(args)+ .stdin(std::process::Stdio::piped())+ .stdout(std::process::Stdio::null())+ .stderr(std::process::Stdio::null());+ if let Some(cwd) = self.session.as_ref().and_then(|s| s.term.cwd()) {+ cmd.current_dir(cwd);+ }+ match cmd.spawn() {+ Ok(mut child) => {+ if let Some(mut stdin) = child.stdin.take() {+ let _ = stdin.write_all(text.as_bytes());+ }+ }+ Err(err) => tracing::warn!("pipe-command-output: spawn failed: {err}"),+ }+ }++ /// Launch another beer process in the shell's reported working directory+ /// (OSC 7), inheriting the same config. The child is fully detached.+ fn spawn_new_window(&mut self) {+ let exe = match std::env::current_exe() {+ Ok(exe) => exe,+ Err(err) => {+ tracing::warn!("locate beer executable: {err}");+ return;+ }+ };+ let mut cmd = std::process::Command::new(exe);+ if let Some(path) = self.config_path.as_ref() {+ cmd.arg("--config").arg(path);+ }+ if let Some(cwd) = self.session.as_ref().and_then(|s| s.term.cwd()) {+ cmd.current_dir(cwd);+ }+ cmd.stdin(std::process::Stdio::null())+ .stdout(std::process::Stdio::null())+ .stderr(std::process::Stdio::null());+ if let Err(err) = cmd.spawn() {+ tracing::warn!("spawn new window: {err}");+ }+ }++ /// Send `count` cursor-up/down keys to the shell for alternate-scroll,+ /// honouring the application cursor-key mode (DECCKM).+ fn alternate_scroll(&mut self, up: bool, count: isize) {+ let app_cursor = self+ .session+ .as_ref()+ .is_some_and(|s| s.term.grid().app_cursor());+ let seq: &[u8] = match (up, app_cursor) {+ (true, false) => b"\x1b[A",+ (true, true) => b"\x1bOA",+ (false, false) => b"\x1b[B",+ (false, true) => b"\x1bOB",+ };+ for _ in 0..count {+ self.write_to_pty(seq);+ }+ }++ /// Scroll the viewport one page back (`up`) or toward the live screen.+ fn scroll_page(&mut self, up: bool) {+ if let Some(session) = self.session.as_mut() {+ let page = session.term.page() as isize;+ session.term.scroll_view(if up { page } else { -page });+ self.needs_draw = true;+ }+ }++ /// Toggle the toplevel between fullscreen and windowed.+ fn toggle_fullscreen(&mut self) {+ self.fullscreen = !self.fullscreen;+ if self.fullscreen {+ self.window.set_fullscreen(None);+ } else {+ self.window.unset_fullscreen();+ }+ }++ /// Re-read the config file and apply it in place (SIGUSR1).+ fn reload_config(&mut self) {+ let new = Config::load(self.config_path.as_deref());+ self.bindings =+ crate::bindings::Bindings::from_config(&new.key_bindings, &new.text_bindings);+ let font_changed = new.main.font != self.config.main.font+ || new.main.font_size != self.config.main.font_size;+ if font_changed {+ self.font_size = new.main.font_size;+ }+ self.config.main.font = new.main.font.clone();+ self.config.main.font_size = new.main.font_size;+ self.config.main.pad_x = new.main.pad_x;+ self.config.main.pad_y = new.main.pad_y;+ // Re-rasterize at the active scale and update padding in one place.+ self.rescale_render();+ if let Some(session) = self.session.as_mut() {+ session+ .term+ .set_theme(crate::theme::Theme::from_config(&new.colors));+ let grid = session.term.grid_mut();+ grid.set_word_delimiters(new.main.word_delimiters.clone());+ grid.set_scrollback_cap(new.scrollback.lines);+ if let Some(shape) = cursor_shape_from(new.cursor.style.as_deref()) {+ grid.set_cursor_shape(shape);+ }+ grid.set_cursor_blink(new.cursor.blink);+ }+ self.config = new;+ self.frames.clear();+ self.resize_grid();+ self.needs_draw = true;+ tracing::info!("config reloaded");+ }++ /// Re-rasterize the font at `new_size`, then re-derive the grid geometry.+ fn change_font_size(&mut self, new_size: u32) {+ let new_size = new_size.clamp(6, 200);+ if new_size == self.font_size {+ return;+ }+ self.font_size = new_size;+ self.rescale_render();+ self.frames.clear();+ self.resize_grid();+ self.needs_draw = true;+ }++ /// Enter or leave incremental search mode.+ fn toggle_search(&mut self) {+ self.searching = !self.searching;+ if let Some(session) = self.session.as_mut() {+ if self.searching {+ session.term.grid_mut().set_search("");+ } else {+ session.term.grid_mut().clear_search();+ }+ }+ self.needs_draw = true;+ }++ /// Handle a key while search mode is active: edit the query incrementally,+ /// step between matches, or exit.+ fn search_key(&mut self, event: &KeyEvent) {+ let Some(session) = self.session.as_mut() else {+ return;+ };+ let grid = session.term.grid_mut();+ match event.keysym {+ Keysym::Escape => {+ grid.clear_search();+ self.searching = false;+ }+ Keysym::Return | Keysym::KP_Enter | Keysym::Up | Keysym::Page_Up => {+ grid.search_step(false);+ }+ Keysym::Down | Keysym::Page_Down => grid.search_step(true),+ Keysym::BackSpace => {+ let mut query = grid.search_query().unwrap_or("").to_string();+ query.pop();+ grid.set_search(&query);+ }+ _ => {+ // Append typed text, ignoring control characters.+ if let Some(text) = event.utf8.as_ref() {+ let printable: String = text.chars().filter(|c| !c.is_control()).collect();+ if !printable.is_empty() {+ let mut query = grid.search_query().unwrap_or("").to_string();+ query.push_str(&printable);+ grid.set_search(&query);+ }+ }+ }+ }+ self.needs_draw = true;+ }++ /// The OSC 8 hyperlink id under the pointer, if any.+ fn link_under_pointer(&self) -> Option<NonZeroU16> {+ let (row, col) = self.cell_at(self.pointer_pos.0, self.pointer_pos.1)?;+ self.session.as_ref()?.term.grid().link_at(row, col)+ }++ /// Recompute the hyperlink under the pointer; when it changes, repaint to+ /// move the hover underline and update the pointer to a hand over a link.+ fn update_hover(&mut self, pointer: &wl_pointer::WlPointer) {+ let link = self.link_under_pointer();+ if link == self.hovered_link {+ return;+ }+ self.hovered_link = link;+ // The hover underline lives in every buffer's snapshot; drop the ring so+ // the affected rows repaint with (or without) it.+ self.frames.clear();+ self.needs_draw = true;+ let shape = if link.is_some() {+ Shape::Pointer+ } else {+ Shape::Text+ };+ if let Some(device) = self+ .seats+ .iter()+ .find(|s| s.pointer.as_ref() == Some(pointer))+ .and_then(|s| s.cursor_shape_device.as_ref())+ {+ device.set_shape(self.pointer_enter_serial, shape);+ }+ }++ /// If the left button was pressed and released on the same hyperlinked cell+ /// (a click, not a drag), open the link.+ fn maybe_open_clicked_link(&mut self) {+ let release = self.cell_at(self.pointer_pos.0, self.pointer_pos.1);+ let Some((row, col)) = release else { return };+ if self.press_cell != Some((row, col)) {+ return;+ }+ let uri = self+ .session+ .as_ref()+ .and_then(|s| s.term.grid().link_at(row, col).map(|id| (s, id)))+ .and_then(|(s, id)| s.term.grid().link_uri(id))+ .map(str::to_owned);+ if let Some(uri) = uri {+ self.open_url(&uri);+ }+ }++ /// Launch the configured opener (default `xdg-open`) on a URL.+ fn open_url(&self, url: &str) {+ let Some((program, args)) = self.config.url.launch.split_first() else {+ tracing::warn!("open url: no [url] launch command configured");+ return;+ };+ let mut cmd = std::process::Command::new(program);+ cmd.args(args)+ .arg(url)+ .stdin(std::process::Stdio::null())+ .stdout(std::process::Stdio::null())+ .stderr(std::process::Stdio::null());+ if let Err(err) = cmd.spawn() {+ tracing::warn!("open url {url:?}: {err}");+ }+ }++ /// Scale a logical pixel length to physical (buffer) pixels at the current+ /// fractional scale, rounding to nearest.+ fn to_phys(&self, v: u32) -> u32 {+ ((u64::from(v) * u64::from(self.scale120) + 60) / 120) as u32+ }++ /// Physical (buffer) pixel size at the current scale.+ fn phys_dims(&self) -> (u32, u32) {+ (+ self.to_phys(self.width).max(1),+ self.to_phys(self.height).max(1),+ )+ }++ /// Columns and rows for the current physical size, metrics, and padding.+ /// Scale-invariant: every term scales together so the cell count is stable.+ fn grid_dims(&self) -> (u16, u16) {+ let (pw, ph) = self.phys_dims();+ grid_size(+ self.renderer.metrics(),+ pw,+ ph,+ (+ self.to_phys(self.config.main.pad_x),+ self.to_phys(self.config.main.pad_y),+ ),+ )+ }++ /// Re-rasterize the font and padding at the current scale × the logical+ /// font size, so glyphs are crisp at fractional scales.+ fn rescale_render(&mut self) {+ self.renderer.set_padding(+ self.to_phys(self.config.main.pad_x),+ self.to_phys(self.config.main.pad_y),+ );+ let px = self.to_phys(self.font_size).max(1);+ if let Err(err) = self.renderer.set_font(&self.config.main.font, px) {+ tracing::warn!("rasterize font at scale {}: {err:#}", self.scale120);+ }+ }++ /// Adopt a new preferred scale (in 120ths): re-rasterize, re-derive the grid+ /// geometry, and update the viewport so the logical size stays put.+ fn set_scale(&mut self, scale120: u32) {+ let scale120 = scale120.max(1);+ if scale120 == self.scale120 {+ return;+ }+ self.scale120 = scale120;+ self.rescale_render();+ self.frames.clear();+ self.buf_dims = (0, 0);+ if let Some(vp) = &self.viewport {+ vp.set_destination(self.width.max(1) as i32, self.height.max(1) as i32);+ }+ self.resize_grid();+ self.needs_draw = true;+ }++ /// Inner padding `(x, y)` in physical pixels (pointer coords are converted+ /// to physical before use).+ fn padding(&self) -> (f64, f64) {+ (+ f64::from(self.to_phys(self.config.main.pad_x)),+ f64::from(self.to_phys(self.config.main.pad_y)),+ )+ }++ /// Convert a logical surface coordinate to a physical buffer coordinate.+ fn to_phys_f(&self, v: f64) -> f64 {+ v * f64::from(self.scale120) / 120.0+ }++ /// The active seat (the one that most recently produced input).+ fn active(&self) -> Option<&SeatData> {+ self.seats.get(self.active_seat)+ }++ /// The active seat's clipboard device, if any.+ fn data_device(&self) -> Option<&DataDevice> {+ self.active().and_then(|s| s.data_device.as_ref())+ }++ /// The active seat's primary-selection device, if any.+ fn primary_device(&self) -> Option<&PrimarySelectionDevice> {+ self.active().and_then(|s| s.primary_device.as_ref())+ }++ /// Find (or create) the per-seat entry for `seat`, returning its index.+ fn seat_index(&mut self, seat: &wl_seat::WlSeat) -> usize {+ if let Some(i) = self.seats.iter().position(|s| &s.seat == seat) {+ return i;+ }+ self.seats.push(SeatData {+ seat: seat.clone(),+ keyboard: None,+ pointer: None,+ cursor_shape_device: None,+ data_device: None,+ primary_device: None,+ text_input: None,+ });+ self.seats.len() - 1+ }++ /// Mark the seat owning `keyboard` as active for clipboard ownership.+ fn activate_keyboard(&mut self, keyboard: &wl_keyboard::WlKeyboard) {+ if let Some(i) = self+ .seats+ .iter()+ .position(|s| s.keyboard.as_ref() == Some(keyboard))+ {+ self.active_seat = i;+ }+ }++ /// Mark the seat owning `pointer` as active for clipboard ownership.+ fn activate_pointer(&mut self, pointer: &wl_pointer::WlPointer) {+ if let Some(i) = self+ .seats+ .iter()+ .position(|s| s.pointer.as_ref() == Some(pointer))+ {+ self.active_seat = i;+ }+ }++ /// Map window pixel coordinates to an absolute `(row, col)` grid point.+ fn cell_at(&self, px: f64, py: f64) -> Option<(usize, usize)> {+ let session = self.session.as_ref()?;+ let m = self.renderer.metrics();+ let (pad_x, pad_y) = self.padding();+ let (px, py) = (self.to_phys_f(px), self.to_phys_f(py));+ let grid = session.term.grid();+ let col =+ ((px - pad_x).max(0.0) as usize / m.width as usize).min(grid.cols().saturating_sub(1));+ let vrow =+ ((py - pad_y).max(0.0) as usize / m.height as usize).min(grid.rows().saturating_sub(1));+ Some((grid.view_to_abs(vrow), col))+ }++ /// Left-button press: start (or word/line-extend) a selection.+ fn pointer_press(&mut self, time: u32) {+ let Some((row, col)) = self.cell_at(self.pointer_pos.0, self.pointer_pos.1) else {+ return;+ };+ let count = match self.last_click {+ Some((t, r, c, n))+ if time.wrapping_sub(t) <= MULTI_CLICK_MS && r == row && c == col =>+ {+ n % 3 + 1+ }+ _ => 1,+ };+ self.last_click = Some((time, row, col, count));+ let Some(session) = self.session.as_mut() else {+ return;+ };+ let ctrl = self.modifiers.ctrl;+ let grid = session.term.grid_mut();+ match count {+ 2 => grid.select_word(row, col),+ 3 => grid.select_line(row),+ // Holding Ctrl starts a rectangular (block) selection.+ _ if ctrl => grid.start_block_selection(row, col),+ _ => grid.start_selection(row, col),+ }+ self.selecting = true;+ self.needs_draw = true;+ }++ /// Pointer motion during a drag: extend the selection head and, if the+ /// pointer has left the top/bottom edge, start autoscrolling.+ fn pointer_drag(&mut self) {+ if !self.selecting {+ return;+ }+ let Some((row, col)) = self.cell_at(self.pointer_pos.0, self.pointer_pos.1) else {+ return;+ };+ if let Some(session) = self.session.as_mut() {+ session.term.grid_mut().extend_selection(row, col);+ self.needs_draw = true;+ }+ self.update_autoscroll();+ }++ /// Arm or disarm edge autoscroll based on the pointer's vertical position.+ fn update_autoscroll(&mut self) {+ let dir = if self.pointer_pos.1 < 0.0 {+ 1 // above the top: reveal older lines+ } else if self.pointer_pos.1 >= f64::from(self.height) {+ -1 // below the bottom: advance toward the live screen+ } else {+ 0+ };+ self.autoscroll = dir;+ if dir != 0 && self.autoscroll_timer.is_none() {+ let timer = Timer::immediate();+ self.autoscroll_timer = self+ .loop_handle+ .insert_source(timer, |_, _, app: &mut App| app.autoscroll_step())+ .ok();+ }+ }++ /// One autoscroll step: scroll the viewport and drag the selection head to+ /// the edge cell under the pointer. Reschedules until the drag ends or the+ /// pointer returns inside the window.+ fn autoscroll_step(&mut self) -> TimeoutAction {+ if !self.selecting || self.autoscroll == 0 {+ self.autoscroll_timer = None;+ return TimeoutAction::Drop;+ }+ if let Some(session) = self.session.as_mut() {+ session.term.scroll_view(self.autoscroll);+ }+ let edge_y = if self.autoscroll > 0 {+ 0.0+ } else {+ f64::from(self.height) - 1.0+ };+ if let Some((row, col)) = self.cell_at(self.pointer_pos.0, edge_y)+ && let Some(session) = self.session.as_mut()+ {+ session.term.grid_mut().extend_selection(row, col);+ }+ self.needs_draw = true;+ TimeoutAction::ToDuration(Duration::from_millis(AUTOSCROLL_MS))+ }++ /// Left-button release: stop autoscrolling and publish the primary selection.+ fn pointer_release(&mut self, qh: &QueueHandle<App>) {+ if !self.selecting {+ return;+ }+ self.selecting = false;+ self.autoscroll = 0;+ if let Some(token) = self.autoscroll_timer.take() {+ self.loop_handle.remove(token);+ }+ self.set_primary(qh);+ }++ /// Whether the application wants mouse reports and the user is not holding+ /// Shift (which forces local selection regardless of mode).+ fn mouse_reporting(&self) -> bool {+ self.session+ .as_ref()+ .is_some_and(|s| s.term.grid().mouse_protocol() != MouseProtocol::Off)+ && !self.modifiers.shift+ }++ /// The viewport cell `(col, row)` under the pointer, clamped to the screen.+ fn report_screen_cell(&self) -> Option<(usize, usize)> {+ let session = self.session.as_ref()?;+ let m = self.renderer.metrics();+ let (pad_x, pad_y) = self.padding();+ let (ppx, ppy) = (+ self.to_phys_f(self.pointer_pos.0),+ self.to_phys_f(self.pointer_pos.1),+ );+ let grid = session.term.grid();+ let col =+ ((ppx - pad_x).max(0.0) as usize / m.width as usize).min(grid.cols().saturating_sub(1));+ let row = ((ppy - pad_y).max(0.0) as usize / m.height as usize)+ .min(grid.rows().saturating_sub(1));+ Some((col, row))+ }++ /// Report a button press/release to the application, if reporting is active.+ /// Returns whether the event was consumed (so local handling is skipped).+ fn try_report_button(&mut self, code: u8, pressed: bool) -> bool {+ let Some(session) = self.session.as_ref() else {+ return false;+ };+ let grid = session.term.grid();+ let proto = grid.mouse_protocol();+ if proto == MouseProtocol::Off || self.modifiers.shift {+ return false;+ }+ let enc = grid.mouse_encoding();+ // X10 (mode 9) reports presses only; a release is swallowed, not sent.+ if (pressed || proto != MouseProtocol::X10)+ && let Some((col, row)) = self.report_screen_cell()+ {+ let bytes =+ crate::input::encode_mouse(enc, code, col, row, pressed, false, self.modifiers);+ self.write_to_pty(&bytes);+ self.last_report_cell = Some((col, row));+ }+ true+ }++ /// Report pointer motion to the application when the active mode wants it.+ /// Returns whether reporting consumed the motion (suppressing local drag).+ fn try_report_motion(&mut self) -> bool {+ let Some(session) = self.session.as_ref() else {+ return false;+ };+ let grid = session.term.grid();+ let proto = grid.mouse_protocol();+ if proto == MouseProtocol::Off || self.modifiers.shift {+ return false;+ }+ let enc = grid.mouse_encoding();+ let wants = match proto {+ MouseProtocol::Any => true,+ MouseProtocol::Button => self.pressed_button.is_some(),+ _ => false,+ };+ if wants+ && let Some((col, row)) = self.report_screen_cell()+ && self.last_report_cell != Some((col, row))+ {+ // Any-event motion with no button held uses the "no button" code 3.+ let code = self.pressed_button.unwrap_or(3);+ let bytes = crate::input::encode_mouse(enc, code, col, row, true, true, self.modifiers);+ self.write_to_pty(&bytes);+ self.last_report_cell = Some((col, row));+ }+ true+ }++ /// Send focus in/out (DECSET 1004) to the application when it asked for it.+ fn report_focus(&mut self, focused: bool) {+ if self+ .session+ .as_ref()+ .is_some_and(|s| s.term.grid().focus_events())+ {+ self.write_to_pty(if focused { b"\x1b[I" } else { b"\x1b[O" });+ }+ }++ /// Write bytes to the PTY master, logging on failure.+ fn write_to_pty(&mut self, bytes: &[u8]) {+ if let Some(session) = self.session.as_mut()+ && let Err(err) = write_all(session.pty.master(), bytes)+ {+ tracing::warn!("write to pty: {err}");+ }+ }++ /// The current selection text, if any and non-empty.+ fn selection_text(&self) -> Option<String> {+ let text = self.session.as_ref()?.term.grid().selection_text()?;+ (!text.is_empty()).then_some(text)+ }++ /// Take ownership of the CLIPBOARD selection, serving `text` to pasters.+ fn claim_clipboard(&mut self, text: String, qh: &QueueHandle<App>) {+ let Some(device) = self.data_device() else {+ return;+ };+ let source = self+ .data_device_manager+ .create_copy_paste_source(qh, TEXT_MIMES.iter().copied());+ source.set_selection(device, self.serial);+ self.clipboard = text;+ self.copy_source = Some(source);+ }++ /// Take ownership of the primary selection, serving `text` to pasters.+ fn claim_primary(&mut self, text: String, qh: &QueueHandle<App>) {+ let (Some(manager), Some(device)) = (self.primary_manager.as_ref(), self.primary_device())+ else {+ return;+ };+ let source = manager.create_selection_source(qh, TEXT_MIMES.iter().copied());+ source.set_selection(device, self.serial);+ self.primary_clip = text;+ self.primary_source = Some(source);+ }++ /// Claim the clipboard (CLIPBOARD) with the current selection (Ctrl+Shift+C).+ fn set_clipboard(&mut self, qh: &QueueHandle<App>) {+ if let Some(text) = self.selection_text() {+ self.claim_clipboard(text, qh);+ }+ }++ /// Claim the primary selection with the current selection (select-to-copy).+ fn set_primary(&mut self, qh: &QueueHandle<App>) {+ if let Some(text) = self.selection_text() {+ self.claim_primary(text, qh);+ }+ }++ /// Point the IME's candidate popup at the terminal cursor, in logical+ /// surface coordinates (the renderer works in physical pixels, so divide+ /// the physical cell rectangle back down by the scale).+ fn ime_set_cursor_rect(&self, ti: &ZwpTextInputV3) {+ let Some(session) = self.session.as_ref() else {+ return;+ };+ let (cx, cy) = session.term.grid().cursor();+ let m = self.renderer.metrics();+ let s = f64::from(self.scale120) / 120.0;+ let pad_x = f64::from(self.to_phys(self.config.main.pad_x));+ let pad_y = f64::from(self.to_phys(self.config.main.pad_y));+ let x = ((pad_x + cx as f64 * f64::from(m.width)) / s) as i32;+ let y = ((pad_y + cy as f64 * f64::from(m.height)) / s) as i32;+ let w = (f64::from(m.width) / s) as i32;+ let h = (f64::from(m.height) / s) as i32;+ ti.set_cursor_rectangle(x, y, w.max(1), h.max(1));+ }++ /// Apply one IME transaction: commit any committed text to the shell, adopt+ /// the new preedit, then re-commit our state (cursor rectangle) to the IME.+ fn ime_done(&mut self, ti: &ZwpTextInputV3) {+ let commit = std::mem::take(&mut self.ime_commit_pending);+ // Preedit is replaced wholesale each cycle; an absent preedit clears it.+ self.preedit = std::mem::take(&mut self.ime_preedit_pending);+ if !commit.is_empty() {+ self.send_to_shell(commit.as_bytes());+ }+ self.ime_set_cursor_rect(ti);+ ti.commit();+ self.needs_draw = true;+ }++ /// Act on the OSC 52 clipboard requests an application made: take ownership+ /// of the selection it set, or answer a query with what we currently hold.+ fn handle_clipboard_ops(&mut self, ops: Vec<crate::vt::ClipboardOp>) {+ use crate::vt::ClipboardOp;+ let qh = self.qh.clone();+ for op in ops {+ match op {+ ClipboardOp::Set {+ primary: true,+ text,+ } => self.claim_primary(text, &qh),+ ClipboardOp::Set {+ primary: false,+ text,+ } => self.claim_clipboard(text, &qh),+ ClipboardOp::Query { primary } => {+ let text = if primary {+ &self.primary_clip+ } else {+ &self.clipboard+ };+ let kind = if primary { 'p' } else { 'c' };+ let reply = format!(+ "\x1b]52;{kind};{}\x07",+ crate::vt::base64_encode(text.as_bytes())+ );+ self.write_to_pty(reply.as_bytes());+ }+ }+ }+ }++ /// Paste the CLIPBOARD selection into the shell (Ctrl+Shift+V).+ fn paste_clipboard(&mut self) {+ let Some(offer) = self.data_device().and_then(|d| d.data().selection_offer()) else {+ return;+ };+ let Some(mime) = offer.with_mime_types(pick_mime) else {+ return;+ };+ if let Ok(pipe) = offer.receive(mime) {+ self.read_paste(pipe);+ }+ }++ /// Paste the primary selection into the shell (middle click).+ fn paste_primary(&mut self) {+ let Some(offer) = self+ .primary_device()+ .and_then(|d| d.data().selection_offer())+ else {+ return;+ };+ let Some(mime) = offer.with_mime_types(pick_mime) else {+ return;+ };+ if let Ok(pipe) = offer.receive(mime) {+ self.read_paste(pipe);+ }+ }++ /// Drain a clipboard read-pipe on the event loop, writing the bytes to the+ /// PTY once the source closes its end.+ fn read_paste(&mut self, pipe: smithay_client_toolkit::data_device_manager::ReadPipe) {+ let mut data: Vec<u8> = Vec::new();+ let registered = self+ .loop_handle+ .insert_source(pipe, move |_, file, app: &mut App| {+ // SAFETY: the file is owned by the source and not closed while read.+ let f: &mut File = unsafe { file.get_mut() };+ let mut tmp = [0u8; 4096];+ match f.read(&mut tmp) {+ Ok(0) => {+ app.paste_bytes(&data);+ PostAction::Remove+ }+ Ok(n) => {+ data.extend_from_slice(&tmp[..n]);+ PostAction::Continue+ }+ Err(e) if matches!(e.kind(), std::io::ErrorKind::Interrupted) => {+ PostAction::Continue+ }+ Err(e) => {+ tracing::warn!("read paste pipe: {e}");+ PostAction::Remove+ }+ }+ });+ if let Err(err) = registered {+ tracing::warn!("register paste pipe: {err}");+ }+ }++ /// Write pasted bytes to the PTY, framing them for bracketed-paste mode and+ /// snapping the viewport to the live screen.+ fn paste_bytes(&mut self, data: &[u8]) {+ let Some(session) = self.session.as_mut() else {+ return;+ };+ session.term.scroll_to_bottom();+ self.needs_draw = true;+ let bracketed = session.term.grid().bracketed_paste();+ // Strip control bytes a terminal must never receive raw from a paste;+ // keep tab and newlines (CR is what the shell expects for Enter).+ let mut clean: Vec<u8> = Vec::with_capacity(data.len());+ for &b in data {+ match b {+ b'\n' => clean.push(b'\r'),+ b'\t' | b'\r' => clean.push(b),+ 0x20..=0xff => clean.push(b),+ _ => {}+ }+ }+ let fd = session.pty.master();+ if bracketed {+ let _ = write_all(fd, b"\x1b[200~");+ let _ = write_all(fd, &clean);+ let _ = write_all(fd, b"\x1b[201~");+ } else if let Err(err) = write_all(fd, &clean) {+ tracing::warn!("write paste to pty: {err}");+ }+ }++ /// After parsing child output: send any replies, sync the title, repaint.+ fn after_feed(&mut self) {+ let Some(session) = self.session.as_mut() else {+ return;+ };+ let reply = session.term.take_response();+ if !reply.is_empty()+ && let Err(err) = write_all(session.pty.master(), &reply)+ {+ tracing::warn!("write to pty: {err}");+ }++ let new_title = session.term.title().map(str::to_owned);+ if new_title.as_deref() != self.title.as_deref() {+ self.title = new_title;+ self.window+ .set_title(self.title.clone().unwrap_or_default());+ }+ let rang = session.term.take_bell();+ let ops = session.term.take_clipboard_ops();+ let notifications = session.term.take_notifications();+ if !ops.is_empty() {+ self.handle_clipboard_ops(ops);+ }+ for note in notifications {+ self.send_notification(¬e);+ }+ if rang {+ self.ring_bell();+ }+ self.needs_draw = true;+ }++ /// React to a `BEL`: optionally flash, run the configured bell command, and+ /// request the compositor's attention when unfocused.+ fn ring_bell(&mut self) {+ if self.config.bell.visual {+ self.start_flash();+ }+ if let Some((program, args)) = self.config.bell.command.split_first() {+ let _ = std::process::Command::new(program)+ .args(args)+ .stdin(std::process::Stdio::null())+ .stdout(std::process::Stdio::null())+ .stderr(std::process::Stdio::null())+ .spawn()+ .inspect_err(|err| tracing::warn!("bell command: {err}"));+ }+ if self.config.bell.urgent && !self.focused {+ self.request_attention();+ }+ }++ /// Deliver a desktop notification through the configured notifier (default+ /// `notify-send`), appending the title and body as the final arguments.+ fn send_notification(&self, note: &crate::vt::Notification) {+ let Some((program, args)) = self.config.notify.command.split_first() else {+ return;+ };+ let title = note+ .title+ .clone()+ .or_else(|| self.title.clone())+ .unwrap_or_else(|| "beer".to_string());+ let _ = std::process::Command::new(program)+ .args(args)+ .arg(title)+ .arg(¬e.body)+ .stdin(std::process::Stdio::null())+ .stdout(std::process::Stdio::null())+ .stderr(std::process::Stdio::null())+ .spawn()+ .inspect_err(|err| tracing::warn!("notify command: {err}"));+ }++ /// Ask the compositor to draw attention to the window (xdg-activation).+ fn request_attention(&mut self) {+ let Some(activation) = self.activation.as_ref() else {+ return;+ };+ let seat_and_serial = self+ .seats+ .get(self.active_seat)+ .map(|s| (s.seat.clone(), self.serial));+ let data = smithay_client_toolkit::activation::RequestData {+ app_id: Some("dev.notashelf.beer".to_string()),+ seat_and_serial,+ surface: Some(self.window.wl_surface().clone()),+ };+ activation.request_token::<App>(&self.qh, data);+ }++ /// Begin a visual-bell flash: invert the screen for a moment. Clearing the+ /// buffer ring forces a full repaint with the inverted theme.+ fn start_flash(&mut self) {+ self.flashing = true;+ self.frames.clear();+ self.needs_draw = true;+ if self.flash_timer.is_none() {+ let timer = Timer::from_duration(Duration::from_millis(FLASH_MS));+ self.flash_timer = self+ .loop_handle+ .insert_source(timer, |_, _, app: &mut App| {+ app.flashing = false;+ app.frames.clear();+ app.needs_draw = true;+ app.flash_timer = None;+ TimeoutAction::Drop+ })+ .ok();+ }+ }++ /// Recompute the grid size for the current window and tell the grid and the+ /// PTY about it if it changed.+ fn resize_grid(&mut self) {+ let (cols, rows) = self.grid_dims();+ let Some(session) = self.session.as_mut() else {+ return;+ };+ if (cols as usize, rows as usize)+ == (session.term.grid().cols(), session.term.grid().rows())+ {+ return;+ }+ session.term.resize(cols as usize, rows as usize);+ if let Err(err) = session.pty.resize(cols, rows) {+ tracing::warn!("resize pty: {err}");+ }+ }++ /// The child shell has gone away; reap it, capture its code, and tear the+ /// window down.+ fn child_exited(&mut self) {+ if let Some(session) = self.session.as_mut() {+ match session.pty.wait() {+ Ok(status) => {+ tracing::info!("shell exited: {status}");+ // Mirror the shell's status: its code, or 128+signal if killed.+ let code = status+ .code()+ .unwrap_or_else(|| 128 + status.signal().unwrap_or(0));+ self.exit_code = ExitCode::from(code as u8);+ }+ Err(err) => tracing::warn!("reap shell: {err}"),+ }+ }+ self.exit = true;+ }++ /// Present a frame if one is wanted and the compositor is ready for it.+ /// Called after every event-loop wake; the frame-callback gate keeps draws+ /// paced to the display instead of one per PTY read. While the app holds+ /// synchronized output (DECSET 2026) we withhold the frame, but arm a+ /// timeout so a stuck `2026h` cannot freeze the window.+ fn flush(&mut self) {+ let sync = self+ .session+ .as_ref()+ .is_some_and(|s| s.term.grid().sync_active());+ if sync {+ if self.sync_timeout.is_none() {+ let timer = Timer::from_duration(Duration::from_millis(SYNC_TIMEOUT_MS));+ self.sync_timeout = self+ .loop_handle+ .insert_source(timer, |_, _, app: &mut App| {+ if let Some(session) = app.session.as_mut() {+ session.term.grid_mut().set_sync(false);+ }+ app.sync_timeout = None;+ app.needs_draw = true;+ TimeoutAction::Drop+ })+ .ok();+ }+ return;+ }+ if let Some(token) = self.sync_timeout.take() {+ self.loop_handle.remove(token);+ }+ if self.needs_draw && !self.frame_pending && self.session.is_some() {+ self.present();+ }+ }+}@@ -0,0 +1,227 @@+use super::*;++/// What determines one rendered row's pixels: its cells, the cursor on it, the+/// selection span over it, and the blink phase. Two equal `RowSnap`s render+/// identically, so a buffer holding an equal snapshot needs no repaint.+#[derive(Clone, PartialEq, Debug)]+struct RowSnap {+ cells: Vec<Cell>,+ /// `(col, shape, focused)` when the cursor is drawn on this row.+ cursor: Option<(usize, CursorShape, bool)>,+ /// Inclusive selected column span on this row.+ sel: Option<(usize, usize)>,+ /// Search-match spans `(lo, hi, is_current)` highlighted on this row.+ search: Vec<(usize, usize, bool)>,+ /// Search-prompt text drawn over this row (only the bottom row, when active).+ overlay: Option<String>,+ /// IME preedit `(start_col, text)` drawn inline over this row (cursor row).+ preedit: Option<(usize, String)>,+ /// Blink phase, but only varied when the row actually has blinking ink, so+ /// non-blinking rows stay equal across phase toggles.+ blink: bool,+}++/// One shm buffer plus the per-row snapshot of what it currently displays.+#[derive(Debug)]+pub(super) struct FrameBuf {+ buffer: Buffer,+ rows: Vec<RowSnap>,+}++/// Snapshot the determinants of viewport row `y`'s pixels.+fn row_snap(grid: &Grid, y: usize, focused: bool, blink_on: bool) -> RowSnap {+ let abs = grid.view_to_abs(y);+ let cells = grid.view_row(y).to_vec();+ let cursor = if grid.view_at_bottom() && grid.cursor().1 == y {+ let visible = grid.cursor_visible() && (!grid.cursor_blink() || blink_on);+ visible.then(|| (grid.cursor().0, grid.cursor_shape(), focused))+ } else {+ None+ };+ let has_blink = cells+ .iter()+ .any(|c| c.flags.contains(crate::grid::Flags::BLINK));+ RowSnap {+ cells,+ cursor,+ sel: grid.selection_span_on(abs),+ search: grid.search_spans_on(abs),+ overlay: None,+ preedit: None,+ blink: if has_blink { blink_on } else { true },+ }+}+impl App {+ /// Render only the rows that changed since the chosen buffer last displayed+ /// them, damage just those rows, and commit with a frame-callback request.+ pub(super) fn present(&mut self) {+ self.needs_draw = false;+ // URL hint labels overlay the grid but are not part of the row snapshot,+ // so force a full redraw while the labels are showing.+ if self.url_mode {+ self.frames.clear();+ }+ // Render into a buffer sized in physical pixels (logical × scale); the+ // viewport presents it back at the logical surface size.+ let (w, h) = self.phys_dims();+ let m = self.renderer.metrics();+ let (focused, blink_on) = (self.focused, self.blink_on);++ // A resize invalidates every buffer's contents and size.+ if self.buf_dims != (w, h) {+ self.frames.clear();+ self.buf_dims = (w, h);+ }++ let Some(session) = self.session.as_ref() else {+ return;+ };+ let grid = session.term.grid();+ // The visual bell inverts fg/bg for the duration of the flash.+ let flashed = self.flashing.then(|| session.term.theme().inverted());+ let theme = flashed.as_ref().unwrap_or(session.term.theme());+ let rows = grid.rows();+ let mut cur: Vec<RowSnap> = (0..rows)+ .map(|y| row_snap(grid, y, focused, blink_on))+ .collect();++ // The search prompt occupies the bottom row while search mode is active.+ // Recording it in the snapshot keeps the row's damage/diff correct.+ let bar_text = self.searching.then(|| {+ let (n, total) = grid.search_count();+ format!(+ "search: {} [{n}/{total}]",+ grid.search_query().unwrap_or("")+ )+ });+ if let Some(text) = &bar_text+ && rows > 0+ {+ cur[rows - 1].overlay = Some(text.clone());+ }++ // The IME preedit is drawn inline at the cursor while composing.+ if !self.preedit.is_empty() && grid.view_at_bottom() {+ let (cx, cy) = grid.cursor();+ if cy < rows {+ cur[cy].preedit = Some((cx, self.preedit.clone()));+ }+ }++ // Reuse a buffer the compositor has released, else grow the ring.+ let stride = w as i32 * 4;+ let mut idx = None;+ for i in 0..self.frames.len() {+ if self.pool.canvas(&self.frames[i].buffer).is_some() {+ idx = Some(i);+ break;+ }+ }+ let idx = match idx {+ Some(i) => i,+ None if self.frames.len() < MAX_BUFFERS => {+ match self+ .pool+ .create_buffer(w as i32, h as i32, stride, wl_shm::Format::Argb8888)+ {+ Ok((buffer, _)) => {+ self.frames.push(FrameBuf {+ buffer,+ rows: Vec::new(),+ });+ self.frames.len() - 1+ }+ Err(err) => {+ tracing::error!("allocate shm buffer: {err}");+ return;+ }+ }+ }+ // All buffers are still held by the compositor; a release event will+ // wake us and `needs_draw` (re-set below) retries then.+ None => {+ self.needs_draw = true;+ return;+ }+ };++ // Rows that differ from what this buffer last showed (all, if fresh).+ let prev = &self.frames[idx].rows;+ let dirty: Vec<usize> = (0..rows)+ .filter(|&y| prev.get(y) != Some(&cur[y]))+ .collect();+ if dirty.is_empty() {+ return;+ }++ // A buffer used for the first time has uninitialized margins; paint the+ // whole thing (background + padding) once, then damage it in full below.+ let fresh = self.frames[idx].rows.is_empty();+ let pad_y = self.to_phys(self.config.main.pad_y) as i32;+ let Some(canvas) = self.pool.canvas(&self.frames[idx].buffer) else {+ return;+ };+ let dims = (w as usize, h as usize);+ let frame = crate::render::Frame {+ theme,+ focused,+ blink_on,+ hovered_link: self.hovered_link,+ };+ if fresh {+ self.renderer.clear(canvas, dims, theme);+ }+ for &y in &dirty {+ self.renderer.render_row(canvas, dims, grid, &frame, y);+ }+ // Draw the search prompt over the (now repainted) bottom row.+ if let Some(text) = &bar_text+ && dirty.contains(&(rows - 1))+ {+ self.renderer+ .render_search_bar(canvas, dims, theme, rows - 1, text);+ }+ // Draw the IME preedit inline over its (repainted) cursor row.+ for &y in &dirty {+ if let Some((col, text)) = &cur[y].preedit {+ self.renderer+ .render_preedit(canvas, dims, theme, y, *col, text);+ }+ }+ // Draw URL hint labels on top, narrowing to those matching the input.+ if self.url_mode {+ for (hit, label) in self.url_hits.iter().zip(&self.url_labels) {+ if label.starts_with(&self.url_input) {+ self.renderer+ .render_label(canvas, dims, theme, hit.row, hit.col, label);+ }+ }+ }+ self.frames[idx].rows = cur;++ let surface = self.window.wl_surface();+ if let Err(err) = self.frames[idx].buffer.attach_to(surface) {+ tracing::error!("attach buffer: {err}");+ return;+ }+ // With a viewport the buffer is presented at the logical destination, so+ // its own scale stays 1; without one, fall back to integer buffer scale.+ if let Some(vp) = &self.viewport {+ surface.set_buffer_scale(1);+ vp.set_destination(self.width.max(1) as i32, self.height.max(1) as i32);+ } else {+ surface.set_buffer_scale((self.scale120 / 120).max(1) as i32);+ }+ if fresh {+ surface.damage_buffer(0, 0, w as i32, h as i32);+ } else {+ for &y in &dirty {+ let top = pad_y + y as i32 * m.height as i32;+ surface.damage_buffer(0, top, w as i32, m.height as i32);+ }+ }+ surface.frame(&self.qh, surface.clone());+ self.window.commit();+ self.frame_pending = true;+ }+}