diff --git a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.pbxproj b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.pbxproj index 77b671085..94ac1e836 100644 --- a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.pbxproj +++ b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.pbxproj @@ -20,6 +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 */; }; + 6CF31D4E2DB6A252006A77FD /* StatusBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6CF31D4D2DB6A252006A77FD /* StatusBar.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -37,6 +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 = ""; }; + 6CF31D4D2DB6A252006A77FD /* StatusBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatusBar.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -115,6 +117,7 @@ isa = PBXGroup; children = ( 6C1365312B8A7B94004A1D18 /* ContentView.swift */, + 6CF31D4D2DB6A252006A77FD /* StatusBar.swift */, 6C1365452B8A7F2D004A1D18 /* LanguagePicker.swift */, 1CB30C392DAA1C28008058A7 /* IndentPicker.swift */, ); @@ -209,6 +212,7 @@ 6C1365482B8A7FBF004A1D18 /* EditorTheme+Default.swift in Sources */, 6C13654D2B8A821E004A1D18 /* NSColor+Hex.swift in Sources */, 6C1365302B8A7B94004A1D18 /* CodeEditSourceEditorExampleDocument.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.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 8c2c83e2e..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" : "66e10658b5a0199479b1534f9bef531df34d0a91", - "version" : "0.9.1" + "revision" : "a5912e60f6bac25cd1cdf8bb532e1125b21cf7f7", + "version" : "0.10.1" } }, { diff --git a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift index 50c70de00..564d2c129 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 @State private var indentOption: IndentOption = .spaces(count: 4) init(document: Binding, fileURL: URL?) { @@ -44,73 +45,28 @@ struct ContentView: View { indentOption: indentOption, 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), - useSystemCursor: useSystemCursor + additionalTextInsets: NSEdgeInsets(top: 1, left: 0, bottom: 1, right: 0), + 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) - IndentPicker(indentOption: $indentOption, enabled: document.text.isEmpty) - .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 - } + StatusBar( + fileURL: fileURL, + document: $document, + wrapLines: $wrapLines, + useSystemCursor: $useSystemCursor, + cursorPositions: $cursorPositions, + isInLongParse: $isInLongParse, + language: $language, + theme: $theme, + showMinimap: $showMinimap, + indentOption: $indentOption + ) } .ignoresSafeArea() .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -133,32 +89,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/StatusBar.swift b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/StatusBar.swift new file mode 100644 index 000000000..e08aa04eb --- /dev/null +++ b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/StatusBar.swift @@ -0,0 +1,119 @@ +// +// StatusBar.swift +// CodeEditSourceEditorExample +// +// Created by Khan Winter on 4/17/25. +// + +import SwiftUI +import CodeEditSourceEditor +import CodeEditLanguages + +struct StatusBar: 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 + @Binding var indentOption: IndentOption + + 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) + IndentPicker(indentOption: $indentOption, enabled: document.text.isEmpty) + .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/Package.resolved b/Package.resolved index b6c0be7cb..b646b2e64 100644 --- a/Package.resolved +++ b/Package.resolved @@ -14,8 +14,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/CodeEditApp/CodeEditTextView.git", "state" : { - "revision" : "337b05f22f381f020ab188d3765767e19556d78c", - "version" : "0.9.0" + "revision" : "a5912e60f6bac25cd1cdf8bb532e1125b21cf7f7", + "version" : "0.10.1" } }, { @@ -36,6 +36,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", @@ -80,6 +89,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/Package.swift b/Package.swift index a26567e2d..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.9.1" + from: "0.10.1" ), // tree-sitter languages .package( 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+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+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+LoadView.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift index 0cab5edf2..15c4f839a 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,6 +28,9 @@ extension TextViewController { for: .horizontal ) + minimapView = MinimapView(textView: textView, theme: theme) + scrollView.addFloatingSubview(minimapView, for: .vertical) + let findViewController = FindViewController(target: self, childView: scrollView) addChild(findViewController) self.findViewController = findViewController @@ -36,8 +38,6 @@ extension TextViewController { findViewController.view.viewDidMoveToSuperview() self.findViewController = findViewController - findViewController.topPadding = contentInsets?.top - if let _undoManager { textView.setUndoManager(_undoManager) } @@ -45,28 +45,65 @@ extension TextViewController { styleTextView() styleScrollView() styleGutterView() + styleMinimapView() setUpHighlighter() setUpTextFormation() + if !cursorPositions.isEmpty { + setCursorPositions(cursorPositions) + } + + setUpConstraints() + setUpListeners() + + textView.updateFrameIfNeeded() + + if let localEventMonitor = self.localEvenMonitor { + NSEvent.removeMonitor(localEventMonitor) + } + setUpKeyBindings(eventMonitor: &self.localEvenMonitor) + updateContentInsets() + } + + func setUpConstraints() { + guard let findViewController else { return } + + let maxWidthConstraint = minimapView.widthAnchor.constraint(lessThanOrEqualToConstant: MinimapView.maxWidth) + 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) - ]) + findViewController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor), - if !cursorPositions.isEmpty { - setCursorPositions(cursorPositions) - } + minimapView.topAnchor.constraint(equalTo: scrollView.contentView.topAnchor), + minimapView.bottomAnchor.constraint(equalTo: scrollView.contentView.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 @@ -78,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( @@ -98,8 +136,6 @@ extension TextViewController { self?.emphasizeSelectionPairs() } - textView.updateFrameIfNeeded() - NSApp.publisher(for: \.effectiveAppearance) .receive(on: RunLoop.main) .sink { [weak self] newValue in @@ -114,11 +150,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+ReloadUI.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+ReloadUI.swift new file mode 100644 index 000000000..e7c584588 --- /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+StyleViews.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift index 7fa819bbf..bcf0a6869 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,18 +64,57 @@ extension TextViewController { scrollView.contentView.postsFrameChangedNotifications = true scrollView.hasVerticalScroller = true scrollView.hasHorizontalScroller = !wrapLines + 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() scrollView.contentView.postsBoundsChangedNotifications = true if let contentInsets { scrollView.automaticallyAdjustsContentInsets = false scrollView.contentInsets = contentInsets + + minimapView.scrollView.automaticallyAdjustsContentInsets = false + minimapView.scrollView.contentInsets.top = contentInsets.top + minimapView.scrollView.contentInsets.bottom = contentInsets.bottom } else { scrollView.automaticallyAdjustsContentInsets = true + 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.contentInsets.top += (findViewController?.isShowingFindPanel ?? false) ? FindPanel.height : 0 + findViewController?.topPadding = contentInsets?.top + + gutterView.frame.origin.y = -scrollView.contentInsets.top + + // Update scrollview tiling + scrollView.reflectScrolledClipView(scrollView.contentView) + 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 } + if textView.textInsets != textViewInsets { + textView.textInsets = textViewInsets + } } } diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift index 9337ece4a..b7bc97262 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift @@ -23,20 +23,17 @@ public class TextViewController: NSViewController { weak var findViewController: FindViewController? var scrollView: NSScrollView! - - // SEARCH - var stackview: NSStackView! - var searchField: NSTextField! - var prevButton: NSButton! - var nextButton: NSButton! - var textView: TextView! var gutterView: GutterView! - internal var _undoManager: CEUndoManager! - internal var systemAppearance: NSAppearance.Name? + var minimapView: MinimapView! + + var minimapXConstraint: NSLayoutConstraint? + + var _undoManager: CEUndoManager! + var systemAppearance: NSAppearance.Name? - package var localEvenMonitor: Any? - package var isPostingCursorNotification: Bool = false + var localEvenMonitor: Any? + var isPostingCursorNotification: Bool = false /// The string contents. public var string: String { @@ -71,6 +68,7 @@ public class TextViewController: NSViewController { highlighter?.invalidate() gutterView.textColor = theme.text.color.withAlphaComponent(0.35) gutterView.selectedLineTextColor = theme.text.color + minimapView.setTheme(theme) } } @@ -101,8 +99,9 @@ public class TextViewController: NSViewController { public var wrapLines: Bool { didSet { textView.layoutManager.wrapLines = wrapLines + minimapView.layoutManager?.wrapLines = wrapLines scrollView.hasHorizontalScroller = !wrapLines - textView.textInsets = textViewInsets + updateTextInsets() } } @@ -128,8 +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() - findViewController?.topPadding = contentInsets?.top + updateContentInsets() } } @@ -195,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? @@ -211,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 { @@ -248,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 @@ -268,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) @@ -318,17 +328,11 @@ 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() + 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 { @@ -349,10 +353,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/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/Extensions/NSScrollView+percentScrolled.swift b/Sources/CodeEditSourceEditor/Extensions/NSScrollView+percentScrolled.swift new file mode 100644 index 000000000..cbab0f955 --- /dev/null +++ b/Sources/CodeEditSourceEditor/Extensions/NSScrollView+percentScrolled.swift @@ -0,0 +1,22 @@ +// +// NSScrollView+percentScrolled.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 4/15/25. +// + +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.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/MinimapContentView.swift b/Sources/CodeEditSourceEditor/Minimap/MinimapContentView.swift new file mode 100644 index 000000000..51cb10764 --- /dev/null +++ b/Sources/CodeEditSourceEditor/Minimap/MinimapContentView.swift @@ -0,0 +1,31 @@ +// +// MinimapContentView.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 4/16/25. +// + +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) { + super.draw(dirtyRect) + if textView?.isSelectable ?? false { + selectionManager?.drawSelections(in: dirtyRect) + } + } + + override public func layout() { + super.layout() + layoutManager?.layoutLines() + } +} diff --git a/Sources/CodeEditSourceEditor/Minimap/MinimapLineFragmentView.swift b/Sources/CodeEditSourceEditor/Minimap/MinimapLineFragmentView.swift new file mode 100644 index 000000000..bab62283b --- /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 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 +/// 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.. CGFloat? { + 3.0 + } + + func lineFragmentView(for lineFragment: LineFragment) -> LineFragmentView { + MinimapLineFragmentView(textStorage: textView?.textStorage) + } + + func characterXPosition(in lineFragment: LineFragment, for offset: Int) -> CGFloat { + // Offset is relative to the whole line, the CTLine is too. + return 8 + (CGFloat(offset - CTLineGetStringRange(lineFragment.ctLine).location) * 1.5) + } +} diff --git a/Sources/CodeEditSourceEditor/Minimap/MinimapView+DocumentVisibleView.swift b/Sources/CodeEditSourceEditor/Minimap/MinimapView+DocumentVisibleView.swift new file mode 100644 index 000000000..70fe4d9e6 --- /dev/null +++ b/Sources/CodeEditSourceEditor/Minimap/MinimapView+DocumentVisibleView.swift @@ -0,0 +1,75 @@ +// +// MinimapView+DocumentVisibleView.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 4/11/25. +// + +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 + } + + 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 = visible height * multiplier + // Visible pane's position = (container height - visible pane height) * scrollPercentage + let visibleRectHeight = availableHeight * multiplier + guard visibleRectHeight < 1e100 else { return } + + let availableContainerHeight = (availableHeight - visibleRectHeight) + let visibleRectYPos = availableContainerHeight * scrollPercentage + + documentVisibleView.frame.origin.y = scrollView.contentInsets.top + visibleRectYPos + documentVisibleView.frame.size.height = visibleRectHeight + + // Minimap scroll offset slowly scrolls down with the visible pane. + if minimapHeight > containerHeight { + setScrollViewPosition(scrollPercentage: scrollPercentage) + } + } + + private func setScrollViewPosition(scrollPercentage: CGFloat) { + 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: yPos - scrollView.contentInsets.top + ) + ) + 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..0bb9e28f2 --- /dev/null +++ b/Sources/CodeEditSourceEditor/Minimap/MinimapView+DragVisibleView.swift @@ -0,0 +1,44 @@ +// +// 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, // Relative to the content's top + 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+TextLayoutManagerDelegate.swift b/Sources/CodeEditSourceEditor/Minimap/MinimapView+TextLayoutManagerDelegate.swift new file mode 100644 index 000000000..f58afebe1 --- /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 { + public func layoutManagerHeightDidUpdate(newHeight: CGFloat) { + updateContentViewHeight() + } + + public func layoutManagerMaxWidthDidChange(newWidth: CGFloat) { } + + public func layoutManagerTypingAttributes() -> [NSAttributedString.Key: Any] { + textView?.layoutManagerTypingAttributes() ?? [:] + } + + 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 + } + + public func layoutManagerYAdjustment(_ yAdjustment: CGFloat) { + var point = scrollView.documentVisibleRect.origin + point.y += yAdjustment + scrollView.documentView?.scroll(point) + } +} 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 new file mode 100644 index 000000000..ce6b6f86e --- /dev/null +++ b/Sources/CodeEditSourceEditor/Minimap/MinimapView.swift @@ -0,0 +1,316 @@ +// +// MinimapView.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 4/10/25. +// + +import AppKit +import CodeEditTextView + +/// 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 { + static let maxWidth: CGFloat = 140.0 + + weak var textView: TextView? + + /// The container scrollview for the minimap contents. + public let scrollView: ForwardingScrollView + /// The view text lines are rendered into. + 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. + public let separatorView: NSView + + /// 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? + 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 + + // MARK: - Calculated Variables + + var minimapHeight: CGFloat { + contentView.frame.height + } + + var editorHeight: CGFloat { + 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 { + scrollView.visibleRect.height - scrollView.contentInsets.vertical + } + + // MARK: - Init + + /// 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.lineRenderer = MinimapLineRenderer(textView: textView) + + self.scrollView = ForwardingScrollView() + scrollView.translatesAutoresizingMaskIntoConstraints = false + scrollView.hasVerticalScroller = false + scrollView.hasHorizontalScroller = false + scrollView.drawsBackground = false + scrollView.verticalScrollElasticity = .none + scrollView.receiver = textView.enclosingScrollView + + self.contentView = MinimapContentView() + contentView.translatesAutoresizingMaskIntoConstraints = false + + self.documentVisibleView = NSView() + documentVisibleView.translatesAutoresizingMaskIntoConstraints = false + 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) + + setUpPanGesture() + + addSubview(scrollView) + addSubview(documentVisibleView) + addSubview(separatorView) + scrollView.documentView = contentView + + 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, + wrapLines: textView.wrapLines, + textView: contentView, + delegate: self, + renderDelegate: lineRenderer + ) + self.layoutManager = layoutManager + self.contentView.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, + textView: textView, + delegate: self + ) + selectionManager?.insertionPointColor = .clear + contentView.textView = textView + contentView.selectionManager = selectionManager + } + + // 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), + 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), + contentViewHeightConstraint, + + // Y position set manually + documentVisibleView.leadingAnchor.constraint(equalTo: leadingAnchor), + 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) + ]) + } + + // 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: + // - 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?.updateDocumentVisibleViewPosition() + } + + NotificationCenter.default.addObserver( + forName: NSView.frameDidChangeNotification, + object: editorScrollView.contentView, + queue: .main + ) { [weak self] _ in + // Frame changed + 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) { + 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 + } + + override public func resetCursorRects() { + // Don't use an iBeam in this view + addCursorRect(bounds, cursor: .arrow) + } + + override public func layout() { + super.layout() + updateContentViewHeight() + updateDocumentVisibleViewPosition() + } + + 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 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 otherwise. + + 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 editorEstimatedHeight = textView?.layoutManager.estimatedHeight(), + let contentViewHeightConstraint else { + return + } + let overscrollAmount = textView?.overscrollAmount ?? 0.0 + 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 contentViewHeightConstraint.constant != newFrame.height + && height.isFinite + && height < (textView?.frame.height ?? 0.0) { + contentViewHeightConstraint.constant = newFrame.height + contentViewHeightConstraint.isActive = true + updateConstraints() + } + } + + /// 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/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/SupportingViews/FlippedNSView.swift b/Sources/CodeEditSourceEditor/SupportingViews/FlippedNSView.swift new file mode 100644 index 000000000..5ed8ab40a --- /dev/null +++ b/Sources/CodeEditSourceEditor/SupportingViews/FlippedNSView.swift @@ -0,0 +1,12 @@ +// +// FlippedNSView.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 4/11/25. +// + +import AppKit + +open class FlippedNSView: NSView { + open override var isFlipped: Bool { true } +} diff --git a/Sources/CodeEditSourceEditor/SupportingViews/ForwardingScrollView.swift b/Sources/CodeEditSourceEditor/SupportingViews/ForwardingScrollView.swift new file mode 100644 index 000000000..fc3fc2aa5 --- /dev/null +++ b/Sources/CodeEditSourceEditor/SupportingViews/ForwardingScrollView.swift @@ -0,0 +1,22 @@ +// +// ForwardingScrollView.swift +// CodeEditSourceEditor +// +// Created by Khan Winter on 4/15/25. +// + +import Cocoa + +/// 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? + + open 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 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..956a763d9 100644 --- a/Tests/CodeEditSourceEditorTests/TextViewControllerTests.swift +++ b/Tests/CodeEditSourceEditorTests/TextViewControllerTests.swift @@ -31,10 +31,13 @@ final class TextViewControllerTests: XCTestCase { isSelectable: true, letterSpacing: 1.0, useSystemCursor: false, - bracketPairEmphasis: .flash + bracketPairEmphasis: .flash, + showMinimap: true ) controller.loadView() + controller.view.frame = NSRect(x: 0, y: 0, width: 1000, height: 1000) + controller.view.layoutSubtreeIfNeeded() } // MARK: Capture Names @@ -440,5 +443,22 @@ final class TextViewControllerTests: XCTestCase { let controller = Mock.textViewController(theme: Mock.theme()) XCTAssertNotNil(controller.treeSitterClient) } + + // MARK: - Minimap + + func test_minimapToggle() { + 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