commit a536cfaadc2cc7bec3ce64fee9f1a196a33c7422 Author: socatlolmeow Date: Sun Oct 11 20:02:56 2026 +0200 Initial commit diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..cf188be --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,395 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "ab_glyph" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01c0457472c38ea5bd1c3b5ada5e368271cb550be7a4ca4a0b4634e9913f6cc2" +dependencies = [ + "ab_glyph_rasterizer", + "owned_ttf_parser", +] + +[[package]] +name = "ab_glyph_rasterizer" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "366ffbaa4442f4684d91e2cd7c5ea7c4ed8add41959a31447066e279e432b618" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "cc" +version = "1.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50a649af8a827553c29fb0cb4bd4a6f1a0dd695bd3232b9bc98bd9c8a3ffbb8b" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "downcast-rs" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "libc" +version = "0.2.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "memmap2" +version = "0.9.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" +dependencies = [ + "libc", +] + +[[package]] +name = "owned_ttf_parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36820e9051aca1014ddc75770aab4d68bc1e9e632f0f5627c4086bc216fb583b" +dependencies = [ + "ttf-parser", +] + +[[package]] +name = "pkg-config" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "smallvec" +version = "1.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" + +[[package]] +name = "softty" +version = "0.1.0" +dependencies = [ + "ab_glyph", + "libc", + "memmap2", + "serde", + "toml", + "unicode-width", + "wayland-client", + "wayland-protocols", + "xkbcommon", +] + +[[package]] +name = "syn" +version = "3.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d62a2e0561533f2ca2561d0cf27fd9fedb640a1bf2616ff5d5c80d99017faadc" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" + +[[package]] +name = "unicode-ident" +version = "1.0.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "wayland-backend" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" +dependencies = [ + "cc", + "downcast-rs", + "rustix", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" +dependencies = [ + "bitflags", + "rustix", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23d0c813de3daa2ed6520af85a3bd49b0e722a3078506899aa9686fea58dc4b6" +dependencies = [ + "bitflags", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" +dependencies = [ + "proc-macro2", + "quick-xml", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "pkg-config", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "xkbcommon" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d66ca9352cbd4eecbbc40871d8a11b4ac8107cfc528a6e14d7c19c69d0e1ac9" +dependencies = [ + "libc", + "memmap2", + "xkeysym", +] + +[[package]] +name = "xkeysym" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9cc00251562a284751c9973bace760d86c0276c471b4be569fe6b068ee97a56" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..4f7fba4 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,21 @@ +[package] +name = "softty" +version = "0.1.0" +edition = "2021" +rust-version = "1.70" + +[dependencies] +ab_glyph = "0.2" +libc = "0.2" +memmap2 = "0.9" +serde = { version = "1", features = ["derive"] } +toml = "0.8" +wayland-client = "0.31" +wayland-protocols = { version = "0.32", features = ["client", "staging", "unstable"] } +unicode-width = "0.2" +xkbcommon = "0.8" + +[profile.release] +lto = true +codegen-units = 1 +strip = true diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..9c5e358 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,390 @@ +use std::io::{ErrorKind, Write}; +use std::path::{Path, PathBuf}; +use std::sync::OnceLock; + +use serde::{Deserialize, Deserializer}; +use xkbcommon::xkb; +use xkbcommon::xkb::keysyms::{KEY_C, KEY_End, KEY_Home, KEY_Insert, KEY_N, KEY_Next, KEY_Prior, KEY_R, KEY_V, KEY_X, KEY_Z}; + +pub const SHIFT: u8 = 1; +pub const ALT: u8 = 2; +pub const CTRL: u8 = 4; + +#[derive(Clone, Copy, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CursorStyle { + Block, + Beam, + Underline, +} + +#[derive(Clone, Copy, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Action { + Copy, + Paste, + ZoomIn, + ZoomOut, + ZoomReset, + ScrollLineUp, + ScrollLineDown, + ScrollPageUp, + ScrollPageDown, + ScrollTop, + ScrollBottom, + Search, + PrevPrompt, + NextPrompt, + NewWindow, +} + +#[derive(Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct Config { + pub term: String, + pub window: Window, + pub font: Font, + pub mouse: Mouse, + pub scrollback: Scrollback, + pub colors: Colors, + pub cursor: Cursor, + pub shortcuts: Vec, +} + +#[derive(Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct Window { + pub title: String, + pub app_id: String, + pub cols: usize, + pub rows: usize, + pub border: usize, + pub alpha: f32, +} + +#[derive(Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct Font { + pub family: String, + pub size: f32, + pub zoom_step: f32, +} + +#[derive(Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct Mouse { + pub word_delims: String, + pub multi_click_ms: u64, + pub scroll_lines: usize, + pub url_launcher: String, +} + +#[derive(Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct Scrollback { + pub lines: usize, +} + +#[derive(Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct Colors { + #[serde(deserialize_with = "color")] + pub foreground: u32, + #[serde(deserialize_with = "color")] + pub background: u32, + #[serde(deserialize_with = "palette")] + pub palette: [u32; 16], +} + +#[derive(Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct Cursor { + pub style: CursorStyle, + #[serde(deserialize_with = "color")] + pub color: u32, + #[serde(deserialize_with = "color")] + pub text: u32, + pub thickness: usize, +} + +#[derive(Deserialize)] +#[serde(try_from = "RawShortcut")] +pub struct Shortcut { + pub mods: u8, + pub sym: u32, + pub action: Action, +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct RawShortcut { + #[serde(default)] + mods: Vec, + key: String, + action: Action, +} + +impl TryFrom for Shortcut { + type Error = String; + + fn try_from(raw: RawShortcut) -> Result { + let mut mods = 0; + for m in &raw.mods { + mods |= match m.to_ascii_lowercase().as_str() { + "shift" => SHIFT, + "alt" => ALT, + "ctrl" | "control" => CTRL, + _ => return Err(format!("unknown modifier {m:?}, expected \"shift\", \"alt\" or \"ctrl\"")), + }; + } + // Letters arrive as upper case keysyms while shift is held, lower case otherwise. + let name = if raw.key.len() == 1 && raw.key.as_bytes()[0].is_ascii_alphabetic() { + if mods & SHIFT != 0 { raw.key.to_ascii_uppercase() } else { raw.key.to_ascii_lowercase() } + } else { + raw.key.clone() + }; + let sym = xkb::keysym_from_name(&name, xkb::KEYSYM_NO_FLAGS).raw(); + if sym == 0 { + return Err(format!("unknown key {:?}, use an xkb keysym name such as \"C\", \"Insert\" or \"Page_Up\"", raw.key)); + } + Ok(Shortcut { mods, sym, action: raw.action }) + } +} + +fn parse_color(text: &str) -> Result { + let hex = text.trim().trim_start_matches('#').trim_start_matches("0x"); + match u32::from_str_radix(hex, 16) { + Ok(value) if hex.len() == 6 => Ok(value), + _ => Err(format!("invalid color {text:?}, expected \"#rrggbb\"")), + } +} + +fn color<'de, D: Deserializer<'de>>(d: D) -> Result { + parse_color(&String::deserialize(d)?).map_err(serde::de::Error::custom) +} + +fn palette<'de, D: Deserializer<'de>>(d: D) -> Result<[u32; 16], D::Error> { + let list = Vec::::deserialize(d)?; + if list.len() != 16 { + return Err(serde::de::Error::custom(format!("palette needs exactly 16 colors, got {}", list.len()))); + } + let mut out = [0; 16]; + for (slot, text) in out.iter_mut().zip(&list) { + *slot = parse_color(text).map_err(serde::de::Error::custom)?; + } + Ok(out) +} + +impl Default for Config { + fn default() -> Config { + Config { + term: "xterm-256color".into(), + window: Window::default(), + font: Font::default(), + mouse: Mouse::default(), + scrollback: Scrollback::default(), + colors: Colors::default(), + cursor: Cursor::default(), + shortcuts: vec![ + Shortcut { mods: CTRL | SHIFT, sym: KEY_C, action: Action::Copy }, + Shortcut { mods: CTRL | SHIFT, sym: KEY_V, action: Action::Paste }, + Shortcut { mods: CTRL, sym: KEY_Insert, action: Action::Copy }, + Shortcut { mods: SHIFT, sym: KEY_Insert, action: Action::Paste }, + Shortcut { mods: CTRL | SHIFT, sym: KEY_Prior, action: Action::ZoomIn }, + Shortcut { mods: CTRL | SHIFT, sym: KEY_Next, action: Action::ZoomOut }, + Shortcut { mods: CTRL | SHIFT, sym: KEY_Home, action: Action::ZoomReset }, + Shortcut { mods: SHIFT, sym: KEY_Prior, action: Action::ScrollPageUp }, + Shortcut { mods: SHIFT, sym: KEY_Next, action: Action::ScrollPageDown }, + Shortcut { mods: SHIFT, sym: KEY_Home, action: Action::ScrollTop }, + Shortcut { mods: SHIFT, sym: KEY_End, action: Action::ScrollBottom }, + Shortcut { mods: CTRL | SHIFT, sym: KEY_R, action: Action::Search }, + Shortcut { mods: CTRL | SHIFT, sym: KEY_Z, action: Action::PrevPrompt }, + Shortcut { mods: CTRL | SHIFT, sym: KEY_X, action: Action::NextPrompt }, + Shortcut { mods: CTRL | SHIFT, sym: KEY_N, action: Action::NewWindow }, + ], + } + } +} + +impl Default for Window { + fn default() -> Window { + Window { title: "softty".into(), app_id: "softty".into(), cols: 80, rows: 24, border: 10, alpha: 0.5 } + } +} + +impl Default for Font { + fn default() -> Font { + Font { family: "Iosevka Nerd Font".into(), size: 24.0, zoom_step: 2.0 } + } +} + +impl Default for Mouse { + fn default() -> Mouse { + Mouse { word_delims: " `'\"()[]{}<>|&;,".into(), multi_click_ms: 300, scroll_lines: 3, url_launcher: "xdg-open".into() } + } +} + +impl Default for Scrollback { + fn default() -> Scrollback { + Scrollback { lines: 10_000 } + } +} + +impl Default for Colors { + fn default() -> Colors { + Colors { + foreground: 0xa9b1d6, + background: 0x1a1b26, + palette: [ + 0x15161e, 0xf7768e, 0x9ece6a, 0xe0af68, 0x7aa2f7, 0xbb9af7, 0x7dcfff, 0xa9b1d6, + 0x414868, 0xf7768e, 0x9ece6a, 0xe0af68, 0x7aa2f7, 0xbb9af7, 0x7dcfff, 0xc0caf5, + ], + } + } +} + +impl Default for Cursor { + fn default() -> Cursor { + Cursor { style: CursorStyle::Block, color: 0x7aa2f7, text: 0x1a1b26, thickness: 2 } + } +} + +impl Config { + /// Returns the key that is wrong and what is wrong with it. + fn check(&self) -> Result<(), (&'static str, &'static str)> { + if self.window.cols == 0 { + return Err(("cols", "window.cols must be at least 1")); + } + if self.window.rows == 0 { + return Err(("rows", "window.rows must be at least 1")); + } + if !(self.font.size > 0.0) { + return Err(("size", "font.size must be greater than 0")); + } + Ok(()) + } +} + +/// Written to the config path on first run. Every value in it is a default. +const TEMPLATE: &str = include_str!("../config.example.toml"); + +static CONFIG: OnceLock = OnceLock::new(); + +/// The active configuration. Falls back to the defaults if `load` has not run. +pub fn get() -> &'static Config { + CONFIG.get_or_init(Config::default) +} + +/// `$XDG_CONFIG_HOME/softty/config.toml`, which is `~/.config/softty/config.toml` by default. +fn path() -> Option { + let base = match std::env::var_os("XDG_CONFIG_HOME") { + Some(dir) if !dir.is_empty() => PathBuf::from(dir), + _ => PathBuf::from(std::env::var_os("HOME")?).join(".config"), + }; + Some(base.join("softty").join("config.toml")) +} + +/// Reads the config file once at startup. A missing file is created from the template. +/// If the file is unreadable or invalid the defaults are used too, and the +/// returned message says what is wrong and where, ready to show to the user. +pub fn load() -> Option { + let (config, error) = match path().map(|p| read(&p)) { + Some(Err(message)) => (Config::default(), Some(message)), + Some(Ok(config)) => (config, None), + None => (Config::default(), None), + }; + let _ = CONFIG.set(config); + error +} + +fn read(path: &Path) -> Result { + let text = match std::fs::read_to_string(path) { + Ok(text) => text, + Err(e) if e.kind() == ErrorKind::NotFound => { + create_default(path); + return Ok(Config::default()); + } + Err(e) => return Err(format!("{}: cannot read file: {e}", path.display())), + }; + let config: Config = toml::from_str(&text) + .map_err(|e| report(path, &text, e.span().map(|s| s.start), e.message()))?; + config + .check() + .map_err(|(key, message)| report(path, &text, find_key(&text, key), message))?; + Ok(config) +} + +fn create_default(path: &Path) { + if let Some(dir) = path.parent() { + let _ = std::fs::create_dir_all(dir); + } + let created = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(path) + .and_then(|mut file| file.write_all(TEMPLATE.as_bytes())); + match created { + Err(e) if e.kind() != ErrorKind::AlreadyExists => eprintln!("softty: cannot create {}: {e}", path.display()), + _ => {} + } +} + +/// Byte offset of the first line that assigns `key`. +fn find_key(text: &str, key: &str) -> Option { + let mut start = 0; + for line in text.split_inclusive('\n') { + let indent = line.len() - line.trim_start().len(); + if let Some(after) = line[indent..].strip_prefix(key) { + if after.trim_start().starts_with('=') { + return Some(start + indent); + } + } + start += line.len(); + } + None +} + +/// Formats `path:line:col: message`, followed by the offending line and a caret under the spot. +fn report(path: &Path, text: &str, offset: Option, message: &str) -> String { + let Some(offset) = offset else { return format!("{}: {message}", path.display()) }; + let offset = offset.min(text.len()); + let line_no = text[..offset].matches('\n').count() + 1; + let line_start = text[..offset].rfind('\n').map_or(0, |i| i + 1); + let col = text[line_start..offset].chars().count() + 1; + let source = text[line_start..].lines().next().unwrap_or(""); + let gutter = line_no.to_string(); + let pad = " ".repeat(gutter.len()); + format!( + "{}:{line_no}:{col}: {message}\n {gutter} | {source}\n {pad} | {}^", + path.display(), + " ".repeat(col - 1) + ) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn report_points_at_the_offending_spot() { + let text = "a = 1\nbad = 2\n"; + let message = report(Path::new("c.toml"), text, Some(6), "oops"); + assert_eq!(message, "c.toml:2:1: oops\n 2 | bad = 2\n | ^"); + } + + #[test] + fn template_is_valid_and_matches_defaults() { + let config: Config = toml::from_str(TEMPLATE).unwrap(); + let default = Config::default(); + assert!(config.check().is_ok()); + assert_eq!(config.colors.palette, default.colors.palette); + assert_eq!(config.mouse.word_delims, default.mouse.word_delims); + assert_eq!(config.shortcuts.len(), default.shortcuts.len()); + } + + #[test] + fn find_key_skips_other_keys() { + let text = "[window]\ncolumns = 1\n cols = 0\n"; + assert_eq!(find_key(text, "cols"), Some(text.find("cols").unwrap())); + } +} diff --git a/src/font.rs b/src/font.rs new file mode 100644 index 0000000..f43c83e --- /dev/null +++ b/src/font.rs @@ -0,0 +1,140 @@ +use ab_glyph::{point, Font, FontRef, PxScale, ScaleFont}; +use memmap2::Mmap; +use std::collections::HashMap; +use std::fs::File; +use std::process::Command; + +use crate::config; + +#[derive(Default)] +pub struct Bitmap { + pub x: i32, + pub y: i32, + pub width: usize, + pub height: usize, + pub alpha: Vec, +} + +pub struct Fonts { + faces: Vec>, + paths: HashMap, + bold: Option, + chosen: HashMap<(char, bool), usize>, + cache: HashMap<(char, bool), Bitmap>, + size: f32, + pub cell_w: usize, + pub cell_h: usize, + pub ascent: usize, +} + +fn match_file(pattern: &str) -> Option { + let output = Command::new("fc-match").args(["-f", "%{file}", pattern]).output().ok()?; + let path = String::from_utf8(output.stdout).ok()?; + (!path.is_empty()).then_some(path) +} + +fn load(path: &str) -> Option> { + let file = File::open(path).ok()?; + let map: &'static Mmap = Box::leak(Box::new(unsafe { Mmap::map(&file) }.ok()?)); + FontRef::try_from_slice(map).ok() +} + +fn px_scale(font: &FontRef, size: f32) -> PxScale { + font.pt_to_px_scale(size * 0.75).expect("font has no units per em") +} + +fn rasterize(font: &FontRef, ch: char, size: f32, baseline: f32) -> Bitmap { + let glyph = font.glyph_id(ch).with_scale_and_position(px_scale(font, size), point(0.0, baseline)); + let Some(outline) = font.outline_glyph(glyph) else { return Bitmap::default() }; + let bounds = outline.px_bounds(); + let (width, height) = (bounds.width() as usize, bounds.height() as usize); + let mut alpha = vec![0u8; width * height]; + outline.draw(|x, y, coverage| alpha[y as usize * width + x as usize] = (coverage.min(1.0) * 255.0) as u8); + Bitmap { x: bounds.min.x as i32, y: bounds.min.y as i32, width, height, alpha } +} + +impl Fonts { + pub fn new() -> Fonts { + let path = match_file(&config::get().font.family).expect("fc-match found no font"); + let regular = load(&path).unwrap_or_else(|| panic!("cannot load font {path:?}")); + let mut fonts = Fonts { + faces: vec![regular], + paths: HashMap::from([(path, 0)]), + bold: None, + chosen: HashMap::new(), + cache: HashMap::new(), + size: config::get().font.size, + cell_w: 0, + cell_h: 0, + ascent: 0, + }; + fonts.measure(); + fonts + } + + pub fn size(&self) -> f32 { + self.size + } + + pub fn set_size(&mut self, size: f32) { + self.size = size.clamp(6.0, 96.0); + self.measure(); + } + + fn measure(&mut self) { + self.cache.clear(); + let regular = &self.faces[0]; + let scaled = regular.as_scaled(px_scale(regular, self.size)); + self.ascent = scaled.ascent().round() as usize; + self.cell_h = (scaled.ascent() - scaled.descent() + scaled.line_gap()).ceil() as usize; + self.cell_w = (scaled.h_advance(regular.glyph_id('M')).round() as usize).max(1); + } + + fn face(&mut self, pattern: &str) -> Option { + let path = match_file(pattern)?; + if let Some(&index) = self.paths.get(&path) { + return Some(index); + } + let font = load(&path)?; + self.faces.push(font); + self.paths.insert(path, self.faces.len() - 1); + Some(self.faces.len() - 1) + } + + fn pick(&mut self, ch: char, bold: bool) -> usize { + let primary = if !bold { + 0 + } else if let Some(index) = self.bold { + index + } else { + let index = self.face(&format!("{}:bold", config::get().font.family)).unwrap_or(0); + self.bold = Some(index); + index + }; + if self.faces[primary].glyph_id(ch).0 != 0 { + return primary; + } + let style = if bold { ":bold" } else { "" }; + let pattern = format!("{}{style}:charset={:x}", config::get().font.family, ch as u32); + match self.face(&pattern) { + Some(index) if self.faces[index].glyph_id(ch).0 != 0 => index, + _ => primary, + } + } + + pub fn glyph(&mut self, ch: char, bold: bool) -> &Bitmap { + if !self.cache.contains_key(&(ch, bold)) { + let face = match self.chosen.get(&(ch, bold)) { + Some(&face) => face, + None => { + let face = self.pick(ch, bold); + self.chosen.insert((ch, bold), face); + face + } + }; + let bitmap = rasterize(&self.faces[face], ch, self.size, self.ascent as f32); + self.cache.insert((ch, bold), bitmap); + } + &self.cache[&(ch, bold)] + } +} diff --git a/src/keys.rs b/src/keys.rs new file mode 100644 index 0000000..a7830da --- /dev/null +++ b/src/keys.rs @@ -0,0 +1,53 @@ +#![allow(non_upper_case_globals)] + +use xkbcommon::xkb::keysyms::*; + +use crate::config::{ALT, CTRL}; + +pub fn encode(sym: u32, text: &str, mods: u8, app_cursor: bool) -> Vec { + let with_mods = |prefix: &str, code: &str, suffix: char| -> Vec { + if mods == 0 { + format!("{prefix}{code}{suffix}").into_bytes() + } else { + format!("\x1b[{};{}{}", if code.is_empty() { "1" } else { code }, mods + 1, suffix).into_bytes() + } + }; + let cursor = |letter: char| { + if app_cursor { with_mods("\x1bO", "", letter) } else { with_mods("\x1b[", "", letter) } + }; + let tilde = |code: &str| with_mods("\x1b[", code, '~'); + let ss3 = |letter: char| with_mods("\x1bO", "", letter); + + match sym { + KEY_Up => cursor('A'), + KEY_Down => cursor('B'), + KEY_Right => cursor('C'), + KEY_Left => cursor('D'), + KEY_Home => cursor('H'), + KEY_End => cursor('F'), + KEY_Insert => tilde("2"), + KEY_Delete => tilde("3"), + KEY_Prior => tilde("5"), + KEY_Next => tilde("6"), + KEY_F1 => ss3('P'), + KEY_F2 => ss3('Q'), + KEY_F3 => ss3('R'), + KEY_F4 => ss3('S'), + KEY_F5 => tilde("15"), + KEY_F6 => tilde("17"), + KEY_F7 => tilde("18"), + KEY_F8 => tilde("19"), + KEY_F9 => tilde("20"), + KEY_F10 => tilde("21"), + KEY_F11 => tilde("23"), + KEY_F12 => tilde("24"), + KEY_ISO_Left_Tab => b"\x1b[Z".to_vec(), + KEY_BackSpace => { + let byte = if mods & CTRL != 0 { 0x08 } else { 0x7f }; + if mods & ALT != 0 { vec![0x1b, byte] } else { vec![byte] } + } + _ if text.is_empty() => Vec::new(), + _ if mods & ALT != 0 => [b"\x1b".as_slice(), text.as_bytes()].concat(), + _ => text.as_bytes().to_vec(), + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..f68e553 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,1230 @@ +#![allow(non_upper_case_globals)] + +mod config; +mod font; +mod keys; +mod pty; +mod term; + +use std::error::Error; +use std::fs::File; +use std::io::{ErrorKind, Read, Write}; +use std::os::fd::{AsFd, AsRawFd, FromRawFd, OwnedFd}; +use std::process::{Command, Stdio}; +use std::time::{Duration, Instant}; + +use memmap2::{MmapMut, MmapOptions}; +use wayland_client::backend::WaylandError; +use wayland_client::globals::{registry_queue_init, GlobalListContents}; +use wayland_client::protocol::wl_buffer::{self, WlBuffer}; +use wayland_client::protocol::wl_callback::{self, WlCallback}; +use wayland_client::protocol::wl_compositor::WlCompositor; +use wayland_client::protocol::wl_data_device::{self, WlDataDevice}; +use wayland_client::protocol::wl_data_device_manager::WlDataDeviceManager; +use wayland_client::protocol::wl_data_offer::{self, WlDataOffer}; +use wayland_client::protocol::wl_data_source::{self, WlDataSource}; +use wayland_client::protocol::wl_keyboard::{self, WlKeyboard}; +use wayland_client::protocol::wl_pointer::{self, WlPointer}; +use wayland_client::protocol::wl_registry::WlRegistry; +use wayland_client::protocol::wl_seat::{self, WlSeat}; +use wayland_client::protocol::wl_shm::{self, WlShm}; +use wayland_client::protocol::wl_shm_pool::WlShmPool; +use wayland_client::protocol::wl_surface::WlSurface; +use wayland_client::{event_created_child, Connection, Dispatch, Proxy, QueueHandle, WEnum}; +use wayland_protocols::wp::cursor_shape::v1::client::wp_cursor_shape_device_v1::{Shape, WpCursorShapeDeviceV1}; +use wayland_protocols::wp::cursor_shape::v1::client::wp_cursor_shape_manager_v1::WpCursorShapeManagerV1; +use wayland_protocols::wp::primary_selection::zv1::client::zwp_primary_selection_device_manager_v1::ZwpPrimarySelectionDeviceManagerV1; +use wayland_protocols::wp::primary_selection::zv1::client::zwp_primary_selection_device_v1::{self, ZwpPrimarySelectionDeviceV1}; +use wayland_protocols::wp::primary_selection::zv1::client::zwp_primary_selection_offer_v1::{self, ZwpPrimarySelectionOfferV1}; +use wayland_protocols::wp::primary_selection::zv1::client::zwp_primary_selection_source_v1::{self, ZwpPrimarySelectionSourceV1}; +use wayland_protocols::xdg::shell::client::xdg_surface::{self, XdgSurface}; +use wayland_protocols::xdg::shell::client::xdg_toplevel::{self, XdgToplevel}; +use wayland_protocols::xdg::shell::client::xdg_wm_base::{self, XdgWmBase}; +use xkbcommon::xkb; +use xkbcommon::xkb::keysyms::{KEY_BackSpace, KEY_Down, KEY_Escape, KEY_R, KEY_Return, KEY_S, KEY_Up, KEY_r, KEY_s}; + +use config::{Action, CursorStyle}; +use font::{Bitmap, Fonts}; +use pty::Pty; +use term::{Term, BOLD, DIM, REVERSE, STRIKE, UNDERLINE}; + +const BTN_LEFT: u32 = 0x110; +const BTN_MIDDLE: u32 = 0x112; +const BTN_RIGHT: u32 = 0x111; +const COPY_MIMES: [&str; 2] = ["text/plain;charset=utf-8", "text/plain"]; +const PASTE_MIMES: [&str; 4] = ["text/plain;charset=utf-8", "text/plain", "UTF8_STRING", "STRING"]; + +type Pos = (usize, usize); + +struct Buffer { + buffer: WlBuffer, + map: MmapMut, + busy: bool, +} + +impl Drop for Buffer { + fn drop(&mut self) { + self.buffer.destroy(); + } +} + +struct Search { + query: String, + pos: Pos, + found: bool, +} + +#[derive(Clone, Copy)] +struct Repeat { + key: u32, + next: Instant, +} + +struct App { + shm: WlShm, + surface: WlSurface, + toplevel: XdgToplevel, + data_manager: WlDataDeviceManager, + data_device: WlDataDevice, + cursor_manager: Option, + cursor_shape: Option, + primary_manager: Option, + primary_device: Option, + keyboard: Option, + pointer: Option, + + xkb: xkb::Context, + keymap: Option, + xkb_state: Option, + repeat: Option, + repeat_rate: u64, + repeat_delay: u64, + serial: u32, + + term: Term, + pty: Pty, + fonts: Fonts, + + width: u32, + height: u32, + pending: (u32, u32), + configured: bool, + buffers: Vec, + dirty: bool, + frame_pending: bool, + focused: bool, + + mouse: (f64, f64), + anchor: Option, + selection: Option<(Pos, Pos)>, + last_click: Instant, + clicks: u32, + held: Option, + last_cell: Pos, + wheel: f64, + + clip_text: String, + clip_source: Option, + offer: Option, + offer_mimes: Vec, + pending_mimes: Vec, + paste: Option, + primary_text: String, + primary_source: Option, + primary_offer: Option, + primary_mimes: Vec, + + search: Option, + sync_deadline: Option, + exit: bool, +} + +fn border() -> usize { + config::get().window.border +} + +fn blend(dst: u32, src: u32, alpha: u32) -> u32 { + let src = src | 0xff000000; + let mix = |shift: u32| { + let (s, d) = ((src >> shift) & 0xff, (dst >> shift) & 0xff); + ((s * alpha + d * (255 - alpha) + 127) / 255) << shift + }; + mix(24) | mix(16) | mix(8) | mix(0) +} + +fn paint(color: u32) -> u32 { + let alpha = (config::get().window.alpha.clamp(0.0, 1.0) * 255.0).round() as u32; + if color != config::get().colors.background || alpha == 255 { + return 0xff000000 | color; + } + let premultiply = |shift: u32| (((color >> shift) & 0xff) * alpha + 127) / 255 << shift; + alpha << 24 | premultiply(16) | premultiply(8) | premultiply(0) +} + +fn fill(px: &mut [u32], stride: usize, x: usize, y: usize, w: usize, h: usize, color: u32) { + for row in y..y + h { + px[row * stride + x..row * stride + x + w].fill(color); + } +} + +fn blit(px: &mut [u32], stride: usize, glyph: &Bitmap, (ox, oy): Pos, (right, bottom): Pos, fg: u32) { + let (x0, y0) = (ox as i32 + glyph.x, oy as i32 + glyph.y); + for gy in 0..glyph.height { + for gx in 0..glyph.width { + let alpha = glyph.alpha[gy * glyph.width + gx] as u32; + let (tx, ty) = (x0 + gx as i32, y0 + gy as i32); + if alpha == 0 || tx < ox as i32 || tx >= right as i32 || ty < oy as i32 || ty >= bottom as i32 { + continue; + } + let p = &mut px[ty as usize * stride + tx as usize]; + *p = blend(*p, fg, alpha); + } + } +} + +fn launch(command: &mut Command) { + command.stdin(Stdio::null()).stdout(Stdio::null()).stderr(Stdio::null()); + if let Ok(mut child) = command.spawn() { + std::thread::spawn(move || { + let _ = child.wait(); + }); + } +} + +fn ordered(a: Pos, b: Pos) -> (Pos, Pos) { + if (a.1, a.0) <= (b.1, b.0) { (a, b) } else { (b, a) } +} + +fn selected(sel: Option<(Pos, Pos)>, x: usize, y: usize) -> bool { + sel.is_some_and(|(a, b)| { + let (a, b) = ordered(a, b); + (y, x) >= (a.1, a.0) && (y, x) <= (b.1, b.0) + }) +} + +fn make_buffer(shm: &WlShm, qh: &QueueHandle, w: usize, h: usize, index: usize) -> Buffer { + let size = w * h * 4; + let fd = unsafe { libc::memfd_create(b"softty\0".as_ptr().cast(), libc::MFD_CLOEXEC) }; + let file = unsafe { File::from_raw_fd(fd) }; + file.set_len(size as u64).expect("cannot size shm file"); + let map = unsafe { MmapMut::map_mut(&file) }.expect("cannot map shm file"); + let pool = shm.create_pool(file.as_fd(), size as i32, qh, ()); + let buffer = pool.create_buffer(0, w as i32, h as i32, (w * 4) as i32, wl_shm::Format::Argb8888, qh, index); + pool.destroy(); + Buffer { buffer, map, busy: false } +} + +impl App { + fn grid_size(&self) -> (usize, usize) { + let usable = |px: u32, cell: usize| ((px as usize).saturating_sub(2 * border()) / cell).max(1); + (usable(self.width, self.fonts.cell_w), usable(self.height, self.fonts.cell_h)) + } + + fn apply_size(&mut self, qh: &QueueHandle) { + let (mut w, mut h) = self.pending; + if w == 0 || h == 0 { + w = if self.width > 0 { self.width } else { (config::get().window.cols * self.fonts.cell_w + 2 * border()) as u32 }; + h = if self.height > 0 { self.height } else { (config::get().window.rows * self.fonts.cell_h + 2 * border()) as u32 }; + } + if !self.configured || (w, h) != (self.width, self.height) { + self.width = w; + self.height = h; + self.buffers = (0..2).map(|i| make_buffer(&self.shm, qh, w as usize, h as usize, i)).collect(); + } + self.configured = true; + self.relayout(); + } + + fn relayout(&mut self) { + let (cols, rows) = self.grid_size(); + if (cols, rows) != (self.term.cols, self.term.rows) { + self.term.resize(cols, rows); + self.pty.resize(cols, rows); + self.selection = None; + } + self.dirty = true; + } + + fn zoom(&mut self, size: f32) { + self.fonts.set_size(size); + self.relayout(); + } + + fn draw(&mut self, qh: &QueueHandle) { + if !self.configured || !self.dirty || self.frame_pending { + return; + } + let Some(index) = self.buffers.iter().position(|b| !b.busy) else { return }; + self.render(index); + let buffer = &mut self.buffers[index]; + buffer.busy = true; + self.surface.attach(Some(&buffer.buffer), 0, 0); + self.surface.damage_buffer(0, 0, i32::MAX, i32::MAX); + self.surface.frame(qh, ()); + self.surface.commit(); + self.frame_pending = true; + self.dirty = false; + } + + fn render(&mut self, index: usize) { + let (w, h) = (self.width as usize, self.height as usize); + let (cw, ch, ascent) = (self.fonts.cell_w, self.fonts.cell_h, self.fonts.ascent); + let map = &mut self.buffers[index].map; + let px = unsafe { std::slice::from_raw_parts_mut(map.as_mut_ptr().cast::(), w * h) }; + px.fill(paint(config::get().colors.background)); + + let term = &self.term; + let (selection, focused) = (self.selection, self.focused); + let search = self.search.as_ref().map(|s| (s.query.clone(), s.found)); + let (cx, cy) = (term.cur.x, term.cur.y); + let cursor_visible = term.cursor_visible && term.view == 0; + let style = term.cursor_style.unwrap_or(config::get().cursor.style); + let block = matches!(style, CursorStyle::Block); + let colors = |x: usize, y: usize| { + let cell = term.cell(x, y); + if block && cursor_visible && focused && (x, y) == (cx, cy) { + return (config::get().cursor.text, config::get().cursor.color); + } + let invert = (cell.flags & REVERSE != 0) ^ selected(selection, x, y); + if invert { (cell.bg, cell.fg) } else { (cell.fg, cell.bg) } + }; + + for y in 0..term.rows { + for x in 0..term.cols { + let (ox, oy) = (border() + x * cw, border() + y * ch); + if ox + cw <= w && oy + ch <= h { + fill(px, w, ox, oy, cw, ch, paint(colors(x, y).1)); + } + } + } + + for y in 0..term.rows { + for x in 0..term.cols { + let cell = term.cell(x, y); + let (ox, oy) = (border() + x * cw, border() + y * ch); + if ox + cw > w || oy + ch > h { + continue; + } + let (mut fg, bg) = colors(x, y); + if cell.flags & DIM != 0 { + fg = blend(0xff000000 | bg, fg, 128) & 0xffffff; + } + if cell.ch != ' ' { + let spill = x + 1 < term.cols && term.cell(x + 1, y).ch == ' ' && ox + 2 * cw <= w; + let right = if spill { ox + 2 * cw } else { ox + cw }; + let glyph = self.fonts.glyph(cell.ch, cell.flags & BOLD != 0); + blit(px, w, glyph, (ox, oy), (right, oy + ch), fg); + } + if cell.flags & STRIKE != 0 { + fill(px, w, ox, oy + ch / 2, cw, 1, 0xff000000 | fg); + } + if cell.flags & UNDERLINE != 0 { + fill(px, w, ox, (oy + ascent + 1).min(oy + ch - 1), cw, 1, 0xff000000 | fg); + } + } + } + + if cursor_visible && cx < term.cols && cy < term.rows { + let (ox, oy) = (border() + cx * cw, border() + cy * ch); + if ox + cw <= w && oy + ch <= h { + let color = 0xff000000 | config::get().cursor.color; + let thick = config::get().cursor.thickness.min(cw).min(ch); + match style { + CursorStyle::Beam => fill(px, w, ox, oy, thick, ch, color), + CursorStyle::Underline => fill(px, w, ox, oy + ch - thick, cw, thick, color), + CursorStyle::Block if !focused => { + fill(px, w, ox, oy, cw, 1, color); + fill(px, w, ox, oy + ch - 1, cw, 1, color); + fill(px, w, ox, oy, 1, ch, color); + fill(px, w, ox + cw - 1, oy, 1, ch, color); + } + CursorStyle::Block => {} + } + } + } + + if let Some((query, found)) = search { + let (fg, bg) = (config::get().cursor.text, config::get().cursor.color); + let oy = border() + (term.rows - 1) * ch; + if oy + ch <= h { + fill(px, w, border(), oy, (term.cols * cw).min(w - border()), ch, paint(bg)); + } + let miss = if found || query.is_empty() { "" } else { " (no match)" }; + let label = format!("search: {query}{miss}"); + for (x, c) in label.chars().take(term.cols).enumerate() { + let ox = border() + x * cw; + if c != ' ' && ox + cw <= w && oy + ch <= h { + blit(px, w, self.fonts.glyph(c, false), (ox, oy), (ox + cw, oy + ch), fg); + } + } + let caret = border() + ("search: ".len() + query.chars().count()) * cw; + if caret + 2 <= w && oy + ch <= h { + fill(px, w, caret, oy, 2, ch, 0xff000000 | fg); + } + } + } + + fn read_pty(&mut self, qh: &QueueHandle) { + let mut buf = [0u8; 65536]; + let n = self.pty.read(&mut buf); + if n < 0 { + if !matches!(std::io::Error::last_os_error().kind(), ErrorKind::WouldBlock | ErrorKind::Interrupted) { + self.exit = true; + } + return; + } + if n == 0 { + self.exit = true; + return; + } + self.term.feed(&buf[..n as usize]); + if self.term.scrolled { + self.term.scrolled = false; + self.selection = None; + } + if let Some(title) = self.term.title.take() { + self.toplevel.set_title(title); + } + if let Some(text) = self.term.clipboard.take() { + self.set_clipboard(text, qh); + } + if !self.term.reply.is_empty() { + let reply = std::mem::take(&mut self.term.reply); + self.pty.send(&reply); + } + if self.term.sync { + self.sync_deadline.get_or_insert(Instant::now() + Duration::from_millis(150)); + } else { + self.sync_deadline = None; + self.dirty = true; + } + } + + fn cell_at(&self, x: f64, y: f64) -> Pos { + let col = ((x - border() as f64) / self.fonts.cell_w as f64).floor().max(0.0) as usize; + let row = ((y - border() as f64) / self.fonts.cell_h as f64).floor().max(0.0) as usize; + (col.min(self.term.cols - 1), row.min(self.term.rows - 1)) + } + + fn click(&mut self) { + let now = Instant::now(); + let quick = now.duration_since(self.last_click).as_millis() < config::get().mouse.multi_click_ms as u128; + self.clicks = if quick { self.clicks % 3 + 1 } else { 1 }; + self.last_click = now; + let (x, y) = self.cell_at(self.mouse.0, self.mouse.1); + self.anchor = None; + match self.clicks { + 1 => { + self.anchor = Some((x, y)); + self.selection = None; + } + 2 => { + let (start, end) = self.term.word_at(x, y); + self.selection = Some(((start, y), (end, y))); + } + _ => self.selection = Some(((0, y), (self.term.cols - 1, y))), + } + self.dirty = true; + } + + fn drag(&mut self) { + if let Some(anchor) = self.anchor { + let cell = self.cell_at(self.mouse.0, self.mouse.1); + if cell != anchor || self.selection.is_some() { + self.selection = Some((anchor, cell)); + self.dirty = true; + } + } + } + + fn selection_text(&self) -> String { + let Some((a, b)) = self.selection else { return String::new() }; + let (a, b) = ordered(a, b); + self.term.text(a, b) + } + + fn copy(&mut self, qh: &QueueHandle) { + let text = self.selection_text(); + self.set_clipboard(text, qh); + } + + fn set_clipboard(&mut self, text: String, qh: &QueueHandle) { + if text.is_empty() { + return; + } + let source = self.data_manager.create_data_source(qh, ()); + for mime in COPY_MIMES { + source.offer(mime.to_string()); + } + self.data_device.set_selection(Some(&source), self.serial); + if let Some(old) = self.clip_source.replace(source) { + old.destroy(); + } + self.clip_text = text; + } + + fn publish_primary(&mut self, qh: &QueueHandle) { + let (Some(manager), Some(device)) = (&self.primary_manager, &self.primary_device) else { return }; + let text = self.selection_text(); + if text.is_empty() { + return; + } + let source = manager.create_source(qh, ()); + for mime in COPY_MIMES { + source.offer(mime.to_string()); + } + device.set_selection(Some(&source), self.serial); + if let Some(old) = self.primary_source.replace(source) { + old.destroy(); + } + self.primary_text = text; + } + + fn paste_begin(&mut self) { + self.snap_to_bottom(); + if self.term.bracketed_paste { + self.pty.send(b"\x1b[200~"); + } + } + + fn paste_end(&mut self) { + if self.term.bracketed_paste { + self.pty.send(b"\x1b[201~"); + } + } + + fn paste_chunk(&mut self, data: &[u8]) { + let data: Vec = data.iter().map(|&b| if b == b'\n' { b'\r' } else { b }).collect(); + self.pty.send(&data); + } + + fn paste(&mut self, primary: bool) { + if self.paste.is_some() { + return; + } + let local = if primary { + self.primary_source.is_some().then(|| self.primary_text.clone()) + } else { + self.clip_source.is_some().then(|| self.clip_text.clone()) + }; + if let Some(text) = local { + self.paste_begin(); + self.paste_chunk(text.as_bytes()); + self.paste_end(); + return; + } + let mimes = if primary { &self.primary_mimes } else { &self.offer_mimes }; + let Some(mime) = PASTE_MIMES.iter().find(|m| mimes.iter().any(|o| o == *m)) else { return }; + let mut fds = [0; 2]; + if unsafe { libc::pipe2(fds.as_mut_ptr(), libc::O_CLOEXEC) } != 0 { + return; + } + let (reader, writer) = unsafe { (File::from_raw_fd(fds[0]), OwnedFd::from_raw_fd(fds[1])) }; + unsafe { libc::fcntl(fds[0], libc::F_SETFL, libc::O_NONBLOCK) }; + match (primary, &self.primary_offer, &self.offer) { + (true, Some(offer), _) => offer.receive(mime.to_string(), writer.as_fd()), + (false, _, Some(offer)) => offer.receive(mime.to_string(), writer.as_fd()), + _ => return, + } + self.paste = Some(reader); + self.paste_begin(); + } + + fn read_paste(&mut self) { + let Some(file) = &mut self.paste else { return }; + let mut buf = [0u8; 4096]; + match file.read(&mut buf) { + Ok(n) if n > 0 => self.paste_chunk(&buf[..n]), + Err(e) if matches!(e.kind(), ErrorKind::WouldBlock | ErrorKind::Interrupted) => {} + _ => { + self.paste = None; + self.paste_end(); + } + } + } + + fn mods(&self) -> u8 { + let Some(state) = &self.xkb_state else { return 0 }; + let active = |name| state.mod_name_is_active(name, xkb::STATE_MODS_EFFECTIVE); + active(xkb::MOD_NAME_SHIFT) as u8 + | (active(xkb::MOD_NAME_ALT) as u8) << 1 + | (active(xkb::MOD_NAME_CTRL) as u8) << 2 + } + + fn mouse_mods(&self) -> u8 { + let mods = self.mods(); + (mods & config::ALT != 0) as u8 * 8 | (mods & config::CTRL != 0) as u8 * 16 + } + + fn grabbed(&self) -> bool { + self.term.mouse > 0 && self.mods() & config::SHIFT == 0 + } + + fn report(&mut self, code: u8, release: bool) { + let (x, y) = self.cell_at(self.mouse.0, self.mouse.1); + let (x, y) = (x + 1, y + 1); + if self.term.mouse_sgr { + let end = if release { 'm' } else { 'M' }; + self.pty.send(format!("\x1b[<{code};{x};{y}{end}").as_bytes()); + } else if x <= 223 && y <= 223 { + let code = if release { code | 3 } else { code }; + self.pty.send(&[0x1b, b'[', b'M', code + 32, x as u8 + 32, y as u8 + 32]); + } + } + + fn report_motion(&mut self) { + let cell = self.cell_at(self.mouse.0, self.mouse.1); + if cell == self.last_cell { + return; + } + self.last_cell = cell; + match (self.term.mouse, self.held) { + (2 | 3, Some(button)) => self.report(button | 32 | self.mouse_mods(), false), + (3, None) => self.report(3 | 32 | self.mouse_mods(), false), + _ => {} + } + } + + fn scroll(&mut self, value: f64) { + self.wheel += value; + let notches = (self.wheel / 15.0) as i32; + if notches == 0 { + return; + } + self.wheel -= notches as f64 * 15.0; + let up = notches < 0; + for _ in 0..notches.abs() { + if self.grabbed() { + self.report(64 + !up as u8 | self.mouse_mods(), false); + } else if self.term.in_alt && self.term.mouse == 0 { + let prefix = if self.term.app_cursor { "\x1bO" } else { "\x1b[" }; + let key = if up { 'A' } else { 'B' }; + for _ in 0..config::get().mouse.scroll_lines { + self.pty.send(format!("{prefix}{key}").as_bytes()); + } + } else if !self.term.in_alt { + let lines = config::get().mouse.scroll_lines as isize; + self.scroll_view(if up { lines } else { -lines }); + } + } + } + + /// Scrolls the history view. Returns false when the key should go to the application + /// instead, which is the case on the alternate screen. + fn scroll_view(&mut self, delta: isize) -> bool { + if self.term.in_alt { + return false; + } + if self.term.scroll_view(delta) { + self.selection = None; + self.dirty = true; + } + true + } + + fn snap_to_bottom(&mut self) { + if self.term.reset_view() { + self.selection = None; + self.dirty = true; + } + } + + fn on_key(&mut self, key: u32, qh: &QueueHandle) { + let Some(state) = &self.xkb_state else { return }; + let code = xkb::Keycode::new(key + 8); + let sym = state.key_get_one_sym(code).raw(); + let text = state.key_get_utf8(code); + let mods = self.mods(); + + if self.search.is_some() { + self.search_key(sym, &text, mods, qh); + return; + } + if let Some(shortcut) = config::get().shortcuts.iter().find(|s| s.mods == mods && s.sym == sym) { + let page = self.term.rows.saturating_sub(1).max(1) as isize; + let consumed = match shortcut.action { + Action::Copy => { + self.copy(qh); + true + } + Action::Paste => { + self.paste(false); + true + } + Action::ZoomIn => { + self.zoom(self.fonts.size() + config::get().font.zoom_step); + true + } + Action::ZoomOut => { + self.zoom(self.fonts.size() - config::get().font.zoom_step); + true + } + Action::ZoomReset => { + self.zoom(config::get().font.size); + true + } + Action::ScrollLineUp => self.scroll_view(1), + Action::ScrollLineDown => self.scroll_view(-1), + Action::ScrollPageUp => self.scroll_view(page), + Action::ScrollPageDown => self.scroll_view(-page), + Action::ScrollTop => self.scroll_view(isize::MAX), + Action::ScrollBottom => self.scroll_view(isize::MIN), + Action::Search => self.start_search(), + Action::PrevPrompt => self.jump_prompt(true), + Action::NextPrompt => self.jump_prompt(false), + Action::NewWindow => { + self.new_window(); + true + } + }; + if consumed { + return; + } + } + let bytes = keys::encode(sym, &text, mods, self.term.app_cursor); + if !bytes.is_empty() { + self.snap_to_bottom(); + self.pty.send(&bytes); + } + } + + fn jump_prompt(&mut self, back: bool) -> bool { + if self.term.in_alt { + return false; + } + if self.term.jump_prompt(back) { + self.selection = None; + self.dirty = true; + } + true + } + + fn start_search(&mut self) -> bool { + if self.term.in_alt { + return false; + } + self.search = Some(Search { query: String::new(), pos: (self.term.lines() - 1, usize::MAX), found: false }); + self.selection = None; + self.dirty = true; + true + } + + fn search_run(&mut self, from: Pos, backward: bool, keep: bool) { + let Some(search) = &mut self.search else { return }; + let hit = if search.query.is_empty() { None } else { self.term.find(&search.query, from, backward) }; + match hit { + Some((line, start, end)) => { + search.found = true; + search.pos = (line, start); + let row = self.term.reveal(line); + self.selection = Some(((start, row), (end, row))); + } + None if keep => {} + None => { + search.found = false; + self.selection = None; + } + } + self.dirty = true; + } + + fn search_step(&mut self, backward: bool) { + let Some(search) = &self.search else { return }; + if !search.found { + return; + } + let (line, col) = search.pos; + let from = match (backward, col, line) { + (true, 0, 0) => return, + (true, 0, _) => (line - 1, usize::MAX), + (true, _, _) => (line, col - 1), + (false, ..) => (line, col + 1), + }; + self.search_run(from, backward, true); + } + + fn search_key(&mut self, sym: u32, text: &str, mods: u8, qh: &QueueHandle) { + let Some(search) = &mut self.search else { return }; + let ctrl = mods & config::CTRL != 0; + match sym { + KEY_Escape => { + self.search = None; + self.selection = None; + self.dirty = true; + } + KEY_Return => { + self.search = None; + self.dirty = true; + self.publish_primary(qh); + } + KEY_BackSpace => { + search.query.pop(); + search.pos = (self.term.lines() - 1, usize::MAX); + let from = search.pos; + self.search_run(from, true, false); + } + KEY_Up => self.search_step(true), + KEY_Down => self.search_step(false), + _ if ctrl && (sym == KEY_r || sym == KEY_R) => self.search_step(true), + _ if ctrl && (sym == KEY_s || sym == KEY_S) => self.search_step(false), + _ if !ctrl && mods & config::ALT == 0 && !text.is_empty() && !text.chars().any(char::is_control) => { + search.query.push_str(text); + let from = search.pos; + self.search_run(from, true, false); + } + _ => {} + } + } + + fn open(&self, url: &str) { + let mut command = Command::new(&config::get().mouse.url_launcher); + command.arg(url); + launch(&mut command); + } + + fn new_window(&self) { + let Ok(exe) = std::env::current_exe() else { return }; + let mut command = Command::new(exe); + if let Some(cwd) = &self.term.cwd { + command.current_dir(cwd); + } + launch(&mut command); + } + + fn set_keymap(&mut self, fd: OwnedFd, size: u32) { + let file = File::from(fd); + let Ok(map) = (unsafe { MmapOptions::new().len(size as usize).map_copy_read_only(&file) }) else { return }; + let Ok(text) = std::str::from_utf8(&map[..size as usize - 1]) else { return }; + let keymap = xkb::Keymap::new_from_string(&self.xkb, text.to_string(), xkb::KEYMAP_FORMAT_TEXT_V1, xkb::KEYMAP_COMPILE_NO_FLAGS); + if let Some(keymap) = keymap { + self.xkb_state = Some(xkb::State::new(&keymap)); + self.keymap = Some(keymap); + } + } +} + +macro_rules! ignore { + ($($proxy:ty),*) => {$( + impl Dispatch<$proxy, ()> for App { + fn event(_: &mut Self, _: &$proxy, _: <$proxy as Proxy>::Event, _: &(), _: &Connection, _: &QueueHandle) {} + } + )*}; +} + +ignore!( + WlCompositor, + WlShm, + WlShmPool, + WlSurface, + WlDataDeviceManager, + WpCursorShapeManagerV1, + WpCursorShapeDeviceV1, + ZwpPrimarySelectionDeviceManagerV1 +); + +impl Dispatch for App { + fn event(_: &mut Self, _: &WlRegistry, _: wayland_client::protocol::wl_registry::Event, _: &GlobalListContents, _: &Connection, _: &QueueHandle) {} +} + +impl Dispatch for App { + fn event(app: &mut Self, _: &WlBuffer, event: wl_buffer::Event, index: &usize, _: &Connection, _: &QueueHandle) { + if let (wl_buffer::Event::Release, Some(buffer)) = (event, app.buffers.get_mut(*index)) { + buffer.busy = false; + } + } +} + +impl Dispatch for App { + fn event(app: &mut Self, _: &WlCallback, event: wl_callback::Event, _: &(), _: &Connection, _: &QueueHandle) { + if let wl_callback::Event::Done { .. } = event { + app.frame_pending = false; + } + } +} + +impl Dispatch for App { + fn event(_: &mut Self, base: &XdgWmBase, event: xdg_wm_base::Event, _: &(), _: &Connection, _: &QueueHandle) { + if let xdg_wm_base::Event::Ping { serial } = event { + base.pong(serial); + } + } +} + +impl Dispatch for App { + fn event(app: &mut Self, surface: &XdgSurface, event: xdg_surface::Event, _: &(), _: &Connection, qh: &QueueHandle) { + if let xdg_surface::Event::Configure { serial } = event { + surface.ack_configure(serial); + app.apply_size(qh); + } + } +} + +impl Dispatch for App { + fn event(app: &mut Self, _: &XdgToplevel, event: xdg_toplevel::Event, _: &(), _: &Connection, _: &QueueHandle) { + match event { + xdg_toplevel::Event::Configure { width, height, .. } => app.pending = (width as u32, height as u32), + xdg_toplevel::Event::Close => app.exit = true, + _ => {} + } + } +} + +impl Dispatch for App { + fn event(app: &mut Self, seat: &WlSeat, event: wl_seat::Event, _: &(), _: &Connection, qh: &QueueHandle) { + let wl_seat::Event::Capabilities { capabilities: WEnum::Value(caps) } = event else { return }; + if caps.contains(wl_seat::Capability::Keyboard) && app.keyboard.is_none() { + app.keyboard = Some(seat.get_keyboard(qh, ())); + } + if caps.contains(wl_seat::Capability::Pointer) && app.pointer.is_none() { + let pointer = seat.get_pointer(qh, ()); + app.cursor_shape = app.cursor_manager.as_ref().map(|m| m.get_pointer(&pointer, qh, ())); + app.pointer = Some(pointer); + } + } +} + +impl Dispatch for App { + fn event(app: &mut Self, _: &WlKeyboard, event: wl_keyboard::Event, _: &(), _: &Connection, qh: &QueueHandle) { + match event { + wl_keyboard::Event::Keymap { format: WEnum::Value(wl_keyboard::KeymapFormat::XkbV1), fd, size } => app.set_keymap(fd, size), + wl_keyboard::Event::Enter { serial, .. } => { + app.serial = serial; + app.focused = true; + app.dirty = true; + if app.term.focus_events { + app.pty.send(b"\x1b[I"); + } + } + wl_keyboard::Event::Leave { .. } => { + app.focused = false; + app.repeat = None; + app.dirty = true; + if app.term.focus_events { + app.pty.send(b"\x1b[O"); + } + } + wl_keyboard::Event::Modifiers { mods_depressed, mods_latched, mods_locked, group, .. } => { + if let Some(state) = &mut app.xkb_state { + state.update_mask(mods_depressed, mods_latched, mods_locked, 0, 0, group); + } + } + wl_keyboard::Event::RepeatInfo { rate, delay } => { + app.repeat_rate = rate as u64; + app.repeat_delay = delay as u64; + } + wl_keyboard::Event::Key { serial, key, state: WEnum::Value(state), .. } => { + app.serial = serial; + match state { + wl_keyboard::KeyState::Pressed => { + app.on_key(key, qh); + let repeats = app.keymap.as_ref().is_some_and(|k| k.key_repeats(xkb::Keycode::new(key + 8))); + if repeats && app.repeat_rate > 0 { + app.repeat = Some(Repeat { key, next: Instant::now() + Duration::from_millis(app.repeat_delay) }); + } + } + wl_keyboard::KeyState::Released => { + if app.repeat.is_some_and(|r| r.key == key) { + app.repeat = None; + } + } + _ => {} + } + } + _ => {} + } + } +} + +impl Dispatch for App { + fn event(app: &mut Self, _: &WlPointer, event: wl_pointer::Event, _: &(), _: &Connection, qh: &QueueHandle) { + match event { + wl_pointer::Event::Enter { serial, surface_x, surface_y, .. } => { + app.mouse = (surface_x, surface_y); + if let Some(shape) = &app.cursor_shape { + shape.set_shape(serial, Shape::Text); + } + } + wl_pointer::Event::Motion { surface_x, surface_y, .. } => { + app.mouse = (surface_x, surface_y); + if app.grabbed() { + app.report_motion(); + } else { + app.drag(); + } + } + wl_pointer::Event::Axis { axis: WEnum::Value(wl_pointer::Axis::VerticalScroll), value, .. } => app.scroll(value), + wl_pointer::Event::Button { serial, button, state: WEnum::Value(state), .. } => { + app.serial = serial; + if app.grabbed() { + let code = match button { + BTN_LEFT => 0, + BTN_MIDDLE => 1, + BTN_RIGHT => 2, + _ => return, + }; + let pressed = matches!(state, wl_pointer::ButtonState::Pressed); + app.held = pressed.then_some(code); + app.last_cell = app.cell_at(app.mouse.0, app.mouse.1); + app.report(code | app.mouse_mods(), !pressed); + return; + } + match (button, state) { + (BTN_LEFT, wl_pointer::ButtonState::Pressed) => { + let (x, y) = app.cell_at(app.mouse.0, app.mouse.1); + let url = (app.mods() & config::CTRL != 0).then(|| app.term.url_at(x, y)).flatten(); + match url { + Some(url) => app.open(&url), + None => app.click(), + } + } + (BTN_LEFT, wl_pointer::ButtonState::Released) => { + app.anchor = None; + if app.selection.is_some() { + app.publish_primary(qh); + } + } + (BTN_MIDDLE, wl_pointer::ButtonState::Pressed) => app.paste(true), + _ => {} + } + } + _ => {} + } + } +} + +impl Dispatch for App { + fn event(app: &mut Self, _: &WlDataDevice, event: wl_data_device::Event, _: &(), _: &Connection, _: &QueueHandle) { + match event { + wl_data_device::Event::DataOffer { .. } => app.pending_mimes.clear(), + wl_data_device::Event::Selection { id } => { + if let Some(old) = std::mem::replace(&mut app.offer, id) { + old.destroy(); + } + app.offer_mimes = std::mem::take(&mut app.pending_mimes); + } + _ => {} + } + } + + event_created_child!(App, WlDataDevice, [ + wl_data_device::EVT_DATA_OFFER_OPCODE => (WlDataOffer, ()), + ]); +} + +impl Dispatch for App { + fn event(app: &mut Self, _: &WlDataOffer, event: wl_data_offer::Event, _: &(), _: &Connection, _: &QueueHandle) { + if let wl_data_offer::Event::Offer { mime_type } = event { + app.pending_mimes.push(mime_type); + } + } +} + +impl Dispatch for App { + fn event(app: &mut Self, source: &WlDataSource, event: wl_data_source::Event, _: &(), _: &Connection, _: &QueueHandle) { + match event { + wl_data_source::Event::Send { fd, .. } => { + let _ = File::from(fd).write_all(app.clip_text.as_bytes()); + } + wl_data_source::Event::Cancelled => { + source.destroy(); + if app.clip_source.as_ref().is_some_and(|s| s.id() == source.id()) { + app.clip_source = None; + } + } + _ => {} + } + } +} + +impl Dispatch for App { + fn event(app: &mut Self, _: &ZwpPrimarySelectionDeviceV1, event: zwp_primary_selection_device_v1::Event, _: &(), _: &Connection, _: &QueueHandle) { + match event { + zwp_primary_selection_device_v1::Event::DataOffer { .. } => app.pending_mimes.clear(), + zwp_primary_selection_device_v1::Event::Selection { id } => { + if let Some(old) = std::mem::replace(&mut app.primary_offer, id) { + old.destroy(); + } + app.primary_mimes = std::mem::take(&mut app.pending_mimes); + } + _ => {} + } + } + + event_created_child!(App, ZwpPrimarySelectionDeviceV1, [ + zwp_primary_selection_device_v1::EVT_DATA_OFFER_OPCODE => (ZwpPrimarySelectionOfferV1, ()), + ]); +} + +impl Dispatch for App { + fn event(app: &mut Self, _: &ZwpPrimarySelectionOfferV1, event: zwp_primary_selection_offer_v1::Event, _: &(), _: &Connection, _: &QueueHandle) { + if let zwp_primary_selection_offer_v1::Event::Offer { mime_type } = event { + app.pending_mimes.push(mime_type); + } + } +} + +impl Dispatch for App { + fn event(app: &mut Self, source: &ZwpPrimarySelectionSourceV1, event: zwp_primary_selection_source_v1::Event, _: &(), _: &Connection, _: &QueueHandle) { + match event { + zwp_primary_selection_source_v1::Event::Send { fd, .. } => { + let _ = File::from(fd).write_all(app.primary_text.as_bytes()); + } + zwp_primary_selection_source_v1::Event::Cancelled => { + source.destroy(); + if app.primary_source.as_ref().is_some_and(|s| s.id() == source.id()) { + app.primary_source = None; + } + } + _ => {} + } + } +} + +fn main() -> Result<(), Box> { + let config_error = config::load(); + let args: Vec = std::env::args().skip(1).collect(); + let command = args.iter().position(|a| a == "-e").map_or(Vec::new(), |i| args[i + 1..].to_vec()); + + let conn = Connection::connect_to_env()?; + let (globals, mut queue) = registry_queue_init::(&conn)?; + let qh = queue.handle(); + + let compositor: WlCompositor = globals.bind(&qh, 4..=6, ())?; + let shm: WlShm = globals.bind(&qh, 1..=1, ())?; + let wm_base: XdgWmBase = globals.bind(&qh, 1..=6, ())?; + let seat: WlSeat = globals.bind(&qh, 4..=7, ())?; + let data_manager: WlDataDeviceManager = globals.bind(&qh, 1..=3, ())?; + let cursor_manager = globals.bind::(&qh, 1..=1, ()).ok(); + let data_device = data_manager.get_data_device(&seat, &qh, ()); + let primary_manager = globals.bind::(&qh, 1..=1, ()).ok(); + let primary_device = primary_manager.as_ref().map(|m| m.get_device(&seat, &qh, ())); + + let surface = compositor.create_surface(&qh, ()); + let xdg_surface = wm_base.get_xdg_surface(&surface, &qh, ()); + let toplevel = xdg_surface.get_toplevel(&qh, ()); + toplevel.set_title(config::get().window.title.clone()); + toplevel.set_app_id(config::get().window.app_id.clone()); + surface.commit(); + + let (cols, rows) = (config::get().window.cols, config::get().window.rows); + let mut term = Term::new(cols, rows); + if let Some(error) = &config_error { + eprintln!("softty: config error: {error}"); + let text = format!( + "\x1b[1;31msoftty: config error\x1b[0m\n{error}\n\x1b[33mUsing default settings.\x1b[0m\n\n" + ); + term.feed(text.replace('\n', "\r\n").as_bytes()); + } + let mut app = App { + shm, + surface, + toplevel, + data_manager, + data_device, + cursor_manager, + cursor_shape: None, + primary_manager, + primary_device, + keyboard: None, + pointer: None, + xkb: xkb::Context::new(xkb::CONTEXT_NO_FLAGS), + keymap: None, + xkb_state: None, + repeat: None, + repeat_rate: 0, + repeat_delay: 0, + serial: 0, + term, + pty: Pty::spawn(&command, cols, rows), + fonts: Fonts::new(), + width: 0, + height: 0, + pending: (0, 0), + configured: false, + buffers: Vec::new(), + dirty: false, + frame_pending: false, + focused: false, + mouse: (0.0, 0.0), + anchor: None, + selection: None, + last_click: Instant::now(), + clicks: 0, + held: None, + last_cell: (0, 0), + wheel: 0.0, + clip_text: String::new(), + clip_source: None, + offer: None, + offer_mimes: Vec::new(), + pending_mimes: Vec::new(), + paste: None, + primary_text: String::new(), + primary_source: None, + primary_offer: None, + primary_mimes: Vec::new(), + search: None, + sync_deadline: None, + exit: false, + }; + + loop { + queue.dispatch_pending(&mut app)?; + app.draw(&qh); + if app.exit { + return Ok(()); + } + let _ = queue.flush(); + let Some(guard) = queue.prepare_read() else { continue }; + + let mut fds = vec![ + libc::pollfd { fd: guard.connection_fd().as_raw_fd(), events: libc::POLLIN, revents: 0 }, + libc::pollfd { + fd: app.pty.fd, + events: libc::POLLIN | if app.pty.out.is_empty() { 0 } else { libc::POLLOUT }, + revents: 0, + }, + ]; + if let Some(file) = &app.paste { + fds.push(libc::pollfd { fd: file.as_raw_fd(), events: libc::POLLIN, revents: 0 }); + } + let now = Instant::now(); + let timeout = [app.repeat.map(|r| r.next), app.sync_deadline] + .into_iter() + .flatten() + .map(|deadline| deadline.saturating_duration_since(now).as_millis() as i32 + 1) + .min() + .unwrap_or(-1); + unsafe { libc::poll(fds.as_mut_ptr(), fds.len() as libc::nfds_t, timeout) }; + + if fds[0].revents & libc::POLLIN != 0 { + match guard.read() { + Err(WaylandError::Io(e)) if e.kind() == ErrorKind::WouldBlock => {} + Err(e) => return Err(e.into()), + Ok(_) => {} + } + } else { + drop(guard); + } + if fds[1].revents & (libc::POLLIN | libc::POLLHUP | libc::POLLERR) != 0 { + app.read_pty(&qh); + } + if fds[1].revents & libc::POLLOUT != 0 { + app.pty.flush(); + } + if fds.len() > 2 && fds[2].revents != 0 { + app.read_paste(); + } + if app.sync_deadline.is_some_and(|deadline| Instant::now() >= deadline) { + app.term.sync = false; + app.sync_deadline = None; + app.dirty = true; + } + if let Some(repeat) = app.repeat { + if Instant::now() >= repeat.next { + app.repeat = Some(Repeat { key: repeat.key, next: repeat.next + Duration::from_millis(1000 / app.repeat_rate.max(1)) }); + app.on_key(repeat.key, &qh); + } + } + } +} diff --git a/src/pty.rs b/src/pty.rs new file mode 100644 index 0000000..f2acc47 --- /dev/null +++ b/src/pty.rs @@ -0,0 +1,73 @@ +use std::ffi::CString; +use std::os::fd::RawFd; + +use crate::config; + +pub struct Pty { + pub fd: RawFd, + pub out: Vec, +} + +fn winsize(cols: usize, rows: usize) -> libc::winsize { + libc::winsize { ws_row: rows as u16, ws_col: cols as u16, ws_xpixel: 0, ws_ypixel: 0 } +} + +impl Pty { + pub fn spawn(cmd: &[String], cols: usize, rows: usize) -> Pty { + let shell = std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".into()); + let args = if cmd.is_empty() { vec![shell] } else { cmd.to_vec() }; + let argv: Vec = args.into_iter().map(|a| CString::new(a).unwrap()).collect(); + let mut ptrs: Vec<*const libc::c_char> = argv.iter().map(|a| a.as_ptr()).collect(); + ptrs.push(std::ptr::null()); + let term = CString::new(config::get().term.as_str()).unwrap(); + let ws = winsize(cols, rows); + let mut fd = 0; + + let pid = unsafe { libc::forkpty(&mut fd, std::ptr::null_mut(), std::ptr::null(), &ws) }; + if pid < 0 { + panic!("forkpty: {}", std::io::Error::last_os_error()); + } + if pid == 0 { + unsafe { + libc::signal(libc::SIGPIPE, libc::SIG_DFL); + libc::setenv(b"TERM\0".as_ptr().cast(), term.as_ptr(), 1); + libc::setenv(b"COLORTERM\0".as_ptr().cast(), b"truecolor\0".as_ptr().cast(), 1); + libc::execvp(ptrs[0], ptrs.as_ptr()); + libc::_exit(1); + } + } + unsafe { + let mut attrs: libc::termios = std::mem::zeroed(); + if libc::tcgetattr(fd, &mut attrs) == 0 { + attrs.c_iflag |= libc::IUTF8; + libc::tcsetattr(fd, libc::TCSANOW, &attrs); + } + libc::fcntl(fd, libc::F_SETFL, libc::O_NONBLOCK); + } + Pty { fd, out: Vec::new() } + } + + pub fn resize(&self, cols: usize, rows: usize) { + let ws = winsize(cols, rows); + unsafe { libc::ioctl(self.fd, libc::TIOCSWINSZ, &ws) }; + } + + pub fn read(&self, buf: &mut [u8]) -> isize { + unsafe { libc::read(self.fd, buf.as_mut_ptr().cast(), buf.len()) } + } + + pub fn send(&mut self, data: &[u8]) { + self.out.extend_from_slice(data); + self.flush(); + } + + pub fn flush(&mut self) { + while !self.out.is_empty() { + let n = unsafe { libc::write(self.fd, self.out.as_ptr().cast(), self.out.len()) }; + if n <= 0 { + break; + } + self.out.drain(..n as usize); + } + } +} diff --git a/src/term.rs b/src/term.rs new file mode 100644 index 0000000..faaf298 --- /dev/null +++ b/src/term.rs @@ -0,0 +1,1250 @@ +use std::collections::VecDeque; + +use unicode_width::UnicodeWidthChar; + +use crate::config::{self, CursorStyle}; + +fn default_fg() -> u32 { + config::get().colors.foreground +} + +fn default_bg() -> u32 { + config::get().colors.background +} + +fn palette(n: usize) -> u32 { + config::get().colors.palette[n] +} + +pub const BOLD: u8 = 1; +pub const UNDERLINE: u8 = 2; +pub const REVERSE: u8 = 4; +const WRAP: u8 = 8; +pub const DIM: u8 = 16; +pub const STRIKE: u8 = 32; +const TAIL: u8 = 64; + +#[derive(Clone, Copy, PartialEq)] +pub struct Cell { + pub ch: char, + pub fg: u32, + pub bg: u32, + pub flags: u8, +} + +impl Cell { + fn blank(bg: u32) -> Cell { + Cell { ch: ' ', fg: default_fg(), bg, flags: 0 } + } +} + +#[derive(Clone, Copy)] +pub struct Cursor { + pub x: usize, + pub y: usize, + fg: u32, + bg: u32, + flags: u8, + wrap_next: bool, + gfx: bool, + origin: bool, +} + +impl Cursor { + fn new() -> Cursor { + Cursor { x: 0, y: 0, fg: default_fg(), bg: default_bg(), flags: 0, wrap_next: false, gfx: false, origin: false } + } +} + +#[derive(Clone, Copy, PartialEq)] +enum State { + Ground, + Esc, + Csi, + Osc, + Str, + Inter(u8), +} + +pub struct Term { + pub cols: usize, + pub rows: usize, + pub cur: Cursor, + pub app_cursor: bool, + pub bracketed_paste: bool, + pub cursor_visible: bool, + pub mouse: u8, + pub mouse_sgr: bool, + pub cursor_style: Option, + pub scrolled: bool, + pub title: Option, + pub reply: Vec, + pub sync: bool, + pub focus_events: bool, + pub cwd: Option, + pub clipboard: Option, + grid: Vec, + alt: Vec, + pub in_alt: bool, + pub view: usize, + history: VecDeque>, + dropped: usize, + marks: Vec, + saved: Cursor, + top: usize, + bot: usize, + tabs: Vec, + autowrap: bool, + last: char, + state: State, + params: Vec, + private: u8, + inter: u8, + osc: Vec, + utf8: [u8; 4], + utf8_len: usize, + utf8_need: usize, +} + +fn tab_stops(cols: usize) -> Vec { + (0..cols).map(|x| x % 8 == 0).collect() +} + +fn color256(n: usize) -> u32 { + match n { + 0..=15 => palette(n), + 16..=231 => { + let n = n - 16; + let level = |v: usize| -> u32 { if v == 0 { 0 } else { 55 + 40 * v as u32 } }; + level(n / 36) << 16 | level(n / 6 % 6) << 8 | level(n % 6) + } + _ => { + let v = 8 + 10 * (n.min(255) - 232) as u32; + v << 16 | v << 8 | v + } + } +} + +fn fold(ch: char, ignore_case: bool) -> char { + if ignore_case { ch.to_lowercase().next().unwrap_or(ch) } else { ch } +} + +fn decode_base64(data: &str) -> Option> { + let mut out = Vec::with_capacity(data.len() / 4 * 3); + let (mut acc, mut bits) = (0u32, 0); + for b in data.bytes().filter(|&b| b != b'=') { + let value = match b { + b'A'..=b'Z' => b - b'A', + b'a'..=b'z' => b - b'a' + 26, + b'0'..=b'9' => b - b'0' + 52, + b'+' => 62, + b'/' => 63, + _ => return None, + }; + acc = acc << 6 | value as u32; + bits += 6; + if bits >= 8 { + bits -= 8; + out.push((acc >> bits) as u8); + acc &= (1 << bits) - 1; + } + } + Some(out) +} + +fn parse_cwd(uri: &str) -> Option { + let rest = uri.strip_prefix("file://")?; + let path = &rest[rest.find('/')?..]; + let bytes = path.as_bytes(); + let mut out = Vec::with_capacity(bytes.len()); + let mut i = 0; + while i < bytes.len() { + if bytes[i] == b'%' { + if let Some(byte) = path.get(i + 1..i + 3).and_then(|hex| u8::from_str_radix(hex, 16).ok()) { + out.push(byte); + i += 3; + continue; + } + } + out.push(bytes[i]); + i += 1; + } + String::from_utf8(out).ok() +} + +fn line_drawing(ch: char) -> char { + match ch { + '`' => '◆', 'a' => '▒', 'f' => '°', 'g' => '±', 'j' => '┘', 'k' => '┐', 'l' => '┌', + 'm' => '└', 'n' => '┼', 'o' => '⎺', 'p' => '⎻', 'q' => '─', 'r' => '⎼', 's' => '⎽', + 't' => '├', 'u' => '┤', 'v' => '┴', 'w' => '┬', 'x' => '│', 'y' => '≤', 'z' => '≥', + '{' => 'π', '|' => '≠', '}' => '£', '~' => '·', + _ => ch, + } +} + +impl Term { + pub fn new(cols: usize, rows: usize) -> Term { + let blank = Cell::blank(default_bg()); + Term { + cols, + rows, + cur: Cursor::new(), + app_cursor: false, + bracketed_paste: false, + cursor_visible: true, + mouse: 0, + mouse_sgr: false, + cursor_style: None, + scrolled: false, + title: None, + reply: Vec::new(), + sync: false, + focus_events: false, + cwd: None, + clipboard: None, + grid: vec![blank; cols * rows], + alt: vec![blank; cols * rows], + in_alt: false, + view: 0, + history: VecDeque::new(), + dropped: 0, + marks: Vec::new(), + saved: Cursor::new(), + top: 0, + bot: rows - 1, + tabs: tab_stops(cols), + autowrap: true, + last: ' ', + state: State::Ground, + params: vec![0], + private: 0, + inter: 0, + osc: Vec::new(), + utf8: [0; 4], + utf8_len: 0, + utf8_need: 0, + } + } + + /// The cells of viewport row `y`, which comes from the history while scrolled back. + fn row_cells(&self, y: usize) -> &[Cell] { + let past = self.history.len(); + let line = past - self.view.min(past) + y; + if line < past { + &self.history[line] + } else { + let y = line - past; + &self.grid[y * self.cols..(y + 1) * self.cols] + } + } + + pub fn cell(&self, x: usize, y: usize) -> Cell { + self.row_cells(y)[x] + } + + /// Moves the view `delta` lines back into the history (negative: towards the live screen). + /// Returns whether the view moved. The alternate screen has no history. + pub fn scroll_view(&mut self, delta: isize) -> bool { + if self.in_alt { + return false; + } + let view = (self.view as isize).saturating_add(delta).clamp(0, self.history.len() as isize) as usize; + let moved = view != self.view; + self.view = view; + moved + } + + /// Jumps back to the live screen. Returns whether the view moved. + pub fn reset_view(&mut self) -> bool { + let moved = self.view != 0; + self.view = 0; + moved + } + + fn push_history(&mut self, row: Vec) { + let max = config::get().scrollback.lines; + if max == 0 { + return; + } + if self.history.len() >= max { + self.history.pop_front(); + self.dropped += 1; + } + self.history.push_back(row); + // Keep the lines on screen in place while the user is reading older output. + if self.view > 0 { + self.view = (self.view + 1).min(self.history.len()); + } + } + + /// Scrolls the whole scroll region up, keeping the lines that leave the top of the screen. + fn scroll_region_up(&mut self, n: usize) { + if !self.in_alt && self.top == 0 { + let c = self.cols; + for y in 0..n.min(self.bot + 1) { + let row = self.grid[y * c..(y + 1) * c].to_vec(); + self.push_history(row); + } + } + self.scroll_up(self.top, n); + } + + pub fn resize(&mut self, cols: usize, rows: usize) { + if cols == self.cols && rows == self.rows { + return; + } + let shift = (self.cur.y + 1).saturating_sub(rows); + let blank = Cell::blank(default_bg()); + let reflow = |old: &[Cell], old_cols: usize, old_rows: usize| { + let mut grid = vec![blank; cols * rows]; + for y in 0..rows.min(old_rows.saturating_sub(shift)) { + for x in 0..cols.min(old_cols) { + grid[y * cols + x] = old[(y + shift) * old_cols + x]; + } + } + grid + }; + if shift > 0 && !self.in_alt { + for y in 0..shift.min(self.rows) { + let row = self.grid[y * self.cols..(y + 1) * self.cols].to_vec(); + self.push_history(row); + } + } + self.grid = reflow(&self.grid, self.cols, self.rows); + self.alt = reflow(&self.alt, self.cols, self.rows); + self.cols = cols; + self.rows = rows; + self.cur.y -= shift; + self.cur.x = self.cur.x.min(cols - 1); + self.cur.wrap_next = false; + self.saved.x = self.saved.x.min(cols - 1); + self.saved.y = self.saved.y.min(rows - 1); + self.top = 0; + self.bot = rows - 1; + self.tabs = tab_stops(cols); + for row in &mut self.history { + row.resize(cols, blank); + } + self.view = self.view.min(self.history.len()); + self.scrolled = true; + } + + pub fn text(&self, a: (usize, usize), b: (usize, usize)) -> String { + let mut out = String::new(); + for y in a.1..=b.1 { + let x1 = if y == a.1 { a.0 } else { 0 }; + let x2 = if y == b.1 { b.0 } else { self.cols - 1 }; + let row = self.row_cells(y); + let mut line: String = row[x1..=x2].iter().filter(|c| c.flags & TAIL == 0).map(|c| c.ch).collect(); + if x2 == self.cols - 1 && row[x2].flags & WRAP == 0 { + line.truncate(line.trim_end().len()); + if y != b.1 { + line.push('\n'); + } + } + out += &line; + } + out + } + + pub fn word_at(&self, x: usize, y: usize) -> (usize, usize) { + let row = self.row_cells(y); + let delim = |c: char| config::get().mouse.word_delims.contains(c); + if delim(row[x].ch) { + return (x, x); + } + let mut start = x; + while start > 0 && !delim(row[start - 1].ch) { + start -= 1; + } + let mut end = x; + while end + 1 < self.cols && !delim(row[end + 1].ch) { + end += 1; + } + (start, end) + } + + pub fn lines(&self) -> usize { + self.history.len() + self.rows + } + + fn line_cells(&self, line: usize) -> &[Cell] { + let past = self.history.len(); + if line < past { + &self.history[line] + } else { + let y = line - past; + &self.grid[y * self.cols..(y + 1) * self.cols] + } + } + + fn row_matches(&self, line: usize, needle: &[char], ignore_case: bool) -> Vec<(usize, usize)> { + let (chars, cols): (Vec, Vec) = self + .line_cells(line) + .iter() + .enumerate() + .filter(|(_, cell)| cell.flags & TAIL == 0) + .map(|(x, cell)| (fold(cell.ch, ignore_case), x)) + .unzip(); + if needle.is_empty() || needle.len() > chars.len() { + return Vec::new(); + } + (0..=chars.len() - needle.len()) + .filter(|&i| chars[i..i + needle.len()] == *needle) + .map(|i| { + let last = cols[i + needle.len() - 1]; + let wide = self.line_cells(line).get(last + 1).is_some_and(|c| c.flags & TAIL != 0); + (cols[i], last + wide as usize) + }) + .collect() + } + + pub fn find(&self, query: &str, from: (usize, usize), backward: bool) -> Option<(usize, usize, usize)> { + let ignore_case = !query.chars().any(char::is_uppercase); + let needle: Vec = query.chars().map(|c| fold(c, ignore_case)).collect(); + let total = self.lines(); + let order: Box> = if backward { + Box::new((0..=from.0.min(total - 1)).rev()) + } else { + Box::new(from.0..total) + }; + for line in order { + let mut found = self.row_matches(line, &needle, ignore_case).into_iter(); + let hit = if backward { + found.rev().find(|m| line < from.0 || m.0 <= from.1) + } else { + found.find(|m| line > from.0 || m.0 >= from.1) + }; + if let Some((start, end)) = hit { + return Some((line, start, end)); + } + } + None + } + + pub fn reveal(&mut self, line: usize) -> usize { + let past = self.history.len(); + let top = past - self.view; + if line < top || line >= top + self.rows { + self.view = past.saturating_sub(line.saturating_sub(self.rows / 2)); + } + line - (past - self.view) + } + + pub fn jump_prompt(&mut self, back: bool) -> bool { + if self.in_alt { + return false; + } + let past = self.history.len(); + let top = self.dropped + past - self.view; + self.marks.retain(|&mark| mark >= self.dropped); + let target = if back { + self.marks.iter().rev().find(|&&mark| mark < top) + } else { + self.marks.iter().find(|&&mark| mark > top) + }; + let view = match target { + Some(&mark) if mark - self.dropped < past => past - (mark - self.dropped), + Some(_) => 0, + None if back => return false, + None => 0, + }; + let moved = view != self.view; + self.view = view; + moved + } + + pub fn url_at(&self, x: usize, y: usize) -> Option { + let row = self.row_cells(y); + let stop = |c: char| c.is_whitespace() || "<>\"'`".contains(c); + if stop(row[x].ch) { + return None; + } + let mut start = x; + while start > 0 && !stop(row[start - 1].ch) { + start -= 1; + } + let mut end = x; + while end + 1 < self.cols && !stop(row[end + 1].ch) { + end += 1; + } + let token: String = row[start..=end].iter().filter(|c| c.flags & TAIL == 0).map(|c| c.ch).collect(); + let mut url = token.trim_start_matches(['(', '[', '{']); + while let Some(last) = url.chars().last() { + let count = |c: char| url.matches(c).count(); + let trim = match last { + ')' => count('(') < count(')'), + ']' => count('[') < count(']'), + '}' => count('{') < count('}'), + '.' | ',' | ';' | ':' | '!' | '?' => true, + _ => false, + }; + if !trim { + break; + } + url = &url[..url.len() - last.len_utf8()]; + } + ["http://", "https://", "ftp://", "file://", "mailto:"] + .iter() + .any(|scheme| url.starts_with(scheme)) + .then(|| url.to_string()) + } + + pub fn feed(&mut self, data: &[u8]) { + for &b in data { + self.byte(b); + } + } + + fn byte(&mut self, b: u8) { + if matches!(self.state, State::Osc | State::Str) { + return self.string(b); + } + match b { + 0x1b => { + self.state = State::Esc; + self.utf8_need = 0; + } + 0x18 | 0x1a => self.state = State::Ground, + _ => match self.state { + State::Ground => self.ground(b), + _ if b < 0x20 => self.control(b), + State::Esc => self.esc(b), + State::Csi => self.csi_byte(b), + State::Inter(i) => { + self.state = State::Ground; + self.intermediate(i, b); + } + _ => {} + }, + } + } + + fn ground(&mut self, b: u8) { + if b < 0x80 { + if self.utf8_need > 0 { + self.utf8_need = 0; + self.put('\u{fffd}'); + } + match b { + 0x20..=0x7e => self.put(b as char), + 0x7f => {} + _ => self.control(b), + } + } else if b & 0xc0 == 0x80 { + if self.utf8_need == 0 { + return self.put('\u{fffd}'); + } + self.utf8[self.utf8_len] = b; + self.utf8_len += 1; + if self.utf8_len == self.utf8_need { + self.utf8_need = 0; + let ch = std::str::from_utf8(&self.utf8[..self.utf8_len]).ok().and_then(|s| s.chars().next()); + self.put(ch.unwrap_or('\u{fffd}')); + } + } else { + if self.utf8_need > 0 { + self.put('\u{fffd}'); + } + self.utf8[0] = b; + self.utf8_len = 1; + self.utf8_need = match b { + 0xc2..=0xdf => 2, + 0xe0..=0xef => 3, + 0xf0..=0xf4 => 4, + _ => 0, + }; + if self.utf8_need == 0 { + self.put('\u{fffd}'); + } + } + } + + fn control(&mut self, b: u8) { + match b { + 0x08 => { + self.cur.x = self.cur.x.saturating_sub(1); + self.cur.wrap_next = false; + } + 0x09 => self.tab(1), + 0x0a..=0x0c => self.linefeed(), + 0x0d => { + self.cur.x = 0; + self.cur.wrap_next = false; + } + _ => {} + } + } + + fn string(&mut self, b: u8) { + match b { + 0x07 | 0x1b => { + if self.state == State::Osc { + self.finish_osc(); + } + self.state = if b == 0x1b { State::Esc } else { State::Ground }; + } + _ if self.state == State::Osc && self.osc.len() < 1 << 20 => self.osc.push(b), + _ => {} + } + } + + fn finish_osc(&mut self) { + let text = String::from_utf8_lossy(&self.osc).into_owned(); + let Some((cmd, rest)) = text.split_once(';') else { return }; + match cmd { + "0" | "2" => self.title = Some(rest.to_string()), + "7" => self.cwd = parse_cwd(rest), + "52" => { + let data = rest.split_once(';').map_or("", |(_, data)| data); + if let Some(text) = decode_base64(data).and_then(|bytes| String::from_utf8(bytes).ok()) { + self.clipboard = Some(text); + } + } + "133" if rest.starts_with('A') && !self.in_alt => { + let line = self.dropped + self.history.len() + self.cur.y; + self.marks.retain(|&mark| mark >= self.dropped); + if self.marks.last() != Some(&line) { + self.marks.push(line); + } + } + _ => {} + } + } + + fn esc(&mut self, b: u8) { + self.state = State::Ground; + match b { + b'[' => { + self.params.clear(); + self.params.push(0); + self.private = 0; + self.inter = 0; + self.state = State::Csi; + } + b']' => { + self.osc.clear(); + self.state = State::Osc; + } + b'P' | b'_' | b'^' | b'X' => self.state = State::Str, + b'(' | b')' | b'*' | b'+' | b'#' | b'%' => self.state = State::Inter(b), + b'7' => self.saved = self.cur, + b'8' => self.restore(), + b'D' => self.linefeed(), + b'E' => { + self.cur.x = 0; + self.linefeed(); + } + b'M' => { + if self.cur.y == self.top { + self.scroll_down(self.top, 1); + } else if self.cur.y > 0 { + self.cur.y -= 1; + } + } + b'H' => self.tabs[self.cur.x] = true, + b'c' => self.reset(), + _ => {} + } + } + + fn intermediate(&mut self, inter: u8, b: u8) { + match (inter, b) { + (b'(', _) => self.cur.gfx = b == b'0', + (b'#', b'8') => self.grid.fill(Cell { ch: 'E', ..Cell::blank(default_bg()) }), + _ => {} + } + } + + fn csi_byte(&mut self, b: u8) { + match b { + b'0'..=b'9' => { + let p = self.params.last_mut().unwrap(); + *p = (*p * 10 + (b - b'0') as usize).min(65535); + } + b';' | b':' => { + if self.params.len() < 32 { + self.params.push(0); + } + } + b'<'..=b'?' => self.private = b, + b' '..=b'/' => self.inter = b, + b'@'..=b'~' => { + self.state = State::Ground; + self.csi(b); + } + _ => {} + } + } + + fn arg(&self, i: usize, default: usize) -> usize { + match self.params.get(i) { + Some(&v) if v > 0 => v, + _ => default, + } + } + + fn csi(&mut self, f: u8) { + match (self.private, self.inter, f) { + (b'?', 0, b'h') => self.dec_mode(true), + (b'?', 0, b'l') => self.dec_mode(false), + (b'>', 0, b'c') => self.reply.extend_from_slice(b"\x1b[>0;0;0c"), + (0, b'!', b'p') => self.reset(), + (0, b' ', b'q') => { + self.cursor_style = match self.params[0] { + 0 => None, + 1 | 2 => Some(CursorStyle::Block), + 3 | 4 => Some(CursorStyle::Underline), + 5 | 6 => Some(CursorStyle::Beam), + _ => self.cursor_style, + } + } + (0, 0, _) => self.csi_plain(f), + _ => {} + } + } + + fn csi_plain(&mut self, f: u8) { + let (x, y, n) = (self.cur.x, self.cur.y, self.arg(0, 1)); + let low = if y >= self.top { self.top } else { 0 }; + let high = if y <= self.bot { self.bot } else { self.rows - 1 }; + match f { + b'@' => self.insert_chars(n), + b'A' => self.goto(x, y.saturating_sub(n).max(low)), + b'B' => self.goto(x, (y + n).min(high)), + b'C' => self.goto(x + n, y), + b'D' => self.goto(x.saturating_sub(n), y), + b'E' => self.goto(0, (y + n).min(high)), + b'F' => self.goto(0, y.saturating_sub(n).max(low)), + b'G' | b'`' => self.goto(n - 1, y), + b'H' | b'f' => self.goto(self.arg(1, 1) - 1, self.arg(0, 1) - 1 + self.row_offset()), + b'I' => self.tab(n as isize), + b'Z' => self.tab(-(n as isize)), + b'J' => match self.params[0] { + 0 => { + self.erase(y, x, self.cols); + for row in y + 1..self.rows { + self.erase(row, 0, self.cols); + } + } + 1 => { + for row in 0..y { + self.erase(row, 0, self.cols); + } + self.erase(y, 0, x + 1); + } + 3 => { + self.dropped += self.history.len(); + self.history.clear(); + self.view = 0; + self.scrolled = true; + } + _ => { + for row in 0..self.rows { + self.erase(row, 0, self.cols); + } + } + }, + b'K' => match self.params[0] { + 0 => self.erase(y, x, self.cols), + 1 => self.erase(y, 0, x + 1), + _ => self.erase(y, 0, self.cols), + }, + b'L' => { + if y >= self.top && y <= self.bot { + self.scroll_down(y, n); + self.goto(0, y); + } + } + b'M' => { + if y >= self.top && y <= self.bot { + self.scroll_up(y, n); + self.goto(0, y); + } + } + b'P' => self.delete_chars(n), + b'X' => self.erase(y, x, (x + n).min(self.cols)), + b'S' => self.scroll_region_up(n), + b'T' => self.scroll_down(self.top, n), + b'b' => { + for _ in 0..n { + self.put(self.last); + } + } + b'd' => self.goto(x, n - 1 + self.row_offset()), + b'g' => match self.params[0] { + 0 => self.tabs[x] = false, + 3 => self.tabs.fill(false), + _ => {} + }, + b'm' => self.sgr(), + b'n' => match self.params[0] { + 5 => self.reply.extend_from_slice(b"\x1b[0n"), + 6 => { + let row = y + 1 - self.row_offset(); + self.reply.extend_from_slice(format!("\x1b[{};{}R", row, x + 1).as_bytes()); + } + _ => {} + }, + b'c' => self.reply.extend_from_slice(b"\x1b[?6c"), + b'r' => { + let (top, bot) = (self.arg(0, 1) - 1, self.arg(1, self.rows) - 1); + if top < bot && bot < self.rows { + self.top = top; + self.bot = bot; + self.goto(0, self.row_offset()); + } + } + b's' => self.saved = self.cur, + b'u' => self.restore(), + _ => {} + } + } + + fn dec_mode(&mut self, set: bool) { + for p in std::mem::take(&mut self.params) { + match p { + 1 => self.app_cursor = set, + 6 => { + self.cur.origin = set; + self.goto(0, self.row_offset()); + } + 7 => self.autowrap = set, + 25 => self.cursor_visible = set, + 1000 => self.mouse = if set { 1 } else { 0 }, + 1002 => self.mouse = if set { 2 } else { 0 }, + 1003 => self.mouse = if set { 3 } else { 0 }, + 1006 => self.mouse_sgr = set, + 47 | 1047 => self.alt_screen(set, false), + 1048 => { + if set { + self.saved = self.cur; + } else { + self.restore(); + } + } + 1049 => self.alt_screen(set, true), + 2004 => self.bracketed_paste = set, + 1004 => self.focus_events = set, + 2026 => self.sync = set, + _ => {} + } + } + } + + fn alt_screen(&mut self, on: bool, save_cursor: bool) { + if on == self.in_alt { + return; + } + if on && save_cursor { + self.saved = self.cur; + } + std::mem::swap(&mut self.grid, &mut self.alt); + self.in_alt = on; + self.view = 0; + if on { + self.grid.fill(Cell::blank(default_bg())); + } else if save_cursor { + self.restore(); + } + self.scrolled = true; + } + + fn sgr(&mut self) { + let p = std::mem::take(&mut self.params); + let mut i = 0; + while i < p.len() { + match p[i] { + 0 => { + self.cur.fg = default_fg(); + self.cur.bg = default_bg(); + self.cur.flags = 0; + } + 1 => self.cur.flags |= BOLD, + 2 => self.cur.flags |= DIM, + 9 => self.cur.flags |= STRIKE, + 4 => self.cur.flags |= UNDERLINE, + 7 => self.cur.flags |= REVERSE, + 22 => self.cur.flags &= !(BOLD | DIM), + 29 => self.cur.flags &= !STRIKE, + 24 => self.cur.flags &= !UNDERLINE, + 27 => self.cur.flags &= !REVERSE, + 30..=37 => self.cur.fg = palette(p[i] - 30), + 40..=47 => self.cur.bg = palette(p[i] - 40), + 90..=97 => self.cur.fg = palette(p[i] - 90 + 8), + 100..=107 => self.cur.bg = palette(p[i] - 100 + 8), + 39 => self.cur.fg = default_fg(), + 49 => self.cur.bg = default_bg(), + 38 | 48 => { + let (color, used) = match (p.get(i + 1), p.get(i + 2), p.get(i + 3), p.get(i + 4)) { + (Some(5), Some(&n), _, _) => (Some(color256(n)), 2), + (Some(2), Some(&r), Some(&g), Some(&b)) => { + (Some((r.min(255) as u32) << 16 | (g.min(255) as u32) << 8 | b.min(255) as u32), 4) + } + _ => (None, p.len()), + }; + if let Some(c) = color { + if p[i] == 38 { + self.cur.fg = c; + } else { + self.cur.bg = c; + } + } + i += used; + } + _ => {} + } + i += 1; + } + } + + fn row_offset(&self) -> usize { + if self.cur.origin { self.top } else { 0 } + } + + fn goto(&mut self, x: usize, y: usize) { + let (low, high) = if self.cur.origin { (self.top, self.bot) } else { (0, self.rows - 1) }; + self.cur.x = x.min(self.cols - 1); + self.cur.y = y.clamp(low, high); + self.cur.wrap_next = false; + } + + fn restore(&mut self) { + self.cur = self.saved; + self.cur.x = self.cur.x.min(self.cols - 1); + self.cur.y = self.cur.y.min(self.rows - 1); + } + + fn tab(&mut self, n: isize) { + let mut x = self.cur.x; + for _ in 0..n.abs() { + if n > 0 { + x += 1; + while x < self.cols - 1 && !self.tabs[x] { + x += 1; + } + } else { + x = x.saturating_sub(1); + while x > 0 && !self.tabs[x] { + x -= 1; + } + } + x = x.min(self.cols - 1); + } + self.cur.x = x; + self.cur.wrap_next = false; + } + + fn linefeed(&mut self) { + if self.cur.y == self.bot { + self.scroll_region_up(1); + } else if self.cur.y + 1 < self.rows { + self.cur.y += 1; + } + self.cur.wrap_next = false; + } + + fn put(&mut self, ch: char) { + let width = if self.cur.gfx { 1 } else { ch.width().unwrap_or(1) }; + if width == 0 { + return; + } + let ch = if self.cur.gfx { line_drawing(ch) } else { ch }; + if self.cur.wrap_next && self.autowrap { + let i = self.cur.y * self.cols + self.cols - 1; + self.grid[i].flags |= WRAP; + self.cur.x = 0; + self.linefeed(); + } + if width == 2 && self.cur.x + 1 >= self.cols { + if !self.autowrap || self.cols < 2 { + return; + } + let i = self.cur.y * self.cols + self.cur.x; + self.grid[i] = Cell { ch: ' ', flags: WRAP, ..Cell::blank(self.cur.bg) }; + self.cur.x = 0; + self.linefeed(); + } + let i = self.cur.y * self.cols + self.cur.x; + let Cursor { fg, bg, flags, .. } = self.cur; + self.grid[i] = Cell { ch, fg, bg, flags }; + if width == 2 { + self.grid[i + 1] = Cell { ch: ' ', fg, bg, flags: flags | TAIL }; + } + self.last = ch; + if self.cur.x + width < self.cols { + self.cur.x += width; + self.cur.wrap_next = false; + } else { + self.cur.x = self.cols - 1; + self.cur.wrap_next = true; + } + } + + fn erase(&mut self, y: usize, x1: usize, x2: usize) { + let blank = Cell::blank(self.cur.bg); + let row = y * self.cols; + self.grid[row + x1..row + x2].fill(blank); + } + + fn scroll_up(&mut self, from: usize, n: usize) { + if from > self.bot { + return; + } + let n = n.min(self.bot + 1 - from); + let c = self.cols; + self.grid.copy_within((from + n) * c..(self.bot + 1) * c, from * c); + for y in self.bot + 1 - n..=self.bot { + self.erase(y, 0, c); + } + self.scrolled = true; + } + + fn scroll_down(&mut self, from: usize, n: usize) { + if from > self.bot { + return; + } + let n = n.min(self.bot + 1 - from); + let c = self.cols; + self.grid.copy_within(from * c..(self.bot + 1 - n) * c, (from + n) * c); + for y in from..from + n { + self.erase(y, 0, c); + } + self.scrolled = true; + } + + fn insert_chars(&mut self, n: usize) { + let (x, y, c) = (self.cur.x, self.cur.y, self.cols); + let n = n.min(c - x); + self.grid.copy_within(y * c + x..y * c + c - n, y * c + x + n); + self.erase(y, x, x + n); + } + + fn delete_chars(&mut self, n: usize) { + let (x, y, c) = (self.cur.x, self.cur.y, self.cols); + let n = n.min(c - x); + self.grid.copy_within(y * c + x + n..y * c + c, y * c + x); + self.erase(y, c - n, c); + } + + fn reset(&mut self) { + let reply = std::mem::take(&mut self.reply); + *self = Term::new(self.cols, self.rows); + self.reply = reply; + self.scrolled = true; + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn row(t: &Term, y: usize) -> String { + (0..t.cols).map(|x| t.cell(x, y).ch).collect::().trim_end().to_string() + } + + #[test] + fn prints_and_wraps() { + let mut t = Term::new(5, 3); + t.feed(b"abcdefg"); + assert_eq!(row(&t, 0), "abcde"); + assert_eq!(row(&t, 1), "fg"); + assert_eq!(t.text((0, 0), (4, 1)), "abcdefg"); + } + + #[test] + fn keeps_scrolled_off_lines_in_history() { + let mut t = Term::new(6, 2); + t.feed(b"one\r\ntwo\r\nthree\r\nfour"); + assert_eq!(t.history.len(), 2); + assert!(t.scroll_view(1)); + assert_eq!(row(&t, 0), "two"); + assert_eq!(row(&t, 1), "three"); + assert!(t.scroll_view(5)); + assert_eq!(row(&t, 0), "one"); + assert_eq!(row(&t, 1), "two"); + assert!(t.scroll_view(-9)); + assert_eq!(row(&t, 0), "three"); + assert_eq!(row(&t, 1), "four"); + } + + #[test] + fn view_stays_put_while_output_arrives() { + let mut t = Term::new(6, 2); + t.feed(b"one\r\ntwo\r\nthree"); + t.scroll_view(1); + assert_eq!(row(&t, 0), "one"); + t.feed(b"\r\nfour"); + assert_eq!(row(&t, 0), "one"); + assert!(t.reset_view()); + assert_eq!(row(&t, 1), "four"); + } + + #[test] + fn alt_screen_has_no_history() { + let mut t = Term::new(6, 2); + t.feed(b"\x1b[?1049ha\r\nb\r\nc\r\nd"); + assert_eq!(t.history.len(), 0); + assert!(!t.scroll_view(1)); + } + + #[test] + fn erase_saved_lines_clears_history() { + let mut t = Term::new(6, 2); + t.feed(b"one\r\ntwo\r\nthree"); + assert_eq!(t.history.len(), 1); + t.feed(b"\x1b[3J"); + assert_eq!(t.history.len(), 0); + } + + #[test] + fn scrolls_at_bottom() { + let mut t = Term::new(6, 2); + t.feed(b"one\r\ntwo\r\nthree"); + assert_eq!(row(&t, 0), "two"); + assert_eq!(row(&t, 1), "three"); + } + + #[test] + fn cursor_movement_and_erase() { + let mut t = Term::new(10, 3); + t.feed(b"hello\x1b[3D\x1b[K"); + assert_eq!(row(&t, 0), "he"); + t.feed(b"\x1b[2;4Hx"); + assert_eq!((t.cur.x, t.cur.y), (4, 1)); + assert_eq!(t.cell(3, 1).ch, 'x'); + } + + #[test] + fn colors() { + let mut t = Term::new(10, 2); + t.feed(b"\x1b[38;2;1;2;3mA\x1b[48;5;196mB\x1b[0mC"); + assert_eq!(t.cell(0, 0).fg, 0x010203); + assert_eq!(t.cell(1, 0).bg, 0xff0000); + assert_eq!(t.cell(2, 0).bg, default_bg()); + } + + #[test] + fn utf8_and_title() { + let mut t = Term::new(10, 2); + t.feed("é€😀".as_bytes()); + t.feed(b"\x1b]0;hi there\x07"); + assert_eq!(row(&t, 0), "é€😀"); + assert_eq!(t.title.as_deref(), Some("hi there")); + } + + #[test] + fn alt_screen_restores() { + let mut t = Term::new(6, 2); + t.feed(b"main\x1b[?1049halt\x1b[?1049l"); + assert_eq!(row(&t, 0), "main"); + assert_eq!(t.cur.x, 4); + } + + #[test] + fn scroll_region_and_insert_line() { + let mut t = Term::new(4, 4); + t.feed(b"a\r\nb\r\nc\r\nd\x1b[2;3r\x1b[2;1H\x1b[L"); + assert_eq!((row(&t, 0), row(&t, 1), row(&t, 2), row(&t, 3)), ("a".into(), "".into(), "b".into(), "d".into())); + } + + #[test] + fn device_status_report() { + let mut t = Term::new(10, 5); + t.feed(b"\x1b[3;4H\x1b[6n"); + assert_eq!(t.reply, b"\x1b[3;4R"); + } + + #[test] + fn resize_keeps_cursor_line() { + let mut t = Term::new(5, 4); + t.feed(b"1\r\n2\r\n3\r\n4"); + t.resize(5, 2); + assert_eq!((row(&t, 0), row(&t, 1)), ("3".into(), "4".into())); + assert_eq!(t.cur.y, 1); + } + + #[test] + fn mouse_modes() { + let mut t = Term::new(10, 2); + t.feed(b"\x1b[?1002h\x1b[?1006h"); + assert_eq!((t.mouse, t.mouse_sgr), (2, true)); + t.feed(b"\x1b[?1002l"); + assert_eq!(t.mouse, 0); + } + + #[test] + fn cursor_shape() { + let mut t = Term::new(10, 2); + t.feed(b"\x1b[5 q"); + assert!(matches!(t.cursor_style, Some(CursorStyle::Beam))); + t.feed(b"\x1b[0 q"); + assert!(t.cursor_style.is_none()); + } + + #[test] + fn wide_chars_take_two_cells() { + let mut t = Term::new(6, 2); + t.feed("a日b".as_bytes()); + assert_eq!((t.cell(1, 0).ch, t.cell(3, 0).ch, t.cur.x), ('日', 'b', 4)); + assert_eq!(t.text((0, 0), (5, 0)), "a日b"); + let mut t = Term::new(4, 2); + t.feed("abc日".as_bytes()); + assert_eq!((t.cell(0, 1).ch, t.cur.y, t.cur.x), ('日', 1, 2)); + } + + #[test] + fn osc_52_and_7() { + let mut t = Term::new(10, 2); + t.feed(b"\x1b]52;c;aGVsbG8=\x07\x1b]7;file://host/tmp/a%20b\x07"); + assert_eq!(t.clipboard.as_deref(), Some("hello")); + assert_eq!(t.cwd.as_deref(), Some("/tmp/a b")); + } + + #[test] + fn sync_and_focus_modes() { + let mut t = Term::new(10, 2); + t.feed(b"\x1b[?2026h\x1b[?1004h"); + assert!(t.sync && t.focus_events); + t.feed(b"\x1b[?2026l"); + assert!(!t.sync); + } + + #[test] + fn dim_and_strike() { + let mut t = Term::new(10, 2); + t.feed(b"\x1b[2;9mA\x1b[22;29mB"); + assert_eq!(t.cell(0, 0).flags & (DIM | STRIKE), DIM | STRIKE); + assert_eq!(t.cell(1, 0).flags & (DIM | STRIKE), 0); + } + + #[test] + fn jumps_between_prompts() { + let mut t = Term::new(6, 2); + t.feed(b"\x1b]133;A\x07one\r\ntwo\r\n\x1b]133;A\x07three\r\nfour"); + assert!(t.jump_prompt(true)); + assert_eq!(row(&t, 0), "one"); + assert!(t.jump_prompt(false)); + assert_eq!(t.view, 0); + assert!(!t.jump_prompt(false)); + } + + #[test] + fn finds_text_in_both_directions() { + let mut t = Term::new(10, 2); + t.feed(b"foo bar\r\nBaz foo"); + assert_eq!(t.find("foo", (1, usize::MAX), true), Some((1, 4, 6))); + assert_eq!(t.find("foo", (1, 3), true), Some((0, 0, 2))); + assert_eq!(t.find("foo", (0, 1), false), Some((1, 4, 6))); + assert_eq!(t.find("baz", (1, usize::MAX), true), Some((1, 0, 2))); + assert_eq!(t.find("BAZ", (1, usize::MAX), true), None); + } + + #[test] + fn detects_urls() { + let mut t = Term::new(40, 2); + t.feed(b"see (https://a.b/c). ok"); + assert_eq!(t.url_at(10, 0).as_deref(), Some("https://a.b/c")); + assert_eq!(t.url_at(1, 0), None); + } +}