From b92766d31f7e5473a60a7fa0bc6124c18f6a16b3 Mon Sep 17 00:00:00 2001 From: arzafran Date: Thu, 20 Aug 2026 17:34:07 -0300 Subject: [PATCH] =?UTF-8?q?feat:=20make=20terminal=20appearance=20themeabl?= =?UTF-8?q?e=20=E2=80=94=20opacity,=20blur,=20fonts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the existing terminal theme managed-config-block pattern (theme name selection) to also cover background opacity, background blur, and custom fonts, each independently controllable from Settings, settings.json, or the CLI. Adds two new bundled themes, Min Light and Min Dark, styled after the Min Theme VS Code extension. --- CLI/CLI+Themes.swift | 121 +++++++++-- GhosttyTabs.xcodeproj/project.pbxproj | 2 +- Resources/Localizable.xcstrings | 121 ++++++++++- Resources/ghostty/themes/Min Dark | 22 ++ Resources/ghostty/themes/Min Light | 22 ++ Resources/settings.schema.json | 40 ++++ Sources/GhosttyConfig.swift | 9 + Sources/ProgramaSettingsFileStore.swift | 243 ++++++++++++++++++++- Sources/SettingsView.swift | 227 ++++++++++++++++--- Sources/TerminalThemeStore.swift | 276 ++++++++++++++++++++---- docs/terminal-themes.md | 21 +- programaTests/WorkspaceUnitTests.swift | 165 ++++++++++++++ 12 files changed, 1165 insertions(+), 104 deletions(-) create mode 100644 Resources/ghostty/themes/Min Dark create mode 100644 Resources/ghostty/themes/Min Light diff --git a/CLI/CLI+Themes.swift b/CLI/CLI+Themes.swift index 41c70a5d..ed3d62cd 100644 --- a/CLI/CLI+Themes.swift +++ b/CLI/CLI+Themes.swift @@ -213,6 +213,7 @@ extension ProgramaCLI { let themes = availableThemeNames() let themeStore = TerminalThemeStore.live() let current = themeStore.currentSelection() + let appearance = themeStore.currentAppearance() let configPath = themeStore.managedConfigURL.path if jsonOutput { @@ -220,7 +221,11 @@ extension ProgramaCLI { "raw_value": current.rawValue ?? NSNull(), "light": current.light ?? NSNull(), "dark": current.dark ?? NSNull(), - "source_path": current.sourcePath ?? NSNull() + "source_path": current.sourcePath ?? NSNull(), + "opacity": appearance.backgroundOpacity ?? NSNull(), + "blur": appearance.backgroundBlur ?? NSNull(), + "font_family": appearance.fontFamily ?? NSNull(), + "font_size": appearance.fontSize ?? NSNull() ] let payload: [String: Any] = [ "themes": themes.map { theme in @@ -239,6 +244,9 @@ extension ProgramaCLI { print("Current light: \(current.light ?? "inherit")") print("Current dark: \(current.dark ?? "inherit")") + print("Opacity: \(appearance.backgroundOpacity.map { String($0) } ?? "inherit")") + print("Blur: \(appearance.backgroundBlur.map { $0 ? "on" : "off" } ?? "inherit")") + print("Font: \(appearance.fontFamily ?? "inherit") \(appearance.fontSize.map { String($0) } ?? "")") print("Config: \(configPath)") if let sourcePath = current.sourcePath { print("Source: \(sourcePath)") @@ -263,41 +271,102 @@ extension ProgramaCLI { } } + private func extractFlag(_ args: [String], name: String) -> (Bool, [String]) { + var found = false + var remaining: [String] = [] + for arg in args { + if arg == name { + found = true + } else { + remaining.append(arg) + } + } + return (found, remaining) + } + private func runThemesSet(args: [String], jsonOutput: Bool) throws { let (lightOpt, rem0) = parseOption(args, name: "--light") let (darkOpt, rem1) = parseOption(rem0, name: "--dark") + let (opacityOpt, rem2) = parseOption(rem1, name: "--opacity") + let (fontOpt, rem3) = parseOption(rem2, name: "--font") + let (fontSizeOpt, rem4) = parseOption(rem3, name: "--font-size") + let (blurFlag, rem5) = extractFlag(rem4, name: "--blur") + let (noBlurFlag, remaining) = extractFlag(rem5, name: "--no-blur") - if let unknown = rem1.first(where: { $0.hasPrefix("--") }) { - throw CLIError(message: "themes set: unknown flag '\(unknown)'. Known flags: --light , --dark ") + if let unknown = remaining.first(where: { $0.hasPrefix("--") }) { + throw CLIError(message: "themes set: unknown flag '\(unknown)'. Known flags: --light , --dark , --opacity <0-1>, --blur, --no-blur, --font , --font-size ") + } + if blurFlag && noBlurFlag { + throw CLIError(message: "themes set: cannot pass both --blur and --no-blur") } let availableThemes = availableThemeNames() - let current = currentThemeSelection() + // Base partial updates on the managed block only (not currentAppearance()'s full + // search-chain resolution) so setting one field never copies a value the user only + // ever set in their own raw Ghostty config into Programa's managed block. + let current = TerminalThemeStore.live().managedRawAppearance() let lightTheme: String? let darkTheme: String? + var didSpecifyField = false - if lightOpt == nil && darkOpt == nil { - let joinedTheme = rem1.joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines) - guard !joinedTheme.isEmpty else { - throw CLIError(message: "themes set requires a theme name or --light/--dark flags") - } + if lightOpt == nil && darkOpt == nil && !remaining.isEmpty { + let joinedTheme = remaining.joined(separator: " ").trimmingCharacters(in: .whitespacesAndNewlines) let resolved = try validatedThemeName(joinedTheme, availableThemes: availableThemes) lightTheme = resolved darkTheme = resolved + didSpecifyField = true } else { - if !rem1.isEmpty { - throw CLIError(message: "themes set: unexpected argument '\(rem1.joined(separator: " "))'") + if !remaining.isEmpty { + throw CLIError(message: "themes set: unexpected argument '\(remaining.joined(separator: " "))'") + } + lightTheme = try lightOpt.map { try validatedThemeName($0, availableThemes: availableThemes) } ?? current.themeLight + darkTheme = try darkOpt.map { try validatedThemeName($0, availableThemes: availableThemes) } ?? current.themeDark + didSpecifyField = lightOpt != nil || darkOpt != nil + } + + var overrides = TerminalAppearanceOverrides( + themeLight: lightTheme, + themeDark: darkTheme, + backgroundOpacity: current.backgroundOpacity, + backgroundBlur: current.backgroundBlur, + fontFamily: current.fontFamily, + fontSize: current.fontSize + ) + + if let opacityOpt { + guard let opacityValue = Double(opacityOpt), opacityValue >= 0, opacityValue <= 1 else { + throw CLIError(message: "themes set: --opacity must be a number between 0 and 1") + } + overrides.backgroundOpacity = opacityValue + didSpecifyField = true + } + if blurFlag { + overrides.backgroundBlur = true + didSpecifyField = true + } + if noBlurFlag { + overrides.backgroundBlur = false + didSpecifyField = true + } + if let fontOpt { + let trimmed = fontOpt.trimmingCharacters(in: .whitespacesAndNewlines) + overrides.fontFamily = trimmed.isEmpty ? nil : trimmed + didSpecifyField = true + } + if let fontSizeOpt { + guard let sizeValue = Double(fontSizeOpt), sizeValue > 0 else { + throw CLIError(message: "themes set: --font-size must be a positive number") } - lightTheme = try lightOpt.map { try validatedThemeName($0, availableThemes: availableThemes) } ?? current.light - darkTheme = try darkOpt.map { try validatedThemeName($0, availableThemes: availableThemes) } ?? current.dark + overrides.fontSize = sizeValue + didSpecifyField = true } - guard let rawThemeValue = TerminalThemeStore.encodedThemeValue(light: lightTheme, dark: darkTheme) else { - throw CLIError(message: "themes set requires at least one theme") + guard didSpecifyField else { + throw CLIError(message: "themes set requires a theme name or at least one flag: --light, --dark, --opacity, --blur/--no-blur, --font, --font-size") } - let configURL = try TerminalThemeStore.live().set(rawThemeValue: rawThemeValue).configURL + let configURL = try TerminalThemeStore.live().set(overrides).configURL let reloadStatus = reloadThemesIfPossible() if jsonOutput { @@ -305,7 +374,10 @@ extension ProgramaCLI { "ok": true, "light": lightTheme ?? NSNull(), "dark": darkTheme ?? NSNull(), - "raw_value": rawThemeValue, + "opacity": overrides.backgroundOpacity ?? NSNull(), + "blur": overrides.backgroundBlur ?? NSNull(), + "font_family": overrides.fontFamily ?? NSNull(), + "font_size": overrides.fontSize ?? NSNull(), "config_path": configURL.path, "reload_requested": reloadStatus.requested, "reload_target_bundle_id": reloadStatus.targetBundleIdentifier @@ -338,10 +410,6 @@ extension ProgramaCLI { print("OK cleared config=\(configURL.path) reload=requested") } - private func currentThemeSelection() -> TerminalThemeSelection { - TerminalThemeStore.live().currentSelection() - } - private func availableThemeNames() -> [String] { let fileManager = FileManager.default var seen: Set = [] @@ -526,6 +594,7 @@ extension ProgramaCLI { programa themes set programa themes set --light [--dark ] programa themes set --dark [--light ] + programa themes set --opacity <0-1> [--blur|--no-blur] [--font ] [--font-size ] programa themes clear When run in a TTY, `programa themes` opens an interactive theme picker with @@ -535,17 +604,23 @@ extension ProgramaCLI { lets you apply it to the light theme, dark theme, or both defaults. Commands: - list List available themes and mark the current light/dark defaults + list List available themes and mark the current light/dark defaults and appearance set Set the same theme for both light and dark appearance set --light Set the light appearance theme set --dark Set the dark appearance theme - clear Remove the programa theme override and fall back to other config + set --opacity <0-1> Set the terminal background opacity + set --blur Enable background blur + set --no-blur Disable background blur + set --font Set the terminal font family + set --font-size Set the terminal font size + clear Remove the programa managed overrides and fall back to other config Examples: programa themes programa themes list programa themes set "Catppuccin Mocha" programa themes set --light "Catppuccin Latte" --dark "Catppuccin Mocha" + programa themes set --opacity 0.85 --blur --font "JetBrains Mono" --font-size 13 programa themes clear """ default: diff --git a/GhosttyTabs.xcodeproj/project.pbxproj b/GhosttyTabs.xcodeproj/project.pbxproj index 7922603c..2515f10d 100644 --- a/GhosttyTabs.xcodeproj/project.pbxproj +++ b/GhosttyTabs.xcodeproj/project.pbxproj @@ -880,7 +880,7 @@ name = "Copy Ghostty Resources"; runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "set -euo pipefail\nDEST=\"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}\"\nGHOSTTY_DEST=\"${DEST}/ghostty\"\nTERMINFO_DEST=\"${DEST}/terminfo\"\nCMUX_SHELL_DEST=\"${DEST}/shell-integration\"\nBIN_DEST=\"${DEST}/bin\"\nSRC_SHARE=\"${SRCROOT}/ghostty/zig-out/share\"\nGHOSTTY_SRC=\"${SRC_SHARE}/ghostty\"\nTERMINFO_SRC=\"${SRC_SHARE}/terminfo\"\nFALLBACK_GHOSTTY=\"${SRCROOT}/Resources/ghostty\"\nFALLBACK_TERMINFO=\"${SRCROOT}/Resources/ghostty/terminfo\"\nTERMINFO_OVERLAY=\"${SRCROOT}/Resources/terminfo-overlay\"\nCMUX_SHELL_SRC=\"${SRCROOT}/Resources/shell-integration\"\nCMUX_GHOSTTY_ZSH_SRC=\"${SRCROOT}/ghostty/src/shell-integration/zsh/ghostty-integration\"\nBUILD_GHOSTTY_HELPER=\"${SRCROOT}/scripts/build-ghostty-cli-helper.sh\"\nGHOSTTY_HELPER_DEST=\"${BIN_DEST}/ghostty\"\nif [ -d \"$GHOSTTY_SRC\" ]; then\n mkdir -p \"$GHOSTTY_DEST\"\n rsync -a --delete \"$GHOSTTY_SRC/\" \"$GHOSTTY_DEST/\"\nelif [ -d \"$FALLBACK_GHOSTTY\" ]; then\n mkdir -p \"$GHOSTTY_DEST\"\n rsync -a --delete \"$FALLBACK_GHOSTTY/\" \"$GHOSTTY_DEST/\"\nfi\nif [ -d \"$TERMINFO_SRC\" ]; then\n mkdir -p \"$TERMINFO_DEST\"\n rsync -a --delete \"$TERMINFO_SRC/\" \"$TERMINFO_DEST/\"\nelif [ -d \"$FALLBACK_TERMINFO\" ]; then\n mkdir -p \"$TERMINFO_DEST\"\n rsync -a --delete \"$FALLBACK_TERMINFO/\" \"$TERMINFO_DEST/\"\nfi\n# Overlay any cmux-specific terminfo adjustments.\n# This intentionally does not use --delete so we only patch specific entries.\nif [ -d \"$TERMINFO_OVERLAY\" ]; then\n mkdir -p \"$TERMINFO_DEST\"\n rsync -a \"$TERMINFO_OVERLAY/\" \"$TERMINFO_DEST/\"\nfi\nif [ -d \"$CMUX_SHELL_SRC\" ]; then\n mkdir -p \"$CMUX_SHELL_DEST\"\n # Use '/.' so dotfiles like .zshenv/.zprofile are copied too.\n rsync -a \"$CMUX_SHELL_SRC/.\" \"$CMUX_SHELL_DEST/\"\nfi\nif [ -f \"$CMUX_GHOSTTY_ZSH_SRC\" ]; then\n mkdir -p \"$CMUX_SHELL_DEST\"\n rsync -a \"$CMUX_GHOSTTY_ZSH_SRC\" \"$CMUX_SHELL_DEST/ghostty-integration.zsh\"\nfi\nif [ ! -x \"$BUILD_GHOSTTY_HELPER\" ]; then\n echo \"error: missing Ghostty CLI helper build script at $BUILD_GHOSTTY_HELPER\" >&2\n exit 1\nfi\nARCHS_LIST=\" ${ARCHS:-} \"\nHAS_ARM64=0\nHAS_X86_64=0\nGHOSTTY_HELPER_TARGET=\"\"\ncase \"$ARCHS_LIST\" in\n *\" arm64 \"*) HAS_ARM64=1 ;;\nesac\ncase \"$ARCHS_LIST\" in\n *\" x86_64 \"*) HAS_X86_64=1 ;;\nesac\nif [ \"$HAS_ARM64\" -eq 1 ] && [ \"$HAS_X86_64\" -eq 1 ]; then\n \"$BUILD_GHOSTTY_HELPER\" --universal --output \"$GHOSTTY_HELPER_DEST\"\nelif [ \"$HAS_ARM64\" -eq 1 ]; then\n GHOSTTY_HELPER_TARGET=\"aarch64-macos\"\nelif [ \"$HAS_X86_64\" -eq 1 ]; then\n GHOSTTY_HELPER_TARGET=\"x86_64-macos\"\nfi\nif [ -n \"$GHOSTTY_HELPER_TARGET\" ]; then\n \"$BUILD_GHOSTTY_HELPER\" --target \"$GHOSTTY_HELPER_TARGET\" --output \"$GHOSTTY_HELPER_DEST\"\nelif [ \"$HAS_ARM64\" -eq 0 ] || [ \"$HAS_X86_64\" -eq 0 ]; then\n \"$BUILD_GHOSTTY_HELPER\" --output \"$GHOSTTY_HELPER_DEST\"\nfi\nif [ ! -x \"$GHOSTTY_HELPER_DEST\" ]; then\n echo \"error: Ghostty CLI helper was not created at $GHOSTTY_HELPER_DEST\" >&2\n exit 1\nfi\nINFO_PLIST=\"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}\"\nCOMMIT=\"$(git -C \"${SRCROOT}\" rev-parse --short=9 HEAD 2>/dev/null || true)\"\nif [ -n \"$COMMIT\" ] && [ -f \"$INFO_PLIST\" ]; then\n /usr/libexec/PlistBuddy -c \"Set :ProgramaCommit $COMMIT\" \"$INFO_PLIST\" >/dev/null 2>&1 || /usr/libexec/PlistBuddy -c \"Add :ProgramaCommit string $COMMIT\" \"$INFO_PLIST\" >/dev/null 2>&1 || true\nfi\n"; + shellScript = "set -euo pipefail\nDEST=\"${TARGET_BUILD_DIR}/${UNLOCALIZED_RESOURCES_FOLDER_PATH}\"\nGHOSTTY_DEST=\"${DEST}/ghostty\"\nTERMINFO_DEST=\"${DEST}/terminfo\"\nCMUX_SHELL_DEST=\"${DEST}/shell-integration\"\nBIN_DEST=\"${DEST}/bin\"\nSRC_SHARE=\"${SRCROOT}/ghostty/zig-out/share\"\nGHOSTTY_SRC=\"${SRC_SHARE}/ghostty\"\nTERMINFO_SRC=\"${SRC_SHARE}/terminfo\"\nFALLBACK_GHOSTTY=\"${SRCROOT}/Resources/ghostty\"\nFALLBACK_TERMINFO=\"${SRCROOT}/Resources/ghostty/terminfo\"\nTERMINFO_OVERLAY=\"${SRCROOT}/Resources/terminfo-overlay\"\nTHEMES_OVERLAY=\"${SRCROOT}/Resources/ghostty/themes\"\nCMUX_SHELL_SRC=\"${SRCROOT}/Resources/shell-integration\"\nCMUX_GHOSTTY_ZSH_SRC=\"${SRCROOT}/ghostty/src/shell-integration/zsh/ghostty-integration\"\nBUILD_GHOSTTY_HELPER=\"${SRCROOT}/scripts/build-ghostty-cli-helper.sh\"\nGHOSTTY_HELPER_DEST=\"${BIN_DEST}/ghostty\"\nif [ -d \"$GHOSTTY_SRC\" ]; then\n mkdir -p \"$GHOSTTY_DEST\"\n rsync -a --delete \"$GHOSTTY_SRC/\" \"$GHOSTTY_DEST/\"\nelif [ -d \"$FALLBACK_GHOSTTY\" ]; then\n mkdir -p \"$GHOSTTY_DEST\"\n rsync -a --delete \"$FALLBACK_GHOSTTY/\" \"$GHOSTTY_DEST/\"\nfi\nif [ -d \"$TERMINFO_SRC\" ]; then\n mkdir -p \"$TERMINFO_DEST\"\n rsync -a --delete \"$TERMINFO_SRC/\" \"$TERMINFO_DEST/\"\nelif [ -d \"$FALLBACK_TERMINFO\" ]; then\n mkdir -p \"$TERMINFO_DEST\"\n rsync -a --delete \"$FALLBACK_TERMINFO/\" \"$TERMINFO_DEST/\"\nfi\n# Overlay any cmux-specific terminfo adjustments.\n# This intentionally does not use --delete so we only patch specific entries.\nif [ -d \"$TERMINFO_OVERLAY\" ]; then\n mkdir -p \"$TERMINFO_DEST\"\n rsync -a \"$TERMINFO_OVERLAY/\" \"$TERMINFO_DEST/\"\nfi\n# Overlay Programa-specific themes on top of whatever ghostty themes were installed above.\n# This intentionally does not use --delete so we only add/patch specific theme files.\nif [ -d \"$THEMES_OVERLAY\" ]; then\n mkdir -p \"$GHOSTTY_DEST/themes\"\n rsync -a \"$THEMES_OVERLAY/\" \"$GHOSTTY_DEST/themes/\"\nfi\nif [ -d \"$CMUX_SHELL_SRC\" ]; then\n mkdir -p \"$CMUX_SHELL_DEST\"\n # Use '/.' so dotfiles like .zshenv/.zprofile are copied too.\n rsync -a \"$CMUX_SHELL_SRC/.\" \"$CMUX_SHELL_DEST/\"\nfi\nif [ -f \"$CMUX_GHOSTTY_ZSH_SRC\" ]; then\n mkdir -p \"$CMUX_SHELL_DEST\"\n rsync -a \"$CMUX_GHOSTTY_ZSH_SRC\" \"$CMUX_SHELL_DEST/ghostty-integration.zsh\"\nfi\nif [ ! -x \"$BUILD_GHOSTTY_HELPER\" ]; then\n echo \"error: missing Ghostty CLI helper build script at $BUILD_GHOSTTY_HELPER\" >&2\n exit 1\nfi\nARCHS_LIST=\" ${ARCHS:-} \"\nHAS_ARM64=0\nHAS_X86_64=0\nGHOSTTY_HELPER_TARGET=\"\"\ncase \"$ARCHS_LIST\" in\n *\" arm64 \"*) HAS_ARM64=1 ;;\nesac\ncase \"$ARCHS_LIST\" in\n *\" x86_64 \"*) HAS_X86_64=1 ;;\nesac\nif [ \"$HAS_ARM64\" -eq 1 ] && [ \"$HAS_X86_64\" -eq 1 ]; then\n \"$BUILD_GHOSTTY_HELPER\" --universal --output \"$GHOSTTY_HELPER_DEST\"\nelif [ \"$HAS_ARM64\" -eq 1 ]; then\n GHOSTTY_HELPER_TARGET=\"aarch64-macos\"\nelif [ \"$HAS_X86_64\" -eq 1 ]; then\n GHOSTTY_HELPER_TARGET=\"x86_64-macos\"\nfi\nif [ -n \"$GHOSTTY_HELPER_TARGET\" ]; then\n \"$BUILD_GHOSTTY_HELPER\" --target \"$GHOSTTY_HELPER_TARGET\" --output \"$GHOSTTY_HELPER_DEST\"\nelif [ \"$HAS_ARM64\" -eq 0 ] || [ \"$HAS_X86_64\" -eq 0 ]; then\n \"$BUILD_GHOSTTY_HELPER\" --output \"$GHOSTTY_HELPER_DEST\"\nfi\nif [ ! -x \"$GHOSTTY_HELPER_DEST\" ]; then\n echo \"error: Ghostty CLI helper was not created at $GHOSTTY_HELPER_DEST\" >&2\n exit 1\nfi\nINFO_PLIST=\"${TARGET_BUILD_DIR}/${INFOPLIST_PATH}\"\nCOMMIT=\"$(git -C \"${SRCROOT}\" rev-parse --short=9 HEAD 2>/dev/null || true)\"\nif [ -n \"$COMMIT\" ] && [ -f \"$INFO_PLIST\" ]; then\n /usr/libexec/PlistBuddy -c \"Set :ProgramaCommit $COMMIT\" \"$INFO_PLIST\" >/dev/null 2>&1 || /usr/libexec/PlistBuddy -c \"Add :ProgramaCommit string $COMMIT\" \"$INFO_PLIST\" >/dev/null 2>&1 || true\nfi\n"; }; /* End PBXShellScriptBuildPhase section */ diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 16409953..921e6ff9 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -13643,7 +13643,126 @@ } } } + }, + "settings.terminalOpacity.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Opacity" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "不透明度" + } + } + } + }, + "settings.terminalOpacity.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Terminal background transparency." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ターミナルの背景の透明度です。" + } + } + } + }, + "settings.terminalBlur.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Background Blur" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "背景のぼかし" + } + } + } + }, + "settings.terminalBlur.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Blur the desktop behind a transparent terminal background." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "透明なターミナル背景の奥にあるデスクトップをぼかします。" + } + } + } + }, + "settings.terminalFont.title": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Font" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "フォント" + } + } + } + }, + "settings.terminalFont.subtitle": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Font family and size used in the terminal." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ターミナルで使用するフォントとサイズです。" + } + } + } + }, + "settings.terminalFont.familyPlaceholder": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "System Default" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "システムのデフォルト" + } + } + } } }, "version": "1.0" -} \ No newline at end of file +} diff --git a/Resources/ghostty/themes/Min Dark b/Resources/ghostty/themes/Min Dark new file mode 100644 index 00000000..eda0e330 --- /dev/null +++ b/Resources/ghostty/themes/Min Dark @@ -0,0 +1,22 @@ +palette = 0=#1f1f1f +palette = 1=#d6656a +palette = 2=#a3d900 +palette = 3=#e7c547 +palette = 4=#8a97ff +palette = 5=#a37acc +palette = 6=#57d9ad +palette = 7=#c7c7c7 +palette = 8=#5c5c5c +palette = 9=#e58a8f +palette = 10=#bde34d +palette = 11=#f2d878 +palette = 12=#aab3ff +palette = 13=#c2a3e0 +palette = 14=#82e6c2 +palette = 15=#f0f0f0 +background = #1f1f1f +foreground = #e0e0e0 +cursor-color = #e0e0e0 +cursor-text = #1f1f1f +selection-background = #333333 +selection-foreground = #e0e0e0 diff --git a/Resources/ghostty/themes/Min Light b/Resources/ghostty/themes/Min Light new file mode 100644 index 00000000..c4d148be --- /dev/null +++ b/Resources/ghostty/themes/Min Light @@ -0,0 +1,22 @@ +palette = 0=#333333 +palette = 1=#D32F2F +palette = 2=#77cc00 +palette = 3=#f29718 +palette = 4=#e0e0e0 +palette = 5=#9966cc +palette = 6=#4dbf99 +palette = 7=#c7c7c7 +palette = 8=#a1a1a1 +palette = 9=#d6656a +palette = 10=#a3d900 +palette = 11=#e7c547 +palette = 12=#6871ff +palette = 13=#a37acc +palette = 14=#57d9ad +palette = 15=#7e7e7e +background = #ffffff +foreground = #212121 +cursor-color = #212121 +cursor-text = #ffffff +selection-background = #e0e0e0 +selection-foreground = #212121 diff --git a/Resources/settings.schema.json b/Resources/settings.schema.json index 3f6403b7..8aa87caa 100644 --- a/Resources/settings.schema.json +++ b/Resources/settings.schema.json @@ -59,6 +59,46 @@ "default": null, "description": "Terminal themes from Settings > Appearance. Use null, or null for both variants, to remove Programa's managed theme override and inherit Ghostty configuration." }, + "terminalOpacity": { + "oneOf": [ + { "type": "number", "minimum": 0, "maximum": 1 }, + { "type": "null" } + ], + "default": null, + "description": "Terminal background opacity from Settings > Appearance. Use null to remove Programa's managed override and inherit Ghostty configuration." + }, + "terminalBlur": { + "oneOf": [ + { "type": "boolean" }, + { "type": "null" } + ], + "default": null, + "description": "Terminal background blur from Settings > Appearance. Use null to remove Programa's managed override and inherit Ghostty configuration." + }, + "terminalFont": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": ["family", "size"], + "properties": { + "family": { + "type": "string", + "minLength": 1, + "description": "Terminal font family." + }, + "size": { + "type": "number", + "exclusiveMinimum": 0, + "description": "Terminal font size." + } + } + }, + { "type": "null" } + ], + "default": null, + "description": "Terminal font from Settings > Appearance. Use null to remove Programa's managed override and inherit Ghostty configuration." + }, "newWorkspacePlacement": { "type": "string", "enum": ["top", "afterCurrent", "end"], diff --git a/Sources/GhosttyConfig.swift b/Sources/GhosttyConfig.swift index 27655005..4af20c65 100644 --- a/Sources/GhosttyConfig.swift +++ b/Sources/GhosttyConfig.swift @@ -23,6 +23,11 @@ struct GhosttyConfig { // Colors (from theme or config) var backgroundColor: NSColor = NSColor(hex: "#272822")! var backgroundOpacity: Double = 1.0 + // Read-only display state: mirrors backgroundOpacity's presence here, but nothing in + // Programa's Swift code currently reads it — the actual blur application already happens + // in GhosttyApp.swift by reading `background-blur` straight off the loaded ghostty_config_t. + // Reserved for future chrome/Settings display parity with backgroundOpacity. + var backgroundBlur: Bool = false var foregroundColor: NSColor = NSColor(hex: "#fdfff1")! var cursorColor: NSColor = NSColor(hex: "#c0c1b5")! var cursorTextColor: NSColor = NSColor(hex: "#8d8e82")! @@ -292,6 +297,10 @@ struct GhosttyConfig { if let opacity = Double(value) { backgroundOpacity = opacity } + case "background-blur": + if let blur = TerminalThemeStore.parseBoolDirective(value) { + backgroundBlur = blur + } case "foreground": if let color = NSColor(hex: value) { foregroundColor = color diff --git a/Sources/ProgramaSettingsFileStore.swift b/Sources/ProgramaSettingsFileStore.swift index 3c68e6de..43158a2b 100644 --- a/Sources/ProgramaSettingsFileStore.swift +++ b/Sources/ProgramaSettingsFileStore.swift @@ -28,6 +28,9 @@ final class ProgramaSettingsFileStore { fileprivate static let trustedDirectoriesBackupIdentifier = "customCommands.trustedDirectories" fileprivate static let socketPasswordBackupIdentifier = "automation.socketPassword" fileprivate static let terminalThemeBackupIdentifier = "app.terminalTheme" + fileprivate static let terminalOpacityBackupIdentifier = "app.terminalOpacity" + fileprivate static let terminalBlurBackupIdentifier = "app.terminalBlur" + fileprivate static let terminalFontBackupIdentifier = "app.terminalFont" static var defaultPrimaryPath: String { let home = FileManager.default.homeDirectoryForCurrentUser.path @@ -173,6 +176,18 @@ final class ProgramaSettingsFileStore { synchronized { activeManagedCustomSettings.terminalTheme != nil } } + func isTerminalOpacityManagedByFile() -> Bool { + synchronized { activeManagedCustomSettings.terminalOpacity != nil } + } + + func isTerminalBlurManagedByFile() -> Bool { + synchronized { activeManagedCustomSettings.terminalBlur != nil } + } + + func isTerminalFontManagedByFile() -> Bool { + synchronized { activeManagedCustomSettings.terminalFont != nil } + } + func settingsFileURLForEditing() -> URL { if let activeSourcePath = synchronized({ activeSourcePath }) { return URL(fileURLWithPath: activeSourcePath) @@ -391,6 +406,39 @@ final class ProgramaSettingsFileStore { logInvalid("app.terminalTheme", sourcePath: sourcePath) } } + if let rawTerminalOpacity = section["terminalOpacity"] { + if rawTerminalOpacity is NSNull { + snapshot.managedCustomSettings.terminalOpacity = ManagedTerminalOpacity(value: nil) + } else if let opacity = jsonDouble(rawTerminalOpacity), opacity >= 0, opacity <= 1 { + snapshot.managedCustomSettings.terminalOpacity = ManagedTerminalOpacity(value: opacity) + } else { + logInvalid("app.terminalOpacity", sourcePath: sourcePath) + } + } + if let rawTerminalBlur = section["terminalBlur"] { + if rawTerminalBlur is NSNull { + snapshot.managedCustomSettings.terminalBlur = ManagedTerminalBlur(value: nil) + } else if let blur = jsonBool(rawTerminalBlur) { + snapshot.managedCustomSettings.terminalBlur = ManagedTerminalBlur(value: blur) + } else { + logInvalid("app.terminalBlur", sourcePath: sourcePath) + } + } + if let rawTerminalFont = section["terminalFont"] { + if rawTerminalFont is NSNull { + snapshot.managedCustomSettings.terminalFont = ManagedTerminalFont(family: nil, size: nil) + } else if let terminalFont = rawTerminalFont as? [String: Any] { + let family = jsonString(terminalFont["family"])?.trimmingCharacters(in: .whitespacesAndNewlines) + let size = jsonDouble(terminalFont["size"]) + if let family, !family.isEmpty, let size, size > 0 { + snapshot.managedCustomSettings.terminalFont = ManagedTerminalFont(family: family, size: size) + } else { + logInvalid("app.terminalFont", sourcePath: sourcePath) + } + } else { + logInvalid("app.terminalFont", sourcePath: sourcePath) + } + } } private func parseTerminalThemeName( @@ -961,6 +1009,18 @@ final class ProgramaSettingsFileStore { backups[Self.terminalThemeBackupIdentifier] == nil { backups[Self.terminalThemeBackupIdentifier] = currentTerminalThemeBackupValue() } + if snapshot.managedCustomSettings.terminalOpacity != nil, + backups[Self.terminalOpacityBackupIdentifier] == nil { + backups[Self.terminalOpacityBackupIdentifier] = currentTerminalOpacityBackupValue() + } + if snapshot.managedCustomSettings.terminalBlur != nil, + backups[Self.terminalBlurBackupIdentifier] == nil { + backups[Self.terminalBlurBackupIdentifier] = currentTerminalBlurBackupValue() + } + if snapshot.managedCustomSettings.terminalFont != nil, + backups[Self.terminalFontBackupIdentifier] == nil { + backups[Self.terminalFontBackupIdentifier] = currentTerminalFontBackupValue() + } } for identifier in currentManagedIdentifiers.subtracting(nextManagedIdentifiers) { @@ -1003,6 +1063,15 @@ final class ProgramaSettingsFileStore { if let terminalTheme = settings.terminalTheme { applyTerminalTheme(light: terminalTheme.light, dark: terminalTheme.dark) } + if let terminalOpacity = settings.terminalOpacity { + applyTerminalOpacity(terminalOpacity.value) + } + if let terminalBlur = settings.terminalBlur { + applyTerminalBlur(terminalBlur.value) + } + if let terminalFont = settings.terminalFont { + applyTerminalFont(family: terminalFont.family, size: terminalFont.size) + } } private func restoreBackup(_ backup: BackupValue, for identifier: String) { @@ -1042,6 +1111,72 @@ final class ProgramaSettingsFileStore { String(describing: error) ) } + case Self.terminalOpacityBackupIdentifier: + do { + let mutation: TerminalThemeMutation + switch backup { + case .double(let value): + mutation = try terminalThemeStore.set(rawAppearanceValue: String(value), forKey: "background-opacity") + case .absent: + mutation = try terminalThemeStore.set(rawAppearanceValue: "", forKey: "background-opacity") + default: + return + } + if mutation.didChange { + terminalThemeReloadHandler() + } + } catch { + NSLog( + "[ProgramaSettingsFileStore] failed to restore terminal opacity: %@", + String(describing: error) + ) + } + case Self.terminalBlurBackupIdentifier: + do { + let mutation: TerminalThemeMutation + switch backup { + case .bool(let value): + mutation = try terminalThemeStore.set(rawAppearanceValue: value ? "true" : "false", forKey: "background-blur") + case .absent: + mutation = try terminalThemeStore.set(rawAppearanceValue: "", forKey: "background-blur") + default: + return + } + if mutation.didChange { + terminalThemeReloadHandler() + } + } catch { + NSLog( + "[ProgramaSettingsFileStore] failed to restore terminal blur: %@", + String(describing: error) + ) + } + case Self.terminalFontBackupIdentifier: + do { + let mutation: TerminalThemeMutation + switch backup { + case .stringDictionary(let values): + mutation = try terminalThemeStore.set(rawAppearanceValues: [ + "font-family": values["family"], + "font-size": values["size"], + ]) + case .absent: + mutation = try terminalThemeStore.set(rawAppearanceValues: [ + "font-family": nil, + "font-size": nil, + ]) + default: + return + } + if mutation.didChange { + terminalThemeReloadHandler() + } + } catch { + NSLog( + "[ProgramaSettingsFileStore] failed to restore terminal font: %@", + String(describing: error) + ) + } default: restoreUserDefaultsBackup(backup, for: identifier) } @@ -1093,9 +1228,34 @@ final class ProgramaSettingsFileStore { return .string(rawValue) } + private func currentTerminalOpacityBackupValue() -> BackupValue { + guard let opacity = terminalThemeStore.managedRawAppearance().backgroundOpacity else { + return .absent + } + return .double(opacity) + } + + private func currentTerminalBlurBackupValue() -> BackupValue { + guard let blur = terminalThemeStore.managedRawAppearance().backgroundBlur else { + return .absent + } + return .bool(blur) + } + + private func currentTerminalFontBackupValue() -> BackupValue { + let appearance = terminalThemeStore.managedRawAppearance() + guard let family = appearance.fontFamily, let size = appearance.fontSize else { + return .absent + } + return .stringDictionary(["family": family, "size": String(size)]) + } + private func applyTerminalTheme(light: String?, dark: String?) { do { - let mutation = try terminalThemeStore.set(light: light, dark: dark) + // Surgical single-key write: preserves any independently-managed opacity/blur/font + // directives already present in the block. + let rawValue = TerminalThemeStore.encodedThemeValue(light: light, dark: dark) ?? "" + let mutation = try terminalThemeStore.set(rawAppearanceValue: rawValue, forKey: "theme") if mutation.didChange { terminalThemeReloadHandler() } @@ -1107,6 +1267,58 @@ final class ProgramaSettingsFileStore { } } + private func applyTerminalOpacity(_ value: Double?) { + do { + let rawValue = value.map { String($0) } ?? "" + let mutation = try terminalThemeStore.set(rawAppearanceValue: rawValue, forKey: "background-opacity") + if mutation.didChange { + terminalThemeReloadHandler() + } + } catch { + NSLog( + "[ProgramaSettingsFileStore] failed to apply terminal opacity: %@", + String(describing: error) + ) + } + } + + private func applyTerminalBlur(_ value: Bool?) { + do { + let rawValue: String + switch value { + case .some(true): rawValue = "true" + case .some(false): rawValue = "false" + case .none: rawValue = "" + } + let mutation = try terminalThemeStore.set(rawAppearanceValue: rawValue, forKey: "background-blur") + if mutation.didChange { + terminalThemeReloadHandler() + } + } catch { + NSLog( + "[ProgramaSettingsFileStore] failed to apply terminal blur: %@", + String(describing: error) + ) + } + } + + private func applyTerminalFont(family: String?, size: Double?) { + do { + let mutation = try terminalThemeStore.set(rawAppearanceValues: [ + "font-family": family, + "font-size": size.map { String($0) }, + ]) + if mutation.didChange { + terminalThemeReloadHandler() + } + } catch { + NSLog( + "[ProgramaSettingsFileStore] failed to apply terminal font: %@", + String(describing: error) + ) + } + } + private func applyManagedUserDefaultsValue(_ value: ManagedSettingsValue, for defaultsKey: String) { let defaults = UserDefaults.standard if defaultsKey == WorkspaceTabColorSettings.paletteKey, @@ -1311,6 +1523,9 @@ final class ProgramaSettingsFileStore { "light": NSNull(), "dark": NSNull(), ], + "terminalOpacity": NSNull(), + "terminalBlur": NSNull(), + "terminalFont": NSNull(), "newWorkspacePlacement": WorkspacePlacementSettings.defaultPlacement.rawValue, "minimalMode": WorkspacePresentationModeSettings.defaultMode == .minimal, "preferredEditor": "", @@ -1443,13 +1658,30 @@ private struct ManagedTerminalTheme: Equatable { let dark: String? } +private struct ManagedTerminalOpacity: Equatable { + let value: Double? +} + +private struct ManagedTerminalBlur: Equatable { + let value: Bool? +} + +private struct ManagedTerminalFont: Equatable { + let family: String? + let size: Double? +} + private struct ManagedCustomSettings: Equatable { var trustedDirectories: [String]? var socketPassword: ManagedStringOverride? var terminalTheme: ManagedTerminalTheme? + var terminalOpacity: ManagedTerminalOpacity? + var terminalBlur: ManagedTerminalBlur? + var terminalFont: ManagedTerminalFont? var isEmpty: Bool { trustedDirectories == nil && socketPassword == nil && terminalTheme == nil + && terminalOpacity == nil && terminalBlur == nil && terminalFont == nil } var managedIdentifiers: Set { @@ -1463,6 +1695,15 @@ private struct ManagedCustomSettings: Equatable { if terminalTheme != nil { identifiers.insert(ProgramaSettingsFileStore.terminalThemeBackupIdentifier) } + if terminalOpacity != nil { + identifiers.insert(ProgramaSettingsFileStore.terminalOpacityBackupIdentifier) + } + if terminalBlur != nil { + identifiers.insert(ProgramaSettingsFileStore.terminalBlurBackupIdentifier) + } + if terminalFont != nil { + identifiers.insert(ProgramaSettingsFileStore.terminalFontBackupIdentifier) + } return identifiers } } diff --git a/Sources/SettingsView.swift b/Sources/SettingsView.swift index cc11c339..117f09a9 100644 --- a/Sources/SettingsView.swift +++ b/Sources/SettingsView.swift @@ -982,6 +982,90 @@ struct SettingsView: View { } .disabled(terminalThemeSettings.isManagedBySettingsFile) + SettingsCardDivider() + + SettingsCardRow( + String(localized: "settings.terminalOpacity.title", defaultValue: "Opacity"), + subtitle: terminalThemeSettings.isOpacityManagedBySettingsFile + ? String(localized: "settings.terminalTheme.managedByFile", defaultValue: "Managed in settings.json") + : String( + localized: "settings.terminalOpacity.subtitle", + defaultValue: "Terminal background transparency." + ), + controlWidth: pickerColumnWidth + ) { + Slider( + value: Binding( + get: { terminalThemeSettings.opacity }, + set: { terminalThemeSettings.setOpacity($0) } + ), + in: 0...1 + ) + } + .disabled(terminalThemeSettings.isOpacityManagedBySettingsFile) + + SettingsCardDivider() + + SettingsCardRow( + String(localized: "settings.terminalBlur.title", defaultValue: "Background Blur"), + subtitle: terminalThemeSettings.isBlurManagedBySettingsFile + ? String(localized: "settings.terminalTheme.managedByFile", defaultValue: "Managed in settings.json") + : String( + localized: "settings.terminalBlur.subtitle", + defaultValue: "Blur the desktop behind a transparent terminal background." + ) + ) { + Toggle( + "", + isOn: Binding( + get: { terminalThemeSettings.blurEnabled }, + set: { terminalThemeSettings.setBlurEnabled($0) } + ) + ) + .labelsHidden() + .controlSize(.small) + } + .disabled(terminalThemeSettings.isBlurManagedBySettingsFile) + + SettingsCardDivider() + + SettingsCardRow( + String(localized: "settings.terminalFont.title", defaultValue: "Font"), + subtitle: terminalThemeSettings.isFontManagedBySettingsFile + ? String(localized: "settings.terminalTheme.managedByFile", defaultValue: "Managed in settings.json") + : String( + localized: "settings.terminalFont.subtitle", + defaultValue: "Font family and size used in the terminal." + ) + ) { + HStack(spacing: 6) { + TextField( + String(localized: "settings.terminalFont.familyPlaceholder", defaultValue: "System Default"), + text: Binding( + get: { terminalThemeSettings.fontFamily }, + set: { terminalThemeSettings.setFontFamily($0) } + ) + ) + .textFieldStyle(.roundedBorder) + .frame(width: 140) + TextField( + "", + value: Binding( + get: { terminalThemeSettings.fontSize }, + set: { terminalThemeSettings.setFontSize($0) } + ), + format: .number + ) + .textFieldStyle(.roundedBorder) + .multilineTextAlignment(.trailing) + .frame(width: 44) + .accessibilityLabel( + String(localized: "settings.terminalFont.title", defaultValue: "Font") + ) + } + } + .disabled(terminalThemeSettings.isFontManagedBySettingsFile) + if let errorMessage = terminalThemeSettings.errorMessage { SettingsCardDivider() SettingsCardNote(errorMessage) @@ -1853,7 +1937,14 @@ private final class TerminalThemeSettingsModel: ObservableObject { @Published private(set) var themeNames: [String] = [] @Published private(set) var lightTheme = "" @Published private(set) var darkTheme = "" + @Published private(set) var opacity: Double = 1.0 + @Published private(set) var blurEnabled = false + @Published private(set) var fontFamily = "" + @Published private(set) var fontSize: Double = 0 @Published private(set) var isManagedBySettingsFile = false + @Published private(set) var isOpacityManagedBySettingsFile = false + @Published private(set) var isBlurManagedBySettingsFile = false + @Published private(set) var isFontManagedBySettingsFile = false @Published private(set) var errorMessage: String? private let store: TerminalThemeStore @@ -1889,12 +1980,9 @@ private final class TerminalThemeSettingsModel: ObservableObject { refresh() return } - if themeName.isEmpty { - clearManagedOverride() - return + applyAppearance { overrides in + overrides.themeLight = themeName.isEmpty ? nil : themeName } - let current = store.currentSelection() - apply(light: themeName, dark: current.dark) } func selectDarkTheme(_ themeName: String) { @@ -1902,61 +1990,136 @@ private final class TerminalThemeSettingsModel: ObservableObject { refresh() return } - if themeName.isEmpty { - clearManagedOverride() + applyAppearance { overrides in + overrides.themeDark = themeName.isEmpty ? nil : themeName + } + } + + func setOpacity(_ value: Double) { + guard !isOpacityManagedBySettingsFile else { + refresh() + return + } + applyAppearance { overrides in + overrides.backgroundOpacity = value + } + } + + func setBlurEnabled(_ value: Bool) { + guard !isBlurManagedBySettingsFile else { + refresh() + return + } + applyAppearance { overrides in + overrides.backgroundBlur = value + } + } + + func setFontFamily(_ value: String) { + guard !isFontManagedBySettingsFile else { + refresh() + return + } + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + applyAppearance { overrides in + overrides.fontFamily = trimmed.isEmpty ? nil : trimmed + } + } + + func setFontSize(_ value: Double) { + guard !isFontManagedBySettingsFile else { + refresh() return } - let current = store.currentSelection() - apply(light: current.light, dark: themeName) + applyAppearance { overrides in + overrides.fontSize = value > 0 ? value : nil + } } func clearManagedOverride() { - guard !isManagedBySettingsFile else { return } - apply(light: nil, dark: nil) + guard !isManagedBySettingsFile, + !isOpacityManagedBySettingsFile, + !isBlurManagedBySettingsFile, + !isFontManagedBySettingsFile else { + return + } + do { + let mutation = try store.clear() + errorMessage = nil + refresh() + if mutation.didChange { + requestReload() + } + } catch { + reportError(error) + } } - private func apply(light: String?, dark: String?) { + private func applyAppearance(_ mutate: (inout TerminalAppearanceOverrides) -> Void) { + // Base partial updates on the managed block only, not the full search-chain resolution + // `currentAppearance()` returns — otherwise a value the user only ever set in their own + // raw Ghostty config would get silently copied into Programa's managed block the moment + // any sibling field changes. + var overrides = store.managedRawAppearance() + mutate(&overrides) do { - let mutation = try store.set(light: light, dark: dark) + let mutation = try store.set(overrides) errorMessage = nil refresh() if mutation.didChange { - TerminalThemeStore.requestReload( - targetBundleIdentifier: Bundle.main.bundleIdentifier - ?? TerminalThemeStore.overrideBundleIdentifier - ) + requestReload() } } catch { - let prefix = String( - localized: "settings.terminalTheme.changeFailed", - defaultValue: "Couldn’t change the terminal theme." - ) - errorMessage = "\(prefix) \(error.localizedDescription)" - refreshSelectionAndOwnership() + reportError(error) } } + private func requestReload() { + TerminalThemeStore.requestReload( + targetBundleIdentifier: Bundle.main.bundleIdentifier + ?? TerminalThemeStore.overrideBundleIdentifier + ) + } + + private func reportError(_ error: Error) { + let prefix = String( + localized: "settings.terminalTheme.changeFailed", + defaultValue: "Couldn’t change the terminal theme." + ) + errorMessage = "\(prefix) \(error.localizedDescription)" + refreshSelectionAndOwnership() + } + private func refresh() { - let current = store.currentSelection() + let current = store.currentAppearance() var names = GhosttyConfig.availableThemeNames() - for selectedName in [current.light, current.dark].compactMap({ $0 }) { + for selectedName in [current.themeLight, current.themeDark].compactMap({ $0 }) { if !names.contains(where: { $0.caseInsensitiveCompare(selectedName) == .orderedSame }) { names.append(selectedName) } } themeNames = names.sorted { $0.localizedStandardCompare($1) == .orderedAscending } + opacity = current.backgroundOpacity ?? 1.0 + blurEnabled = current.backgroundBlur ?? false + fontFamily = current.fontFamily ?? "" + fontSize = current.fontSize ?? 0 refreshSelectionAndOwnership(current: current) } private func refreshSelectionAndOwnership( - current: TerminalThemeSelection? = nil + current: TerminalAppearanceOverrides? = nil ) { - let current = current ?? store.currentSelection() - let hasManagedOverride = store.managedRawThemeValue() != nil - lightTheme = hasManagedOverride ? current.light ?? "" : "" - darkTheme = hasManagedOverride ? current.dark ?? "" : "" - isManagedBySettingsFile = KeyboardShortcutSettings.settingsFileStore - .isTerminalThemeManagedByFile() + let current = current ?? store.currentAppearance() + let managedOverride = store.managedRawAppearance() + let hasManagedThemeOverride = managedOverride.themeLight != nil || managedOverride.themeDark != nil + lightTheme = hasManagedThemeOverride ? current.themeLight ?? "" : "" + darkTheme = hasManagedThemeOverride ? current.themeDark ?? "" : "" + + let fileStore = KeyboardShortcutSettings.settingsFileStore + isManagedBySettingsFile = fileStore.isTerminalThemeManagedByFile() + isOpacityManagedBySettingsFile = fileStore.isTerminalOpacityManagedByFile() + isBlurManagedBySettingsFile = fileStore.isTerminalBlurManagedByFile() + isFontManagedBySettingsFile = fileStore.isTerminalFontManagedByFile() } } diff --git a/Sources/TerminalThemeStore.swift b/Sources/TerminalThemeStore.swift index 98c53bc5..6e8813fa 100644 --- a/Sources/TerminalThemeStore.swift +++ b/Sources/TerminalThemeStore.swift @@ -13,17 +13,58 @@ struct TerminalThemeMutation: Equatable { let didChange: Bool } -/// Owns Programa's managed `theme` block so the app and CLI cannot drift. +/// Overrides tracked in Programa's managed config block. Each field maps to one Ghostty +/// config directive (`theme`, `background-opacity`, `background-blur`, `font-family`, +/// `font-size`) and is independently nil-able so callers can read/write a subset of keys +/// without disturbing the others. +struct TerminalAppearanceOverrides: Equatable { + var themeLight: String? + var themeDark: String? + var backgroundOpacity: Double? + /// `nil` = inherit (no directive written); `.some(true)`/`.some(false)` = explicit on/off, + /// both written verbatim. `false` is not the same as `nil` — see `set(_:)`. + var backgroundBlur: Bool? + var fontFamily: String? + var fontSize: Double? + + init( + themeLight: String? = nil, + themeDark: String? = nil, + backgroundOpacity: Double? = nil, + backgroundBlur: Bool? = nil, + fontFamily: String? = nil, + fontSize: Double? = nil + ) { + self.themeLight = themeLight + self.themeDark = themeDark + self.backgroundOpacity = backgroundOpacity + self.backgroundBlur = backgroundBlur + self.fontFamily = fontFamily + self.fontSize = fontSize + } +} + +/// Owns Programa's managed config block so the app and CLI cannot drift. /// /// User Ghostty configuration remains untouched. Programa only replaces the block delimited by /// `managedBlockStart`/`managedBlockEnd` in its Application Support config and preserves every -/// unrelated directive in that file. +/// unrelated directive in that file. The block can hold up to five directives (theme, +/// background-opacity, background-blur, font-family, font-size), each independently settable. struct TerminalThemeStore { static let overrideBundleIdentifier = "com.darkroom.programa" static let managedBlockStart = "# programa themes start" static let managedBlockEnd = "# programa themes end" static let reloadNotificationName = "com.darkroom.programa.themes.reload-config" + /// Fixed write order for directives inside the managed block. + private static let orderedDirectiveKeys = [ + "theme", + "background-opacity", + "background-blur", + "font-family", + "font-size", + ] + private static let managedBlockPattern = #"(?ms)\n?# programa themes start\r?\n(.*?)\r?\n# programa themes end\n?"# let fileManager: FileManager @@ -69,13 +110,15 @@ struct TerminalThemeStore { ) } + // MARK: - Reading + func currentSelection() -> TerminalThemeSelection { var rawValue: String? var sourcePath: String? for url in configSearchURLs { guard let contents = try? String(contentsOf: url, encoding: .utf8), - let nextValue = Self.lastThemeDirective(in: contents) else { + let nextValue = Self.directiveValue(for: "theme", in: contents) else { continue } rawValue = nextValue @@ -85,52 +128,136 @@ struct TerminalThemeStore { return Self.parseSelection(rawValue: rawValue, sourcePath: sourcePath) } + /// Resolves all five directives across `configSearchURLs`, independently per key — a later + /// file in the chain wins for that key only, matching `currentSelection()`'s theme behavior. + func currentAppearance() -> TerminalAppearanceOverrides { + var overrides = TerminalAppearanceOverrides() + + for url in configSearchURLs { + guard let contents = try? String(contentsOf: url, encoding: .utf8) else { continue } + + if let themeValue = Self.directiveValue(for: "theme", in: contents) { + let selection = Self.parseSelection(rawValue: themeValue, sourcePath: url.path) + overrides.themeLight = selection.light + overrides.themeDark = selection.dark + } + if let opacityValue = Self.directiveValue(for: "background-opacity", in: contents), + let opacity = Double(opacityValue) { + overrides.backgroundOpacity = opacity + } + if let blurValue = Self.directiveValue(for: "background-blur", in: contents), + let blur = Self.parseBoolDirective(blurValue) { + overrides.backgroundBlur = blur + } + if let fontFamilyValue = Self.directiveValue(for: "font-family", in: contents) { + overrides.fontFamily = fontFamilyValue + } + if let fontSizeValue = Self.directiveValue(for: "font-size", in: contents), + let fontSize = Double(fontSizeValue) { + overrides.fontSize = fontSize + } + } + + return overrides + } + func managedRawThemeValue() -> String? { - guard let contents = try? String(contentsOf: managedConfigURL, encoding: .utf8), - let regex = try? NSRegularExpression(pattern: Self.managedBlockPattern), - let match = regex.matches( - in: contents, - range: NSRange(contents.startIndex.. 1, - let bodyRange = Range(match.range(at: 1), in: contents) else { - return nil + guard let body = managedBlockBody() else { return nil } + return Self.directiveValue(for: "theme", in: body) + } + + /// Reads only the managed block (replaces `managedRawThemeValue()`'s role for backups, now + /// generalized to all five directives). + func managedRawAppearance() -> TerminalAppearanceOverrides { + guard let body = managedBlockBody() else { return TerminalAppearanceOverrides() } + + var overrides = TerminalAppearanceOverrides() + if let themeValue = Self.directiveValue(for: "theme", in: body) { + let selection = Self.parseSelection(rawValue: themeValue, sourcePath: nil) + overrides.themeLight = selection.light + overrides.themeDark = selection.dark + } + if let opacityValue = Self.directiveValue(for: "background-opacity", in: body), + let opacity = Double(opacityValue) { + overrides.backgroundOpacity = opacity + } + if let blurValue = Self.directiveValue(for: "background-blur", in: body), + let blur = Self.parseBoolDirective(blurValue) { + overrides.backgroundBlur = blur } - return Self.lastThemeDirective(in: String(contents[bodyRange])) + if let fontFamilyValue = Self.directiveValue(for: "font-family", in: body) { + overrides.fontFamily = fontFamilyValue + } + if let fontSizeValue = Self.directiveValue(for: "font-size", in: body), + let fontSize = Double(fontSizeValue) { + overrides.fontSize = fontSize + } + return overrides } + // MARK: - Writing + func set(light: String?, dark: String?) throws -> TerminalThemeMutation { - guard let rawThemeValue = Self.encodedThemeValue(light: light, dark: dark) else { - return try clear() + try set(TerminalAppearanceOverrides(themeLight: light, themeDark: dark)) + } + + /// Writes only the non-nil fields as directive lines, in `orderedDirectiveKeys` order. + /// This is a full desired-state replace of the managed block's directives — callers that + /// want to change one field while preserving the others must read `managedRawAppearance()` + /// first and re-supply the sibling fields (mirrors how `set(light:dark:)` already worked + /// for the single-directive case; reading from the *managed* block, not `currentAppearance()`'s + /// full search-chain resolution, avoids capturing a value the user only ever set in their own + /// raw Ghostty config into Programa's managed block). An all-nil `overrides` clears the block. + /// + /// `backgroundBlur` is a true tri-state: `nil` omits the directive (inherit), `false` writes + /// an explicit `background-blur = false` (distinct from inherit — needed because a raw Ghostty + /// config earlier in the search chain may set `background-blur = true`, and an omitted line + /// would silently fall through to that instead of turning blur off). + func set(_ overrides: TerminalAppearanceOverrides) throws -> TerminalThemeMutation { + var directives: [String: String] = [:] + if let themeValue = Self.encodedThemeValue(light: overrides.themeLight, dark: overrides.themeDark) { + directives["theme"] = themeValue + } + if let opacity = overrides.backgroundOpacity { + directives["background-opacity"] = String(opacity) + } + if let blur = overrides.backgroundBlur { + directives["background-blur"] = blur ? "true" : "false" } - return try set(rawThemeValue: rawThemeValue) + if let fontFamily = Self.normalizedThemeName(overrides.fontFamily) { + directives["font-family"] = fontFamily + } + if let fontSize = overrides.fontSize { + directives["font-size"] = String(fontSize) + } + return try writeDirectives(directives) } func set(rawThemeValue: String) throws -> TerminalThemeMutation { - let trimmedThemeValue = rawThemeValue.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmedThemeValue.isEmpty else { return try clear() } + try set(rawAppearanceValue: rawThemeValue, forKey: "theme") + } - let existingContents = try readOptionalContents(at: managedConfigURL) ?? "" - let strippedContents = Self.removingManagedBlock(from: existingContents) - .trimmingCharacters(in: .whitespacesAndNewlines) - let block = """ - \(Self.managedBlockStart) - theme = \(trimmedThemeValue) - \(Self.managedBlockEnd) - """ - let nextContents = strippedContents.isEmpty ? "\(block)\n" : "\(strippedContents)\n\n\(block)\n" + /// Surgically writes (or, if `rawAppearanceValue` is empty, removes) a single directive's + /// line within the managed block without touching any other directive's line. Used by + /// settings.json's per-key backup/restore paths, where only one key changes ownership. + func set(rawAppearanceValue: String, forKey key: String) throws -> TerminalThemeMutation { + try set(rawAppearanceValues: [key: rawAppearanceValue]) + } - guard nextContents != existingContents else { - return TerminalThemeMutation(configURL: managedConfigURL, didChange: false) + /// Same surgical semantics as `set(rawAppearanceValue:forKey:)`, generalized to update + /// several directives atomically in one write (e.g. font-family and font-size together). + /// A nil or empty value removes that directive's line. + func set(rawAppearanceValues: [String: String?]) throws -> TerminalThemeMutation { + var directives = currentManagedDirectives() + for (key, value) in rawAppearanceValues { + guard Self.orderedDirectiveKeys.contains(key) else { continue } + if let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmed.isEmpty { + directives[key] = trimmed + } else { + directives.removeValue(forKey: key) + } } - - try fileManager.createDirectory( - at: managedConfigURL.deletingLastPathComponent(), - withIntermediateDirectories: true, - attributes: nil - ) - try nextContents.write(to: managedConfigURL, atomically: true, encoding: .utf8) - return TerminalThemeMutation(configURL: managedConfigURL, didChange: true) + return try writeDirectives(directives) } func clear() throws -> TerminalThemeMutation { @@ -235,7 +362,19 @@ struct TerminalThemeStore { return trimmed } - private static func lastThemeDirective(in contents: String) -> String? { + /// Shared with `GhosttyConfig`'s `background-blur` parsing so both surfaces accept the same + /// literal spellings for a Ghostty boolean directive. + static func parseBoolDirective(_ value: String) -> Bool? { + switch value.lowercased() { + case "true", "1", "yes": return true + case "false", "0", "no": return false + default: return nil + } + } + + /// "Last matching line wins" scan for a single `key = value` directive, generalized from + /// the theme-only scanner this store used to have. + private static func directiveValue(for key: String, in contents: String) -> String? { var lastValue: String? for line in contents.components(separatedBy: .newlines) { @@ -244,7 +383,7 @@ struct TerminalThemeStore { let parts = trimmed.split(separator: "=", maxSplits: 1).map(String.init) guard parts.count == 2, - parts[0].trimmingCharacters(in: .whitespacesAndNewlines) == "theme" else { + parts[0].trimmingCharacters(in: .whitespacesAndNewlines) == key else { continue } @@ -257,6 +396,65 @@ struct TerminalThemeStore { return lastValue } + private func managedBlockBody() -> String? { + guard let contents = try? String(contentsOf: managedConfigURL, encoding: .utf8), + let regex = try? NSRegularExpression(pattern: Self.managedBlockPattern), + let match = regex.matches( + in: contents, + range: NSRange(contents.startIndex.. 1, + let bodyRange = Range(match.range(at: 1), in: contents) else { + return nil + } + return String(contents[bodyRange]) + } + + private func currentManagedDirectives() -> [String: String] { + guard let body = managedBlockBody() else { return [:] } + var result: [String: String] = [:] + for key in Self.orderedDirectiveKeys { + if let value = Self.directiveValue(for: key, in: body) { + result[key] = value + } + } + return result + } + + private func writeDirectives(_ directives: [String: String]) throws -> TerminalThemeMutation { + let lines = Self.orderedDirectiveKeys.compactMap { key -> String? in + guard let value = directives[key] else { return nil } + return "\(key) = \(value)" + } + guard !lines.isEmpty else { return try clear() } + return try writeManagedBlock(lines: lines) + } + + private func writeManagedBlock(lines: [String]) throws -> TerminalThemeMutation { + let existingContents = try readOptionalContents(at: managedConfigURL) ?? "" + let strippedContents = Self.removingManagedBlock(from: existingContents) + .trimmingCharacters(in: .whitespacesAndNewlines) + let blockBody = lines.joined(separator: "\n") + let block = """ + \(Self.managedBlockStart) + \(blockBody) + \(Self.managedBlockEnd) + """ + let nextContents = strippedContents.isEmpty ? "\(block)\n" : "\(strippedContents)\n\n\(block)\n" + + guard nextContents != existingContents else { + return TerminalThemeMutation(configURL: managedConfigURL, didChange: false) + } + + try fileManager.createDirectory( + at: managedConfigURL.deletingLastPathComponent(), + withIntermediateDirectories: true, + attributes: nil + ) + try nextContents.write(to: managedConfigURL, atomically: true, encoding: .utf8) + return TerminalThemeMutation(configURL: managedConfigURL, didChange: true) + } + private static func removingManagedBlock(from contents: String) -> String { guard let regex = try? NSRegularExpression(pattern: managedBlockPattern) else { return contents diff --git a/docs/terminal-themes.md b/docs/terminal-themes.md index 7d8b49a1..ba3b56bf 100644 --- a/docs/terminal-themes.md +++ b/docs/terminal-themes.md @@ -1,22 +1,23 @@ -# Terminal themes +# Terminal themes and appearance -Programa reads the themes installed with its embedded Ghostty, Ghostty's standard user theme directories, and any directories configured through `GHOSTTY_RESOURCES_DIR` or `XDG_DATA_DIRS`. +Programa reads the themes installed with its embedded Ghostty, Ghostty's standard user theme directories, and any directories configured through `GHOSTTY_RESOURCES_DIR` or `XDG_DATA_DIRS`. Two Programa-specific themes, **Min Light** and **Min Dark**, ship alongside Ghostty's bundled catalog — minimal, low-saturation palettes in the style of the [Min Theme](https://marketplace.visualstudio.com/items?itemName=miguelsolorio.min-theme) VS Code extension. -Choose separate light and dark themes in **Settings → Appearance → Terminal**. Changes apply to every open terminal without relaunching Programa. Choosing **Use Ghostty Configuration** removes Programa's managed override and returns theme selection to your Ghostty config. +Choose separate light and dark themes, background opacity, background blur, and a terminal font in **Settings → Appearance → Terminal**. Changes apply to every open terminal without relaunching Programa. Choosing **Use Ghostty Configuration** for a theme removes Programa's managed override for that field and returns it to your Ghostty config. -The same selection is available from the CLI: +The same settings are available from the CLI: ```bash programa themes list programa themes set --light "Catppuccin Latte" --dark "Catppuccin Mocha" +programa themes set --opacity 0.85 --blur --font "JetBrains Mono" --font-size 13 programa themes clear ``` -Both surfaces write the managed block in `~/Library/Application Support/com.darkroom.programa/config.ghostty`, preserving unrelated directives in that file. +All surfaces write the same managed block in `~/Library/Application Support/com.darkroom.programa/config.ghostty`, preserving unrelated directives in that file. Setting one field (e.g. opacity) never disturbs another field Programa already manages there (e.g. theme or font) — but it also never captures a field it doesn't yet manage, even if that field currently resolves to a value from your own raw Ghostty config. Programa only ever writes the specific field you changed. ## settings.json -Set `app.terminalTheme` in `~/.config/programa/settings.json` to manage the selection as configuration: +Four independent `app.*` keys manage these settings as configuration in `~/.config/programa/settings.json`: ```jsonc { @@ -24,9 +25,15 @@ Set `app.terminalTheme` in `~/.config/programa/settings.json` to manage the sele "terminalTheme": { "light": "Catppuccin Latte", "dark": "Catppuccin Mocha" + }, + "terminalOpacity": 0.85, + "terminalBlur": true, + "terminalFont": { + "family": "JetBrains Mono", + "size": 13 } } } ``` -While this key is present, the Settings pickers are read-only. Removing the key restores the theme selection that was active before `settings.json` took ownership. Set the value to `null` (or set both variants to `null`) to explicitly inherit your Ghostty configuration while keeping the setting file-managed. +Each key is managed independently — pin `terminalFont` while leaving `terminalTheme`, `terminalOpacity`, and `terminalBlur` editable in Settings, or any other combination. While a key is present, its corresponding Settings row (or pair of rows, for theme) is read-only. Removing a key restores the value that was active before `settings.json` took ownership of it. Set `terminalTheme`/`terminalOpacity`/`terminalBlur`/`terminalFont` to `null` to explicitly inherit your Ghostty configuration for that field while keeping it file-managed. diff --git a/programaTests/WorkspaceUnitTests.swift b/programaTests/WorkspaceUnitTests.swift index 8551f645..0757dded 100644 --- a/programaTests/WorkspaceUnitTests.swift +++ b/programaTests/WorkspaceUnitTests.swift @@ -1302,6 +1302,171 @@ final class TerminalThemeSettingsTests: XCTestCase { XCTAssertEqual(reloadRequestCount, 2) } + func testAppearanceOverridesRoundTripAllFourDirectives() throws { + let directoryURL = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directoryURL) } + + let configURL = directoryURL.appendingPathComponent("config.ghostty", isDirectory: false) + let store = TerminalThemeStore( + fileManager: .default, + managedConfigURL: configURL, + configSearchURLs: [configURL] + ) + + let overrides = TerminalAppearanceOverrides( + themeLight: "Cloud Light", + themeDark: "Midnight Dark", + backgroundOpacity: 0.85, + backgroundBlur: true, + fontFamily: "JetBrains Mono", + fontSize: 13 + ) + let mutation = try store.set(overrides) + XCTAssertTrue(mutation.didChange) + + let readBack = store.currentAppearance() + XCTAssertEqual(readBack.themeLight, "Cloud Light") + XCTAssertEqual(readBack.themeDark, "Midnight Dark") + XCTAssertEqual(readBack.backgroundOpacity, 0.85) + XCTAssertEqual(readBack.backgroundBlur, true) + XCTAssertEqual(readBack.fontFamily, "JetBrains Mono") + XCTAssertEqual(readBack.fontSize, 13) + + let managedContents = try String(contentsOf: configURL, encoding: .utf8) + XCTAssertTrue(managedContents.contains("theme = light:Cloud Light,dark:Midnight Dark")) + XCTAssertTrue(managedContents.contains("background-opacity = 0.85")) + XCTAssertTrue(managedContents.contains("background-blur = true")) + XCTAssertTrue(managedContents.contains("font-family = JetBrains Mono")) + XCTAssertTrue(managedContents.contains("font-size = 13.0")) + } + + func testSurgicalSingleKeyWritePreservesOtherDirectives() throws { + let directoryURL = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directoryURL) } + + let configURL = directoryURL.appendingPathComponent("config.ghostty", isDirectory: false) + let store = TerminalThemeStore( + fileManager: .default, + managedConfigURL: configURL, + configSearchURLs: [configURL] + ) + + _ = try store.set(TerminalAppearanceOverrides(themeLight: "Cloud Light", themeDark: "Midnight Dark")) + _ = try store.set(rawAppearanceValue: "0.7", forKey: "background-opacity") + + let afterOpacity = store.currentAppearance() + XCTAssertEqual(afterOpacity.themeLight, "Cloud Light") + XCTAssertEqual(afterOpacity.themeDark, "Midnight Dark") + XCTAssertEqual(afterOpacity.backgroundOpacity, 0.7) + + _ = try store.set(rawAppearanceValues: ["font-family": "Menlo", "font-size": "14"]) + + let afterFont = store.currentAppearance() + XCTAssertEqual(afterFont.themeLight, "Cloud Light", "setting font must not clobber theme") + XCTAssertEqual(afterFont.backgroundOpacity, 0.7, "setting font must not clobber opacity") + XCTAssertEqual(afterFont.fontFamily, "Menlo") + XCTAssertEqual(afterFont.fontSize, 14) + + _ = try store.set(rawAppearanceValue: "", forKey: "background-opacity") + let afterClearingOpacity = store.currentAppearance() + XCTAssertNil(afterClearingOpacity.backgroundOpacity, "clearing one key must not clear the block") + XCTAssertEqual(afterClearingOpacity.themeLight, "Cloud Light") + XCTAssertEqual(afterClearingOpacity.fontFamily, "Menlo") + } + + func testAllNilOverridesClearsTheManagedBlock() throws { + let directoryURL = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directoryURL) } + + let configURL = directoryURL.appendingPathComponent("config.ghostty", isDirectory: false) + try "font-size = 15\n".write(to: configURL, atomically: true, encoding: .utf8) + let store = TerminalThemeStore( + fileManager: .default, + managedConfigURL: configURL, + configSearchURLs: [configURL] + ) + + _ = try store.set(TerminalAppearanceOverrides(backgroundOpacity: 0.5, backgroundBlur: true)) + XCTAssertNotNil(store.managedRawAppearance().backgroundOpacity) + + let clearMutation = try store.set(TerminalAppearanceOverrides()) + XCTAssertTrue(clearMutation.didChange) + XCTAssertEqual(try String(contentsOf: configURL, encoding: .utf8), "font-size = 15\n") + XCTAssertEqual(store.managedRawAppearance(), TerminalAppearanceOverrides()) + } + + func testPartialWriteBasedOnManagedBlockDoesNotCaptureValuesFromAnUnmanagedConfigFile() throws { + let directoryURL = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directoryURL) } + + // A separate, user-owned config file earlier in the search chain — never written to by + // Programa — with its own theme and font that Programa doesn't manage. + let userConfigURL = directoryURL.appendingPathComponent("config", isDirectory: false) + try """ + theme = light:UserTheme,dark:UserThemeDark + font-family = User Mono + """.write(to: userConfigURL, atomically: true, encoding: .utf8) + + let managedConfigURL = directoryURL.appendingPathComponent("config.ghostty", isDirectory: false) + let store = TerminalThemeStore( + fileManager: .default, + managedConfigURL: managedConfigURL, + configSearchURLs: [userConfigURL, managedConfigURL] + ) + + // The effective, resolved state includes the user's own config... + let effective = store.currentAppearance() + XCTAssertEqual(effective.themeLight, "UserTheme") + XCTAssertEqual(effective.fontFamily, "User Mono") + // ...but nothing is Programa-managed yet. + XCTAssertEqual(store.managedRawAppearance(), TerminalAppearanceOverrides()) + + // Simulate the caller contract used by Settings/CLI: build the write from + // managedRawAppearance() (not currentAppearance()), touching only the one field. + var overrides = store.managedRawAppearance() + overrides.backgroundOpacity = 0.6 + _ = try store.set(overrides) + + let managedContents = try String(contentsOf: managedConfigURL, encoding: .utf8) + XCTAssertTrue(managedContents.contains("background-opacity = 0.6")) + XCTAssertFalse(managedContents.contains("theme ="), "the user's theme must not be captured into the managed block") + XCTAssertFalse(managedContents.contains("font-family ="), "the user's font must not be captured into the managed block") + + // The user's own config is untouched and still resolves as before. + let effectiveAfter = store.currentAppearance() + XCTAssertEqual(effectiveAfter.themeLight, "UserTheme") + XCTAssertEqual(effectiveAfter.fontFamily, "User Mono") + XCTAssertEqual(effectiveAfter.backgroundOpacity, 0.6) + XCTAssertEqual(try String(contentsOf: userConfigURL, encoding: .utf8).contains("User Mono"), true) + } + + func testExplicitBlurFalseIsDistinctFromInherit() throws { + let directoryURL = try makeTemporaryDirectory() + defer { try? FileManager.default.removeItem(at: directoryURL) } + + // A raw Ghostty config earlier in the chain turns blur on. + let userConfigURL = directoryURL.appendingPathComponent("config", isDirectory: false) + try "background-blur = true\n".write(to: userConfigURL, atomically: true, encoding: .utf8) + + let managedConfigURL = directoryURL.appendingPathComponent("config.ghostty", isDirectory: false) + let store = TerminalThemeStore( + fileManager: .default, + managedConfigURL: managedConfigURL, + configSearchURLs: [userConfigURL, managedConfigURL] + ) + + XCTAssertEqual(store.currentAppearance().backgroundBlur, true) + + // Explicitly turning it off via Programa must write a real `false`, not omit the key — + // an omitted key would fall through to the user's `background-blur = true` above. + _ = try store.set(TerminalAppearanceOverrides(backgroundBlur: false)) + + let managedContents = try String(contentsOf: managedConfigURL, encoding: .utf8) + XCTAssertTrue(managedContents.contains("background-blur = false")) + XCTAssertEqual(store.managedRawAppearance().backgroundBlur, false) + XCTAssertEqual(store.currentAppearance().backgroundBlur, false) + } + private func makeTemporaryDirectory() throws -> URL { let directoryURL = FileManager.default.temporaryDirectory .appendingPathComponent(UUID().uuidString, isDirectory: true)