macOS: add `toggle_background_opacity` keybind action (#9117)

Related Issue: #5047 
Discussion: #4664 

### Investigation
The behavior of iTerm mentioned on the Discussion thread was as follows:

- `cmd + u` can be used to toggle "Use Transparency"
- The "Use Transparency" toggle operates on a per-surface basis
- The "Use Transparency" state persists even after reloading the config

### Summary
Based on the investigation and discussions in the preceding pull
requests, this implements the `toggle_background_opacity` keybind action
for macOS with the following specifications:

- Switches background opacity on a per-surface basis
- The background opacity state persists even after reloading the config
- Background opacity switching functionality is also available in Quick
Terminal
- Does nothing if `background-opacity` is set to 1 or higher
- Does nothing if in fullscreen mode

### Verification
This functionality has been tested across following scenarios,
confirming correct action behavior for:

- Split Window
  - Each split window maintains synchronized background opacity states
- Tab Group
  - Background opacity states do not synchronize between tabs
- Quick Terminal
- Background opacity states remain synchronized even when windows are
closed
- Command Palette

### AI Disclosure
This pull request was made with assistance from Claude Code.
I reviewed all AI-generated code and wrote the final output manually.


https://github.com/user-attachments/assets/e46ff8f0-42f2-442f-97dd-d5f5c33b10f1
pull/9933/head
Mitchell Hashimoto 2025-12-16 13:11:51 -08:00 committed by GitHub
commit 4827c7f53a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 104 additions and 13 deletions

View File

@ -803,6 +803,7 @@ typedef enum {
GHOSTTY_ACTION_TOGGLE_QUICK_TERMINAL, GHOSTTY_ACTION_TOGGLE_QUICK_TERMINAL,
GHOSTTY_ACTION_TOGGLE_COMMAND_PALETTE, GHOSTTY_ACTION_TOGGLE_COMMAND_PALETTE,
GHOSTTY_ACTION_TOGGLE_VISIBILITY, GHOSTTY_ACTION_TOGGLE_VISIBILITY,
GHOSTTY_ACTION_TOGGLE_BACKGROUND_OPACITY,
GHOSTTY_ACTION_MOVE_TAB, GHOSTTY_ACTION_MOVE_TAB,
GHOSTTY_ACTION_GOTO_TAB, GHOSTTY_ACTION_GOTO_TAB,
GHOSTTY_ACTION_GOTO_SPLIT, GHOSTTY_ACTION_GOTO_SPLIT,

View File

@ -596,7 +596,7 @@ class QuickTerminalController: BaseTerminalController {
}) })
} }
private func syncAppearance() { override func syncAppearance() {
guard let window else { return } guard let window else { return }
defer { updateColorSchemeForSurfaceTree() } defer { updateColorSchemeForSurfaceTree() }
@ -608,7 +608,8 @@ class QuickTerminalController: BaseTerminalController {
guard window.isVisible else { return } guard window.isVisible else { return }
// If we have window transparency then set it transparent. Otherwise set it opaque. // If we have window transparency then set it transparent. Otherwise set it opaque.
if (self.derivedConfig.backgroundOpacity < 1) { // Also check if the user has overridden transparency to be fully opaque.
if !isBackgroundOpaque && self.derivedConfig.backgroundOpacity < 1 {
window.isOpaque = false window.isOpaque = false
// This is weird, but we don't use ".clear" because this creates a look that // This is weird, but we don't use ".clear" because this creates a look that

View File

@ -78,6 +78,9 @@ class BaseTerminalController: NSWindowController,
/// The configuration derived from the Ghostty config so we don't need to rely on references. /// The configuration derived from the Ghostty config so we don't need to rely on references.
private var derivedConfig: DerivedConfig private var derivedConfig: DerivedConfig
/// Track whether background is forced opaque (true) or using config transparency (false)
var isBackgroundOpaque: Bool = false
/// The cancellables related to our focused surface. /// The cancellables related to our focused surface.
private var focusedSurfaceCancellables: Set<AnyCancellable> = [] private var focusedSurfaceCancellables: Set<AnyCancellable> = []
@ -812,6 +815,35 @@ class BaseTerminalController: NSWindowController,
} }
} }
// MARK: Appearance
/// Toggle the background opacity between transparent and opaque states.
/// Do nothing if the configured background-opacity is >= 1 (already opaque).
/// Subclasses should override this to add platform-specific checks and sync appearance.
func toggleBackgroundOpacity() {
// Do nothing if config is already fully opaque
guard ghostty.config.backgroundOpacity < 1 else { return }
// Do nothing if in fullscreen (transparency doesn't apply in fullscreen)
guard let window, !window.styleMask.contains(.fullScreen) else { return }
// Toggle between transparent and opaque
isBackgroundOpaque.toggle()
// Update our appearance
syncAppearance()
}
/// Override this to resync any appearance related properties. This will be called automatically
/// when certain window properties change that affect appearance. The list below should be updated
/// as we add new things:
///
/// - ``toggleBackgroundOpacity``
func syncAppearance() {
// Purposely a no-op. This lets subclasses override this and we can call
// it virtually from here.
}
// MARK: Fullscreen // MARK: Fullscreen
/// Toggle fullscreen for the given mode. /// Toggle fullscreen for the given mode.
@ -872,6 +904,9 @@ class BaseTerminalController: NSWindowController,
} else { } else {
updateOverlayIsVisible = defaultUpdateOverlayVisibility() updateOverlayIsVisible = defaultUpdateOverlayVisibility()
} }
// Always resync our appearance
syncAppearance()
} }
// MARK: Clipboard Confirmation // MARK: Clipboard Confirmation

View File

@ -165,17 +165,6 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
} }
} }
override func fullscreenDidChange() {
super.fullscreenDidChange()
// When our fullscreen state changes, we resync our appearance because some
// properties change when fullscreen or not.
guard let focusedSurface else { return }
syncAppearance(focusedSurface.derivedConfig)
}
// MARK: Terminal Creation // MARK: Terminal Creation
/// Returns all the available terminal controllers present in the app currently. /// Returns all the available terminal controllers present in the app currently.
@ -489,6 +478,13 @@ class TerminalController: BaseTerminalController, TabGroupCloseCoordinator.Contr
tabWindowsHash = v tabWindowsHash = v
self.relabelTabs() self.relabelTabs()
} }
override func syncAppearance() {
// When our focus changes, we update our window appearance based on the
// currently focused surface.
guard let focusedSurface else { return }
syncAppearance(focusedSurface.derivedConfig)
}
private func syncAppearance(_ surfaceConfig: Ghostty.SurfaceView.DerivedConfig) { private func syncAppearance(_ surfaceConfig: Ghostty.SurfaceView.DerivedConfig) {
// Let our window handle its own appearance // Let our window handle its own appearance

View File

@ -469,7 +469,11 @@ class TerminalWindow: NSWindow {
// Window transparency only takes effect if our window is not native fullscreen. // Window transparency only takes effect if our window is not native fullscreen.
// In native fullscreen we disable transparency/opacity because the background // In native fullscreen we disable transparency/opacity because the background
// becomes gray and widgets show through. // becomes gray and widgets show through.
//
// Also check if the user has overridden transparency to be fully opaque.
let forceOpaque = terminalController?.isBackgroundOpaque ?? false
if !styleMask.contains(.fullScreen) && if !styleMask.contains(.fullScreen) &&
!forceOpaque &&
surfaceConfig.backgroundOpacity < 1 surfaceConfig.backgroundOpacity < 1
{ {
isOpaque = false isOpaque = false

View File

@ -573,6 +573,9 @@ extension Ghostty {
case GHOSTTY_ACTION_TOGGLE_VISIBILITY: case GHOSTTY_ACTION_TOGGLE_VISIBILITY:
toggleVisibility(app, target: target) toggleVisibility(app, target: target)
case GHOSTTY_ACTION_TOGGLE_BACKGROUND_OPACITY:
toggleBackgroundOpacity(app, target: target)
case GHOSTTY_ACTION_KEY_SEQUENCE: case GHOSTTY_ACTION_KEY_SEQUENCE:
keySequence(app, target: target, v: action.action.key_sequence) keySequence(app, target: target, v: action.action.key_sequence)
@ -1375,6 +1378,27 @@ extension Ghostty {
} }
} }
private static func toggleBackgroundOpacity(
_ app: ghostty_app_t,
target: ghostty_target_s
) {
switch (target.tag) {
case GHOSTTY_TARGET_APP:
Ghostty.logger.warning("toggle background opacity does nothing with an app target")
return
case GHOSTTY_TARGET_SURFACE:
guard let surface = target.target.surface,
let surfaceView = self.surfaceView(from: surface),
let controller = surfaceView.window?.windowController as? BaseTerminalController else { return }
controller.toggleBackgroundOpacity()
default:
assertionFailure()
}
}
private static func toggleSecureInput( private static func toggleSecureInput(
_ app: ghostty_app_t, _ app: ghostty_app_t,
target: ghostty_target_s, target: ghostty_target_s,

View File

@ -5518,6 +5518,12 @@ pub fn performBindingAction(self: *Surface, action: input.Binding.Action) !bool
{}, {},
), ),
.toggle_background_opacity => return try self.rt_app.performAction(
.{ .surface = self },
.toggle_background_opacity,
{},
),
.show_on_screen_keyboard => return try self.rt_app.performAction( .show_on_screen_keyboard => return try self.rt_app.performAction(
.{ .surface = self }, .{ .surface = self },
.show_on_screen_keyboard, .show_on_screen_keyboard,

View File

@ -115,6 +115,11 @@ pub const Action = union(Key) {
/// Toggle the visibility of all Ghostty terminal windows. /// Toggle the visibility of all Ghostty terminal windows.
toggle_visibility, toggle_visibility,
/// Toggle the window background opacity. This only has an effect
/// if the window started as transparent (non-opaque), and toggles
/// it between fully opaque and the configured background opacity.
toggle_background_opacity,
/// Moves a tab by a relative offset. /// Moves a tab by a relative offset.
/// ///
/// Adjusts the tab position based on `offset` (e.g., -1 for left, +1 /// Adjusts the tab position based on `offset` (e.g., -1 for left, +1
@ -335,6 +340,7 @@ pub const Action = union(Key) {
toggle_quick_terminal, toggle_quick_terminal,
toggle_command_palette, toggle_command_palette,
toggle_visibility, toggle_visibility,
toggle_background_opacity,
move_tab, move_tab,
goto_tab, goto_tab,
goto_split, goto_split,

View File

@ -741,6 +741,7 @@ pub const Application = extern struct {
.close_all_windows, .close_all_windows,
.float_window, .float_window,
.toggle_visibility, .toggle_visibility,
.toggle_background_opacity,
.cell_size, .cell_size,
.key_sequence, .key_sequence,
.render_inspector, .render_inspector,

View File

@ -755,6 +755,16 @@ pub const Action = union(enum) {
/// Only implemented on macOS. /// Only implemented on macOS.
toggle_visibility, toggle_visibility,
/// Toggle the window background opacity between transparent and opaque.
///
/// This does nothing when `background-opacity` is set to 1 or above.
///
/// When `background-opacity` is less than 1, this action will either make
/// the window transparent or not depending on its current transparency state.
///
/// Only implemented on macOS.
toggle_background_opacity,
/// Check for updates. /// Check for updates.
/// ///
/// Only implemented on macOS. /// Only implemented on macOS.
@ -1240,6 +1250,7 @@ pub const Action = union(enum) {
.toggle_secure_input, .toggle_secure_input,
.toggle_mouse_reporting, .toggle_mouse_reporting,
.toggle_command_palette, .toggle_command_palette,
.toggle_background_opacity,
.show_on_screen_keyboard, .show_on_screen_keyboard,
.reset_window_size, .reset_window_size,
.crash, .crash,

View File

@ -618,6 +618,12 @@ fn actionCommands(action: Action.Key) []const Command {
.description = "Toggle whether mouse events are reported to terminal applications.", .description = "Toggle whether mouse events are reported to terminal applications.",
}}, }},
.toggle_background_opacity => comptime &.{.{
.action = .toggle_background_opacity,
.title = "Toggle Background Opacity",
.description = "Toggle the background opacity of a window that started transparent.",
}},
.check_for_updates => comptime &.{.{ .check_for_updates => comptime &.{.{
.action = .check_for_updates, .action = .check_for_updates,
.title = "Check for Updates", .title = "Check for Updates",