Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
5b3c52d
Add support for custom gutter views in STTextView
romanr Mar 5, 2026
098221d
Refactor gutter SwiftUI API into separate TextViewWithGutter type
romanr Mar 5, 2026
50f04dc
Add custom gutter demo to SwiftUI example app
romanr Mar 5, 2026
bca7575
Fix custom gutter: clipping, interactions, and view lifecycle
romanr Mar 5, 2026
93966b6
Fix Xcode preview thunking conflict with @MainActor closures
romanr Mar 5, 2026
f4341a0
Update gutter demo to match Figma design
romanr Mar 5, 2026
6794499
Fix custom gutter vertical alignment and separator z-order
romanr Mar 5, 2026
6e66c42
Fix bookmark icon shifting when word count is absent
romanr Mar 5, 2026
d768f30
Refine breakpoint badge: 28×15 shape, stable layout, keep bookmark
romanr Mar 5, 2026
f83ddb7
Remove breakpoint badge overhang — fit within gutter bounds
romanr Mar 5, 2026
ba18a69
Restore breakpoint badge position at separator edge
romanr Mar 5, 2026
c6739ef
Restore overhanging breakpoint badge, fix clipping on NSHostingView
romanr Mar 5, 2026
6eb23e7
Fix custom gutter offset when scroll view has content insets
romanr Mar 5, 2026
cfca336
Fix custom gutter top-alignment for wrapped paragraphs
romanr Mar 5, 2026
8a532cb
Consume mouse events in custom gutter container to prevent click-through
romanr Mar 6, 2026
6070366
Refactor gutter line view handling to use a data source protocol for …
romanr Mar 6, 2026
c172e7c
Address PR review: fix text container sizing, scope gutter modifiers,…
romanr Mar 6, 2026
8b4f6b4
Revert gutter modifier scoping — keep on TextViewModifier for chaining
romanr Mar 6, 2026
bb8300b
Add custom gutter tests and fix stale doc reference
romanr Mar 6, 2026
cc0a994
feat: Add overscrollFraction to STTextView and SwiftUI wrapper
romanr Mar 7, 2026
f33d7e5
fix: Use full fragment height for custom gutter line views
romanr Mar 7, 2026
89c3f25
fix: Adjust gutter line view height to align with text baseline
romanr Mar 8, 2026
a579d0e
feat: Add scroll restoration and offset change reporting to TextViewM…
romanr Mar 8, 2026
c53f53a
fix: Clip custom gutter views to scroll view bounds to prevent render…
romanr Mar 8, 2026
8084d15
Fixes for custom gutter.
romanr Mar 11, 2026
c1553db
feat: Add topContentInset property to STTextView for improved line sp…
romanr Mar 22, 2026
1c55995
fix: unnecessary gutter relayout on keystroke and mid-resize layout pass
romanr Mar 22, 2026
ab4e7cc
fix: Remove stale layout fragments from fragmentViewMap in didLayout
romanr Mar 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Sources/STTextViewAppKit/Gutter/STGutterView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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.
///
Expand Down
47 changes: 47 additions & 0 deletions Sources/STTextViewAppKit/STGutterLineViewDataSource.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
// 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

/// 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
}
}
336 changes: 330 additions & 6 deletions Sources/STTextViewAppKit/STTextView+Gutter.swift

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down Expand Up @@ -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)
Expand All @@ -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()

Expand Down
113 changes: 106 additions & 7 deletions Sources/STTextViewAppKit/STTextView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -378,6 +378,93 @@ 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
/// (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 {
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why overscroll is part of this PR at all?

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``.
///
/// 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 {
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is STGutterView domain

didSet {
if customGutterWidth <= 0 {
customGutterContainerView?.removeFromSuperview()
customGutterContainerView = nil
}
needsLayout = true
}
}

/// A data source that provides custom views for each visible line in the gutter area.
///
/// 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 gutterLineViewDataSource == 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`
Expand Down Expand Up @@ -514,7 +601,7 @@ open class STTextView: NSView, NSTextInput, NSTextContent, STTextViewProtocol {
let selectionView: STSelectionView

var fragmentViewMap: NSMapTable<NSTextLayoutFragment, STTextLayoutFragmentView>
var lastUsedFragmentViews: Set<STTextLayoutFragmentView> = []
var lastUsedFragments: Set<NSTextLayoutFragment> = []
Comment on lines -517 to +604
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What is such change needed for?

private var _usageBoundsForTextContainerObserver: NSKeyValueObservation?

lazy var _speechSynthesizer = AVSpeechSynthesizer()
Expand Down Expand Up @@ -967,7 +1054,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,
Expand Down Expand Up @@ -1393,7 +1480,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

Expand Down Expand Up @@ -1456,7 +1543,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

Expand All @@ -1478,8 +1565,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)
Comment thread
romanr marked this conversation as resolved.
Expand All @@ -1491,7 +1580,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
Expand Down Expand Up @@ -1522,6 +1611,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
Expand Down
Loading