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:
1 parent
062c643ea8
commit
7868e56273
24 files changed
+655
-512
No files matched your search
@@ -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"
|
||||||
|
|||||||
@@ -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}");
|
||||||
|
}
|
||||||
@@ -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)));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -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`.
|
||||||
|
|||||||
@@ -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)?;
|
||||||
|
|||||||
@@ -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.
|
||||||
///
|
///
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
"##
|
"##
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,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::*;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
@@ -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)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -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~");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"));
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -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
|
||||||
Executable
+22
@@ -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
|
||||||
Reference in new issue
Block a user