| | |
| | | |
| | | 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 = 68 |
| | | static let reservedHeight: CGFloat = 104 |
| | | static let bottomMargin: CGFloat = 18 |
| | | static let horizontalInset: CGFloat = AppGridCollectionMetrics.outerPadding |
| | | } |
| | | |
| | | struct AppGridCollectionView: NSViewRepresentable { |
| | |
| | | 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 onEditNote: (AppInfo, CGRect) -> Void |
| | | let onDropApp: (String, String, String, Bool) -> Void |
| | | let onDropOutsideGroup: (String, String, Bool) -> Void |
| | | let onReorderApps: (String, [String]) -> Void |
| | | let onGroupActivate: (String) -> Void |
| | | let onScrollActivity: () -> Void |
| | | let onDragModeChange: (Bool) -> 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, |
| | |
| | | bubbleDisabled: bubbleDisabled, |
| | | showUncommonAppBubbles: showUncommonAppBubbles, |
| | | highlightedGroupName: highlightedGroupName, |
| | | bottomContentPadding: bottomContentPadding, |
| | | usageTipsVisible: usageTipsVisible, |
| | | usageTips: usageTips, |
| | | selectedUsageTipIndex: selectedUsageTipIndex, |
| | | contentRevision: contentRevision, |
| | | scrollTargetID: scrollTargetID, |
| | | scrollRequestToken: scrollRequestToken, |
| | |
| | | onEditNote: onEditNote, |
| | | onDropApp: onDropApp, |
| | | onDropOutsideGroup: onDropOutsideGroup, |
| | | onReorderApps: onReorderApps, |
| | | onGroupActivate: onGroupActivate, |
| | | onScrollActivity: onScrollActivity, |
| | | onDragModeChange: onDragModeChange |
| | | onDragModeChange: onDragModeChange, |
| | | onUsageTipIndexChange: { selectedUsageTipIndexBinding.wrappedValue = $0 }, |
| | | onUsageTipsHoverChange: onUsageTipsHoverChange |
| | | ) |
| | | view.applyCoordinatorUpdate() |
| | | } |
| | |
| | | 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 onEditNote: (AppInfo, CGRect) -> Void = { _, _ in } |
| | | var onDropApp: (String, String, String, Bool) -> Void = { _, _, _, _ in } |
| | | var onDropOutsideGroup: (String, String, Bool) -> Void = { _, _, _ in } |
| | | var onReorderApps: (String, [String]) -> Void = { _, _ in } |
| | | var onGroupActivate: (String) -> Void = { _ in } |
| | | var onScrollActivity: () -> Void = {} |
| | | var onDragModeChange: (Bool) -> Void = { _ in } |
| | | var onUsageTipIndexChange: (Int) -> Void = { _ in } |
| | | var onUsageTipsHoverChange: (Bool) -> Void = { _ in } |
| | | |
| | | private weak var activeReorderCard: AppGridGroupCardView? |
| | | private var activeDragPath = "" |
| | | private var activeDragSourceContainerID = "" |
| | | private var lastReorderContainerID = "" |
| | | private var lastReorderScreenFrame: NSRect? |
| | | |
| | | func update( |
| | | groups: [TagGroup], |
| | |
| | | bubbleDisabled: Bool, |
| | | showUncommonAppBubbles: Bool, |
| | | highlightedGroupName: String?, |
| | | bottomContentPadding: CGFloat, |
| | | usageTipsVisible: Bool, |
| | | usageTips: [AppGridUsageTip], |
| | | selectedUsageTipIndex: Int, |
| | | contentRevision: Int, |
| | | scrollTargetID: String?, |
| | | scrollRequestToken: Int, |
| | |
| | | onEditNote: @escaping (AppInfo, CGRect) -> Void, |
| | | onDropApp: @escaping (String, String, String, Bool) -> Void, |
| | | onDropOutsideGroup: @escaping (String, String, Bool) -> Void, |
| | | onReorderApps: @escaping (String, [String]) -> Void, |
| | | onGroupActivate: @escaping (String) -> Void, |
| | | onScrollActivity: @escaping () -> Void, |
| | | onDragModeChange: @escaping (Bool) -> Void |
| | | onDragModeChange: @escaping (Bool) -> Void, |
| | | onUsageTipIndexChange: @escaping (Int) -> Void, |
| | | onUsageTipsHoverChange: @escaping (Bool) -> Void |
| | | ) { |
| | | self.groups = groups |
| | | self.tagColors = tagColors |
| | |
| | | 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.onEditNote = onEditNote |
| | | self.onDropApp = onDropApp |
| | | self.onDropOutsideGroup = onDropOutsideGroup |
| | | self.onReorderApps = onReorderApps |
| | | self.onGroupActivate = onGroupActivate |
| | | self.onScrollActivity = onScrollActivity |
| | | self.onDragModeChange = onDragModeChange |
| | | self.onUsageTipIndexChange = onUsageTipIndexChange |
| | | self.onUsageTipsHoverChange = onUsageTipsHoverChange |
| | | if !self.usageTipsVisible { |
| | | usageTipsBubbleDisabled = false |
| | | } |
| | | |
| | | let nextSignature = Self.signature( |
| | | tagColors: tagColors, |
| | |
| | | iconSize: iconSize, |
| | | showNames: showNames, |
| | | showUncommonAppBubbles: showUncommonAppBubbles, |
| | | bottomContentPadding: self.bottomContentPadding, |
| | | contentRevision: contentRevision |
| | | ) |
| | | if nextSignature != contentSignature { |
| | |
| | | if scrollBubbleDisabled { |
| | | reasons.insert(.scroll) |
| | | } |
| | | if usageTipsBubbleDisabled { |
| | | reasons.insert(.usageTipsHover) |
| | | } |
| | | return reasons |
| | | } |
| | | |
| | |
| | | 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) { |
| | | activeDragPath = path |
| | | activeDragSourceContainerID = sourceContainerID |
| | | lastReorderContainerID = "" |
| | | lastReorderScreenFrame = nil |
| | | clearReorderInsertion() |
| | | onDragModeChange(true) |
| | | } |
| | | |
| | | fileprivate func endAppIconDrag() { |
| | | clearReorderInsertion() |
| | | activeDragPath = "" |
| | | activeDragSourceContainerID = "" |
| | | lastReorderContainerID = "" |
| | | lastReorderScreenFrame = nil |
| | | onDragModeChange(false) |
| | | } |
| | | |
| | | fileprivate func cancelAppIconDrag() { |
| | | clearReorderInsertion() |
| | | activeDragPath = "" |
| | | activeDragSourceContainerID = "" |
| | | lastReorderContainerID = "" |
| | | lastReorderScreenFrame = nil |
| | | onDragModeChange(false) |
| | | } |
| | | |
| | | fileprivate func setReorderInsertion(card: AppGridGroupCardView, insertionIndex: Int) { |
| | | if let activeReorderCard, activeReorderCard !== card { |
| | | activeReorderCard.clearReorderInsertion() |
| | | } |
| | | activeReorderCard = card |
| | | lastReorderContainerID = card.containerID |
| | | lastReorderScreenFrame = card.screenFrame() |
| | | card.setReorderInsertion(index: insertionIndex) |
| | | } |
| | | |
| | | fileprivate func clearReorderInsertion(card: AppGridGroupCardView? = nil) { |
| | | if let card { |
| | | card.clearReorderInsertion() |
| | | if activeReorderCard === card { |
| | | activeReorderCard = nil |
| | | } |
| | | if lastReorderContainerID == card.containerID { |
| | | lastReorderContainerID = "" |
| | | lastReorderScreenFrame = nil |
| | | } |
| | | return |
| | | } |
| | | activeReorderCard?.clearReorderInsertion() |
| | | activeReorderCard = nil |
| | | lastReorderContainerID = "" |
| | | lastReorderScreenFrame = nil |
| | | } |
| | | |
| | | fileprivate func activeReorderPath(in containerID: String, copy: Bool) -> String? { |
| | | guard !copy, |
| | | !activeDragPath.isEmpty, |
| | | activeDragSourceContainerID == containerID |
| | | else { return nil } |
| | | return activeDragPath |
| | | } |
| | | |
| | | fileprivate func shouldCancelEmptyDropForActiveReorder( |
| | | path: String, |
| | | screenPoint: NSPoint, |
| | | copy: Bool |
| | | ) -> Bool { |
| | | guard !copy, |
| | | !activeDragPath.isEmpty, |
| | | activeDragPath == path, |
| | | activeDragSourceContainerID == lastReorderContainerID, |
| | | let lastReorderScreenFrame |
| | | else { return false } |
| | | |
| | | let guardOutset = AppGridCollectionMetrics.reorderEmptyDropCancelOutset |
| | | return lastReorderScreenFrame.insetBy(dx: -guardOutset, dy: -guardOutset).contains(screenPoint) |
| | | } |
| | | |
| | | private static func signature( |
| | | tagColors: [String: Int], |
| | | displayMode: String, |
| | | iconSize: CGFloat, |
| | | showNames: Bool, |
| | | showUncommonAppBubbles: Bool, |
| | | bottomContentPadding: CGFloat, |
| | | contentRevision: Int |
| | | ) -> String { |
| | | let colorPart = tagColors |
| | |
| | | "\(Int(iconSize.rounded()))", |
| | | showNames ? "names" : "nonames", |
| | | showUncommonAppBubbles ? "uncommon" : "allbubbles", |
| | | "bottom=\(Int(bottomContentPadding.rounded()))", |
| | | colorPart, |
| | | "rev=\(contentRevision)" |
| | | ].joined(separator: "|") |
| | | } |
| | | |
| | | 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 usageTipsView = AppGridUsageTipsNSView() |
| | | private weak var coordinator: AppGridCollectionView.Coordinator? |
| | | private var scrollObserver: NSObjectProtocol? |
| | | private var lastLayoutSize: NSSize = .zero |
| | |
| | | NotificationCenter.default.removeObserver(scrollObserver) |
| | | } |
| | | 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 |
| | | } |
| | |
| | | } |
| | | } |
| | | } |
| | | 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 { |
| | | AppDragCoordinator.shared.cancelDrag() |
| | | coordinator?.cancelAppIconDrag() |
| | | AppDragCoordinator.shared.unregisterEmptyDropTarget(id: emptyDropTargetID) |
| | | } else { |
| | | AppDragCoordinator.shared.registerEmptyDropTarget(id: emptyDropTargetID, view: self) |
| | |
| | | guard let coordinator, |
| | | coordinator.displayStyle != .flat |
| | | else { return } |
| | | if coordinator.shouldCancelEmptyDropForActiveReorder( |
| | | path: path, |
| | | screenPoint: screenPoint, |
| | | copy: copy |
| | | ) { |
| | | coordinator.cancelAppIconDrag() |
| | | return |
| | | } |
| | | DispatchQueue.main.async { |
| | | coordinator.onDropOutsideGroup(path, source, copy) |
| | | } |
| | |
| | | scrollView.borderType = .noBorder |
| | | scrollView.documentView = collectionView |
| | | addSubview(scrollView) |
| | | usageTipsView.isHidden = true |
| | | addSubview(usageTipsView) |
| | | |
| | | scrollView.contentView.postsBoundsChangedNotifications = true |
| | | scrollObserver = NotificationCenter.default.addObserver( |
| | |
| | | } |
| | | } |
| | | |
| | | private func positionUsageTipsView() { |
| | | guard let coordinator, |
| | | coordinator.usageTipsVisible |
| | | else { |
| | | usageTipsView.isHidden = true |
| | | usageTipsView.clearHoverState() |
| | | usageTipsView.frame = .zero |
| | | return |
| | | } |
| | | |
| | | let inset = min(AppGridUsageTipsMetrics.horizontalInset, max(0, bounds.width / 4)) |
| | | let width = max(1, bounds.width - inset * 2) |
| | | let height = AppGridUsageTipsMetrics.barHeight |
| | | let y = max(0, bounds.height - AppGridUsageTipsMetrics.bottomMargin - height) |
| | | usageTipsView.isHidden = false |
| | | usageTipsView.frame = NSRect(x: inset, y: y, width: width, height: height) |
| | | } |
| | | |
| | | private func handleScrollActivity() { |
| | | if AppDragCoordinator.shared.hasActiveDrag { |
| | | AppDragCoordinator.shared.cancelDrag() |
| | | } |
| | | coordinator?.cancelAppIconDrag() |
| | | |
| | | if !scrollActivityIsActive { |
| | | scrollActivityIsActive = true |
| | | if coordinator?.setScrollBubbleDisabled(true) == true { |
| | |
| | | } |
| | | } |
| | | |
| | | fileprivate func refreshVisibleRuntimeStateForUsageTips() { |
| | | refreshVisibleRuntimeState() |
| | | } |
| | | |
| | | private func replayPointerHover() { |
| | | guard let window else { return } |
| | | let windowPoint = window.convertPoint(fromScreen: NSEvent.mouseLocation) |
| | |
| | | lastReportedBoundsOrigin = origin |
| | | } |
| | | return didScroll |
| | | } |
| | | } |
| | | |
| | | private final class AppGridUsageTipsNSView: NSView { |
| | | weak var coordinator: AppGridCollectionView.Coordinator? |
| | | |
| | | private let titleBackgroundView = NSView() |
| | | private let titleLabel = NSTextField(labelWithString: "") |
| | | private let detailScrollView = NSScrollView() |
| | | private let detailLabel = NSTextField(labelWithString: "") |
| | | private let controlsView = NSView() |
| | | 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 |
| | | |
| | | override var isFlipped: Bool { true } |
| | | override var acceptsFirstResponder: Bool { false } |
| | | |
| | | override init(frame frameRect: NSRect) { |
| | | super.init(frame: frameRect) |
| | | setup() |
| | | } |
| | | |
| | | required init?(coder: NSCoder) { |
| | | super.init(coder: coder) |
| | | setup() |
| | | } |
| | | |
| | | 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, .activeAlways, .inVisibleRect], |
| | | owner: self, |
| | | userInfo: nil |
| | | ) |
| | | addTrackingArea(area) |
| | | trackingAreaRef = area |
| | | } |
| | | |
| | | override func hitTest(_ point: NSPoint) -> NSView? { |
| | | guard !isHidden, bounds.contains(point) else { return nil } |
| | | return super.hitTest(point) ?? self |
| | | } |
| | | |
| | | override func mouseEntered(with event: NSEvent) { |
| | | setPointerInside(true) |
| | | } |
| | | |
| | | override func mouseExited(with event: NSEvent) { |
| | | setPointerInside(false) |
| | | } |
| | | |
| | | override func mouseDown(with event: NSEvent) { |
| | | window?.makeKeyAndOrderFront(nil) |
| | | } |
| | | |
| | | override func mouseUp(with event: NSEvent) {} |
| | | override func rightMouseDown(with event: NSEvent) {} |
| | | override func rightMouseUp(with event: NSEvent) {} |
| | | |
| | | override func scrollWheel(with event: NSEvent) { |
| | | detailScrollView.scrollWheel(with: event) |
| | | } |
| | | |
| | | override func layout() { |
| | | super.layout() |
| | | let titleWidth = min(320, max(240, bounds.width * 0.24)) |
| | | let controlsWidth: CGFloat = 120 |
| | | let detailWidth = max(1, bounds.width - titleWidth - controlsWidth) |
| | | |
| | | titleBackgroundView.frame = NSRect(x: 0, y: 0, width: titleWidth, height: bounds.height) |
| | | titleLabel.frame = titleBackgroundView.bounds.insetBy(dx: 18, dy: 0) |
| | | detailScrollView.frame = NSRect(x: titleWidth, y: 0, width: detailWidth, height: bounds.height) |
| | | controlsView.frame = NSRect(x: titleWidth + detailWidth, y: 0, width: controlsWidth, height: bounds.height) |
| | | |
| | | let buttonSize: CGFloat = 34 |
| | | let buttonY: CGFloat = 9 |
| | | previousButton.frame = NSRect(x: 22, y: buttonY, width: buttonSize, height: buttonSize) |
| | | nextButton.frame = NSRect(x: 64, y: buttonY, width: buttonSize, height: buttonSize) |
| | | dotsView.frame = NSRect(x: 18, y: 48, width: controlsWidth - 36, height: 10) |
| | | |
| | | 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) |
| | | detailLabel.stringValue = tr(tip.detailKey) |
| | | previousButton.accessibilityLabel = tr("usageTips.previous") |
| | | nextButton.accessibilityLabel = tr("usageTips.next") |
| | | dotsView.configure(count: coordinator.usageTips.count, selectedIndex: safeIndex) |
| | | updateColors() |
| | | layoutDetailLabel() |
| | | } |
| | | |
| | | func clearHoverState() { |
| | | guard isPointerInside else { return } |
| | | setPointerInside(false) |
| | | } |
| | | |
| | | private func setup() { |
| | | wantsLayer = true |
| | | layer?.cornerRadius = 8 |
| | | layer?.masksToBounds = true |
| | | |
| | | titleBackgroundView.wantsLayer = true |
| | | controlsView.wantsLayer = true |
| | | |
| | | titleLabel.isEditable = false |
| | | titleLabel.isSelectable = false |
| | | titleLabel.drawsBackground = false |
| | | titleLabel.isBordered = false |
| | | titleLabel.lineBreakMode = .byTruncatingTail |
| | | titleLabel.maximumNumberOfLines = 1 |
| | | titleLabel.font = NSFont.systemFont(ofSize: 24, weight: .bold) |
| | | |
| | | detailLabel.isEditable = false |
| | | detailLabel.isSelectable = false |
| | | detailLabel.drawsBackground = false |
| | | detailLabel.isBordered = false |
| | | detailLabel.lineBreakMode = .byClipping |
| | | detailLabel.maximumNumberOfLines = 1 |
| | | detailLabel.font = NSFont.systemFont(ofSize: 24, weight: .bold) |
| | | |
| | | detailScrollView.drawsBackground = false |
| | | detailScrollView.hasVerticalScroller = false |
| | | detailScrollView.hasHorizontalScroller = true |
| | | detailScrollView.autohidesScrollers = true |
| | | detailScrollView.borderType = .noBorder |
| | | detailScrollView.documentView = detailLabel |
| | | |
| | | previousButton.action = { [weak self] in self?.selectUsageTip(offset: -1) } |
| | | nextButton.action = { [weak self] in self?.selectUsageTip(offset: 1) } |
| | | |
| | | addSubview(titleBackgroundView) |
| | | titleBackgroundView.addSubview(titleLabel) |
| | | addSubview(detailScrollView) |
| | | addSubview(controlsView) |
| | | controlsView.addSubview(previousButton) |
| | | controlsView.addSubview(nextButton) |
| | | controlsView.addSubview(dotsView) |
| | | |
| | | setAccessibilityRole(.group) |
| | | updateColors() |
| | | } |
| | | |
| | | private func selectUsageTip(offset: Int) { |
| | | coordinator?.selectUsageTip(offset: offset) |
| | | applyCoordinatorState() |
| | | } |
| | | |
| | | 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 textWidth = detailLabel.attributedStringValue.size().width |
| | | let width = max(detailScrollView.contentView.bounds.width, ceil(textWidth) + 36) |
| | | detailLabel.frame = NSRect(x: 18, y: 0, width: width, height: bounds.height) |
| | | } |
| | | |
| | | private func updateColors() { |
| | | layer?.backgroundColor = NSColor.black.withAlphaComponent(0.88).cgColor |
| | | layer?.borderColor = NSColor.labelColor.withAlphaComponent(0.18).cgColor |
| | | layer?.borderWidth = 1 |
| | | titleBackgroundView.layer?.backgroundColor = NSColor.controlBackgroundColor.withAlphaComponent(0.96).cgColor |
| | | controlsView.layer?.backgroundColor = NSColor.black.withAlphaComponent(0.88).cgColor |
| | | titleLabel.textColor = .labelColor |
| | | detailLabel.textColor = NSColor.white.withAlphaComponent(0.96) |
| | | } |
| | | } |
| | | |
| | | private final class AppGridUsageTipIconButton: NSView { |
| | | var action: (() -> Void)? |
| | | var accessibilityLabel: String = "" { |
| | | didSet { |
| | | setAccessibilityLabel(accessibilityLabel) |
| | | } |
| | | } |
| | | |
| | | private let imageView = NSImageView() |
| | | private var trackingAreaRef: NSTrackingArea? |
| | | private var isHovered = false |
| | | |
| | | override var isFlipped: Bool { true } |
| | | override var acceptsFirstResponder: Bool { false } |
| | | |
| | | init(systemImage: String) { |
| | | super.init(frame: .zero) |
| | | setup(systemImage: systemImage) |
| | | } |
| | | |
| | | required init?(coder: NSCoder) { fatalError() } |
| | | |
| | | override func layout() { |
| | | super.layout() |
| | | let size: CGFloat = 24 |
| | | 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 |
| | | } |
| | | |
| | | override func mouseExited(with event: NSEvent) { |
| | | isHovered = false |
| | | needsDisplay = true |
| | | } |
| | | |
| | | override func mouseDown(with event: NSEvent) { |
| | | action?() |
| | | } |
| | | |
| | | override func draw(_ dirtyRect: NSRect) { |
| | | guard isHovered else { return } |
| | | NSColor.white.withAlphaComponent(0.12).setFill() |
| | | NSBezierPath(roundedRect: bounds.insetBy(dx: 1, dy: 1), xRadius: 7, yRadius: 7).fill() |
| | | } |
| | | |
| | | private func setup(systemImage: String) { |
| | | wantsLayer = true |
| | | let config = NSImage.SymbolConfiguration(pointSize: 24, weight: .bold) |
| | | imageView.image = NSImage( |
| | | systemSymbolName: systemImage, |
| | | accessibilityDescription: nil |
| | | )?.withSymbolConfiguration(config) |
| | | imageView.imageScaling = .scaleProportionallyDown |
| | | imageView.contentTintColor = .white |
| | | addSubview(imageView) |
| | | setAccessibilityRole(.button) |
| | | } |
| | | } |
| | | |
| | | private final class AppGridUsageTipDotsView: NSView { |
| | | private var count = 0 |
| | | private var selectedIndex = 0 |
| | | |
| | | override var isFlipped: Bool { true } |
| | | |
| | | 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 dotSize: CGFloat = 5 |
| | | let spacing: CGFloat = 5 |
| | | let totalWidth = CGFloat(count) * dotSize + CGFloat(max(0, count - 1)) * spacing |
| | | var x = (bounds.width - totalWidth) / 2 |
| | | let y = (bounds.height - dotSize) / 2 |
| | | |
| | | for index in 0..<count { |
| | | let color = index == selectedIndex |
| | | ? NSColor.controlAccentColor |
| | | : NSColor.white.withAlphaComponent(0.58) |
| | | color.setFill() |
| | | NSBezierPath( |
| | | ovalIn: NSRect(x: x, y: y, width: dotSize, height: dotSize) |
| | | ).fill() |
| | | x += dotSize + spacing |
| | | } |
| | | } |
| | | } |
| | | |
| | |
| | | groups: visibleGroups, |
| | | contentWidth: collectionView.bounds.width, |
| | | iconSize: iconSize, |
| | | displayMode: coordinator?.displayMode ?? AppDefaults.displayMode |
| | | displayMode: coordinator?.displayMode ?? AppDefaults.displayMode, |
| | | bottomContentPadding: coordinator?.bottomContentPadding ?? 0 |
| | | ) |
| | | |
| | | var nextAttributes: [IndexPath: NSCollectionViewLayoutAttributes] = [:] |
| | |
| | | groups: [TagGroup], |
| | | contentWidth: CGFloat, |
| | | iconSize: CGFloat, |
| | | displayMode: String |
| | | displayMode: String, |
| | | bottomContentPadding: CGFloat |
| | | ) -> LayoutPlan { |
| | | switch AppGridCollectionDisplayMode(displayMode) { |
| | | case .flat: |
| | | return makeFlatPlan(groups: groups, contentWidth: contentWidth, iconSize: iconSize) |
| | | return makeFlatPlan( |
| | | groups: groups, |
| | | contentWidth: contentWidth, |
| | | iconSize: iconSize, |
| | | bottomContentPadding: bottomContentPadding |
| | | ) |
| | | case .masonryContainer: |
| | | return makeMasonryPlan(groups: groups, contentWidth: contentWidth, iconSize: iconSize) |
| | | return makeMasonryPlan( |
| | | groups: groups, |
| | | contentWidth: contentWidth, |
| | | iconSize: iconSize, |
| | | bottomContentPadding: bottomContentPadding |
| | | ) |
| | | case .gridContainer: |
| | | return makeGridPlan(groups: groups, contentWidth: contentWidth, iconSize: iconSize) |
| | | return makeGridPlan( |
| | | groups: groups, |
| | | contentWidth: contentWidth, |
| | | iconSize: iconSize, |
| | | bottomContentPadding: bottomContentPadding |
| | | ) |
| | | } |
| | | } |
| | | |
| | | private static func makeGridPlan( |
| | | groups: [TagGroup], |
| | | contentWidth: CGFloat, |
| | | iconSize: CGFloat |
| | | iconSize: CGFloat, |
| | | bottomContentPadding: CGFloat |
| | | ) -> LayoutPlan { |
| | | let boundedContentWidth = max(1, contentWidth) |
| | | let outerPadding = AppGridCollectionMetrics.outerPadding |
| | |
| | | y += height + gap |
| | | } |
| | | |
| | | let contentHeight = rows.isEmpty ? outerPadding * 2 : y - gap + outerPadding |
| | | let contentHeight = (rows.isEmpty ? outerPadding * 2 : y - gap + outerPadding) |
| | | + max(0, bottomContentPadding) |
| | | return LayoutPlan( |
| | | items: items, |
| | | contentSize: NSSize(width: boundedContentWidth, height: max(1, contentHeight)) |
| | |
| | | private static func makeFlatPlan( |
| | | groups: [TagGroup], |
| | | contentWidth: CGFloat, |
| | | iconSize: CGFloat |
| | | iconSize: CGFloat, |
| | | bottomContentPadding: CGFloat |
| | | ) -> LayoutPlan { |
| | | let boundedContentWidth = max(1, contentWidth) |
| | | let outerPadding = AppGridCollectionMetrics.outerPadding |
| | |
| | | y += height + AppGridCollectionMetrics.flatGroupGap |
| | | } |
| | | |
| | | let contentHeight = groups.isEmpty ? outerPadding * 2 : y - AppGridCollectionMetrics.flatGroupGap + outerPadding |
| | | let contentHeight = (groups.isEmpty ? outerPadding * 2 : y - AppGridCollectionMetrics.flatGroupGap + outerPadding) |
| | | + max(0, bottomContentPadding) |
| | | return LayoutPlan( |
| | | items: items, |
| | | contentSize: NSSize(width: boundedContentWidth, height: max(1, contentHeight)) |
| | |
| | | private static func makeMasonryPlan( |
| | | groups: [TagGroup], |
| | | contentWidth: CGFloat, |
| | | iconSize: CGFloat |
| | | iconSize: CGFloat, |
| | | bottomContentPadding: CGFloat |
| | | ) -> LayoutPlan { |
| | | let boundedContentWidth = max(1, contentWidth) |
| | | let outerPadding = AppGridCollectionMetrics.outerPadding |
| | |
| | | } |
| | | |
| | | let tallest = columnHeights.max() ?? outerPadding |
| | | let contentHeight = groups.isEmpty ? outerPadding * 2 : tallest - gap + outerPadding |
| | | let contentHeight = (groups.isEmpty ? outerPadding * 2 : tallest - gap + outerPadding) |
| | | + max(0, bottomContentPadding) |
| | | return LayoutPlan( |
| | | items: items, |
| | | contentSize: NSSize(width: boundedContentWidth, height: max(1, contentHeight)) |
| | |
| | | static let headerBottomGap: CGFloat = 6 |
| | | static let iconColumnGap: CGFloat = 6 |
| | | static let iconRowGap: CGFloat = 2 |
| | | static let reorderEmptyDropCancelOutset: CGFloat = 4 |
| | | static let hoverScale: CGFloat = 1.22 |
| | | static let labelHeight: CGFloat = 14 |
| | | |
| | |
| | | private weak var coordinator: AppGridCollectionView.Coordinator? |
| | | private var group: TagGroup? |
| | | private var iconViews: [AppGridIconNSView] = [] |
| | | private var reorderInsertionIndex: Int? |
| | | private var isMouseInside = false |
| | | private var isHovered = false |
| | | private var trackingAreaRef: NSTrackingArea? |
| | | var containerID: String { group?.containerID ?? "" } |
| | | |
| | | override var isFlipped: Bool { true } |
| | | |
| | |
| | | AppDragCoordinator.shared.unregister(id: dropTargetID) |
| | | group = nil |
| | | coordinator = nil |
| | | reorderInsertionIndex = nil |
| | | isMouseInside = false |
| | | isHovered = false |
| | | layer?.shadowOpacity = 0 |
| | |
| | | } |
| | | |
| | | drawHeader(title: group.name, displayStyle: displayStyle) |
| | | drawReorderInsertionIfNeeded() |
| | | } |
| | | |
| | | func performDrop(path: String, source: String, copy: Bool) { |
| | | performDrop(path: path, source: source, sourceContainerID: "", copy: copy) |
| | | } |
| | | |
| | | func performDrop(path: String, source: String, sourceContainerID: String, copy: Bool) { |
| | | guard let group else { return } |
| | | if sourceContainerID == group.containerID { |
| | | if !copy, |
| | | let orderedPaths = reorderedAppPaths(moving: path) { |
| | | coordinator?.onReorderApps(group.containerID, orderedPaths) |
| | | } |
| | | coordinator?.endAppIconDrag() |
| | | clearReorderInsertion() |
| | | return |
| | | } |
| | | |
| | | coordinator?.onDropApp(path, source, group.name, copy) |
| | | if source != group.name || copy { |
| | | coordinator?.onDragModeChange(false) |
| | | } |
| | | } |
| | | |
| | | func appDragHoverChanged(active: Bool) { |
| | | if !active { |
| | | coordinator?.clearReorderInsertion(card: self) |
| | | } |
| | | } |
| | | |
| | | func appDragLocationChanged(screenPoint: NSPoint, copy: Bool) { |
| | | guard let coordinator, |
| | | let group, |
| | | coordinator.activeReorderPath(in: group.containerID, copy: copy) != nil, |
| | | let window |
| | | else { |
| | | coordinator?.clearReorderInsertion(card: self) |
| | | return |
| | | } |
| | | |
| | | let windowPoint = window.convertPoint(fromScreen: screenPoint) |
| | | let localPoint = convert(windowPoint, from: nil) |
| | | coordinator.setReorderInsertion( |
| | | card: self, |
| | | insertionIndex: insertionIndex(for: localPoint) |
| | | ) |
| | | } |
| | | |
| | | func replayPointerHover(windowPoint: NSPoint) { |
| | | setPointerInside(bounds.contains(convert(windowPoint, from: nil))) |
| | | iconViews.forEach { $0.replayPointerHover(windowPoint: windowPoint) } |
| | | } |
| | | |
| | | func setReorderInsertion(index: Int) { |
| | | let clamped = min(max(0, index), iconViews.count) |
| | | guard reorderInsertionIndex != clamped else { return } |
| | | reorderInsertionIndex = clamped |
| | | needsDisplay = true |
| | | } |
| | | |
| | | func clearReorderInsertion() { |
| | | guard reorderInsertionIndex != nil else { return } |
| | | reorderInsertionIndex = nil |
| | | needsDisplay = true |
| | | } |
| | | |
| | | private func rebuildIconViews() { |
| | |
| | | iconView.configure( |
| | | app: app, |
| | | sourceTag: group.name, |
| | | sourceContainerID: group.containerID, |
| | | iconSize: coordinator.iconSize, |
| | | showName: coordinator.showNames, |
| | | coordinator: coordinator |
| | |
| | | addSubview(iconView) |
| | | return iconView |
| | | } |
| | | } |
| | | |
| | | private func insertionIndex(for point: NSPoint) -> Int { |
| | | guard !iconViews.isEmpty else { return 0 } |
| | | |
| | | var bestIndex = iconViews.count |
| | | var bestDistance = CGFloat.greatestFiniteMagnitude |
| | | |
| | | for index in 0...iconViews.count { |
| | | guard let candidate = insertionCandidatePoint(for: index) else { continue } |
| | | let dx = point.x - candidate.x |
| | | let dy = point.y - candidate.y |
| | | let distance = dx * dx + dy * dy |
| | | if distance < bestDistance { |
| | | bestDistance = distance |
| | | bestIndex = index |
| | | } |
| | | } |
| | | |
| | | return bestIndex |
| | | } |
| | | |
| | | private func reorderedAppPaths(moving path: String) -> [String]? { |
| | | guard let group, |
| | | let insertionIndex = reorderInsertionIndex |
| | | else { return nil } |
| | | |
| | | var paths = group.apps.map { $0.path.path } |
| | | guard let sourceIndex = paths.firstIndex(of: path) else { return nil } |
| | | |
| | | paths.remove(at: sourceIndex) |
| | | var targetIndex = insertionIndex |
| | | if sourceIndex < insertionIndex { |
| | | targetIndex -= 1 |
| | | } |
| | | targetIndex = min(max(0, targetIndex), paths.count) |
| | | paths.insert(path, at: targetIndex) |
| | | |
| | | let originalPaths = group.apps.map { $0.path.path } |
| | | guard paths != originalPaths else { return nil } |
| | | return paths |
| | | } |
| | | |
| | | private func drawReorderInsertionIfNeeded() { |
| | | guard let insertionIndex = reorderInsertionIndex, |
| | | let rect = insertionLineRect(for: insertionIndex) |
| | | else { return } |
| | | |
| | | NSColor.controlAccentColor.withAlphaComponent(0.92).setFill() |
| | | let path = NSBezierPath(roundedRect: rect, xRadius: 2, yRadius: 2) |
| | | path.fill() |
| | | } |
| | | |
| | | private func insertionLineRect(for index: Int) -> NSRect? { |
| | | guard let candidate = insertionCandidatePoint(for: index) else { return nil } |
| | | let referenceFrame: NSRect |
| | | if iconViews.indices.contains(index) { |
| | | referenceFrame = iconViews[index].frame |
| | | } else if let lastFrame = iconViews.last?.frame { |
| | | referenceFrame = lastFrame |
| | | } else { |
| | | return nil |
| | | } |
| | | |
| | | let iconSize = coordinator?.iconSize ?? CGFloat(AppDefaults.iconSize) |
| | | let lineHeight = max(28, min(referenceFrame.height - 8, iconSize + 18)) |
| | | return NSRect( |
| | | x: candidate.x - 2, |
| | | y: referenceFrame.midY - lineHeight / 2, |
| | | width: 4, |
| | | height: lineHeight |
| | | ) |
| | | } |
| | | |
| | | private func insertionCandidatePoint(for index: Int) -> NSPoint? { |
| | | guard !iconViews.isEmpty else { return nil } |
| | | |
| | | if index <= 0 { |
| | | let first = iconViews[0].frame |
| | | return NSPoint(x: first.minX - 3, y: first.midY) |
| | | } |
| | | |
| | | if index >= iconViews.count { |
| | | let last = iconViews[iconViews.count - 1].frame |
| | | return NSPoint(x: last.maxX + 3, y: last.midY) |
| | | } |
| | | |
| | | let previous = iconViews[index - 1].frame |
| | | let next = iconViews[index].frame |
| | | if abs(previous.midY - next.midY) < 4 { |
| | | return NSPoint(x: (previous.maxX + next.minX) / 2, y: previous.midY) |
| | | } |
| | | |
| | | return NSPoint(x: next.minX - 3, y: next.midY) |
| | | } |
| | | |
| | | private func registerDropTargetIfNeeded() { |
| | |
| | | private weak var coordinator: AppGridCollectionView.Coordinator? |
| | | private var app: AppInfo? |
| | | private var sourceTag = "" |
| | | private var sourceContainerID = "" |
| | | private var iconSize: CGFloat = AppDefaults.iconSize |
| | | private var showName = true |
| | | private var isMouseInside = false |
| | |
| | | func configure( |
| | | app: AppInfo, |
| | | sourceTag: String, |
| | | sourceContainerID: String, |
| | | iconSize: CGFloat, |
| | | showName: Bool, |
| | | coordinator: AppGridCollectionView.Coordinator |
| | | ) { |
| | | self.app = app |
| | | self.sourceTag = sourceTag |
| | | self.sourceContainerID = sourceContainerID |
| | | self.iconSize = iconSize |
| | | self.showName = showName |
| | | self.coordinator = coordinator |
| | |
| | | mouseDownEvent = nil |
| | | didStartDrag = false |
| | | isLongPressActive = false |
| | | sourceContainerID = "" |
| | | longPressWorkItem = nil |
| | | } |
| | | |
| | |
| | | |
| | | didStartDrag = true |
| | | longPressWorkItem?.cancel() |
| | | if let app { |
| | | coordinator?.beginAppIconDrag( |
| | | path: app.path.path, |
| | | sourceContainerID: sourceContainerID |
| | | ) |
| | | } |
| | | AppDragCoordinator.shared.beginDrag( |
| | | image: makeDragImage(), |
| | | payload: dragPayload, |
| | |
| | | at: screenPoint(for: event), |
| | | copy: event.modifierFlags.contains(.option) |
| | | ) |
| | | coordinator?.onDragModeChange(false) |
| | | coordinator?.endAppIconDrag() |
| | | } else if !isLongPressActive, let app { |
| | | coordinator?.onSelectApp(app) |
| | | } else { |
| | | coordinator?.onDragModeChange(false) |
| | | coordinator?.cancelAppIconDrag() |
| | | } |
| | | didStartDrag = false |
| | | isLongPressActive = false |
| | |
| | | |
| | | private var dragPayload: String { |
| | | guard let app else { return "" } |
| | | return "\(app.path.path)\n\(sourceTag)" |
| | | return "\(app.path.path)\n\(sourceTag)\n\(sourceContainerID)" |
| | | } |
| | | |
| | | private func setHover(_ hover: Bool, notify: Bool) { |