From cd4b66275bbea387e0e55016030beaf361d58025 Mon Sep 17 00:00:00 2001 From: Vaayne Date: Mon, 20 Apr 2026 13:07:25 +0800 Subject: [PATCH 1/8] =?UTF-8?q?=F0=9F=8E=A8=20feat:=20derive=20chrome=20fr?= =?UTF-8?q?om=20Ghostty=20theme?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assisted-by: pi:gpt-5.4 --- CHANGELOG.md | 4 + CHANGELOG.zh-Hans.md | 4 + .../Sources/MoriUI/AgentWindowRowView.swift | 5 +- .../MoriUI/Sources/MoriUI/DesignTokens.swift | 47 +++++ .../Sources/MoriUI/GhosttySettingsView.swift | 15 +- .../Sources/MoriUI/MoriChromePalette.swift | 116 +++++++++++ .../MoriUI/Sources/MoriUI/PaneTileView.swift | 12 +- .../Sources/MoriUI/SidebarContainerView.swift | 2 + .../Sources/MoriUI/TaskWorktreeRowView.swift | 11 +- .../MoriUI/Sources/MoriUI/WindowRowView.swift | 7 +- .../Sources/MoriUI/WorktreeRowView.swift | 19 +- Sources/Mori/App/AgentDashboardPanel.swift | 21 +- Sources/Mori/App/AppDelegate.swift | 74 +++++-- .../App/CompanionToolPaneController.swift | 16 +- Sources/Mori/App/HostingControllers.swift | 13 +- Sources/Mori/App/MainWindowController.swift | 9 +- Sources/Mori/App/MoriChromeThemeBuilder.swift | 131 +++++++++++++ .../Mori/App/RootSplitViewController.swift | 12 +- .../Mori/App/WorktreeCreationController.swift | 20 +- plan.md | 182 ++++++++++++++++++ 20 files changed, 639 insertions(+), 81 deletions(-) create mode 100644 Packages/MoriUI/Sources/MoriUI/MoriChromePalette.swift create mode 100644 Sources/Mori/App/MoriChromeThemeBuilder.swift create mode 100644 plan.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 6e8b4eab..eafe8099 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### 🎨 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 + ## [0.4.1] - 2026-04-19 ### ✨ Features diff --git a/CHANGELOG.zh-Hans.md b/CHANGELOG.zh-Hans.md index 72553d71..b59fdecc 100644 --- a/CHANGELOG.zh-Hans.md +++ b/CHANGELOG.zh-Hans.md @@ -7,6 +7,10 @@ ## [Unreleased] +### 🎨 界面优化 + +- Mori 现在会从所选 Ghostty 主题派生应用外壳配色,而不再把原始终端背景直接铺到整套界面上;这样浅色主题在主窗口、侧边栏、设置窗口、右侧工具面板、工作树创建面板、Agent Dashboard 以及侧边栏行状态上都有了更清晰的层级,同时仍保持 Ghostty 驱动的深浅模式同步 + ## [0.4.1] - 2026-04-19 ### ✨ 新功能 diff --git a/Packages/MoriUI/Sources/MoriUI/AgentWindowRowView.swift b/Packages/MoriUI/Sources/MoriUI/AgentWindowRowView.swift index d36e3d35..0637d299 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 8505f459..e7e39d73 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 da26f3f3..7ab5c987 100644 --- a/Packages/MoriUI/Sources/MoriUI/GhosttySettingsView.swift +++ b/Packages/MoriUI/Sources/MoriUI/GhosttySettingsView.swift @@ -251,6 +251,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 +297,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 +340,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 +363,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 +418,7 @@ public struct GhosttySettingsView: View { } } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .background(chromePaletteStore.palette.panelBackground.color) } } @@ -445,6 +451,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 +461,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) ) } } diff --git a/Packages/MoriUI/Sources/MoriUI/MoriChromePalette.swift b/Packages/MoriUI/Sources/MoriUI/MoriChromePalette.swift new file mode 100644 index 00000000..ee3d54cd --- /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 21f62c7f..237ab5d5 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/SidebarContainerView.swift b/Packages/MoriUI/Sources/MoriUI/SidebarContainerView.swift index 280745a5..51f17ec8 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 71afa973..29f56a7e 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 c0a1afbc..1ed3bb62 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 2e6b1eff..2b115888 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 52b4c4ac..3951348f 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 872a71f8..9394c1ca 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] = [:] @@ -120,9 +121,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent } 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 +229,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 +242,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 +273,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 +284,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) @@ -684,11 +695,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,6 +733,9 @@ 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( @@ -803,12 +819,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 +834,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") @@ -881,15 +898,16 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent adapter.reloadConfig() 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 +944,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 +1343,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 +1833,7 @@ private struct SettingsWindowContent: View { var onKeyBindingReset: ((String) -> Void)? var onKeyBindingResetAll: (() -> Void)? var keyBindingsRefresh: (() -> [KeyBinding])? + let chromePaletteStore: MoriChromePaletteStore init( initial: GhosttySettingsModel, @@ -1825,7 +1854,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 +1876,7 @@ private struct SettingsWindowContent: View { self.onKeyBindingReset = onKeyBindingReset self.onKeyBindingResetAll = onKeyBindingResetAll self.keyBindingsRefresh = keyBindingsRefresh + self.chromePaletteStore = chromePaletteStore } var body: some View { @@ -1878,5 +1909,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 e2831f43..65081149 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 46a057df..0281485c 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,9 +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 + view.layer?.backgroundColor = chromePalette.sidebarBackground.nsColor.cgColor // Force SwiftUI to re-render with the updated appearance context. view.needsDisplay = true } @@ -77,6 +82,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 +125,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 3efc6cd4..8b56fcfd 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,11 @@ final class MainWindowController: NSWindowController { onShowCreateWorktreePanel?() } + func updateAppearance(themeInfo: GhosttyThemeInfo, chromePalette: MoriChromePalette) { + window?.backgroundColor = chromePalette.windowBackground.nsColor + window?.appearance = NSAppearance(named: themeInfo.isDark ? .darkAqua : .aqua) + } + 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 00000000..020a1ae9 --- /dev/null +++ b/Sources/Mori/App/MoriChromeThemeBuilder.swift @@ -0,0 +1,131 @@ +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 windowBackground = ghosttyBackground.moriBlended( + toward: windowBase, + fraction: themeInfo.isDark ? 0.24 : 0.44 + ) + let sidebarBackground = windowBackground.moriBlended( + toward: sidebarBase, + fraction: themeInfo.isDark ? 0.22 : 0.38 + ) + let panelBackground = windowBackground.moriBlended( + toward: panelBase, + fraction: themeInfo.isDark ? 0.16 : 0.28 + ) + let headerBackground = panelBackground.moriBlended( + toward: cardBase, + fraction: themeInfo.isDark ? 0.12 : 0.20 + ) + let cardBackground = panelBackground.moriBlended( + toward: cardBase, + fraction: themeInfo.isDark ? 0.20 : 0.32 + ) + + 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 + ) + + return MoriChromePalette( + isDark: themeInfo.isDark, + windowBackground: MoriChromeColor(nsColor: windowBackground), + sidebarBackground: MoriChromeColor(nsColor: sidebarBackground), + panelBackground: MoriChromeColor(nsColor: panelBackground), + headerBackground: MoriChromeColor(nsColor: headerBackground), + cardBackground: MoriChromeColor(nsColor: 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 moriSRGBComponents: (red: CGFloat, green: CGFloat, blue: CGFloat, alpha: CGFloat) { + let color = usingColorSpace(.sRGB) ?? self + return (color.redComponent, color.greenComponent, color.blueComponent, color.alphaComponent) + } + + var moriRelativeLuminance: CGFloat { + let components = moriSRGBComponents + return 0.2126 * components.red + 0.7152 * components.green + 0.0722 * components.blue + } + + 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 636baf75..0da41852 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 a7422a65..951fd242 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 diff --git a/plan.md b/plan.md new file mode 100644 index 00000000..93f3b984 --- /dev/null +++ b/plan.md @@ -0,0 +1,182 @@ +# Plan: Ghostty-driven Mori chrome theme + +## Problem +Mori already flips `NSAppearance` between `.darkAqua` and `.aqua` from `GhosttyThemeInfo.isDark`, but most app chrome still uses Ghostty’s raw terminal background directly. That makes dark themes feel acceptable while light themes collapse into a low-contrast white sheet: sidebar, content surround, settings, companion pane, and headers all sit on nearly the same surface with faint selection and divider states. + +The goal is to keep Ghostty as the source of truth for theme choice — when the user picks a dark Ghostty theme in Settings, Mori should become dark; when they pick a light theme, Mori should become light — while making Mori’s non-terminal chrome read like a real macOS app instead of a terminal canvas stretched across the whole window. + +## How we got here +I traced the current theme flow through the codebase: + +- `Packages/MoriTerminal/Sources/MoriTerminal/GhosttyThemeInfo.swift` resolves Ghostty colors and computes `isDark` from background luminance. +- `Sources/Mori/App/AppDelegate.swift` writes the selected Ghostty theme to config, then calls `reloadGhosttyConfig()` after settings changes. +- `reloadGhosttyConfig()` already fans the new `themeInfo` out to the main window, sidebar, terminal area, companion pane, settings window, and tmux. +- `Sources/Mori/App/MainWindowController.swift`, `HostingControllers.swift`, `CompanionToolPaneController.swift`, `TerminalAreaViewController.swift`, `WorktreeCreationController.swift`, and `AgentDashboardPanel.swift` currently use Ghostty background colors directly for app chrome. +- `Packages/MoriUI/Sources/MoriUI/DesignTokens.swift`, `WorktreeRowView.swift`, and `WindowRowView.swift` use fixed highlight opacities that are too subtle against light backgrounds. +- `Sources/Mori/App/RootSplitViewController.swift` uses default separator hairlines, which disappear on very light surfaces. + +That means the settings plumbing already exists; the missing piece is a better chrome palette and stronger light-mode hierarchy. + +## Design decisions + +### 1. Ghostty remains the single source of truth for Mori’s light/dark mode +**Decision:** Mori will continue deriving overall appearance mode from `GhosttyThemeInfo`. A dark Ghostty theme keeps Mori in dark appearance; a light Ghostty theme keeps Mori in light appearance. + +**Alternatives considered:** +- Follow macOS system appearance for Mori chrome only. Rejected because it breaks the user expectation that changing the Ghostty theme in Mori Settings updates the whole app together. +- Add a separate manual Mori light/dark override now. Rejected for this pass because it adds product complexity before we fix the current visual model. + +**Tradeoff:** Chrome and terminal remain coupled at the mode level, but not at the raw surface-color level. This plan does **not** change the existing `isDark` threshold logic; instead, Phase 4 will explicitly validate a few near-threshold Ghostty themes so we catch any surprising mode flips before shipping and spin threshold tuning into a follow-up if needed. + +> **Tradeoff:** Chrome and terminal remain coupled at the mode level, but not at the raw surface-color level. + +**Review (claude):** `isDark` is computed from background luminance with an undocumented threshold. Ghostty themes whose background sits near the boundary (warm sepia, muted teal) may produce unexpected appearance flips as users browse themes — dark-appearing themes classified as light, or vice versa. The plan accepts this coupling as a non-issue without acknowledging the threshold sensitivity or stating whether any clamping or hysteresis is planned. + +**Resolved:** Kept the existing threshold behavior in scope for this plan, but documented that the validation phase must exercise near-threshold themes and treat threshold tuning as a follow-up if the current classification proves unstable. + +### 2. Use a deterministic Ghostty → chrome derivation algorithm instead of ad-hoc tweaks +**Decision:** Introduce a shared `MoriChromePalette`-style value that is derived from `GhosttyThemeInfo` using one documented algorithm, not per-callsite color math. The derivation will: +- normalize Ghostty colors into shared sRGB helpers once, +- use the Ghostty background/effective background as the hue anchor, +- derive window/sidebar/panel/header surfaces by blending toward semantic macOS surfaces with fixed appearance-specific ratios, +- derive separators and selected/hover fills from Ghostty accent/selection colors but clamp them to minimum-contrast targets appropriate for light and dark mode. + +That gives Mori one place to tune hierarchy while still feeling tied to the selected Ghostty theme. + +**Alternatives considered:** +- Keep using `themeInfo.background` / `effectiveBackground` everywhere and only bump row highlight opacity. Rejected because it does not solve the missing surface hierarchy. +- Ignore Ghostty colors and use pure system colors only. Rejected because Mori should still feel connected to the selected Ghostty theme. +- Let each controller/view derive small adjustments locally. Rejected because it would recreate today’s drift in a different form. + +**Tradeoff:** We introduce one more theming layer, but in return we get a stable, reviewable place to tune light-mode contrast without disturbing terminal rendering. + +> Introduce a shared derived theme/palette type (name TBD: `MoriChromeTheme` or similar) that takes `GhosttyThemeInfo` and produces colors for app chrome surfaces: window background, sidebar background, secondary panels, headers, dividers, and selection fills. + +**Review (claude):** The derivation algorithm is entirely unspecified — "takes `GhosttyThemeInfo` and produces colors" leaves open whether this is luminance-adjusted blending, fixed offsets in sRGB/HSL, or something else entirely. Without a concrete formula committed to here, every implementer will invent their own math, defeating the point of a single shared palette type. The core technical decision of this plan should not be deferred to Phase 1. + +**Resolved:** Rewrote the decision to commit to one derivation strategy up front: shared color normalization helpers, fixed surface-blend ladders, and contrast-clamped selection/divider values. + +### 3. Keep the palette type package-safe and inject it into `MoriUI` +**Decision:** Split ownership between a package-safe palette type and an app-side builder: +- `Packages/MoriUI` will own the palette/token value type plus any environment or initializer plumbing needed by row views and settings content. +- `Sources/Mori/App` will own the `GhosttyThemeInfo -> MoriChromePalette` builder and will pass the resulting palette into AppKit controllers and SwiftUI root views during startup and `reloadGhosttyConfig()`. + +This keeps `MoriUI` free of upward dependencies on the app target and avoids forcing the package to import Ghostty-specific types directly. + +**Alternatives considered:** +- Put both the palette type and the Ghostty-specific derivation logic in `Sources/Mori/App/`. Rejected because `MoriUI` cannot import the app target. +- Make `MoriUI` depend directly on `MoriTerminal`. Rejected for this change because the UI package only needs already-derived chrome tokens, not terminal-host responsibilities. +- Keep palette values entirely outside `MoriUI` and thread loose color parameters through every view. Rejected because it would make view APIs noisy and inconsistent. + +**Tradeoff:** Theme propagation gains an extra injection step, but the package boundary stays clean and the shared palette remains usable from both AppKit hosts and SwiftUI views. + +> `Packages/MoriUI/Sources/MoriUI/DesignTokens.swift` +> - Introduce appearance-aware highlight/separator helpers or new tokens used by light-mode row styling. + +**Review (claude):** `MoriUI` is a separate Swift package and cannot import `MoriChromeTheme` if it lives in `Sources/Mori/App/` — that would create an upward dependency from the package into the app target. The plan never resolves how the derived chrome colors reach `DesignTokens`, `WorktreeRowView`, and `WindowRowView`. Either `MoriChromeTheme` must move into a shared package, or the SwiftUI views must receive values via injection — neither path is chosen or designed here. + +**Resolved:** Chose the split design explicitly: `MoriUI` owns the palette/token type and receives values by injection; the app target owns Ghostty-specific derivation and propagation. + +### 4. Keep the raw Ghostty theme only where Mori is actually rendering terminal content +**Decision:** Raw `GhosttyThemeInfo` colors remain the source of truth only for terminal-facing surfaces: terminal canvas, transparent/glass handling, tmux synchronization, and any explicit terminal theme previews. All app-shell surfaces — main window, sidebar, settings window chrome, companion pane, worktree creation panel, dashboard panel, headers, row states, and split dividers — will use the derived chrome palette. + +**Alternatives considered:** +- Apply the derived chrome palette to the terminal area too. Rejected because it would make the embedded terminal diverge from Ghostty and tmux. +- Continue deciding raw-vs-derived usage at each callsite during implementation. Rejected because it would make the palette API unstable while the migration is in flight. + +**Tradeoff:** There will be a deliberate distinction between terminal canvas and app chrome, especially in light mode. That separation is the point. + +> - [ ] Decide and document which surfaces continue using raw Ghostty colors versus derived chrome colors. + +**Review (claude):** This is a design decision masquerading as an implementation task. Deferring the raw-vs-derived boundary to Phase 1 means the `MoriChromeTheme` API surface (which properties exist, what they're named) cannot be finalized until mid-implementation, risking churn across all Phase 2 callsites. The boundary should be defined in the plan before any code is written. + +**Resolved:** Promoted the raw-vs-derived boundary into its own design decision and changed the Phase 1 task from "decide" to "codify/document" so implementation starts from a fixed surface map. + +### 5. Make row selection, hover, and divider contrast appearance-aware +**Decision:** Strengthen light-mode hierarchy in sidebar rows, active states, and split dividers. Use appearance-aware values rather than one shared opacity for both schemes. + +**Alternatives considered:** +- Keep one set of global opacity constants for all modes. Rejected because light mode needs visibly stronger separation than dark mode. +- Hard-code a bunch of one-off colors in row views. Rejected because it will drift and be hard to tune consistently. + +**Tradeoff:** Some SwiftUI views will need palette-aware styling, but that is a contained change and fits macOS behavior. + +## What changes where +- `Packages/MoriTerminal/Sources/MoriTerminal/GhosttyThemeInfo.swift` + - Add or expose the shared color helpers the builder needs (for example normalized sRGB components, blending helpers, and luminance accessors) while keeping the raw Ghostty model authoritative. +- `Packages/MoriUI/Sources/MoriUI/` (new file, likely `MoriChromePalette.swift` plus environment/plumbing helpers) + - Add the package-safe palette/token type consumed by row views and any SwiftUI settings chrome. +- `Packages/MoriUI/Sources/MoriUI/DesignTokens.swift` + - Introduce appearance-aware highlight/separator helpers backed by the shared palette. +- `Packages/MoriUI/Sources/MoriUI/WorktreeRowView.swift` + - Strengthen selected and hover presentation for light mode while preserving the current dark-mode feel. +- `Packages/MoriUI/Sources/MoriUI/WindowRowView.swift` + - Strengthen active/hover row presentation and shortcut pill contrast for light mode. +- `Packages/MoriUI/Sources/MoriUI/TaskWorktreeRowView.swift` + - Keep the alternate row implementation aligned with the same palette rules. +- `Sources/Mori/App/` (new file, likely `MoriChromeThemeBuilder.swift` or similar) + - Add the Ghostty-specific builder that converts `GhosttyThemeInfo` into the shared `MoriChromePalette`, including the fixed derivation rules documented above. +- `Sources/Mori/App/MainWindowController.swift` + - Stop setting the main window background directly from the raw terminal background; apply the derived chrome background while keeping `appearance` sourced from Ghostty dark/light. +- `Sources/Mori/App/HostingControllers.swift` + - Inject the derived palette into sidebar/content hosting roots instead of relying on `themeInfo.effectiveBackground` alone. +- `Sources/Mori/App/CompanionToolPaneController.swift` + - Apply derived body/header/divider colors so the companion pane has clear layering in light themes. +- `Sources/Mori/App/RootSplitViewController.swift` + - Use stronger derived divider colors rather than a barely visible default separator on very light themes. +- `Sources/Mori/App/AppDelegate.swift` + - Centralize creation and propagation of the derived chrome palette during startup and `reloadGhosttyConfig()` so theme changes made in Settings immediately restyle every affected surface, including the settings window. +- `Sources/Mori/App/WorktreeCreationController.swift` + - Apply the derived chrome palette to the panel background/container while keeping light/dark mode synced to Ghostty. +- `Sources/Mori/App/AgentDashboardPanel.swift` + - Apply the derived chrome background instead of the raw Ghostty background. + +> `reloadGhosttyConfig()` already fans the new `themeInfo` out to the main window, sidebar, terminal area, companion pane, settings window, and tmux. + +**Review (claude):** The settings window is explicitly listed as a current `reloadGhosttyConfig()` recipient in "How we got here," but it is absent from the "What changes where" section. If the settings window currently uses raw Ghostty background colors (the same pattern being fixed everywhere else), it should appear in the file list; if it doesn't need changes, that should be explicitly justified. + +**Resolved:** Added the settings window explicitly to the `AppDelegate.swift` change list and to Phase 2 so it is part of the same palette propagation work as the main shell. + +## Migration / implementation order +1. Define the package-safe palette type and the raw-vs-derived surface boundary first so the shared API is stable before any callsites move. +2. Implement the Ghostty → palette builder and wire `AppDelegate.reloadGhosttyConfig()` / startup propagation, including the settings window, before tuning individual views. +3. Update the AppKit shell surfaces next (`MainWindowController`, sidebar host, companion pane, split dividers, auxiliary panels). This establishes the new hierarchy and keeps refresh behavior centralized. +4. Update `MoriUI` row styling after the shell colors land; otherwise row contrast would be tuned against the wrong background assumptions. +5. Validate theme changes from Settings end to end, including near-threshold themes for `isDark`, to confirm Mori chrome updates live without regressing terminal or tmux behavior. + +This sequence keeps the shared theme model stable before touching many visual callsites and lets the compiler surface all consumers that still depend on raw background colors. + +## Tasks + +### Phase 1: Define the shared palette contract + +- [x] Add a package-safe `MoriChromePalette`-style type in `MoriUI` for chrome surfaces, separators, and selection states. +- [x] Add any shared color math/helpers needed to derive those values cleanly from `GhosttyThemeInfo`. +- [x] Codify the raw-vs-derived surface boundary in palette docs/comments so later phases do not reopen the API. + +### Phase 2: Build and propagate the palette from the app target + +- [x] Add the `GhosttyThemeInfo -> MoriChromePalette` builder with fixed blend/contrast rules. +- [x] Update `AppDelegate.reloadGhosttyConfig()` and startup wiring to propagate the derived palette alongside `GhosttyThemeInfo`. +- [x] Include the settings window in the same propagation path so Settings live-reloads with the rest of the app. +- [x] Keep `NSAppearance(named: themeInfo.isDark ? .darkAqua : .aqua)` behavior intact everywhere so Ghostty theme choice still controls dark vs. light mode. + +### Phase 3: Apply the palette to AppKit shell surfaces + +- [x] Update `MainWindowController`, `HostingControllers`, and `CompanionToolPaneController` to use derived chrome backgrounds/headers/dividers. +- [x] Update `RootSplitViewController` divider styling to remain readable in light mode. +- [x] Update `WorktreeCreationController` and `AgentDashboardPanel` to use the same chrome palette. + +### Phase 4: Strengthen `MoriUI` contrast for light themes + +- [x] Add appearance-aware highlight helpers/tokens in `DesignTokens.swift`. +- [x] Update `WorktreeRowView` selected/hover styling for clearer light-mode hierarchy. +- [x] Update `WindowRowView` active/hover styling and shortcut pill contrast. +- [x] Update `TaskWorktreeRowView` to match the same rules. + +### Phase 5: Validate Settings-driven theme sync end to end + +- [ ] Verify changing Ghostty theme in Settings live-updates Mori chrome without reopening windows. +- [ ] Verify switching between light and dark Ghostty themes flips Mori appearance mode correctly, including a few near-threshold themes. +- [ ] Verify terminal rendering, tmux theme sync, and transparent/glass modes still behave as before. +- [x] Run the relevant build/tests to catch regressions in shared packages and AppKit callsites. From a0dad17df5480a627414124e51a9fc7a41207d44 Mon Sep 17 00:00:00 2001 From: Vaayne Date: Mon, 20 Apr 2026 13:52:01 +0800 Subject: [PATCH 2/8] =?UTF-8?q?=F0=9F=90=9B=20fix:=20persist=20quoted=20Gh?= =?UTF-8?q?ostty=20theme=20values?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assisted-by: pi:gpt-5.4 --- CHANGELOG.md | 4 ++ CHANGELOG.zh-Hans.md | 4 ++ .../MoriTerminal/GhosttyConfigFile.swift | 38 +++++++++++++++---- .../Sources/MoriUI/GhosttySettingsView.swift | 10 +++-- Sources/Mori/App/MoriChromeThemeBuilder.swift | 38 ++++++++++++++++--- 5 files changed, 78 insertions(+), 16 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index eafe8099..8624ec05 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - 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 b59fdecc..0eddf904 100644 --- a/CHANGELOG.zh-Hans.md +++ b/CHANGELOG.zh-Hans.md @@ -11,6 +11,10 @@ - Mori 现在会从所选 Ghostty 主题派生应用外壳配色,而不再把原始终端背景直接铺到整套界面上;这样浅色主题在主窗口、侧边栏、设置窗口、右侧工具面板、工作树创建面板、Agent Dashboard 以及侧边栏行状态上都有了更清晰的层级,同时仍保持 Ghostty 驱动的深浅模式同步 +### 🐛 问题修复 + +- 在设置里保存 Ghostty 配置时,现会对包含空格的值自动加引号,并清理重复的单值键;这样像 `Ayu Light` 这类主题切换才会真正应用到正在运行的终端和 Mori 外壳,而不会悄悄停留在旧主题上 + ## [0.4.1] - 2026-04-19 ### ✨ 新功能 diff --git a/Packages/MoriTerminal/Sources/MoriTerminal/GhosttyConfigFile.swift b/Packages/MoriTerminal/Sources/MoriTerminal/GhosttyConfigFile.swift index 6cdeb742..d29403b7 100644 --- a/Packages/MoriTerminal/Sources/MoriTerminal/GhosttyConfigFile.swift +++ b/Packages/MoriTerminal/Sources/MoriTerminal/GhosttyConfigFile.swift @@ -68,17 +68,32 @@ 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 + let rendered = renderKeyValue(key: key, value: value) + + var firstMatchIndex: Int? for i in lines.indices { if case .keyValue(let k, _, _) = lines[i], k == key { - lines[i] = .keyValue(key, value, "\(key) = \(value)") - return + if firstMatchIndex == nil { + firstMatchIndex = i + } + } + } + + if let firstMatchIndex { + lines[firstMatchIndex] = .keyValue(key, value, rendered) + lines = lines.enumerated().compactMap { index, line in + guard case .keyValue(let existingKey, _, _) = line, existingKey == key else { + return line + } + return index == firstMatchIndex ? line : nil } + return } - // Append new key - lines.append(.keyValue(key, value, "\(key) = \(value)")) + + lines.append(.keyValue(key, value, rendered)) } /// Remove a key from the config. @@ -148,10 +163,19 @@ 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)\"" + } + /// List ghostty default keybindings by running `ghostty +list-keybinds`. /// Returns array of "key=action" strings. public static func defaultKeybinds() -> [String] { diff --git a/Packages/MoriUI/Sources/MoriUI/GhosttySettingsView.swift b/Packages/MoriUI/Sources/MoriUI/GhosttySettingsView.swift index 7ab5c987..f5100956 100644 --- a/Packages/MoriUI/Sources/MoriUI/GhosttySettingsView.swift +++ b/Packages/MoriUI/Sources/MoriUI/GhosttySettingsView.swift @@ -615,6 +615,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 @@ -713,7 +715,7 @@ private struct ThemeSettingsContent: View { .padding(8) .background( RoundedRectangle(cornerRadius: 6) - .fill(Color.primary.opacity(0.04)) + .fill(MoriTokens.Chrome.shortcutPillFill(chromePaletteStore.palette)) ) ScrollView { @@ -727,7 +729,7 @@ private struct ThemeSettingsContent: View { .clipShape(RoundedRectangle(cornerRadius: 6)) .overlay( RoundedRectangle(cornerRadius: 6) - .strokeBorder(Color.primary.opacity(0.06), lineWidth: 1) + .strokeBorder(MoriTokens.Chrome.divider(chromePaletteStore.palette), lineWidth: 1) ) CardDivider() @@ -817,12 +819,12 @@ private struct ThemeSettingsContent: View { if isSelected { 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(isSelected ? MoriTokens.Chrome.rowSelectionFill(chromePaletteStore.palette) : .clear) .contentShape(Rectangle()) .onTapGesture { model.theme = name diff --git a/Sources/Mori/App/MoriChromeThemeBuilder.swift b/Sources/Mori/App/MoriChromeThemeBuilder.swift index 020a1ae9..fb74c256 100644 --- a/Sources/Mori/App/MoriChromeThemeBuilder.swift +++ b/Sources/Mori/App/MoriChromeThemeBuilder.swift @@ -24,27 +24,55 @@ enum MoriChromeThemeBuilder { 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 + ) - let windowBackground = ghosttyBackground.moriBlended( + var windowBackground = ghosttyBackground.moriBlended( toward: windowBase, fraction: themeInfo.isDark ? 0.24 : 0.44 ) - let sidebarBackground = windowBackground.moriBlended( + 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 ) - let panelBackground = windowBackground.moriBlended( + 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 ) - let headerBackground = panelBackground.moriBlended( + 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 ) - let cardBackground = panelBackground.moriBlended( + 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), From 78bfbe52f453fa09467bff618aa2840607c1be59 Mon Sep 17 00:00:00 2001 From: Vaayne Date: Mon, 20 Apr 2026 15:58:09 +0800 Subject: [PATCH 3/8] =?UTF-8?q?=E2=9C=A8=20feat:=20add=20Ghostty=20auto=20?= =?UTF-8?q?theme=20settings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Assisted-by: pi:gpt-5.4 --- CHANGELOG.md | 4 + CHANGELOG.zh-Hans.md | 4 + .../Sources/MoriTerminal/GhosttyAdapter.swift | 5 + .../Sources/MoriTerminal/GhosttyApp.swift | 60 +++- .../MoriTerminal/GhosttyConfigFile.swift | 40 +++ .../Sources/MoriUI/GhosttySettingsView.swift | 331 +++++++++++++++--- .../Resources/en.lproj/Localizable.strings | 8 + .../zh-Hans.lproj/Localizable.strings | 8 + Sources/Mori/App/AppDelegate.swift | 39 ++- 9 files changed, 442 insertions(+), 57 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8624ec05..f782f4e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ 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 diff --git a/CHANGELOG.zh-Hans.md b/CHANGELOG.zh-Hans.md index 0eddf904..2dfd56be 100644 --- a/CHANGELOG.zh-Hans.md +++ b/CHANGELOG.zh-Hans.md @@ -7,6 +7,10 @@ ## [Unreleased] +### ✨ 新功能 + +- 设置 → Theme 现在支持直接配置跟随外观切换的 Ghostty 主题:可在 Mori 内分别选择浅色 / 深色主题,并以 `theme = light:Ayu Light,dark:Ayu` 这样的 Ghostty 语法持久化,同时继续保持终端与应用外壳的实时刷新 + ### 🎨 界面优化 - Mori 现在会从所选 Ghostty 主题派生应用外壳配色,而不再把原始终端背景直接铺到整套界面上;这样浅色主题在主窗口、侧边栏、设置窗口、右侧工具面板、工作树创建面板、Agent Dashboard 以及侧边栏行状态上都有了更清晰的层级,同时仍保持 Ghostty 驱动的深浅模式同步 diff --git a/Packages/MoriTerminal/Sources/MoriTerminal/GhosttyAdapter.swift b/Packages/MoriTerminal/Sources/MoriTerminal/GhosttyAdapter.swift index d842d0b8..c8e370c5 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 73db4a84..0b4ac2a3 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 d29403b7..dfc07f4a 100644 --- a/Packages/MoriTerminal/Sources/MoriTerminal/GhosttyConfigFile.swift +++ b/Packages/MoriTerminal/Sources/MoriTerminal/GhosttyConfigFile.swift @@ -176,6 +176,46 @@ public final class GhosttyConfigFile { 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`. /// Returns array of "key=action" strings. public static func defaultKeybinds() -> [String] { diff --git a/Packages/MoriUI/Sources/MoriUI/GhosttySettingsView.swift b/Packages/MoriUI/Sources/MoriUI/GhosttySettingsView.swift index f5100956..886a3b31 100644 --- a/Packages/MoriUI/Sources/MoriUI/GhosttySettingsView.swift +++ b/Packages/MoriUI/Sources/MoriUI/GhosttySettingsView.swift @@ -58,10 +58,136 @@ 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: + let theme = Self.normalizedThemeName(lightTheme) + return theme.isEmpty ? nil : theme + case .dark: + let theme = Self.normalizedThemeName(darkTheme) + return theme.isEmpty ? nil : theme + 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 +203,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, @@ -662,6 +788,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) }, @@ -673,7 +826,6 @@ private struct ThemeSettingsContent: View { } var body: some View { - // Preview TerminalPreview( fontFamily: model.fontFamily, fontSize: model.fontSize, @@ -681,56 +833,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(MoriTokens.Chrome.shortcutPillFill(chromePaletteStore.palette)) - ) + .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(MoriTokens.Chrome.divider(chromePaletteStore.palette), lineWidth: 1) - ) CardDivider() @@ -802,21 +968,92 @@ 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: + Text(model.theme.lightTheme.isEmpty ? .localized("Default") : model.theme.lightTheme) + .font(.system(size: 12)) + .foregroundStyle(.secondary) + .lineLimit(1) + .frame(width: 220, alignment: .trailing) + case .dark: + Text(model.theme.darkTheme.isEmpty ? .localized("Default") : model.theme.darkTheme) + .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 { + model.theme.mode == .light ? .light : .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(MoriTokens.Chrome.selectionAccent(chromePaletteStore.palette)) @@ -824,10 +1061,10 @@ private struct ThemeSettingsContent: View { } .padding(.horizontal, 10) .padding(.vertical, 5) - .background(isSelected ? MoriTokens.Chrome.rowSelectionFill(chromePaletteStore.palette) : .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/Resources/en.lproj/Localizable.strings b/Packages/MoriUI/Sources/MoriUI/Resources/en.lproj/Localizable.strings index 94275198..e54bad2d 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 6673ecb2..3e8e463c 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/Sources/Mori/App/AppDelegate.swift b/Sources/Mori/App/AppDelegate.swift index 9394c1ca..6e053242 100644 --- a/Sources/Mori/App/AppDelegate.swift +++ b/Sources/Mori/App/AppDelegate.swift @@ -35,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] = [:] @@ -119,6 +120,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent self?.handleGhosttyAction(action) } } + installSystemAppearanceObserver() let themeInfo = terminalArea.themeInfo let chromePalette = MoriChromeThemeBuilder.palette(from: themeInfo) @@ -414,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() } @@ -739,7 +744,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent let store = self.keyBindingStore! let settingsView = SettingsWindowContent( - initial: readSettingsModel(from: cf), + initial: readSettingsModel(from: cf, themeInfo: themeInfo), availableThemes: themes, ghosttyDefaults: ghosttyDefaults, initialAgentHooks: AgentHookModel( @@ -845,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, @@ -874,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) @@ -892,11 +900,30 @@ 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 { From b7c6344445d9941cd080a30b62d922468ba17d47 Mon Sep 17 00:00:00 2001 From: Vaayne Date: Mon, 20 Apr 2026 16:07:33 +0800 Subject: [PATCH 4/8] =?UTF-8?q?=E2=99=BB=EF=B8=8F=20refactor:=20simplify?= =?UTF-8?q?=20theme=20selection=20and=20config=20write=20logic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Collapse GhosttyConfigFile.set() two-pass dedup into single compactMap scan - Merge GhosttyThemeSelection.configValue .light/.dark cases via activeTheme - Merge selectedThemeSummary .light/.dark view cases via theme(for:) - Replace activeThemeTarget ternary with explicit switch (.auto case visible) - Inline single-use moriSRGBComponents into moriRelativeLuminance - Delete plan.md from repo root Assisted-by: claude-code:claude-sonnet-4-6 --- .../MoriTerminal/GhosttyConfigFile.swift | 26 +-- .../Sources/MoriUI/GhosttySettingsView.swift | 24 +-- Sources/Mori/App/MoriChromeThemeBuilder.swift | 9 +- plan.md | 182 ------------------ 4 files changed, 20 insertions(+), 221 deletions(-) delete mode 100644 plan.md diff --git a/Packages/MoriTerminal/Sources/MoriTerminal/GhosttyConfigFile.swift b/Packages/MoriTerminal/Sources/MoriTerminal/GhosttyConfigFile.swift index dfc07f4a..1e3fc8ad 100644 --- a/Packages/MoriTerminal/Sources/MoriTerminal/GhosttyConfigFile.swift +++ b/Packages/MoriTerminal/Sources/MoriTerminal/GhosttyConfigFile.swift @@ -72,28 +72,20 @@ public final class GhosttyConfigFile { /// or appends if the key does not exist. public func set(_ key: String, value: String) { let rendered = renderKeyValue(key: key, value: value) + var didUpdate = false - var firstMatchIndex: Int? - for i in lines.indices { - if case .keyValue(let k, _, _) = lines[i], k == key { - if firstMatchIndex == nil { - firstMatchIndex = i - } + 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 let firstMatchIndex { - lines[firstMatchIndex] = .keyValue(key, value, rendered) - lines = lines.enumerated().compactMap { index, line in - guard case .keyValue(let existingKey, _, _) = line, existingKey == key else { - return line - } - return index == firstMatchIndex ? line : nil - } - return + if !didUpdate { + lines.append(.keyValue(key, value, rendered)) } - - lines.append(.keyValue(key, value, rendered)) } /// Remove a key from the config. diff --git a/Packages/MoriUI/Sources/MoriUI/GhosttySettingsView.swift b/Packages/MoriUI/Sources/MoriUI/GhosttySettingsView.swift index 886a3b31..2e629f9d 100644 --- a/Packages/MoriUI/Sources/MoriUI/GhosttySettingsView.swift +++ b/Packages/MoriUI/Sources/MoriUI/GhosttySettingsView.swift @@ -101,12 +101,8 @@ public struct GhosttyThemeSelection: Equatable { public var configValue: String? { switch mode { - case .light: - let theme = Self.normalizedThemeName(lightTheme) - return theme.isEmpty ? nil : theme - case .dark: - let theme = Self.normalizedThemeName(darkTheme) - return theme.isEmpty ? nil : theme + case .light, .dark: + return activeTheme.isEmpty ? nil : activeTheme case .auto: let light = Self.normalizedThemeName(lightTheme) let dark = Self.normalizedThemeName(darkTheme) @@ -982,14 +978,9 @@ private struct ThemeSettingsContent: View { private var selectedThemeSummary: some View { Group { switch model.theme.mode { - case .light: - Text(model.theme.lightTheme.isEmpty ? .localized("Default") : model.theme.lightTheme) - .font(.system(size: 12)) - .foregroundStyle(.secondary) - .lineLimit(1) - .frame(width: 220, alignment: .trailing) - case .dark: - Text(model.theme.darkTheme.isEmpty ? .localized("Default") : model.theme.darkTheme) + case .light, .dark: + let name = model.theme.theme(for: activeThemeTarget) + Text(name.isEmpty ? .localized("Default") : name) .font(.system(size: 12)) .foregroundStyle(.secondary) .lineLimit(1) @@ -1019,7 +1010,10 @@ private struct ThemeSettingsContent: View { } private var activeThemeTarget: GhosttyThemeAssignmentTarget { - model.theme.mode == .light ? .light : .dark + switch model.theme.mode { + case .light: .light + case .dark, .auto: .dark + } } @ViewBuilder diff --git a/Sources/Mori/App/MoriChromeThemeBuilder.swift b/Sources/Mori/App/MoriChromeThemeBuilder.swift index fb74c256..2b4e9870 100644 --- a/Sources/Mori/App/MoriChromeThemeBuilder.swift +++ b/Sources/Mori/App/MoriChromeThemeBuilder.swift @@ -141,14 +141,9 @@ enum MoriChromeThemeBuilder { } private extension NSColor { - var moriSRGBComponents: (red: CGFloat, green: CGFloat, blue: CGFloat, alpha: CGFloat) { - let color = usingColorSpace(.sRGB) ?? self - return (color.redComponent, color.greenComponent, color.blueComponent, color.alphaComponent) - } - var moriRelativeLuminance: CGFloat { - let components = moriSRGBComponents - return 0.2126 * components.red + 0.7152 * components.green + 0.0722 * components.blue + 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 { diff --git a/plan.md b/plan.md deleted file mode 100644 index 93f3b984..00000000 --- a/plan.md +++ /dev/null @@ -1,182 +0,0 @@ -# Plan: Ghostty-driven Mori chrome theme - -## Problem -Mori already flips `NSAppearance` between `.darkAqua` and `.aqua` from `GhosttyThemeInfo.isDark`, but most app chrome still uses Ghostty’s raw terminal background directly. That makes dark themes feel acceptable while light themes collapse into a low-contrast white sheet: sidebar, content surround, settings, companion pane, and headers all sit on nearly the same surface with faint selection and divider states. - -The goal is to keep Ghostty as the source of truth for theme choice — when the user picks a dark Ghostty theme in Settings, Mori should become dark; when they pick a light theme, Mori should become light — while making Mori’s non-terminal chrome read like a real macOS app instead of a terminal canvas stretched across the whole window. - -## How we got here -I traced the current theme flow through the codebase: - -- `Packages/MoriTerminal/Sources/MoriTerminal/GhosttyThemeInfo.swift` resolves Ghostty colors and computes `isDark` from background luminance. -- `Sources/Mori/App/AppDelegate.swift` writes the selected Ghostty theme to config, then calls `reloadGhosttyConfig()` after settings changes. -- `reloadGhosttyConfig()` already fans the new `themeInfo` out to the main window, sidebar, terminal area, companion pane, settings window, and tmux. -- `Sources/Mori/App/MainWindowController.swift`, `HostingControllers.swift`, `CompanionToolPaneController.swift`, `TerminalAreaViewController.swift`, `WorktreeCreationController.swift`, and `AgentDashboardPanel.swift` currently use Ghostty background colors directly for app chrome. -- `Packages/MoriUI/Sources/MoriUI/DesignTokens.swift`, `WorktreeRowView.swift`, and `WindowRowView.swift` use fixed highlight opacities that are too subtle against light backgrounds. -- `Sources/Mori/App/RootSplitViewController.swift` uses default separator hairlines, which disappear on very light surfaces. - -That means the settings plumbing already exists; the missing piece is a better chrome palette and stronger light-mode hierarchy. - -## Design decisions - -### 1. Ghostty remains the single source of truth for Mori’s light/dark mode -**Decision:** Mori will continue deriving overall appearance mode from `GhosttyThemeInfo`. A dark Ghostty theme keeps Mori in dark appearance; a light Ghostty theme keeps Mori in light appearance. - -**Alternatives considered:** -- Follow macOS system appearance for Mori chrome only. Rejected because it breaks the user expectation that changing the Ghostty theme in Mori Settings updates the whole app together. -- Add a separate manual Mori light/dark override now. Rejected for this pass because it adds product complexity before we fix the current visual model. - -**Tradeoff:** Chrome and terminal remain coupled at the mode level, but not at the raw surface-color level. This plan does **not** change the existing `isDark` threshold logic; instead, Phase 4 will explicitly validate a few near-threshold Ghostty themes so we catch any surprising mode flips before shipping and spin threshold tuning into a follow-up if needed. - -> **Tradeoff:** Chrome and terminal remain coupled at the mode level, but not at the raw surface-color level. - -**Review (claude):** `isDark` is computed from background luminance with an undocumented threshold. Ghostty themes whose background sits near the boundary (warm sepia, muted teal) may produce unexpected appearance flips as users browse themes — dark-appearing themes classified as light, or vice versa. The plan accepts this coupling as a non-issue without acknowledging the threshold sensitivity or stating whether any clamping or hysteresis is planned. - -**Resolved:** Kept the existing threshold behavior in scope for this plan, but documented that the validation phase must exercise near-threshold themes and treat threshold tuning as a follow-up if the current classification proves unstable. - -### 2. Use a deterministic Ghostty → chrome derivation algorithm instead of ad-hoc tweaks -**Decision:** Introduce a shared `MoriChromePalette`-style value that is derived from `GhosttyThemeInfo` using one documented algorithm, not per-callsite color math. The derivation will: -- normalize Ghostty colors into shared sRGB helpers once, -- use the Ghostty background/effective background as the hue anchor, -- derive window/sidebar/panel/header surfaces by blending toward semantic macOS surfaces with fixed appearance-specific ratios, -- derive separators and selected/hover fills from Ghostty accent/selection colors but clamp them to minimum-contrast targets appropriate for light and dark mode. - -That gives Mori one place to tune hierarchy while still feeling tied to the selected Ghostty theme. - -**Alternatives considered:** -- Keep using `themeInfo.background` / `effectiveBackground` everywhere and only bump row highlight opacity. Rejected because it does not solve the missing surface hierarchy. -- Ignore Ghostty colors and use pure system colors only. Rejected because Mori should still feel connected to the selected Ghostty theme. -- Let each controller/view derive small adjustments locally. Rejected because it would recreate today’s drift in a different form. - -**Tradeoff:** We introduce one more theming layer, but in return we get a stable, reviewable place to tune light-mode contrast without disturbing terminal rendering. - -> Introduce a shared derived theme/palette type (name TBD: `MoriChromeTheme` or similar) that takes `GhosttyThemeInfo` and produces colors for app chrome surfaces: window background, sidebar background, secondary panels, headers, dividers, and selection fills. - -**Review (claude):** The derivation algorithm is entirely unspecified — "takes `GhosttyThemeInfo` and produces colors" leaves open whether this is luminance-adjusted blending, fixed offsets in sRGB/HSL, or something else entirely. Without a concrete formula committed to here, every implementer will invent their own math, defeating the point of a single shared palette type. The core technical decision of this plan should not be deferred to Phase 1. - -**Resolved:** Rewrote the decision to commit to one derivation strategy up front: shared color normalization helpers, fixed surface-blend ladders, and contrast-clamped selection/divider values. - -### 3. Keep the palette type package-safe and inject it into `MoriUI` -**Decision:** Split ownership between a package-safe palette type and an app-side builder: -- `Packages/MoriUI` will own the palette/token value type plus any environment or initializer plumbing needed by row views and settings content. -- `Sources/Mori/App` will own the `GhosttyThemeInfo -> MoriChromePalette` builder and will pass the resulting palette into AppKit controllers and SwiftUI root views during startup and `reloadGhosttyConfig()`. - -This keeps `MoriUI` free of upward dependencies on the app target and avoids forcing the package to import Ghostty-specific types directly. - -**Alternatives considered:** -- Put both the palette type and the Ghostty-specific derivation logic in `Sources/Mori/App/`. Rejected because `MoriUI` cannot import the app target. -- Make `MoriUI` depend directly on `MoriTerminal`. Rejected for this change because the UI package only needs already-derived chrome tokens, not terminal-host responsibilities. -- Keep palette values entirely outside `MoriUI` and thread loose color parameters through every view. Rejected because it would make view APIs noisy and inconsistent. - -**Tradeoff:** Theme propagation gains an extra injection step, but the package boundary stays clean and the shared palette remains usable from both AppKit hosts and SwiftUI views. - -> `Packages/MoriUI/Sources/MoriUI/DesignTokens.swift` -> - Introduce appearance-aware highlight/separator helpers or new tokens used by light-mode row styling. - -**Review (claude):** `MoriUI` is a separate Swift package and cannot import `MoriChromeTheme` if it lives in `Sources/Mori/App/` — that would create an upward dependency from the package into the app target. The plan never resolves how the derived chrome colors reach `DesignTokens`, `WorktreeRowView`, and `WindowRowView`. Either `MoriChromeTheme` must move into a shared package, or the SwiftUI views must receive values via injection — neither path is chosen or designed here. - -**Resolved:** Chose the split design explicitly: `MoriUI` owns the palette/token type and receives values by injection; the app target owns Ghostty-specific derivation and propagation. - -### 4. Keep the raw Ghostty theme only where Mori is actually rendering terminal content -**Decision:** Raw `GhosttyThemeInfo` colors remain the source of truth only for terminal-facing surfaces: terminal canvas, transparent/glass handling, tmux synchronization, and any explicit terminal theme previews. All app-shell surfaces — main window, sidebar, settings window chrome, companion pane, worktree creation panel, dashboard panel, headers, row states, and split dividers — will use the derived chrome palette. - -**Alternatives considered:** -- Apply the derived chrome palette to the terminal area too. Rejected because it would make the embedded terminal diverge from Ghostty and tmux. -- Continue deciding raw-vs-derived usage at each callsite during implementation. Rejected because it would make the palette API unstable while the migration is in flight. - -**Tradeoff:** There will be a deliberate distinction between terminal canvas and app chrome, especially in light mode. That separation is the point. - -> - [ ] Decide and document which surfaces continue using raw Ghostty colors versus derived chrome colors. - -**Review (claude):** This is a design decision masquerading as an implementation task. Deferring the raw-vs-derived boundary to Phase 1 means the `MoriChromeTheme` API surface (which properties exist, what they're named) cannot be finalized until mid-implementation, risking churn across all Phase 2 callsites. The boundary should be defined in the plan before any code is written. - -**Resolved:** Promoted the raw-vs-derived boundary into its own design decision and changed the Phase 1 task from "decide" to "codify/document" so implementation starts from a fixed surface map. - -### 5. Make row selection, hover, and divider contrast appearance-aware -**Decision:** Strengthen light-mode hierarchy in sidebar rows, active states, and split dividers. Use appearance-aware values rather than one shared opacity for both schemes. - -**Alternatives considered:** -- Keep one set of global opacity constants for all modes. Rejected because light mode needs visibly stronger separation than dark mode. -- Hard-code a bunch of one-off colors in row views. Rejected because it will drift and be hard to tune consistently. - -**Tradeoff:** Some SwiftUI views will need palette-aware styling, but that is a contained change and fits macOS behavior. - -## What changes where -- `Packages/MoriTerminal/Sources/MoriTerminal/GhosttyThemeInfo.swift` - - Add or expose the shared color helpers the builder needs (for example normalized sRGB components, blending helpers, and luminance accessors) while keeping the raw Ghostty model authoritative. -- `Packages/MoriUI/Sources/MoriUI/` (new file, likely `MoriChromePalette.swift` plus environment/plumbing helpers) - - Add the package-safe palette/token type consumed by row views and any SwiftUI settings chrome. -- `Packages/MoriUI/Sources/MoriUI/DesignTokens.swift` - - Introduce appearance-aware highlight/separator helpers backed by the shared palette. -- `Packages/MoriUI/Sources/MoriUI/WorktreeRowView.swift` - - Strengthen selected and hover presentation for light mode while preserving the current dark-mode feel. -- `Packages/MoriUI/Sources/MoriUI/WindowRowView.swift` - - Strengthen active/hover row presentation and shortcut pill contrast for light mode. -- `Packages/MoriUI/Sources/MoriUI/TaskWorktreeRowView.swift` - - Keep the alternate row implementation aligned with the same palette rules. -- `Sources/Mori/App/` (new file, likely `MoriChromeThemeBuilder.swift` or similar) - - Add the Ghostty-specific builder that converts `GhosttyThemeInfo` into the shared `MoriChromePalette`, including the fixed derivation rules documented above. -- `Sources/Mori/App/MainWindowController.swift` - - Stop setting the main window background directly from the raw terminal background; apply the derived chrome background while keeping `appearance` sourced from Ghostty dark/light. -- `Sources/Mori/App/HostingControllers.swift` - - Inject the derived palette into sidebar/content hosting roots instead of relying on `themeInfo.effectiveBackground` alone. -- `Sources/Mori/App/CompanionToolPaneController.swift` - - Apply derived body/header/divider colors so the companion pane has clear layering in light themes. -- `Sources/Mori/App/RootSplitViewController.swift` - - Use stronger derived divider colors rather than a barely visible default separator on very light themes. -- `Sources/Mori/App/AppDelegate.swift` - - Centralize creation and propagation of the derived chrome palette during startup and `reloadGhosttyConfig()` so theme changes made in Settings immediately restyle every affected surface, including the settings window. -- `Sources/Mori/App/WorktreeCreationController.swift` - - Apply the derived chrome palette to the panel background/container while keeping light/dark mode synced to Ghostty. -- `Sources/Mori/App/AgentDashboardPanel.swift` - - Apply the derived chrome background instead of the raw Ghostty background. - -> `reloadGhosttyConfig()` already fans the new `themeInfo` out to the main window, sidebar, terminal area, companion pane, settings window, and tmux. - -**Review (claude):** The settings window is explicitly listed as a current `reloadGhosttyConfig()` recipient in "How we got here," but it is absent from the "What changes where" section. If the settings window currently uses raw Ghostty background colors (the same pattern being fixed everywhere else), it should appear in the file list; if it doesn't need changes, that should be explicitly justified. - -**Resolved:** Added the settings window explicitly to the `AppDelegate.swift` change list and to Phase 2 so it is part of the same palette propagation work as the main shell. - -## Migration / implementation order -1. Define the package-safe palette type and the raw-vs-derived surface boundary first so the shared API is stable before any callsites move. -2. Implement the Ghostty → palette builder and wire `AppDelegate.reloadGhosttyConfig()` / startup propagation, including the settings window, before tuning individual views. -3. Update the AppKit shell surfaces next (`MainWindowController`, sidebar host, companion pane, split dividers, auxiliary panels). This establishes the new hierarchy and keeps refresh behavior centralized. -4. Update `MoriUI` row styling after the shell colors land; otherwise row contrast would be tuned against the wrong background assumptions. -5. Validate theme changes from Settings end to end, including near-threshold themes for `isDark`, to confirm Mori chrome updates live without regressing terminal or tmux behavior. - -This sequence keeps the shared theme model stable before touching many visual callsites and lets the compiler surface all consumers that still depend on raw background colors. - -## Tasks - -### Phase 1: Define the shared palette contract - -- [x] Add a package-safe `MoriChromePalette`-style type in `MoriUI` for chrome surfaces, separators, and selection states. -- [x] Add any shared color math/helpers needed to derive those values cleanly from `GhosttyThemeInfo`. -- [x] Codify the raw-vs-derived surface boundary in palette docs/comments so later phases do not reopen the API. - -### Phase 2: Build and propagate the palette from the app target - -- [x] Add the `GhosttyThemeInfo -> MoriChromePalette` builder with fixed blend/contrast rules. -- [x] Update `AppDelegate.reloadGhosttyConfig()` and startup wiring to propagate the derived palette alongside `GhosttyThemeInfo`. -- [x] Include the settings window in the same propagation path so Settings live-reloads with the rest of the app. -- [x] Keep `NSAppearance(named: themeInfo.isDark ? .darkAqua : .aqua)` behavior intact everywhere so Ghostty theme choice still controls dark vs. light mode. - -### Phase 3: Apply the palette to AppKit shell surfaces - -- [x] Update `MainWindowController`, `HostingControllers`, and `CompanionToolPaneController` to use derived chrome backgrounds/headers/dividers. -- [x] Update `RootSplitViewController` divider styling to remain readable in light mode. -- [x] Update `WorktreeCreationController` and `AgentDashboardPanel` to use the same chrome palette. - -### Phase 4: Strengthen `MoriUI` contrast for light themes - -- [x] Add appearance-aware highlight helpers/tokens in `DesignTokens.swift`. -- [x] Update `WorktreeRowView` selected/hover styling for clearer light-mode hierarchy. -- [x] Update `WindowRowView` active/hover styling and shortcut pill contrast. -- [x] Update `TaskWorktreeRowView` to match the same rules. - -### Phase 5: Validate Settings-driven theme sync end to end - -- [ ] Verify changing Ghostty theme in Settings live-updates Mori chrome without reopening windows. -- [ ] Verify switching between light and dark Ghostty themes flips Mori appearance mode correctly, including a few near-threshold themes. -- [ ] Verify terminal rendering, tmux theme sync, and transparent/glass modes still behave as before. -- [x] Run the relevant build/tests to catch regressions in shared packages and AppKit callsites. From cb0acd43b40ab30ede99bf995d6cfbe94c0e9ea2 Mon Sep 17 00:00:00 2001 From: Vaayne Date: Mon, 20 Apr 2026 16:15:09 +0800 Subject: [PATCH 5/8] =?UTF-8?q?=F0=9F=90=9B=20fix:=20restore=20window=20tr?= =?UTF-8?q?ansparency=20when=20background-opacity=20<=201?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit syncWorkspaceWindowAppearance correctly sets the main window to nearly-transparent for non-opaque themes, but the subsequent refreshGhosttyThemeBackgrounds → MainWindowController.updateAppearance call overwrote it with the solid chrome palette background, cancelling out the opacity effect. Guard the backgroundColor update so it is skipped when themeInfo.usesTransparentWindowBackground is true; syncWorkspaceWindowAppearance remains the sole owner of the window background in that case. Assisted-by: claude-code:claude-sonnet-4-6 --- Sources/Mori/App/MainWindowController.swift | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/Sources/Mori/App/MainWindowController.swift b/Sources/Mori/App/MainWindowController.swift index 8b56fcfd..595d866e 100644 --- a/Sources/Mori/App/MainWindowController.swift +++ b/Sources/Mori/App/MainWindowController.swift @@ -113,8 +113,11 @@ final class MainWindowController: NSWindowController { } func updateAppearance(themeInfo: GhosttyThemeInfo, chromePalette: MoriChromePalette) { - window?.backgroundColor = chromePalette.windowBackground.nsColor 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) { From ecd4536c50430be74e9e6a29e894560482d27f42 Mon Sep 17 00:00:00 2001 From: Vaayne Date: Mon, 20 Apr 2026 16:25:17 +0800 Subject: [PATCH 6/8] =?UTF-8?q?=F0=9F=90=9B=20fix:=20restore=20transparenc?= =?UTF-8?q?y=20in=20all=20chrome=20surfaces?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When backgroundOpacity < 1, sidebar, companion pane, and root split view now clear their layer backgrounds instead of painting solid palette-derived colors, letting the window's visual effect show through. Assisted-by: claude-code:claude-sonnet-4-6 --- Sources/Mori/App/AppDelegate.swift | 2 +- Sources/Mori/App/CompanionToolPaneController.swift | 5 +++-- Sources/Mori/App/HostingControllers.swift | 5 +++-- Sources/Mori/App/RootSplitViewController.swift | 4 ++-- 4 files changed, 9 insertions(+), 7 deletions(-) diff --git a/Sources/Mori/App/AppDelegate.swift b/Sources/Mori/App/AppDelegate.swift index 6e053242..672bc8f3 100644 --- a/Sources/Mori/App/AppDelegate.swift +++ b/Sources/Mori/App/AppDelegate.swift @@ -974,7 +974,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private func refreshGhosttyThemeBackgrounds(themeInfo: GhosttyThemeInfo, chromePalette: MoriChromePalette) { let isKeyWindow = mainWindowController?.window?.isKeyWindow ?? true mainWindowController?.updateAppearance(themeInfo: themeInfo, chromePalette: chromePalette) - rootSplitVC?.updateAppearance(chromePalette: chromePalette) + rootSplitVC?.updateAppearance(chromePalette: chromePalette, isTransparent: themeInfo.usesTransparentWindowBackground) sidebarController?.updateAppearance(themeInfo: themeInfo, chromePalette: chromePalette) terminalAreaController?.updateAppearance(themeInfo: themeInfo, isKeyWindow: isKeyWindow) companionToolController?.updateAppearance(themeInfo: themeInfo, chromePalette: chromePalette, isKeyWindow: isKeyWindow) diff --git a/Sources/Mori/App/CompanionToolPaneController.swift b/Sources/Mori/App/CompanionToolPaneController.swift index 65081149..50e47e39 100644 --- a/Sources/Mori/App/CompanionToolPaneController.swift +++ b/Sources/Mori/App/CompanionToolPaneController.swift @@ -150,8 +150,9 @@ final class CompanionToolPaneController: NSViewController { func updateAppearance(themeInfo: GhosttyThemeInfo, chromePalette: MoriChromePalette, isKeyWindow: Bool) { view.appearance = NSAppearance(named: themeInfo.isDark ? .darkAqua : .aqua) - view.layer?.backgroundColor = chromePalette.panelBackground.nsColor.cgColor - headerView.layer?.backgroundColor = chromePalette.headerBackground.nsColor.cgColor + let isTransparent = themeInfo.usesTransparentWindowBackground + view.layer?.backgroundColor = isTransparent ? NSColor.clear.cgColor : chromePalette.panelBackground.nsColor.cgColor + headerView.layer?.backgroundColor = isTransparent ? NSColor.clear.cgColor : chromePalette.headerBackground.nsColor.cgColor dividerView.layer?.backgroundColor = chromePalette.divider.nsColor.cgColor titleLabel.textColor = .secondaryLabelColor terminalController.updateAppearance(themeInfo: themeInfo, isKeyWindow: isKeyWindow) diff --git a/Sources/Mori/App/HostingControllers.swift b/Sources/Mori/App/HostingControllers.swift index 0281485c..4e5751e5 100644 --- a/Sources/Mori/App/HostingControllers.swift +++ b/Sources/Mori/App/HostingControllers.swift @@ -73,8 +73,9 @@ final class SidebarHostingController: NSHostingController { func updateAppearance(themeInfo: GhosttyThemeInfo, chromePalette: MoriChromePalette) { chromePaletteStore.palette = chromePalette view.appearance = NSAppearance(named: themeInfo.isDark ? .darkAqua : .aqua) - view.layer?.backgroundColor = chromePalette.sidebarBackground.nsColor.cgColor - // Force SwiftUI to re-render with the updated appearance context. + view.layer?.backgroundColor = themeInfo.usesTransparentWindowBackground + ? NSColor.clear.cgColor + : chromePalette.sidebarBackground.nsColor.cgColor view.needsDisplay = true } } diff --git a/Sources/Mori/App/RootSplitViewController.swift b/Sources/Mori/App/RootSplitViewController.swift index 0da41852..b65881ff 100644 --- a/Sources/Mori/App/RootSplitViewController.swift +++ b/Sources/Mori/App/RootSplitViewController.swift @@ -220,9 +220,9 @@ final class RootSplitViewController: NSViewController { updateLayout() } - func updateAppearance(chromePalette: MoriChromePalette) { + func updateAppearance(chromePalette: MoriChromePalette, isTransparent: Bool = false) { self.chromePalette = chromePalette - view.layer?.backgroundColor = chromePalette.windowBackground.nsColor.cgColor + view.layer?.backgroundColor = isTransparent ? NSColor.clear.cgColor : chromePalette.windowBackground.nsColor.cgColor sidebarDividerView.layer?.backgroundColor = chromePalette.divider.nsColor.cgColor companionDividerView.layer?.backgroundColor = chromePalette.divider.nsColor.cgColor } From 40133d01b24ebb4481b3dcf35bfcd1c637132d33 Mon Sep 17 00:00:00 2001 From: Vaayne Date: Mon, 20 Apr 2026 16:33:50 +0800 Subject: [PATCH 7/8] =?UTF-8?q?=F0=9F=90=9B=20fix:=20clear=20sidebar=20Swi?= =?UTF-8?q?ftUI=20background=20when=20window=20is=20transparent?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SidebarContainerView painted a solid sidebarBackground regardless of opacity. Added isTransparent to MoriChromePalette (set from themeInfo.usesTransparentWindowBackground) so the SwiftUI background modifier can switch to Color.clear when transparency is active. Assisted-by: claude-code:claude-sonnet-4-6 --- Packages/MoriUI/Sources/MoriUI/MoriChromePalette.swift | 3 +++ Packages/MoriUI/Sources/MoriUI/SidebarContainerView.swift | 2 +- Sources/Mori/App/MoriChromeThemeBuilder.swift | 1 + 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/Packages/MoriUI/Sources/MoriUI/MoriChromePalette.swift b/Packages/MoriUI/Sources/MoriUI/MoriChromePalette.swift index ee3d54cd..65ef367e 100644 --- a/Packages/MoriUI/Sources/MoriUI/MoriChromePalette.swift +++ b/Packages/MoriUI/Sources/MoriUI/MoriChromePalette.swift @@ -43,6 +43,7 @@ public struct MoriChromeColor: Sendable, Equatable { public struct MoriChromePalette: Sendable, Equatable { public let isDark: Bool + public let isTransparent: Bool public let windowBackground: MoriChromeColor public let sidebarBackground: MoriChromeColor public let panelBackground: MoriChromeColor @@ -59,6 +60,7 @@ public struct MoriChromePalette: Sendable, Equatable { public init( isDark: Bool, + isTransparent: Bool = false, windowBackground: MoriChromeColor, sidebarBackground: MoriChromeColor, panelBackground: MoriChromeColor, @@ -74,6 +76,7 @@ public struct MoriChromePalette: Sendable, Equatable { selectionAccent: MoriChromeColor ) { self.isDark = isDark + self.isTransparent = isTransparent self.windowBackground = windowBackground self.sidebarBackground = sidebarBackground self.panelBackground = panelBackground diff --git a/Packages/MoriUI/Sources/MoriUI/SidebarContainerView.swift b/Packages/MoriUI/Sources/MoriUI/SidebarContainerView.swift index 51f17ec8..d8604df3 100644 --- a/Packages/MoriUI/Sources/MoriUI/SidebarContainerView.swift +++ b/Packages/MoriUI/Sources/MoriUI/SidebarContainerView.swift @@ -110,7 +110,7 @@ public struct SidebarContainerView: View { onReorderProjects: onReorderProjects ) .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(chromePaletteStore.palette.sidebarBackground.color) + .background(chromePaletteStore.palette.isTransparent ? Color.clear : chromePaletteStore.palette.sidebarBackground.color) .onAppear { shortcutHintMonitor.start() } diff --git a/Sources/Mori/App/MoriChromeThemeBuilder.swift b/Sources/Mori/App/MoriChromeThemeBuilder.swift index 2b4e9870..b86b1e57 100644 --- a/Sources/Mori/App/MoriChromeThemeBuilder.swift +++ b/Sources/Mori/App/MoriChromeThemeBuilder.swift @@ -95,6 +95,7 @@ enum MoriChromeThemeBuilder { return MoriChromePalette( isDark: themeInfo.isDark, + isTransparent: themeInfo.usesTransparentWindowBackground, windowBackground: MoriChromeColor(nsColor: windowBackground), sidebarBackground: MoriChromeColor(nsColor: sidebarBackground), panelBackground: MoriChromeColor(nsColor: panelBackground), From b3eadfcf5b540e93ce55bca862531370b0095fae Mon Sep 17 00:00:00 2001 From: Vaayne Date: Mon, 20 Apr 2026 16:48:57 +0800 Subject: [PATCH 8/8] =?UTF-8?q?=F0=9F=8E=A8=20fix:=20match=20chrome=20surf?= =?UTF-8?q?ace=20opacity=20to=20terminal=20background-opacity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Instead of setting surfaces to Color.clear (making them holes), apply backgroundOpacity as alpha to all surface palette colors in the builder. This makes sidebar, companion pane, and root split tint-transparent at the same opacity level as the Ghostty terminal, so they blend consistently over the wallpaper. Removes isTransparent from MoriChromePalette and all isTransparent ? .clear ternaries — opacity is now carried by the palette colors themselves. Assisted-by: claude-code:claude-sonnet-4-6 --- .../Sources/MoriUI/MoriChromePalette.swift | 3 --- .../Sources/MoriUI/SidebarContainerView.swift | 2 +- Sources/Mori/App/AppDelegate.swift | 2 +- .../Mori/App/CompanionToolPaneController.swift | 5 ++--- Sources/Mori/App/HostingControllers.swift | 4 +--- Sources/Mori/App/MoriChromeThemeBuilder.swift | 17 +++++++++++------ Sources/Mori/App/RootSplitViewController.swift | 4 ++-- 7 files changed, 18 insertions(+), 19 deletions(-) diff --git a/Packages/MoriUI/Sources/MoriUI/MoriChromePalette.swift b/Packages/MoriUI/Sources/MoriUI/MoriChromePalette.swift index 65ef367e..ee3d54cd 100644 --- a/Packages/MoriUI/Sources/MoriUI/MoriChromePalette.swift +++ b/Packages/MoriUI/Sources/MoriUI/MoriChromePalette.swift @@ -43,7 +43,6 @@ public struct MoriChromeColor: Sendable, Equatable { public struct MoriChromePalette: Sendable, Equatable { public let isDark: Bool - public let isTransparent: Bool public let windowBackground: MoriChromeColor public let sidebarBackground: MoriChromeColor public let panelBackground: MoriChromeColor @@ -60,7 +59,6 @@ public struct MoriChromePalette: Sendable, Equatable { public init( isDark: Bool, - isTransparent: Bool = false, windowBackground: MoriChromeColor, sidebarBackground: MoriChromeColor, panelBackground: MoriChromeColor, @@ -76,7 +74,6 @@ public struct MoriChromePalette: Sendable, Equatable { selectionAccent: MoriChromeColor ) { self.isDark = isDark - self.isTransparent = isTransparent self.windowBackground = windowBackground self.sidebarBackground = sidebarBackground self.panelBackground = panelBackground diff --git a/Packages/MoriUI/Sources/MoriUI/SidebarContainerView.swift b/Packages/MoriUI/Sources/MoriUI/SidebarContainerView.swift index d8604df3..51f17ec8 100644 --- a/Packages/MoriUI/Sources/MoriUI/SidebarContainerView.swift +++ b/Packages/MoriUI/Sources/MoriUI/SidebarContainerView.swift @@ -110,7 +110,7 @@ public struct SidebarContainerView: View { onReorderProjects: onReorderProjects ) .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(chromePaletteStore.palette.isTransparent ? Color.clear : chromePaletteStore.palette.sidebarBackground.color) + .background(chromePaletteStore.palette.sidebarBackground.color) .onAppear { shortcutHintMonitor.start() } diff --git a/Sources/Mori/App/AppDelegate.swift b/Sources/Mori/App/AppDelegate.swift index 672bc8f3..6e053242 100644 --- a/Sources/Mori/App/AppDelegate.swift +++ b/Sources/Mori/App/AppDelegate.swift @@ -974,7 +974,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private func refreshGhosttyThemeBackgrounds(themeInfo: GhosttyThemeInfo, chromePalette: MoriChromePalette) { let isKeyWindow = mainWindowController?.window?.isKeyWindow ?? true mainWindowController?.updateAppearance(themeInfo: themeInfo, chromePalette: chromePalette) - rootSplitVC?.updateAppearance(chromePalette: chromePalette, isTransparent: themeInfo.usesTransparentWindowBackground) + rootSplitVC?.updateAppearance(chromePalette: chromePalette) sidebarController?.updateAppearance(themeInfo: themeInfo, chromePalette: chromePalette) terminalAreaController?.updateAppearance(themeInfo: themeInfo, isKeyWindow: isKeyWindow) companionToolController?.updateAppearance(themeInfo: themeInfo, chromePalette: chromePalette, isKeyWindow: isKeyWindow) diff --git a/Sources/Mori/App/CompanionToolPaneController.swift b/Sources/Mori/App/CompanionToolPaneController.swift index 50e47e39..65081149 100644 --- a/Sources/Mori/App/CompanionToolPaneController.swift +++ b/Sources/Mori/App/CompanionToolPaneController.swift @@ -150,9 +150,8 @@ final class CompanionToolPaneController: NSViewController { func updateAppearance(themeInfo: GhosttyThemeInfo, chromePalette: MoriChromePalette, isKeyWindow: Bool) { view.appearance = NSAppearance(named: themeInfo.isDark ? .darkAqua : .aqua) - let isTransparent = themeInfo.usesTransparentWindowBackground - view.layer?.backgroundColor = isTransparent ? NSColor.clear.cgColor : chromePalette.panelBackground.nsColor.cgColor - headerView.layer?.backgroundColor = isTransparent ? NSColor.clear.cgColor : chromePalette.headerBackground.nsColor.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) diff --git a/Sources/Mori/App/HostingControllers.swift b/Sources/Mori/App/HostingControllers.swift index 4e5751e5..06e1ad4e 100644 --- a/Sources/Mori/App/HostingControllers.swift +++ b/Sources/Mori/App/HostingControllers.swift @@ -73,9 +73,7 @@ final class SidebarHostingController: NSHostingController { func updateAppearance(themeInfo: GhosttyThemeInfo, chromePalette: MoriChromePalette) { chromePaletteStore.palette = chromePalette view.appearance = NSAppearance(named: themeInfo.isDark ? .darkAqua : .aqua) - view.layer?.backgroundColor = themeInfo.usesTransparentWindowBackground - ? NSColor.clear.cgColor - : chromePalette.sidebarBackground.nsColor.cgColor + view.layer?.backgroundColor = chromePalette.sidebarBackground.nsColor.cgColor view.needsDisplay = true } } diff --git a/Sources/Mori/App/MoriChromeThemeBuilder.swift b/Sources/Mori/App/MoriChromeThemeBuilder.swift index b86b1e57..0b43b945 100644 --- a/Sources/Mori/App/MoriChromeThemeBuilder.swift +++ b/Sources/Mori/App/MoriChromeThemeBuilder.swift @@ -93,14 +93,19 @@ enum MoriChromeThemeBuilder { 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, - isTransparent: themeInfo.usesTransparentWindowBackground, - windowBackground: MoriChromeColor(nsColor: windowBackground), - sidebarBackground: MoriChromeColor(nsColor: sidebarBackground), - panelBackground: MoriChromeColor(nsColor: panelBackground), - headerBackground: MoriChromeColor(nsColor: headerBackground), - cardBackground: MoriChromeColor(nsColor: cardBackground), + 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), diff --git a/Sources/Mori/App/RootSplitViewController.swift b/Sources/Mori/App/RootSplitViewController.swift index b65881ff..0da41852 100644 --- a/Sources/Mori/App/RootSplitViewController.swift +++ b/Sources/Mori/App/RootSplitViewController.swift @@ -220,9 +220,9 @@ final class RootSplitViewController: NSViewController { updateLayout() } - func updateAppearance(chromePalette: MoriChromePalette, isTransparent: Bool = false) { + func updateAppearance(chromePalette: MoriChromePalette) { self.chromePalette = chromePalette - view.layer?.backgroundColor = isTransparent ? NSColor.clear.cgColor : chromePalette.windowBackground.nsColor.cgColor + view.layer?.backgroundColor = chromePalette.windowBackground.nsColor.cgColor sidebarDividerView.layer?.backgroundColor = chromePalette.divider.nsColor.cgColor companionDividerView.layer?.backgroundColor = chromePalette.divider.nsColor.cgColor }