| | |
| | | let items: [TagNavigationItem] |
| | | let orientation: Orientation |
| | | let contentInsets: NSEdgeInsets |
| | | let dragModeActive: Bool |
| | | let draggingItemID: String? |
| | | let onActivate: (String) -> Void |
| | | let onHover: (String) -> Void |
| | | let onHoverChange: (String, Bool) -> Void |
| | | let canReorder: (String) -> Bool |
| | | let onReorderBegan: (String) -> Void |
| | | let onReorderMoved: (String, String) -> Void |
| | | let onReorderEnded: () -> Void |
| | | |
| | | func makeNSView(context: Context) -> TagNavigationHostView { |
| | | let view = TagNavigationHostView() |
| | |
| | | items: items, |
| | | orientation: orientation, |
| | | contentInsets: contentInsets, |
| | | dragModeActive: dragModeActive, |
| | | draggingItemID: draggingItemID, |
| | | onActivate: onActivate, |
| | | onHover: onHover |
| | | onHoverChange: onHoverChange, |
| | | canReorder: canReorder, |
| | | onReorderBegan: onReorderBegan, |
| | | onReorderMoved: onReorderMoved, |
| | | onReorderEnded: onReorderEnded |
| | | ) |
| | | return view |
| | | } |
| | |
| | | items: items, |
| | | orientation: orientation, |
| | | contentInsets: contentInsets, |
| | | dragModeActive: dragModeActive, |
| | | draggingItemID: draggingItemID, |
| | | onActivate: onActivate, |
| | | onHover: onHover |
| | | onHoverChange: onHoverChange, |
| | | canReorder: canReorder, |
| | | onReorderBegan: onReorderBegan, |
| | | onReorderMoved: onReorderMoved, |
| | | onReorderEnded: onReorderEnded |
| | | ) |
| | | } |
| | | } |
| | |
| | | items: [TagNavigationItem], |
| | | orientation: TagNavigationView.Orientation, |
| | | contentInsets: NSEdgeInsets, |
| | | dragModeActive: Bool, |
| | | draggingItemID: String?, |
| | | onActivate: @escaping (String) -> Void, |
| | | onHover: @escaping (String) -> Void |
| | | onHoverChange: @escaping (String, Bool) -> Void, |
| | | canReorder: @escaping (String) -> Bool, |
| | | onReorderBegan: @escaping (String) -> Void, |
| | | onReorderMoved: @escaping (String, String) -> Void, |
| | | onReorderEnded: @escaping () -> Void |
| | | ) { |
| | | documentView.update( |
| | | items: items, |
| | | orientation: orientation, |
| | | contentInsets: contentInsets, |
| | | dragModeActive: dragModeActive, |
| | | draggingItemID: draggingItemID, |
| | | onActivate: onActivate, |
| | | onHover: onHover |
| | | onHoverChange: onHoverChange, |
| | | canReorder: canReorder, |
| | | onReorderBegan: onReorderBegan, |
| | | onReorderMoved: onReorderMoved, |
| | | onReorderEnded: onReorderEnded |
| | | ) |
| | | scrollView.hasHorizontalScroller = orientation == .horizontal |
| | | scrollView.hasVerticalScroller = orientation == .vertical |
| | |
| | | private var orientation: TagNavigationView.Orientation = .horizontal |
| | | private var contentInsets = NSEdgeInsets(top: 0, left: 0, bottom: 0, right: 0) |
| | | private var buttons: [TagNavigationButton] = [] |
| | | private var dragModeActive = false |
| | | private var draggingItemID: String? |
| | | private var onActivate: (String) -> Void = { _ in } |
| | | private var onHover: (String) -> Void = { _ in } |
| | | private var onHoverChange: (String, Bool) -> Void = { _, _ in } |
| | | private var canReorder: (String) -> Bool = { _ in false } |
| | | private var onReorderBegan: (String) -> Void = { _ in } |
| | | private var onReorderMoved: (String, String) -> Void = { _, _ in } |
| | | private var onReorderEnded: () -> Void = {} |
| | | |
| | | override var isFlipped: Bool { true } |
| | | |
| | |
| | | items: [TagNavigationItem], |
| | | orientation: TagNavigationView.Orientation, |
| | | contentInsets: NSEdgeInsets, |
| | | dragModeActive: Bool, |
| | | draggingItemID: String?, |
| | | onActivate: @escaping (String) -> Void, |
| | | onHover: @escaping (String) -> Void |
| | | onHoverChange: @escaping (String, Bool) -> Void, |
| | | canReorder: @escaping (String) -> Bool, |
| | | onReorderBegan: @escaping (String) -> Void, |
| | | onReorderMoved: @escaping (String, String) -> Void, |
| | | onReorderEnded: @escaping () -> Void |
| | | ) { |
| | | let needsRebuild = self.items != items || self.orientation != orientation |
| | | let oldNames = self.items.map(\.name) |
| | | let newNames = items.map(\.name) |
| | | let needsRebuild = Set(oldNames) != Set(newNames) || self.orientation != orientation |
| | | self.items = items |
| | | self.orientation = orientation |
| | | self.contentInsets = contentInsets |
| | | self.dragModeActive = dragModeActive |
| | | self.draggingItemID = draggingItemID |
| | | self.onActivate = onActivate |
| | | self.onHover = onHover |
| | | self.onHoverChange = onHoverChange |
| | | self.canReorder = canReorder |
| | | self.onReorderBegan = onReorderBegan |
| | | self.onReorderMoved = onReorderMoved |
| | | self.onReorderEnded = onReorderEnded |
| | | |
| | | if needsRebuild { |
| | | rebuildButtons() |
| | | } else { |
| | | for (button, item) in zip(buttons, items) { |
| | | button.configure(item: item, orientation: orientation) |
| | | } |
| | | reuseButtonsInCurrentOrder() |
| | | } |
| | | updateButtonRuntimeState() |
| | | needsLayout = true |
| | | } |
| | | |
| | |
| | | buttons = items.map { item in |
| | | let button = TagNavigationButton(item: item, orientation: orientation) |
| | | button.onActivate = { [weak self] tagID in self?.onActivate(tagID) } |
| | | button.onHover = { [weak self] tagID in self?.onHover(tagID) } |
| | | button.onHoverChange = { [weak self] tagID, active in self?.onHoverChange(tagID, active) } |
| | | button.canReorder = { [weak self] tagID in self?.canReorder(tagID) ?? false } |
| | | button.onReorderBegan = { [weak self] tagID in self?.onReorderBegan(tagID) } |
| | | button.onReorderMoved = { [weak self] tagID, screenPoint in |
| | | self?.handleReorderMove(tagID: tagID, screenPoint: screenPoint) |
| | | } |
| | | button.onReorderEnded = { [weak self] in self?.onReorderEnded() } |
| | | addSubview(button) |
| | | return button |
| | | } |
| | | } |
| | | |
| | | private func reuseButtonsInCurrentOrder() { |
| | | let existing = Dictionary(uniqueKeysWithValues: buttons.map { ($0.itemName, $0) }) |
| | | buttons = items.compactMap { item in |
| | | guard let button = existing[item.name] else { return nil } |
| | | button.configure(item: item, orientation: orientation) |
| | | return button |
| | | } |
| | | } |
| | | |
| | | private func updateButtonRuntimeState() { |
| | | for button in buttons { |
| | | button.configureRuntime( |
| | | dragModeActive: dragModeActive && canReorder(button.itemName), |
| | | isDragging: draggingItemID == button.itemName |
| | | ) |
| | | } |
| | | } |
| | | |
| | | private func handleReorderMove(tagID: String, screenPoint: NSPoint) { |
| | | guard let window else { return } |
| | | let windowPoint = window.convertPoint(fromScreen: screenPoint) |
| | | let point = convert(windowPoint, from: nil) |
| | | guard let target = buttons.first(where: { button in |
| | | button.itemName != tagID |
| | | && canReorder(button.itemName) |
| | | && button.frame.insetBy(dx: -4, dy: -4).contains(point) |
| | | }) else { return } |
| | | onReorderMoved(tagID, target.itemName) |
| | | } |
| | | |
| | | private func layoutHorizontal() { |
| | |
| | | |
| | | final class TagNavigationButton: NSButton { |
| | | var onActivate: (String) -> Void = { _ in } |
| | | var onHover: (String) -> Void = { _ in } |
| | | var onHoverChange: (String, Bool) -> Void = { _, _ in } |
| | | var canReorder: (String) -> Bool = { _ in false } |
| | | var onReorderBegan: (String) -> Void = { _ in } |
| | | var onReorderMoved: (String, NSPoint) -> Void = { _, _ in } |
| | | var onReorderEnded: () -> Void = {} |
| | | |
| | | private var item: TagNavigationItem |
| | | private var orientation: TagNavigationView.Orientation |
| | | private var trackingAreaRef: NSTrackingArea? |
| | | private var isMouseInside = false |
| | | private var dragModeActive = false |
| | | private var isDragging = false |
| | | |
| | | var itemName: String { item.name } |
| | | |
| | | var preferredSize: NSSize { |
| | | let font = NSFont.systemFont(ofSize: 13, weight: .medium) |
| | |
| | | needsLayout = true |
| | | } |
| | | |
| | | func configureRuntime(dragModeActive: Bool, isDragging: Bool) { |
| | | self.dragModeActive = dragModeActive |
| | | self.isDragging = isDragging |
| | | updateAppearance() |
| | | } |
| | | |
| | | override func updateTrackingAreas() { |
| | | super.updateTrackingAreas() |
| | | if let trackingAreaRef { |
| | |
| | | |
| | | override func mouseEntered(with event: NSEvent) { |
| | | super.mouseEntered(with: event) |
| | | onHover(item.id) |
| | | guard !isMouseInside else { return } |
| | | isMouseInside = true |
| | | onHoverChange(item.id, true) |
| | | } |
| | | |
| | | override func mouseExited(with event: NSEvent) { |
| | | super.mouseExited(with: event) |
| | | guard isMouseInside else { return } |
| | | isMouseInside = false |
| | | onHoverChange(item.id, false) |
| | | } |
| | | |
| | | override func mouseDown(with event: NSEvent) { |
| | | guard canReorder(item.id) else { |
| | | onActivate(item.id) |
| | | return |
| | | } |
| | | |
| | | let start = Date() |
| | | let longPressDuration: TimeInterval = 0.35 |
| | | var reorderActive = false |
| | | var finished = false |
| | | |
| | | while !finished { |
| | | if !reorderActive && Date().timeIntervalSince(start) >= longPressDuration { |
| | | reorderActive = true |
| | | onReorderBegan(item.id) |
| | | } |
| | | |
| | | let nextEvent = window?.nextEvent( |
| | | matching: [.leftMouseDragged, .leftMouseUp], |
| | | until: Date().addingTimeInterval(0.04), |
| | | inMode: .eventTracking, |
| | | dequeue: true |
| | | ) |
| | | |
| | | guard let nextEvent else { continue } |
| | | switch nextEvent.type { |
| | | case .leftMouseDragged: |
| | | if reorderActive { |
| | | onReorderMoved(item.id, NSEvent.mouseLocation) |
| | | } |
| | | case .leftMouseUp: |
| | | if reorderActive { |
| | | onReorderEnded() |
| | | } else { |
| | | onActivate(item.id) |
| | | } |
| | | finished = true |
| | | default: |
| | | break |
| | | } |
| | | } |
| | | } |
| | | |
| | | @objc private func performActivation() { |
| | |
| | | |
| | | private func updateAppearance() { |
| | | let background = TagColor.nsColor(for: item.colorIndex) |
| | | let dragScale: CGFloat = isDragging ? 1.04 : 1.0 |
| | | layer?.backgroundColor = background.cgColor |
| | | layer?.cornerRadius = orientation == .horizontal ? 7 : 6 |
| | | layer?.shadowColor = NSColor.black.withAlphaComponent(0.20).cgColor |
| | | layer?.shadowColor = NSColor.black.withAlphaComponent(isDragging ? 0.50 : 0.20).cgColor |
| | | layer?.shadowOpacity = 1 |
| | | layer?.shadowRadius = 3 |
| | | layer?.shadowOffset = NSSize(width: 0, height: -1) |
| | | layer?.shadowRadius = isDragging ? 18 : 3 |
| | | layer?.shadowOffset = NSSize(width: 0, height: isDragging ? -9 : -1) |
| | | layer?.zPosition = isDragging ? 30 : 0 |
| | | layer?.setAffineTransform(CGAffineTransform(scaleX: dragScale, y: dragScale)) |
| | | alphaValue = dragModeActive ? (isDragging ? 1.0 : 0.62) : 1.0 |
| | | |
| | | let textColor: NSColor = (item.colorIndex == 0 || item.colorIndex == 5) ? .labelColor : .white |
| | | let paragraph = NSMutableParagraphStyle() |