| | |
| | | import SwiftUI |
| | | import AppKit |
| | | import Carbon.HIToolbox |
| | | |
| | | fileprivate enum AppGridCollectionDisplayMode: Equatable { |
| | | case flat |
| | |
| | | } |
| | | |
| | | enum AppGridUsageTipsMetrics { |
| | | static let barHeight: CGFloat = 68 |
| | | static let reservedHeight: CGFloat = 104 |
| | | static let bottomMargin: CGFloat = 18 |
| | | static let horizontalInset: CGFloat = AppGridCollectionMetrics.outerPadding |
| | | static let barHeight: CGFloat = 136 |
| | | static let reservedHeight: CGFloat = 176 |
| | | static let bottomMargin: CGFloat = 20 |
| | | static let horizontalInset: CGFloat = 24 |
| | | static let minWidth: CGFloat = 640 |
| | | } |
| | | |
| | | struct AppGridCollectionView: NSViewRepresentable { |
| | |
| | | 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 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) |
| | |
| | | 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) { |
| | |
| | | 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(usageTipsView) |
| | | addSubview(usageTipsShieldView, positioned: .above, relativeTo: scrollView) |
| | | addSubview(usageTipsView, positioned: .above, relativeTo: usageTipsShieldView) |
| | | |
| | | scrollView.contentView.postsBoundsChangedNotifications = true |
| | | scrollObserver = NotificationCenter.default.addObserver( |
| | |
| | | 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 width = max(1, bounds.width - inset * 2) |
| | | let availableWidth = max(1, bounds.width - inset * 2) |
| | | let width = usageTipsView.preferredWidth(maxAvailableWidth: availableWidth) |
| | | let height = AppGridUsageTipsMetrics.barHeight |
| | | let y = max(0, bounds.height - AppGridUsageTipsMetrics.bottomMargin - height) |
| | | usageTipsShieldView.isHidden = false |
| | | usageTipsShieldView.frame = usageTipsEventRegion() |
| | | usageTipsView.configureVisualLayout( |
| | | width: width, |
| | | height: height, |
| | | bottomMargin: AppGridUsageTipsMetrics.bottomMargin |
| | | ) |
| | | usageTipsView.isHidden = false |
| | | usageTipsView.frame = NSRect(x: inset, y: y, width: width, height: height) |
| | | 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() { |
| | |
| | | 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) |
| | |
| | | } |
| | | } |
| | | |
| | | 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 titleBackgroundView = NSView() |
| | | private let backgroundView = NSVisualEffectView() |
| | | private let iconView = AppGridDecorativeImageView() |
| | | 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 |
| | | private var visualWidth: CGFloat = AppGridUsageTipsMetrics.minWidth |
| | | private var visualHeight: CGFloat = AppGridUsageTipsMetrics.barHeight |
| | | private var visualBottomMargin: CGFloat = AppGridUsageTipsMetrics.bottomMargin |
| | | |
| | | private let titleFont = NSFont.systemFont(ofSize: 24, weight: .semibold) |
| | | private let detailFont = NSFont.systemFont(ofSize: 24, weight: .regular) |
| | | |
| | | override var isFlipped: Bool { true } |
| | | override var acceptsFirstResponder: Bool { false } |
| | | override var acceptsFirstResponder: Bool { true } |
| | | override var mouseDownCanMoveWindow: Bool { false } |
| | | |
| | | override init(frame frameRect: NSRect) { |
| | | super.init(frame: frameRect) |
| | |
| | | required init?(coder: NSCoder) { |
| | | super.init(coder: coder) |
| | | setup() |
| | | } |
| | | |
| | | override func acceptsFirstMouse(for event: NSEvent?) -> Bool { |
| | | true |
| | | } |
| | | |
| | | override func viewDidMoveToWindow() { |
| | |
| | | } |
| | | let area = NSTrackingArea( |
| | | rect: .zero, |
| | | options: [.mouseEnteredAndExited, .activeAlways, .inVisibleRect], |
| | | options: [.mouseEnteredAndExited, .mouseMoved, .activeAlways, .inVisibleRect], |
| | | owner: self, |
| | | userInfo: nil |
| | | ) |
| | |
| | | |
| | | override func hitTest(_ point: NSPoint) -> NSView? { |
| | | guard !isHidden, bounds.contains(point) else { return nil } |
| | | return super.hitTest(point) ?? self |
| | | 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) { |
| | | setPointerInside(true) |
| | | claimInteractionFocus() |
| | | } |
| | | |
| | | override func mouseMoved(with event: NSEvent) { |
| | | claimInteractionFocus() |
| | | } |
| | | |
| | | override func mouseExited(with event: NSEvent) { |
| | |
| | | } |
| | | |
| | | override func mouseDown(with event: NSEvent) { |
| | | window?.makeKeyAndOrderFront(nil) |
| | | 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) {} |
| | | 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 titleWidth = min(320, max(240, bounds.width * 0.24)) |
| | | let controlsWidth: CGFloat = 120 |
| | | let detailWidth = max(1, bounds.width - titleWidth - controlsWidth) |
| | | let visualFrame = currentVisualFrame() |
| | | backgroundView.frame = visualFrame |
| | | |
| | | 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 paddingLeft: CGFloat = 22 |
| | | let paddingRight: CGFloat = 16 |
| | | let iconSize: CGFloat = 24 |
| | | let iconTextGap: CGFloat = 10 |
| | | let controlZoneWidth: CGFloat = 176 |
| | | let buttonGap: CGFloat = 6 |
| | | let buttonSize: CGFloat = 48 |
| | | let buttonDotsGap: CGFloat = 6 |
| | | let dotsHeight: CGFloat = 10 |
| | | let dotsWidth = dotsView.preferredWidth |
| | | let fullTitleWidth = ceil(titleLabel.attributedStringValue.size().width) |
| | | |
| | | 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) |
| | | let titleX = visualFrame.minX + paddingLeft + iconSize + iconTextGap |
| | | let buttonsWidth = buttonSize * 2 + buttonGap |
| | | let buttonsGroupMinX = visualFrame.maxX - paddingRight - buttonsWidth |
| | | let textRight = visualFrame.maxX - controlZoneWidth |
| | | let availableTextWidth = max(1, textRight - titleX) |
| | | let titleWidth = min(fullTitleWidth, availableTextWidth) |
| | | let titleHeight: CGFloat = 32 |
| | | let detailHeight: CGFloat = 68 |
| | | let lineGap: CGFloat = 8 |
| | | let textBlockHeight = titleHeight + lineGap + detailHeight |
| | | let textBlockY = visualFrame.minY + max(0, (visualFrame.height - textBlockHeight) / 2) |
| | | |
| | | iconView.frame = NSRect( |
| | | x: visualFrame.minX + paddingLeft, |
| | | y: visualFrame.midY - iconSize / 2, |
| | | width: iconSize, |
| | | height: iconSize |
| | | ) |
| | | |
| | | titleLabel.frame = NSRect( |
| | | x: titleX, |
| | | y: textBlockY, |
| | | width: titleWidth, |
| | | height: titleHeight |
| | | ) |
| | | |
| | | 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 |
| | | ) |
| | | |
| | | let detailX = titleX |
| | | let detailRight = textRight |
| | | detailScrollView.frame = NSRect( |
| | | x: detailX, |
| | | y: titleLabel.frame.maxY + lineGap, |
| | | width: max(1, detailRight - detailX), |
| | | height: detailHeight |
| | | ) |
| | | layoutDetailLabel() |
| | | } |
| | | |
| | |
| | | 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") |
| | | detailLabel.stringValue = formattedTipDetail(tr(tip.detailKey)) |
| | | previousButton.buttonAccessibilityLabel = tr("usageTips.previous") |
| | | nextButton.buttonAccessibilityLabel = tr("usageTips.next") |
| | | dotsView.configure(count: coordinator.usageTips.count, selectedIndex: safeIndex) |
| | | updateColors() |
| | | layoutDetailLabel() |
| | | needsLayout = true |
| | | enclosingHostView?.relayoutUsageTipsAfterContentChange() |
| | | } |
| | | |
| | | func clearHoverState() { |
| | |
| | | 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 { |
| | | let padding: CGFloat = 22 + 16 |
| | | let fixedWidth: CGFloat = 24 + 10 + 24 + 48 + 6 + 48 |
| | | let titleWidth = ceil(titleLabel.attributedStringValue.size().width) |
| | | let detailWidth = measuredDetailLineWidth() |
| | | let contentWidth = padding + fixedWidth + max(titleWidth, detailWidth) |
| | | let preferredWidth = max(AppGridUsageTipsMetrics.minWidth, contentWidth) |
| | | return min(maxAvailableWidth, preferredWidth) |
| | | } |
| | | |
| | | private func setup() { |
| | | wantsLayer = true |
| | | layer?.cornerRadius = 8 |
| | | layer?.masksToBounds = true |
| | | layer?.shadowColor = NSColor.black.cgColor |
| | | layer?.shadowOpacity = 0.14 |
| | | layer?.shadowRadius = 12 |
| | | layer?.shadowOffset = NSSize(width: 0, height: -2) |
| | | |
| | | titleBackgroundView.wantsLayer = true |
| | | controlsView.wantsLayer = true |
| | | 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) |
| | | |
| | | 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) |
| | | let iconConfig = NSImage.SymbolConfiguration(pointSize: 24, weight: .semibold) |
| | | iconView.image = NSImage( |
| | | systemSymbolName: "lightbulb.fill", |
| | | accessibilityDescription: nil |
| | | )?.withSymbolConfiguration(iconConfig) |
| | | iconView.imageScaling = .scaleProportionallyDown |
| | | addSubview(iconView) |
| | | |
| | | 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) |
| | | configureLabel(titleLabel, font: titleFont, lineBreakMode: .byClipping) |
| | | configureDetailLabel() |
| | | |
| | | detailScrollView.drawsBackground = false |
| | | detailScrollView.hasVerticalScroller = false |
| | |
| | | previousButton.action = { [weak self] in self?.selectUsageTip(offset: -1) } |
| | | nextButton.action = { [weak self] in self?.selectUsageTip(offset: 1) } |
| | | |
| | | addSubview(titleBackgroundView) |
| | | titleBackgroundView.addSubview(titleLabel) |
| | | addSubview(titleLabel) |
| | | addSubview(detailScrollView) |
| | | addSubview(controlsView) |
| | | controlsView.addSubview(previousButton) |
| | | controlsView.addSubview(nextButton) |
| | | controlsView.addSubview(dotsView) |
| | | addSubview(dotsView) |
| | | 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 = 1 |
| | | label.font = font |
| | | } |
| | | |
| | | private func configureDetailLabel() { |
| | | detailLabel.cell = NSTextFieldCell(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 routeButtonClickIfNeeded(_ event: NSEvent) -> Bool { |
| | | let point = usageTipsPoint(for: event) |
| | | let hitOutset: CGFloat = 24 |
| | | 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) { |
| | |
| | | } |
| | | |
| | | 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) |
| | | 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 measuredDetailLineWidth() -> CGFloat { |
| | | let attributes: [NSAttributedString.Key: Any] = [.font: detailFont] |
| | | let lineWidths = detailLabel.stringValue |
| | | .components(separatedBy: .newlines) |
| | | .map { NSAttributedString(string: $0, attributes: attributes).size().width } |
| | | return ceil(lineWidths.max() ?? detailLabel.attributedStringValue.size().width) |
| | | } |
| | | |
| | | 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 { |
| | | 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) |
| | | } |
| | | |
| | | 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) |
| | | backgroundView.layer?.borderColor = NSColor.separatorColor.withAlphaComponent(0.35).cgColor |
| | | iconView.contentTintColor = .systemYellow |
| | | titleLabel.textColor = .secondaryLabelColor |
| | | detailLabel.textColor = .labelColor |
| | | previousButton.tintColor = .labelColor |
| | | nextButton.tintColor = .labelColor |
| | | dotsView.needsDisplay = true |
| | | } |
| | | } |
| | | |
| | | 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 AppGridDecorativeImageView: NSImageView { |
| | | override func hitTest(_ point: NSPoint) -> NSView? { |
| | | nil |
| | | } |
| | | |
| | | override func isAccessibilityElement() -> Bool { |
| | | false |
| | | } |
| | | } |
| | | |
| | | private final class AppGridUsageTipIconButton: NSView { |
| | | var action: (() -> Void)? |
| | | var accessibilityLabel: String = "" { |
| | | var buttonAccessibilityLabel: String = "" { |
| | | didSet { |
| | | setAccessibilityLabel(accessibilityLabel) |
| | | setAccessibilityLabel(buttonAccessibilityLabel) |
| | | } |
| | | } |
| | | var tintColor: NSColor = .labelColor { |
| | | didSet { |
| | | imageView.contentTintColor = tintColor |
| | | } |
| | | } |
| | | |
| | | private let imageView = NSImageView() |
| | | 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) |
| | |
| | | |
| | | 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 |
| | | } |
| | | |
| | | override func layout() { |
| | | super.layout() |
| | | let size: CGFloat = 24 |
| | | let size: CGFloat = 26 |
| | | imageView.frame = NSRect( |
| | | x: (bounds.width - size) / 2, |
| | | y: (bounds.height - size) / 2, |
| | |
| | | |
| | | override func mouseExited(with event: NSEvent) { |
| | | isHovered = false |
| | | isPressed = false |
| | | needsDisplay = true |
| | | } |
| | | |
| | | override func mouseDown(with event: NSEvent) { |
| | | Diagnostics.log("usageTips.button.mouseDown", [ |
| | | "label": buttonAccessibilityLabel |
| | | ]) |
| | | isPressed = true |
| | | needsDisplay = true |
| | | action?() |
| | | DispatchQueue.main.asyncAfter(deadline: .now() + 0.10) { [weak self] in |
| | | self?.isPressed = false |
| | | self?.needsDisplay = true |
| | | } |
| | | } |
| | | |
| | | 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() |
| | | guard isHovered || isPressed else { return } |
| | | tintColor.withAlphaComponent(isPressed ? 0.14 : 0.08).setFill() |
| | | NSBezierPath(roundedRect: bounds.insetBy(dx: 1, dy: 1), xRadius: 6, yRadius: 6).fill() |
| | | } |
| | | |
| | | private func setup(systemImage: String) { |
| | | wantsLayer = true |
| | | let config = NSImage.SymbolConfiguration(pointSize: 24, weight: .bold) |
| | | let config = NSImage.SymbolConfiguration(pointSize: 26, weight: .semibold) |
| | | imageView.image = NSImage( |
| | | systemSymbolName: systemImage, |
| | | accessibilityDescription: nil |
| | | )?.withSymbolConfiguration(config) |
| | | imageView.imageScaling = .scaleProportionallyDown |
| | | imageView.contentTintColor = .white |
| | | imageView.contentTintColor = tintColor |
| | | addSubview(imageView) |
| | | setAccessibilityRole(.button) |
| | | } |
| | |
| | | |
| | | 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 |
| | |
| | | |
| | | 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 |
| | | let totalWidth = preferredWidth |
| | | var x = (bounds.width - totalWidth) / 2 |
| | | let y = (bounds.height - dotSize) / 2 |
| | | let centerY = bounds.midY |
| | | |
| | | for index in 0..<count { |
| | | let color = index == selectedIndex |
| | | ? NSColor.controlAccentColor |
| | | : NSColor.white.withAlphaComponent(0.58) |
| | | let selected = index == selectedIndex |
| | | let dotSize: CGFloat = selected ? 6 : 5 |
| | | let color = NSColor.labelColor.withAlphaComponent(selected ? 0.85 : 0.28) |
| | | color.setFill() |
| | | NSBezierPath( |
| | | ovalIn: NSRect(x: x, y: y, width: dotSize, height: dotSize) |
| | | ovalIn: NSRect( |
| | | x: x, |
| | | y: centerY - dotSize / 2, |
| | | width: dotSize, |
| | | height: dotSize |
| | | ) |
| | | ).fill() |
| | | x += dotSize + spacing |
| | | x += 5 + spacing |
| | | } |
| | | } |
| | | } |