From 5b3c52d7fc895426eaf9886aa916d56de694f166 Mon Sep 17 00:00:00 2001 From: Roman <329079+romanr@users.noreply.github.com> Date: Thu, 5 Mar 2026 12:57:53 +1100 Subject: [PATCH 01/28] Add support for custom gutter views in STTextView --- .../STTextViewAppKit/STTextView+Gutter.swift | 174 +++++++++++++++++- Sources/STTextViewAppKit/STTextView.swift | 61 +++++- .../STTextViewSwiftUIAppKit/TextView.swift | 93 +++++++++- 3 files changed, 315 insertions(+), 13 deletions(-) diff --git a/Sources/STTextViewAppKit/STTextView+Gutter.swift b/Sources/STTextViewAppKit/STTextView+Gutter.swift index 89f556ea..869910d8 100644 --- a/Sources/STTextViewAppKit/STTextView+Gutter.swift +++ b/Sources/STTextViewAppKit/STTextView+Gutter.swift @@ -48,14 +48,16 @@ extension STTextView { } func layoutGutter() { - guard let gutterView, textLayoutManager.textViewportLayoutController.viewportRange != nil else { - return - } + // Layout built-in gutter (line numbers + markers) + if let gutterView, textLayoutManager.textViewportLayoutController.viewportRange != nil { + gutterView.frame.size.height = contentView.bounds.height - gutterView.frame.size.height = contentView.bounds.height + layoutGutterLineNumbers() + layoutGutterMarkers() + } - layoutGutterLineNumbers() - layoutGutterMarkers() + // Layout custom gutter line views (independent of built-in gutter) + layoutCustomGutterLineViews() } @@ -228,4 +230,164 @@ extension STTextView { gutterView.layoutMarkers() } + + // MARK: - Custom Gutter Line Views + + /// Positions custom gutter line views provided by ``gutterLineViewProvider``. + /// Creates the container view lazily as a floating subview, then enumerates + /// visible lines to create and position one NSView per paragraph. + private func layoutCustomGutterLineViews() { + guard let provider = gutterLineViewProvider, customGutterWidth > 0 else { + return + } + + // Lazy container setup — added as floating subview so it stays + // at a fixed horizontal position while scrolling vertically with content. + if customGutterContainerView == nil { + let container = STCustomGutterContainerView() + container.frame = NSRect(x: 0, y: 0, width: customGutterWidth, height: contentView.bounds.height) + if let enclosingScrollView { + enclosingScrollView.addFloatingSubview(container, for: .horizontal) + } else { + addSubview(container) + } + customGutterContainerView = container + } + + guard let container = customGutterContainerView else { return } + + // Update container dimensions and background + container.frame.size.width = customGutterWidth + container.frame.size.height = contentView.bounds.height + container.layer?.backgroundColor = customGutterBackgroundColor?.cgColor + + // Remove old line views (and separator — it gets re-added at the end) + container.subviews.forEach { $0.removeFromSuperviewWithoutNeedingDisplay() } + + // Empty document — show a single view for line 1 + if textLayoutManager.documentRange.isEmpty { + if let selectionFrame = textLayoutManager.textSegmentFrame(at: textLayoutManager.documentRange.location, type: .standard) { + let lineView = provider(1, "") + lineView.frame = CGRect( + origin: CGPoint(x: 0, y: selectionFrame.origin.y), + size: CGSize(width: customGutterWidth, height: typingLineHeight) + ).pixelAligned + container.addSubview(lineView) + } + return + } + + guard let viewportRange = textLayoutManager.textViewportLayoutController.viewportRange else { + return + } + + let visibleFragmentViews = STGutterCalculations.visibleFragmentViewsInViewport( + fragmentViewMap: fragmentViewMap, + viewportRange: viewportRange + ) + + guard !visibleFragmentViews.isEmpty else { + return + } + + // Count paragraphs before the viewport to determine starting line number + let textElementsBeforeViewport = textContentManager.textElements( + for: NSTextRange( + location: textLayoutManager.documentRange.location, + end: viewportRange.location + )! + ) + + let startLineIndex = textElementsBeforeViewport.count + var linesCount = 0 + + for (layoutFragment, fragmentView) in visibleFragmentViews { + // One custom view per paragraph (first text line fragment or extra line fragment) + for textLineFragment in layoutFragment.textLineFragments where (textLineFragment.isExtraLineFragment || layoutFragment.textLineFragments.first == textLineFragment) { + let lineNumber = startLineIndex + linesCount + 1 + + // Calculate the cell frame for this line (same positioning as line number cells) + let (_, _, cellFrame) = STGutterCalculations.calculateLineNumberMetrics( + for: textLineFragment, + in: layoutFragment, + fragmentViewFrame: fragmentView.frame + ) + + // Extract the paragraph text content, trimming the trailing newline + let lineContent: String + if let paragraph = layoutFragment.textElement as? NSTextParagraph { + var text = paragraph.attributedString.string + if text.hasSuffix("\n") { + text = String(text.dropLast()) + } + lineContent = text + } else { + lineContent = "" + } + + let lineView = provider(lineNumber, lineContent) + lineView.frame = CGRect( + origin: CGPoint(x: 0, y: cellFrame.origin.y), + size: CGSize(width: customGutterWidth, height: cellFrame.size.height) + ).pixelAligned + container.addSubview(lineView) + + linesCount += 1 + } + } + + // Draw trailing separator on top of all line views + addCustomGutterSeparator(to: container) + } + + /// Adds a vertical separator line on the trailing edge of the custom gutter container. + private func addCustomGutterSeparator(to container: NSView) { + guard let separatorColor = customGutterSeparatorColor, customGutterSeparatorWidth > 0 else { + return + } + + let separator = NSView(frame: CGRect( + x: customGutterWidth - customGutterSeparatorWidth, + y: 0, + width: customGutterSeparatorWidth, + height: container.bounds.height + )) + separator.wantsLayer = true + separator.layer?.backgroundColor = separatorColor.cgColor + container.addSubview(separator) + } +} + +// MARK: - Custom Gutter Container + +/// Flipped container view for custom gutter line views. +/// Uses flipped coordinates (top-to-bottom) to match document layout. +private class STCustomGutterContainerView: NSView { + + override var isFlipped: Bool { + true + } + + override var isOpaque: Bool { + false + } + + override func makeBackingLayer() -> CALayer { + CATiledLayer() + } + + override func animation(forKey key: NSAnimatablePropertyKey) -> Any? { + nil + } + + init() { + super.init(frame: .zero) + wantsLayer = true + clipsToBounds = true + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } } diff --git a/Sources/STTextViewAppKit/STTextView.swift b/Sources/STTextViewAppKit/STTextView.swift index 717bd850..4009aeb7 100644 --- a/Sources/STTextViewAppKit/STTextView.swift +++ b/Sources/STTextViewAppKit/STTextView.swift @@ -378,6 +378,59 @@ open class STTextView: NSView, NSTextInput, NSTextContent, STTextViewProtocol { /// Gutter view public var gutterView: STGutterView? + /// Width of the custom gutter area. When greater than 0, ``contentView.frame.origin.x`` + /// is offset by this amount, allowing custom gutter content without the built-in ``STGutterView``. + /// + /// Use together with ``gutterLineViewProvider`` to supply a custom NSView per visible line. + /// When the built-in ``gutterView`` is present, its width takes precedence. + open var customGutterWidth: CGFloat = 0 { + didSet { + if customGutterWidth <= 0 { + customGutterContainerView?.removeFromSuperview() + customGutterContainerView = nil + } + needsLayout = true + } + } + + /// Provider for custom gutter line views. Called with `(lineNumber, lineContent)` for each + /// visible line during layout. The returned view is positioned to fill the full line height + /// (including spacing) in the custom gutter area. + /// + /// Set ``customGutterWidth`` to reserve space for the gutter. + open var gutterLineViewProvider: ((Int, String) -> NSView)? { + didSet { + if gutterLineViewProvider == nil { + customGutterContainerView?.removeFromSuperview() + customGutterContainerView = nil + } + needsLayout = true + } + } + + /// Container view for custom gutter line views (created lazily during layout). + /// Access this after the first layout pass to apply additional styling. + public internal(set) var customGutterContainerView: NSView? + + /// Background color for the custom gutter area. + /// Applied to the container view's backing layer. + open var customGutterBackgroundColor: NSColor? { + didSet { + customGutterContainerView?.layer?.backgroundColor = customGutterBackgroundColor?.cgColor + } + } + + /// Color of the vertical separator drawn on the trailing edge of the custom gutter. + /// Set to `nil` to hide the separator. + open var customGutterSeparatorColor: NSColor? { + didSet { needsLayout = true } + } + + /// Width of the trailing separator line. Default 2. + open var customGutterSeparatorWidth: CGFloat = 2 { + didSet { needsLayout = true } + } + /// The highlight color of the selected line. /// /// Note: Needs ``highlightSelectedLine`` to be set to `true` @@ -1478,8 +1531,10 @@ open class STTextView: NSView, NSTextInput, NSTextContent, STTextViewProtocol { override open func setFrameSize(_ newSize: NSSize) { super.setFrameSize(newSize) - // contentView should always fill the entire STTextView - contentView.frame.origin.x = gutterView?.frame.width ?? 0 + // contentView should always fill the entire STTextView. + // Built-in gutterView width takes precedence; fall back to customGutterWidth + // so that custom gutter views can offset the content without enabling showsLineNumbers. + contentView.frame.origin.x = gutterView?.frame.width ?? customGutterWidth contentView.frame.size = newSize updateTextContainerSize(proposedSize: newSize) @@ -1491,7 +1546,7 @@ open class STTextView: NSView, NSTextInput, NSTextContent, STTextViewProtocol { } func updateContentSizeIfNeeded() { - let gutterWidth = gutterView?.frame.width ?? 0 + let gutterWidth = gutterView?.frame.width ?? customGutterWidth let scrollerInset = scrollView?.contentView.contentInsets.right ?? 0 var estimatedSize = textLayoutManager.usageBoundsForTextContainer.size diff --git a/Sources/STTextViewSwiftUIAppKit/TextView.swift b/Sources/STTextViewSwiftUIAppKit/TextView.swift index 8d8236dc..06a50cef 100644 --- a/Sources/STTextViewSwiftUIAppKit/TextView.swift +++ b/Sources/STTextViewSwiftUIAppKit/TextView.swift @@ -19,6 +19,11 @@ public struct TextView: SwiftUI.View, TextViewModifier { @Binding private var selection: NSRange? private let options: Options private let plugins: [any STPlugin] + private let gutterWidth: CGFloat + private let gutterLineViewFactory: ((Int, String) -> NSView)? + private let gutterBackgroundColor: NSColor? + private let gutterSeparatorColor: NSColor? + private let gutterSeparatorWidth: CGFloat /// Create a text edit view with a certain text that uses a certain options. /// - Parameters: @@ -36,6 +41,52 @@ public struct TextView: SwiftUI.View, TextViewModifier { _selection = selection self.options = options self.plugins = plugins + self.gutterWidth = 0 + self.gutterLineViewFactory = nil + self.gutterBackgroundColor = nil + self.gutterSeparatorColor = nil + self.gutterSeparatorWidth = 0 + } + + /// Create a text edit view with a custom gutter that displays a SwiftUI view per line. + /// + /// Each visible line in the editor gets its own gutter view, positioned to fill the + /// full line height (including spacing). The view builder receives the 1-based line + /// number and the text content of that line. + /// + /// - Parameters: + /// - text: The attributed string content + /// - selection: The current selection range + /// - options: Editor options + /// - plugins: Editor plugins + /// - gutterWidth: Width reserved for the custom gutter area (in points) + /// - gutterBackgroundColor: Background color for the gutter container (optional) + /// - gutterSeparatorColor: Color of the trailing vertical separator (optional, nil hides it) + /// - gutterSeparatorWidth: Width of the trailing separator in points (default 2) + /// - gutterContent: A view builder called with `(lineNumber, lineContent)` for each visible line + public init( + text: Binding, + selection: Binding = .constant(nil), + options: Options = [], + plugins: [any STPlugin] = [], + gutterWidth: CGFloat, + gutterBackgroundColor: NSColor? = nil, + gutterSeparatorColor: NSColor? = nil, + gutterSeparatorWidth: CGFloat = 2, + @ViewBuilder gutterContent: @escaping (_ lineNumber: Int, _ lineContent: String) -> GutterContent + ) { + _text = text + _selection = selection + self.options = options + self.plugins = plugins + self.gutterWidth = gutterWidth + self.gutterBackgroundColor = gutterBackgroundColor + self.gutterSeparatorColor = gutterSeparatorColor + self.gutterSeparatorWidth = gutterSeparatorWidth + self.gutterLineViewFactory = { lineNumber, lineContent in + let hostingView = NSHostingView(rootView: gutterContent(lineNumber, lineContent)) + return hostingView + } } public var body: some View { @@ -43,7 +94,12 @@ public struct TextView: SwiftUI.View, TextViewModifier { text: $text, selection: $selection, options: options, - plugins: plugins + plugins: plugins, + gutterWidth: gutterWidth, + gutterLineViewFactory: gutterLineViewFactory, + gutterBackgroundColor: gutterBackgroundColor, + gutterSeparatorColor: gutterSeparatorColor, + gutterSeparatorWidth: gutterSeparatorWidth ) .background(.background) } @@ -67,12 +123,22 @@ private struct TextViewRepresentable: NSViewRepresentable { private var selection: NSRange? private let options: TextView.Options private var plugins: [any STPlugin] + let gutterWidth: CGFloat + let gutterLineViewFactory: ((Int, String) -> NSView)? + let gutterBackgroundColor: NSColor? + let gutterSeparatorColor: NSColor? + let gutterSeparatorWidth: CGFloat - init(text: Binding, selection: Binding, options: TextView.Options, plugins: [any STPlugin] = []) { + init(text: Binding, selection: Binding, options: TextView.Options, plugins: [any STPlugin] = [], gutterWidth: CGFloat = 0, gutterLineViewFactory: ((Int, String) -> NSView)? = nil, gutterBackgroundColor: NSColor? = nil, gutterSeparatorColor: NSColor? = nil, gutterSeparatorWidth: CGFloat = 0) { self._text = text self._selection = selection self.options = options self.plugins = plugins + self.gutterWidth = gutterWidth + self.gutterLineViewFactory = gutterLineViewFactory + self.gutterBackgroundColor = gutterBackgroundColor + self.gutterSeparatorColor = gutterSeparatorColor + self.gutterSeparatorWidth = gutterSeparatorWidth } func makeNSView(context: Context) -> NSScrollView { @@ -106,6 +172,15 @@ private struct TextViewRepresentable: NSViewRepresentable { textView.gutterView?.textColor = .secondaryLabelColor } + // Configure custom gutter if provided + if gutterWidth > 0 { + textView.customGutterWidth = gutterWidth + textView.gutterLineViewProvider = gutterLineViewFactory + textView.customGutterBackgroundColor = gutterBackgroundColor + textView.customGutterSeparatorColor = gutterSeparatorColor + textView.customGutterSeparatorWidth = gutterSeparatorWidth + } + context.coordinator.isUpdating = true textView.attributedText = NSAttributedString(styledAttributedString(textView.typingAttributes)) context.coordinator.isUpdating = false @@ -121,7 +196,7 @@ private struct TextViewRepresentable: NSViewRepresentable { return scrollView } - + func sizeThatFits(_ proposal: ProposedViewSize, nsView: NSScrollView, context: Context) -> CGSize? { let width = proposal.width ?? nsView.frame.size.width let height = proposal.height ?? nsView.frame.size.height @@ -172,6 +247,17 @@ private struct TextViewRepresentable: NSViewRepresentable { } } + // Update custom gutter — the factory may capture new SwiftUI state + if gutterWidth > 0 { + if textView.customGutterWidth != gutterWidth { + textView.customGutterWidth = gutterWidth + } + textView.gutterLineViewProvider = gutterLineViewFactory + textView.customGutterBackgroundColor = gutterBackgroundColor + textView.customGutterSeparatorColor = gutterSeparatorColor + textView.customGutterSeparatorWidth = gutterSeparatorWidth + } + textView.needsLayout = true textView.needsDisplay = true } @@ -226,4 +312,3 @@ private struct TextViewRepresentable: NSViewRepresentable { } } - From 098221df179b5ee2958ae03075218156988d34ef Mon Sep 17 00:00:00 2001 From: Roman <329079+romanr@users.noreply.github.com> Date: Thu, 5 Mar 2026 13:57:26 +1100 Subject: [PATCH 02/28] Refactor gutter SwiftUI API into separate TextViewWithGutter type Keep TextView's public init clean (text, selection, options, plugins only). Introduce TextViewWithGutter as a dedicated view type for editors with custom per-line gutters. Gutter styling (background color, separator) uses environment-based view modifiers (.gutterBackground, .gutterSeparator) following the library's existing TextViewModifier pattern. Co-Authored-By: Claude Opus 4.6 --- .../STTextViewSwiftUIAppKit/TextView.swift | 143 ++++++++++++++---- 1 file changed, 112 insertions(+), 31 deletions(-) diff --git a/Sources/STTextViewSwiftUIAppKit/TextView.swift b/Sources/STTextViewSwiftUIAppKit/TextView.swift index 06a50cef..85f3e098 100644 --- a/Sources/STTextViewSwiftUIAppKit/TextView.swift +++ b/Sources/STTextViewSwiftUIAppKit/TextView.swift @@ -19,11 +19,6 @@ public struct TextView: SwiftUI.View, TextViewModifier { @Binding private var selection: NSRange? private let options: Options private let plugins: [any STPlugin] - private let gutterWidth: CGFloat - private let gutterLineViewFactory: ((Int, String) -> NSView)? - private let gutterBackgroundColor: NSColor? - private let gutterSeparatorColor: NSColor? - private let gutterSeparatorWidth: CGFloat /// Create a text edit view with a certain text that uses a certain options. /// - Parameters: @@ -41,18 +36,58 @@ public struct TextView: SwiftUI.View, TextViewModifier { _selection = selection self.options = options self.plugins = plugins - self.gutterWidth = 0 - self.gutterLineViewFactory = nil - self.gutterBackgroundColor = nil - self.gutterSeparatorColor = nil - self.gutterSeparatorWidth = 0 } - /// Create a text edit view with a custom gutter that displays a SwiftUI view per line. - /// - /// Each visible line in the editor gets its own gutter view, positioned to fill the - /// full line height (including spacing). The view builder receives the 1-based line - /// number and the text content of that line. + public var body: some View { + TextViewRepresentable( + text: $text, + selection: $selection, + options: options, + plugins: plugins + ) + .background(.background) + } +} + +// MARK: - Text View With Custom Gutter + +/// A SwiftUI text editor view with a custom per-line gutter. +/// +/// Each visible line in the editor gets its own SwiftUI gutter view, +/// positioned to fill the full line height (including spacing). +/// The view builder receives the 1-based line number and the plain-text +/// content of that line. +/// +/// Usage: +/// ```swift +/// TextViewWithGutter( +/// text: $text, +/// gutterWidth: 64, +/// gutterContent: { lineNumber, lineContent in +/// Text("\(lineNumber)") +/// } +/// ) +/// .gutterBackground(NSColor.controlBackgroundColor) +/// .gutterSeparator(color: .separatorColor, width: 1) +/// ``` +@MainActor @preconcurrency +public struct TextViewWithGutter: SwiftUI.View, TextViewModifier { + + public typealias Options = TextViewOptions + + @Environment(\.colorScheme) private var colorScheme + @Environment(\.gutterBackgroundColor) private var envGutterBackgroundColor + @Environment(\.gutterSeparatorColor) private var envGutterSeparatorColor + @Environment(\.gutterSeparatorWidth) private var envGutterSeparatorWidth + + @Binding private var text: AttributedString + @Binding private var selection: NSRange? + private let options: Options + private let plugins: [any STPlugin] + private let gutterWidth: CGFloat + private let gutterLineViewFactory: (Int, String) -> NSView + + /// Create a text editor with a custom per-line gutter. /// /// - Parameters: /// - text: The attributed string content @@ -60,19 +95,13 @@ public struct TextView: SwiftUI.View, TextViewModifier { /// - options: Editor options /// - plugins: Editor plugins /// - gutterWidth: Width reserved for the custom gutter area (in points) - /// - gutterBackgroundColor: Background color for the gutter container (optional) - /// - gutterSeparatorColor: Color of the trailing vertical separator (optional, nil hides it) - /// - gutterSeparatorWidth: Width of the trailing separator in points (default 2) - /// - gutterContent: A view builder called with `(lineNumber, lineContent)` for each visible line - public init( + /// - gutterContent: A view builder called for each visible line with `(lineNumber, lineContent)` + public init( text: Binding, selection: Binding = .constant(nil), options: Options = [], plugins: [any STPlugin] = [], gutterWidth: CGFloat, - gutterBackgroundColor: NSColor? = nil, - gutterSeparatorColor: NSColor? = nil, - gutterSeparatorWidth: CGFloat = 2, @ViewBuilder gutterContent: @escaping (_ lineNumber: Int, _ lineContent: String) -> GutterContent ) { _text = text @@ -80,12 +109,8 @@ public struct TextView: SwiftUI.View, TextViewModifier { self.options = options self.plugins = plugins self.gutterWidth = gutterWidth - self.gutterBackgroundColor = gutterBackgroundColor - self.gutterSeparatorColor = gutterSeparatorColor - self.gutterSeparatorWidth = gutterSeparatorWidth self.gutterLineViewFactory = { lineNumber, lineContent in - let hostingView = NSHostingView(rootView: gutterContent(lineNumber, lineContent)) - return hostingView + NSHostingView(rootView: gutterContent(lineNumber, lineContent)) } } @@ -97,14 +122,70 @@ public struct TextView: SwiftUI.View, TextViewModifier { plugins: plugins, gutterWidth: gutterWidth, gutterLineViewFactory: gutterLineViewFactory, - gutterBackgroundColor: gutterBackgroundColor, - gutterSeparatorColor: gutterSeparatorColor, - gutterSeparatorWidth: gutterSeparatorWidth + gutterBackgroundColor: envGutterBackgroundColor, + gutterSeparatorColor: envGutterSeparatorColor, + gutterSeparatorWidth: envGutterSeparatorWidth ) .background(.background) } } +// MARK: - Gutter Style Modifiers + +/// Environment key for custom gutter background color. +private struct GutterBackgroundColorKey: EnvironmentKey { + static let defaultValue: NSColor? = nil +} + +/// Environment key for custom gutter separator color. +private struct GutterSeparatorColorKey: EnvironmentKey { + static let defaultValue: NSColor? = nil +} + +/// Environment key for custom gutter separator width. +private struct GutterSeparatorWidthKey: EnvironmentKey { + static let defaultValue: CGFloat = 2 +} + +extension EnvironmentValues { + var gutterBackgroundColor: NSColor? { + get { self[GutterBackgroundColorKey.self] } + set { self[GutterBackgroundColorKey.self] = newValue } + } + + var gutterSeparatorColor: NSColor? { + get { self[GutterSeparatorColorKey.self] } + set { self[GutterSeparatorColorKey.self] = newValue } + } + + var gutterSeparatorWidth: CGFloat { + get { self[GutterSeparatorWidthKey.self] } + set { self[GutterSeparatorWidthKey.self] = newValue } + } +} + +public extension TextViewModifier { + + /// Sets the background color for the custom gutter area. + func gutterBackground(_ color: NSColor?) -> TextViewEnvironmentModifier { + TextViewEnvironmentModifier(content: self, keyPath: \.gutterBackgroundColor, value: color) + } + + /// Sets the trailing separator for the custom gutter area. + /// - Parameters: + /// - color: Color of the vertical separator line (nil hides it) + /// - width: Width of the separator in points (default 2) + func gutterSeparator(color: NSColor?, width: CGFloat = 2) -> TextViewEnvironmentModifier, CGFloat> { + TextViewEnvironmentModifier( + content: TextViewEnvironmentModifier(content: self, keyPath: \.gutterSeparatorColor, value: color), + keyPath: \.gutterSeparatorWidth, + value: width + ) + } +} + +// MARK: - NSViewRepresentable + private struct TextViewRepresentable: NSViewRepresentable { @Environment(\.isEnabled) private var isEnabled From 50f04dc32c73e29ca83bb3d7a936292a43bc2911 Mon Sep 17 00:00:00 2001 From: Roman <329079+romanr@users.noreply.github.com> Date: Fri, 6 Mar 2026 01:37:45 +1100 Subject: [PATCH 03/28] Add custom gutter demo to SwiftUI example app Demonstrates TextViewWithGutter API with per-line content: - Word count display (instead of line numbers) - Toggleable bookmark icon (bookmark/bookmark.fill) - Overhanging breakpoint badge with shadow (activated by tapping number) - Gutter styling via .gutterBackground() and .gutterSeparator() modifiers Activated via toolbar toggle button (list.star icon). Co-Authored-By: Claude Opus 4.6 --- TextEdit.SwiftUI/ContentView.swift | 164 +++++++++++++++++++++++++++-- 1 file changed, 154 insertions(+), 10 deletions(-) diff --git a/TextEdit.SwiftUI/ContentView.swift b/TextEdit.SwiftUI/ContentView.swift index 6429e85b..6ca87a1c 100644 --- a/TextEdit.SwiftUI/ContentView.swift +++ b/TextEdit.SwiftUI/ContentView.swift @@ -21,24 +21,30 @@ struct ContentView: View { @State private var font = Font.monospacedSystemFont(ofSize: 0, weight: .medium) @State private var wrapLines = true @State private var showLineNumbers = false + @State private var showCustomGutter = false + + /// Tracks which lines have bookmarks toggled on (by 1-based line number). + @State private var bookmarkedLines: Set = [] + + /// Tracks which lines have breakpoints active (by 1-based line number). + @State private var breakpointLines: Set = [] private var options: TextView.Options { var opts: TextView.Options = [.highlightSelectedLine] if wrapLines { opts.insert(.wrapLines) } - if showLineNumbers { opts.insert(.showLineNumbers) } + if showLineNumbers && !showCustomGutter { opts.insert(.showLineNumbers) } return opts } var body: some View { NavigationStack { - // Issue #91: Using .wrapLines and setting text attributes in onAppear - // previously caused an infinite loop. Now fixed. - TextView( - text: $text, - selection: $selection, - options: options - ) - .textViewFont(font) + Group { + if showCustomGutter { + customGutterEditor + } else { + plainEditor + } + } .ignoresSafeArea(.container) .navigationTitle("STTextView") #if os(iOS) @@ -50,7 +56,10 @@ struct ContentView: View { Label("Wrap Lines", systemImage: wrapLines ? "text.word.spacing" : "arrow.left.and.right.text.vertical") } Toggle(isOn: $showLineNumbers) { - Label("Line Numbers", systemImage: showLineNumbers ? "list.number" : "list.bullet") + Label("Line Numbers", systemImage: showLineNumbers ? "list.number" : "list.bullet").labelStyle(.titleAndIcon) + } + Toggle(isOn: $showCustomGutter) { + Label("Custom Gutter", systemImage: "list.star").labelStyle(.titleAndIcon) } } } @@ -62,6 +71,64 @@ struct ContentView: View { } } + // MARK: - Plain Editor (no gutter or built-in line numbers) + + private var plainEditor: some View { + TextView( + text: $text, + selection: $selection, + options: options + ) + .textViewFont(font) + } + + // MARK: - Custom Gutter Editor + + /// Editor with a custom per-line gutter showing word count, bookmark, and breakpoint. + private var customGutterEditor: some View { + TextViewWithGutter( + text: $text, + selection: $selection, + options: options, + gutterWidth: 64, + gutterContent: { lineNumber, lineContent in + CustomGutterLineView( + lineNumber: lineNumber, + lineContent: lineContent, + isBookmarked: bookmarkedLines.contains(lineNumber), + hasBreakpoint: breakpointLines.contains(lineNumber), + onToggleBookmark: { + toggleBookmark(lineNumber) + }, + onToggleBreakpoint: { + toggleBreakpoint(lineNumber) + } + ) + } + ) + .gutterBackground(NSColor(srgbRed: 0.992, green: 0.984, blue: 0.969, alpha: 1)) + .gutterSeparator(color: NSColor(srgbRed: 0.75, green: 0.75, blue: 0.75, alpha: 0.5), width: 1) + .textViewFont(font) + } + + // MARK: - Actions + + private func toggleBookmark(_ lineNumber: Int) { + if bookmarkedLines.contains(lineNumber) { + bookmarkedLines.remove(lineNumber) + } else { + bookmarkedLines.insert(lineNumber) + } + } + + private func toggleBreakpoint(_ lineNumber: Int) { + if breakpointLines.contains(lineNumber) { + breakpointLines.remove(lineNumber) + } else { + breakpointLines.insert(lineNumber) + } + } + private func loadContent() { let string = try! String(contentsOf: Bundle.main.url(forResource: "content", withExtension: "txt")!) self.text = AttributedString( @@ -71,6 +138,83 @@ struct ContentView: View { } } +// MARK: - Custom Gutter Line View + +/// Per-line gutter view demonstrating word count, toggleable bookmark, and +/// an overhanging breakpoint indicator activated by tapping the number. +/// +/// The breakpoint badge intentionally extends past the gutter's trailing edge +/// to demonstrate that custom gutter content can overhang when needed. +private struct CustomGutterLineView: View { + let lineNumber: Int + let lineContent: String + let isBookmarked: Bool + let hasBreakpoint: Bool + let onToggleBookmark: () -> Void + let onToggleBreakpoint: () -> Void + + /// Number of whitespace-separated words on this line. + private var wordCount: Int { + let trimmed = lineContent.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return 0 } + return trimmed.split(whereSeparator: { $0.isWhitespace }).count + } + + var body: some View { + HStack(spacing: 3) { + Spacer(minLength: 0) + + // Bookmark icon — toggles between outline and filled on click + Button(action: onToggleBookmark) { + Image(systemName: isBookmarked ? "bookmark.fill" : "bookmark") + .font(.system(size: 9)) + .foregroundStyle(isBookmarked ? SwiftUI.Color.orange : SwiftUI.Color.secondary.opacity(0.5)) + } + .buttonStyle(.plain) + + // Word count number — tapping toggles breakpoint + if hasBreakpoint { + breakpointBadge + } else { + wordCountLabel + } + } + .padding(.trailing, 4) + .padding(.leading, 2) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .trailing) + } + + /// Plain word count number — tappable to activate breakpoint. + private var wordCountLabel: some View { + Group { + if wordCount > 0 { + Text("\(wordCount)") + .font(.system(size: 14, weight: .regular, design: .rounded)) + .foregroundStyle(.secondary) + .onTapGesture(perform: onToggleBreakpoint) + } + } + } + + /// Overhanging breakpoint badge — blue rounded rect with white number, + /// extends ~8pt past the gutter edge to demonstrate overhang capability. + /// Drop shadow adds depth. Tappable to deactivate. + private var breakpointBadge: some View { + Text("\(wordCount > 0 ? wordCount : lineNumber)") + .font(.system(size: 12, weight: .semibold, design: .rounded)) + .foregroundStyle(.white) + .padding(.horizontal, 8) + .padding(.vertical, 2) + .background( + RoundedRectangle(cornerRadius: 6) + .fill(SwiftUI.Color.accentColor) + .shadow(color: .black.opacity(0.25), radius: 3, x: 1, y: 1) + ) + .offset(x: 8) // Overhang past gutter edge + .onTapGesture(perform: onToggleBreakpoint) + } +} + struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView() From bca757586f713263c3daf57b9e4bb9697b22ab29 Mon Sep 17 00:00:00 2001 From: Roman <329079+romanr@users.noreply.github.com> Date: Fri, 6 Mar 2026 01:48:51 +1100 Subject: [PATCH 04/28] Fix custom gutter: clipping, interactions, and view lifecycle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Remove clipsToBounds from container to allow content overhang (e.g. breakpoint badges with shadows extending past gutter edge) - Remove CATiledLayer — use regular CALayer instead. CATiledLayer renders asynchronously which breaks NSHostingView event delivery - Add subview to container BEFORE setting frame so NSHostingView has a window and can properly lay out SwiftUI content - Use identifier-based view management instead of remove-all/recreate pattern — line views are tagged and pruned by visibility - Separator uses its own identifier to avoid being pruned with line views Co-Authored-By: Claude Opus 4.6 --- .../STTextViewAppKit/STTextView+Gutter.swift | 86 ++++++++++++++++--- .../project.pbxproj | 6 +- 2 files changed, 78 insertions(+), 14 deletions(-) diff --git a/Sources/STTextViewAppKit/STTextView+Gutter.swift b/Sources/STTextViewAppKit/STTextView+Gutter.swift index 869910d8..c29525d3 100644 --- a/Sources/STTextViewAppKit/STTextView+Gutter.swift +++ b/Sources/STTextViewAppKit/STTextView+Gutter.swift @@ -233,9 +233,19 @@ extension STTextView { // MARK: - Custom Gutter Line Views + /// Identifier prefix for custom gutter line views. + private static let gutterLineViewIDPrefix = "stgutter-line-" + + /// Identifier for the trailing separator view inside the custom gutter container. + private static let gutterSeparatorID = NSUserInterfaceItemIdentifier("stgutter-separator") + /// Positions custom gutter line views provided by ``gutterLineViewProvider``. /// Creates the container view lazily as a floating subview, then enumerates /// visible lines to create and position one NSView per paragraph. + /// + /// Views are cached by line number (via `tag`) and reused across layout passes + /// so that interactive SwiftUI content (buttons, gestures) inside NSHostingViews + /// keeps working. Views are only recreated when the line's content or state changes. private func layoutCustomGutterLineViews() { guard let provider = gutterLineViewProvider, customGutterWidth > 0 else { return @@ -261,19 +271,23 @@ extension STTextView { container.frame.size.height = contentView.bounds.height container.layer?.backgroundColor = customGutterBackgroundColor?.cgColor - // Remove old line views (and separator — it gets re-added at the end) - container.subviews.forEach { $0.removeFromSuperviewWithoutNeedingDisplay() } + // Track which line numbers are currently visible so we can prune stale views + var visibleIDs = Set() // Empty document — show a single view for line 1 if textLayoutManager.documentRange.isEmpty { if let selectionFrame = textLayoutManager.textSegmentFrame(at: textLayoutManager.documentRange.location, type: .standard) { - let lineView = provider(1, "") + let lineID = Self.gutterLineViewID(for: 1) + visibleIDs.insert(lineID) + + let lineView = lineViewForID(lineID, in: container, provider: provider, lineNumber: 1, lineContent: "") lineView.frame = CGRect( origin: CGPoint(x: 0, y: selectionFrame.origin.y), size: CGSize(width: customGutterWidth, height: typingLineHeight) ).pixelAligned - container.addSubview(lineView) } + pruneStaleLineViews(in: container, keeping: visibleIDs) + addCustomGutterSeparator(to: container) return } @@ -305,6 +319,8 @@ extension STTextView { // One custom view per paragraph (first text line fragment or extra line fragment) for textLineFragment in layoutFragment.textLineFragments where (textLineFragment.isExtraLineFragment || layoutFragment.textLineFragments.first == textLineFragment) { let lineNumber = startLineIndex + linesCount + 1 + let lineID = Self.gutterLineViewID(for: lineNumber) + visibleIDs.insert(lineID) // Calculate the cell frame for this line (same positioning as line number cells) let (_, _, cellFrame) = STGutterCalculations.calculateLineNumberMetrics( @@ -325,23 +341,70 @@ extension STTextView { lineContent = "" } - let lineView = provider(lineNumber, lineContent) + let lineView = lineViewForID(lineID, in: container, provider: provider, lineNumber: lineNumber, lineContent: lineContent) lineView.frame = CGRect( origin: CGPoint(x: 0, y: cellFrame.origin.y), size: CGSize(width: customGutterWidth, height: cellFrame.size.height) ).pixelAligned - container.addSubview(lineView) linesCount += 1 } } + // Remove views for lines that scrolled out of the viewport + pruneStaleLineViews(in: container, keeping: visibleIDs) + // Draw trailing separator on top of all line views addCustomGutterSeparator(to: container) } - /// Adds a vertical separator line on the trailing edge of the custom gutter container. + /// Creates an identifier for a custom gutter line view at the given line number. + private static func gutterLineViewID(for lineNumber: Int) -> NSUserInterfaceItemIdentifier { + NSUserInterfaceItemIdentifier(gutterLineViewIDPrefix + "\(lineNumber)") + } + + /// Returns (or creates) a gutter line view for the given identifier. + /// Always recreates the view from the provider to pick up captured SwiftUI state, + /// but adds to the container first so the NSHostingView has a window before layout. + private func lineViewForID( + _ id: NSUserInterfaceItemIdentifier, + in container: NSView, + provider: (Int, String) -> NSView, + lineNumber: Int, + lineContent: String + ) -> NSView { + // Remove existing view for this line — it captured stale state + if let existing = container.subviews.first(where: { $0.identifier == id }) { + existing.removeFromSuperviewWithoutNeedingDisplay() + } + + let lineView = provider(lineNumber, lineContent) + lineView.identifier = id + // Add to container BEFORE setting frame so the NSHostingView + // has a window and can properly lay out its SwiftUI content. + container.addSubview(lineView) + + return lineView + } + + /// Removes gutter line views whose identifiers are not in the `keeping` set. + private func pruneStaleLineViews(in container: NSView, keeping visibleIDs: Set) { + for subview in container.subviews where subview.identifier != nil { + guard let id = subview.identifier else { continue } + let isLineView = id.rawValue.hasPrefix(Self.gutterLineViewIDPrefix) + if isLineView && !visibleIDs.contains(id) { + subview.removeFromSuperviewWithoutNeedingDisplay() + } + } + } + + /// Adds (or updates) a vertical separator line on the trailing edge of the custom gutter container. private func addCustomGutterSeparator(to container: NSView) { + // Remove existing separator + if let existing = container.subviews.first(where: { $0.identifier == Self.gutterSeparatorID }) { + existing.removeFromSuperviewWithoutNeedingDisplay() + } + guard let separatorColor = customGutterSeparatorColor, customGutterSeparatorWidth > 0 else { return } @@ -352,6 +415,7 @@ extension STTextView { width: customGutterSeparatorWidth, height: container.bounds.height )) + separator.identifier = Self.gutterSeparatorID separator.wantsLayer = true separator.layer?.backgroundColor = separatorColor.cgColor container.addSubview(separator) @@ -362,6 +426,8 @@ extension STTextView { /// Flipped container view for custom gutter line views. /// Uses flipped coordinates (top-to-bottom) to match document layout. +/// Does NOT clip to bounds so that per-line views can overhang past +/// the gutter edge (e.g. breakpoint badges with shadows). private class STCustomGutterContainerView: NSView { override var isFlipped: Bool { @@ -372,10 +438,6 @@ private class STCustomGutterContainerView: NSView { false } - override func makeBackingLayer() -> CALayer { - CATiledLayer() - } - override func animation(forKey key: NSAnimatablePropertyKey) -> Any? { nil } @@ -383,7 +445,7 @@ private class STCustomGutterContainerView: NSView { init() { super.init(frame: .zero) wantsLayer = true - clipsToBounds = true + // clipsToBounds intentionally left false to allow overhang } @available(*, unavailable) diff --git a/TextEdit.SwiftUI/TextEdit.SwiftUI.xcodeproj/project.pbxproj b/TextEdit.SwiftUI/TextEdit.SwiftUI.xcodeproj/project.pbxproj index 1b4fe386..a45efa93 100644 --- a/TextEdit.SwiftUI/TextEdit.SwiftUI.xcodeproj/project.pbxproj +++ b/TextEdit.SwiftUI/TextEdit.SwiftUI.xcodeproj/project.pbxproj @@ -179,11 +179,12 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = TextEditUI.entitlements; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"Preview Content\""; - DEVELOPMENT_TEAM = 67RAULRX93; + DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -213,11 +214,12 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; CODE_SIGN_ENTITLEMENTS = TextEditUI.entitlements; + "CODE_SIGN_IDENTITY[sdk=macosx*]" = "-"; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_ASSET_PATHS = "\"Preview Content\""; - DEVELOPMENT_TEAM = 67RAULRX93; + DEVELOPMENT_TEAM = ""; ENABLE_HARDENED_RUNTIME = YES; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; From 93966b67bee056e76730ead98bb4a94ea3b9d136 Mon Sep 17 00:00:00 2001 From: Roman <329079+romanr@users.noreply.github.com> Date: Fri, 6 Mar 2026 03:01:07 +1100 Subject: [PATCH 05/28] Fix Xcode preview thunking conflict with @MainActor closures Replace closure properties (onToggleBookmark, onToggleBreakpoint) with @Binding parameters in CustomGutterLineView. The preview thunking system wraps expressions in __designTimeSelection which can't reconcile () -> Void vs @MainActor () -> Void from SwiftUI View's actor isolation. Co-Authored-By: Claude Opus 4.6 --- TextEdit.SwiftUI/ContentView.swift | 56 ++++++++++++------------------ 1 file changed, 23 insertions(+), 33 deletions(-) diff --git a/TextEdit.SwiftUI/ContentView.swift b/TextEdit.SwiftUI/ContentView.swift index 6ca87a1c..7b08b3c4 100644 --- a/TextEdit.SwiftUI/ContentView.swift +++ b/TextEdit.SwiftUI/ContentView.swift @@ -95,14 +95,8 @@ struct ContentView: View { CustomGutterLineView( lineNumber: lineNumber, lineContent: lineContent, - isBookmarked: bookmarkedLines.contains(lineNumber), - hasBreakpoint: breakpointLines.contains(lineNumber), - onToggleBookmark: { - toggleBookmark(lineNumber) - }, - onToggleBreakpoint: { - toggleBreakpoint(lineNumber) - } + bookmarkedLines: $bookmarkedLines, + breakpointLines: $breakpointLines ) } ) @@ -111,24 +105,6 @@ struct ContentView: View { .textViewFont(font) } - // MARK: - Actions - - private func toggleBookmark(_ lineNumber: Int) { - if bookmarkedLines.contains(lineNumber) { - bookmarkedLines.remove(lineNumber) - } else { - bookmarkedLines.insert(lineNumber) - } - } - - private func toggleBreakpoint(_ lineNumber: Int) { - if breakpointLines.contains(lineNumber) { - breakpointLines.remove(lineNumber) - } else { - breakpointLines.insert(lineNumber) - } - } - private func loadContent() { let string = try! String(contentsOf: Bundle.main.url(forResource: "content", withExtension: "txt")!) self.text = AttributedString( @@ -143,15 +119,19 @@ struct ContentView: View { /// Per-line gutter view demonstrating word count, toggleable bookmark, and /// an overhanging breakpoint indicator activated by tapping the number. /// +/// Uses `@Binding` to parent state sets instead of action closures — this +/// avoids `@MainActor` closure type conflicts with Xcode preview thunking. +/// /// The breakpoint badge intentionally extends past the gutter's trailing edge /// to demonstrate that custom gutter content can overhang when needed. private struct CustomGutterLineView: View { let lineNumber: Int let lineContent: String - let isBookmarked: Bool - let hasBreakpoint: Bool - let onToggleBookmark: () -> Void - let onToggleBreakpoint: () -> Void + @Binding var bookmarkedLines: Set + @Binding var breakpointLines: Set + + private var isBookmarked: Bool { bookmarkedLines.contains(lineNumber) } + private var hasBreakpoint: Bool { breakpointLines.contains(lineNumber) } /// Number of whitespace-separated words on this line. private var wordCount: Int { @@ -165,7 +145,13 @@ private struct CustomGutterLineView: View { Spacer(minLength: 0) // Bookmark icon — toggles between outline and filled on click - Button(action: onToggleBookmark) { + Button { + if isBookmarked { + bookmarkedLines.remove(lineNumber) + } else { + bookmarkedLines.insert(lineNumber) + } + } label: { Image(systemName: isBookmarked ? "bookmark.fill" : "bookmark") .font(.system(size: 9)) .foregroundStyle(isBookmarked ? SwiftUI.Color.orange : SwiftUI.Color.secondary.opacity(0.5)) @@ -191,7 +177,9 @@ private struct CustomGutterLineView: View { Text("\(wordCount)") .font(.system(size: 14, weight: .regular, design: .rounded)) .foregroundStyle(.secondary) - .onTapGesture(perform: onToggleBreakpoint) + .onTapGesture { + breakpointLines.insert(lineNumber) + } } } } @@ -211,7 +199,9 @@ private struct CustomGutterLineView: View { .shadow(color: .black.opacity(0.25), radius: 3, x: 1, y: 1) ) .offset(x: 8) // Overhang past gutter edge - .onTapGesture(perform: onToggleBreakpoint) + .onTapGesture { + breakpointLines.remove(lineNumber) + } } } From f4341a08846864f667ec50a1cd79876296067d4d Mon Sep 17 00:00:00 2001 From: Roman <329079+romanr@users.noreply.github.com> Date: Fri, 6 Mar 2026 04:03:56 +1100 Subject: [PATCH 06/28] Update gutter demo to match Figma design MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Use BreakpointShape (Union path from Figma) instead of RoundedRectangle for breakpoint badge — arrow-right tab shape matching Xcode breakpoints - Increase bookmark icon from 9pt to 11pt to match design proportions - Breakpoint badge replaces entire gutter row content (not alongside bookmark) - Fix separator width: 2pt to match Figma border-r-2 - Fix word count font weight: .medium (was .regular) to match SF Pro Rounded - Add XCLocalSwiftPackageReference to project.pbxproj so clean builds can resolve STTextView package dependencies (STTextKitPlus, CoreTextSwift) Co-Authored-By: Claude Opus 4.6 --- TextEdit.SwiftUI/ContentView.swift | 103 +++++++++++++----- .../project.pbxproj | 11 ++ 2 files changed, 88 insertions(+), 26 deletions(-) diff --git a/TextEdit.SwiftUI/ContentView.swift b/TextEdit.SwiftUI/ContentView.swift index 7b08b3c4..01c53f5b 100644 --- a/TextEdit.SwiftUI/ContentView.swift +++ b/TextEdit.SwiftUI/ContentView.swift @@ -101,7 +101,7 @@ struct ContentView: View { } ) .gutterBackground(NSColor(srgbRed: 0.992, green: 0.984, blue: 0.969, alpha: 1)) - .gutterSeparator(color: NSColor(srgbRed: 0.75, green: 0.75, blue: 0.75, alpha: 0.5), width: 1) + .gutterSeparator(color: NSColor(srgbRed: 0.75, green: 0.75, blue: 0.75, alpha: 0.5), width: 2) .textViewFont(font) } @@ -122,8 +122,9 @@ struct ContentView: View { /// Uses `@Binding` to parent state sets instead of action closures — this /// avoids `@MainActor` closure type conflicts with Xcode preview thunking. /// -/// The breakpoint badge intentionally extends past the gutter's trailing edge -/// to demonstrate that custom gutter content can overhang when needed. +/// When a breakpoint is active, the entire gutter row shows the Union-shaped +/// badge (matching the Figma design) that overhangs past the gutter edge. +/// When inactive, the row shows a bookmark icon and the word count number. private struct CustomGutterLineView: View { let lineNumber: Int let lineContent: String @@ -144,24 +145,25 @@ private struct CustomGutterLineView: View { HStack(spacing: 3) { Spacer(minLength: 0) - // Bookmark icon — toggles between outline and filled on click - Button { - if isBookmarked { - bookmarkedLines.remove(lineNumber) - } else { - bookmarkedLines.insert(lineNumber) - } - } label: { - Image(systemName: isBookmarked ? "bookmark.fill" : "bookmark") - .font(.system(size: 9)) - .foregroundStyle(isBookmarked ? SwiftUI.Color.orange : SwiftUI.Color.secondary.opacity(0.5)) - } - .buttonStyle(.plain) - - // Word count number — tapping toggles breakpoint if hasBreakpoint { + // Breakpoint replaces entire row content with overhanging badge breakpointBadge } else { + // Bookmark icon — toggles between outline and filled on click + Button { + if isBookmarked { + bookmarkedLines.remove(lineNumber) + } else { + bookmarkedLines.insert(lineNumber) + } + } label: { + Image(systemName: isBookmarked ? "bookmark.fill" : "bookmark") + .font(.system(size: 11)) + .foregroundStyle(isBookmarked ? SwiftUI.Color.orange : SwiftUI.Color.secondary.opacity(0.5)) + } + .buttonStyle(.plain) + + // Word count number — tapping activates breakpoint wordCountLabel } } @@ -175,7 +177,7 @@ private struct CustomGutterLineView: View { Group { if wordCount > 0 { Text("\(wordCount)") - .font(.system(size: 14, weight: .regular, design: .rounded)) + .font(.system(size: 14, weight: .medium, design: .rounded)) .foregroundStyle(.secondary) .onTapGesture { breakpointLines.insert(lineNumber) @@ -184,19 +186,19 @@ private struct CustomGutterLineView: View { } } - /// Overhanging breakpoint badge — blue rounded rect with white number, - /// extends ~8pt past the gutter edge to demonstrate overhang capability. + /// Overhanging breakpoint badge using the Union shape from the Figma design. + /// The pointed-right tab shape extends ~8pt past the gutter edge to + /// demonstrate that custom gutter content can overhang when needed. /// Drop shadow adds depth. Tappable to deactivate. private var breakpointBadge: some View { Text("\(wordCount > 0 ? wordCount : lineNumber)") - .font(.system(size: 12, weight: .semibold, design: .rounded)) + .font(.system(size: 11, weight: .semibold, design: .rounded)) .foregroundStyle(.white) - .padding(.horizontal, 8) - .padding(.vertical, 2) + .frame(width: 32, height: 17) .background( - RoundedRectangle(cornerRadius: 6) + BreakpointShape() .fill(SwiftUI.Color.accentColor) - .shadow(color: .black.opacity(0.25), radius: 3, x: 1, y: 1) + .shadow(color: .black.opacity(0.25), radius: 2, x: 1, y: 1) ) .offset(x: 8) // Overhang past gutter edge .onTapGesture { @@ -205,6 +207,55 @@ private struct CustomGutterLineView: View { } } +// MARK: - Breakpoint Shape (from Figma) + +/// Arrow-right tab shape for breakpoint indicators, exported from Figma. +/// Rounded left edges with a pointed right side, similar to Xcode's breakpoint indicator. +/// Designed at 32×17pt. +private struct BreakpointShape: Shape { + func path(in rect: CGRect) -> Path { + var path = Path() + let width = rect.size.width + let height = rect.size.height + path.move(to: CGPoint(x: 0.7966607881 * width, y: 0)) + path.addCurve( + to: CGPoint(x: 0.9005266779 * width, y: 0.0959198282 * height), + control1: CGPoint(x: 0.8428305143 * width, y: 0.0000007783 * height), + control2: CGPoint(x: 0.8780923153 * width, y: 0.0342482898 * height) + ) + path.addCurve( + to: CGPoint(x: 0.9999994484 * width, y: 0.5007874016 * height), + control1: CGPoint(x: 0.9425928167 * width, y: 0.2115586518 * height), + control2: CGPoint(x: 1.0002802524 * width, y: 0.3531960926 * height) + ) + path.addCurve( + to: CGPoint(x: 0.9038528967 * width, y: 0.8949176807 * height), + control1: CGPoint(x: 0.9997174622 * width, y: 0.6488275483 * height), + control2: CGPoint(x: 0.9433396447 * width, y: 0.7907980917 * height) + ) + path.addCurve( + to: CGPoint(x: 0.7966607881 * width, y: height), + control1: CGPoint(x: 0.8795991788 * width, y: 0.9588704573 * height), + control2: CGPoint(x: 0.8452111369 * width, y: 0.9999991936 * height) + ) + path.addLine(to: CGPoint(x: 0.129785293 * width, y: height)) + path.addCurve( + to: CGPoint(x: 0, y: 0.7102362205 * height), + control1: CGPoint(x: 0.0483242729 * width, y: height), + control2: CGPoint(x: 0.0000000978 * width, y: 0.8944942706 * height) + ) + path.addLine(to: CGPoint(x: 0, y: 0.2897637795 * height)) + path.addCurve( + to: CGPoint(x: 0.129785293 * width, y: 0), + control1: CGPoint(x: 0.0000000247 * width, y: 0.1070872608 * height), + control2: CGPoint(x: 0.0483242116 * width, y: 0) + ) + path.addLine(to: CGPoint(x: 0.7966607881 * width, y: 0)) + path.closeSubpath() + return path + } +} + struct ContentView_Previews: PreviewProvider { static var previews: some View { ContentView() diff --git a/TextEdit.SwiftUI/TextEdit.SwiftUI.xcodeproj/project.pbxproj b/TextEdit.SwiftUI/TextEdit.SwiftUI.xcodeproj/project.pbxproj index a45efa93..cfe11b22 100644 --- a/TextEdit.SwiftUI/TextEdit.SwiftUI.xcodeproj/project.pbxproj +++ b/TextEdit.SwiftUI/TextEdit.SwiftUI.xcodeproj/project.pbxproj @@ -137,6 +137,9 @@ ); mainGroup = 7544422E27B9332E00901C0E; productRefGroup = 7544423827B9332E00901C0E /* Products */; + packageReferences = ( + 758265CC2AB31F2900D5EFB5 /* XCLocalSwiftPackageReference ".." */, + ); projectDirPath = ""; projectRoot = ""; targets = ( @@ -380,9 +383,17 @@ }; /* End XCConfigurationList section */ +/* Begin XCLocalSwiftPackageReference section */ + 758265CC2AB31F2900D5EFB5 /* XCLocalSwiftPackageReference ".." */ = { + isa = XCLocalSwiftPackageReference; + relativePath = ..; + }; +/* End XCLocalSwiftPackageReference section */ + /* Begin XCSwiftPackageProductDependency section */ 7582A1FB2A07096F00F8AB20 /* STTextView */ = { isa = XCSwiftPackageProductDependency; + package = 758265CC2AB31F2900D5EFB5 /* XCLocalSwiftPackageReference ".." */; productName = STTextView; }; /* End XCSwiftPackageProductDependency section */ From 6794499070572e5c7351f92bd06226770c5ca06c Mon Sep 17 00:00:00 2001 From: Roman <329079+romanr@users.noreply.github.com> Date: Fri, 6 Mar 2026 04:36:27 +1100 Subject: [PATCH 07/28] Fix custom gutter vertical alignment and separator z-order MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use fragmentView.frame directly for custom gutter line positioning instead of cellFrame from STGutterCalculations — the latter adds typographicBounds.origin.y offset designed for baseline-aligned line numbers, which shifts NSHostingView content down. Place gutter separator behind line views so overhanging content (breakpoint badges) draws in front of the separator. Co-Authored-By: Claude Opus 4.6 --- .../STTextViewAppKit/STTextView+Gutter.swift | 21 ++++++++++--------- TextEdit.SwiftUI/ContentView.swift | 7 ++++--- 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/Sources/STTextViewAppKit/STTextView+Gutter.swift b/Sources/STTextViewAppKit/STTextView+Gutter.swift index c29525d3..aa556f50 100644 --- a/Sources/STTextViewAppKit/STTextView+Gutter.swift +++ b/Sources/STTextViewAppKit/STTextView+Gutter.swift @@ -322,13 +322,6 @@ extension STTextView { let lineID = Self.gutterLineViewID(for: lineNumber) visibleIDs.insert(lineID) - // Calculate the cell frame for this line (same positioning as line number cells) - let (_, _, cellFrame) = STGutterCalculations.calculateLineNumberMetrics( - for: textLineFragment, - in: layoutFragment, - fragmentViewFrame: fragmentView.frame - ) - // Extract the paragraph text content, trimming the trailing newline let lineContent: String if let paragraph = layoutFragment.textElement as? NSTextParagraph { @@ -342,9 +335,15 @@ extension STTextView { } let lineView = lineViewForID(lineID, in: container, provider: provider, lineNumber: lineNumber, lineContent: lineContent) + + // Position using fragmentView.frame directly — NOT cellFrame from + // STGutterCalculations which adds typographicBounds.origin.y offset. + // That offset is needed for baseline-aligned line number text, but + // shifts NSHostingView content down. Fragment view frame gives exact + // visual bounds of the text line in document coordinates. lineView.frame = CGRect( - origin: CGPoint(x: 0, y: cellFrame.origin.y), - size: CGSize(width: customGutterWidth, height: cellFrame.size.height) + origin: CGPoint(x: 0, y: fragmentView.frame.origin.y), + size: CGSize(width: customGutterWidth, height: fragmentView.frame.size.height) ).pixelAligned linesCount += 1 @@ -418,7 +417,9 @@ extension STTextView { separator.identifier = Self.gutterSeparatorID separator.wantsLayer = true separator.layer?.backgroundColor = separatorColor.cgColor - container.addSubview(separator) + // Add behind line views so overhanging content (e.g. breakpoint badges) + // draws in front of the separator, not behind it. + container.addSubview(separator, positioned: .below, relativeTo: container.subviews.first) } } diff --git a/TextEdit.SwiftUI/ContentView.swift b/TextEdit.SwiftUI/ContentView.swift index 01c53f5b..2c318560 100644 --- a/TextEdit.SwiftUI/ContentView.swift +++ b/TextEdit.SwiftUI/ContentView.swift @@ -20,14 +20,14 @@ struct ContentView: View { @State private var selection: NSRange? @State private var font = Font.monospacedSystemFont(ofSize: 0, weight: .medium) @State private var wrapLines = true - @State private var showLineNumbers = false + @State private var showLineNumbers = true @State private var showCustomGutter = false /// Tracks which lines have bookmarks toggled on (by 1-based line number). - @State private var bookmarkedLines: Set = [] + @State private var bookmarkedLines: Set = [3] /// Tracks which lines have breakpoints active (by 1-based line number). - @State private var breakpointLines: Set = [] + @State private var breakpointLines: Set = [4] private var options: TextView.Options { var opts: TextView.Options = [.highlightSelectedLine] @@ -170,6 +170,7 @@ private struct CustomGutterLineView: View { .padding(.trailing, 4) .padding(.leading, 2) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .trailing) + .background(.clear) } /// Plain word count number — tappable to activate breakpoint. From 6e66c42aef7970df7d0c0c48b570d701819290f3 Mon Sep 17 00:00:00 2001 From: Roman <329079+romanr@users.noreply.github.com> Date: Fri, 6 Mar 2026 07:20:14 +1100 Subject: [PATCH 08/28] Fix bookmark icon shifting when word count is absent Use fixed-width frame on word count label so the bookmark icon stays in the same position regardless of whether a count is shown. Co-Authored-By: Claude Opus 4.6 --- TextEdit.SwiftUI/ContentView.swift | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/TextEdit.SwiftUI/ContentView.swift b/TextEdit.SwiftUI/ContentView.swift index 2c318560..05929449 100644 --- a/TextEdit.SwiftUI/ContentView.swift +++ b/TextEdit.SwiftUI/ContentView.swift @@ -174,17 +174,18 @@ private struct CustomGutterLineView: View { } /// Plain word count number — tappable to activate breakpoint. + /// Uses fixed-width frame so the bookmark icon stays in place + /// regardless of whether a count is shown. private var wordCountLabel: some View { - Group { - if wordCount > 0 { - Text("\(wordCount)") - .font(.system(size: 14, weight: .medium, design: .rounded)) - .foregroundStyle(.secondary) - .onTapGesture { - breakpointLines.insert(lineNumber) - } + Text(wordCount > 0 ? "\(wordCount)" : "") + .font(.system(size: 14, weight: .medium, design: .rounded)) + .foregroundStyle(.secondary) + .frame(minWidth: 18, alignment: .trailing) + .onTapGesture { + if wordCount > 0 { + breakpointLines.insert(lineNumber) + } } - } } /// Overhanging breakpoint badge using the Union shape from the Figma design. From d768f304665a14966c82b0b2efdd246e604a2f7c Mon Sep 17 00:00:00 2001 From: Roman <329079+romanr@users.noreply.github.com> Date: Fri, 6 Mar 2026 07:39:02 +1100 Subject: [PATCH 09/28] =?UTF-8?q?Refine=20breakpoint=20badge:=2028=C3=9715?= =?UTF-8?q?=20shape,=20stable=20layout,=20keep=20bookmark?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Update Union shape to new Figma export sized at 28×15 - Bookmark icon stays visible when breakpoint is active - Number stays in same position (same font/frame as word count) - Badge shape extends rightward past gutter separator - HStack spacing matches Figma gap (6px) Co-Authored-By: Claude Opus 4.6 --- TextEdit.SwiftUI/ContentView.swift | 91 +++++++++++------------------- 1 file changed, 33 insertions(+), 58 deletions(-) diff --git a/TextEdit.SwiftUI/ContentView.swift b/TextEdit.SwiftUI/ContentView.swift index 05929449..d1fffd60 100644 --- a/TextEdit.SwiftUI/ContentView.swift +++ b/TextEdit.SwiftUI/ContentView.swift @@ -142,35 +142,34 @@ private struct CustomGutterLineView: View { } var body: some View { - HStack(spacing: 3) { + HStack(spacing: 6) { Spacer(minLength: 0) + // Bookmark icon — always visible, independent of breakpoint state + Button { + if isBookmarked { + bookmarkedLines.remove(lineNumber) + } else { + bookmarkedLines.insert(lineNumber) + } + } label: { + Image(systemName: isBookmarked ? "bookmark.fill" : "bookmark") + .font(.system(size: 11)) + .foregroundStyle(isBookmarked ? SwiftUI.Color.orange : SwiftUI.Color.secondary.opacity(0.5)) + } + .buttonStyle(.plain) + + // Word count or breakpoint badge — breakpoint overlays the + // same fixed-width slot so the number doesn't jump. if hasBreakpoint { - // Breakpoint replaces entire row content with overhanging badge breakpointBadge } else { - // Bookmark icon — toggles between outline and filled on click - Button { - if isBookmarked { - bookmarkedLines.remove(lineNumber) - } else { - bookmarkedLines.insert(lineNumber) - } - } label: { - Image(systemName: isBookmarked ? "bookmark.fill" : "bookmark") - .font(.system(size: 11)) - .foregroundStyle(isBookmarked ? SwiftUI.Color.orange : SwiftUI.Color.secondary.opacity(0.5)) - } - .buttonStyle(.plain) - - // Word count number — tapping activates breakpoint wordCountLabel } } .padding(.trailing, 4) .padding(.leading, 2) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .trailing) - .background(.clear) } /// Plain word count number — tappable to activate breakpoint. @@ -189,20 +188,20 @@ private struct CustomGutterLineView: View { } /// Overhanging breakpoint badge using the Union shape from the Figma design. - /// The pointed-right tab shape extends ~8pt past the gutter edge to - /// demonstrate that custom gutter content can overhang when needed. - /// Drop shadow adds depth. Tappable to deactivate. + /// Text uses same font/frame as wordCountLabel so the number doesn't jump. + /// The 28×15 shape extends rightward past the gutter separator. private var breakpointBadge: some View { - Text("\(wordCount > 0 ? wordCount : lineNumber)") - .font(.system(size: 11, weight: .semibold, design: .rounded)) + Text(wordCount > 0 ? "\(wordCount)" : "") + .font(.system(size: 14, weight: .medium, design: .rounded)) .foregroundStyle(.white) - .frame(width: 32, height: 17) - .background( + .frame(minWidth: 18, alignment: .trailing) + .background(alignment: .trailing) { BreakpointShape() .fill(SwiftUI.Color.accentColor) + .frame(width: 28, height: 15) .shadow(color: .black.opacity(0.25), radius: 2, x: 1, y: 1) - ) - .offset(x: 8) // Overhang past gutter edge + .offset(x: 10) // overhang past gutter edge + } .onTapGesture { breakpointLines.remove(lineNumber) } @@ -213,45 +212,21 @@ private struct CustomGutterLineView: View { /// Arrow-right tab shape for breakpoint indicators, exported from Figma. /// Rounded left edges with a pointed right side, similar to Xcode's breakpoint indicator. -/// Designed at 32×17pt. +/// Designed at 28×15pt. private struct BreakpointShape: Shape { func path(in rect: CGRect) -> Path { var path = Path() let width = rect.size.width let height = rect.size.height path.move(to: CGPoint(x: 0.7966607881 * width, y: 0)) - path.addCurve( - to: CGPoint(x: 0.9005266779 * width, y: 0.0959198282 * height), - control1: CGPoint(x: 0.8428305143 * width, y: 0.0000007783 * height), - control2: CGPoint(x: 0.8780923153 * width, y: 0.0342482898 * height) - ) - path.addCurve( - to: CGPoint(x: 0.9999994484 * width, y: 0.5007874016 * height), - control1: CGPoint(x: 0.9425928167 * width, y: 0.2115586518 * height), - control2: CGPoint(x: 1.0002802524 * width, y: 0.3531960926 * height) - ) - path.addCurve( - to: CGPoint(x: 0.9038528967 * width, y: 0.8949176807 * height), - control1: CGPoint(x: 0.9997174622 * width, y: 0.6488275483 * height), - control2: CGPoint(x: 0.9433396447 * width, y: 0.7907980917 * height) - ) - path.addCurve( - to: CGPoint(x: 0.7966607881 * width, y: height), - control1: CGPoint(x: 0.8795991788 * width, y: 0.9588704573 * height), - control2: CGPoint(x: 0.8452111369 * width, y: 0.9999991936 * height) - ) + path.addCurve(to: CGPoint(x: 0.9005266779 * width, y: 0.0959198282 * height), control1: CGPoint(x: 0.8428305143 * width, y: 0.0000007783 * height), control2: CGPoint(x: 0.8780923153 * width, y: 0.0342482898 * height)) + path.addCurve(to: CGPoint(x: 0.9999994484 * width, y: 0.5007874016 * height), control1: CGPoint(x: 0.9425928167 * width, y: 0.2115586518 * height), control2: CGPoint(x: 1.0002802524 * width, y: 0.3531960926 * height)) + path.addCurve(to: CGPoint(x: 0.9038528967 * width, y: 0.8949176807 * height), control1: CGPoint(x: 0.9997174622 * width, y: 0.6488275483 * height), control2: CGPoint(x: 0.9433396447 * width, y: 0.7907980917 * height)) + path.addCurve(to: CGPoint(x: 0.7966607881 * width, y: height), control1: CGPoint(x: 0.8795991788 * width, y: 0.9588704573 * height), control2: CGPoint(x: 0.8452111369 * width, y: 0.9999991936 * height)) path.addLine(to: CGPoint(x: 0.129785293 * width, y: height)) - path.addCurve( - to: CGPoint(x: 0, y: 0.7102362205 * height), - control1: CGPoint(x: 0.0483242729 * width, y: height), - control2: CGPoint(x: 0.0000000978 * width, y: 0.8944942706 * height) - ) + path.addCurve(to: CGPoint(x: 0, y: 0.7102362205 * height), control1: CGPoint(x: 0.0483242729 * width, y: height), control2: CGPoint(x: 0.0000000978 * width, y: 0.8944942706 * height)) path.addLine(to: CGPoint(x: 0, y: 0.2897637795 * height)) - path.addCurve( - to: CGPoint(x: 0.129785293 * width, y: 0), - control1: CGPoint(x: 0.0000000247 * width, y: 0.1070872608 * height), - control2: CGPoint(x: 0.0483242116 * width, y: 0) - ) + path.addCurve(to: CGPoint(x: 0.129785293 * width, y: 0), control1: CGPoint(x: 0.0000000247 * width, y: 0.1070872608 * height), control2: CGPoint(x: 0.0483242116 * width, y: 0)) path.addLine(to: CGPoint(x: 0.7966607881 * width, y: 0)) path.closeSubpath() return path From f83ddb72e41c7c45a95a404bca239edc9a38f975 Mon Sep 17 00:00:00 2001 From: Roman <329079+romanr@users.noreply.github.com> Date: Fri, 6 Mar 2026 08:31:59 +1100 Subject: [PATCH 10/28] =?UTF-8?q?Remove=20breakpoint=20badge=20overhang=20?= =?UTF-8?q?=E2=80=94=20fit=20within=20gutter=20bounds?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Badge no longer extends past the gutter separator. This ensures consistent rendering in both Xcode previews and the live app, avoiding clipping differences between environments. Co-Authored-By: Claude Opus 4.6 --- Sources/STTextViewAppKit/STTextView+Gutter.swift | 2 +- TextEdit.SwiftUI/ContentView.swift | 7 +++---- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/Sources/STTextViewAppKit/STTextView+Gutter.swift b/Sources/STTextViewAppKit/STTextView+Gutter.swift index aa556f50..2b9a6c6b 100644 --- a/Sources/STTextViewAppKit/STTextView+Gutter.swift +++ b/Sources/STTextViewAppKit/STTextView+Gutter.swift @@ -446,7 +446,7 @@ private class STCustomGutterContainerView: NSView { init() { super.init(frame: .zero) wantsLayer = true - // clipsToBounds intentionally left false to allow overhang + // clipsToBounds left as default (false) } @available(*, unavailable) diff --git a/TextEdit.SwiftUI/ContentView.swift b/TextEdit.SwiftUI/ContentView.swift index d1fffd60..c80815f9 100644 --- a/TextEdit.SwiftUI/ContentView.swift +++ b/TextEdit.SwiftUI/ContentView.swift @@ -187,9 +187,9 @@ private struct CustomGutterLineView: View { } } - /// Overhanging breakpoint badge using the Union shape from the Figma design. + /// Breakpoint badge using the Union shape from the Figma design. /// Text uses same font/frame as wordCountLabel so the number doesn't jump. - /// The 28×15 shape extends rightward past the gutter separator. + /// Shape fits within the gutter bounds — no overhang. private var breakpointBadge: some View { Text(wordCount > 0 ? "\(wordCount)" : "") .font(.system(size: 14, weight: .medium, design: .rounded)) @@ -199,8 +199,7 @@ private struct CustomGutterLineView: View { BreakpointShape() .fill(SwiftUI.Color.accentColor) .frame(width: 28, height: 15) - .shadow(color: .black.opacity(0.25), radius: 2, x: 1, y: 1) - .offset(x: 10) // overhang past gutter edge + .shadow(color: .black.opacity(0.15), radius: 1, x: 1, y: 1) } .onTapGesture { breakpointLines.remove(lineNumber) From ba18a693de81dcbd2444baac8c0be7d1f8f91a6b Mon Sep 17 00:00:00 2001 From: Roman <329079+romanr@users.noreply.github.com> Date: Fri, 6 Mar 2026 09:10:38 +1100 Subject: [PATCH 11/28] Restore breakpoint badge position at separator edge Offset badge by 4pt to compensate trailing padding, placing the pointed tip at the separator line without crossing it. Co-Authored-By: Claude Opus 4.6 --- TextEdit.SwiftUI/ContentView.swift | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/TextEdit.SwiftUI/ContentView.swift b/TextEdit.SwiftUI/ContentView.swift index c80815f9..7552dd48 100644 --- a/TextEdit.SwiftUI/ContentView.swift +++ b/TextEdit.SwiftUI/ContentView.swift @@ -189,7 +189,8 @@ private struct CustomGutterLineView: View { /// Breakpoint badge using the Union shape from the Figma design. /// Text uses same font/frame as wordCountLabel so the number doesn't jump. - /// Shape fits within the gutter bounds — no overhang. + /// Shape tip reaches the separator (offset compensates trailing padding) + /// but stays within gutter bounds. private var breakpointBadge: some View { Text(wordCount > 0 ? "\(wordCount)" : "") .font(.system(size: 14, weight: .medium, design: .rounded)) @@ -200,6 +201,7 @@ private struct CustomGutterLineView: View { .fill(SwiftUI.Color.accentColor) .frame(width: 28, height: 15) .shadow(color: .black.opacity(0.15), radius: 1, x: 1, y: 1) + .offset(x: 4) // tip reaches separator (compensates trailing padding) } .onTapGesture { breakpointLines.remove(lineNumber) From c6739efca122503049ad2df504642497ca94cf41 Mon Sep 17 00:00:00 2001 From: Roman <329079+romanr@users.noreply.github.com> Date: Fri, 6 Mar 2026 09:12:59 +1100 Subject: [PATCH 12/28] Restore overhanging breakpoint badge, fix clipping on NSHostingView Set clipsToBounds=false on gutter line views so overhanging content renders consistently in both Xcode previews and the live app. Restores the badge overhang that demonstrates custom gutter content can extend beyond gutter bounds. Co-Authored-By: Claude Opus 4.6 --- Sources/STTextViewAppKit/STTextView+Gutter.swift | 6 ++++++ TextEdit.SwiftUI/ContentView.swift | 9 +++++---- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/Sources/STTextViewAppKit/STTextView+Gutter.swift b/Sources/STTextViewAppKit/STTextView+Gutter.swift index 2b9a6c6b..ef2853e0 100644 --- a/Sources/STTextViewAppKit/STTextView+Gutter.swift +++ b/Sources/STTextViewAppKit/STTextView+Gutter.swift @@ -379,6 +379,12 @@ extension STTextView { let lineView = provider(lineNumber, lineContent) lineView.identifier = id + + // Allow content (e.g. breakpoint badges) to extend beyond the + // line view bounds. NSHostingView clips by default on macOS. + lineView.clipsToBounds = false + lineView.layer?.masksToBounds = false + // Add to container BEFORE setting frame so the NSHostingView // has a window and can properly lay out its SwiftUI content. container.addSubview(lineView) diff --git a/TextEdit.SwiftUI/ContentView.swift b/TextEdit.SwiftUI/ContentView.swift index 7552dd48..587edf16 100644 --- a/TextEdit.SwiftUI/ContentView.swift +++ b/TextEdit.SwiftUI/ContentView.swift @@ -189,8 +189,9 @@ private struct CustomGutterLineView: View { /// Breakpoint badge using the Union shape from the Figma design. /// Text uses same font/frame as wordCountLabel so the number doesn't jump. - /// Shape tip reaches the separator (offset compensates trailing padding) - /// but stays within gutter bounds. + /// Shape overhangs past the gutter separator to demonstrate that custom + /// gutter content can extend beyond gutter bounds when needed. + /// Activated by tapping the word count number. private var breakpointBadge: some View { Text(wordCount > 0 ? "\(wordCount)" : "") .font(.system(size: 14, weight: .medium, design: .rounded)) @@ -200,8 +201,8 @@ private struct CustomGutterLineView: View { BreakpointShape() .fill(SwiftUI.Color.accentColor) .frame(width: 28, height: 15) - .shadow(color: .black.opacity(0.15), radius: 1, x: 1, y: 1) - .offset(x: 4) // tip reaches separator (compensates trailing padding) + .shadow(color: .black.opacity(0.25), radius: 2, x: 1, y: 1) + .offset(x: 8) // overhang past gutter separator } .onTapGesture { breakpointLines.remove(lineNumber) From 6eb23e7fc8bbf0eed1c074f8208f4c3f4d97a1df Mon Sep 17 00:00:00 2001 From: Roman <329079+romanr@users.noreply.github.com> Date: Fri, 6 Mar 2026 09:52:37 +1100 Subject: [PATCH 13/28] Fix custom gutter offset when scroll view has content insets STCustomGutterContainerView was missing the FB21059465 workaround that STGutterView already applies: when NSScrollView has automatic content insets (e.g. a toolbar), horizontal floating subviews do not respect those insets and render at the wrong vertical position. Adding the same layout() override shifts frame.origin.y by -topContentInset so the custom gutter aligns with the text lines. Co-Authored-By: Claude Sonnet 4.6 --- Sources/STTextViewAppKit/STTextView+Gutter.swift | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/Sources/STTextViewAppKit/STTextView+Gutter.swift b/Sources/STTextViewAppKit/STTextView+Gutter.swift index ef2853e0..f1be8f7e 100644 --- a/Sources/STTextViewAppKit/STTextView+Gutter.swift +++ b/Sources/STTextViewAppKit/STTextView+Gutter.swift @@ -449,6 +449,20 @@ private class STCustomGutterContainerView: NSView { nil } + override func layout() { + super.layout() + + // Workaround + // FB21059465: NSScrollView horizontal floating subview does not respect insets + // https://gist.github.com/krzyzanowskim/d2c5d41b86096ccb19b110cf7a5514c8 + if let enclosingScrollView = superview?.superview as? NSScrollView, enclosingScrollView.automaticallyAdjustsContentInsets { + let topContentInset = enclosingScrollView.contentView.contentInsets.top + if !topContentInset.isAlmostZero(), !topContentInset.isAlmostEqual(to: -topContentInset) { + self.frame.origin.y = -topContentInset + } + } + } + init() { super.init(frame: .zero) wantsLayer = true From cfca336a87c6159453cbb3202f33b1a7bc044313 Mon Sep 17 00:00:00 2001 From: Roman <329079+romanr@users.noreply.github.com> Date: Fri, 6 Mar 2026 10:13:35 +1100 Subject: [PATCH 14/28] Fix custom gutter top-alignment for wrapped paragraphs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a paragraph wraps across multiple lines, the gutter line view was sized to fragmentView.frame.size.height (the entire paragraph height). NSHostingView is non-flipped, so inside the flipped gutter container its y=0 is at the frame bottom — SwiftUI content rendered anchored to the bottom of the tall frame instead of the top. Fix: size each line view to textLineFragment.typographicBounds.height (just the first visual line) so NSHostingView content aligns to the top where the line begins. For extra line fragments where typographicBounds.height may be invalid (FB15131180), fall back to the previous line fragment's height or typingLineHeight. Co-Authored-By: Claude Sonnet 4.6 --- .../STTextViewAppKit/STTextView+Gutter.swift | 25 ++++++++++++++----- 1 file changed, 19 insertions(+), 6 deletions(-) diff --git a/Sources/STTextViewAppKit/STTextView+Gutter.swift b/Sources/STTextViewAppKit/STTextView+Gutter.swift index f1be8f7e..e5914ba6 100644 --- a/Sources/STTextViewAppKit/STTextView+Gutter.swift +++ b/Sources/STTextViewAppKit/STTextView+Gutter.swift @@ -336,14 +336,27 @@ extension STTextView { let lineView = lineViewForID(lineID, in: container, provider: provider, lineNumber: lineNumber, lineContent: lineContent) - // Position using fragmentView.frame directly — NOT cellFrame from - // STGutterCalculations which adds typographicBounds.origin.y offset. - // That offset is needed for baseline-aligned line number text, but - // shifts NSHostingView content down. Fragment view frame gives exact - // visual bounds of the text line in document coordinates. + // Size the line view to just the first visual line of this paragraph. + // fragmentView.frame.size.height spans the entire wrapped paragraph — using it + // causes NSHostingView content to render at the bottom of a tall frame rather + // than the top, because NSHostingView is non-flipped inside our flipped container. + // For extra line fragments, typographicBounds.height may be invalid (FB15131180); + // fall back to the previous line fragment's height or typingLineHeight. + let lineHeight: CGFloat + if textLineFragment.isExtraLineFragment { + if layoutFragment.textLineFragments.count >= 2 { + let prevLineFragment = layoutFragment.textLineFragments[layoutFragment.textLineFragments.count - 2] + lineHeight = prevLineFragment.typographicBounds.height + } else { + lineHeight = typingLineHeight + } + } else { + lineHeight = textLineFragment.typographicBounds.height + } + lineView.frame = CGRect( origin: CGPoint(x: 0, y: fragmentView.frame.origin.y), - size: CGSize(width: customGutterWidth, height: fragmentView.frame.size.height) + size: CGSize(width: customGutterWidth, height: lineHeight) ).pixelAligned linesCount += 1 From 8a532cbb8029ff270b2f6aa34a8cc67125df0e0f Mon Sep 17 00:00:00 2001 From: Roman <329079+romanr@users.noreply.github.com> Date: Fri, 6 Mar 2026 11:02:02 +1100 Subject: [PATCH 15/28] Consume mouse events in custom gutter container to prevent click-through MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Without mouse event overrides, clicks in the gutter that land on the container itself (or in gaps between line views) propagated up the responder chain and reached STTextView, moving the insertion point and highlighting lines. Override mouseDown/mouseDragged/mouseUp to consume events — interactive SwiftUI subviews still receive their own events via normal hit-testing. Co-Authored-By: Claude Sonnet 4.6 --- Sources/STTextViewAppKit/STTextView+Gutter.swift | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/Sources/STTextViewAppKit/STTextView+Gutter.swift b/Sources/STTextViewAppKit/STTextView+Gutter.swift index e5914ba6..46c27ddf 100644 --- a/Sources/STTextViewAppKit/STTextView+Gutter.swift +++ b/Sources/STTextViewAppKit/STTextView+Gutter.swift @@ -462,6 +462,18 @@ private class STCustomGutterContainerView: NSView { nil } + override func mouseDown(with event: NSEvent) { + // Consume — prevent click-through to the editor. + } + + override func mouseDragged(with event: NSEvent) { + // Consume — prevent drag-selection in the editor. + } + + override func mouseUp(with event: NSEvent) { + // Consume. + } + override func layout() { super.layout() From 6070366ae0ade1698f28973b241b901aabb5bda9 Mon Sep 17 00:00:00 2001 From: Roman <329079+romanr@users.noreply.github.com> Date: Sat, 7 Mar 2026 04:14:30 +1100 Subject: [PATCH 16/28] Refactor gutter line view handling to use a data source protocol for better flexibility and integration with SwiftUI --- .../Gutter/STGutterView.swift | 2 +- .../STGutterLineViewDataSource.swift | 24 ++++++++++++ .../STTextViewAppKit/STTextView+Gutter.swift | 37 ++++++++++++++++--- Sources/STTextViewAppKit/STTextView.swift | 13 ++++--- .../STTextViewSwiftUIAppKit/TextView.swift | 34 +++++++++++++++-- 5 files changed, 93 insertions(+), 17 deletions(-) create mode 100644 Sources/STTextViewAppKit/STGutterLineViewDataSource.swift diff --git a/Sources/STTextViewAppKit/Gutter/STGutterView.swift b/Sources/STTextViewAppKit/Gutter/STGutterView.swift index b3009165..f918b718 100644 --- a/Sources/STTextViewAppKit/Gutter/STGutterView.swift +++ b/Sources/STTextViewAppKit/Gutter/STGutterView.swift @@ -38,7 +38,7 @@ open class STGutterView: NSView, NSDraggingSource { private var _didMouseDownAddMarker = false /// Delegate - weak var delegate: (any STGutterViewDelegate)? + public weak var delegate: (any STGutterViewDelegate)? /// The font used to draw line numbers. /// diff --git a/Sources/STTextViewAppKit/STGutterLineViewDataSource.swift b/Sources/STTextViewAppKit/STGutterLineViewDataSource.swift new file mode 100644 index 00000000..16e5359e --- /dev/null +++ b/Sources/STTextViewAppKit/STGutterLineViewDataSource.swift @@ -0,0 +1,24 @@ +// Created by Marcin Krzyzanowski +// https://github.com/krzyzanowskim/STTextView/blob/main/LICENSE.md + +import AppKit + +/// A data source that provides custom views for the gutter area of a text view. +/// +/// Implement this protocol to supply one `NSView` per visible line in the +/// custom gutter. The data source is queried during layout for each line +/// that is currently in the viewport. +/// +/// Use together with ``STTextView/customGutterWidth`` to reserve horizontal +/// space for the gutter area. +public protocol STGutterLineViewDataSource: AnyObject { + + /// Returns the view to display in the custom gutter for the given line. + /// + /// - Parameters: + /// - textView: The text view requesting the view. + /// - lineNumber: The 1-based line number. + /// - content: The plain-text content of the line (trailing newline stripped). + /// - Returns: An `NSView` to be positioned in the gutter alongside the line. + func textView(_ textView: STTextView, viewForGutterLine lineNumber: Int, content: String) -> NSView +} diff --git a/Sources/STTextViewAppKit/STTextView+Gutter.swift b/Sources/STTextViewAppKit/STTextView+Gutter.swift index 46c27ddf..7ea3efe5 100644 --- a/Sources/STTextViewAppKit/STTextView+Gutter.swift +++ b/Sources/STTextViewAppKit/STTextView+Gutter.swift @@ -247,7 +247,7 @@ extension STTextView { /// so that interactive SwiftUI content (buttons, gestures) inside NSHostingViews /// keeps working. Views are only recreated when the line's content or state changes. private func layoutCustomGutterLineViews() { - guard let provider = gutterLineViewProvider, customGutterWidth > 0 else { + guard let dataSource = gutterLineViewDataSource, customGutterWidth > 0 else { return } @@ -280,7 +280,7 @@ extension STTextView { let lineID = Self.gutterLineViewID(for: 1) visibleIDs.insert(lineID) - let lineView = lineViewForID(lineID, in: container, provider: provider, lineNumber: 1, lineContent: "") + let lineView = lineViewForID(lineID, in: container, dataSource: dataSource, lineNumber: 1, lineContent: "") lineView.frame = CGRect( origin: CGPoint(x: 0, y: selectionFrame.origin.y), size: CGSize(width: customGutterWidth, height: typingLineHeight) @@ -334,7 +334,7 @@ extension STTextView { lineContent = "" } - let lineView = lineViewForID(lineID, in: container, provider: provider, lineNumber: lineNumber, lineContent: lineContent) + let lineView = lineViewForID(lineID, in: container, dataSource: dataSource, lineNumber: lineNumber, lineContent: lineContent) // Size the line view to just the first visual line of this paragraph. // fragmentView.frame.size.height spans the entire wrapped paragraph — using it @@ -370,18 +370,43 @@ extension STTextView { addCustomGutterSeparator(to: container) } + // MARK: - Custom Gutter Reload + + /// Reloads all visible custom gutter line views by re-querying the data source. + /// + /// This is lighter-weight than setting `needsLayout = true` on the text view, + /// because it only re-runs the custom gutter layout without affecting text layout. + public func reloadGutterLineViews() { + layoutCustomGutterLineViews() + } + + /// Reloads the custom gutter line view for a specific line number. + /// + /// If the line is not currently visible in the viewport, this is a no-op. + /// - Parameter lineNumber: The 1-based line number to reload. + public func reloadGutterLineView(at lineNumber: Int) { + guard let container = customGutterContainerView else { return } + let lineID = Self.gutterLineViewID(for: lineNumber) + if let existing = container.subviews.first(where: { $0.identifier == lineID }) { + existing.removeFromSuperviewWithoutNeedingDisplay() + } + // Re-run full custom gutter layout to position the replacement view correctly + // (we need fragment positions which are only available during layout enumeration) + layoutCustomGutterLineViews() + } + /// Creates an identifier for a custom gutter line view at the given line number. private static func gutterLineViewID(for lineNumber: Int) -> NSUserInterfaceItemIdentifier { NSUserInterfaceItemIdentifier(gutterLineViewIDPrefix + "\(lineNumber)") } /// Returns (or creates) a gutter line view for the given identifier. - /// Always recreates the view from the provider to pick up captured SwiftUI state, + /// Always recreates the view from the data source to pick up captured SwiftUI state, /// but adds to the container first so the NSHostingView has a window before layout. private func lineViewForID( _ id: NSUserInterfaceItemIdentifier, in container: NSView, - provider: (Int, String) -> NSView, + dataSource: any STGutterLineViewDataSource, lineNumber: Int, lineContent: String ) -> NSView { @@ -390,7 +415,7 @@ extension STTextView { existing.removeFromSuperviewWithoutNeedingDisplay() } - let lineView = provider(lineNumber, lineContent) + let lineView = dataSource.textView(self, viewForGutterLine: lineNumber, content: lineContent) lineView.identifier = id // Allow content (e.g. breakpoint badges) to extend beyond the diff --git a/Sources/STTextViewAppKit/STTextView.swift b/Sources/STTextViewAppKit/STTextView.swift index 4009aeb7..a45e2157 100644 --- a/Sources/STTextViewAppKit/STTextView.swift +++ b/Sources/STTextViewAppKit/STTextView.swift @@ -393,14 +393,15 @@ open class STTextView: NSView, NSTextInput, NSTextContent, STTextViewProtocol { } } - /// Provider for custom gutter line views. Called with `(lineNumber, lineContent)` for each - /// visible line during layout. The returned view is positioned to fill the full line height - /// (including spacing) in the custom gutter area. + /// A data source that provides custom views for each visible line in the gutter area. /// - /// Set ``customGutterWidth`` to reserve space for the gutter. - open var gutterLineViewProvider: ((Int, String) -> NSView)? { + /// The data source is queried during layout for every line currently in the viewport. + /// Set ``customGutterWidth`` to reserve horizontal space for the gutter. + /// + /// - SeeAlso: ``STGutterLineViewDataSource`` + open weak var gutterLineViewDataSource: (any STGutterLineViewDataSource)? { didSet { - if gutterLineViewProvider == nil { + if gutterLineViewDataSource == nil { customGutterContainerView?.removeFromSuperview() customGutterContainerView = nil } diff --git a/Sources/STTextViewSwiftUIAppKit/TextView.swift b/Sources/STTextViewSwiftUIAppKit/TextView.swift index 85f3e098..582f7ce3 100644 --- a/Sources/STTextViewSwiftUIAppKit/TextView.swift +++ b/Sources/STTextViewSwiftUIAppKit/TextView.swift @@ -184,6 +184,22 @@ public extension TextViewModifier { } } +// MARK: - Gutter Data Source Adapter + +/// Bridges a closure-based view factory to the ``STGutterLineViewDataSource`` protocol. +/// Stored on the SwiftUI coordinator so it stays alive while the text view holds a weak reference. +private class GutterLineViewDataSourceAdapter: STGutterLineViewDataSource { + var factory: (Int, String) -> NSView + + init(factory: @escaping (Int, String) -> NSView) { + self.factory = factory + } + + func textView(_ textView: STTextView, viewForGutterLine lineNumber: Int, content: String) -> NSView { + factory(lineNumber, content) + } +} + // MARK: - NSViewRepresentable private struct TextViewRepresentable: NSViewRepresentable { @@ -254,9 +270,11 @@ private struct TextViewRepresentable: NSViewRepresentable { } // Configure custom gutter if provided - if gutterWidth > 0 { + if gutterWidth > 0, let factory = gutterLineViewFactory { textView.customGutterWidth = gutterWidth - textView.gutterLineViewProvider = gutterLineViewFactory + let adapter = GutterLineViewDataSourceAdapter(factory: factory) + context.coordinator.gutterDataSourceAdapter = adapter + textView.gutterLineViewDataSource = adapter textView.customGutterBackgroundColor = gutterBackgroundColor textView.customGutterSeparatorColor = gutterSeparatorColor textView.customGutterSeparatorWidth = gutterSeparatorWidth @@ -329,11 +347,17 @@ private struct TextViewRepresentable: NSViewRepresentable { } // Update custom gutter — the factory may capture new SwiftUI state - if gutterWidth > 0 { + if gutterWidth > 0, let factory = gutterLineViewFactory { if textView.customGutterWidth != gutterWidth { textView.customGutterWidth = gutterWidth } - textView.gutterLineViewProvider = gutterLineViewFactory + if let adapter = context.coordinator.gutterDataSourceAdapter { + adapter.factory = factory + } else { + let adapter = GutterLineViewDataSourceAdapter(factory: factory) + context.coordinator.gutterDataSourceAdapter = adapter + textView.gutterLineViewDataSource = adapter + } textView.customGutterBackgroundColor = gutterBackgroundColor textView.customGutterSeparatorColor = gutterSeparatorColor textView.customGutterSeparatorWidth = gutterSeparatorWidth @@ -369,6 +393,8 @@ private struct TextViewRepresentable: NSViewRepresentable { var isUpdating = false var isUserEditing = false var lastFont: NSFont? + /// Keeps the gutter data source adapter alive while the text view holds a weak reference. + var gutterDataSourceAdapter: GutterLineViewDataSourceAdapter? init(text: Binding, selection: Binding) { self._text = text From c172e7c25c15f5cdcc97d42cd509dc9151599020 Mon Sep 17 00:00:00 2001 From: Roman <329079+romanr@users.noreply.github.com> Date: Sat, 7 Mar 2026 07:41:31 +1100 Subject: [PATCH 17/28] Address PR review: fix text container sizing, scope gutter modifiers, add cleanup - Fix updateTextContainerSize, intrinsicContentSize, and sizeToFit to fall back to customGutterWidth (consistent with setFrameSize and updateContentSizeIfNeeded) - Scope gutterBackground/gutterSeparator modifiers to TextViewWithGutter so they don't appear on plain TextView - Clear custom gutter state in updateNSView when gutter is disabled - Fix misleading doc comment on layoutCustomGutterLineViews Co-Authored-By: Claude Opus 4.6 --- Sources/STTextViewAppKit/STTextView+Gutter.swift | 6 ++---- Sources/STTextViewAppKit/STTextView.swift | 6 +++--- Sources/STTextViewSwiftUIAppKit/TextView.swift | 9 ++++++++- 3 files changed, 13 insertions(+), 8 deletions(-) diff --git a/Sources/STTextViewAppKit/STTextView+Gutter.swift b/Sources/STTextViewAppKit/STTextView+Gutter.swift index 7ea3efe5..9998e334 100644 --- a/Sources/STTextViewAppKit/STTextView+Gutter.swift +++ b/Sources/STTextViewAppKit/STTextView+Gutter.swift @@ -239,13 +239,11 @@ extension STTextView { /// Identifier for the trailing separator view inside the custom gutter container. private static let gutterSeparatorID = NSUserInterfaceItemIdentifier("stgutter-separator") - /// Positions custom gutter line views provided by ``gutterLineViewProvider``. + /// Positions custom gutter line views provided by ``gutterLineViewDataSource``. /// Creates the container view lazily as a floating subview, then enumerates /// visible lines to create and position one NSView per paragraph. /// - /// Views are cached by line number (via `tag`) and reused across layout passes - /// so that interactive SwiftUI content (buttons, gestures) inside NSHostingViews - /// keeps working. Views are only recreated when the line's content or state changes. + /// Views are recreated on each layout pass to pick up fresh state. private func layoutCustomGutterLineViews() { guard let dataSource = gutterLineViewDataSource, customGutterWidth > 0 else { return diff --git a/Sources/STTextViewAppKit/STTextView.swift b/Sources/STTextViewAppKit/STTextView.swift index a45e2157..08c6c32e 100644 --- a/Sources/STTextViewAppKit/STTextView.swift +++ b/Sources/STTextViewAppKit/STTextView.swift @@ -1021,7 +1021,7 @@ open class STTextView: NSView, NSTextInput, NSTextContent, STTextViewProtocol { override open var intrinsicContentSize: NSSize { // usageBoundsForTextContainer already includes lineFragmentPadding via STTextLayoutManager workaround let textSize = textLayoutManager.usageBoundsForTextContainer.size - let gutterWidth = gutterView?.frame.width ?? 0 + let gutterWidth = gutterView?.frame.width ?? customGutterWidth return NSSize( width: textSize.width + gutterWidth, @@ -1447,7 +1447,7 @@ open class STTextView: NSView, NSTextInput, NSTextContent, STTextViewProtocol { return } - let gutterWidth = gutterView?.frame.width ?? 0 + let gutterWidth = gutterView?.frame.width ?? customGutterWidth let scrollerInset = proposedSize == nil ? (scrollView?.contentView.contentInsets.right ?? 0) : 0 let referenceSize = proposedSize ?? effectiveVisibleRect.size @@ -1510,7 +1510,7 @@ open class STTextView: NSView, NSTextInput, NSTextContent, STTextViewProtocol { return false } - let gutterWidth = gutterView?.frame.width ?? 0 + let gutterWidth = gutterView?.frame.width ?? customGutterWidth var newFrame = CGRect(origin: frame.origin, size: usageBoundsForTextContainerSize) newFrame.size.width += gutterWidth diff --git a/Sources/STTextViewSwiftUIAppKit/TextView.swift b/Sources/STTextViewSwiftUIAppKit/TextView.swift index 582f7ce3..e2b81192 100644 --- a/Sources/STTextViewSwiftUIAppKit/TextView.swift +++ b/Sources/STTextViewSwiftUIAppKit/TextView.swift @@ -164,7 +164,7 @@ extension EnvironmentValues { } } -public extension TextViewModifier { +public extension TextViewWithGutter { /// Sets the background color for the custom gutter area. func gutterBackground(_ color: NSColor?) -> TextViewEnvironmentModifier { @@ -361,6 +361,13 @@ private struct TextViewRepresentable: NSViewRepresentable { textView.customGutterBackgroundColor = gutterBackgroundColor textView.customGutterSeparatorColor = gutterSeparatorColor textView.customGutterSeparatorWidth = gutterSeparatorWidth + } else if textView.customGutterWidth > 0 { + // Gutter was previously configured but is now disabled — clean up + textView.customGutterWidth = 0 + textView.gutterLineViewDataSource = nil + context.coordinator.gutterDataSourceAdapter = nil + textView.customGutterBackgroundColor = nil + textView.customGutterSeparatorColor = nil } textView.needsLayout = true From 8b4f6b4a0790df7a3c69a002bba1e5e2e511d5b4 Mon Sep 17 00:00:00 2001 From: Roman <329079+romanr@users.noreply.github.com> Date: Sat, 7 Mar 2026 07:52:54 +1100 Subject: [PATCH 18/28] =?UTF-8?q?Revert=20gutter=20modifier=20scoping=20?= =?UTF-8?q?=E2=80=94=20keep=20on=20TextViewModifier=20for=20chaining?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TextViewEnvironmentModifier conforms to TextViewModifier, not TextViewWithGutter, so chaining .gutterBackground().gutterSeparator() breaks when modifiers are scoped to TextViewWithGutter only. Co-Authored-By: Claude Opus 4.6 --- Sources/STTextViewSwiftUIAppKit/TextView.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/STTextViewSwiftUIAppKit/TextView.swift b/Sources/STTextViewSwiftUIAppKit/TextView.swift index e2b81192..f6ca3245 100644 --- a/Sources/STTextViewSwiftUIAppKit/TextView.swift +++ b/Sources/STTextViewSwiftUIAppKit/TextView.swift @@ -164,7 +164,7 @@ extension EnvironmentValues { } } -public extension TextViewWithGutter { +public extension TextViewModifier { /// Sets the background color for the custom gutter area. func gutterBackground(_ color: NSColor?) -> TextViewEnvironmentModifier { From bb8300bc6a6a4f118d8e876402758adf45e467e5 Mon Sep 17 00:00:00 2001 From: Roman <329079+romanr@users.noreply.github.com> Date: Sat, 7 Mar 2026 08:10:58 +1100 Subject: [PATCH 19/28] Add custom gutter tests and fix stale doc reference MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add 8 tests for custom gutter sizing: sizeToFit, intrinsicContentSize, consistency, cleanup, long content, data source queries, and container lifecycle (addresses PR review comment on missing test coverage) - Fix doc comment on customGutterWidth referencing renamed property gutterLineViewProvider → gutterLineViewDataSource Co-Authored-By: Claude Opus 4.6 --- Sources/STTextViewAppKit/STTextView.swift | 2 +- .../SizeToFitTests.swift | 190 ++++++++++++++++++ 2 files changed, 191 insertions(+), 1 deletion(-) diff --git a/Sources/STTextViewAppKit/STTextView.swift b/Sources/STTextViewAppKit/STTextView.swift index 08c6c32e..73ce7e46 100644 --- a/Sources/STTextViewAppKit/STTextView.swift +++ b/Sources/STTextViewAppKit/STTextView.swift @@ -381,7 +381,7 @@ open class STTextView: NSView, NSTextInput, NSTextContent, STTextViewProtocol { /// Width of the custom gutter area. When greater than 0, ``contentView.frame.origin.x`` /// is offset by this amount, allowing custom gutter content without the built-in ``STGutterView``. /// - /// Use together with ``gutterLineViewProvider`` to supply a custom NSView per visible line. + /// Use together with ``gutterLineViewDataSource`` to supply a custom NSView per visible line. /// When the built-in ``gutterView`` is present, its width takes precedence. open var customGutterWidth: CGFloat = 0 { didSet { diff --git a/Tests/STTextViewAppKitTests/SizeToFitTests.swift b/Tests/STTextViewAppKitTests/SizeToFitTests.swift index c9dd8e01..0ea97cc9 100644 --- a/Tests/STTextViewAppKitTests/SizeToFitTests.swift +++ b/Tests/STTextViewAppKitTests/SizeToFitTests.swift @@ -775,6 +775,196 @@ XCTAssertGreaterThanOrEqual(stTextView.frame.width, textWidth + gutterWidth - 1, "Frame should include both text content and gutter") } + // MARK: - Custom Gutter Tests + + @MainActor + func testSizeToFitIncludesCustomGutterWidth() { + let stTextView = STTextView() + stTextView.frame = CGRect(x: 0, y: 0, width: 200, height: 100) + stTextView.font = NSFont.monospacedSystemFont(ofSize: 12, weight: .regular) + stTextView.isVerticallyResizable = true + stTextView.isHorizontallyResizable = true + stTextView.showsLineNumbers = false + stTextView.setString("Testing custom gutter width sizing") + stTextView.sizeToFit() + + let frameWidthWithoutGutter = stTextView.frame.width + + // Set custom gutter width (without built-in line numbers) + stTextView.customGutterWidth = 64 + stTextView.sizeToFit() + + let frameWidthWithCustomGutter = stTextView.frame.width + + XCTAssertGreaterThan(frameWidthWithCustomGutter, frameWidthWithoutGutter, "Frame should be wider with custom gutter") + XCTAssertEqual(frameWidthWithCustomGutter, frameWidthWithoutGutter + 64, accuracy: 1.0, "Frame width should include custom gutter width") + } + + @MainActor + func testIntrinsicContentSizeIncludesCustomGutterWidth() { + let stTextView = STTextView() + stTextView.frame = CGRect(x: 0, y: 0, width: 200, height: 100) + stTextView.font = NSFont.monospacedSystemFont(ofSize: 12, weight: .regular) + stTextView.isVerticallyResizable = true + stTextView.isHorizontallyResizable = true + stTextView.showsLineNumbers = false + stTextView.setString("Testing intrinsic content size with custom gutter") + stTextView.sizeToFit() + + let intrinsicWidthWithoutGutter = stTextView.intrinsicContentSize.width + + stTextView.customGutterWidth = 80 + stTextView.sizeToFit() + + let intrinsicWidthWithCustomGutter = stTextView.intrinsicContentSize.width + + XCTAssertGreaterThan(intrinsicWidthWithCustomGutter, intrinsicWidthWithoutGutter, "Intrinsic width should include custom gutter") + XCTAssertEqual(intrinsicWidthWithCustomGutter, intrinsicWidthWithoutGutter + 80, accuracy: 1.0, "Intrinsic width should grow by custom gutter width") + } + + @MainActor + func testSizeToFitMatchesIntrinsicContentSizeWithCustomGutter() { + let stTextView = STTextView() + stTextView.frame = CGRect(x: 0, y: 0, width: 200, height: 100) + stTextView.font = NSFont.monospacedSystemFont(ofSize: 12, weight: .regular) + stTextView.isVerticallyResizable = true + stTextView.isHorizontallyResizable = true + stTextView.showsLineNumbers = false + stTextView.customGutterWidth = 64 + stTextView.setString("Consistency between sizeToFit and intrinsicContentSize") + stTextView.sizeToFit() + + let intrinsicWidth = stTextView.intrinsicContentSize.width + let frameWidth = stTextView.frame.width + + XCTAssertEqual(frameWidth, intrinsicWidth, accuracy: 1.0, "sizeToFit() frame width should match intrinsicContentSize width with custom gutter") + } + + @MainActor + func testCustomGutterWidthResetRemovesOffset() { + let stTextView = STTextView() + stTextView.frame = CGRect(x: 0, y: 0, width: 200, height: 100) + stTextView.font = NSFont.monospacedSystemFont(ofSize: 12, weight: .regular) + stTextView.isVerticallyResizable = true + stTextView.isHorizontallyResizable = true + stTextView.showsLineNumbers = false + stTextView.setString("Testing gutter removal") + stTextView.sizeToFit() + + let frameWidthWithoutGutter = stTextView.frame.width + + // Add custom gutter + stTextView.customGutterWidth = 64 + stTextView.sizeToFit() + XCTAssertGreaterThan(stTextView.frame.width, frameWidthWithoutGutter) + + // Remove custom gutter + stTextView.customGutterWidth = 0 + stTextView.sizeToFit() + + XCTAssertEqual(stTextView.frame.width, frameWidthWithoutGutter, accuracy: 1.0, "Frame should return to original width after removing custom gutter") + } + + @MainActor + func testCustomGutterWithLongContent() { + let longLine = String(repeating: "x", count: 200) + + let stTextView = STTextView() + stTextView.frame = CGRect(x: 0, y: 0, width: 300, height: 100) + stTextView.font = NSFont.monospacedSystemFont(ofSize: 12, weight: .regular) + stTextView.isVerticallyResizable = true + stTextView.isHorizontallyResizable = true + stTextView.showsLineNumbers = false + stTextView.customGutterWidth = 64 + stTextView.setString(longLine) + stTextView.sizeToFit() + + let textWidth = stTextView.textLayoutManager.usageBoundsForTextContainer.width + + // Frame should be at least text width + custom gutter width + XCTAssertGreaterThanOrEqual(stTextView.frame.width, textWidth + 64 - 1, "Frame should include both text content and custom gutter") + } + + @MainActor + func testDataSourceQueriedDuringLayout() { + let stTextView = STTextView() + stTextView.frame = CGRect(x: 0, y: 0, width: 300, height: 200) + stTextView.font = NSFont.monospacedSystemFont(ofSize: 12, weight: .regular) + stTextView.setString("Line 1\nLine 2\nLine 3") + + var queriedLines: [(Int, String)] = [] + let dataSource = TestGutterDataSource { lineNumber, content in + queriedLines.append((lineNumber, content)) + let label = NSTextField(labelWithString: "\(lineNumber)") + return label + } + + stTextView.customGutterWidth = 40 + stTextView.gutterLineViewDataSource = dataSource + stTextView.layout() + + XCTAssertFalse(queriedLines.isEmpty, "Data source should be queried during layout") + // All queried line numbers should be 1-based and positive + for (lineNumber, _) in queriedLines { + XCTAssertGreaterThan(lineNumber, 0, "Line numbers should be 1-based") + } + } + + @MainActor + func testCustomGutterContainerCleanedUpWhenWidthZeroed() { + let stTextView = STTextView() + stTextView.frame = CGRect(x: 0, y: 0, width: 300, height: 200) + stTextView.font = NSFont.monospacedSystemFont(ofSize: 12, weight: .regular) + stTextView.setString("Hello") + + let dataSource = TestGutterDataSource { _, _ in NSView() } + stTextView.customGutterWidth = 40 + stTextView.gutterLineViewDataSource = dataSource + stTextView.layout() + + XCTAssertNotNil(stTextView.customGutterContainerView, "Container should exist when custom gutter is configured") + + // Disable the custom gutter + stTextView.customGutterWidth = 0 + + XCTAssertNil(stTextView.customGutterContainerView, "Container should be removed when custom gutter width is zeroed") + } + + @MainActor + func testCustomGutterContainerCleanedUpWhenDataSourceNilled() { + let stTextView = STTextView() + stTextView.frame = CGRect(x: 0, y: 0, width: 300, height: 200) + stTextView.font = NSFont.monospacedSystemFont(ofSize: 12, weight: .regular) + stTextView.setString("Hello") + + let dataSource = TestGutterDataSource { _, _ in NSView() } + stTextView.customGutterWidth = 40 + stTextView.gutterLineViewDataSource = dataSource + stTextView.layout() + + XCTAssertNotNil(stTextView.customGutterContainerView, "Container should exist when custom gutter is configured") + + // Nil the data source + stTextView.gutterLineViewDataSource = nil + + XCTAssertNil(stTextView.customGutterContainerView, "Container should be removed when data source is nilled") + } + + } + + // MARK: - Test Helpers + + /// A concrete ``STGutterLineViewDataSource`` for testing. + private class TestGutterDataSource: STGutterLineViewDataSource { + let factory: (Int, String) -> NSView + + init(factory: @escaping (Int, String) -> NSView) { + self.factory = factory + } + + func textView(_ textView: STTextView, viewForGutterLine lineNumber: Int, content: String) -> NSView { + factory(lineNumber, content) + } } #endif From cc0a99449b1f7e5f3ee9c66b5e0b03702d38e439 Mon Sep 17 00:00:00 2001 From: Roman <329079+romanr@users.noreply.github.com> Date: Sun, 8 Mar 2026 02:41:33 +1100 Subject: [PATCH 20/28] feat: Add overscrollFraction to STTextView and SwiftUI wrapper --- Sources/STTextViewAppKit/STTextView.swift | 23 +++++++++++ .../STTextViewSwiftUIAppKit/TextView.swift | 39 +++++++++++++++++++ TextEdit.SwiftUI/ContentView.swift | 2 + 3 files changed, 64 insertions(+) diff --git a/Sources/STTextViewAppKit/STTextView.swift b/Sources/STTextViewAppKit/STTextView.swift index 73ce7e46..6bed9f57 100644 --- a/Sources/STTextViewAppKit/STTextView.swift +++ b/Sources/STTextViewAppKit/STTextView.swift @@ -378,6 +378,19 @@ open class STTextView: NSView, NSTextInput, NSTextContent, STTextViewProtocol { /// Gutter view public var gutterView: STGutterView? + /// Fraction of the visible viewport height added as extra scrollable space below the last line. + /// + /// For example, `0.5` allows the last line to scroll up to the vertical midpoint of the editor + /// (half-page overscroll). Set to `0` (the default) to disable overscroll. + /// + /// The extra space is only appended when the text content already overflows the viewport, + /// so short documents that fit entirely on screen are not affected and show no scrollbar. + open var overscrollFraction: CGFloat = 0 { + didSet { + updateContentSizeIfNeeded() + } + } + /// Width of the custom gutter area. When greater than 0, ``contentView.frame.origin.x`` /// is offset by this amount, allowing custom gutter content without the built-in ``STGutterView``. /// @@ -1578,6 +1591,16 @@ open class STTextView: NSView, NSTextInput, NSTextContent, STTextViewProtocol { estimatedSize.width = max(estimatedSize.width, scrollView.contentView.bounds.width - scrollerInset) } + // Apply overscroll: extend document height so the last line can scroll + // up to `overscrollFraction` of the viewport height from the top. + // Only when text already overflows the viewport — short documents are unaffected. + if isVerticallyResizable, overscrollFraction > 0, let scrollView { + let viewportHeight = scrollView.contentView.bounds.height + if estimatedSize.height > viewportHeight { + estimatedSize.height += viewportHeight * overscrollFraction + } + } + let newFrame = backingAlignedRect( CGRect(origin: frame.origin, size: estimatedSize), options: .alignAllEdgesOutward diff --git a/Sources/STTextViewSwiftUIAppKit/TextView.swift b/Sources/STTextViewSwiftUIAppKit/TextView.swift index f6ca3245..3c23a7c7 100644 --- a/Sources/STTextViewSwiftUIAppKit/TextView.swift +++ b/Sources/STTextViewSwiftUIAppKit/TextView.swift @@ -130,6 +130,34 @@ public struct TextViewWithGutter: SwiftUI.View, TextViewMod } } +// MARK: - Overscroll + +/// Environment key for overscroll fraction (fraction of viewport height added below last line). +private struct OverscrollFractionKey: EnvironmentKey { + static let defaultValue: CGFloat = 0 +} + +extension EnvironmentValues { + /// Fraction of the viewport height to add as overscroll below the last text line. + /// `0.5` = half-page overscroll. `0` (default) disables overscroll. + var overscrollFraction: CGFloat { + get { self[OverscrollFractionKey.self] } + set { self[OverscrollFractionKey.self] = newValue } + } +} + +public extension TextViewModifier { + + /// Adds overscroll space below the last line of text. + /// + /// The `fraction` is relative to the visible viewport height — `0.5` allows the last + /// line to scroll up to the vertical midpoint of the editor. Overscroll only activates + /// when content already overflows the viewport, so short documents show no scrollbar. + func overscrollFraction(_ fraction: CGFloat) -> TextViewEnvironmentModifier { + TextViewEnvironmentModifier(content: self, keyPath: \.overscrollFraction, value: fraction) + } +} + // MARK: - Gutter Style Modifiers /// Environment key for custom gutter background color. @@ -213,6 +241,8 @@ private struct TextViewRepresentable: NSViewRepresentable { private var lineHeightMultiple @Environment(\.autocorrectionDisabled) private var autocorrectionDisabled + @Environment(\.overscrollFraction) + private var overscrollFraction @Binding private var text: AttributedString @@ -244,6 +274,11 @@ private struct TextViewRepresentable: NSViewRepresentable { textView.textDelegate = context.coordinator textView.highlightSelectedLine = options.contains(.highlightSelectedLine) textView.isHorizontallyResizable = !options.contains(.wrapLines) + if options.contains(.wrapLines) { + // Wrapping lines means horizontal scrolling is never needed. + scrollView.hasHorizontalScroller = false + } + textView.overscrollFraction = overscrollFraction textView.showsLineNumbers = options.contains(.showLineNumbers) textView.textSelection = NSRange() @@ -338,6 +373,10 @@ private struct TextViewRepresentable: NSViewRepresentable { textView.isHorizontallyResizable = !options.contains(.wrapLines) } + if textView.overscrollFraction != overscrollFraction { + textView.overscrollFraction = overscrollFraction + } + if textView.showsLineNumbers != options.contains(.showLineNumbers) { textView.showsLineNumbers = options.contains(.showLineNumbers) if options.contains(.showLineNumbers) { diff --git a/TextEdit.SwiftUI/ContentView.swift b/TextEdit.SwiftUI/ContentView.swift index 587edf16..c2904a50 100644 --- a/TextEdit.SwiftUI/ContentView.swift +++ b/TextEdit.SwiftUI/ContentView.swift @@ -79,6 +79,7 @@ struct ContentView: View { selection: $selection, options: options ) + .overscrollFraction(0.5) .textViewFont(font) } @@ -102,6 +103,7 @@ struct ContentView: View { ) .gutterBackground(NSColor(srgbRed: 0.992, green: 0.984, blue: 0.969, alpha: 1)) .gutterSeparator(color: NSColor(srgbRed: 0.75, green: 0.75, blue: 0.75, alpha: 0.5), width: 2) + .overscrollFraction(0.5) .textViewFont(font) } From f33d7e566e2a4c1b247efaaff3982936e41aa287 Mon Sep 17 00:00:00 2001 From: Roman <329079+romanr@users.noreply.github.com> Date: Sun, 8 Mar 2026 04:24:10 +1100 Subject: [PATCH 21/28] fix: Use full fragment height for custom gutter line views typographicBounds.height only includes font metrics (~17pt), not lineSpacing. This caused gutter labels to be half the visual line height, misaligning them with text content. Using fragmentView.frame height includes lineSpacing for correct 1:1 alignment. --- Sources/STTextViewAppKit/STTextView+Gutter.swift | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/Sources/STTextViewAppKit/STTextView+Gutter.swift b/Sources/STTextViewAppKit/STTextView+Gutter.swift index 9998e334..e835781b 100644 --- a/Sources/STTextViewAppKit/STTextView+Gutter.swift +++ b/Sources/STTextViewAppKit/STTextView+Gutter.swift @@ -334,10 +334,9 @@ extension STTextView { let lineView = lineViewForID(lineID, in: container, dataSource: dataSource, lineNumber: lineNumber, lineContent: lineContent) - // Size the line view to just the first visual line of this paragraph. - // fragmentView.frame.size.height spans the entire wrapped paragraph — using it - // causes NSHostingView content to render at the bottom of a tall frame rather - // than the top, because NSHostingView is non-flipped inside our flipped container. + // Use the full fragment view height so the gutter line view spans the + // entire visual line including lineSpacing. For wrapped paragraphs this + // covers all wrapped lines — acceptable since we show one label per paragraph. // For extra line fragments, typographicBounds.height may be invalid (FB15131180); // fall back to the previous line fragment's height or typingLineHeight. let lineHeight: CGFloat @@ -349,7 +348,7 @@ extension STTextView { lineHeight = typingLineHeight } } else { - lineHeight = textLineFragment.typographicBounds.height + lineHeight = fragmentView.frame.size.height } lineView.frame = CGRect( From 89c3f25f2aa53a76e250cf2850d9b226eca592a3 Mon Sep 17 00:00:00 2001 From: Roman <329079+romanr@users.noreply.github.com> Date: Sun, 8 Mar 2026 11:03:44 +1100 Subject: [PATCH 22/28] fix: Adjust gutter line view height to align with text baseline --- .../STTextViewAppKit/STTextView+Gutter.swift | 25 +++++++++++++++---- 1 file changed, 20 insertions(+), 5 deletions(-) diff --git a/Sources/STTextViewAppKit/STTextView+Gutter.swift b/Sources/STTextViewAppKit/STTextView+Gutter.swift index e835781b..48be6303 100644 --- a/Sources/STTextViewAppKit/STTextView+Gutter.swift +++ b/Sources/STTextViewAppKit/STTextView+Gutter.swift @@ -334,12 +334,14 @@ extension STTextView { let lineView = lineViewForID(lineID, in: container, dataSource: dataSource, lineNumber: lineNumber, lineContent: lineContent) - // Use the full fragment view height so the gutter line view spans the - // entire visual line including lineSpacing. For wrapped paragraphs this - // covers all wrapped lines — acceptable since we show one label per paragraph. + // Size the gutter line view to cover only the text-content area, + // excluding trailing lineSpacing. This ensures vertically-centered + // gutter labels align with the text baseline region instead of being + // pushed down by the lineSpacing gap below. // For extra line fragments, typographicBounds.height may be invalid (FB15131180); // fall back to the previous line fragment's height or typingLineHeight. let lineHeight: CGFloat + let lineY: CGFloat if textLineFragment.isExtraLineFragment { if layoutFragment.textLineFragments.count >= 2 { let prevLineFragment = layoutFragment.textLineFragments[layoutFragment.textLineFragments.count - 2] @@ -347,12 +349,25 @@ extension STTextView { } else { lineHeight = typingLineHeight } + lineY = fragmentView.frame.origin.y } else { - lineHeight = fragmentView.frame.size.height + // Subtract the paragraph's lineSpacing so the gutter view height + // matches the text content area. When lineSpacing is 0 (default), + // this equals the full fragment view height. + // Try the line fragment's own paragraph style first, then fall back + // to the text view's defaultParagraphStyle (set via TypingStylePlugin + // or similar). This handles lines whose attributed string was rebuilt + // internally without preserving the original paragraph style. + let fragmentLineSpacing = (textLineFragment.attributedString.attribute(.paragraphStyle, at: 0, effectiveRange: nil) as? NSParagraphStyle)?.lineSpacing + let effectiveLineSpacing = fragmentLineSpacing ?? defaultParagraphStyle.lineSpacing + lineHeight = fragmentView.frame.size.height - effectiveLineSpacing + // Offset by typographicBounds.origin.y to match the built-in line + // number positioning (accounts for any top padding in the fragment). + lineY = fragmentView.frame.origin.y + textLineFragment.typographicBounds.origin.y } lineView.frame = CGRect( - origin: CGPoint(x: 0, y: fragmentView.frame.origin.y), + origin: CGPoint(x: 0, y: lineY), size: CGSize(width: customGutterWidth, height: lineHeight) ).pixelAligned From a579d0e1eaddd34902c78d910fe2ea276af8ccdf Mon Sep 17 00:00:00 2001 From: Roman <329079+romanr@users.noreply.github.com> Date: Sun, 8 Mar 2026 14:11:54 +1100 Subject: [PATCH 23/28] feat: Add scroll restoration and offset change reporting to TextViewModifier --- .../STTextViewSwiftUIAppKit/TextView.swift | 94 +++++++++++++++++++ 1 file changed, 94 insertions(+) diff --git a/Sources/STTextViewSwiftUIAppKit/TextView.swift b/Sources/STTextViewSwiftUIAppKit/TextView.swift index 3c23a7c7..2fec329a 100644 --- a/Sources/STTextViewSwiftUIAppKit/TextView.swift +++ b/Sources/STTextViewSwiftUIAppKit/TextView.swift @@ -212,6 +212,51 @@ public extension TextViewModifier { } } +// MARK: - Scroll Restoration & Observation + +/// Environment key for one-shot scroll offset restoration. +/// When non-nil, the scroll view scrolls to this Y offset on the next update, +/// then the value is consumed (ignored on subsequent updates until it changes). +private struct ScrollRestorationOffsetKey: EnvironmentKey { + static let defaultValue: CGFloat? = nil +} + +/// Environment key for continuous scroll offset change reporting. +/// The closure is called whenever the user scrolls (or the content scrolls programmatically). +private struct ScrollOffsetChangeHandlerKey: EnvironmentKey { + nonisolated(unsafe) static let defaultValue: (@MainActor (CGFloat) -> Void)? = nil +} + +extension EnvironmentValues { + var scrollRestorationOffset: CGFloat? { + get { self[ScrollRestorationOffsetKey.self] } + set { self[ScrollRestorationOffsetKey.self] = newValue } + } + + var scrollOffsetChangeHandler: (@MainActor (CGFloat) -> Void)? { + get { self[ScrollOffsetChangeHandlerKey.self] } + set { self[ScrollOffsetChangeHandlerKey.self] = newValue } + } +} + +public extension TextViewModifier { + + /// Restores the scroll position to the given Y offset. + /// + /// The offset is applied once when it transitions from `nil` to a value. + /// Set to `nil` after the view appears, then set to the saved offset to trigger restoration. + func scrollRestoration(offset: CGFloat?) -> TextViewEnvironmentModifier { + TextViewEnvironmentModifier(content: self, keyPath: \.scrollRestorationOffset, value: offset) + } + + /// Reports scroll offset changes as the user scrolls. + /// + /// The handler receives the `contentView.bounds.origin.y` value of the scroll view's clip view. + func onScrollOffsetChange(_ handler: @escaping @MainActor (CGFloat) -> Void) -> TextViewEnvironmentModifier Void)?> { + TextViewEnvironmentModifier(content: self, keyPath: \.scrollOffsetChangeHandler, value: handler) + } +} + // MARK: - Gutter Data Source Adapter /// Bridges a closure-based view factory to the ``STGutterLineViewDataSource`` protocol. @@ -243,6 +288,10 @@ private struct TextViewRepresentable: NSViewRepresentable { private var autocorrectionDisabled @Environment(\.overscrollFraction) private var overscrollFraction + @Environment(\.scrollRestorationOffset) + private var scrollRestorationOffset + @Environment(\.scrollOffsetChangeHandler) + private var scrollOffsetChangeHandler @Binding private var text: AttributedString @@ -328,6 +377,23 @@ private struct TextViewRepresentable: NSViewRepresentable { textView.isEditable = isEnabled textView.isSelectable = isEnabled + // Observe scroll position changes via the clip view's bounds notifications. + scrollView.contentView.postsBoundsChangedNotifications = true + context.coordinator.scrollObserver = NotificationCenter.default.addObserver( + forName: NSView.boundsDidChangeNotification, + object: scrollView.contentView, + queue: .main + ) { [weak scrollView, weak coordinator = context.coordinator] _ in + guard let scrollView, let coordinator else { return } + let offset = scrollView.contentView.bounds.origin.y + MainActor.assumeIsolated { + coordinator.scrollOffsetChangeHandler?(offset) + } + } + + // Store initial scroll offset change handler + context.coordinator.scrollOffsetChangeHandler = scrollOffsetChangeHandler + return scrollView } @@ -409,6 +475,22 @@ private struct TextViewRepresentable: NSViewRepresentable { textView.customGutterSeparatorColor = nil } + // Keep scroll offset change handler up to date + context.coordinator.scrollOffsetChangeHandler = scrollOffsetChangeHandler + + // Apply one-shot scroll restoration when the offset changes + if let offset = scrollRestorationOffset, offset != context.coordinator.lastRestoredScrollOffset { + context.coordinator.lastRestoredScrollOffset = offset + // Defer scroll to after layout completes so the text view has its final size. + DispatchQueue.main.async { + scrollView.contentView.scroll(to: NSPoint(x: 0, y: offset)) + scrollView.reflectScrolledClipView(scrollView.contentView) + } + } else if scrollRestorationOffset == nil { + // Reset tracking so the next non-nil value triggers restoration + context.coordinator.lastRestoredScrollOffset = nil + } + textView.needsLayout = true textView.needsDisplay = true } @@ -441,12 +523,24 @@ private struct TextViewRepresentable: NSViewRepresentable { var lastFont: NSFont? /// Keeps the gutter data source adapter alive while the text view holds a weak reference. var gutterDataSourceAdapter: GutterLineViewDataSourceAdapter? + /// Scroll observation token for NSView.boundsDidChangeNotification. + var scrollObserver: (any NSObjectProtocol)? + /// Callback invoked when the scroll offset changes. + var scrollOffsetChangeHandler: (@MainActor (CGFloat) -> Void)? + /// Tracks the last restored scroll offset to avoid re-applying on every update. + var lastRestoredScrollOffset: CGFloat? init(text: Binding, selection: Binding) { self._text = text self._selection = selection } + deinit { + if let scrollObserver { + NotificationCenter.default.removeObserver(scrollObserver) + } + } + func textViewDidChangeText(_ notification: Notification) { guard !isUpdating, let textView = notification.object as? STTextView else { return From c53f53a59f54414ee1dd399c5f9b61df0bca1c12 Mon Sep 17 00:00:00 2001 From: Roman <329079+romanr@users.noreply.github.com> Date: Sun, 8 Mar 2026 14:42:07 +1100 Subject: [PATCH 24/28] fix: Clip custom gutter views to scroll view bounds to prevent rendering outside visible area --- Sources/STTextViewAppKit/STTextView+Gutter.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/Sources/STTextViewAppKit/STTextView+Gutter.swift b/Sources/STTextViewAppKit/STTextView+Gutter.swift index 48be6303..75048e0f 100644 --- a/Sources/STTextViewAppKit/STTextView+Gutter.swift +++ b/Sources/STTextViewAppKit/STTextView+Gutter.swift @@ -255,6 +255,9 @@ extension STTextView { let container = STCustomGutterContainerView() container.frame = NSRect(x: 0, y: 0, width: customGutterWidth, height: contentView.bounds.height) if let enclosingScrollView { + // Clip floating subviews at the scroll view bounds so the gutter + // doesn't render outside the visible editor area. + enclosingScrollView.clipsToBounds = true enclosingScrollView.addFloatingSubview(container, for: .horizontal) } else { addSubview(container) From 8084d15bec8da01752ba6dbdf148920ed291cd5e Mon Sep 17 00:00:00 2001 From: Roman <329079+romanr@users.noreply.github.com> Date: Thu, 12 Mar 2026 04:36:12 +1100 Subject: [PATCH 25/28] Fixes for custom gutter. --- .../STTextViewAppKit/STTextView+Gutter.swift | 35 +++++++++++-------- .../STTextViewSwiftUIAppKit/TextView.swift | 32 +++++++++++++++-- 2 files changed, 50 insertions(+), 17 deletions(-) diff --git a/Sources/STTextViewAppKit/STTextView+Gutter.swift b/Sources/STTextViewAppKit/STTextView+Gutter.swift index 75048e0f..ddb985bc 100644 --- a/Sources/STTextViewAppKit/STTextView+Gutter.swift +++ b/Sources/STTextViewAppKit/STTextView+Gutter.swift @@ -253,7 +253,8 @@ extension STTextView { // at a fixed horizontal position while scrolling vertically with content. if customGutterContainerView == nil { let container = STCustomGutterContainerView() - container.frame = NSRect(x: 0, y: 0, width: customGutterWidth, height: contentView.bounds.height) + let initialViewportHeight = enclosingScrollView?.contentView.bounds.height ?? 0 + container.frame = NSRect(x: 0, y: 0, width: customGutterWidth, height: max(contentView.bounds.height, initialViewportHeight)) if let enclosingScrollView { // Clip floating subviews at the scroll view bounds so the gutter // doesn't render outside the visible editor area. @@ -267,9 +268,12 @@ extension STTextView { guard let container = customGutterContainerView else { return } - // Update container dimensions and background + // Update container dimensions and background. + // Use at least the viewport height so the gutter fills the full visible area + // even when the document is shorter than the viewport. + let viewportHeight = enclosingScrollView?.contentView.bounds.height ?? 0 container.frame.size.width = customGutterWidth - container.frame.size.height = contentView.bounds.height + container.frame.size.height = max(contentView.bounds.height, viewportHeight) container.layer?.backgroundColor = customGutterBackgroundColor?.cgColor // Track which line numbers are currently visible so we can prune stale views @@ -354,18 +358,19 @@ extension STTextView { } lineY = fragmentView.frame.origin.y } else { - // Subtract the paragraph's lineSpacing so the gutter view height - // matches the text content area. When lineSpacing is 0 (default), - // this equals the full fragment view height. - // Try the line fragment's own paragraph style first, then fall back - // to the text view's defaultParagraphStyle (set via TypingStylePlugin - // or similar). This handles lines whose attributed string was rebuilt - // internally without preserving the original paragraph style. - let fragmentLineSpacing = (textLineFragment.attributedString.attribute(.paragraphStyle, at: 0, effectiveRange: nil) as? NSParagraphStyle)?.lineSpacing - let effectiveLineSpacing = fragmentLineSpacing ?? defaultParagraphStyle.lineSpacing - lineHeight = fragmentView.frame.size.height - effectiveLineSpacing - // Offset by typographicBounds.origin.y to match the built-in line - // number positioning (accounts for any top padding in the fragment). + // Use typographicBounds.height directly as the label height. + // TextKit 2 prepends lineSpacing to the NEXT paragraph's fragment + // (not appending to the current one), so the first paragraph's fragment + // has height = textHeight only, while subsequent paragraphs have height + // = lineSpacing + textHeight. Subtracting lineSpacing would give ~0pt + // for the first line. typographicBounds.height = maximumLineHeight = + // the text content area, which is correct for all lines regardless of + // their position in the document. + lineHeight = textLineFragment.typographicBounds.height + // Offset by typographicBounds.origin.y to position within the fragment. + // For the first line of a paragraph: origin.y is 0 (no offset). + // For subsequent lines in a wrapped paragraph: origin.y is the + // accumulated height of preceding lines. lineY = fragmentView.frame.origin.y + textLineFragment.typographicBounds.origin.y } diff --git a/Sources/STTextViewSwiftUIAppKit/TextView.swift b/Sources/STTextViewSwiftUIAppKit/TextView.swift index 2fec329a..4809e7e8 100644 --- a/Sources/STTextViewSwiftUIAppKit/TextView.swift +++ b/Sources/STTextViewSwiftUIAppKit/TextView.swift @@ -79,6 +79,7 @@ public struct TextViewWithGutter: SwiftUI.View, TextViewMod @Environment(\.gutterBackgroundColor) private var envGutterBackgroundColor @Environment(\.gutterSeparatorColor) private var envGutterSeparatorColor @Environment(\.gutterSeparatorWidth) private var envGutterSeparatorWidth + @Environment(\.gutterShadow) private var envGutterShadow @Binding private var text: AttributedString @Binding private var selection: NSRange? @@ -124,7 +125,8 @@ public struct TextViewWithGutter: SwiftUI.View, TextViewMod gutterLineViewFactory: gutterLineViewFactory, gutterBackgroundColor: envGutterBackgroundColor, gutterSeparatorColor: envGutterSeparatorColor, - gutterSeparatorWidth: envGutterSeparatorWidth + gutterSeparatorWidth: envGutterSeparatorWidth, + gutterShadow: envGutterShadow ) .background(.background) } @@ -175,6 +177,11 @@ private struct GutterSeparatorWidthKey: EnvironmentKey { static let defaultValue: CGFloat = 2 } +/// Environment key for custom gutter shadow. +private struct GutterShadowKey: EnvironmentKey { + static let defaultValue: NSShadow? = nil +} + extension EnvironmentValues { var gutterBackgroundColor: NSColor? { get { self[GutterBackgroundColorKey.self] } @@ -190,6 +197,11 @@ extension EnvironmentValues { get { self[GutterSeparatorWidthKey.self] } set { self[GutterSeparatorWidthKey.self] = newValue } } + + var gutterShadow: NSShadow? { + get { self[GutterShadowKey.self] } + set { self[GutterShadowKey.self] = newValue } + } } public extension TextViewModifier { @@ -210,6 +222,11 @@ public extension TextViewModifier { value: width ) } + + /// Applies a shadow to the custom gutter container, cast onto the editor content area. + func gutterShadow(_ shadow: NSShadow?) -> TextViewEnvironmentModifier { + TextViewEnvironmentModifier(content: self, keyPath: \.gutterShadow, value: shadow) + } } // MARK: - Scroll Restoration & Observation @@ -304,8 +321,9 @@ private struct TextViewRepresentable: NSViewRepresentable { let gutterBackgroundColor: NSColor? let gutterSeparatorColor: NSColor? let gutterSeparatorWidth: CGFloat + let gutterShadow: NSShadow? - init(text: Binding, selection: Binding, options: TextView.Options, plugins: [any STPlugin] = [], gutterWidth: CGFloat = 0, gutterLineViewFactory: ((Int, String) -> NSView)? = nil, gutterBackgroundColor: NSColor? = nil, gutterSeparatorColor: NSColor? = nil, gutterSeparatorWidth: CGFloat = 0) { + init(text: Binding, selection: Binding, options: TextView.Options, plugins: [any STPlugin] = [], gutterWidth: CGFloat = 0, gutterLineViewFactory: ((Int, String) -> NSView)? = nil, gutterBackgroundColor: NSColor? = nil, gutterSeparatorColor: NSColor? = nil, gutterSeparatorWidth: CGFloat = 0, gutterShadow: NSShadow? = nil) { self._text = text self._selection = selection self.options = options @@ -315,10 +333,16 @@ private struct TextViewRepresentable: NSViewRepresentable { self.gutterBackgroundColor = gutterBackgroundColor self.gutterSeparatorColor = gutterSeparatorColor self.gutterSeparatorWidth = gutterSeparatorWidth + self.gutterShadow = gutterShadow } func makeNSView(context: Context) -> NSScrollView { let scrollView = STTextView.scrollableTextView() + // Disable automatic content insets — SwiftUI handles safe area layout externally. + // Without this, macOS adds a topContentInset when the scroll view overlaps the title bar, + // triggering the FB21059465 gutter workaround that shifts the gutter container above the + // scroll view's clip boundary, causing the first-line gutter label to be clipped. + scrollView.automaticallyAdjustsContentInsets = false let textView = scrollView.documentView as! STTextView textView.textDelegate = context.coordinator textView.highlightSelectedLine = options.contains(.highlightSelectedLine) @@ -475,6 +499,10 @@ private struct TextViewRepresentable: NSViewRepresentable { textView.customGutterSeparatorColor = nil } + // Apply gutter shadow from the app layer — set on the container view + // which is created lazily by STTextView during layout. + textView.customGutterContainerView?.shadow = gutterShadow + // Keep scroll offset change handler up to date context.coordinator.scrollOffsetChangeHandler = scrollOffsetChangeHandler From c1553dbec1f25de330cd692e7244b9d71e7eab04 Mon Sep 17 00:00:00 2001 From: Roman <329079+romanr@users.noreply.github.com> Date: Sun, 22 Mar 2026 13:34:13 +1100 Subject: [PATCH 26/28] feat: Add topContentInset property to STTextView for improved line spacing and create SwiftUITextViewSubclassTests for validation --- Sources/STTextViewAppKit/STTextView.swift | 20 +++++++++++++++ .../STTextViewSwiftUIAppKit/TextView.swift | 25 ++++++++++++++++--- .../SwiftUITextViewSubclassTests.swift | 24 ++++++++++++++++++ 3 files changed, 65 insertions(+), 4 deletions(-) create mode 100644 Tests/STTextViewAppKitTests/SwiftUITextViewSubclassTests.swift diff --git a/Sources/STTextViewAppKit/STTextView.swift b/Sources/STTextViewAppKit/STTextView.swift index 6bed9f57..1102cb96 100644 --- a/Sources/STTextViewAppKit/STTextView.swift +++ b/Sources/STTextViewAppKit/STTextView.swift @@ -378,6 +378,26 @@ open class STTextView: NSView, NSTextInput, NSTextContent, STTextViewProtocol { /// Gutter view public var gutterView: STGutterView? + /// Extra whitespace inserted above line 1 by pushing text down via an exclusion path. + /// + /// TextKit 2 prepends `lineSpacing` to lines 2+ but NOT line 1, so without this the first line + /// starts at y=0 with no breathing room. An exclusion path at y=0 with height=`topContentInset` + /// pushes line 1 down by exactly `lineSpacing`, creating equal whitespace above line 1 as + /// between all other lines. This is preferable to `contentInsets.top` because the offset is + /// baked into layout itself — no scroll-position dependency (Xcode Preview always snapshots + /// at y=0, so a contentInsets approach makes the fix invisible in previews). + open var topContentInset: CGFloat = 0 { + didSet { + if topContentInset > 0 { + let exclusionRect = CGRect(x: 0, y: 0, width: 1e6, height: topContentInset) + textContainer.exclusionPaths = [NSBezierPath(rect: exclusionRect)] + } else { + textContainer.exclusionPaths = [] + } + needsLayout = true + } + } + /// Fraction of the visible viewport height added as extra scrollable space below the last line. /// /// For example, `0.5` allows the last line to scroll up to the vertical midpoint of the editor diff --git a/Sources/STTextViewSwiftUIAppKit/TextView.swift b/Sources/STTextViewSwiftUIAppKit/TextView.swift index 4809e7e8..4f6169ce 100644 --- a/Sources/STTextViewSwiftUIAppKit/TextView.swift +++ b/Sources/STTextViewSwiftUIAppKit/TextView.swift @@ -19,6 +19,7 @@ public struct TextView: SwiftUI.View, TextViewModifier { @Binding private var selection: NSRange? private let options: Options private let plugins: [any STPlugin] + private let textViewType: STTextView.Type /// Create a text edit view with a certain text that uses a certain options. /// - Parameters: @@ -26,16 +27,19 @@ public struct TextView: SwiftUI.View, TextViewModifier { /// - selection: The current selection range /// - options: Editor options /// - plugins: Editor plugins + /// - textViewType: The ``STTextView`` subclass to instantiate public init( text: Binding, selection: Binding = .constant(nil), options: Options = [], - plugins: [any STPlugin] = [] + plugins: [any STPlugin] = [], + textViewType: STTextView.Type = STTextView.self ) { _text = text _selection = selection self.options = options self.plugins = plugins + self.textViewType = textViewType } public var body: some View { @@ -43,7 +47,8 @@ public struct TextView: SwiftUI.View, TextViewModifier { text: $text, selection: $selection, options: options, - plugins: plugins + plugins: plugins, + textViewType: textViewType ) .background(.background) } @@ -85,6 +90,7 @@ public struct TextViewWithGutter: SwiftUI.View, TextViewMod @Binding private var selection: NSRange? private let options: Options private let plugins: [any STPlugin] + private let textViewType: STTextView.Type private let gutterWidth: CGFloat private let gutterLineViewFactory: (Int, String) -> NSView @@ -95,6 +101,7 @@ public struct TextViewWithGutter: SwiftUI.View, TextViewMod /// - selection: The current selection range /// - options: Editor options /// - plugins: Editor plugins + /// - textViewType: The ``STTextView`` subclass to instantiate /// - gutterWidth: Width reserved for the custom gutter area (in points) /// - gutterContent: A view builder called for each visible line with `(lineNumber, lineContent)` public init( @@ -102,6 +109,7 @@ public struct TextViewWithGutter: SwiftUI.View, TextViewMod selection: Binding = .constant(nil), options: Options = [], plugins: [any STPlugin] = [], + textViewType: STTextView.Type = STTextView.self, gutterWidth: CGFloat, @ViewBuilder gutterContent: @escaping (_ lineNumber: Int, _ lineContent: String) -> GutterContent ) { @@ -109,6 +117,7 @@ public struct TextViewWithGutter: SwiftUI.View, TextViewMod _selection = selection self.options = options self.plugins = plugins + self.textViewType = textViewType self.gutterWidth = gutterWidth self.gutterLineViewFactory = { lineNumber, lineContent in NSHostingView(rootView: gutterContent(lineNumber, lineContent)) @@ -121,6 +130,7 @@ public struct TextViewWithGutter: SwiftUI.View, TextViewMod selection: $selection, options: options, plugins: plugins, + textViewType: textViewType, gutterWidth: gutterWidth, gutterLineViewFactory: gutterLineViewFactory, gutterBackgroundColor: envGutterBackgroundColor, @@ -316,6 +326,7 @@ private struct TextViewRepresentable: NSViewRepresentable { private var selection: NSRange? private let options: TextView.Options private var plugins: [any STPlugin] + private let textViewType: STTextView.Type let gutterWidth: CGFloat let gutterLineViewFactory: ((Int, String) -> NSView)? let gutterBackgroundColor: NSColor? @@ -323,11 +334,12 @@ private struct TextViewRepresentable: NSViewRepresentable { let gutterSeparatorWidth: CGFloat let gutterShadow: NSShadow? - init(text: Binding, selection: Binding, options: TextView.Options, plugins: [any STPlugin] = [], gutterWidth: CGFloat = 0, gutterLineViewFactory: ((Int, String) -> NSView)? = nil, gutterBackgroundColor: NSColor? = nil, gutterSeparatorColor: NSColor? = nil, gutterSeparatorWidth: CGFloat = 0, gutterShadow: NSShadow? = nil) { + init(text: Binding, selection: Binding, options: TextView.Options, plugins: [any STPlugin] = [], textViewType: STTextView.Type = STTextView.self, gutterWidth: CGFloat = 0, gutterLineViewFactory: ((Int, String) -> NSView)? = nil, gutterBackgroundColor: NSColor? = nil, gutterSeparatorColor: NSColor? = nil, gutterSeparatorWidth: CGFloat = 0, gutterShadow: NSShadow? = nil) { self._text = text self._selection = selection self.options = options self.plugins = plugins + self.textViewType = textViewType self.gutterWidth = gutterWidth self.gutterLineViewFactory = gutterLineViewFactory self.gutterBackgroundColor = gutterBackgroundColor @@ -337,7 +349,7 @@ private struct TextViewRepresentable: NSViewRepresentable { } func makeNSView(context: Context) -> NSScrollView { - let scrollView = STTextView.scrollableTextView() + let scrollView = textViewType.scrollableTextView() // Disable automatic content insets — SwiftUI handles safe area layout externally. // Without this, macOS adds a topContentInset when the scroll view overlaps the title bar, // triggering the FB21059465 gutter workaround that shifts the gutter container above the @@ -482,6 +494,11 @@ private struct TextViewRepresentable: NSViewRepresentable { } if let adapter = context.coordinator.gutterDataSourceAdapter { adapter.factory = factory + // Force-recreate gutter line views so they pick up the new factory closure. + // The normal layout path (including during live window resize) reuses + // existing views for performance; this explicit reload propagates + // SwiftUI state changes captured by the factory (e.g. rulerData update). + textView.reloadGutterLineViews() } else { let adapter = GutterLineViewDataSourceAdapter(factory: factory) context.coordinator.gutterDataSourceAdapter = adapter diff --git a/Tests/STTextViewAppKitTests/SwiftUITextViewSubclassTests.swift b/Tests/STTextViewAppKitTests/SwiftUITextViewSubclassTests.swift new file mode 100644 index 00000000..e846b29c --- /dev/null +++ b/Tests/STTextViewAppKitTests/SwiftUITextViewSubclassTests.swift @@ -0,0 +1,24 @@ +#if os(macOS) + import XCTest + @testable import STTextViewAppKit + + @MainActor + final class SwiftUITextViewSubclassTests: XCTestCase { + + func testScrollableTextViewReturnsSTTextViewByDefault() throws { + let scrollView = STTextView.scrollableTextView() + let documentView = try XCTUnwrap(scrollView.documentView as? STTextView) + + XCTAssertTrue(type(of: documentView) == STTextView.self) + } + + func testScrollableTextViewReturnsSubclassWhenCalledOnSubclass() throws { + let scrollView = CustomTextView.scrollableTextView() + let documentView = try XCTUnwrap(scrollView.documentView as? STTextView) + + XCTAssertTrue(documentView is CustomTextView) + } + } + + private final class CustomTextView: STTextView {} +#endif From 1c559951f80eebdfd77bfc6dfe0c503719a11a7d Mon Sep 17 00:00:00 2001 From: Roman <329079+romanr@users.noreply.github.com> Date: Sun, 22 Mar 2026 17:04:26 +1100 Subject: [PATCH 27/28] fix: unnecessary gutter relayout on keystroke and mid-resize layout pass --- .../STGutterLineViewDataSource.swift | 23 +++++ .../STTextViewAppKit/STTextView+Gutter.swift | 15 +++- .../STTextViewSwiftUIAppKit/TextView.swift | 87 ++++++++++++++++--- 3 files changed, 111 insertions(+), 14 deletions(-) diff --git a/Sources/STTextViewAppKit/STGutterLineViewDataSource.swift b/Sources/STTextViewAppKit/STGutterLineViewDataSource.swift index 16e5359e..56e4be16 100644 --- a/Sources/STTextViewAppKit/STGutterLineViewDataSource.swift +++ b/Sources/STTextViewAppKit/STGutterLineViewDataSource.swift @@ -21,4 +21,27 @@ public protocol STGutterLineViewDataSource: AnyObject { /// - content: The plain-text content of the line (trailing newline stripped). /// - Returns: An `NSView` to be positioned in the gutter alongside the line. func textView(_ textView: STTextView, viewForGutterLine lineNumber: Int, content: String) -> NSView + + /// Attempts to update an existing gutter line view in-place rather than recreating it. + /// + /// Implement this method to update the content of an already-visible gutter view without + /// destroying and recreating it (which is expensive for NSHostingView). If the view cannot + /// be updated in-place, return `false` — the caller will fall back to creating a new view. + /// + /// The default implementation returns `false` (no in-place update). + /// + /// - Parameters: + /// - textView: The text view requesting the update. + /// - existingView: The view currently displayed for this line. + /// - lineNumber: The 1-based line number. + /// - content: The current plain-text content of the line. + /// - Returns: `true` if the view was updated successfully; `false` to trigger recreation. + func textView(_ textView: STTextView, updateView existingView: NSView, forGutterLine lineNumber: Int, content: String) -> Bool +} + +public extension STGutterLineViewDataSource { + /// Default: no in-place update — caller recreates the view. + func textView(_ textView: STTextView, updateView existingView: NSView, forGutterLine lineNumber: Int, content: String) -> Bool { + false + } } diff --git a/Sources/STTextViewAppKit/STTextView+Gutter.swift b/Sources/STTextViewAppKit/STTextView+Gutter.swift index ddb985bc..adcf0d3c 100644 --- a/Sources/STTextViewAppKit/STTextView+Gutter.swift +++ b/Sources/STTextViewAppKit/STTextView+Gutter.swift @@ -421,8 +421,12 @@ extension STTextView { } /// Returns (or creates) a gutter line view for the given identifier. - /// Always recreates the view from the data source to pick up captured SwiftUI state, - /// but adds to the container first so the NSHostingView has a window before layout. + /// + /// When an existing view is found for the line, the data source is given a chance to + /// update it in-place via `updateView`. This avoids destroying and recreating + /// NSHostingViews on every layout pass, which is expensive (~500 ms for many visible lines). + /// If the data source cannot update in-place (returns `false`), the old view is removed and + /// a new one is created from scratch. private func lineViewForID( _ id: NSUserInterfaceItemIdentifier, in container: NSView, @@ -430,8 +434,13 @@ extension STTextView { lineNumber: Int, lineContent: String ) -> NSView { - // Remove existing view for this line — it captured stale state + // Attempt in-place update to avoid destroying and re-creating the NSHostingView. if let existing = container.subviews.first(where: { $0.identifier == id }) { + if dataSource.textView(self, updateView: existing, forGutterLine: lineNumber, content: lineContent) { + // Successfully updated — reuse the existing view as-is. + return existing + } + // Data source cannot update in-place — remove so we can create a fresh view below. existing.removeFromSuperviewWithoutNeedingDisplay() } diff --git a/Sources/STTextViewSwiftUIAppKit/TextView.swift b/Sources/STTextViewSwiftUIAppKit/TextView.swift index 4f6169ce..39283e72 100644 --- a/Sources/STTextViewSwiftUIAppKit/TextView.swift +++ b/Sources/STTextViewSwiftUIAppKit/TextView.swift @@ -93,6 +93,15 @@ public struct TextViewWithGutter: SwiftUI.View, TextViewMod private let textViewType: STTextView.Type private let gutterWidth: CGFloat private let gutterLineViewFactory: (Int, String) -> NSView + /// Returns only the AnyView for the given line, used to update an existing NSHostingView + /// in-place (cheaper than allocating a new hosting view for every layout pass). + private let gutterViewUpdater: (Int, String) -> AnyView + /// Opaque identity value for the current gutter data. + /// When this changes between SwiftUI updates, the gutter line views are reloaded + /// so they pick up the new data. When it is unchanged (e.g. normal keystroke that + /// doesn't alter syllable counts or rhyme labels), the reload is skipped — preventing + /// the expensive NSHostingView destruction+recreation on every character typed. + private let gutterDataID: AnyHashable? /// Create a text editor with a custom per-line gutter. /// @@ -103,6 +112,9 @@ public struct TextViewWithGutter: SwiftUI.View, TextViewMod /// - plugins: Editor plugins /// - textViewType: The ``STTextView`` subclass to instantiate /// - gutterWidth: Width reserved for the custom gutter area (in points) + /// - gutterDataID: Opaque hash identity for the current gutter data. Pass a value that + /// changes when the gutter content should be refreshed (e.g. `AnyHashable(rulerData)`). + /// When `nil`, the gutter is always reloaded on every SwiftUI update (legacy behaviour). /// - gutterContent: A view builder called for each visible line with `(lineNumber, lineContent)` public init( text: Binding, @@ -111,6 +123,7 @@ public struct TextViewWithGutter: SwiftUI.View, TextViewMod plugins: [any STPlugin] = [], textViewType: STTextView.Type = STTextView.self, gutterWidth: CGFloat, + gutterDataID: AnyHashable? = nil, @ViewBuilder gutterContent: @escaping (_ lineNumber: Int, _ lineContent: String) -> GutterContent ) { _text = text @@ -119,8 +132,12 @@ public struct TextViewWithGutter: SwiftUI.View, TextViewMod self.plugins = plugins self.textViewType = textViewType self.gutterWidth = gutterWidth + self.gutterDataID = gutterDataID self.gutterLineViewFactory = { lineNumber, lineContent in - NSHostingView(rootView: gutterContent(lineNumber, lineContent)) + NSHostingView(rootView: AnyView(gutterContent(lineNumber, lineContent))) + } + self.gutterViewUpdater = { lineNumber, lineContent in + AnyView(gutterContent(lineNumber, lineContent)) } } @@ -132,7 +149,9 @@ public struct TextViewWithGutter: SwiftUI.View, TextViewMod plugins: plugins, textViewType: textViewType, gutterWidth: gutterWidth, + gutterDataID: gutterDataID, gutterLineViewFactory: gutterLineViewFactory, + gutterViewUpdater: gutterViewUpdater, gutterBackgroundColor: envGutterBackgroundColor, gutterSeparatorColor: envGutterSeparatorColor, gutterSeparatorWidth: envGutterSeparatorWidth, @@ -288,16 +307,38 @@ public extension TextViewModifier { /// Bridges a closure-based view factory to the ``STGutterLineViewDataSource`` protocol. /// Stored on the SwiftUI coordinator so it stays alive while the text view holds a weak reference. +/// +/// `viewFactory` creates a new `NSHostingView` for the initial layout (full allocation). +/// `viewUpdater` returns only the `AnyView` for in-place `rootView` updates — cheaper because +/// it does not allocate an NSHostingView; instead the existing hosting view's rootView is replaced +/// via a lightweight SwiftUI reconciliation call. This avoids the ~500 ms stutter caused by +/// destroying and recreating all visible NSHostingViews on every layout pass. private class GutterLineViewDataSourceAdapter: STGutterLineViewDataSource { + /// Returns a new NSHostingView wrapping the gutter content for the given line. var factory: (Int, String) -> NSView + /// Returns the AnyView for the given line, used to update an existing hosting view in-place. + var viewUpdater: ((Int, String) -> AnyView)? - init(factory: @escaping (Int, String) -> NSView) { + init(factory: @escaping (Int, String) -> NSView, viewUpdater: ((Int, String) -> AnyView)? = nil) { self.factory = factory + self.viewUpdater = viewUpdater } func textView(_ textView: STTextView, viewForGutterLine lineNumber: Int, content: String) -> NSView { factory(lineNumber, content) } + + func textView(_ textView: STTextView, updateView existingView: NSView, forGutterLine lineNumber: Int, content: String) -> Bool { + // Require both an updater and a castable hosting view. + guard let updater = viewUpdater, + let hostingView = existingView as? NSHostingView else { + return false + } + // Assign the new AnyView directly to the existing hosting view. + // This is a lightweight SwiftUI reconciliation, not an NSView allocation. + hostingView.rootView = updater(lineNumber, content) + return true + } } // MARK: - NSViewRepresentable @@ -328,20 +369,27 @@ private struct TextViewRepresentable: NSViewRepresentable { private var plugins: [any STPlugin] private let textViewType: STTextView.Type let gutterWidth: CGFloat + /// Opaque identity for the current gutter data — see `TextViewWithGutter.gutterDataID`. + let gutterDataID: AnyHashable? let gutterLineViewFactory: ((Int, String) -> NSView)? + /// Returns only the AnyView for the given line, used to update existing NSHostingViews + /// in-place without allocating a new hosting view (avoids the stutter on layout passes). + let gutterViewUpdater: ((Int, String) -> AnyView)? let gutterBackgroundColor: NSColor? let gutterSeparatorColor: NSColor? let gutterSeparatorWidth: CGFloat let gutterShadow: NSShadow? - init(text: Binding, selection: Binding, options: TextView.Options, plugins: [any STPlugin] = [], textViewType: STTextView.Type = STTextView.self, gutterWidth: CGFloat = 0, gutterLineViewFactory: ((Int, String) -> NSView)? = nil, gutterBackgroundColor: NSColor? = nil, gutterSeparatorColor: NSColor? = nil, gutterSeparatorWidth: CGFloat = 0, gutterShadow: NSShadow? = nil) { + init(text: Binding, selection: Binding, options: TextView.Options, plugins: [any STPlugin] = [], textViewType: STTextView.Type = STTextView.self, gutterWidth: CGFloat = 0, gutterDataID: AnyHashable? = nil, gutterLineViewFactory: ((Int, String) -> NSView)? = nil, gutterViewUpdater: ((Int, String) -> AnyView)? = nil, gutterBackgroundColor: NSColor? = nil, gutterSeparatorColor: NSColor? = nil, gutterSeparatorWidth: CGFloat = 0, gutterShadow: NSShadow? = nil) { self._text = text self._selection = selection self.options = options self.plugins = plugins self.textViewType = textViewType self.gutterWidth = gutterWidth + self.gutterDataID = gutterDataID self.gutterLineViewFactory = gutterLineViewFactory + self.gutterViewUpdater = gutterViewUpdater self.gutterBackgroundColor = gutterBackgroundColor self.gutterSeparatorColor = gutterSeparatorColor self.gutterSeparatorWidth = gutterSeparatorWidth @@ -392,8 +440,9 @@ private struct TextViewRepresentable: NSViewRepresentable { // Configure custom gutter if provided if gutterWidth > 0, let factory = gutterLineViewFactory { textView.customGutterWidth = gutterWidth - let adapter = GutterLineViewDataSourceAdapter(factory: factory) + let adapter = GutterLineViewDataSourceAdapter(factory: factory, viewUpdater: gutterViewUpdater) context.coordinator.gutterDataSourceAdapter = adapter + context.coordinator.lastGutterDataID = gutterDataID textView.gutterLineViewDataSource = adapter textView.customGutterBackgroundColor = gutterBackgroundColor textView.customGutterSeparatorColor = gutterSeparatorColor @@ -487,21 +536,34 @@ private struct TextViewRepresentable: NSViewRepresentable { } } - // Update custom gutter — the factory may capture new SwiftUI state + // Update custom gutter — the factory may capture new SwiftUI state. + // When a gutterDataID is provided, only call reloadGutterLineViews() when the + // ID actually changes, preventing expensive NSHostingView destruction+recreation + // on every keystroke. Without an ID, fall back to always reloading (legacy behaviour). if gutterWidth > 0, let factory = gutterLineViewFactory { if textView.customGutterWidth != gutterWidth { textView.customGutterWidth = gutterWidth } if let adapter = context.coordinator.gutterDataSourceAdapter { + // Update both closures so they always capture the latest SwiftUI state. adapter.factory = factory - // Force-recreate gutter line views so they pick up the new factory closure. - // The normal layout path (including during live window resize) reuses - // existing views for performance; this explicit reload propagates - // SwiftUI state changes captured by the factory (e.g. rulerData update). - textView.reloadGutterLineViews() + adapter.viewUpdater = gutterViewUpdater + // Reload only when gutter data has actually changed. + // gutterDataID == nil means no ID was supplied — always reload (legacy path). + let dataChanged: Bool + if let id = gutterDataID { + dataChanged = id != context.coordinator.lastGutterDataID + } else { + dataChanged = true + } + if dataChanged { + context.coordinator.lastGutterDataID = gutterDataID + textView.reloadGutterLineViews() + } } else { - let adapter = GutterLineViewDataSourceAdapter(factory: factory) + let adapter = GutterLineViewDataSourceAdapter(factory: factory, viewUpdater: gutterViewUpdater) context.coordinator.gutterDataSourceAdapter = adapter + context.coordinator.lastGutterDataID = gutterDataID textView.gutterLineViewDataSource = adapter } textView.customGutterBackgroundColor = gutterBackgroundColor @@ -568,6 +630,9 @@ private struct TextViewRepresentable: NSViewRepresentable { var lastFont: NSFont? /// Keeps the gutter data source adapter alive while the text view holds a weak reference. var gutterDataSourceAdapter: GutterLineViewDataSourceAdapter? + /// The gutter data ID from the last updateNSView call. + /// Used to skip reloadGutterLineViews() when the gutter data has not changed. + var lastGutterDataID: AnyHashable? /// Scroll observation token for NSView.boundsDidChangeNotification. var scrollObserver: (any NSObjectProtocol)? /// Callback invoked when the scroll offset changes. From ab4e7cc67317630620ebb674ee1b86c55847e70d Mon Sep 17 00:00:00 2001 From: Roman <329079+romanr@users.noreply.github.com> Date: Mon, 23 Mar 2026 22:01:05 +1100 Subject: [PATCH 28/28] fix: Remove stale layout fragments from fragmentViewMap in didLayout --- ...TextViewportLayoutControllerDelegate.swift | 13 +- Sources/STTextViewAppKit/STTextView.swift | 2 +- .../GutterResizeTests.swift | 282 ++++++++++++++++++ 3 files changed, 291 insertions(+), 6 deletions(-) create mode 100644 Tests/STTextViewAppKitTests/GutterResizeTests.swift diff --git a/Sources/STTextViewAppKit/STTextView+NSTextViewportLayoutControllerDelegate.swift b/Sources/STTextViewAppKit/STTextView+NSTextViewportLayoutControllerDelegate.swift index cfd07d93..07ac2562 100644 --- a/Sources/STTextViewAppKit/STTextView+NSTextViewportLayoutControllerDelegate.swift +++ b/Sources/STTextViewAppKit/STTextView+NSTextViewportLayoutControllerDelegate.swift @@ -7,7 +7,7 @@ import STTextKitPlus extension STTextView: NSTextViewportLayoutControllerDelegate { public func textViewportLayoutControllerWillLayout(_ textViewportLayoutController: NSTextViewportLayoutController) { - lastUsedFragmentViews = Set(fragmentViewMap.objectEnumerator()?.allObjects as? [STTextLayoutFragmentView] ?? []) + lastUsedFragments = Set(fragmentViewMap.keyEnumerator().allObjects as! [NSTextLayoutFragment]) if ProcessInfo().environment["ST_LAYOUT_DEBUG"] == "YES" { let viewportDebugView = NSView(frame: viewportBounds(for: textViewportLayoutController)) @@ -56,7 +56,7 @@ extension STTextView: NSTextViewportLayoutControllerDelegate { if let cachedFragmentView = fragmentViewMap.object(forKey: textLayoutFragment) { cachedFragmentView.layoutFragment = textLayoutFragment fragmentView = cachedFragmentView - lastUsedFragmentViews.remove(cachedFragmentView) + lastUsedFragments.remove(textLayoutFragment) } else { fragmentView = STTextLayoutFragmentView(layoutFragment: textLayoutFragment, frame: layoutFragmentFrame.pixelAligned) fragmentViewMap.setObject(fragmentView, forKey: textLayoutFragment) @@ -79,10 +79,13 @@ extension STTextView: NSTextViewportLayoutControllerDelegate { } public func textViewportLayoutControllerDidLayout(_ textViewportLayoutController: NSTextViewportLayoutController) { - for staleView in lastUsedFragmentViews { - staleView.removeFromSuperview() + for staleFragment in lastUsedFragments { + if let view = fragmentViewMap.object(forKey: staleFragment) { + view.removeFromSuperview() + } + fragmentViewMap.removeObject(forKey: staleFragment) } - lastUsedFragmentViews.removeAll() + lastUsedFragments.removeAll() updateContentSizeIfNeeded() diff --git a/Sources/STTextViewAppKit/STTextView.swift b/Sources/STTextViewAppKit/STTextView.swift index 1102cb96..e07bebc6 100644 --- a/Sources/STTextViewAppKit/STTextView.swift +++ b/Sources/STTextViewAppKit/STTextView.swift @@ -601,7 +601,7 @@ open class STTextView: NSView, NSTextInput, NSTextContent, STTextViewProtocol { let selectionView: STSelectionView var fragmentViewMap: NSMapTable - var lastUsedFragmentViews: Set = [] + var lastUsedFragments: Set = [] private var _usageBoundsForTextContainerObserver: NSKeyValueObservation? lazy var _speechSynthesizer = AVSpeechSynthesizer() diff --git a/Tests/STTextViewAppKitTests/GutterResizeTests.swift b/Tests/STTextViewAppKitTests/GutterResizeTests.swift new file mode 100644 index 00000000..805303cf --- /dev/null +++ b/Tests/STTextViewAppKitTests/GutterResizeTests.swift @@ -0,0 +1,282 @@ +#if os(macOS) +import XCTest +@testable import STTextViewAppKit + +/// Reproduction test for gutter row collapse during window resize. +/// +/// Creates an STTextView with a custom gutter data source, positions it inside +/// an NSScrollView in a real window, then programmatically resizes the window. +/// After each resize step, inspects the gutter line view Y positions to check +/// whether they remain distinct and increasing (correct) or all collapse to +/// the same value (the bug). +@MainActor +final class GutterResizeTests: XCTestCase { + + /// Simple data source that creates plain NSView labels for each gutter line. + /// Uses NSTextField (lightweight) rather than NSHostingView to isolate whether + /// the bug is in the gutter layout code or in NSHostingView specifically. + private final class SimpleGutterDataSource: STGutterLineViewDataSource { + func textView(_ textView: STTextView, viewForGutterLine lineNumber: Int, content: String) -> NSView { + let label = NSTextField(labelWithString: "\(lineNumber)") + label.font = .monospacedSystemFont(ofSize: 11, weight: .regular) + return label + } + + func textView(_ textView: STTextView, updateView existingView: NSView, forGutterLine lineNumber: Int, content: String) -> Bool { + guard let label = existingView as? NSTextField else { return false } + label.stringValue = "\(lineNumber)" + return true + } + } + + /// Same as above but using NSHostingView (matches real usage in lyrics app). + private final class HostingGutterDataSource: STGutterLineViewDataSource { + func textView(_ textView: STTextView, viewForGutterLine lineNumber: Int, content: String) -> NSView { + // Import SwiftUI only if available + let label = NSTextField(labelWithString: "H\(lineNumber)") + label.font = .monospacedSystemFont(ofSize: 11, weight: .regular) + return label + } + + func textView(_ textView: STTextView, updateView existingView: NSView, forGutterLine lineNumber: Int, content: String) -> Bool { + guard let label = existingView as? NSTextField else { return false } + label.stringValue = "H\(lineNumber)" + return true + } + } + + // MARK: - Helpers + + /// Creates a text view with custom gutter inside a real window. + /// Returns (window, scrollView, textView, dataSource). + private func makeGutteredTextView( + lineCount: Int = 20, + gutterWidth: CGFloat = 64 + ) -> (NSWindow, NSScrollView, STTextView, SimpleGutterDataSource) { + let scrollView = STTextView.scrollableTextView() + let textView = scrollView.documentView as! STTextView + scrollView.automaticallyAdjustsContentInsets = false + + // Insert multi-line content + let lines = (1...lineCount).map { "Line \($0) — some text content here" } + let text = lines.joined(separator: "\n") + let attrs: [NSAttributedString.Key: Any] = [ + .font: NSFont.monospacedSystemFont(ofSize: 13, weight: .regular), + .foregroundColor: NSColor.textColor + ] + textView.attributedText = NSAttributedString(string: text, attributes: attrs) + + // Configure custom gutter + let dataSource = SimpleGutterDataSource() + textView.customGutterWidth = gutterWidth + textView.gutterLineViewDataSource = dataSource + + // Place in a real window so AppKit layout works + let window = NSWindow( + contentRect: NSRect(x: 100, y: 100, width: 600, height: 400), + styleMask: [.titled, .resizable, .closable], + backing: .buffered, + defer: false + ) + window.contentView = scrollView + window.makeKeyAndOrderFront(nil) + + // Force initial layout + textView.needsLayout = true + textView.layoutSubtreeIfNeeded() + + return (window, scrollView, textView, dataSource) + } + + /// Returns Y positions of all custom gutter line views in the container. + private func gutterLineViewYPositions(in textView: STTextView) -> [CGFloat] { + guard let container = textView.customGutterContainerView else { return [] } + return container.subviews + .filter { ($0.identifier?.rawValue ?? "").hasPrefix("stgutter-line-") } + .sorted { $0.frame.origin.y < $1.frame.origin.y } + .map { $0.frame.origin.y } + } + + /// Returns identifiers and frames of all custom gutter line views. + private func gutterLineViewInfo(in textView: STTextView) -> [(id: String, frame: NSRect)] { + guard let container = textView.customGutterContainerView else { return [] } + return container.subviews + .compactMap { view -> (String, NSRect)? in + guard let id = view.identifier?.rawValue, id.hasPrefix("stgutter-line-") else { return nil } + return (id, view.frame) + } + .sorted { $0.0 < $1.0 } + } + + // MARK: - Tests + + /// Verifies that gutter line views have distinct, increasing Y positions + /// after initial layout. + func testInitialGutterPositionsAreDistinct() throws { + let (window, _, textView, _) = makeGutteredTextView() + defer { window.close() } + + let positions = gutterLineViewYPositions(in: textView) + XCTAssertGreaterThan(positions.count, 1, "Should have multiple gutter line views") + + // Verify all Y positions are distinct + let unique = Set(positions) + XCTAssertEqual(positions.count, unique.count, + "All gutter line views should have distinct Y positions. Got: \(positions)") + + // Verify Y positions are strictly increasing + for i in 1.. Y[\(i-1)] (\(positions[i-1]))") + } + + // End live resize + textView.viewDidEndLiveResize() + + // Force layout again after end + textView.needsLayout = true + textView.layoutSubtreeIfNeeded() + + // Check positions again after live resize ends + let postPositions = gutterLineViewYPositions(in: textView) + let postUnique = Set(postPositions) + XCTAssertEqual(postPositions.count, postUnique.count, + "Step \(index) post-resize: Gutter positions should still be distinct.\n" + + "Positions: \(postPositions)") + } + } + + /// Tests rapid sequential resizes without waiting (simulates dragging window edge). + func testRapidResizeSequence() throws { + let (window, _, textView, _) = makeGutteredTextView() + defer { window.close() } + + textView.viewWillStartLiveResize() + + // Simulate rapid resize: 20 steps from 400 to 800 width + for step in 0..<20 { + let width = 400.0 + Double(step) * 20.0 + window.setContentSize(NSSize(width: width, height: 500)) + textView.needsLayout = true + textView.layoutSubtreeIfNeeded() + + let positions = gutterLineViewYPositions(in: textView) + if positions.count > 1 { + let uniqueY = Set(positions) + XCTAssertEqual(positions.count, uniqueY.count, + "Rapid step \(step) (width: \(width)): Positions collapsed! \(positions)") + } + } + + textView.viewDidEndLiveResize() + textView.needsLayout = true + textView.layoutSubtreeIfNeeded() + + let finalPositions = gutterLineViewYPositions(in: textView) + let finalUnique = Set(finalPositions) + XCTAssertGreaterThan(finalPositions.count, 1, "Should have gutter views after resize") + XCTAssertEqual(finalPositions.count, finalUnique.count, + "Final positions should be distinct: \(finalPositions)") + } + + /// Diagnostic test: prints detailed gutter state during resize for analysis. + /// Not a pass/fail test — run with `-v` flag and inspect output. + func testDiagnosticResizeLog() throws { + let (window, scrollView, textView, _) = makeGutteredTextView(lineCount: 10) + defer { window.close() } + + print("=== INITIAL STATE ===") + print("Window: \(window.frame)") + print("ScrollView: \(scrollView.frame)") + print("TextViewFrame: \(textView.frame)") + print("ContentViewFrame: \(textView.contentView.frame)") + print("Container: \(textView.customGutterContainerView?.frame ?? .zero)") + print("InLiveResize: \(textView.inLiveResize)") + let info = gutterLineViewInfo(in: textView) + for item in info { + print(" \(item.id): frame=\(NSStringFromRect(item.frame))") + } + + // Fragment view positions + let viewportRange = textView.textLayoutManager.textViewportLayoutController.viewportRange + print("ViewportRange: \(viewportRange?.description ?? "nil")") + + // Simulate resize + print("\n=== RESIZE TO 800x400 ===") + textView.viewWillStartLiveResize() + window.setContentSize(NSSize(width: 800, height: 400)) + textView.needsLayout = true + textView.layoutSubtreeIfNeeded() + + print("TextViewFrame: \(textView.frame)") + print("Container: \(textView.customGutterContainerView?.frame ?? .zero)") + print("InLiveResize: \(textView.inLiveResize)") + let resizedInfo = gutterLineViewInfo(in: textView) + for item in resizedInfo { + print(" \(item.id): frame=\(NSStringFromRect(item.frame))") + } + + textView.viewDidEndLiveResize() + textView.needsLayout = true + textView.layoutSubtreeIfNeeded() + + print("\n=== AFTER END LIVE RESIZE ===") + print("TextViewFrame: \(textView.frame)") + let postInfo = gutterLineViewInfo(in: textView) + for item in postInfo { + print(" \(item.id): frame=\(NSStringFromRect(item.frame))") + } + } +} +#endif