Skip to content

Commit cb422bb

Browse files
committed
Render A Minimap
1 parent f444927 commit cb422bb

File tree

10 files changed

+338
-42
lines changed

10 files changed

+338
-42
lines changed

Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved

Lines changed: 0 additions & 9 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Package.swift

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,9 @@ let package = Package(
1616
dependencies: [
1717
// A fast, efficient, text view for code.
1818
.package(
19-
url: "https://github.com/CodeEditApp/CodeEditTextView.git",
20-
from: "0.8.2"
19+
// url: "https://github.com/CodeEditApp/CodeEditTextView.git",
20+
// from: "0.8.2"
21+
path: "../CodeEditTextView"
2122
),
2223
// tree-sitter languages
2324
.package(
Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,41 @@
1+
//
2+
// EditorContainerView.swift
3+
// CodeEditSourceEditor
4+
//
5+
// Created by Khan Winter on 4/10/25.
6+
//
7+
8+
import AppKit
9+
10+
class EditorContainerView: NSView {
11+
weak var scrollView: NSScrollView?
12+
weak var minimapView: MinimapView?
13+
14+
init(scrollView: NSScrollView, minimapView: MinimapView) {
15+
self.scrollView = scrollView
16+
self.minimapView = minimapView
17+
18+
super.init(frame: .zero)
19+
20+
self.translatesAutoresizingMaskIntoConstraints = false
21+
22+
addSubview(scrollView)
23+
addSubview(minimapView)
24+
25+
NSLayoutConstraint.activate([
26+
scrollView.topAnchor.constraint(equalTo: topAnchor),
27+
scrollView.bottomAnchor.constraint(equalTo: bottomAnchor),
28+
scrollView.leadingAnchor.constraint(equalTo: leadingAnchor),
29+
scrollView.trailingAnchor.constraint(equalTo: trailingAnchor),
30+
31+
minimapView.topAnchor.constraint(equalTo: safeAreaLayoutGuide.topAnchor),
32+
minimapView.bottomAnchor.constraint(equalTo: bottomAnchor),
33+
minimapView.trailingAnchor.constraint(equalTo: trailingAnchor),
34+
minimapView.widthAnchor.constraint(equalToConstant: 150)
35+
])
36+
}
37+
38+
required init?(coder: NSCoder) {
39+
fatalError("init(coder:) has not been implemented")
40+
}
41+
}

Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift

Lines changed: 17 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@ import CodeEditTextView
99
import AppKit
1010

1111
extension TextViewController {
12-
// swiftlint:disable:next function_body_length
1312
override public func loadView() {
1413
super.loadView()
1514

@@ -29,7 +28,11 @@ extension TextViewController {
2928
for: .horizontal
3029
)
3130

32-
let findViewController = FindViewController(target: self, childView: scrollView)
31+
minimapView = MinimapView(textView: textView, theme: theme)
32+
33+
editorContainer = EditorContainerView(scrollView: scrollView, minimapView: minimapView)
34+
35+
let findViewController = FindViewController(target: self, childView: editorContainer)
3336
addChild(findViewController)
3437
self.findViewController = findViewController
3538
self.view.addSubview(findViewController.view)
@@ -52,13 +55,24 @@ extension TextViewController {
5255
findViewController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor),
5356
findViewController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor),
5457
findViewController.view.topAnchor.constraint(equalTo: view.topAnchor),
55-
findViewController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor)
58+
findViewController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor),
5659
])
5760

5861
if !cursorPositions.isEmpty {
5962
setCursorPositions(cursorPositions)
6063
}
6164

65+
setUpListeners()
66+
67+
textView.updateFrameIfNeeded()
68+
69+
if let localEventMonitor = self.localEvenMonitor {
70+
NSEvent.removeMonitor(localEventMonitor)
71+
}
72+
setUpKeyBindings(eventMonitor: &self.localEvenMonitor)
73+
}
74+
75+
func setUpListeners() {
6276
// Layout on scroll change
6377
NotificationCenter.default.addObserver(
6478
forName: NSView.boundsDidChangeNotification,
@@ -98,8 +112,6 @@ extension TextViewController {
98112
self?.emphasizeSelectionPairs()
99113
}
100114

101-
textView.updateFrameIfNeeded()
102-
103115
NSApp.publisher(for: \.effectiveAppearance)
104116
.receive(on: RunLoop.main)
105117
.sink { [weak self] newValue in
@@ -114,11 +126,6 @@ extension TextViewController {
114126
}
115127
}
116128
.store(in: &cancellables)
117-
118-
if let localEventMonitor = self.localEvenMonitor {
119-
NSEvent.removeMonitor(localEventMonitor)
120-
}
121-
setUpKeyBindings(eventMonitor: &self.localEvenMonitor)
122129
}
123130

124131
func setUpKeyBindings(eventMonitor: inout Any?) {

Sources/CodeEditSourceEditor/Controller/TextViewController.swift

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -22,16 +22,14 @@ public class TextViewController: NSViewController {
2222

2323
weak var findViewController: FindViewController?
2424

25+
// Container view for the editor contents (scrolling textview, gutter, and minimap)
26+
// Is a child of the find container, so editor contents all move below the find panel when open.
27+
var editorContainer: EditorContainerView!
2528
var scrollView: NSScrollView!
26-
27-
// SEARCH
28-
var stackview: NSStackView!
29-
var searchField: NSTextField!
30-
var prevButton: NSButton!
31-
var nextButton: NSButton!
32-
3329
var textView: TextView!
3430
var gutterView: GutterView!
31+
var minimapView: MinimapView!
32+
3533
internal var _undoManager: CEUndoManager!
3634
internal var systemAppearance: NSAppearance.Name?
3735

@@ -71,6 +69,7 @@ public class TextViewController: NSViewController {
7169
highlighter?.invalidate()
7270
gutterView.textColor = theme.text.color.withAlphaComponent(0.35)
7371
gutterView.selectedLineTextColor = theme.text.color
72+
minimapView.theme = theme
7473
}
7574
}
7675

Sources/CodeEditSourceEditor/Extensions/TextView+/TextView+TextFormation.swift

Lines changed: 0 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -39,23 +39,11 @@ extension TextView: TextInterface {
3939
/// - Parameter mutation: The mutation to apply.
4040
public func applyMutation(_ mutation: TextMutation) {
4141
guard !mutation.isEmpty else { return }
42-
43-
delegate?.textView(self, willReplaceContentsIn: mutation.range, with: mutation.string)
44-
45-
layoutManager.beginTransaction()
46-
textStorage.beginEditing()
47-
48-
layoutManager.willReplaceCharactersInRange(range: mutation.range, with: mutation.string)
4942
_undoManager?.registerMutation(mutation)
5043
textStorage.replaceCharacters(in: mutation.range, with: mutation.string)
5144
selectionManager.didReplaceCharacters(
5245
in: mutation.range,
5346
replacementLength: (mutation.string as NSString).length
5447
)
55-
56-
textStorage.endEditing()
57-
layoutManager.endTransaction()
58-
59-
delegate?.textView(self, didReplaceContentsIn: mutation.range, with: mutation.string)
6048
}
6149
}

Sources/CodeEditSourceEditor/Highlighting/Highlighter.swift

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -266,7 +266,6 @@ extension Highlighter: NSTextStorageDelegate {
266266
extension Highlighter: StyledRangeContainerDelegate {
267267
func styleContainerDidUpdate(in range: NSRange) {
268268
guard let textView, let attributeProvider else { return }
269-
textView.layoutManager.beginTransaction()
270269
textView.textStorage.beginEditing()
271270

272271
let storage = textView.textStorage
@@ -281,7 +280,6 @@ extension Highlighter: StyledRangeContainerDelegate {
281280
}
282281

283282
textView.textStorage.endEditing()
284-
textView.layoutManager.endTransaction()
285283
textView.layoutManager.invalidateLayoutForRange(range)
286284
}
287285
}
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
//
2+
// MinimapLineFragmentView.swift
3+
// CodeEditSourceEditor
4+
//
5+
// Created by Khan Winter on 4/10/25.
6+
//
7+
8+
import AppKit
9+
import CodeEditTextView
10+
11+
/// A custom line fragment view for the minimap.
12+
///
13+
/// Instead of drawing line contents, this view calculates a series of boxes or 'runs' to draw to represent the text
14+
/// in the line fragment.
15+
///
16+
/// Runs are calculated when the view's fragment is set, and cached until invalidated, and all whitespace
17+
/// characters are ignored.
18+
final class MinimapLineFragmentView: LineFragmentView {
19+
/// A run represents a position, length, and color that we can draw.
20+
/// ``MinimapLineFragmentView`` class will calculate cache these when a new line fragment is set.
21+
struct Run {
22+
let color: NSColor
23+
let range: NSRange
24+
}
25+
26+
private weak var textStorage: NSTextStorage?
27+
private var drawingRuns: [Run] = []
28+
29+
init(textStorage: NSTextStorage?) {
30+
self.textStorage = textStorage
31+
super.init(frame: .zero)
32+
}
33+
34+
@MainActor required init?(coder: NSCoder) {
35+
fatalError("init(coder:) has not been implemented")
36+
}
37+
38+
/// Prepare the view for reuse, clearing cached drawing runs.
39+
override func prepareForReuse() {
40+
super.prepareForReuse()
41+
drawingRuns.removeAll()
42+
}
43+
44+
/// Set the new line fragment, and calculate drawing runs for drawing the fragment in the view.
45+
/// - Parameter newFragment: The new fragment to use.
46+
override func setLineFragment(_ newFragment: LineFragment) {
47+
super.setLineFragment(newFragment)
48+
guard let textStorage else { return }
49+
50+
// Create the drawing runs using attribute information
51+
var position = newFragment.documentRange.location
52+
53+
while position < newFragment.documentRange.max {
54+
var longestRange: NSRange = .notFound
55+
defer { position = longestRange.max }
56+
57+
guard let foregroundColor = textStorage.attribute(
58+
.foregroundColor,
59+
at: position,
60+
longestEffectiveRange: &longestRange,
61+
in: NSRange(start: position, end: newFragment.documentRange.max)
62+
) as? NSColor else {
63+
continue
64+
}
65+
66+
// Now that we have the foreground color for drawing, filter our runs to only include non-whitespace
67+
// characters
68+
var range: NSRange = .notFound
69+
for idx in longestRange.location..<longestRange.max {
70+
let char = (textStorage.string as NSString).character(at: idx)
71+
if let scalar = UnicodeScalar(char), CharacterSet.whitespacesAndNewlines.contains(scalar) {
72+
// Whitespace
73+
if range != .notFound {
74+
appendDrawingRun(color: foregroundColor, range: range)
75+
range = .notFound
76+
}
77+
} else {
78+
// Not whitespace
79+
if range == .notFound {
80+
range = NSRange(location: idx, length: 1)
81+
} else {
82+
range = NSRange(start: range.location, end: idx + 1)
83+
}
84+
}
85+
}
86+
87+
if range != .notFound {
88+
appendDrawingRun(color: foregroundColor, range: range)
89+
}
90+
}
91+
}
92+
93+
/// Appends a new drawing run to the list.
94+
/// - Parameters:
95+
/// - color: The color of the run, will have opacity applied by this method.
96+
/// - range: The range, relative to the document. Will be normalized to the fragment by this method.
97+
private func appendDrawingRun(color: NSColor, range: NSRange) {
98+
drawingRuns.append(
99+
Run(
100+
color: color.withAlphaComponent(0.4),
101+
range: NSRange(
102+
location: range.location - (lineFragment?.documentRange.location ?? 0),
103+
length: range.length
104+
)
105+
)
106+
)
107+
}
108+
109+
/// Draw our cached drawing runs in the current graphics context.
110+
override func draw(_ dirtyRect: NSRect) {
111+
guard let context = NSGraphicsContext.current?.cgContext else { return }
112+
113+
context.saveGState()
114+
for run in drawingRuns {
115+
let rect = CGRect(
116+
x: 8 + (CGFloat(run.range.location) * 2),
117+
y: 0,
118+
width: CGFloat(run.range.length) * 2,
119+
height: 2.0
120+
)
121+
context.setFillColor(run.color.cgColor)
122+
context.fill(rect.pixelAligned)
123+
}
124+
125+
context.restoreGState()
126+
}
127+
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
//
2+
// MinimapLineRenderer.swift
3+
// CodeEditSourceEditor
4+
//
5+
// Created by Khan Winter on 4/10/25.
6+
//
7+
8+
import AppKit
9+
import CodeEditTextView
10+
11+
final class MinimapLineRenderer: TextLayoutManagerRenderDelegate {
12+
weak var textView: TextView?
13+
14+
init(textView: TextView) {
15+
self.textView = textView
16+
}
17+
18+
func prepareForDisplay( // swiftlint:disable:this function_parameter_count
19+
textLine: TextLine,
20+
displayData: TextLine.DisplayData,
21+
range: NSRange,
22+
stringRef: NSTextStorage,
23+
markedRanges: MarkedRanges?,
24+
breakStrategy: LineBreakStrategy
25+
) {
26+
let maxWidth: CGFloat = if let textView, textView.wrapLines {
27+
textView.frame.width
28+
} else {
29+
.infinity
30+
}
31+
32+
textLine.prepareForDisplay(
33+
displayData: TextLine.DisplayData(maxWidth: maxWidth, lineHeightMultiplier: 1.0, estimatedLineHeight: 3.0),
34+
range: range,
35+
stringRef: stringRef,
36+
markedRanges: markedRanges,
37+
breakStrategy: breakStrategy
38+
)
39+
40+
// Make all fragments 2px tall
41+
textLine.lineFragments.forEach { fragmentPosition in
42+
textLine.lineFragments.update(
43+
atIndex: fragmentPosition.index,
44+
delta: 0,
45+
deltaHeight: -(fragmentPosition.height - 3.0)
46+
)
47+
fragmentPosition.data.height = 2.0
48+
fragmentPosition.data.scaledHeight = 3.0
49+
}
50+
}
51+
52+
func lineFragmentView(for lineFragment: LineFragment) -> LineFragmentView {
53+
MinimapLineFragmentView(textStorage: textView?.textStorage)
54+
}
55+
}

0 commit comments

Comments
 (0)