diff --git a/Sources/Lithe/LitheApp.swift b/Sources/Lithe/LitheApp.swift index 852eaf0d8..fbf7b1914 100644 --- a/Sources/Lithe/LitheApp.swift +++ b/Sources/Lithe/LitheApp.swift @@ -408,7 +408,6 @@ struct LitheApp: App { .environmentObject(settings) .environmentObject(updateChecker) .environment(\.locale, settings.language.locale) - .preferredColorScheme(settings.themePreference.preferredColorScheme) } .defaultSize(width: 1040, height: 720) .windowResizability(.contentMinSize) @@ -439,18 +438,31 @@ private struct SettingsWindow: View { @ObservedObject var model: AppModel @ObservedObject var settings: AppSettings @StateObject private var windowReference = SettingsWindowReference() + @StateObject private var viewState: SettingsViewState + + init(model: AppModel, settings: AppSettings) { + self.model = model + self.settings = settings + _viewState = StateObject(wrappedValue: SettingsViewState( + initialCategory: model.requestedSettingsCategory + )) + } var body: some View { - SettingsView( - settings: settings, - initialCategory: model.requestedSettingsCategory, - onDismiss: close - ) - .environmentObject(model) + SettingsAppearanceContainer(themePreference: settings.themePreference) { + SettingsView( + settings: settings, + viewState: viewState, + initialCategory: model.requestedSettingsCategory, + onDismiss: close + ) + .environmentObject(model) + } .background( SettingsWindowAccessor( reference: windowReference, - title: settingsWindowTitle(for: settings.language) + title: settingsWindowTitle(for: settings.language), + themePreference: settings.themePreference ) ) .onDisappear { @@ -464,37 +476,124 @@ private struct SettingsWindow: View { } } +struct SettingsAppearanceContainer: View { + let themePreference: AppThemePreference + let content: Content + + init( + themePreference: AppThemePreference, + @ViewBuilder content: () -> Content + ) { + self.themePreference = themePreference + self.content = content() + } + + var body: some View { + content.preferredColorScheme(themePreference.preferredColorScheme) + } +} + @MainActor private final class SettingsWindowReference: ObservableObject { weak var window: NSWindow? } +private final class SettingsTitlebarBackgroundView: NSView { + override func hitTest(_ point: NSPoint) -> NSView? { nil } +} + +private final class SettingsWindowProbe: NSView { + var onEffectiveAppearanceChange: (() -> Void)? + + override func viewDidChangeEffectiveAppearance() { + super.viewDidChangeEffectiveAppearance() + onEffectiveAppearanceChange?() + } +} + private struct SettingsWindowAccessor: NSViewRepresentable { let reference: SettingsWindowReference let title: String + let themePreference: AppThemePreference - func makeNSView(context: Context) -> NSView { - let view = NSView(frame: .zero) + func makeNSView(context: Context) -> SettingsWindowProbe { + let view = SettingsWindowProbe(frame: .zero) + bindAppearanceUpdates(to: view) configureWindow(for: view) return view } - func updateNSView(_ view: NSView, context: Context) { + func updateNSView(_ view: SettingsWindowProbe, context: Context) { + bindAppearanceUpdates(to: view) configureWindow(for: view) } + private func bindAppearanceUpdates(to view: SettingsWindowProbe) { + view.onEffectiveAppearanceChange = { [weak view] in + guard let view else { return } + configureWindow(for: view) + } + } + private func configureWindow(for view: NSView) { DispatchQueue.main.async { guard let window = view.window else { return } reference.window = window window.title = title + let windowAppearance = themePreference.windowAppearance + if window.appearance?.name != windowAppearance?.name { + window.appearance = windowAppearance + } + if window.contentView?.appearance?.name != windowAppearance?.name { + window.contentView?.appearance = windowAppearance + } + window.styleMask.insert(.fullSizeContentView) window.titlebarAppearsTransparent = true window.titleVisibility = .visible - window.backgroundColor = NSColor(LitheTheme.settingsSurface) + window.titlebarSeparatorStyle = .none + window.isOpaque = true + let settingsSurface = LitheTheme.settingsSurfaceNSColor( + for: window.effectiveAppearance + ) + window.backgroundColor = settingsSurface + applySettingsSurface(toTitlebarOf: window, color: settingsSurface) window.standardWindowButton(.miniaturizeButton)?.isEnabled = false window.standardWindowButton(.zoomButton)?.isEnabled = true } } + + private func applySettingsSurface(toTitlebarOf window: NSWindow, color: NSColor) { + // AppKit places the titlebar in multiple nested views. Styling only + // the close-button's immediate superview leaves the opaque theme + // frame above it untouched, which is the extra strip seen in the + // settings window. Apply the same surface to each titlebar ancestor. + var view = window.standardWindowButton(.closeButton)?.superview + var titlebarHost: NSView? + while let current = view, current !== window.contentView { + current.wantsLayer = true + current.layer?.backgroundColor = color.cgColor + if current.bounds.width >= window.frame.width * 0.8, + current.bounds.height <= 100 { + titlebarHost = current + } + view = current.superview + } + + guard let titlebarHost else { return } + let backgroundView: SettingsTitlebarBackgroundView + if let existing = titlebarHost.subviews.first(where: { + $0 is SettingsTitlebarBackgroundView + }) as? SettingsTitlebarBackgroundView { + backgroundView = existing + } else { + backgroundView = SettingsTitlebarBackgroundView(frame: titlebarHost.bounds) + titlebarHost.addSubview(backgroundView, positioned: .below, relativeTo: nil) + } + backgroundView.frame = titlebarHost.bounds + backgroundView.autoresizingMask = [.width, .height] + backgroundView.wantsLayer = true + backgroundView.layer?.backgroundColor = color.cgColor + } } private func settingsWindowTitle(for language: AppLanguage) -> String { @@ -506,6 +605,14 @@ private func settingsWindowTitle(for language: AppLanguage) -> String { } private extension AppThemePreference { + var windowAppearance: NSAppearance? { + switch self { + case .system: nil + case .light: NSAppearance(named: .aqua) + case .dark: NSAppearance(named: .darkAqua) + } + } + var preferredColorScheme: ColorScheme? { switch self { case .system: nil diff --git a/Sources/Lithe/Theme/LitheTheme.swift b/Sources/Lithe/Theme/LitheTheme.swift index c69cdb837..00209e3c5 100644 --- a/Sources/Lithe/Theme/LitheTheme.swift +++ b/Sources/Lithe/Theme/LitheTheme.swift @@ -266,7 +266,35 @@ enum LitheTheme { // MARK: - 背景层次 static var window: Color { adaptive(\.window) } static var titlebar: Color { adaptive(\.titlebar) } - static var settingsSurface: Color { editor } + static var settingsSurface: Color { + Color(nsColor: NSColor(name: nil) { appearance in + settingsSurfaceNSColor(for: appearance) + }) + } + static func settingsSurfaceNSColor(for appearance: NSAppearance) -> NSColor { + let isDark = appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua + return isDark + ? NSColor(srgbRed: 0.157, green: 0.161, blue: 0.173, alpha: 1) + : NSColor(srgbRed: 0.925, green: 0.933, blue: 0.945, alpha: 1) + } + static let settingsPrimaryAction = Color( + red: 56.0 / 255.0, + green: 113.0 / 255.0, + blue: 225.0 / 255.0 + ) + static let settingsSelection = Color( + red: 43.0 / 255.0, + green: 66.0 / 255.0, + blue: 113.0 / 255.0 + ) + static var settingsControlBackground: Color { + Color(nsColor: NSColor(name: nil) { appearance in + let isDark = appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua + return isDark + ? NSColor(srgbRed: 43.0 / 255.0, green: 45.0 / 255.0, blue: 48.0 / 255.0, alpha: 1) + : NSColor(srgbRed: 1, green: 1, blue: 1, alpha: 1) + }) + } static var toolHeader: Color { adaptive(\.toolHeader) } static var toolHeaderInactive: Color { adaptive(\.toolHeaderInactive) } static var sidebar: Color { adaptive(\.sidebar) } @@ -459,6 +487,8 @@ private struct LitheRowHoverModifier: ViewModifier { // MARK: - 按钮样式 struct LithePrimaryButtonStyle: ButtonStyle { + var backgroundColor = LitheTheme.accent + var restingOpacity = 0.92 @State private var isHovering = false func makeBody(configuration: Configuration) -> some View { @@ -469,7 +499,7 @@ struct LithePrimaryButtonStyle: ButtonStyle { .frame(height: 30) .background( RoundedRectangle(cornerRadius: LitheTheme.Metrics.controlCornerRadius) - .fill(LitheTheme.accent.opacity(configuration.isPressed ? 0.78 : (isHovering ? 1 : 0.92))) + .fill(backgroundColor.opacity(configuration.isPressed ? 0.78 : (isHovering ? 1 : restingOpacity))) ) .contentShape(Rectangle()) .onHover { isHovering = $0 } diff --git a/Sources/Lithe/Views/App/KeyboardShortcutSettingsView.swift b/Sources/Lithe/Views/App/KeyboardShortcutSettingsView.swift index bbf8253ca..760738d9b 100644 --- a/Sources/Lithe/Views/App/KeyboardShortcutSettingsView.swift +++ b/Sources/Lithe/Views/App/KeyboardShortcutSettingsView.swift @@ -41,8 +41,9 @@ struct KeyboardShortcutSettingsView: View { .padding(.horizontal, 24) .padding(.bottom, 24) } + .litheScrollViewChrome(alwaysShowVertical: true, usesCompactScrollers: true) } - .background(LitheTheme.window) + .background(LitheTheme.settingsSurface) } private var header: some View { @@ -65,12 +66,10 @@ struct KeyboardShortcutSettingsView: View { .lithePointer() } - TextField("Search actions or shortcuts", text: $query) - .textFieldStyle(.roundedBorder) - .onChange(of: query) { _ in + LitheSettingsSearchField("Search actions or shortcuts", text: $query) { _ in editingTarget = nil validationIssue = nil - } + } } .foregroundStyle(LitheTheme.primaryText) .padding(24) @@ -203,7 +202,7 @@ struct KeyboardShortcutSettingsView: View { } .padding(.horizontal, 7) .frame(height: 24) - .background(LitheTheme.inputBackground) + .background(LitheTheme.settingsControlBackground) .clipShape(RoundedRectangle(cornerRadius: 4)) .overlay { RoundedRectangle(cornerRadius: 4) diff --git a/Sources/Lithe/Views/App/SettingsView.swift b/Sources/Lithe/Views/App/SettingsView.swift index ab7a0c96f..f3cf0abd5 100644 --- a/Sources/Lithe/Views/App/SettingsView.swift +++ b/Sources/Lithe/Views/App/SettingsView.swift @@ -3,31 +3,41 @@ import LitheCoreContracts import LitheGitModule import LitheModuleAPI +@MainActor +final class SettingsViewState: ObservableObject { + @Published var selection: SettingsCategory + @Published var searchQuery = "" + @Published var hiddenDirectoriesDraft = "" + @Published var hiddenFilePatternsDraft = "" + @Published var aiAPIKeyDraft = "" + @Published var isFormatPickerPresented = false + + init(initialCategory: SettingsCategory) { + selection = initialCategory + } +} + struct SettingsView: View { @Environment(\.dismiss) private var dismiss @Environment(\.accessibilityReduceMotion) private var accessibilityReduceMotion @EnvironmentObject private var model: AppModel @EnvironmentObject private var updateChecker: UpdateChecker @ObservedObject var settings: AppSettings - @State private var selection: SettingsCategory - @State private var searchQuery = "" - @State private var hiddenDirectoriesDraft = "" - @State private var hiddenFilePatternsDraft = "" - @State private var aiAPIKeyDraft = "" - @State private var isFormatPickerPresented = false + @ObservedObject var viewState: SettingsViewState let initialCategory: SettingsCategory private let onDismiss: (() -> Void)? private static let footerActionLabelWidth: CGFloat = 52 init( settings: AppSettings, + viewState: SettingsViewState, initialCategory: SettingsCategory = .general, onDismiss: (() -> Void)? = nil ) { self.settings = settings + self.viewState = viewState self.initialCategory = initialCategory self.onDismiss = onDismiss - _selection = State(initialValue: initialCategory) } var body: some View { @@ -41,7 +51,10 @@ struct SettingsView: View { footer } .frame(minWidth: 820, minHeight: 620) - .background(LitheTheme.settingsSurface) + .background { + LitheTheme.settingsSurface + .ignoresSafeArea() + } .onAppear { syncVisibilityDrafts() model.refreshAIConfigurations() @@ -51,13 +64,13 @@ struct SettingsView: View { .onChange(of: settings.hiddenFilePatterns) { _ in syncVisibilityDrafts() } .onChange(of: settings.commitMessageAI.activeProviderID) { _ in syncAIProviderDraft() } .onChange(of: initialCategory) { category in - searchQuery = "" - selection = category + viewState.searchQuery = "" + viewState.selection = category } - .onChange(of: searchQuery) { _ in - guard !filteredCategories.contains(selection), + .onChange(of: viewState.searchQuery) { _ in + guard !filteredCategories.contains(viewState.selection), let firstMatch = filteredCategories.first else { return } - selection = firstMatch + viewState.selection = firstMatch } .environment(\.locale, settings.language.locale) } @@ -89,6 +102,7 @@ struct SettingsView: View { } .padding(8) } + .litheScrollViewChrome(alwaysShowVertical: true, usesCompactScrollers: true) } .frame(width: 244) .frame(maxHeight: .infinity) @@ -96,42 +110,13 @@ struct SettingsView: View { } private var settingsSearchField: some View { - HStack(spacing: 7) { - Image(systemName: "magnifyingglass") - .font(.system(size: 12, weight: .medium)) - .foregroundStyle(LitheTheme.tertiaryText) - - TextField("Search settings", text: $searchQuery) - .textFieldStyle(.plain) - .font(.system(size: 12.5)) - - if !searchQuery.isEmpty { - Button { - searchQuery = "" - } label: { - Image(systemName: "xmark.circle.fill") - .font(.system(size: 11)) - .foregroundStyle(LitheTheme.tertiaryText) - } - .buttonStyle(.plain) - .lithePointer() - .help("Clear search") - } - } - .padding(.horizontal, 9) - .frame(height: 28) - .background(LitheTheme.inputBackground) - .clipShape(RoundedRectangle(cornerRadius: LitheTheme.Metrics.controlCornerRadius)) - .overlay { - RoundedRectangle(cornerRadius: LitheTheme.Metrics.controlCornerRadius) - .stroke(LitheTheme.inputBorder, lineWidth: 1) - } + LitheSettingsSearchField("Search settings", text: $viewState.searchQuery) } private func categoryButton(_ category: SettingsCategory) -> some View { - let isSelected = selection == category + let isSelected = viewState.selection == category return Button { - selection = category + viewState.selection = category } label: { HStack(spacing: 10) { Image(systemName: category.icon) @@ -144,7 +129,7 @@ struct SettingsView: View { .padding(.horizontal, 10) .frame(maxWidth: .infinity, alignment: .leading) .frame(height: LitheTheme.Metrics.treeRowHeight) - .background(isSelected ? LitheTheme.selection : .clear) + .background(isSelected ? LitheTheme.settingsSelection : .clear) .clipShape(RoundedRectangle(cornerRadius: LitheTheme.Metrics.cornerRadius)) .contentShape(Rectangle()) } @@ -153,7 +138,7 @@ struct SettingsView: View { } private var filteredCategories: [SettingsCategory] { - let query = searchQuery.trimmingCharacters(in: .whitespacesAndNewlines) + let query = viewState.searchQuery.trimmingCharacters(in: .whitespacesAndNewlines) guard !query.isEmpty else { return SettingsCategory.allCases } return SettingsCategory.allCases.filter { category in @@ -204,10 +189,10 @@ struct SettingsView: View { } .foregroundStyle(LitheTheme.secondaryText) .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if selection == .lsp { + } else if viewState.selection == .lsp { LSPControlCenterView() .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if selection == .keymap { + } else if viewState.selection == .keymap { KeyboardShortcutSettingsView( feature: model.keyboardShortcutFeature, language: settings.language @@ -216,12 +201,12 @@ struct SettingsView: View { } else { ScrollView { VStack(alignment: .leading, spacing: 8) { - Text(LocalizedStringKey(selection.rawValue)) + Text(LocalizedStringKey(viewState.selection.rawValue)) .font(.system(size: 22, weight: .semibold)) .foregroundStyle(LitheTheme.primaryText) .padding(.bottom, 8) - switch selection { + switch viewState.selection { case .general: generalSettings case .editor: editorSettings case .keymap: EmptyView() @@ -235,6 +220,7 @@ struct SettingsView: View { .padding(.vertical, 22) .frame(maxWidth: .infinity, alignment: .leading) } + .litheScrollViewChrome(alwaysShowVertical: true, usesCompactScrollers: true) } } @@ -339,7 +325,7 @@ struct SettingsView: View { Text("Directories") .font(.system(size: 11.5, weight: .medium)) - TextEditor(text: $hiddenDirectoriesDraft) + TextEditor(text: $viewState.hiddenDirectoriesDraft) .font(.system(size: 12, design: .monospaced)) .frame(height: 66) .padding(5) @@ -351,7 +337,7 @@ struct SettingsView: View { Text("File patterns") .font(.system(size: 11.5, weight: .medium)) - TextEditor(text: $hiddenFilePatternsDraft) + TextEditor(text: $viewState.hiddenFilePatternsDraft) .font(.system(size: 12, design: .monospaced)) .frame(height: 52) .padding(5) @@ -364,7 +350,10 @@ struct SettingsView: View { HStack { Spacer() Button("Apply") { applyVisibilityDrafts() } - .buttonStyle(LithePrimaryButtonStyle()) + .buttonStyle(LithePrimaryButtonStyle( + backgroundColor: LitheTheme.settingsPrimaryAction, + restingOpacity: 1 + )) } } @@ -591,11 +580,11 @@ struct SettingsView: View { .disabled(model.activeCommitMessageCredentialIsConfigurationManaged) HStack(spacing: 8) { - SecureField("API key or token", text: $aiAPIKeyDraft) + SecureField("API key or token", text: $viewState.aiAPIKeyDraft) .litheSettingsTextField() .disabled(model.activeCommitMessageCredentialIsConfigurationManaged) Button("Save Key") { - model.saveActiveCommitMessageAPIKey(aiAPIKeyDraft) + model.saveActiveCommitMessageAPIKey(viewState.aiAPIKeyDraft) } .buttonStyle(LitheSecondaryButtonStyle()) .disabled(model.activeCommitMessageCredentialIsConfigurationManaged) @@ -642,7 +631,10 @@ struct SettingsView: View { syncAIProviderDraft() } } - .buttonStyle(LithePrimaryButtonStyle()) + .buttonStyle(LithePrimaryButtonStyle( + backgroundColor: LitheTheme.settingsPrimaryAction, + restingOpacity: 1 + )) } .padding(10) .background(LitheTheme.inputBackground) @@ -833,16 +825,16 @@ struct SettingsView: View { Image(systemName: "chevron.down") .font(.system(size: 10, weight: .semibold)) .foregroundStyle(LitheTheme.secondaryText) - .rotationEffect(.degrees(isFormatPickerPresented ? 180 : 0)) - .animation(formatPickerAnimation, value: isFormatPickerPresented) + .rotationEffect(.degrees(viewState.isFormatPickerPresented ? 180 : 0)) + .animation(formatPickerAnimation, value: viewState.isFormatPickerPresented) } .padding(.horizontal, 10) .frame(maxWidth: .infinity, minHeight: 48, alignment: .leading) - .background(isFormatPickerPresented ? LitheTheme.inputBackground.opacity(0.9) : LitheTheme.inputBackground) + .background(viewState.isFormatPickerPresented ? LitheTheme.inputBackground.opacity(0.9) : LitheTheme.inputBackground) .overlay { RoundedRectangle(cornerRadius: LitheTheme.Metrics.controlCornerRadius) .stroke( - isFormatPickerPresented ? LitheTheme.inputFocusBorder : LitheTheme.inputBorder, + viewState.isFormatPickerPresented ? LitheTheme.inputFocusBorder : LitheTheme.inputBorder, lineWidth: 1 ) } @@ -850,7 +842,7 @@ struct SettingsView: View { } .buttonStyle(.plain) .lithePointer() - .popover(isPresented: $isFormatPickerPresented, arrowEdge: .bottom) { + .popover(isPresented: $viewState.isFormatPickerPresented, arrowEdge: .bottom) { formatPickerPopover } @@ -875,7 +867,7 @@ struct SettingsView: View { Spacer(minLength: 8) Button { - isFormatPickerPresented = false + viewState.isFormatPickerPresented = false } label: { Image(systemName: "xmark") .font(.system(size: 10, weight: .semibold)) @@ -1002,14 +994,14 @@ struct SettingsView: View { private func toggleFormatPicker() { withAnimation(formatPickerAnimation) { - isFormatPickerPresented.toggle() + viewState.isFormatPickerPresented.toggle() } } private func selectFormat(_ format: CommitMessageFormat) { withAnimation(formatPickerAnimation) { settings.commitMessageAI.format = format - isFormatPickerPresented = false + viewState.isFormatPickerPresented = false } } @@ -1038,7 +1030,10 @@ struct SettingsView: View { systemImage: "arrow.clockwise" ) } - .buttonStyle(LithePrimaryButtonStyle()) + .buttonStyle(LithePrimaryButtonStyle( + backgroundColor: LitheTheme.settingsPrimaryAction, + restingOpacity: 1 + )) .disabled(updateChecker.isBusy) if case .available(let version, _) = updateChecker.status { @@ -1139,7 +1134,7 @@ struct SettingsView: View { } private func syncAIProviderDraft() { - aiAPIKeyDraft = model.activeCommitMessageAPIKey + viewState.aiAPIKeyDraft = model.activeCommitMessageAPIKey } private func providerTitle(_ id: UUID) -> String { @@ -1208,7 +1203,10 @@ struct SettingsView: View { Text("OK") .frame(minWidth: Self.footerActionLabelWidth) } - .buttonStyle(LithePrimaryButtonStyle()) + .buttonStyle(LithePrimaryButtonStyle( + backgroundColor: LitheTheme.settingsPrimaryAction, + restingOpacity: 1 + )) .keyboardShortcut(.defaultAction) } } @@ -1218,13 +1216,13 @@ struct SettingsView: View { } private func syncVisibilityDrafts() { - hiddenDirectoriesDraft = settings.hiddenDirectoryNames.joined(separator: "\n") - hiddenFilePatternsDraft = settings.hiddenFilePatterns.joined(separator: "\n") + viewState.hiddenDirectoriesDraft = settings.hiddenDirectoryNames.joined(separator: "\n") + viewState.hiddenFilePatternsDraft = settings.hiddenFilePatterns.joined(separator: "\n") } private func applyVisibilityDrafts() { - settings.hiddenDirectoryNames = entries(from: hiddenDirectoriesDraft) - settings.hiddenFilePatterns = entries(from: hiddenFilePatternsDraft) + settings.hiddenDirectoryNames = entries(from: viewState.hiddenDirectoriesDraft) + settings.hiddenFilePatterns = entries(from: viewState.hiddenFilePatternsDraft) } private func entries(from text: String) -> [String] { diff --git a/Sources/Lithe/Views/Components/LitheScrollViewChrome.swift b/Sources/Lithe/Views/Components/LitheScrollViewChrome.swift index 3cb75f70a..229fadc59 100644 --- a/Sources/Lithe/Views/Components/LitheScrollViewChrome.swift +++ b/Sources/Lithe/Views/Components/LitheScrollViewChrome.swift @@ -1,6 +1,79 @@ import AppKit import SwiftUI +enum LitheScrollWheelDestination: Equatable { + case nested + case outer + case unchanged +} + +enum LitheScrollWheelRouting { + static func destination( + hitsNestedScrollView: Bool, + nestedCanScrollInDirection: Bool, + outerCanScrollInDirection: Bool + ) -> LitheScrollWheelDestination { + if hitsNestedScrollView, nestedCanScrollInDirection { + return .nested + } + return outerCanScrollInDirection ? .outer : .unchanged + } + + static func destination( + hitView: NSView?, + within outerScrollView: NSScrollView, + deltaX: CGFloat, + deltaY: CGFloat + ) -> LitheScrollWheelDestination { + let nearestScrollView = nearestScrollView(from: hitView, within: outerScrollView) + let hitsNestedScrollView = nearestScrollView != nil && nearestScrollView !== outerScrollView + return destination( + hitsNestedScrollView: hitsNestedScrollView, + nestedCanScrollInDirection: hitsNestedScrollView + && canScroll(nearestScrollView, deltaX: deltaX, deltaY: deltaY), + outerCanScrollInDirection: canScroll( + outerScrollView, + deltaX: deltaX, + deltaY: deltaY + ) + ) + } + + static func nearestScrollView( + from hitView: NSView?, + within outerScrollView: NSScrollView + ) -> NSScrollView? { + var candidate = hitView + while let current = candidate { + if let scrollView = current as? NSScrollView, + scrollView === outerScrollView || scrollView.isDescendant(of: outerScrollView) { + return scrollView + } + candidate = current.superview + } + return nil + } + + static func canScroll( + _ scrollView: NSScrollView?, + deltaX: CGFloat, + deltaY: CGFloat + ) -> Bool { + guard let scrollView, + abs(deltaY) > abs(deltaX), + deltaY != 0 else { return false } + let clipView = scrollView.contentView + let documentRect = clipView.documentRect + let minimumY = documentRect.minY + let maximumY = max(minimumY, documentRect.maxY - clipView.bounds.height) + guard maximumY - minimumY > 0.5 else { return false } + let currentY = clipView.bounds.minY + return deltaY > 0 + ? currentY > minimumY + 0.5 + : currentY < maximumY - 0.5 + } +} + /// Keeps SwiftUI scroll views visually close to IntelliJ's overlay scrollers. /// SwiftUI otherwise inherits the user's macOS "Always show scroll bars" /// setting, which can turn a compact tool window into a set of bright, thick @@ -9,26 +82,38 @@ import SwiftUI struct LitheScrollViewChrome: NSViewRepresentable { var hideHorizontal = false var alwaysShowVertical = false + var usesCompactScrollers = false func makeNSView(context: Context) -> ScrollViewProbe { - ScrollViewProbe(hideHorizontal: hideHorizontal, alwaysShowVertical: alwaysShowVertical) + ScrollViewProbe( + hideHorizontal: hideHorizontal, + alwaysShowVertical: alwaysShowVertical, + usesCompactScrollers: usesCompactScrollers + ) } func updateNSView(_ nsView: ScrollViewProbe, context: Context) { nsView.hideHorizontal = hideHorizontal nsView.alwaysShowVertical = alwaysShowVertical + nsView.usesCompactScrollers = usesCompactScrollers nsView.configureEnclosingScrollView() } final class ScrollViewProbe: NSView { var hideHorizontal: Bool var alwaysShowVertical: Bool + var usesCompactScrollers: Bool private weak var configuredScrollView: NSScrollView? private var scrollWheelMonitor: Any? - init(hideHorizontal: Bool, alwaysShowVertical: Bool) { + init( + hideHorizontal: Bool, + alwaysShowVertical: Bool, + usesCompactScrollers: Bool + ) { self.hideHorizontal = hideHorizontal self.alwaysShowVertical = alwaysShowVertical + self.usesCompactScrollers = usesCompactScrollers super.init(frame: .zero) } @@ -56,6 +141,31 @@ struct LitheScrollViewChrome: NSViewRepresentable { func configureEnclosingScrollView() { guard let scrollView = enclosingScrollView else { return } + if !scrollView.hasVerticalScroller { + scrollView.hasVerticalScroller = true + } + if usesCompactScrollers { + if !(scrollView.verticalScroller is CompactScroller) { + scrollView.verticalScroller = CompactScroller() + } + if !hideHorizontal, + !(scrollView.horizontalScroller is CompactScroller) { + scrollView.horizontalScroller = CompactScroller() + } + if scrollView.drawsBackground { + scrollView.drawsBackground = false + } + if scrollView.contentView.drawsBackground { + scrollView.contentView.drawsBackground = false + } + for scroller in [scrollView.verticalScroller, scrollView.horizontalScroller] { + scroller?.wantsLayer = true + scroller?.layer?.backgroundColor = NSColor.clear.cgColor + } + } + + // Persistent scrollers require legacy style because AppKit owns + // overlay fade behavior. Non-persistent scrollers remain overlay. let scrollerStyle: NSScroller.Style = alwaysShowVertical ? .legacy : .overlay if scrollView.scrollerStyle != scrollerStyle { scrollView.scrollerStyle = scrollerStyle @@ -63,15 +173,19 @@ struct LitheScrollViewChrome: NSViewRepresentable { if scrollView.autohidesScrollers != !alwaysShowVertical { scrollView.autohidesScrollers = !alwaysShowVertical } - if !scrollView.hasVerticalScroller { - scrollView.hasVerticalScroller = true - } if scrollView.verticalScroller?.knobStyle != .dark { scrollView.verticalScroller?.knobStyle = .dark } if scrollView.horizontalScroller?.knobStyle != .dark { scrollView.horizontalScroller?.knobStyle = .dark } + let controlSize: NSControl.ControlSize = usesCompactScrollers ? .mini : .regular + if scrollView.verticalScroller?.controlSize != controlSize { + scrollView.verticalScroller?.controlSize = controlSize + } + if scrollView.horizontalScroller?.controlSize != controlSize { + scrollView.horizontalScroller?.controlSize = controlSize + } if hideHorizontal { if scrollView.hasHorizontalScroller { @@ -100,10 +214,20 @@ struct LitheScrollViewChrome: NSViewRepresentable { scrollWheelMonitor = NSEvent.addLocalMonitorForEvents(matching: .scrollWheel) { [weak self, weak scrollView] event in guard let self, let scrollView, - self.isEvent(event, inside: scrollView), - self.canScrollVertically(scrollView) else { return event } - scrollView.scrollWheel(with: event) - return nil + self.isEvent(event, inside: scrollView) else { return event } + let destination = LitheScrollWheelRouting.destination( + hitView: self.hitView(for: event), + within: scrollView, + deltaX: event.scrollingDeltaX, + deltaY: event.scrollingDeltaY + ) + switch destination { + case .nested, .unchanged: + return event + case .outer: + scrollView.scrollWheel(with: event) + return nil + } } } @@ -120,9 +244,47 @@ struct LitheScrollViewChrome: NSViewRepresentable { return scrollView.bounds.contains(point) } - private func canScrollVertically(_ scrollView: NSScrollView) -> Bool { - guard let documentView = scrollView.documentView else { return false } - return documentView.bounds.height > scrollView.contentView.bounds.height + 0.5 + private func hitView(for event: NSEvent) -> NSView? { + guard let contentView = event.window?.contentView else { return nil } + let point = contentView.convert(event.locationInWindow, from: nil) + return contentView.hitTest(point) + } + } + + /// Draws only a compact thumb in either persistent legacy or fading overlay + /// mode. Omitting the knob slot avoids adding a visible track background. + final class CompactScroller: NSScroller { + override class var isCompatibleWithOverlayScrollers: Bool { true } + override var isOpaque: Bool { false } + + override func draw(_ dirtyRect: NSRect) { + drawKnob() + } + + override func drawKnob() { + var knobRect = rect(for: .knob) + guard !knobRect.isEmpty else { return } + + if bounds.height >= bounds.width { + let horizontalInset = max(2, (knobRect.width - 5) / 2) + knobRect = knobRect.insetBy(dx: horizontalInset, dy: 1) + } else { + let verticalInset = max(2, (knobRect.height - 5) / 2) + knobRect = knobRect.insetBy(dx: 1, dy: verticalInset) + } + guard knobRect.width > 0, knobRect.height > 0 else { return } + + let isDark = effectiveAppearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua + let sidebar = LitheTheme.nsColor(.sidebar, isDark: isDark) + let secondaryText = LitheTheme.nsColor(.secondaryText, isDark: isDark) + let thumbColor = sidebar.blended(withFraction: 0.28, of: secondaryText) + ?? secondaryText + thumbColor.withAlphaComponent(0.95).setFill() + NSBezierPath( + roundedRect: knobRect, + xRadius: min(2.5, knobRect.width / 2), + yRadius: min(2.5, knobRect.height / 2) + ).fill() } } } @@ -130,11 +292,13 @@ struct LitheScrollViewChrome: NSViewRepresentable { extension View { func litheScrollViewChrome( hideHorizontal: Bool = false, - alwaysShowVertical: Bool = false + alwaysShowVertical: Bool = false, + usesCompactScrollers: Bool = false ) -> some View { background(LitheScrollViewChrome( hideHorizontal: hideHorizontal, - alwaysShowVertical: alwaysShowVertical + alwaysShowVertical: alwaysShowVertical, + usesCompactScrollers: usesCompactScrollers )) } } diff --git a/Sources/Lithe/Views/Components/LitheSettingsControls.swift b/Sources/Lithe/Views/Components/LitheSettingsControls.swift index 04d7b13a5..ab23c9cfc 100644 --- a/Sources/Lithe/Views/Components/LitheSettingsControls.swift +++ b/Sources/Lithe/Views/Components/LitheSettingsControls.swift @@ -1,5 +1,57 @@ import SwiftUI +struct LitheSettingsSearchField: View { + private let placeholder: LocalizedStringKey + @Binding private var text: String + private let onTextChanged: ((String) -> Void)? + + init( + _ placeholder: LocalizedStringKey, + text: Binding, + onTextChanged: ((String) -> Void)? = nil + ) { + self.placeholder = placeholder + _text = text + self.onTextChanged = onTextChanged + } + + var body: some View { + HStack(spacing: 7) { + Image(systemName: "magnifyingglass") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(LitheTheme.tertiaryText) + + TextField(placeholder, text: $text) + .textFieldStyle(.plain) + .font(.system(size: 12.5)) + + if !text.isEmpty { + Button { + text = "" + } label: { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 11)) + .foregroundStyle(LitheTheme.tertiaryText) + } + .buttonStyle(.plain) + .lithePointer() + .help("Clear search") + } + } + .padding(.horizontal, 9) + .frame(height: 28) + .background(Color.clear) + .clipShape(RoundedRectangle(cornerRadius: LitheTheme.Metrics.controlCornerRadius)) + .overlay { + RoundedRectangle(cornerRadius: LitheTheme.Metrics.controlCornerRadius) + .stroke(LitheTheme.inputBorder, lineWidth: 1) + } + .onChange(of: text) { value in + onTextChanged?(value) + } + } +} + struct LitheSettingsSelect: View { @Binding private var selection: Value private let options: [Value] @@ -43,7 +95,7 @@ struct LitheSettingsSelect: View { .frame(width: width, height: 30, alignment: .leading) .background( RoundedRectangle(cornerRadius: LitheTheme.Metrics.controlCornerRadius) - .fill(LitheTheme.inputBackground) + .fill(LitheTheme.settingsControlBackground) ) .overlay { RoundedRectangle(cornerRadius: LitheTheme.Metrics.controlCornerRadius) @@ -126,7 +178,7 @@ struct LitheSettingsSegmentedControl: View { .litheRowHover( isActive: selection == option, cornerRadius: LitheTheme.Metrics.cornerRadius, - activeBackground: LitheTheme.selection + activeBackground: LitheTheme.settingsSelection ) } .buttonStyle(LitheTreeRowButtonStyle()) @@ -137,7 +189,7 @@ struct LitheSettingsSegmentedControl: View { .frame(width: width, height: 30) .background( RoundedRectangle(cornerRadius: LitheTheme.Metrics.controlCornerRadius) - .fill(LitheTheme.inputBackground) + .fill(LitheTheme.settingsControlBackground) ) .overlay { RoundedRectangle(cornerRadius: LitheTheme.Metrics.controlCornerRadius) diff --git a/Sources/Lithe/Views/Editor/CodeEditorView.swift b/Sources/Lithe/Views/Editor/CodeEditorView.swift index 03e927f85..6d1aa4519 100644 --- a/Sources/Lithe/Views/Editor/CodeEditorView.swift +++ b/Sources/Lithe/Views/Editor/CodeEditorView.swift @@ -39,7 +39,7 @@ struct CodeEditorPalette { var foldHover: NSColor { color(light: (0, 0, 0, 0.07), dark: (1, 1, 1, 0.07)) } var foldIndicator: NSColor { color(light: (0.28, 0.30, 0.34, 0.58), dark: (0.62, 0.62, 0.62, 0.46)) } var foldIndicatorHover: NSColor { color(light: (0.12, 0.14, 0.17, 0.90), dark: (0.86, 0.86, 0.86, 0.96)) } - var blameText: NSColor { color(light: (0.38, 0.40, 0.44, 1), dark: (0.53, 0.53, 0.53, 1)) } + var blameText: NSColor { color(light: (0.42, 0.44, 0.48, 1), dark: (0.46, 0.46, 0.46, 1)) } var gitAdded: NSColor { color(light: (0.15, 0.62, 0.31, 1), dark: (0.31, 0.78, 0.45, 1)) } var gitModified: NSColor { color(light: (0.16, 0.48, 0.86, 1), dark: (0.31, 0.64, 0.96, 1)) } var gitDeleted: NSColor { color(light: (0.82, 0.22, 0.25, 1), dark: (0.94, 0.34, 0.37, 1)) } @@ -79,11 +79,25 @@ struct CodeEditorPalette { } } -private enum EditorLayoutMetrics { - static let standardGutterWidth: CGFloat = 45 - static let leadingInset: CGFloat = 0 +enum EditorLayoutMetrics { + static let markerColumnWidth: CGFloat = 16 + static let lineNumberWidth: CGFloat = 36 + static let foldIndicatorWidth: CGFloat = 13 + static let standardGutterWidth = markerColumnWidth + lineNumberWidth + foldIndicatorWidth + static let blameMetadataWidth: CGFloat = 140 + static let blameGutterWidth = blameMetadataWidth + standardGutterWidth + static let leadingInset: CGFloat = 4 static let lineFragmentPadding: CGFloat = 4 static let caretWidth: CGFloat = 2 + + static func showsBlameMetadata( + line: Int, + firstVisibleLine: Int, + commitHash: String, + previousCommitHash: String? + ) -> Bool { + line == firstVisibleLine || previousCommitHash != commitHash + } } struct EditorViewportState: Equatable { @@ -993,7 +1007,7 @@ struct CodeEditorView: NSViewRepresentable { appliedBlameLines = blameLines appliedDebugBreakpointLines = debugBreakpointLines container?.gutterWidthConstraint?.constant = isBlameVisible - ? 224 + ? EditorLayoutMetrics.blameGutterWidth : EditorLayoutMetrics.standardGutterWidth gutter?.update(blameLines: blameLines, isVisible: isBlameVisible) { [weak model] blame in Task { await model?.showGitCommit(blame.commitHash) } @@ -1521,13 +1535,6 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { let source = string as NSString let caret = min(selectedRange().location, source.length) - let lineRange = source.lineRange(for: NSRange(location: caret, length: 0)) - layoutManager.addTemporaryAttribute( - .backgroundColor, - value: currentLineColor, - forCharacterRange: lineRange - ) - lastCaretBackgroundRanges.append(lineRange) for range in matchingBracketRanges(in: source, caret: caret) { layoutManager.addTemporaryAttribute(.backgroundColor, value: bracketColor, forCharacterRange: range) @@ -1574,13 +1581,6 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { lastCaretBackgroundRanges = [] let source = string as NSString let caret = min(selectedRange().location, source.length) - let lineRange = source.lineRange(for: NSRange(location: caret, length: 0)) - layoutManager.addTemporaryAttribute( - .backgroundColor, - value: currentLineColor, - forCharacterRange: lineRange - ) - lastCaretBackgroundRanges.append(lineRange) for range in matchingBracketRanges(in: source, caret: caret) { layoutManager.addTemporaryAttribute(.backgroundColor, value: bracketColor, forCharacterRange: range) @@ -1843,14 +1843,18 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { forGlyphRange glyphRange: NSRange ) -> Bool { let characterRange = layoutManager.characterRange(forGlyphRange: glyphRange, actualGlyphRange: nil) - guard collapsedFoldIDs.contains(where: { id in + let isCollapsedLine = collapsedFoldIDs.contains(where: { id in guard let region = foldRegions.first(where: { $0.id == id }) else { return false } return NSLocationInRange(characterRange.location, region.hiddenRange) - }) else { return false } + }) - lineFragmentRect.pointee.size.height = 0 - lineFragmentUsedRect.pointee.size.height = 0 - baselineOffset.pointee = 0 + if isCollapsedLine { + lineFragmentRect.pointee.size.height = 0 + lineFragmentUsedRect.pointee.size.height = 0 + baselineOffset.pointee = 0 + } else { + baselineOffset.pointee -= LitheTheme.editorBaselineLift + } return true } @@ -2074,9 +2078,9 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { let lineRect = layoutManager.lineFragmentRect(forGlyphAt: lastGlyph, effectiveRange: nil) return NSRect( x: min(bounds.width - 32, textContainerOrigin.x + contentRect.maxX + 7), - y: textContainerOrigin.y + lineRect.minY + max(0, (lineRect.height - 17) / 2), + y: textContainerOrigin.y + lineRect.minY, width: 28, - height: 17 + height: lineRect.height ) } @@ -2084,20 +2088,28 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { super.draw(dirtyRect) for region in foldRegions where collapsedFoldIDs.contains(region.id) { guard let rect = foldSummaryRect(for: region), rect.intersects(dirtyRect) else { continue } - let path = NSBezierPath(roundedRect: rect, xRadius: 3, yRadius: 3) + let path = NSBezierPath(roundedRect: rect, xRadius: 2, yRadius: 2) let isHovered = hoveredFoldID == region.id NSColor(white: isHovered ? 0.34 : 0.25, alpha: isHovered ? 0.80 : 0.38).setFill() path.fill() - ("…" as NSString).draw( - in: rect.offsetBy(dx: 0, dy: -1), - withAttributes: [ - .font: NSFont.monospacedSystemFont(ofSize: 11, weight: .semibold), - .foregroundColor: NSColor( - white: isHovered ? 0.90 : 0.68, - alpha: isHovered ? 1 : 0.62 - ), - .paragraphStyle: centeredParagraphStyle - ] + let label = "..." as NSString + let attributes: [NSAttributedString.Key: Any] = [ + .font: NSFont.monospacedSystemFont(ofSize: 11, weight: .semibold), + .foregroundColor: NSColor( + white: isHovered ? 0.90 : 0.68, + alpha: isHovered ? 1 : 0.62 + ), + .paragraphStyle: centeredParagraphStyle + ] + let labelHeight = label.size(withAttributes: attributes).height + label.draw( + in: NSRect( + x: rect.minX, + y: rect.midY - labelHeight / 2, + width: rect.width, + height: labelHeight + ), + withAttributes: attributes ) } drawCaret() @@ -2144,9 +2156,7 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { override func mouseDown(with event: NSEvent) { let point = convert(event.locationInWindow, from: nil) - if let region = foldRegions.first(where: { - collapsedFoldIDs.contains($0.id) && foldSummaryRect(for: $0)?.contains(point) == true - }) { + if let region = foldSummaryRegion(at: point) { onToggleFold?(region) return } @@ -2172,7 +2182,13 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { } let area = NSTrackingArea( rect: bounds, - options: [.mouseMoved, .mouseEnteredAndExited, .activeInKeyWindow, .inVisibleRect], + options: [ + .mouseMoved, + .mouseEnteredAndExited, + .cursorUpdate, + .activeAlways, + .inVisibleRect + ], owner: self, userInfo: nil ) @@ -2215,26 +2231,56 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { override func mouseEntered(with event: NSEvent) { super.mouseEntered(with: event) - updateFoldHover(at: convert(event.locationInWindow, from: nil)) + let point = convert(event.locationInWindow, from: nil) + let summaryRegion = foldSummaryRegion(at: point) + updateFoldHover(to: summaryRegion?.id) + if summaryRegion != nil { + NSCursor.pointingHand.set() + } } override func mouseMoved(with event: NSEvent) { - super.mouseMoved(with: event) let point = convert(event.locationInWindow, from: nil) - updateFoldHover(at: point) - guard isLanguageNavigationEnabled, - hasNavigationModifier(event.modifierFlags) else { return } - updateLinkHighlight(at: point) + let summaryRegion = foldSummaryRegion(at: point) + updateFoldHover(to: summaryRegion?.id) + if summaryRegion != nil { + NSCursor.pointingHand.set() + return + } + if hitTest(point) is CodeVisionLinkButton { + NSCursor.pointingHand.set() + return + } + if isLanguageNavigationEnabled, + hasNavigationModifier(event.modifierFlags) { + updateLinkHighlight(at: point) + if linkRange != nil { return } + } + NSCursor.iBeam.set() + } + + override func cursorUpdate(with event: NSEvent) { + let point = convert(event.locationInWindow, from: nil) + if foldSummaryRegion(at: point) != nil { + NSCursor.pointingHand.set() + return + } + if hitTest(point) is CodeVisionLinkButton { + NSCursor.pointingHand.set() + return + } + NSCursor.iBeam.set() } override func mouseExited(with event: NSEvent) { super.mouseExited(with: event) - updateFoldHover(at: nil) + updateFoldHover(to: nil) clearLinkHighlight() + NSCursor.arrow.set() } override func resignFirstResponder() -> Bool { - updateFoldHover(at: nil) + updateFoldHover(to: nil) clearLinkHighlight() return super.resignFirstResponder() } @@ -2247,12 +2293,14 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { return becameFirstResponder } - private func updateFoldHover(at point: NSPoint?) { - let nextID = point.flatMap { point in - foldRegions.first(where: { - collapsedFoldIDs.contains($0.id) && foldSummaryRect(for: $0)?.contains(point) == true - })?.id - } + private func foldSummaryRegion(at point: NSPoint) -> JavaFoldRegion? { + foldRegions.first(where: { + collapsedFoldIDs.contains($0.id) + && foldSummaryRect(for: $0)?.contains(point) == true + }) + } + + private func updateFoldHover(to nextID: String?) { guard hoveredFoldID != nextID else { return } hoveredFoldID = nextID needsDisplay = true @@ -2780,6 +2828,7 @@ final class LineNumberGutterView: NSView { private var isBlameVisible = false private var onSelectBlame: ((GitBlameLine) -> Void)? private var blameButtons: [Int: NSButton] = [:] + private var visibleBlameButtonLines: Set = [] private var foldRegions: [JavaFoldRegion] = [] private var collapsedFoldIDs: Set = [] private var onToggleFold: ((JavaFoldRegion) -> Void)? @@ -2789,6 +2838,8 @@ final class LineNumberGutterView: NSView { private var onToggleDebugBreakpoint: ((Int) -> Void)? private var scrollRefreshScheduled = false private var hoveredFoldID: String? + private var foldIndicatorOpacities: [String: CGFloat] = [:] + private var foldIndicatorAnimationTimer: Timer? private var trackingArea: NSTrackingArea? private var palette = CodeEditorPalette.dark private var gitLineChangeMarkersByLine: [Int: GitLineChangeMarker] = [:] @@ -2798,6 +2849,20 @@ final class LineNumberGutterView: NSView { private var onDiscardGitLineChange: ((GitLineChangeMarker) -> Void)? private var contextGitLineChange: GitLineChangeMarker? + private var editorGutterOriginX: CGFloat { + isBlameVisible ? EditorLayoutMetrics.blameMetadataWidth : 0 + } + + private var foldIndicatorX: CGFloat { + editorGutterOriginX + + EditorLayoutMetrics.markerColumnWidth + + EditorLayoutMetrics.lineNumberWidth + } + + private var markerColumnRange: ClosedRange { + editorGutterOriginX...(editorGutterOriginX + EditorLayoutMetrics.markerColumnWidth) + } + override var isFlipped: Bool { true } override func updateTrackingAreas() { @@ -2806,7 +2871,13 @@ final class LineNumberGutterView: NSView { } let area = NSTrackingArea( rect: bounds, - options: [.mouseMoved, .mouseEnteredAndExited, .activeInKeyWindow, .inVisibleRect], + options: [ + .mouseMoved, + .mouseEnteredAndExited, + .cursorUpdate, + .activeAlways, + .inVisibleRect + ], owner: self, userInfo: nil ) @@ -2860,15 +2931,18 @@ final class LineNumberGutterView: NSView { onSelectBlame = onSelect blameButtons.values.forEach { $0.removeFromSuperview() } blameButtons = [:] + visibleBlameButtonLines = [] if isVisible { for blame in blameLines { let button = ClosureButton(title: "") { onSelect(blame) } button.isBordered = false + button.isHidden = true button.setAccessibilityElement(true) button.setAccessibilityRole(.button) button.setAccessibilityLabel( "Line \(blame.line + 1): \(blame.date), \(blame.authorName)" ) + button.toolTip = "\(blame.authorName) · \(blame.date) · \(blame.commitHash.prefix(8))" addSubview(button) blameButtons[blame.line] = button } @@ -2885,10 +2959,14 @@ final class LineNumberGutterView: NSView { foldRegions = regions collapsedFoldIDs = collapsedIDs onToggleFold = onToggle + foldIndicatorOpacities = foldIndicatorOpacities.filter { opacity in + regions.contains { $0.id == opacity.key } + } if let hoveredFoldID, !regions.contains(where: { $0.id == hoveredFoldID }) { self.hoveredFoldID = nil } + animateFoldIndicators() needsDisplay = true } @@ -2929,10 +3007,29 @@ final class LineNumberGutterView: NSView { guard isBlameVisible, let textView, let scrollView, - let layoutManager = textView.layoutManager else { return } + let layoutManager = textView.layoutManager, + let textContainer = textView.textContainer else { + hideAllBlameButtons() + return + } let source = textView.string as NSString let visibleRect = scrollView.documentVisibleRect - for (line, button) in blameButtons { + guard let visibleLines = visibleLineRange( + source: source, + visibleRect: visibleRect, + layoutManager: layoutManager, + textContainer: textContainer, + textView: textView + ) else { + hideAllBlameButtons() + return + } + let firstVisibleLine = visibleLines.lowerBound + var newlyVisibleLines: Set = [] + var accessibilityButtons: [NSButton] = [] + for line in visibleLines { + guard let button = blameButtons[line], + showsBlameMetadata(line: line, firstVisibleLine: firstVisibleLine) else { continue } let characterIndex = characterOffset(forLine: line, in: source) guard characterIndex < source.length else { button.isHidden = true @@ -2941,10 +3038,74 @@ final class LineNumberGutterView: NSView { let glyphIndex = layoutManager.glyphIndexForCharacter(at: characterIndex) let lineRect = layoutManager.lineFragmentRect(forGlyphAt: glyphIndex, effectiveRange: nil) let y = lineRect.minY + textView.textContainerOrigin.y - visibleRect.minY - button.frame = NSRect(x: 0, y: y, width: bounds.width, height: max(16, lineRect.height)) - button.isHidden = button.frame.maxY < 0 || button.frame.minY > bounds.height + button.frame = NSRect( + x: 0, + y: y, + width: EditorLayoutMetrics.blameMetadataWidth, + height: max(16, lineRect.height) + ) + button.isHidden = !button.frame.intersects(bounds) + if !button.isHidden { + newlyVisibleLines.insert(line) + accessibilityButtons.append(button) + } + } + for line in visibleBlameButtonLines.subtracting(newlyVisibleLines) { + blameButtons[line]?.isHidden = true + } + visibleBlameButtonLines = newlyVisibleLines + setAccessibilityChildren(accessibilityButtons.sorted { $0.frame.minY < $1.frame.minY }) + } + + private func hideAllBlameButtons() { + for line in visibleBlameButtonLines { + blameButtons[line]?.isHidden = true } - setAccessibilityChildren(Array(blameButtons.values)) + visibleBlameButtonLines = [] + setAccessibilityChildren([]) + } + + private func visibleLineRange( + source: NSString, + visibleRect: NSRect, + layoutManager: NSLayoutManager, + textContainer: NSTextContainer, + textView: NSTextView + ) -> ClosedRange? { + guard layoutManager.numberOfGlyphs > 0 else { return nil } + let textContainerVisibleRect = NSRect( + x: visibleRect.minX - textView.textContainerOrigin.x, + y: visibleRect.minY - textView.textContainerOrigin.y, + width: visibleRect.width, + height: visibleRect.height + ) + let glyphRange = layoutManager.glyphRange( + forBoundingRect: textContainerVisibleRect, + in: textContainer + ) + guard glyphRange.length > 0 else { return nil } + let firstGlyph = min(glyphRange.location, layoutManager.numberOfGlyphs - 1) + let lastGlyph = min(NSMaxRange(glyphRange) - 1, layoutManager.numberOfGlyphs - 1) + let firstCharacter = layoutManager.characterIndexForGlyph(at: firstGlyph) + let lastCharacter = layoutManager.characterIndexForGlyph(at: lastGlyph) + let codeTextView = textView as? CodeTextView + let firstLine = codeTextView?.lineNumber(at: firstCharacter, in: source) + ?? source.substring(to: min(source.length, firstCharacter)) + .reduce(0) { $1 == "\n" ? $0 + 1 : $0 } + let lastLine = codeTextView?.lineNumber(at: lastCharacter, in: source) + ?? source.substring(to: min(source.length, lastCharacter)) + .reduce(0) { $1 == "\n" ? $0 + 1 : $0 } + return firstLine...max(firstLine, lastLine) + } + + private func showsBlameMetadata(line: Int, firstVisibleLine: Int) -> Bool { + guard let blame = blameByLine[line] else { return false } + return EditorLayoutMetrics.showsBlameMetadata( + line: line, + firstVisibleLine: firstVisibleLine, + commitHash: blame.commitHash, + previousCommitHash: blameByLine[line - 1]?.commitHash + ) } private func characterOffset(forLine targetLine: Int, in source: NSString) -> Int { @@ -3025,14 +3186,15 @@ final class LineNumberGutterView: NSView { palette.currentLine.setFill() NSRect(x: 0, y: y, width: bounds.width, height: lineRect.height).fill() } - if isBlameVisible, let blame = blameByLine[lineNumber - 1] { - drawBlame(blame, y: y + 1) - } - if let marker = implementationMarkers.first(where: { $0.line == lineNumber - 1 }) { - drawImplementationMarker(marker, y: y, height: lineRect.height) + if isBlameVisible, + let blame = blameByLine[lineNumber - 1], + showsBlameMetadata(line: lineNumber - 1, firstVisibleLine: firstLine) { + drawBlame(blame, y: y, height: lineRect.height) } if !isBlameVisible, debugBreakpointLines.contains(lineNumber - 1) { drawDebugBreakpoint(y: y, height: lineRect.height) + } else if let marker = implementationMarkers.first(where: { $0.line == lineNumber - 1 }) { + drawImplementationMarker(marker, y: y, height: lineRect.height) } if let marker = gitLineChangeMarkersByLine[lineNumber - 1] { drawGitLineChange(marker, y: y, height: lineRect.height) @@ -3055,6 +3217,14 @@ final class LineNumberGutterView: NSView { private func drawEditorDivider(in dirtyRect: NSRect) { palette.gutterDivider.setFill() + if isBlameVisible { + NSRect( + x: EditorLayoutMetrics.blameMetadataWidth, + y: dirtyRect.minY, + width: 1, + height: dirtyRect.height + ).fill() + } NSRect( x: bounds.width - 1, y: dirtyRect.minY, @@ -3093,47 +3263,71 @@ final class LineNumberGutterView: NSView { private func drawLineNumber(_ number: Int, y: CGFloat, height: CGFloat) { let label = String(number) as NSString let attributes: [NSAttributedString.Key: Any] = [ - .font: NSFont.monospacedDigitSystemFont(ofSize: 10.5, weight: .regular), + .font: { + let editorFont = textView?.font ?? LitheTheme.editorFont(size: 13) + return LitheTheme.editorFont( + size: max(8, editorFont.pointSize - 1), + weight: .regular + ) + }(), .foregroundColor: palette.lineNumber ] let size = label.size(withAttributes: attributes) let centeredY = y + max(0, (height - size.height) / 2) label.draw( - at: NSPoint(x: (bounds.width - size.width) / 2, y: centeredY), + at: NSPoint( + x: editorGutterOriginX + + EditorLayoutMetrics.markerColumnWidth + + (EditorLayoutMetrics.lineNumberWidth - size.width) / 2, + y: centeredY + ), withAttributes: attributes ) } private func drawFoldIndicator(_ region: JavaFoldRegion, y: CGFloat, height: CGFloat) { - let isHovered = hoveredFoldID == region.id + let opacity = foldIndicatorOpacity(for: region) + guard opacity > 0.01 else { return } let centerY = y + height / 2 - if isHovered { - let hoverRect = NSRect( - x: 1, - y: y + max(0, (height - 17) / 2), - width: 18, - height: 17 - ) - palette.foldHover.setFill() - NSBezierPath(roundedRect: hoverRect, xRadius: 3, yRadius: 3).fill() - } + let editorFont = textView?.font ?? LitheTheme.editorFont(size: 13) + let lineNumberFont = LitheTheme.editorFont( + size: max(8, editorFont.pointSize - 1), + weight: .regular + ) + // Match the line number's visual glyph height while preserving the + // narrower chevron geometry chosen for the gutter. + let symbolSize = max(6, lineNumberFont.capHeight - 1) + let symbolWidth = symbolSize * 0.55 + let leftX = foldIndicatorX + 4 + let rightX = leftX + symbolWidth let path = NSBezierPath() if collapsedFoldIDs.contains(region.id) { - path.move(to: NSPoint(x: 6, y: centerY - 4)) - path.line(to: NSPoint(x: 11, y: centerY)) - path.line(to: NSPoint(x: 6, y: centerY + 4)) + path.move(to: NSPoint(x: leftX, y: centerY - symbolSize / 2)) + path.line(to: NSPoint(x: rightX, y: centerY)) + path.line(to: NSPoint(x: leftX, y: centerY + symbolSize / 2)) } else { - path.move(to: NSPoint(x: 5, y: centerY - 2)) - path.line(to: NSPoint(x: 9, y: centerY + 3)) - path.line(to: NSPoint(x: 13, y: centerY - 2)) - } - path.close() - (isHovered ? palette.foldIndicatorHover : palette.foldIndicator).setFill() - path.fill() + let centerX = leftX + symbolWidth / 2 + path.move(to: NSPoint(x: centerX - symbolSize / 2, y: centerY - symbolWidth / 2)) + path.line(to: NSPoint(x: centerX, y: centerY + symbolWidth / 2)) + path.line(to: NSPoint(x: centerX + symbolSize / 2, y: centerY - symbolWidth / 2)) + } + NSGraphicsContext.saveGraphicsState() + NSGraphicsContext.current?.cgContext.setAlpha(opacity) + palette.foldIndicator.setStroke() + path.lineWidth = 1.3 + path.lineCapStyle = .round + path.lineJoinStyle = .round + path.stroke() + NSGraphicsContext.restoreGraphicsState() } private func drawImplementationMarker(_ marker: JavaImplementationMarker, y: CGFloat, height: CGFloat) { - let rect = NSRect(x: 18, y: y + max(0, (height - 13) / 2), width: 13, height: 13) + let rect = NSRect( + x: editorGutterOriginX + (EditorLayoutMetrics.markerColumnWidth - 13) / 2, + y: y + max(0, (height - 13) / 2), + width: 13, + height: 13 + ) if let image = LitheIcons.implementationMarkerImage( pointingDown: marker.direction == .down, size: 13 @@ -3151,7 +3345,12 @@ final class LineNumberGutterView: NSView { private func drawDebugBreakpoint(y: CGFloat, height: CGFloat) { NSColor(red: 0.92, green: 0.28, blue: 0.30, alpha: 0.96).setFill() NSBezierPath( - ovalIn: NSRect(x: 29, y: y + max(0, (height - 8) / 2), width: 8, height: 8) + ovalIn: NSRect( + x: editorGutterOriginX + (EditorLayoutMetrics.markerColumnWidth - 8) / 2, + y: y + max(0, (height - 8) / 2), + width: 8, + height: 8 + ) ).fill() } @@ -3186,14 +3385,24 @@ final class LineNumberGutterView: NSView { return style } - private func drawBlame(_ blame: GitBlameLine, y: CGFloat) { + private func drawBlame(_ blame: GitBlameLine, y: CGFloat, height: CGFloat) { + let style = NSMutableParagraphStyle() + style.lineBreakMode = .byTruncatingTail + let font = textView?.font ?? LitheTheme.editorFont(size: 13) let attributes: [NSAttributedString.Key: Any] = [ - .font: NSFont.systemFont(ofSize: 10.5), - .foregroundColor: palette.blameText + .font: font, + .foregroundColor: palette.blameText, + .paragraphStyle: style ] - (blame.date as NSString).draw(at: NSPoint(x: 8, y: y), withAttributes: attributes) - (blame.authorName as NSString).draw( - in: NSRect(x: 76, y: y, width: max(0, bounds.width - 128), height: 16), + let label = "\(blame.authorName) · \(blame.date)" as NSString + let labelHeight = label.size(withAttributes: attributes).height + label.draw( + in: NSRect( + x: 6, + y: y + max(0, (height - labelHeight) / 2) - LitheTheme.editorBaselineLift, + width: EditorLayoutMetrics.blameMetadataWidth - 12, + height: height + ), withAttributes: attributes ) } @@ -3205,7 +3414,20 @@ final class LineNumberGutterView: NSView { override func mouseMoved(with event: NSEvent) { super.mouseMoved(with: event) - updateFoldHover(at: convert(event.locationInWindow, from: nil)) + let point = convert(event.locationInWindow, from: nil) + updateFoldHover(at: point) + if foldRegion(at: point) != nil { + NSCursor.pointingHand.set() + } + } + + override func cursorUpdate(with event: NSEvent) { + let point = convert(event.locationInWindow, from: nil) + if foldRegion(at: point) != nil { + NSCursor.pointingHand.set() + return + } + super.cursorUpdate(with: event) } override func mouseExited(with event: NSEvent) { @@ -3217,11 +3439,54 @@ final class LineNumberGutterView: NSView { let nextID = point.flatMap { foldRegion(at: $0)?.id } guard hoveredFoldID != nextID else { return } hoveredFoldID = nextID + animateFoldIndicators() + } + + private func foldIndicatorOpacity(for region: JavaFoldRegion) -> CGFloat { + if collapsedFoldIDs.contains(region.id) { return 1 } + return foldIndicatorOpacities[region.id] ?? 0 + } + + private func animateFoldIndicators() { + guard foldIndicatorAnimationTimer == nil else { return } + let timer = Timer(timeInterval: 1.0 / 60, repeats: true) { [weak self] _ in + Task { @MainActor [weak self] in + self?.advanceFoldIndicatorAnimation() + } + } + RunLoop.main.add(timer, forMode: .common) + foldIndicatorAnimationTimer = timer + } + + private func advanceFoldIndicatorAnimation() { + let activeIDs = Set(foldIndicatorOpacities.keys) + .union(collapsedFoldIDs) + .union(hoveredFoldID.map { [$0] } ?? []) + var hasAnimation = false + for id in activeIDs { + let target: CGFloat = collapsedFoldIDs.contains(id) || hoveredFoldID == id ? 1 : 0 + let current = foldIndicatorOpacities[id] ?? (collapsedFoldIDs.contains(id) ? 1 : 0) + let next = current + (target - current) * 0.3 + if abs(next - target) < 0.02 { + if target == 0 { + foldIndicatorOpacities.removeValue(forKey: id) + } else { + foldIndicatorOpacities[id] = target + } + } else { + foldIndicatorOpacities[id] = next + hasAnimation = true + } + } needsDisplay = true + guard !hasAnimation else { return } + foldIndicatorAnimationTimer?.invalidate() + foldIndicatorAnimationTimer = nil } private func foldRegion(at point: NSPoint) -> JavaFoldRegion? { - guard point.x <= 18, + guard point.x >= foldIndicatorX, + point.x <= foldIndicatorX + EditorLayoutMetrics.foldIndicatorWidth, let textView, let scrollView, let layoutManager = textView.layoutManager, @@ -3260,14 +3525,21 @@ final class LineNumberGutterView: NSView { ?? source.substring(to: min(characterIndex, source.length)).reduce(0) { $1 == "\n" ? $0 + 1 : $0 } if point.x >= bounds.width - 8, let marker = gitLineChangeMarkersByLine[line] { onShowGitLineChange?(marker) - } else if point.x <= 16, let region = foldRegions.first(where: { $0.startLine == line }) { + } else if point.x >= foldIndicatorX, + point.x <= foldIndicatorX + EditorLayoutMetrics.foldIndicatorWidth, + let region = foldRegions.first(where: { $0.startLine == line }) { onToggleFold?(region) - } else if point.x <= 34, - let marker = implementationMarkers.first(where: { $0.line == line }) { - onSelectImplementation?(marker) - } else if !isBlameVisible, point.x <= 52 { - onToggleDebugBreakpoint?(line) - } else if isBlameVisible, let blame = blameByLine[line] { + } else if markerColumnRange.contains(point.x) { + if !isBlameVisible, debugBreakpointLines.contains(line) { + onToggleDebugBreakpoint?(line) + } else if let marker = implementationMarkers.first(where: { $0.line == line }) { + onSelectImplementation?(marker) + } else if !isBlameVisible { + onToggleDebugBreakpoint?(line) + } + } else if isBlameVisible, + point.x < EditorLayoutMetrics.blameMetadataWidth, + let blame = blameByLine[line] { onSelectBlame?(blame) } else { textView.window?.makeFirstResponder(textView) @@ -3337,6 +3609,7 @@ final class LineNumberGutterView: NSView { } deinit { + foldIndicatorAnimationTimer?.invalidate() if let boundsObserver { NotificationCenter.default.removeObserver(boundsObserver) } @@ -3345,6 +3618,9 @@ final class LineNumberGutterView: NSView { @MainActor final class CodeVisionOverlayController { + private static let itemSpacing: CGFloat = 4 + private static let buttonHeight: CGFloat = 18 + private weak var textView: NSTextView? private var buttons: [NSButton] = [] private var currentHints: [JavaCodeVisionHint] = [] @@ -3393,39 +3669,54 @@ final class CodeVisionOverlayController { ) let lastGlyph = max(glyphRange.location, NSMaxRange(glyphRange) - 1) let lineRect = layoutManager.lineFragmentRect(forGlyphAt: lastGlyph, effectiveRange: nil) - let x = textView.textContainerOrigin.x + contentRect.maxX + 8 + let x = textView.textContainerOrigin.x + contentRect.maxX + 12 let y = lineRect.minY + textView.textContainerOrigin.y - 1 - let requiredWidth = buttonWidth(for: hint) - guard x + requiredWidth <= textView.bounds.maxX - 8 else { continue } - - let usageButton = makeButton(title: "\(hint.usageCount) usage\(hint.usageCount == 1 ? "" : "s")") { - onUsages(hint) + var items: [NSButton] = [] + if hint.usageCount > 0 { + let usageButton = makeButton( + title: "\(hint.usageCount) usage\(hint.usageCount == 1 ? "" : "s")", + hoverUnderlineStyle: .afterFirstSpace + ) { + onUsages(hint) + } + items.append(usageButton) } - usageButton.frame = NSRect(x: x, y: y, width: 70, height: 18) - textView.addSubview(usageButton) - buttons.append(usageButton) - var nextX = x + 72 if hint.implementationCount > 0 { let title = "\(hint.implementationCount) implementation\(hint.implementationCount == 1 ? "" : "s")" - let implementationButton = makeButton(title: title) { + let implementationButton = makeButton( + title: title, + hoverUnderlineStyle: .afterFirstSpace + ) { onImplementations(hint) } - let width = max(108, CGFloat(title.count) * 5.8 + 16) - implementationButton.frame = NSRect(x: nextX, y: y, width: width, height: 18) - textView.addSubview(implementationButton) - buttons.append(implementationButton) - nextX += width + 2 + items.append(implementationButton) } if let authorName = hint.authorName, !authorName.isEmpty { - let authorButton = makeButton(title: authorName, systemImage: "person") { + let authorButton = makeButton( + title: authorName, + systemImage: "person", + hoverUnderlineStyle: .all + ) { onAuthor() } - authorButton.frame = NSRect(x: nextX, y: y, width: 112, height: 18) - textView.addSubview(authorButton) - buttons.append(authorButton) + items.append(authorButton) + } + + let widths = items.map(buttonWidth) + let requiredWidth = widths.reduce(0, +) + + CGFloat(max(0, items.count - 1)) * Self.itemSpacing + guard x + requiredWidth <= textView.bounds.maxX - 8 else { continue } + + var nextX = x + for (index, item) in items.enumerated() { + let width = widths[index] + item.frame = NSRect(x: nextX, y: y, width: width, height: Self.buttonHeight) + textView.addSubview(item) + buttons.append(item) + nextX += width + Self.itemSpacing } } textView.setAccessibilityChildren(buttons) @@ -3447,33 +3738,29 @@ final class CodeVisionOverlayController { private func makeButton( title: String, systemImage: String? = nil, + hoverUnderlineStyle: CodeVisionHoverUnderlineStyle = .none, action: @escaping () -> Void ) -> NSButton { - let button = ClosureButton(title: title, action: action) + let button = CodeVisionLinkButton( + title: title, + systemImage: systemImage, + hoverUnderlineStyle: hoverUnderlineStyle, + font: .systemFont(ofSize: 10.5, weight: .medium), + textColor: NSColor(white: 0.52, alpha: 1), + action: action + ) button.isBordered = false - button.font = .systemFont(ofSize: 10.5) button.contentTintColor = NSColor(white: 0.52, alpha: 1) - button.alignment = .left + button.alignment = .center button.setAccessibilityElement(true) button.setAccessibilityRole(.button) button.setAccessibilityLabel(title) - if let systemImage { - button.image = NSImage(systemSymbolName: systemImage, accessibilityDescription: nil) - button.imagePosition = .imageLeading - } return button } - private func buttonWidth(for hint: JavaCodeVisionHint) -> CGFloat { - var width: CGFloat = 70 - if hint.implementationCount > 0 { - let title = "\(hint.implementationCount) implementation\(hint.implementationCount == 1 ? "" : "s")" - width += max(108, CGFloat(title.count) * 5.8 + 16) + 2 - } - if let authorName = hint.authorName, !authorName.isEmpty { - width += 114 - } - return width + private func buttonWidth(_ button: NSButton) -> CGFloat { + button.sizeToFit() + return ceil(button.frame.width) } } @@ -3584,6 +3871,148 @@ private final class ClosureButton: NSButton { addCursorRect(bounds, cursor: .pointingHand) } + override func cursorUpdate(with event: NSEvent) { + NSCursor.pointingHand.set() + } + + @objc private func invoke() { + handler() + } +} + +@MainActor +private enum CodeVisionHoverUnderlineStyle { + case none + case all + case afterFirstSpace +} + +@MainActor +private final class CodeVisionLinkButton: NSButton { + private let handler: () -> Void + private let linkTitle: String + private let systemImageName: String? + private let hoverUnderlineStyle: CodeVisionHoverUnderlineStyle + private let linkFont: NSFont + private let linkColor: NSColor + private var hoverTrackingArea: NSTrackingArea? + + init( + title: String, + systemImage: String?, + hoverUnderlineStyle: CodeVisionHoverUnderlineStyle, + font: NSFont, + textColor: NSColor, + action: @escaping () -> Void + ) { + handler = action + linkTitle = title + systemImageName = systemImage + self.hoverUnderlineStyle = hoverUnderlineStyle + linkFont = font + linkColor = textColor + super.init(frame: .zero) + attributedTitle = styledTitle(isHovered: false) + target = self + self.action = #selector(invoke) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func updateTrackingAreas() { + super.updateTrackingAreas() + if let hoverTrackingArea { + removeTrackingArea(hoverTrackingArea) + } + let area = NSTrackingArea( + rect: bounds, + options: [ + .mouseMoved, + .mouseEnteredAndExited, + .activeAlways, + .inVisibleRect + ], + owner: self, + userInfo: nil + ) + addTrackingArea(area) + hoverTrackingArea = area + } + + override func resetCursorRects() { + super.resetCursorRects() + addCursorRect(bounds, cursor: .pointingHand) + } + + override func cursorUpdate(with event: NSEvent) { + NSCursor.pointingHand.set() + } + + override func mouseEntered(with event: NSEvent) { + super.mouseEntered(with: event) + attributedTitle = styledTitle(isHovered: true) + NSCursor.pointingHand.set() + } + + override func mouseMoved(with event: NSEvent) { + NSCursor.pointingHand.set() + } + + override func mouseExited(with event: NSEvent) { + super.mouseExited(with: event) + attributedTitle = styledTitle(isHovered: false) + } + + private func styledTitle(isHovered: Bool) -> NSAttributedString { + var attributes: [NSAttributedString.Key: Any] = [ + .font: linkFont, + .foregroundColor: linkColor + ] + let hasInlineIcon = systemImageName != nil + if hasInlineIcon { + // Keep the author label on the same visual baseline as the + // text-only usage link. NSTextAttachment otherwise lowers the + // adjacent title when AppKit vertically centers the button. + attributes[.baselineOffset] = 1 + } + let result = NSMutableAttributedString() + if let systemImageName, + let image = NSImage( + systemSymbolName: systemImageName, + accessibilityDescription: nil + )?.withSymbolConfiguration( + NSImage.SymbolConfiguration(pointSize: 10, weight: .medium) + ) { + let attachment = NSTextAttachment() + attachment.image = image + attachment.bounds = NSRect(x: 0, y: 1, width: 10, height: 10) + result.append(NSAttributedString(attachment: attachment)) + result.append(NSAttributedString(string: " ")) + } + if isHovered, + hoverUnderlineStyle == .afterFirstSpace, + let separator = linkTitle.firstIndex(of: " ") { + let prefix = String(linkTitle[.. ( + outer: NSScrollView, + nested: NSScrollView, + textView: NSTextView + ) { + let outer = NSScrollView(frame: NSRect(x: 0, y: 0, width: 400, height: 100)) + let outerDocument = FlippedTestView(frame: NSRect(x: 0, y: 0, width: 400, height: 600)) + outer.documentView = outerDocument + + let nested = NSScrollView(frame: NSRect( + x: 20, + y: nestedOriginY, + width: 300, + height: 100 + )) + let textView = NSTextView(frame: NSRect(x: 0, y: 0, width: 300, height: 400)) + nested.documentView = textView + textView.frame = NSRect(x: 0, y: 0, width: 300, height: 400) + outerDocument.addSubview(nested) + outer.contentView.scroll(to: NSPoint(x: 0, y: outerScrollY)) + outer.reflectScrolledClipView(outer.contentView) + outer.layoutSubtreeIfNeeded() + return (outer, nested, textView) + } +} + +private final class FlippedTestView: NSView { + override var isFlipped: Bool { true } +} diff --git a/Tests/LitheTests/SettingsAppearanceContainerTests.swift b/Tests/LitheTests/SettingsAppearanceContainerTests.swift new file mode 100644 index 000000000..65ca4304a --- /dev/null +++ b/Tests/LitheTests/SettingsAppearanceContainerTests.swift @@ -0,0 +1,93 @@ +import AppKit +import SwiftUI +import Testing +@testable import Lithe + +@MainActor +struct SettingsAppearanceContainerTests { + @Test + func changingAppearanceKeepsTheContentIdentity() { + let recorder = SettingsContentIdentityRecorder() + let hostingView = NSHostingView(rootView: makeContent( + themePreference: .light, + recorder: recorder + )) + hostingView.frame = NSRect(x: 0, y: 0, width: 200, height: 100) + hostingView.layoutSubtreeIfNeeded() + let initialSnapshot = recorder.snapshots.last + + hostingView.rootView = makeContent(themePreference: .dark, recorder: recorder) + hostingView.layoutSubtreeIfNeeded() + let darkSnapshot = recorder.snapshots.last + + hostingView.rootView = makeContent(themePreference: .system, recorder: recorder) + hostingView.layoutSubtreeIfNeeded() + let systemSnapshot = recorder.snapshots.last + + #expect(initialSnapshot != nil) + #expect(darkSnapshot?.identity == initialSnapshot?.identity) + #expect(systemSnapshot?.identity == initialSnapshot?.identity) + #expect(darkSnapshot?.draft == "unsaved draft") + #expect(systemSnapshot?.draft == "unsaved draft") + } + + private func makeContent( + themePreference: AppThemePreference, + recorder: SettingsContentIdentityRecorder + ) -> SettingsAppearanceContainer { + SettingsAppearanceContainer(themePreference: themePreference) { + SettingsContentIdentityProbe(recorder: recorder) + } + } +} + +@MainActor +private final class SettingsContentIdentityRecorder { + struct Snapshot { + let identity: ObjectIdentifier + let draft: String + } + + var snapshots: [Snapshot] = [] +} + +@MainActor +private final class SettingsContentState: ObservableObject { + var draft = "" +} + +@MainActor +private struct SettingsContentIdentityProbe: View { + @StateObject private var state = SettingsContentState() + let recorder: SettingsContentIdentityRecorder + + var body: some View { + SettingsContentIdentityReporter( + state: state, + recorder: recorder + ) + } +} + +@MainActor +private struct SettingsContentIdentityReporter: NSViewRepresentable { + let state: SettingsContentState + let recorder: SettingsContentIdentityRecorder + + func makeNSView(context: Context) -> NSView { + state.draft = "unsaved draft" + recordIdentity() + return NSView(frame: .zero) + } + + func updateNSView(_ nsView: NSView, context: Context) { + recordIdentity() + } + + private func recordIdentity() { + recorder.snapshots.append(.init( + identity: ObjectIdentifier(state), + draft: state.draft + )) + } +}