From 7868e5627336c7788d1f51537300d470bf79214e Mon Sep 17 00:00:00 2001 From: ddidderr Date: Mon, 12 Oct 2026 02:08:22 +0200 Subject: [PATCH] refactor: resolve cross-crate integration TODOs and resources discovery Cleanup pass over items collected in docs/rewrite/INTEGRATION_TODO.md: - OSC 133 printf-q / percent decoders live once in ghostty_vt::string_encoding; ghostty-os re-exports them. - CursorStyle and clipboard codepoint map entries have one definition (ghostty-terminal); ghostty-config now depends on ghostty-terminal. - ActionKey::docs() trailing blank lines fixed at the source; cli's workaround removed (+list-actions --docs still byte-identical). - x11_color exposes names in original case; cli no longer re-parses rgb.txt. - One cgroup scope wait loop (ghostty_os::cgroup::wait_for_scope). - Flatpak passwd lookup implemented in ghostty-os via a local pty. - +version shows the rustc version captured at build time. Resources: dev builds find /resources automatically (keeping Ghostty's sentinel-file detection and GHOSTTY_RESOURCES_DIR); scripts/gen-terminfo.sh (just terminfo) regenerates the compiled terminfo; `just install [PREFIX]` installs binary, share/ghostty, terminfo, desktop/D-Bus/systemd/metainfo files and icons. Test Plan: - cargo test for vt, os, config, input, cli - diff `+list-keybinds`, `+list-actions --docs`, `+show-config --default` against /usr/bin/ghostty --- crates/ghostty-cli/Cargo.toml | 1 + crates/ghostty-cli/build.rs | 18 ++ crates/ghostty-cli/src/list_actions.rs | 52 +--- crates/ghostty-cli/src/list_colors.rs | 29 +- crates/ghostty-cli/src/version.rs | 6 +- crates/ghostty-config/Cargo.toml | 1 + .../src/clipboard_codepoint_map.rs | 25 +- crates/ghostty-config/src/types_a.rs | 44 +-- crates/ghostty-input/src/binding/action.rs | 4 - crates/ghostty-input/src/binding/set.rs | 5 +- crates/ghostty-os/Cargo.toml | 3 +- crates/ghostty-os/src/cgroup.rs | 24 ++ crates/ghostty-os/src/passwd.rs | 103 ++++++- crates/ghostty-os/src/resourcesdir.rs | 46 ++++ crates/ghostty-os/src/string_encoding.rs | 251 +----------------- crates/ghostty-termio/examples/terminfo.rs | 6 + crates/ghostty-vt/src/lib.rs | 1 + .../src/osc/parsers/semantic_prompt.rs | 119 +-------- crates/ghostty-vt/src/string_encoding.rs | 250 +++++++++++++++++ crates/ghostty-vt/src/x11_color.rs | 48 +++- docs/rewrite/INTEGRATION_TODO.md | 20 +- docs/rewrite/notes/cleanup-V2.md | 55 ++++ justfile | 36 +++ scripts/gen-terminfo.sh | 22 ++ 24 files changed, 656 insertions(+), 513 deletions(-) create mode 100644 crates/ghostty-cli/build.rs create mode 100644 crates/ghostty-termio/examples/terminfo.rs create mode 100644 crates/ghostty-vt/src/string_encoding.rs create mode 100644 docs/rewrite/notes/cleanup-V2.md create mode 100755 scripts/gen-terminfo.sh diff --git a/crates/ghostty-cli/Cargo.toml b/crates/ghostty-cli/Cargo.toml index b989660..9bee436 100644 --- a/crates/ghostty-cli/Cargo.toml +++ b/crates/ghostty-cli/Cargo.toml @@ -11,6 +11,7 @@ ghostty-config.workspace = true ghostty-font.workspace = true ghostty-input.workspace = true ghostty-os.workspace = true +ghostty-vt.workspace = true log = "0.4.34" ratatui = "0.30.2" thiserror = "2.0.21" diff --git a/crates/ghostty-cli/build.rs b/crates/ghostty-cli/build.rs new file mode 100644 index 0000000..126a777 --- /dev/null +++ b/crates/ghostty-cli/build.rs @@ -0,0 +1,18 @@ +//! Captures the compiler version for `+version` (Zig: `builtin.zig_version_string`). + +use std::process::Command; + +fn main() { + println!("cargo:rerun-if-changed=build.rs"); + println!("cargo:rerun-if-env-changed=RUSTC"); + let rustc = std::env::var("RUSTC").unwrap_or_else(|_| "rustc".into()); + let version = Command::new(rustc) + .arg("--version") + .output() + .ok() + .and_then(|o| String::from_utf8(o.stdout).ok()) + // "rustc 1.99.0 (b940084d7 2026-09-28)" -> "1.99.0" + .and_then(|s| s.split_whitespace().nth(1).map(str::to_owned)) + .unwrap_or_else(|| "unknown".into()); + println!("cargo:rustc-env=GHOSTTY_RUSTC_VERSION={version}"); +} diff --git a/crates/ghostty-cli/src/list_actions.rs b/crates/ghostty-cli/src/list_actions.rs index 48f59da..a4b1527 100644 --- a/crates/ghostty-cli/src/list_actions.rs +++ b/crates/ghostty-cli/src/list_actions.rs @@ -3,7 +3,6 @@ use std::io::Write; use ghostty_config::config_struct; -use ghostty_input::binding::ActionKey; use crate::error::Error; @@ -35,44 +34,12 @@ pub fn run(args: &[String]) -> Result { } /// `helpgen_actions.generate(.plaintext, show_docs)`. -/// -/// TODO(integration): this is a local copy of -/// `ghostty_input::helpgen_actions::generate`. The embedded docs of a few -/// actions in ghostty-input (`adjust_selection`, ...) end with an extra blank -/// line compared to Ghostty's `help_strings`, which adds a stray `" "` line -/// to `--docs` output. Trailing newlines are normalised here; once the data -/// is fixed in ghostty-input this should call `helpgen_actions::generate`. #[must_use] pub fn generate_plaintext(show_docs: bool) -> String { - let mut writer = String::new(); - let mut stream = String::new(); - for &key in ActionKey::ALL { - let name = key.name(); - if name.starts_with('_') { - continue; - } - - // Write previously stored doc comment below all related actions - if show_docs { - writer.push_str(&stream); - writer.push('\n'); - stream.clear(); - writer.push_str(name); - writer.push_str(":\n"); - for line in key.docs().trim_end_matches('\n').split('\n') { - stream.push_str(" "); - stream.push_str(line); - stream.push('\n'); - } - } else { - writer.push_str(name); - writer.push('\n'); - } - } - - // Write any remaining buffered documentation - writer.push_str(&stream); - writer + ghostty_input::helpgen_actions::generate_string( + ghostty_input::helpgen_actions::Format::Plaintext, + show_docs, + ) } #[cfg(test)] @@ -94,15 +61,4 @@ mod tests { assert!(s.contains(" respectively.\n\njump_to_prompt:\n")); assert!(s.ends_with('\n')); } - - #[test] - fn matches_input_crate_when_docs_are_clean() { - // The first actions have well-formed docs. - let ours = generate_plaintext(true); - let theirs = ghostty_input::helpgen_actions::generate_string( - ghostty_input::helpgen_actions::Format::Plaintext, - true, - ); - assert!(ours.lines().take(100).eq(theirs.lines().take(100))); - } } diff --git a/crates/ghostty-cli/src/list_colors.rs b/crates/ghostty-cli/src/list_colors.rs index bb97965..6e6d689 100644 --- a/crates/ghostty-cli/src/list_colors.rs +++ b/crates/ghostty-cli/src/list_colors.rs @@ -16,35 +16,14 @@ config_struct! { } cli_options!(Options, help); -/// `terminal/res/rgb.txt`, the same file `x11_color` is built from. -/// -/// TODO(integration): ghostty-vt's `x11_color` only exposes lookups (and -/// lower-cases the names); expose its key list and use it instead of -/// re-parsing the data here. -const RGB_DATA: &str = include_str!("../../ghostty-vt/src/res/rgb.txt"); - /// All X11 color names (with their original spelling) and values. Like the /// case-insensitive Zig map the first spelling of a name wins. #[must_use] pub fn x11_colors() -> Vec<(String, (u8, u8, u8))> { - let mut seen = std::collections::HashSet::new(); - let mut out = vec![]; - for line in RGB_DATA.split('\n') { - if line.len() < 12 { - continue; - } - let parse = |s: &str| s.trim_matches(' ').parse::().ok(); - let (Some(r), Some(g), Some(b)) = - (parse(&line[0..3]), parse(&line[4..7]), parse(&line[8..11])) - else { - continue; - }; - let name = line[12..].trim_matches([' ', '\t', '\n']); - if seen.insert(name.to_ascii_lowercase()) { - out.push((name.to_string(), (r, g, b))); - } - } - out + ghostty_vt::x11_color::names() + .iter() + .map(|(n, c)| ((*n).to_string(), (c.r, c.g, c.b))) + .collect() } /// `std.ascii.orderIgnoreCase`. diff --git a/crates/ghostty-cli/src/version.rs b/crates/ghostty-cli/src/version.rs index 001d6d8..253f055 100644 --- a/crates/ghostty-cli/src/version.rs +++ b/crates/ghostty-cli/src/version.rs @@ -41,11 +41,7 @@ pub fn write_version(out: &mut dyn Write, info: &BuildInfo, tty: bool) -> std::i writeln!(out, "Build Config")?; // The Zig build prints its compiler version here. - writeln!( - out, - " - Rust version : {}", - option_env!("CARGO_PKG_RUST_VERSION").unwrap_or("unknown") - )?; + writeln!(out, " - Rust version : {}", env!("GHOSTTY_RUSTC_VERSION"))?; writeln!(out, " - build mode : {}", info.build_mode)?; writeln!(out, " - app runtime : {}", info.app_runtime)?; writeln!(out, " - font engine : {}", info.font_backend)?; diff --git a/crates/ghostty-config/Cargo.toml b/crates/ghostty-config/Cargo.toml index 138debe..19b0cb1 100644 --- a/crates/ghostty-config/Cargo.toml +++ b/crates/ghostty-config/Cargo.toml @@ -8,6 +8,7 @@ license.workspace = true const_format = "0.2.36" ghostty-input.workspace = true ghostty-os.workspace = true +ghostty-terminal.workspace = true ghostty-vt.workspace = true log = "0.4.34" thiserror = "2.0.21" diff --git a/crates/ghostty-config/src/clipboard_codepoint_map.rs b/crates/ghostty-config/src/clipboard_codepoint_map.rs index f49fd68..0e9a1c0 100644 --- a/crates/ghostty-config/src/clipboard_codepoint_map.rs +++ b/crates/ghostty-config/src/clipboard_codepoint_map.rs @@ -2,27 +2,10 @@ //! for clipboard operations. When copying text to clipboard, matching //! codepoints will be replaced with their mapped values. -/// Replacement value for a codepoint range. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum Replacement { - /// A single replacement codepoint. - Codepoint(u32), - /// A UTF-8 encoded string to replace with. - String(String), -} - -/// One entry of the map. -/// -/// TODO(integration): this mirrors `terminal/formatter.zig` `CodepointMap`; -/// replace with `ghostty_terminal::formatter::CodepointMap` once available. -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct Entry { - /// Unicode codepoint range to replace. - /// Asserts: range[0] <= range[1] - pub range: [u32; 2], - /// Replacement value for this range. - pub replacement: Replacement, -} +/// One entry of the map and its replacement value. The canonical +/// definitions live in `ghostty_terminal::formatter` (`CodepointMap`, +/// `Replacement`), which is what the terminal formatter consumes. +pub use ghostty_terminal::formatter::{CodepointMap as Entry, Replacement}; /// The list of entries. /// diff --git a/crates/ghostty-config/src/types_a.rs b/crates/ghostty-config/src/types_a.rs index bdeb500..aad4d54 100644 --- a/crates/ghostty-config/src/types_a.rs +++ b/crates/ghostty-config/src/types_a.rs @@ -21,23 +21,35 @@ fn write_void(f: &mut EntryFormatter<'_>) -> fmt::Result { } // --------------------------------------------------------------------- -// Cursor style (ZIG/src/terminal/cursor.zig `Style`), duplicated here -// because config cannot depend on ghostty-terminal. +// Cursor style: the canonical type is `ghostty_terminal::cursor::Style` +// (ZIG/src/terminal/cursor.zig `Style`); config only adds the field impl. -config_enum! { - /// The visual style of the cursor. Whether or not it blinks - /// is determined by mode 12 (modes.zig). This mode is synchronized - /// with CSI q, the same as xterm. - pub enum CursorStyle { - /// DECSCUSR 5, 6 - Bar => "bar", - /// DECSCUSR 1, 2 - Block => "block", - /// DECSCUSR 3, 4 - Underline => "underline", - /// Hollow block cursor. This is a block cursor with the center empty. - /// Reported as DECSCUSR 1 or 2 (block). - BlockHollow => "block_hollow", +pub use ghostty_terminal::cursor::Style as CursorStyle; + +impl ConfigField for CursorStyle { + const VALID_VALUES: Option<&'static [&'static str]> = + Some(&["bar", "block", "underline", "block_hollow"]); + + fn parse_cli(&mut self, value: Option<&str>) -> Result<(), ArgError> { + let v = value.ok_or(ArgError::ValueRequired)?; + *self = match v { + "bar" => CursorStyle::Bar, + "block" => CursorStyle::Block, + "underline" => CursorStyle::Underline, + "block_hollow" => CursorStyle::BlockHollow, + _ => return Err(ArgError::InvalidValue), + }; + Ok(()) + } + + fn format_entry(&self, f: &mut EntryFormatter<'_>) -> fmt::Result { + let name = match self { + CursorStyle::Bar => "bar", + CursorStyle::Block => "block", + CursorStyle::Underline => "underline", + CursorStyle::BlockHollow => "block_hollow", + }; + writeln!(f.writer, "{} = {}", f.name, name) } } diff --git a/crates/ghostty-input/src/binding/action.rs b/crates/ghostty-input/src/binding/action.rs index 848f8ac..493edf7 100644 --- a/crates/ghostty-input/src/binding/action.rs +++ b/crates/ghostty-input/src/binding/action.rs @@ -1438,7 +1438,6 @@ Valid arguments are: Adjust the selection to the beginning or the end of the line respectively. - "## } ActionKey::JumpToPrompt => { @@ -1469,7 +1468,6 @@ Valid actions are: The default OS editor is determined by using `open` on macOS and `xdg-open` on Linux. - "## } ActionKey::WriteScreenFile => { @@ -1576,7 +1574,6 @@ Valid arguments: Creates a new split along the larger direction. For example, if the parent split is currently wider than it is tall, then a left-right split would be created, and vice versa. - "## } ActionKey::GotoSplit => { @@ -1943,7 +1940,6 @@ The value determines the crash location: - `render` Crash on the render thread for the focused surface. - "## } } diff --git a/crates/ghostty-input/src/binding/set.rs b/crates/ghostty-input/src/binding/set.rs index a725124..94dae21 100644 --- a/crates/ghostty-input/src/binding/set.rs +++ b/crates/ghostty-input/src/binding/set.rs @@ -9,8 +9,9 @@ use indexmap::IndexMap; use super::{Action, Elem, Error, Flags, FoldedTrigger, Parser, Trigger, TriggerKey}; use crate::key::KeyEvent; -/// TODO(integration): stand-in for `config/formatter.zig` `EntryFormatter`. -/// The config crate implements this to write `keybind = ` lines. +/// Sink for `config/formatter.zig` `EntryFormatter`. The config crate +/// implements this (`ghostty_config::keybinds`) to write `keybind = ` +/// lines. pub trait EntryFormatter { /// Format a single `key = value` entry where `value` is a string. fn format_entry(&mut self, value: &str); diff --git a/crates/ghostty-os/Cargo.toml b/crates/ghostty-os/Cargo.toml index 25836eb..541f6c8 100644 --- a/crates/ghostty-os/Cargo.toml +++ b/crates/ghostty-os/Cargo.toml @@ -5,10 +5,11 @@ edition.workspace = true license.workspace = true [dependencies] +ghostty-vt.workspace = true gettext-rs = { version = "0.8.0", features = ["gettext-system"] } log = "0.4.34" nix = { version = "0.31.3", features = ["user"] } -rustix = { version = "1.1.5", features = ["fs", "process", "system", "pipe", "net", "time", "rand"] } +rustix = { version = "1.1.5", features = ["fs", "process", "system", "pipe", "net", "time", "rand", "pty"] } thiserror = "2.0.21" zbus = "5.19.0" diff --git a/crates/ghostty-os/src/cgroup.rs b/crates/ghostty-os/src/cgroup.rs index dc8a214..9768532 100644 --- a/crates/ghostty-os/src/cgroup.rs +++ b/crates/ghostty-os/src/cgroup.rs @@ -24,6 +24,30 @@ pub fn current(pid: u32) -> Option { Some(String::from_utf8_lossy(&rest[..end]).into_owned()) } +/// True if the final path component of `pid`'s cgroup equals `expected`. +pub fn scope_matches(pid: u32, expected: &str) -> bool { + current(pid) + .as_deref() + .and_then(|raw| raw.rsplit_once('/')) + .is_some_and(|(_, last)| last == expected) +} + +/// Poll (every 25ms, at most 250ms) until `pid`'s cgroup ends in `expected`. +/// Returns true if seen, false on timeout. This is the single wait loop +/// shared by the termio `Command` hooks and apprt-gtk's pre-exec/post-fork. +pub fn wait_for_scope(pid: u32, expected: &str) -> bool { + let start = std::time::Instant::now(); + loop { + if start.elapsed() > std::time::Duration::from_millis(250) { + return false; + } + if scope_matches(pid, expected) { + return true; + } + std::thread::sleep(std::time::Duration::from_millis(25)); + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/ghostty-os/src/passwd.rs b/crates/ghostty-os/src/passwd.rs index 21fb6d5..52b7eb8 100644 --- a/crates/ghostty-os/src/passwd.rs +++ b/crates/ghostty-os/src/passwd.rs @@ -25,16 +25,12 @@ pub fn get() -> std::io::Result { } }; - // TODO(integration): Zig shells out to the host via - // FlatpakHostCommand + a pty (`getent passwd `) when running in - // Flatpak, because the sandbox passwd entry only has a correct username. - // That needs the `ghostty-termio` Pty; until then we use the sandbox - // entry and warn. + // If we're in flatpak then our entry is always empty so we grab it + // by shelling out to the host. note that we do HAVE an entry in the + // sandbox but only the username is correct. if is_flatpak() { - log::warn!( - "flatpak detected, but host passwd lookup is not implemented; \ - using sandbox passwd entry" - ); + log::info!("flatpak detected, will use host command to get our entry"); + return host_entry(&user.name); } Ok(Entry { @@ -44,10 +40,99 @@ pub fn get() -> std::io::Result { }) } +/// Flatpak: look up our entry on the host with `getent passwd ` over a +/// pty (Zig `passwd.get`, flatpak branch). +/// +/// Dependency note: Zig uses `pty.zig`; `ghostty-termio` (which owns the Rust +/// `Pty`) depends on this crate, so a minimal openpt/ptsname pair is opened +/// here with rustix instead. Like Zig, only `shell` and `home` are filled in. +fn host_entry(name: &str) -> std::io::Result { + use rustix::fs::{Mode, OFlags}; + use rustix::pty::{OpenptFlags, grantpt, openpt, ptsname, unlockpt}; + use std::io::Read; + + let err = std::io::Error::from; + let master = + openpt(OpenptFlags::RDWR | OpenptFlags::NOCTTY | OpenptFlags::CLOEXEC).map_err(err)?; + grantpt(&master).map_err(err)?; + unlockpt(&master).map_err(err)?; + let slave_name = ptsname(&master, Vec::new()).map_err(err)?; + let slave = rustix::fs::open( + slave_name.as_c_str(), + OFlags::RDWR | OFlags::NOCTTY, + Mode::empty(), + ) + .map_err(err)?; + + // Note: we wrap our getent call in a /bin/sh login shell because + // some operating systems (NixOS tested) don't set the PATH for various + // utilities properly until we get a login shell. + let mut cmd = crate::flatpak::FlatpakHostCommand::new( + vec![ + "/bin/sh".into(), + "-l".into(), + "-c".into(), + format!("getent passwd {name}"), + ], + slave.try_clone()?, + slave.try_clone()?, + slave.try_clone()?, + ); + cmd.spawn().map_err(std::io::Error::other)?; + cmd.wait().map_err(std::io::Error::other)?; + + // Once started, we can close the child side. We do this after + // wait right now but that is fine too. This lets us read the + // parent and detect EOF. + drop(cmd); + drop(slave); + + // Read all of our output (max total size is 1024 bytes, like Zig). + let mut file = std::fs::File::from(master); + let mut output = Vec::new(); + let mut buf = [0u8; 1024]; + loop { + let n = match file.read(&mut buf) { + Ok(0) => break, + Ok(n) => n, + // EIO is triggered at the end since we closed our child side. + // This is just EOF for this. + Err(e) if e.raw_os_error() == Some(rustix::io::Errno::IO.raw_os_error()) => break, + Err(e) => return Err(e), + }; + output.extend_from_slice(&buf[..n]); + if output.len() > buf.len() { + break; + } + } + + Ok(parse_getent(&String::from_utf8_lossy(&output))) +} + +/// Shell and home are the last two `:`-separated fields of the output. +fn parse_getent(output: &str) -> Entry { + let mut it = output.trim_end_matches([' ', '\r', '\n']).rsplit(':'); + let shell = it.next().map(str::to_owned); + let home = it.next().map(str::to_owned); + Entry { + shell, + home, + name: None, + } +} + #[cfg(test)] mod tests { use super::*; + #[test] + fn parse_getent_output() { + let e = parse_getent("mitchell:x:1000:1000:Mitchell:/home/mitchell:/bin/zsh\r\n"); + assert_eq!(e.shell.as_deref(), Some("/bin/zsh")); + assert_eq!(e.home.as_deref(), Some("/home/mitchell")); + assert_eq!(e.name, None); + } + #[test] fn get_entry() { // We should be able to get an entry diff --git a/crates/ghostty-os/src/resourcesdir.rs b/crates/ghostty-os/src/resourcesdir.rs index cc0ae00..968a9ed 100644 --- a/crates/ghostty-os/src/resourcesdir.rs +++ b/crates/ghostty-os/src/resourcesdir.rs @@ -64,6 +64,7 @@ fn resources_dir_impl(debug: bool, exe: Option<&Path>, env: Option) -> R let Some(mut exe) = exe else { return ResourcesDir::default(); }; + let exe_start = exe; // We have an exe path! Climb the tree looking for the terminfo // bundle as we expect it. @@ -91,9 +92,41 @@ fn resources_dir_impl(debug: bool, exe: Option<&Path>, env: Option) -> R }; } + // Rust-specific: development builds run out of `/target`, where + // there is no installed `share/` tree. Fall back to the in-repo + // `/resources` directory (it carries the same sentinel, + // `terminfo/x/xterm-ghostty`, see scripts/gen-terminfo.sh) if the binary + // lives inside this workspace. + if debug && let Some(dir) = dev_resources_dir(exe_start) { + return ResourcesDir { + app_path: Some(dir), + host_path: None, + }; + } + ResourcesDir::default() } +/// The workspace root this crate was compiled in (`crates/ghostty-os/../..`). +fn workspace_root() -> std::path::PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("../..") +} + +/// Returns `/resources` if `exe` is inside the workspace this +/// crate was built from and the directory contains our sentinel. +fn dev_resources_dir(exe: &Path) -> Option { + let root = workspace_root().canonicalize().ok()?; + let exe = exe.canonicalize().unwrap_or_else(|_| exe.to_path_buf()); + if !exe.starts_with(&root) { + return None; + } + let res = root.join("resources"); + SENTINELS + .iter() + .any(|s| res.join(s).exists()) + .then(|| res.to_string_lossy().into_owned()) +} + /// Little helper to check if the "base/sub/suffix" directory exists and /// if so return "base/sub". The "suffix" is just used as a way to verify a /// directory seems roughly right. @@ -168,6 +201,19 @@ mod tests { ); } + #[test] + fn dev_fallback_finds_workspace_resources() { + let exe = workspace_root().join("target/debug/ghostty"); + let r = resources_dir_impl(true, Some(&exe), None); + assert!(r.app().is_some_and(|d| d.ends_with("/resources"))); + // Outside the workspace there is no fallback. + let r = resources_dir_impl(true, Some(Path::new("/nonexistent/bin/x")), None); + assert_eq!(r.app(), None); + // Release builds never use it. + let r = resources_dir_impl(false, Some(&exe), None); + assert_eq!(r.app(), None); + } + #[test] fn maybe_dir_checks_suffix() { let td = make_tree(); diff --git a/crates/ghostty-os/src/string_encoding.rs b/crates/ghostty-os/src/string_encoding.rs index 144218b..c55a39a 100644 --- a/crates/ghostty-os/src/string_encoding.rs +++ b/crates/ghostty-os/src/string_encoding.rs @@ -1,245 +1,8 @@ -use std::io::{self, Write}; +//! String decoding/encoding helpers (ZIG/src/os/string_encoding.zig). +//! +//! The implementation lives in `ghostty-vt` (the OSC 133 parser needs it and +//! vt cannot depend on os); this module re-exports it. -#[derive(Debug, thiserror::Error)] -pub enum Error { - #[error("decode error")] - DecodeError, - #[error(transparent)] - Io(#[from] io::Error), -} - -impl PartialEq for Error { - fn eq(&self, other: &Self) -> bool { - matches!((self, other), (Error::DecodeError, Error::DecodeError)) - } -} - -/// Decode date from the buffer that has been encoded in the same way that -/// `bash`'s `printf %q` encodes a string and write it to the writer. If an -/// error is returned garbage may have been written to the buffer. -pub fn printf_q_decode(writer: &mut W, buf: &[u8]) -> Result<(), Error> { - let data: &[u8] = if buf.starts_with(b"$'") { - // Strip off `$''` quoting. - if buf.len() < 3 || !buf.ends_with(b"'") { - return Err(Error::DecodeError); - } - &buf[2..buf.len() - 1] - } else if buf.starts_with(b"'") { - // Strip off `''` quoting. - if buf.len() < 2 || !buf.ends_with(b"'") { - return Err(Error::DecodeError); - } - &buf[1..buf.len() - 1] - } else { - buf - }; - - let mut src = 0; - while src < data.len() { - match data[src] { - b'\\' => { - if src + 1 >= data.len() { - return Err(Error::DecodeError); - } - let out = match data[src + 1] { - c @ (b' ' | b'\\' | b'"' | b'\'' | b'$') => c, - b'e' => 0x1b, - b'n' => b'\n', - b'r' => b'\r', - b't' => b'\t', - b'v' => 0x0b, - _ => return Err(Error::DecodeError), - }; - writer.write_all(&[out])?; - src += 2; - } - c => { - writer.write_all(&[c])?; - src += 1; - } - } - } - Ok(()) -} - -/// Decode data from the buffer that has been URL percent encoded and write -/// it to the given buffer. If an error is returned the garbage may have been -/// written to the writer. -pub fn url_percent_decode(writer: &mut W, buf: &[u8]) -> Result<(), Error> { - let mut src = 0; - while src < buf.len() { - match buf[src] { - b'%' => { - if src + 2 >= buf.len() { - return Err(Error::DecodeError); - } - let (Some(hi), Some(lo)) = (hex(buf[src + 1]), hex(buf[src + 2])) else { - return Err(Error::DecodeError); - }; - writer.write_all(&[(hi << 4) | lo])?; - src += 3; - } - c => { - writer.write_all(&[c])?; - src += 1; - } - } - } - Ok(()) -} - -fn hex(c: u8) -> Option { - match c { - b'0'..=b'9' => Some(c - b'0'), - b'a'..=b'f' => Some(c - b'a' + 10), - b'A'..=b'F' => Some(c - b'A' + 10), - _ => None, - } -} - -/// Is the given character valid in URI percent encoding? -fn is_valid_char(c: u8) -> bool { - match c { - b' ' | b';' | b'=' => false, - _ => (0x20..=0x7e).contains(&c), - } -} - -/// Write data to the writer after URI percent encoding. -pub fn url_percent_encode(writer: &mut W, data: &[u8]) -> io::Result<()> { - for &c in data { - if is_valid_char(c) { - writer.write_all(&[c])?; - } else { - write!(writer, "%{c:02X}")?; - } - } - Ok(()) -} - -#[cfg(test)] -mod tests { - use super::*; - - fn pq(s: &[u8]) -> Result, Error> { - let mut w = Vec::new(); - printf_q_decode(&mut w, s).map(|()| w) - } - - fn pd(s: &[u8]) -> Result, Error> { - let mut w = Vec::new(); - url_percent_decode(&mut w, s).map(|()| w) - } - - #[test] - fn printf_q_1() { - assert_eq!(pq(b"bobr\\ kurwa").unwrap(), b"bobr kurwa"); - } - - #[test] - fn printf_q_2() { - assert_eq!(pq(b"bobr\\nkurwa").unwrap(), b"bobr\nkurwa"); - } - - #[test] - fn printf_q_3() { - assert_eq!(pq(b"bobr\\dkurwa"), Err(Error::DecodeError)); - } - - #[test] - fn printf_q_4() { - assert_eq!(pq(b"bobr kurwa\\"), Err(Error::DecodeError)); - } - - #[test] - fn printf_q_5() { - assert_eq!(pq(b"$'bobr kurwa'").unwrap(), b"bobr kurwa"); - } - - #[test] - fn printf_q_6() { - assert_eq!(pq(b"'bobr kurwa'").unwrap(), b"bobr kurwa"); - } - - #[test] - fn printf_q_7() { - assert_eq!(pq(b"$'bobr kurwa"), Err(Error::DecodeError)); - } - - #[test] - fn printf_q_8() { - assert_eq!(pq(b"$'"), Err(Error::DecodeError)); - } - - #[test] - fn printf_q_9() { - assert_eq!(pq(b"'bobr kurwa"), Err(Error::DecodeError)); - } - - #[test] - fn printf_q_10() { - assert_eq!(pq(b"'"), Err(Error::DecodeError)); - } - - #[test] - fn printf_q_escapes() { - assert_eq!( - pq(b"\\e\\r\\t\\v\\\\\\\"\\'\\$").unwrap(), - b"\x1b\r\t\x0b\\\"'$" - ); - } - - #[test] - fn singles_percent() { - for c in 0..255u32 { - let lower = format!("%{c:02x}"); - let decoded = pd(lower.as_bytes()).unwrap(); - assert_eq!(decoded, [c as u8]); - let upper = format!("%{c:02X}"); - let decoded = pd(upper.as_bytes()).unwrap(); - assert_eq!(decoded, [c as u8]); - } - } - - #[test] - fn percent_1() { - assert_eq!(pd(b"bobr%20kurwa").unwrap(), b"bobr kurwa"); - } - - #[test] - fn percent_2() { - assert_eq!(pd(b"bobr%2kurwa"), Err(Error::DecodeError)); - } - - #[test] - fn percent_3() { - assert_eq!(pd(b"bobr%kurwa"), Err(Error::DecodeError)); - } - - #[test] - fn percent_4() { - assert_eq!(pd(b"bobr%%kurwa"), Err(Error::DecodeError)); - } - - #[test] - fn percent_5() { - assert_eq!(pd(b"bobr%20kurwa%20").unwrap(), b"bobr kurwa "); - } - - #[test] - fn percent_6() { - assert_eq!(pd(b"bobr%20kurwa%2"), Err(Error::DecodeError)); - } - - #[test] - fn percent_7() { - assert_eq!(pd(b"bobr%20kurwa%"), Err(Error::DecodeError)); - } - - #[test] - fn percent_encode() { - let mut w = Vec::new(); - url_percent_encode(&mut w, b"a b;c=d\n\xc3\xa9~").unwrap(); - assert_eq!(String::from_utf8(w).unwrap(), "a%20b%3Bc%3Dd%0A%C3%A9~"); - } -} +pub use ghostty_vt::string_encoding::{ + Error, printf_q_decode, url_percent_decode, url_percent_encode, +}; diff --git a/crates/ghostty-termio/examples/terminfo.rs b/crates/ghostty-termio/examples/terminfo.rs new file mode 100644 index 0000000..a7c630d --- /dev/null +++ b/crates/ghostty-termio/examples/terminfo.rs @@ -0,0 +1,6 @@ +//! Prints the xterm-ghostty terminfo source (Zig: `ghostty +terminfo` in +//! `main_build_data.zig`). Used by `scripts/gen-terminfo.sh`. + +fn main() { + print!("{}", ghostty_termio::terminfo::ghostty.encode_to_string()); +} diff --git a/crates/ghostty-vt/src/lib.rs b/crates/ghostty-vt/src/lib.rs index 75081b1..b5f266c 100644 --- a/crates/ghostty-vt/src/lib.rs +++ b/crates/ghostty-vt/src/lib.rs @@ -17,6 +17,7 @@ pub mod parser; pub mod sgr; pub mod simd; pub mod stream; +pub mod string_encoding; pub mod tmux; pub mod utf8_decoder; pub mod x11_color; diff --git a/crates/ghostty-vt/src/osc/parsers/semantic_prompt.rs b/crates/ghostty-vt/src/osc/parsers/semantic_prompt.rs index 0cd44c7..1d4951d 100644 --- a/crates/ghostty-vt/src/osc/parsers/semantic_prompt.rs +++ b/crates/ghostty-vt/src/osc/parsers/semantic_prompt.rs @@ -4,19 +4,11 @@ //! use crate::osc::{Command as OscCommand, Parser, State}; +use crate::string_encoding::{printf_q_decode, url_percent_decode}; /// Error returned when decoding a command line fails (Zig /// `error.DecodeError`). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct DecodeError; - -impl std::fmt::Display for DecodeError { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str("decode error") - } -} - -impl std::error::Error for DecodeError {} +pub type DecodeError = crate::string_encoding::Error; /// A single semantic prompt command. /// @@ -121,11 +113,6 @@ impl Command { /// Write the decoded command line (if any) to the writer. If an error /// occurs garbage may have been written to the writer. - /// - /// TODO(integration): uses local copies of `printfQDecode` / - /// `urlPercentDecode` (ZIG/src/os/string_encoding.zig); switch to - /// `ghostty-os` once available (that crate would need to be a dependency - /// of ghostty-vt, so these remain local for now). pub fn write_command_line(&self, writer: &mut Vec) -> Result<(), DecodeError> { if let Some(OptionValue::Bytes(command_line)) = self.read_option(OptionKey::Cmdline) { return printf_q_decode(writer, command_line); @@ -416,91 +403,6 @@ pub fn parse(parser: &mut Parser, _terminator_ch: Option) -> Option, buf: &[u8]) -> Result<(), DecodeError> { - let data: &[u8] = if buf.starts_with(b"$'") { - // Strip off `$''` quoting. - if buf.len() < 3 || !buf.ends_with(b"'") { - return Err(DecodeError); - } - &buf[2..buf.len() - 1] - } else if buf.starts_with(b"'") { - // Strip off `''` quoting. - if buf.len() < 2 || !buf.ends_with(b"'") { - return Err(DecodeError); - } - &buf[1..buf.len() - 1] - } else { - buf - }; - - let mut src = 0; - while src < data.len() { - match data[src] { - b'\\' => { - if src + 1 >= data.len() { - return Err(DecodeError); - } - match data[src + 1] { - c @ (b' ' | b'\\' | b'"' | b'\'' | b'$') => writer.push(c), - b'e' => writer.push(0x1b), - b'n' => writer.push(b'\n'), - b'r' => writer.push(b'\r'), - b't' => writer.push(b'\t'), - b'v' => writer.push(0x0b), - _ => return Err(DecodeError), - } - src += 2; - } - c => { - writer.push(c); - src += 1; - } - } - } - Ok(()) -} - -/// Decode data that has been URL percent encoded and write it to the given -/// buffer. If an error is returned garbage may have been written. -pub fn url_percent_decode(writer: &mut Vec, buf: &[u8]) -> Result<(), DecodeError> { - fn hex(c: u8) -> Option { - match c { - b'0'..=b'9' => Some(c - b'0'), - b'a'..=b'f' => Some(c - b'a' + 10), - b'A'..=b'F' => Some(c - b'A' + 10), - _ => None, - } - } - - let mut src = 0; - while src < buf.len() { - match buf[src] { - b'%' => { - if src + 2 >= buf.len() { - return Err(DecodeError); - } - match (hex(buf[src + 1]), hex(buf[src + 2])) { - (Some(a), Some(b)) => { - writer.push((a << 4) | b); - src += 3; - } - _ => return Err(DecodeError), - } - } - c => { - writer.push(c); - src += 1; - } - } - } - Ok(()) -} - #[cfg(test)] #[allow(clippy::unwrap_used, clippy::panic)] mod tests { @@ -531,7 +433,7 @@ mod tests { } fn cmdline_err(input: &str) { - assert_eq!(cmdline(input).1, Err(DecodeError)); + assert_eq!(cmdline(input).1, Err(DecodeError::DecodeError)); } #[test] @@ -980,19 +882,4 @@ mod tests { assert!(r("abc").is_none()); assert_eq!(r("127;aid=foo"), Some(OptionValue::Int(127))); } - - #[test] - fn string_encoding_extra_printf_q_and_percent() { - let mut w = Vec::new(); - printf_q_decode(&mut w, b"bobr\\ kurwa").unwrap(); - assert_eq!(w, b"bobr kurwa"); - assert_eq!( - printf_q_decode(&mut Vec::new(), b"bobr\\dkurwa"), - Err(DecodeError) - ); - assert_eq!( - printf_q_decode(&mut Vec::new(), b"bobr kurwa\\"), - Err(DecodeError) - ); - } } diff --git a/crates/ghostty-vt/src/string_encoding.rs b/crates/ghostty-vt/src/string_encoding.rs new file mode 100644 index 0000000..af6acb2 --- /dev/null +++ b/crates/ghostty-vt/src/string_encoding.rs @@ -0,0 +1,250 @@ +//! String decoding/encoding helpers (ZIG/src/os/string_encoding.zig). +//! +//! Canonical implementation; `ghostty-os::string_encoding` re-exports it. + +use std::io::{self, Write}; + +#[derive(Debug, thiserror::Error)] +pub enum Error { + #[error("decode error")] + DecodeError, + #[error(transparent)] + Io(#[from] io::Error), +} + +impl PartialEq for Error { + fn eq(&self, other: &Self) -> bool { + matches!((self, other), (Error::DecodeError, Error::DecodeError)) + } +} + +/// Decode date from the buffer that has been encoded in the same way that +/// `bash`'s `printf %q` encodes a string and write it to the writer. If an +/// error is returned garbage may have been written to the buffer. +pub fn printf_q_decode(writer: &mut W, buf: &[u8]) -> Result<(), Error> { + let data: &[u8] = if buf.starts_with(b"$'") { + // Strip off `$''` quoting. + if buf.len() < 3 || !buf.ends_with(b"'") { + return Err(Error::DecodeError); + } + &buf[2..buf.len() - 1] + } else if buf.starts_with(b"'") { + // Strip off `''` quoting. + if buf.len() < 2 || !buf.ends_with(b"'") { + return Err(Error::DecodeError); + } + &buf[1..buf.len() - 1] + } else { + buf + }; + + let mut src = 0; + while src < data.len() { + match data[src] { + b'\\' => { + if src + 1 >= data.len() { + return Err(Error::DecodeError); + } + let out = match data[src + 1] { + c @ (b' ' | b'\\' | b'"' | b'\'' | b'$') => c, + b'e' => 0x1b, + b'n' => b'\n', + b'r' => b'\r', + b't' => b'\t', + b'v' => 0x0b, + _ => return Err(Error::DecodeError), + }; + writer.write_all(&[out])?; + src += 2; + } + c => { + writer.write_all(&[c])?; + src += 1; + } + } + } + Ok(()) +} + +/// Decode data from the buffer that has been URL percent encoded and write +/// it to the given buffer. If an error is returned the garbage may have been +/// written to the writer. +pub fn url_percent_decode(writer: &mut W, buf: &[u8]) -> Result<(), Error> { + let mut src = 0; + while src < buf.len() { + match buf[src] { + b'%' => { + if src + 2 >= buf.len() { + return Err(Error::DecodeError); + } + let (Some(hi), Some(lo)) = (hex(buf[src + 1]), hex(buf[src + 2])) else { + return Err(Error::DecodeError); + }; + writer.write_all(&[(hi << 4) | lo])?; + src += 3; + } + c => { + writer.write_all(&[c])?; + src += 1; + } + } + } + Ok(()) +} + +fn hex(c: u8) -> Option { + match c { + b'0'..=b'9' => Some(c - b'0'), + b'a'..=b'f' => Some(c - b'a' + 10), + b'A'..=b'F' => Some(c - b'A' + 10), + _ => None, + } +} + +/// Is the given character valid in URI percent encoding? +fn is_valid_char(c: u8) -> bool { + match c { + b' ' | b';' | b'=' => false, + _ => (0x20..=0x7e).contains(&c), + } +} + +/// Write data to the writer after URI percent encoding. +pub fn url_percent_encode(writer: &mut W, data: &[u8]) -> io::Result<()> { + for &c in data { + if is_valid_char(c) { + writer.write_all(&[c])?; + } else { + write!(writer, "%{c:02X}")?; + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + #![allow(clippy::unwrap_used)] + use super::*; + + fn pq(s: &[u8]) -> Result, Error> { + let mut w = Vec::new(); + printf_q_decode(&mut w, s).map(|()| w) + } + + fn pd(s: &[u8]) -> Result, Error> { + let mut w = Vec::new(); + url_percent_decode(&mut w, s).map(|()| w) + } + + #[test] + fn printf_q_1() { + assert_eq!(pq(b"bobr\\ kurwa").unwrap(), b"bobr kurwa"); + } + + #[test] + fn printf_q_2() { + assert_eq!(pq(b"bobr\\nkurwa").unwrap(), b"bobr\nkurwa"); + } + + #[test] + fn printf_q_3() { + assert_eq!(pq(b"bobr\\dkurwa"), Err(Error::DecodeError)); + } + + #[test] + fn printf_q_4() { + assert_eq!(pq(b"bobr kurwa\\"), Err(Error::DecodeError)); + } + + #[test] + fn printf_q_5() { + assert_eq!(pq(b"$'bobr kurwa'").unwrap(), b"bobr kurwa"); + } + + #[test] + fn printf_q_6() { + assert_eq!(pq(b"'bobr kurwa'").unwrap(), b"bobr kurwa"); + } + + #[test] + fn printf_q_7() { + assert_eq!(pq(b"$'bobr kurwa"), Err(Error::DecodeError)); + } + + #[test] + fn printf_q_8() { + assert_eq!(pq(b"$'"), Err(Error::DecodeError)); + } + + #[test] + fn printf_q_9() { + assert_eq!(pq(b"'bobr kurwa"), Err(Error::DecodeError)); + } + + #[test] + fn printf_q_10() { + assert_eq!(pq(b"'"), Err(Error::DecodeError)); + } + + #[test] + fn printf_q_escapes() { + assert_eq!( + pq(b"\\e\\r\\t\\v\\\\\\\"\\'\\$").unwrap(), + b"\x1b\r\t\x0b\\\"'$" + ); + } + + #[test] + fn singles_percent() { + for c in 0..255u32 { + let lower = format!("%{c:02x}"); + let decoded = pd(lower.as_bytes()).unwrap(); + assert_eq!(decoded, [c as u8]); + let upper = format!("%{c:02X}"); + let decoded = pd(upper.as_bytes()).unwrap(); + assert_eq!(decoded, [c as u8]); + } + } + + #[test] + fn percent_1() { + assert_eq!(pd(b"bobr%20kurwa").unwrap(), b"bobr kurwa"); + } + + #[test] + fn percent_2() { + assert_eq!(pd(b"bobr%2kurwa"), Err(Error::DecodeError)); + } + + #[test] + fn percent_3() { + assert_eq!(pd(b"bobr%kurwa"), Err(Error::DecodeError)); + } + + #[test] + fn percent_4() { + assert_eq!(pd(b"bobr%%kurwa"), Err(Error::DecodeError)); + } + + #[test] + fn percent_5() { + assert_eq!(pd(b"bobr%20kurwa%20").unwrap(), b"bobr kurwa "); + } + + #[test] + fn percent_6() { + assert_eq!(pd(b"bobr%20kurwa%2"), Err(Error::DecodeError)); + } + + #[test] + fn percent_7() { + assert_eq!(pd(b"bobr%20kurwa%"), Err(Error::DecodeError)); + } + + #[test] + fn percent_encode() { + let mut w = Vec::new(); + url_percent_encode(&mut w, b"a b;c=d\n\xc3\xa9~").unwrap(); + assert_eq!(String::from_utf8(w).unwrap(), "a%20b%3Bc%3Dd%0A%C3%A9~"); + } +} diff --git a/crates/ghostty-vt/src/x11_color.rs b/crates/ghostty-vt/src/x11_color.rs index 4c265e7..99d8d44 100644 --- a/crates/ghostty-vt/src/x11_color.rs +++ b/crates/ghostty-vt/src/x11_color.rs @@ -10,29 +10,45 @@ use std::sync::OnceLock; /// licensed under the same license as Ghostty. const DATA: &str = include_str!("res/rgb.txt"); +/// Parse rgb.txt into `(name with original case, value)` entries in file +/// order. This is not very robust parsing, because we expect a very exact +/// format for rgb.txt. +fn parse_entries() -> impl Iterator { + DATA.split('\n').filter(|l| !l.is_empty()).map(|line| { + let r: u8 = line[0..3].trim_matches(' ').parse().expect("bad rgb.txt"); + let g: u8 = line[4..7].trim_matches(' ').parse().expect("bad rgb.txt"); + let b: u8 = line[8..11].trim_matches(' ').parse().expect("bad rgb.txt"); + let name = line[12..].trim_matches(|c| c == ' ' || c == '\t' || c == '\n'); + (name, Rgb { r, g, b }) + }) +} + /// The map of all available X11 colors. Keys are lowercase ASCII; lookups /// are ASCII case-insensitive (see [`get`]). fn map() -> &'static HashMap { static MAP: OnceLock> = OnceLock::new(); MAP.get_or_init(|| { let mut m = HashMap::new(); - // Parse the line. This is not very robust parsing, because we expect - // a very exact format for rgb.txt. - for line in DATA.split('\n') { - if line.is_empty() { - continue; - } - let r: u8 = line[0..3].trim_matches(' ').parse().expect("bad rgb.txt"); - let g: u8 = line[4..7].trim_matches(' ').parse().expect("bad rgb.txt"); - let b: u8 = line[8..11].trim_matches(' ').parse().expect("bad rgb.txt"); - let name = line[12..].trim_matches(|c| c == ' ' || c == '\t' || c == '\n'); - m.entry(name.to_ascii_lowercase()) - .or_insert(Rgb { r, g, b }); + for (name, rgb) in parse_entries() { + m.entry(name.to_ascii_lowercase()).or_insert(rgb); } m }) } +/// All X11 color names with their original spelling (e.g. `AliceBlue`, +/// `alice blue`) and values, in rgb.txt order. Like the case-insensitive +/// map, the first spelling of a name wins. +pub fn names() -> &'static [(&'static str, Rgb)] { + static NAMES: OnceLock> = OnceLock::new(); + NAMES.get_or_init(|| { + let mut seen = std::collections::HashSet::new(); + parse_entries() + .filter(|(n, _)| seen.insert(n.to_ascii_lowercase())) + .collect() + }) +} + /// Look up an X11 color by name (ASCII case-insensitive). pub fn get(name: &str) -> Option { map().get(&name.to_ascii_lowercase()).copied() @@ -48,6 +64,14 @@ mod tests { #![allow(clippy::unwrap_used)] use super::*; + #[test] + fn names_keep_original_case() { + let n = names(); + assert!(n.iter().any(|(name, _)| *name == "AliceBlue")); + assert!(n.iter().any(|(name, _)| *name == "alice blue")); + assert!(n.iter().all(|(name, rgb)| get(name) == Some(*rgb))); + } + #[test] fn x11_color_map() { assert_eq!(None, get("nosuchcolor")); diff --git a/docs/rewrite/INTEGRATION_TODO.md b/docs/rewrite/INTEGRATION_TODO.md index d1f8086..b41f1f3 100644 --- a/docs/rewrite/INTEGRATION_TODO.md +++ b/docs/rewrite/INTEGRATION_TODO.md @@ -2,26 +2,26 @@ Collected from agent reports; resolve during integration phases. -- [ ] vt `osc/parsers/semantic_prompt.rs`: local printf-q / percent decoders → share with `ghostty-os::string_encoding` (vt can't depend on os; maybe move into vt and have os re-export). +- [x] vt `osc/parsers/semantic_prompt.rs`: local printf-q / percent decoders → share with `ghostty-os::string_encoding` (vt can't depend on os; maybe move into vt and have os re-export). (resolved by cleanup-V2: single impl in `ghostty_vt::string_encoding`, os re-exports) - [x] os `locale::ensure_locale` no-op → call setlocale from apprt-gtk (unsafe allowed) at startup, as Ghostty's global.zig does. (resolved: apprt-gtk Application calls setlocale at startup; the core/root binary do not need unsafe) -- [ ] os `passwd` Flatpak branch needs termio Pty. +- [x] os `passwd` Flatpak branch needs termio Pty. (resolved by cleanup-V2: implemented in os with a local rustix pty (termio depends on os, so it cannot use termio Pty)) - [x] os `open::OpenUrlKind` → replace with core apprt action type. - [x] terminal crate: re-add deps on unicode/datastruct once those land. - [x] input: key_encode::Options / paste::Options from terminal modes (Zig `fromTerminal`) — in terminal or core. (resolved: `ghostty_core::surface::keyboard::encode_options_from_terminal`, `clipboard::paste_options_from_terminal`) -- [ ] input: `binding::set::EntryFormatter` must be implemented by config formatter. -- [ ] config: clipboard_codepoint_map::Entry and CursorStyle are local copies → unify with terminal/formatter (font types are unified: canonical in ghostty-config, re-exported by ghostty-font). +- [x] input: `binding::set::EntryFormatter` must be implemented by config formatter. (resolved by cleanup-V2: config `keybinds.rs` adapter implements it; +list-keybinds/+show-config verified vs /usr/bin/ghostty) +- [x] config: clipboard_codepoint_map::Entry and CursorStyle are local copies → unify with terminal/formatter (font types are unified: canonical in ghostty-config, re-exported by ghostty-font). (resolved by cleanup-V2: Entry = `ghostty_terminal::formatter::CodepointMap`, CursorStyle = `ghostty_terminal::cursor::Style`; config now depends on terminal) - [ ] config: url::REGEX needs onig (lookbehind) in renderer/core link matching. -- [ ] config: theme::open needs resources dir from ghostty_os. +- [x] config: theme::open needs resources dir from ghostty_os. (resolved by cleanup-V2: already used `ghostty_os::resources_dir()`; plus dev fallback to `/resources`) - NOTE: Ghostty 1.3.1 is installed at /usr/bin/ghostty — use it as reference oracle (e.g. +show-config, +list-actions, terminfo). -- [ ] termio: Pty closes slave on drop (Zig didn't) — verify Exec semantics; cgroup/rlimit/D-Bus scope wiring; local option stand-ins → config types. +- [x] termio: Pty closes slave on drop (Zig didn't) — verify Exec semantics; cgroup/rlimit/D-Bus scope wiring; local option stand-ins → config types. (resolved by cleanup-V2: verified identical to Zig Exec; cgroup wait loop now single `ghostty_os::cgroup::wait_for_scope`; rlimit/D-Bus wiring was done earlier) - [x] cli: wire list-fonts/show-face (font discovery) and new-window (`set_ipc_handler`, root must register it). - [x] cli: call build_info::set from root (done in src/main.rs; build versions are the cargo feature levels 4.14/1.5). -- [ ] input: ActionKey::docs() has extra trailing blank line for 4 actions (cli works around locally). -- [ ] vt: x11_color lacks name listing with original case (cli re-parses rgb.txt). +- [x] input: ActionKey::docs() has extra trailing blank line for 4 actions (cli works around locally). (resolved by cleanup-V2: data fixed, cli uses helpgen_actions) +- [x] vt: x11_color lacks name listing with original case (cli re-parses rgb.txt). (resolved by cleanup-V2: `x11_color::names()`) - [x] apprt: Scrollbar/RendererHealth stand-ins → terminal/renderer types; core implements AppMailbox. (resolved: apprt re-exports the real types; core implements AppMailbox) - [x] terminal (after T4/T5/K1): Codex round 3 on notes/ghostty-terminal-pagelist-requests.md items 1,2,3,4,5,7 + append_grapheme dirty + clone_partial_row_between; then remove Screen/Terminal workarounds. Items 6 and the two T3 test items are resolved by the parity pass. -- [ ] tmux viewer wiring into termio stream handler (see notes/ghostty-terminal-search-tmux.md); renderer sets search_viewport_dirty. -- [ ] apprt-gtk: stubs toggle_visibility/float_window/secure_input/close_all_windows; inspector no-op; cgroup wait loop duplicated with termio; GLArea framebuffer id must be queried by renderer; layer-shell lib load order. +- [x] tmux viewer wiring into termio stream handler (see notes/ghostty-terminal-search-tmux.md); renderer sets search_viewport_dirty. (resolved by cleanup-V2: wired in termio stream_handler (Enter/Exit/viewer.next, Command -> pty); search_viewport_dirty already set by renderer) +- [x] apprt-gtk: stubs toggle_visibility/float_window/secure_input/close_all_windows; inspector no-op; cgroup wait loop duplicated with termio; GLArea framebuffer id must be queried by renderer; layer-shell lib load order. (resolved by cleanup-V2: toggle_visibility/float_window/close_all_windows/secure_input are unimplemented in Zig GTK too (verified) so the stubs are faithful; cgroup loop deduped; inspector no-op, GLArea fb id, layer-shell remain as noted in cleanup-V2.md) - [x] RendererState defined in termio::state; renderer R2 ports State.zig → unify (canonical in renderer, termio depends on renderer). (resolved: termio uses `ghostty_renderer::state::{State, SharedState}`) - [x] termio: hook kitty graphics exec/replies (terminal now has it) and image_storage_limit; tmux viewer. (resolved: APC exec already in stream handler; image-storage-limit applied at init/config change, clear-screen deletes images; tmux viewer still open) - [x] termio: Flatpak stop path needs ghostty-os FlatpakHostCommand change; apprt-gtk create_scope; core restore_rlimit. (partially: core supplies restore_rlimit; apprt-gtk supplies create_scope via RtSurface::create_scope_fn; Flatpak stop unchanged) diff --git a/docs/rewrite/notes/cleanup-V2.md b/docs/rewrite/notes/cleanup-V2.md new file mode 100644 index 0000000..450d526 --- /dev/null +++ b/docs/rewrite/notes/cleanup-V2.md @@ -0,0 +1,55 @@ +# Cleanup V2 + +## Resources (self-contained) +- `resources/` mirrors Ghostty's `share/ghostty` plus its sibling `share/terminfo`: + `shell-integration/`, `themes/` (463 files copied from the installed Ghostty 1.3.1, + `themes/LICENSE-iTerm2-Color-Schemes` = MIT attribution, written from the upstream license; + no upstream copy existed on this machine, check the copyright line if it matters) and + `terminfo/` (compiled `xterm-ghostty`, `g/ghostty`). +- `scripts/gen-terminfo.sh` (`just terminfo`): `cargo run -p ghostty-termio --example terminfo` + prints the source from `ghostty_termio::terminfo::ghostty`, `tic -x -o resources/terminfo`, then + compares with the installed entry via `infocmp -x` (identical). +- `ghostty_os::resourcesdir`: Ghostty's logic unchanged (exe-relative `share/terminfo/...` sentinel, + `GHOSTTY_RESOURCES_DIR` first in release / fallback in debug). New last-resort (debug only): if the + exe lives inside the workspace this crate was compiled in (`env!("CARGO_MANIFEST_DIR")/../..`) and + `/resources` has the same sentinel (`terminfo/x/xterm-ghostty`), use it. `cargo run` + now works without the env var. +- Child `TERMINFO`: Zig sets `/../terminfo`. Rust prefers `/terminfo` when + it holds the sentinel (dev layout), else the Zig sibling path. +- `config::theme::open` already took `ghostty_os::resources_dir()`; the dev fallback makes it work in dev. +- `just install [PREFIX]` (default /usr/local, honours DESTDIR): bin, share/ghostty/{shell-integration,themes}, + share/terminfo, desktop file, D-Bus + systemd units, metainfo, kio/nautilus extensions, icons + (templates vendored in `dist/linux`, `dist/icons`, copied from ZIG). Not covered: shell completions, + vim/bat syntax (no `+fish`/`+vim-syntax` generators in the Rust cli). Install tested with DESTDIR on a fake binary. + +## Integration items +- vt `string_encoding` is canonical; `ghostty-os` now depends on `ghostty-vt` and re-exports it + (semantic_prompt's `DecodeError` = `string_encoding::Error`). +- `binding::set::EntryFormatter` was already implemented by an adapter in `ghostty-config/src/keybinds.rs`. + `+list-keybinds` (plain/--default/--docs) and `+list-actions [--docs]` are byte-identical to /usr/bin/ghostty. + `+show-config --default [--docs]` differs only in `gtk-opengl-debug` (debug build, same as Zig). +- config now depends on `ghostty-terminal` (terminal dev-depends on config; cargo allows it). `CursorStyle` is + `ghostty_terminal::cursor::Style` (ConfigField impl in config/types_a.rs); termio's `cursor_style_from_config` + was removed. `clipboard_codepoint_map::{Entry, Replacement}` re-export terminal formatter types; core no longer converts. +- `ActionKey::docs()`: four docs had an extra trailing blank line (data fixed); cli `generate_plaintext` now calls + `helpgen_actions::generate_string`. +- `x11_color::names()` (original case, deduped, file order); cli depends on ghostty-vt for it. + Note: /usr/bin/ghostty `+list-colors` output is truncated at 12297 bytes when redirected (oracle bug); the prefix is identical. +- Pty/slave: Zig closes the slave in exactly two places (parent after successful spawn, error path) and `Pty.deinit` + only runs after; Rust `Exec` calls `close_slave()` after success and drops the Pty on error, so lifetimes match. Documented in pty.rs. +- Cgroup wait loop: single `ghostty_os::cgroup::{scope_matches, wait_for_scope}` used by termio `Command` hooks and + apprt-gtk `pre_exec`/`post_fork`. +- apprt-gtk stubs: in Zig 1.3.1 `application.zig` lists secure_input, close_all_windows, float_window, toggle_visibility + under "Unimplemented" (log warn, return false) and the command palette hides them; the Rust stubs are therefore faithful and + left as is. `toggle_visibility` on Window exists only for the quick terminal (as Zig). secure_input is macOS-only. +- tmux: `HandlerState.tmux_viewer: Option>`; DCS Enter creates, Exit drops, other notifications go to + `viewer.next`, `Command` -> `write_req`, `Exit` ignored, `Windows` TODO (same TODO in Zig). Test added. Renderer already sets `search_viewport_dirty`. +- passwd Flatpak: implemented in `ghostty-os` (termio depends on os, so no termio Pty) with rustix openpt + `FlatpakHostCommand` + running `/bin/sh -l -c "getent passwd "`; parse of shell/home is unit tested, the host call itself is untestable here. +- `+version`: `crates/ghostty-cli/build.rs` captures `rustc --version` (number only) into `GHOSTTY_RUSTC_VERSION`. + +## Gate +fmt, clippy `-D warnings` clean for the workspace except `ghostty-bench` (another agent's new crate, 3 lints) and +`ghostty-core` integration tests under construction by V1 (lib/bin clean; fixed one `is_multiple_of` lint in core clipboard.rs). +Tests: all crates except core integration tests and bench pass (`ghostty-core --lib` 20). One run saw +`ghostty-termio/tests/exec_integration large_write_with_backpressure_is_delivered_in_order` fail (10s timeout) and pass on rerun: flaky/V1 area. diff --git a/justfile b/justfile index 8d41d09..8111ddd 100644 --- a/justfile +++ b/justfile @@ -33,3 +33,39 @@ test: clean: cargo clean + +# Install bin + share/ghostty (shell-integration, themes) + share/terminfo + +# desktop file, D-Bus/systemd/metainfo and icons, mirroring Ghostty's +# GhosttyResources.zig. Usage: `just install /usr/local` (default PREFIX +# /usr/local; honours DESTDIR). Builds the release profile. +install PREFIX="/usr/local": build-release + #!/usr/bin/env bash + set -euo pipefail + P="${DESTDIR:-}{{ PREFIX }}" + install -Dm755 target/release/ghostty "$P/bin/ghostty" + mkdir -p "$P/share/ghostty" "$P/share/terminfo" + cp -R resources/shell-integration resources/themes "$P/share/ghostty/" + find "$P/share/ghostty/shell-integration" -name '*.md' -delete + find "$P/share/ghostty/themes" -name '*.md' -delete + cp -R resources/terminfo/. "$P/share/terminfo/" + gen() { # template out + mkdir -p "$(dirname "$2")" + sed -e 's|@NAME@|Ghostty|g' -e 's|@APPID@|com.mitchellh.ghostty|g' \ + -e "s|@GHOSTTY@|{{ PREFIX }}/bin/ghostty|g" "$1" > "$2" + } + gen dist/linux/app.desktop.in "$P/share/applications/com.mitchellh.ghostty.desktop" + gen dist/linux/dbus.service.in "$P/share/dbus-1/services/com.mitchellh.ghostty.service" + gen dist/linux/systemd.service.in "$P/share/systemd/user/app-com.mitchellh.ghostty.service" + gen dist/linux/com.mitchellh.ghostty.metainfo.xml.in "$P/share/metainfo/com.mitchellh.ghostty.metainfo.xml" + install -Dm644 dist/linux/ghostty_dolphin.desktop "$P/share/kio/servicemenus/com.mitchellh.ghostty.desktop" + install -Dm755 dist/linux/ghostty_nautilus.py "$P/share/nautilus-python/extensions/ghostty.py" + ic="$P/share/icons/hicolor" + for pair in 16:16x16 32:32x32 128:128x128 256:256x256 512:512x512 1024:1024x1024 \ + 32:16x16@2 64:32x32@2 256:128x128@2 512:256x256@2; do + install -Dm644 "dist/icons/${pair%%:*}.png" "$ic/${pair##*:}/apps/com.mitchellh.ghostty.png" + done + echo "installed to $P" + +# Regenerate resources/terminfo from the terminfo source in ghostty-termio. +terminfo: + scripts/gen-terminfo.sh diff --git a/scripts/gen-terminfo.sh b/scripts/gen-terminfo.sh new file mode 100755 index 0000000..9f4e697 --- /dev/null +++ b/scripts/gen-terminfo.sh @@ -0,0 +1,22 @@ +#!/bin/bash +# Regenerates resources/terminfo (compiled xterm-ghostty) from the terminfo +# source embedded in ghostty-termio, like Ghostty's GhosttyResources.zig +# (`tic -x -o ghostty.terminfo`), then compares against the installed +# entry (if any) with `infocmp -x`. +set -eu +root=$(cd "$(dirname "$0")/.." && pwd) +src=$(mktemp) +trap 'rm -f "$src"' EXIT +cargo run -q -p ghostty-termio --example terminfo > "$src" +rm -rf "$root/resources/terminfo" +mkdir -p "$root/resources/terminfo" +tic -x -o "$root/resources/terminfo" "$src" +echo "wrote $root/resources/terminfo" +if infocmp -x xterm-ghostty >/dev/null 2>&1; then + if diff <(infocmp -x xterm-ghostty | grep -v '^#') \ + <(TERMINFO="$root/resources/terminfo" infocmp -x xterm-ghostty | grep -v '^#') >/dev/null 2>&1; then + echo "infocmp: identical to installed xterm-ghostty" + else + echo "infocmp: DIFFERS from installed xterm-ghostty" >&2 + fi +fi