diff --git a/Package.resolved b/Package.resolved index 1be7fc1a..8e67648d 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,5 +1,5 @@ { - "originHash" : "c7538017d5a84c9119e78e47b189a0e9f8751f463d32ee202500635b863ce5b2", + "originHash" : "174978129e44c6ca8e408691371fef707d0bd079d5dd55de25c3f90f483f08f4", "pins" : [ { "identity" : "swift-argument-parser", @@ -10,6 +10,24 @@ "version" : "1.8.2" } }, + { + "identity" : "swift-syntax", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-syntax.git", + "state" : { + "revision" : "4799286537280063c85a32f09884cfbca301b1a1", + "version" : "602.0.0" + } + }, + { + "identity" : "swift-testing", + "kind" : "remoteSourceControl", + "location" : "https://github.com/swiftlang/swift-testing.git", + "state" : { + "revision" : "5ee435b15ad40ec1f644b5eb9d247f263ccd2170", + "version" : "6.2.4" + } + }, { "identity" : "swiftterm", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index 2e2cda22..ee01c8e4 100644 --- a/Package.swift +++ b/Package.swift @@ -28,7 +28,8 @@ let package = Package( .executable(name: "LitheOfficialPluginVerifier", targets: ["LitheOfficialPluginVerifier"]) ], dependencies: [ - .package(url: "https://github.com/migueldeicaza/SwiftTerm.git", exact: "1.15.0") + .package(url: "https://github.com/migueldeicaza/SwiftTerm.git", exact: "1.15.0"), + .package(url: "https://github.com/swiftlang/swift-testing.git", exact: "6.2.4") ], targets: [ .target( @@ -118,7 +119,7 @@ let package = Package( ), .testTarget( name: "LitheTests", - dependencies: ["Lithe", "LitheModuleAPI", "LitheApplicationKernel", "LitheCoreContracts", "LitheGitModule", "LitheDatabaseModule", "LitheAIAssistanceModule", "LitheLanguageIntelligenceModule", "LitheGoSupportModule"], + dependencies: ["Lithe", "LitheModuleAPI", "LitheApplicationKernel", "LitheCoreContracts", "LitheGitModule", "LitheDatabaseModule", "LitheAIAssistanceModule", "LitheLanguageIntelligenceModule", "LitheGoSupportModule", .product(name: "Testing", package: "swift-testing")], path: "macos/Tests/LitheTests", resources: [ .copy("Fixtures") @@ -129,7 +130,7 @@ let package = Package( ), .testTarget( name: "LitheApplicationKernelTests", - dependencies: ["LitheModuleAPI", "LitheApplicationKernel"], + dependencies: ["LitheModuleAPI", "LitheApplicationKernel", .product(name: "Testing", package: "swift-testing")], path: "macos/Tests/LitheApplicationKernelTests", swiftSettings: [ .swiftLanguageMode(.v6) @@ -137,61 +138,61 @@ let package = Package( ), .testTarget( name: "LitheTerminalModuleTests", - dependencies: ["LitheTerminalModule"], + dependencies: ["LitheTerminalModule", .product(name: "Testing", package: "swift-testing")], path: "macos/Tests/LitheTerminalModuleTests", swiftSettings: [.swiftLanguageMode(.v6)] ), .testTarget( name: "LitheAIAssistanceModuleTests", - dependencies: ["LitheAIAssistanceModule", "LitheApplicationKernel", "LitheCoreContracts"], + dependencies: ["LitheAIAssistanceModule", "LitheApplicationKernel", "LitheCoreContracts", .product(name: "Testing", package: "swift-testing")], path: "macos/Tests/LitheAIAssistanceModuleTests", swiftSettings: [.swiftLanguageMode(.v6)] ), .testTarget( name: "LitheSearchModuleTests", - dependencies: ["LitheSearchModule", "LitheApplicationKernel"], + dependencies: ["LitheSearchModule", "LitheApplicationKernel", .product(name: "Testing", package: "swift-testing")], path: "macos/Tests/LitheSearchModuleTests", swiftSettings: [.swiftLanguageMode(.v6)] ), .testTarget( name: "LitheLocalHistoryModuleTests", - dependencies: ["LitheLocalHistoryModule", "LitheApplicationKernel"], + dependencies: ["LitheLocalHistoryModule", "LitheApplicationKernel", .product(name: "Testing", package: "swift-testing")], path: "macos/Tests/LitheLocalHistoryModuleTests", swiftSettings: [.swiftLanguageMode(.v6)] ), .testTarget( name: "LitheGitModuleTests", - dependencies: ["LitheGitModule", "LitheApplicationKernel"], + dependencies: ["LitheGitModule", "LitheApplicationKernel", .product(name: "Testing", package: "swift-testing")], path: "macos/Tests/LitheGitModuleTests", swiftSettings: [.swiftLanguageMode(.v6)] ), .testTarget( name: "LitheDatabaseModuleTests", - dependencies: ["LitheDatabaseModule", "LitheApplicationKernel"], + dependencies: ["LitheDatabaseModule", "LitheApplicationKernel", .product(name: "Testing", package: "swift-testing")], path: "macos/Tests/LitheDatabaseModuleTests", swiftSettings: [.swiftLanguageMode(.v6)] ), .testTarget( name: "LitheLanguageIntelligenceModuleTests", - dependencies: ["LitheLanguageIntelligenceModule", "LitheApplicationKernel", "LitheCoreContracts"], + dependencies: ["LitheLanguageIntelligenceModule", "LitheApplicationKernel", "LitheCoreContracts", .product(name: "Testing", package: "swift-testing")], path: "macos/Tests/LitheLanguageIntelligenceModuleTests", swiftSettings: [.swiftLanguageMode(.v6)] ), .testTarget( name: "LitheDebugModuleTests", - dependencies: ["LitheDebugModule", "LitheApplicationKernel"], + dependencies: ["LitheDebugModule", "LitheApplicationKernel", .product(name: "Testing", package: "swift-testing")], path: "macos/Tests/LitheDebugModuleTests", swiftSettings: [.swiftLanguageMode(.v6)] ), .testTarget( name: "LitheExecutionModuleTests", - dependencies: ["LitheExecutionModule", "LitheApplicationKernel", "LitheCoreContracts"], + dependencies: ["LitheExecutionModule", "LitheApplicationKernel", "LitheCoreContracts", .product(name: "Testing", package: "swift-testing")], path: "macos/Tests/LitheExecutionModuleTests", swiftSettings: [.swiftLanguageMode(.v6)] ), .testTarget( name: "LitheWorkspaceModuleTests", - dependencies: ["LitheWorkspaceModule", "LitheApplicationKernel"], + dependencies: ["LitheWorkspaceModule", "LitheApplicationKernel", .product(name: "Testing", package: "swift-testing")], path: "macos/Tests/LitheWorkspaceModuleTests", swiftSettings: [.swiftLanguageMode(.v6)] ), @@ -200,7 +201,8 @@ let package = Package( dependencies: [ "LitheGoSupportModule", "LitheApplicationKernel", - "LitheLanguageIntelligenceModule" + "LitheLanguageIntelligenceModule", + .product(name: "Testing", package: "swift-testing") ], path: "Plugins/mac/Official/GoSupport/Tests/LitheGoSupportModuleTests", swiftSettings: [.swiftLanguageMode(.v6)] diff --git a/macos/Sources/Lithe/LitheApp.swift b/macos/Sources/Lithe/LitheApp.swift index fbf7b191..7f6d0f23 100644 --- a/macos/Sources/Lithe/LitheApp.swift +++ b/macos/Sources/Lithe/LitheApp.swift @@ -540,6 +540,7 @@ private struct SettingsWindowAccessor: NSViewRepresentable { guard let window = view.window else { return } reference.window = window window.title = title + window.level = .floating let windowAppearance = themePreference.windowAppearance if window.appearance?.name != windowAppearance?.name { window.appearance = windowAppearance diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel+Notifications.swift b/macos/Sources/Lithe/Models/AppModel/AppModel+Notifications.swift index a633d89d..8fc874b2 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel+Notifications.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel+Notifications.swift @@ -1,5 +1,10 @@ import Foundation +private enum WorkbenchNotificationTiming { + static let displayDuration: Duration = .seconds(4) + static let maximumVisibleCount = 3 +} + extension AppModel { func showNotification(_ message: String) { let notification = WorkbenchNotification(message: message) @@ -7,16 +12,53 @@ extension AppModel { if notifications.count > 100 { notifications.removeLast(notifications.count - 100) } + + activeNotifications.append(notification) + if activeNotifications.count > WorkbenchNotificationTiming.maximumVisibleCount { + let removed = activeNotifications.removeFirst() + cancelNotificationDismissal(for: removed.id) + } notificationMessage = message - Task { @MainActor [weak self] in - try? await Task.sleep(for: .seconds(2)) - if self?.notifications.first?.id == notification.id, - self?.notificationMessage == message { - self?.notificationMessage = nil + if areNotificationsHovered { + notificationRemainingDurations[notification.id] = WorkbenchNotificationTiming.displayDuration + } else { + scheduleNotificationDismissal( + for: notification, + after: WorkbenchNotificationTiming.displayDuration + ) + } + } + + func setNotificationStackHovered(_ isHovered: Bool) { + guard areNotificationsHovered != isHovered else { return } + areNotificationsHovered = isHovered + + if isHovered { + let now = ContinuousClock().now + for notification in activeNotifications { + if let deadline = notificationDismissalDeadlines.removeValue(forKey: notification.id) { + notificationRemainingDurations[notification.id] = now < deadline + ? now.duration(to: deadline) + : .zero + } + notificationDismissalTasks.removeValue(forKey: notification.id)?.cancel() + } + } else { + for notification in activeNotifications { + let remaining = notificationRemainingDurations.removeValue(forKey: notification.id) + ?? WorkbenchNotificationTiming.displayDuration + scheduleNotificationDismissal(for: notification, after: remaining) } } } + func dismissNotification(_ id: UUID) { + guard activeNotifications.contains(where: { $0.id == id }) else { return } + activeNotifications.removeAll { $0.id == id } + cancelNotificationDismissal(for: id) + notificationMessage = activeNotifications.last?.message + } + func markAllNotificationsRead() { for index in notifications.indices { notifications[index].isRead = true @@ -25,6 +67,40 @@ extension AppModel { func clearNotifications() { notifications.removeAll() + activeNotifications.removeAll() + for task in notificationDismissalTasks.values { + task.cancel() + } + notificationDismissalTasks.removeAll() + notificationDismissalDeadlines.removeAll() + notificationRemainingDurations.removeAll() + areNotificationsHovered = false notificationMessage = nil } + + private func scheduleNotificationDismissal( + for notification: WorkbenchNotification, + after duration: Duration + ) { + notificationDismissalTasks[notification.id]?.cancel() + let deadline = ContinuousClock().now.advanced(by: duration) + notificationDismissalDeadlines[notification.id] = deadline + notificationDismissalTasks[notification.id] = Task { @MainActor [weak self] in + do { + try await Task.sleep(for: duration) + } catch { + return + } + guard let self, + !self.areNotificationsHovered, + self.notificationDismissalDeadlines[notification.id] == deadline else { return } + self.dismissNotification(notification.id) + } + } + + private func cancelNotificationDismissal(for id: UUID) { + notificationDismissalTasks.removeValue(forKey: id)?.cancel() + notificationDismissalDeadlines.removeValue(forKey: id) + notificationRemainingDurations.removeValue(forKey: id) + } } diff --git a/macos/Sources/Lithe/Models/AppModel/AppModel.swift b/macos/Sources/Lithe/Models/AppModel/AppModel.swift index 21b5f6c8..d32e9385 100644 --- a/macos/Sources/Lithe/Models/AppModel/AppModel.swift +++ b/macos/Sources/Lithe/Models/AppModel/AppModel.swift @@ -109,7 +109,12 @@ final class AppModel: ObservableObject, Identifiable { workspaceFeature.isPerformingProjectItemOperation } @Published var notificationMessage: String? + @Published var activeNotifications: [WorkbenchNotification] = [] @Published var notifications: [WorkbenchNotification] = [] + var notificationDismissalTasks: [UUID: Task] = [:] + var notificationDismissalDeadlines: [UUID: ContinuousClock.Instant] = [:] + var notificationRemainingDurations: [UUID: Duration] = [:] + var areNotificationsHovered = false @Published var detectedAIConfigurations: [AIConfigurationSnapshot] = [] @Published var commitMessage = "" @Published var amendCommit = false diff --git a/macos/Sources/Lithe/Theme/LitheTheme.swift b/macos/Sources/Lithe/Theme/LitheTheme.swift index 427db650..274c15fe 100644 --- a/macos/Sources/Lithe/Theme/LitheTheme.swift +++ b/macos/Sources/Lithe/Theme/LitheTheme.swift @@ -49,6 +49,7 @@ enum LitheTheme { let sidebar: RGBA let editor: RGBA let raised: RGBA + let notification: RGBA let selection: RGBA let subtleSelection: RGBA let hoverBackground: RGBA @@ -137,6 +138,7 @@ enum LitheTheme { sidebar: surface, editor: surface, raised: surface.mixed(with: ink, amount: isDark ? 0.085 : 0.018), + notification: surface.mixed(with: ink, amount: isDark ? 0.085 : 0.018), selection: accent, subtleSelection: subtleAccent, hoverBackground: ink.withAlpha(isDark ? 0.065 : 0.055), @@ -187,6 +189,7 @@ enum LitheTheme { sidebar: adaptive(light: (1, 1, 1, 1), dark: (0.110, 0.114, 0.122, 1)), editor: adaptive(light: (1, 1, 1, 1), dark: (0.110, 0.114, 0.122, 1)), raised: adaptive(light: (1, 1, 1, 1), dark: (0.165, 0.175, 0.190, 1)), + notification: adaptive(light: (1, 1, 1, 1), dark: (51.0 / 255.0, 54.0 / 255.0, 59.0 / 255.0, 1)), selection: adaptive(light: (0.275, 0.455, 0.945, 1), dark: (0.208, 0.455, 0.941, 1)), subtleSelection: adaptive(light: (0.855, 0.902, 0.973, 1), dark: (0.205, 0.218, 0.238, 1)), hoverBackground: adaptive(light: (0, 0, 0, 0.050), dark: (1, 1, 1, 0.055)), @@ -299,6 +302,16 @@ enum LitheTheme { static var sidebar: Color { adaptive(\.sidebar) } static var editor: Color { adaptive(\.editor) } static var raised: Color { adaptive(\.raised) } + static var notificationBackground: Color { adaptive(\.notification) } + static var contextMenuBackground: Color { + Color(nsColor: NSColor(name: nil) { appearance in + let isDark = appearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua + if activeTheme == .lithe, isDark { + return NSColor(srgbRed: 38.0 / 255.0, green: 39.0 / 255.0, blue: 44.0 / 255.0, alpha: 1) + } + return Palette.make(theme: activeTheme, isDark: isDark).popupBackground.nsColor + }) + } // MARK: - 选中与悬停 static var selection: Color { adaptive(\.selection) } @@ -393,7 +406,7 @@ enum LitheTheme { static let treeRowHeight: CGFloat = 27 // IntelliJ IDEA New UI uses contiguous project-tree rows, 4/12 pt // tree insets, and an 8 pt selection arc (4 pt corner radius). - static let projectTreeRowSpacing: CGFloat = 0 + static let projectTreeRowSpacing: CGFloat = 1 static let projectTreeContentVerticalInset: CGFloat = 4 static let projectTreeContentHorizontalInset: CGFloat = 12 static let projectTreeSelectionCornerRadius: CGFloat = 4 diff --git a/macos/Sources/Lithe/Views/Components/LitheContextMenu.swift b/macos/Sources/Lithe/Views/Components/LitheContextMenu.swift index bcd299dd..eedcdf61 100644 --- a/macos/Sources/Lithe/Views/Components/LitheContextMenu.swift +++ b/macos/Sources/Lithe/Views/Components/LitheContextMenu.swift @@ -1,10 +1,21 @@ import AppKit import SwiftUI +private enum LitheContextMenuMetrics { + static let minimumWidth: CGFloat = 252 + static let maximumWidth: CGFloat = 360 + static let itemFont = NSFont.menuFont(ofSize: 12) + static let shortcutFont = NSFont.menuFont(ofSize: 11) + static let rowHeight: CGFloat = 26 + static let separatorHeight: CGFloat = 11 + static let verticalPadding: CGFloat = 12 +} + struct LitheContextMenuItem: Identifiable { enum Kind { case action case separator + case submenu([LitheContextMenuItem]) } enum Role { @@ -51,28 +62,97 @@ struct LitheContextMenuItem: Identifiable { action: {} ) } + + static func submenu( + _ title: String, + systemImage: String? = nil, + items: [LitheContextMenuItem] + ) -> Self { + Self( + kind: .submenu(items), + title: title, + systemImage: systemImage, + shortcut: nil, + role: .standard, + isEnabled: true, + action: {} + ) + } } private struct LitheContextMenuContent: View { let items: [LitheContextMenuItem] let width: CGFloat let dismiss: () -> Void + let submenuWidth: CGFloat + let submenuHeight: CGFloat + let onSubmenuVisibilityChanged: (Bool) -> Void + @State private var openSubmenuID: UUID? + + private var openSubmenuItems: [LitheContextMenuItem]? { + guard let openSubmenuID else { return nil } + guard case .submenu(let items) = self.items.first(where: { $0.id == openSubmenuID })?.kind else { + return nil + } + return items + } var body: some View { + HStack(spacing: 0) { + menuColumn( + items, + width: width, + onSubmenuHover: { item, hovering in + guard hovering else { return } + openSubmenuID = item.id + } + ) + + if let openSubmenuItems { + menuColumn(openSubmenuItems, width: submenuWidth) + } + } + .frame( + width: width + (openSubmenuItems == nil ? 0 : submenuWidth), + height: max( + Self.menuHeight(for: items), + openSubmenuItems == nil ? 0 : submenuHeight + ) + ) + .onHover { isHovering in + if !isHovering { + openSubmenuID = nil + } + } + .onChange(of: openSubmenuID) { submenuID in + onSubmenuVisibilityChanged(submenuID != nil) + } + } + + @ViewBuilder + private func menuColumn( + _ items: [LitheContextMenuItem], + width: CGFloat, + onSubmenuHover: ((LitheContextMenuItem, Bool) -> Void)? = nil + ) -> some View { VStack(spacing: 0) { ForEach(items) { item in switch item.kind { case .action: - LitheContextMenuRow(item: item) { + LitheContextMenuRow(item: item, action: { dismiss() item.action() - } + }) case .separator: Rectangle() .fill(LitheTheme.divider) .frame(height: 1) .padding(.horizontal, 8) .padding(.vertical, 5) + case .submenu: + LitheContextMenuRow(item: item) { hovering in + onSubmenuHover?(item, hovering) + } } } } @@ -80,22 +160,50 @@ private struct LitheContextMenuContent: View { .frame(width: width) .background { RoundedRectangle(cornerRadius: 9, style: .continuous) - .fill(LitheTheme.sidebar) + .fill(LitheTheme.contextMenuBackground) } - .overlay { - RoundedRectangle(cornerRadius: 9, style: .continuous) - .stroke(LitheTheme.panelBorder, lineWidth: 1) + } + + private static func menuHeight(for items: [LitheContextMenuItem]) -> CGFloat { + items.reduce(LitheContextMenuMetrics.verticalPadding) { height, item in + switch item.kind { + case .separator: + height + LitheContextMenuMetrics.separatorHeight + case .action, .submenu: + height + LitheContextMenuMetrics.rowHeight + } } } } private struct LitheContextMenuRow: View { let item: LitheContextMenuItem - let action: () -> Void + let action: (() -> Void)? + let onSubmenuHover: ((Bool) -> Void)? @State private var isHovering = false + init(item: LitheContextMenuItem, action: @escaping () -> Void) { + self.item = item + self.action = action + self.onSubmenuHover = nil + } + + init(item: LitheContextMenuItem, onSubmenuHover: @escaping (Bool) -> Void) { + self.item = item + self.action = nil + self.onSubmenuHover = onSubmenuHover + } + + private var submenuItems: [LitheContextMenuItem]? { + guard case .submenu(let items) = item.kind else { return nil } + return items + } + var body: some View { - Button(action: action) { + Button { + guard submenuItems == nil else { return } + action?() + } label: { HStack(spacing: 9) { Group { if let systemImage = item.systemImage { @@ -107,26 +215,28 @@ private struct LitheContextMenuRow: View { } .frame(width: 16, height: 16) .foregroundStyle( - item.role == .destructive - ? LitheTheme.error - : (isHovering ? LitheTheme.toolWindowSelectedText : LitheTheme.secondaryText) + isHovering ? LitheTheme.toolWindowSelectedText : LitheTheme.secondaryText ) Text(LocalizedStringKey(item.title)) - .font(.system(size: 13, weight: .regular)) + .font(Font(LitheContextMenuMetrics.itemFont)) .foregroundStyle(isHovering ? LitheTheme.toolWindowSelectedText : LitheTheme.primaryText) .lineLimit(1) Spacer(minLength: 14) - if let shortcut = item.shortcut { + if submenuItems != nil { + Image(systemName: "chevron.right") + .font(.system(size: 9, weight: .semibold)) + .foregroundStyle(isHovering ? LitheTheme.toolWindowSelectedText : LitheTheme.secondaryText) + } else if let shortcut = item.shortcut { Text(shortcut) - .font(.system(size: 12, weight: .regular)) + .font(Font(LitheContextMenuMetrics.shortcutFont)) .foregroundStyle(isHovering ? LitheTheme.toolWindowSelectedText.opacity(0.78) : LitheTheme.tertiaryText) } } .padding(.horizontal, 9) - .frame(height: 28) + .frame(height: LitheContextMenuMetrics.rowHeight) .contentShape(Rectangle()) .background { RoundedRectangle(cornerRadius: 5, style: .continuous) @@ -137,7 +247,10 @@ private struct LitheContextMenuRow: View { .buttonStyle(.plain) .disabled(!item.isEnabled) .opacity(item.isEnabled ? 1 : 0.45) - .onHover { isHovering = $0 } + .onHover { hovering in + isHovering = hovering + onSubmenuHover?(hovering) + } } } @@ -150,7 +263,6 @@ private final class LitheContextMenuPanel: NSPanel { private final class LitheContextMenuPresenter: NSObject, NSWindowDelegate { static let shared = LitheContextMenuPresenter() - private let menuWidth: CGFloat = 252 private var panel: LitheContextMenuPanel? private var localEventMonitor: Any? private var globalEventMonitor: Any? @@ -164,16 +276,35 @@ private final class LitheContextMenuPresenter: NSObject, NSWindowDelegate { dismiss() guard !items.isEmpty else { return } - let menuHeight = items.reduce(CGFloat(12)) { height, item in - height + (item.kind == .separator ? 11 : 28) + let menuWidth = Self.menuWidth(for: items) + let menuHeight = Self.menuHeight(for: items) + let submenuWidths = items.compactMap { item -> CGFloat? in + guard case .submenu(let submenuItems) = item.kind else { return nil } + return Self.menuWidth(for: submenuItems) } + let submenuHeights = items.compactMap { item -> CGFloat? in + guard case .submenu(let submenuItems) = item.kind else { return nil } + return Self.menuHeight(for: submenuItems) + } + let submenuWidth = submenuWidths.max() ?? 0 + let submenuHeight = submenuHeights.max() ?? 0 let content = LitheContextMenuContent( items: items, width: menuWidth, - dismiss: { [weak self] in self?.dismiss() } + dismiss: { [weak self] in self?.dismiss() }, + submenuWidth: submenuWidth, + submenuHeight: submenuHeight, + onSubmenuVisibilityChanged: { [weak self] isVisible in + self?.resizeMenu( + isSubmenuVisible: isVisible, + rootWidth: menuWidth, + rootHeight: menuHeight, + submenuWidth: submenuWidth, + submenuHeight: submenuHeight + ) + } ) .environment(\.locale, locale) - .frame(width: menuWidth, height: menuHeight) let panel = LitheContextMenuPanel( contentRect: NSRect(x: 0, y: 0, width: menuWidth, height: menuHeight), @@ -183,6 +314,7 @@ private final class LitheContextMenuPresenter: NSObject, NSWindowDelegate { ) panel.contentViewController = NSHostingController(rootView: content) panel.appearance = appearance + panel.animationBehavior = .none panel.backgroundColor = .clear panel.isOpaque = false panel.hasShadow = true @@ -208,6 +340,61 @@ private final class LitheContextMenuPresenter: NSObject, NSWindowDelegate { panel.makeKey() } + private func resizeMenu( + isSubmenuVisible: Bool, + rootWidth: CGFloat, + rootHeight: CGFloat, + submenuWidth: CGFloat, + submenuHeight: CGFloat + ) { + guard let panel else { return } + let width = rootWidth + (isSubmenuVisible ? submenuWidth : 0) + let height = max(rootHeight, isSubmenuVisible ? submenuHeight : 0) + var frame = panel.frame + frame.origin.y += frame.height - height + frame.size = NSSize(width: width, height: height) + panel.setFrame(frame, display: true) + } + + fileprivate static func menuWidth(for items: [LitheContextMenuItem]) -> CGFloat { + let widestItem = items.reduce(CGFloat.zero) { width, item in + guard case .action = item.kind else { + guard case .submenu = item.kind else { return width } + return max(width, menuItemWidth(item)) + } + return max(width, menuItemWidth(item)) + } + let contentWidth = widestItem + 67 + return min( + max(contentWidth, LitheContextMenuMetrics.minimumWidth), + LitheContextMenuMetrics.maximumWidth + ) + } + + fileprivate static func menuHeight(for items: [LitheContextMenuItem]) -> CGFloat { + items.reduce(LitheContextMenuMetrics.verticalPadding) { height, item in + switch item.kind { + case .separator: + height + LitheContextMenuMetrics.separatorHeight + case .action, .submenu: + height + LitheContextMenuMetrics.rowHeight + } + } + } + + private static func menuItemWidth(_ item: LitheContextMenuItem) -> CGFloat { + let titleWidth = (item.title as NSString).size( + withAttributes: [.font: LitheContextMenuMetrics.itemFont] + ).width + let shortcutWidth = item.shortcut.map { + ($0 as NSString).size( + withAttributes: [.font: LitheContextMenuMetrics.shortcutFont] + ).width + } ?? 0 + let shortcutSpacing: CGFloat = item.shortcut == nil ? 0 : 18 + return titleWidth + shortcutWidth + shortcutSpacing + } + func dismiss() { removeEventMonitors() panel?.orderOut(nil) @@ -256,6 +443,7 @@ private final class LitheContextMenuPresenter: NSObject, NSWindowDelegate { private struct LitheContextMenuTrigger: NSViewRepresentable { @Environment(\.locale) private var locale let items: () -> [LitheContextMenuItem] + let onRightClick: () -> Void func makeNSView(context: Context) -> LitheRightClickCaptureView { let view = LitheRightClickCaptureView() @@ -269,6 +457,7 @@ private struct LitheContextMenuTrigger: NSViewRepresentable { private func update(_ view: LitheRightClickCaptureView) { view.onRightClick = { screenPoint, appearance in + onRightClick() LitheContextMenuPresenter.shared.show( items: items(), at: screenPoint, @@ -295,9 +484,12 @@ private final class LitheRightClickCaptureView: NSView { } extension View { - func litheContextMenu(items: @escaping () -> [LitheContextMenuItem]) -> some View { + func litheContextMenu( + items: @escaping () -> [LitheContextMenuItem], + onRightClick: @escaping () -> Void = {} + ) -> some View { overlay { - LitheContextMenuTrigger(items: items) + LitheContextMenuTrigger(items: items, onRightClick: onRightClick) } } } diff --git a/macos/Sources/Lithe/Views/Components/LitheScrollViewChrome.swift b/macos/Sources/Lithe/Views/Components/LitheScrollViewChrome.swift index 229fadc5..2d1a15a4 100644 --- a/macos/Sources/Lithe/Views/Components/LitheScrollViewChrome.swift +++ b/macos/Sources/Lithe/Views/Components/LitheScrollViewChrome.swift @@ -97,6 +97,7 @@ struct LitheScrollViewChrome: NSViewRepresentable { nsView.alwaysShowVertical = alwaysShowVertical nsView.usesCompactScrollers = usesCompactScrollers nsView.configureEnclosingScrollView() + nsView.needsDisplay = true } final class ScrollViewProbe: NSView { @@ -144,6 +145,23 @@ struct LitheScrollViewChrome: NSViewRepresentable { if !scrollView.hasVerticalScroller { scrollView.hasVerticalScroller = true } + // 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 + } + if scrollView.autohidesScrollers != !alwaysShowVertical { + scrollView.autohidesScrollers = !alwaysShowVertical + } + let isDark = scrollView.effectiveAppearance.bestMatch(from: [.aqua, .darkAqua]) == .darkAqua + let knobStyle: NSScroller.KnobStyle = isDark ? .light : .dark + if scrollView.verticalScroller?.knobStyle != knobStyle { + scrollView.verticalScroller?.knobStyle = knobStyle + } + if scrollView.horizontalScroller?.knobStyle != knobStyle { + scrollView.horizontalScroller?.knobStyle = knobStyle + } if usesCompactScrollers { if !(scrollView.verticalScroller is CompactScroller) { scrollView.verticalScroller = CompactScroller() @@ -163,22 +181,6 @@ struct LitheScrollViewChrome: NSViewRepresentable { 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 - } - if scrollView.autohidesScrollers != !alwaysShowVertical { - scrollView.autohidesScrollers = !alwaysShowVertical - } - 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 @@ -275,11 +277,11 @@ struct LitheScrollViewChrome: NSViewRepresentable { 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() + let thumbColor = isDark && LitheTheme.activeTheme == .lithe + ? NSColor(srgbRed: 67.0 / 255.0, green: 67.0 / 255.0, blue: 67.0 / 255.0, alpha: 1) + : secondaryText.withAlphaComponent(isDark ? 0.62 : 0.36) + thumbColor.setFill() NSBezierPath( roundedRect: knobRect, xRadius: min(2.5, knobRect.width / 2), diff --git a/macos/Sources/Lithe/Views/Components/LitheToolWindowHeader.swift b/macos/Sources/Lithe/Views/Components/LitheToolWindowHeader.swift index ed94fcbd..956b30a6 100644 --- a/macos/Sources/Lithe/Views/Components/LitheToolWindowHeader.swift +++ b/macos/Sources/Lithe/Views/Components/LitheToolWindowHeader.swift @@ -1,8 +1,8 @@ import SwiftUI /// Shared title-bar chrome for bottom tool windows. Individual windows provide -/// their own controls while the title, spacing, border and minimize affordance -/// remain visually consistent. +/// their own controls while the title, spacing, and minimize affordance remain +/// visually consistent. struct LitheToolWindowHeader: View { let title: String let systemImage: String? @@ -64,9 +64,6 @@ struct LitheToolWindowHeader: View { .padding(.trailing, 7) .frame(height: LitheTheme.Metrics.toolWindowHeaderHeight) .litheWorkbenchSurface(LitheTheme.toolHeader) - .overlay(alignment: .bottom) { - Rectangle().fill(LitheTheme.divider).frame(height: 1) - } } } diff --git a/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift b/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift index 197534cc..b7f883c3 100644 --- a/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift +++ b/macos/Sources/Lithe/Views/Editor/CodeEditorView.swift @@ -86,6 +86,7 @@ enum EditorLayoutMetrics { static let leadingInset: CGFloat = 4 static let lineFragmentPadding: CGFloat = 4 static let caretWidth: CGFloat = 2 + static let currentLineHorizontalInset: CGFloat = 1 static func showsBlameMetadata( line: Int, @@ -327,17 +328,17 @@ struct CodeEditorView: NSViewRepresentable { func makeNSView(context: Context) -> EditorContainerView { let palette = CodeEditorPalette(isDark: colorScheme == .dark, theme: settings.colorTheme) - let showsWorkbenchBackground = model.workbenchBackgroundFeature.hasImage let container = EditorContainerView() - container.displaysTransparentBackground = showsWorkbenchBackground + container.displaysTransparentBackground = true let scrollView = NSScrollView(frame: .zero) scrollView.translatesAutoresizingMaskIntoConstraints = false scrollView.borderType = .noBorder scrollView.hasVerticalScroller = true scrollView.autohidesScrollers = true - scrollView.drawsBackground = !showsWorkbenchBackground - scrollView.backgroundColor = palette.background - scrollView.contentView.drawsBackground = !showsWorkbenchBackground + scrollView.drawsBackground = false + scrollView.backgroundColor = .clear + scrollView.contentView.drawsBackground = false + scrollView.contentView.backgroundColor = .clear scrollView.wantsLayer = true scrollView.layer?.masksToBounds = true @@ -373,8 +374,15 @@ struct CodeEditorView: NSViewRepresentable { textView.font = LitheTheme.editorFont(size: settings.editorFontSize) textView.defaultParagraphStyle = LitheTheme.editorParagraphStyle textView.indentationWidth = settings.tabWidth - textView.applyAppearance(palette, isTransparent: showsWorkbenchBackground) - textView.drawsBackground = !showsWorkbenchBackground + textView.applyAppearance(palette, isTransparent: true) + textView.drawsBackground = false + let viewportState = viewportStore.state(for: document.id) + let textLength = (textView.string as NSString).length + let selectionLocation = min(viewportState.selectionLocation, textLength) + let selectionLength = min(viewportState.selectionLength, textLength - selectionLocation) + textView.setSelectedRange( + NSRange(location: selectionLocation, length: selectionLength) + ) textView.isEditable = !document.isReadOnly textView.isSelectable = true textView.onWindowAttached = { [weak coordinator = context.coordinator] in @@ -448,9 +456,12 @@ struct CodeEditorView: NSViewRepresentable { coordinator?.updateStandardGutterWidth(width) } gutter.attach(textView: textView, scrollView: scrollView) - gutter.applyAppearance(palette, isTransparent: showsWorkbenchBackground) + gutter.applyAppearance(palette, isTransparent: true) context.coordinator.attachMarkdownScrollSync(to: scrollView) context.coordinator.attachViewportTracking(to: scrollView) + if let initialImportFold = JavaInitialImportFold.region(in: document.text as NSString) { + context.coordinator.primeJavaImportFold(initialImportFold) + } textView.onCaretPresentationChanged = { [weak gutter] in gutter?.needsDisplay = true @@ -475,7 +486,6 @@ struct CodeEditorView: NSViewRepresentable { func updateNSView(_ container: EditorContainerView, context: Context) { guard let textView = container.scrollView?.documentView as? NSTextView else { return } let palette = CodeEditorPalette(isDark: colorScheme == .dark, theme: settings.colorTheme) - let showsWorkbenchBackground = model.workbenchBackgroundFeature.hasImage let appearanceChanged = context.coordinator.isDarkAppearance != palette.isDark || context.coordinator.colorTheme != settings.colorTheme context.coordinator.document = document @@ -483,11 +493,12 @@ struct CodeEditorView: NSViewRepresentable { context.coordinator.debugService = debugService context.coordinator.shouldFocus = shouldFocus context.coordinator.markdownScrollPosition = markdownScrollPosition - container.displaysTransparentBackground = showsWorkbenchBackground + container.displaysTransparentBackground = true if let scrollView = container.scrollView { - scrollView.drawsBackground = !showsWorkbenchBackground - scrollView.backgroundColor = palette.background - scrollView.contentView.drawsBackground = !showsWorkbenchBackground + scrollView.drawsBackground = false + scrollView.backgroundColor = .clear + scrollView.contentView.drawsBackground = false + scrollView.contentView.backgroundColor = .clear context.coordinator.attachMarkdownScrollSync(to: scrollView) context.coordinator.attachMarkdownImagePasteMonitor(to: scrollView) context.coordinator.attachViewportTracking(to: scrollView) @@ -504,7 +515,7 @@ struct CodeEditorView: NSViewRepresentable { tabWidth: tabWidth, languageFeatures: languageFeatures, isReadOnly: document.isReadOnly, - isTransparent: showsWorkbenchBackground, + isTransparent: true, palette: palette, textView: textView, gutter: container.gutter @@ -1056,6 +1067,13 @@ struct CodeEditorView: NSViewRepresentable { highlightedRanges.removeAll() } + func primeJavaImportFold(_ region: JavaFoldRegion) { + guard fileExtension.lowercased() == "java" else { return } + foldRegions = [region] + collapsedFoldIDs = [region.id] + applyFoldState() + } + func scheduleFoldRefresh(useDefaultImportFold: Bool = false) { scheduleJavaNavigationMarkerRefresh() foldRefreshTask?.cancel() @@ -2261,7 +2279,7 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { let currentLineRect = NSRect( x: 0, y: textContainerOrigin.y + lineRect.minY, - width: bounds.width, + width: max(0, bounds.width - EditorLayoutMetrics.currentLineHorizontalInset), height: lineRect.height ) guard currentLineRect.intersects(rect) else { return } @@ -2463,18 +2481,25 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { override func flagsChanged(with event: NSEvent) { super.flagsChanged(with: event) + guard let window, + isEditorHitTarget(at: convert(window.mouseLocationOutsideOfEventStream, from: nil)) else { + NSCursor.arrow.set() + return + } guard isLanguageNavigationEnabled, hasNavigationModifier(event.modifierFlags) else { clearLinkHighlight() return } - if let window { - updateLinkHighlight(at: convert(window.mouseLocationOutsideOfEventStream, from: nil)) - } + updateLinkHighlight(at: convert(window.mouseLocationOutsideOfEventStream, from: nil)) } override func mouseEntered(with event: NSEvent) { super.mouseEntered(with: event) let point = convert(event.locationInWindow, from: nil) + guard isEditorHitTarget(at: point) else { + NSCursor.arrow.set() + return + } let summaryRegion = foldSummaryRegion(at: point) updateFoldHover(to: summaryRegion?.id) if summaryRegion != nil { @@ -2484,6 +2509,10 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { override func mouseMoved(with event: NSEvent) { let point = convert(event.locationInWindow, from: nil) + guard isEditorHitTarget(at: point) else { + NSCursor.arrow.set() + return + } let summaryRegion = foldSummaryRegion(at: point) updateFoldHover(to: summaryRegion?.id) if summaryRegion != nil { @@ -2504,6 +2533,10 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { override func cursorUpdate(with event: NSEvent) { let point = convert(event.locationInWindow, from: nil) + guard isEditorHitTarget(at: point) else { + NSCursor.arrow.set() + return + } if foldSummaryRegion(at: point) != nil { NSCursor.pointingHand.set() return @@ -2515,6 +2548,14 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { NSCursor.iBeam.set() } + private func isEditorHitTarget(at point: NSPoint) -> Bool { + guard let contentView = window?.contentView, + let hitView = contentView.hitTest(convert(point, to: contentView)) else { + return true + } + return hitView === self || hitView.isDescendant(of: self) + } + override func mouseExited(with event: NSEvent) { super.mouseExited(with: event) updateFoldHover(to: nil) @@ -3044,6 +3085,38 @@ final class CodeTextView: NSTextView, NSLayoutManagerDelegate { } } +private enum JavaInitialImportFold { + private static let pattern = #"(?m)^[ \t]*import[ \t]+[^;]+;[ \t]*$"# + + static func region(in source: NSString) -> JavaFoldRegion? { + guard let expression = try? NSRegularExpression(pattern: pattern) else { return nil } + let matches = expression.matches( + in: source as String, + range: NSRange(location: 0, length: source.length) + ) + guard matches.count >= 2, + let first = matches.first, + let last = matches.last else { return nil } + + let firstLine = source.lineRange(for: NSRange(location: first.range.location, length: 0)) + let lastLine = source.lineRange(for: NSRange(location: last.range.location, length: 0)) + let hiddenStart = NSMaxRange(firstLine) + let hiddenEnd = NSMaxRange(lastLine) + return JavaFoldRegion( + kind: .imports, + startLine: lineNumber(in: source, at: first.range.location), + endLine: lineNumber(in: source, at: last.range.location), + hiddenRange: NSRange(location: hiddenStart, length: hiddenEnd - hiddenStart) + ) + } + + private static func lineNumber(in source: NSString, at location: Int) -> Int { + source.substring(to: min(location, source.length)).reduce(into: 0) { count, character in + if character == "\n" { count += 1 } + } + } +} + @MainActor final class EditorContainerView: NSView { weak var scrollView: NSScrollView? @@ -3131,7 +3204,7 @@ final class LineNumberGutterView: NSView { self.textView = textView self.scrollView = scrollView wantsLayer = true - layer?.backgroundColor = palette.gutterBackground.cgColor + layer?.backgroundColor = NSColor.clear.cgColor refreshLineNumberLayout() scrollView.contentView.postsBoundsChangedNotifications = true boundsObserver = NotificationCenter.default.addObserver( @@ -3454,7 +3527,12 @@ final class LineNumberGutterView: NSView { } if lineNumber - 1 == currentLine { palette.currentLine.setFill() - NSRect(x: 0, y: y, width: bounds.width, height: lineRect.height).fill() + NSRect( + x: EditorLayoutMetrics.currentLineHorizontalInset, + y: y, + width: max(0, bounds.width - EditorLayoutMetrics.currentLineHorizontalInset), + height: lineRect.height + ).fill() } if isBlameVisible, let blame = blameByLine[lineNumber - 1], diff --git a/macos/Sources/Lithe/Views/Editor/EditorAreaView.swift b/macos/Sources/Lithe/Views/Editor/EditorAreaView.swift index 67fdcbcd..e3f0400c 100644 --- a/macos/Sources/Lithe/Views/Editor/EditorAreaView.swift +++ b/macos/Sources/Lithe/Views/Editor/EditorAreaView.swift @@ -944,14 +944,12 @@ struct EditorAreaView: View { private var editorWorkspace: some View { VStack(spacing: 0) { editorTabs - Rectangle().fill(LitheTheme.divider).frame(height: 1) if model.activeEditorTerminalSession == nil, let splitDocumentID, let splitDocument = model.openDocuments.first(where: { $0.id == splitDocumentID }) { HStack(spacing: 0) { editorPane(model.activeDocument) - Rectangle().fill(LitheTheme.divider).frame(width: 1) editorPane(splitDocument, showsHeader: true) } } else { @@ -987,7 +985,6 @@ struct EditorAreaView: View { .padding(.horizontal, 10) .frame(height: 30) .background(LitheTheme.toolHeader) - Rectangle().fill(LitheTheme.divider).frame(height: 1) } if let document { @@ -1123,9 +1120,6 @@ struct EditorAreaView: View { HStack(spacing: 0) { editorWithFindBar(document, markdownScrollPosition: scrollPosition) .frame(maxWidth: .infinity, maxHeight: .infinity) - Rectangle() - .fill(LitheTheme.divider) - .frame(width: 1) MarkdownPreviewView( document: document, scrollPosition: scrollPosition diff --git a/macos/Sources/Lithe/Views/Git/ChangesSidebarView.swift b/macos/Sources/Lithe/Views/Git/ChangesSidebarView.swift index 9023ccd3..1b4bdfc3 100644 --- a/macos/Sources/Lithe/Views/Git/ChangesSidebarView.swift +++ b/macos/Sources/Lithe/Views/Git/ChangesSidebarView.swift @@ -798,7 +798,7 @@ struct ChangesSidebarView: View { } .controlSize(.small) } - .padding(10) + .padding([.top, .horizontal], 10) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .background(model.workbenchBackgroundFeature.hasImage ? Color.clear : LitheTheme.toolHeader) } diff --git a/macos/Sources/Lithe/Views/Workbench/SplitHandleView.swift b/macos/Sources/Lithe/Views/Workbench/SplitHandleView.swift index 1383e505..f0d598ce 100644 --- a/macos/Sources/Lithe/Views/Workbench/SplitHandleView.swift +++ b/macos/Sources/Lithe/Views/Workbench/SplitHandleView.swift @@ -21,10 +21,6 @@ struct SplitHandleView: View { @State private var isHovering = false @State private var isDragging = false - @State private var lastTranslation: CGFloat = 0 - @State private var dragUpdateBuffer = FrameCoalescedDragUpdateBuffer() - @State private var dragUpdateTask: Task? - @State private var cursor = SplitHandleCursor() init( axis: LitheSplitAxis, @@ -44,56 +40,37 @@ struct SplitHandleView: View { self.onDragEnded = onDragEnded } + @ViewBuilder var body: some View { + if axis == .horizontal { + handleSurface.frame(maxHeight: .infinity) + } else { + handleSurface.frame(maxWidth: .infinity) + } + } + + private var handleSurface: some View { ZStack { trackBackground - Color.clear dividerLine + SplitHandleInteractionView( + axis: axis, + onHoverChanged: { isHovering = $0 }, + onDragStateChanged: { isDragging = $0 }, + onDragStarted: onDragStarted, + onDragChanged: onDragChanged, + onDragEnded: onDragEnded + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) } .frame( width: axis == .horizontal ? Self.thickness : nil, height: axis == .vertical ? Self.thickness : nil ) .contentShape(Rectangle()) - .gesture( - // The handle moves with the resized pane, so local coordinates create a - // feedback loop where translation jumps as the coordinate origin moves. - DragGesture(minimumDistance: 0, coordinateSpace: .global) - .onChanged { value in - if !isDragging { - isDragging = true - lastTranslation = 0 - cursor.update(isResizing: true, cursor: resizeCursor) - onDragStarted() - } - let currentTranslation = axis == .horizontal ? value.translation.width : value.translation.height - // Pointer devices can deliver substantially more events than the - // display can present. Keep only the newest translation for the - // next frame instead of forcing every intermediate layout. - if abs(currentTranslation - lastTranslation) >= 1 { - lastTranslation = currentTranslation - scheduleDragUpdate(currentTranslation) - } - } - .onEnded { value in - let finalTranslation = axis == .horizontal - ? value.translation.width - : value.translation.height - cancelScheduledDragUpdate() - isDragging = false - lastTranslation = 0 - cursor.update(isResizing: isHovering, cursor: resizeCursor) - onDragEnded(finalTranslation) - } - ) - .onHover { isInside in - guard isInside != isHovering else { return } - isHovering = isInside - cursor.update(isResizing: isInside || isDragging, cursor: resizeCursor) - } .onDisappear { - cancelScheduledDragUpdate() - cursor.update(isResizing: false, cursor: resizeCursor) + isHovering = false + isDragging = false } .help(axis == .horizontal ? "Drag left or right to resize" : "Drag up or down to resize") .accessibilityLabel(axis == .horizontal ? "Horizontal pane resize handle" : "Vertical pane resize handle") @@ -121,7 +98,7 @@ struct SplitHandleView: View { @ViewBuilder private var dividerLine: some View { let isHighlighted = isHovering || isDragging - if showsIdleDivider || isHighlighted { + if showsIdleDivider { let color = isHighlighted ? LitheTheme.accent : LitheTheme.divider if axis == .horizontal { @@ -138,41 +115,183 @@ struct SplitHandleView: View { } } - private var resizeCursor: NSCursor { - axis == .horizontal ? .resizeLeftRight : .resizeUpDown +} + +private struct SplitHandleInteractionView: NSViewRepresentable { + let axis: LitheSplitAxis + let onHoverChanged: (Bool) -> Void + let onDragStateChanged: (Bool) -> Void + let onDragStarted: () -> Void + let onDragChanged: (CGFloat) -> Void + let onDragEnded: (CGFloat) -> Void + + func makeNSView(context: Context) -> SplitHandleInteractionNSView { + SplitHandleInteractionNSView( + axis: axis, + onHoverChanged: onHoverChanged, + onDragStateChanged: onDragStateChanged, + onDragStarted: onDragStarted, + onDragChanged: onDragChanged, + onDragEnded: onDragEnded + ) } - private func scheduleDragUpdate(_ translation: CGFloat) { - guard dragUpdateBuffer.submit(translation) else { return } - dragUpdateTask = Task { @MainActor in - try? await Task.sleep(for: .milliseconds(16)) - guard !Task.isCancelled else { return } - let translation = dragUpdateBuffer.takePendingValue() - dragUpdateTask = nil - if let translation { - onDragChanged(translation) - } - } + func updateNSView(_ nsView: SplitHandleInteractionNSView, context: Context) { + nsView.update( + axis: axis, + onHoverChanged: onHoverChanged, + onDragStateChanged: onDragStateChanged, + onDragStarted: onDragStarted, + onDragChanged: onDragChanged, + onDragEnded: onDragEnded + ) } - private func cancelScheduledDragUpdate() { - dragUpdateTask?.cancel() - dragUpdateTask = nil - dragUpdateBuffer.cancel() + static func dismantleNSView(_ nsView: SplitHandleInteractionNSView, coordinator: ()) { + nsView.cancelInteraction() } } -private final class SplitHandleCursor { - private var isResizing = false +private final class SplitHandleInteractionNSView: NSView { + private var axis: LitheSplitAxis + private var onHoverChanged: (Bool) -> Void + private var onDragStateChanged: (Bool) -> Void + private var onDragStarted: () -> Void + private var onDragChanged: (CGFloat) -> Void + private var onDragEnded: (CGFloat) -> Void + private var trackingArea: NSTrackingArea? + private var dragStartInWindow: NSPoint? + private var isDragging = false + private var isInside = false - @MainActor - func update(isResizing newValue: Bool, cursor: NSCursor) { - guard newValue != isResizing else { return } - isResizing = newValue - if newValue { - cursor.push() - } else { - NSCursor.pop() + init( + axis: LitheSplitAxis, + onHoverChanged: @escaping (Bool) -> Void, + onDragStateChanged: @escaping (Bool) -> Void, + onDragStarted: @escaping () -> Void, + onDragChanged: @escaping (CGFloat) -> Void, + onDragEnded: @escaping (CGFloat) -> Void + ) { + self.axis = axis + self.onHoverChanged = onHoverChanged + self.onDragStateChanged = onDragStateChanged + self.onDragStarted = onDragStarted + self.onDragChanged = onDragChanged + self.onDragEnded = onDragEnded + super.init(frame: .zero) + wantsLayer = true + layer?.backgroundColor = NSColor.clear.cgColor + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func updateTrackingAreas() { + if let trackingArea { + removeTrackingArea(trackingArea) + } + let area = NSTrackingArea( + rect: bounds, + options: [.mouseEnteredAndExited, .cursorUpdate, .activeInKeyWindow, .inVisibleRect], + owner: self, + userInfo: nil + ) + addTrackingArea(area) + trackingArea = area + super.updateTrackingAreas() + } + + override func resetCursorRects() { + super.resetCursorRects() + addCursorRect(bounds, cursor: resizeCursor) + } + + override func cursorUpdate(with event: NSEvent) { + resizeCursor.set() + } + + override func mouseEntered(with event: NSEvent) { + isInside = true + onHoverChanged(true) + resizeCursor.set() + } + + override func mouseExited(with event: NSEvent) { + isInside = false + onHoverChanged(false) + if !isDragging { + NSCursor.arrow.set() + } + } + + override func acceptsFirstMouse(for event: NSEvent?) -> Bool { + true + } + + override func mouseDown(with event: NSEvent) { + isDragging = true + dragStartInWindow = event.locationInWindow + onDragStateChanged(true) + resizeCursor.set() + onDragStarted() + } + + override func mouseDragged(with event: NSEvent) { + guard let dragStartInWindow else { return } + onDragChanged(translation(from: dragStartInWindow, to: event.locationInWindow)) + } + + override func mouseUp(with event: NSEvent) { + guard let dragStartInWindow else { return } + onDragEnded(translation(from: dragStartInWindow, to: event.locationInWindow)) + self.dragStartInWindow = nil + isDragging = false + onDragStateChanged(false) + (isInside ? resizeCursor : NSCursor.arrow).set() + } + + func update( + axis: LitheSplitAxis, + onHoverChanged: @escaping (Bool) -> Void, + onDragStateChanged: @escaping (Bool) -> Void, + onDragStarted: @escaping () -> Void, + onDragChanged: @escaping (CGFloat) -> Void, + onDragEnded: @escaping (CGFloat) -> Void + ) { + self.axis = axis + self.onHoverChanged = onHoverChanged + self.onDragStateChanged = onDragStateChanged + self.onDragStarted = onDragStarted + self.onDragChanged = onDragChanged + self.onDragEnded = onDragEnded + window?.invalidateCursorRects(for: self) + } + + func cancelInteraction() { + dragStartInWindow = nil + if isDragging { + isDragging = false + onDragStateChanged(false) + } + if isInside { + isInside = false + onHoverChanged(false) + } + NSCursor.arrow.set() + } + + private var resizeCursor: NSCursor { + axis == .horizontal ? .resizeLeftRight : .resizeUpDown + } + + private func translation(from start: NSPoint, to current: NSPoint) -> CGFloat { + axis == .horizontal ? current.x - start.x : start.y - current.y + } + + deinit { + if isDragging || isInside { + NSCursor.arrow.set() } } } diff --git a/macos/Sources/Lithe/Views/Workbench/WorkbenchModuleUIComposition.swift b/macos/Sources/Lithe/Views/Workbench/WorkbenchModuleUIComposition.swift index e5ae9e40..6b831274 100644 --- a/macos/Sources/Lithe/Views/Workbench/WorkbenchModuleUIComposition.swift +++ b/macos/Sources/Lithe/Views/Workbench/WorkbenchModuleUIComposition.swift @@ -43,7 +43,6 @@ enum WorkbenchModuleUIComposition { contributions: GitModule.moduleContributions, actions: [ .init(id: "git.log.toggle", perform: { model in - if !model.isGitLogVisible { model.selectedSidebar = .changes } Task { await model.toggleGitLog() } }) ], diff --git a/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift b/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift index b0ad619b..ffbe6530 100644 --- a/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift +++ b/macos/Sources/Lithe/Views/Workbench/WorkbenchView.swift @@ -4,8 +4,7 @@ import LitheGitModule enum WorkbenchLayoutMetrics { static let rightActivityBarWidth: CGFloat = 40 - static let rightActivityBarDividerWidth: CGFloat = 1 - static let workspaceTrailingInset = rightActivityBarWidth + rightActivityBarDividerWidth + static let workspaceTrailingInset = rightActivityBarWidth } private enum ActivityBarMetrics { @@ -19,9 +18,11 @@ private enum ActivityBarMetrics { } private enum WorkbenchWorkspaceMetrics { - static let paneInset: CGFloat = 6 + static let paneInset: CGFloat = 0 static let paneSpacing: CGFloat = 6 static let paneCornerRadius: CGFloat = 10 + static let minimumTopPaneHeight: CGFloat = 220 + static let changesMinimumTopPaneHeight: CGFloat = 332 } struct WorkbenchView: View { @@ -52,18 +53,13 @@ struct WorkbenchView: View { var body: some View { VStack(spacing: 0) { topBar - Rectangle().fill(LitheTheme.divider).frame(height: 1) if projectSessions.openProjects.count > 1 { projectTabBar - Rectangle().fill(LitheTheme.divider).frame(height: 1) } HStack(spacing: 0) { activityBar - Rectangle() - .fill(LitheTheme.divider) - .frame(width: 1) workspaceArea .padding(.trailing, WorkbenchLayoutMetrics.workspaceTrailingInset) } @@ -72,7 +68,6 @@ struct WorkbenchView: View { rightHoverRegion } - Rectangle().fill(LitheTheme.divider).frame(height: 1) statusBar } .background { @@ -236,17 +231,53 @@ struct WorkbenchView: View { } message: { Text("This sends the current branch to its configured remote.") } - .overlay(alignment: .bottom) { - if let message = model.notificationMessage { - Text(LocalizedStringKey(message)) - .font(.system(size: 12, weight: .medium)) - .foregroundStyle(LitheTheme.primaryText) - .padding(.horizontal, 14) - .frame(height: 34) - .background(LitheTheme.raised) - .clipShape(RoundedRectangle(cornerRadius: 7)) - .shadow(color: .black.opacity(0.35), radius: 12, y: 4) - .padding(.bottom, 38) + .overlay(alignment: .bottomTrailing) { + if !model.activeNotifications.isEmpty { + VStack(alignment: .trailing, spacing: 8) { + ForEach(model.activeNotifications) { notification in + HStack(alignment: .center, spacing: 10) { + Image(systemName: "info.circle.fill") + .font(.system(size: 14)) + .foregroundStyle(LitheTheme.accent) + + Text(LocalizedStringKey(notification.message)) + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(LitheTheme.primaryText) + .fixedSize(horizontal: false, vertical: true) + + Spacer(minLength: 4) + + Button { + model.dismissNotification(notification.id) + } label: { + Image(systemName: "xmark") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(LitheTheme.tertiaryText) + } + .buttonStyle(.plain) + .frame(width: 16, height: 16) + .contentShape(Rectangle()) + .litheRowHover(cornerRadius: LitheTheme.Metrics.cornerRadius, animation: nil) + .accessibilityLabel("Dismiss notification") + } + .padding(.leading, 12) + .padding(.trailing, 6) + .padding(.vertical, 10) + .frame(minWidth: 280, maxWidth: 360, alignment: .topLeading) + .background(LitheTheme.notificationBackground) + .clipShape(RoundedRectangle(cornerRadius: 7)) + .contentShape(RoundedRectangle(cornerRadius: 7)) + .onContinuousHover(coordinateSpace: .local) { phase in + if case .active = phase { + NSCursor.arrow.set() + } + } + } + } + .contentShape(Rectangle()) + .onHover { model.setNotificationStackHovered($0) } + .padding(.trailing, WorkbenchLayoutMetrics.rightActivityBarWidth + 12) + .padding(.bottom, 38) } } .overlay { @@ -435,11 +466,6 @@ struct WorkbenchView: View { .environmentObject(model) } - Rectangle() - .fill(LitheTheme.divider) - .frame(width: 1, height: 20) - .padding(.horizontal, 5) - Button { isBranchSwitcherPresented.toggle() if isBranchSwitcherPresented { @@ -773,9 +799,6 @@ struct WorkbenchView: View { } } } - Rectangle() - .fill(LitheTheme.divider) - .frame(width: 1) pluginActivityBar } .fixedSize(horizontal: true, vertical: false) @@ -881,6 +904,12 @@ struct WorkbenchView: View { WorkbenchWorkspaceSplitView( sidebarWidth: sidebarWidth, topPaneHeight: topPaneHeight, + sidebarPaneBackground: model.selectedSidebar == .changes + ? LitheTheme.toolHeader + : LitheTheme.editor, + minimumTopPaneHeight: model.selectedSidebar == .changes + ? WorkbenchWorkspaceMetrics.changesMinimumTopPaneHeight + : WorkbenchWorkspaceMetrics.minimumTopPaneHeight, isBottomToolVisible: isBottomToolVisible, onSidebarWidthCommitted: { width in sidebarWidth = width @@ -1160,10 +1189,6 @@ private struct WorkbenchNotificationCenterView: View { .padding(.horizontal, 14) .frame(height: 38) - Rectangle() - .fill(LitheTheme.divider) - .frame(height: 1) - if model.notifications.isEmpty { VStack(spacing: 8) { Image(systemName: "bell") @@ -1223,6 +1248,8 @@ private struct WorkbenchNotificationCenterView: View { private struct WorkbenchWorkspaceSplitView: View { let sidebarWidth: CGFloat let topPaneHeight: CGFloat? + let sidebarPaneBackground: Color + let minimumTopPaneHeight: CGFloat let isBottomToolVisible: Bool let onSidebarWidthCommitted: (CGFloat) -> Void let onTopPaneHeightCommitted: (CGFloat) -> Void @@ -1241,6 +1268,8 @@ private struct WorkbenchWorkspaceSplitView Void, onTopPaneHeightCommitted: @escaping (CGFloat) -> Void, @@ -1253,6 +1282,8 @@ private struct WorkbenchWorkspaceSplitView = [] @State private var expandedTreeRootPath: String? + @State private var contextMenuPath: String? var body: some View { VStack(spacing: 0) { sidebarHeader - Rectangle().fill(LitheTheme.divider).frame(height: 1) if model.isLoadingWorkspace { VStack(spacing: 10) { @@ -41,7 +41,8 @@ struct ProjectSidebarView: View { ), actions: ProjectTreeActions(model: model), expandedDirectoryPathsSnapshot: expandedDirectoryPaths, - expandedDirectoryPaths: $expandedDirectoryPaths + expandedDirectoryPaths: $expandedDirectoryPaths, + contextMenuPath: $contextMenuPath ) .equatable() } @@ -53,6 +54,7 @@ struct ProjectSidebarView: View { ) } .scrollContentBackground(.hidden) + .litheScrollViewChrome(usesCompactScrollers: true) .task( id: ProjectTreeTaskID( rootPath: root.url.standardizedFileURL.path, @@ -87,28 +89,6 @@ struct ProjectSidebarView: View { model.consumeProjectTreeRevealRequest(id: request.id) } } - .contextMenu { - Button("New File…") { - model.requestCreateFile(in: root.url) - } - Button("New Directory…") { - model.requestCreateDirectory(in: root.url) - } - Divider() - Button("Show Project in Finder") { - model.revealProjectItemInFinder(root.url) - } - Button("Show Project Local History…") { - model.showProjectLocalHistory() - } - Button("Copy Project Path") { - model.copyProjectItemPath(root.url, relative: false) - } - Divider() - Button("Refresh") { - Task { await model.refreshWorkspace() } - } - } } } } else if let error = model.workspaceLoadErrorMessage { @@ -271,6 +251,9 @@ private final class ProjectTreeActions: @unchecked Sendable { nonisolated func showLocalHistory(_ url: URL) { Task { @MainActor in self.model.showLocalHistory(for: url) } } + nonisolated func showProjectLocalHistory() { + Task { @MainActor in self.model.showProjectLocalHistory() } + } func javaIconKind(_ url: URL) async -> LitheIconKind? { await model.javaIconKind(for: url) } @@ -285,6 +268,7 @@ private struct ProjectFileTreeContent: View, Equatable { let actions: ProjectTreeActions let expandedDirectoryPathsSnapshot: Set @Binding var expandedDirectoryPaths: Set + @Binding var contextMenuPath: String? static func == (lhs: ProjectFileTreeContent, rhs: ProjectFileTreeContent) -> Bool { lhs.root == rhs.root @@ -293,6 +277,7 @@ private struct ProjectFileTreeContent: View, Equatable { && lhs.activeDocumentURL == rhs.activeDocumentURL && lhs.gitStatus == rhs.gitStatus && lhs.expandedDirectoryPathsSnapshot == rhs.expandedDirectoryPathsSnapshot + && lhs.contextMenuPath == rhs.contextMenuPath } var body: some View { @@ -304,7 +289,8 @@ private struct ProjectFileTreeContent: View, Equatable { activeDocumentURL: activeDocumentURL, gitStatus: gitStatus, actions: actions, - expandedDirectoryPaths: $expandedDirectoryPaths + expandedDirectoryPaths: $expandedDirectoryPaths, + contextMenuPath: $contextMenuPath ) .id(root.url.standardizedFileURL.path) } @@ -319,6 +305,7 @@ private struct FileNodeRow: View { let gitStatus: ProjectGitStatusSnapshot let actions: ProjectTreeActions @Binding var expandedDirectoryPaths: Set + @Binding var contextMenuPath: String? @State private var resolvedJavaIconKind: LitheIconKind? private var rowWidth: CGFloat { @@ -349,7 +336,8 @@ private struct FileNodeRow: View { activeDocumentURL: activeDocumentURL, gitStatus: gitStatus, actions: actions, - expandedDirectoryPaths: $expandedDirectoryPaths + expandedDirectoryPaths: $expandedDirectoryPaths, + contextMenuPath: $contextMenuPath ) .id(child.url.standardizedFileURL.path) } @@ -362,6 +350,7 @@ private struct FileNodeRow: View { private var directoryRow: some View { Button { + contextMenuPath = nil if isExpanded { expandedDirectoryPaths.remove(node.url.path) node.collapsedAncestorPaths.forEach { expandedDirectoryPaths.remove($0) } @@ -392,18 +381,24 @@ private struct FileNodeRow: View { .frame(height: rowHeight) .contentShape(Rectangle()) .litheRowHover( + isActive: contextMenuPath == node.url.standardizedFileURL.path, cornerRadius: LitheTheme.Metrics.projectTreeSelectionCornerRadius, + activeBackground: LitheTheme.subtleSelection, animation: nil ) } .buttonStyle(LitheTreeRowButtonStyle()) .lithePointer() .padding(.horizontal, LitheTheme.Metrics.projectTreeContentHorizontalInset) - .contextMenu { directoryContextMenu } + .litheContextMenu( + items: { directoryContextMenuItems }, + onRightClick: { contextMenuPath = node.url.standardizedFileURL.path } + ) } private var fileRow: some View { Button { + contextMenuPath = nil actions.openFile(node.url) } label: { HStack(spacing: 6) { @@ -431,7 +426,8 @@ private struct FileNodeRow: View { .contentShape(Rectangle()) .litheRowHover( isActive: activeDocumentURL?.standardizedFileURL.path - == node.url.standardizedFileURL.path, + == node.url.standardizedFileURL.path + || contextMenuPath == node.url.standardizedFileURL.path, cornerRadius: LitheTheme.Metrics.projectTreeSelectionCornerRadius, activeBackground: LitheTheme.subtleSelection, animation: nil @@ -440,106 +436,129 @@ private struct FileNodeRow: View { .buttonStyle(LitheTreeRowButtonStyle()) .lithePointer() .padding(.horizontal, LitheTheme.Metrics.projectTreeContentHorizontalInset) - .contextMenu { fileContextMenu } + .litheContextMenu( + items: { fileContextMenuItems }, + onRightClick: { contextMenuPath = node.url.standardizedFileURL.path } + ) .task(id: node.url.standardizedFileURL.path) { guard node.url.pathExtension.lowercased() == "java" else { return } resolvedJavaIconKind = await actions.javaIconKind(node.url) } } - @ViewBuilder - private var directoryContextMenu: some View { - if gitStatus.kind(for: node.url, isDirectory: true) != nil { - Button("Show Git Diff") { - actions.showGitDirectoryDiff(node.url) - } - Divider() - } + private var directoryContextMenuItems: [LitheContextMenuItem] { + var items: [LitheContextMenuItem] = [] - Button("New File…") { - actions.requestCreateFile(node.url) - } - Button("New Directory…") { - actions.requestCreateDirectory(node.url) + if gitStatus.kind(for: node.url, isDirectory: true) != nil { + items += [ + .action("Show Git Diff", systemImage: "arrow.triangle.branch") { + actions.showGitDirectoryDiff(node.url) + }, + .separator + ] } - Divider() - - Button("Show in Finder") { - actions.revealInFinder(node.url) - } - Button("Copy Path") { - actions.copyPath(node.url, relative: false) - } - Button("Copy Relative Path") { - actions.copyPath(node.url, relative: true) + items += [ + .submenu("New", items: [ + .action("New File…") { + actions.requestCreateFile(node.url) + }, + .action("New Directory…") { + actions.requestCreateDirectory(node.url) + } + ]), + .separator + ] + + if depth == 0 { + items += [ + .action("Show Project in Finder", systemImage: "folder") { + actions.revealInFinder(node.url) + }, + .action("Show Project Local History…", systemImage: "clock.arrow.circlepath") { + actions.showProjectLocalHistory() + }, + .action("Copy Project Path", systemImage: "doc.on.doc") { + actions.copyPath(node.url, relative: false) + }, + .action("Copy Relative Path") { + actions.copyPath(node.url, relative: true) + } + ] + } else { + items += [ + .action("Show in Finder", systemImage: "folder") { + actions.revealInFinder(node.url) + }, + .action("Copy Path", systemImage: "doc.on.doc") { + actions.copyPath(node.url, relative: false) + }, + .action("Copy Relative Path") { + actions.copyPath(node.url, relative: true) + }, + .separator, + .action("Duplicate") { + actions.duplicate(node.url) + }, + .action("Rename…") { + actions.requestRename(node.url) + }, + .action("Move to Trash", systemImage: "trash", role: .destructive) { + actions.requestDelete(node.url, true) + } + ] } - if depth > 0 { - Divider() - - Button("Duplicate") { - actions.duplicate(node.url) - } - Button("Rename…") { - actions.requestRename(node.url) - } - Button("Move to Trash", role: .destructive) { - actions.requestDelete(node.url, true) + items += [ + .separator, + .action("Refresh", systemImage: "arrow.clockwise") { + actions.refreshWorkspace() } - } - - Divider() - - Button("Refresh") { - actions.refreshWorkspace() - } + ] + return items } - @ViewBuilder - private var fileContextMenu: some View { - Group { - Button("Open") { + private var fileContextMenuItems: [LitheContextMenuItem] { + var items: [LitheContextMenuItem] = [ + .action("Open") { actions.openFile(node.url) } + ] - if let change = gitStatus.change(for: node.url) { - Button("Show Git Diff") { + if let change = gitStatus.change(for: node.url) { + items += [ + .action("Show Git Diff", systemImage: "arrow.triangle.branch") { actions.selectChange(change) } - } + ] } - Divider() - - Group { - Button("Duplicate") { + items += [ + .separator, + .action("Duplicate") { actions.duplicate(node.url) - } - Button("Rename…") { + }, + .action("Rename…") { actions.requestRename(node.url) - } - Button("Local History…") { + }, + .action("Local History…", systemImage: "clock.arrow.circlepath") { actions.showLocalHistory(node.url) - } - Button("Move to Trash", role: .destructive) { + }, + .action("Move to Trash", systemImage: "trash", role: .destructive) { actions.requestDelete(node.url, false) - } - } - - Divider() - - Group { - Button("Show in Finder") { + }, + .separator, + .action("Show in Finder", systemImage: "folder") { actions.revealInFinder(node.url) - } - Button("Copy Path") { + }, + .action("Copy Path", systemImage: "doc.on.doc") { actions.copyPath(node.url, relative: false) - } - Button("Copy Relative Path") { + }, + .action("Copy Relative Path") { actions.copyPath(node.url, relative: true) } - } + ] + return items } private var gitStatusColor: Color? { diff --git a/macos/Tests/LitheCoreVerifier/main.swift b/macos/Tests/LitheCoreVerifier/CoreVerification.swift similarity index 100% rename from macos/Tests/LitheCoreVerifier/main.swift rename to macos/Tests/LitheCoreVerifier/CoreVerification.swift diff --git a/macos/Tests/LitheGitGraphVerifier/main.swift b/macos/Tests/LitheGitGraphVerifier/GitGraphVerification.swift similarity index 100% rename from macos/Tests/LitheGitGraphVerifier/main.swift rename to macos/Tests/LitheGitGraphVerifier/GitGraphVerification.swift diff --git a/macos/Tests/LitheOfficialPluginVerifier/main.swift b/macos/Tests/LitheOfficialPluginVerifier/OfficialPluginVerifier.swift similarity index 100% rename from macos/Tests/LitheOfficialPluginVerifier/main.swift rename to macos/Tests/LitheOfficialPluginVerifier/OfficialPluginVerifier.swift diff --git a/macos/Tests/LitheTests/ProjectTreeLayoutMetricsTests.swift b/macos/Tests/LitheTests/ProjectTreeLayoutMetricsTests.swift index 426d5343..fc8ec1b5 100644 --- a/macos/Tests/LitheTests/ProjectTreeLayoutMetricsTests.swift +++ b/macos/Tests/LitheTests/ProjectTreeLayoutMetricsTests.swift @@ -4,7 +4,7 @@ import Testing struct ProjectTreeLayoutMetricsTests { @Test func treeRowsMatchIntelliJNewUILayoutMetrics() { - #expect(LitheTheme.Metrics.projectTreeRowSpacing == 0) + #expect(LitheTheme.Metrics.projectTreeRowSpacing == 1) #expect(LitheTheme.Metrics.projectTreeContentVerticalInset == 4) #expect(LitheTheme.Metrics.projectTreeContentHorizontalInset == 12) #expect(LitheTheme.Metrics.projectTreeSelectionCornerRadius == 4) diff --git a/macos/Tests/LitheTests/WorkbenchNotificationTests.swift b/macos/Tests/LitheTests/WorkbenchNotificationTests.swift index 4608b530..c50427b4 100644 --- a/macos/Tests/LitheTests/WorkbenchNotificationTests.swift +++ b/macos/Tests/LitheTests/WorkbenchNotificationTests.swift @@ -24,6 +24,15 @@ struct WorkbenchNotificationTests { #expect(model.notifications.first?.message == "Message 100") #expect(model.notifications.last?.message == "Message 1") #expect(model.notifications.allSatisfy { !$0.isRead }) + #expect(model.activeNotifications.map(\.message) == ["Message 98", "Message 99", "Message 100"]) + + model.setNotificationStackHovered(true) + model.showNotification("Message 101") + #expect(model.activeNotifications.map(\.message) == ["Message 99", "Message 100", "Message 101"]) + + let dismissedID = model.activeNotifications[1].id + model.dismissNotification(dismissedID) + #expect(model.activeNotifications.map(\.message) == ["Message 99", "Message 101"]) model.markAllNotificationsRead() #expect(model.notifications.allSatisfy { $0.isRead }) diff --git a/macos/Tests/LitheTests/WorkbenchRenderingSafetyTests.swift b/macos/Tests/LitheTests/WorkbenchRenderingSafetyTests.swift index 9039b7e5..65c7815a 100644 --- a/macos/Tests/LitheTests/WorkbenchRenderingSafetyTests.swift +++ b/macos/Tests/LitheTests/WorkbenchRenderingSafetyTests.swift @@ -5,12 +5,8 @@ import Testing @Suite("Workbench rendering safety") struct WorkbenchRenderingSafetyTests { @Test - func workspaceReservesTheRightActivityBarAndItsDivider() { - #expect( - WorkbenchLayoutMetrics.workspaceTrailingInset - == WorkbenchLayoutMetrics.rightActivityBarWidth - + WorkbenchLayoutMetrics.rightActivityBarDividerWidth - ) + func workspaceReservesTheRightActivityBar() { + #expect(WorkbenchLayoutMetrics.workspaceTrailingInset == WorkbenchLayoutMetrics.rightActivityBarWidth) } @Test