| New file |
| | |
| | | import AppKit |
| | | import SwiftUI |
| | | |
| | | struct TagNavigationItem: Identifiable, Equatable { |
| | | var id: String { name } |
| | | let name: String |
| | | let colorIndex: Int |
| | | } |
| | | |
| | | struct TagNavigationView: NSViewRepresentable { |
| | | enum Orientation: Equatable { |
| | | case horizontal |
| | | case vertical |
| | | } |
| | | |
| | | let items: [TagNavigationItem] |
| | | let orientation: Orientation |
| | | let contentInsets: NSEdgeInsets |
| | | let onActivate: (String) -> Void |
| | | let onHover: (String) -> Void |
| | | |
| | | func makeNSView(context: Context) -> TagNavigationHostView { |
| | | let view = TagNavigationHostView() |
| | | view.update( |
| | | items: items, |
| | | orientation: orientation, |
| | | contentInsets: contentInsets, |
| | | onActivate: onActivate, |
| | | onHover: onHover |
| | | ) |
| | | return view |
| | | } |
| | | |
| | | func updateNSView(_ nsView: TagNavigationHostView, context: Context) { |
| | | nsView.update( |
| | | items: items, |
| | | orientation: orientation, |
| | | contentInsets: contentInsets, |
| | | onActivate: onActivate, |
| | | onHover: onHover |
| | | ) |
| | | } |
| | | } |
| | | |
| | | final class TagNavigationHostView: NSView { |
| | | private let scrollView = NSScrollView() |
| | | private let documentView = TagNavigationDocumentView() |
| | | |
| | | override var isFlipped: Bool { true } |
| | | |
| | | override init(frame frameRect: NSRect) { |
| | | super.init(frame: frameRect) |
| | | setup() |
| | | } |
| | | |
| | | required init?(coder: NSCoder) { |
| | | super.init(coder: coder) |
| | | setup() |
| | | } |
| | | |
| | | func update( |
| | | items: [TagNavigationItem], |
| | | orientation: TagNavigationView.Orientation, |
| | | contentInsets: NSEdgeInsets, |
| | | onActivate: @escaping (String) -> Void, |
| | | onHover: @escaping (String) -> Void |
| | | ) { |
| | | documentView.update( |
| | | items: items, |
| | | orientation: orientation, |
| | | contentInsets: contentInsets, |
| | | onActivate: onActivate, |
| | | onHover: onHover |
| | | ) |
| | | scrollView.hasHorizontalScroller = orientation == .horizontal |
| | | scrollView.hasVerticalScroller = orientation == .vertical |
| | | needsLayout = true |
| | | } |
| | | |
| | | override func layout() { |
| | | super.layout() |
| | | scrollView.frame = bounds |
| | | let visibleSize = scrollView.contentView.bounds.size |
| | | documentView.frame = NSRect( |
| | | origin: .zero, |
| | | size: documentView.contentSize(fitting: visibleSize) |
| | | ) |
| | | documentView.needsLayout = true |
| | | } |
| | | |
| | | private func setup() { |
| | | wantsLayer = true |
| | | layer?.backgroundColor = NSColor.clear.cgColor |
| | | |
| | | scrollView.drawsBackground = false |
| | | scrollView.borderType = .noBorder |
| | | scrollView.autohidesScrollers = true |
| | | scrollView.scrollerStyle = .overlay |
| | | scrollView.hasHorizontalScroller = false |
| | | scrollView.hasVerticalScroller = false |
| | | scrollView.documentView = documentView |
| | | addSubview(scrollView) |
| | | } |
| | | } |
| | | |
| | | final class TagNavigationDocumentView: NSView { |
| | | private var items: [TagNavigationItem] = [] |
| | | private var orientation: TagNavigationView.Orientation = .horizontal |
| | | private var contentInsets = NSEdgeInsets(top: 0, left: 0, bottom: 0, right: 0) |
| | | private var buttons: [TagNavigationButton] = [] |
| | | private var onActivate: (String) -> Void = { _ in } |
| | | private var onHover: (String) -> Void = { _ in } |
| | | |
| | | override var isFlipped: Bool { true } |
| | | |
| | | func update( |
| | | items: [TagNavigationItem], |
| | | orientation: TagNavigationView.Orientation, |
| | | contentInsets: NSEdgeInsets, |
| | | onActivate: @escaping (String) -> Void, |
| | | onHover: @escaping (String) -> Void |
| | | ) { |
| | | let needsRebuild = self.items != items || self.orientation != orientation |
| | | self.items = items |
| | | self.orientation = orientation |
| | | self.contentInsets = contentInsets |
| | | self.onActivate = onActivate |
| | | self.onHover = onHover |
| | | |
| | | if needsRebuild { |
| | | rebuildButtons() |
| | | } else { |
| | | for (button, item) in zip(buttons, items) { |
| | | button.configure(item: item, orientation: orientation) |
| | | } |
| | | } |
| | | needsLayout = true |
| | | } |
| | | |
| | | func contentSize(fitting visibleSize: NSSize) -> NSSize { |
| | | switch orientation { |
| | | case .horizontal: |
| | | let width = buttons.reduce(contentInsets.left + contentInsets.right) { total, button in |
| | | total + button.preferredSize.width |
| | | } + CGFloat(max(0, buttons.count - 1)) * 8 |
| | | let height = max(visibleSize.height, contentInsets.top + 28 + contentInsets.bottom) |
| | | return NSSize(width: max(width, visibleSize.width), height: height) |
| | | case .vertical: |
| | | let rowHeight: CGFloat = 28 |
| | | let height = contentInsets.top |
| | | + CGFloat(buttons.count) * rowHeight |
| | | + CGFloat(max(0, buttons.count - 1)) * 6 |
| | | + contentInsets.bottom |
| | | return NSSize(width: max(visibleSize.width, 1), height: max(height, visibleSize.height)) |
| | | } |
| | | } |
| | | |
| | | override func layout() { |
| | | super.layout() |
| | | switch orientation { |
| | | case .horizontal: |
| | | layoutHorizontal() |
| | | case .vertical: |
| | | layoutVertical() |
| | | } |
| | | } |
| | | |
| | | private func rebuildButtons() { |
| | | buttons.forEach { $0.removeFromSuperview() } |
| | | 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) } |
| | | addSubview(button) |
| | | return button |
| | | } |
| | | } |
| | | |
| | | private func layoutHorizontal() { |
| | | var x = contentInsets.left |
| | | let height: CGFloat = 28 |
| | | let y = max(contentInsets.top, (bounds.height - height) / 2) |
| | | for button in buttons { |
| | | let size = button.preferredSize |
| | | button.frame = NSRect(x: x, y: y, width: size.width, height: height) |
| | | x += size.width + 8 |
| | | } |
| | | } |
| | | |
| | | private func layoutVertical() { |
| | | let rowHeight: CGFloat = 28 |
| | | let width = max(1, bounds.width - contentInsets.left - contentInsets.right) |
| | | var y = contentInsets.top |
| | | for button in buttons { |
| | | button.frame = NSRect(x: contentInsets.left, y: y, width: width, height: rowHeight) |
| | | y += rowHeight + 6 |
| | | } |
| | | } |
| | | } |
| | | |
| | | final class TagNavigationButton: NSButton { |
| | | var onActivate: (String) -> Void = { _ in } |
| | | var onHover: (String) -> Void = { _ in } |
| | | |
| | | private var item: TagNavigationItem |
| | | private var orientation: TagNavigationView.Orientation |
| | | private var trackingAreaRef: NSTrackingArea? |
| | | |
| | | var preferredSize: NSSize { |
| | | let font = NSFont.systemFont(ofSize: 13, weight: .medium) |
| | | let textWidth = (item.name as NSString).size(withAttributes: [.font: font]).width |
| | | switch orientation { |
| | | case .horizontal: |
| | | return NSSize(width: ceil(textWidth + 24), height: 28) |
| | | case .vertical: |
| | | return NSSize(width: ceil(textWidth + 20), height: 28) |
| | | } |
| | | } |
| | | |
| | | init(item: TagNavigationItem, orientation: TagNavigationView.Orientation) { |
| | | self.item = item |
| | | self.orientation = orientation |
| | | super.init(frame: .zero) |
| | | isBordered = false |
| | | setButtonType(.momentaryChange) |
| | | target = self |
| | | action = #selector(performActivation) |
| | | wantsLayer = true |
| | | configure(item: item, orientation: orientation) |
| | | } |
| | | |
| | | required init?(coder: NSCoder) { |
| | | self.item = TagNavigationItem(name: "", colorIndex: 0) |
| | | self.orientation = .horizontal |
| | | super.init(coder: coder) |
| | | } |
| | | |
| | | func configure(item: TagNavigationItem, orientation: TagNavigationView.Orientation) { |
| | | self.item = item |
| | | self.orientation = orientation |
| | | title = item.name |
| | | alignment = orientation == .horizontal ? .center : .left |
| | | setAccessibilityLabel(item.name) |
| | | updateAppearance() |
| | | needsLayout = true |
| | | } |
| | | |
| | | override func updateTrackingAreas() { |
| | | super.updateTrackingAreas() |
| | | if let trackingAreaRef { |
| | | removeTrackingArea(trackingAreaRef) |
| | | } |
| | | let area = NSTrackingArea( |
| | | rect: bounds, |
| | | options: [.mouseEnteredAndExited, .activeAlways, .inVisibleRect], |
| | | owner: self, |
| | | userInfo: nil |
| | | ) |
| | | trackingAreaRef = area |
| | | addTrackingArea(area) |
| | | } |
| | | |
| | | override func mouseEntered(with event: NSEvent) { |
| | | super.mouseEntered(with: event) |
| | | onHover(item.id) |
| | | } |
| | | |
| | | @objc private func performActivation() { |
| | | onActivate(item.id) |
| | | } |
| | | |
| | | private func updateAppearance() { |
| | | let background = TagColor.nsColor(for: item.colorIndex) |
| | | layer?.backgroundColor = background.cgColor |
| | | layer?.cornerRadius = orientation == .horizontal ? 7 : 6 |
| | | layer?.shadowColor = NSColor.black.withAlphaComponent(0.20).cgColor |
| | | layer?.shadowOpacity = 1 |
| | | layer?.shadowRadius = 3 |
| | | layer?.shadowOffset = NSSize(width: 0, height: -1) |
| | | |
| | | let textColor: NSColor = (item.colorIndex == 0 || item.colorIndex == 5) ? .labelColor : .white |
| | | let paragraph = NSMutableParagraphStyle() |
| | | paragraph.alignment = alignment |
| | | if orientation == .vertical { |
| | | paragraph.firstLineHeadIndent = 10 |
| | | paragraph.headIndent = 10 |
| | | } |
| | | let attributes: [NSAttributedString.Key: Any] = [ |
| | | .font: NSFont.systemFont(ofSize: 13, weight: .medium), |
| | | .foregroundColor: textColor, |
| | | .paragraphStyle: paragraph |
| | | ] |
| | | attributedTitle = NSAttributedString(string: item.name, attributes: attributes) |
| | | } |
| | | } |