diff --git a/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift b/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift index e7eb8e2..3cbdb94 100644 --- a/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift +++ b/Mactrix/Views/ChatView/TimelineView/TimelineTableView.swift @@ -87,6 +87,8 @@ class TimelineViewController: NSViewController { let timeline: LiveTimeline var timelineItems: [TimelineItem] + var rowHeights: [Int: CGFloat] = [:] + private var pendingHeightUpdate = false init(coordinator: TimelineViewRepresentable.Coordinator, timeline: LiveTimeline, timelineItems: [TimelineItem]) { self.coordinator = coordinator @@ -104,10 +106,8 @@ class TimelineViewController: NSViewController { tableView.allowsColumnSelection = false tableView.selectionHighlightStyle = .none - tableView.rowHeight = -1 - tableView.usesAutomaticRowHeights = true - - oldWidth = tableView.frame.width + tableView.usesAutomaticRowHeights = false + tableView.rowHeight = 60 dataSource = .init(tableView: tableView) { [weak self] tableView, _, row, _ in guard let self else { return NSView() } @@ -115,19 +115,20 @@ class TimelineViewController: NSViewController { let item = timelineItems[row] let view = TimelineItemRowView(rowInfo: item.rowInfo, timeline: timeline, coordinator: coordinator) - let hostView: NSHostingView + let hostView: SelfSizingHostingView if let recycledView = tableView.makeView(withIdentifier: item.rowInfo.reuseIdentifier, owner: self) - as? NSHostingView + as? SelfSizingHostingView { recycledView.rootView = view hostView = recycledView } else { - hostView = NSHostingView(rootView: view) + hostView = SelfSizingHostingView(rootView: view) hostView.identifier = item.rowInfo.reuseIdentifier + hostView.sizingOptions = [] hostView.autoresizingMask = [.width, .height] - hostView.sizingOptions = [.preferredContentSize] - hostView.setContentHuggingPriority(.required, for: .vertical) } + hostView.row = row + hostView.controller = self return hostView } @@ -142,13 +143,6 @@ class TimelineViewController: NSViewController { // Subscribe to view resize notifications scrollView.contentView.postsBoundsChangedNotifications = true - NotificationCenter.default.addObserver( - self, - selector: #selector(handleTableResize), - name: NSView.frameDidChangeNotification, - object: scrollView.contentView - ) - NotificationCenter.default.addObserver( self, selector: #selector(viewDidScroll(_:)), @@ -159,21 +153,6 @@ class TimelineViewController: NSViewController { listenForFocusTimelineItem() } - @objc func handleTableResize(_ notification: Notification) { - if oldWidth != tableView.frame.width { - oldWidth = tableView.frame.width - - NSAnimationContext.runAnimationGroup { context in - context.duration = 0 - context.allowsImplicitAnimation = false - - let visibleRect = tableView.visibleRect - let visibleRows = tableView.rows(in: visibleRect) - tableView.noteHeightOfRows(withIndexesChanged: IndexSet(integersIn: visibleRows.lowerBound ..< visibleRows.upperBound)) - } - } - } - var timelineFetchTask: Task? @objc func viewDidScroll(_ notification: Notification) { @@ -252,18 +231,10 @@ class TimelineViewController: NSViewController { // Re-measure visible rows after hosting views settle DispatchQueue.main.async { [weak self] in guard let self else { return } - let visibleRows = tableView.rows(in: tableView.visibleRect) - tableView.noteHeightOfRows(withIndexesChanged: IndexSet(integersIn: visibleRows.lowerBound.. CGFloat { - let item = timelineItems[row] - - measurementHostingView.rootView = AnyView(TimelineItemRowView(rowInfo: item.rowInfo, timeline: timeline, coordinator: coordinator)) + return rowHeights[row] ?? 60 + } - let targetWidth = tableView.tableColumns[0].width - let proposedSize = CGSize(width: targetWidth, height: CGFloat.greatestFiniteMagnitude) + func scheduleHeightUpdate() { + guard !pendingHeightUpdate else { return } + pendingHeightUpdate = true + DispatchQueue.main.async { [weak self] in + self?.pendingHeightUpdate = false + self?.updateVisibleRowHeights() + } + } - let size = measurementHostingView.sizeThatFits(in: proposedSize) - // Avoid undefined-height rows which can cause NSTableView layout issues - return max(size.height, 1) + private func updateVisibleRowHeights() { + let visibleRows = tableView.rows(in: tableView.visibleRect) + var changed = IndexSet() + for row in visibleRows.lowerBound.. else { continue } + let h = cellView.measureHeight() + if h > 0, abs(h - (rowHeights[row] ?? 60)) > 1 { + rowHeights[row] = h + changed.insert(row) + } + } + if !changed.isEmpty { + NSAnimationContext.beginGrouping() + NSAnimationContext.current.duration = 0 + tableView.noteHeightOfRows(withIndexesChanged: changed) + NSAnimationContext.endGrouping() + } } } @@ -295,3 +286,28 @@ class BottomStickyTableView: NSTableView { return false } } + +class SelfSizingHostingView: NSHostingView { + weak var controller: TimelineViewController? + var row: Int = 0 + + override func layout() { + super.layout() + controller?.scheduleHeightUpdate() + } + + override func invalidateIntrinsicContentSize() { + // Don't call super — prevents constraint loop + // Instead schedule a deferred height update + controller?.scheduleHeightUpdate() + } + + func measureHeight() -> CGFloat { + let width = frame.width + guard width > 0 else { return 0 } + let controller = NSHostingController(rootView: rootView) + controller.sizingOptions = [.preferredContentSize] + let size = controller.sizeThatFits(in: CGSize(width: width, height: CGFloat.greatestFiniteMagnitude)) + return max(size.height, 1) + } +}