From cb422bb781f514e7f960e7b86db17b5973d168be Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Fri, 11 Apr 2025 09:32:37 -0500 Subject: [PATCH 01/23] Render A Minimap --- .../xcshareddata/swiftpm/Package.resolved | 9 -- Package.swift | 5 +- .../Controller/EditorContainerView.swift | 41 ++++++ .../TextViewController+LoadView.swift | 27 ++-- .../Controller/TextViewController.swift | 13 +- .../TextView+/TextView+TextFormation.swift | 12 -- .../Highlighting/Highlighter.swift | 2 - .../Minimap/MinimapLineFragmentView.swift | 127 ++++++++++++++++++ .../Minimap/MinimapLineRenderer.swift | 55 ++++++++ .../Minimap/MinimapView.swift | 89 ++++++++++++ 10 files changed, 338 insertions(+), 42 deletions(-) create mode 100644 Sources/CodeEditSourceEditor/Controller/EditorContainerView.swift create mode 100644 Sources/CodeEditSourceEditor/Minimap/MinimapLineFragmentView.swift create mode 100644 Sources/CodeEditSourceEditor/Minimap/MinimapLineRenderer.swift create mode 100644 Sources/CodeEditSourceEditor/Minimap/MinimapView.swift diff --git a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 70b1e3e5a..a7d1aaa89 100644 --- a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -9,15 +9,6 @@ "version" : "0.1.20" } }, - { - "identity" : "codeedittextview", - "kind" : "remoteSourceControl", - "location" : "https://github.com/CodeEditApp/CodeEditTextView.git", - "state" : { - "revision" : "02202a8d925dc902f18626e953b3447e320253d1", - "version" : "0.8.1" - } - }, { "identity" : "rearrange", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index c0d9431e2..254897b23 100644 --- a/Package.swift +++ b/Package.swift @@ -16,8 +16,9 @@ let package = Package( dependencies: [ // A fast, efficient, text view for code. .package( - url: "https://github.com/CodeEditApp/CodeEditTextView.git", - from: "0.8.2" +// url: "https://github.com/CodeEditApp/CodeEditTextView.git", +// from: "0.8.2" + path: "../CodeEditTextView" ), // tree-sitter languages .package( diff --git a/Sources/CodeEditSourceEditor/Controller/EditorContainerView.swift b/Sources/CodeEditSourceEditor/Controller/EditorContainerView.swift new file mode 100644 index 000000000..927e8d1d8 --- /dev/null +++ b/Sources/CodeEditSourceEditor/Controller/EditorContainerView.swift @@ -0,0 +1,41 @@ +// +// EditorContainerView.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 4/10/25. +// + +import AppKit + +class EditorContainerView: NSView { + weak var scrollView: NSScrollView? + weak var minimapView: MinimapView? + + init(scrollView: NSScrollView, minimapView: MinimapView) { + self.scrollView = scrollView + self.minimapView = minimapView + + super.init(frame: .zero) + + self.translatesAutoresizingMaskIntoConstraints = false + + addSubview(scrollView) + addSubview(minimapView) + + NSLayoutConstraint.activate([ + scrollView.topAnchor.constraint(equalTo: topAnchor), + scrollView.bottomAnchor.constraint(equalTo: bottomAnchor), + scrollView.leadingAnchor.constraint(equalTo: leadingAnchor), + scrollView.trailingAnchor.constraint(equalTo: trailingAnchor), + + minimapView.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor), + minimapView.bottomAnchor.constraint(equalTo: bottomAnchor), + minimapView.trailingAnchor.constraint(equalTo: trailingAnchor), + minimapView.widthAnchor.constraint(equalToConstant: 150) + ]) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift index a4e2cf76d..3f33f5586 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift @@ -9,7 +9,6 @@ import CodeEditTextView import AppKit extension TextViewController { - // swiftlint:disable:next function_body_length override public func loadView() { super.loadView() @@ -29,7 +28,11 @@ extension TextViewController { for: .horizontal ) - let findViewController = FindViewController(target: self, childView: scrollView) + minimapView = MinimapView(textView: textView, theme: theme) + + editorContainer = EditorContainerView(scrollView: scrollView, minimapView: minimapView) + + let findViewController = FindViewController(target: self, childView: editorContainer) addChild(findViewController) self.findViewController = findViewController self.view.addSubview(findViewController.view) @@ -52,13 +55,24 @@ extension TextViewController { findViewController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), findViewController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), findViewController.view.topAnchor.constraint(equalTo: view.topAnchor), - findViewController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor) + findViewController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), ]) if !cursorPositions.isEmpty { setCursorPositions(cursorPositions) } + setUpListeners() + + textView.updateFrameIfNeeded() + + if let localEventMonitor = self.localEvenMonitor { + NSEvent.removeMonitor(localEventMonitor) + } + setUpKeyBindings(eventMonitor: &self.localEvenMonitor) + } + + func setUpListeners() { // Layout on scroll change NotificationCenter.default.addObserver( forName: NSView.boundsDidChangeNotification, @@ -98,8 +112,6 @@ extension TextViewController { self?.emphasizeSelectionPairs() } - textView.updateFrameIfNeeded() - NSApp.publisher(for: \.effectiveAppearance) .receive(on: RunLoop.main) .sink { [weak self] newValue in @@ -114,11 +126,6 @@ extension TextViewController { } } .store(in: &cancellables) - - if let localEventMonitor = self.localEvenMonitor { - NSEvent.removeMonitor(localEventMonitor) - } - setUpKeyBindings(eventMonitor: &self.localEvenMonitor) } func setUpKeyBindings(eventMonitor: inout Any?) { diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift index 9337ece4a..bcfa9d209 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift @@ -22,16 +22,14 @@ public class TextViewController: NSViewController { weak var findViewController: FindViewController? + // Container view for the editor contents (scrolling textview, gutter, and minimap) + // Is a child of the find container, so editor contents all move below the find panel when open. + var editorContainer: EditorContainerView! var scrollView: NSScrollView! - - // SEARCH - var stackview: NSStackView! - var searchField: NSTextField! - var prevButton: NSButton! - var nextButton: NSButton! - var textView: TextView! var gutterView: GutterView! + var minimapView: MinimapView! + internal var _undoManager: CEUndoManager! internal var systemAppearance: NSAppearance.Name? @@ -71,6 +69,7 @@ public class TextViewController: NSViewController { highlighter?.invalidate() gutterView.textColor = theme.text.color.withAlphaComponent(0.35) gutterView.selectedLineTextColor = theme.text.color + minimapView.theme = theme } } diff --git a/Sources/CodeEditSourceEditor/Extensions/TextView+/TextView+TextFormation.swift b/Sources/CodeEditSourceEditor/Extensions/TextView+/TextView+TextFormation.swift index 99e80effb..346410874 100644 --- a/Sources/CodeEditSourceEditor/Extensions/TextView+/TextView+TextFormation.swift +++ b/Sources/CodeEditSourceEditor/Extensions/TextView+/TextView+TextFormation.swift @@ -39,23 +39,11 @@ extension TextView: TextInterface { /// - Parameter mutation: The mutation to apply. public func applyMutation(_ mutation: TextMutation) { guard !mutation.isEmpty else { return } - - delegate?.textView(self, willReplaceContentsIn: mutation.range, with: mutation.string) - - layoutManager.beginTransaction() - textStorage.beginEditing() - - layoutManager.willReplaceCharactersInRange(range: mutation.range, with: mutation.string) _undoManager?.registerMutation(mutation) textStorage.replaceCharacters(in: mutation.range, with: mutation.string) selectionManager.didReplaceCharacters( in: mutation.range, replacementLength: (mutation.string as NSString).length ) - - textStorage.endEditing() - layoutManager.endTransaction() - - delegate?.textView(self, didReplaceContentsIn: mutation.range, with: mutation.string) } } diff --git a/Sources/CodeEditSourceEditor/Highlighting/Highlighter.swift b/Sources/CodeEditSourceEditor/Highlighting/Highlighter.swift index d10610a12..0ef843093 100644 --- a/Sources/CodeEditSourceEditor/Highlighting/Highlighter.swift +++ b/Sources/CodeEditSourceEditor/Highlighting/Highlighter.swift @@ -266,7 +266,6 @@ extension Highlighter: NSTextStorageDelegate { extension Highlighter: StyledRangeContainerDelegate { func styleContainerDidUpdate(in range: NSRange) { guard let textView, let attributeProvider else { return } - textView.layoutManager.beginTransaction() textView.textStorage.beginEditing() let storage = textView.textStorage @@ -281,7 +280,6 @@ extension Highlighter: StyledRangeContainerDelegate { } textView.textStorage.endEditing() - textView.layoutManager.endTransaction() textView.layoutManager.invalidateLayoutForRange(range) } } diff --git a/Sources/CodeEditSourceEditor/Minimap/MinimapLineFragmentView.swift b/Sources/CodeEditSourceEditor/Minimap/MinimapLineFragmentView.swift new file mode 100644 index 000000000..675f63e71 --- /dev/null +++ b/Sources/CodeEditSourceEditor/Minimap/MinimapLineFragmentView.swift @@ -0,0 +1,127 @@ +// +// MinimapLineFragmentView.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 4/10/25. +// + +import AppKit +import CodeEditTextView + +/// A custom line fragment view for the minimap. +/// +/// Instead of drawing line contents, this view calculates a series of boxes or 'runs' to draw to represent the text +/// in the line fragment. +/// +/// Runs are calculated when the view's fragment is set, and cached until invalidated, and all whitespace +/// characters are ignored. +final class MinimapLineFragmentView: LineFragmentView { + /// A run represents a position, length, and color that we can draw. + /// ``MinimapLineFragmentView`` class will calculate cache these when a new line fragment is set. + struct Run { + let color: NSColor + let range: NSRange + } + + private weak var textStorage: NSTextStorage? + private var drawingRuns: [Run] = [] + + init(textStorage: NSTextStorage?) { + self.textStorage = textStorage + super.init(frame: .zero) + } + + @MainActor required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + /// Prepare the view for reuse, clearing cached drawing runs. + override func prepareForReuse() { + super.prepareForReuse() + drawingRuns.removeAll() + } + + /// Set the new line fragment, and calculate drawing runs for drawing the fragment in the view. + /// - Parameter newFragment: The new fragment to use. + override func setLineFragment(_ newFragment: LineFragment) { + super.setLineFragment(newFragment) + guard let textStorage else { return } + + // Create the drawing runs using attribute information + var position = newFragment.documentRange.location + + while position < newFragment.documentRange.max { + var longestRange: NSRange = .notFound + defer { position = longestRange.max } + + guard let foregroundColor = textStorage.attribute( + .foregroundColor, + at: position, + longestEffectiveRange: &longestRange, + in: NSRange(start: position, end: newFragment.documentRange.max) + ) as? NSColor else { + continue + } + + // Now that we have the foreground color for drawing, filter our runs to only include non-whitespace + // characters + var range: NSRange = .notFound + for idx in longestRange.location.. LineFragmentView { + MinimapLineFragmentView(textStorage: textView?.textStorage) + } +} diff --git a/Sources/CodeEditSourceEditor/Minimap/MinimapView.swift b/Sources/CodeEditSourceEditor/Minimap/MinimapView.swift new file mode 100644 index 000000000..1159f4cf0 --- /dev/null +++ b/Sources/CodeEditSourceEditor/Minimap/MinimapView.swift @@ -0,0 +1,89 @@ +// +// MinimapView.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 4/10/25. +// + +import AppKit +import CodeEditTextView + +class MinimapView: NSView { + weak var textView: TextView? + var layoutManager: TextLayoutManager? + let lineRenderer: MinimapLineRenderer + + var theme: EditorTheme { + didSet { + layer?.backgroundColor = theme.background.cgColor + } + } + + override var isFlipped: Bool { true } + + init(textView: TextView, theme: EditorTheme) { + self.textView = textView + self.theme = theme + self.lineRenderer = MinimapLineRenderer(textView: textView) + + super.init(frame: .zero) + + self.translatesAutoresizingMaskIntoConstraints = false + let layoutManager = TextLayoutManager( + textStorage: textView.textStorage, + lineHeightMultiplier: 1.0, + wrapLines: textView.wrapLines, + textView: self, + delegate: self, + renderDelegate: lineRenderer + ) + self.layoutManager = layoutManager + (textView.textStorage.delegate as? MultiStorageDelegate)?.addDelegate(layoutManager) + + wantsLayer = true + layer?.backgroundColor = theme.background.cgColor + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func layout() { + layoutManager?.layoutLines() + super.layout() + } + + override func draw(_ dirtyRect: NSRect) { + guard let context = NSGraphicsContext.current?.cgContext else { return } + context.saveGState() + + context.setFillColor(NSColor.separatorColor.cgColor) + context.fill([ + CGRect(x: 0, y: 0, width: 1, height: frame.height) + ]) + + context.restoreGState() + } +} + +extension MinimapView: TextLayoutManagerDelegate { + func layoutManagerHeightDidUpdate(newHeight: CGFloat) { + + } + + func layoutManagerMaxWidthDidChange(newWidth: CGFloat) { + + } + + func layoutManagerTypingAttributes() -> [NSAttributedString.Key: Any] { + textView?.layoutManagerTypingAttributes() ?? [:] + } + + func textViewportSize() -> CGSize { + self.frame.size + } + + func layoutManagerYAdjustment(_ yAdjustment: CGFloat) { + // TODO: Adjust things + } +} From 17035f6d1b70f895c75165296db24e1d13ae3936 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Fri, 11 Apr 2025 11:39:50 -0500 Subject: [PATCH 02/23] Correctly Wrap Lines, Render Line Breaks, Add to ScrollView --- .../Controller/EditorContainerView.swift | 21 +++++- .../TextViewController+StyleViews.swift | 6 ++ .../Minimap/MinimapLineFragmentView.swift | 4 +- .../Minimap/MinimapLineRenderer.swift | 7 +- ...inimapView+TextLayoutManagerDelegate.swift | 34 ++++++++++ .../Minimap/MinimapView.swift | 65 ++++++++++++------- .../Utils/FlippedNSView.swift | 12 ++++ 7 files changed, 118 insertions(+), 31 deletions(-) create mode 100644 Sources/CodeEditSourceEditor/Minimap/MinimapView+TextLayoutManagerDelegate.swift create mode 100644 Sources/CodeEditSourceEditor/Utils/FlippedNSView.swift diff --git a/Sources/CodeEditSourceEditor/Controller/EditorContainerView.swift b/Sources/CodeEditSourceEditor/Controller/EditorContainerView.swift index 927e8d1d8..e96476a22 100644 --- a/Sources/CodeEditSourceEditor/Controller/EditorContainerView.swift +++ b/Sources/CodeEditSourceEditor/Controller/EditorContainerView.swift @@ -22,16 +22,31 @@ class EditorContainerView: NSView { addSubview(scrollView) addSubview(minimapView) + scrollView.hasVerticalScroller = true + + let maxWidthConstraint = minimapView.widthAnchor.constraint(lessThanOrEqualToConstant: 150) + let relativeWidthConstraint = minimapView.widthAnchor.constraint( + equalTo: widthAnchor, + multiplier: 0.18 + ) + relativeWidthConstraint.priority = .defaultLow + + guard let scrollerAnchor = scrollView.verticalScroller?.leadingAnchor else { + assertionFailure("Scroll view failed to create a scroller.") + return + } + NSLayoutConstraint.activate([ scrollView.topAnchor.constraint(equalTo: topAnchor), scrollView.bottomAnchor.constraint(equalTo: bottomAnchor), scrollView.leadingAnchor.constraint(equalTo: leadingAnchor), scrollView.trailingAnchor.constraint(equalTo: trailingAnchor), - minimapView.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor), + minimapView.topAnchor.constraint(equalTo: topAnchor), minimapView.bottomAnchor.constraint(equalTo: bottomAnchor), - minimapView.trailingAnchor.constraint(equalTo: trailingAnchor), - minimapView.widthAnchor.constraint(equalToConstant: 150) + minimapView.trailingAnchor.constraint(equalTo: scrollerAnchor), + maxWidthConstraint, + relativeWidthConstraint ]) } diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift index 7fa819bbf..9579698e2 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift @@ -71,13 +71,19 @@ extension TextViewController { if let contentInsets { scrollView.automaticallyAdjustsContentInsets = false scrollView.contentInsets = contentInsets + minimapView.scrollView.contentInsets.top = contentInsets.top + minimapView.scrollView.contentInsets.top = contentInsets.bottom } else { scrollView.automaticallyAdjustsContentInsets = true + minimapView.scrollView.automaticallyAdjustsContentInsets = true } scrollView.contentInsets.top += additionalTextInsets?.top ?? 0 scrollView.contentInsets.bottom += additionalTextInsets?.bottom ?? 0 scrollView.contentInsets.top += (findViewController?.isShowingFindPanel ?? false) ? FindPanel.height : 0 + minimapView.scrollView.contentInsets.top += ( + findViewController?.isShowingFindPanel ?? false + ) ? FindPanel.height : 0 } } diff --git a/Sources/CodeEditSourceEditor/Minimap/MinimapLineFragmentView.swift b/Sources/CodeEditSourceEditor/Minimap/MinimapLineFragmentView.swift index 675f63e71..94b084cb1 100644 --- a/Sources/CodeEditSourceEditor/Minimap/MinimapLineFragmentView.swift +++ b/Sources/CodeEditSourceEditor/Minimap/MinimapLineFragmentView.swift @@ -113,9 +113,9 @@ final class MinimapLineFragmentView: LineFragmentView { context.saveGState() for run in drawingRuns { let rect = CGRect( - x: 8 + (CGFloat(run.range.location) * 2), + x: 8 + (CGFloat(run.range.location) * 1.5), y: 0, - width: CGFloat(run.range.length) * 2, + width: CGFloat(run.range.length) * 1.5, height: 2.0 ) context.setFillColor(run.color.cgColor) diff --git a/Sources/CodeEditSourceEditor/Minimap/MinimapLineRenderer.swift b/Sources/CodeEditSourceEditor/Minimap/MinimapLineRenderer.swift index 560b016b3..799d943c3 100644 --- a/Sources/CodeEditSourceEditor/Minimap/MinimapLineRenderer.swift +++ b/Sources/CodeEditSourceEditor/Minimap/MinimapLineRenderer.swift @@ -24,7 +24,7 @@ final class MinimapLineRenderer: TextLayoutManagerRenderDelegate { breakStrategy: LineBreakStrategy ) { let maxWidth: CGFloat = if let textView, textView.wrapLines { - textView.frame.width + textView.layoutManager.maxLineLayoutWidth } else { .infinity } @@ -39,10 +39,11 @@ final class MinimapLineRenderer: TextLayoutManagerRenderDelegate { // Make all fragments 2px tall textLine.lineFragments.forEach { fragmentPosition in + let remainingHeight = fragmentPosition.height - 3.0 textLine.lineFragments.update( - atIndex: fragmentPosition.index, + atOffset: fragmentPosition.range.location, delta: 0, - deltaHeight: -(fragmentPosition.height - 3.0) + deltaHeight: -remainingHeight ) fragmentPosition.data.height = 2.0 fragmentPosition.data.scaledHeight = 3.0 diff --git a/Sources/CodeEditSourceEditor/Minimap/MinimapView+TextLayoutManagerDelegate.swift b/Sources/CodeEditSourceEditor/Minimap/MinimapView+TextLayoutManagerDelegate.swift new file mode 100644 index 000000000..116f0b921 --- /dev/null +++ b/Sources/CodeEditSourceEditor/Minimap/MinimapView+TextLayoutManagerDelegate.swift @@ -0,0 +1,34 @@ +// +// MinimapView+TextLayoutManagerDelegate.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 4/11/25. +// + +import AppKit +import CodeEditTextView + +extension MinimapView: TextLayoutManagerDelegate { + func layoutManagerHeightDidUpdate(newHeight: CGFloat) { + contentView.frame.size.height = newHeight + } + + func layoutManagerMaxWidthDidChange(newWidth: CGFloat) { } + + func layoutManagerTypingAttributes() -> [NSAttributedString.Key: Any] { + textView?.layoutManagerTypingAttributes() ?? [:] + } + + func textViewportSize() -> CGSize { + var size = scrollView.contentSize + size.height -= scrollView.contentInsets.top + scrollView.contentInsets.bottom + size.width = textView?.layoutManager.maxLineLayoutWidth ?? size.width + return size + } + + func layoutManagerYAdjustment(_ yAdjustment: CGFloat) { + var point = scrollView.documentVisibleRect.origin + point.y += yAdjustment + scrollView.documentView?.scroll(point) + } +} diff --git a/Sources/CodeEditSourceEditor/Minimap/MinimapView.swift b/Sources/CodeEditSourceEditor/Minimap/MinimapView.swift index 1159f4cf0..adb8386ba 100644 --- a/Sources/CodeEditSourceEditor/Minimap/MinimapView.swift +++ b/Sources/CodeEditSourceEditor/Minimap/MinimapView.swift @@ -10,6 +10,9 @@ import CodeEditTextView class MinimapView: NSView { weak var textView: TextView? + + let scrollView: NSScrollView + let contentView: FlippedNSView var layoutManager: TextLayoutManager? let lineRenderer: MinimapLineRenderer @@ -19,21 +22,32 @@ class MinimapView: NSView { } } - override var isFlipped: Bool { true } - init(textView: TextView, theme: EditorTheme) { self.textView = textView self.theme = theme self.lineRenderer = MinimapLineRenderer(textView: textView) + self.scrollView = NSScrollView() + scrollView.translatesAutoresizingMaskIntoConstraints = false + scrollView.hasVerticalScroller = false + scrollView.hasHorizontalScroller = false + scrollView.drawsBackground = false + scrollView.verticalScrollElasticity = .none + + self.contentView = FlippedNSView(frame: .zero) + contentView.translatesAutoresizingMaskIntoConstraints = false + super.init(frame: .zero) + addSubview(scrollView) + scrollView.documentView = contentView + self.translatesAutoresizingMaskIntoConstraints = false let layoutManager = TextLayoutManager( textStorage: textView.textStorage, lineHeightMultiplier: 1.0, wrapLines: textView.wrapLines, - textView: self, + textView: contentView, delegate: self, renderDelegate: lineRenderer ) @@ -42,12 +56,31 @@ class MinimapView: NSView { wantsLayer = true layer?.backgroundColor = theme.background.cgColor + + setUpConstraints() + } + + private func setUpConstraints() { + NSLayoutConstraint.activate([ + scrollView.topAnchor.constraint(equalTo: topAnchor), + scrollView.bottomAnchor.constraint(equalTo: bottomAnchor), + scrollView.leadingAnchor.constraint(equalTo: leadingAnchor), + scrollView.trailingAnchor.constraint(equalTo: trailingAnchor), + + contentView.widthAnchor.constraint(equalTo: widthAnchor) + ]) } required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } + override public var visibleRect: NSRect { + var rect = scrollView.documentVisibleRect + rect.origin.y += scrollView.contentInsets.top + return rect.pixelAligned + } + override func layout() { layoutManager?.layoutLines() super.layout() @@ -64,26 +97,12 @@ class MinimapView: NSView { context.restoreGState() } -} - -extension MinimapView: TextLayoutManagerDelegate { - func layoutManagerHeightDidUpdate(newHeight: CGFloat) { - - } - - func layoutManagerMaxWidthDidChange(newWidth: CGFloat) { - - } - - func layoutManagerTypingAttributes() -> [NSAttributedString.Key: Any] { - textView?.layoutManagerTypingAttributes() ?? [:] - } - func textViewportSize() -> CGSize { - self.frame.size - } - - func layoutManagerYAdjustment(_ yAdjustment: CGFloat) { - // TODO: Adjust things + override func hitTest(_ point: NSPoint) -> NSView? { + if visibleRect.contains(point) { + return self + } else { + return super.hitTest(point) + } } } diff --git a/Sources/CodeEditSourceEditor/Utils/FlippedNSView.swift b/Sources/CodeEditSourceEditor/Utils/FlippedNSView.swift new file mode 100644 index 000000000..cfe6b0fd8 --- /dev/null +++ b/Sources/CodeEditSourceEditor/Utils/FlippedNSView.swift @@ -0,0 +1,12 @@ +// +// FlippedNSView.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 4/11/25. +// + +import AppKit + +class FlippedNSView: NSView { + override var isFlipped: Bool { true } +} From a8928edc9c31d7460874ac03cb55280f419f44ec Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 14 Apr 2025 09:43:26 -0500 Subject: [PATCH 03/23] Start on Scrolling Offset --- Package.resolved | 9 --- .../Minimap/MinimapContentView.swift | 22 ++++++ .../Minimap/MinimapLineRenderer.swift | 16 ++-- .../MinimapView+DocumentVisibleView.swift | 32 ++++++++ .../Minimap/MinimapView.swift | 78 +++++++++++++++---- 5 files changed, 128 insertions(+), 29 deletions(-) create mode 100644 Sources/CodeEditSourceEditor/Minimap/MinimapContentView.swift create mode 100644 Sources/CodeEditSourceEditor/Minimap/MinimapView+DocumentVisibleView.swift diff --git a/Package.resolved b/Package.resolved index 6446cba69..149ed0791 100644 --- a/Package.resolved +++ b/Package.resolved @@ -9,15 +9,6 @@ "version" : "0.1.20" } }, - { - "identity" : "codeedittextview", - "kind" : "remoteSourceControl", - "location" : "https://github.com/CodeEditApp/CodeEditTextView.git", - "state" : { - "revision" : "47faec9fb571c9c695897e69f0a4f08512ae682e", - "version" : "0.8.2" - } - }, { "identity" : "rearrange", "kind" : "remoteSourceControl", diff --git a/Sources/CodeEditSourceEditor/Minimap/MinimapContentView.swift b/Sources/CodeEditSourceEditor/Minimap/MinimapContentView.swift new file mode 100644 index 000000000..3a79c2a5e --- /dev/null +++ b/Sources/CodeEditSourceEditor/Minimap/MinimapContentView.swift @@ -0,0 +1,22 @@ +// +// MinimapContentView.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 4/11/25. +// + +import AppKit + +final class MinimapContentView: FlippedNSView { + override func draw(_ dirtyRect: NSRect) { + guard let context = NSGraphicsContext.current?.cgContext else { return } + context.saveGState() + + context.setFillColor(NSColor.separatorColor.cgColor) + context.fill([ + CGRect(x: 0, y: 0, width: 1, height: frame.height) + ]) + + context.restoreGState() + } +} diff --git a/Sources/CodeEditSourceEditor/Minimap/MinimapLineRenderer.swift b/Sources/CodeEditSourceEditor/Minimap/MinimapLineRenderer.swift index 799d943c3..97997de04 100644 --- a/Sources/CodeEditSourceEditor/Minimap/MinimapLineRenderer.swift +++ b/Sources/CodeEditSourceEditor/Minimap/MinimapLineRenderer.swift @@ -40,16 +40,22 @@ final class MinimapLineRenderer: TextLayoutManagerRenderDelegate { // Make all fragments 2px tall textLine.lineFragments.forEach { fragmentPosition in let remainingHeight = fragmentPosition.height - 3.0 - textLine.lineFragments.update( - atOffset: fragmentPosition.range.location, - delta: 0, - deltaHeight: -remainingHeight - ) + if remainingHeight != 0 { + textLine.lineFragments.update( + atOffset: fragmentPosition.range.location, + delta: 0, + deltaHeight: -remainingHeight + ) + } fragmentPosition.data.height = 2.0 fragmentPosition.data.scaledHeight = 3.0 } } + func estimatedLineHeight() -> CGFloat? { + 3.0 + } + func lineFragmentView(for lineFragment: LineFragment) -> LineFragmentView { MinimapLineFragmentView(textStorage: textView?.textStorage) } diff --git a/Sources/CodeEditSourceEditor/Minimap/MinimapView+DocumentVisibleView.swift b/Sources/CodeEditSourceEditor/Minimap/MinimapView+DocumentVisibleView.swift new file mode 100644 index 000000000..e72521017 --- /dev/null +++ b/Sources/CodeEditSourceEditor/Minimap/MinimapView+DocumentVisibleView.swift @@ -0,0 +1,32 @@ +// +// MinimapView+DocumentVisibleView.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 4/11/25. +// + +import AppKit + +extension MinimapView { + func updateDocumentVisibleViewPosition() { + guard let textView = textView, let editorScrollView = textView.enclosingScrollView else { return } + layoutManager?.layoutLines(in: scrollView.documentVisibleRect) + let editorHeight = textView.frame.height + let minimapHeight = contentView.frame.height + + let containerHeight = scrollView.documentVisibleRect.height + let scrollPercentage = ( + editorScrollView.documentVisibleRect.origin.y + editorScrollView.contentInsets.top + ) / textView.frame.height +// let scrollOffset = editorScrollView.documentVisibleRect.origin.y + +// let scrollMultiplier: CGFloat = if minimapHeight < containerHeight { +// 1.0 +// } else { +// 1.0 - (minimapHeight - containerHeight) / (editorHeight - containerHeight) +// } + + let newMinimapOrigin = minimapHeight * scrollPercentage + scrollView.contentView.bounds.origin.y = newMinimapOrigin - editorScrollView.contentInsets.top + } +} diff --git a/Sources/CodeEditSourceEditor/Minimap/MinimapView.swift b/Sources/CodeEditSourceEditor/Minimap/MinimapView.swift index adb8386ba..de18d5618 100644 --- a/Sources/CodeEditSourceEditor/Minimap/MinimapView.swift +++ b/Sources/CodeEditSourceEditor/Minimap/MinimapView.swift @@ -11,17 +11,40 @@ import CodeEditTextView class MinimapView: NSView { weak var textView: TextView? + /// The container scrollview for the minimap contents. let scrollView: NSScrollView - let contentView: FlippedNSView + /// The view text lines are rendered into. + let contentView: MinimapContentView + /// The box displaying the visible region on the minimap. + let documentVisibleView: NSView + + /// The layout manager that uses the ``lineRenderer`` to render and layout lines. var layoutManager: TextLayoutManager? + /// A custom line renderer that lays out lines of text as 2px tall and draws contents as small lines + /// using ``MinimapLineFragmentView`` let lineRenderer: MinimapLineRenderer var theme: EditorTheme { didSet { + documentVisibleView.layer?.backgroundColor = theme.text.color.withAlphaComponent(0.1).cgColor layer?.backgroundColor = theme.background.cgColor } } + var visibleTextRange: NSRange? { + guard let layoutManager = layoutManager else { return nil } + let minY = max(visibleRect.minY, 0) + let maxY = min(visibleRect.maxY, layoutManager.estimatedHeight()) + guard let minYLine = layoutManager.textLineForPosition(minY), + let maxYLine = layoutManager.textLineForPosition(maxY) else { + return nil + } + return NSRange( + location: minYLine.range.location, + length: (maxYLine.range.location - minYLine.range.location) + maxYLine.range.length + ) + } + init(textView: TextView, theme: EditorTheme) { self.textView = textView self.theme = theme @@ -34,12 +57,18 @@ class MinimapView: NSView { scrollView.drawsBackground = false scrollView.verticalScrollElasticity = .none - self.contentView = FlippedNSView(frame: .zero) + self.contentView = MinimapContentView(frame: .zero) contentView.translatesAutoresizingMaskIntoConstraints = false + self.documentVisibleView = NSView() + documentVisibleView.translatesAutoresizingMaskIntoConstraints = false + documentVisibleView.wantsLayer = true + documentVisibleView.layer?.backgroundColor = theme.text.color.withAlphaComponent(0.1).cgColor + super.init(frame: .zero) addSubview(scrollView) + addSubview(documentVisibleView) scrollView.documentView = contentView self.translatesAutoresizingMaskIntoConstraints = false @@ -58,6 +87,7 @@ class MinimapView: NSView { layer?.backgroundColor = theme.background.cgColor setUpConstraints() + setUpListeners() } private func setUpConstraints() { @@ -67,10 +97,40 @@ class MinimapView: NSView { scrollView.leadingAnchor.constraint(equalTo: leadingAnchor), scrollView.trailingAnchor.constraint(equalTo: trailingAnchor), - contentView.widthAnchor.constraint(equalTo: widthAnchor) + contentView.widthAnchor.constraint(equalTo: widthAnchor), + + documentVisibleView.leadingAnchor.constraint(equalTo: leadingAnchor), + documentVisibleView.trailingAnchor.constraint(equalTo: trailingAnchor) ]) } + private func setUpListeners() { + guard let editorScrollView = textView?.enclosingScrollView else { return } + // Need to listen to: + // - ScrollView offset changed + // - ScrollView frame changed + // and update the document visible box to match. + NotificationCenter.default.addObserver( + forName: NSView.boundsDidChangeNotification, + object: editorScrollView.contentView, + queue: .main + ) { [weak self] _ in + // Scroll changed + self?.layoutManager?.layoutLines() + self?.updateDocumentVisibleViewPosition() + } + + NotificationCenter.default.addObserver( + forName: NSView.frameDidChangeNotification, + object: editorScrollView.contentView, + queue: .main + ) { [weak self] _ in + // Frame changed + self?.layoutManager?.layoutLines() + self?.updateDocumentVisibleViewPosition() + } + } + required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } @@ -86,18 +146,6 @@ class MinimapView: NSView { super.layout() } - override func draw(_ dirtyRect: NSRect) { - guard let context = NSGraphicsContext.current?.cgContext else { return } - context.saveGState() - - context.setFillColor(NSColor.separatorColor.cgColor) - context.fill([ - CGRect(x: 0, y: 0, width: 1, height: frame.height) - ]) - - context.restoreGState() - } - override func hitTest(_ point: NSPoint) -> NSView? { if visibleRect.contains(point) { return self From 95e0b91de8c04a1889df8d4b1f0f9e8fe257b8d7 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Tue, 15 Apr 2025 14:57:14 -0500 Subject: [PATCH 04/23] Scroll Syncing Done! --- .../TextViewController+StyleViews.swift | 16 +++-- .../Extensions/NSEdgeInsets+Helpers.swift | 18 ++++++ .../Minimap/MinimapContentView.swift | 22 ------- .../MinimapView+DocumentVisibleView.swift | 63 ++++++++++++++----- .../Minimap/MinimapView.swift | 25 ++++++-- 5 files changed, 97 insertions(+), 47 deletions(-) create mode 100644 Sources/CodeEditSourceEditor/Extensions/NSEdgeInsets+Helpers.swift delete mode 100644 Sources/CodeEditSourceEditor/Minimap/MinimapContentView.swift diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift index 9579698e2..1f58dc2ac 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift @@ -67,6 +67,10 @@ extension TextViewController { scrollView.hasVerticalScroller = true scrollView.hasHorizontalScroller = !wrapLines + updateContentInsets() + } + + package func updateContentInsets() { scrollView.contentView.postsBoundsChangedNotifications = true if let contentInsets { scrollView.automaticallyAdjustsContentInsets = false @@ -80,10 +84,14 @@ extension TextViewController { scrollView.contentInsets.top += additionalTextInsets?.top ?? 0 scrollView.contentInsets.bottom += additionalTextInsets?.bottom ?? 0 + minimapView.scrollView.contentInsets.top += additionalTextInsets?.top ?? 0 + minimapView.scrollView.contentInsets.bottom += additionalTextInsets?.bottom ?? 0 + + let findInset = (findViewController?.isShowingFindPanel ?? false) ? FindPanel.height : 0 + scrollView.contentInsets.top += findInset + minimapView.scrollView.contentInsets.top += findInset - scrollView.contentInsets.top += (findViewController?.isShowingFindPanel ?? false) ? FindPanel.height : 0 - minimapView.scrollView.contentInsets.top += ( - findViewController?.isShowingFindPanel ?? false - ) ? FindPanel.height : 0 + scrollView.reflectScrolledClipView(scrollView.contentView) + minimapView.scrollView.reflectScrolledClipView(minimapView.scrollView.contentView) } } diff --git a/Sources/CodeEditSourceEditor/Extensions/NSEdgeInsets+Helpers.swift b/Sources/CodeEditSourceEditor/Extensions/NSEdgeInsets+Helpers.swift new file mode 100644 index 000000000..83b2cb81c --- /dev/null +++ b/Sources/CodeEditSourceEditor/Extensions/NSEdgeInsets+Helpers.swift @@ -0,0 +1,18 @@ +// +// NSEdgeInsets+Helpers.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 4/15/25. +// + +import Foundation + +extension NSEdgeInsets { + var vertical: CGFloat { + top + bottom + } + + var horizontal: CGFloat { + left + right + } +} diff --git a/Sources/CodeEditSourceEditor/Minimap/MinimapContentView.swift b/Sources/CodeEditSourceEditor/Minimap/MinimapContentView.swift deleted file mode 100644 index 3a79c2a5e..000000000 --- a/Sources/CodeEditSourceEditor/Minimap/MinimapContentView.swift +++ /dev/null @@ -1,22 +0,0 @@ -// -// MinimapContentView.swift -// CodeEditSourceEditor -// -// Created by Khan Winter on 4/11/25. -// - -import AppKit - -final class MinimapContentView: FlippedNSView { - override func draw(_ dirtyRect: NSRect) { - guard let context = NSGraphicsContext.current?.cgContext else { return } - context.saveGState() - - context.setFillColor(NSColor.separatorColor.cgColor) - context.fill([ - CGRect(x: 0, y: 0, width: 1, height: frame.height) - ]) - - context.restoreGState() - } -} diff --git a/Sources/CodeEditSourceEditor/Minimap/MinimapView+DocumentVisibleView.swift b/Sources/CodeEditSourceEditor/Minimap/MinimapView+DocumentVisibleView.swift index e72521017..e9ede490b 100644 --- a/Sources/CodeEditSourceEditor/Minimap/MinimapView+DocumentVisibleView.swift +++ b/Sources/CodeEditSourceEditor/Minimap/MinimapView+DocumentVisibleView.swift @@ -7,26 +7,59 @@ import AppKit +extension NSScrollView { + var percentScrolled: CGFloat { + get { + let currentYPos = documentVisibleRect.origin.y + contentInsets.top + let totalHeight = (documentView?.frame.height ?? 0.0) + contentInsets.top + let goalYPos = totalHeight - (documentVisibleRect.height - contentInsets.top) + + return currentYPos / goalYPos + } + set { + let totalHeight = (documentView?.frame.height ?? 0.0) + contentInsets.top + contentView.scroll( + to: NSPoint( + x: contentView.frame.origin.x, + y: (newValue * (totalHeight - (documentVisibleRect.height - contentInsets.top))) - contentInsets.top + ) + ) + reflectScrolledClipView(contentView) + } + } +} + extension MinimapView { func updateDocumentVisibleViewPosition() { - guard let textView = textView, let editorScrollView = textView.enclosingScrollView else { return } - layoutManager?.layoutLines(in: scrollView.documentVisibleRect) - let editorHeight = textView.frame.height + guard let textView = textView, let editorScrollView = textView.enclosingScrollView, let layoutManager else { + return + } + let minimapHeight = contentView.frame.height + let editorHeight = textView.frame.height + let editorToMinimapHeightRatio = minimapHeight / editorHeight + + let containerHeight = editorScrollView.visibleRect.height - editorScrollView.contentInsets.vertical + let availableHeight = min(minimapHeight, containerHeight) + let scrollPercentage = editorScrollView.percentScrolled + + // Update Visible Pane, should scroll down slowly as the user scrolls the document, following the scroller. + // Visible pane's height = scrollview visible height * (minimap line height / editor line height) + // Visible pane's position = (container height - visible pane height) * scrollPercentage + let visibleRectHeight = containerHeight * editorToMinimapHeightRatio + guard visibleRectHeight < 1e100 else { return } + + let availableContainerHeight = (availableHeight - visibleRectHeight) + let visibleRectYPos = availableContainerHeight * scrollPercentage - let containerHeight = scrollView.documentVisibleRect.height - let scrollPercentage = ( - editorScrollView.documentVisibleRect.origin.y + editorScrollView.contentInsets.top - ) / textView.frame.height -// let scrollOffset = editorScrollView.documentVisibleRect.origin.y + documentVisibleView.frame.origin.y = scrollView.contentInsets.top + visibleRectYPos + documentVisibleView.frame.size.height = visibleRectHeight -// let scrollMultiplier: CGFloat = if minimapHeight < containerHeight { -// 1.0 -// } else { -// 1.0 - (minimapHeight - containerHeight) / (editorHeight - containerHeight) -// } + // Minimap scroll offset slowly scrolls down with the visible pane. + if minimapHeight > containerHeight { + scrollView.percentScrolled = scrollPercentage + } - let newMinimapOrigin = minimapHeight * scrollPercentage - scrollView.contentView.bounds.origin.y = newMinimapOrigin - editorScrollView.contentInsets.top + layoutManager.layoutLines() } } diff --git a/Sources/CodeEditSourceEditor/Minimap/MinimapView.swift b/Sources/CodeEditSourceEditor/Minimap/MinimapView.swift index de18d5618..91df77460 100644 --- a/Sources/CodeEditSourceEditor/Minimap/MinimapView.swift +++ b/Sources/CodeEditSourceEditor/Minimap/MinimapView.swift @@ -8,13 +8,13 @@ import AppKit import CodeEditTextView -class MinimapView: NSView { +class MinimapView: FlippedNSView { weak var textView: TextView? /// The container scrollview for the minimap contents. let scrollView: NSScrollView /// The view text lines are rendered into. - let contentView: MinimapContentView + let contentView: FlippedNSView /// The box displaying the visible region on the minimap. let documentVisibleView: NSView @@ -26,7 +26,7 @@ class MinimapView: NSView { var theme: EditorTheme { didSet { - documentVisibleView.layer?.backgroundColor = theme.text.color.withAlphaComponent(0.1).cgColor + documentVisibleView.layer?.backgroundColor = theme.text.color.withAlphaComponent(0.05).cgColor layer?.backgroundColor = theme.background.cgColor } } @@ -57,13 +57,13 @@ class MinimapView: NSView { scrollView.drawsBackground = false scrollView.verticalScrollElasticity = .none - self.contentView = MinimapContentView(frame: .zero) + self.contentView = FlippedNSView(frame: .zero) contentView.translatesAutoresizingMaskIntoConstraints = false self.documentVisibleView = NSView() documentVisibleView.translatesAutoresizingMaskIntoConstraints = false documentVisibleView.wantsLayer = true - documentVisibleView.layer?.backgroundColor = theme.text.color.withAlphaComponent(0.1).cgColor + documentVisibleView.layer?.backgroundColor = theme.text.color.withAlphaComponent(0.05).cgColor super.init(frame: .zero) @@ -97,7 +97,8 @@ class MinimapView: NSView { scrollView.leadingAnchor.constraint(equalTo: leadingAnchor), scrollView.trailingAnchor.constraint(equalTo: trailingAnchor), - contentView.widthAnchor.constraint(equalTo: widthAnchor), + contentView.leadingAnchor.constraint(equalTo: leadingAnchor), + contentView.trailingAnchor.constraint(equalTo: trailingAnchor), documentVisibleView.leadingAnchor.constraint(equalTo: leadingAnchor), documentVisibleView.trailingAnchor.constraint(equalTo: trailingAnchor) @@ -153,4 +154,16 @@ class MinimapView: NSView { return super.hitTest(point) } } + + override func draw(_ dirtyRect: NSRect) { + guard let context = NSGraphicsContext.current?.cgContext else { return } + context.saveGState() + + context.setFillColor(NSColor.separatorColor.cgColor) + context.fill([ + CGRect(x: 0, y: 0, width: 1, height: frame.height) + ]) + + context.restoreGState() + } } From 6cf5e5fb4dab5f2a0e2e06882a0438c451d40459 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Wed, 16 Apr 2025 13:31:46 -0500 Subject: [PATCH 05/23] Move to Under Scroller, Performance Improvements --- Package.swift | 2 +- .../Controller/EditorContainerView.swift | 56 ---------- .../TextViewController+LoadView.swift | 45 ++++++-- .../TextViewController+StyleViews.swift | 2 + .../Controller/TextViewController.swift | 16 +-- .../NSScrollView+percentScrolled.swift | 20 ++++ .../MinimapView+DocumentVisibleView.swift | 44 +++----- .../Minimap/MinimapView.swift | 103 +++++++++++++----- .../BezelNotification.swift | 0 .../EffectView.swift | 0 .../FlippedNSView.swift | 0 .../ForwardingScrollView.swift | 18 +++ .../IconButtonStyle.swift | 0 .../IconToggleStyle.swift | 0 .../PanelStyles.swift | 0 .../PanelTextField.swift | 0 16 files changed, 175 insertions(+), 131 deletions(-) delete mode 100644 Sources/CodeEditSourceEditor/Controller/EditorContainerView.swift create mode 100644 Sources/CodeEditSourceEditor/Extensions/NSScrollView+percentScrolled.swift rename Sources/CodeEditSourceEditor/{CodeEditUI => SupportingViews}/BezelNotification.swift (100%) rename Sources/CodeEditSourceEditor/{CodeEditUI => SupportingViews}/EffectView.swift (100%) rename Sources/CodeEditSourceEditor/{Utils => SupportingViews}/FlippedNSView.swift (100%) create mode 100644 Sources/CodeEditSourceEditor/SupportingViews/ForwardingScrollView.swift rename Sources/CodeEditSourceEditor/{CodeEditUI => SupportingViews}/IconButtonStyle.swift (100%) rename Sources/CodeEditSourceEditor/{CodeEditUI => SupportingViews}/IconToggleStyle.swift (100%) rename Sources/CodeEditSourceEditor/{CodeEditUI => SupportingViews}/PanelStyles.swift (100%) rename Sources/CodeEditSourceEditor/{CodeEditUI => SupportingViews}/PanelTextField.swift (100%) diff --git a/Package.swift b/Package.swift index 254897b23..10a9b8629 100644 --- a/Package.swift +++ b/Package.swift @@ -17,7 +17,7 @@ let package = Package( // A fast, efficient, text view for code. .package( // url: "https://github.com/CodeEditApp/CodeEditTextView.git", -// from: "0.8.2" +// from: "0.9.1" path: "../CodeEditTextView" ), // tree-sitter languages diff --git a/Sources/CodeEditSourceEditor/Controller/EditorContainerView.swift b/Sources/CodeEditSourceEditor/Controller/EditorContainerView.swift deleted file mode 100644 index e96476a22..000000000 --- a/Sources/CodeEditSourceEditor/Controller/EditorContainerView.swift +++ /dev/null @@ -1,56 +0,0 @@ -// -// EditorContainerView.swift -// CodeEditSourceEditor -// -// Created by Khan Winter on 4/10/25. -// - -import AppKit - -class EditorContainerView: NSView { - weak var scrollView: NSScrollView? - weak var minimapView: MinimapView? - - init(scrollView: NSScrollView, minimapView: MinimapView) { - self.scrollView = scrollView - self.minimapView = minimapView - - super.init(frame: .zero) - - self.translatesAutoresizingMaskIntoConstraints = false - - addSubview(scrollView) - addSubview(minimapView) - - scrollView.hasVerticalScroller = true - - let maxWidthConstraint = minimapView.widthAnchor.constraint(lessThanOrEqualToConstant: 150) - let relativeWidthConstraint = minimapView.widthAnchor.constraint( - equalTo: widthAnchor, - multiplier: 0.18 - ) - relativeWidthConstraint.priority = .defaultLow - - guard let scrollerAnchor = scrollView.verticalScroller?.leadingAnchor else { - assertionFailure("Scroll view failed to create a scroller.") - return - } - - NSLayoutConstraint.activate([ - scrollView.topAnchor.constraint(equalTo: topAnchor), - scrollView.bottomAnchor.constraint(equalTo: bottomAnchor), - scrollView.leadingAnchor.constraint(equalTo: leadingAnchor), - scrollView.trailingAnchor.constraint(equalTo: trailingAnchor), - - minimapView.topAnchor.constraint(equalTo: topAnchor), - minimapView.bottomAnchor.constraint(equalTo: bottomAnchor), - minimapView.trailingAnchor.constraint(equalTo: scrollerAnchor), - maxWidthConstraint, - relativeWidthConstraint - ]) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } -} diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift index 3f33f5586..16bd8be56 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift @@ -29,10 +29,9 @@ extension TextViewController { ) minimapView = MinimapView(textView: textView, theme: theme) + scrollView.addFloatingSubview(minimapView, for: .vertical) - editorContainer = EditorContainerView(scrollView: scrollView, minimapView: minimapView) - - let findViewController = FindViewController(target: self, childView: editorContainer) + let findViewController = FindViewController(target: self, childView: scrollView) addChild(findViewController) self.findViewController = findViewController self.view.addSubview(findViewController.view) @@ -51,17 +50,11 @@ extension TextViewController { setUpHighlighter() setUpTextFormation() - NSLayoutConstraint.activate([ - findViewController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), - findViewController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), - findViewController.view.topAnchor.constraint(equalTo: view.topAnchor), - findViewController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), - ]) - if !cursorPositions.isEmpty { setCursorPositions(cursorPositions) } + setUpConstraints() setUpListeners() textView.updateFrameIfNeeded() @@ -72,15 +65,45 @@ extension TextViewController { setUpKeyBindings(eventMonitor: &self.localEvenMonitor) } + func setUpConstraints() { + guard let findViewController else { return } + + let maxWidthConstraint = minimapView.widthAnchor.constraint(lessThanOrEqualToConstant: 150) + let relativeWidthConstraint = minimapView.widthAnchor.constraint( + equalTo: view.widthAnchor, + multiplier: 0.17 + ) + relativeWidthConstraint.priority = .defaultLow + let minimapXConstraint = minimapView.trailingAnchor.constraint( + equalTo: scrollView.contentView.safeAreaLayoutGuide.trailingAnchor + ) + self.minimapXConstraint = minimapXConstraint + + NSLayoutConstraint.activate([ + findViewController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), + findViewController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), + findViewController.view.topAnchor.constraint(equalTo: view.topAnchor), + findViewController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), + + minimapView.topAnchor.constraint(equalTo: scrollView.contentView.safeAreaLayoutGuide.topAnchor), + minimapView.bottomAnchor.constraint(equalTo: scrollView.contentView.safeAreaLayoutGuide.bottomAnchor), + minimapXConstraint, + maxWidthConstraint, + relativeWidthConstraint, + ]) + } + func setUpListeners() { // Layout on scroll change NotificationCenter.default.addObserver( forName: NSView.boundsDidChangeNotification, object: scrollView.contentView, queue: .main - ) { [weak self] _ in + ) { [weak self] notification in + guard let clipView = notification.object as? NSClipView else { return } self?.textView.updatedViewport(self?.scrollView.documentVisibleRect ?? .zero) self?.gutterView.needsDisplay = true + self?.minimapXConstraint?.constant = clipView.bounds.origin.x } // Layout on frame change diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift index 1f58dc2ac..9f404e538 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift @@ -67,6 +67,8 @@ extension TextViewController { scrollView.hasVerticalScroller = true scrollView.hasHorizontalScroller = !wrapLines + scrollView.scrollerStyle = .overlay + updateContentInsets() } diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift index bcfa9d209..5de19d3a0 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift @@ -22,19 +22,18 @@ public class TextViewController: NSViewController { weak var findViewController: FindViewController? - // Container view for the editor contents (scrolling textview, gutter, and minimap) - // Is a child of the find container, so editor contents all move below the find panel when open. - var editorContainer: EditorContainerView! var scrollView: NSScrollView! var textView: TextView! var gutterView: GutterView! var minimapView: MinimapView! - internal var _undoManager: CEUndoManager! - internal var systemAppearance: NSAppearance.Name? + var minimapXConstraint: NSLayoutConstraint? - package var localEvenMonitor: Any? - package var isPostingCursorNotification: Bool = false + var _undoManager: CEUndoManager! + var systemAppearance: NSAppearance.Name? + + var localEvenMonitor: Any? + var isPostingCursorNotification: Bool = false /// The string contents. public var string: String { @@ -100,6 +99,7 @@ public class TextViewController: NSViewController { public var wrapLines: Bool { didSet { textView.layoutManager.wrapLines = wrapLines + minimapView.layoutManager?.wrapLines = wrapLines scrollView.hasHorizontalScroller = !wrapLines textView.textInsets = textViewInsets } @@ -127,7 +127,7 @@ public class TextViewController: NSViewController { /// Optional insets to offset the text view and find panel in the scroll view by. public var contentInsets: NSEdgeInsets? { didSet { - styleScrollView() + updateContentInsets() findViewController?.topPadding = contentInsets?.top } } diff --git a/Sources/CodeEditSourceEditor/Extensions/NSScrollView+percentScrolled.swift b/Sources/CodeEditSourceEditor/Extensions/NSScrollView+percentScrolled.swift new file mode 100644 index 000000000..bb939ec7d --- /dev/null +++ b/Sources/CodeEditSourceEditor/Extensions/NSScrollView+percentScrolled.swift @@ -0,0 +1,20 @@ +// +// NSScrollView+percentScrolled.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 4/15/25. +// + +import AppKit + +extension NSScrollView { + var documentMaxOriginY: CGFloat { + let totalHeight = (documentView?.frame.height ?? 0.0) + contentInsets.top + return totalHeight - (documentVisibleRect.height - contentInsets.top) + } + + var percentScrolled: CGFloat { + let currentYPos = documentVisibleRect.origin.y + contentInsets.top + return currentYPos / documentMaxOriginY + } +} diff --git a/Sources/CodeEditSourceEditor/Minimap/MinimapView+DocumentVisibleView.swift b/Sources/CodeEditSourceEditor/Minimap/MinimapView+DocumentVisibleView.swift index e9ede490b..d1c4efb9d 100644 --- a/Sources/CodeEditSourceEditor/Minimap/MinimapView+DocumentVisibleView.swift +++ b/Sources/CodeEditSourceEditor/Minimap/MinimapView+DocumentVisibleView.swift @@ -7,41 +7,15 @@ import AppKit -extension NSScrollView { - var percentScrolled: CGFloat { - get { - let currentYPos = documentVisibleRect.origin.y + contentInsets.top - let totalHeight = (documentView?.frame.height ?? 0.0) + contentInsets.top - let goalYPos = totalHeight - (documentVisibleRect.height - contentInsets.top) - - return currentYPos / goalYPos - } - set { - let totalHeight = (documentView?.frame.height ?? 0.0) + contentInsets.top - contentView.scroll( - to: NSPoint( - x: contentView.frame.origin.x, - y: (newValue * (totalHeight - (documentVisibleRect.height - contentInsets.top))) - contentInsets.top - ) - ) - reflectScrolledClipView(contentView) - } - } -} - extension MinimapView { func updateDocumentVisibleViewPosition() { guard let textView = textView, let editorScrollView = textView.enclosingScrollView, let layoutManager else { return } - let minimapHeight = contentView.frame.height - let editorHeight = textView.frame.height - let editorToMinimapHeightRatio = minimapHeight / editorHeight - - let containerHeight = editorScrollView.visibleRect.height - editorScrollView.contentInsets.vertical let availableHeight = min(minimapHeight, containerHeight) let scrollPercentage = editorScrollView.percentScrolled + guard scrollPercentage.isFinite else { return } // Update Visible Pane, should scroll down slowly as the user scrolls the document, following the scroller. // Visible pane's height = scrollview visible height * (minimap line height / editor line height) @@ -57,9 +31,23 @@ extension MinimapView { // Minimap scroll offset slowly scrolls down with the visible pane. if minimapHeight > containerHeight { - scrollView.percentScrolled = scrollPercentage + setScrollViewPosition(scrollPercentage: scrollPercentage) } layoutManager.layoutLines() } + + private func setScrollViewPosition(scrollPercentage: CGFloat) { + let totalHeight = contentView.frame.height + scrollView.contentInsets.top + let topInset = scrollView.contentInsets.top + scrollView.contentView.scroll( + to: NSPoint( + x: scrollView.contentView.frame.origin.x, + y: ( + scrollPercentage * (totalHeight - (scrollView.documentVisibleRect.height - topInset)) + ) - topInset + ) + ) + scrollView.reflectScrolledClipView(scrollView.contentView) + } } diff --git a/Sources/CodeEditSourceEditor/Minimap/MinimapView.swift b/Sources/CodeEditSourceEditor/Minimap/MinimapView.swift index 91df77460..78cc6ab46 100644 --- a/Sources/CodeEditSourceEditor/Minimap/MinimapView.swift +++ b/Sources/CodeEditSourceEditor/Minimap/MinimapView.swift @@ -12,12 +12,16 @@ class MinimapView: FlippedNSView { weak var textView: TextView? /// The container scrollview for the minimap contents. - let scrollView: NSScrollView + let scrollView: ForwardingScrollView /// The view text lines are rendered into. let contentView: FlippedNSView /// The box displaying the visible region on the minimap. let documentVisibleView: NSView + let separatorView: NSView + + var documentVisibleViewDragGesture: NSPanGestureRecognizer? + /// The layout manager that uses the ``lineRenderer`` to render and layout lines. var layoutManager: TextLayoutManager? /// A custom line renderer that lays out lines of text as 2px tall and draws contents as small lines @@ -31,18 +35,21 @@ class MinimapView: FlippedNSView { } } - var visibleTextRange: NSRange? { - guard let layoutManager = layoutManager else { return nil } - let minY = max(visibleRect.minY, 0) - let maxY = min(visibleRect.maxY, layoutManager.estimatedHeight()) - guard let minYLine = layoutManager.textLineForPosition(minY), - let maxYLine = layoutManager.textLineForPosition(maxY) else { - return nil - } - return NSRange( - location: minYLine.range.location, - length: (maxYLine.range.location - minYLine.range.location) + maxYLine.range.length - ) + var minimapHeight: CGFloat { + contentView.frame.height + } + + var editorHeight: CGFloat { + textView?.frame.height ?? 0.0 + } + + var editorToMinimapHeightRatio: CGFloat { + minimapHeight / editorHeight + } + + var containerHeight: CGFloat { + (textView?.enclosingScrollView?.visibleRect.height ?? 0.0) + - (textView?.enclosingScrollView?.contentInsets.vertical ?? 0.0) } init(textView: TextView, theme: EditorTheme) { @@ -50,12 +57,13 @@ class MinimapView: FlippedNSView { self.theme = theme self.lineRenderer = MinimapLineRenderer(textView: textView) - self.scrollView = NSScrollView() + self.scrollView = ForwardingScrollView() scrollView.translatesAutoresizingMaskIntoConstraints = false scrollView.hasVerticalScroller = false scrollView.hasHorizontalScroller = false scrollView.drawsBackground = false scrollView.verticalScrollElasticity = .none + scrollView.receiver = textView.enclosingScrollView self.contentView = FlippedNSView(frame: .zero) contentView.translatesAutoresizingMaskIntoConstraints = false @@ -65,10 +73,23 @@ class MinimapView: FlippedNSView { documentVisibleView.wantsLayer = true documentVisibleView.layer?.backgroundColor = theme.text.color.withAlphaComponent(0.05).cgColor + self.separatorView = NSView() + separatorView.translatesAutoresizingMaskIntoConstraints = false + separatorView.wantsLayer = true + separatorView.layer?.backgroundColor = NSColor.separatorColor.cgColor + super.init(frame: .zero) + let documentVisibleViewDragGesture = NSPanGestureRecognizer( + target: self, + action: #selector(documentVisibleViewDragged(_:)) + ) + documentVisibleView.addGestureRecognizer(documentVisibleViewDragGesture) + self.documentVisibleViewDragGesture = documentVisibleViewDragGesture + addSubview(scrollView) addSubview(documentVisibleView) + addSubview(separatorView) scrollView.documentView = contentView self.translatesAutoresizingMaskIntoConstraints = false @@ -92,16 +113,25 @@ class MinimapView: FlippedNSView { private func setUpConstraints() { NSLayoutConstraint.activate([ + // Constrain to all sides scrollView.topAnchor.constraint(equalTo: topAnchor), scrollView.bottomAnchor.constraint(equalTo: bottomAnchor), scrollView.leadingAnchor.constraint(equalTo: leadingAnchor), scrollView.trailingAnchor.constraint(equalTo: trailingAnchor), + // Scrolling, but match width contentView.leadingAnchor.constraint(equalTo: leadingAnchor), contentView.trailingAnchor.constraint(equalTo: trailingAnchor), + // Y position set manually documentVisibleView.leadingAnchor.constraint(equalTo: leadingAnchor), - documentVisibleView.trailingAnchor.constraint(equalTo: trailingAnchor) + documentVisibleView.trailingAnchor.constraint(equalTo: trailingAnchor), + + // Separator on leading side + separatorView.leadingAnchor.constraint(equalTo: leadingAnchor), + separatorView.topAnchor.constraint(equalTo: topAnchor), + separatorView.bottomAnchor.constraint(equalTo: bottomAnchor), + separatorView.widthAnchor.constraint(equalToConstant: 1.0) ]) } @@ -117,7 +147,6 @@ class MinimapView: FlippedNSView { queue: .main ) { [weak self] _ in // Scroll changed - self?.layoutManager?.layoutLines() self?.updateDocumentVisibleViewPosition() } @@ -127,7 +156,6 @@ class MinimapView: FlippedNSView { queue: .main ) { [weak self] _ in // Frame changed - self?.layoutManager?.layoutLines() self?.updateDocumentVisibleViewPosition() } } @@ -148,22 +176,43 @@ class MinimapView: FlippedNSView { } override func hitTest(_ point: NSPoint) -> NSView? { - if visibleRect.contains(point) { - return self + if documentVisibleView.frame.contains(point) { + return documentVisibleView + } else if visibleRect.contains(point) { + return textView } else { return super.hitTest(point) } } - override func draw(_ dirtyRect: NSRect) { - guard let context = NSGraphicsContext.current?.cgContext else { return } - context.saveGState() + /// Responds to a drag gesture on the document visible view. Dragging the view scrolls the editor a relative amount. + @objc func documentVisibleViewDragged(_ sender: NSPanGestureRecognizer) { + guard let editorScrollView = textView?.enclosingScrollView else { + return + } - context.setFillColor(NSColor.separatorColor.cgColor) - context.fill([ - CGRect(x: 0, y: 0, width: 1, height: frame.height) - ]) + let translation = sender.translation(in: documentVisibleView) + let ratio = if minimapHeight > containerHeight { + containerHeight / (textView?.frame.height ?? 0.0) + } else { + editorToMinimapHeightRatio + } + let editorTranslation = translation.y / ratio + sender.setTranslation(.zero, in: documentVisibleView) + + var newScrollViewY = editorScrollView.contentView.bounds.origin.y - editorTranslation + newScrollViewY = max(-editorScrollView.contentInsets.top, newScrollViewY) + newScrollViewY = min( + editorScrollView.documentMaxOriginY - editorScrollView.contentInsets.top, + newScrollViewY + ) - context.restoreGState() + editorScrollView.contentView.scroll( + to: NSPoint( + x: editorScrollView.contentView.bounds.origin.x, + y: newScrollViewY + ) + ) + editorScrollView.reflectScrolledClipView(editorScrollView.contentView) } } diff --git a/Sources/CodeEditSourceEditor/CodeEditUI/BezelNotification.swift b/Sources/CodeEditSourceEditor/SupportingViews/BezelNotification.swift similarity index 100% rename from Sources/CodeEditSourceEditor/CodeEditUI/BezelNotification.swift rename to Sources/CodeEditSourceEditor/SupportingViews/BezelNotification.swift diff --git a/Sources/CodeEditSourceEditor/CodeEditUI/EffectView.swift b/Sources/CodeEditSourceEditor/SupportingViews/EffectView.swift similarity index 100% rename from Sources/CodeEditSourceEditor/CodeEditUI/EffectView.swift rename to Sources/CodeEditSourceEditor/SupportingViews/EffectView.swift diff --git a/Sources/CodeEditSourceEditor/Utils/FlippedNSView.swift b/Sources/CodeEditSourceEditor/SupportingViews/FlippedNSView.swift similarity index 100% rename from Sources/CodeEditSourceEditor/Utils/FlippedNSView.swift rename to Sources/CodeEditSourceEditor/SupportingViews/FlippedNSView.swift diff --git a/Sources/CodeEditSourceEditor/SupportingViews/ForwardingScrollView.swift b/Sources/CodeEditSourceEditor/SupportingViews/ForwardingScrollView.swift new file mode 100644 index 000000000..92595e58d --- /dev/null +++ b/Sources/CodeEditSourceEditor/SupportingViews/ForwardingScrollView.swift @@ -0,0 +1,18 @@ +// +// ForwardingScrollView.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 4/15/25. +// + +import Cocoa + +class ForwardingScrollView: NSScrollView { + + weak var receiver: NSScrollView? + + override func scrollWheel(with event: NSEvent) { + receiver?.scrollWheel(with: event) + } + +} diff --git a/Sources/CodeEditSourceEditor/CodeEditUI/IconButtonStyle.swift b/Sources/CodeEditSourceEditor/SupportingViews/IconButtonStyle.swift similarity index 100% rename from Sources/CodeEditSourceEditor/CodeEditUI/IconButtonStyle.swift rename to Sources/CodeEditSourceEditor/SupportingViews/IconButtonStyle.swift diff --git a/Sources/CodeEditSourceEditor/CodeEditUI/IconToggleStyle.swift b/Sources/CodeEditSourceEditor/SupportingViews/IconToggleStyle.swift similarity index 100% rename from Sources/CodeEditSourceEditor/CodeEditUI/IconToggleStyle.swift rename to Sources/CodeEditSourceEditor/SupportingViews/IconToggleStyle.swift diff --git a/Sources/CodeEditSourceEditor/CodeEditUI/PanelStyles.swift b/Sources/CodeEditSourceEditor/SupportingViews/PanelStyles.swift similarity index 100% rename from Sources/CodeEditSourceEditor/CodeEditUI/PanelStyles.swift rename to Sources/CodeEditSourceEditor/SupportingViews/PanelStyles.swift diff --git a/Sources/CodeEditSourceEditor/CodeEditUI/PanelTextField.swift b/Sources/CodeEditSourceEditor/SupportingViews/PanelTextField.swift similarity index 100% rename from Sources/CodeEditSourceEditor/CodeEditUI/PanelTextField.swift rename to Sources/CodeEditSourceEditor/SupportingViews/PanelTextField.swift From ef2f8f0a93546afc78ab293dce83d37b583e6471 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Wed, 16 Apr 2025 15:11:33 -0500 Subject: [PATCH 06/23] Make Scroller on Top, Utilize Content Insets --- .../Views/ContentView.swift | 1 + .../TextViewController+FindPanelTarget.swift | 6 +-- .../TextViewController+LoadView.swift | 7 ++- .../TextViewController+StyleViews.swift | 13 +++--- .../Controller/TextViewController.swift | 1 - .../Minimap/MinimapLineFragmentView.swift | 2 +- .../MinimapView+DocumentVisibleView.swift | 8 ++-- .../Minimap/MinimapView+DragVisibleView.swift | 46 +++++++++++++++++++ .../Minimap/MinimapView.swift | 39 +++------------- 9 files changed, 71 insertions(+), 52 deletions(-) create mode 100644 Sources/CodeEditSourceEditor/Minimap/MinimapView+DragVisibleView.swift diff --git a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift index e40d0c905..5fedff134 100644 --- a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift +++ b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift @@ -46,6 +46,7 @@ struct ContentView: View { useThemeBackground: true, highlightProviders: [treeSitterClient], contentInsets: NSEdgeInsets(top: proxy.safeAreaInsets.top, left: 0, bottom: 28.0, right: 0), + additionalTextInsets: NSEdgeInsets(top: 1, left: 0, bottom: proxy.size.height * 0.3, right: 0), useSystemCursor: useSystemCursor ) .overlay(alignment: .bottom) { diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+FindPanelTarget.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+FindPanelTarget.swift index 36e4b45f7..697ccc54b 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+FindPanelTarget.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+FindPanelTarget.swift @@ -10,13 +10,11 @@ import CodeEditTextView extension TextViewController: FindPanelTarget { func findPanelWillShow(panelHeight: CGFloat) { - scrollView.contentInsets.top += panelHeight - gutterView.frame.origin.y = -scrollView.contentInsets.top + updateContentInsets() } func findPanelWillHide(panelHeight: CGFloat) { - scrollView.contentInsets.top -= panelHeight - gutterView.frame.origin.y = -scrollView.contentInsets.top + updateContentInsets() } var emphasisManager: EmphasisManager? { diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift index 16bd8be56..eb396c9cd 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift @@ -38,8 +38,6 @@ extension TextViewController { findViewController.view.viewDidMoveToSuperview() self.findViewController = findViewController - findViewController.topPadding = contentInsets?.top - if let _undoManager { textView.setUndoManager(_undoManager) } @@ -63,6 +61,7 @@ extension TextViewController { NSEvent.removeMonitor(localEventMonitor) } setUpKeyBindings(eventMonitor: &self.localEvenMonitor) + updateContentInsets() } func setUpConstraints() { @@ -85,8 +84,8 @@ extension TextViewController { findViewController.view.topAnchor.constraint(equalTo: view.topAnchor), findViewController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), - minimapView.topAnchor.constraint(equalTo: scrollView.contentView.safeAreaLayoutGuide.topAnchor), - minimapView.bottomAnchor.constraint(equalTo: scrollView.contentView.safeAreaLayoutGuide.bottomAnchor), + minimapView.topAnchor.constraint(equalTo: scrollView.contentView.topAnchor), + minimapView.bottomAnchor.constraint(equalTo: scrollView.contentView.bottomAnchor), minimapXConstraint, maxWidthConstraint, relativeWidthConstraint, diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift index 9f404e538..d7efb78ee 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift @@ -46,8 +46,6 @@ extension TextViewController { /// Style the gutter view. package func styleGutterView() { - // Note: If changing this value, also change in ``findPanelWillShow/Hide()`` - gutterView.frame.origin.y = -scrollView.contentInsets.top gutterView.selectedLineColor = useThemeBackground ? theme.lineHighlight : systemAppearance == .darkAqua ? NSColor.quaternaryLabelColor : NSColor.selectedTextBackgroundColor.withSystemEffect(.disabled) @@ -66,10 +64,7 @@ extension TextViewController { scrollView.contentView.postsFrameChangedNotifications = true scrollView.hasVerticalScroller = true scrollView.hasHorizontalScroller = !wrapLines - scrollView.scrollerStyle = .overlay - - updateContentInsets() } package func updateContentInsets() { @@ -77,8 +72,10 @@ extension TextViewController { if let contentInsets { scrollView.automaticallyAdjustsContentInsets = false scrollView.contentInsets = contentInsets + + minimapView.scrollView.automaticallyAdjustsContentInsets = false minimapView.scrollView.contentInsets.top = contentInsets.top - minimapView.scrollView.contentInsets.top = contentInsets.bottom + minimapView.scrollView.contentInsets.bottom = contentInsets.bottom } else { scrollView.automaticallyAdjustsContentInsets = true minimapView.scrollView.automaticallyAdjustsContentInsets = true @@ -95,5 +92,9 @@ extension TextViewController { scrollView.reflectScrolledClipView(scrollView.contentView) minimapView.scrollView.reflectScrolledClipView(minimapView.scrollView.contentView) + + findViewController?.topPadding = contentInsets?.top + + gutterView.frame.origin.y = -scrollView.contentInsets.top } } diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift index 5de19d3a0..f3fccfe11 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift @@ -128,7 +128,6 @@ public class TextViewController: NSViewController { public var contentInsets: NSEdgeInsets? { didSet { updateContentInsets() - findViewController?.topPadding = contentInsets?.top } } diff --git a/Sources/CodeEditSourceEditor/Minimap/MinimapLineFragmentView.swift b/Sources/CodeEditSourceEditor/Minimap/MinimapLineFragmentView.swift index 94b084cb1..d46529fd1 100644 --- a/Sources/CodeEditSourceEditor/Minimap/MinimapLineFragmentView.swift +++ b/Sources/CodeEditSourceEditor/Minimap/MinimapLineFragmentView.swift @@ -119,7 +119,7 @@ final class MinimapLineFragmentView: LineFragmentView { height: 2.0 ) context.setFillColor(run.color.cgColor) - context.fill(rect.pixelAligned) + context.fill(rect) } context.restoreGState() diff --git a/Sources/CodeEditSourceEditor/Minimap/MinimapView+DocumentVisibleView.swift b/Sources/CodeEditSourceEditor/Minimap/MinimapView+DocumentVisibleView.swift index d1c4efb9d..63bbf7bce 100644 --- a/Sources/CodeEditSourceEditor/Minimap/MinimapView+DocumentVisibleView.swift +++ b/Sources/CodeEditSourceEditor/Minimap/MinimapView+DocumentVisibleView.swift @@ -38,14 +38,14 @@ extension MinimapView { } private func setScrollViewPosition(scrollPercentage: CGFloat) { - let totalHeight = contentView.frame.height + scrollView.contentInsets.top - let topInset = scrollView.contentInsets.top + let topInsets = scrollView.contentInsets.top + let totalHeight = contentView.frame.height + topInsets scrollView.contentView.scroll( to: NSPoint( x: scrollView.contentView.frame.origin.x, y: ( - scrollPercentage * (totalHeight - (scrollView.documentVisibleRect.height - topInset)) - ) - topInset + scrollPercentage * (totalHeight - (scrollView.documentVisibleRect.height - topInsets)) + ) - topInsets ) ) scrollView.reflectScrolledClipView(scrollView.contentView) diff --git a/Sources/CodeEditSourceEditor/Minimap/MinimapView+DragVisibleView.swift b/Sources/CodeEditSourceEditor/Minimap/MinimapView+DragVisibleView.swift new file mode 100644 index 000000000..5705efbbf --- /dev/null +++ b/Sources/CodeEditSourceEditor/Minimap/MinimapView+DragVisibleView.swift @@ -0,0 +1,46 @@ +// +// MinimapView+DragVisibleView.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 4/16/25. +// + +import AppKit + +extension MinimapView { + /// Responds to a drag gesture on the document visible view. Dragging the view scrolls the editor a relative amount. + @objc func documentVisibleViewDragged(_ sender: NSPanGestureRecognizer) { + guard let editorScrollView = textView?.enclosingScrollView else { + return + } + + // Convert the drag distance in the minimap to the drag distance in the editor. + let translation = sender.translation(in: documentVisibleView) + let ratio = if minimapHeight > containerHeight { + containerHeight / (textView?.frame.height ?? 0.0) + } else { + editorToMinimapHeightRatio + } + let editorTranslation = translation.y / ratio + sender.setTranslation(.zero, in: documentVisibleView) + + // Clamp the scroll amount to the content, so we don't scroll crazy far past the end of the document. + var newScrollViewY = editorScrollView.contentView.bounds.origin.y - editorTranslation + // Minimum Y value is the top of the scroll view + newScrollViewY = max(-editorScrollView.contentInsets.top, newScrollViewY) + newScrollViewY = min( // Max y value needs to take into account the editor overscroll + editorScrollView.documentMaxOriginY + - editorScrollView.contentInsets.top + + editorScrollView.contentInsets.bottom, + newScrollViewY + ) + + editorScrollView.contentView.scroll( + to: NSPoint( + x: editorScrollView.contentView.bounds.origin.x, + y: newScrollViewY + ) + ) + editorScrollView.reflectScrolledClipView(editorScrollView.contentView) + } +} diff --git a/Sources/CodeEditSourceEditor/Minimap/MinimapView.swift b/Sources/CodeEditSourceEditor/Minimap/MinimapView.swift index 78cc6ab46..1dad3ddf1 100644 --- a/Sources/CodeEditSourceEditor/Minimap/MinimapView.swift +++ b/Sources/CodeEditSourceEditor/Minimap/MinimapView.swift @@ -17,7 +17,7 @@ class MinimapView: FlippedNSView { let contentView: FlippedNSView /// The box displaying the visible region on the minimap. let documentVisibleView: NSView - + /// A small gray line on the left of the minimap distinguishing it from the editor. let separatorView: NSView var documentVisibleViewDragGesture: NSPanGestureRecognizer? @@ -52,6 +52,8 @@ class MinimapView: FlippedNSView { - (textView?.enclosingScrollView?.contentInsets.vertical ?? 0.0) } + // MARK: - Init + init(textView: TextView, theme: EditorTheme) { self.textView = textView self.theme = theme @@ -111,6 +113,8 @@ class MinimapView: FlippedNSView { setUpListeners() } + // MARK: - Constraints + private func setUpConstraints() { NSLayoutConstraint.activate([ // Constrain to all sides @@ -135,6 +139,8 @@ class MinimapView: FlippedNSView { ]) } + // MARK: - Scroll listeners + private func setUpListeners() { guard let editorScrollView = textView?.enclosingScrollView else { return } // Need to listen to: @@ -184,35 +190,4 @@ class MinimapView: FlippedNSView { return super.hitTest(point) } } - - /// Responds to a drag gesture on the document visible view. Dragging the view scrolls the editor a relative amount. - @objc func documentVisibleViewDragged(_ sender: NSPanGestureRecognizer) { - guard let editorScrollView = textView?.enclosingScrollView else { - return - } - - let translation = sender.translation(in: documentVisibleView) - let ratio = if minimapHeight > containerHeight { - containerHeight / (textView?.frame.height ?? 0.0) - } else { - editorToMinimapHeightRatio - } - let editorTranslation = translation.y / ratio - sender.setTranslation(.zero, in: documentVisibleView) - - var newScrollViewY = editorScrollView.contentView.bounds.origin.y - editorTranslation - newScrollViewY = max(-editorScrollView.contentInsets.top, newScrollViewY) - newScrollViewY = min( - editorScrollView.documentMaxOriginY - editorScrollView.contentInsets.top, - newScrollViewY - ) - - editorScrollView.contentView.scroll( - to: NSPoint( - x: editorScrollView.contentView.bounds.origin.x, - y: newScrollViewY - ) - ) - editorScrollView.reflectScrolledClipView(editorScrollView.contentView) - } } From b7f341fa13cd0ea1082a6de16a9f4e9f5945aff7 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Wed, 16 Apr 2025 16:11:49 -0500 Subject: [PATCH 07/23] Overscroll, Finalize Mouse Interaction, Document Everything --- .../Views/ContentView.swift | 3 +- .../Controller/TextViewController.swift | 2 +- .../MinimapView+DocumentVisibleView.swift | 17 +++- ...inimapView+TextLayoutManagerDelegate.swift | 13 +-- .../Minimap/MinimapView.swift | 97 +++++++++++++++---- .../SupportingViews/FlippedNSView.swift | 4 +- .../ForwardingScrollView.swift | 14 ++- 7 files changed, 114 insertions(+), 36 deletions(-) diff --git a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift index 5fedff134..b7bc0aa47 100644 --- a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift +++ b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift @@ -42,11 +42,12 @@ struct ContentView: View { tabWidth: 4, lineHeight: 1.2, wrapLines: wrapLines, + editorOverscroll: 0.3, cursorPositions: $cursorPositions, useThemeBackground: true, highlightProviders: [treeSitterClient], contentInsets: NSEdgeInsets(top: proxy.safeAreaInsets.top, left: 0, bottom: 28.0, right: 0), - additionalTextInsets: NSEdgeInsets(top: 1, left: 0, bottom: proxy.size.height * 0.3, right: 0), + additionalTextInsets: NSEdgeInsets(top: 1, left: 0, bottom: 1, right: 0), useSystemCursor: useSystemCursor ) .overlay(alignment: .bottom) { diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift index f3fccfe11..521b30b0c 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift @@ -68,7 +68,7 @@ public class TextViewController: NSViewController { highlighter?.invalidate() gutterView.textColor = theme.text.color.withAlphaComponent(0.35) gutterView.selectedLineTextColor = theme.text.color - minimapView.theme = theme + minimapView.setTheme(theme) } } diff --git a/Sources/CodeEditSourceEditor/Minimap/MinimapView+DocumentVisibleView.swift b/Sources/CodeEditSourceEditor/Minimap/MinimapView+DocumentVisibleView.swift index 63bbf7bce..a5fe058a7 100644 --- a/Sources/CodeEditSourceEditor/Minimap/MinimapView+DocumentVisibleView.swift +++ b/Sources/CodeEditSourceEditor/Minimap/MinimapView+DocumentVisibleView.swift @@ -8,6 +8,20 @@ import AppKit extension MinimapView { + /// Updates the ``documentVisibleView`` and ``scrollView`` to match the editor's scroll offset. + /// + /// - Note: In this context, the 'container' is the visible rect in the minimap. + /// - Note: This is *tricky*, there's two cases for both views. If modifying, make sure to test both when the + /// minimap is shorter than the container height and when the minimap should scroll. + /// + /// The ``documentVisibleView`` uses a position that's entirely relative to the percent of the available scroll height scrolled. + /// If the minimap is smaller than the container, it uses the same percent scrolled, but as a percent of the minimap height. + /// + /// The height of the ``documentVisibleView`` is calculated using a ratio of the editor's height to the + /// minimap's height, then applying that to the container's height. + /// + /// The ``scrollView`` uses the scroll percentage calculated for the first case, and scrolls its content to that percentage. + /// The ``scrollView`` is only modified if the minimap is longer than the container view. func updateDocumentVisibleViewPosition() { guard let textView = textView, let editorScrollView = textView.enclosingScrollView, let layoutManager else { return @@ -17,7 +31,8 @@ extension MinimapView { let scrollPercentage = editorScrollView.percentScrolled guard scrollPercentage.isFinite else { return } - // Update Visible Pane, should scroll down slowly as the user scrolls the document, following the scroller. + // Update Visible Pane, should scroll down slowly as the user scrolls the document, following a similar pace + // as the vertical `NSScroller`. // Visible pane's height = scrollview visible height * (minimap line height / editor line height) // Visible pane's position = (container height - visible pane height) * scrollPercentage let visibleRectHeight = containerHeight * editorToMinimapHeightRatio diff --git a/Sources/CodeEditSourceEditor/Minimap/MinimapView+TextLayoutManagerDelegate.swift b/Sources/CodeEditSourceEditor/Minimap/MinimapView+TextLayoutManagerDelegate.swift index 116f0b921..dd55c339f 100644 --- a/Sources/CodeEditSourceEditor/Minimap/MinimapView+TextLayoutManagerDelegate.swift +++ b/Sources/CodeEditSourceEditor/Minimap/MinimapView+TextLayoutManagerDelegate.swift @@ -9,24 +9,25 @@ import AppKit import CodeEditTextView extension MinimapView: TextLayoutManagerDelegate { - func layoutManagerHeightDidUpdate(newHeight: CGFloat) { - contentView.frame.size.height = newHeight + public func layoutManagerHeightDidUpdate(newHeight: CGFloat) { +// contentView.frame.size.height = newHeight + updateContentViewHeight() } - func layoutManagerMaxWidthDidChange(newWidth: CGFloat) { } + public func layoutManagerMaxWidthDidChange(newWidth: CGFloat) { } - func layoutManagerTypingAttributes() -> [NSAttributedString.Key: Any] { + public func layoutManagerTypingAttributes() -> [NSAttributedString.Key: Any] { textView?.layoutManagerTypingAttributes() ?? [:] } - func textViewportSize() -> CGSize { + public func textViewportSize() -> CGSize { var size = scrollView.contentSize size.height -= scrollView.contentInsets.top + scrollView.contentInsets.bottom size.width = textView?.layoutManager.maxLineLayoutWidth ?? size.width return size } - func layoutManagerYAdjustment(_ yAdjustment: CGFloat) { + public func layoutManagerYAdjustment(_ yAdjustment: CGFloat) { var point = scrollView.documentVisibleRect.origin point.y += yAdjustment scrollView.documentView?.scroll(point) diff --git a/Sources/CodeEditSourceEditor/Minimap/MinimapView.swift b/Sources/CodeEditSourceEditor/Minimap/MinimapView.swift index 1dad3ddf1..2867a61e4 100644 --- a/Sources/CodeEditSourceEditor/Minimap/MinimapView.swift +++ b/Sources/CodeEditSourceEditor/Minimap/MinimapView.swift @@ -8,18 +8,35 @@ import AppKit import CodeEditTextView -class MinimapView: FlippedNSView { +/// The minimap view displays a copy of editor contents as a series of small bubbles in place of text. +/// +/// This view consists of the following subviews in order +/// ``` +/// MinimapView +/// |-> separatorView: A small, grey, leading, separator that distinguishes the minimap from other content. +/// |-> documentVisibleView: Displays a rectangle that represents the portion of the minimap visible in the editor's +/// | visible rect. This is draggable and responds to the editor's height. +/// |-> scrollView: Container for the summary bubbles +/// | |-> contentView: Target view for the summary bubble content +/// ``` +/// +/// To keep contents in sync with the text view, this view requires that its ``scrollView`` have the same vertical +/// content insets as the editor's content insets. +/// +/// The minimap can be styled using an ``EditorTheme``. See ``setTheme(_:)`` for use and colors used by this view. +public class MinimapView: FlippedNSView { weak var textView: TextView? /// The container scrollview for the minimap contents. - let scrollView: ForwardingScrollView + public let scrollView: ForwardingScrollView /// The view text lines are rendered into. - let contentView: FlippedNSView + public let contentView: FlippedNSView /// The box displaying the visible region on the minimap. - let documentVisibleView: NSView + public let documentVisibleView: NSView /// A small gray line on the left of the minimap distinguishing it from the editor. - let separatorView: NSView + public let separatorView: NSView + /// Responder for a drag gesture on the ``documentVisibleView``. var documentVisibleViewDragGesture: NSPanGestureRecognizer? /// The layout manager that uses the ``lineRenderer`` to render and layout lines. @@ -28,35 +45,33 @@ class MinimapView: FlippedNSView { /// using ``MinimapLineFragmentView`` let lineRenderer: MinimapLineRenderer - var theme: EditorTheme { - didSet { - documentVisibleView.layer?.backgroundColor = theme.text.color.withAlphaComponent(0.05).cgColor - layer?.backgroundColor = theme.background.cgColor - } - } + // MARK: - Calculated Variables var minimapHeight: CGFloat { contentView.frame.height } var editorHeight: CGFloat { - textView?.frame.height ?? 0.0 + textView?.layoutManager.estimatedHeight() ?? 1.0 } var editorToMinimapHeightRatio: CGFloat { minimapHeight / editorHeight } + /// The height of the available container, less the scroll insets to reflect the visible height. var containerHeight: CGFloat { - (textView?.enclosingScrollView?.visibleRect.height ?? 0.0) - - (textView?.enclosingScrollView?.contentInsets.vertical ?? 0.0) + scrollView.visibleRect.height - scrollView.contentInsets.vertical } // MARK: - Init - init(textView: TextView, theme: EditorTheme) { + /// Creates a minimap view with the text view to track, and an initial theme. + /// - Parameters: + /// - textView: The text view to match contents with. + /// - theme: The theme for the minimap to use. + public init(textView: TextView, theme: EditorTheme) { self.textView = textView - self.theme = theme self.lineRenderer = MinimapLineRenderer(textView: textView) self.scrollView = ForwardingScrollView() @@ -67,7 +82,7 @@ class MinimapView: FlippedNSView { scrollView.verticalScrollElasticity = .none scrollView.receiver = textView.enclosingScrollView - self.contentView = FlippedNSView(frame: .zero) + self.contentView = FlippedNSView() contentView.translatesAutoresizingMaskIntoConstraints = false self.documentVisibleView = NSView() @@ -162,6 +177,7 @@ class MinimapView: FlippedNSView { queue: .main ) { [weak self] _ in // Frame changed + self?.updateContentViewHeight() self?.updateDocumentVisibleViewPosition() } } @@ -176,18 +192,59 @@ class MinimapView: FlippedNSView { return rect.pixelAligned } - override func layout() { + override public func resetCursorRects() { + // Don't use an iBeam + addCursorRect(bounds, cursor: .arrow) + } + + override public func layout() { layoutManager?.layoutLines() super.layout() } - override func hitTest(_ point: NSPoint) -> NSView? { + override public func hitTest(_ point: NSPoint) -> NSView? { + guard let point = superview?.convert(point, to: self) else { return nil } + // For performance, don't hitTest the layout fragment views, but make sure the `documentVisibleView` is + // hittable. if documentVisibleView.frame.contains(point) { return documentVisibleView } else if visibleRect.contains(point) { - return textView + return self } else { return super.hitTest(point) } } + + // Eat mouse events so we don't pass them on to the text view. Leads to some odd behavior. + + override public func mouseDown(with event: NSEvent) { } + override public func mouseDragged(with event: NSEvent) { } + + /// Sets the content view height, matching the text view's overscroll setting as well as the layout manager's + /// cached height. + func updateContentViewHeight() { + guard let estimatedContentHeight = layoutManager?.estimatedHeight(), + let overscrollAmount = textView?.overscrollAmount else { + return + } + let overscroll = containerHeight * overscrollAmount * editorToMinimapHeightRatio + let height = estimatedContentHeight + overscroll + + // Only update a frame if needed + if contentView.frame.height != height { + contentView.frame.size.height = height + } + } + + /// Updates the minimap to reflect a new theme. + /// + /// Colors used: + /// - ``documentVisibleView``'s background color = `theme.text` with `0.05` alpha. + /// - The minimap's background color = `theme.background`. + /// + /// - Parameter theme: The selected theme. + public func setTheme(_ theme: EditorTheme) { + documentVisibleView.layer?.backgroundColor = theme.text.color.withAlphaComponent(0.05).cgColor + layer?.backgroundColor = theme.background.cgColor + } } diff --git a/Sources/CodeEditSourceEditor/SupportingViews/FlippedNSView.swift b/Sources/CodeEditSourceEditor/SupportingViews/FlippedNSView.swift index cfe6b0fd8..5ed8ab40a 100644 --- a/Sources/CodeEditSourceEditor/SupportingViews/FlippedNSView.swift +++ b/Sources/CodeEditSourceEditor/SupportingViews/FlippedNSView.swift @@ -7,6 +7,6 @@ import AppKit -class FlippedNSView: NSView { - override var isFlipped: Bool { true } +open class FlippedNSView: NSView { + open override var isFlipped: Bool { true } } diff --git a/Sources/CodeEditSourceEditor/SupportingViews/ForwardingScrollView.swift b/Sources/CodeEditSourceEditor/SupportingViews/ForwardingScrollView.swift index 92595e58d..fc3fc2aa5 100644 --- a/Sources/CodeEditSourceEditor/SupportingViews/ForwardingScrollView.swift +++ b/Sources/CodeEditSourceEditor/SupportingViews/ForwardingScrollView.swift @@ -7,12 +7,16 @@ import Cocoa -class ForwardingScrollView: NSScrollView { +/// A custom ``NSScrollView`` subclass that forwards scroll wheel events to another scroll view. +/// This class does not process any other scrolling events. However, it still lays out it's contents like a +/// regular scroll view. +/// +/// Set ``receiver`` to target events. +open class ForwardingScrollView: NSScrollView { + /// The target scroll view to send scroll events to. + open weak var receiver: NSScrollView? - weak var receiver: NSScrollView? - - override func scrollWheel(with event: NSEvent) { + open override func scrollWheel(with event: NSEvent) { receiver?.scrollWheel(with: event) } - } From b5c4f8a314fed579b0cb4c202b14eadc9beb218d Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Thu, 17 Apr 2025 09:43:10 -0500 Subject: [PATCH 08/23] Draw Selections --- .../Minimap/MinimapLineFragmentView.swift | 6 ++-- .../Minimap/MinimapLineRenderer.swift | 4 +++ .../Minimap/MinimapView+Draw.swift | 21 ++++++++++++++ ...inimapView+TextLayoutManagerDelegate.swift | 1 - ...mapView+TextSelectionManagerDelegate.swift | 29 +++++++++++++++++++ .../Minimap/MinimapView.swift | 24 +++++++++++++-- 6 files changed, 79 insertions(+), 6 deletions(-) create mode 100644 Sources/CodeEditSourceEditor/Minimap/MinimapView+Draw.swift create mode 100644 Sources/CodeEditSourceEditor/Minimap/MinimapView+TextSelectionManagerDelegate.swift diff --git a/Sources/CodeEditSourceEditor/Minimap/MinimapLineFragmentView.swift b/Sources/CodeEditSourceEditor/Minimap/MinimapLineFragmentView.swift index d46529fd1..bab62283b 100644 --- a/Sources/CodeEditSourceEditor/Minimap/MinimapLineFragmentView.swift +++ b/Sources/CodeEditSourceEditor/Minimap/MinimapLineFragmentView.swift @@ -10,7 +10,7 @@ import CodeEditTextView /// A custom line fragment view for the minimap. /// -/// Instead of drawing line contents, this view calculates a series of boxes or 'runs' to draw to represent the text +/// Instead of drawing line contents, this view calculates a series of bubbles or 'runs' to draw to represent the text /// in the line fragment. /// /// Runs are calculated when the view's fragment is set, and cached until invalidated, and all whitespace @@ -114,12 +114,12 @@ final class MinimapLineFragmentView: LineFragmentView { for run in drawingRuns { let rect = CGRect( x: 8 + (CGFloat(run.range.location) * 1.5), - y: 0, + y: 0.25, width: CGFloat(run.range.length) * 1.5, height: 2.0 ) context.setFillColor(run.color.cgColor) - context.fill(rect) + context.fill(rect.pixelAligned) } context.restoreGState() diff --git a/Sources/CodeEditSourceEditor/Minimap/MinimapLineRenderer.swift b/Sources/CodeEditSourceEditor/Minimap/MinimapLineRenderer.swift index 97997de04..cee62ee53 100644 --- a/Sources/CodeEditSourceEditor/Minimap/MinimapLineRenderer.swift +++ b/Sources/CodeEditSourceEditor/Minimap/MinimapLineRenderer.swift @@ -59,4 +59,8 @@ final class MinimapLineRenderer: TextLayoutManagerRenderDelegate { func lineFragmentView(for lineFragment: LineFragment) -> LineFragmentView { MinimapLineFragmentView(textStorage: textView?.textStorage) } + + func characterXPosition(in lineFragment: LineFragment, for offset: Int) -> CGFloat { + 8 + (CGFloat(offset) * 1.5) + } } diff --git a/Sources/CodeEditSourceEditor/Minimap/MinimapView+Draw.swift b/Sources/CodeEditSourceEditor/Minimap/MinimapView+Draw.swift new file mode 100644 index 000000000..244d629e7 --- /dev/null +++ b/Sources/CodeEditSourceEditor/Minimap/MinimapView+Draw.swift @@ -0,0 +1,21 @@ +// +// MinimapView+Draw.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 4/16/25. +// + +import AppKit +import CodeEditTextView + +public class MinimapContentView: FlippedNSView { + weak var textView: TextView? + weak var selectionManager: TextSelectionManager? + + override public func draw(_ dirtyRect: NSRect) { + super.draw(dirtyRect) + if textView?.isSelectable ?? false { + selectionManager?.drawSelections(in: dirtyRect) + } + } +} diff --git a/Sources/CodeEditSourceEditor/Minimap/MinimapView+TextLayoutManagerDelegate.swift b/Sources/CodeEditSourceEditor/Minimap/MinimapView+TextLayoutManagerDelegate.swift index dd55c339f..f58afebe1 100644 --- a/Sources/CodeEditSourceEditor/Minimap/MinimapView+TextLayoutManagerDelegate.swift +++ b/Sources/CodeEditSourceEditor/Minimap/MinimapView+TextLayoutManagerDelegate.swift @@ -10,7 +10,6 @@ import CodeEditTextView extension MinimapView: TextLayoutManagerDelegate { public func layoutManagerHeightDidUpdate(newHeight: CGFloat) { -// contentView.frame.size.height = newHeight updateContentViewHeight() } diff --git a/Sources/CodeEditSourceEditor/Minimap/MinimapView+TextSelectionManagerDelegate.swift b/Sources/CodeEditSourceEditor/Minimap/MinimapView+TextSelectionManagerDelegate.swift new file mode 100644 index 000000000..c01d75a96 --- /dev/null +++ b/Sources/CodeEditSourceEditor/Minimap/MinimapView+TextSelectionManagerDelegate.swift @@ -0,0 +1,29 @@ +// +// MinimapView+TextSelectionManagerDelegate.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 4/16/25. +// + +import AppKit +import CodeEditTextView + +extension MinimapView: TextSelectionManagerDelegate { + public var visibleTextRange: NSRange? { + let minY = max(visibleRect.minY, 0) + let maxY = min(visibleRect.maxY, layoutManager?.estimatedHeight() ?? 3.0) + guard let minYLine = layoutManager?.textLineForPosition(minY), + let maxYLine = layoutManager?.textLineForPosition(maxY) else { + return nil + } + return NSRange(start: minYLine.range.location, end: maxYLine.range.max) + } + + public func setNeedsDisplay() { + contentView.needsDisplay = true + } + + public func estimatedLineHeight() -> CGFloat { + layoutManager?.estimateLineHeight() ?? 3.0 + } +} diff --git a/Sources/CodeEditSourceEditor/Minimap/MinimapView.swift b/Sources/CodeEditSourceEditor/Minimap/MinimapView.swift index 2867a61e4..562f0e677 100644 --- a/Sources/CodeEditSourceEditor/Minimap/MinimapView.swift +++ b/Sources/CodeEditSourceEditor/Minimap/MinimapView.swift @@ -30,7 +30,7 @@ public class MinimapView: FlippedNSView { /// The container scrollview for the minimap contents. public let scrollView: ForwardingScrollView /// The view text lines are rendered into. - public let contentView: FlippedNSView + public let contentView: MinimapContentView /// The box displaying the visible region on the minimap. public let documentVisibleView: NSView /// A small gray line on the left of the minimap distinguishing it from the editor. @@ -41,6 +41,7 @@ public class MinimapView: FlippedNSView { /// The layout manager that uses the ``lineRenderer`` to render and layout lines. var layoutManager: TextLayoutManager? + var selectionManager: TextSelectionManager? /// A custom line renderer that lays out lines of text as 2px tall and draws contents as small lines /// using ``MinimapLineFragmentView`` let lineRenderer: MinimapLineRenderer @@ -82,7 +83,7 @@ public class MinimapView: FlippedNSView { scrollView.verticalScrollElasticity = .none scrollView.receiver = textView.enclosingScrollView - self.contentView = FlippedNSView() + self.contentView = MinimapContentView() contentView.translatesAutoresizingMaskIntoConstraints = false self.documentVisibleView = NSView() @@ -121,6 +122,15 @@ public class MinimapView: FlippedNSView { self.layoutManager = layoutManager (textView.textStorage.delegate as? MultiStorageDelegate)?.addDelegate(layoutManager) + self.selectionManager = TextSelectionManager( + layoutManager: layoutManager, + textStorage: textView.textStorage, + textView: textView, + delegate: self + ) + contentView.textView = textView + contentView.selectionManager = selectionManager + wantsLayer = true layer?.backgroundColor = theme.background.cgColor @@ -180,6 +190,16 @@ public class MinimapView: FlippedNSView { self?.updateContentViewHeight() self?.updateDocumentVisibleViewPosition() } + + NotificationCenter.default.addObserver( + forName: TextSelectionManager.selectionChangedNotification, + object: textView?.selectionManager, + queue: .main + ) { [weak self] _ in + self?.selectionManager?.setSelectedRanges( + self?.textView?.selectionManager.textSelections.map(\.range) ?? [] + ) + } } required init?(coder: NSCoder) { From da173aea03e280667b8a067e7d790786e790ebe5 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Thu, 17 Apr 2025 09:54:29 -0500 Subject: [PATCH 09/23] Small Bugfix with Selection Drawing --- Sources/CodeEditSourceEditor/Minimap/MinimapLineRenderer.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Sources/CodeEditSourceEditor/Minimap/MinimapLineRenderer.swift b/Sources/CodeEditSourceEditor/Minimap/MinimapLineRenderer.swift index cee62ee53..f4de2e376 100644 --- a/Sources/CodeEditSourceEditor/Minimap/MinimapLineRenderer.swift +++ b/Sources/CodeEditSourceEditor/Minimap/MinimapLineRenderer.swift @@ -61,6 +61,7 @@ final class MinimapLineRenderer: TextLayoutManagerRenderDelegate { } func characterXPosition(in lineFragment: LineFragment, for offset: Int) -> CGFloat { - 8 + (CGFloat(offset) * 1.5) + // Offset is relative to the whole line, the CTLine is too. + return 8 + (CGFloat(offset - CTLineGetStringRange(lineFragment.ctLine).location) * 1.5) } } From 98808d5fe6582a7600403ca489fd39dbf43b9393 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Thu, 17 Apr 2025 10:22:20 -0500 Subject: [PATCH 10/23] Toggle Minimap --- .../project.pbxproj | 4 + .../Views/ContentView.swift | 99 +++------------ .../Views/Toolbar.swift | 116 ++++++++++++++++++ .../CodeEditSourceEditor.swift | 14 ++- .../TextViewController+LoadView.swift | 2 + .../TextViewController+StyleViews.swift | 19 ++- .../Controller/TextViewController.swift | 20 ++- 7 files changed, 179 insertions(+), 95 deletions(-) create mode 100644 Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/Toolbar.swift diff --git a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.pbxproj b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.pbxproj index 328c585b6..950434684 100644 --- a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.pbxproj +++ b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.pbxproj @@ -19,6 +19,7 @@ 6C1365462B8A7F2D004A1D18 /* LanguagePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C1365452B8A7F2D004A1D18 /* LanguagePicker.swift */; }; 6C1365482B8A7FBF004A1D18 /* EditorTheme+Default.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C1365472B8A7FBF004A1D18 /* EditorTheme+Default.swift */; }; 6C13654D2B8A821E004A1D18 /* NSColor+Hex.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C13654C2B8A821E004A1D18 /* NSColor+Hex.swift */; }; + 6C2EE57E2DB1522E007E0A26 /* Toolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C2EE57D2DB1522E007E0A26 /* Toolbar.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -35,6 +36,7 @@ 6C1365452B8A7F2D004A1D18 /* LanguagePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LanguagePicker.swift; sourceTree = ""; }; 6C1365472B8A7FBF004A1D18 /* EditorTheme+Default.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EditorTheme+Default.swift"; sourceTree = ""; }; 6C13654C2B8A821E004A1D18 /* NSColor+Hex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSColor+Hex.swift"; sourceTree = ""; }; + 6C2EE57D2DB1522E007E0A26 /* Toolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Toolbar.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -113,6 +115,7 @@ isa = PBXGroup; children = ( 6C1365312B8A7B94004A1D18 /* ContentView.swift */, + 6C2EE57D2DB1522E007E0A26 /* Toolbar.swift */, 6C1365452B8A7F2D004A1D18 /* LanguagePicker.swift */, ); path = Views; @@ -206,6 +209,7 @@ 6C1365482B8A7FBF004A1D18 /* EditorTheme+Default.swift in Sources */, 6C13654D2B8A821E004A1D18 /* NSColor+Hex.swift in Sources */, 6C1365302B8A7B94004A1D18 /* CodeEditSourceEditorExampleDocument.swift in Sources */, + 6C2EE57E2DB1522E007E0A26 /* Toolbar.swift in Sources */, 6C13652E2B8A7B94004A1D18 /* CodeEditSourceEditorExampleApp.swift in Sources */, 6C1365442B8A7EED004A1D18 /* String+Lines.swift in Sources */, 6C1365322B8A7B94004A1D18 /* ContentView.swift in Sources */, diff --git a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift index b7bc0aa47..a3c921c72 100644 --- a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift +++ b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift @@ -26,6 +26,7 @@ struct ContentView: View { @State private var isInLongParse = false @State private var settingsIsPresented: Bool = false @State private var treeSitterClient = TreeSitterClient() + @AppStorage("showMinimap") private var showMinimap: Bool = true init(document: Binding, fileURL: URL?) { self._document = document @@ -48,67 +49,21 @@ struct ContentView: View { highlightProviders: [treeSitterClient], contentInsets: NSEdgeInsets(top: proxy.safeAreaInsets.top, left: 0, bottom: 28.0, right: 0), additionalTextInsets: NSEdgeInsets(top: 1, left: 0, bottom: 1, right: 0), - useSystemCursor: useSystemCursor + useSystemCursor: useSystemCursor, + showMinimap: showMinimap ) .overlay(alignment: .bottom) { - HStack { - Menu { - Toggle("Wrap Lines", isOn: $wrapLines) - if #available(macOS 14, *) { - Toggle("Use System Cursor", isOn: $useSystemCursor) - } else { - Toggle("Use System Cursor", isOn: $useSystemCursor) - .disabled(true) - .help("macOS 14 required") - } - } label: {} - .background { - Image(systemName: "switch.2") - .foregroundStyle(.secondary) - .font(.system(size: 13.5, weight: .regular)) - } - .menuStyle(.borderlessButton) - .menuIndicator(.hidden) - .frame(maxWidth: 18, alignment: .center) - Spacer() - Group { - if isInLongParse { - HStack(spacing: 5) { - ProgressView() - .controlSize(.small) - Text("Parsing Document") - } - } else { - Text(getLabel(cursorPositions)) - } - } - .foregroundStyle(.secondary) - Divider() - .frame(height: 12) - LanguagePicker(language: $language) - .buttonStyle(.borderless) - } - .font(.subheadline) - .fontWeight(.medium) - .controlSize(.small) - .padding(.horizontal, 8) - .frame(height: 28) - .background(.bar) - .overlay(alignment: .top) { - VStack { - Divider() - .overlay { - if colorScheme == .dark { - Color.black - } - } - } - } - .zIndex(2) - .onAppear { - self.language = detectLanguage(fileURL: fileURL) ?? .default - self.theme = colorScheme == .dark ? .dark : .light - } + Toolbar( + fileURL: fileURL, + document: $document, + wrapLines: $wrapLines, + useSystemCursor: $useSystemCursor, + cursorPositions: $cursorPositions, + isInLongParse: $isInLongParse, + language: $language, + theme: $theme, + showMinimap: $showMinimap + ) } .ignoresSafeArea() .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -131,32 +86,6 @@ struct ContentView: View { } } } - - private func detectLanguage(fileURL: URL?) -> CodeLanguage? { - guard let fileURL else { return nil } - return CodeLanguage.detectLanguageFrom( - url: fileURL, - prefixBuffer: document.text.getFirstLines(5), - suffixBuffer: document.text.getLastLines(5) - ) - } - - /// Create a label string for cursor positions. - /// - Parameter cursorPositions: The cursor positions to create the label for. - /// - Returns: A string describing the user's location in a document. - func getLabel(_ cursorPositions: [CursorPosition]) -> String { - if cursorPositions.isEmpty { - return "No cursor" - } - - // More than one selection, display the number of selections. - if cursorPositions.count > 1 { - return "\(cursorPositions.count) selected ranges" - } - - // When there's a single cursor, display the line and column. - return "Line: \(cursorPositions[0].line) Col: \(cursorPositions[0].column) Range: \(cursorPositions[0].range)" - } } #Preview { diff --git a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/Toolbar.swift b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/Toolbar.swift new file mode 100644 index 000000000..d3c7afeb0 --- /dev/null +++ b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/Toolbar.swift @@ -0,0 +1,116 @@ +// +// Toolbar.swift +// CodeEditSourceEditorExample +// +// Created by Khan Winter on 4/17/25. +// + +import SwiftUI +import CodeEditSourceEditor +import CodeEditLanguages + +struct Toolbar: View { + let fileURL: URL? + + @Environment(\.colorScheme) + var colorScheme + + @Binding var document: CodeEditSourceEditorExampleDocument + @Binding var wrapLines: Bool + @Binding var useSystemCursor: Bool + @Binding var cursorPositions: [CursorPosition] + @Binding var isInLongParse: Bool + @Binding var language: CodeLanguage + @Binding var theme: EditorTheme + @Binding var showMinimap: Bool + + var body: some View { + HStack { + Menu { + Toggle("Wrap Lines", isOn: $wrapLines) + Toggle("Show Minimap", isOn: $showMinimap) + if #available(macOS 14, *) { + Toggle("Use System Cursor", isOn: $useSystemCursor) + } else { + Toggle("Use System Cursor", isOn: $useSystemCursor) + .disabled(true) + .help("macOS 14 required") + } + } label: {} + .background { + Image(systemName: "switch.2") + .foregroundStyle(.secondary) + .font(.system(size: 13.5, weight: .regular)) + } + .menuStyle(.borderlessButton) + .menuIndicator(.hidden) + .frame(maxWidth: 18, alignment: .center) + + Spacer() + + Group { + if isInLongParse { + HStack(spacing: 5) { + ProgressView() + .controlSize(.small) + Text("Parsing Document") + } + } else { + Text(getLabel(cursorPositions)) + } + } + .foregroundStyle(.secondary) + Divider() + .frame(height: 12) + LanguagePicker(language: $language) + .buttonStyle(.borderless) + } + .font(.subheadline) + .fontWeight(.medium) + .controlSize(.small) + .padding(.horizontal, 8) + .frame(height: 28) + .background(.bar) + .overlay(alignment: .top) { + VStack { + Divider() + .overlay { + if colorScheme == .dark { + Color.black + } + } + } + } + .zIndex(2) + .onAppear { + self.language = detectLanguage(fileURL: fileURL) ?? .default + self.theme = colorScheme == .dark ? .dark : .light + } + } + + private func detectLanguage(fileURL: URL?) -> CodeLanguage? { + guard let fileURL else { return nil } + return CodeLanguage.detectLanguageFrom( + url: fileURL, + prefixBuffer: document.text.getFirstLines(5), + suffixBuffer: document.text.getLastLines(5) + ) + } + + /// Create a label string for cursor positions. + /// - Parameter cursorPositions: The cursor positions to create the label for. + /// - Returns: A string describing the user's location in a document. + func getLabel(_ cursorPositions: [CursorPosition]) -> String { + if cursorPositions.isEmpty { + return "No cursor" + } + + // More than one selection, display the number of selections. + if cursorPositions.count > 1 { + return "\(cursorPositions.count) selected ranges" + } + + // When there's a single cursor, display the line and column. + return "Line: \(cursorPositions[0].line) Col: \(cursorPositions[0].column) Range: \(cursorPositions[0].range)" + } +} diff --git a/Sources/CodeEditSourceEditor/CodeEditSourceEditor/CodeEditSourceEditor.swift b/Sources/CodeEditSourceEditor/CodeEditSourceEditor/CodeEditSourceEditor.swift index 4d0c64e5d..67bc709bd 100644 --- a/Sources/CodeEditSourceEditor/CodeEditSourceEditor/CodeEditSourceEditor.swift +++ b/Sources/CodeEditSourceEditor/CodeEditSourceEditor/CodeEditSourceEditor.swift @@ -71,7 +71,8 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { bracketPairEmphasis: BracketPairEmphasis? = .flash, useSystemCursor: Bool = true, undoManager: CEUndoManager? = nil, - coordinators: [any TextViewCoordinator] = [] + coordinators: [any TextViewCoordinator] = [], + showMinimap: Bool ) { self.text = .binding(text) self.language = language @@ -98,6 +99,7 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { } self.undoManager = undoManager self.coordinators = coordinators + self.showMinimap = showMinimap } /// Initializes a Text Editor @@ -148,7 +150,8 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { bracketPairEmphasis: BracketPairEmphasis? = .flash, useSystemCursor: Bool = true, undoManager: CEUndoManager? = nil, - coordinators: [any TextViewCoordinator] = [] + coordinators: [any TextViewCoordinator] = [], + showMinimap: Bool ) { self.text = .storage(text) self.language = language @@ -175,6 +178,7 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { } self.undoManager = undoManager self.coordinators = coordinators + self.showMinimap = showMinimap } package var text: TextAPI @@ -198,6 +202,7 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { private var useSystemCursor: Bool private var undoManager: CEUndoManager? package var coordinators: [any TextViewCoordinator] + package var showMinimap: Bool public typealias NSViewControllerType = TextViewController @@ -223,7 +228,8 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { useSystemCursor: useSystemCursor, bracketPairEmphasis: bracketPairEmphasis, undoManager: undoManager, - coordinators: coordinators + coordinators: coordinators, + showMinimap: showMinimap ) switch text { case .binding(let binding): @@ -301,6 +307,7 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { controller.editorOverscroll = editorOverscroll controller.contentInsets = contentInsets controller.additionalTextInsets = additionalTextInsets + controller.showMinimap = showMinimap if controller.indentOption != indentOption { controller.indentOption = indentOption @@ -359,6 +366,7 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable { controller.letterSpacing == letterSpacing && controller.bracketPairEmphasis == bracketPairEmphasis && controller.useSystemCursor == useSystemCursor && + controller.showMinimap == showMinimap && areHighlightProvidersEqual(controller: controller) } diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift index eb396c9cd..4b8ffc74f 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift @@ -29,6 +29,7 @@ extension TextViewController { ) minimapView = MinimapView(textView: textView, theme: theme) + minimapView.isHidden = !showMinimap scrollView.addFloatingSubview(minimapView, for: .vertical) let findViewController = FindViewController(target: self, childView: scrollView) @@ -114,6 +115,7 @@ extension TextViewController { self?.textView.updatedViewport(self?.scrollView.documentVisibleRect ?? .zero) self?.gutterView.needsDisplay = true self?.emphasisManager?.removeEmphases(for: EmphasisGroup.brackets) + self?.updateTextInsets() } NotificationCenter.default.addObserver( diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift index d7efb78ee..2298b11c2 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift @@ -68,6 +68,8 @@ extension TextViewController { } package func updateContentInsets() { + updateTextInsets() + scrollView.contentView.postsBoundsChangedNotifications = true if let contentInsets { scrollView.automaticallyAdjustsContentInsets = false @@ -81,20 +83,31 @@ extension TextViewController { minimapView.scrollView.automaticallyAdjustsContentInsets = true } + // `additionalTextInsets` only effects text content. scrollView.contentInsets.top += additionalTextInsets?.top ?? 0 scrollView.contentInsets.bottom += additionalTextInsets?.bottom ?? 0 minimapView.scrollView.contentInsets.top += additionalTextInsets?.top ?? 0 minimapView.scrollView.contentInsets.bottom += additionalTextInsets?.bottom ?? 0 + // Inset the top by the find panel height let findInset = (findViewController?.isShowingFindPanel ?? false) ? FindPanel.height : 0 scrollView.contentInsets.top += findInset minimapView.scrollView.contentInsets.top += findInset - scrollView.reflectScrolledClipView(scrollView.contentView) - minimapView.scrollView.reflectScrolledClipView(minimapView.scrollView.contentView) - findViewController?.topPadding = contentInsets?.top gutterView.frame.origin.y = -scrollView.contentInsets.top + + // Update scrollview tiling + scrollView.reflectScrolledClipView(scrollView.contentView) + minimapView.scrollView.reflectScrolledClipView(minimapView.scrollView.contentView) + } + + func updateTextInsets() { + // Allow this method to be called before ``loadView()`` + guard textView != nil, minimapView != nil else { return } + if textView.textInsets != textViewInsets { + textView.textInsets = textViewInsets + } } } diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift index 521b30b0c..b8fe78f56 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift @@ -101,7 +101,7 @@ public class TextViewController: NSViewController { textView.layoutManager.wrapLines = wrapLines minimapView.layoutManager?.wrapLines = wrapLines scrollView.hasHorizontalScroller = !wrapLines - textView.textInsets = textViewInsets + updateTextInsets() } } @@ -193,6 +193,16 @@ public class TextViewController: NSViewController { } } + public var showMinimap: Bool { + didSet { + minimapView?.isHidden = !showMinimap + if scrollView != nil { // Check for view existence + updateContentInsets() + updateTextInsets() + } + } + } + var textCoordinators: [WeakCoordinator] = [] var highlighter: Highlighter? @@ -209,11 +219,11 @@ public class TextViewController: NSViewController { internal var cancellables = Set() - /// The trailing inset for the editor. Grows when line wrapping is disabled. + /// The trailing inset for the editor. Grows when line wrapping is disabled or when the minimap is shown. package var textViewTrailingInset: CGFloat { // See https://github.com/CodeEditApp/CodeEditTextView/issues/66 // wrapLines ? 1 : 48 - 0 + (minimapView?.isHidden ?? false) ? 0 : (minimapView?.frame.width ?? 0.0) } package var textViewInsets: HorizontalEdgeInsets { @@ -246,7 +256,8 @@ public class TextViewController: NSViewController { useSystemCursor: Bool, bracketPairEmphasis: BracketPairEmphasis?, undoManager: CEUndoManager? = nil, - coordinators: [TextViewCoordinator] = [] + coordinators: [TextViewCoordinator] = [], + showMinimap: Bool ) { self.language = language self.font = font @@ -266,6 +277,7 @@ public class TextViewController: NSViewController { self.letterSpacing = letterSpacing self.bracketPairEmphasis = bracketPairEmphasis self._undoManager = undoManager + self.showMinimap = showMinimap super.init(nibName: nil, bundle: nil) From 8976e1feef057b9f2f8cc85cd040a3e59b56820c Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Thu, 17 Apr 2025 10:32:34 -0500 Subject: [PATCH 11/23] Docs & Linter --- .../Views/Toolbar.swift | 3 ++ .../MinimapView+DocumentVisibleView.swift | 9 ++-- .../Minimap/MinimapView.swift | 52 +++++++++++++------ 3 files changed, 44 insertions(+), 20 deletions(-) diff --git a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/Toolbar.swift b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/Toolbar.swift index d3c7afeb0..8a55b50d6 100644 --- a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/Toolbar.swift +++ b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/Toolbar.swift @@ -23,6 +23,7 @@ struct Toolbar: View { @Binding var language: CodeLanguage @Binding var theme: EditorTheme @Binding var showMinimap: Bool + @Binding var indentOption: IndentOption var body: some View { HStack { @@ -64,6 +65,8 @@ struct Toolbar: View { .frame(height: 12) LanguagePicker(language: $language) .buttonStyle(.borderless) + IndentPicker(indentOption: $indentOption, enabled: document.text.isEmpty) + .buttonStyle(.borderless) } .font(.subheadline) .fontWeight(.medium) diff --git a/Sources/CodeEditSourceEditor/Minimap/MinimapView+DocumentVisibleView.swift b/Sources/CodeEditSourceEditor/Minimap/MinimapView+DocumentVisibleView.swift index a5fe058a7..eb7ae50cb 100644 --- a/Sources/CodeEditSourceEditor/Minimap/MinimapView+DocumentVisibleView.swift +++ b/Sources/CodeEditSourceEditor/Minimap/MinimapView+DocumentVisibleView.swift @@ -14,14 +14,15 @@ extension MinimapView { /// - Note: This is *tricky*, there's two cases for both views. If modifying, make sure to test both when the /// minimap is shorter than the container height and when the minimap should scroll. /// - /// The ``documentVisibleView`` uses a position that's entirely relative to the percent of the available scroll height scrolled. - /// If the minimap is smaller than the container, it uses the same percent scrolled, but as a percent of the minimap height. + /// The ``documentVisibleView`` uses a position that's entirely relative to the percent of the available scroll + /// height scrolled. If the minimap is smaller than the container, it uses the same percent scrolled, but as a + /// percent of the minimap height. /// /// The height of the ``documentVisibleView`` is calculated using a ratio of the editor's height to the /// minimap's height, then applying that to the container's height. /// - /// The ``scrollView`` uses the scroll percentage calculated for the first case, and scrolls its content to that percentage. - /// The ``scrollView`` is only modified if the minimap is longer than the container view. + /// The ``scrollView`` uses the scroll percentage calculated for the first case, and scrolls its content to that + /// percentage. The ``scrollView`` is only modified if the minimap is longer than the container view. func updateDocumentVisibleViewPosition() { guard let textView = textView, let editorScrollView = textView.enclosingScrollView, let layoutManager else { return diff --git a/Sources/CodeEditSourceEditor/Minimap/MinimapView.swift b/Sources/CodeEditSourceEditor/Minimap/MinimapView.swift index 562f0e677..4c8de1921 100644 --- a/Sources/CodeEditSourceEditor/Minimap/MinimapView.swift +++ b/Sources/CodeEditSourceEditor/Minimap/MinimapView.swift @@ -37,7 +37,7 @@ public class MinimapView: FlippedNSView { public let separatorView: NSView /// Responder for a drag gesture on the ``documentVisibleView``. - var documentVisibleViewDragGesture: NSPanGestureRecognizer? + var documentVisibleViewPanGesture: NSPanGestureRecognizer? /// The layout manager that uses the ``lineRenderer`` to render and layout lines. var layoutManager: TextLayoutManager? @@ -98,19 +98,36 @@ public class MinimapView: FlippedNSView { super.init(frame: .zero) - let documentVisibleViewDragGesture = NSPanGestureRecognizer( - target: self, - action: #selector(documentVisibleViewDragged(_:)) - ) - documentVisibleView.addGestureRecognizer(documentVisibleViewDragGesture) - self.documentVisibleViewDragGesture = documentVisibleViewDragGesture + setUpPanGesture() addSubview(scrollView) addSubview(documentVisibleView) addSubview(separatorView) scrollView.documentView = contentView - self.translatesAutoresizingMaskIntoConstraints = false + translatesAutoresizingMaskIntoConstraints = false + wantsLayer = true + layer?.backgroundColor = theme.background.cgColor + + setUpLayoutManager(textView: textView) + setUpSelectionManager(textView: textView) + + setUpConstraints() + setUpListeners() + } + + /// Creates a pan gesture and attaches it to the ``documentVisibleView``. + private func setUpPanGesture() { + let documentVisibleViewPanGesture = NSPanGestureRecognizer( + target: self, + action: #selector(documentVisibleViewDragged(_:)) + ) + documentVisibleView.addGestureRecognizer(documentVisibleViewPanGesture) + self.documentVisibleViewPanGesture = documentVisibleViewPanGesture + } + + /// Create the layout manager, using text contents from the given textview. + private func setUpLayoutManager(textView: TextView) { let layoutManager = TextLayoutManager( textStorage: textView.textStorage, lineHeightMultiplier: 1.0, @@ -121,7 +138,15 @@ public class MinimapView: FlippedNSView { ) self.layoutManager = layoutManager (textView.textStorage.delegate as? MultiStorageDelegate)?.addDelegate(layoutManager) + } + /// Set up a selection manager for drawing selections in the minimap. + /// Requires ``layoutManager`` to not be `nil`. + private func setUpSelectionManager(textView: TextView) { + guard let layoutManager = layoutManager else { + assertionFailure("No layout manager setup for the minimap.") + return + } self.selectionManager = TextSelectionManager( layoutManager: layoutManager, textStorage: textView.textStorage, @@ -130,12 +155,6 @@ public class MinimapView: FlippedNSView { ) contentView.textView = textView contentView.selectionManager = selectionManager - - wantsLayer = true - layer?.backgroundColor = theme.background.cgColor - - setUpConstraints() - setUpListeners() } // MARK: - Constraints @@ -166,6 +185,7 @@ public class MinimapView: FlippedNSView { // MARK: - Scroll listeners + /// Set up listeners for relevant frame and selection updates. private func setUpListeners() { guard let editorScrollView = textView?.enclosingScrollView else { return } // Need to listen to: @@ -213,7 +233,7 @@ public class MinimapView: FlippedNSView { } override public func resetCursorRects() { - // Don't use an iBeam + // Don't use an iBeam in this view addCursorRect(bounds, cursor: .arrow) } @@ -235,7 +255,7 @@ public class MinimapView: FlippedNSView { } } - // Eat mouse events so we don't pass them on to the text view. Leads to some odd behavior. + // Eat mouse events so we don't pass them on to the text view. Leads to some odd behavior otherwise. override public func mouseDown(with event: NSEvent) { } override public func mouseDragged(with event: NSEvent) { } From ffdb74065cfffe1efdb18dcd1ada9c6760240f7c Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Thu, 17 Apr 2025 15:57:20 -0500 Subject: [PATCH 12/23] Fix More Scrolling Edge Cases --- .../Controller/TextViewController.swift | 2 ++ .../NSScrollView+percentScrolled.swift | 6 +++-- .../MinimapView+DocumentVisibleView.swift | 22 +++++++++++++------ .../Minimap/MinimapView+DragVisibleView.swift | 4 +--- .../Minimap/MinimapView.swift | 16 +++++++++++--- 5 files changed, 35 insertions(+), 15 deletions(-) diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift index b8fe78f56..1d61e41df 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift @@ -339,6 +339,8 @@ public class TextViewController: NSViewController { styleGutterView() highlighter?.invalidate() + minimapView.updateContentViewHeight() + minimapView.updateDocumentVisibleViewPosition() } deinit { diff --git a/Sources/CodeEditSourceEditor/Extensions/NSScrollView+percentScrolled.swift b/Sources/CodeEditSourceEditor/Extensions/NSScrollView+percentScrolled.swift index bb939ec7d..cbab0f955 100644 --- a/Sources/CodeEditSourceEditor/Extensions/NSScrollView+percentScrolled.swift +++ b/Sources/CodeEditSourceEditor/Extensions/NSScrollView+percentScrolled.swift @@ -8,11 +8,13 @@ import AppKit extension NSScrollView { + /// The maximum `Y` value that can be scrolled to, as the origin of the `documentVisibleRect`. var documentMaxOriginY: CGFloat { - let totalHeight = (documentView?.frame.height ?? 0.0) + contentInsets.top - return totalHeight - (documentVisibleRect.height - contentInsets.top) + let totalHeight = (documentView?.frame.height ?? 0.0) + contentInsets.vertical + return totalHeight - documentVisibleRect.height } + /// The percent amount the scroll view has been scrolled. Measured as the available space that can be scrolled. var percentScrolled: CGFloat { let currentYPos = documentVisibleRect.origin.y + contentInsets.top return currentYPos / documentMaxOriginY diff --git a/Sources/CodeEditSourceEditor/Minimap/MinimapView+DocumentVisibleView.swift b/Sources/CodeEditSourceEditor/Minimap/MinimapView+DocumentVisibleView.swift index eb7ae50cb..09e2df726 100644 --- a/Sources/CodeEditSourceEditor/Minimap/MinimapView+DocumentVisibleView.swift +++ b/Sources/CodeEditSourceEditor/Minimap/MinimapView+DocumentVisibleView.swift @@ -29,14 +29,23 @@ extension MinimapView { } let availableHeight = min(minimapHeight, containerHeight) + let editorScrollViewVisibleRect = ( + editorScrollView.documentVisibleRect.height - editorScrollView.contentInsets.vertical + ) let scrollPercentage = editorScrollView.percentScrolled guard scrollPercentage.isFinite else { return } + let multiplier = if minimapHeight < containerHeight { + editorScrollViewVisibleRect / textView.frame.height + } else { + editorToMinimapHeightRatio + } + // Update Visible Pane, should scroll down slowly as the user scrolls the document, following a similar pace // as the vertical `NSScroller`. - // Visible pane's height = scrollview visible height * (minimap line height / editor line height) + // Visible pane's height = visible height * multiplier // Visible pane's position = (container height - visible pane height) * scrollPercentage - let visibleRectHeight = containerHeight * editorToMinimapHeightRatio + let visibleRectHeight = availableHeight * multiplier guard visibleRectHeight < 1e100 else { return } let availableContainerHeight = (availableHeight - visibleRectHeight) @@ -54,14 +63,13 @@ extension MinimapView { } private func setScrollViewPosition(scrollPercentage: CGFloat) { - let topInsets = scrollView.contentInsets.top - let totalHeight = contentView.frame.height + topInsets + let totalHeight = contentView.frame.height + scrollView.contentInsets.vertical + let visibleHeight = scrollView.documentVisibleRect.height + let yPos = (totalHeight - visibleHeight) * scrollPercentage scrollView.contentView.scroll( to: NSPoint( x: scrollView.contentView.frame.origin.x, - y: ( - scrollPercentage * (totalHeight - (scrollView.documentVisibleRect.height - topInsets)) - ) - topInsets + y: yPos - scrollView.contentInsets.top ) ) scrollView.reflectScrolledClipView(scrollView.contentView) diff --git a/Sources/CodeEditSourceEditor/Minimap/MinimapView+DragVisibleView.swift b/Sources/CodeEditSourceEditor/Minimap/MinimapView+DragVisibleView.swift index 5705efbbf..0bb9e28f2 100644 --- a/Sources/CodeEditSourceEditor/Minimap/MinimapView+DragVisibleView.swift +++ b/Sources/CodeEditSourceEditor/Minimap/MinimapView+DragVisibleView.swift @@ -29,9 +29,7 @@ extension MinimapView { // Minimum Y value is the top of the scroll view newScrollViewY = max(-editorScrollView.contentInsets.top, newScrollViewY) newScrollViewY = min( // Max y value needs to take into account the editor overscroll - editorScrollView.documentMaxOriginY - - editorScrollView.contentInsets.top - + editorScrollView.contentInsets.bottom, + editorScrollView.documentMaxOriginY - editorScrollView.contentInsets.top, // Relative to the content's top newScrollViewY ) diff --git a/Sources/CodeEditSourceEditor/Minimap/MinimapView.swift b/Sources/CodeEditSourceEditor/Minimap/MinimapView.swift index 4c8de1921..7c9980c82 100644 --- a/Sources/CodeEditSourceEditor/Minimap/MinimapView.swift +++ b/Sources/CodeEditSourceEditor/Minimap/MinimapView.swift @@ -85,6 +85,9 @@ public class MinimapView: FlippedNSView { self.contentView = MinimapContentView() contentView.translatesAutoresizingMaskIntoConstraints = false + contentView.wantsLayer = true + contentView.layer?.borderWidth = 1.0 + contentView.layer?.borderColor = NSColor.blue.cgColor self.documentVisibleView = NSView() documentVisibleView.translatesAutoresizingMaskIntoConstraints = false @@ -153,6 +156,7 @@ public class MinimapView: FlippedNSView { textView: textView, delegate: self ) + selectionManager?.insertionPointColor = .clear contentView.textView = textView contentView.selectionManager = selectionManager } @@ -226,9 +230,14 @@ public class MinimapView: FlippedNSView { fatalError("init(coder:) has not been implemented") } + deinit { + NotificationCenter.default.removeObserver(self) + } + override public var visibleRect: NSRect { var rect = scrollView.documentVisibleRect rect.origin.y += scrollView.contentInsets.top + rect.size.height -= scrollView.contentInsets.vertical return rect.pixelAligned } @@ -264,14 +273,15 @@ public class MinimapView: FlippedNSView { /// cached height. func updateContentViewHeight() { guard let estimatedContentHeight = layoutManager?.estimatedHeight(), - let overscrollAmount = textView?.overscrollAmount else { + let editorEstimatedHeight = textView?.layoutManager.estimatedHeight() else { return } - let overscroll = containerHeight * overscrollAmount * editorToMinimapHeightRatio + let overscrollAmount = textView?.overscrollAmount ?? 0.0 + let overscroll = containerHeight * overscrollAmount * (estimatedContentHeight / editorEstimatedHeight) let height = estimatedContentHeight + overscroll // Only update a frame if needed - if contentView.frame.height != height { + if contentView.frame.height != height && height.isFinite && height < (textView?.frame.height ?? 0.0) { contentView.frame.size.height = height } } From d6fd1ebcdf010dedd5b2044b970236c1187557be Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Thu, 17 Apr 2025 15:57:43 -0500 Subject: [PATCH 13/23] Accidental Blue! --- Sources/CodeEditSourceEditor/Minimap/MinimapView.swift | 3 --- 1 file changed, 3 deletions(-) diff --git a/Sources/CodeEditSourceEditor/Minimap/MinimapView.swift b/Sources/CodeEditSourceEditor/Minimap/MinimapView.swift index 7c9980c82..90e16c612 100644 --- a/Sources/CodeEditSourceEditor/Minimap/MinimapView.swift +++ b/Sources/CodeEditSourceEditor/Minimap/MinimapView.swift @@ -85,9 +85,6 @@ public class MinimapView: FlippedNSView { self.contentView = MinimapContentView() contentView.translatesAutoresizingMaskIntoConstraints = false - contentView.wantsLayer = true - contentView.layer?.borderWidth = 1.0 - contentView.layer?.borderColor = NSColor.blue.cgColor self.documentVisibleView = NSView() documentVisibleView.translatesAutoresizingMaskIntoConstraints = false From 00bcea1289fdb162177db04b08372895a6ebd23a Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Thu, 17 Apr 2025 16:49:29 -0500 Subject: [PATCH 14/23] Update Positions On Layout, Linting Fixes --- ...extViewController+GutterViewDelegate.swift | 15 ++++++++++++ .../TextViewController+ReloadUI.swift | 23 +++++++++++++++++++ .../Controller/TextViewController.swift | 22 ------------------ .../Minimap/MinimapView.swift | 2 ++ 4 files changed, 40 insertions(+), 22 deletions(-) create mode 100644 Sources/CodeEditSourceEditor/Controller/TextViewController+GutterViewDelegate.swift create mode 100644 Sources/CodeEditSourceEditor/Controller/TextViewController+ReloadUI.swift diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+GutterViewDelegate.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+GutterViewDelegate.swift new file mode 100644 index 000000000..20abe130c --- /dev/null +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+GutterViewDelegate.swift @@ -0,0 +1,15 @@ +// +// TextViewController+GutterViewDelegate.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 4/17/25. +// + +import Foundation + +extension TextViewController: GutterViewDelegate { + public func gutterViewWidthDidUpdate(newWidth: CGFloat) { + gutterView?.frame.size.width = newWidth + textView?.textInsets = textViewInsets + } +} diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+ReloadUI.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+ReloadUI.swift new file mode 100644 index 000000000..47f032380 --- /dev/null +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+ReloadUI.swift @@ -0,0 +1,23 @@ +// +// TextViewController+ReloadUI.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 4/17/25. +// + +import AppKit + +extension TextViewController [ + func reloadUI() { + textView.isEditable = isEditable + textView.isSelectable = isSelectable + + styleScrollView() + styleTextView() + styleGutterView() + + highlighter?.invalidate() + minimapView.updateContentViewHeight() + minimapView.updateDocumentVisibleViewPosition() + } +] diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift index 1d61e41df..27ee90714 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift @@ -328,21 +328,6 @@ public class TextViewController: NSViewController { /// A default `NSParagraphStyle` with a set `lineHeight` package lazy var paragraphStyle: NSMutableParagraphStyle = generateParagraphStyle() - // MARK: - Reload UI - - func reloadUI() { - textView.isEditable = isEditable - textView.isSelectable = isSelectable - - styleScrollView() - styleTextView() - styleGutterView() - - highlighter?.invalidate() - minimapView.updateContentViewHeight() - minimapView.updateDocumentVisibleViewPosition() - } - deinit { if let highlighter { textView.removeStorageDelegate(highlighter) @@ -361,10 +346,3 @@ public class TextViewController: NSViewController { localEvenMonitor = nil } } - -extension TextViewController: GutterViewDelegate { - public func gutterViewWidthDidUpdate(newWidth: CGFloat) { - gutterView?.frame.size.width = newWidth - textView?.textInsets = textViewInsets - } -} diff --git a/Sources/CodeEditSourceEditor/Minimap/MinimapView.swift b/Sources/CodeEditSourceEditor/Minimap/MinimapView.swift index 90e16c612..d8bd5d8a9 100644 --- a/Sources/CodeEditSourceEditor/Minimap/MinimapView.swift +++ b/Sources/CodeEditSourceEditor/Minimap/MinimapView.swift @@ -246,6 +246,8 @@ public class MinimapView: FlippedNSView { override public func layout() { layoutManager?.layoutLines() super.layout() + updateContentViewHeight() + updateDocumentVisibleViewPosition() } override public func hitTest(_ point: NSPoint) -> NSView? { From 2fdbed0f39fc29c671806d1ffc9eb5bf72e49888 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Fri, 18 Apr 2025 09:13:26 -0500 Subject: [PATCH 15/23] Layout on WillAppear --- .../Controller/TextViewController+LoadView.swift | 3 ++- .../Controller/TextViewController+ReloadUI.swift | 4 ++-- .../Controller/TextViewController.swift | 7 +++++++ 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift index 433243d47..5f7e0722d 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift @@ -29,6 +29,7 @@ extension TextViewController { ) minimapView = MinimapView(textView: textView, theme: theme) + minimapView.postsFrameChangedNotifications = true minimapView.isHidden = !showMinimap scrollView.addFloatingSubview(minimapView, for: .vertical) @@ -68,7 +69,7 @@ extension TextViewController { func setUpConstraints() { guard let findViewController else { return } - let maxWidthConstraint = minimapView.widthAnchor.constraint(lessThanOrEqualToConstant: 150) + let maxWidthConstraint = minimapView.widthAnchor.constraint(lessThanOrEqualToConstant: 140) let relativeWidthConstraint = minimapView.widthAnchor.constraint( equalTo: view.widthAnchor, multiplier: 0.17 diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+ReloadUI.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+ReloadUI.swift index 47f032380..e7c584588 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+ReloadUI.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+ReloadUI.swift @@ -7,7 +7,7 @@ import AppKit -extension TextViewController [ +extension TextViewController { func reloadUI() { textView.isEditable = isEditable textView.isSelectable = isSelectable @@ -20,4 +20,4 @@ extension TextViewController [ minimapView.updateContentViewHeight() minimapView.updateDocumentVisibleViewPosition() } -] +} diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift index 27ee90714..b7bc97262 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift @@ -328,6 +328,13 @@ public class TextViewController: NSViewController { /// A default `NSParagraphStyle` with a set `lineHeight` package lazy var paragraphStyle: NSMutableParagraphStyle = generateParagraphStyle() + override public func viewWillAppear() { + super.viewWillAppear() + // The calculation this causes cannot be done until the view knows it's final position + updateTextInsets() + minimapView.layout() + } + deinit { if let highlighter { textView.removeStorageDelegate(highlighter) From 4f4de3682a3567becddabf4585e17f08be9115c3 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Fri, 18 Apr 2025 10:41:57 -0500 Subject: [PATCH 16/23] Correctly Call Layout --- ...nimapView+Draw.swift => MinimapContentView.swift} | 12 +++++++++++- .../Minimap/MinimapView+DocumentVisibleView.swift | 2 -- .../CodeEditSourceEditor/Minimap/MinimapView.swift | 12 +++++++++--- 3 files changed, 20 insertions(+), 6 deletions(-) rename Sources/CodeEditSourceEditor/Minimap/{MinimapView+Draw.swift => MinimapContentView.swift} (55%) diff --git a/Sources/CodeEditSourceEditor/Minimap/MinimapView+Draw.swift b/Sources/CodeEditSourceEditor/Minimap/MinimapContentView.swift similarity index 55% rename from Sources/CodeEditSourceEditor/Minimap/MinimapView+Draw.swift rename to Sources/CodeEditSourceEditor/Minimap/MinimapContentView.swift index 244d629e7..51cb10764 100644 --- a/Sources/CodeEditSourceEditor/Minimap/MinimapView+Draw.swift +++ b/Sources/CodeEditSourceEditor/Minimap/MinimapContentView.swift @@ -1,5 +1,5 @@ // -// MinimapView+Draw.swift +// MinimapContentView.swift // CodeEditSourceEditor // // Created by Khan Winter on 4/16/25. @@ -8,8 +8,13 @@ import AppKit import CodeEditTextView +/// Displays the real contents of the minimap. The layout manager and selection manager place views and draw into this +/// view. +/// +/// Height and position are managed by ``MinimapView``. public class MinimapContentView: FlippedNSView { weak var textView: TextView? + weak var layoutManager: TextLayoutManager? weak var selectionManager: TextSelectionManager? override public func draw(_ dirtyRect: NSRect) { @@ -18,4 +23,9 @@ public class MinimapContentView: FlippedNSView { selectionManager?.drawSelections(in: dirtyRect) } } + + override public func layout() { + super.layout() + layoutManager?.layoutLines() + } } diff --git a/Sources/CodeEditSourceEditor/Minimap/MinimapView+DocumentVisibleView.swift b/Sources/CodeEditSourceEditor/Minimap/MinimapView+DocumentVisibleView.swift index 09e2df726..70fe4d9e6 100644 --- a/Sources/CodeEditSourceEditor/Minimap/MinimapView+DocumentVisibleView.swift +++ b/Sources/CodeEditSourceEditor/Minimap/MinimapView+DocumentVisibleView.swift @@ -58,8 +58,6 @@ extension MinimapView { if minimapHeight > containerHeight { setScrollViewPosition(scrollPercentage: scrollPercentage) } - - layoutManager.layoutLines() } private func setScrollViewPosition(scrollPercentage: CGFloat) { diff --git a/Sources/CodeEditSourceEditor/Minimap/MinimapView.swift b/Sources/CodeEditSourceEditor/Minimap/MinimapView.swift index d8bd5d8a9..02a1e50bf 100644 --- a/Sources/CodeEditSourceEditor/Minimap/MinimapView.swift +++ b/Sources/CodeEditSourceEditor/Minimap/MinimapView.swift @@ -137,6 +137,7 @@ public class MinimapView: FlippedNSView { renderDelegate: lineRenderer ) self.layoutManager = layoutManager + self.contentView.layoutManager = layoutManager (textView.textStorage.delegate as? MultiStorageDelegate)?.addDelegate(layoutManager) } @@ -244,7 +245,6 @@ public class MinimapView: FlippedNSView { } override public func layout() { - layoutManager?.layoutLines() super.layout() updateContentViewHeight() updateDocumentVisibleViewPosition() @@ -279,9 +279,15 @@ public class MinimapView: FlippedNSView { let overscroll = containerHeight * overscrollAmount * (estimatedContentHeight / editorEstimatedHeight) let height = estimatedContentHeight + overscroll + // This seems odd, but this reduces layout passes drastically + let newFrame = CGRect( + origin: contentView.frame.origin, + size: CGSize(width: contentView.frame.width, height: height) + ).pixelAligned + // Only update a frame if needed - if contentView.frame.height != height && height.isFinite && height < (textView?.frame.height ?? 0.0) { - contentView.frame.size.height = height + if contentView.frame != newFrame && height.isFinite && height < (textView?.frame.height ?? 0.0) { + contentView.frame = newFrame } } From 58030bba3e861150653d48b95414b3c79de84d09 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Fri, 18 Apr 2025 10:46:37 -0500 Subject: [PATCH 17/23] Docs, Tests --- Package.resolved | 18 ++++++++++++++++++ .../TextViewController+StyleViews.swift | 2 ++ Tests/CodeEditSourceEditorTests/Mock.swift | 3 ++- .../TextViewControllerTests.swift | 3 ++- 4 files changed, 24 insertions(+), 2 deletions(-) diff --git a/Package.resolved b/Package.resolved index 149ed0791..89e82645a 100644 --- a/Package.resolved +++ b/Package.resolved @@ -27,6 +27,15 @@ "version" : "1.1.4" } }, + { + "identity" : "swift-custom-dump", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-custom-dump", + "state" : { + "revision" : "82645ec760917961cfa08c9c0c7104a57a0fa4b1", + "version" : "1.3.3" + } + }, { "identity" : "swiftlintplugin", "kind" : "remoteSourceControl", @@ -71,6 +80,15 @@ "revision" : "d97db6d63507eb62c536bcb2c4ac7d70c8ec665e", "version" : "0.23.2" } + }, + { + "identity" : "xctest-dynamic-overlay", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/xctest-dynamic-overlay", + "state" : { + "revision" : "39de59b2d47f7ef3ca88a039dff3084688fe27f4", + "version" : "1.5.2" + } } ], "version" : 2 diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift index 2298b11c2..79c208348 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift @@ -67,6 +67,7 @@ extension TextViewController { scrollView.scrollerStyle = .overlay } + /// Updates all relevant content insets including the find panel, scroll view, minimap and gutter position. package func updateContentInsets() { updateTextInsets() @@ -103,6 +104,7 @@ extension TextViewController { minimapView.scrollView.reflectScrolledClipView(minimapView.scrollView.contentView) } + /// Updates the text view's text insets. See ``textViewInsets`` for calculation. func updateTextInsets() { // Allow this method to be called before ``loadView()`` guard textView != nil, minimapView != nil else { return } diff --git a/Tests/CodeEditSourceEditorTests/Mock.swift b/Tests/CodeEditSourceEditorTests/Mock.swift index 173f1cad3..5e1140286 100644 --- a/Tests/CodeEditSourceEditorTests/Mock.swift +++ b/Tests/CodeEditSourceEditorTests/Mock.swift @@ -63,7 +63,8 @@ enum Mock { isSelectable: true, letterSpacing: 1.0, useSystemCursor: false, - bracketPairEmphasis: .flash + bracketPairEmphasis: .flash, + showMinimap: true ) } diff --git a/Tests/CodeEditSourceEditorTests/TextViewControllerTests.swift b/Tests/CodeEditSourceEditorTests/TextViewControllerTests.swift index b40e5c566..f9848b94c 100644 --- a/Tests/CodeEditSourceEditorTests/TextViewControllerTests.swift +++ b/Tests/CodeEditSourceEditorTests/TextViewControllerTests.swift @@ -31,7 +31,8 @@ final class TextViewControllerTests: XCTestCase { isSelectable: true, letterSpacing: 1.0, useSystemCursor: false, - bracketPairEmphasis: .flash + bracketPairEmphasis: .flash, + showMinimap: true ) controller.loadView() From fa69a420453adaa7b242c843da57cf2b734287e2 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Fri, 18 Apr 2025 11:57:55 -0500 Subject: [PATCH 18/23] Update MinimapView.swift --- Sources/CodeEditSourceEditor/Minimap/MinimapView.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Sources/CodeEditSourceEditor/Minimap/MinimapView.swift b/Sources/CodeEditSourceEditor/Minimap/MinimapView.swift index 02a1e50bf..d3fabf3d3 100644 --- a/Sources/CodeEditSourceEditor/Minimap/MinimapView.swift +++ b/Sources/CodeEditSourceEditor/Minimap/MinimapView.swift @@ -286,8 +286,9 @@ public class MinimapView: FlippedNSView { ).pixelAligned // Only update a frame if needed - if contentView.frame != newFrame && height.isFinite && height < (textView?.frame.height ?? 0.0) { + if contentView.frame.height != newFrame.height && height.isFinite && height < (textView?.frame.height ?? 0.0) { contentView.frame = newFrame + layout() } } From f9f260eb326358ecaefb7bb6fb76126495814984 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 21 Apr 2025 09:37:44 -0500 Subject: [PATCH 19/23] Bump CodeEditTextView --- .../xcshareddata/swiftpm/Package.resolved | 9 +++++++++ Package.swift | 5 ++--- .../TextViewControllerTests.swift | 13 +++++++++++++ 3 files changed, 24 insertions(+), 3 deletions(-) diff --git a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index e56a9beac..e937e279f 100644 --- a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -9,6 +9,15 @@ "version" : "0.1.20" } }, + { + "identity" : "codeedittextview", + "kind" : "remoteSourceControl", + "location" : "https://github.com/CodeEditApp/CodeEditTextView.git", + "state" : { + "revision" : "2603ff1d8b45c2c48b8b06a68b1bd4aee905ee73", + "version" : "0.10.0" + } + }, { "identity" : "rearrange", "kind" : "remoteSourceControl", diff --git a/Package.swift b/Package.swift index d287039b2..1d4797cdb 100644 --- a/Package.swift +++ b/Package.swift @@ -16,9 +16,8 @@ let package = Package( dependencies: [ // A fast, efficient, text view for code. .package( -// url: "https://github.com/CodeEditApp/CodeEditTextView.git", -// from: "0.9.1" - path: "../CodeEditTextView" + url: "https://github.com/CodeEditApp/CodeEditTextView.git", + from: "0.10.0" ), // tree-sitter languages .package( diff --git a/Tests/CodeEditSourceEditorTests/TextViewControllerTests.swift b/Tests/CodeEditSourceEditorTests/TextViewControllerTests.swift index f9848b94c..e29493c48 100644 --- a/Tests/CodeEditSourceEditorTests/TextViewControllerTests.swift +++ b/Tests/CodeEditSourceEditorTests/TextViewControllerTests.swift @@ -441,5 +441,18 @@ final class TextViewControllerTests: XCTestCase { let controller = Mock.textViewController(theme: Mock.theme()) XCTAssertNotNil(controller.treeSitterClient) } + + // MARK: - Minimap + + func test_minimapToggle() { + controller.view.frame = NSRect(x: 0, y: 0, width: 1000, height: 1000) + XCTAssertFalse(controller.minimapView.isHidden) + + controller.showMinimap = false + XCTAssertTrue(controller.minimapView.isHidden) + + controller.showMinimap = true + XCTAssertFalse(controller.minimapView.isHidden) + } } // swiftlint:enable all From 8eef18793d98c655a9ea662436ba84d99cf968a8 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 21 Apr 2025 09:40:55 -0500 Subject: [PATCH 20/23] Update Package.resolved --- Package.resolved | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/Package.resolved b/Package.resolved index 89e82645a..febb65c94 100644 --- a/Package.resolved +++ b/Package.resolved @@ -9,6 +9,15 @@ "version" : "0.1.20" } }, + { + "identity" : "codeedittextview", + "kind" : "remoteSourceControl", + "location" : "https://github.com/CodeEditApp/CodeEditTextView.git", + "state" : { + "revision" : "2603ff1d8b45c2c48b8b06a68b1bd4aee905ee73", + "version" : "0.10.0" + } + }, { "identity" : "rearrange", "kind" : "remoteSourceControl", From c688bcbc2750a65388c56beadab6f9f55f12b69b Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 21 Apr 2025 10:27:59 -0500 Subject: [PATCH 21/23] Use Constraint For Minimap ContentView Height --- .../xcshareddata/swiftpm/Package.resolved | 4 ++-- Package.resolved | 4 ++-- Package.swift | 2 +- .../TextViewController+LoadView.swift | 2 +- .../Minimap/MinimapView.swift | 18 ++++++++++++++---- .../TextViewControllerTests.swift | 8 +++++++- 6 files changed, 27 insertions(+), 11 deletions(-) diff --git a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index e937e279f..a1eb3b548 100644 --- a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -14,8 +14,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/CodeEditApp/CodeEditTextView.git", "state" : { - "revision" : "2603ff1d8b45c2c48b8b06a68b1bd4aee905ee73", - "version" : "0.10.0" + "revision" : "a5912e60f6bac25cd1cdf8bb532e1125b21cf7f7", + "version" : "0.10.1" } }, { diff --git a/Package.resolved b/Package.resolved index febb65c94..b646b2e64 100644 --- a/Package.resolved +++ b/Package.resolved @@ -14,8 +14,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/CodeEditApp/CodeEditTextView.git", "state" : { - "revision" : "2603ff1d8b45c2c48b8b06a68b1bd4aee905ee73", - "version" : "0.10.0" + "revision" : "a5912e60f6bac25cd1cdf8bb532e1125b21cf7f7", + "version" : "0.10.1" } }, { diff --git a/Package.swift b/Package.swift index 1d4797cdb..a14085dcf 100644 --- a/Package.swift +++ b/Package.swift @@ -17,7 +17,7 @@ let package = Package( // A fast, efficient, text view for code. .package( url: "https://github.com/CodeEditApp/CodeEditTextView.git", - from: "0.10.0" + from: "0.10.1" ), // tree-sitter languages .package( diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift index 5f7e0722d..19ff73ede 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift @@ -69,7 +69,7 @@ extension TextViewController { func setUpConstraints() { guard let findViewController else { return } - let maxWidthConstraint = minimapView.widthAnchor.constraint(lessThanOrEqualToConstant: 140) + let maxWidthConstraint = minimapView.widthAnchor.constraint(lessThanOrEqualToConstant: MinimapView.maxWidth) let relativeWidthConstraint = minimapView.widthAnchor.constraint( equalTo: view.widthAnchor, multiplier: 0.17 diff --git a/Sources/CodeEditSourceEditor/Minimap/MinimapView.swift b/Sources/CodeEditSourceEditor/Minimap/MinimapView.swift index d3fabf3d3..ce6b6f86e 100644 --- a/Sources/CodeEditSourceEditor/Minimap/MinimapView.swift +++ b/Sources/CodeEditSourceEditor/Minimap/MinimapView.swift @@ -25,6 +25,8 @@ import CodeEditTextView /// /// The minimap can be styled using an ``EditorTheme``. See ``setTheme(_:)`` for use and colors used by this view. public class MinimapView: FlippedNSView { + static let maxWidth: CGFloat = 140.0 + weak var textView: TextView? /// The container scrollview for the minimap contents. @@ -38,6 +40,7 @@ public class MinimapView: FlippedNSView { /// Responder for a drag gesture on the ``documentVisibleView``. var documentVisibleViewPanGesture: NSPanGestureRecognizer? + var contentViewHeightConstraint: NSLayoutConstraint? /// The layout manager that uses the ``lineRenderer`` to render and layout lines. var layoutManager: TextLayoutManager? @@ -162,6 +165,8 @@ public class MinimapView: FlippedNSView { // MARK: - Constraints private func setUpConstraints() { + let contentViewHeightConstraint = contentView.heightAnchor.constraint(equalToConstant: 1.0) + self.contentViewHeightConstraint = contentViewHeightConstraint NSLayoutConstraint.activate([ // Constrain to all sides scrollView.topAnchor.constraint(equalTo: topAnchor), @@ -172,6 +177,7 @@ public class MinimapView: FlippedNSView { // Scrolling, but match width contentView.leadingAnchor.constraint(equalTo: leadingAnchor), contentView.trailingAnchor.constraint(equalTo: trailingAnchor), + contentViewHeightConstraint, // Y position set manually documentVisibleView.leadingAnchor.constraint(equalTo: leadingAnchor), @@ -272,7 +278,8 @@ public class MinimapView: FlippedNSView { /// cached height. func updateContentViewHeight() { guard let estimatedContentHeight = layoutManager?.estimatedHeight(), - let editorEstimatedHeight = textView?.layoutManager.estimatedHeight() else { + let editorEstimatedHeight = textView?.layoutManager.estimatedHeight(), + let contentViewHeightConstraint else { return } let overscrollAmount = textView?.overscrollAmount ?? 0.0 @@ -286,9 +293,12 @@ public class MinimapView: FlippedNSView { ).pixelAligned // Only update a frame if needed - if contentView.frame.height != newFrame.height && height.isFinite && height < (textView?.frame.height ?? 0.0) { - contentView.frame = newFrame - layout() + if contentViewHeightConstraint.constant != newFrame.height + && height.isFinite + && height < (textView?.frame.height ?? 0.0) { + contentViewHeightConstraint.constant = newFrame.height + contentViewHeightConstraint.isActive = true + updateConstraints() } } diff --git a/Tests/CodeEditSourceEditorTests/TextViewControllerTests.swift b/Tests/CodeEditSourceEditorTests/TextViewControllerTests.swift index e29493c48..956a763d9 100644 --- a/Tests/CodeEditSourceEditorTests/TextViewControllerTests.swift +++ b/Tests/CodeEditSourceEditorTests/TextViewControllerTests.swift @@ -36,6 +36,8 @@ final class TextViewControllerTests: XCTestCase { ) controller.loadView() + controller.view.frame = NSRect(x: 0, y: 0, width: 1000, height: 1000) + controller.view.layoutSubtreeIfNeeded() } // MARK: Capture Names @@ -445,14 +447,18 @@ final class TextViewControllerTests: XCTestCase { // MARK: - Minimap func test_minimapToggle() { - controller.view.frame = NSRect(x: 0, y: 0, width: 1000, height: 1000) XCTAssertFalse(controller.minimapView.isHidden) + XCTAssertEqual(controller.minimapView.frame.width, MinimapView.maxWidth) + XCTAssertEqual(controller.textViewInsets.right, MinimapView.maxWidth) controller.showMinimap = false XCTAssertTrue(controller.minimapView.isHidden) + XCTAssertEqual(controller.textViewInsets.right, 0) controller.showMinimap = true XCTAssertFalse(controller.minimapView.isHidden) + XCTAssertEqual(controller.minimapView.frame.width, MinimapView.maxWidth) + XCTAssertEqual(controller.textViewInsets.right, MinimapView.maxWidth) } } // swiftlint:enable all From 6f605a2ac70d775daf25277b9a98dd067140c78b Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 21 Apr 2025 10:29:08 -0500 Subject: [PATCH 22/23] Move to `styleMinimapView` --- .../Controller/TextViewController+LoadView.swift | 3 +-- .../Controller/TextViewController+StyleViews.swift | 5 +++++ 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift index 19ff73ede..15c4f839a 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift @@ -29,8 +29,6 @@ extension TextViewController { ) minimapView = MinimapView(textView: textView, theme: theme) - minimapView.postsFrameChangedNotifications = true - minimapView.isHidden = !showMinimap scrollView.addFloatingSubview(minimapView, for: .vertical) let findViewController = FindViewController(target: self, childView: scrollView) @@ -47,6 +45,7 @@ extension TextViewController { styleTextView() styleScrollView() styleGutterView() + styleMinimapView() setUpHighlighter() setUpTextFormation() diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift index 79c208348..bcf0a6869 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift @@ -67,6 +67,11 @@ extension TextViewController { scrollView.scrollerStyle = .overlay } + package func styleMinimapView() { + minimapView.postsFrameChangedNotifications = true + minimapView.isHidden = !showMinimap + } + /// Updates all relevant content insets including the find panel, scroll view, minimap and gutter position. package func updateContentInsets() { updateTextInsets() From e3a774ed074ba330fde2e845553fdab40afd5e56 Mon Sep 17 00:00:00 2001 From: Khan Winter <35942988+thecoolwinter@users.noreply.github.com> Date: Mon, 21 Apr 2025 10:57:59 -0500 Subject: [PATCH 23/23] Rename to StatusBar --- .../CodeEditSourceEditorExample.xcodeproj/project.pbxproj | 8 ++++---- .../CodeEditSourceEditorExample/Views/ContentView.swift | 2 +- .../Views/{Toolbar.swift => StatusBar.swift} | 4 ++-- 3 files changed, 7 insertions(+), 7 deletions(-) rename Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/{Toolbar.swift => StatusBar.swift} (98%) diff --git a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.pbxproj b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.pbxproj index 8268f0d07..94ac1e836 100644 --- a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.pbxproj +++ b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.pbxproj @@ -20,7 +20,7 @@ 6C1365462B8A7F2D004A1D18 /* LanguagePicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C1365452B8A7F2D004A1D18 /* LanguagePicker.swift */; }; 6C1365482B8A7FBF004A1D18 /* EditorTheme+Default.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C1365472B8A7FBF004A1D18 /* EditorTheme+Default.swift */; }; 6C13654D2B8A821E004A1D18 /* NSColor+Hex.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C13654C2B8A821E004A1D18 /* NSColor+Hex.swift */; }; - 6C2EE57E2DB1522E007E0A26 /* Toolbar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6C2EE57D2DB1522E007E0A26 /* Toolbar.swift */; }; + 6CF31D4E2DB6A252006A77FD /* StatusBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CF31D4D2DB6A252006A77FD /* StatusBar.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -38,7 +38,7 @@ 6C1365452B8A7F2D004A1D18 /* LanguagePicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LanguagePicker.swift; sourceTree = ""; }; 6C1365472B8A7FBF004A1D18 /* EditorTheme+Default.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "EditorTheme+Default.swift"; sourceTree = ""; }; 6C13654C2B8A821E004A1D18 /* NSColor+Hex.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSColor+Hex.swift"; sourceTree = ""; }; - 6C2EE57D2DB1522E007E0A26 /* Toolbar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Toolbar.swift; sourceTree = ""; }; + 6CF31D4D2DB6A252006A77FD /* StatusBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusBar.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -117,7 +117,7 @@ isa = PBXGroup; children = ( 6C1365312B8A7B94004A1D18 /* ContentView.swift */, - 6C2EE57D2DB1522E007E0A26 /* Toolbar.swift */, + 6CF31D4D2DB6A252006A77FD /* StatusBar.swift */, 6C1365452B8A7F2D004A1D18 /* LanguagePicker.swift */, 1CB30C392DAA1C28008058A7 /* IndentPicker.swift */, ); @@ -212,7 +212,7 @@ 6C1365482B8A7FBF004A1D18 /* EditorTheme+Default.swift in Sources */, 6C13654D2B8A821E004A1D18 /* NSColor+Hex.swift in Sources */, 6C1365302B8A7B94004A1D18 /* CodeEditSourceEditorExampleDocument.swift in Sources */, - 6C2EE57E2DB1522E007E0A26 /* Toolbar.swift in Sources */, + 6CF31D4E2DB6A252006A77FD /* StatusBar.swift in Sources */, 6C13652E2B8A7B94004A1D18 /* CodeEditSourceEditorExampleApp.swift in Sources */, 6C1365442B8A7EED004A1D18 /* String+Lines.swift in Sources */, 1CB30C3A2DAA1C28008058A7 /* IndentPicker.swift in Sources */, diff --git a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift index cf549fe3d..564d2c129 100644 --- a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift +++ b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift @@ -55,7 +55,7 @@ struct ContentView: View { showMinimap: showMinimap ) .overlay(alignment: .bottom) { - Toolbar( + StatusBar( fileURL: fileURL, document: $document, wrapLines: $wrapLines, diff --git a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/Toolbar.swift b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/StatusBar.swift similarity index 98% rename from Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/Toolbar.swift rename to Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/StatusBar.swift index 8a55b50d6..e08aa04eb 100644 --- a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/Toolbar.swift +++ b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/StatusBar.swift @@ -1,5 +1,5 @@ // -// Toolbar.swift +// StatusBar.swift // CodeEditSourceEditorExample // // Created by Khan Winter on 4/17/25. @@ -9,7 +9,7 @@ import SwiftUI import CodeEditSourceEditor import CodeEditLanguages -struct Toolbar: View { +struct StatusBar: View { let fileURL: URL? @Environment(\.colorScheme)