From 9a5696e9c6bcdac1548c3d3f541823d4822a9cfb Mon Sep 17 00:00:00 2001 From: noodanee Date: Sat, 21 Mar 2026 14:14:30 +0800 Subject: [PATCH] feat(ui): add sidebar appearance settings (font, size, spacing) Add user-configurable sidebar appearance with font family, font size, and spacing controls. Settings persist via UserDefaults and take effect immediately across all sidebar views. - New SidebarAppearance model with Environment-based propagation - SidebarAppearanceStore (@Observable) with UserDefaults persistence - "Appearance" category in Settings UI with font picker, size slider (10-22pt), spacing slider (0.8x-1.8x), and live preview - All sidebar views (WorktreeSidebar, WorktreeRow, WindowRow, ProjectRail) read appearance from @Environment - Default font size bumped from 13pt to 14pt for better readability --- .../MoriCore/Models/SidebarAppearance.swift | 125 +++++++++++++ .../Sources/MoriUI/GhosttySettingsView.swift | 175 +++++++++++++++++- .../Sources/MoriUI/ProjectRailView.swift | 31 ++-- .../MoriUI/Sources/MoriUI/WindowRowView.swift | 14 +- .../Sources/MoriUI/WorktreeRowView.swift | 23 ++- .../Sources/MoriUI/WorktreeSidebarView.swift | 53 +++--- Sources/Mori/App/AppDelegate.swift | 11 +- Sources/Mori/App/HostingControllers.swift | 10 +- 8 files changed, 371 insertions(+), 71 deletions(-) create mode 100644 Packages/MoriCore/Sources/MoriCore/Models/SidebarAppearance.swift diff --git a/Packages/MoriCore/Sources/MoriCore/Models/SidebarAppearance.swift b/Packages/MoriCore/Sources/MoriCore/Models/SidebarAppearance.swift new file mode 100644 index 0000000..4e27cb6 --- /dev/null +++ b/Packages/MoriCore/Sources/MoriCore/Models/SidebarAppearance.swift @@ -0,0 +1,125 @@ +import Foundation +import SwiftUI + +public struct SidebarAppearance: Sendable, Equatable { + public var fontFamily: String + public var fontSize: CGFloat + public var spacing: CGFloat + + public static let `default` = SidebarAppearance( + fontFamily: "", + fontSize: 14, + spacing: 1.0 + ) + + public init(fontFamily: String = "", fontSize: CGFloat = 14, spacing: CGFloat = 1.0) { + self.fontFamily = fontFamily + self.fontSize = fontSize + self.spacing = spacing + } + + public func font(_ style: FontStyle) -> Font { + let size = style.size(base: fontSize) + let weight = style.weight + let design = style.design + if fontFamily.isEmpty { + return .system(size: size, weight: weight, design: design) + } + return .custom(fontFamily, size: size).weight(weight) + } + + public func scaled(_ value: CGFloat) -> CGFloat { + (value * spacing).rounded() + } + + public enum FontStyle: Sendable { + case sectionTitle + case rowTitle + case windowTitle + case label + case caption + case badgeCount + case monoSmall + case arrowIcon + + func size(base: CGFloat) -> CGFloat { + switch self { + case .sectionTitle: return base - 1 + case .rowTitle: return base + 1 + case .windowTitle: return base + case .label: return base - 2 + case .caption: return base - 3 + case .badgeCount: return base - 5 + case .monoSmall: return base - 4 + case .arrowIcon: return base - 6 + } + } + + var weight: Font.Weight { + switch self { + case .sectionTitle: return .semibold + case .rowTitle: return .semibold + case .windowTitle: return .regular + case .label: return .regular + case .caption: return .regular + case .badgeCount: return .bold + case .monoSmall: return .regular + case .arrowIcon: return .regular + } + } + + var design: Font.Design { + switch self { + case .badgeCount: return .rounded + case .monoSmall: return .monospaced + case .arrowIcon: return .default + default: return .default + } + } + } +} + +private enum SidebarAppearanceKey: EnvironmentKey { + static let defaultValue = SidebarAppearance.default +} + +public extension EnvironmentValues { + var sidebarAppearance: SidebarAppearance { + get { self[SidebarAppearanceKey.self] } + set { self[SidebarAppearanceKey.self] = newValue } + } +} + +@Observable +public final class SidebarAppearanceStore: @unchecked Sendable { + + public var appearance: SidebarAppearance { + didSet { + guard appearance != oldValue else { return } + save() + } + } + + private static let fontFamilyKey = "MoriSidebarFontFamily" + private static let fontSizeKey = "MoriSidebarFontSize" + private static let spacingKey = "MoriSidebarSpacing" + + public init() { + let defaults = UserDefaults.standard + let family = defaults.string(forKey: Self.fontFamilyKey) ?? "" + let size = defaults.double(forKey: Self.fontSizeKey) + let spacing = defaults.double(forKey: Self.spacingKey) + self.appearance = SidebarAppearance( + fontFamily: family, + fontSize: size > 0 ? CGFloat(size) : SidebarAppearance.default.fontSize, + spacing: spacing > 0 ? CGFloat(spacing) : SidebarAppearance.default.spacing + ) + } + + private func save() { + let defaults = UserDefaults.standard + defaults.set(appearance.fontFamily, forKey: Self.fontFamilyKey) + defaults.set(Double(appearance.fontSize), forKey: Self.fontSizeKey) + defaults.set(Double(appearance.spacing), forKey: Self.spacingKey) + } +} diff --git a/Packages/MoriUI/Sources/MoriUI/GhosttySettingsView.swift b/Packages/MoriUI/Sources/MoriUI/GhosttySettingsView.swift index cd03143..d05d53f 100644 --- a/Packages/MoriUI/Sources/MoriUI/GhosttySettingsView.swift +++ b/Packages/MoriUI/Sources/MoriUI/GhosttySettingsView.swift @@ -1,4 +1,5 @@ import SwiftUI +import MoriCore /// Settings model representing user-facing ghostty config options. /// Read from and written to ~/.config/ghostty/config. @@ -63,6 +64,7 @@ public struct AgentHookModel: Equatable { // MARK: - Settings Category enum SettingsCategory: String, CaseIterable, Identifiable { + case appearance = "Appearance" case theme = "Theme" case fonts = "Fonts" case cursor = "Cursor" @@ -75,6 +77,7 @@ enum SettingsCategory: String, CaseIterable, Identifiable { var icon: String { switch self { + case .appearance: return "sidebar.left" case .theme: return "paintpalette" case .fonts: return "textformat" case .cursor: return "character.cursor.ibeam" @@ -96,6 +99,7 @@ public struct GhosttySettingsView: View { var onOpenConfigFile: () -> Void @Binding var agentHooks: AgentHookModel var onAgentHookChanged: ((AgentHookModel) -> Void)? + var appearanceStore: SidebarAppearanceStore? @State private var selectedCategory: SettingsCategory = .theme @@ -106,7 +110,8 @@ public struct GhosttySettingsView: View { onChanged: @escaping () -> Void, onOpenConfigFile: @escaping () -> Void, agentHooks: Binding = .constant(AgentHookModel()), - onAgentHookChanged: ((AgentHookModel) -> Void)? = nil + onAgentHookChanged: ((AgentHookModel) -> Void)? = nil, + appearanceStore: SidebarAppearanceStore? = nil ) { self._model = model self.availableThemes = availableThemes @@ -115,6 +120,7 @@ public struct GhosttySettingsView: View { self.onOpenConfigFile = onOpenConfigFile self._agentHooks = agentHooks self.onAgentHookChanged = onAgentHookChanged + self.appearanceStore = appearanceStore } public var body: some View { @@ -201,6 +207,10 @@ public struct GhosttySettingsView: View { ScrollView { VStack(alignment: .leading, spacing: 20) { switch selectedCategory { + case .appearance: + if let store = appearanceStore { + SidebarAppearanceSettingsContent(store: store) + } case .theme: ThemeSettingsContent(model: $model, availableThemes: availableThemes, onChanged: onChanged) case .fonts: FontSettingsContent(model: $model, onChanged: onChanged) case .cursor: CursorSettingsContent(model: $model, onChanged: onChanged) @@ -1077,3 +1087,166 @@ private struct AgentHookSettingsContent: View { } } } + +// MARK: - Sidebar Appearance Settings + +private struct SidebarAppearanceSettingsContent: View { + @Bindable var store: SidebarAppearanceStore + + @State private var fontSearch = "" + + private var availableFonts: [String] { + let families = NSFontManager.shared.availableFontFamilies.sorted() + if fontSearch.isEmpty { return families } + return families.filter { $0.localizedCaseInsensitiveContains(fontSearch) } + } + + var body: some View { + VStack(alignment: .leading, spacing: 20) { + SettingsCard { + VStack(alignment: .leading, spacing: 12) { + Text("Sidebar Font") + .font(.system(size: 13, weight: .medium)) + + TextField("Search fonts…", text: $fontSearch) + .textFieldStyle(.roundedBorder) + .font(.system(size: 12)) + + ScrollView { + LazyVStack(alignment: .leading, spacing: 0) { + fontRow(name: "System Default", family: "") + + ForEach(availableFonts, id: \.self) { family in + fontRow(name: family, family: family) + } + } + } + .frame(height: 180) + .background(Color(nsColor: .textBackgroundColor).opacity(0.3)) + .clipShape(RoundedRectangle(cornerRadius: 6)) + } + } + + SettingsCard { + VStack(alignment: .leading, spacing: 16) { + HStack { + Text("Font Size") + .font(.system(size: 13, weight: .medium)) + Spacer() + Text("\(Int(store.appearance.fontSize)) pt") + .font(.system(size: 12, design: .monospaced)) + .foregroundStyle(.secondary) + } + + Slider(value: $store.appearance.fontSize, in: 10...22, step: 1) + } + + CardDivider() + + VStack(alignment: .leading, spacing: 16) { + HStack { + Text("Spacing") + .font(.system(size: 13, weight: .medium)) + Spacer() + Text(String(format: "%.1fx", store.appearance.spacing)) + .font(.system(size: 12, design: .monospaced)) + .foregroundStyle(.secondary) + } + + Slider(value: $store.appearance.spacing, in: 0.8...1.8, step: 0.1) + + Text("Adjusts spacing between sidebar elements") + .font(.system(size: 11)) + .foregroundStyle(.secondary) + } + } + + SettingsCard { + VStack(alignment: .leading, spacing: 12) { + Text("Preview") + .font(.system(size: 13, weight: .medium)) + + sidebarPreview + } + } + } + } + + private func fontRow(name: String, family: String) -> some View { + let isSelected = store.appearance.fontFamily == family + return Button { + store.appearance.fontFamily = family + } label: { + HStack { + Text(name) + .font(family.isEmpty ? .system(size: 13) : .custom(family, size: 13)) + .lineLimit(1) + Spacer() + if isSelected { + Image(systemName: "checkmark") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(Color.accentColor) + } + } + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background(isSelected ? Color.accentColor.opacity(0.1) : .clear) + } + .buttonStyle(.plain) + } + + private var sidebarPreview: some View { + let a = store.appearance + return VStack(alignment: .leading, spacing: 0) { + HStack(spacing: a.scaled(6)) { + Image(systemName: "chevron.down") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(.secondary) + Text("my-project") + .font(a.font(.sectionTitle)) + .foregroundStyle(.secondary) + Spacer() + } + .padding(.horizontal, a.scaled(12)) + .padding(.vertical, a.scaled(8)) + + HStack(spacing: a.scaled(6)) { + Image(systemName: "star.fill") + .font(a.font(.label)) + .foregroundStyle(.yellow) + VStack(alignment: .leading, spacing: 1) { + Text("main") + .font(a.font(.rowTitle)) + Text("main worktree") + .font(a.font(.caption)) + .foregroundStyle(.secondary) + } + Spacer() + } + .padding(.vertical, a.scaled(6)) + .padding(.horizontal, a.scaled(8)) + .background(Color.accentColor.opacity(0.12)) + .clipShape(RoundedRectangle(cornerRadius: 4)) + .padding(.horizontal, a.scaled(4)) + + HStack(spacing: a.scaled(6)) { + Image(systemName: "terminal") + .font(a.font(.label)) + .foregroundStyle(.secondary) + Text("zsh") + .font(a.font(.windowTitle)) + .foregroundStyle(.secondary) + Spacer() + Text("⌘1") + .font(a.font(.monoSmall)) + .foregroundStyle(.secondary) + } + .padding(.vertical, a.scaled(4)) + .padding(.horizontal, a.scaled(8)) + .padding(.leading, a.scaled(16)) + } + .padding(.vertical, a.scaled(8)) + .background(Color(nsColor: .controlBackgroundColor).opacity(0.4)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } +} diff --git a/Packages/MoriUI/Sources/MoriUI/ProjectRailView.swift b/Packages/MoriUI/Sources/MoriUI/ProjectRailView.swift index ebf44bb..b6287f4 100644 --- a/Packages/MoriUI/Sources/MoriUI/ProjectRailView.swift +++ b/Packages/MoriUI/Sources/MoriUI/ProjectRailView.swift @@ -1,8 +1,6 @@ import SwiftUI import MoriCore -/// Narrow rail displaying projects as first-letter circle icons with names, -/// plus action buttons at the bottom. public struct ProjectRailView: View { private let projects: [Project] private let selectedProjectId: UUID? @@ -11,6 +9,8 @@ public struct ProjectRailView: View { private let onOpenSettings: (() -> Void)? private let onToggleSidebar: (() -> Void)? + @Environment(\.sidebarAppearance) private var appearance + public init( projects: [Project], selectedProjectId: UUID?, @@ -30,7 +30,7 @@ public struct ProjectRailView: View { public var body: some View { VStack(spacing: 0) { ScrollView(.vertical) { - LazyVStack(spacing: MoriTokens.Spacing.lg) { + LazyVStack(spacing: appearance.scaled(MoriTokens.Spacing.lg)) { ForEach(projects) { project in ProjectRailRow( project: project, @@ -39,7 +39,7 @@ public struct ProjectRailView: View { ) } } - .padding(.vertical, MoriTokens.Spacing.lg) + .padding(.vertical, appearance.scaled(MoriTokens.Spacing.lg)) } Spacer(minLength: 0) @@ -52,13 +52,13 @@ public struct ProjectRailView: View { @ViewBuilder private var railFooter: some View { - VStack(spacing: MoriTokens.Spacing.lg) { + VStack(spacing: appearance.scaled(MoriTokens.Spacing.lg)) { Divider() if let onToggleSidebar { Button(action: onToggleSidebar) { Image(systemName: "sidebar.left") - .font(.system(size: MoriTokens.Size.avatarFont)) + .font(.system(size: appearance.fontSize + 2)) .foregroundStyle(MoriTokens.Color.muted) .frame(maxWidth: .infinity) } @@ -70,7 +70,7 @@ public struct ProjectRailView: View { if let onAddProject { Button(action: onAddProject) { Image(systemName: "plus.rectangle.on.folder") - .font(.system(size: MoriTokens.Size.avatarFont)) + .font(.system(size: appearance.fontSize + 2)) .foregroundStyle(MoriTokens.Color.muted) .frame(maxWidth: .infinity) } @@ -82,7 +82,7 @@ public struct ProjectRailView: View { if let onOpenSettings { Button(action: onOpenSettings) { Image(systemName: "gearshape") - .font(.system(size: MoriTokens.Size.avatarFont)) + .font(.system(size: appearance.fontSize + 2)) .foregroundStyle(MoriTokens.Color.muted) .frame(maxWidth: .infinity) } @@ -91,7 +91,7 @@ public struct ProjectRailView: View { .accessibilityLabel("Settings") } } - .padding(.bottom, MoriTokens.Spacing.lg) + .padding(.bottom, appearance.scaled(MoriTokens.Spacing.lg)) } } @@ -102,27 +102,30 @@ private struct ProjectRailRow: View { let isSelected: Bool let onSelect: () -> Void + @Environment(\.sidebarAppearance) private var appearance + var body: some View { Button(action: onSelect) { - VStack(spacing: MoriTokens.Spacing.sm) { + VStack(spacing: appearance.scaled(MoriTokens.Spacing.sm)) { + let avatarSize = appearance.scaled(MoriTokens.Size.avatar) ZStack { Circle() .fill(isSelected ? MoriTokens.Color.active : MoriTokens.Color.muted.opacity(MoriTokens.Opacity.medium)) - .frame(width: MoriTokens.Size.avatar, height: MoriTokens.Size.avatar) + .frame(width: avatarSize, height: avatarSize) Text(firstLetter) - .font(.system(size: MoriTokens.Size.avatarFont, weight: .semibold, design: .rounded)) + .font(.system(size: appearance.fontSize + 2, weight: .semibold, design: .rounded)) .foregroundStyle(isSelected ? Color.white : Color.primary) } Text(project.name) - .font(MoriTokens.Font.caption) + .font(appearance.font(.caption)) .lineLimit(1) .truncationMode(.tail) .foregroundStyle(isSelected ? MoriTokens.Color.active : MoriTokens.Color.muted) } .frame(maxWidth: .infinity) - .padding(.vertical, MoriTokens.Spacing.sm) + .padding(.vertical, appearance.scaled(MoriTokens.Spacing.sm)) .contentShape(Rectangle()) } .buttonStyle(.plain) diff --git a/Packages/MoriUI/Sources/MoriUI/WindowRowView.swift b/Packages/MoriUI/Sources/MoriUI/WindowRowView.swift index df4b6fb..10729df 100644 --- a/Packages/MoriUI/Sources/MoriUI/WindowRowView.swift +++ b/Packages/MoriUI/Sources/MoriUI/WindowRowView.swift @@ -1,13 +1,13 @@ import SwiftUI import MoriCore -/// A row representing a single tmux window within a worktree section. public struct WindowRowView: View { let window: RuntimeWindow let isActive: Bool let shortcutIndex: Int? let onSelect: () -> Void + @Environment(\.sidebarAppearance) private var appearance @State private var isHovered = false public init( @@ -24,13 +24,13 @@ public struct WindowRowView: View { public var body: some View { Button(action: onSelect) { - HStack(spacing: MoriTokens.Spacing.md) { + HStack(spacing: appearance.scaled(MoriTokens.Spacing.md)) { Image(systemName: window.tag?.symbolName ?? "terminal") - .font(MoriTokens.Font.label) + .font(appearance.font(.label)) .foregroundStyle(isActive ? MoriTokens.Color.active : MoriTokens.Color.muted) Text(window.title.isEmpty ? "Window \(window.tmuxWindowIndex)" : window.title) - .font(MoriTokens.Font.windowTitle) + .font(appearance.font(.windowTitle)) .lineLimit(1) .foregroundStyle(isActive ? Color.primary : MoriTokens.Color.muted) @@ -38,7 +38,7 @@ public struct WindowRowView: View { if let shortcutIndex { Text("\u{2318}\(shortcutIndex)") - .font(MoriTokens.Font.monoSmall) + .font(appearance.font(.monoSmall)) .foregroundStyle(MoriTokens.Color.muted) .accessibilityLabel("Command \(shortcutIndex)") } @@ -52,8 +52,8 @@ public struct WindowRowView: View { .accessibilityLabel("Active window") } } - .padding(.vertical, MoriTokens.Spacing.xs) - .padding(.horizontal, MoriTokens.Spacing.lg) + .padding(.vertical, appearance.scaled(MoriTokens.Spacing.sm)) + .padding(.horizontal, appearance.scaled(MoriTokens.Spacing.lg)) .contentShape(Rectangle()) } .buttonStyle(.plain) diff --git a/Packages/MoriUI/Sources/MoriUI/WorktreeRowView.swift b/Packages/MoriUI/Sources/MoriUI/WorktreeRowView.swift index 713248e..ec64249 100644 --- a/Packages/MoriUI/Sources/MoriUI/WorktreeRowView.swift +++ b/Packages/MoriUI/Sources/MoriUI/WorktreeRowView.swift @@ -1,14 +1,13 @@ import SwiftUI import MoriCore -/// A two-line row representing a single worktree: bold name + subtitle with status. -/// Shows a hover-reveal `...` menu for management actions. public struct WorktreeRowView: View { let worktree: Worktree let isSelected: Bool let onSelect: () -> Void var onRemove: (() -> Void)? + @Environment(\.sidebarAppearance) private var appearance @State private var isHovered = false public init( @@ -25,15 +24,15 @@ public struct WorktreeRowView: View { public var body: some View { Button(action: onSelect) { - HStack(alignment: .center, spacing: MoriTokens.Spacing.md) { + HStack(alignment: .center, spacing: appearance.scaled(MoriTokens.Spacing.md)) { Image(systemName: worktree.isMainWorktree ? "star.fill" : "arrow.triangle.branch") - .font(MoriTokens.Font.label) + .font(appearance.font(.label)) .foregroundStyle(worktree.isMainWorktree ? MoriTokens.Color.attention : MoriTokens.Color.muted) VStack(alignment: .leading, spacing: MoriTokens.Spacing.xxs) { - HStack(spacing: MoriTokens.Spacing.sm) { + HStack(spacing: appearance.scaled(MoriTokens.Spacing.sm)) { Text(worktree.branch ?? worktree.name) - .font(.system(.body, weight: .semibold)) + .font(appearance.font(.rowTitle)) .lineLimit(1) gitStatusBadges @@ -51,8 +50,8 @@ public struct WorktreeRowView: View { .transition(.opacity) } } - .padding(.vertical, MoriTokens.Spacing.md) - .padding(.horizontal, MoriTokens.Spacing.lg) + .padding(.vertical, appearance.scaled(MoriTokens.Spacing.md)) + .padding(.horizontal, appearance.scaled(MoriTokens.Spacing.lg)) .contentShape(Rectangle()) } .buttonStyle(.plain) @@ -119,9 +118,9 @@ public struct WorktreeRowView: View { // MARK: - Subtitle private var subtitleText: some View { - HStack(spacing: MoriTokens.Spacing.sm) { + HStack(spacing: appearance.scaled(MoriTokens.Spacing.sm)) { Text(worktree.name) - .font(MoriTokens.Font.caption) + .font(appearance.font(.caption)) .foregroundStyle(MoriTokens.Color.muted) .lineLimit(1) @@ -143,13 +142,13 @@ public struct WorktreeRowView: View { HStack(spacing: MoriTokens.Spacing.xxs) { if worktree.aheadCount > 0 { Text("+\(worktree.aheadCount)") - .font(MoriTokens.Font.monoSmall) + .font(appearance.font(.monoSmall)) .foregroundStyle(MoriTokens.Color.success) .accessibilityLabel("\(worktree.aheadCount) ahead") } if worktree.behindCount > 0 { Text("-\(worktree.behindCount)") - .font(MoriTokens.Font.monoSmall) + .font(appearance.font(.monoSmall)) .foregroundStyle(MoriTokens.Color.error) .accessibilityLabel("\(worktree.behindCount) behind") } diff --git a/Packages/MoriUI/Sources/MoriUI/WorktreeSidebarView.swift b/Packages/MoriUI/Sources/MoriUI/WorktreeSidebarView.swift index 31fc3b8..b68fb62 100644 --- a/Packages/MoriUI/Sources/MoriUI/WorktreeSidebarView.swift +++ b/Packages/MoriUI/Sources/MoriUI/WorktreeSidebarView.swift @@ -1,8 +1,6 @@ import SwiftUI import MoriCore -/// Unified sidebar: all projects as flat sections, worktrees as two-line rows, -/// windows indented below, and action footer at the bottom. public struct WorktreeSidebarView: View { private let projects: [Project] private let selectedProjectId: UUID? @@ -22,6 +20,7 @@ public struct WorktreeSidebarView: View { private let onOpenSettings: (() -> Void)? private let onOpenCommandPalette: (() -> Void)? + @Environment(\.sidebarAppearance) private var appearance @State private var editingProjectId: UUID? @State private var newBranchName = "" @State private var isSubmitting = false @@ -71,12 +70,12 @@ public struct WorktreeSidebarView: View { ForEach(Array(projects.enumerated()), id: \.element.id) { index, project in if index > 0 { Divider() - .padding(.horizontal, MoriTokens.Spacing.xl) + .padding(.horizontal, appearance.scaled(MoriTokens.Spacing.xl)) } projectSection(project) } } - .padding(.top, MoriTokens.Spacing.lg) + .padding(.top, appearance.scaled(MoriTokens.Spacing.lg)) } Spacer(minLength: 0) @@ -92,15 +91,14 @@ public struct WorktreeSidebarView: View { @ViewBuilder private func projectSection(_ project: Project) -> some View { - // Section header: chevron + name + hover-reveal + button - HStack(spacing: MoriTokens.Spacing.md) { + HStack(spacing: appearance.scaled(MoriTokens.Spacing.md)) { Image(systemName: project.isCollapsed ? "chevron.right" : "chevron.down") .font(.system(size: 10, weight: .semibold)) .foregroundStyle(MoriTokens.Color.muted) .frame(width: 12) Text(project.name) - .font(MoriTokens.Font.sectionTitle) + .font(appearance.font(.sectionTitle)) .foregroundStyle(MoriTokens.Color.muted) Spacer() @@ -173,9 +171,9 @@ public struct WorktreeSidebarView: View { .transition(.opacity) } } - .padding(.horizontal, MoriTokens.Spacing.xl) - .padding(.top, MoriTokens.Spacing.xl) - .padding(.bottom, MoriTokens.Spacing.sm) + .padding(.horizontal, appearance.scaled(MoriTokens.Spacing.xl)) + .padding(.top, appearance.scaled(MoriTokens.Spacing.xl)) + .padding(.bottom, appearance.scaled(MoriTokens.Spacing.sm)) .contentShape(Rectangle()) .onTapGesture { onToggleCollapse?(project.id) @@ -228,21 +226,19 @@ public struct WorktreeSidebarView: View { } if !project.isCollapsed { - // Branch input (only for the project being edited) if editingProjectId == project.id { branchNameInput - .padding(.horizontal, MoriTokens.Spacing.sm) + .padding(.horizontal, appearance.scaled(MoriTokens.Spacing.sm)) } - // Worktrees for this project let projectWorktrees = worktrees.filter { $0.projectId == project.id && $0.status != .unavailable } if projectWorktrees.isEmpty, project.id == selectedProjectId { Text("No worktrees") - .font(MoriTokens.Font.caption) + .font(appearance.font(.caption)) .foregroundStyle(MoriTokens.Color.muted) - .padding(.horizontal, MoriTokens.Spacing.xl) - .padding(.vertical, MoriTokens.Spacing.sm) + .padding(.horizontal, appearance.scaled(MoriTokens.Spacing.xl)) + .padding(.vertical, appearance.scaled(MoriTokens.Spacing.sm)) } ForEach(projectWorktrees) { worktree in @@ -296,7 +292,6 @@ public struct WorktreeSidebarView: View { } } - // Show windows (tabs) under every worktree that has them if !worktreeWindows.isEmpty { ForEach(Array(worktreeWindows.enumerated()), id: \.element.id) { index, window in WindowRowView( @@ -305,7 +300,7 @@ public struct WorktreeSidebarView: View { shortcutIndex: isSelected && index < 9 ? index + 1 : nil, onSelect: { onSelectWindow(window.tmuxWindowId) } ) - .padding(.leading, MoriTokens.Spacing.xxl) + .padding(.leading, appearance.scaled(MoriTokens.Spacing.xxl)) .contextMenu { if let onCloseWindow { Button(role: .destructive) { @@ -318,7 +313,7 @@ public struct WorktreeSidebarView: View { } } } - .padding(.horizontal, MoriTokens.Spacing.sm) + .padding(.horizontal, appearance.scaled(MoriTokens.Spacing.sm)) } // MARK: - Branch Name Input @@ -330,7 +325,7 @@ public struct WorktreeSidebarView: View { .controlSize(.small) } else { Image(systemName: "arrow.triangle.branch") - .font(MoriTokens.Font.label) + .font(appearance.font(.label)) .foregroundStyle(MoriTokens.Color.muted) } @@ -354,8 +349,8 @@ public struct WorktreeSidebarView: View { .buttonStyle(.plain) .disabled(isSubmitting) } - .padding(.horizontal, MoriTokens.Spacing.lg) - .padding(.vertical, MoriTokens.Spacing.sm) + .padding(.horizontal, appearance.scaled(MoriTokens.Spacing.lg)) + .padding(.vertical, appearance.scaled(MoriTokens.Spacing.sm)) .background(MoriTokens.Color.muted.opacity(MoriTokens.Opacity.subtle)) .clipShape(RoundedRectangle(cornerRadius: MoriTokens.Radius.small)) } @@ -375,19 +370,17 @@ public struct WorktreeSidebarView: View { newBranchName = "" } - // MARK: - Helpers - // MARK: - Footer private var sidebarFooter: some View { VStack(spacing: 0) { Divider() - HStack(spacing: MoriTokens.Spacing.xl) { + HStack(spacing: appearance.scaled(MoriTokens.Spacing.xl)) { if let onAddProject { Button(action: onAddProject) { Image(systemName: "plus.rectangle.on.folder") - .font(.system(size: 13)) + .font(.system(size: appearance.fontSize)) .foregroundStyle(MoriTokens.Color.muted) } .buttonStyle(.plain) @@ -400,7 +393,7 @@ public struct WorktreeSidebarView: View { if let onOpenCommandPalette { Button(action: onOpenCommandPalette) { Image(systemName: "text.magnifyingglass") - .font(.system(size: 13)) + .font(.system(size: appearance.fontSize)) .foregroundStyle(MoriTokens.Color.muted) } .buttonStyle(.plain) @@ -411,7 +404,7 @@ public struct WorktreeSidebarView: View { if let onOpenSettings { Button(action: onOpenSettings) { Image(systemName: "gearshape") - .font(.system(size: 13)) + .font(.system(size: appearance.fontSize)) .foregroundStyle(MoriTokens.Color.muted) } .buttonStyle(.plain) @@ -419,8 +412,8 @@ public struct WorktreeSidebarView: View { .accessibilityLabel("Settings") } } - .padding(.horizontal, MoriTokens.Spacing.xl) - .padding(.vertical, MoriTokens.Spacing.lg) + .padding(.horizontal, appearance.scaled(MoriTokens.Spacing.xl)) + .padding(.vertical, appearance.scaled(MoriTokens.Spacing.lg)) } } } diff --git a/Sources/Mori/App/AppDelegate.swift b/Sources/Mori/App/AppDelegate.swift index 2c473eb..ea0045e 100644 --- a/Sources/Mori/App/AppDelegate.swift +++ b/Sources/Mori/App/AppDelegate.swift @@ -24,6 +24,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent private var ipcHandler: IPCHandler? private var settingsWindowController: NSWindowController? private var configFile: GhosttyConfigFile? + private var appearanceStore: SidebarAppearanceStore? func applicationDidFinishLaunching(_ notification: Notification) { // Task 3.8: Single instance check @@ -100,8 +101,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent self.mainWindowController = windowController // Build split view children + let appearStore = SidebarAppearanceStore() + self.appearanceStore = appearStore let sidebarController = SidebarHostingController( appState: state, + appearanceStore: appearStore, onSelectProject: { [weak manager, weak self] projectId in manager?.selectProject(projectId) self?.updateWindowTitle() @@ -314,6 +318,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCent codexEnabled: AgentHookConfigurator.isCodexHookInstalled(), piEnabled: AgentHookConfigurator.isPiExtensionInstalled() ), + appearanceStore: appearanceStore, onChanged: { [weak self] newModel in guard let self else { return } self.writeSettingsModel(newModel, to: cf) @@ -931,6 +936,7 @@ private struct SettingsWindowContent: View { @State var agentHooks: AgentHookModel let availableThemes: [String] let ghosttyDefaults: [String] + var appearanceStore: SidebarAppearanceStore? var onChanged: (GhosttySettingsModel) -> Void var onOpenConfigFile: () -> Void var onAgentHookChanged: (AgentHookModel) -> Void @@ -940,6 +946,7 @@ private struct SettingsWindowContent: View { availableThemes: [String], ghosttyDefaults: [String] = [], initialAgentHooks: AgentHookModel = AgentHookModel(), + appearanceStore: SidebarAppearanceStore? = nil, onChanged: @escaping (GhosttySettingsModel) -> Void, onOpenConfigFile: @escaping () -> Void, onAgentHookChanged: @escaping (AgentHookModel) -> Void = { _ in } @@ -948,6 +955,7 @@ private struct SettingsWindowContent: View { self._agentHooks = State(initialValue: initialAgentHooks) self.availableThemes = availableThemes self.ghosttyDefaults = ghosttyDefaults + self.appearanceStore = appearanceStore self.onChanged = onChanged self.onOpenConfigFile = onOpenConfigFile self.onAgentHookChanged = onAgentHookChanged @@ -961,7 +969,8 @@ private struct SettingsWindowContent: View { onChanged: { onChanged(model) }, onOpenConfigFile: onOpenConfigFile, agentHooks: $agentHooks, - onAgentHookChanged: onAgentHookChanged + onAgentHookChanged: onAgentHookChanged, + appearanceStore: appearanceStore ) } } diff --git a/Sources/Mori/App/HostingControllers.swift b/Sources/Mori/App/HostingControllers.swift index 1f3f1d3..5f3f2a6 100644 --- a/Sources/Mori/App/HostingControllers.swift +++ b/Sources/Mori/App/HostingControllers.swift @@ -6,7 +6,6 @@ import MoriUI // MARK: - Sidebar Hosting (unified: project picker + worktrees + actions) -/// Wraps WorktreeSidebarView in an NSHostingController, observing AppState. @MainActor final class SidebarHostingController: NSHostingController { @@ -14,6 +13,7 @@ final class SidebarHostingController: NSHostingController { init( appState: AppState, + appearanceStore: SidebarAppearanceStore, onSelectProject: @escaping (UUID) -> Void, onSelectWorktree: @escaping (UUID) -> Void, onSelectWindow: @escaping (String) -> Void, @@ -29,6 +29,7 @@ final class SidebarHostingController: NSHostingController { self.appState = appState let rootView = SidebarContentView( appState: appState, + appearanceStore: appearanceStore, onSelectProject: onSelectProject, onSelectWorktree: onSelectWorktree, onSelectWindow: onSelectWindow, @@ -42,9 +43,6 @@ final class SidebarHostingController: NSHostingController { onOpenCommandPalette: onOpenCommandPalette ) super.init(rootView: rootView) - // Prevent SwiftUI's layout from dictating the view size. - // Without this, the hosting controller sets a preferred content size - // that locks the split view sidebar to a fixed width. sizingOptions = [] } @@ -53,7 +51,6 @@ final class SidebarHostingController: NSHostingController { fatalError("init(coder:) has not been implemented") } - /// Sync the hosting controller's view appearance with the ghostty theme. func updateAppearance(themeInfo: GhosttyThemeInfo) { view.appearance = NSAppearance(named: themeInfo.isDark ? .darkAqua : .aqua) view.wantsLayer = true @@ -61,9 +58,9 @@ final class SidebarHostingController: NSHostingController { } } -/// Bindable wrapper that reads AppState observables into WorktreeSidebarView. struct SidebarContentView: View { @Bindable var appState: AppState + @Bindable var appearanceStore: SidebarAppearanceStore let onSelectProject: (UUID) -> Void let onSelectWorktree: (UUID) -> Void let onSelectWindow: (String) -> Void @@ -96,5 +93,6 @@ struct SidebarContentView: View { onOpenSettings: onOpenSettings, onOpenCommandPalette: onOpenCommandPalette ) + .environment(\.sidebarAppearance, appearanceStore.appearance) } }