Skip to content

Commit 8a47aa4

Browse files
Code Folding (CodeEditApp#341)
### Description Adds a code folding ribbon. > [!NOTE] > For reviewers: Most of these changes have been reviewed individually in separate PRs. This shouldn't require a detailed combing over. If you can review a few things in particular like: architecture, naming, and bugs with real use I think those would be the best use of your limited time. #### Detailed Changes - Folding-related changes - Added `FoldingRibbonView` - A view that draws a set of folding ranges. This does all the drawing manually to avoid having to manage view reuse, it does the drawing in the regions macOS designates so it only draws a small part of the visible ribbon at a time. - Added `LineFoldProvider` - A protocol for classes to provide fold information to CodeEditSourceEditor. This takes a line number, range, depth, and a reference to the text controller and returns a list of 'line info' objects. - Added `LineIndentationFoldProvider`, an implementation of `LineFoldProvider` that calculates folds based on the user's indent setting. - Added `LineFoldCalculator`, which interfaces with the given `LineFoldProvider` to calculate new folds. This happens *asynchronously* and recalculates every so often. It's very fast but stays off the main thread to avoid ever gumming up the user's typing. - Added `LineFoldingModel`, which is the glue between the text view, calculator, and view. - To display folded folds, we display a `LineFoldPlaceholder` in the text view. This keeps a reference to the `LineFoldingModel` via a delegate protocol to tell the model when it's dismissed. <details><summary>This is a slightly complicated object graph, so I've added a diagram here</summary> ```mermaid flowchart TD TV["TextView"] -->|Text Updates| LFM["LineFoldingModel"] PL["LineFoldPlaceholder"] --> |Placeholder Removed| LFM LFM -->|Text Updates| LFC["LineFoldCalculator"] LFC -->|LineFoldStorage| LFM LFM -->|Storage Updated| FRV["FoldingRibbonView"] LFC <--> |Fold Info| LFP["any LineFoldProvider"] ``` </details> - Gutter View changes - New property `showFoldingRibbon` toggles the visibility of the folding ribbon view. - The gutter now manages the folding ribbon view as a subview. - Added an additional padding option on the gutter for the folding ribbon. - Makes a slight modification to how it draws line numbers by using a line iterator in the drawn lines, rather than the 'visible lines'. Uses less resources when macOS only requests a redraw for a specific rect rather than the whole gutter. - Other, related changes - Modified the minimap. The minimap is now an attachment delegate to the text view's attachment object. It receives updates to the text view's attachments and adds a fake attachment to it's own layout manager. This effectively syncs the text view's attachments to the minimap. - Renamed `DispatchQueue.syncMainIfNot` to `waitMainIfNot` and updated it to use a better method for waiting for a dispatched work item. - Added an extension to `NSBezierPath` for rounding corners. - Added a new `NSColor` convenience initializer for creating light and dark mode colors more easily. - Moved the font character width calculation from `TextViewController` to an `NSFont` extension. ### Related Issues * CodeEditApp#43 ### Checklist - [x] Folds reflected in minimap. - [x] Double-click fold expands it. - [x] I read and understood the [contributing guide](https://github.com/CodeEditApp/CodeEdit/blob/main/CONTRIBUTING.md) as well as the [code of conduct](https://github.com/CodeEditApp/CodeEdit/blob/main/CODE_OF_CONDUCT.md) - [x] The issues this PR addresses are related to each other - [x] My changes generate no new warnings - [x] My code builds and runs on my machine - [x] My changes are all related to the related issue above - [x] I documented my code ### Screenshots https://github.com/user-attachments/assets/25e46ae4-3db5-4f08-8027-1fea9a8e64cb --------- Co-authored-by: Austin Condiff <[email protected]>
1 parent a148366 commit 8a47aa4

File tree

49 files changed

+1930
-131
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

49 files changed

+1930
-131
lines changed

Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,12 +26,14 @@ struct ContentView: View {
2626
@State private var font: NSFont = NSFont.monospacedSystemFont(ofSize: 12, weight: .medium)
2727
@AppStorage("wrapLines") private var wrapLines: Bool = true
2828
@AppStorage("systemCursor") private var useSystemCursor: Bool = false
29+
2930
@State private var indentOption: IndentOption = .spaces(count: 4)
3031
@AppStorage("reformatAtColumn") private var reformatAtColumn: Int = 80
3132

3233
@AppStorage("showGutter") private var showGutter: Bool = true
3334
@AppStorage("showMinimap") private var showMinimap: Bool = true
3435
@AppStorage("showReformattingGuide") private var showReformattingGuide: Bool = false
36+
@AppStorage("showFoldingRibbon") private var showFoldingRibbon: Bool = true
3537
@State private var invisibleCharactersConfig: InvisibleCharactersConfiguration = .empty
3638
@State private var warningCharacters: Set<UInt16> = []
3739

@@ -86,6 +88,7 @@ struct ContentView: View {
8688
indentOption: $indentOption,
8789
reformatAtColumn: $reformatAtColumn,
8890
showReformattingGuide: $showReformattingGuide,
91+
showFoldingRibbon: $showFoldingRibbon
8992
invisibles: $invisibleCharactersConfig,
9093
warningCharacters: $warningCharacters
9194
)

Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/StatusBar.swift

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ struct StatusBar: View {
2727
@Binding var indentOption: IndentOption
2828
@Binding var reformatAtColumn: Int
2929
@Binding var showReformattingGuide: Bool
30+
@Binding var showFoldingRibbon: Bool
3031
@Binding var invisibles: InvisibleCharactersConfiguration
3132
@Binding var warningCharacters: Set<UInt16>
3233

@@ -47,6 +48,7 @@ struct StatusBar: View {
4748
.onChange(of: reformatAtColumn) { _, newValue in
4849
reformatAtColumn = max(1, min(200, newValue))
4950
}
51+
Toggle("Show Folding Ribbon", isOn: $showFoldingRibbon)
5052
if #available(macOS 14, *) {
5153
Toggle("Use System Cursor", isOn: $useSystemCursor)
5254
} else {

Package.resolved

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

Package.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ let package = Package(
1717
// A fast, efficient, text view for code.
1818
.package(
1919
url: "https://github.com/CodeEditApp/CodeEditTextView.git",
20-
from: "0.11.3"
20+
from: "0.11.4"
2121
),
2222
// tree-sitter languages
2323
.package(

Sources/CodeEditSourceEditor/Controller/TextViewController+GutterViewDelegate.swift

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,7 @@
88
import Foundation
99

1010
extension TextViewController: GutterViewDelegate {
11-
public func gutterViewWidthDidUpdate(newWidth: CGFloat) {
12-
gutterView?.frame.size.width = newWidth
13-
textView?.textInsets = textViewInsets
11+
public func gutterViewWidthDidUpdate() {
12+
updateTextInsets()
1413
}
1514
}

Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift renamed to Sources/CodeEditSourceEditor/Controller/TextViewController+Lifecycle.swift

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,23 @@ import CodeEditTextView
99
import AppKit
1010

1111
extension TextViewController {
12+
override public func viewWillAppear() {
13+
super.viewWillAppear()
14+
// The calculation this causes cannot be done until the view knows it's final position
15+
updateTextInsets()
16+
minimapView.layout()
17+
}
18+
19+
override public func viewDidAppear() {
20+
super.viewDidAppear()
21+
textCoordinators.forEach { $0.val?.controllerDidAppear(controller: self) }
22+
}
23+
24+
override public func viewDidDisappear() {
25+
super.viewDidDisappear()
26+
textCoordinators.forEach { $0.val?.controllerDidDisappear(controller: self) }
27+
}
28+
1229
override public func loadView() {
1330
super.loadView()
1431

@@ -17,7 +34,7 @@ extension TextViewController {
1734

1835
gutterView = GutterView(
1936
configuration: configuration,
20-
textView: textView,
37+
controller: self,
2138
delegate: self
2239
)
2340
gutterView.updateWidthIfNeeded()
@@ -129,6 +146,7 @@ extension TextViewController {
129146
self.gutterView.frame.size.height = self.textView.frame.height + 10
130147
self.gutterView.frame.origin.y = self.textView.frame.origin.y - self.scrollView.contentInsets.top
131148
self.gutterView.needsDisplay = true
149+
self.gutterView.foldingRibbon.needsDisplay = true
132150
self.reformattingGuideView?.updatePosition(in: self)
133151
self.scrollView.needsLayout = true
134152
}

Sources/CodeEditSourceEditor/Controller/TextViewController+StyleViews.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ extension TextViewController {
1313
// swiftlint:disable:next force_cast
1414
let paragraph = NSParagraphStyle.default.mutableCopy() as! NSMutableParagraphStyle
1515
paragraph.tabStops.removeAll()
16-
paragraph.defaultTabInterval = CGFloat(tabWidth) * fontCharWidth
16+
paragraph.defaultTabInterval = CGFloat(tabWidth) * font.charWidth
1717
return paragraph
1818
}
1919

Sources/CodeEditSourceEditor/Controller/TextViewController+TextFormation.swift

Lines changed: 0 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -11,23 +11,6 @@ import TextFormation
1111
import TextStory
1212

1313
extension TextViewController {
14-
15-
internal enum BracketPairs {
16-
static let allValues: [(String, String)] = [
17-
("{", "}"),
18-
("[", "]"),
19-
("(", ")"),
20-
("\"", "\""),
21-
("'", "'")
22-
]
23-
24-
static let emphasisValues: [(String, String)] = [
25-
("{", "}"),
26-
("[", "]"),
27-
("(", ")")
28-
]
29-
}
30-
3114
// MARK: - Filter Configuration
3215

3316
/// Initializes any filters for text editing.

Sources/CodeEditSourceEditor/Controller/TextViewController.swift

Lines changed: 11 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,9 @@ public class TextViewController: NSViewController {
4848
var localEvenMonitor: Any?
4949
var isPostingCursorNotification: Bool = false
5050

51+
/// A default `NSParagraphStyle` with a set `lineHeight`
52+
lazy var paragraphStyle: NSMutableParagraphStyle = generateParagraphStyle()
53+
5154
// MARK: - Public Variables
5255

5356
/// Passthrough value for the `textView`s string
@@ -170,23 +173,23 @@ public class TextViewController: NSViewController {
170173
/// This will be `nil` if another highlighter provider is passed to the source editor.
171174
internal(set) public var treeSitterClient: TreeSitterClient?
172175

173-
package var fontCharWidth: CGFloat { (" " as NSString).size(withAttributes: [.font: font]).width }
176+
var foldProvider: LineFoldProvider
174177

175178
/// Filters used when applying edits..
176-
internal var textFilters: [TextFormation.Filter] = []
179+
var textFilters: [TextFormation.Filter] = []
177180

178-
internal var cancellables = Set<AnyCancellable>()
181+
var cancellables = Set<AnyCancellable>()
179182

180183
/// The trailing inset for the editor. Grows when line wrapping is disabled or when the minimap is shown.
181-
package var textViewTrailingInset: CGFloat {
184+
var textViewTrailingInset: CGFloat {
182185
// See https://github.com/CodeEditApp/CodeEditTextView/issues/66
183186
// wrapLines ? 1 : 48
184187
(minimapView?.isHidden ?? false) ? 0 : (minimapView?.frame.width ?? 0.0)
185188
}
186189

187-
package var textViewInsets: HorizontalEdgeInsets {
190+
var textViewInsets: HorizontalEdgeInsets {
188191
HorizontalEdgeInsets(
189-
left: showGutter ? gutterView.gutterWidth : 0.0,
192+
left: showGutter ? gutterView.frame.width : 0.0,
190193
right: textViewTrailingInset
191194
)
192195
}
@@ -199,13 +202,15 @@ public class TextViewController: NSViewController {
199202
configuration: SourceEditorConfiguration,
200203
cursorPositions: [CursorPosition],
201204
highlightProviders: [HighlightProviding] = [TreeSitterClient()],
205+
foldProvider: LineFoldProvider? = nil,
202206
undoManager: CEUndoManager? = nil,
203207
coordinators: [TextViewCoordinator] = []
204208
) {
205209
self.language = language
206210
self.configuration = configuration
207211
self.cursorPositions = cursorPositions
208212
self.highlightProviders = highlightProviders
213+
self.foldProvider = foldProvider ?? LineIndentationFoldProvider()
209214
self._undoManager = undoManager
210215
self.invisibleCharactersCoordinator = InvisibleCharactersCoordinator(configuration: configuration)
211216

@@ -249,28 +254,6 @@ public class TextViewController: NSViewController {
249254
self.gutterView.setNeedsDisplay(self.gutterView.frame)
250255
}
251256

252-
// MARK: Paragraph Style
253-
254-
/// A default `NSParagraphStyle` with a set `lineHeight`
255-
package lazy var paragraphStyle: NSMutableParagraphStyle = generateParagraphStyle()
256-
257-
override public func viewWillAppear() {
258-
super.viewWillAppear()
259-
// The calculation this causes cannot be done until the view knows it's final position
260-
updateTextInsets()
261-
minimapView.layout()
262-
}
263-
264-
override public func viewDidAppear() {
265-
super.viewDidAppear()
266-
textCoordinators.forEach { $0.val?.controllerDidAppear(controller: self) }
267-
}
268-
269-
override public func viewDidDisappear() {
270-
super.viewDidDisappear()
271-
textCoordinators.forEach { $0.val?.controllerDidDisappear(controller: self) }
272-
}
273-
274257
deinit {
275258
if let highlighter {
276259
textView.removeStorageDelegate(highlighter)
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
//
2+
// BracketPairs.swift
3+
// CodeEditSourceEditor
4+
//
5+
// Created by Khan Winter on 6/5/25.
6+
//
7+
8+
enum BracketPairs {
9+
static let allValues: [(String, String)] = [
10+
("{", "}"),
11+
("[", "]"),
12+
("(", ")"),
13+
("\"", "\""),
14+
("'", "'")
15+
]
16+
17+
static let emphasisValues: [(String, String)] = [
18+
("{", "}"),
19+
("[", "]"),
20+
("(", ")")
21+
]
22+
23+
/// Checks if the given string is a matchable emphasis string.
24+
/// - Parameter potentialMatch: The string to check for matches.
25+
/// - Returns: True if a match was found with either start or end bracket pairs.
26+
static func matches(_ potentialMatch: String) -> Bool {
27+
allValues.contains(where: { $0.0 == potentialMatch || $0.1 == potentialMatch })
28+
}
29+
}

0 commit comments

Comments
 (0)