From 5cba919c783e11bc77983157fe8b9003fd6d6b67 Mon Sep 17 00:00:00 2001 From: NotAShelf Date: Thu, 25 Jun 2026 14:42:15 +0300 Subject: [PATCH] treewide: split terminal core modules Signed-off-by: NotAShelf Change-Id: I9cace0b7c6995c0fca21ff2cf465ae1f6a6a6964 --- README.md | 155 ++++- doc/beer.toml.5.scd | 2 +- nix/package.nix | 124 ++-- src/grid/links.rs | 136 +++++ src/{grid.rs => grid/mod.rs} | 471 +-------------- src/grid/search.rs | 148 +++++ src/grid/selection.rs | 190 ++++++ src/main.rs | 2 +- src/{vt.rs => vt/mod.rs} | 241 +------- src/vt/perform.rs | 240 ++++++++ src/wayland/handlers.rs | 711 ++++++++++++++++++++++ src/{wayland.rs => wayland/mod.rs} | 941 +---------------------------- src/wayland/rendering.rs | 227 +++++++ 13 files changed, 1882 insertions(+), 1706 deletions(-) create mode 100644 src/grid/links.rs rename src/{grid.rs => grid/mod.rs} (73%) create mode 100644 src/grid/search.rs create mode 100644 src/grid/selection.rs rename src/{vt.rs => vt/mod.rs} (76%) create mode 100644 src/vt/perform.rs create mode 100644 src/wayland/handlers.rs rename src/{wayland.rs => wayland/mod.rs} (68%) create mode 100644 src/wayland/rendering.rs diff --git a/README.md b/README.md index 334c400..d364679 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/doc/beer.toml.5.scd b/doc/beer.toml.5.scd index 7888765..5ee8ea4 100644 --- a/doc/beer.toml.5.scd +++ b/doc/beer.toml.5.scd @@ -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] diff --git a/nix/package.nix b/nix/package.nix index fe609f6..fe56ad0 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -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; + + strictDeps = true; + nativeBuildInputs = [ + pkg-config + makeBinaryWrapper + ncurses # tic + scdoc # man page generation + installShellFiles # installManPage + ]; + + 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 + + # 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]} + ''; + + 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; }; - - cargoLock.lockFile = "${finalAttrs.src}/Cargo.lock"; - enableParallelBuilding = true; - - strictDeps = true; - nativeBuildInputs = [ - pkg-config - makeBinaryWrapper - ncurses # tic - scdoc # man page generation - installShellFiles # installManPage - ]; - - 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 - - # 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]} - ''; - - 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; - }; -}) + }) diff --git a/src/grid/links.rs b/src/grid/links.rs new file mode 100644 index 0000000..9215316 --- /dev/null +++ b/src/grid/links.rs @@ -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 { + 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 { + let mut chars: Vec = 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() + } +} diff --git a/src/grid.rs b/src/grid/mod.rs similarity index 73% rename from src/grid.rs rename to src/grid/mod.rs index 66124aa..08defc2 100644 --- a/src/grid.rs +++ b/src/grid/mod.rs @@ -1,11 +1,18 @@ //! 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; @@ -143,67 +150,6 @@ struct Cursor { 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. @@ -244,22 +190,6 @@ pub struct Point { 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, - current: usize, -} - /// The active screen plus cursor, scroll region, and current pen. #[derive(Debug)] pub struct Grid { @@ -377,43 +307,6 @@ 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 { - 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) { if let Some(d) = delims { @@ -1129,40 +1022,6 @@ impl Grid { } } - /// 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 { - let mut chars: Vec = 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() { @@ -1247,322 +1106,6 @@ impl Grid { 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 { - 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 { - // 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 = 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 = 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; } diff --git a/src/grid/search.rs b/src/grid/search.rs new file mode 100644 index 0000000..804d594 --- /dev/null +++ b/src/grid/search.rs @@ -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, + 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 { + // 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 = 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 = 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)); + } + } +} diff --git a/src/grid/selection.rs b/src/grid/selection.rs new file mode 100644 index 0000000..74d1fe3 --- /dev/null +++ b/src/grid/selection.rs @@ -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 { + 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 + } +} diff --git a/src/main.rs b/src/main.rs index 742b5ca..c31d897 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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)] diff --git a/src/vt.rs b/src/vt/mod.rs similarity index 76% rename from src/vt.rs rename to src/vt/mod.rs index dbb16c1..e4d4f24 100644 --- a/src/vt.rs +++ b/src/vt/mod.rs @@ -1,8 +1,10 @@ //! VT emulation: feed bytes through `vte` and drive the [`Grid`]. +mod perform; + use std::io::Write as _; -use vte::{Params, Perform}; +use vte::Params; use crate::grid::{ Color, CursorShape, Flags, Grid, MouseEncoding, MouseProtocol, PromptKind, Underline, @@ -557,243 +559,6 @@ fn dec_special(c: char) -> char { } } -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 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 { diff --git a/src/vt/perform.rs b/src/vt/perform.rs new file mode 100644 index 0000000..b9d57b8 --- /dev/null +++ b/src/vt/perform.rs @@ -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 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); + } + } +} diff --git a/src/wayland/handlers.rs b/src/wayland/handlers.rs new file mode 100644 index 0000000..4f6a935 --- /dev/null +++ b/src/wayland/handlers.rs @@ -0,0 +1,711 @@ +use super::*; + +impl CompositorHandler for App { + fn scale_factor_changed( + &mut self, + _: &Connection, + _: &QueueHandle, + _: &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, + _: &wl_surface::WlSurface, + _: wl_output::Transform, + ) { + } + + fn frame(&mut self, _: &Connection, _: &QueueHandle, _: &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, + _: &wl_surface::WlSurface, + _: &wl_output::WlOutput, + ) { + } + + fn surface_leave( + &mut self, + _: &Connection, + _: &QueueHandle, + _: &wl_surface::WlSurface, + _: &wl_output::WlOutput, + ) { + } +} + +impl WindowHandler for App { + fn request_close(&mut self, _: &Connection, _: &QueueHandle, _: &Window) { + self.exit = true; + } + + fn configure( + &mut self, + _: &Connection, + _: &QueueHandle, + _: &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, 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, + 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, + 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, 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, + 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, + _: &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, + 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, + _: &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, + _: &wl_keyboard::WlKeyboard, + _: u32, + _: KeyEvent, + ) { + } + + fn update_modifiers( + &mut self, + _: &Connection, + _: &QueueHandle, + _: &wl_keyboard::WlKeyboard, + _: u32, + modifiers: Modifiers, + _: RawModifiers, + _: u32, + ) { + self.modifiers = modifiers; + } + + fn update_repeat_info( + &mut self, + _: &Connection, + _: &QueueHandle, + _: &wl_keyboard::WlKeyboard, + _: RepeatInfo, + ) { + } +} + +/// Parse a configured cursor-style name into a [`CursorShape`]. +pub(super) fn cursor_shape_from(style: Option<&str>) -> Option { + 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 { + 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 { + 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, + 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, _: wl_output::WlOutput) {} + + fn update_output(&mut self, _: &Connection, _: &QueueHandle, _: wl_output::WlOutput) {} + + fn output_destroyed(&mut self, _: &Connection, _: &QueueHandle, _: 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, + _: &WlDataDevice, + _: f64, + _: f64, + _: &wl_surface::WlSurface, + ) { + } + fn leave(&mut self, _: &Connection, _: &QueueHandle, _: &WlDataDevice) {} + fn motion(&mut self, _: &Connection, _: &QueueHandle, _: &WlDataDevice, _: f64, _: f64) {} + fn selection(&mut self, _: &Connection, _: &QueueHandle, _: &WlDataDevice) {} + fn drop_performed(&mut self, _: &Connection, _: &QueueHandle, _: &WlDataDevice) {} +} + +impl DataOfferHandler for App { + fn source_actions( + &mut self, + _: &Connection, + _: &QueueHandle, + _: &mut DragOffer, + _: DndAction, + ) { + } + fn selected_action( + &mut self, + _: &Connection, + _: &QueueHandle, + _: &mut DragOffer, + _: DndAction, + ) { + } +} + +impl DataSourceHandler for App { + fn accept_mime( + &mut self, + _: &Connection, + _: &QueueHandle, + _: &WlDataSource, + _: Option, + ) { + } + + fn send_request( + &mut self, + _: &Connection, + _: &QueueHandle, + 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, 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, _: &WlDataSource) {} + fn dnd_finished(&mut self, _: &Connection, _: &QueueHandle, _: &WlDataSource) {} + fn action(&mut self, _: &Connection, _: &QueueHandle, _: &WlDataSource, _: DndAction) {} +} + +impl PrimarySelectionDeviceHandler for App { + fn selection( + &mut self, + _: &Connection, + _: &QueueHandle, + _: &ZwpPrimarySelectionDeviceV1, + ) { + } +} + +impl PrimarySelectionSourceHandler for App { + fn send_request( + &mut self, + _: &Connection, + _: &QueueHandle, + 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, + 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 for App { + fn event( + state: &mut Self, + _: &WpFractionalScaleV1, + event: wp_fractional_scale_v1::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + if let wp_fractional_scale_v1::Event::PreferredScale { scale } = event { + state.set_scale(scale); + } + } +} + +impl Dispatch for App { + fn event( + _: &mut Self, + _: &WpFractionalScaleManagerV1, + _: ::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + } +} + +impl Dispatch for App { + fn event( + _: &mut Self, + _: &WpViewporter, + _: ::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + } +} + +impl Dispatch for App { + fn event( + _: &mut Self, + _: &WpViewport, + _: ::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + } +} + +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::(self.window.wl_surface(), token); + } + } +} + +impl Dispatch for App { + fn event( + _: &mut Self, + _: &ZwpTextInputManagerV3, + _: ::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + } +} + +// 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 for App { + fn event( + state: &mut Self, + ti: &ZwpTextInputV3, + event: zwp_text_input_v3::Event, + _: &(), + _: &Connection, + _: &QueueHandle, + ) { + 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); diff --git a/src/wayland.rs b/src/wayland/mod.rs similarity index 68% rename from src/wayland.rs rename to src/wayland/mod.rs index 1b1a5c4..fd7b9dc 100644 --- a/src/wayland.rs +++ b/src/wayland/mod.rs @@ -3,6 +3,9 @@ //! 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; @@ -93,6 +96,9 @@ 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", @@ -130,58 +136,6 @@ 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, - /// `(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, - /// 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, -} - -/// 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; @@ -1739,887 +1693,4 @@ impl App { 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 = (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 = (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, - _: &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, - _: &wl_surface::WlSurface, - _: wl_output::Transform, - ) { - } - - fn frame(&mut self, _: &Connection, _: &QueueHandle, _: &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, - _: &wl_surface::WlSurface, - _: &wl_output::WlOutput, - ) { - } - - fn surface_leave( - &mut self, - _: &Connection, - _: &QueueHandle, - _: &wl_surface::WlSurface, - _: &wl_output::WlOutput, - ) { - } -} - -impl WindowHandler for App { - fn request_close(&mut self, _: &Connection, _: &QueueHandle, _: &Window) { - self.exit = true; - } - - fn configure( - &mut self, - _: &Connection, - _: &QueueHandle, - _: &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, 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, - 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, - 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, 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, - 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, - _: &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, - 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, - _: &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, - _: &wl_keyboard::WlKeyboard, - _: u32, - _: KeyEvent, - ) { - } - - fn update_modifiers( - &mut self, - _: &Connection, - _: &QueueHandle, - _: &wl_keyboard::WlKeyboard, - _: u32, - modifiers: Modifiers, - _: RawModifiers, - _: u32, - ) { - self.modifiers = modifiers; - } - - fn update_repeat_info( - &mut self, - _: &Connection, - _: &QueueHandle, - _: &wl_keyboard::WlKeyboard, - _: RepeatInfo, - ) { - } -} - -/// Parse a configured cursor-style name into a [`CursorShape`]. -fn cursor_shape_from(style: Option<&str>) -> Option { - 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 { - 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 { - 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, - 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, _: wl_output::WlOutput) {} - - fn update_output(&mut self, _: &Connection, _: &QueueHandle, _: wl_output::WlOutput) {} - - fn output_destroyed(&mut self, _: &Connection, _: &QueueHandle, _: 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, - _: &WlDataDevice, - _: f64, - _: f64, - _: &wl_surface::WlSurface, - ) { - } - fn leave(&mut self, _: &Connection, _: &QueueHandle, _: &WlDataDevice) {} - fn motion(&mut self, _: &Connection, _: &QueueHandle, _: &WlDataDevice, _: f64, _: f64) {} - fn selection(&mut self, _: &Connection, _: &QueueHandle, _: &WlDataDevice) {} - fn drop_performed(&mut self, _: &Connection, _: &QueueHandle, _: &WlDataDevice) {} -} - -impl DataOfferHandler for App { - fn source_actions( - &mut self, - _: &Connection, - _: &QueueHandle, - _: &mut DragOffer, - _: DndAction, - ) { - } - fn selected_action( - &mut self, - _: &Connection, - _: &QueueHandle, - _: &mut DragOffer, - _: DndAction, - ) { - } -} - -impl DataSourceHandler for App { - fn accept_mime( - &mut self, - _: &Connection, - _: &QueueHandle, - _: &WlDataSource, - _: Option, - ) { - } - - fn send_request( - &mut self, - _: &Connection, - _: &QueueHandle, - 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, 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, _: &WlDataSource) {} - fn dnd_finished(&mut self, _: &Connection, _: &QueueHandle, _: &WlDataSource) {} - fn action(&mut self, _: &Connection, _: &QueueHandle, _: &WlDataSource, _: DndAction) {} -} - -impl PrimarySelectionDeviceHandler for App { - fn selection( - &mut self, - _: &Connection, - _: &QueueHandle, - _: &ZwpPrimarySelectionDeviceV1, - ) { - } -} - -impl PrimarySelectionSourceHandler for App { - fn send_request( - &mut self, - _: &Connection, - _: &QueueHandle, - 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, - 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 for App { - fn event( - state: &mut Self, - _: &WpFractionalScaleV1, - event: wp_fractional_scale_v1::Event, - _: &(), - _: &Connection, - _: &QueueHandle, - ) { - if let wp_fractional_scale_v1::Event::PreferredScale { scale } = event { - state.set_scale(scale); - } - } -} - -impl Dispatch for App { - fn event( - _: &mut Self, - _: &WpFractionalScaleManagerV1, - _: ::Event, - _: &(), - _: &Connection, - _: &QueueHandle, - ) { - } -} - -impl Dispatch for App { - fn event( - _: &mut Self, - _: &WpViewporter, - _: ::Event, - _: &(), - _: &Connection, - _: &QueueHandle, - ) { - } -} - -impl Dispatch for App { - fn event( - _: &mut Self, - _: &WpViewport, - _: ::Event, - _: &(), - _: &Connection, - _: &QueueHandle, - ) { - } -} - -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::(self.window.wl_surface(), token); - } - } -} - -impl Dispatch for App { - fn event( - _: &mut Self, - _: &ZwpTextInputManagerV3, - _: ::Event, - _: &(), - _: &Connection, - _: &QueueHandle, - ) { - } -} - -// 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 for App { - fn event( - state: &mut Self, - ti: &ZwpTextInputV3, - event: zwp_text_input_v3::Event, - _: &(), - _: &Connection, - _: &QueueHandle, - ) { - 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); diff --git a/src/wayland/rendering.rs b/src/wayland/rendering.rs new file mode 100644 index 0000000..04c3ac4 --- /dev/null +++ b/src/wayland/rendering.rs @@ -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, + /// `(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, + /// 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, +} + +/// 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 = (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 = (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; + } +}