Skip to content

Code Folding #341

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 19 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -26,12 +26,14 @@ struct ContentView: View {
@State private var font: NSFont = NSFont.monospacedSystemFont(ofSize: 12, weight: .medium)
@AppStorage("wrapLines") private var wrapLines: Bool = true
@AppStorage("systemCursor") private var useSystemCursor: Bool = false

@State private var indentOption: IndentOption = .spaces(count: 4)
@AppStorage("reformatAtColumn") private var reformatAtColumn: Int = 80

@AppStorage("showGutter") private var showGutter: Bool = true
@AppStorage("showMinimap") private var showMinimap: Bool = true
@AppStorage("showReformattingGuide") private var showReformattingGuide: Bool = false
@AppStorage("showFoldingRibbon") private var showFoldingRibbon: Bool = true
@State private var invisibleCharactersConfig: InvisibleCharactersConfiguration = .empty
@State private var warningCharacters: Set<UInt16> = []

Expand Down Expand Up @@ -86,6 +88,7 @@ struct ContentView: View {
indentOption: $indentOption,
reformatAtColumn: $reformatAtColumn,
showReformattingGuide: $showReformattingGuide,
showFoldingRibbon: $showFoldingRibbon
invisibles: $invisibleCharactersConfig,
warningCharacters: $warningCharacters
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ struct StatusBar: View {
@Binding var indentOption: IndentOption
@Binding var reformatAtColumn: Int
@Binding var showReformattingGuide: Bool
@Binding var showFoldingRibbon: Bool
@Binding var invisibles: InvisibleCharactersConfiguration
@Binding var warningCharacters: Set<UInt16>

Expand All @@ -47,6 +48,7 @@ struct StatusBar: View {
.onChange(of: reformatAtColumn) { _, newValue in
reformatAtColumn = max(1, min(200, newValue))
}
Toggle("Show Folding Ribbon", isOn: $showFoldingRibbon)
if #available(macOS 14, *) {
Toggle("Use System Cursor", isOn: $useSystemCursor)
} else {
Expand Down
4 changes: 2 additions & 2 deletions Package.resolved

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Package.swift
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ let package = Package(
// A fast, efficient, text view for code.
.package(
url: "https://github.com/CodeEditApp/CodeEditTextView.git",
from: "0.11.3"
from: "0.11.4"
),
// tree-sitter languages
.package(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,7 @@
import Foundation

extension TextViewController: GutterViewDelegate {
public func gutterViewWidthDidUpdate(newWidth: CGFloat) {
gutterView?.frame.size.width = newWidth
textView?.textInsets = textViewInsets
public func gutterViewWidthDidUpdate() {
updateTextInsets()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,23 @@ import CodeEditTextView
import AppKit

extension TextViewController {
override public func viewWillAppear() {
super.viewWillAppear()
// The calculation this causes cannot be done until the view knows it's final position
updateTextInsets()
minimapView.layout()
}

override public func viewDidAppear() {
super.viewDidAppear()
textCoordinators.forEach { $0.val?.controllerDidAppear(controller: self) }
}

override public func viewDidDisappear() {
super.viewDidDisappear()
textCoordinators.forEach { $0.val?.controllerDidDisappear(controller: self) }
}

override public func loadView() {
super.loadView()

Expand All @@ -17,7 +34,7 @@ extension TextViewController {

gutterView = GutterView(
configuration: configuration,
textView: textView,
controller: self,
delegate: self
)
gutterView.updateWidthIfNeeded()
Expand Down Expand Up @@ -129,6 +146,7 @@ extension TextViewController {
self.gutterView.frame.size.height = self.textView.frame.height + 10
self.gutterView.frame.origin.y = self.textView.frame.origin.y - self.scrollView.contentInsets.top
self.gutterView.needsDisplay = true
self.gutterView.foldingRibbon.needsDisplay = true
self.reformattingGuideView?.updatePosition(in: self)
self.scrollView.needsLayout = true
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ extension TextViewController {
// swiftlint:disable:next force_cast
let paragraph = NSParagraphStyle.default.mutableCopy() as! NSMutableParagraphStyle
paragraph.tabStops.removeAll()
paragraph.defaultTabInterval = CGFloat(tabWidth) * fontCharWidth
paragraph.defaultTabInterval = CGFloat(tabWidth) * font.charWidth
return paragraph
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,23 +11,6 @@ import TextFormation
import TextStory

extension TextViewController {

internal enum BracketPairs {
static let allValues: [(String, String)] = [
("{", "}"),
("[", "]"),
("(", ")"),
("\"", "\""),
("'", "'")
]

static let emphasisValues: [(String, String)] = [
("{", "}"),
("[", "]"),
("(", ")")
]
}

// MARK: - Filter Configuration

/// Initializes any filters for text editing.
Expand Down
39 changes: 11 additions & 28 deletions Sources/CodeEditSourceEditor/Controller/TextViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ public class TextViewController: NSViewController {
var localEvenMonitor: Any?
var isPostingCursorNotification: Bool = false

/// A default `NSParagraphStyle` with a set `lineHeight`
lazy var paragraphStyle: NSMutableParagraphStyle = generateParagraphStyle()

// MARK: - Public Variables

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

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

/// Filters used when applying edits..
internal var textFilters: [TextFormation.Filter] = []
var textFilters: [TextFormation.Filter] = []

internal var cancellables = Set<AnyCancellable>()
var cancellables = Set<AnyCancellable>()

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

package var textViewInsets: HorizontalEdgeInsets {
var textViewInsets: HorizontalEdgeInsets {
HorizontalEdgeInsets(
left: showGutter ? gutterView.gutterWidth : 0.0,
left: showGutter ? gutterView.frame.width : 0.0,
right: textViewTrailingInset
)
}
Expand All @@ -199,13 +202,15 @@ public class TextViewController: NSViewController {
configuration: SourceEditorConfiguration,
cursorPositions: [CursorPosition],
highlightProviders: [HighlightProviding] = [TreeSitterClient()],
foldProvider: LineFoldProvider? = nil,
undoManager: CEUndoManager? = nil,
coordinators: [TextViewCoordinator] = []
) {
self.language = language
self.configuration = configuration
self.cursorPositions = cursorPositions
self.highlightProviders = highlightProviders
self.foldProvider = foldProvider ?? LineIndentationFoldProvider()
self._undoManager = undoManager
self.invisibleCharactersCoordinator = InvisibleCharactersCoordinator(configuration: configuration)

Expand Down Expand Up @@ -249,28 +254,6 @@ public class TextViewController: NSViewController {
self.gutterView.setNeedsDisplay(self.gutterView.frame)
}

// MARK: Paragraph Style

/// 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()
}

override public func viewDidAppear() {
super.viewDidAppear()
textCoordinators.forEach { $0.val?.controllerDidAppear(controller: self) }
}

override public func viewDidDisappear() {
super.viewDidDisappear()
textCoordinators.forEach { $0.val?.controllerDidDisappear(controller: self) }
}

deinit {
if let highlighter {
textView.removeStorageDelegate(highlighter)
Expand Down
29 changes: 29 additions & 0 deletions Sources/CodeEditSourceEditor/Enums/BracketPairs.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
//
// BracketPairs.swift
// CodeEditSourceEditor
//
// Created by Khan Winter on 6/5/25.
//

enum BracketPairs {
static let allValues: [(String, String)] = [
("{", "}"),
("[", "]"),
("(", ")"),
("\"", "\""),
("'", "'")
]

static let emphasisValues: [(String, String)] = [
("{", "}"),
("[", "]"),
("(", ")")
]

/// Checks if the given string is a matchable emphasis string.
/// - Parameter potentialMatch: The string to check for matches.
/// - Returns: True if a match was found with either start or end bracket pairs.
static func matches(_ potentialMatch: String) -> Bool {
allValues.contains(where: { $0.0 == potentialMatch || $0.1 == potentialMatch })
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,11 @@ extension DispatchQueue {
/// executed if not already on the main thread.
/// - Parameter item: The work item to execute.
/// - Returns: The value of the work item.
static func syncMainIfNot<T>(_ item: @escaping () -> T) -> T {
static func waitMainIfNot<T>(_ item: () -> T) -> T {
if Thread.isMainThread {
return item()
} else {
return DispatchQueue.main.sync {
return item()
}
return DispatchQueue.main.asyncAndWait(execute: item)
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
//
// NSBezierPath+RoundedCorners.swift
// CodeEditSourceEditor
//
// Created by Khan Winter on 6/3/25.
//

import AppKit

// Wonderful NSBezierPath extension taken with modification from the playground code at:
// https://github.com/janheiermann/BezierPath-Corners

extension NSBezierPath {
struct Corners: OptionSet {
public let rawValue: Int

public init(rawValue: Corners.RawValue) {
self.rawValue = rawValue
}

public static let topLeft = Corners(rawValue: 1 << 0)
public static let bottomLeft = Corners(rawValue: 1 << 1)
public static let topRight = Corners(rawValue: 1 << 2)
public static let bottomRight = Corners(rawValue: 1 << 3)
public static let all: Corners = Corners(rawValue: 0b1111)
}

// swiftlint:disable:next function_body_length
convenience init(rect: CGRect, roundedCorners corners: Corners, cornerRadius: CGFloat) {
self.init()

let maxX = rect.maxX
let minX = rect.minX
let maxY = rect.maxY
let minY = rect.minY
let radius = min(cornerRadius, min(rect.width, rect.height) / 2)

// Start at bottom-left corner
move(to: CGPoint(x: minX + (corners.contains(.bottomLeft) ? radius : 0), y: minY))

// Bottom edge
if corners.contains(.bottomRight) {
line(to: CGPoint(x: maxX - radius, y: minY))
appendArc(
withCenter: CGPoint(x: maxX - radius, y: minY + radius),
radius: radius,
startAngle: 270,
endAngle: 0,
clockwise: false
)
} else {
line(to: CGPoint(x: maxX, y: minY))
}

// Right edge
if corners.contains(.topRight) {
line(to: CGPoint(x: maxX, y: maxY - radius))
appendArc(
withCenter: CGPoint(x: maxX - radius, y: maxY - radius),
radius: radius,
startAngle: 0,
endAngle: 90,
clockwise: false
)
} else {
line(to: CGPoint(x: maxX, y: maxY))
}

// Top edge
if corners.contains(.topLeft) {
line(to: CGPoint(x: minX + radius, y: maxY))
appendArc(
withCenter: CGPoint(x: minX + radius, y: maxY - radius),
radius: radius,
startAngle: 90,
endAngle: 180,
clockwise: false
)
} else {
line(to: CGPoint(x: minX, y: maxY))
}

// Left edge
if corners.contains(.bottomLeft) {
line(to: CGPoint(x: minX, y: minY + radius))
appendArc(
withCenter: CGPoint(x: minX + radius, y: minY + radius),
radius: radius,
startAngle: 180,
endAngle: 270,
clockwise: false
)
} else {
line(to: CGPoint(x: minX, y: minY))
}

close()
}

convenience init(roundingRect: CGRect, capTop: Bool, capBottom: Bool, cornerRadius radius: CGFloat) {
switch (capTop, capBottom) {
case (true, true):
self.init(rect: roundingRect)
case (false, true):
self.init(rect: roundingRect, roundedCorners: [.bottomLeft, .bottomRight], cornerRadius: radius)
case (true, false):
self.init(rect: roundingRect, roundedCorners: [.topLeft, .topRight], cornerRadius: radius)
case (false, false):
self.init(roundedRect: roundingRect, xRadius: radius, yRadius: radius)
}
}
}
Loading