-
Notifications
You must be signed in to change notification settings - Fork 102
Customizable Gutter #105
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
romanr
wants to merge
28
commits into
krzyzanowskim:main
Choose a base branch
from
romanr:gutter-mutter
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Customizable Gutter #105
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 098221d
Refactor gutter SwiftUI API into separate TextViewWithGutter type
romanr 50f04dc
Add custom gutter demo to SwiftUI example app
romanr bca7575
Fix custom gutter: clipping, interactions, and view lifecycle
romanr 93966b6
Fix Xcode preview thunking conflict with @MainActor closures
romanr f4341a0
Update gutter demo to match Figma design
romanr 6794499
Fix custom gutter vertical alignment and separator z-order
romanr 6e66c42
Fix bookmark icon shifting when word count is absent
romanr d768f30
Refine breakpoint badge: 28×15 shape, stable layout, keep bookmark
romanr f83ddb7
Remove breakpoint badge overhang — fit within gutter bounds
romanr ba18a69
Restore breakpoint badge position at separator edge
romanr c6739ef
Restore overhanging breakpoint badge, fix clipping on NSHostingView
romanr 6eb23e7
Fix custom gutter offset when scroll view has content insets
romanr cfca336
Fix custom gutter top-alignment for wrapped paragraphs
romanr 8a532cb
Consume mouse events in custom gutter container to prevent click-through
romanr 6070366
Refactor gutter line view handling to use a data source protocol for …
romanr c172e7c
Address PR review: fix text container sizing, scope gutter modifiers,…
romanr 8b4f6b4
Revert gutter modifier scoping — keep on TextViewModifier for chaining
romanr bb8300b
Add custom gutter tests and fix stale doc reference
romanr cc0a994
feat: Add overscrollFraction to STTextView and SwiftUI wrapper
romanr f33d7e5
fix: Use full fragment height for custom gutter line views
romanr 89c3f25
fix: Adjust gutter line view height to align with text baseline
romanr a579d0e
feat: Add scroll restoration and offset change reporting to TextViewM…
romanr c53f53a
fix: Clip custom gutter views to scroll view bounds to prevent render…
romanr 8084d15
Fixes for custom gutter.
romanr c1553db
feat: Add topContentInset property to STTextView for improved line sp…
romanr 1c55995
fix: unnecessary gutter relayout on keystroke and mid-resize layout pass
romanr ab4e7cc
fix: Remove stale layout fragments from fragmentViewMap in didLayout
romanr File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
| } | ||
| } |
Large diffs are not rendered by default.
Oops, something went wrong.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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 { | ||
| 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 { | ||
|
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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` | ||
|
|
@@ -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
Owner
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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() | ||
|
|
@@ -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, | ||
|
|
@@ -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 | ||
|
|
||
|
|
@@ -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 | ||
|
|
||
|
|
@@ -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) | ||
|
romanr marked this conversation as resolved.
|
||
|
|
@@ -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 | ||
|
|
@@ -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 | ||
|
|
||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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?