Skip to content

Commit bb1c3b2

Browse files
authored
Let TUI-painted cell backgrounds follow the window opacity (#306)
1 parent b7c67b4 commit bb1c3b2

9 files changed

Lines changed: 302 additions & 51 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,7 @@ Automatic mode delegates the default user layer to `ghostty_config_load_default_
192192
`GhosttyConfigSource` is the matching raw-text seam for features whose values the C API cannot expose or whose user-stated value matters. It concatenates readable default root files in Ghostty order (XDG `config`, XDG `config.ghostty`, Application Support `config`, Application Support `config.ghostty`), followed by the custom files in Settings order. Recursive `config-file` includes are loaded by libghostty at runtime but remain outside those raw-text scans.
193193
194194
- **`macterm-defaults.conf`** — first-launch tasteful defaults; anything in the user's config overrides them.
195-
- **`macterm-overrides.conf`** — keys Macterm must lock: `background-opacity = 0` / `background-blur = 0` (so `WindowAppearance` composites translucency itself without double-tinting), plus `env = GHOSTTY_BIN_DIR=<bundle>/Contents/Resources/ssh-bridge` and a `shell-integration-features` line forcing `no-path`. The shell-integration `ssh` wrapper execs `"$GHOSTTY_BIN_DIR/ghostty" +ssh`, and that dir holds Macterm's own `ghostty` shim (`scripts/ghostty-shim.sh`, installed by the "Bundle macterm CLI" post-build step), which relays to `macterm ssh` — so `ssh-env`/`ssh-terminfo` work with **no Ghostty.app installed** and are never forced off (only if the shim is missing from the bundle — a broken build — are they, so the wrapper falls through to plain `ssh` instead of dying on a bad exec). The shim lives in its own dir, NOT `Resources/bin` or `Contents/MacOS`, because both land on pane PATHs (`EnvironmentSetup` prepends bin; libghostty unconditionally appends the exe dir) and a `ghostty` that only answers `+ssh` must never be reachable by name — which is also why `no-path` is always forced (that feature's only effect is putting `GHOSTTY_BIN_DIR` on PATH). The features key can't be written bare — libghostty re-parses it from defaults on every occurrence, wiping the user's own flags — so `ShellIntegrationFeatures.overrideValue` re-emits the user's effective value with our `no-*` flags appended (#75). Because the override depends on the user's config content, `loadConfig` calls `regenerate()` before every load.
195+
- **`macterm-overrides.conf`** — keys Macterm must lock: `background-default-transparent = true` (fork patch 0004 — the renderer never paints the *default* background, so `WindowAppearance` composites translucency itself without double-tinting; the same renderer mechanism ghostty's macOS glass styles use, exposed as a config key), `background-opacity = <Preferences.windowOpacity>` (the REAL opacity, not a pinned 0 — that's what lets the user's own `background-opacity-cells` flag work as ghostty documents it, making TUI-painted cell backgrounds translucent at the window opacity; under the old `background-opacity = 0` pin that flag multiplied every painted cell to invisible. `Preferences.windowOpacity` keeps it current via a debounced regenerate+reload — the instant window resync stays on the cheap no-reload path), `background-blur = 0` (Macterm calls the CGS blur SPI itself), plus `env = GHOSTTY_BIN_DIR=<bundle>/Contents/Resources/ssh-bridge` and a `shell-integration-features` line forcing `no-path`. The shell-integration `ssh` wrapper execs `"$GHOSTTY_BIN_DIR/ghostty" +ssh`, and that dir holds Macterm's own `ghostty` shim (`scripts/ghostty-shim.sh`, installed by the "Bundle macterm CLI" post-build step), which relays to `macterm ssh` — so `ssh-env`/`ssh-terminfo` work with **no Ghostty.app installed** and are never forced off (only if the shim is missing from the bundle — a broken build — are they, so the wrapper falls through to plain `ssh` instead of dying on a bad exec). The shim lives in its own dir, NOT `Resources/bin` or `Contents/MacOS`, because both land on pane PATHs (`EnvironmentSetup` prepends bin; libghostty unconditionally appends the exe dir) and a `ghostty` that only answers `+ssh` must never be reachable by name — which is also why `no-path` is always forced (that feature's only effect is putting `GHOSTTY_BIN_DIR` on PATH). The features key can't be written bare — libghostty re-parses it from defaults on every occurrence, wiping the user's own flags — so `ShellIntegrationFeatures.overrideValue` re-emits the user's effective value with our `no-*` flags appended (#75). Because the override depends on the user's config content, `loadConfig` calls `regenerate()` before every load.
196196
197197
Macterm-specific UI state (window opacity/blur, quick terminal, hotkeys, auto-tile) lives in `Preferences` and never touches the ghostty config pipeline.
198198

Macterm/App/Preferences.swift

Lines changed: 36 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -372,15 +372,20 @@ final class Preferences {
372372

373373
// MARK: - Window
374374

375-
/// Macterm-painted window background opacity (0–1). Independent from
376-
/// ghostty's renderer — `macterm-overrides.conf` pins `background-opacity
377-
/// = 0` so ghostty draws fully transparent, then Macterm composites this
378-
/// translucency at the window level. Avoids the double-paint problem when
379-
/// both layers tint.
375+
/// Macterm-painted window background opacity (0–1). Macterm composites
376+
/// this translucency at the window level while `macterm-overrides.conf`
377+
/// sets `background-default-transparent` so ghostty never paints the
378+
/// default background — avoiding the double-paint problem when both
379+
/// layers tint. The value is also written into the overrides as
380+
/// `background-opacity`, which ghostty applies to TUI-painted cell
381+
/// backgrounds when the user's own `background-opacity-cells` flag is
382+
/// on — hence the debounced config reload alongside the instant window
383+
/// resync.
380384
var windowOpacity: Double {
381385
didSet {
382386
defaults.set(windowOpacity, forKey: Keys.windowOpacity)
383387
notifyWindowAppearanceChanged()
388+
scheduleGhosttyConfigReload()
384389
}
385390
}
386391

@@ -481,16 +486,36 @@ final class Preferences {
481486

482487
/// Notify observers that a WINDOW-APPEARANCE pref (opacity/blur/glass)
483488
/// changed, WITHOUT regenerating the ghostty config or reloading libghostty.
484-
/// Those values don't appear in the regenerated files (`background-opacity`
485-
/// is pinned to 0 unconditionally) — `WindowAppearance.sync` reads them
486-
/// straight from Preferences. Previously these setters ran the full
487-
/// `notifyConfigChanged()` (two file writes + a whole-config libghostty
488-
/// reload) purely to piggy-back on the `.mactermConfigDidChange` post it
489-
/// ends with — heavyweight, and fired continuously while dragging a slider.
489+
/// `WindowAppearance.sync` reads these values straight from Preferences.
490+
/// Previously these setters ran the full `notifyConfigChanged()` (two file
491+
/// writes + a whole-config libghostty reload) purely to piggy-back on the
492+
/// `.mactermConfigDidChange` post it ends with — heavyweight, and fired
493+
/// continuously while dragging a slider.
494+
///
495+
/// One value DOES also live in the regenerated overrides: `windowOpacity`
496+
/// is written as ghostty's `background-opacity` (so the user's
497+
/// `background-opacity-cells` makes painted cells translucent at the
498+
/// window opacity). That side is followed by `scheduleGhosttyConfigReload`
499+
/// below — debounced, so slider drags stay on this cheap path and the
500+
/// libghostty reload fires once after the value settles.
490501
private func notifyWindowAppearanceChanged() {
491502
NotificationCenter.default.post(name: .mactermConfigDidChange, object: nil)
492503
}
493504

505+
/// The pending debounced reload for `windowOpacity`'s ghostty-side copy.
506+
@ObservationIgnored private var ghosttyOpacityReloadTask: Task<Void, Never>?
507+
508+
/// Regenerate + reload the ghostty config shortly after the last call,
509+
/// so a slider drag costs one whole-config reload instead of dozens.
510+
private func scheduleGhosttyConfigReload() {
511+
ghosttyOpacityReloadTask?.cancel()
512+
ghosttyOpacityReloadTask = Task { @MainActor [weak self] in
513+
try? await Task.sleep(for: .milliseconds(250))
514+
guard !Task.isCancelled else { return }
515+
self?.notifyConfigChanged()
516+
}
517+
}
518+
494519
// MARK: - Quick terminal
495520

496521
/// Fraction of screen width (0–1).

Macterm/Config/MactermConfig.swift

Lines changed: 38 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -56,11 +56,39 @@ final class MactermConfig {
5656
].joined(separator: "\n") + "\n"
5757
write(Data(defaults.utf8), to: defaultsURL)
5858

59+
let body = Self.overridesBody(
60+
windowOpacity: Preferences.shared.windowOpacity,
61+
userConfigText: userGhosttyConfigText(),
62+
shimDirectory: Self.sshShimDirectory()
63+
)
64+
write(Data(body.utf8), to: overridesURL)
65+
}
66+
67+
/// The full text of `macterm-overrides.conf`. Pure — live inputs are
68+
/// passed in — so the wire contract with libghostty (most importantly the
69+
/// fork's `background-default-transparent` key) is unit-testable without
70+
/// touching disk.
71+
static func overridesBody(
72+
windowOpacity: Double,
73+
userConfigText: String?,
74+
shimDirectory: String?
75+
) -> String {
5976
var overrides = [
6077
// Macterm composites window translucency at the AppKit level —
61-
// ghostty must draw a fully transparent terminal or we'd double-
62-
// tint. See WindowAppearance.swift.
63-
"background-opacity = 0",
78+
// ghostty must not paint the default background or we'd double-
79+
// tint. See WindowAppearance.swift. This fork key skips exactly
80+
// that paint (the same renderer mechanism ghostty's own macOS
81+
// glass styles use) while leaving `background-opacity`
82+
// meaningful for everything else.
83+
"background-default-transparent = true",
84+
// The real window opacity, so the user's own
85+
// `background-opacity-cells` flag works as ghostty documents it:
86+
// TUI-painted cell backgrounds become translucent at the window
87+
// opacity. Before the fork key existed this was pinned to 0 —
88+
// which that flag multiplies into every painted cell, turning
89+
// them invisible instead of translucent. Kept current by the
90+
// debounced reload in `Preferences.windowOpacity`.
91+
"background-opacity = \(windowOpacity)",
6492
// We call CGSSetWindowBackgroundBlurRadius ourselves; ghostty's
6593
// own blur would compose on top of it.
6694
"background-blur = 0",
@@ -81,8 +109,8 @@ final class MactermConfig {
81109
// GHOSTTY_BIN_DIR on PATH, and the shim answers nothing but `+ssh` —
82110
// exposing it as a bare `ghostty` would impersonate the real CLI.
83111
var disabledFeatures = ["no-path"]
84-
if let shimDir = Self.sshShimDirectory() {
85-
overrides.append("env = GHOSTTY_BIN_DIR=\(shimDir)")
112+
if let shimDirectory {
113+
overrides.append("env = GHOSTTY_BIN_DIR=\(shimDirectory)")
86114
} else {
87115
disabledFeatures.append(contentsOf: ["no-ssh-env", "no-ssh-terminfo"])
88116
}
@@ -92,21 +120,21 @@ final class MactermConfig {
92120
// Re-emit the user's effective value with our forced flags appended
93121
// so only those change. (#75)
94122
let value = ShellIntegrationFeatures.overrideValue(
95-
userConfigText: userGhosttyConfigText(),
123+
userConfigText: userConfigText,
96124
disabled: disabledFeatures
97125
)
98126
if let value {
99127
overrides.append("shell-integration-features = \(value)")
100128
}
101129

102-
let body = overrides.joined(separator: "\n") + "\n"
103-
write(Data(body.utf8), to: overridesURL)
130+
return overrides.joined(separator: "\n") + "\n"
104131
}
105132

106133
/// Write a wrapper-config file, logging on failure. These writes are
107134
/// behavior-changing — a failed overrides write silently breaks the
108-
/// translucency contract (`background-opacity = 0` never lands, causing
109-
/// double-tinting) — so a swallowed `try?` would leave zero diagnostics.
135+
/// translucency contract (`background-default-transparent` never lands,
136+
/// causing double-tinting) — so a swallowed `try?` would leave zero
137+
/// diagnostics.
110138
private func write(_ data: Data, to url: URL) {
111139
do {
112140
try data.write(to: url, options: .atomic)

Macterm/Ghostty/GhosttyApp.swift

Lines changed: 10 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -84,9 +84,13 @@ final class GhosttyApp {
8484
rt.supports_selection_clipboard = true
8585
rt.wakeup_cb = { _ in GhosttyApp.shared.callbacks.wakeup() }
8686
rt.action_cb = { _, target, action in GhosttyApp.shared.callbacks.action(target: target, action: action) }
87-
rt.read_clipboard_cb = { ud, loc, state in GhosttyApp.shared.callbacks.readClipboard(ud: ud, location: loc, state: state) }
88-
rt.confirm_read_clipboard_cb = { ud, content, state, _ in
89-
GhosttyApp.shared.callbacks.confirmReadClipboard(ud: ud, content: content, state: state)
87+
rt.read_clipboard_cb = { ud, _, state, mimes, mimesLen, list in
88+
GhosttyApp.shared.callbacks.readClipboard(
89+
ud: ud, state: state, mimes: mimes, mimesLen: mimesLen, list: list
90+
)
91+
}
92+
rt.confirm_read_clipboard_cb = { ud, confirm, state, _ in
93+
GhosttyApp.shared.callbacks.confirmReadClipboard(ud: ud, confirm: confirm, state: state)
9094
}
9195
rt.write_clipboard_cb = { _, loc, content, len, confirm in
9296
GhosttyApp.shared.callbacks.writeClipboard(
@@ -445,8 +449,9 @@ final class GhosttyApp {
445449
// 2. User's Ghostty config files, overriding any default. In automatic
446450
// mode, libghostty loads its default roots.
447451
// 3. Macterm overrides — keys Macterm absolutely needs to control,
448-
// currently just background-opacity/blur for the window-level
449-
// translucency contract. Loaded last so it overrides the user.
452+
// currently the background keys for the window-level translucency
453+
// contract (default-background paint, opacity value, blur).
454+
// Loaded last so it overrides the user.
450455
// libghostty merges last-wins, so this ordering produces:
451456
// Macterm defaults < user's Ghostty config < Macterm overrides
452457
MactermConfig.shared.defaultsPath.withCString { ghostty_config_load_file(cfg, $0) }

0 commit comments

Comments
 (0)