forked from NotAShelf/beer
treewide: split terminal core modules
Signed-off-by: NotAShelf <raf@notashelf.dev> Change-Id: I9cace0b7c6995c0fca21ff2cf465ae1f6a6a6964
This commit is contained in:
parent
bf27abc9f4
commit
5cba919c78
13 changed files with 1876 additions and 1700 deletions
155
README.md
155
README.md
|
|
@ -1,20 +1,163 @@
|
||||||
# beer
|
# beer
|
||||||
|
|
||||||
A fast, software-rendered, **Wayland-native** terminal emulator written in Rust.
|
`beer` is a small Wayland-native terminal emulator written in Rust. It renders
|
||||||
Lightweight in dependencies, on disk, and in memory.
|
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
|
## 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
|
```sh
|
||||||
# Enter a devshell for necessary deps
|
# Enter the development shell.
|
||||||
$ nix develop
|
$ nix develop
|
||||||
|
|
||||||
# Build in release mode
|
# Build an optimized local binary.
|
||||||
cargo build --release
|
$ 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
|
## License
|
||||||
|
|
||||||
EUPL-1.2.
|
EUPL-1.2.
|
||||||
|
|
|
||||||
|
|
@ -114,7 +114,7 @@ _jump-prompt-up_, _jump-prompt-down_, _pipe-command-output_, _url-mode_.
|
||||||
```
|
```
|
||||||
[key-bindings]
|
[key-bindings]
|
||||||
"Ctrl+Shift+C" = "copy"
|
"Ctrl+Shift+C" = "copy"
|
||||||
"Ctrl+grave" = "none"
|
"Ctrl+`" = "none"
|
||||||
```
|
```
|
||||||
|
|
||||||
# [shell-integration]
|
# [shell-integration]
|
||||||
|
|
|
||||||
|
|
@ -10,10 +10,12 @@
|
||||||
libxkbcommon,
|
libxkbcommon,
|
||||||
freetype,
|
freetype,
|
||||||
fontconfig,
|
fontconfig,
|
||||||
}:
|
}: let
|
||||||
rustPlatform.buildRustPackage (finalAttrs: {
|
cargoTOML = (lib.importTOML ../Cargo.toml).package.version;
|
||||||
|
in
|
||||||
|
rustPlatform.buildRustPackage (finalAttrs: {
|
||||||
pname = "beer";
|
pname = "beer";
|
||||||
version = "0.0.1";
|
version = cargoTOML.package.version;
|
||||||
|
|
||||||
src = let
|
src = let
|
||||||
fs = lib.fileset;
|
fs = lib.fileset;
|
||||||
|
|
@ -22,7 +24,7 @@ rustPlatform.buildRustPackage (finalAttrs: {
|
||||||
fs.toSource {
|
fs.toSource {
|
||||||
root = s;
|
root = s;
|
||||||
fileset = fs.unions [
|
fileset = fs.unions [
|
||||||
(fs.fileFilter (file: builtins.any file.hasExt ["rs"]) (s + /src))
|
(s + /src)
|
||||||
(s + /Cargo.lock)
|
(s + /Cargo.lock)
|
||||||
(s + /Cargo.toml)
|
(s + /Cargo.toml)
|
||||||
(s + /terminfo)
|
(s + /terminfo)
|
||||||
|
|
@ -72,4 +74,4 @@ rustPlatform.buildRustPackage (finalAttrs: {
|
||||||
mainProgram = "beer";
|
mainProgram = "beer";
|
||||||
platforms = lib.platforms.linux;
|
platforms = lib.platforms.linux;
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
|
|
|
||||||
136
src/grid/links.rs
Normal file
136
src/grid/links.rs
Normal 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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,11 +1,18 @@
|
||||||
//! The terminal screen: a grid of styled cells, a cursor, and the editing
|
//! The terminal screen: a grid of styled cells, a cursor, and the editing
|
||||||
//! operations the VT parser drives.
|
//! operations the VT parser drives.
|
||||||
|
|
||||||
|
mod links;
|
||||||
|
mod search;
|
||||||
|
mod selection;
|
||||||
|
|
||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
use std::num::NonZeroU16;
|
use std::num::NonZeroU16;
|
||||||
|
|
||||||
use unicode_width::UnicodeWidthChar;
|
use unicode_width::UnicodeWidthChar;
|
||||||
|
|
||||||
|
pub use links::UrlHit;
|
||||||
|
use search::SearchState;
|
||||||
|
|
||||||
/// Maximum scrollback lines retained for the main screen.
|
/// Maximum scrollback lines retained for the main screen.
|
||||||
const SCROLLBACK_CAP: usize = 10_000;
|
const SCROLLBACK_CAP: usize = 10_000;
|
||||||
|
|
||||||
|
|
@ -143,67 +150,6 @@ struct Cursor {
|
||||||
y: usize,
|
y: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A URL detected in the visible viewport, with the `(row, col)` of its first
|
|
||||||
/// character in viewport coordinates.
|
|
||||||
#[derive(Clone, PartialEq, Eq, Debug)]
|
|
||||||
pub struct UrlHit {
|
|
||||||
pub url: String,
|
|
||||||
pub row: usize,
|
|
||||||
pub col: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Whether `c` may appear inside a URL (excludes whitespace, controls, and the
|
|
||||||
/// delimiters that conventionally bound a URL in flowing text).
|
|
||||||
fn is_url_char(c: char) -> bool {
|
|
||||||
!c.is_whitespace()
|
|
||||||
&& !c.is_control()
|
|
||||||
&& !matches!(c, '<' | '>' | '"' | '`' | '{' | '}' | '|' | '\\' | '^')
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Find `scheme://…` URLs in `chars`, returning `(start, end)` index ranges.
|
|
||||||
/// The scheme is a run of `[A-Za-z][A-Za-z0-9+.-]*` before `://`; the body runs
|
|
||||||
/// to the first non-URL character, with trailing sentence punctuation trimmed.
|
|
||||||
fn find_urls(chars: &[char]) -> Vec<(usize, usize)> {
|
|
||||||
let mut out = Vec::new();
|
|
||||||
let mut i = 0;
|
|
||||||
while i + 2 < chars.len() {
|
|
||||||
if chars[i] == ':' && chars[i + 1] == '/' && chars[i + 2] == '/' {
|
|
||||||
// Backtrack over the scheme.
|
|
||||||
let mut start = i;
|
|
||||||
while start > 0 {
|
|
||||||
let c = chars[start - 1];
|
|
||||||
if c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.') {
|
|
||||||
start -= 1;
|
|
||||||
} else {
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if start < i && chars[start].is_ascii_alphabetic() {
|
|
||||||
let mut end = i + 3;
|
|
||||||
while end < chars.len() && is_url_char(chars[end]) {
|
|
||||||
end += 1;
|
|
||||||
}
|
|
||||||
// Trim trailing punctuation that is usually sentence-level.
|
|
||||||
while end > i + 3
|
|
||||||
&& matches!(
|
|
||||||
chars[end - 1],
|
|
||||||
'.' | ',' | ';' | ':' | '!' | '?' | ')' | ']' | '\'' | '"'
|
|
||||||
)
|
|
||||||
{
|
|
||||||
end -= 1;
|
|
||||||
}
|
|
||||||
if end > i + 3 {
|
|
||||||
out.push((start, end));
|
|
||||||
i = end;
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
out
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Shell-integration prompt mark on a line (OSC 133): the start of a prompt,
|
/// 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
|
/// the start of typed command input, the start of command output, or the line
|
||||||
/// where the command finished.
|
/// where the command finished.
|
||||||
|
|
@ -244,22 +190,6 @@ pub struct Point {
|
||||||
pub col: usize,
|
pub col: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One scrollback-search hit: a run of `len` cells at absolute `(row, col)`.
|
|
||||||
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
|
|
||||||
struct Match {
|
|
||||||
row: usize,
|
|
||||||
col: usize,
|
|
||||||
len: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Incremental scrollback search: the query, every hit, and the focused one.
|
|
||||||
#[derive(Clone, Debug, Default)]
|
|
||||||
struct SearchState {
|
|
||||||
query: String,
|
|
||||||
matches: Vec<Match>,
|
|
||||||
current: usize,
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The active screen plus cursor, scroll region, and current pen.
|
/// The active screen plus cursor, scroll region, and current pen.
|
||||||
#[derive(Debug)]
|
#[derive(Debug)]
|
||||||
pub struct Grid {
|
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.
|
/// Override the word-delimiter set; `None` keeps the built-in default.
|
||||||
pub fn set_word_delimiters(&mut self, delims: Option<String>) {
|
pub fn set_word_delimiters(&mut self, delims: Option<String>) {
|
||||||
if let Some(d) = delims {
|
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).
|
/// Cells of an absolute row (scrollback first, then the live screen).
|
||||||
fn abs_row(&self, row: usize) -> &[Cell] {
|
fn abs_row(&self, row: usize) -> &[Cell] {
|
||||||
if row < self.scrollback.len() {
|
if row < self.scrollback.len() {
|
||||||
|
|
@ -1247,322 +1106,6 @@ impl Grid {
|
||||||
Some(out)
|
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) {
|
pub fn set_bracketed_paste(&mut self, on: bool) {
|
||||||
self.bracketed_paste = on;
|
self.bracketed_paste = on;
|
||||||
}
|
}
|
||||||
148
src/grid/search.rs
Normal file
148
src/grid/search.rs
Normal 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
190
src/grid/selection.rs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -20,7 +20,7 @@ use crate::config::Config;
|
||||||
|
|
||||||
/// A fast, software-rendered, Wayland-native terminal emulator.
|
/// A fast, software-rendered, Wayland-native terminal emulator.
|
||||||
#[derive(Parse)]
|
#[derive(Parse)]
|
||||||
#[pound(name = "beer", version = "0.0.0")]
|
#[pound(name = "beer", version = "0.2.0")]
|
||||||
struct Cli {
|
struct Cli {
|
||||||
/// Run as a daemon hosting multiple windows.
|
/// Run as a daemon hosting multiple windows.
|
||||||
#[pound(long)]
|
#[pound(long)]
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,10 @@
|
||||||
//! VT emulation: feed bytes through `vte` and drive the [`Grid`].
|
//! VT emulation: feed bytes through `vte` and drive the [`Grid`].
|
||||||
|
|
||||||
|
mod perform;
|
||||||
|
|
||||||
use std::io::Write as _;
|
use std::io::Write as _;
|
||||||
|
|
||||||
use vte::{Params, Perform};
|
use vte::Params;
|
||||||
|
|
||||||
use crate::grid::{
|
use crate::grid::{
|
||||||
Color, CursorShape, Flags, Grid, MouseEncoding, MouseProtocol, PromptKind, Underline,
|
Color, CursorShape, Flags, Grid, MouseEncoding, MouseProtocol, PromptKind, Underline,
|
||||||
|
|
@ -557,243 +559,6 @@ fn dec_special(c: char) -> char {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Perform for Term {
|
|
||||||
fn print(&mut self, c: char) {
|
|
||||||
let c = if self.active_charset() == Charset::DecSpecial {
|
|
||||||
dec_special(c)
|
|
||||||
} else {
|
|
||||||
c
|
|
||||||
};
|
|
||||||
self.grid.print(c);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn execute(&mut self, byte: u8) {
|
|
||||||
match byte {
|
|
||||||
0x07 => self.bell = true,
|
|
||||||
0x08 => self.grid.backspace(),
|
|
||||||
0x09 => self.grid.tab(),
|
|
||||||
0x0A..=0x0C => self.grid.line_feed(),
|
|
||||||
0x0D => self.grid.carriage_return(),
|
|
||||||
0x0E => self.shift_out = true,
|
|
||||||
0x0F => self.shift_out = false,
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn csi_dispatch(&mut self, params: &Params, intermediates: &[u8], _ignore: bool, action: char) {
|
|
||||||
let private = intermediates.first() == Some(&b'?');
|
|
||||||
match action {
|
|
||||||
'A' => self.grid.cursor_up(n(params, 0, 1)),
|
|
||||||
'B' | 'e' => self.grid.cursor_down(n(params, 0, 1)),
|
|
||||||
'C' | 'a' => self.grid.cursor_fwd(n(params, 0, 1)),
|
|
||||||
'D' => self.grid.cursor_back(n(params, 0, 1)),
|
|
||||||
'E' => {
|
|
||||||
self.grid.cursor_down(n(params, 0, 1));
|
|
||||||
self.grid.carriage_return();
|
|
||||||
}
|
|
||||||
'F' => {
|
|
||||||
self.grid.cursor_up(n(params, 0, 1));
|
|
||||||
self.grid.carriage_return();
|
|
||||||
}
|
|
||||||
'G' | '`' => self.grid.move_to_col(n(params, 0, 1) - 1),
|
|
||||||
'd' => self.grid.move_to_row(n(params, 0, 1) - 1),
|
|
||||||
'H' | 'f' => self.grid.move_to(n(params, 1, 1) - 1, n(params, 0, 1) - 1),
|
|
||||||
'J' => self.grid.erase_display(raw(params, 0)),
|
|
||||||
'K' => self.grid.erase_line(raw(params, 0)),
|
|
||||||
'@' => self.grid.insert_chars(n(params, 0, 1)),
|
|
||||||
'P' => self.grid.delete_chars(n(params, 0, 1)),
|
|
||||||
'L' => self.grid.insert_lines(n(params, 0, 1)),
|
|
||||||
'M' => self.grid.delete_lines(n(params, 0, 1)),
|
|
||||||
'X' => self.grid.erase_chars(n(params, 0, 1)),
|
|
||||||
'S' => self.grid.scroll_up(n(params, 0, 1)),
|
|
||||||
'T' => self.grid.scroll_down(n(params, 0, 1)),
|
|
||||||
'm' => self.sgr(params),
|
|
||||||
'r' => {
|
|
||||||
let top = n(params, 0, 1) - 1;
|
|
||||||
let bottom = match params.iter().nth(1).and_then(|p| p.first().copied()) {
|
|
||||||
Some(0) | None => self.grid.rows() - 1,
|
|
||||||
Some(v) => (v as usize).saturating_sub(1),
|
|
||||||
};
|
|
||||||
self.grid.set_scroll_region(top, bottom);
|
|
||||||
}
|
|
||||||
'h' => self.set_mode(params, private, true),
|
|
||||||
'l' => self.set_mode(params, private, false),
|
|
||||||
'c' => self.device_attrs(match intermediates.first() {
|
|
||||||
Some(b'>') => DaLevel::Secondary,
|
|
||||||
Some(b'=') => DaLevel::Tertiary,
|
|
||||||
_ => DaLevel::Primary,
|
|
||||||
}),
|
|
||||||
'q' if intermediates.first() == Some(&b'>') => self.report_version(),
|
|
||||||
'q' if intermediates.first() == Some(&b' ') => {
|
|
||||||
let code = raw(params, 0);
|
|
||||||
self.grid.set_cursor_shape(match code {
|
|
||||||
3 | 4 => CursorShape::Underline,
|
|
||||||
5 | 6 => CursorShape::Beam,
|
|
||||||
_ => CursorShape::Block,
|
|
||||||
});
|
|
||||||
// Even codes are steady; 0/1 and other odd codes blink.
|
|
||||||
self.grid.set_cursor_blink(code == 0 || code % 2 == 1);
|
|
||||||
}
|
|
||||||
'p' if intermediates.contains(&b'$') => self.report_mode(params, private),
|
|
||||||
'n' => self.device_status(params),
|
|
||||||
's' => self.grid.save_cursor(),
|
|
||||||
'u' => self.grid.restore_cursor(),
|
|
||||||
't' => self.title_stack_op(params),
|
|
||||||
'g' => match raw(params, 0) {
|
|
||||||
3 => self.grid.clear_all_tabs(),
|
|
||||||
_ => self.grid.clear_tab(),
|
|
||||||
},
|
|
||||||
_ => tracing::trace!("unhandled CSI {action:?} {intermediates:?}"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn esc_dispatch(&mut self, intermediates: &[u8], _ignore: bool, byte: u8) {
|
|
||||||
match (intermediates.first().copied(), byte) {
|
|
||||||
(None, b'D') => self.grid.line_feed(),
|
|
||||||
(None, b'M') => self.grid.reverse_index(),
|
|
||||||
(None, b'E') => self.grid.next_line(),
|
|
||||||
(None, b'7') => self.grid.save_cursor(),
|
|
||||||
(None, b'8') => self.grid.restore_cursor(),
|
|
||||||
(None, b'H') => self.grid.set_tab(),
|
|
||||||
(None, b'c') => {
|
|
||||||
self.grid.reset_pen();
|
|
||||||
self.grid.set_scroll_region(0, self.grid.rows() - 1);
|
|
||||||
self.grid.set_autowrap(true);
|
|
||||||
self.grid.set_origin(false);
|
|
||||||
self.grid.erase_display(2);
|
|
||||||
self.grid.move_to(0, 0);
|
|
||||||
self.g0 = Charset::Ascii;
|
|
||||||
self.g1 = Charset::Ascii;
|
|
||||||
self.shift_out = false;
|
|
||||||
}
|
|
||||||
(Some(b'('), c) => self.g0 = charset(c),
|
|
||||||
(Some(b')'), c) => self.g1 = charset(c),
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn osc_dispatch(&mut self, params: &[&[u8]], bell: bool) {
|
|
||||||
match params.first() {
|
|
||||||
Some(&n) if n == b"0" || n == b"2" => {
|
|
||||||
if let Some(text) = params.get(1) {
|
|
||||||
self.title = Some(String::from_utf8_lossy(text).into_owned());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// OSC 7: the shell reports its cwd as a `file://host/path` URI.
|
|
||||||
Some(&n) if n == b"7" => {
|
|
||||||
if let Some(uri) = params.get(1) {
|
|
||||||
self.cwd = file_uri_path(uri);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// OSC 133: shell-integration prompt marks (A/B/C/D, with optional
|
|
||||||
// `;key=value` attributes we ignore).
|
|
||||||
Some(&n) if n == b"133" => {
|
|
||||||
if let Some(kind) = params
|
|
||||||
.get(1)
|
|
||||||
.and_then(|p| p.first())
|
|
||||||
.and_then(|&b| prompt_kind(b))
|
|
||||||
{
|
|
||||||
self.grid.set_prompt_mark(kind);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// OSC 8: hyperlink. `OSC 8 ; params ; URI ST`; an empty URI ends the
|
|
||||||
// link. The URI is everything after the second field, rejoined since
|
|
||||||
// a URI may itself contain ';'.
|
|
||||||
Some(&n) if n == b"8" => {
|
|
||||||
let uri_bytes = params
|
|
||||||
.get(2..)
|
|
||||||
.map(|parts| parts.join(&b';'))
|
|
||||||
.unwrap_or_default();
|
|
||||||
let uri = std::str::from_utf8(&uri_bytes).unwrap_or("");
|
|
||||||
self.grid.set_link((!uri.is_empty()).then_some(uri));
|
|
||||||
}
|
|
||||||
// OSC 9: iTerm2-style notification (`OSC 9 ; body`).
|
|
||||||
Some(&n) if n == b"9" => {
|
|
||||||
if let Some(body) = osc_text(params.get(1)) {
|
|
||||||
self.notifications.push(Notification { title: None, body });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// OSC 777: `OSC 777 ; notify ; title ; body`.
|
|
||||||
Some(&n) if n == b"777" && params.get(1) == Some(&&b"notify"[..]) => {
|
|
||||||
let title = osc_text(params.get(2));
|
|
||||||
if let Some(body) = osc_text(params.get(3)) {
|
|
||||||
self.notifications.push(Notification { title, body });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// OSC 99: kitty desktop-notification protocol. We honour the common
|
|
||||||
// single-chunk form, taking the payload as the body and ignoring the
|
|
||||||
// metadata key=value field.
|
|
||||||
Some(&n) if n == b"99" => {
|
|
||||||
if let Some(body) = osc_text(params.get(2)).filter(|b| !b.is_empty()) {
|
|
||||||
self.notifications.push(Notification { title: None, body });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// OSC 4: set/query palette entries (pairs of index;spec).
|
|
||||||
Some(&n) if n == b"4" => self.osc_palette(params, bell),
|
|
||||||
// OSC 104: reset palette (all, or the listed indices).
|
|
||||||
Some(&n) if n == b"104" => {
|
|
||||||
if params.len() <= 1 {
|
|
||||||
self.theme.reset_palette();
|
|
||||||
} else {
|
|
||||||
for p in ¶ms[1..] {
|
|
||||||
if let Some(i) = parse_index(p) {
|
|
||||||
self.theme.reset_palette_index(i);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// OSC 10/11: foreground / background; OSC 110/111 reset them.
|
|
||||||
Some(&n) if n == b"10" => self.osc_dynamic_color(Dynamic::Fg, params.get(1), bell),
|
|
||||||
Some(&n) if n == b"11" => self.osc_dynamic_color(Dynamic::Bg, params.get(1), bell),
|
|
||||||
Some(&n) if n == b"110" => self.theme.reset_fg(),
|
|
||||||
Some(&n) if n == b"111" => self.theme.reset_bg(),
|
|
||||||
// OSC 17/19: selection (highlight) background / foreground.
|
|
||||||
Some(&n) if n == b"17" => self.osc_dynamic_color(Dynamic::SelBg, params.get(1), bell),
|
|
||||||
Some(&n) if n == b"19" => self.osc_dynamic_color(Dynamic::SelFg, params.get(1), bell),
|
|
||||||
// OSC 12: set cursor colour; OSC 112: reset to default.
|
|
||||||
Some(&n) if n == b"12" => {
|
|
||||||
self.grid
|
|
||||||
.set_cursor_color(params.get(1).and_then(|s| parse_spec(s)).map(rgb_tuple));
|
|
||||||
}
|
|
||||||
Some(&n) if n == b"112" => self.grid.set_cursor_color(None),
|
|
||||||
// OSC 52: clipboard get/set. Pc selects the target, Pd is base64 or
|
|
||||||
// `?` to query. We only touch `c` (clipboard) and `p` (primary).
|
|
||||||
Some(&n) if n == b"52" => {
|
|
||||||
let target = params.get(1).copied().unwrap_or(b"");
|
|
||||||
let data = params.get(2).copied().unwrap_or(b"");
|
|
||||||
let primary = target.first() == Some(&b'p');
|
|
||||||
if data == b"?" {
|
|
||||||
self.clipboard_ops.push(ClipboardOp::Query { primary });
|
|
||||||
} else if let Some(text) =
|
|
||||||
base64_decode(data).and_then(|b| String::from_utf8(b).ok())
|
|
||||||
{
|
|
||||||
self.clipboard_ops.push(ClipboardOp::Set { primary, text });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn hook(&mut self, _: &Params, intermediates: &[u8], _: bool, action: char) {
|
|
||||||
// XTGETTCAP arrives as `DCS + q <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.
|
/// Look up a terminfo capability beer reports via XTGETTCAP.
|
||||||
fn cap_value(name: &[u8]) -> Option<&'static str> {
|
fn cap_value(name: &[u8]) -> Option<&'static str> {
|
||||||
match name {
|
match name {
|
||||||
240
src/vt/perform.rs
Normal file
240
src/vt/perform.rs
Normal 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 ¶ms[1..] {
|
||||||
|
if let Some(i) = parse_index(p) {
|
||||||
|
self.theme.reset_palette_index(i);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// OSC 10/11: foreground / background; OSC 110/111 reset them.
|
||||||
|
Some(&n) if n == b"10" => self.osc_dynamic_color(Dynamic::Fg, params.get(1), bell),
|
||||||
|
Some(&n) if n == b"11" => self.osc_dynamic_color(Dynamic::Bg, params.get(1), bell),
|
||||||
|
Some(&n) if n == b"110" => self.theme.reset_fg(),
|
||||||
|
Some(&n) if n == b"111" => self.theme.reset_bg(),
|
||||||
|
// OSC 17/19: selection (highlight) background / foreground.
|
||||||
|
Some(&n) if n == b"17" => self.osc_dynamic_color(Dynamic::SelBg, params.get(1), bell),
|
||||||
|
Some(&n) if n == b"19" => self.osc_dynamic_color(Dynamic::SelFg, params.get(1), bell),
|
||||||
|
// OSC 12: set cursor colour; OSC 112: reset to default.
|
||||||
|
Some(&n) if n == b"12" => {
|
||||||
|
self.grid
|
||||||
|
.set_cursor_color(params.get(1).and_then(|s| parse_spec(s)).map(rgb_tuple));
|
||||||
|
}
|
||||||
|
Some(&n) if n == b"112" => self.grid.set_cursor_color(None),
|
||||||
|
// OSC 52: clipboard get/set. Pc selects the target, Pd is base64 or
|
||||||
|
// `?` to query. We only touch `c` (clipboard) and `p` (primary).
|
||||||
|
Some(&n) if n == b"52" => {
|
||||||
|
let target = params.get(1).copied().unwrap_or(b"");
|
||||||
|
let data = params.get(2).copied().unwrap_or(b"");
|
||||||
|
let primary = target.first() == Some(&b'p');
|
||||||
|
if data == b"?" {
|
||||||
|
self.clipboard_ops.push(ClipboardOp::Query { primary });
|
||||||
|
} else if let Some(text) =
|
||||||
|
base64_decode(data).and_then(|b| String::from_utf8(b).ok())
|
||||||
|
{
|
||||||
|
self.clipboard_ops.push(ClipboardOp::Set { primary, text });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hook(&mut self, _: &Params, intermediates: &[u8], _: bool, action: char) {
|
||||||
|
// XTGETTCAP arrives as `DCS + q <names> ST`.
|
||||||
|
if action == 'q' && intermediates == [b'+'] {
|
||||||
|
self.xtgettcap = Some(Vec::new());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn put(&mut self, byte: u8) {
|
||||||
|
if let Some(buf) = self.xtgettcap.as_mut() {
|
||||||
|
buf.push(byte);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unhook(&mut self) {
|
||||||
|
if let Some(payload) = self.xtgettcap.take() {
|
||||||
|
self.answer_xtgettcap(&payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
711
src/wayland/handlers.rs
Normal file
711
src/wayland/handlers.rs
Normal 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);
|
||||||
|
|
@ -3,6 +3,9 @@
|
||||||
//! Uses smithay-client-toolkit for protocol boilerplate and calloop for the
|
//! Uses smithay-client-toolkit for protocol boilerplate and calloop for the
|
||||||
//! event loop, so the PTY master fd and timers share one loop.
|
//! event loop, so the PTY master fd and timers share one loop.
|
||||||
|
|
||||||
|
mod handlers;
|
||||||
|
mod rendering;
|
||||||
|
|
||||||
use std::fs::File;
|
use std::fs::File;
|
||||||
use std::io::{Read as _, Write as _};
|
use std::io::{Read as _, Write as _};
|
||||||
use std::num::NonZeroU16;
|
use std::num::NonZeroU16;
|
||||||
|
|
@ -93,6 +96,9 @@ use wayland_protocols::wp::viewporter::client::{
|
||||||
wp_viewport::WpViewport, wp_viewporter::WpViewporter,
|
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.
|
/// MIME types beer offers and accepts for clipboard text.
|
||||||
const TEXT_MIMES: &[&str] = &[
|
const TEXT_MIMES: &[&str] = &[
|
||||||
"text/plain;charset=utf-8",
|
"text/plain;charset=utf-8",
|
||||||
|
|
@ -130,58 +136,6 @@ const AUTOSCROLL_MS: u64 = 40;
|
||||||
/// How long the visual bell inverts the screen.
|
/// How long the visual bell inverts the screen.
|
||||||
const FLASH_MS: u64 = 80;
|
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.
|
/// Fallback window size in pixels if the configured geometry yields nothing.
|
||||||
const DEFAULT_W: u32 = 800;
|
const DEFAULT_W: u32 = 800;
|
||||||
const DEFAULT_H: u32 = 600;
|
const DEFAULT_H: u32 = 600;
|
||||||
|
|
@ -1739,887 +1693,4 @@ impl App {
|
||||||
self.present();
|
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
227
src/wayland/rendering.rs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue