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..56e4be16 --- /dev/null +++ b/Sources/STTextViewAppKit/STGutterLineViewDataSource.swift @@ -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 + } +} diff --git a/Sources/STTextViewAppKit/STTextView+Gutter.swift b/Sources/STTextViewAppKit/STTextView+Gutter.swift index 89f556ea..adcf0d3c 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,326 @@ extension STTextView { gutterView.layoutMarkers() } + + // 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 ``gutterLineViewDataSource``. + /// Creates the container view lazily as a floating subview, then enumerates + /// visible lines to create and position one NSView per paragraph. + /// + /// Views are recreated on each layout pass to pick up fresh state. + private func layoutCustomGutterLineViews() { + guard let dataSource = gutterLineViewDataSource, 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() + 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. + enclosingScrollView.clipsToBounds = true + enclosingScrollView.addFloatingSubview(container, for: .horizontal) + } else { + addSubview(container) + } + customGutterContainerView = container + } + + guard let container = customGutterContainerView else { return } + + // 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 = max(contentView.bounds.height, viewportHeight) + container.layer?.backgroundColor = customGutterBackgroundColor?.cgColor + + // 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 lineID = Self.gutterLineViewID(for: 1) + visibleIDs.insert(lineID) + + 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) + ).pixelAligned + } + pruneStaleLineViews(in: container, keeping: visibleIDs) + addCustomGutterSeparator(to: container) + 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 + let lineID = Self.gutterLineViewID(for: lineNumber) + visibleIDs.insert(lineID) + + // 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 = lineViewForID(lineID, in: container, dataSource: dataSource, lineNumber: lineNumber, lineContent: lineContent) + + // 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] + lineHeight = prevLineFragment.typographicBounds.height + } else { + lineHeight = typingLineHeight + } + lineY = fragmentView.frame.origin.y + } else { + // 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 + } + + lineView.frame = CGRect( + origin: CGPoint(x: 0, y: lineY), + size: CGSize(width: customGutterWidth, height: lineHeight) + ).pixelAligned + + 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) + } + + // 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. + /// + /// 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, + dataSource: any STGutterLineViewDataSource, + lineNumber: Int, + lineContent: String + ) -> NSView { + // 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() + } + + let lineView = dataSource.textView(self, viewForGutterLine: lineNumber, content: 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) + + 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 + } + + let separator = NSView(frame: CGRect( + x: customGutterWidth - customGutterSeparatorWidth, + y: 0, + width: customGutterSeparatorWidth, + height: container.bounds.height + )) + separator.identifier = Self.gutterSeparatorID + separator.wantsLayer = true + separator.layer?.backgroundColor = separatorColor.cgColor + // 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) + } +} + +// MARK: - Custom Gutter Container + +/// 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 { + true + } + + override var isOpaque: Bool { + false + } + + override func animation(forKey key: NSAnimatablePropertyKey) -> Any? { + 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() + + // 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 + // clipsToBounds left as default (false) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } } 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 717bd850..e07bebc6 100644 --- a/Sources/STTextViewAppKit/STTextView.swift +++ b/Sources/STTextViewAppKit/STTextView.swift @@ -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 { + 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 - var lastUsedFragmentViews: Set = [] + var lastUsedFragments: Set = [] 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) @@ -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 diff --git a/Sources/STTextViewSwiftUIAppKit/TextView.swift b/Sources/STTextViewSwiftUIAppKit/TextView.swift index 8d8236dc..39283e72 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,12 +47,302 @@ public struct TextView: SwiftUI.View, TextViewModifier { text: $text, selection: $selection, options: options, - plugins: plugins + plugins: plugins, + textViewType: textViewType ) .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 + @Environment(\.gutterShadow) private var envGutterShadow + + @Binding private var text: AttributedString + @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 + /// 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. + /// + /// - Parameters: + /// - text: The attributed string content + /// - 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) + /// - 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, + selection: Binding = .constant(nil), + options: Options = [], + plugins: [any STPlugin] = [], + textViewType: STTextView.Type = STTextView.self, + gutterWidth: CGFloat, + gutterDataID: AnyHashable? = nil, + @ViewBuilder gutterContent: @escaping (_ lineNumber: Int, _ lineContent: String) -> GutterContent + ) { + _text = text + _selection = selection + self.options = options + self.plugins = plugins + self.textViewType = textViewType + self.gutterWidth = gutterWidth + self.gutterDataID = gutterDataID + self.gutterLineViewFactory = { lineNumber, lineContent in + NSHostingView(rootView: AnyView(gutterContent(lineNumber, lineContent))) + } + self.gutterViewUpdater = { lineNumber, lineContent in + AnyView(gutterContent(lineNumber, lineContent)) + } + } + + public var body: some View { + TextViewRepresentable( + text: $text, + selection: $selection, + options: options, + plugins: plugins, + textViewType: textViewType, + gutterWidth: gutterWidth, + gutterDataID: gutterDataID, + gutterLineViewFactory: gutterLineViewFactory, + gutterViewUpdater: gutterViewUpdater, + gutterBackgroundColor: envGutterBackgroundColor, + gutterSeparatorColor: envGutterSeparatorColor, + gutterSeparatorWidth: envGutterSeparatorWidth, + gutterShadow: envGutterShadow + ) + .background(.background) + } +} + +// 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. +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 +} + +/// Environment key for custom gutter shadow. +private struct GutterShadowKey: EnvironmentKey { + static let defaultValue: NSShadow? = nil +} + +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 } + } + + var gutterShadow: NSShadow? { + get { self[GutterShadowKey.self] } + set { self[GutterShadowKey.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 + ) + } + + /// 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 + +/// 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. +/// 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, 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 + private struct TextViewRepresentable: NSViewRepresentable { @Environment(\.isEnabled) private var isEnabled @@ -60,6 +354,12 @@ private struct TextViewRepresentable: NSViewRepresentable { private var lineHeightMultiple @Environment(\.autocorrectionDisabled) private var autocorrectionDisabled + @Environment(\.overscrollFraction) + private var overscrollFraction + @Environment(\.scrollRestorationOffset) + private var scrollRestorationOffset + @Environment(\.scrollOffsetChangeHandler) + private var scrollOffsetChangeHandler @Binding private var text: AttributedString @@ -67,20 +367,51 @@ private struct TextViewRepresentable: NSViewRepresentable { private var selection: NSRange? private let options: TextView.Options private var plugins: [any STPlugin] - - init(text: Binding, selection: Binding, options: TextView.Options, 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, 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 + self.gutterShadow = gutterShadow } 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 + // 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) 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() @@ -106,6 +437,18 @@ private struct TextViewRepresentable: NSViewRepresentable { textView.gutterView?.textColor = .secondaryLabelColor } + // Configure custom gutter if provided + if gutterWidth > 0, let factory = gutterLineViewFactory { + textView.customGutterWidth = gutterWidth + let adapter = GutterLineViewDataSourceAdapter(factory: factory, viewUpdater: gutterViewUpdater) + context.coordinator.gutterDataSourceAdapter = adapter + context.coordinator.lastGutterDataID = gutterDataID + textView.gutterLineViewDataSource = adapter + textView.customGutterBackgroundColor = gutterBackgroundColor + textView.customGutterSeparatorColor = gutterSeparatorColor + textView.customGutterSeparatorWidth = gutterSeparatorWidth + } + context.coordinator.isUpdating = true textView.attributedText = NSAttributedString(styledAttributedString(textView.typingAttributes)) context.coordinator.isUpdating = false @@ -119,9 +462,26 @@ 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 } - + 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 @@ -164,6 +524,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) { @@ -172,6 +536,68 @@ private struct TextViewRepresentable: NSViewRepresentable { } } + // 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 + 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, viewUpdater: gutterViewUpdater) + context.coordinator.gutterDataSourceAdapter = adapter + context.coordinator.lastGutterDataID = gutterDataID + textView.gutterLineViewDataSource = adapter + } + 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 + } + + // 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 + + // 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 } @@ -202,12 +628,29 @@ 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? + /// 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. + 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 @@ -226,4 +669,3 @@ private struct TextViewRepresentable: NSViewRepresentable { } } - 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 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 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 diff --git a/TextEdit.SwiftUI/ContentView.swift b/TextEdit.SwiftUI/ContentView.swift index 6429e85b..c2904a50 100644 --- a/TextEdit.SwiftUI/ContentView.swift +++ b/TextEdit.SwiftUI/ContentView.swift @@ -20,25 +20,31 @@ 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 = [3] + + /// Tracks which lines have breakpoints active (by 1-based line number). + @State private var breakpointLines: Set = [4] 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,42 @@ 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 + ) + .overscrollFraction(0.5) + .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, + bookmarkedLines: $bookmarkedLines, + breakpointLines: $breakpointLines + ) + } + ) + .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) + } + private func loadContent() { let string = try! String(contentsOf: Bundle.main.url(forResource: "content", withExtension: "txt")!) self.text = AttributedString( @@ -71,6 +116,127 @@ 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. +/// +/// Uses `@Binding` to parent state sets instead of action closures — this +/// avoids `@MainActor` closure type conflicts with Xcode preview thunking. +/// +/// 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 + @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 { + 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: 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 { + breakpointBadge + } else { + wordCountLabel + } + } + .padding(.trailing, 4) + .padding(.leading, 2) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .trailing) + } + + /// 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 { + 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) + } + } + } + + /// Breakpoint badge using the Union shape from the Figma design. + /// Text uses same font/frame as wordCountLabel so the number doesn't jump. + /// 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)) + .foregroundStyle(.white) + .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 separator + } + .onTapGesture { + breakpointLines.remove(lineNumber) + } + } +} + +// 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 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.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 1b4fe386..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 = ( @@ -179,11 +182,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 +217,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; @@ -378,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 */