|
| 1 | +import AppKit |
| 2 | +import SwiftUI |
| 3 | + |
| 4 | +/// A native log viewport keeps scroll position and text selection independent |
| 5 | +/// of SwiftUI's text updates. Following resumes when the user reaches the end. |
| 6 | +struct FollowingLogView: NSViewRepresentable { |
| 7 | + let text: String |
| 8 | + var isPlaceholder = false |
| 9 | + |
| 10 | + func makeNSView(context: Context) -> FollowingLogScrollView { |
| 11 | + FollowingLogScrollView() |
| 12 | + } |
| 13 | + |
| 14 | + func updateNSView(_ view: FollowingLogScrollView, context: Context) { |
| 15 | + view.setLog(text, isPlaceholder: isPlaceholder) |
| 16 | + } |
| 17 | +} |
| 18 | + |
| 19 | +final class FollowingLogScrollView: NSScrollView { |
| 20 | + let logTextView = NSTextView(frame: NSRect(x: 0, y: 0, width: 400, height: 1)) |
| 21 | + private var previousText: String? |
| 22 | + private var previousPlaceholder = false |
| 23 | + private var updating = false |
| 24 | + |
| 25 | + var isAtBottom: Bool { |
| 26 | + guard let documentView else { return true } |
| 27 | + return documentView.frame.height - contentView.bounds.maxY <= 2 |
| 28 | + } |
| 29 | + |
| 30 | + init() { |
| 31 | + super.init(frame: NSRect(x: 0, y: 0, width: 400, height: 200)) |
| 32 | + drawsBackground = false |
| 33 | + hasVerticalScroller = true |
| 34 | + hasHorizontalScroller = false |
| 35 | + scrollerStyle = .overlay |
| 36 | + autohidesScrollers = true |
| 37 | + scrollerKnobStyle = .light |
| 38 | + logTextView.isEditable = false |
| 39 | + logTextView.isSelectable = true |
| 40 | + logTextView.drawsBackground = false |
| 41 | + logTextView.isRichText = false |
| 42 | + logTextView.isVerticallyResizable = true |
| 43 | + logTextView.isHorizontallyResizable = false |
| 44 | + logTextView.autoresizingMask = [.width] |
| 45 | + logTextView.minSize = .zero |
| 46 | + logTextView.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, height: CGFloat.greatestFiniteMagnitude) |
| 47 | + logTextView.textContainerInset = NSSize(width: 14, height: 14) |
| 48 | + logTextView.textContainer?.widthTracksTextView = true |
| 49 | + logTextView.textContainer?.containerSize = NSSize(width: 400, height: CGFloat.greatestFiniteMagnitude) |
| 50 | + documentView = logTextView |
| 51 | + } |
| 52 | + |
| 53 | + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } |
| 54 | + |
| 55 | + override func layout() { |
| 56 | + let follow = previousText == nil || isAtBottom |
| 57 | + super.layout() |
| 58 | + guard !updating, follow else { return } |
| 59 | + scrollToBottom() |
| 60 | + } |
| 61 | + |
| 62 | + func setLog(_ text: String, isPlaceholder: Bool = false) { |
| 63 | + guard text != previousText || isPlaceholder != previousPlaceholder else { return } |
| 64 | + let follow = previousText == nil || text.isEmpty || isPlaceholder || isAtBottom |
| 65 | + let origin = contentView.bounds.origin |
| 66 | + let selection = logTextView.selectedRanges |
| 67 | + updating = true |
| 68 | + defer { updating = false } |
| 69 | + |
| 70 | + let rendered = NSMutableAttributedString(string: "") |
| 71 | + for segment in ANSILogRenderer.segments(from: text) { |
| 72 | + let color = isPlaceholder ? NSColor.secondaryLabelColor |
| 73 | + : segment.foreground.map { NSColor($0.swiftUIColor) } ?? NSColor.white |
| 74 | + rendered.append(NSAttributedString(string: segment.text, attributes: [ |
| 75 | + .font: NSFont.monospacedSystemFont(ofSize: 11, weight: .regular), |
| 76 | + .foregroundColor: color |
| 77 | + ])) |
| 78 | + } |
| 79 | + logTextView.textStorage?.setAttributedString(rendered) |
| 80 | + logTextView.layoutManager?.ensureLayout(for: logTextView.textContainer!) |
| 81 | + logTextView.sizeToFit() |
| 82 | + layoutSubtreeIfNeeded() |
| 83 | + logTextView.selectedRanges = selection.map { |
| 84 | + let range = $0.rangeValue |
| 85 | + let location = min(range.location, rendered.length) |
| 86 | + return NSValue(range: NSRange(location: location, length: min(range.length, rendered.length - location))) |
| 87 | + } |
| 88 | + previousText = text |
| 89 | + previousPlaceholder = isPlaceholder |
| 90 | + if follow { |
| 91 | + scrollToBottom() |
| 92 | + } else { |
| 93 | + contentView.scroll(to: NSPoint(x: origin.x, y: min(origin.y, maximumOffset))) |
| 94 | + reflectScrolledClipView(contentView) |
| 95 | + } |
| 96 | + } |
| 97 | + |
| 98 | + private var maximumOffset: CGFloat { |
| 99 | + max(0, (documentView?.frame.height ?? 0) - contentView.bounds.height) |
| 100 | + } |
| 101 | + |
| 102 | + private func scrollToBottom() { |
| 103 | + contentView.scroll(to: NSPoint(x: contentView.bounds.minX, y: maximumOffset)) |
| 104 | + reflectScrolledClipView(contentView) |
| 105 | + } |
| 106 | +} |
0 commit comments