treewide: split terminal core modules

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I9cace0b7c6995c0fca21ff2cf465ae1f6a6a6964
This commit is contained in:
raf 2026-06-25 14:42:15 +03:00
commit 5cba919c78
No known key found for this signature in database
GPG key ID: 29D95B64378DB4BF
13 changed files with 1876 additions and 1700 deletions

155
README.md
View file

@ -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.

View file

@ -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]

View file

@ -10,10 +10,12 @@
libxkbcommon,
freetype,
fontconfig,
}:
}: let
cargoTOML = (lib.importTOML ../Cargo.toml).package.version;
in
rustPlatform.buildRustPackage (finalAttrs: {
pname = "beer";
version = "0.0.1";
version = cargoTOML.package.version;
src = let
fs = lib.fileset;
@ -22,7 +24,7 @@ rustPlatform.buildRustPackage (finalAttrs: {
fs.toSource {
root = s;
fileset = fs.unions [
(fs.fileFilter (file: builtins.any file.hasExt ["rs"]) (s + /src))
(s + /src)
(s + /Cargo.lock)
(s + /Cargo.toml)
(s + /terminfo)

136
src/grid/links.rs Normal file
View file

@ -0,0 +1,136 @@
use std::num::NonZeroU16;
use super::*;
/// A URL detected in the visible viewport, with the `(row, col)` of its first
/// character in viewport coordinates.
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct UrlHit {
pub url: String,
pub row: usize,
pub col: usize,
}
/// Whether `c` may appear inside a URL (excludes whitespace, controls, and the
/// delimiters that conventionally bound a URL in flowing text).
fn is_url_char(c: char) -> bool {
!c.is_whitespace()
&& !c.is_control()
&& !matches!(c, '<' | '>' | '"' | '`' | '{' | '}' | '|' | '\\' | '^')
}
/// Find `scheme://…` URLs in `chars`, returning `(start, end)` index ranges.
/// The scheme is a run of `[A-Za-z][A-Za-z0-9+.-]*` before `://`; the body runs
/// to the first non-URL character, with trailing sentence punctuation trimmed.
fn find_urls(chars: &[char]) -> Vec<(usize, usize)> {
let mut out = Vec::new();
let mut i = 0;
while i + 2 < chars.len() {
if chars[i] == ':' && chars[i + 1] == '/' && chars[i + 2] == '/' {
// Backtrack over the scheme.
let mut start = i;
while start > 0 {
let c = chars[start - 1];
if c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.') {
start -= 1;
} else {
break;
}
}
if start < i && chars[start].is_ascii_alphabetic() {
let mut end = i + 3;
while end < chars.len() && is_url_char(chars[end]) {
end += 1;
}
// Trim trailing punctuation that is usually sentence-level.
while end > i + 3
&& matches!(
chars[end - 1],
'.' | ',' | ';' | ':' | '!' | '?' | ')' | ']' | '\'' | '"'
)
{
end -= 1;
}
if end > i + 3 {
out.push((start, end));
i = end;
continue;
}
}
}
i += 1;
}
out
}
impl Grid {
/// Set (or clear) the active OSC 8 hyperlink applied to printed cells. An
/// empty/`None` URI ends the current link.
pub fn set_link(&mut self, uri: Option<&str>) {
self.pen.link = match uri {
Some(uri) if !uri.is_empty() => Some(self.intern_link(uri)),
_ => None,
};
}
/// Intern a hyperlink URI, returning its 1-based id (deduplicated; the table
/// is capped so a pathological stream cannot grow it without bound).
fn intern_link(&mut self, uri: &str) -> NonZeroU16 {
if let Some(i) = self.links.iter().position(|u| u.as_ref() == uri) {
return NonZeroU16::new(i as u16 + 1).expect("index + 1 is non-zero");
}
// u16::MAX distinct links is far past any real document; reuse the last
// slot once saturated rather than overflow the id space.
if self.links.len() < usize::from(u16::MAX) - 1 {
self.links.push(uri.into());
} else {
*self.links.last_mut().expect("table is non-empty when full") = uri.into();
}
NonZeroU16::new(self.links.len() as u16).expect("len after push is non-zero")
}
/// The URI for a hyperlink id, if it is still in the table.
pub fn link_uri(&self, id: NonZeroU16) -> Option<&str> {
self.links
.get(usize::from(id.get()) - 1)
.map(|s| s.as_ref())
}
/// The hyperlink id of the cell at an absolute `(row, col)`, if any.
pub fn link_at(&self, abs_row: usize, col: usize) -> Option<NonZeroU16> {
self.abs_row(abs_row).get(col).and_then(|c| c.link)
}
/// Detect plain-text URLs across the visible viewport, returning each with
/// the viewport `(row, col)` of its first character. Soft-wrapped rows are
/// joined so a URL split across a wrap is found whole; hard line breaks end
/// a URL.
pub fn visible_urls(&self) -> Vec<UrlHit> {
let mut chars: Vec<char> = Vec::new();
let mut pos: Vec<(usize, usize)> = Vec::new();
for y in 0..self.rows {
let line = self.line_at_abs(self.view_to_abs(y));
for (x, cell) in line.cells.iter().enumerate() {
if cell.flags.contains(Flags::WIDE_CONT) {
continue;
}
chars.push(cell.c);
pos.push((y, x));
}
if !line.wrapped {
chars.push('\n'); // a hard break terminates any URL
pos.push((y, usize::MAX));
}
}
find_urls(&chars)
.into_iter()
.map(|(s, e)| {
let (row, col) = pos[s];
UrlHit {
url: chars[s..e].iter().collect(),
row,
col,
}
})
.collect()
}
}

View file

@ -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<Match>,
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<NonZeroU16> {
self.abs_row(abs_row).get(col).and_then(|c| c.link)
}
/// Override the word-delimiter set; `None` keeps the built-in default.
pub fn set_word_delimiters(&mut self, delims: Option<String>) {
if let Some(d) = delims {
@ -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<UrlHit> {
let mut chars: Vec<char> = Vec::new();
let mut pos: Vec<(usize, usize)> = Vec::new();
for y in 0..self.rows {
let line = self.line_at_abs(self.view_to_abs(y));
for (x, cell) in line.cells.iter().enumerate() {
if cell.flags.contains(Flags::WIDE_CONT) {
continue;
}
chars.push(cell.c);
pos.push((y, x));
}
if !line.wrapped {
chars.push('\n'); // a hard break terminates any URL
pos.push((y, usize::MAX));
}
}
find_urls(&chars)
.into_iter()
.map(|(s, e)| {
let (row, col) = pos[s];
UrlHit {
url: chars[s..e].iter().collect(),
row,
col,
}
})
.collect()
}
/// Cells of an absolute row (scrollback first, then the live screen).
fn abs_row(&self, row: usize) -> &[Cell] {
if row < self.scrollback.len() {
@ -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<String> {
if self.selection_block {
let (r0, r1, c0, c1) = self.block_rect()?;
let mut out = String::new();
for row in r0..=r1 {
out.push_str(self.row_slice_text(row, c0, c1 + 1).trim_end());
if row != r1 {
out.push('\n');
}
}
return Some(out);
}
let (start, end) = self.ordered_selection()?;
let mut out = String::new();
for row in start.row..=end.row {
let line = self.abs_row(row);
let lo = if row == start.row { start.col } else { 0 };
let hi = if row == end.row {
(end.col + 1).min(line.len())
} else {
line.len()
};
out.push_str(self.row_slice_text(row, lo, hi).trim_end());
if row != end.row {
out.push('\n');
}
}
Some(out)
}
/// The characters of an absolute row in `[from, to)`, skipping wide
/// continuation cells.
fn row_slice_text(&self, row: usize, from: usize, to: usize) -> String {
let mut out = String::new();
for cell in self
.abs_row(row)
.get(from..to.min(self.abs_row(row).len()))
.unwrap_or(&[])
.iter()
.filter(|c| !c.flags.contains(Flags::WIDE_CONT))
{
out.push(cell.c);
if let Some(marks) = &cell.combining {
out.push_str(marks);
}
}
out
}
// --- scrollback search ---
/// Set the search query and recompute matches over scrollback + the live
/// screen, focusing the most recent hit and scrolling it into view. An
/// empty query keeps search mode active but clears the hit list.
pub fn set_search(&mut self, query: &str) {
let matches = if query.is_empty() {
Vec::new()
} else {
self.compute_matches(query)
};
let current = matches.len().saturating_sub(1);
self.search = Some(SearchState {
query: query.to_string(),
matches,
current,
});
self.jump_to_current();
}
/// Move the focused match `forward` (toward newer) or back (toward older),
/// wrapping, and scroll it into view.
pub fn search_step(&mut self, forward: bool) {
if let Some(s) = self.search.as_mut()
&& !s.matches.is_empty()
{
let n = s.matches.len();
s.current = if forward {
(s.current + 1) % n
} else {
(s.current + n - 1) % n
};
}
self.jump_to_current();
}
pub fn clear_search(&mut self) {
self.search = None;
}
/// The current query, while search mode is active.
pub fn search_query(&self) -> Option<&str> {
self.search.as_ref().map(|s| s.query.as_str())
}
/// `(focused match index 1-based, total matches)` for the search prompt.
pub fn search_count(&self) -> (usize, usize) {
self.search.as_ref().map_or((0, 0), |s| {
let total = s.matches.len();
(if total == 0 { 0 } else { s.current + 1 }, total)
})
}
/// Match spans `(lo, hi, is_current)` on absolute row `row`, for highlight.
pub fn search_spans_on(&self, row: usize) -> Vec<(usize, usize, bool)> {
let Some(s) = self.search.as_ref() else {
return Vec::new();
};
s.matches
.iter()
.enumerate()
.filter(|(_, m)| m.row == row && m.len > 0)
.map(|(i, m)| (m.col, m.col + m.len - 1, i == s.current))
.collect()
}
fn compute_matches(&self, query: &str) -> Vec<Match> {
// Smart case: an uppercase letter in the query forces case sensitivity.
let sensitive = query.chars().any(|c| c.is_ascii_uppercase());
let fold = |c: char| {
if sensitive { c } else { c.to_ascii_lowercase() }
};
let needle: Vec<char> = query.chars().map(fold).collect();
if needle.is_empty() {
return Vec::new();
}
let total = self.scrollback.len() + self.rows;
let mut matches = Vec::new();
for row in 0..total {
let hay: Vec<char> = self.abs_row(row).iter().map(|cell| fold(cell.c)).collect();
let mut i = 0;
while i + needle.len() <= hay.len() {
if hay[i..i + needle.len()] == needle[..] {
matches.push(Match {
row,
col: i,
len: needle.len(),
});
i += needle.len();
} else {
i += 1;
}
}
}
matches
}
/// Scroll the viewport so the focused match is on screen, centering it only
/// when it would otherwise be off the visible range.
fn jump_to_current(&mut self) {
let Some(abs) = self
.search
.as_ref()
.and_then(|s| s.matches.get(s.current))
.map(|m| m.row)
else {
return;
};
let sb = self.scrollback.len();
let top = sb - self.view_offset;
let bottom = top + self.rows - 1;
if abs < top || abs > bottom {
let target = sb as isize + self.rows as isize / 2 - abs as isize;
self.view_offset = target.clamp(0, sb as isize) as usize;
}
}
/// Slide search hits up by `n` rows after scrollback eviction, dropping any
/// that scrolled off the top.
fn shift_search(&mut self, n: usize) {
if let Some(s) = self.search.as_mut() {
s.matches.retain(|m| m.row >= n);
for m in &mut s.matches {
m.row -= n;
}
s.current = s.current.min(s.matches.len().saturating_sub(1));
}
}
pub fn set_bracketed_paste(&mut self, on: bool) {
self.bracketed_paste = on;
}

148
src/grid/search.rs Normal file
View file

@ -0,0 +1,148 @@
use super::*;
/// One scrollback-search hit: a run of `len` cells at absolute `(row, col)`.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
struct Match {
row: usize,
col: usize,
len: usize,
}
/// Incremental scrollback search: the query, every hit, and the focused one.
#[derive(Clone, Debug, Default)]
pub(super) struct SearchState {
query: String,
matches: Vec<Match>,
current: usize,
}
impl Grid {
// --- scrollback search ---
/// Set the search query and recompute matches over scrollback + the live
/// screen, focusing the most recent hit and scrolling it into view. An
/// empty query keeps search mode active but clears the hit list.
pub fn set_search(&mut self, query: &str) {
let matches = if query.is_empty() {
Vec::new()
} else {
self.compute_matches(query)
};
let current = matches.len().saturating_sub(1);
self.search = Some(SearchState {
query: query.to_string(),
matches,
current,
});
self.jump_to_current();
}
/// Move the focused match `forward` (toward newer) or back (toward older),
/// wrapping, and scroll it into view.
pub fn search_step(&mut self, forward: bool) {
if let Some(s) = self.search.as_mut()
&& !s.matches.is_empty()
{
let n = s.matches.len();
s.current = if forward {
(s.current + 1) % n
} else {
(s.current + n - 1) % n
};
}
self.jump_to_current();
}
pub fn clear_search(&mut self) {
self.search = None;
}
/// The current query, while search mode is active.
pub fn search_query(&self) -> Option<&str> {
self.search.as_ref().map(|s| s.query.as_str())
}
/// `(focused match index 1-based, total matches)` for the search prompt.
pub fn search_count(&self) -> (usize, usize) {
self.search.as_ref().map_or((0, 0), |s| {
let total = s.matches.len();
(if total == 0 { 0 } else { s.current + 1 }, total)
})
}
/// Match spans `(lo, hi, is_current)` on absolute row `row`, for highlight.
pub fn search_spans_on(&self, row: usize) -> Vec<(usize, usize, bool)> {
let Some(s) = self.search.as_ref() else {
return Vec::new();
};
s.matches
.iter()
.enumerate()
.filter(|(_, m)| m.row == row && m.len > 0)
.map(|(i, m)| (m.col, m.col + m.len - 1, i == s.current))
.collect()
}
fn compute_matches(&self, query: &str) -> Vec<Match> {
// Smart case: an uppercase letter in the query forces case sensitivity.
let sensitive = query.chars().any(|c| c.is_ascii_uppercase());
let fold = |c: char| {
if sensitive { c } else { c.to_ascii_lowercase() }
};
let needle: Vec<char> = query.chars().map(fold).collect();
if needle.is_empty() {
return Vec::new();
}
let total = self.scrollback.len() + self.rows;
let mut matches = Vec::new();
for row in 0..total {
let hay: Vec<char> = self.abs_row(row).iter().map(|cell| fold(cell.c)).collect();
let mut i = 0;
while i + needle.len() <= hay.len() {
if hay[i..i + needle.len()] == needle[..] {
matches.push(Match {
row,
col: i,
len: needle.len(),
});
i += needle.len();
} else {
i += 1;
}
}
}
matches
}
/// Scroll the viewport so the focused match is on screen, centering it only
/// when it would otherwise be off the visible range.
fn jump_to_current(&mut self) {
let Some(abs) = self
.search
.as_ref()
.and_then(|s| s.matches.get(s.current))
.map(|m| m.row)
else {
return;
};
let sb = self.scrollback.len();
let top = sb - self.view_offset;
let bottom = top + self.rows - 1;
if abs < top || abs > bottom {
let target = sb as isize + self.rows as isize / 2 - abs as isize;
self.view_offset = target.clamp(0, sb as isize) as usize;
}
}
/// Slide search hits up by `n` rows after scrollback eviction, dropping any
/// that scrolled off the top.
pub(super) fn shift_search(&mut self, n: usize) {
if let Some(s) = self.search.as_mut() {
s.matches.retain(|m| m.row >= n);
for m in &mut s.matches {
m.row -= n;
}
s.current = s.current.min(s.matches.len().saturating_sub(1));
}
}
}

190
src/grid/selection.rs Normal file
View file

@ -0,0 +1,190 @@
use super::*;
impl Grid {
/// Slide an active selection up by `n` rows after scrollback eviction,
/// dropping it if either endpoint scrolled off the top.
pub(super) fn shift_selection(&mut self, n: usize) {
if let Some((a, b)) = self.selection {
if a.row < n || b.row < n {
self.selection = None;
} else {
self.selection = Some((
Point {
row: a.row - n,
..a
},
Point {
row: b.row - n,
..b
},
));
}
}
}
pub fn clear_selection(&mut self) {
self.selection = None;
}
/// Begin a linear selection at an absolute point (drag anchor).
pub fn start_selection(&mut self, row: usize, col: usize) {
let p = Point { row, col };
self.selection = Some((p, p));
self.selection_block = false;
}
/// Begin a rectangular (block) selection at an absolute point.
pub fn start_block_selection(&mut self, row: usize, col: usize) {
let p = Point { row, col };
self.selection = Some((p, p));
self.selection_block = true;
}
/// Move the selection head (drag), keeping the anchor fixed.
pub fn extend_selection(&mut self, row: usize, col: usize) {
if let Some((_, head)) = self.selection.as_mut() {
*head = Point { row, col };
}
}
/// Select the word at an absolute point, breaking on whitespace and the
/// default delimiter set.
pub fn select_word(&mut self, row: usize, col: usize) {
let delims = &self.word_delimiters;
let line = self.abs_row(row);
if col >= line.len() || !is_word(line[col].c, delims) {
self.start_selection(row, col);
return;
}
let mut lo = col;
while lo > 0 && is_word(line[lo - 1].c, delims) {
lo -= 1;
}
let mut hi = col;
while hi + 1 < line.len() && is_word(line[hi + 1].c, delims) {
hi += 1;
}
self.selection = Some((Point { row, col: lo }, Point { row, col: hi }));
self.selection_block = false;
}
/// Select the whole line at an absolute row.
pub fn select_line(&mut self, row: usize) {
let last = self.abs_row(row).len().saturating_sub(1);
self.selection = Some((Point { row, col: 0 }, Point { row, col: last }));
self.selection_block = false;
}
/// The rectangle `(top_row, bottom_row, left_col, right_col)` of a block
/// selection.
fn block_rect(&self) -> Option<(usize, usize, usize, usize)> {
let (a, b) = self.selection?;
Some((
a.row.min(b.row),
a.row.max(b.row),
a.col.min(b.col),
a.col.max(b.col),
))
}
/// Normalized selection (start <= end in reading order), if any.
fn ordered_selection(&self) -> Option<(Point, Point)> {
self.selection.map(|(a, b)| {
if (a.row, a.col) <= (b.row, b.col) {
(a, b)
} else {
(b, a)
}
})
}
/// Whether the cell at an absolute `(row, col)` falls inside the selection.
pub fn is_selected(&self, row: usize, col: usize) -> bool {
if self.selection_block {
let Some((r0, r1, c0, c1)) = self.block_rect() else {
return false;
};
return row >= r0 && row <= r1 && col >= c0 && col <= c1;
}
let Some((start, end)) = self.ordered_selection() else {
return false;
};
if row < start.row || row > end.row {
return false;
}
let lo = if row == start.row { start.col } else { 0 };
let hi = if row == end.row { end.col } else { usize::MAX };
col >= lo && col <= hi
}
/// The inclusive `(lo, hi)` column span selected on absolute row `row`, if
/// any part of that row is selected.
pub fn selection_span_on(&self, row: usize) -> Option<(usize, usize)> {
if self.selection_block {
let (r0, r1, c0, c1) = self.block_rect()?;
return (row >= r0 && row <= r1).then_some((c0, c1));
}
let (start, end) = self.ordered_selection()?;
if row < start.row || row > end.row {
return None;
}
let lo = if row == start.row { start.col } else { 0 };
let hi = if row == end.row {
end.col
} else {
self.abs_row(row).len().saturating_sub(1)
};
Some((lo, hi))
}
/// The selected text, with trailing blanks trimmed per line and rows joined
/// by newlines. `None` if there is no selection.
pub fn selection_text(&self) -> Option<String> {
if self.selection_block {
let (r0, r1, c0, c1) = self.block_rect()?;
let mut out = String::new();
for row in r0..=r1 {
out.push_str(self.row_slice_text(row, c0, c1 + 1).trim_end());
if row != r1 {
out.push('\n');
}
}
return Some(out);
}
let (start, end) = self.ordered_selection()?;
let mut out = String::new();
for row in start.row..=end.row {
let line = self.abs_row(row);
let lo = if row == start.row { start.col } else { 0 };
let hi = if row == end.row {
(end.col + 1).min(line.len())
} else {
line.len()
};
out.push_str(self.row_slice_text(row, lo, hi).trim_end());
if row != end.row {
out.push('\n');
}
}
Some(out)
}
/// The characters of an absolute row in `[from, to)`, skipping wide
/// continuation cells.
pub(super) fn row_slice_text(&self, row: usize, from: usize, to: usize) -> String {
let mut out = String::new();
for cell in self
.abs_row(row)
.get(from..to.min(self.abs_row(row).len()))
.unwrap_or(&[])
.iter()
.filter(|c| !c.flags.contains(Flags::WIDE_CONT))
{
out.push(cell.c);
if let Some(marks) = &cell.combining {
out.push_str(marks);
}
}
out
}
}

View file

@ -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)]

View file

@ -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 &params[1..] {
if let Some(i) = parse_index(p) {
self.theme.reset_palette_index(i);
}
}
}
}
// OSC 10/11: foreground / background; OSC 110/111 reset them.
Some(&n) if n == b"10" => self.osc_dynamic_color(Dynamic::Fg, params.get(1), bell),
Some(&n) if n == b"11" => self.osc_dynamic_color(Dynamic::Bg, params.get(1), bell),
Some(&n) if n == b"110" => self.theme.reset_fg(),
Some(&n) if n == b"111" => self.theme.reset_bg(),
// OSC 17/19: selection (highlight) background / foreground.
Some(&n) if n == b"17" => self.osc_dynamic_color(Dynamic::SelBg, params.get(1), bell),
Some(&n) if n == b"19" => self.osc_dynamic_color(Dynamic::SelFg, params.get(1), bell),
// OSC 12: set cursor colour; OSC 112: reset to default.
Some(&n) if n == b"12" => {
self.grid
.set_cursor_color(params.get(1).and_then(|s| parse_spec(s)).map(rgb_tuple));
}
Some(&n) if n == b"112" => self.grid.set_cursor_color(None),
// OSC 52: clipboard get/set. Pc selects the target, Pd is base64 or
// `?` to query. We only touch `c` (clipboard) and `p` (primary).
Some(&n) if n == b"52" => {
let target = params.get(1).copied().unwrap_or(b"");
let data = params.get(2).copied().unwrap_or(b"");
let primary = target.first() == Some(&b'p');
if data == b"?" {
self.clipboard_ops.push(ClipboardOp::Query { primary });
} else if let Some(text) =
base64_decode(data).and_then(|b| String::from_utf8(b).ok())
{
self.clipboard_ops.push(ClipboardOp::Set { primary, text });
}
}
_ => {}
}
}
fn hook(&mut self, _: &Params, intermediates: &[u8], _: bool, action: char) {
// XTGETTCAP arrives as `DCS + q <names> ST`.
if action == 'q' && intermediates == [b'+'] {
self.xtgettcap = Some(Vec::new());
}
}
fn put(&mut self, byte: u8) {
if let Some(buf) = self.xtgettcap.as_mut() {
buf.push(byte);
}
}
fn unhook(&mut self) {
if let Some(payload) = self.xtgettcap.take() {
self.answer_xtgettcap(&payload);
}
}
}
/// Look up a terminfo capability beer reports via XTGETTCAP.
fn cap_value(name: &[u8]) -> Option<&'static str> {
match name {

240
src/vt/perform.rs Normal file
View file

@ -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 &params[1..] {
if let Some(i) = parse_index(p) {
self.theme.reset_palette_index(i);
}
}
}
}
// OSC 10/11: foreground / background; OSC 110/111 reset them.
Some(&n) if n == b"10" => self.osc_dynamic_color(Dynamic::Fg, params.get(1), bell),
Some(&n) if n == b"11" => self.osc_dynamic_color(Dynamic::Bg, params.get(1), bell),
Some(&n) if n == b"110" => self.theme.reset_fg(),
Some(&n) if n == b"111" => self.theme.reset_bg(),
// OSC 17/19: selection (highlight) background / foreground.
Some(&n) if n == b"17" => self.osc_dynamic_color(Dynamic::SelBg, params.get(1), bell),
Some(&n) if n == b"19" => self.osc_dynamic_color(Dynamic::SelFg, params.get(1), bell),
// OSC 12: set cursor colour; OSC 112: reset to default.
Some(&n) if n == b"12" => {
self.grid
.set_cursor_color(params.get(1).and_then(|s| parse_spec(s)).map(rgb_tuple));
}
Some(&n) if n == b"112" => self.grid.set_cursor_color(None),
// OSC 52: clipboard get/set. Pc selects the target, Pd is base64 or
// `?` to query. We only touch `c` (clipboard) and `p` (primary).
Some(&n) if n == b"52" => {
let target = params.get(1).copied().unwrap_or(b"");
let data = params.get(2).copied().unwrap_or(b"");
let primary = target.first() == Some(&b'p');
if data == b"?" {
self.clipboard_ops.push(ClipboardOp::Query { primary });
} else if let Some(text) =
base64_decode(data).and_then(|b| String::from_utf8(b).ok())
{
self.clipboard_ops.push(ClipboardOp::Set { primary, text });
}
}
_ => {}
}
}
fn hook(&mut self, _: &Params, intermediates: &[u8], _: bool, action: char) {
// XTGETTCAP arrives as `DCS + q <names> ST`.
if action == 'q' && intermediates == [b'+'] {
self.xtgettcap = Some(Vec::new());
}
}
fn put(&mut self, byte: u8) {
if let Some(buf) = self.xtgettcap.as_mut() {
buf.push(byte);
}
}
fn unhook(&mut self) {
if let Some(payload) = self.xtgettcap.take() {
self.answer_xtgettcap(&payload);
}
}
}

711
src/wayland/handlers.rs Normal file
View file

@ -0,0 +1,711 @@
use super::*;
impl CompositorHandler for App {
fn scale_factor_changed(
&mut self,
_: &Connection,
_: &QueueHandle<Self>,
_: &wl_surface::WlSurface,
factor: i32,
) {
// Integer fallback for compositors without fractional-scale-v1; ignored
// when the fractional-scale object drives the scale instead.
if self.fractional_scale.is_none() {
self.set_scale((factor.max(1) as u32) * 120);
}
}
fn transform_changed(
&mut self,
_: &Connection,
_: &QueueHandle<Self>,
_: &wl_surface::WlSurface,
_: wl_output::Transform,
) {
}
fn frame(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &wl_surface::WlSurface, _: u32) {
// The compositor is ready for another frame; `flush` will repaint if the
// grid has changed since the last present.
self.frame_pending = false;
}
fn surface_enter(
&mut self,
_: &Connection,
_: &QueueHandle<Self>,
_: &wl_surface::WlSurface,
_: &wl_output::WlOutput,
) {
}
fn surface_leave(
&mut self,
_: &Connection,
_: &QueueHandle<Self>,
_: &wl_surface::WlSurface,
_: &wl_output::WlOutput,
) {
}
}
impl WindowHandler for App {
fn request_close(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &Window) {
self.exit = true;
}
fn configure(
&mut self,
_: &Connection,
_: &QueueHandle<Self>,
_: &Window,
configure: WindowConfigure,
_serial: u32,
) {
if let (Some(w), Some(h)) = configure.new_size {
self.width = w.get();
self.height = h.get();
if let Some(vp) = &self.viewport {
vp.set_destination(self.width.max(1) as i32, self.height.max(1) as i32);
}
}
self.focused = configure.is_activated();
if self.session.is_none() {
self.spawn_session();
} else {
self.resize_grid();
}
self.needs_draw = true;
}
}
impl ShmHandler for App {
fn shm_state(&mut self) -> &mut Shm {
&mut self.shm
}
}
impl SeatHandler for App {
fn seat_state(&mut self) -> &mut SeatState {
&mut self.seat_state
}
fn new_seat(&mut self, _: &Connection, qh: &QueueHandle<Self>, seat: wl_seat::WlSeat) {
// Clipboard/primary devices are seat-scoped, not capability-scoped.
let data_device = Some(self.data_device_manager.get_data_device(qh, &seat));
let primary_device = self
.primary_manager
.as_ref()
.map(|m| m.get_selection_device(qh, &seat));
let i = self.seat_index(&seat);
self.seats[i].data_device = data_device;
self.seats[i].primary_device = primary_device;
}
fn new_capability(
&mut self,
_: &Connection,
qh: &QueueHandle<Self>,
seat: wl_seat::WlSeat,
capability: Capability,
) {
let i = self.seat_index(&seat);
if capability == Capability::Keyboard && self.seats[i].keyboard.is_none() {
// get_keyboard_with_repeat drives key repeat off a calloop timer and
// delivers each repeat through the callback.
let loop_handle = self.loop_handle.clone();
let keyboard = self.seat_state.get_keyboard_with_repeat(
qh,
&seat,
None,
loop_handle,
Box::new(|app: &mut App, _kbd, event| app.handle_key(&event)),
);
match keyboard {
Ok(keyboard) => self.seats[i].keyboard = Some(keyboard),
Err(err) => tracing::warn!("get keyboard: {err}"),
}
if self.seats[i].text_input.is_none()
&& let Some(mgr) = self.text_input_manager.as_ref()
{
self.seats[i].text_input = Some(mgr.get_text_input(&seat, qh, ()));
}
}
if capability == Capability::Pointer && self.seats[i].pointer.is_none() {
match self.seat_state.get_pointer(qh, &seat) {
Ok(pointer) => {
self.seats[i].cursor_shape_device = self
.cursor_shape_manager
.as_ref()
.map(|m| m.get_shape_device(&pointer, qh));
self.seats[i].pointer = Some(pointer);
}
Err(err) => tracing::warn!("get pointer: {err}"),
}
}
}
fn remove_capability(
&mut self,
_: &Connection,
_: &QueueHandle<Self>,
seat: wl_seat::WlSeat,
capability: Capability,
) {
let Some(s) = self.seats.iter_mut().find(|s| s.seat == seat) else {
return;
};
match capability {
Capability::Keyboard => {
if let Some(keyboard) = s.keyboard.take() {
keyboard.release();
}
}
Capability::Pointer => {
s.cursor_shape_device = None;
if let Some(pointer) = s.pointer.take() {
pointer.release();
}
}
_ => {}
}
}
fn remove_seat(&mut self, _: &Connection, _: &QueueHandle<Self>, seat: wl_seat::WlSeat) {
self.seats.retain(|s| s.seat != seat);
self.active_seat = self.active_seat.min(self.seats.len().saturating_sub(1));
}
}
impl KeyboardHandler for App {
fn enter(
&mut self,
_: &Connection,
_: &QueueHandle<Self>,
keyboard: &wl_keyboard::WlKeyboard,
_: &wl_surface::WlSurface,
serial: u32,
_: &[u32],
_: &[Keysym],
) {
self.activate_keyboard(keyboard);
self.serial = serial;
self.focused = true;
self.report_focus(true);
self.needs_draw = true;
}
fn leave(
&mut self,
_: &Connection,
_: &QueueHandle<Self>,
_: &wl_keyboard::WlKeyboard,
_: &wl_surface::WlSurface,
_: u32,
) {
self.focused = false;
self.report_focus(false);
self.needs_draw = true;
}
fn press_key(
&mut self,
_: &Connection,
_: &QueueHandle<Self>,
keyboard: &wl_keyboard::WlKeyboard,
serial: u32,
event: KeyEvent,
) {
self.activate_keyboard(keyboard);
self.serial = serial;
self.handle_key(&event);
}
fn repeat_key(
&mut self,
_: &Connection,
_: &QueueHandle<Self>,
_: &wl_keyboard::WlKeyboard,
_: u32,
_: KeyEvent,
) {
// Repeats are delivered through the get_keyboard_with_repeat callback;
// this non-calloop hook is unused.
}
fn release_key(
&mut self,
_: &Connection,
_: &QueueHandle<Self>,
_: &wl_keyboard::WlKeyboard,
_: u32,
_: KeyEvent,
) {
}
fn update_modifiers(
&mut self,
_: &Connection,
_: &QueueHandle<Self>,
_: &wl_keyboard::WlKeyboard,
_: u32,
modifiers: Modifiers,
_: RawModifiers,
_: u32,
) {
self.modifiers = modifiers;
}
fn update_repeat_info(
&mut self,
_: &Connection,
_: &QueueHandle<Self>,
_: &wl_keyboard::WlKeyboard,
_: RepeatInfo,
) {
}
}
/// Parse a configured cursor-style name into a [`CursorShape`].
pub(super) fn cursor_shape_from(style: Option<&str>) -> Option<CursorShape> {
match style? {
"block" => Some(CursorShape::Block),
"beam" | "bar" => Some(CursorShape::Beam),
"underline" => Some(CursorShape::Underline),
other => {
tracing::warn!("unknown cursor style {other:?}");
None
}
}
}
/// Generate `n` distinct keyboard hint labels (a, b, …, z, aa, ab, …), all the
/// same length so prefix matching is unambiguous.
pub(super) fn hint_labels(n: usize) -> Vec<String> {
const ALPHABET: &[u8] = b"abcdefghijklmnopqrstuvwxyz";
if n == 0 {
return Vec::new();
}
let (mut width, mut capacity) = (1usize, 26usize);
while capacity < n {
width += 1;
capacity *= 26;
}
(0..n)
.map(|i| {
let mut idx = i;
let mut chars = vec![b'a'; width];
for slot in chars.iter_mut().rev() {
*slot = ALPHABET[idx % 26];
idx /= 26;
}
String::from_utf8(chars).expect("ascii labels are valid utf-8")
})
.collect()
}
/// Map a Wayland button code to the terminal mouse base code, if reportable.
fn button_code(button: u32) -> Option<u8> {
match button {
BTN_LEFT => Some(0),
BTN_MIDDLE => Some(1),
BTN_RIGHT => Some(2),
_ => None,
}
}
impl PointerHandler for App {
fn pointer_frame(
&mut self,
_: &Connection,
qh: &QueueHandle<Self>,
pointer: &wl_pointer::WlPointer,
events: &[PointerEvent],
) {
self.activate_pointer(pointer);
let cell_h = f64::from(self.renderer.metrics().height);
for event in events {
match &event.kind {
PointerEventKind::Enter { serial } => {
self.pointer_pos = event.position;
self.pointer_enter_serial = *serial;
self.update_hover(pointer);
self.pointer_drag();
}
PointerEventKind::Motion { .. } => {
self.pointer_pos = event.position;
if self.try_report_motion() {
continue;
}
if !self.selecting {
self.update_hover(pointer);
}
self.pointer_drag();
}
PointerEventKind::Press {
button,
serial,
time,
..
} => {
self.serial = *serial;
self.pointer_pos = event.position;
if let Some(code) = button_code(*button)
&& self.try_report_button(code, true)
{
self.pressed_button = Some(code);
continue;
}
match *button {
BTN_LEFT => {
self.press_cell = self.cell_at(self.pointer_pos.0, self.pointer_pos.1);
self.pointer_press(*time);
}
BTN_MIDDLE => self.paste_primary(),
_ => {}
}
}
PointerEventKind::Release { button, .. } => {
self.pointer_pos = event.position;
let code = button_code(*button);
if let Some(code) = code
&& self.try_report_button(code, false)
{
if self.pressed_button == Some(code) {
self.pressed_button = None;
}
continue;
}
if *button == BTN_LEFT {
self.maybe_open_clicked_link();
self.pointer_release(qh);
}
}
PointerEventKind::Axis { vertical, .. } => {
// Wheel notches arrive as value120 (÷120) or legacy discrete
// steps, ~3 lines each; touchpads send pixels per cell height.
let (raw, scale) = if vertical.value120 != 0 {
(f64::from(vertical.value120) / 120.0, 3.0)
} else if vertical.discrete != 0 {
(f64::from(vertical.discrete), 3.0)
} else if cell_h > 0.0 {
(vertical.absolute / cell_h, 1.0)
} else {
continue;
};
if raw == 0.0 {
continue;
}
let mult = self.config.mouse.scroll_multiplier.max(0.0);
let lines = (raw.abs() * scale * mult).ceil().max(1.0) as isize;
let up = raw < 0.0;
// Reporting apps get wheel buttons (64 up / 65 down) as
// presses, one per line, capped so a flick cannot flood.
if self.mouse_reporting() {
let code = if up { 64 } else { 65 };
for _ in 0..lines.clamp(1, 8) {
self.try_report_button(code, true);
}
continue;
}
// On the alternate screen there is no scrollback to move, so
// (when enabled) translate the wheel into cursor-key presses
// for apps that did not request mouse reporting.
let alt = self
.session
.as_ref()
.is_some_and(|s| s.term.grid().alt_active());
if alt && self.config.mouse.alternate_scroll {
self.alternate_scroll(up, lines.clamp(1, 8));
continue;
}
// Positive axis = scroll down (toward live); the viewport
// scrolls the opposite way (negative offset delta).
let delta = if up { lines } else { -lines };
if let Some(session) = self.session.as_mut() {
session.term.scroll_view(delta);
self.needs_draw = true;
}
}
_ => {}
}
}
}
}
impl OutputHandler for App {
fn output_state(&mut self) -> &mut OutputState {
&mut self.output_state
}
fn new_output(&mut self, _: &Connection, _: &QueueHandle<Self>, _: wl_output::WlOutput) {}
fn update_output(&mut self, _: &Connection, _: &QueueHandle<Self>, _: wl_output::WlOutput) {}
fn output_destroyed(&mut self, _: &Connection, _: &QueueHandle<Self>, _: wl_output::WlOutput) {}
}
impl ProvidesRegistryState for App {
fn registry(&mut self) -> &mut RegistryState {
&mut self.registry_state
}
registry_handlers![OutputState, SeatState];
}
/// Serve the held clipboard text when a paste target requests it.
fn serve(text: &str, fd: WritePipe) {
let mut file = File::from(OwnedFd::from(fd));
let _ = file.write_all(text.as_bytes());
}
impl DataDeviceHandler for App {
fn enter(
&mut self,
_: &Connection,
_: &QueueHandle<Self>,
_: &WlDataDevice,
_: f64,
_: f64,
_: &wl_surface::WlSurface,
) {
}
fn leave(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &WlDataDevice) {}
fn motion(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &WlDataDevice, _: f64, _: f64) {}
fn selection(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &WlDataDevice) {}
fn drop_performed(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &WlDataDevice) {}
}
impl DataOfferHandler for App {
fn source_actions(
&mut self,
_: &Connection,
_: &QueueHandle<Self>,
_: &mut DragOffer,
_: DndAction,
) {
}
fn selected_action(
&mut self,
_: &Connection,
_: &QueueHandle<Self>,
_: &mut DragOffer,
_: DndAction,
) {
}
}
impl DataSourceHandler for App {
fn accept_mime(
&mut self,
_: &Connection,
_: &QueueHandle<Self>,
_: &WlDataSource,
_: Option<String>,
) {
}
fn send_request(
&mut self,
_: &Connection,
_: &QueueHandle<Self>,
source: &WlDataSource,
_mime: String,
fd: WritePipe,
) {
if self
.copy_source
.as_ref()
.is_some_and(|s| s.inner() == source)
{
serve(&self.clipboard, fd);
}
}
fn cancelled(&mut self, _: &Connection, _: &QueueHandle<Self>, source: &WlDataSource) {
if self
.copy_source
.as_ref()
.is_some_and(|s| s.inner() == source)
{
self.copy_source = None;
}
}
fn dnd_dropped(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &WlDataSource) {}
fn dnd_finished(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &WlDataSource) {}
fn action(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &WlDataSource, _: DndAction) {}
}
impl PrimarySelectionDeviceHandler for App {
fn selection(
&mut self,
_: &Connection,
_: &QueueHandle<Self>,
_: &ZwpPrimarySelectionDeviceV1,
) {
}
}
impl PrimarySelectionSourceHandler for App {
fn send_request(
&mut self,
_: &Connection,
_: &QueueHandle<Self>,
source: &ZwpPrimarySelectionSourceV1,
_mime: String,
fd: WritePipe,
) {
if self
.primary_source
.as_ref()
.is_some_and(|s| s.inner() == source)
{
serve(&self.primary_clip, fd);
}
}
fn cancelled(
&mut self,
_: &Connection,
_: &QueueHandle<Self>,
source: &ZwpPrimarySelectionSourceV1,
) {
if self
.primary_source
.as_ref()
.is_some_and(|s| s.inner() == source)
{
self.primary_source = None;
}
}
}
// Fractional-scale and viewporter are not wrapped by sctk, so dispatch them by
// hand. Only the fractional-scale object carries an event we act on.
impl Dispatch<WpFractionalScaleV1, ()> for App {
fn event(
state: &mut Self,
_: &WpFractionalScaleV1,
event: wp_fractional_scale_v1::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
if let wp_fractional_scale_v1::Event::PreferredScale { scale } = event {
state.set_scale(scale);
}
}
}
impl Dispatch<WpFractionalScaleManagerV1, ()> for App {
fn event(
_: &mut Self,
_: &WpFractionalScaleManagerV1,
_: <WpFractionalScaleManagerV1 as Proxy>::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
}
}
impl Dispatch<WpViewporter, ()> for App {
fn event(
_: &mut Self,
_: &WpViewporter,
_: <WpViewporter as Proxy>::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
}
}
impl Dispatch<WpViewport, ()> for App {
fn event(
_: &mut Self,
_: &WpViewport,
_: <WpViewport as Proxy>::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
}
}
impl ActivationHandler for App {
type RequestData = RequestData;
fn new_token(&mut self, token: String, _: &RequestData) {
// The compositor granted an activation token; use it to draw attention.
if let Some(activation) = self.activation.as_ref() {
activation.activate::<App>(self.window.wl_surface(), token);
}
}
}
impl Dispatch<ZwpTextInputManagerV3, ()> for App {
fn event(
_: &mut Self,
_: &ZwpTextInputManagerV3,
_: <ZwpTextInputManagerV3 as Proxy>::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
}
}
// text-input-v3 batches preedit/commit between `enter` and `done`; we apply the
// accumulated transaction on `done` and re-enable on focus enter.
impl Dispatch<ZwpTextInputV3, ()> for App {
fn event(
state: &mut Self,
ti: &ZwpTextInputV3,
event: zwp_text_input_v3::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
use zwp_text_input_v3::Event;
match event {
Event::Enter { .. } => {
ti.enable();
ti.set_content_type(ContentHint::None, ContentPurpose::Terminal);
state.ime_set_cursor_rect(ti);
ti.commit();
}
Event::Leave { .. } => {
ti.disable();
ti.commit();
state.preedit.clear();
state.ime_preedit_pending.clear();
state.ime_commit_pending.clear();
state.needs_draw = true;
}
Event::PreeditString { text, .. } => {
state.ime_preedit_pending = text.unwrap_or_default();
}
Event::CommitString { text } => {
state.ime_commit_pending.push_str(&text.unwrap_or_default());
}
Event::Done { .. } => state.ime_done(ti),
// We do not expose surrounding text, so nothing to delete.
Event::DeleteSurroundingText { .. } => {}
_ => {}
}
}
}
delegate_compositor!(App);
delegate_output!(App);
delegate_shm!(App);
delegate_seat!(App);
delegate_keyboard!(App);
delegate_pointer!(App);
delegate_xdg_shell!(App);
delegate_xdg_window!(App);
delegate_data_device!(App);
delegate_primary_selection!(App);
delegate_registry!(App);
delegate_activation!(App);

View file

@ -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<Cell>,
/// `(col, shape, focused)` when the cursor is drawn on this row.
cursor: Option<(usize, CursorShape, bool)>,
/// Inclusive selected column span on this row.
sel: Option<(usize, usize)>,
/// Search-match spans `(lo, hi, is_current)` highlighted on this row.
search: Vec<(usize, usize, bool)>,
/// Search-prompt text drawn over this row (only the bottom row, when active).
overlay: Option<String>,
/// IME preedit `(start_col, text)` drawn inline over this row (cursor row).
preedit: Option<(usize, String)>,
/// Blink phase, but only varied when the row actually has blinking ink, so
/// non-blinking rows stay equal across phase toggles.
blink: bool,
}
/// One shm buffer plus the per-row snapshot of what it currently displays.
#[derive(Debug)]
struct FrameBuf {
buffer: Buffer,
rows: Vec<RowSnap>,
}
/// Snapshot the determinants of viewport row `y`'s pixels.
fn row_snap(grid: &Grid, y: usize, focused: bool, blink_on: bool) -> RowSnap {
let abs = grid.view_to_abs(y);
let cells = grid.view_row(y).to_vec();
let cursor = if grid.view_at_bottom() && grid.cursor().1 == y {
let visible = grid.cursor_visible() && (!grid.cursor_blink() || blink_on);
visible.then(|| (grid.cursor().0, grid.cursor_shape(), focused))
} else {
None
};
let has_blink = cells
.iter()
.any(|c| c.flags.contains(crate::grid::Flags::BLINK));
RowSnap {
cells,
cursor,
sel: grid.selection_span_on(abs),
search: grid.search_spans_on(abs),
overlay: None,
preedit: None,
blink: if has_blink { blink_on } else { true },
}
}
/// Fallback window size in pixels if the configured geometry yields nothing.
const DEFAULT_W: u32 = 800;
const DEFAULT_H: u32 = 600;
@ -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<RowSnap> = (0..rows)
.map(|y| row_snap(grid, y, focused, blink_on))
.collect();
// The search prompt occupies the bottom row while search mode is active.
// Recording it in the snapshot keeps the row's damage/diff correct.
let bar_text = self.searching.then(|| {
let (n, total) = grid.search_count();
format!(
"search: {} [{n}/{total}]",
grid.search_query().unwrap_or("")
)
});
if let Some(text) = &bar_text
&& rows > 0
{
cur[rows - 1].overlay = Some(text.clone());
}
// The IME preedit is drawn inline at the cursor while composing.
if !self.preedit.is_empty() && grid.view_at_bottom() {
let (cx, cy) = grid.cursor();
if cy < rows {
cur[cy].preedit = Some((cx, self.preedit.clone()));
}
}
// Reuse a buffer the compositor has released, else grow the ring.
let stride = w as i32 * 4;
let mut idx = None;
for i in 0..self.frames.len() {
if self.pool.canvas(&self.frames[i].buffer).is_some() {
idx = Some(i);
break;
}
}
let idx = match idx {
Some(i) => i,
None if self.frames.len() < MAX_BUFFERS => {
match self
.pool
.create_buffer(w as i32, h as i32, stride, wl_shm::Format::Argb8888)
{
Ok((buffer, _)) => {
self.frames.push(FrameBuf {
buffer,
rows: Vec::new(),
});
self.frames.len() - 1
}
Err(err) => {
tracing::error!("allocate shm buffer: {err}");
return;
}
}
}
// All buffers are still held by the compositor; a release event will
// wake us and `needs_draw` (re-set below) retries then.
None => {
self.needs_draw = true;
return;
}
};
// Rows that differ from what this buffer last showed (all, if fresh).
let prev = &self.frames[idx].rows;
let dirty: Vec<usize> = (0..rows)
.filter(|&y| prev.get(y) != Some(&cur[y]))
.collect();
if dirty.is_empty() {
return;
}
// A buffer used for the first time has uninitialized margins; paint the
// whole thing (background + padding) once, then damage it in full below.
let fresh = self.frames[idx].rows.is_empty();
let pad_y = self.to_phys(self.config.main.pad_y) as i32;
let Some(canvas) = self.pool.canvas(&self.frames[idx].buffer) else {
return;
};
let dims = (w as usize, h as usize);
let frame = crate::render::Frame {
theme,
focused,
blink_on,
hovered_link: self.hovered_link,
};
if fresh {
self.renderer.clear(canvas, dims, theme);
}
for &y in &dirty {
self.renderer.render_row(canvas, dims, grid, &frame, y);
}
// Draw the search prompt over the (now repainted) bottom row.
if let Some(text) = &bar_text
&& dirty.contains(&(rows - 1))
{
self.renderer
.render_search_bar(canvas, dims, theme, rows - 1, text);
}
// Draw the IME preedit inline over its (repainted) cursor row.
for &y in &dirty {
if let Some((col, text)) = &cur[y].preedit {
self.renderer
.render_preedit(canvas, dims, theme, y, *col, text);
}
}
// Draw URL hint labels on top, narrowing to those matching the input.
if self.url_mode {
for (hit, label) in self.url_hits.iter().zip(&self.url_labels) {
if label.starts_with(&self.url_input) {
self.renderer
.render_label(canvas, dims, theme, hit.row, hit.col, label);
}
}
}
self.frames[idx].rows = cur;
let surface = self.window.wl_surface();
if let Err(err) = self.frames[idx].buffer.attach_to(surface) {
tracing::error!("attach buffer: {err}");
return;
}
// With a viewport the buffer is presented at the logical destination, so
// its own scale stays 1; without one, fall back to integer buffer scale.
if let Some(vp) = &self.viewport {
surface.set_buffer_scale(1);
vp.set_destination(self.width.max(1) as i32, self.height.max(1) as i32);
} else {
surface.set_buffer_scale((self.scale120 / 120).max(1) as i32);
}
if fresh {
surface.damage_buffer(0, 0, w as i32, h as i32);
} else {
for &y in &dirty {
let top = pad_y + y as i32 * m.height as i32;
surface.damage_buffer(0, top, w as i32, m.height as i32);
}
}
surface.frame(&self.qh, surface.clone());
self.window.commit();
self.frame_pending = true;
}
}
impl CompositorHandler for App {
fn scale_factor_changed(
&mut self,
_: &Connection,
_: &QueueHandle<Self>,
_: &wl_surface::WlSurface,
factor: i32,
) {
// Integer fallback for compositors without fractional-scale-v1; ignored
// when the fractional-scale object drives the scale instead.
if self.fractional_scale.is_none() {
self.set_scale((factor.max(1) as u32) * 120);
}
}
fn transform_changed(
&mut self,
_: &Connection,
_: &QueueHandle<Self>,
_: &wl_surface::WlSurface,
_: wl_output::Transform,
) {
}
fn frame(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &wl_surface::WlSurface, _: u32) {
// The compositor is ready for another frame; `flush` will repaint if the
// grid has changed since the last present.
self.frame_pending = false;
}
fn surface_enter(
&mut self,
_: &Connection,
_: &QueueHandle<Self>,
_: &wl_surface::WlSurface,
_: &wl_output::WlOutput,
) {
}
fn surface_leave(
&mut self,
_: &Connection,
_: &QueueHandle<Self>,
_: &wl_surface::WlSurface,
_: &wl_output::WlOutput,
) {
}
}
impl WindowHandler for App {
fn request_close(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &Window) {
self.exit = true;
}
fn configure(
&mut self,
_: &Connection,
_: &QueueHandle<Self>,
_: &Window,
configure: WindowConfigure,
_serial: u32,
) {
if let (Some(w), Some(h)) = configure.new_size {
self.width = w.get();
self.height = h.get();
if let Some(vp) = &self.viewport {
vp.set_destination(self.width.max(1) as i32, self.height.max(1) as i32);
}
}
self.focused = configure.is_activated();
if self.session.is_none() {
self.spawn_session();
} else {
self.resize_grid();
}
self.needs_draw = true;
}
}
impl ShmHandler for App {
fn shm_state(&mut self) -> &mut Shm {
&mut self.shm
}
}
impl SeatHandler for App {
fn seat_state(&mut self) -> &mut SeatState {
&mut self.seat_state
}
fn new_seat(&mut self, _: &Connection, qh: &QueueHandle<Self>, seat: wl_seat::WlSeat) {
// Clipboard/primary devices are seat-scoped, not capability-scoped.
let data_device = Some(self.data_device_manager.get_data_device(qh, &seat));
let primary_device = self
.primary_manager
.as_ref()
.map(|m| m.get_selection_device(qh, &seat));
let i = self.seat_index(&seat);
self.seats[i].data_device = data_device;
self.seats[i].primary_device = primary_device;
}
fn new_capability(
&mut self,
_: &Connection,
qh: &QueueHandle<Self>,
seat: wl_seat::WlSeat,
capability: Capability,
) {
let i = self.seat_index(&seat);
if capability == Capability::Keyboard && self.seats[i].keyboard.is_none() {
// get_keyboard_with_repeat drives key repeat off a calloop timer and
// delivers each repeat through the callback.
let loop_handle = self.loop_handle.clone();
let keyboard = self.seat_state.get_keyboard_with_repeat(
qh,
&seat,
None,
loop_handle,
Box::new(|app: &mut App, _kbd, event| app.handle_key(&event)),
);
match keyboard {
Ok(keyboard) => self.seats[i].keyboard = Some(keyboard),
Err(err) => tracing::warn!("get keyboard: {err}"),
}
if self.seats[i].text_input.is_none()
&& let Some(mgr) = self.text_input_manager.as_ref()
{
self.seats[i].text_input = Some(mgr.get_text_input(&seat, qh, ()));
}
}
if capability == Capability::Pointer && self.seats[i].pointer.is_none() {
match self.seat_state.get_pointer(qh, &seat) {
Ok(pointer) => {
self.seats[i].cursor_shape_device = self
.cursor_shape_manager
.as_ref()
.map(|m| m.get_shape_device(&pointer, qh));
self.seats[i].pointer = Some(pointer);
}
Err(err) => tracing::warn!("get pointer: {err}"),
}
}
}
fn remove_capability(
&mut self,
_: &Connection,
_: &QueueHandle<Self>,
seat: wl_seat::WlSeat,
capability: Capability,
) {
let Some(s) = self.seats.iter_mut().find(|s| s.seat == seat) else {
return;
};
match capability {
Capability::Keyboard => {
if let Some(keyboard) = s.keyboard.take() {
keyboard.release();
}
}
Capability::Pointer => {
s.cursor_shape_device = None;
if let Some(pointer) = s.pointer.take() {
pointer.release();
}
}
_ => {}
}
}
fn remove_seat(&mut self, _: &Connection, _: &QueueHandle<Self>, seat: wl_seat::WlSeat) {
self.seats.retain(|s| s.seat != seat);
self.active_seat = self.active_seat.min(self.seats.len().saturating_sub(1));
}
}
impl KeyboardHandler for App {
fn enter(
&mut self,
_: &Connection,
_: &QueueHandle<Self>,
keyboard: &wl_keyboard::WlKeyboard,
_: &wl_surface::WlSurface,
serial: u32,
_: &[u32],
_: &[Keysym],
) {
self.activate_keyboard(keyboard);
self.serial = serial;
self.focused = true;
self.report_focus(true);
self.needs_draw = true;
}
fn leave(
&mut self,
_: &Connection,
_: &QueueHandle<Self>,
_: &wl_keyboard::WlKeyboard,
_: &wl_surface::WlSurface,
_: u32,
) {
self.focused = false;
self.report_focus(false);
self.needs_draw = true;
}
fn press_key(
&mut self,
_: &Connection,
_: &QueueHandle<Self>,
keyboard: &wl_keyboard::WlKeyboard,
serial: u32,
event: KeyEvent,
) {
self.activate_keyboard(keyboard);
self.serial = serial;
self.handle_key(&event);
}
fn repeat_key(
&mut self,
_: &Connection,
_: &QueueHandle<Self>,
_: &wl_keyboard::WlKeyboard,
_: u32,
_: KeyEvent,
) {
// Repeats are delivered through the get_keyboard_with_repeat callback;
// this non-calloop hook is unused.
}
fn release_key(
&mut self,
_: &Connection,
_: &QueueHandle<Self>,
_: &wl_keyboard::WlKeyboard,
_: u32,
_: KeyEvent,
) {
}
fn update_modifiers(
&mut self,
_: &Connection,
_: &QueueHandle<Self>,
_: &wl_keyboard::WlKeyboard,
_: u32,
modifiers: Modifiers,
_: RawModifiers,
_: u32,
) {
self.modifiers = modifiers;
}
fn update_repeat_info(
&mut self,
_: &Connection,
_: &QueueHandle<Self>,
_: &wl_keyboard::WlKeyboard,
_: RepeatInfo,
) {
}
}
/// Parse a configured cursor-style name into a [`CursorShape`].
fn cursor_shape_from(style: Option<&str>) -> Option<CursorShape> {
match style? {
"block" => Some(CursorShape::Block),
"beam" | "bar" => Some(CursorShape::Beam),
"underline" => Some(CursorShape::Underline),
other => {
tracing::warn!("unknown cursor style {other:?}");
None
}
}
}
/// Generate `n` distinct keyboard hint labels (a, b, …, z, aa, ab, …), all the
/// same length so prefix matching is unambiguous.
fn hint_labels(n: usize) -> Vec<String> {
const ALPHABET: &[u8] = b"abcdefghijklmnopqrstuvwxyz";
if n == 0 {
return Vec::new();
}
let (mut width, mut capacity) = (1usize, 26usize);
while capacity < n {
width += 1;
capacity *= 26;
}
(0..n)
.map(|i| {
let mut idx = i;
let mut chars = vec![b'a'; width];
for slot in chars.iter_mut().rev() {
*slot = ALPHABET[idx % 26];
idx /= 26;
}
String::from_utf8(chars).expect("ascii labels are valid utf-8")
})
.collect()
}
/// Map a Wayland button code to the terminal mouse base code, if reportable.
fn button_code(button: u32) -> Option<u8> {
match button {
BTN_LEFT => Some(0),
BTN_MIDDLE => Some(1),
BTN_RIGHT => Some(2),
_ => None,
}
}
impl PointerHandler for App {
fn pointer_frame(
&mut self,
_: &Connection,
qh: &QueueHandle<Self>,
pointer: &wl_pointer::WlPointer,
events: &[PointerEvent],
) {
self.activate_pointer(pointer);
let cell_h = f64::from(self.renderer.metrics().height);
for event in events {
match &event.kind {
PointerEventKind::Enter { serial } => {
self.pointer_pos = event.position;
self.pointer_enter_serial = *serial;
self.update_hover(pointer);
self.pointer_drag();
}
PointerEventKind::Motion { .. } => {
self.pointer_pos = event.position;
if self.try_report_motion() {
continue;
}
if !self.selecting {
self.update_hover(pointer);
}
self.pointer_drag();
}
PointerEventKind::Press {
button,
serial,
time,
..
} => {
self.serial = *serial;
self.pointer_pos = event.position;
if let Some(code) = button_code(*button)
&& self.try_report_button(code, true)
{
self.pressed_button = Some(code);
continue;
}
match *button {
BTN_LEFT => {
self.press_cell = self.cell_at(self.pointer_pos.0, self.pointer_pos.1);
self.pointer_press(*time);
}
BTN_MIDDLE => self.paste_primary(),
_ => {}
}
}
PointerEventKind::Release { button, .. } => {
self.pointer_pos = event.position;
let code = button_code(*button);
if let Some(code) = code
&& self.try_report_button(code, false)
{
if self.pressed_button == Some(code) {
self.pressed_button = None;
}
continue;
}
if *button == BTN_LEFT {
self.maybe_open_clicked_link();
self.pointer_release(qh);
}
}
PointerEventKind::Axis { vertical, .. } => {
// Wheel notches arrive as value120 (÷120) or legacy discrete
// steps, ~3 lines each; touchpads send pixels per cell height.
let (raw, scale) = if vertical.value120 != 0 {
(f64::from(vertical.value120) / 120.0, 3.0)
} else if vertical.discrete != 0 {
(f64::from(vertical.discrete), 3.0)
} else if cell_h > 0.0 {
(vertical.absolute / cell_h, 1.0)
} else {
continue;
};
if raw == 0.0 {
continue;
}
let mult = self.config.mouse.scroll_multiplier.max(0.0);
let lines = (raw.abs() * scale * mult).ceil().max(1.0) as isize;
let up = raw < 0.0;
// Reporting apps get wheel buttons (64 up / 65 down) as
// presses, one per line, capped so a flick cannot flood.
if self.mouse_reporting() {
let code = if up { 64 } else { 65 };
for _ in 0..lines.clamp(1, 8) {
self.try_report_button(code, true);
}
continue;
}
// On the alternate screen there is no scrollback to move, so
// (when enabled) translate the wheel into cursor-key presses
// for apps that did not request mouse reporting.
let alt = self
.session
.as_ref()
.is_some_and(|s| s.term.grid().alt_active());
if alt && self.config.mouse.alternate_scroll {
self.alternate_scroll(up, lines.clamp(1, 8));
continue;
}
// Positive axis = scroll down (toward live); the viewport
// scrolls the opposite way (negative offset delta).
let delta = if up { lines } else { -lines };
if let Some(session) = self.session.as_mut() {
session.term.scroll_view(delta);
self.needs_draw = true;
}
}
_ => {}
}
}
}
}
impl OutputHandler for App {
fn output_state(&mut self) -> &mut OutputState {
&mut self.output_state
}
fn new_output(&mut self, _: &Connection, _: &QueueHandle<Self>, _: wl_output::WlOutput) {}
fn update_output(&mut self, _: &Connection, _: &QueueHandle<Self>, _: wl_output::WlOutput) {}
fn output_destroyed(&mut self, _: &Connection, _: &QueueHandle<Self>, _: wl_output::WlOutput) {}
}
impl ProvidesRegistryState for App {
fn registry(&mut self) -> &mut RegistryState {
&mut self.registry_state
}
registry_handlers![OutputState, SeatState];
}
/// Serve the held clipboard text when a paste target requests it.
fn serve(text: &str, fd: WritePipe) {
let mut file = File::from(OwnedFd::from(fd));
let _ = file.write_all(text.as_bytes());
}
impl DataDeviceHandler for App {
fn enter(
&mut self,
_: &Connection,
_: &QueueHandle<Self>,
_: &WlDataDevice,
_: f64,
_: f64,
_: &wl_surface::WlSurface,
) {
}
fn leave(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &WlDataDevice) {}
fn motion(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &WlDataDevice, _: f64, _: f64) {}
fn selection(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &WlDataDevice) {}
fn drop_performed(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &WlDataDevice) {}
}
impl DataOfferHandler for App {
fn source_actions(
&mut self,
_: &Connection,
_: &QueueHandle<Self>,
_: &mut DragOffer,
_: DndAction,
) {
}
fn selected_action(
&mut self,
_: &Connection,
_: &QueueHandle<Self>,
_: &mut DragOffer,
_: DndAction,
) {
}
}
impl DataSourceHandler for App {
fn accept_mime(
&mut self,
_: &Connection,
_: &QueueHandle<Self>,
_: &WlDataSource,
_: Option<String>,
) {
}
fn send_request(
&mut self,
_: &Connection,
_: &QueueHandle<Self>,
source: &WlDataSource,
_mime: String,
fd: WritePipe,
) {
if self
.copy_source
.as_ref()
.is_some_and(|s| s.inner() == source)
{
serve(&self.clipboard, fd);
}
}
fn cancelled(&mut self, _: &Connection, _: &QueueHandle<Self>, source: &WlDataSource) {
if self
.copy_source
.as_ref()
.is_some_and(|s| s.inner() == source)
{
self.copy_source = None;
}
}
fn dnd_dropped(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &WlDataSource) {}
fn dnd_finished(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &WlDataSource) {}
fn action(&mut self, _: &Connection, _: &QueueHandle<Self>, _: &WlDataSource, _: DndAction) {}
}
impl PrimarySelectionDeviceHandler for App {
fn selection(
&mut self,
_: &Connection,
_: &QueueHandle<Self>,
_: &ZwpPrimarySelectionDeviceV1,
) {
}
}
impl PrimarySelectionSourceHandler for App {
fn send_request(
&mut self,
_: &Connection,
_: &QueueHandle<Self>,
source: &ZwpPrimarySelectionSourceV1,
_mime: String,
fd: WritePipe,
) {
if self
.primary_source
.as_ref()
.is_some_and(|s| s.inner() == source)
{
serve(&self.primary_clip, fd);
}
}
fn cancelled(
&mut self,
_: &Connection,
_: &QueueHandle<Self>,
source: &ZwpPrimarySelectionSourceV1,
) {
if self
.primary_source
.as_ref()
.is_some_and(|s| s.inner() == source)
{
self.primary_source = None;
}
}
}
// Fractional-scale and viewporter are not wrapped by sctk, so dispatch them by
// hand. Only the fractional-scale object carries an event we act on.
impl Dispatch<WpFractionalScaleV1, ()> for App {
fn event(
state: &mut Self,
_: &WpFractionalScaleV1,
event: wp_fractional_scale_v1::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
if let wp_fractional_scale_v1::Event::PreferredScale { scale } = event {
state.set_scale(scale);
}
}
}
impl Dispatch<WpFractionalScaleManagerV1, ()> for App {
fn event(
_: &mut Self,
_: &WpFractionalScaleManagerV1,
_: <WpFractionalScaleManagerV1 as Proxy>::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
}
}
impl Dispatch<WpViewporter, ()> for App {
fn event(
_: &mut Self,
_: &WpViewporter,
_: <WpViewporter as Proxy>::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
}
}
impl Dispatch<WpViewport, ()> for App {
fn event(
_: &mut Self,
_: &WpViewport,
_: <WpViewport as Proxy>::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
}
}
impl ActivationHandler for App {
type RequestData = RequestData;
fn new_token(&mut self, token: String, _: &RequestData) {
// The compositor granted an activation token; use it to draw attention.
if let Some(activation) = self.activation.as_ref() {
activation.activate::<App>(self.window.wl_surface(), token);
}
}
}
impl Dispatch<ZwpTextInputManagerV3, ()> for App {
fn event(
_: &mut Self,
_: &ZwpTextInputManagerV3,
_: <ZwpTextInputManagerV3 as Proxy>::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
}
}
// text-input-v3 batches preedit/commit between `enter` and `done`; we apply the
// accumulated transaction on `done` and re-enable on focus enter.
impl Dispatch<ZwpTextInputV3, ()> for App {
fn event(
state: &mut Self,
ti: &ZwpTextInputV3,
event: zwp_text_input_v3::Event,
_: &(),
_: &Connection,
_: &QueueHandle<Self>,
) {
use zwp_text_input_v3::Event;
match event {
Event::Enter { .. } => {
ti.enable();
ti.set_content_type(ContentHint::None, ContentPurpose::Terminal);
state.ime_set_cursor_rect(ti);
ti.commit();
}
Event::Leave { .. } => {
ti.disable();
ti.commit();
state.preedit.clear();
state.ime_preedit_pending.clear();
state.ime_commit_pending.clear();
state.needs_draw = true;
}
Event::PreeditString { text, .. } => {
state.ime_preedit_pending = text.unwrap_or_default();
}
Event::CommitString { text } => {
state.ime_commit_pending.push_str(&text.unwrap_or_default());
}
Event::Done { .. } => state.ime_done(ti),
// We do not expose surrounding text, so nothing to delete.
Event::DeleteSurroundingText { .. } => {}
_ => {}
}
}
}
delegate_compositor!(App);
delegate_output!(App);
delegate_shm!(App);
delegate_seat!(App);
delegate_keyboard!(App);
delegate_pointer!(App);
delegate_xdg_shell!(App);
delegate_xdg_window!(App);
delegate_data_device!(App);
delegate_primary_selection!(App);
delegate_registry!(App);
delegate_activation!(App);

227
src/wayland/rendering.rs Normal file
View file

@ -0,0 +1,227 @@
use super::*;
/// What determines one rendered row's pixels: its cells, the cursor on it, the
/// selection span over it, and the blink phase. Two equal `RowSnap`s render
/// identically, so a buffer holding an equal snapshot needs no repaint.
#[derive(Clone, PartialEq, Debug)]
struct RowSnap {
cells: Vec<Cell>,
/// `(col, shape, focused)` when the cursor is drawn on this row.
cursor: Option<(usize, CursorShape, bool)>,
/// Inclusive selected column span on this row.
sel: Option<(usize, usize)>,
/// Search-match spans `(lo, hi, is_current)` highlighted on this row.
search: Vec<(usize, usize, bool)>,
/// Search-prompt text drawn over this row (only the bottom row, when active).
overlay: Option<String>,
/// IME preedit `(start_col, text)` drawn inline over this row (cursor row).
preedit: Option<(usize, String)>,
/// Blink phase, but only varied when the row actually has blinking ink, so
/// non-blinking rows stay equal across phase toggles.
blink: bool,
}
/// One shm buffer plus the per-row snapshot of what it currently displays.
#[derive(Debug)]
pub(super) struct FrameBuf {
buffer: Buffer,
rows: Vec<RowSnap>,
}
/// Snapshot the determinants of viewport row `y`'s pixels.
fn row_snap(grid: &Grid, y: usize, focused: bool, blink_on: bool) -> RowSnap {
let abs = grid.view_to_abs(y);
let cells = grid.view_row(y).to_vec();
let cursor = if grid.view_at_bottom() && grid.cursor().1 == y {
let visible = grid.cursor_visible() && (!grid.cursor_blink() || blink_on);
visible.then(|| (grid.cursor().0, grid.cursor_shape(), focused))
} else {
None
};
let has_blink = cells
.iter()
.any(|c| c.flags.contains(crate::grid::Flags::BLINK));
RowSnap {
cells,
cursor,
sel: grid.selection_span_on(abs),
search: grid.search_spans_on(abs),
overlay: None,
preedit: None,
blink: if has_blink { blink_on } else { true },
}
}
impl App {
/// Render only the rows that changed since the chosen buffer last displayed
/// them, damage just those rows, and commit with a frame-callback request.
pub(super) fn present(&mut self) {
self.needs_draw = false;
// URL hint labels overlay the grid but are not part of the row snapshot,
// so force a full redraw while the labels are showing.
if self.url_mode {
self.frames.clear();
}
// Render into a buffer sized in physical pixels (logical × scale); the
// viewport presents it back at the logical surface size.
let (w, h) = self.phys_dims();
let m = self.renderer.metrics();
let (focused, blink_on) = (self.focused, self.blink_on);
// A resize invalidates every buffer's contents and size.
if self.buf_dims != (w, h) {
self.frames.clear();
self.buf_dims = (w, h);
}
let Some(session) = self.session.as_ref() else {
return;
};
let grid = session.term.grid();
// The visual bell inverts fg/bg for the duration of the flash.
let flashed = self.flashing.then(|| session.term.theme().inverted());
let theme = flashed.as_ref().unwrap_or(session.term.theme());
let rows = grid.rows();
let mut cur: Vec<RowSnap> = (0..rows)
.map(|y| row_snap(grid, y, focused, blink_on))
.collect();
// The search prompt occupies the bottom row while search mode is active.
// Recording it in the snapshot keeps the row's damage/diff correct.
let bar_text = self.searching.then(|| {
let (n, total) = grid.search_count();
format!(
"search: {} [{n}/{total}]",
grid.search_query().unwrap_or("")
)
});
if let Some(text) = &bar_text
&& rows > 0
{
cur[rows - 1].overlay = Some(text.clone());
}
// The IME preedit is drawn inline at the cursor while composing.
if !self.preedit.is_empty() && grid.view_at_bottom() {
let (cx, cy) = grid.cursor();
if cy < rows {
cur[cy].preedit = Some((cx, self.preedit.clone()));
}
}
// Reuse a buffer the compositor has released, else grow the ring.
let stride = w as i32 * 4;
let mut idx = None;
for i in 0..self.frames.len() {
if self.pool.canvas(&self.frames[i].buffer).is_some() {
idx = Some(i);
break;
}
}
let idx = match idx {
Some(i) => i,
None if self.frames.len() < MAX_BUFFERS => {
match self
.pool
.create_buffer(w as i32, h as i32, stride, wl_shm::Format::Argb8888)
{
Ok((buffer, _)) => {
self.frames.push(FrameBuf {
buffer,
rows: Vec::new(),
});
self.frames.len() - 1
}
Err(err) => {
tracing::error!("allocate shm buffer: {err}");
return;
}
}
}
// All buffers are still held by the compositor; a release event will
// wake us and `needs_draw` (re-set below) retries then.
None => {
self.needs_draw = true;
return;
}
};
// Rows that differ from what this buffer last showed (all, if fresh).
let prev = &self.frames[idx].rows;
let dirty: Vec<usize> = (0..rows)
.filter(|&y| prev.get(y) != Some(&cur[y]))
.collect();
if dirty.is_empty() {
return;
}
// A buffer used for the first time has uninitialized margins; paint the
// whole thing (background + padding) once, then damage it in full below.
let fresh = self.frames[idx].rows.is_empty();
let pad_y = self.to_phys(self.config.main.pad_y) as i32;
let Some(canvas) = self.pool.canvas(&self.frames[idx].buffer) else {
return;
};
let dims = (w as usize, h as usize);
let frame = crate::render::Frame {
theme,
focused,
blink_on,
hovered_link: self.hovered_link,
};
if fresh {
self.renderer.clear(canvas, dims, theme);
}
for &y in &dirty {
self.renderer.render_row(canvas, dims, grid, &frame, y);
}
// Draw the search prompt over the (now repainted) bottom row.
if let Some(text) = &bar_text
&& dirty.contains(&(rows - 1))
{
self.renderer
.render_search_bar(canvas, dims, theme, rows - 1, text);
}
// Draw the IME preedit inline over its (repainted) cursor row.
for &y in &dirty {
if let Some((col, text)) = &cur[y].preedit {
self.renderer
.render_preedit(canvas, dims, theme, y, *col, text);
}
}
// Draw URL hint labels on top, narrowing to those matching the input.
if self.url_mode {
for (hit, label) in self.url_hits.iter().zip(&self.url_labels) {
if label.starts_with(&self.url_input) {
self.renderer
.render_label(canvas, dims, theme, hit.row, hit.col, label);
}
}
}
self.frames[idx].rows = cur;
let surface = self.window.wl_surface();
if let Err(err) = self.frames[idx].buffer.attach_to(surface) {
tracing::error!("attach buffer: {err}");
return;
}
// With a viewport the buffer is presented at the logical destination, so
// its own scale stays 1; without one, fall back to integer buffer scale.
if let Some(vp) = &self.viewport {
surface.set_buffer_scale(1);
vp.set_destination(self.width.max(1) as i32, self.height.max(1) as i32);
} else {
surface.set_buffer_scale((self.scale120 / 120).max(1) as i32);
}
if fresh {
surface.damage_buffer(0, 0, w as i32, h as i32);
} else {
for &y in &dirty {
let top = pad_y + y as i32 * m.height as i32;
surface.damage_buffer(0, top, w as i32, m.height as i32);
}
}
surface.frame(&self.qh, surface.clone());
self.window.commit();
self.frame_pending = true;
}
}