fix: close remaining gaps found by the completeness audit

Addresses the partial ports listed in
docs/rewrite/notes/review-completeness-codex.md (details in
docs/rewrite/notes/gaps-X2.md):
- Flatpak: the exit watcher no longer consumes the host command; a
  shared FlatpakSignalHandle lets closing a surface still SIGHUP the
  host process group.
- Child environment is byte-preserving end to end (ghostty_os::EnvMap
  of OsStrings) and Snap paths are filtered like filterSnapPaths.
- argv handling no longer panics on non-UTF-8 arguments.
- URI percent-decoding keeps raw bytes; OSC 7 pwd is passed through
  without lossy conversion.
- Keybinding comparison/hashing uses full Unicode case folding from a
  table generated from CaseFolding.txt (Unicode 17.0.0).
- Link regexes set Oniguruma's whole-search retry limit through the new
  minimal ghostty-onig crate (one SAFETY-commented FFI call).
- OSC 9 NUL-capacity check ordering matches Zig.
- Wayland registry binds/clears globals advertised after startup.
- Custom shaders may pass sampler2D to helper functions (naga adapter).
- +list-themes preview renders OSC 8 links, styled underlines and
  follows color-scheme reports.

Also includes the inspector's integration edits in shared files
(renderer state hook, termio VT recording, core/apprt-gtk wiring), X1's
config/input/cli additions for completion generation, and a fix for a
race in the termio backpressure test (it wrote before `stty raw`
applied, so the cooked line discipline dropped bytes past MAX_CANON).

Test Plan:
- cargo test --workspace
- cargo clippy --workspace --all-targets -- -D warnings
This commit is contained in:
ddidderr committed 2026-10-12 04:39:24 +02:00
1 parent d1a5e55198
commit 2aa2a12de0
61 files changed
+4210 -298

No files matched your search

+5 -1
View File
@@ -1723,7 +1723,11 @@ impl Renderer {
mouse_mods: st.mouse.mods,
preedit,
scrollbar,
overlay_features: st.overlay_features.clone(),
overlay_features: st
.inspector
.as_ref()
.map(|i| i.overlay_features())
.unwrap_or_default(),
}
};
+1
View File
@@ -25,6 +25,7 @@ pub mod options;
pub mod overlay;
pub mod row;
pub mod shadertoy;
mod shadertoy_sampler;
pub mod size;
pub mod state;
pub mod thread;
+64 -1
View File
@@ -8,6 +8,7 @@
//! `docs/rewrite/notes/ghostty-renderer-R1.md` for the (small) source
//! adaptations needed for naga's Vulkan-flavoured GLSL frontend.
use crate::shadertoy_sampler;
use std::io::Read;
use std::path::Path;
@@ -250,7 +251,13 @@ pub fn spirv_from_glsl(src: &str, errlog: Option<&mut ShaderLog>) -> Result<Comp
/// * the `mainImage` prototype and `main` are moved after the user code
/// (naga requires callee definitions before their callers).
fn adapt_for_naga(full: &str) -> String {
let mut s = full
// Only the verbatim prefix gets the constructs below; the user's code is
// only touched by the combined-sampler rewrite.
let (prefix, user) = match full.strip_prefix(PREFIX) {
Some(rest) => (PREFIX, rest),
None => (full, ""),
};
let mut s = prefix
.replacen("#version 430 core", "#version 460 core", 1)
.replace(" uniform ", " ")
.replacen("layout(location = 0) in vec4 gl_FragCoord;", "", 1)
@@ -276,6 +283,12 @@ fn adapt_for_naga(full: &str) -> String {
"",
1,
);
if !user.is_empty() {
// `texture2D` is a type for naga (and the `sampler2D` parameter
// rewrite): the prefix macro is replaced by rewriting the calls.
s = s.replacen("#define texture2D texture", "", 1);
s.push_str(&shadertoy_sampler::rewrite(user));
}
s.push_str("\nvoid main() { mainImage (_fragColor, gl_FragCoord.xy); }\n");
s
}
@@ -406,6 +419,56 @@ mod tests {
compile_shadertoy(src, None).unwrap();
}
#[test]
fn sampler2d_parameter_helper_compiles() {
let src = "vec4 fetch(sampler2D tex, vec2 uv) { return texture(tex, uv); }\n\
void mainImage(out vec4 c, in vec2 p) { c = fetch(iChannel0, p / iResolution.xy); }\n";
let mut log = ShaderLog::default();
let r = compile_shadertoy(src, Some(&mut log));
assert!(r.is_ok(), "{}", log.info);
}
fn compiles(src: &str) {
let mut log = ShaderLog::default();
let c = compile_shadertoy(src, Some(&mut log));
assert!(c.is_ok(), "{}\n{}", log.info, log.debug);
glsl_from_compiled(&c.unwrap()).unwrap();
}
#[test]
fn sampler2d_parameter_forwarding_and_prototype() {
compiles(
"vec4 inner(in sampler2D s, vec2 uv);\n\
vec4 outer(sampler2D t, vec2 uv) { return inner(t, uv) + inner(t, uv * 2.0); }\n\
vec4 inner(in sampler2D s, vec2 uv) { return texture(s, uv); }\n\
void mainImage(out vec4 c, in vec2 p) { c = outer(iChannel0, p / iResolution.xy); }\n",
);
}
#[test]
fn sampler2d_parameter_other_texture_functions() {
compiles(
"vec4 f(sampler2D s, vec2 uv) {\n\
vec2 sz = vec2(textureSize(s, 0));\n\
vec4 a = textureLod(s, uv, 0.0);\n\
vec4 b = texelFetch(s, ivec2(uv * sz), 0);\n\
return a + b + texture2D(s, uv);\n\
}\n\
void mainImage(out vec4 c, in vec2 p) { c = f(iChannel0, p / iResolution.xy); }\n",
);
}
#[test]
fn combined_sampler_builtin_uses_compile() {
compiles(
"void mainImage(out vec4 c, in vec2 p) {\n\
vec2 uv = p / iResolution.xy;\n\
c = textureLod(iChannel0, uv, 0.0) + texelFetch(iChannel0, ivec2(p), 0)\n\
+ vec4(textureSize(iChannel0, 0), 0.0, 0.0) + texture2D(iChannel0, uv);\n\
}\n",
);
}
#[test]
fn load_from_files_skips_missing_optional() {
let dir =
@@ -0,0 +1,359 @@
//! Source-level adaptation of combined image samplers for naga.
//!
//! glslang (Vulkan GLSL) accepts `sampler2D` as a function parameter type and
//! lets shaders pass the combined `iChannel0` around. naga's GLSL frontend
//! has no combined sampler *type*, only separate `texture2D` + `sampler`
//! objects (and a `sampler2D(texture, sampler)` constructor expression that
//! is valid only directly inside texture calls).
//!
//! This module rewrites user code so such helpers work:
//!
//! * `T f(.., [qual] sampler2D s, ..)` -> `T f(.., texture2D s_tex, sampler s_smp, ..)`
//! * uses of `s` in the function body -> `sampler2D(s_tex, s_smp)`
//! * `f(.., s, ..)` / `f(.., iChannel0, ..)` call arguments in sampler
//! positions -> `s_tex, s_smp` / `iChannel0_tex, iChannel0_smp`
//! * legacy `texture2D(` calls -> `texture(` (glslang gets this from the
//! prefix macro `#define texture2D texture`, which would otherwise clash
//! with the `texture2D` type).
//!
//! Limitations (documented, reported as compile errors by naga): a sampler
//! argument must be a plain identifier (no ternaries or struct members),
//! samplers cannot be stored in local variables/arrays/struct fields, and
//! overloads of one function name must agree on which parameters are
//! samplers.
use std::collections::HashMap;
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum Kind {
/// Whitespace, comments and preprocessor lines (not significant).
Trivia,
Ident,
Punct,
Other,
}
struct Tok<'a> {
kind: Kind,
text: &'a str,
}
fn lex(src: &str) -> Vec<Tok<'_>> {
let b = src.as_bytes();
let mut toks = Vec::new();
let mut i = 0;
let mut line_start = true;
while i < b.len() {
let start = i;
let c = b[i];
let kind;
if c.is_ascii_whitespace() {
while i < b.len() && b[i].is_ascii_whitespace() {
if b[i] == b'\n' {
line_start = true;
}
i += 1;
}
// line_start must reflect "only whitespace since newline".
toks.push(Tok {
kind: Kind::Trivia,
text: &src[start..i],
});
continue;
} else if c == b'/' && b.get(i + 1) == Some(&b'/') {
while i < b.len() && b[i] != b'\n' {
i += 1;
}
kind = Kind::Trivia;
} else if c == b'/' && b.get(i + 1) == Some(&b'*') {
i += 2;
while i + 1 < b.len() && !(b[i] == b'*' && b[i + 1] == b'/') {
i += 1;
}
i = (i + 2).min(b.len());
kind = Kind::Trivia;
} else if c == b'#' && line_start {
// Preprocessor line (with backslash continuations).
while i < b.len() && b[i] != b'\n' {
if b[i] == b'\\' && b.get(i + 1) == Some(&b'\n') {
i += 1;
}
i += 1;
}
kind = Kind::Trivia;
} else if c.is_ascii_alphabetic() || c == b'_' {
while i < b.len() && (b[i].is_ascii_alphanumeric() || b[i] == b'_') {
i += 1;
}
kind = Kind::Ident;
} else if c.is_ascii_digit() || (c == b'.' && b.get(i + 1).is_some_and(u8::is_ascii_digit))
{
while i < b.len() && (b[i].is_ascii_alphanumeric() || b[i] == b'_' || b[i] == b'.') {
i += 1;
}
kind = Kind::Other;
} else {
// Any single (possibly multi-byte) character.
i += 1;
while i < b.len() && (b[i] & 0xC0) == 0x80 {
i += 1;
}
kind = Kind::Punct;
}
line_start = false;
toks.push(Tok {
kind,
text: &src[start..i],
});
}
toks
}
fn is_sig(t: &Tok<'_>) -> bool {
t.kind != Kind::Trivia
}
/// Index of the token matching the opener at `sig[open]` (indices into the
/// significant-token list), or None.
fn matching(toks: &[Tok<'_>], sig: &[usize], open: usize) -> Option<usize> {
let o = toks[sig[open]].text;
let c = match o {
"(" => ")",
"{" => "}",
"[" => "]",
_ => return None,
};
let mut depth = 0usize;
for (k, &ti) in sig.iter().enumerate().skip(open) {
let t = toks[ti].text;
if t == o {
depth += 1;
} else if t == c {
depth -= 1;
if depth == 0 {
return Some(k);
}
}
}
None
}
/// Split the significant range `(from..to)` at top-level commas, returning
/// `(start, end)` ranges (exclusive end) in significant-token indices.
fn split_args(toks: &[Tok<'_>], sig: &[usize], from: usize, to: usize) -> Vec<(usize, usize)> {
let mut out = Vec::new();
if from >= to {
return out;
}
let mut depth = 0i32;
let mut start = from;
for k in from..to {
match toks[sig[k]].text {
"(" | "[" | "{" => depth += 1,
")" | "]" | "}" => depth -= 1,
"," if depth == 0 => {
out.push((start, k));
start = k + 1;
}
_ => {}
}
}
out.push((start, to));
out
}
/// A top-level function definition/prototype (indices are into the
/// significant-token list).
struct Def {
name_k: usize,
params: Vec<(usize, usize)>,
body: Option<(usize, usize)>,
samplers: Vec<Option<String>>,
}
struct FnInfo {
/// Per parameter: the sampler name, if the parameter is a `sampler2D`.
samplers: Vec<Option<String>>,
}
/// Rewrite the user's ShaderToy source. Returns the source unchanged if it
/// needs no adaptation other than `texture2D(` -> `texture(`.
pub fn rewrite(src: &str) -> String {
let toks = lex(src);
let sig: Vec<usize> = toks
.iter()
.enumerate()
.filter(|(_, t)| is_sig(t))
.map(|(i, _)| i)
.collect();
// Pass 1: find top-level functions with sampler2D parameters.
let mut funcs: HashMap<&str, FnInfo> = HashMap::new();
let mut defs: Vec<Def> = Vec::new();
let mut k = 0;
let mut brace = 0i32;
while k < sig.len() {
let t = &toks[sig[k]];
match t.text {
"{" => brace += 1,
"}" => brace -= 1,
_ => {}
}
if brace == 0
&& t.kind == Kind::Ident
&& k + 2 < sig.len()
&& toks[sig[k + 1]].kind == Kind::Ident
&& toks[sig[k + 2]].text == "("
{
let name_k = k + 1;
if let Some(close) = matching(&toks, &sig, k + 2) {
let next = sig.get(close + 1).map(|&i| toks[i].text);
if next == Some("{") || next == Some(";") {
let params = split_args(&toks, &sig, k + 3, close);
let mut samplers = Vec::new();
let mut any = false;
for &(a, b) in &params {
let has = (a..b).any(|q| toks[sig[q]].text == "sampler2D");
if has {
let name = (a..b)
.rev()
.map(|q| &toks[sig[q]])
.find(|t| t.kind == Kind::Ident && t.text != "sampler2D")
.map(|t| t.text.to_owned());
any |= name.is_some();
samplers.push(name);
} else {
samplers.push(None);
}
}
let body = if next == Some("{") {
matching(&toks, &sig, close + 1).map(|e| (close + 1, e))
} else {
None
};
if any {
funcs
.entry(toks[sig[name_k]].text)
.or_insert_with(|| FnInfo {
samplers: samplers.clone(),
});
}
defs.push(Def {
name_k,
params,
body,
samplers,
});
// Skip past the signature (bodies are scanned for
// braces by the normal loop).
k = close + 1;
continue;
}
}
}
k += 1;
}
// Pass 2: collect replacements keyed by token index.
let mut repl: HashMap<usize, String> = HashMap::new();
let mut def_names: Vec<usize> = Vec::new();
for d in &defs {
def_names.push(d.name_k);
for (pi, &(a, b)) in d.params.iter().enumerate() {
if let Some(name) = d.samplers.get(pi).and_then(Option::as_ref) {
repl.insert(sig[a], format!("texture2D {name}_tex, sampler {name}_smp"));
// Blank the rest of the parameter, including interior trivia.
for ti in (sig[a] + 1)..=sig[b - 1] {
repl.insert(ti, String::new());
}
}
}
// Uses of the sampler parameters in the body.
if let Some((open, close)) = d.body {
let names: Vec<&String> = d.samplers.iter().flatten().collect();
for q in open + 1..close {
let t = &toks[sig[q]];
if t.kind == Kind::Ident && names.iter().any(|n| n.as_str() == t.text) {
repl.insert(sig[q], format!("sampler2D({0}_tex, {0}_smp)", t.text));
}
}
}
}
// Call sites (and legacy texture2D).
for k in 0..sig.len() {
let t = &toks[sig[k]];
if t.kind != Kind::Ident || sig.get(k + 1).map(|&i| toks[i].text) != Some("(") {
continue;
}
if t.text == "texture2D" {
repl.insert(sig[k], "texture".to_owned());
continue;
}
if def_names.contains(&k) {
continue;
}
let Some(info) = funcs.get(t.text) else {
continue;
};
let Some(close) = matching(&toks, &sig, k + 1) else {
continue;
};
let args = split_args(&toks, &sig, k + 2, close);
for (ai, &(a, b)) in args.iter().enumerate() {
if info.samplers.get(ai).is_some_and(Option::is_some)
&& b == a + 1
&& toks[sig[a]].kind == Kind::Ident
{
let n = toks[sig[a]].text;
repl.insert(sig[a], format!("{n}_tex, {n}_smp"));
}
}
}
let mut out = String::with_capacity(src.len() + 64);
for (i, t) in toks.iter().enumerate() {
match repl.get(&i) {
Some(r) => out.push_str(r),
None => out.push_str(t.text),
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rewrites_param_use_and_call() {
let out = rewrite(
"vec4 fetch(sampler2D tex, vec2 uv) { return texture(tex, uv); }\n\
void mainImage(out vec4 c, in vec2 p) { c = fetch(iChannel0, p); }",
);
assert!(out.contains("vec4 fetch(texture2D tex_tex, sampler tex_smp, vec2 uv)"));
assert!(out.contains("texture(sampler2D(tex_tex, tex_smp), uv)"));
assert!(out.contains("fetch(iChannel0_tex, iChannel0_smp, p)"));
}
#[test]
fn leaves_plain_code_and_rewrites_legacy_texture2d() {
let src = "void mainImage(out vec4 c, in vec2 p) { c = texture2D(iChannel0, p); }";
assert_eq!(
rewrite(src),
"void mainImage(out vec4 c, in vec2 p) { c = texture(iChannel0, p); }"
);
}
#[test]
fn ignores_comments_and_forwards_samplers() {
let out = rewrite(
"// sampler2D in comment\n\
vec4 a(in sampler2D s) { return texture(s, vec2(0.0)); }\n\
vec4 b(sampler2D t, float x) { return a(t) * x; }\n",
);
assert!(out.contains("// sampler2D in comment"));
assert!(out.contains("vec4 a(texture2D s_tex, sampler s_smp)"));
assert!(out.contains("return a(t_tex, t_smp) * x;"));
}
}
+20 -6
View File
@@ -22,11 +22,11 @@ pub struct State {
/// The terminal data.
pub terminal: Terminal,
/// The overlay features currently requested by the terminal inspector
/// (Zig: `inspector: ?*Inspector` and `inspector.rendererInfo()
/// .overlayFeatures()`). Empty while the inspector is not active.
// TODO(integration): the inspector is deferred (Phase 7); it fills this.
pub overlay_features: Vec<OverlayFeature>,
/// The terminal inspector, if any (Zig: `inspector: ?*Inspector`). This
/// is `None` while the inspector is not active. It is accessed under the
/// same lock as the terminal (lock order: this state, then the
/// inspector's own lock).
pub inspector: Option<Arc<dyn InspectorHook>>,
/// Dead key state. This will render the current dead key preedit text
/// over the cursor. This currently only ever renders a single codepoint.
@@ -39,6 +39,20 @@ pub struct State {
pub mouse: Mouse,
}
/// The view of the terminal inspector that the renderer and termio need
/// (the inspector crate itself depends on this crate, so the Zig
/// `*Inspector` pointer is expressed as a trait object).
pub trait InspectorHook: Send + Sync + std::fmt::Debug {
/// The overlay features requested in the inspector's renderer window
/// (Zig: `inspector.rendererInfo().overlayFeatures()`).
fn overlay_features(&self) -> Vec<OverlayFeature>;
/// Record data read from the pty before it is processed by the terminal
/// (Zig: `Inspector.recordPtyRead`). Termio calls this one byte at a
/// time while an inspector is active.
fn record_pty_read(&self, t: &Terminal, data: &[u8]);
}
/// The shared, mutex-protected renderer state.
pub type SharedState = Arc<Mutex<State>>;
@@ -46,7 +60,7 @@ impl State {
pub fn new(terminal: Terminal) -> State {
State {
terminal,
overlay_features: Vec::new(),
inspector: None,
preedit: None,
mouse: Mouse::default(),
}