├── AKList.swift ├── LICENSE └── README.md /AKList.swift: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2023 Orbital Labs, LLC 3 | * 4 | * Permission is hereby granted, free of charge, to any person obtaining a copy 5 | * of this software and associated documentation files (the "Software"), to deal 6 | * in the Software without restriction, including without limitation the rights 7 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 8 | * copies of the Software, and to permit persons to whom the Software is 9 | * furnished to do so, subject to the following conditions: 10 | * 11 | * The above copyright notice and this permission notice shall be included in all 12 | * copies or substantial portions of the Software. 13 | * 14 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 16 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 17 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 18 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 19 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 20 | * SOFTWARE. 21 | */ 22 | 23 | import AppKit 24 | import SwiftUI 25 | import Combine 26 | 27 | private let maxReuseSlots = 2 28 | 29 | // Hierarchical list using AppKit's NSOutlineView and NSTreeController. 30 | // Can also be used for non-hierarchical ("flat") lists. 31 | // 32 | // TO MIGRATE FROM SwiftUI List: 33 | // - List -> AKList 34 | // - Section -> [AKSection] 35 | // - .onRawDoubleClick -> .akListOnDoubleClick 36 | // - .contextMenu -> .akListContextMenu 37 | // - increase .vertical padding (4->8) to match SwiftUI List 38 | // * AKList doesn't add implicit padding 39 | // - add .environmentObjects to the item view 40 | // * needs to be reinjected across NSHostingView boundary 41 | // - (optional) set rowHeight: for performance 42 | // 43 | // benefits: 44 | // - fix black bar w/o covering up rect 45 | // -> fixes scrollbar 46 | // - slightly faster 47 | // - double click to expand 48 | // - no random holes / buggy behavior 49 | // - row separator lines, but only when selected 50 | // - should no longer crash 51 | // - scroll position moves to follow selection 52 | // - native double click implementation for reliability 53 | // - sections for hierarchical lists 54 | // - hides empty sections 55 | struct AKList: View { 56 | @StateObject private var envModel = AKListModel() 57 | 58 | private let sections: [AKSection] 59 | @Binding private var selection: Set 60 | private let rowHeight: CGFloat? 61 | private let makeRowView: (Item) -> ItemView 62 | private var singleSelection = false 63 | private var flat = false 64 | 65 | // hierarchical OR flat, with sections, multiple selection 66 | init(_ sections: [AKSection], 67 | selection: Binding>, 68 | rowHeight: CGFloat? = nil, 69 | flat: Bool = true, 70 | @ViewBuilder makeRowView: @escaping (Item) -> ItemView) { 71 | self.sections = sections 72 | self._selection = selection 73 | self.rowHeight = rowHeight 74 | self.makeRowView = makeRowView 75 | self.flat = flat 76 | } 77 | 78 | var body: some View { 79 | AKTreeListImpl(envModel: envModel, 80 | sections: sections, 81 | rowHeight: rowHeight, 82 | singleSelection: singleSelection, 83 | isFlat: flat, 84 | makeRowView: makeRowView) 85 | // fix toolbar color and blur (fullSizeContentView) 86 | .ignoresSafeArea() 87 | .onReceive(envModel.$selection) { selection in 88 | self.selection = selection as! Set 89 | } 90 | } 91 | } 92 | 93 | // structs can't have convenience init, so use an extension 94 | extension AKList { 95 | // hierarchical OR flat, with sections, single selection 96 | init(_ sections: [AKSection], 97 | selection singleBinding: Binding, 98 | rowHeight: CGFloat? = nil, 99 | flat: Bool = true, 100 | @ViewBuilder makeRowView: @escaping (Item) -> ItemView) { 101 | let selBinding = Binding>( 102 | get: { 103 | if let id = singleBinding.wrappedValue { 104 | return [id] 105 | } else { 106 | return [] 107 | } 108 | }, 109 | set: { 110 | singleBinding.wrappedValue = $0.first 111 | }) 112 | self.init(sections, 113 | selection: selBinding, 114 | rowHeight: rowHeight, 115 | flat: flat, 116 | makeRowView: makeRowView) 117 | self.singleSelection = true 118 | } 119 | 120 | // hierarchical OR flat, no sections, multiple selection 121 | init(_ items: [Item], 122 | selection: Binding>, 123 | rowHeight: CGFloat? = nil, 124 | flat: Bool = true, 125 | @ViewBuilder makeRowView: @escaping (Item) -> ItemView) { 126 | self.init(AKSection.single(items), 127 | selection: selection, 128 | rowHeight: rowHeight, 129 | flat: flat, 130 | makeRowView: makeRowView) 131 | } 132 | 133 | // hierarchical OR flat, no sections, single selection 134 | init(_ items: [Item], 135 | selection singleBinding: Binding, 136 | rowHeight: CGFloat? = nil, 137 | flat: Bool = true, 138 | @ViewBuilder makeRowView: @escaping (Item) -> ItemView) { 139 | self.init(AKSection.single(items), 140 | selection: singleBinding, 141 | rowHeight: rowHeight, 142 | flat: flat, 143 | makeRowView: makeRowView) 144 | self.singleSelection = true 145 | } 146 | } 147 | 148 | private class AKOutlineView: NSOutlineView { 149 | // workaround for off-center disclosure arrow: https://stackoverflow.com/a/74894605 150 | override func frameOfOutlineCell(atRow row: Int) -> NSRect { 151 | super.frameOfOutlineCell(atRow: row) 152 | } 153 | 154 | // we get here if the right click wasn't handled by SwiftUI, usually b/c out of bounds 155 | // e.g. clicked on arrow or margin 156 | // never use the fake menu, but try to forward it 157 | override func menu(for event: NSEvent) -> NSMenu? { 158 | // calling super.menu makes highlight ring appear, so do this ourselves 159 | // otherwise right-clicking section header triggers ring 160 | let targetRow = row(at: convert(event.locationInWindow, from: nil)) 161 | 162 | // find the clicked view 163 | if targetRow != -1, 164 | let view = self.view(atColumn: 0, row: targetRow, makeIfNecessary: false) { 165 | // make a fake event for its center 166 | let center = CGPointMake(NSMidX(view.frame), NSMidY(view.frame)) 167 | // ... relative to the window 168 | let centerInWindow = view.convert(center, to: nil) 169 | 170 | if let fakeEvent = NSEvent.mouseEvent( 171 | with: event.type, 172 | location: centerInWindow, 173 | modifierFlags: event.modifierFlags, 174 | timestamp: event.timestamp, 175 | windowNumber: event.windowNumber, 176 | context: nil, // deprecated 177 | eventNumber: event.eventNumber, 178 | clickCount: event.clickCount, 179 | pressure: event.pressure 180 | ) { 181 | return view.menu(for: fakeEvent) 182 | } 183 | } 184 | 185 | // failed to forward 186 | return nil 187 | } 188 | 189 | // for AKHostingView to trigger highlight 190 | func injectMenu(for event: NSEvent) { 191 | super.menu(for: event) 192 | } 193 | } 194 | 195 | // forward menu request/open/close events to NSOutlineView so it triggers highlight ring, 196 | // but *actually* use the menu from SwiftUI 197 | private class AKHostingView: NSHostingView { 198 | weak var outlineParent: AKOutlineView? 199 | var releaser: (() -> Void)? 200 | 201 | override func menu(for event: NSEvent) -> NSMenu? { 202 | // trigger NSOutlineView's highlight 203 | outlineParent?.injectMenu(for: event) 204 | return super.menu(for: event) 205 | } 206 | 207 | // forward menu events 208 | override func willOpenMenu(_ menu: NSMenu, with event: NSEvent) { 209 | super.willOpenMenu(menu, with: event) 210 | outlineParent?.willOpenMenu(menu, with: event) 211 | } 212 | 213 | override func didCloseMenu(_ menu: NSMenu, with event: NSEvent?) { 214 | super.didCloseMenu(menu, with: event) 215 | outlineParent?.didCloseMenu(menu, with: event) 216 | } 217 | 218 | override func mouseDown(with event: NSEvent) { 219 | super.mouseDown(with: event) 220 | } 221 | 222 | override func viewDidMoveToSuperview() { 223 | super.viewDidMoveToSuperview() 224 | if superview == nil { 225 | releaser?() 226 | } 227 | } 228 | } 229 | 230 | class AKListModel: ObservableObject { 231 | let doubleClicks = PassthroughSubject() 232 | @Published var selection: Set = [] 233 | } 234 | 235 | private class AKListItemModel: ObservableObject { 236 | // TODO silence AppCode inspection 237 | @Published var item: (any AKListItem)? 238 | @Published var itemId: AnyHashable 239 | 240 | init(itemId: AnyHashable) { 241 | self.itemId = itemId 242 | } 243 | } 244 | 245 | private class CachedViewHolder { 246 | var view: AKHostingView 247 | var model: AKListItemModel 248 | 249 | init(view: AKHostingView, model: AKListItemModel) { 250 | self.view = view 251 | self.model = model 252 | } 253 | } 254 | 255 | typealias AKListItemBase = Identifiable & Equatable 256 | 257 | protocol AKListItem: AKListItemBase { 258 | var listChildren: [any AKListItem]? { get } 259 | } 260 | 261 | extension AKListItem { 262 | var listChildren: [any AKListItem]? { 263 | nil 264 | } 265 | } 266 | 267 | struct AKSection: AKListItemBase { 268 | // nil = no header 269 | let title: String? 270 | let items: [Element] 271 | 272 | var id: String? { 273 | title 274 | } 275 | 276 | init(_ title: String?, _ items: [Element]) { 277 | self.title = title 278 | self.items = items 279 | } 280 | 281 | static func single(_ items: [Element]) -> [AKSection] { 282 | [AKSection(nil, items)] 283 | } 284 | } 285 | 286 | @objc protocol AKNode {} 287 | 288 | private class AKItemNode: NSObject, AKNode { 289 | // don't try to be smart with these properties. NSTreeController requires KVO to work 290 | @objc dynamic var children: [AKItemNode]? 291 | @objc dynamic var isLeaf = true 292 | @objc dynamic var count = 0 293 | 294 | var value: any AKListItem 295 | 296 | init(value: any AKListItem) { 297 | self.value = value 298 | } 299 | } 300 | 301 | private class AKSectionNode: NSObject, AKNode { 302 | @objc dynamic var children: [AKItemNode]? 303 | @objc dynamic var isLeaf = true 304 | @objc dynamic var count = 0 305 | 306 | var value: String 307 | 308 | init(value: String, children: [AKItemNode]?) { 309 | self.value = value 310 | self.children = children 311 | } 312 | } 313 | 314 | private struct HostedItemView: View { 315 | @ObservedObject var envModel: AKListModel 316 | @ObservedObject var itemModel: AKListItemModel 317 | 318 | @ViewBuilder let makeRowView: (Item) -> ItemView 319 | 320 | var body: some View { 321 | if let item = itemModel.item { 322 | makeRowView(item as! Item) 323 | .environmentObject(envModel) 324 | .environmentObject(itemModel) 325 | } else { 326 | EmptyView() 327 | } 328 | } 329 | } 330 | 331 | private struct AKTreeListImpl: NSViewRepresentable { 332 | typealias Section = AKSection 333 | typealias CachedView = CachedViewHolder> 334 | 335 | @ObservedObject var envModel: AKListModel 336 | 337 | let sections: [Section] 338 | let rowHeight: CGFloat? 339 | let singleSelection: Bool 340 | let isFlat: Bool 341 | let makeRowView: (Item) -> ItemView 342 | 343 | final class Coordinator: NSObject, NSOutlineViewDelegate { 344 | var parent: AKTreeListImpl 345 | 346 | @objc fileprivate dynamic var content: [AKNode] = [] 347 | var lastSections: [Section]? 348 | 349 | private var observation: NSKeyValueObservation? 350 | var treeController: NSTreeController? { 351 | didSet { 352 | // KVO-observing selectedObjects is better than outlineViewSelectionDidChange 353 | // because it changes to empty when items are deleted 354 | observation = treeController?.observe(\.selectedObjects) { [weak self] _, _ in 355 | guard let self, let treeController else { return } 356 | let selectedIds = treeController.selectedObjects 357 | .compactMap { ($0 as? AKItemNode)?.value.id as? Item.ID } 358 | // Publishing changes from within view updates is not allowed, this will cause undefined behavior. 359 | let newSelection = Set(selectedIds) as Set 360 | if self.parent.envModel.selection != newSelection { 361 | DispatchQueue.main.async { 362 | self.parent.envModel.selection = newSelection 363 | } 364 | } 365 | } 366 | } 367 | } 368 | 369 | // preserve objc object identity to avoid losing state 370 | // overriding isEqual would probably work but this is also good for perf 371 | private var objCache = [Item.ID: AKItemNode]() 372 | // array is fastest since we just iterate and clear this 373 | private var objAccessTracker = [Item.ID]() 374 | 375 | // preserve view identity to avoid losing state (e.g. popovers) 376 | private var viewCache = [Item.ID: CachedView]() 377 | // custom reuse queue. hard to use nibs, and we need the identity-preserving cache logic too 378 | private var reuseQueue = [CachedView]() 379 | 380 | init(_ parent: AKTreeListImpl) { 381 | self.parent = parent 382 | reuseQueue.reserveCapacity(maxReuseSlots) 383 | } 384 | 385 | private func getOrCreateItemView(outlineView: NSOutlineView, itemId: Item.ID) -> CachedView { 386 | // 1. cached for ID, to preserve identity 387 | if let cached = viewCache[itemId] { 388 | return cached 389 | } 390 | 391 | // 2. look for reusable one 392 | if let cached = reuseQueue.popLast() { 393 | // a reused view should be added back to the cache once it's been rebound 394 | viewCache[itemId] = cached 395 | return cached 396 | } 397 | 398 | // 3. make a new one 399 | let itemModel = AKListItemModel(itemId: itemId as AnyHashable) 400 | // doing .environmentObject in the SwiftUI view lets us avoid AnyView here 401 | let hostedView = HostedItemView(envModel: parent.envModel, 402 | itemModel: itemModel, 403 | makeRowView: parent.makeRowView) 404 | let nsView = AKHostingView(rootView: hostedView) 405 | nsView.outlineParent = (outlineView as! AKOutlineView) 406 | 407 | let cached = CachedView(view: nsView, model: itemModel) 408 | viewCache[itemId] = cached 409 | 410 | // set releaser 411 | nsView.releaser = { [weak self, weak cached] in 412 | guard let self, let cached else { return } 413 | // remove from active cache 414 | self.viewCache.removeValue(forKey: cached.model.itemId as! Item.ID) 415 | // add to reuse queue if space is available 416 | if self.reuseQueue.count < maxReuseSlots { 417 | self.reuseQueue.append(cached) 418 | } 419 | // remove item 420 | cached.model.item = nil 421 | } 422 | 423 | return cached 424 | } 425 | 426 | // make views 427 | func outlineView(_ outlineView: NSOutlineView, viewFor tableColumn: NSTableColumn?, item: Any) -> NSView? { 428 | let nsNode = item as! NSTreeNode 429 | 430 | if let node = nsNode.representedObject as? AKItemNode { 431 | let cached = getOrCreateItemView(outlineView: outlineView, itemId: node.value.id as! Item.ID) 432 | 433 | // update value if needed 434 | // updateNSView does async so it's fine to update right here 435 | if (cached.model.item as? Item) != (node.value as? Item) { 436 | cached.model.item = node.value 437 | } 438 | if (cached.model.itemId as? Item.ID) != (node.value.id as? Item.ID) { 439 | cached.model.itemId = node.value.id as! Item.ID 440 | } 441 | 442 | return cached.view 443 | } else if let node = nsNode.representedObject as? AKSectionNode { 444 | // pixel-perfect match of SwiftUI default section header 445 | let cellView = NSTableCellView() 446 | let field = NSTextField(labelWithString: node.value) 447 | field.font = NSFont.systemFont(ofSize: NSFont.smallSystemFontSize, weight: .semibold) 448 | field.textColor = .secondaryLabelColor 449 | field.isEditable = false 450 | cellView.addSubview(field) 451 | 452 | // center vertically, align to left 453 | field.translatesAutoresizingMaskIntoConstraints = false 454 | NSLayoutConstraint.activate([ 455 | field.leadingAnchor.constraint(equalTo: cellView.leadingAnchor), 456 | field.centerYAnchor.constraint(equalTo: cellView.centerYAnchor), 457 | ]) 458 | 459 | return cellView 460 | } else { 461 | return nil 462 | } 463 | } 464 | 465 | // at first glance this isn't needed because section nodes don't render selections, 466 | // but it still gets selected internally and breaks the rounding of adjacent rows 467 | func outlineView(_ outlineView: NSOutlineView, shouldSelectItem item: Any) -> Bool { 468 | let nsNode = item as! NSTreeNode 469 | if nsNode.representedObject is AKItemNode { 470 | return true 471 | } else { 472 | return false 473 | } 474 | } 475 | 476 | func outlineView(_ outlineView: NSOutlineView, heightOfRowByItem item: Any) -> CGFloat { 477 | let nsNode = item as! NSTreeNode 478 | if nsNode.representedObject is AKItemNode { 479 | return parent.rowHeight ?? outlineView.rowHeight 480 | } else if nsNode.representedObject is AKSectionNode { 481 | // match SwiftUI section 482 | return 28 483 | } else { 484 | return 0 485 | } 486 | } 487 | 488 | @objc func onDoubleClick(_ sender: Any) { 489 | // expand or collapse row 490 | let outlineView = sender as! NSOutlineView 491 | let row = outlineView.clickedRow 492 | guard row != -1 else { 493 | return 494 | } 495 | 496 | let item = outlineView.item(atRow: row) 497 | if outlineView.isItemExpanded(item) { 498 | outlineView.animator().collapseItem(item) 499 | } else { 500 | outlineView.animator().expandItem(item) 501 | } 502 | 503 | // emit double click event via notification center 504 | let nsNode = item as! NSTreeNode 505 | if let node = nsNode.representedObject as? AKItemNode { 506 | parent.envModel.doubleClicks.send(node.value.id as! AnyHashable) 507 | } 508 | } 509 | 510 | func mapNode(item: Item) -> AKItemNode { 511 | var node: AKItemNode 512 | if let cachedNode = objCache[item.id] { 513 | node = cachedNode 514 | } else { 515 | node = AKItemNode(value: item) 516 | objCache[item.id] = node 517 | } 518 | objAccessTracker.append(item.id) 519 | 520 | var nodeChildren = item.listChildren?.map { mapNode(item: $0 as! Item) } 521 | // map empty to nil 522 | if nodeChildren?.isEmpty ?? false { 523 | nodeChildren = nil 524 | } 525 | 526 | // do we need to update this node? if not, avoid triggering NSTreeController's KVO 527 | // isLeaf and count are derived from children, so no need to check 528 | if (node.value as! Item) != item || nodeChildren != node.children { 529 | if let nodeChildren { 530 | node.children = nodeChildren 531 | node.isLeaf = false 532 | node.count = nodeChildren.count 533 | } else { 534 | node.children = nil 535 | node.isLeaf = true 536 | node.count = 0 537 | } 538 | node.value = item 539 | } 540 | return node 541 | } 542 | 543 | func mapAllNodes(sections: [Section]) -> [AKNode] { 544 | // record accessed nodes 545 | let newNodes = sections.flatMap { 546 | // don't show empty sections 547 | if $0.items.isEmpty { 548 | return [AKNode]() 549 | } 550 | 551 | // more efficient than map and concat 552 | var sectionNodes = [AKNode]() 553 | sectionNodes.reserveCapacity($0.items.count + 1) 554 | if let title = $0.title { 555 | // TODO: if we use children, then groups are collapsible 556 | sectionNodes.append(AKSectionNode(value: title, children: nil)) 557 | } 558 | for item in $0.items { 559 | sectionNodes.append(mapNode(item: item)) 560 | } 561 | return sectionNodes 562 | } 563 | 564 | // remove unused nodes 565 | let unusedNodes = objCache.filter { !objAccessTracker.contains($0.key) } 566 | for (id, _) in unusedNodes { 567 | objCache.removeValue(forKey: id) 568 | } 569 | 570 | // clear access tracker 571 | objAccessTracker.removeAll() 572 | return newNodes 573 | } 574 | 575 | func outlineView(_ outlineView: NSOutlineView, isGroupItem item: Any) -> Bool { 576 | let nsNode = item as! NSTreeNode 577 | return nsNode.representedObject is AKSectionNode 578 | } 579 | } 580 | 581 | func makeNSView(context: Context) -> NSScrollView { 582 | let coordinator = context.coordinator 583 | coordinator.parent = self 584 | 585 | let treeController = NSTreeController() 586 | treeController.bind(.contentArray, to: coordinator, withKeyPath: "content") 587 | treeController.objectClass = AKItemNode.self 588 | treeController.childrenKeyPath = "children" 589 | treeController.countKeyPath = "count" 590 | treeController.leafKeyPath = "isLeaf" 591 | treeController.preservesSelection = true 592 | treeController.avoidsEmptySelection = false 593 | treeController.selectsInsertedObjects = false 594 | treeController.alwaysUsesMultipleValuesMarker = true // perf 595 | coordinator.treeController = treeController 596 | 597 | let outlineView = AKOutlineView() 598 | outlineView.delegate = coordinator 599 | outlineView.bind(.content, to: treeController, withKeyPath: "arrangedObjects") 600 | outlineView.bind(.selectionIndexPaths, to: treeController, withKeyPath: "selectionIndexPaths") 601 | // fix width changing when expanding/collapsing 602 | outlineView.autoresizesOutlineColumn = false 603 | outlineView.allowsMultipleSelection = !singleSelection 604 | outlineView.allowsEmptySelection = true 605 | if let rowHeight { 606 | outlineView.rowHeight = rowHeight 607 | } else { 608 | outlineView.usesAutomaticRowHeights = true 609 | } 610 | if isFlat { 611 | // remove padding at left 612 | outlineView.indentationPerLevel = 0 613 | } 614 | // dummy menu to trigger highlight 615 | outlineView.menu = NSMenu() 616 | 617 | // hide header 618 | outlineView.headerView = nil 619 | 620 | // use outlineView's double click. more reliable than Swift onDoubleClick 621 | outlineView.target = coordinator 622 | outlineView.doubleAction = #selector(Coordinator.onDoubleClick) 623 | 624 | // add one column 625 | let column = NSTableColumn(identifier: NSUserInterfaceItemIdentifier("column")) 626 | column.isEditable = false 627 | outlineView.addTableColumn(column) 628 | 629 | let scrollView = NSScrollView() 630 | scrollView.documentView = outlineView 631 | scrollView.hasVerticalScroller = true 632 | return scrollView 633 | } 634 | 635 | func updateNSView(_ nsView: NSScrollView, context: Context) { 636 | let coordinator = context.coordinator 637 | coordinator.parent = self 638 | guard sections != coordinator.lastSections else { 639 | return 640 | } 641 | 642 | // convert to nodes 643 | // DispatchQueue.main.async causes initial flicker, 644 | // but later we need it to avoid AttributeGraph cycles when clicking popovers during updates 645 | // because updating .content updates SwiftUI hosting views, but updateNSView is called inside a SwiftUI view update 646 | // this makes the updating non-atomic but it's fine 647 | if coordinator.lastSections == nil { 648 | let nodes = coordinator.mapAllNodes(sections: sections) 649 | // update tree controller and reload view (via KVO) 650 | coordinator.content = nodes 651 | } else { 652 | DispatchQueue.main.async { 653 | let nodes = coordinator.mapAllNodes(sections: sections) 654 | coordinator.content = nodes 655 | } 656 | } 657 | coordinator.lastSections = sections 658 | } 659 | 660 | static func dismantleNSView(_ nsView: NSViewType, coordinator: Coordinator) { 661 | // break KVO reference cycle 662 | coordinator.treeController = nil 663 | } 664 | 665 | func makeCoordinator() -> Coordinator { 666 | Coordinator(self) 667 | } 668 | } 669 | 670 | private struct DoubleClickViewModifier: ViewModifier { 671 | @EnvironmentObject private var listModel: AKListModel 672 | @EnvironmentObject private var itemModel: AKListItemModel 673 | 674 | let action: () -> Void 675 | 676 | func body(content: Content) -> some View { 677 | content 678 | .onReceive(listModel.doubleClicks) { id in 679 | if id == itemModel.itemId { 680 | action() 681 | } 682 | } 683 | } 684 | } 685 | 686 | private struct BoundingBoxOverlayView: NSViewRepresentable { 687 | func makeNSView(context: Context) -> NSView { 688 | NSView(frame: .zero) 689 | } 690 | 691 | func updateNSView(_ nsView: NSView, context: Context) { 692 | } 693 | } 694 | 695 | extension View { 696 | // SwiftUI rejects menu(forEvent:) unless it thinks it owns the view at which 697 | // the click occurred. add a big NSView overlay to fix it 698 | func akListContextMenu(@ViewBuilder menuItems: () -> MenuItems) -> some View { 699 | self 700 | .overlay { BoundingBoxOverlayView() } 701 | .contextMenu(menuItems: menuItems) 702 | } 703 | 704 | func akListOnDoubleClick(perform action: @escaping () -> Void) -> some View { 705 | self.modifier(DoubleClickViewModifier(action: action)) 706 | } 707 | } 708 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2023 Orbital Labs, LLC 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AKList 2 | 3 | Fast, stable, flexible SwiftUI wrapper for AppKit's NSOutlineView. 4 | 5 | Built for [OrbStack](https://orbstack.dev); your needs may differ. This is a snapshot from the internal codebase. 6 | --------------------------------------------------------------------------------