diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e8b4ea..f782f4e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### ✨ Features + +- Settings → Theme now supports appearance-aware Ghostty theme selection, so you can configure separate light and dark themes directly in Mori and persist them in Ghostty syntax like `theme = light:Ayu Light,dark:Ayu` while keeping live terminal + chrome reload behavior + +### 🎨 Design + +- Derive Mori’s app chrome from the selected Ghostty theme instead of reusing the raw terminal background everywhere, giving light themes stronger surface hierarchy across the main window, sidebar, settings window, companion pane, worktree creation panel, agent dashboard, and sidebar row states while keeping Ghostty-driven dark/light mode in sync + +### 🐛 Bug Fixes + +- Quote Ghostty setting values that contain spaces and collapse duplicate singleton keys when saving from Settings, so changing themes like `Ayu Light` actually updates the running terminal and Mori chrome instead of silently leaving the old theme active + ## [0.4.1] - 2026-04-19 ### ✨ Features diff --git a/CHANGELOG.zh-Hans.md b/CHANGELOG.zh-Hans.md index 72553d7..2dfd56b 100644 --- a/CHANGELOG.zh-Hans.md +++ b/CHANGELOG.zh-Hans.md @@ -7,6 +7,18 @@ ## [Unreleased] +### ✨ 新功能 + +- 设置 → Theme 现在支持直接配置跟随外观切换的 Ghostty 主题:可在 Mori 内分别选择浅色 / 深色主题,并以 `theme = light:Ayu Light,dark:Ayu` 这样的 Ghostty 语法持久化,同时继续保持终端与应用外壳的实时刷新 + +### 🎨 界面优化 + +- Mori 现在会从所选 Ghostty 主题派生应用外壳配色,而不再把原始终端背景直接铺到整套界面上;这样浅色主题在主窗口、侧边栏、设置窗口、右侧工具面板、工作树创建面板、Agent Dashboard 以及侧边栏行状态上都有了更清晰的层级,同时仍保持 Ghostty 驱动的深浅模式同步 + +### 🐛 问题修复 + +- 在设置里保存 Ghostty 配置时,现会对包含空格的值自动加引号,并清理重复的单值键;这样像 `Ayu Light` 这类主题切换才会真正应用到正在运行的终端和 Mori 外壳,而不会悄悄停留在旧主题上 + ## [0.4.1] - 2026-04-19 ### ✨ 新功能 diff --git a/Packages/MoriTerminal/Sources/MoriTerminal/GhosttyAdapter.swift b/Packages/MoriTerminal/Sources/MoriTerminal/GhosttyAdapter.swift index d842d0b..c8e370c 100644 --- a/Packages/MoriTerminal/Sources/MoriTerminal/GhosttyAdapter.swift +++ b/Packages/MoriTerminal/Sources/MoriTerminal/GhosttyAdapter.swift @@ -35,6 +35,11 @@ public final class GhosttyAdapter: TerminalHost { GhosttyApp.shared.reloadConfig() } + /// Sync Ghostty's light/dark conditional theme branch to the current system appearance. + public func setColorScheme(isDark: Bool) { + GhosttyApp.shared.setColorScheme(isDark: isDark) + } + /// Apply Ghostty-derived window translucency and blur to the main workspace window. public func syncWorkspaceWindowAppearance(_ window: NSWindow) { applyWindowAppearance(window, allowsTransparency: true) diff --git a/Packages/MoriTerminal/Sources/MoriTerminal/GhosttyApp.swift b/Packages/MoriTerminal/Sources/MoriTerminal/GhosttyApp.swift index 73db4a8..0b4ac2a 100644 --- a/Packages/MoriTerminal/Sources/MoriTerminal/GhosttyApp.swift +++ b/Packages/MoriTerminal/Sources/MoriTerminal/GhosttyApp.swift @@ -88,8 +88,8 @@ final class GhosttyApp { return } - // Extract theme info before the config is consumed by ghostty_app_new - self.themeInfo = GhosttyThemeInfo.from(config: config) + // Extract theme info using the active appearance-specific theme branch. + self.themeInfo = resolvedThemeInfo(forDarkAppearance: currentSystemAppearanceIsDark) // Build runtime config in nonisolated context so closures don't // inherit @MainActor isolation (they're called from renderer thread). @@ -132,7 +132,7 @@ final class GhosttyApp { // MARK: - Config /// Build a ghostty config: load user's config first, then apply Mori overrides. - func buildConfig() -> ghostty_config_t? { + func buildConfig(themeOverride: String? = nil) -> ghostty_config_t? { guard let config = ghostty_config_new() else { return nil } // 1. Load user's ghostty config (standard path) @@ -145,6 +145,15 @@ final class GhosttyApp { let overridePath = GhosttyConfigWriter.write(appSupportDirectory: MoriPaths.appSupportDirectory) ghostty_config_load_file(config, overridePath) + // 3. For Mori chrome theme derivation, optionally force the active + // theme branch so paired light/dark themes resolve deterministically. + if let themeOverride, + !themeOverride.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, + let temporaryOverridePath = writeTemporaryThemeOverrideFile(themeOverride) { + ghostty_config_load_file(config, temporaryOverridePath) + try? FileManager.default.removeItem(atPath: temporaryOverridePath) + } + ghostty_config_finalize(config) return config } @@ -154,11 +163,54 @@ final class GhosttyApp { func reloadConfig() { guard let app else { return } guard let config = buildConfig() else { return } - self.themeInfo = GhosttyThemeInfo.from(config: config) + self.themeInfo = resolvedThemeInfo(forDarkAppearance: currentSystemAppearanceIsDark) ghostty_app_update_config(app, config) ghostty_config_free(config) } + /// Update the running Ghostty color scheme for system light/dark changes + /// without rewriting the user's config. + func setColorScheme(isDark: Bool) { + guard let app else { return } + self.themeInfo = resolvedThemeInfo(forDarkAppearance: isDark) + applyColorScheme(to: app, isDark: isDark) + } + + private var currentSystemAppearanceIsDark: Bool { + let bestMatch = NSApp.effectiveAppearance.bestMatch(from: [.darkAqua, .aqua]) + return bestMatch == .darkAqua + } + + private func resolvedThemeInfo(forDarkAppearance isDark: Bool) -> GhosttyThemeInfo { + let themeOverride = GhosttyConfigFile.resolvedThemeValue(forDarkAppearance: isDark) + guard let config = buildConfig(themeOverride: themeOverride) else { return .fallback } + let info = GhosttyThemeInfo.from(config: config) + ghostty_config_free(config) + return info + } + + private func applyColorScheme(to app: ghostty_app_t, isDark: Bool) { + let scheme: ghostty_color_scheme_e = isDark ? GHOSTTY_COLOR_SCHEME_DARK : GHOSTTY_COLOR_SCHEME_LIGHT + ghostty_app_set_color_scheme(app, scheme) + } + + private func writeTemporaryThemeOverrideFile(_ theme: String) -> String? { + let sanitized = theme.trimmingCharacters(in: .whitespacesAndNewlines) + guard !sanitized.isEmpty else { return nil } + + let escaped = sanitized.replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + let content = "theme = \"\(escaped)\"\n" + let path = (NSTemporaryDirectory() as NSString).appendingPathComponent("mori-ghostty-theme-override-\(UUID().uuidString).conf") + do { + try content.write(toFile: path, atomically: true, encoding: .utf8) + return path + } catch { + NSLog("[GhosttyApp] failed to write temporary theme override: \(error)") + return nil + } + } + // MARK: - Event Loop func tick() { diff --git a/Packages/MoriTerminal/Sources/MoriTerminal/GhosttyConfigFile.swift b/Packages/MoriTerminal/Sources/MoriTerminal/GhosttyConfigFile.swift index 6cdeb74..1e3fc8a 100644 --- a/Packages/MoriTerminal/Sources/MoriTerminal/GhosttyConfigFile.swift +++ b/Packages/MoriTerminal/Sources/MoriTerminal/GhosttyConfigFile.swift @@ -68,17 +68,24 @@ public final class GhosttyConfigFile { // MARK: - Write - /// Set a config value. Updates existing key or appends if new. + /// Set a config value. Updates the first occurrence, removes later duplicates, + /// or appends if the key does not exist. public func set(_ key: String, value: String) { - // Update first occurrence - for i in lines.indices { - if case .keyValue(let k, _, _) = lines[i], k == key { - lines[i] = .keyValue(key, value, "\(key) = \(value)") - return + let rendered = renderKeyValue(key: key, value: value) + var didUpdate = false + + lines = lines.compactMap { line in + guard case .keyValue(let k, _, _) = line, k == key else { return line } + if !didUpdate { + didUpdate = true + return .keyValue(key, value, rendered) } + return nil + } + + if !didUpdate { + lines.append(.keyValue(key, value, rendered)) } - // Append new key - lines.append(.keyValue(key, value, "\(key) = \(value)")) } /// Remove a key from the config. @@ -148,8 +155,57 @@ public final class GhosttyConfigFile { } // Append new entries for value in values { - lines.append(.keyValue(key, value, "\(key) = \(value)")) + lines.append(.keyValue(key, value, renderKeyValue(key: key, value: value))) + } + } + + private func renderKeyValue(key: String, value: String) -> String { + let needsQuotes = value.contains { $0.isWhitespace || $0 == "#" || $0 == "\"" } + guard needsQuotes else { return "\(key) = \(value)" } + + let escaped = value.replacingOccurrences(of: "\\", with: "\\\\") + .replacingOccurrences(of: "\"", with: "\\\"") + return "\(key) = \"\(escaped)\"" + } + + /// Resolve the active theme value for a given appearance. + /// For paired values like `light:Foo,dark:Bar`, returns the matching side. + /// For single values, returns the value as-is. + public static func resolvedThemeValue(forDarkAppearance isDark: Bool) -> String? { + let config = GhosttyConfigFile() + guard let rawThemeValue = config.get("theme")?.trimmingCharacters(in: .whitespacesAndNewlines), + !rawThemeValue.isEmpty else { + return nil } + + guard let pair = parseAppearanceAwareThemeValue(rawThemeValue) else { + return rawThemeValue + } + + let selectedTheme = isDark ? pair.dark : pair.light + return selectedTheme.isEmpty ? nil : selectedTheme + } + + private static func parseAppearanceAwareThemeValue(_ value: String) -> (light: String, dark: String)? { + var lightTheme = "" + var darkTheme = "" + var foundAppearanceToken = false + + for component in value.split(separator: ",", omittingEmptySubsequences: true) { + let token = component.trimmingCharacters(in: .whitespacesAndNewlines) + let lowercasedToken = token.lowercased() + if lowercasedToken.hasPrefix("light:") { + foundAppearanceToken = true + lightTheme = String(token.dropFirst("light:".count)).trimmingCharacters(in: .whitespacesAndNewlines) + } else if lowercasedToken.hasPrefix("dark:") { + foundAppearanceToken = true + darkTheme = String(token.dropFirst("dark:".count)).trimmingCharacters(in: .whitespacesAndNewlines) + } else { + return nil + } + } + + return foundAppearanceToken ? (lightTheme, darkTheme) : nil } /// List ghostty default keybindings by running `ghostty +list-keybinds`. diff --git a/Packages/MoriUI/Sources/MoriUI/AgentWindowRowView.swift b/Packages/MoriUI/Sources/MoriUI/AgentWindowRowView.swift index d36e3d3..0637d29 100644 --- a/Packages/MoriUI/Sources/MoriUI/AgentWindowRowView.swift +++ b/Packages/MoriUI/Sources/MoriUI/AgentWindowRowView.swift @@ -13,6 +13,7 @@ public struct AgentWindowRowView: View { let onRequestPaneOutput: ((String, @escaping (String?) -> Void) -> Void)? let onSendKeys: ((String, String) -> Void)? + @EnvironmentObject private var chromePaletteStore: MoriChromePaletteStore @State private var isHovered = false @State private var showPopover = false @State private var popoverOutput: String? @@ -194,9 +195,9 @@ public struct AgentWindowRowView: View { private var rowBackground: some ShapeStyle { if isSelected { - return AnyShapeStyle(MoriTokens.Color.active.opacity(MoriTokens.Opacity.subtle)) + return AnyShapeStyle(MoriTokens.Chrome.rowSelectionFill(chromePaletteStore.palette)) } else if isHovered { - return AnyShapeStyle(MoriTokens.Color.muted.opacity(MoriTokens.Opacity.subtle)) + return AnyShapeStyle(MoriTokens.Chrome.rowHoverFill(chromePaletteStore.palette)) } else { return AnyShapeStyle(Color.clear) } diff --git a/Packages/MoriUI/Sources/MoriUI/DesignTokens.swift b/Packages/MoriUI/Sources/MoriUI/DesignTokens.swift index 8505f45..e7e39d7 100644 --- a/Packages/MoriUI/Sources/MoriUI/DesignTokens.swift +++ b/Packages/MoriUI/Sources/MoriUI/DesignTokens.swift @@ -119,6 +119,53 @@ public enum MoriTokens { public static let medium: Double = 0.2 } + // MARK: - Chrome helpers + + public enum Chrome { + public static func rowHoverFill(_ palette: MoriChromePalette) -> SwiftUI.Color { + palette.hoverFill.color + } + + public static func rowSelectionGradient(_ palette: MoriChromePalette) -> LinearGradient { + LinearGradient( + gradient: Gradient(colors: [ + palette.rowSelectionLeading.color, + palette.rowSelectionTrailing.color, + ]), + startPoint: .leading, + endPoint: .trailing + ) + } + + public static func rowSelectionFill(_ palette: MoriChromePalette) -> SwiftUI.Color { + palette.rowSelectionLeading.color + } + + public static func iconBackground(selected: Bool, palette: MoriChromePalette) -> SwiftUI.Color { + selected ? palette.rowSelectionLeading.color : palette.inactiveIconFill.color + } + + public static func shortcutPillFill(_ palette: MoriChromePalette) -> SwiftUI.Color { + palette.shortcutPillFill.color + } + + public static func cardBackground(_ palette: MoriChromePalette) -> SwiftUI.Color { + palette.cardBackground.color + } + + public static func divider(_ palette: MoriChromePalette) -> SwiftUI.Color { + palette.divider.color + } + + public static func strongSelectionFill(_ palette: MoriChromePalette) -> SwiftUI.Color { + palette.strongSelectionFill.color + } + + public static func selectionAccent(_ palette: MoriChromePalette) -> SwiftUI.Color { + palette.selectionAccent.color + } + } + // MARK: - Project Avatar Palette /// Seven warm/cool duotone pairs (background + foreground) for project letter diff --git a/Packages/MoriUI/Sources/MoriUI/GhosttySettingsView.swift b/Packages/MoriUI/Sources/MoriUI/GhosttySettingsView.swift index da26f3f..2e629f9 100644 --- a/Packages/MoriUI/Sources/MoriUI/GhosttySettingsView.swift +++ b/Packages/MoriUI/Sources/MoriUI/GhosttySettingsView.swift @@ -58,10 +58,132 @@ public enum GhosttyBackgroundBlur: Equatable { } } +public enum GhosttyThemeMode: String, Equatable, CaseIterable { + case light + case dark + case auto +} + +public enum GhosttyThemeAssignmentTarget: String, Equatable, CaseIterable, Identifiable { + case light + case dark + + public var id: String { rawValue } +} + +public struct GhosttyThemeSelection: Equatable { + public var mode: GhosttyThemeMode + public var lightTheme: String + public var darkTheme: String + + public init( + mode: GhosttyThemeMode = .dark, + lightTheme: String = "", + darkTheme: String = "" + ) { + self.mode = mode + self.lightTheme = Self.normalizedThemeName(lightTheme) + self.darkTheme = Self.normalizedThemeName(darkTheme) + } + + public init(configValue: String, inferredSingleMode: GhosttyThemeMode = .dark) { + let trimmedValue = Self.normalizedThemeName(configValue) + if let appearanceAwareThemes = Self.parseAppearanceAwareThemes(from: trimmedValue) { + self.mode = .auto + self.lightTheme = appearanceAwareThemes.light + self.darkTheme = appearanceAwareThemes.dark + } else { + self.mode = inferredSingleMode == .light ? .light : .dark + self.lightTheme = self.mode == .light ? trimmedValue : "" + self.darkTheme = self.mode == .dark ? trimmedValue : "" + } + } + + public var configValue: String? { + switch mode { + case .light, .dark: + return activeTheme.isEmpty ? nil : activeTheme + case .auto: + let light = Self.normalizedThemeName(lightTheme) + let dark = Self.normalizedThemeName(darkTheme) + var components: [String] = [] + if !light.isEmpty { + components.append("light:\(light)") + } + if !dark.isEmpty { + components.append("dark:\(dark)") + } + return components.isEmpty ? nil : components.joined(separator: ",") + } + } + + public func theme(for target: GhosttyThemeAssignmentTarget) -> String { + switch target { + case .light: + lightTheme + case .dark: + darkTheme + } + } + + public mutating func setTheme(_ theme: String, for target: GhosttyThemeAssignmentTarget) { + let normalizedTheme = Self.normalizedThemeName(theme) + switch target { + case .light: + lightTheme = normalizedTheme + case .dark: + darkTheme = normalizedTheme + } + } + + public func matches(_ candidateTheme: String, for target: GhosttyThemeAssignmentTarget) -> Bool { + theme(for: target).caseInsensitiveCompare(Self.normalizedThemeName(candidateTheme)) == .orderedSame + } + + public var activeTheme: String { + switch mode { + case .light: + lightTheme + case .dark: + darkTheme + case .auto: + "" + } + } + + private static func parseAppearanceAwareThemes(from value: String) -> (light: String, dark: String)? { + guard !value.isEmpty else { return nil } + + var lightTheme = "" + var darkTheme = "" + var foundAppearanceToken = false + + for component in value.split(separator: ",", omittingEmptySubsequences: true) { + let token = component.trimmingCharacters(in: .whitespacesAndNewlines) + let lowercasedToken = token.lowercased() + if lowercasedToken.hasPrefix("light:") { + foundAppearanceToken = true + lightTheme = normalizedThemeName(String(token.dropFirst("light:".count))) + } else if lowercasedToken.hasPrefix("dark:") { + foundAppearanceToken = true + darkTheme = normalizedThemeName(String(token.dropFirst("dark:".count))) + } else { + return nil + } + } + + return foundAppearanceToken ? (lightTheme, darkTheme) : nil + } + + private static func normalizedThemeName(_ value: String) -> String { + value.trimmingCharacters(in: .whitespacesAndNewlines) + } +} + public struct GhosttySettingsModel: Equatable { public var fontFamily: String public var fontSize: Int - public var theme: String + public var theme: GhosttyThemeSelection public var cursorStyle: String public var cursorBlink: Bool public var backgroundOpacity: Double @@ -77,7 +199,7 @@ public struct GhosttySettingsModel: Equatable { public init( fontFamily: String = "", fontSize: Int = 13, - theme: String = "", + theme: GhosttyThemeSelection = GhosttyThemeSelection(), cursorStyle: String = "block", cursorBlink: Bool = true, backgroundOpacity: Double = 1.0, @@ -251,6 +373,7 @@ public struct GhosttySettingsView: View { var onKeyBindingReset: ((String) -> Void)? var onKeyBindingResetAll: (() -> Void)? + @EnvironmentObject private var chromePaletteStore: MoriChromePaletteStore @State private var selectedCategory: SettingsCategory = .general public init( @@ -296,9 +419,12 @@ public struct GhosttySettingsView: View { public var body: some View { HStack(spacing: 0) { sidebar - Divider() + Rectangle() + .fill(MoriTokens.Chrome.divider(chromePaletteStore.palette)) + .frame(width: 1) contentArea } + .background(MoriTokens.Chrome.cardBackground(chromePaletteStore.palette)) .frame(minWidth: 740, idealWidth: 780, minHeight: 540, idealHeight: 600) } @@ -336,6 +462,7 @@ public struct GhosttySettingsView: View { .padding(.bottom, 8) } .frame(width: 180) + .background(chromePaletteStore.palette.sidebarBackground.color) } private func sidebarRow(_ category: SettingsCategory) -> some View { @@ -358,7 +485,7 @@ public struct GhosttySettingsView: View { .contentShape(Rectangle()) .background( RoundedRectangle(cornerRadius: 6) - .fill(isSelected ? Color.accentColor : .clear) + .fill(isSelected ? MoriTokens.Chrome.strongSelectionFill(chromePaletteStore.palette) : .clear) ) } .buttonStyle(.plain) @@ -413,6 +540,7 @@ public struct GhosttySettingsView: View { } } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .background(chromePaletteStore.palette.panelBackground.color) } } @@ -445,6 +573,7 @@ private struct SettingRow: View { /// A card container for grouping related settings. private struct SettingsCard: View { + @EnvironmentObject private var chromePaletteStore: MoriChromePaletteStore @ViewBuilder var content: () -> Content var body: some View { @@ -454,11 +583,11 @@ private struct SettingsCard: View { .padding(16) .background( RoundedRectangle(cornerRadius: 10) - .fill(Color(nsColor: .controlBackgroundColor).opacity(0.6)) + .fill(MoriTokens.Chrome.cardBackground(chromePaletteStore.palette)) ) .overlay( RoundedRectangle(cornerRadius: 10) - .strokeBorder(Color.primary.opacity(0.06), lineWidth: 1) + .strokeBorder(MoriTokens.Chrome.divider(chromePaletteStore.palette), lineWidth: 1) ) } } @@ -608,6 +737,8 @@ private struct GeneralSettingsContent: View { // MARK: - Theme Settings private struct ThemeSettingsContent: View { + @EnvironmentObject private var chromePaletteStore: MoriChromePaletteStore + private enum BackgroundBlurPreset: String, CaseIterable, Identifiable { case disabled case standard @@ -653,6 +784,33 @@ private struct ThemeSettingsContent: View { ) } + private var themeMode: Binding { + Binding( + get: { model.theme.mode }, + set: { newMode in + switch newMode { + case .light: + if model.theme.lightTheme.isEmpty { + model.theme.lightTheme = model.theme.darkTheme + } + case .dark: + if model.theme.darkTheme.isEmpty { + model.theme.darkTheme = model.theme.lightTheme + } + case .auto: + if model.theme.lightTheme.isEmpty { + model.theme.lightTheme = model.theme.darkTheme + } + if model.theme.darkTheme.isEmpty { + model.theme.darkTheme = model.theme.lightTheme + } + } + model.theme.mode = newMode + onChanged() + } + ) + } + private var blurRadius: Binding { Binding( get: { Double(model.backgroundBlur.radiusValue) }, @@ -664,7 +822,6 @@ private struct ThemeSettingsContent: View { } var body: some View { - // Preview TerminalPreview( fontFamily: model.fontFamily, fontSize: model.fontSize, @@ -672,56 +829,70 @@ private struct ThemeSettingsContent: View { opacity: model.backgroundOpacity ) - // Theme settings card SettingsCard { SettingRow( - title: .localized("Color theme"), - description: .localized("Select a color scheme for the terminal.") + title: .localized("Theme mode"), + description: .localized("Choose a fixed light theme, a fixed dark theme, or switch automatically.") ) { - Text(model.theme.isEmpty ? .localized("Default") : model.theme) - .font(.system(size: 12)) - .foregroundStyle(.secondary) - .frame(width: 160, alignment: .trailing) + Picker("", selection: themeMode) { + Text(String.localized("Light")).tag(GhosttyThemeMode.light) + Text(String.localized("Dark")).tag(GhosttyThemeMode.dark) + Text(String.localized("Auto")).tag(GhosttyThemeMode.auto) + } + .labelsHidden() + .pickerStyle(.segmented) + .frame(width: 260) } CardDivider() - // Theme search and list - HStack { - Image(systemName: "magnifyingglass") - .foregroundStyle(.tertiary) - .font(.system(size: 12)) - TextField("Search themes…", text: $themeSearch) - .textFieldStyle(.plain) - .font(.system(size: 12)) - if !themeSearch.isEmpty { - Button { themeSearch = "" } label: { - Image(systemName: "xmark.circle.fill") - .foregroundStyle(.tertiary) - .font(.system(size: 11)) + SettingRow( + title: .localized("Color theme"), + description: themeDescription + ) { + selectedThemeSummary + } + + if model.theme.mode != .auto { + CardDivider() + + HStack { + Image(systemName: "magnifyingglass") + .foregroundStyle(.tertiary) + .font(.system(size: 12)) + TextField("Search themes…", text: $themeSearch) + .textFieldStyle(.plain) + .font(.system(size: 12)) + if !themeSearch.isEmpty { + Button { themeSearch = "" } label: { + Image(systemName: "xmark.circle.fill") + .foregroundStyle(.tertiary) + .font(.system(size: 11)) + } + .buttonStyle(.plain) } - .buttonStyle(.plain) } - } - .padding(8) - .background( - RoundedRectangle(cornerRadius: 6) - .fill(Color.primary.opacity(0.04)) - ) + .padding(8) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(MoriTokens.Chrome.shortcutPillFill(chromePaletteStore.palette)) + ) - ScrollView { - LazyVStack(spacing: 0) { - ForEach(filteredThemes, id: \.self) { name in - themeListRow(name) + ScrollView { + LazyVStack(spacing: 0) { + themeListRow(label: String.localized("Default"), themeName: "") + ForEach(filteredThemes, id: \.self) { name in + themeListRow(label: name, themeName: name) + } } } + .frame(height: 200) + .clipShape(RoundedRectangle(cornerRadius: 6)) + .overlay( + RoundedRectangle(cornerRadius: 6) + .strokeBorder(MoriTokens.Chrome.divider(chromePaletteStore.palette), lineWidth: 1) + ) } - .frame(height: 200) - .clipShape(RoundedRectangle(cornerRadius: 6)) - .overlay( - RoundedRectangle(cornerRadius: 6) - .strokeBorder(Color.primary.opacity(0.06), lineWidth: 1) - ) CardDivider() @@ -793,32 +964,101 @@ private struct ThemeSettingsContent: View { } } + private var themeDescription: String { + switch model.theme.mode { + case .light: + return .localized("Pick the theme used when Theme mode is Light.") + case .dark: + return .localized("Pick the theme used when Theme mode is Dark.") + case .auto: + return .localized("Auto uses the themes you set in Light and Dark mode.") + } + } + + private var selectedThemeSummary: some View { + Group { + switch model.theme.mode { + case .light, .dark: + let name = model.theme.theme(for: activeThemeTarget) + Text(name.isEmpty ? .localized("Default") : name) + .font(.system(size: 12)) + .foregroundStyle(.secondary) + .lineLimit(1) + .frame(width: 220, alignment: .trailing) + case .auto: + VStack(alignment: .trailing, spacing: 6) { + appearanceSummaryPill( + systemImage: "sun.max.fill", + title: .localized("Light"), + themeName: model.theme.lightTheme + ) + appearanceSummaryPill( + systemImage: "moon.fill", + title: .localized("Dark"), + themeName: model.theme.darkTheme + ) + } + .frame(width: 220, alignment: .trailing) + } + } + } + private var filteredThemes: [String] { guard !themeSearch.isEmpty else { return availableThemes } let query = themeSearch.lowercased() return availableThemes.filter { $0.lowercased().contains(query) } } + private var activeThemeTarget: GhosttyThemeAssignmentTarget { + switch model.theme.mode { + case .light: .light + case .dark, .auto: .dark + } + } + @ViewBuilder - private func themeListRow(_ name: String) -> some View { - let isSelected = model.theme.lowercased() == name.lowercased() + private func appearanceSummaryPill(systemImage: String, title: String, themeName: String) -> some View { + HStack(spacing: 6) { + Image(systemName: systemImage) + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(.secondary) + Text(title) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(.secondary) + Text(themeName.isEmpty ? .localized("Default") : themeName) + .font(.system(size: 12)) + .foregroundStyle(.secondary) + .lineLimit(1) + } + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(MoriTokens.Chrome.shortcutPillFill(chromePaletteStore.palette)) + ) + } + + @ViewBuilder + private func themeListRow(label: String, themeName: String) -> some View { + let isActiveSelection = model.theme.matches(themeName, for: activeThemeTarget) + HStack { - Text(name) - .font(.system(size: 12, design: .monospaced)) + Text(label) + .font(.system(size: 12, design: themeName.isEmpty ? .default : .monospaced)) .lineLimit(1) Spacer() - if isSelected { + if isActiveSelection { Image(systemName: "checkmark") .font(.system(size: 10, weight: .bold)) - .foregroundStyle(Color.accentColor) + .foregroundStyle(MoriTokens.Chrome.selectionAccent(chromePaletteStore.palette)) } } .padding(.horizontal, 10) .padding(.vertical, 5) - .background(isSelected ? Color.accentColor.opacity(0.1) : .clear) + .background(isActiveSelection ? MoriTokens.Chrome.rowSelectionFill(chromePaletteStore.palette) : .clear) .contentShape(Rectangle()) .onTapGesture { - model.theme = name + model.theme.setTheme(themeName, for: activeThemeTarget) onChanged() } } diff --git a/Packages/MoriUI/Sources/MoriUI/MoriChromePalette.swift b/Packages/MoriUI/Sources/MoriUI/MoriChromePalette.swift new file mode 100644 index 0000000..ee3d54c --- /dev/null +++ b/Packages/MoriUI/Sources/MoriUI/MoriChromePalette.swift @@ -0,0 +1,116 @@ +import AppKit +import Combine +import SwiftUI + +public struct MoriChromeColor: Sendable, Equatable { + public let red: Double + public let green: Double + public let blue: Double + public let alpha: Double + + public init(red: Double, green: Double, blue: Double, alpha: Double = 1) { + self.red = red + self.green = green + self.blue = blue + self.alpha = alpha + } + + public init(nsColor: NSColor) { + let color = nsColor.usingColorSpace(.sRGB) ?? nsColor + self.red = Double(color.redComponent) + self.green = Double(color.greenComponent) + self.blue = Double(color.blueComponent) + self.alpha = Double(color.alphaComponent) + } + + public var color: SwiftUI.Color { + SwiftUI.Color(.sRGB, red: red, green: green, blue: blue, opacity: alpha) + } + + public var nsColor: NSColor { + NSColor( + srgbRed: CGFloat(red), + green: CGFloat(green), + blue: CGFloat(blue), + alpha: CGFloat(alpha) + ) + } + + public func withAlpha(_ alpha: Double) -> MoriChromeColor { + MoriChromeColor(red: red, green: green, blue: blue, alpha: alpha) + } +} + +public struct MoriChromePalette: Sendable, Equatable { + public let isDark: Bool + public let windowBackground: MoriChromeColor + public let sidebarBackground: MoriChromeColor + public let panelBackground: MoriChromeColor + public let headerBackground: MoriChromeColor + public let cardBackground: MoriChromeColor + public let divider: MoriChromeColor + public let hoverFill: MoriChromeColor + public let inactiveIconFill: MoriChromeColor + public let shortcutPillFill: MoriChromeColor + public let rowSelectionLeading: MoriChromeColor + public let rowSelectionTrailing: MoriChromeColor + public let strongSelectionFill: MoriChromeColor + public let selectionAccent: MoriChromeColor + + public init( + isDark: Bool, + windowBackground: MoriChromeColor, + sidebarBackground: MoriChromeColor, + panelBackground: MoriChromeColor, + headerBackground: MoriChromeColor, + cardBackground: MoriChromeColor, + divider: MoriChromeColor, + hoverFill: MoriChromeColor, + inactiveIconFill: MoriChromeColor, + shortcutPillFill: MoriChromeColor, + rowSelectionLeading: MoriChromeColor, + rowSelectionTrailing: MoriChromeColor, + strongSelectionFill: MoriChromeColor, + selectionAccent: MoriChromeColor + ) { + self.isDark = isDark + self.windowBackground = windowBackground + self.sidebarBackground = sidebarBackground + self.panelBackground = panelBackground + self.headerBackground = headerBackground + self.cardBackground = cardBackground + self.divider = divider + self.hoverFill = hoverFill + self.inactiveIconFill = inactiveIconFill + self.shortcutPillFill = shortcutPillFill + self.rowSelectionLeading = rowSelectionLeading + self.rowSelectionTrailing = rowSelectionTrailing + self.strongSelectionFill = strongSelectionFill + self.selectionAccent = selectionAccent + } + + public static let fallback = MoriChromePalette( + isDark: true, + windowBackground: MoriChromeColor(nsColor: .windowBackgroundColor), + sidebarBackground: MoriChromeColor(nsColor: .controlBackgroundColor), + panelBackground: MoriChromeColor(nsColor: .underPageBackgroundColor), + headerBackground: MoriChromeColor(nsColor: .controlBackgroundColor), + cardBackground: MoriChromeColor(nsColor: .controlBackgroundColor), + divider: MoriChromeColor(nsColor: NSColor.separatorColor.withAlphaComponent(0.7)), + hoverFill: MoriChromeColor(nsColor: NSColor.labelColor.withAlphaComponent(0.10)), + inactiveIconFill: MoriChromeColor(nsColor: NSColor.labelColor.withAlphaComponent(0.08)), + shortcutPillFill: MoriChromeColor(nsColor: NSColor.labelColor.withAlphaComponent(0.08)), + rowSelectionLeading: MoriChromeColor(nsColor: NSColor.controlAccentColor.withAlphaComponent(0.18)), + rowSelectionTrailing: MoriChromeColor(nsColor: NSColor.controlAccentColor.withAlphaComponent(0.03)), + strongSelectionFill: MoriChromeColor(nsColor: NSColor.controlAccentColor), + selectionAccent: MoriChromeColor(nsColor: NSColor.controlAccentColor) + ) +} + +public final class MoriChromePaletteStore: ObservableObject { + @Published public var palette: MoriChromePalette + + public init(palette: MoriChromePalette = .fallback) { + self.palette = palette + } +} diff --git a/Packages/MoriUI/Sources/MoriUI/PaneTileView.swift b/Packages/MoriUI/Sources/MoriUI/PaneTileView.swift index 21f62c7..237ab5d 100644 --- a/Packages/MoriUI/Sources/MoriUI/PaneTileView.swift +++ b/Packages/MoriUI/Sources/MoriUI/PaneTileView.swift @@ -11,6 +11,8 @@ public struct PaneTileView: View { let agentState: AgentState let output: String + @EnvironmentObject private var chromePaletteStore: MoriChromePaletteStore + public init( agentName: String, windowTitle: String, @@ -55,9 +57,11 @@ public struct PaneTileView: View { } .padding(.horizontal, MoriTokens.Spacing.lg) .padding(.vertical, MoriTokens.Spacing.sm) - .background(MoriTokens.Color.muted.opacity(MoriTokens.Opacity.subtle)) + .background(chromePaletteStore.palette.headerBackground.color) - Divider() + Rectangle() + .fill(MoriTokens.Chrome.divider(chromePaletteStore.palette)) + .frame(height: 1) // Output ScrollView(.vertical) { @@ -74,11 +78,11 @@ public struct PaneTileView: View { } } } - .background(Color(nsColor: .controlBackgroundColor)) + .background(MoriTokens.Chrome.cardBackground(chromePaletteStore.palette)) .clipShape(RoundedRectangle(cornerRadius: MoriTokens.Radius.medium)) .overlay( RoundedRectangle(cornerRadius: MoriTokens.Radius.medium) - .stroke(MoriTokens.Color.muted.opacity(MoriTokens.Opacity.medium), lineWidth: 1) + .stroke(MoriTokens.Chrome.divider(chromePaletteStore.palette), lineWidth: 1) ) } diff --git a/Packages/MoriUI/Sources/MoriUI/Resources/en.lproj/Localizable.strings b/Packages/MoriUI/Sources/MoriUI/Resources/en.lproj/Localizable.strings index 9427519..e54bad2 100644 --- a/Packages/MoriUI/Sources/MoriUI/Resources/en.lproj/Localizable.strings +++ b/Packages/MoriUI/Sources/MoriUI/Resources/en.lproj/Localizable.strings @@ -33,6 +33,14 @@ "Close Window" = "Close Window"; "Codex CLI" = "Codex CLI"; "Color theme" = "Color theme"; +"Theme mode" = "Theme mode"; +"Choose a fixed light theme, a fixed dark theme, or switch automatically." = "Choose a fixed light theme, a fixed dark theme, or switch automatically."; +"Pick the theme used when Theme mode is Light." = "Pick the theme used when Theme mode is Light."; +"Pick the theme used when Theme mode is Dark." = "Pick the theme used when Theme mode is Dark."; +"Auto uses the themes you set in Light and Dark mode." = "Auto uses the themes you set in Light and Dark mode."; +"Light" = "Light"; +"Dark" = "Dark"; +"Auto" = "Auto"; "Command %lld" = "Command %lld"; "Command Palette" = "Command Palette"; "Command Palette (⇧⌘P)" = "Command Palette (⇧⌘P)"; diff --git a/Packages/MoriUI/Sources/MoriUI/Resources/zh-Hans.lproj/Localizable.strings b/Packages/MoriUI/Sources/MoriUI/Resources/zh-Hans.lproj/Localizable.strings index 6673ecb..3e8e463 100644 --- a/Packages/MoriUI/Sources/MoriUI/Resources/zh-Hans.lproj/Localizable.strings +++ b/Packages/MoriUI/Sources/MoriUI/Resources/zh-Hans.lproj/Localizable.strings @@ -33,6 +33,14 @@ "Close Window" = "关闭窗口"; "Codex CLI" = "Codex CLI"; "Color theme" = "颜色主题"; +"Theme mode" = "主题模式"; +"Choose a fixed light theme, a fixed dark theme, or switch automatically." = "选择固定的浅色主题、固定的深色主题,或自动切换。"; +"Pick the theme used when Theme mode is Light." = "选择 Theme mode 为浅色时使用的主题。"; +"Pick the theme used when Theme mode is Dark." = "选择 Theme mode 为深色时使用的主题。"; +"Auto uses the themes you set in Light and Dark mode." = "自动模式会使用你在浅色和深色模式下分别设置的主题。"; +"Light" = "浅色"; +"Dark" = "深色"; +"Auto" = "自动"; "Command %lld" = "命令 %lld"; "Command Palette" = "命令面板"; "Command Palette (⇧⌘P)" = "命令面板 (⇧⌘P)"; diff --git a/Packages/MoriUI/Sources/MoriUI/SidebarContainerView.swift b/Packages/MoriUI/Sources/MoriUI/SidebarContainerView.swift index 280745a..51f17ec 100644 --- a/Packages/MoriUI/Sources/MoriUI/SidebarContainerView.swift +++ b/Packages/MoriUI/Sources/MoriUI/SidebarContainerView.swift @@ -80,6 +80,7 @@ public struct SidebarContainerView: View { } /// Shared Cmd-hold shortcut hint monitor — one instance for the entire sidebar. + @EnvironmentObject private var chromePaletteStore: MoriChromePaletteStore @StateObject private var shortcutHintMonitor = ShortcutHintModifierMonitor() public var body: some View { @@ -109,6 +110,7 @@ public struct SidebarContainerView: View { onReorderProjects: onReorderProjects ) .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(chromePaletteStore.palette.sidebarBackground.color) .onAppear { shortcutHintMonitor.start() } diff --git a/Packages/MoriUI/Sources/MoriUI/TaskWorktreeRowView.swift b/Packages/MoriUI/Sources/MoriUI/TaskWorktreeRowView.swift index 71afa97..29f56a7 100644 --- a/Packages/MoriUI/Sources/MoriUI/TaskWorktreeRowView.swift +++ b/Packages/MoriUI/Sources/MoriUI/TaskWorktreeRowView.swift @@ -11,6 +11,7 @@ public struct TaskWorktreeRowView: View { let shortcutHintsVisible: Bool let onSelect: () -> Void + @EnvironmentObject private var chromePaletteStore: MoriChromePaletteStore @State private var isHovered = false public init( @@ -37,9 +38,7 @@ public struct TaskWorktreeRowView: View { // Icon box ZStack { RoundedRectangle(cornerRadius: MoriTokens.Icon.worktreeBoxRadius) - .fill(isSelected - ? MoriTokens.Color.active.opacity(MoriTokens.Opacity.light) - : MoriTokens.Color.muted.opacity(MoriTokens.Opacity.subtle)) + .fill(MoriTokens.Chrome.iconBackground(selected: isSelected, palette: chromePaletteStore.palette)) .frame(width: MoriTokens.Icon.worktreeBox, height: MoriTokens.Icon.worktreeBox) Image(systemName: worktreeIcon) .font(.system(size: 13, weight: .medium)) @@ -141,9 +140,9 @@ public struct TaskWorktreeRowView: View { private var rowBackground: some ShapeStyle { if isSelected { - return AnyShapeStyle(MoriTokens.Color.active.opacity(MoriTokens.Opacity.light)) + return AnyShapeStyle(MoriTokens.Chrome.rowSelectionFill(chromePaletteStore.palette)) } else if isHovered { - return AnyShapeStyle(MoriTokens.Color.muted.opacity(MoriTokens.Opacity.subtle)) + return AnyShapeStyle(MoriTokens.Chrome.rowHoverFill(chromePaletteStore.palette)) } else { return AnyShapeStyle(Color.clear) } @@ -177,7 +176,7 @@ public struct TaskWorktreeRowView: View { } .padding(.horizontal, MoriTokens.Spacing.sm) .padding(.vertical, MoriTokens.Spacing.xxs) - .background(MoriTokens.Color.muted.opacity(MoriTokens.Opacity.subtle)) + .background(MoriTokens.Chrome.shortcutPillFill(chromePaletteStore.palette)) .clipShape(RoundedRectangle(cornerRadius: MoriTokens.Radius.small)) } } diff --git a/Packages/MoriUI/Sources/MoriUI/WindowRowView.swift b/Packages/MoriUI/Sources/MoriUI/WindowRowView.swift index c0a1afb..1ed3bb6 100644 --- a/Packages/MoriUI/Sources/MoriUI/WindowRowView.swift +++ b/Packages/MoriUI/Sources/MoriUI/WindowRowView.swift @@ -11,6 +11,7 @@ public struct WindowRowView: View { let onRequestPaneOutput: ((String, @escaping (String?) -> Void) -> Void)? let onSendKeys: ((String, String) -> Void)? + @EnvironmentObject private var chromePaletteStore: MoriChromePaletteStore @State private var isHovered = false @State private var showPopover = false @State private var popoverOutput: String? @@ -110,7 +111,7 @@ public struct WindowRowView: View { .foregroundStyle(MoriTokens.Color.muted) .padding(.horizontal, MoriTokens.Spacing.sm) .padding(.vertical, MoriTokens.Spacing.xxs) - .background(MoriTokens.Color.muted.opacity(0.04)) + .background(MoriTokens.Chrome.shortcutPillFill(chromePaletteStore.palette)) .clipShape(RoundedRectangle(cornerRadius: MoriTokens.Radius.badge)) .accessibilityLabel("Command Option \(shortcutIndex)") } @@ -153,9 +154,9 @@ public struct WindowRowView: View { private var rowBackground: some ShapeStyle { if isActive { - return AnyShapeStyle(MoriTokens.Color.active.opacity(MoriTokens.Opacity.subtle)) + return AnyShapeStyle(MoriTokens.Chrome.rowSelectionFill(chromePaletteStore.palette)) } else if isHovered { - return AnyShapeStyle(MoriTokens.Color.muted.opacity(MoriTokens.Opacity.subtle)) + return AnyShapeStyle(MoriTokens.Chrome.rowHoverFill(chromePaletteStore.palette)) } else { return AnyShapeStyle(Color.clear) } diff --git a/Packages/MoriUI/Sources/MoriUI/WorktreeRowView.swift b/Packages/MoriUI/Sources/MoriUI/WorktreeRowView.swift index 2e6b1ef..2b11588 100644 --- a/Packages/MoriUI/Sources/MoriUI/WorktreeRowView.swift +++ b/Packages/MoriUI/Sources/MoriUI/WorktreeRowView.swift @@ -16,6 +16,7 @@ public struct WorktreeRowView: View { let onSelect: () -> Void var onRemove: (() -> Void)? + @EnvironmentObject private var chromePaletteStore: MoriChromePaletteStore @State private var isHovered = false public init( @@ -71,7 +72,7 @@ public struct WorktreeRowView: View { // 2pt accent bar, inset 6pt top/bottom, rounded on the trailing edge. // Mirrors `.wt.sel::before` in the V1 design. Rectangle() - .fill(MoriTokens.Color.active) + .fill(MoriTokens.Chrome.selectionAccent(chromePaletteStore.palette)) .frame(width: 2) .padding(.vertical, 6) .clipShape(RoundedRectangle(cornerRadius: 1)) @@ -88,9 +89,7 @@ public struct WorktreeRowView: View { private var iconView: some View { ZStack { RoundedRectangle(cornerRadius: MoriTokens.Icon.worktreeBoxRadius) - .fill(isSelected - ? MoriTokens.Color.active.opacity(MoriTokens.Opacity.light) - : MoriTokens.Color.muted.opacity(MoriTokens.Opacity.subtle)) + .fill(MoriTokens.Chrome.iconBackground(selected: isSelected, palette: chromePaletteStore.palette)) .frame(width: MoriTokens.Icon.worktreeBox, height: MoriTokens.Icon.worktreeBox) Image(systemName: worktreeIcon) .font(.system(size: 13, weight: .medium)) @@ -200,18 +199,10 @@ public struct WorktreeRowView: View { if isSelected { // Left-anchored gradient fade — accent fog on the left, clear on the right. // Gives the selected row real presence without a flat tinted block. - let gradient = LinearGradient( - gradient: Gradient(colors: [ - MoriTokens.Color.active.opacity(MoriTokens.Opacity.light), - Color.clear - ]), - startPoint: .leading, - endPoint: .trailing - ) - return AnyShapeStyle(gradient) + return AnyShapeStyle(MoriTokens.Chrome.rowSelectionGradient(chromePaletteStore.palette)) } if isHovered { - return AnyShapeStyle(MoriTokens.Color.muted.opacity(MoriTokens.Opacity.subtle)) + return AnyShapeStyle(MoriTokens.Chrome.rowHoverFill(chromePaletteStore.palette)) } return AnyShapeStyle(Color.clear) } diff --git a/Sources/Mori/App/AgentDashboardPanel.swift b/Sources/Mori/App/AgentDashboardPanel.swift index 52b4c4a..3951348 100644 --- a/Sources/Mori/App/AgentDashboardPanel.swift +++ b/Sources/Mori/App/AgentDashboardPanel.swift @@ -13,6 +13,9 @@ final class AgentDashboardPanel: NSObject, NSWindowDelegate { private weak var workspaceManager: WorkspaceManager? private let paneOutputCache: PaneOutputCache private let tilesModel = MultiPaneDashboardView.Model() + private let chromePaletteStore = MoriChromePaletteStore() + private var chromePalette: MoriChromePalette = .fallback + private var currentAppearance: NSAppearance? init(workspaceManager: WorkspaceManager, paneOutputCache: PaneOutputCache) { self.workspaceManager = workspaceManager @@ -20,10 +23,15 @@ final class AgentDashboardPanel: NSObject, NSWindowDelegate { } /// Sync panel appearance with the Ghostty terminal theme. - func updateAppearance(themeInfo: GhosttyThemeInfo) { + func updateAppearance(themeInfo: GhosttyThemeInfo, chromePalette: MoriChromePalette) { + self.chromePalette = chromePalette + chromePaletteStore.palette = chromePalette + let appearance = NSAppearance(named: themeInfo.isDark ? .darkAqua : .aqua) + currentAppearance = appearance guard let panel else { return } - panel.appearance = NSAppearance(named: themeInfo.isDark ? .darkAqua : .aqua) - panel.backgroundColor = themeInfo.background + panel.appearance = appearance + panel.backgroundColor = chromePalette.panelBackground.nsColor + panel.contentView?.layer?.backgroundColor = chromePalette.panelBackground.nsColor.cgColor } var isVisible: Bool { @@ -71,13 +79,18 @@ final class AgentDashboardPanel: NSObject, NSWindowDelegate { panel.delegate = self panel.center() panel.minSize = NSSize(width: 400, height: 300) + panel.isOpaque = true + panel.backgroundColor = chromePalette.panelBackground.nsColor + panel.appearance = currentAppearance let view = MultiPaneDashboardView(model: self.tilesModel) - let hostingView = NSHostingView(rootView: view) + let hostingView = NSHostingView(rootView: view.environmentObject(chromePaletteStore)) hostingView.translatesAutoresizingMaskIntoConstraints = false // Use a wrapper view so auto layout anchors the hosting view to fill the panel let wrapper = NSView(frame: contentRect) + wrapper.wantsLayer = true + wrapper.layer?.backgroundColor = chromePalette.panelBackground.nsColor.cgColor wrapper.addSubview(hostingView) NSLayoutConstraint.activate([ hostingView.topAnchor.constraint(equalTo: wrapper.topAnchor), diff --git a/Sources/Mori/App/AppDelegate.swift b/Sources/Mori/App/AppDelegate.swift index 872a71f..6e05324 100644 --- a/Sources/Mori/App/AppDelegate.swift +++ b/Sources/Mori/App/AppDelegate.swift @@ -27,6 +27,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private var worktreeCreationController: WorktreeCreationController? private let sidebarPaneOutputCache = PaneOutputCache() private var settingsWindowController: NSWindowController? + private var settingsChromePaletteStore: MoriChromePaletteStore? private var configFile: GhosttyConfigFile? private var proxyApplyTask: Task? private var tmuxConfigurationApplyTasks: [ObjectIdentifier: Task] = [:] @@ -34,6 +35,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private var remoteConnectWizardController: RemoteConnectWizardController? private var updateController: UpdateController? private var agentDashboardPanel: AgentDashboardPanel? + private var appearanceChangeObserver: NSKeyValueObservation? private var keyBindingStore: KeyBindingStore! private var configurableMenuItems: [String: NSMenuItem] = [:] private var keyMonitorActionMap: [String: () -> Void] = [:] @@ -118,11 +120,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent self?.handleGhosttyAction(action) } } + installSystemAppearanceObserver() let themeInfo = terminalArea.themeInfo + let chromePalette = MoriChromeThemeBuilder.palette(from: themeInfo) // Build the window with ghostty theme - let windowController = MainWindowController(themeInfo: themeInfo) + let windowController = MainWindowController(themeInfo: themeInfo, chromePalette: chromePalette) self.mainWindowController = windowController // Build split view children @@ -227,11 +231,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent }, onReorderProjects: { [weak manager] orderedIds in manager?.reorderProjects(orderedIds) - } + }, + chromePalette: chromePalette ) self.sidebarController = sidebarController - sidebarController.updateAppearance(themeInfo: themeInfo) + sidebarController.updateAppearance(themeInfo: themeInfo, chromePalette: chromePalette) let splitVC = RootSplitViewController( sidebarController: sidebarController, @@ -239,6 +244,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent companionController: companionTool ) self.rootSplitVC = splitVC + splitVC.updateAppearance(chromePalette: chromePalette) companionToolState.width = splitVC.currentCompanionWidth splitVC.onCompanionWidthChanged = { [weak self] width in self?.companionToolState.width = width @@ -269,7 +275,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let adapter = terminalArea?.terminalHost as? GhosttyAdapter, let window = windowController?.window else { return } adapter.syncWorkspaceWindowAppearance(window) - self.refreshGhosttyThemeBackgrounds(themeInfo: adapter.themeInfo) + let themeInfo = adapter.themeInfo + let chromePalette = MoriChromeThemeBuilder.palette(from: themeInfo) + self.refreshGhosttyThemeBackgrounds(themeInfo: themeInfo, chromePalette: chromePalette) } windowController.contentViewController = splitVC @@ -278,7 +286,12 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let window = windowController.window { adapter.syncWorkspaceWindowAppearance(window) } - companionTool.updateAppearance(themeInfo: themeInfo, isKeyWindow: windowController.window?.isKeyWindow ?? true) + refreshGhosttyThemeBackgrounds(themeInfo: themeInfo, chromePalette: chromePalette) + companionTool.updateAppearance( + themeInfo: themeInfo, + chromePalette: chromePalette, + isKeyWindow: windowController.window?.isKeyWindow ?? true + ) // Restore saved frame after all layout is complete windowController.restoreSavedFrame() NSApp.activate(ignoringOtherApps: true) @@ -403,6 +416,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent // Persist UI state before exit workspaceManager?.saveUIStateOnTerminate() + appearanceChangeObserver?.invalidate() + appearanceChangeObserver = nil + // Clean up terminal surfaces terminalAreaController?.removeAllSurfaces() } @@ -684,11 +700,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let controller = worktreeCreationController! let themeInfo = terminalAreaController?.themeInfo ?? .fallback + let chromePalette = MoriChromeThemeBuilder.palette(from: themeInfo) controller.show( projects: state.projects, selectedProjectId: projectId, repoPath: project.repoRootPath, - themeInfo: themeInfo + themeInfo: themeInfo, + chromePalette: chromePalette ) } @@ -720,10 +738,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let themes = GhosttyConfigFile.availableThemes() let ghosttyDefaults = GhosttyConfigFile.defaultKeybinds() let themeInfo = terminalAreaController?.themeInfo ?? .fallback + let chromePalette = MoriChromeThemeBuilder.palette(from: themeInfo) + let chromePaletteStore = MoriChromePaletteStore(palette: chromePalette) + self.settingsChromePaletteStore = chromePaletteStore let store = self.keyBindingStore! let settingsView = SettingsWindowContent( - initial: readSettingsModel(from: cf), + initial: readSettingsModel(from: cf, themeInfo: themeInfo), availableThemes: themes, ghosttyDefaults: ghosttyDefaults, initialAgentHooks: AgentHookModel( @@ -803,12 +824,13 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent }, keyBindingsRefresh: { store.bindings - } + }, + chromePaletteStore: chromePaletteStore ) let hostingController = NSHostingController(rootView: settingsView) hostingController.view.wantsLayer = true - hostingController.view.layer?.backgroundColor = themeInfo.background.cgColor + hostingController.view.layer?.backgroundColor = chromePalette.panelBackground.nsColor.cgColor let window = NSWindow(contentViewController: hostingController) window.title = .localized("Settings") window.styleMask = [.titled, .closable, .fullSizeContentView] @@ -817,9 +839,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent if let adapter = terminalAreaController?.terminalHost as? GhosttyAdapter { adapter.syncThemedWindowAppearance(window) } else { - window.backgroundColor = themeInfo.background window.appearance = NSAppearance(named: themeInfo.isDark ? .darkAqua : .aqua) } + window.backgroundColor = chromePalette.panelBackground.nsColor window.center() window.setFrameAutosaveName("MoriSettings") @@ -828,11 +850,14 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent controller.showWindow(nil) } - private func readSettingsModel(from cf: GhosttyConfigFile) -> GhosttySettingsModel { + private func readSettingsModel(from cf: GhosttyConfigFile, themeInfo: GhosttyThemeInfo) -> GhosttySettingsModel { GhosttySettingsModel( fontFamily: cf.get("font-family") ?? "", fontSize: Int(cf.get("font-size") ?? "") ?? 13, - theme: cf.get("theme") ?? "", + theme: GhosttyThemeSelection( + configValue: cf.get("theme") ?? "", + inferredSingleMode: themeInfo.isDark ? .dark : .light + ), cursorStyle: cf.get("cursor-style") ?? "block", cursorBlink: (cf.get("cursor-style-blink") ?? "true") != "false", backgroundOpacity: Double(cf.get("background-opacity") ?? "1.0") ?? 1.0, @@ -857,10 +882,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } cf.set("font-size", value: "\(model.fontSize)") - if model.theme.isEmpty { - cf.remove("theme") + if let themeValue = model.theme.configValue { + cf.set("theme", value: themeValue) } else { - cf.set("theme", value: model.theme) + cf.remove("theme") } cf.set("cursor-style", value: model.cursorStyle) @@ -875,21 +900,41 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent cf.set("window-padding-balance", value: model.windowPaddingBalance ? "true" : "false") } + private func installSystemAppearanceObserver() { + appearanceChangeObserver = NSApp.observe(\.effectiveAppearance, options: [.new, .initial]) { [weak self] _, change in + guard let appearance = change.newValue else { return } + let isDark = appearance.bestMatch(from: [.darkAqua, .aqua]) == .darkAqua + Task { @MainActor [weak self] in + self?.handleSystemAppearanceChange(isDark: isDark) + } + } + } + + private func handleSystemAppearanceChange(isDark: Bool) { + guard let adapter = terminalAreaController?.terminalHost as? GhosttyAdapter else { return } + adapter.setColorScheme(isDark: isDark) + syncGhosttyAppearance(adapter: adapter) + } + /// Reload ghostty config and sync theme to window/sidebar/tmux. private func reloadGhosttyConfig() { guard let adapter = terminalAreaController?.terminalHost as? GhosttyAdapter else { return } adapter.reloadConfig() + syncGhosttyAppearance(adapter: adapter) + } + private func syncGhosttyAppearance(adapter: GhosttyAdapter) { let themeInfo = adapter.themeInfo + let chromePalette = MoriChromeThemeBuilder.palette(from: themeInfo) if let window = mainWindowController?.window { adapter.syncWorkspaceWindowAppearance(window) } - refreshGhosttyThemeBackgrounds(themeInfo: themeInfo) + refreshGhosttyThemeBackgrounds(themeInfo: themeInfo, chromePalette: chromePalette) - refreshSettingsWindowAppearance(adapter: adapter, themeInfo: themeInfo) + refreshSettingsWindowAppearance(adapter: adapter, themeInfo: themeInfo, chromePalette: chromePalette) // Update agent dashboard appearance - agentDashboardPanel?.updateAppearance(themeInfo: themeInfo) + agentDashboardPanel?.updateAppearance(themeInfo: themeInfo, chromePalette: chromePalette) // Sync to tmux if let manager = workspaceManager { @@ -926,18 +971,27 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } } - private func refreshGhosttyThemeBackgrounds(themeInfo: GhosttyThemeInfo) { + private func refreshGhosttyThemeBackgrounds(themeInfo: GhosttyThemeInfo, chromePalette: MoriChromePalette) { let isKeyWindow = mainWindowController?.window?.isKeyWindow ?? true - sidebarController?.updateAppearance(themeInfo: themeInfo) + mainWindowController?.updateAppearance(themeInfo: themeInfo, chromePalette: chromePalette) + rootSplitVC?.updateAppearance(chromePalette: chromePalette) + sidebarController?.updateAppearance(themeInfo: themeInfo, chromePalette: chromePalette) terminalAreaController?.updateAppearance(themeInfo: themeInfo, isKeyWindow: isKeyWindow) - companionToolController?.updateAppearance(themeInfo: themeInfo, isKeyWindow: isKeyWindow) + companionToolController?.updateAppearance(themeInfo: themeInfo, chromePalette: chromePalette, isKeyWindow: isKeyWindow) + worktreeCreationController?.updateAppearance(themeInfo: themeInfo, chromePalette: chromePalette) } - private func refreshSettingsWindowAppearance(adapter: GhosttyAdapter, themeInfo: GhosttyThemeInfo) { + private func refreshSettingsWindowAppearance( + adapter: GhosttyAdapter, + themeInfo: GhosttyThemeInfo, + chromePalette: MoriChromePalette + ) { guard let settingsWindow = settingsWindowController?.window else { return } + settingsChromePaletteStore?.palette = chromePalette adapter.syncThemedWindowAppearance(settingsWindow) + settingsWindow.backgroundColor = chromePalette.panelBackground.nsColor settingsWindow.contentViewController?.view.wantsLayer = true - settingsWindow.contentViewController?.view.layer?.backgroundColor = themeInfo.background.cgColor + settingsWindow.contentViewController?.view.layer?.backgroundColor = chromePalette.panelBackground.nsColor.cgColor } // MARK: - Proxy @@ -1316,10 +1370,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent paneOutputCache: PaneOutputCache() ) } - agentDashboardPanel?.toggle() - // Sync appearance with Ghostty terminal theme + // Sync appearance with Ghostty terminal theme before showing the panel. let themeInfo = terminalAreaController?.themeInfo ?? .fallback - agentDashboardPanel?.updateAppearance(themeInfo: themeInfo) + let chromePalette = MoriChromeThemeBuilder.palette(from: themeInfo) + agentDashboardPanel?.updateAppearance(themeInfo: themeInfo, chromePalette: chromePalette) + agentDashboardPanel?.toggle() } private func toggleCompanionTool(_ tool: CompanionTool) { @@ -1805,6 +1860,7 @@ private struct SettingsWindowContent: View { var onKeyBindingReset: ((String) -> Void)? var onKeyBindingResetAll: (() -> Void)? var keyBindingsRefresh: (() -> [KeyBinding])? + let chromePaletteStore: MoriChromePaletteStore init( initial: GhosttySettingsModel, @@ -1825,7 +1881,8 @@ private struct SettingsWindowContent: View { onKeyBindingUpdate: ((KeyBinding) -> Void)? = nil, onKeyBindingReset: ((String) -> Void)? = nil, onKeyBindingResetAll: (() -> Void)? = nil, - keyBindingsRefresh: (() -> [KeyBinding])? = nil + keyBindingsRefresh: (() -> [KeyBinding])? = nil, + chromePaletteStore: MoriChromePaletteStore = MoriChromePaletteStore() ) { self._model = State(initialValue: initial) self._agentHooks = State(initialValue: initialAgentHooks) @@ -1846,6 +1903,7 @@ private struct SettingsWindowContent: View { self.onKeyBindingReset = onKeyBindingReset self.onKeyBindingResetAll = onKeyBindingResetAll self.keyBindingsRefresh = keyBindingsRefresh + self.chromePaletteStore = chromePaletteStore } var body: some View { @@ -1878,5 +1936,6 @@ private struct SettingsWindowContent: View { if let refresh = keyBindingsRefresh { keyBindings = refresh() } } ) + .environmentObject(chromePaletteStore) } } diff --git a/Sources/Mori/App/CompanionToolPaneController.swift b/Sources/Mori/App/CompanionToolPaneController.swift index e2831f4..6508114 100644 --- a/Sources/Mori/App/CompanionToolPaneController.swift +++ b/Sources/Mori/App/CompanionToolPaneController.swift @@ -1,6 +1,7 @@ import AppKit import MoriCore import MoriTerminal +import MoriUI @MainActor enum CompanionTool: String, CaseIterable { @@ -147,19 +148,12 @@ final class CompanionToolPaneController: NSViewController { return responder.isDescendant(of: view) } - func updateAppearance(themeInfo: GhosttyThemeInfo, isKeyWindow: Bool) { + func updateAppearance(themeInfo: GhosttyThemeInfo, chromePalette: MoriChromePalette, isKeyWindow: Bool) { view.appearance = NSAppearance(named: themeInfo.isDark ? .darkAqua : .aqua) - view.layer?.backgroundColor = themeInfo.effectiveBackground.cgColor - headerView.layer?.backgroundColor = headerBackgroundColor(for: themeInfo).cgColor - dividerView.layer?.backgroundColor = NSColor.separatorColor.withAlphaComponent(0.45).cgColor + view.layer?.backgroundColor = chromePalette.panelBackground.nsColor.cgColor + headerView.layer?.backgroundColor = chromePalette.headerBackground.nsColor.cgColor + dividerView.layer?.backgroundColor = chromePalette.divider.nsColor.cgColor titleLabel.textColor = .secondaryLabelColor terminalController.updateAppearance(themeInfo: themeInfo, isKeyWindow: isKeyWindow) } - - private func headerBackgroundColor(for themeInfo: GhosttyThemeInfo) -> NSColor { - let base = themeInfo.effectiveBackground.usingColorSpace(.deviceRGB) ?? themeInfo.effectiveBackground - let blend: CGFloat = themeInfo.isDark ? 0.12 : 0.06 - let tint = themeInfo.isDark ? NSColor.white : NSColor.black - return base.blended(withFraction: blend, of: tint) ?? base - } } diff --git a/Sources/Mori/App/HostingControllers.swift b/Sources/Mori/App/HostingControllers.swift index 46a057d..06e1ad4 100644 --- a/Sources/Mori/App/HostingControllers.swift +++ b/Sources/Mori/App/HostingControllers.swift @@ -11,6 +11,7 @@ import MoriUI final class SidebarHostingController: NSHostingController { private let appState: AppState + private let chromePaletteStore: MoriChromePaletteStore init( appState: AppState, @@ -29,11 +30,14 @@ final class SidebarHostingController: NSHostingController { onRequestPaneOutput: ((String, @escaping (String?) -> Void) -> Void)? = nil, onSendKeys: ((String, String) -> Void)? = nil, onUpdateProject: ((Project) -> Void)? = nil, - onReorderProjects: (([UUID]) -> Void)? = nil + onReorderProjects: (([UUID]) -> Void)? = nil, + chromePalette: MoriChromePalette = .fallback ) { self.appState = appState + self.chromePaletteStore = MoriChromePaletteStore(palette: chromePalette) let rootView = SidebarContentView( appState: appState, + chromePaletteStore: chromePaletteStore, onSelectProject: onSelectProject, onSelectWorktree: onSelectWorktree, onSelectWindow: onSelectWindow, @@ -66,10 +70,10 @@ final class SidebarHostingController: NSHostingController { } /// Sync the hosting controller's view appearance with the ghostty theme. - func updateAppearance(themeInfo: GhosttyThemeInfo) { + func updateAppearance(themeInfo: GhosttyThemeInfo, chromePalette: MoriChromePalette) { + chromePaletteStore.palette = chromePalette view.appearance = NSAppearance(named: themeInfo.isDark ? .darkAqua : .aqua) - view.layer?.backgroundColor = themeInfo.effectiveBackground.cgColor - // Force SwiftUI to re-render with the updated appearance context. + view.layer?.backgroundColor = chromePalette.sidebarBackground.nsColor.cgColor view.needsDisplay = true } } @@ -77,6 +81,7 @@ final class SidebarHostingController: NSHostingController { /// Bindable wrapper that reads AppState observables into SidebarContainerView. struct SidebarContentView: View { @Bindable var appState: AppState + let chromePaletteStore: MoriChromePaletteStore let onSelectProject: (UUID) -> Void let onSelectWorktree: (UUID) -> Void let onSelectWindow: (String) -> Void @@ -119,5 +124,6 @@ struct SidebarContentView: View { onUpdateProject: onUpdateProject, onReorderProjects: onReorderProjects ) + .environmentObject(chromePaletteStore) } } diff --git a/Sources/Mori/App/MainWindowController.swift b/Sources/Mori/App/MainWindowController.swift index 3efc6cd..595d866 100644 --- a/Sources/Mori/App/MainWindowController.swift +++ b/Sources/Mori/App/MainWindowController.swift @@ -64,7 +64,7 @@ final class MainWindowController: NSWindowController { // MARK: - Init - init(themeInfo: GhosttyThemeInfo = .fallback) { + init(themeInfo: GhosttyThemeInfo = .fallback, chromePalette: MoriChromePalette = .fallback) { let window = NSWindow( contentRect: NSRect(x: 0, y: 0, width: 1200, height: 800), styleMask: [.titled, .closable, .miniaturizable, .resizable], @@ -75,7 +75,7 @@ final class MainWindowController: NSWindowController { window.title = "Mori" window.titleVisibility = .hidden window.titlebarAppearsTransparent = true - window.backgroundColor = themeInfo.effectiveBackground + window.backgroundColor = chromePalette.windowBackground.nsColor window.appearance = NSAppearance(named: themeInfo.isDark ? .darkAqua : .aqua) window.center() @@ -112,6 +112,14 @@ final class MainWindowController: NSWindowController { onShowCreateWorktreePanel?() } + func updateAppearance(themeInfo: GhosttyThemeInfo, chromePalette: MoriChromePalette) { + window?.appearance = NSAppearance(named: themeInfo.isDark ? .darkAqua : .aqua) + // Skip background when transparency is active — syncWorkspaceWindowAppearance owns that. + if !themeInfo.usesTransparentWindowBackground { + window?.backgroundColor = chromePalette.windowBackground.nsColor + } + } + func addUpdateAccessory(viewModel: UpdateViewModel) { guard let window else { return } guard let themeFrame = window.contentView?.superview else { return } diff --git a/Sources/Mori/App/MoriChromeThemeBuilder.swift b/Sources/Mori/App/MoriChromeThemeBuilder.swift new file mode 100644 index 0000000..0b43b94 --- /dev/null +++ b/Sources/Mori/App/MoriChromeThemeBuilder.swift @@ -0,0 +1,160 @@ +import AppKit +import MoriTerminal +import MoriUI + +@MainActor +enum MoriChromeThemeBuilder { + static func palette(from themeInfo: GhosttyThemeInfo) -> MoriChromePalette { + let ghosttyBackground = themeInfo.background.usingColorSpace(.sRGB) ?? themeInfo.background + let windowBase = themeInfo.isDark + ? NSColor(srgbRed: 0.12, green: 0.125, blue: 0.14, alpha: 1) + : NSColor(srgbRed: 0.955, green: 0.96, blue: 0.972, alpha: 1) + let sidebarBase = themeInfo.isDark + ? NSColor(srgbRed: 0.10, green: 0.105, blue: 0.12, alpha: 1) + : NSColor(srgbRed: 0.93, green: 0.94, blue: 0.955, alpha: 1) + let panelBase = themeInfo.isDark + ? NSColor(srgbRed: 0.145, green: 0.15, blue: 0.17, alpha: 1) + : NSColor(srgbRed: 0.975, green: 0.978, blue: 0.986, alpha: 1) + let cardBase = themeInfo.isDark + ? NSColor(srgbRed: 0.165, green: 0.17, blue: 0.19, alpha: 1) + : NSColor(srgbRed: 0.965, green: 0.97, blue: 0.98, alpha: 1) + let separatorBase = themeInfo.isDark + ? NSColor(srgbRed: 1, green: 1, blue: 1, alpha: 0.14) + : NSColor(srgbRed: 0, green: 0, blue: 0, alpha: 0.18) + let labelBase = themeInfo.isDark ? NSColor.white : NSColor.black + let accentFallback = NSColor.systemBlue.usingColorSpace(.sRGB) ?? .systemBlue + let ghosttyAccent = preferredAccent(from: themeInfo) ?? accentFallback + let surfaceTint = ghosttyAccent.moriBlended( + toward: ghosttyBackground, + fraction: themeInfo.isDark ? 0.84 : 0.76 + ) + + var windowBackground = ghosttyBackground.moriBlended( + toward: windowBase, + fraction: themeInfo.isDark ? 0.24 : 0.44 + ) + windowBackground = windowBackground.moriBlended( + toward: surfaceTint, + fraction: themeInfo.isDark ? 0.08 : 0.12 + ) + + var sidebarBackground = windowBackground.moriBlended( + toward: sidebarBase, + fraction: themeInfo.isDark ? 0.22 : 0.38 + ) + sidebarBackground = sidebarBackground.moriBlended( + toward: surfaceTint, + fraction: themeInfo.isDark ? 0.10 : 0.14 + ) + + var panelBackground = windowBackground.moriBlended( + toward: panelBase, + fraction: themeInfo.isDark ? 0.16 : 0.28 + ) + panelBackground = panelBackground.moriBlended( + toward: surfaceTint, + fraction: themeInfo.isDark ? 0.06 : 0.10 + ) + + var headerBackground = panelBackground.moriBlended( + toward: cardBase, + fraction: themeInfo.isDark ? 0.12 : 0.20 + ) + headerBackground = headerBackground.moriBlended( + toward: surfaceTint, + fraction: themeInfo.isDark ? 0.08 : 0.12 + ) + + var cardBackground = panelBackground.moriBlended( + toward: cardBase, + fraction: themeInfo.isDark ? 0.20 : 0.32 + ) + cardBackground = cardBackground.moriBlended( + toward: surfaceTint, + fraction: themeInfo.isDark ? 0.05 : 0.08 + ) + + let selectionAccent = adjustedSelectionAccent( + ghosttyAccent.moriBlended(toward: accentFallback, fraction: 0.22), + against: sidebarBackground, + isDark: themeInfo.isDark + ) + + let divider = sidebarBackground + .moriBlended(toward: separatorBase, fraction: themeInfo.isDark ? 0.62 : 0.88) + .withAlphaComponent(themeInfo.isDark ? 0.72 : 0.95) + let hoverFill = labelBase.withAlphaComponent(themeInfo.isDark ? 0.10 : 0.12) + let inactiveIconFill = labelBase.withAlphaComponent(themeInfo.isDark ? 0.08 : 0.10) + let shortcutPillFill = labelBase.withAlphaComponent(themeInfo.isDark ? 0.10 : 0.12) + let rowSelectionLeading = selectionAccent.withAlphaComponent(themeInfo.isDark ? 0.20 : 0.24) + let rowSelectionTrailing = selectionAccent.withAlphaComponent(themeInfo.isDark ? 0.02 : 0.08) + let strongSelectionFill = selectionAccent.moriBlended( + toward: sidebarBackground, + fraction: themeInfo.isDark ? 0.08 : 0.14 + ) + + let surfaceAlpha = themeInfo.usesTransparentWindowBackground ? themeInfo.backgroundOpacity : 1.0 + + func surface(_ color: NSColor) -> MoriChromeColor { + MoriChromeColor(nsColor: color.withAlphaComponent(color.alphaComponent * surfaceAlpha)) + } + + return MoriChromePalette( + isDark: themeInfo.isDark, + windowBackground: surface(windowBackground), + sidebarBackground: surface(sidebarBackground), + panelBackground: surface(panelBackground), + headerBackground: surface(headerBackground), + cardBackground: surface(cardBackground), + divider: MoriChromeColor(nsColor: divider), + hoverFill: MoriChromeColor(nsColor: hoverFill), + inactiveIconFill: MoriChromeColor(nsColor: inactiveIconFill), + shortcutPillFill: MoriChromeColor(nsColor: shortcutPillFill), + rowSelectionLeading: MoriChromeColor(nsColor: rowSelectionLeading), + rowSelectionTrailing: MoriChromeColor(nsColor: rowSelectionTrailing), + strongSelectionFill: MoriChromeColor(nsColor: strongSelectionFill), + selectionAccent: MoriChromeColor(nsColor: selectionAccent) + ) + } + + private static func preferredAccent(from themeInfo: GhosttyThemeInfo) -> NSColor? { + let candidates = [12, 4, 14, 6] + for index in candidates where themeInfo.palette.indices.contains(index) { + let color = themeInfo.palette[index].usingColorSpace(.sRGB) ?? themeInfo.palette[index] + let luminance = color.moriRelativeLuminance + if luminance > 0.18, luminance < 0.90 { + return color + } + } + return nil + } + + private static func adjustedSelectionAccent(_ color: NSColor, against background: NSColor, isDark: Bool) -> NSColor { + let backgroundLuminance = background.moriRelativeLuminance + var candidate = color.usingColorSpace(.sRGB) ?? color + + for _ in 0..<4 { + let contrast = abs(candidate.moriRelativeLuminance - backgroundLuminance) + if contrast >= (isDark ? 0.36 : 0.28) { + return candidate + } + let target = isDark ? NSColor.white : NSColor.black + candidate = candidate.moriBlended(toward: target, fraction: 0.16) + } + + return candidate + } +} + +private extension NSColor { + var moriRelativeLuminance: CGFloat { + let color = usingColorSpace(.sRGB) ?? self + return 0.2126 * color.redComponent + 0.7152 * color.greenComponent + 0.0722 * color.blueComponent + } + + func moriBlended(toward color: NSColor, fraction: CGFloat) -> NSColor { + let source = usingColorSpace(.sRGB) ?? self + let target = color.usingColorSpace(.sRGB) ?? color + return source.blended(withFraction: fraction, of: target) ?? source + } +} diff --git a/Sources/Mori/App/RootSplitViewController.swift b/Sources/Mori/App/RootSplitViewController.swift index 636baf7..0da4185 100644 --- a/Sources/Mori/App/RootSplitViewController.swift +++ b/Sources/Mori/App/RootSplitViewController.swift @@ -1,4 +1,5 @@ import AppKit +import MoriUI @MainActor final class RootSplitViewController: NSViewController { @@ -27,6 +28,7 @@ final class RootSplitViewController: NSViewController { private var sidebarWidth: CGFloat = 280 private var companionWidth: CGFloat = CompanionToolPaneState.defaultWidth + private var chromePalette: MoriChromePalette = .fallback private var dragTarget: DividerDragTarget? private var collapsed = false private var toolPaneState = CompanionToolPaneState() @@ -51,14 +53,13 @@ final class RootSplitViewController: NSViewController { let root = NSView() root.wantsLayer = true sidebarDividerView.wantsLayer = true - sidebarDividerView.layer?.backgroundColor = NSColor.separatorColor.cgColor companionDividerView.wantsLayer = true - companionDividerView.layer?.backgroundColor = NSColor.separatorColor.cgColor for subview in [sidebarContainer, sidebarDividerView, contentContainer, companionDividerView, companionContainer] { root.addSubview(subview) } self.view = root + updateAppearance(chromePalette: chromePalette) embed(sidebarController, in: sidebarContainer) embed(contentController, in: contentContainer) @@ -219,6 +220,13 @@ final class RootSplitViewController: NSViewController { updateLayout() } + func updateAppearance(chromePalette: MoriChromePalette) { + self.chromePalette = chromePalette + view.layer?.backgroundColor = chromePalette.windowBackground.nsColor.cgColor + sidebarDividerView.layer?.backgroundColor = chromePalette.divider.nsColor.cgColor + companionDividerView.layer?.backgroundColor = chromePalette.divider.nsColor.cgColor + } + func saveSidebarWidth() { guard !collapsed, sidebarWidth > 0 else { return } UserDefaults.standard.set(Double(sidebarWidth), forKey: Self.sidebarWidthKey) diff --git a/Sources/Mori/App/WorktreeCreationController.swift b/Sources/Mori/App/WorktreeCreationController.swift index a7422a6..951fd24 100644 --- a/Sources/Mori/App/WorktreeCreationController.swift +++ b/Sources/Mori/App/WorktreeCreationController.swift @@ -2,6 +2,7 @@ import AppKit import MoriCore import MoriGit import MoriTerminal +import MoriUI // MARK: - Controller @@ -44,6 +45,7 @@ final class WorktreeCreationController: NSWindowController { private let baseBranchPopup = NSPopUpButton() private let createHintLabel = NSTextField(labelWithString: "") private let containerView = NSView() + private var chromePalette: MoriChromePalette = .fallback // MARK: - Layout Constants @@ -112,7 +114,8 @@ final class WorktreeCreationController: NSWindowController { projects: [Project], selectedProjectId: UUID, repoPath: String, - themeInfo: GhosttyThemeInfo + themeInfo: GhosttyThemeInfo, + chromePalette: MoriChromePalette ) { self.projects = projects self.selectedProjectId = selectedProjectId @@ -121,7 +124,7 @@ final class WorktreeCreationController: NSWindowController { branchNameField.stringValue = "" dataSource = nil - applyTheme(themeInfo) + applyTheme(themeInfo: themeInfo, chromePalette: chromePalette) populateProjectPopup() resetBaseBranchPopup() @@ -158,11 +161,17 @@ final class WorktreeCreationController: NSWindowController { // MARK: - Theme - private func applyTheme(_ themeInfo: GhosttyThemeInfo) { + func updateAppearance(themeInfo: GhosttyThemeInfo, chromePalette: MoriChromePalette) { + applyTheme(themeInfo: themeInfo, chromePalette: chromePalette) + } + + private func applyTheme(themeInfo: GhosttyThemeInfo, chromePalette: MoriChromePalette) { guard let panel = window as? NSPanel else { return } - panel.backgroundColor = themeInfo.background + self.chromePalette = chromePalette + panel.backgroundColor = chromePalette.panelBackground.nsColor panel.appearance = NSAppearance(named: themeInfo.isDark ? .darkAqua : .aqua) - containerView.layer?.backgroundColor = themeInfo.background.cgColor + containerView.layer?.backgroundColor = chromePalette.panelBackground.nsColor.cgColor + toolbarContainer.layer?.backgroundColor = chromePalette.headerBackground.nsColor.cgColor } // MARK: - Setup @@ -231,6 +240,7 @@ final class WorktreeCreationController: NSWindowController { private func setupToolbarRow() { toolbarContainer.translatesAutoresizingMaskIntoConstraints = false + toolbarContainer.wantsLayer = true containerView.addSubview(toolbarContainer) // Project popup