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)