| | |
| | | import SwiftUI |
| | | import AppKit |
| | | import Carbon.HIToolbox |
| | | |
| | | fileprivate enum AppGridCollectionDisplayMode: Equatable { |
| | | case flat |
| | |
| | | |
| | | static let externalInteraction = AppGridBubbleSuppressionReasons(rawValue: 1 << 0) |
| | | static let scroll = AppGridBubbleSuppressionReasons(rawValue: 1 << 1) |
| | | static let usageTipsHover = AppGridBubbleSuppressionReasons(rawValue: 1 << 2) |
| | | } |
| | | |
| | | struct AppGridUsageTip: Equatable { |
| | | let id: Int |
| | | let titleKey: String |
| | | let detailKey: String |
| | | } |
| | | |
| | | enum AppGridUsageTipsMetrics { |
| | | static let barHeight: CGFloat = 154 |
| | | static let reservedHeight: CGFloat = 194 |
| | | static let bottomMargin: CGFloat = 20 |
| | | static let horizontalInset: CGFloat = 24 |
| | | static let minWidth: CGFloat = 640 |
| | | } |
| | | |
| | | struct AppGridCollectionView: NSViewRepresentable { |
| | | let groups: [TagGroup] |
| | | let tagColors: [String: Int] |
| | | let tagCustomColors: [String: TagCustomColor] |
| | | let displayMode: String |
| | | let iconSize: CGFloat |
| | | let showNames: Bool |
| | | let appGridTheme: AppGridTheme |
| | | let bubbleDisabled: Bool |
| | | let showUncommonAppBubbles: Bool |
| | | let highlightedGroupName: String? |
| | | let bottomContentPadding: CGFloat |
| | | let usageTipsVisible: Bool |
| | | let usageTips: [AppGridUsageTip] |
| | | @Binding var selectedUsageTipIndex: Int |
| | | let contentRevision: Int |
| | | let scrollTargetID: String? |
| | | let scrollRequestToken: Int |
| | |
| | | let onGroupActivate: (String) -> Void |
| | | let onScrollActivity: () -> Void |
| | | let onDragModeChange: (Bool) -> Void |
| | | let onHideUsageTips: () -> Void |
| | | let onUsageTipsHoverChange: (Bool) -> Void |
| | | |
| | | func makeCoordinator() -> Coordinator { |
| | | Coordinator() |
| | |
| | | } |
| | | |
| | | func updateNSView(_ view: AppGridCollectionHostView, context: Context) { |
| | | let selectedUsageTipIndexBinding = $selectedUsageTipIndex |
| | | context.coordinator.update( |
| | | groups: groups, |
| | | tagColors: tagColors, |
| | | tagCustomColors: tagCustomColors, |
| | | displayMode: displayMode, |
| | | iconSize: iconSize, |
| | | showNames: showNames, |
| | | appGridTheme: appGridTheme, |
| | | bubbleDisabled: bubbleDisabled, |
| | | showUncommonAppBubbles: showUncommonAppBubbles, |
| | | highlightedGroupName: highlightedGroupName, |
| | | bottomContentPadding: bottomContentPadding, |
| | | usageTipsVisible: usageTipsVisible, |
| | | usageTips: usageTips, |
| | | selectedUsageTipIndex: selectedUsageTipIndex, |
| | | contentRevision: contentRevision, |
| | | scrollTargetID: scrollTargetID, |
| | | scrollRequestToken: scrollRequestToken, |
| | |
| | | onReorderApps: onReorderApps, |
| | | onGroupActivate: onGroupActivate, |
| | | onScrollActivity: onScrollActivity, |
| | | onDragModeChange: onDragModeChange |
| | | onDragModeChange: onDragModeChange, |
| | | onHideUsageTips: onHideUsageTips, |
| | | onUsageTipIndexChange: { selectedUsageTipIndexBinding.wrappedValue = $0 }, |
| | | onUsageTipsHoverChange: onUsageTipsHoverChange |
| | | ) |
| | | view.applyCoordinatorUpdate() |
| | | } |
| | |
| | | final class Coordinator: NSObject, NSCollectionViewDataSource, NSCollectionViewDelegate { |
| | | var groups: [TagGroup] = [] |
| | | var tagColors: [String: Int] = [:] |
| | | var tagCustomColors: [String: TagCustomColor] = [:] |
| | | var displayMode = AppDefaults.displayMode |
| | | var iconSize: CGFloat = AppDefaults.iconSize |
| | | var showNames = true |
| | | var appGridTheme = AppGridTheme.fallback |
| | | private var externalBubbleDisabled = false |
| | | private var scrollBubbleDisabled = false |
| | | var showUncommonAppBubbles = AppDefaults.showUncommonAppBubbles |
| | | var highlightedGroupName: String? |
| | | var bottomContentPadding: CGFloat = 0 |
| | | var usageTipsVisible = false |
| | | var usageTips: [AppGridUsageTip] = [] |
| | | var selectedUsageTipIndex = 0 |
| | | private var usageTipsBubbleDisabled = false |
| | | var contentRevision = 0 |
| | | var scrollTargetID: String? |
| | | var scrollRequestToken = 0 |
| | |
| | | var onGroupActivate: (String) -> Void = { _ in } |
| | | var onScrollActivity: () -> Void = {} |
| | | var onDragModeChange: (Bool) -> Void = { _ in } |
| | | var onHideUsageTips: () -> Void = {} |
| | | var onUsageTipIndexChange: (Int) -> Void = { _ in } |
| | | var onUsageTipsHoverChange: (Bool) -> Void = { _ in } |
| | | |
| | | private weak var activeReorderCard: AppGridGroupCardView? |
| | | private var activeDragPath = "" |
| | |
| | | func update( |
| | | groups: [TagGroup], |
| | | tagColors: [String: Int], |
| | | tagCustomColors: [String: TagCustomColor], |
| | | displayMode: String, |
| | | iconSize: CGFloat, |
| | | showNames: Bool, |
| | | appGridTheme: AppGridTheme, |
| | | bubbleDisabled: Bool, |
| | | showUncommonAppBubbles: Bool, |
| | | highlightedGroupName: String?, |
| | | bottomContentPadding: CGFloat, |
| | | usageTipsVisible: Bool, |
| | | usageTips: [AppGridUsageTip], |
| | | selectedUsageTipIndex: Int, |
| | | contentRevision: Int, |
| | | scrollTargetID: String?, |
| | | scrollRequestToken: Int, |
| | |
| | | onReorderApps: @escaping (String, [String]) -> Void, |
| | | onGroupActivate: @escaping (String) -> Void, |
| | | onScrollActivity: @escaping () -> Void, |
| | | onDragModeChange: @escaping (Bool) -> Void |
| | | onDragModeChange: @escaping (Bool) -> Void, |
| | | onHideUsageTips: @escaping () -> Void, |
| | | onUsageTipIndexChange: @escaping (Int) -> Void, |
| | | onUsageTipsHoverChange: @escaping (Bool) -> Void |
| | | ) { |
| | | self.groups = groups |
| | | self.tagColors = tagColors |
| | | self.tagCustomColors = tagCustomColors |
| | | self.displayMode = displayMode |
| | | self.iconSize = iconSize |
| | | self.showNames = showNames |
| | | self.appGridTheme = appGridTheme |
| | | self.externalBubbleDisabled = bubbleDisabled |
| | | self.showUncommonAppBubbles = showUncommonAppBubbles |
| | | self.highlightedGroupName = highlightedGroupName |
| | | self.bottomContentPadding = max(0, bottomContentPadding) |
| | | self.usageTipsVisible = usageTipsVisible && !usageTips.isEmpty |
| | | self.usageTips = usageTips |
| | | self.selectedUsageTipIndex = Self.clampedUsageTipIndex(selectedUsageTipIndex, tips: usageTips) |
| | | self.contentRevision = contentRevision |
| | | self.scrollTargetID = scrollTargetID |
| | | self.scrollRequestToken = scrollRequestToken |
| | |
| | | self.onGroupActivate = onGroupActivate |
| | | self.onScrollActivity = onScrollActivity |
| | | self.onDragModeChange = onDragModeChange |
| | | self.onHideUsageTips = onHideUsageTips |
| | | self.onUsageTipIndexChange = onUsageTipIndexChange |
| | | self.onUsageTipsHoverChange = onUsageTipsHoverChange |
| | | if !self.usageTipsVisible { |
| | | usageTipsBubbleDisabled = false |
| | | } |
| | | |
| | | let nextSignature = Self.signature( |
| | | tagColors: tagColors, |
| | | tagCustomColors: tagCustomColors, |
| | | displayMode: displayMode, |
| | | iconSize: iconSize, |
| | | showNames: showNames, |
| | | appGridTheme: appGridTheme, |
| | | showUncommonAppBubbles: showUncommonAppBubbles, |
| | | bottomContentPadding: self.bottomContentPadding, |
| | | contentRevision: contentRevision |
| | |
| | | if scrollBubbleDisabled { |
| | | reasons.insert(.scroll) |
| | | } |
| | | if usageTipsBubbleDisabled { |
| | | reasons.insert(.usageTipsHover) |
| | | } |
| | | return reasons |
| | | } |
| | | |
| | |
| | | guard scrollBubbleDisabled != disabled else { return false } |
| | | scrollBubbleDisabled = disabled |
| | | return true |
| | | } |
| | | |
| | | @discardableResult |
| | | func setUsageTipsBubbleDisabled(_ disabled: Bool) -> Bool { |
| | | guard usageTipsBubbleDisabled != disabled else { return false } |
| | | usageTipsBubbleDisabled = disabled |
| | | return true |
| | | } |
| | | |
| | | func selectUsageTip(offset: Int) { |
| | | guard !usageTips.isEmpty else { return } |
| | | let count = usageTips.count |
| | | let nextIndex = (selectedUsageTipIndex + offset + count) % count |
| | | guard nextIndex != selectedUsageTipIndex else { return } |
| | | selectedUsageTipIndex = nextIndex |
| | | onUsageTipIndexChange(nextIndex) |
| | | } |
| | | |
| | | fileprivate func beginAppIconDrag(path: String, sourceContainerID: String) { |
| | |
| | | |
| | | private static func signature( |
| | | tagColors: [String: Int], |
| | | tagCustomColors: [String: TagCustomColor], |
| | | displayMode: String, |
| | | iconSize: CGFloat, |
| | | showNames: Bool, |
| | | appGridTheme: AppGridTheme, |
| | | showUncommonAppBubbles: Bool, |
| | | bottomContentPadding: CGFloat, |
| | | contentRevision: Int |
| | |
| | | .sorted { $0.key < $1.key } |
| | | .map { "\($0.key)=\($0.value)" } |
| | | .joined(separator: ",") |
| | | let customColorPart = tagCustomColors |
| | | .sorted { $0.key < $1.key } |
| | | .map { "\($0.key)=\($0.value.signature)" } |
| | | .joined(separator: ",") |
| | | return [ |
| | | displayMode, |
| | | "\(Int(iconSize.rounded()))", |
| | | showNames ? "names" : "nonames", |
| | | "theme=\(appGridTheme.rawValue)", |
| | | showUncommonAppBubbles ? "uncommon" : "allbubbles", |
| | | "bottom=\(Int(bottomContentPadding.rounded()))", |
| | | colorPart, |
| | | customColorPart, |
| | | "rev=\(contentRevision)" |
| | | ].joined(separator: "|") |
| | | } |
| | | |
| | | func tagColor(for groupName: String) -> NSColor { |
| | | TagColor.nsColor( |
| | | for: tagColors[groupName] ?? 0, |
| | | customColor: tagCustomColors[groupName] |
| | | ) |
| | | } |
| | | |
| | | private static func clampedUsageTipIndex(_ index: Int, tips: [AppGridUsageTip]) -> Int { |
| | | guard !tips.isEmpty else { return 0 } |
| | | return min(max(0, index), tips.count - 1) |
| | | } |
| | | } |
| | | } |
| | |
| | | private let scrollView = AppGridScrollView() |
| | | private let collectionView = NSCollectionView() |
| | | private let gridLayout = AppGridContainerCollectionLayout() |
| | | private let usageTipsShieldView = AppGridUsageTipsShieldView() |
| | | private let usageTipsView = AppGridUsageTipsNSView() |
| | | private weak var coordinator: AppGridCollectionView.Coordinator? |
| | | private var scrollObserver: NSObjectProtocol? |
| | | private var lastLayoutSize: NSSize = .zero |
| | | private var lastReportedBoundsOrigin: NSPoint? |
| | | private var scrollUnfreezeWorkItem: DispatchWorkItem? |
| | | private var scrollActivityIsActive = false |
| | | private var usageTipsEventRegionIsClaimed = false |
| | | private var usageTipsMouseMonitor: Any? |
| | | |
| | | override var isFlipped: Bool { true } |
| | | |
| | |
| | | if let scrollObserver { |
| | | NotificationCenter.default.removeObserver(scrollObserver) |
| | | } |
| | | removeUsageTipsMouseMonitor() |
| | | scrollUnfreezeWorkItem?.cancel() |
| | | coordinator?.cancelAppIconDrag() |
| | | AppDragCoordinator.shared.unregisterEmptyDropTarget(id: emptyDropTargetID) |
| | |
| | | func configure(coordinator: AppGridCollectionView.Coordinator) { |
| | | self.coordinator = coordinator |
| | | gridLayout.coordinator = coordinator |
| | | usageTipsView.coordinator = coordinator |
| | | collectionView.dataSource = coordinator |
| | | collectionView.delegate = coordinator |
| | | } |
| | | |
| | | func applyCoordinatorUpdate() { |
| | | guard let coordinator else { return } |
| | | applyAppearance() |
| | | if coordinator.needsReload { |
| | | coordinator.needsReload = false |
| | | collectionView.reloadData() |
| | |
| | | } |
| | | } |
| | | } |
| | | usageTipsView.applyCoordinatorState() |
| | | positionUsageTipsView() |
| | | } |
| | | |
| | | override func layout() { |
| | | super.layout() |
| | | scrollView.frame = bounds |
| | | positionUsageTipsView() |
| | | if lastLayoutSize != bounds.size { |
| | | lastLayoutSize = bounds.size |
| | | gridLayout.invalidateLayout() |
| | |
| | | override func viewDidMoveToWindow() { |
| | | super.viewDidMoveToWindow() |
| | | if window == nil { |
| | | removeUsageTipsMouseMonitor() |
| | | AppDragCoordinator.shared.cancelDrag() |
| | | coordinator?.cancelAppIconDrag() |
| | | AppDragCoordinator.shared.unregisterEmptyDropTarget(id: emptyDropTargetID) |
| | | } else { |
| | | installUsageTipsMouseMonitor() |
| | | AppDragCoordinator.shared.registerEmptyDropTarget(id: emptyDropTargetID, view: self) |
| | | } |
| | | } |
| | | |
| | | override func hitTest(_ point: NSPoint) -> NSView? { |
| | | if !usageTipsView.isHidden, |
| | | usageTipsView.frame.contains(point) { |
| | | let tipsPoint = usageTipsView.convert(point, from: self) |
| | | return usageTipsView.hitTest(tipsPoint) ?? usageTipsView |
| | | } |
| | | return super.hitTest(point) |
| | | } |
| | | |
| | | func performEmptyDrop(path: String, source: String, screenPoint: NSPoint, copy: Bool) { |
| | |
| | | DispatchQueue.main.async { |
| | | coordinator.onDropOutsideGroup(path, source, copy) |
| | | } |
| | | } |
| | | |
| | | func canPreviewEmptyDrop(path: String, source: String, screenPoint: NSPoint, copy: Bool) -> Bool { |
| | | guard let coordinator, |
| | | coordinator.displayStyle != .flat |
| | | else { return false } |
| | | return !coordinator.shouldCancelEmptyDropForActiveReorder( |
| | | path: path, |
| | | screenPoint: screenPoint, |
| | | copy: copy |
| | | ) |
| | | } |
| | | |
| | | private func setup() { |
| | |
| | | scrollView.borderType = .noBorder |
| | | scrollView.documentView = collectionView |
| | | addSubview(scrollView) |
| | | usageTipsShieldView.isHidden = true |
| | | usageTipsShieldView.onClaim = { [weak self] in |
| | | self?.claimUsageTipsEventRegion() |
| | | } |
| | | usageTipsShieldView.onRelease = { [weak self] in |
| | | self?.releaseUsageTipsEventRegion() |
| | | } |
| | | usageTipsView.isHidden = true |
| | | addSubview(usageTipsShieldView, positioned: .above, relativeTo: scrollView) |
| | | addSubview(usageTipsView, positioned: .above, relativeTo: usageTipsShieldView) |
| | | |
| | | scrollView.contentView.postsBoundsChangedNotifications = true |
| | | scrollObserver = NotificationCenter.default.addObserver( |
| | |
| | | else { return } |
| | | self.handleScrollActivity() |
| | | } |
| | | } |
| | | |
| | | private func positionUsageTipsView() { |
| | | guard let coordinator, |
| | | coordinator.usageTipsVisible |
| | | else { |
| | | usageTipsView.isHidden = true |
| | | usageTipsShieldView.isHidden = true |
| | | usageTipsView.clearHoverState() |
| | | releaseUsageTipsEventRegion() |
| | | usageTipsView.frame = .zero |
| | | usageTipsShieldView.frame = .zero |
| | | return |
| | | } |
| | | |
| | | let inset = min(AppGridUsageTipsMetrics.horizontalInset, max(0, bounds.width / 4)) |
| | | let availableWidth = max(1, bounds.width - inset * 2) |
| | | let width = usageTipsView.preferredWidth(maxAvailableWidth: availableWidth) |
| | | let height = AppGridUsageTipsMetrics.barHeight |
| | | usageTipsShieldView.isHidden = false |
| | | usageTipsShieldView.frame = usageTipsEventRegion() |
| | | usageTipsView.configureVisualLayout( |
| | | width: width, |
| | | height: height, |
| | | bottomMargin: AppGridUsageTipsMetrics.bottomMargin |
| | | ) |
| | | usageTipsView.isHidden = false |
| | | usageTipsView.frame = usageTipsEventRegion() |
| | | } |
| | | |
| | | fileprivate func relayoutUsageTipsAfterContentChange() { |
| | | positionUsageTipsView() |
| | | usageTipsView.needsLayout = true |
| | | } |
| | | |
| | | private func usageTipsEventRegion() -> NSRect { |
| | | guard let coordinator, |
| | | coordinator.usageTipsVisible |
| | | else { return .zero } |
| | | let height = min(bounds.height, AppGridUsageTipsMetrics.reservedHeight) |
| | | return NSRect( |
| | | x: 0, |
| | | y: max(0, bounds.height - height), |
| | | width: bounds.width, |
| | | height: height |
| | | ) |
| | | } |
| | | |
| | | private func claimUsageTipsEventRegion() { |
| | | usageTipsEventRegionIsClaimed = true |
| | | window?.makeFirstResponder(usageTipsView) |
| | | if coordinator?.setUsageTipsBubbleDisabled(true) == true { |
| | | refreshVisibleRuntimeState() |
| | | } |
| | | coordinator?.onUsageTipsHoverChange(true) |
| | | } |
| | | |
| | | private func releaseUsageTipsEventRegion() { |
| | | guard usageTipsEventRegionIsClaimed else { return } |
| | | usageTipsEventRegionIsClaimed = false |
| | | if coordinator?.setUsageTipsBubbleDisabled(false) == true { |
| | | refreshVisibleRuntimeState() |
| | | } |
| | | coordinator?.onUsageTipsHoverChange(false) |
| | | } |
| | | |
| | | private func handleScrollActivity() { |
| | |
| | | } |
| | | } |
| | | |
| | | fileprivate func refreshVisibleRuntimeStateForUsageTips() { |
| | | refreshVisibleRuntimeState() |
| | | } |
| | | |
| | | func handleUsageTipsMouseDown(_ event: NSEvent) -> Bool { |
| | | handleUsageTipsMouseEvent(event, triggerButtons: true) |
| | | } |
| | | |
| | | private func handleUsageTipsMouseEvent(_ event: NSEvent, triggerButtons: Bool) -> Bool { |
| | | let point = usageTipsHostPoint(for: event) |
| | | guard !usageTipsView.isHidden, |
| | | usageTipsView.frame.contains(point) |
| | | else { return false } |
| | | |
| | | return usageTipsView.handleMouseEventFromHost(event, triggerButtons: triggerButtons) |
| | | } |
| | | |
| | | private func installUsageTipsMouseMonitor() { |
| | | guard usageTipsMouseMonitor == nil else { return } |
| | | usageTipsMouseMonitor = NSEvent.addLocalMonitorForEvents( |
| | | matching: [ |
| | | .leftMouseDown, |
| | | .leftMouseUp, |
| | | .rightMouseDown, |
| | | .rightMouseUp, |
| | | .otherMouseDown, |
| | | .otherMouseUp |
| | | ] |
| | | ) { [weak self] event in |
| | | guard let self, |
| | | self.window != nil, |
| | | self.handleUsageTipsMouseEvent( |
| | | event, |
| | | triggerButtons: event.type == .leftMouseDown |
| | | ) |
| | | else { return event } |
| | | return nil |
| | | } |
| | | } |
| | | |
| | | private func usageTipsHostPoint(for event: NSEvent) -> NSPoint { |
| | | if event.window === window { |
| | | return convert(event.locationInWindow, from: nil) |
| | | } |
| | | guard let window else { return .zero } |
| | | let windowPoint = window.convertPoint(fromScreen: NSEvent.mouseLocation) |
| | | return convert(windowPoint, from: nil) |
| | | } |
| | | |
| | | private func removeUsageTipsMouseMonitor() { |
| | | if let usageTipsMouseMonitor { |
| | | NSEvent.removeMonitor(usageTipsMouseMonitor) |
| | | self.usageTipsMouseMonitor = nil |
| | | } |
| | | } |
| | | |
| | | private func replayPointerHover() { |
| | | guard let window else { return } |
| | | let windowPoint = window.convertPoint(fromScreen: NSEvent.mouseLocation) |
| | |
| | | } |
| | | return didScroll |
| | | } |
| | | |
| | | private func applyAppearance() { |
| | | let usesDarkGlass = coordinator?.appGridTheme.usesDarkGlass == true |
| | | layer?.backgroundColor = NSColor.clear.cgColor |
| | | collectionView.appearance = usesDarkGlass ? NSAppearance(named: .darkAqua) : nil |
| | | } |
| | | } |
| | | |
| | | private final class AppGridUsageTipsShieldView: NSView { |
| | | var onClaim: (() -> Void)? |
| | | var onRelease: (() -> Void)? |
| | | |
| | | private var trackingAreaRef: NSTrackingArea? |
| | | |
| | | override var isFlipped: Bool { true } |
| | | override var acceptsFirstResponder: Bool { false } |
| | | override var mouseDownCanMoveWindow: Bool { false } |
| | | |
| | | override func acceptsFirstMouse(for event: NSEvent?) -> Bool { |
| | | true |
| | | } |
| | | |
| | | override func updateTrackingAreas() { |
| | | super.updateTrackingAreas() |
| | | if let trackingAreaRef { |
| | | removeTrackingArea(trackingAreaRef) |
| | | } |
| | | let area = NSTrackingArea( |
| | | rect: .zero, |
| | | options: [.mouseEnteredAndExited, .mouseMoved, .activeAlways, .inVisibleRect], |
| | | owner: self, |
| | | userInfo: nil |
| | | ) |
| | | addTrackingArea(area) |
| | | trackingAreaRef = area |
| | | } |
| | | |
| | | override func hitTest(_ point: NSPoint) -> NSView? { |
| | | guard !isHidden, bounds.contains(point) else { return nil } |
| | | onClaim?() |
| | | return self |
| | | } |
| | | |
| | | override func mouseEntered(with event: NSEvent) { onClaim?() } |
| | | override func mouseMoved(with event: NSEvent) { onClaim?() } |
| | | override func mouseDragged(with event: NSEvent) { onClaim?() } |
| | | override func mouseExited(with event: NSEvent) { onRelease?() } |
| | | override func mouseDown(with event: NSEvent) { |
| | | Diagnostics.log("usageTips.shield.mouseDown") |
| | | onClaim?() |
| | | } |
| | | override func mouseUp(with event: NSEvent) {} |
| | | override func rightMouseDown(with event: NSEvent) { onClaim?() } |
| | | override func rightMouseUp(with event: NSEvent) {} |
| | | override func otherMouseDown(with event: NSEvent) { onClaim?() } |
| | | override func otherMouseUp(with event: NSEvent) {} |
| | | override func scrollWheel(with event: NSEvent) { onClaim?() } |
| | | } |
| | | |
| | | private final class AppGridUsageTipsNSView: NSView { |
| | | weak var coordinator: AppGridCollectionView.Coordinator? |
| | | |
| | | private let backgroundView = NSVisualEffectView() |
| | | private let titlePanelView = NSView() |
| | | private let iconView = AppGridDecorativeImageView() |
| | | private let titleLabel = NSTextField(labelWithString: "") |
| | | private let detailScrollView = NSScrollView() |
| | | private let detailLabel = NSTextField(labelWithString: "") |
| | | private let closeButton = AppGridUsageTipIconButton(systemImage: "xmark") |
| | | private let previousButton = AppGridUsageTipIconButton(systemImage: "chevron.left") |
| | | private let nextButton = AppGridUsageTipIconButton(systemImage: "chevron.right") |
| | | private let dotsView = AppGridUsageTipDotsView() |
| | | private var trackingAreaRef: NSTrackingArea? |
| | | private var isPointerInside = false |
| | | private var visualWidth: CGFloat = AppGridUsageTipsMetrics.minWidth |
| | | private var visualHeight: CGFloat = AppGridUsageTipsMetrics.barHeight |
| | | private var visualBottomMargin: CGFloat = AppGridUsageTipsMetrics.bottomMargin |
| | | private var currentDetailText = "" |
| | | |
| | | private let titleFont = NSFont.systemFont(ofSize: 25, weight: .bold) |
| | | private let detailFont = NSFont.systemFont(ofSize: 24, weight: .medium) |
| | | |
| | | override var isFlipped: Bool { true } |
| | | override var acceptsFirstResponder: Bool { true } |
| | | override var mouseDownCanMoveWindow: Bool { false } |
| | | |
| | | override init(frame frameRect: NSRect) { |
| | | super.init(frame: frameRect) |
| | | setup() |
| | | } |
| | | |
| | | required init?(coder: NSCoder) { |
| | | super.init(coder: coder) |
| | | setup() |
| | | } |
| | | |
| | | override func acceptsFirstMouse(for event: NSEvent?) -> Bool { |
| | | true |
| | | } |
| | | |
| | | override func viewDidMoveToWindow() { |
| | | super.viewDidMoveToWindow() |
| | | if window == nil { |
| | | clearHoverState() |
| | | } |
| | | } |
| | | |
| | | override func updateTrackingAreas() { |
| | | super.updateTrackingAreas() |
| | | if let trackingAreaRef { |
| | | removeTrackingArea(trackingAreaRef) |
| | | } |
| | | let area = NSTrackingArea( |
| | | rect: .zero, |
| | | options: [.mouseEnteredAndExited, .mouseMoved, .activeAlways, .inVisibleRect], |
| | | owner: self, |
| | | userInfo: nil |
| | | ) |
| | | addTrackingArea(area) |
| | | trackingAreaRef = area |
| | | } |
| | | |
| | | override func hitTest(_ point: NSPoint) -> NSView? { |
| | | guard !isHidden, bounds.contains(point) else { return nil } |
| | | setPointerInside(true) |
| | | let hitView = super.hitTest(point) |
| | | if hitView == nil || hitView === backgroundView || hitView?.isDescendant(of: backgroundView) == true { |
| | | return self |
| | | } |
| | | return hitView |
| | | } |
| | | |
| | | override func mouseEntered(with event: NSEvent) { |
| | | claimInteractionFocus() |
| | | } |
| | | |
| | | override func mouseMoved(with event: NSEvent) { |
| | | claimInteractionFocus() |
| | | } |
| | | |
| | | override func mouseExited(with event: NSEvent) { |
| | | setPointerInside(false) |
| | | } |
| | | |
| | | override func mouseDown(with event: NSEvent) { |
| | | Diagnostics.log("usageTips.hud.mouseDown") |
| | | if routeButtonClickIfNeeded(event) { |
| | | return |
| | | } |
| | | claimInteractionFocus() |
| | | } |
| | | |
| | | override func mouseDragged(with event: NSEvent) { |
| | | claimInteractionFocus() |
| | | } |
| | | |
| | | override func mouseUp(with event: NSEvent) {} |
| | | override func rightMouseDown(with event: NSEvent) { claimInteractionFocus() } |
| | | override func rightMouseUp(with event: NSEvent) {} |
| | | override func otherMouseDown(with event: NSEvent) { claimInteractionFocus() } |
| | | override func otherMouseUp(with event: NSEvent) {} |
| | | |
| | | override func keyDown(with event: NSEvent) { |
| | | switch Int(event.keyCode) { |
| | | case kVK_LeftArrow: |
| | | selectUsageTip(offset: -1) |
| | | case kVK_RightArrow: |
| | | selectUsageTip(offset: 1) |
| | | default: |
| | | super.keyDown(with: event) |
| | | } |
| | | } |
| | | |
| | | override func scrollWheel(with event: NSEvent) { |
| | | detailScrollView.scrollWheel(with: event) |
| | | } |
| | | |
| | | override func layout() { |
| | | super.layout() |
| | | let visualFrame = currentVisualFrame() |
| | | backgroundView.frame = visualFrame |
| | | |
| | | let outerPaddingX: CGFloat = 20 |
| | | let outerPaddingY: CGFloat = 19 |
| | | let titlePanelWidth = min( |
| | | max(350, visualFrame.width * 0.24), |
| | | min(460, visualFrame.width * 0.36) |
| | | ) |
| | | let titlePanelHeight = max(1, min(116, visualFrame.height - outerPaddingY * 2)) |
| | | let titlePanelFrame = NSRect( |
| | | x: visualFrame.minX + outerPaddingX, |
| | | y: visualFrame.midY - titlePanelHeight / 2, |
| | | width: titlePanelWidth, |
| | | height: titlePanelHeight |
| | | ) |
| | | titlePanelView.frame = titlePanelFrame |
| | | |
| | | let iconSize: CGFloat = 26 |
| | | let controlZoneWidth: CGFloat = min(148, max(128, visualFrame.width * 0.08)) |
| | | let buttonGap: CGFloat = 12 |
| | | let buttonSize: CGFloat = 52 |
| | | let buttonDotsGap: CGFloat = 10 |
| | | let dotsHeight: CGFloat = 10 |
| | | let dotsWidth = dotsView.preferredWidth |
| | | |
| | | let titleIconGap: CGFloat = 20 |
| | | let titleSidePadding: CGFloat = 28 |
| | | let titleX = titlePanelFrame.minX + titleSidePadding + iconSize + titleIconGap |
| | | let titleWidth = max(1, titlePanelFrame.maxX - titleX - titleSidePadding) |
| | | let titleHeight: CGFloat = 68 |
| | | let titleY = titlePanelFrame.midY - titleHeight / 2 |
| | | let detailGap = visualFrame.width < 980 ? CGFloat(30) : CGFloat(52) |
| | | let controlGap = visualFrame.width < 980 ? CGFloat(24) : CGFloat(36) |
| | | let detailX = titlePanelFrame.maxX + detailGap |
| | | let controlsMaxX = visualFrame.maxX - 54 |
| | | let textRight = controlsMaxX - controlZoneWidth - controlGap |
| | | let detailHeight: CGFloat = 88 |
| | | let detailY = visualFrame.minY + max(0, (visualFrame.height - detailHeight) / 2) |
| | | |
| | | let buttonsWidth = buttonSize * 2 + buttonGap |
| | | let buttonsGroupMinX = controlsMaxX - buttonsWidth |
| | | |
| | | iconView.frame = NSRect( |
| | | x: titlePanelFrame.minX + titleSidePadding, |
| | | y: titlePanelFrame.midY - iconSize / 2, |
| | | width: iconSize, |
| | | height: iconSize |
| | | ) |
| | | |
| | | titleLabel.frame = NSRect( |
| | | x: titleX, |
| | | y: titleY, |
| | | width: titleWidth, |
| | | height: titleHeight |
| | | ) |
| | | |
| | | closeButton.frame = NSRect( |
| | | x: visualFrame.maxX - 40, |
| | | y: visualFrame.minY + 10, |
| | | width: 30, |
| | | height: 30 |
| | | ) |
| | | |
| | | let controlGroupHeight = buttonSize + buttonDotsGap + dotsHeight |
| | | let buttonY = visualFrame.minY + max(0, (visualFrame.height - controlGroupHeight) / 2) |
| | | nextButton.frame = NSRect( |
| | | x: buttonsGroupMinX + buttonSize + buttonGap, |
| | | y: buttonY, |
| | | width: buttonSize, |
| | | height: buttonSize |
| | | ) |
| | | previousButton.frame = NSRect( |
| | | x: buttonsGroupMinX, |
| | | y: buttonY, |
| | | width: buttonSize, |
| | | height: buttonSize |
| | | ) |
| | | dotsView.frame = NSRect( |
| | | x: buttonsGroupMinX + (buttonsWidth - dotsWidth) / 2, |
| | | y: previousButton.frame.maxY + buttonDotsGap, |
| | | width: dotsWidth, |
| | | height: dotsHeight |
| | | ) |
| | | |
| | | detailScrollView.frame = NSRect( |
| | | x: detailX, |
| | | y: detailY, |
| | | width: max(1, textRight - detailX), |
| | | height: detailHeight |
| | | ) |
| | | layoutDetailLabel() |
| | | } |
| | | |
| | | func applyCoordinatorState() { |
| | | guard let coordinator, |
| | | coordinator.usageTipsVisible, |
| | | !coordinator.usageTips.isEmpty |
| | | else { |
| | | isHidden = true |
| | | clearHoverState() |
| | | return |
| | | } |
| | | |
| | | isHidden = false |
| | | let safeIndex = min(max(0, coordinator.selectedUsageTipIndex), coordinator.usageTips.count - 1) |
| | | let tip = coordinator.usageTips[safeIndex] |
| | | titleLabel.stringValue = tr(tip.titleKey) |
| | | updateTipIcon(id: tip.id) |
| | | currentDetailText = formattedTipDetail(tr(tip.detailKey)) |
| | | closeButton.buttonAccessibilityLabel = tr("usageTips.close") |
| | | previousButton.buttonAccessibilityLabel = tr("usageTips.previous") |
| | | nextButton.buttonAccessibilityLabel = tr("usageTips.next") |
| | | dotsView.configure(count: coordinator.usageTips.count, selectedIndex: safeIndex) |
| | | updateColors() |
| | | needsLayout = true |
| | | enclosingHostView?.relayoutUsageTipsAfterContentChange() |
| | | } |
| | | |
| | | func clearHoverState() { |
| | | guard isPointerInside else { return } |
| | | setPointerInside(false) |
| | | } |
| | | |
| | | func configureVisualLayout(width: CGFloat, height: CGFloat, bottomMargin: CGFloat) { |
| | | visualWidth = max(1, width) |
| | | visualHeight = max(1, height) |
| | | visualBottomMargin = max(0, bottomMargin) |
| | | needsLayout = true |
| | | } |
| | | |
| | | func preferredWidth(maxAvailableWidth: CGFloat) -> CGFloat { |
| | | max(1, maxAvailableWidth) |
| | | } |
| | | |
| | | private func setup() { |
| | | wantsLayer = true |
| | | layer?.shadowColor = NSColor.black.cgColor |
| | | layer?.shadowOpacity = 0.14 |
| | | layer?.shadowRadius = 12 |
| | | layer?.shadowOffset = NSSize(width: 0, height: -2) |
| | | |
| | | backgroundView.material = .popover |
| | | backgroundView.blendingMode = .withinWindow |
| | | backgroundView.state = .active |
| | | backgroundView.wantsLayer = true |
| | | backgroundView.layer?.cornerRadius = 10 |
| | | backgroundView.layer?.masksToBounds = true |
| | | backgroundView.layer?.borderWidth = 1 |
| | | addSubview(backgroundView) |
| | | |
| | | titlePanelView.wantsLayer = true |
| | | titlePanelView.layer?.cornerRadius = 18 |
| | | titlePanelView.layer?.masksToBounds = true |
| | | titlePanelView.layer?.borderWidth = 1 |
| | | addSubview(titlePanelView) |
| | | |
| | | let iconConfig = NSImage.SymbolConfiguration(pointSize: 26, weight: .bold) |
| | | iconView.image = NSImage( |
| | | systemSymbolName: "lightbulb.fill", |
| | | accessibilityDescription: nil |
| | | )?.withSymbolConfiguration(iconConfig) |
| | | iconView.imageScaling = .scaleProportionallyDown |
| | | addSubview(iconView) |
| | | |
| | | configureLabel(titleLabel, font: titleFont, lineBreakMode: .byWordWrapping) |
| | | configureDetailLabel() |
| | | |
| | | detailScrollView.drawsBackground = false |
| | | detailScrollView.hasVerticalScroller = false |
| | | detailScrollView.hasHorizontalScroller = true |
| | | detailScrollView.autohidesScrollers = true |
| | | detailScrollView.borderType = .noBorder |
| | | detailScrollView.documentView = detailLabel |
| | | |
| | | closeButton.action = { [weak self] in self?.requestHideUsageTips() } |
| | | previousButton.action = { [weak self] in self?.selectUsageTip(offset: -1) } |
| | | nextButton.action = { [weak self] in self?.selectUsageTip(offset: 1) } |
| | | |
| | | addSubview(titleLabel) |
| | | addSubview(detailScrollView) |
| | | addSubview(dotsView) |
| | | addSubview(closeButton) |
| | | addSubview(previousButton) |
| | | addSubview(nextButton) |
| | | |
| | | setAccessibilityRole(.group) |
| | | updateColors() |
| | | } |
| | | |
| | | private func configureLabel(_ label: NSTextField, font: NSFont, lineBreakMode: NSLineBreakMode) { |
| | | label.cell = AppGridCenteredTextFieldCell(textCell: "") |
| | | label.isEditable = false |
| | | label.isSelectable = false |
| | | label.drawsBackground = false |
| | | label.isBordered = false |
| | | label.lineBreakMode = lineBreakMode |
| | | label.maximumNumberOfLines = 2 |
| | | label.alignment = .left |
| | | label.font = font |
| | | } |
| | | |
| | | private func configureDetailLabel() { |
| | | detailLabel.cell = AppGridCenteredMultilineTextFieldCell(textCell: "") |
| | | detailLabel.isEditable = false |
| | | detailLabel.isSelectable = false |
| | | detailLabel.drawsBackground = false |
| | | detailLabel.isBordered = false |
| | | detailLabel.lineBreakMode = .byWordWrapping |
| | | detailLabel.maximumNumberOfLines = 2 |
| | | detailLabel.font = detailFont |
| | | detailLabel.cell?.usesSingleLineMode = false |
| | | detailLabel.cell?.wraps = true |
| | | } |
| | | |
| | | private func claimInteractionFocus() { |
| | | setPointerInside(true) |
| | | window?.makeFirstResponder(self) |
| | | } |
| | | |
| | | func handleMouseEventFromHost(_ event: NSEvent, triggerButtons: Bool) -> Bool { |
| | | let point = usageTipsPoint(for: event) |
| | | guard bounds.contains(point) else { return false } |
| | | if triggerButtons, routeButtonClickIfNeeded(event) { |
| | | return true |
| | | } |
| | | claimInteractionFocus() |
| | | return true |
| | | } |
| | | |
| | | private func selectUsageTip(offset: Int) { |
| | | coordinator?.selectUsageTip(offset: offset) |
| | | applyCoordinatorState() |
| | | } |
| | | |
| | | private func requestHideUsageTips() { |
| | | Diagnostics.log("usageTips.hud.close") |
| | | clearHoverState() |
| | | coordinator?.onHideUsageTips() |
| | | } |
| | | |
| | | private func routeButtonClickIfNeeded(_ event: NSEvent) -> Bool { |
| | | let point = usageTipsPoint(for: event) |
| | | let closeHitOutset: CGFloat = 8 |
| | | if closeButton.frame.insetBy(dx: -closeHitOutset, dy: -closeHitOutset).contains(point) { |
| | | closeButton.performPressFeedback() |
| | | requestHideUsageTips() |
| | | return true |
| | | } |
| | | let hitOutset: CGFloat = 6 |
| | | if nextButton.frame.insetBy(dx: -hitOutset, dy: -hitOutset).contains(point) { |
| | | Diagnostics.log("usageTips.hud.routedNext") |
| | | selectUsageTip(offset: 1) |
| | | return true |
| | | } |
| | | if previousButton.frame.insetBy(dx: -hitOutset, dy: -hitOutset).contains(point) { |
| | | Diagnostics.log("usageTips.hud.routedPrevious") |
| | | selectUsageTip(offset: -1) |
| | | return true |
| | | } |
| | | return false |
| | | } |
| | | |
| | | private func usageTipsPoint(for event: NSEvent) -> NSPoint { |
| | | if event.window === window { |
| | | return convert(event.locationInWindow, from: nil) |
| | | } |
| | | guard let window else { return .zero } |
| | | let windowPoint = window.convertPoint(fromScreen: NSEvent.mouseLocation) |
| | | return convert(windowPoint, from: nil) |
| | | } |
| | | |
| | | private func setPointerInside(_ inside: Bool) { |
| | | guard isPointerInside != inside else { return } |
| | | isPointerInside = inside |
| | | if coordinator?.setUsageTipsBubbleDisabled(inside) == true { |
| | | enclosingHostView?.refreshVisibleRuntimeStateForUsageTips() |
| | | } |
| | | coordinator?.onUsageTipsHoverChange(inside) |
| | | } |
| | | |
| | | private var enclosingHostView: AppGridCollectionHostView? { |
| | | var current = superview |
| | | while let view = current { |
| | | if let host = view as? AppGridCollectionHostView { |
| | | return host |
| | | } |
| | | current = view.superview |
| | | } |
| | | return nil |
| | | } |
| | | |
| | | private func layoutDetailLabel() { |
| | | let width = max(1, detailScrollView.contentView.bounds.width) |
| | | detailLabel.preferredMaxLayoutWidth = width |
| | | detailLabel.frame = NSRect(x: 0, y: 0, width: width, height: detailScrollView.bounds.height) |
| | | } |
| | | |
| | | private func currentVisualFrame() -> NSRect { |
| | | let width = min(bounds.width, visualWidth) |
| | | let height = min(bounds.height, visualHeight) |
| | | return NSRect( |
| | | x: max(0, (bounds.width - width) / 2), |
| | | y: max(0, bounds.height - visualBottomMargin - height), |
| | | width: width, |
| | | height: height |
| | | ) |
| | | } |
| | | |
| | | private func formattedTipDetail(_ text: String) -> String { |
| | | let normalized = text |
| | | .replacingOccurrences(of: "\\s*(?:-->|->|>|→|>|,)\\s*", with: "\n", options: .regularExpression) |
| | | .replacingOccurrences(of: "[ \\t]{2,}", with: " ", options: .regularExpression) |
| | | .replacingOccurrences(of: "\\n{2,}", with: "\n", options: .regularExpression) |
| | | .trimmingCharacters(in: .whitespacesAndNewlines) |
| | | return orderedTipDetail(normalized) |
| | | } |
| | | |
| | | private func orderedTipDetail(_ text: String) -> String { |
| | | let lines = text |
| | | .components(separatedBy: .newlines) |
| | | .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } |
| | | .filter { !$0.isEmpty } |
| | | guard lines.count > 1 else { return text } |
| | | return lines |
| | | .enumerated() |
| | | .map { "\($0.offset + 1). \($0.element)" } |
| | | .joined(separator: "\n") |
| | | } |
| | | |
| | | private func updateTipIcon(id: Int) { |
| | | let symbolName: String |
| | | switch id { |
| | | case 1: |
| | | symbolName = "tag.fill" |
| | | case 2: |
| | | symbolName = "checkmark.seal.fill" |
| | | case 3: |
| | | symbolName = "arrow.up.and.down.and.arrow.left.and.right" |
| | | case 4: |
| | | symbolName = "doc.on.doc.fill" |
| | | case 5: |
| | | symbolName = "minus.circle.fill" |
| | | case 6: |
| | | symbolName = "arrow.up.arrow.down.circle.fill" |
| | | case 7: |
| | | symbolName = "square.grid.2x2.fill" |
| | | case 8: |
| | | symbolName = "note.text" |
| | | default: |
| | | symbolName = "lightbulb.fill" |
| | | } |
| | | let iconConfig = NSImage.SymbolConfiguration(pointSize: 26, weight: .bold) |
| | | iconView.image = NSImage( |
| | | systemSymbolName: symbolName, |
| | | accessibilityDescription: nil |
| | | )?.withSymbolConfiguration(iconConfig) |
| | | ?? NSImage( |
| | | systemSymbolName: "lightbulb.fill", |
| | | accessibilityDescription: nil |
| | | )?.withSymbolConfiguration(iconConfig) |
| | | } |
| | | |
| | | private func updateColors() { |
| | | let usesDarkGlass = coordinator?.appGridTheme.usesDarkGlass == true |
| | | |
| | | backgroundView.material = usesDarkGlass ? .underWindowBackground : .popover |
| | | backgroundView.appearance = usesDarkGlass ? NSAppearance(named: .darkAqua) : nil |
| | | backgroundView.layer?.backgroundColor = ( |
| | | usesDarkGlass |
| | | ? NSColor.black.withAlphaComponent(0.46) |
| | | : NSColor.white.withAlphaComponent(0.54) |
| | | ).cgColor |
| | | backgroundView.layer?.borderColor = ( |
| | | usesDarkGlass |
| | | ? NSColor.white.withAlphaComponent(0.22) |
| | | : NSColor.black.withAlphaComponent(0.18) |
| | | ).cgColor |
| | | |
| | | titlePanelView.layer?.backgroundColor = ( |
| | | usesDarkGlass |
| | | ? NSColor.white.withAlphaComponent(0.08) |
| | | : NSColor.white.withAlphaComponent(0.42) |
| | | ).cgColor |
| | | titlePanelView.layer?.borderColor = ( |
| | | usesDarkGlass |
| | | ? NSColor.white.withAlphaComponent(0.12) |
| | | : NSColor.black.withAlphaComponent(0.08) |
| | | ).cgColor |
| | | |
| | | let theme = coordinator?.appGridTheme ?? .fallback |
| | | let accentColor = usageTipAccentColor(for: theme) |
| | | iconView.contentTintColor = accentColor |
| | | titleLabel.textColor = usesDarkGlass |
| | | ? NSColor.white.withAlphaComponent(0.92) |
| | | : accentColor.withAlphaComponent(0.94) |
| | | detailLabel.textColor = usesDarkGlass |
| | | ? NSColor.white.withAlphaComponent(0.95) |
| | | : NSColor(calibratedRed: 0.08, green: 0.10, blue: 0.13, alpha: 0.94) |
| | | applyDetailTextStyle() |
| | | |
| | | let buttonTint = usesDarkGlass |
| | | ? NSColor.white.withAlphaComponent(0.90) |
| | | : NSColor(calibratedWhite: 0.08, alpha: 0.92) |
| | | let buttonHoverTint = usesDarkGlass |
| | | ? NSColor.white |
| | | : NSColor(calibratedWhite: 0.04, alpha: 1.00) |
| | | let buttonPressedTint = usesDarkGlass |
| | | ? NSColor.white |
| | | : NSColor.black |
| | | let buttonSurface = usesDarkGlass |
| | | ? NSColor.white.withAlphaComponent(0.10) |
| | | : NSColor.white.withAlphaComponent(0.38) |
| | | let buttonHoverSurface = usesDarkGlass |
| | | ? NSColor.white.withAlphaComponent(0.20) |
| | | : NSColor.white.withAlphaComponent(0.70) |
| | | let buttonPressedSurface = usesDarkGlass |
| | | ? NSColor.white.withAlphaComponent(0.28) |
| | | : NSColor.white.withAlphaComponent(0.86) |
| | | let buttonBorder = usesDarkGlass |
| | | ? NSColor.white.withAlphaComponent(0.14) |
| | | : NSColor.black.withAlphaComponent(0.10) |
| | | [previousButton, nextButton].forEach { button in |
| | | button.tintColor = buttonTint |
| | | button.hoveredTintColor = buttonHoverTint |
| | | button.pressedTintColor = buttonPressedTint |
| | | button.surfaceColor = buttonSurface |
| | | button.hoveredSurfaceColor = buttonHoverSurface |
| | | button.pressedSurfaceColor = buttonPressedSurface |
| | | button.borderColor = buttonBorder |
| | | } |
| | | |
| | | closeButton.tintColor = usesDarkGlass |
| | | ? NSColor.white.withAlphaComponent(0.42) |
| | | : NSColor.black.withAlphaComponent(0.34) |
| | | closeButton.hoveredTintColor = usesDarkGlass |
| | | ? NSColor.white.withAlphaComponent(0.86) |
| | | : NSColor.black.withAlphaComponent(0.78) |
| | | closeButton.pressedTintColor = usesDarkGlass ? .white : .black |
| | | closeButton.surfaceColor = usesDarkGlass |
| | | ? NSColor.white.withAlphaComponent(0.04) |
| | | : NSColor.white.withAlphaComponent(0.08) |
| | | closeButton.hoveredSurfaceColor = usesDarkGlass |
| | | ? NSColor.white.withAlphaComponent(0.14) |
| | | : NSColor.white.withAlphaComponent(0.62) |
| | | closeButton.pressedSurfaceColor = usesDarkGlass |
| | | ? NSColor.white.withAlphaComponent(0.22) |
| | | : NSColor.white.withAlphaComponent(0.82) |
| | | closeButton.borderColor = usesDarkGlass |
| | | ? NSColor.white.withAlphaComponent(0.08) |
| | | : NSColor.black.withAlphaComponent(0.08) |
| | | |
| | | dotsView.selectedColor = usesDarkGlass |
| | | ? NSColor.white.withAlphaComponent(0.88) |
| | | : NSColor.labelColor.withAlphaComponent(0.74) |
| | | dotsView.idleColor = usesDarkGlass |
| | | ? NSColor.white.withAlphaComponent(0.28) |
| | | : NSColor.labelColor.withAlphaComponent(0.24) |
| | | |
| | | layer?.shadowOpacity = usesDarkGlass ? 0.28 : 0.14 |
| | | layer?.shadowRadius = usesDarkGlass ? 18 : 12 |
| | | needsDisplay = true |
| | | } |
| | | |
| | | private func usageTipAccentColor(for theme: AppGridTheme) -> NSColor { |
| | | switch theme { |
| | | case .defaultLight: |
| | | return NSColor(calibratedRed: 0.34, green: 0.40, blue: 0.48, alpha: 1.00) |
| | | case .deepBlue: |
| | | return NSColor(calibratedRed: 0.42, green: 0.80, blue: 1.00, alpha: 1.00) |
| | | case .black: |
| | | return NSColor(calibratedRed: 0.36, green: 0.74, blue: 1.00, alpha: 1.00) |
| | | case .pink: |
| | | return NSColor(calibratedRed: 0.70, green: 0.18, blue: 0.45, alpha: 1.00) |
| | | case .purple: |
| | | return NSColor(calibratedRed: 0.40, green: 0.22, blue: 0.70, alpha: 1.00) |
| | | case .green: |
| | | return NSColor(calibratedRed: 0.08, green: 0.42, blue: 0.22, alpha: 1.00) |
| | | case .blue: |
| | | return NSColor(calibratedRed: 0.06, green: 0.34, blue: 0.74, alpha: 1.00) |
| | | case .colorful: |
| | | return NSColor(calibratedRed: 0.06, green: 0.44, blue: 0.78, alpha: 1.00) |
| | | } |
| | | } |
| | | |
| | | private func applyDetailTextStyle() { |
| | | let paragraphStyle = NSMutableParagraphStyle() |
| | | paragraphStyle.alignment = .left |
| | | paragraphStyle.lineBreakMode = .byWordWrapping |
| | | paragraphStyle.minimumLineHeight = 31 |
| | | paragraphStyle.maximumLineHeight = 34 |
| | | paragraphStyle.lineSpacing = 6 |
| | | |
| | | detailLabel.attributedStringValue = NSAttributedString( |
| | | string: currentDetailText, |
| | | attributes: [ |
| | | .font: detailFont, |
| | | .foregroundColor: detailLabel.textColor ?? NSColor.labelColor, |
| | | .paragraphStyle: paragraphStyle |
| | | ] |
| | | ) |
| | | } |
| | | } |
| | | |
| | | private final class AppGridCenteredTextFieldCell: NSTextFieldCell { |
| | | override func drawingRect(forBounds rect: NSRect) -> NSRect { |
| | | var drawingRect = super.drawingRect(forBounds: rect) |
| | | let textHeight = cellSize(forBounds: rect).height |
| | | drawingRect.origin.y = rect.origin.y + max(0, (rect.height - textHeight) / 2) |
| | | drawingRect.size.height = min(rect.height, textHeight + 2) |
| | | return drawingRect |
| | | } |
| | | } |
| | | |
| | | private final class AppGridCenteredMultilineTextFieldCell: NSTextFieldCell { |
| | | override func drawingRect(forBounds rect: NSRect) -> NSRect { |
| | | var drawingRect = super.drawingRect(forBounds: rect) |
| | | let textHeight = attributedStringValue.boundingRect( |
| | | with: NSSize(width: max(1, rect.width), height: .greatestFiniteMagnitude), |
| | | options: [.usesLineFragmentOrigin, .usesFontLeading] |
| | | ).height |
| | | let centeredHeight = min(rect.height, ceil(textHeight) + 4) |
| | | drawingRect.origin.y = rect.origin.y + max(0, (rect.height - centeredHeight) / 2) |
| | | drawingRect.size.height = centeredHeight |
| | | return drawingRect |
| | | } |
| | | } |
| | | |
| | | private final class AppGridDecorativeImageView: NSImageView { |
| | | override func hitTest(_ point: NSPoint) -> NSView? { |
| | | nil |
| | | } |
| | | |
| | | override func isAccessibilityElement() -> Bool { |
| | | false |
| | | } |
| | | } |
| | | |
| | | private final class AppGridUsageTipIconButton: NSView { |
| | | var action: (() -> Void)? |
| | | var buttonAccessibilityLabel: String = "" { |
| | | didSet { |
| | | setAccessibilityLabel(buttonAccessibilityLabel) |
| | | } |
| | | } |
| | | var tintColor: NSColor = .labelColor { |
| | | didSet { |
| | | updateIconTint() |
| | | } |
| | | } |
| | | var hoveredTintColor: NSColor? { |
| | | didSet { updateIconTint() } |
| | | } |
| | | var pressedTintColor: NSColor? { |
| | | didSet { updateIconTint() } |
| | | } |
| | | var surfaceColor: NSColor = .clear { |
| | | didSet { needsDisplay = true } |
| | | } |
| | | var hoveredSurfaceColor: NSColor = NSColor.labelColor.withAlphaComponent(0.08) { |
| | | didSet { needsDisplay = true } |
| | | } |
| | | var pressedSurfaceColor: NSColor = NSColor.labelColor.withAlphaComponent(0.14) { |
| | | didSet { needsDisplay = true } |
| | | } |
| | | var borderColor: NSColor = .clear { |
| | | didSet { needsDisplay = true } |
| | | } |
| | | |
| | | private let imageView = AppGridDecorativeImageView() |
| | | private var trackingAreaRef: NSTrackingArea? |
| | | private var isHovered = false |
| | | private var isPressed = false |
| | | |
| | | override var isFlipped: Bool { true } |
| | | override var acceptsFirstResponder: Bool { false } |
| | | override var mouseDownCanMoveWindow: Bool { false } |
| | | |
| | | init(systemImage: String) { |
| | | super.init(frame: .zero) |
| | | setup(systemImage: systemImage) |
| | | } |
| | | |
| | | required init?(coder: NSCoder) { fatalError() } |
| | | |
| | | override func acceptsFirstMouse(for event: NSEvent?) -> Bool { |
| | | true |
| | | } |
| | | |
| | | override func isAccessibilityElement() -> Bool { |
| | | true |
| | | } |
| | | |
| | | override func accessibilityRole() -> NSAccessibility.Role? { |
| | | .button |
| | | } |
| | | |
| | | override func accessibilityLabel() -> String? { |
| | | buttonAccessibilityLabel |
| | | } |
| | | |
| | | override func accessibilityPerformPress() -> Bool { |
| | | action?() |
| | | return true |
| | | } |
| | | |
| | | func performPressFeedback() { |
| | | isPressed = true |
| | | needsDisplay = true |
| | | updateIconTint() |
| | | DispatchQueue.main.asyncAfter(deadline: .now() + 0.10) { [weak self] in |
| | | self?.isPressed = false |
| | | self?.needsDisplay = true |
| | | self?.updateIconTint() |
| | | } |
| | | } |
| | | |
| | | override func layout() { |
| | | super.layout() |
| | | let size = min(CGFloat(26), max(14, min(bounds.width, bounds.height) * 0.62)) |
| | | imageView.frame = NSRect( |
| | | x: (bounds.width - size) / 2, |
| | | y: (bounds.height - size) / 2, |
| | | width: size, |
| | | height: size |
| | | ) |
| | | } |
| | | |
| | | override func updateTrackingAreas() { |
| | | super.updateTrackingAreas() |
| | | if let trackingAreaRef { |
| | | removeTrackingArea(trackingAreaRef) |
| | | } |
| | | let area = NSTrackingArea( |
| | | rect: .zero, |
| | | options: [.mouseEnteredAndExited, .activeAlways, .inVisibleRect], |
| | | owner: self, |
| | | userInfo: nil |
| | | ) |
| | | addTrackingArea(area) |
| | | trackingAreaRef = area |
| | | } |
| | | |
| | | override func hitTest(_ point: NSPoint) -> NSView? { |
| | | bounds.contains(point) ? self : nil |
| | | } |
| | | |
| | | override func mouseEntered(with event: NSEvent) { |
| | | isHovered = true |
| | | needsDisplay = true |
| | | updateIconTint() |
| | | } |
| | | |
| | | override func mouseExited(with event: NSEvent) { |
| | | isHovered = false |
| | | isPressed = false |
| | | needsDisplay = true |
| | | updateIconTint() |
| | | } |
| | | |
| | | override func mouseDown(with event: NSEvent) { |
| | | Diagnostics.log("usageTips.button.mouseDown", [ |
| | | "label": buttonAccessibilityLabel |
| | | ]) |
| | | performPressFeedback() |
| | | action?() |
| | | } |
| | | |
| | | override func draw(_ dirtyRect: NSRect) { |
| | | let path = NSBezierPath(roundedRect: bounds.insetBy(dx: 1, dy: 1), xRadius: 8, yRadius: 8) |
| | | let fill = isPressed ? pressedSurfaceColor : isHovered ? hoveredSurfaceColor : surfaceColor |
| | | fill.setFill() |
| | | path.fill() |
| | | borderColor.setStroke() |
| | | path.lineWidth = 1 |
| | | path.stroke() |
| | | } |
| | | |
| | | private func setup(systemImage: String) { |
| | | wantsLayer = true |
| | | let config = NSImage.SymbolConfiguration(pointSize: 26, weight: .semibold) |
| | | imageView.image = NSImage( |
| | | systemSymbolName: systemImage, |
| | | accessibilityDescription: nil |
| | | )?.withSymbolConfiguration(config) |
| | | imageView.imageScaling = .scaleProportionallyDown |
| | | imageView.contentTintColor = tintColor |
| | | addSubview(imageView) |
| | | setAccessibilityRole(.button) |
| | | } |
| | | |
| | | private func updateIconTint() { |
| | | if isPressed, let pressedTintColor { |
| | | imageView.contentTintColor = pressedTintColor |
| | | } else if isHovered, let hoveredTintColor { |
| | | imageView.contentTintColor = hoveredTintColor |
| | | } else { |
| | | imageView.contentTintColor = tintColor |
| | | } |
| | | } |
| | | } |
| | | |
| | | private final class AppGridUsageTipDotsView: NSView { |
| | | private var count = 0 |
| | | private var selectedIndex = 0 |
| | | var selectedColor: NSColor = .labelColor { |
| | | didSet { needsDisplay = true } |
| | | } |
| | | var idleColor: NSColor = NSColor.labelColor.withAlphaComponent(0.28) { |
| | | didSet { needsDisplay = true } |
| | | } |
| | | |
| | | override var isFlipped: Bool { true } |
| | | |
| | | var preferredWidth: CGFloat { |
| | | guard count > 0 else { return 0 } |
| | | return CGFloat(count) * 5 + CGFloat(max(0, count - 1)) * 5 + 2 |
| | | } |
| | | |
| | | func configure(count: Int, selectedIndex: Int) { |
| | | self.count = count |
| | | self.selectedIndex = selectedIndex |
| | | needsDisplay = true |
| | | } |
| | | |
| | | override func draw(_ dirtyRect: NSRect) { |
| | | guard count > 0 else { return } |
| | | let spacing: CGFloat = 5 |
| | | let totalWidth = preferredWidth |
| | | var x = (bounds.width - totalWidth) / 2 |
| | | let centerY = bounds.midY |
| | | |
| | | for index in 0..<count { |
| | | let selected = index == selectedIndex |
| | | let dotSize: CGFloat = selected ? 6 : 5 |
| | | let color = selected ? selectedColor : idleColor |
| | | color.setFill() |
| | | NSBezierPath( |
| | | ovalIn: NSRect( |
| | | x: x, |
| | | y: centerY - dotSize / 2, |
| | | width: dotSize, |
| | | height: dotSize |
| | | ) |
| | | ).fill() |
| | | x += 5 + spacing |
| | | } |
| | | } |
| | | |
| | | } |
| | | |
| | | private final class AppGridContainerCollectionLayout: NSCollectionViewLayout { |
| | |
| | | |
| | | private struct LayoutItem { |
| | | let index: Int |
| | | let frame: NSRect |
| | | var frame: NSRect |
| | | } |
| | | |
| | | private struct LayoutPlan { |
| | |
| | | let columnWidth = floor((availableWidth - gap * CGFloat(columnCount - 1)) / CGFloat(columnCount)) |
| | | var columnHeights = Array(repeating: outerPadding, count: columnCount) |
| | | var items: [LayoutItem] = [] |
| | | var lastItemIndexForColumn = Array<Int?>(repeating: nil, count: columnCount) |
| | | let bottomPinnedGroups = groups.enumerated() |
| | | .filter { bottomPinnedPriority(for: $0.element.containerID) != nil } |
| | | .sorted { |
| | | let lhsPriority = bottomPinnedPriority(for: $0.element.containerID) ?? Int.max |
| | | let rhsPriority = bottomPinnedPriority(for: $1.element.containerID) ?? Int.max |
| | | if lhsPriority != rhsPriority { return lhsPriority < rhsPriority } |
| | | return $0.offset < $1.offset |
| | | } |
| | | let bottomPinnedIndexes = Set(bottomPinnedGroups.map { $0.offset }) |
| | | |
| | | for (index, group) in groups.enumerated() { |
| | | if bottomPinnedIndexes.contains(index) { continue } |
| | | |
| | | let columnIndex = columnHeights.indices.min { columnHeights[$0] < columnHeights[$1] } ?? 0 |
| | | let x = outerPadding + CGFloat(columnIndex) * (columnWidth + gap) |
| | | let y = columnHeights[columnIndex] |
| | |
| | | frame: NSRect(x: x, y: y, width: columnWidth, height: height) |
| | | ) |
| | | ) |
| | | lastItemIndexForColumn[columnIndex] = items.count - 1 |
| | | columnHeights[columnIndex] = y + height + gap |
| | | } |
| | | |
| | | let tallest = columnHeights.max() ?? outerPadding |
| | | let contentHeight = (groups.isEmpty ? outerPadding * 2 : tallest - gap + outerPadding) |
| | | var contentBottom = columnHeights.max() ?? outerPadding |
| | | if !bottomPinnedGroups.isEmpty { |
| | | let alignedBottom = max(outerPadding, contentBottom - gap) |
| | | for itemIndex in lastItemIndexForColumn.compactMap({ $0 }) { |
| | | let currentFrame = items[itemIndex].frame |
| | | let alignedHeight = alignedBottom - currentFrame.minY |
| | | if alignedHeight > currentFrame.height { |
| | | items[itemIndex].frame.size.height = alignedHeight |
| | | } |
| | | } |
| | | } |
| | | for (index, group) in bottomPinnedGroups { |
| | | let y = max(outerPadding, contentBottom) |
| | | let height = AppGridCollectionMetrics.cardHeight( |
| | | appCount: group.apps.count, |
| | | width: availableWidth, |
| | | iconSize: iconSize |
| | | ) |
| | | items.append( |
| | | LayoutItem( |
| | | index: index, |
| | | frame: NSRect(x: outerPadding, y: y, width: availableWidth, height: height) |
| | | ) |
| | | ) |
| | | contentBottom = y + height + gap |
| | | } |
| | | |
| | | let contentHeight = (items.isEmpty ? outerPadding * 2 : contentBottom - gap + outerPadding) |
| | | + max(0, bottomContentPadding) |
| | | return LayoutPlan( |
| | | items: items, |
| | | contentSize: NSSize(width: boundedContentWidth, height: max(1, contentHeight)) |
| | | ) |
| | | } |
| | | |
| | | private static func bottomPinnedPriority(for containerID: String) -> Int? { |
| | | switch containerID { |
| | | case AppContainerID.uncategorized: |
| | | return 0 |
| | | case AppContainerID.appleBuiltIn: |
| | | return 1 |
| | | default: |
| | | return nil |
| | | } |
| | | } |
| | | |
| | | private struct RowItem { |
| | |
| | | override func draw(_ dirtyRect: NSRect) { |
| | | guard let coordinator, let group else { return } |
| | | let displayStyle = coordinator.displayStyle |
| | | let tagColor = TagColor.nsColor(for: coordinator.tagColors[group.name] ?? 0) |
| | | let tagColor = coordinator.tagColor(for: group.name) |
| | | let isNavigationHighlighted = coordinator.highlightedGroupName == group.name |
| | | let isColorlessActive = coordinator.isColorlessContainerMode |
| | | && (isHovered || isNavigationHighlighted) |
| | |
| | | if displayStyle.usesCardSurface { |
| | | let rect = bounds.insetBy(dx: 0.5, dy: 0.5) |
| | | let path = NSBezierPath(roundedRect: rect, xRadius: 14, yRadius: 14) |
| | | cardSurfaceColor().setFill() |
| | | path.fill() |
| | | if coordinator.isColoredContainerMode || isColorlessActive { |
| | | tagColor.withAlphaComponent(0.30).setFill() |
| | | if coordinator.appGridTheme.usesDarkGlass { |
| | | drawDarkCardSurface( |
| | | path: path, |
| | | tagColor: tagColor, |
| | | isActive: isHovered || isNavigationHighlighted, |
| | | isTinted: coordinator.isColoredContainerMode || isColorlessActive |
| | | ) |
| | | } else { |
| | | cardSurfaceColor().setFill() |
| | | path.fill() |
| | | if coordinator.isColoredContainerMode || isColorlessActive { |
| | | tagColor.withAlphaComponent(0.30).setFill() |
| | | path.fill() |
| | | } |
| | | NSColor.labelColor.withAlphaComponent(0.08).setStroke() |
| | | path.lineWidth = 1 |
| | | path.stroke() |
| | | } |
| | | NSColor.labelColor.withAlphaComponent(0.08).setStroke() |
| | | path.lineWidth = 1 |
| | | path.stroke() |
| | | } |
| | | |
| | | drawHeader(title: group.name, displayStyle: displayStyle) |
| | |
| | | && (isHovered || isNavigationHighlighted) |
| | | let shouldShadow = coordinator.displayStyle.usesCardSurface |
| | | && ((coordinator.isColoredContainerMode && (isHovered || isNavigationHighlighted)) || isColorlessActive) |
| | | if coordinator.appGridTheme.usesDarkGlass && coordinator.displayStyle.usesCardSurface { |
| | | let tagColor = coordinator.tagColor(for: group?.name ?? "") |
| | | let active = isHovered || isNavigationHighlighted |
| | | layer?.shadowColor = active |
| | | ? tagColor.withAlphaComponent(0.82).cgColor |
| | | : NSColor.black.withAlphaComponent(0.72).cgColor |
| | | layer?.shadowOpacity = active ? 0.50 : 0.28 |
| | | layer?.shadowRadius = active ? 24 : 14 |
| | | layer?.shadowOffset = NSSize(width: 0, height: active ? -8 : -4) |
| | | return |
| | | } |
| | | layer?.shadowColor = NSColor.black.cgColor |
| | | layer?.shadowOpacity = shouldShadow ? 0.22 : 0 |
| | | layer?.shadowRadius = shouldShadow ? 8 : 0 |
| | |
| | | return NSColor.white.withAlphaComponent(0.62) |
| | | } |
| | | |
| | | private func drawDarkCardSurface( |
| | | path: NSBezierPath, |
| | | tagColor: NSColor, |
| | | isActive: Bool, |
| | | isTinted: Bool |
| | | ) { |
| | | NSGraphicsContext.saveGraphicsState() |
| | | if isActive { |
| | | let glow = NSShadow() |
| | | glow.shadowColor = tagColor.withAlphaComponent(0.36) |
| | | glow.shadowBlurRadius = 28 |
| | | glow.shadowOffset = NSSize(width: 0, height: -8) |
| | | glow.set() |
| | | } |
| | | NSColor(calibratedRed: 0.12, green: 0.18, blue: 0.25, alpha: 0.58).setFill() |
| | | path.fill() |
| | | NSGraphicsContext.restoreGraphicsState() |
| | | |
| | | if isTinted { |
| | | tagColor.withAlphaComponent(isActive ? 0.18 : 0.10).setFill() |
| | | path.fill() |
| | | } |
| | | |
| | | NSColor.white.withAlphaComponent(isActive ? 0.08 : 0.045).setFill() |
| | | path.fill() |
| | | |
| | | let strokeColor = isActive |
| | | ? tagColor.withAlphaComponent(0.72) |
| | | : NSColor.white.withAlphaComponent(0.18) |
| | | strokeColor.setStroke() |
| | | path.lineWidth = isActive ? 1.4 : 1.0 |
| | | path.stroke() |
| | | } |
| | | |
| | | private func drawHeader(title: String, displayStyle: AppGridCollectionDisplayMode) { |
| | | let horizontalInset = displayStyle.usesCardSurface ? AppGridCollectionMetrics.cardPadding : 0 |
| | | let verticalInset = displayStyle.usesCardSurface ? AppGridCollectionMetrics.cardPadding : 0 |
| | |
| | | width: max(1, bounds.width - horizontalInset * 2), |
| | | height: AppGridCollectionMetrics.headerHeight |
| | | ) |
| | | let isDarkGrid = coordinator?.appGridTheme.usesDarkGlass == true |
| | | let headerTextColor = isDarkGrid |
| | | ? NSColor.white.withAlphaComponent(0.74) |
| | | : NSColor.secondaryLabelColor |
| | | let attributes: [NSAttributedString.Key: Any] = [ |
| | | .font: NSFont.systemFont(ofSize: 18, weight: .semibold), |
| | | .foregroundColor: NSColor.secondaryLabelColor, |
| | | .foregroundColor: headerTextColor, |
| | | .paragraphStyle: centeredParagraph(lineBreak: .byTruncatingMiddle) |
| | | ] |
| | | let titleSize = title.size(withAttributes: attributes) |
| | |
| | | height: headerRect.height - 6 |
| | | ) |
| | | let lineY = headerRect.midY |
| | | NSColor.secondaryLabelColor.withAlphaComponent(0.25).setStroke() |
| | | (isDarkGrid ? NSColor.white : NSColor.secondaryLabelColor) |
| | | .withAlphaComponent(isDarkGrid ? 0.16 : 0.25) |
| | | .setStroke() |
| | | let leftLine = NSBezierPath() |
| | | leftLine.move(to: NSPoint(x: headerRect.minX, y: lineY)) |
| | | leftLine.line(to: NSPoint(x: max(headerRect.minX, titleRect.minX - 2), y: lineY)) |