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 <workspace>/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
This commit is contained in:
ddidderr committed 2026-10-12 02:08:22 +02:00
1 parent 0c4f0ee412
commit 967e3a176d
24 files changed
+656 -513

No files matched your search

+1
View File
@@ -11,6 +11,7 @@ ghostty-config.workspace = true
ghostty-font.workspace = true ghostty-font.workspace = true
ghostty-input.workspace = true ghostty-input.workspace = true
ghostty-os.workspace = true ghostty-os.workspace = true
ghostty-vt.workspace = true
log = "0.4.34" log = "0.4.34"
ratatui = "0.30.2" ratatui = "0.30.2"
thiserror = "2.0.21" thiserror = "2.0.21"
+18
View File
@@ -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}");
}
+4 -48
View File
@@ -3,7 +3,6 @@
use std::io::Write; use std::io::Write;
use ghostty_config::config_struct; use ghostty_config::config_struct;
use ghostty_input::binding::ActionKey;
use crate::error::Error; use crate::error::Error;
@@ -35,44 +34,12 @@ pub fn run(args: &[String]) -> Result<u8, Error> {
} }
/// `helpgen_actions.generate(.plaintext, show_docs)`. /// `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] #[must_use]
pub fn generate_plaintext(show_docs: bool) -> String { pub fn generate_plaintext(show_docs: bool) -> String {
let mut writer = String::new(); ghostty_input::helpgen_actions::generate_string(
let mut stream = String::new(); ghostty_input::helpgen_actions::Format::Plaintext,
for &key in ActionKey::ALL { show_docs,
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
} }
#[cfg(test)] #[cfg(test)]
@@ -94,15 +61,4 @@ mod tests {
assert!(s.contains(" respectively.\n\njump_to_prompt:\n")); assert!(s.contains(" respectively.\n\njump_to_prompt:\n"));
assert!(s.ends_with('\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)));
}
} }
+4 -25
View File
@@ -16,35 +16,14 @@ config_struct! {
} }
cli_options!(Options, help); 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 /// All X11 color names (with their original spelling) and values. Like the
/// case-insensitive Zig map the first spelling of a name wins. /// case-insensitive Zig map the first spelling of a name wins.
#[must_use] #[must_use]
pub fn x11_colors() -> Vec<(String, (u8, u8, u8))> { pub fn x11_colors() -> Vec<(String, (u8, u8, u8))> {
let mut seen = std::collections::HashSet::new(); ghostty_vt::x11_color::names()
let mut out = vec![]; .iter()
for line in RGB_DATA.split('\n') { .map(|(n, c)| ((*n).to_string(), (c.r, c.g, c.b)))
if line.len() < 12 { .collect()
continue;
}
let parse = |s: &str| s.trim_matches(' ').parse::<u8>().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
} }
/// `std.ascii.orderIgnoreCase`. /// `std.ascii.orderIgnoreCase`.
+1 -5
View File
@@ -41,11 +41,7 @@ pub fn write_version(out: &mut dyn Write, info: &BuildInfo, tty: bool) -> std::i
writeln!(out, "Build Config")?; writeln!(out, "Build Config")?;
// The Zig build prints its compiler version here. // The Zig build prints its compiler version here.
writeln!( writeln!(out, " - Rust version : {}", env!("GHOSTTY_RUSTC_VERSION"))?;
out,
" - Rust version : {}",
option_env!("CARGO_PKG_RUST_VERSION").unwrap_or("unknown")
)?;
writeln!(out, " - build mode : {}", info.build_mode)?; writeln!(out, " - build mode : {}", info.build_mode)?;
writeln!(out, " - app runtime : {}", info.app_runtime)?; writeln!(out, " - app runtime : {}", info.app_runtime)?;
writeln!(out, " - font engine : {}", info.font_backend)?; writeln!(out, " - font engine : {}", info.font_backend)?;
+1
View File
@@ -8,6 +8,7 @@ license.workspace = true
const_format = "0.2.36" const_format = "0.2.36"
ghostty-input.workspace = true ghostty-input.workspace = true
ghostty-os.workspace = true ghostty-os.workspace = true
ghostty-terminal.workspace = true
ghostty-vt.workspace = true ghostty-vt.workspace = true
log = "0.4.34" log = "0.4.34"
thiserror = "2.0.21" thiserror = "2.0.21"
@@ -2,27 +2,10 @@
//! for clipboard operations. When copying text to clipboard, matching //! for clipboard operations. When copying text to clipboard, matching
//! codepoints will be replaced with their mapped values. //! codepoints will be replaced with their mapped values.
/// Replacement value for a codepoint range. /// One entry of the map and its replacement value. The canonical
#[derive(Debug, Clone, PartialEq, Eq)] /// definitions live in `ghostty_terminal::formatter` (`CodepointMap`,
pub enum Replacement { /// `Replacement`), which is what the terminal formatter consumes.
/// A single replacement codepoint. pub use ghostty_terminal::formatter::{CodepointMap as Entry, Replacement};
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,
}
/// The list of entries. /// The list of entries.
/// ///
+28 -16
View File
@@ -21,23 +21,35 @@ fn write_void(f: &mut EntryFormatter<'_>) -> fmt::Result {
} }
// --------------------------------------------------------------------- // ---------------------------------------------------------------------
// Cursor style (ZIG/src/terminal/cursor.zig `Style`), duplicated here // Cursor style: the canonical type is `ghostty_terminal::cursor::Style`
// because config cannot depend on ghostty-terminal. // (ZIG/src/terminal/cursor.zig `Style`); config only adds the field impl.
config_enum! { pub use ghostty_terminal::cursor::Style as CursorStyle;
/// The visual style of the cursor. Whether or not it blinks
/// is determined by mode 12 (modes.zig). This mode is synchronized impl ConfigField for CursorStyle {
/// with CSI q, the same as xterm. const VALID_VALUES: Option<&'static [&'static str]> =
pub enum CursorStyle { Some(&["bar", "block", "underline", "block_hollow"]);
/// DECSCUSR 5, 6
Bar => "bar", fn parse_cli(&mut self, value: Option<&str>) -> Result<(), ArgError> {
/// DECSCUSR 1, 2 let v = value.ok_or(ArgError::ValueRequired)?;
Block => "block", *self = match v {
/// DECSCUSR 3, 4 "bar" => CursorStyle::Bar,
Underline => "underline", "block" => CursorStyle::Block,
/// Hollow block cursor. This is a block cursor with the center empty. "underline" => CursorStyle::Underline,
/// Reported as DECSCUSR 1 or 2 (block). "block_hollow" => CursorStyle::BlockHollow,
BlockHollow => "block_hollow", _ => 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)
} }
} }
@@ -1438,7 +1438,6 @@ Valid arguments are:
Adjust the selection to the beginning or the end of the line Adjust the selection to the beginning or the end of the line
respectively. respectively.
"## "##
} }
ActionKey::JumpToPrompt => { ActionKey::JumpToPrompt => {
@@ -1469,7 +1468,6 @@ Valid actions are:
The default OS editor is determined by using `open` on macOS The default OS editor is determined by using `open` on macOS
and `xdg-open` on Linux. and `xdg-open` on Linux.
"## "##
} }
ActionKey::WriteScreenFile => { ActionKey::WriteScreenFile => {
@@ -1576,7 +1574,6 @@ Valid arguments:
Creates a new split along the larger direction. Creates a new split along the larger direction.
For example, if the parent split is currently wider than it is tall, For example, if the parent split is currently wider than it is tall,
then a left-right split would be created, and vice versa. then a left-right split would be created, and vice versa.
"## "##
} }
ActionKey::GotoSplit => { ActionKey::GotoSplit => {
@@ -1943,7 +1940,6 @@ The value determines the crash location:
- `render` - `render`
Crash on the render thread for the focused surface. Crash on the render thread for the focused surface.
"## "##
} }
} }
+3 -2
View File
@@ -9,8 +9,9 @@ use indexmap::IndexMap;
use super::{Action, Elem, Error, Flags, FoldedTrigger, Parser, Trigger, TriggerKey}; use super::{Action, Elem, Error, Flags, FoldedTrigger, Parser, Trigger, TriggerKey};
use crate::key::KeyEvent; use crate::key::KeyEvent;
/// TODO(integration): stand-in for `config/formatter.zig` `EntryFormatter`. /// Sink for `config/formatter.zig` `EntryFormatter`. The config crate
/// The config crate implements this to write `keybind = <value>` lines. /// implements this (`ghostty_config::keybinds`) to write `keybind = <value>`
/// lines.
pub trait EntryFormatter { pub trait EntryFormatter {
/// Format a single `key = value` entry where `value` is a string. /// Format a single `key = value` entry where `value` is a string.
fn format_entry(&mut self, value: &str); fn format_entry(&mut self, value: &str);
+2 -1
View File
@@ -5,10 +5,11 @@ edition.workspace = true
license.workspace = true license.workspace = true
[dependencies] [dependencies]
ghostty-vt.workspace = true
gettext-rs = { version = "0.8.0", features = ["gettext-system"] } gettext-rs = { version = "0.8.0", features = ["gettext-system"] }
log = "0.4.34" log = "0.4.34"
nix = { version = "0.31.3", features = ["user"] } 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" thiserror = "2.0.21"
zbus = "5.19.0" zbus = "5.19.0"
+24
View File
@@ -24,6 +24,30 @@ pub fn current(pid: u32) -> Option<String> {
Some(String::from_utf8_lossy(&rest[..end]).into_owned()) 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
+94 -9
View File
@@ -25,16 +25,12 @@ pub fn get() -> std::io::Result<Entry> {
} }
}; };
// TODO(integration): Zig shells out to the host via // If we're in flatpak then our entry is always empty so we grab it
// FlatpakHostCommand + a pty (`getent passwd <name>`) when running in // by shelling out to the host. note that we do HAVE an entry in the
// Flatpak, because the sandbox passwd entry only has a correct username. // sandbox but only the username is correct.
// That needs the `ghostty-termio` Pty; until then we use the sandbox
// entry and warn.
if is_flatpak() { if is_flatpak() {
log::warn!( log::info!("flatpak detected, will use host command to get our entry");
"flatpak detected, but host passwd lookup is not implemented; \ return host_entry(&user.name);
using sandbox passwd entry"
);
} }
Ok(Entry { Ok(Entry {
@@ -44,10 +40,99 @@ pub fn get() -> std::io::Result<Entry> {
}) })
} }
/// Flatpak: look up our entry on the host with `getent passwd <name>` 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<Entry> {
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)] #[cfg(test)]
mod tests { mod tests {
use super::*; 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] #[test]
fn get_entry() { fn get_entry() {
// We should be able to get an entry // We should be able to get an entry
+46
View File
@@ -64,6 +64,7 @@ fn resources_dir_impl(debug: bool, exe: Option<&Path>, env: Option<String>) -> R
let Some(mut exe) = exe else { let Some(mut exe) = exe else {
return ResourcesDir::default(); return ResourcesDir::default();
}; };
let exe_start = exe;
// We have an exe path! Climb the tree looking for the terminfo // We have an exe path! Climb the tree looking for the terminfo
// bundle as we expect it. // bundle as we expect it.
@@ -91,9 +92,41 @@ fn resources_dir_impl(debug: bool, exe: Option<&Path>, env: Option<String>) -> R
}; };
} }
// Rust-specific: development builds run out of `<workspace>/target`, where
// there is no installed `share/` tree. Fall back to the in-repo
// `<workspace>/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() 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 `<workspace>/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<String> {
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 /// 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 /// if so return "base/sub". The "suffix" is just used as a way to verify a
/// directory seems roughly right. /// 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] #[test]
fn maybe_dir_checks_suffix() { fn maybe_dir_checks_suffix() {
let td = make_tree(); let td = make_tree();
+7 -244
View File
@@ -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 use ghostty_vt::string_encoding::{
pub enum Error { Error, printf_q_decode, url_percent_decode, url_percent_encode,
#[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<W: Write>(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<W: Write>(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<u8> {
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<W: Write>(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<Vec<u8>, Error> {
let mut w = Vec::new();
printf_q_decode(&mut w, s).map(|()| w)
}
fn pd(s: &[u8]) -> Result<Vec<u8>, 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~");
}
}
@@ -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());
}
+1
View File
@@ -17,6 +17,7 @@ pub mod parser;
pub mod sgr; pub mod sgr;
pub mod simd; pub mod simd;
pub mod stream; pub mod stream;
pub mod string_encoding;
pub mod tmux; pub mod tmux;
pub mod utf8_decoder; pub mod utf8_decoder;
pub mod x11_color; pub mod x11_color;
@@ -4,19 +4,11 @@
//! <https://gitlab.freedesktop.org/Per_Bothner/specifications/blob/master/proposals/semantic-prompts.md> //! <https://gitlab.freedesktop.org/Per_Bothner/specifications/blob/master/proposals/semantic-prompts.md>
use crate::osc::{Command as OscCommand, Parser, State}; 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 returned when decoding a command line fails (Zig
/// `error.DecodeError`). /// `error.DecodeError`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub type DecodeError = crate::string_encoding::Error;
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 {}
/// A single semantic prompt command. /// A single semantic prompt command.
/// ///
@@ -121,11 +113,6 @@ impl Command {
/// Write the decoded command line (if any) to the writer. If an error /// Write the decoded command line (if any) to the writer. If an error
/// occurs garbage may have been written to the writer. /// 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<u8>) -> Result<(), DecodeError> { pub fn write_command_line(&self, writer: &mut Vec<u8>) -> Result<(), DecodeError> {
if let Some(OptionValue::Bytes(command_line)) = self.read_option(OptionKey::Cmdline) { if let Some(OptionValue::Bytes(command_line)) = self.read_option(OptionKey::Cmdline) {
return printf_q_decode(writer, command_line); return printf_q_decode(writer, command_line);
@@ -416,91 +403,6 @@ pub fn parse(parser: &mut Parser, _terminator_ch: Option<u8>) -> Option<OscComma
Some(OscCommand::SemanticPrompt(cmd)) Some(OscCommand::SemanticPrompt(cmd))
} }
// TODO(integration): local copies of ZIG/src/os/string_encoding.zig.
/// Decode data 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 Vec<u8>, 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<u8>, buf: &[u8]) -> Result<(), DecodeError> {
fn hex(c: u8) -> Option<u8> {
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)] #[cfg(test)]
#[allow(clippy::unwrap_used, clippy::panic)] #[allow(clippy::unwrap_used, clippy::panic)]
mod tests { mod tests {
@@ -531,7 +433,7 @@ mod tests {
} }
fn cmdline_err(input: &str) { fn cmdline_err(input: &str) {
assert_eq!(cmdline(input).1, Err(DecodeError)); assert_eq!(cmdline(input).1, Err(DecodeError::DecodeError));
} }
#[test] #[test]
@@ -980,19 +882,4 @@ mod tests {
assert!(r("abc").is_none()); assert!(r("abc").is_none());
assert_eq!(r("127;aid=foo"), Some(OptionValue::Int(127))); 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)
);
}
} }
+250
View File
@@ -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<W: Write>(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<W: Write>(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<u8> {
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<W: Write>(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<Vec<u8>, Error> {
let mut w = Vec::new();
printf_q_decode(&mut w, s).map(|()| w)
}
fn pd(s: &[u8]) -> Result<Vec<u8>, 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~");
}
}
+36 -12
View File
@@ -10,29 +10,45 @@ use std::sync::OnceLock;
/// licensed under the same license as Ghostty. /// licensed under the same license as Ghostty.
const DATA: &str = include_str!("res/rgb.txt"); 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<Item = (&'static str, Rgb)> {
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 /// The map of all available X11 colors. Keys are lowercase ASCII; lookups
/// are ASCII case-insensitive (see [`get`]). /// are ASCII case-insensitive (see [`get`]).
fn map() -> &'static HashMap<String, Rgb> { fn map() -> &'static HashMap<String, Rgb> {
static MAP: OnceLock<HashMap<String, Rgb>> = OnceLock::new(); static MAP: OnceLock<HashMap<String, Rgb>> = OnceLock::new();
MAP.get_or_init(|| { MAP.get_or_init(|| {
let mut m = HashMap::new(); let mut m = HashMap::new();
// Parse the line. This is not very robust parsing, because we expect for (name, rgb) in parse_entries() {
// a very exact format for rgb.txt. m.entry(name.to_ascii_lowercase()).or_insert(rgb);
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 });
} }
m 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<Vec<(&'static str, Rgb)>> = 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). /// Look up an X11 color by name (ASCII case-insensitive).
pub fn get(name: &str) -> Option<Rgb> { pub fn get(name: &str) -> Option<Rgb> {
map().get(&name.to_ascii_lowercase()).copied() map().get(&name.to_ascii_lowercase()).copied()
@@ -48,6 +64,14 @@ mod tests {
#![allow(clippy::unwrap_used)] #![allow(clippy::unwrap_used)]
use super::*; 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] #[test]
fn x11_color_map() { fn x11_color_map() {
assert_eq!(None, get("nosuchcolor")); assert_eq!(None, get("nosuchcolor"));
+10 -10
View File
@@ -2,26 +2,26 @@
Collected from agent reports; resolve during integration phases. 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) - [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] os `open::OpenUrlKind` → replace with core apprt action type.
- [x] terminal crate: re-add deps on unicode/datastruct once those land. - [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`) - [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. - [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)
- [ ] 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] 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: 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 `<workspace>/resources`)
- NOTE: Ghostty 1.3.1 is installed at /usr/bin/ghostty — use it as reference oracle (e.g. +show-config, +list-actions, terminfo). - 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: 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). - [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). - [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)
- [ ] vt: x11_color lacks name listing with original case (cli re-parses rgb.txt). - [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<S>. (resolved: apprt re-exports the real types; core implements AppMailbox<SurfaceId>) - [x] apprt: Scrollbar/RendererHealth stand-ins → terminal/renderer types; core implements AppMailbox<S>. (resolved: apprt re-exports the real types; core implements AppMailbox<SurfaceId>)
- [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. - [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. - [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)
- [ ] 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] 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] 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: 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) - [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)
+55
View File
@@ -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
`<workspace>/resources` has the same sentinel (`terminfo/x/xterm-ghostty`), use it. `cargo run`
now works without the env var.
- Child `TERMINFO`: Zig sets `<resources>/../terminfo`. Rust prefers `<resources>/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<Box<Viewer>>`; 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 <name>"`; 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.
+36
View File
@@ -33,3 +33,39 @@ test:
clean: clean:
cargo 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
+22
View File
@@ -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 <dir> 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