Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,8 @@ struct ContentView: View {
@State private var treeSitterClient = TreeSitterClient()
@AppStorage("showMinimap") private var showMinimap: Bool = true
@State private var indentOption: IndentOption = .spaces(count: 4)
@AppStorage("reformatAtColumn") private var reformatAtColumn: Int = 80
@AppStorage("showReformattingGuide") private var showReformattingGuide: Bool = false

init(document: Binding<CodeEditSourceEditorExampleDocument>, fileURL: URL?) {
self._document = document
Expand All @@ -52,7 +54,9 @@ struct ContentView: View {
contentInsets: NSEdgeInsets(top: proxy.safeAreaInsets.top, left: 0, bottom: 28.0, right: 0),
additionalTextInsets: NSEdgeInsets(top: 1, left: 0, bottom: 1, right: 0),
useSystemCursor: useSystemCursor,
showMinimap: showMinimap
showMinimap: showMinimap,
reformatAtColumn: reformatAtColumn,
showReformattingGuide: showReformattingGuide
)
.overlay(alignment: .bottom) {
StatusBar(
Expand All @@ -65,7 +69,9 @@ struct ContentView: View {
language: $language,
theme: $theme,
showMinimap: $showMinimap,
indentOption: $indentOption
indentOption: $indentOption,
reformatAtColumn: $reformatAtColumn,
showReformattingGuide: $showReformattingGuide
)
}
.ignoresSafeArea()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,12 +24,25 @@ struct StatusBar: View {
@Binding var theme: EditorTheme
@Binding var showMinimap: Bool
@Binding var indentOption: IndentOption
@Binding var reformatAtColumn: Int
@Binding var showReformattingGuide: Bool

var body: some View {
HStack {
Menu {
IndentPicker(indentOption: $indentOption, enabled: document.text.isEmpty)
.buttonStyle(.borderless)
Toggle("Wrap Lines", isOn: $wrapLines)
Toggle("Show Minimap", isOn: $showMinimap)
Toggle("Show Reformatting Guide", isOn: $showReformattingGuide)
Picker("Reformat column at column", selection: $reformatAtColumn) {
ForEach([40, 60, 80, 100, 120, 140, 160, 180, 200], id: \.self) { column in
Text("\(column)").tag(column)
}
}
.onChange(of: reformatAtColumn) { _, newValue in
reformatAtColumn = max(1, min(200, newValue))
}
if #available(macOS 14, *) {
Toggle("Use System Cursor", isOn: $useSystemCursor)
} else {
Expand Down Expand Up @@ -65,8 +78,6 @@ struct StatusBar: View {
.frame(height: 12)
LanguagePicker(language: $language)
.buttonStyle(.borderless)
IndentPicker(indentOption: $indentOption, enabled: document.text.isEmpty)
.buttonStyle(.borderless)
}
.font(.subheadline)
.fontWeight(.medium)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,9 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable {
/// - useSystemCursor: If true, uses the system cursor on `>=macOS 14`.
/// - undoManager: The undo manager for the text view. Defaults to `nil`, which will create a new CEUndoManager
/// - coordinators: Any text coordinators for the view to use. See ``TextViewCoordinator`` for more information.
/// - showMinimap: Whether to show the minimap
/// - reformatAtColumn: The column to reformat at
/// - showReformattingGuide: Whether to show the reformatting guide
public init(
_ text: Binding<String>,
language: CodeLanguage,
Expand All @@ -72,7 +75,9 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable {
useSystemCursor: Bool = true,
undoManager: CEUndoManager? = nil,
coordinators: [any TextViewCoordinator] = [],
showMinimap: Bool
showMinimap: Bool,
reformatAtColumn: Int,
showReformattingGuide: Bool
) {
self.text = .binding(text)
self.language = language
Expand Down Expand Up @@ -100,6 +105,8 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable {
self.undoManager = undoManager
self.coordinators = coordinators
self.showMinimap = showMinimap
self.reformatAtColumn = reformatAtColumn
self.showReformattingGuide = showReformattingGuide
}

/// Initializes a Text Editor
Expand Down Expand Up @@ -129,6 +136,9 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable {
/// See `BracketPairEmphasis` for more information. Defaults to `nil`
/// - undoManager: The undo manager for the text view. Defaults to `nil`, which will create a new CEUndoManager
/// - coordinators: Any text coordinators for the view to use. See ``TextViewCoordinator`` for more information.
/// - showMinimap: Whether to show the minimap
/// - reformatAtColumn: The column to reformat at
/// - showReformattingGuide: Whether to show the reformatting guide
public init(
_ text: NSTextStorage,
language: CodeLanguage,
Expand All @@ -151,7 +161,9 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable {
useSystemCursor: Bool = true,
undoManager: CEUndoManager? = nil,
coordinators: [any TextViewCoordinator] = [],
showMinimap: Bool
showMinimap: Bool,
reformatAtColumn: Int,
showReformattingGuide: Bool
) {
self.text = .storage(text)
self.language = language
Expand Down Expand Up @@ -179,6 +191,8 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable {
self.undoManager = undoManager
self.coordinators = coordinators
self.showMinimap = showMinimap
self.reformatAtColumn = reformatAtColumn
self.showReformattingGuide = showReformattingGuide
}

package var text: TextAPI
Expand All @@ -203,6 +217,8 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable {
private var undoManager: CEUndoManager?
package var coordinators: [any TextViewCoordinator]
package var showMinimap: Bool
private var reformatAtColumn: Int
private var showReformattingGuide: Bool

public typealias NSViewControllerType = TextViewController

Expand All @@ -229,7 +245,9 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable {
bracketPairEmphasis: bracketPairEmphasis,
undoManager: undoManager,
coordinators: coordinators,
showMinimap: showMinimap
showMinimap: showMinimap,
reformatAtColumn: reformatAtColumn,
showReformattingGuide: showReformattingGuide
)
switch text {
case .binding(let binding):
Expand Down Expand Up @@ -286,6 +304,14 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable {
updateEditorProperties(controller)
updateThemeAndLanguage(controller)
updateHighlighting(controller, coordinator: coordinator)

if controller.reformatAtColumn != reformatAtColumn {
controller.reformatAtColumn = reformatAtColumn
}

if controller.showReformattingGuide != showReformattingGuide {
controller.showReformattingGuide = showReformattingGuide
}
}

private func updateTextProperties(_ controller: TextViewController) {
Expand Down Expand Up @@ -369,6 +395,8 @@ public struct CodeEditSourceEditor: NSViewControllerRepresentable {
controller.bracketPairEmphasis == bracketPairEmphasis &&
controller.useSystemCursor == useSystemCursor &&
controller.showMinimap == showMinimap &&
controller.reformatAtColumn == reformatAtColumn &&
controller.showReformattingGuide == showReformattingGuide &&
areHighlightProvidersEqual(controller: controller, coordinator: coordinator)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,15 @@ extension TextViewController {
gutterView.updateWidthIfNeeded()
scrollView.addFloatingSubview(gutterView, for: .horizontal)

guideView = ReformattingGuideView(
column: self.reformatAtColumn,
isVisible: self.showReformattingGuide,
theme: theme
)
guideView.wantsLayer = true
scrollView.addFloatingSubview(guideView, for: .vertical)
guideView.updatePosition(in: textView)

minimapView = MinimapView(textView: textView, theme: theme)
scrollView.addFloatingSubview(minimapView, for: .vertical)

Expand All @@ -43,6 +52,7 @@ extension TextViewController {
styleScrollView()
styleGutterView()
styleMinimapView()

setUpHighlighter()
setUpTextFormation()

Expand All @@ -51,7 +61,7 @@ extension TextViewController {
}

setUpConstraints()
setUpListeners()
setUpOberservers()

textView.updateFrameIfNeeded()

Expand Down Expand Up @@ -90,20 +100,21 @@ extension TextViewController {
])
}

func setUpListeners() {
// Layout on scroll change
func setUpOnScrollChangeObserver() {
NotificationCenter.default.addObserver(
forName: NSView.boundsDidChangeNotification,
object: scrollView.contentView,
queue: .main
) { [weak self] notification in
guard let clipView = notification.object as? NSClipView else { return }
self?.textView.updatedViewport(self?.scrollView.documentVisibleRect ?? .zero)
guard let clipView = notification.object as? NSClipView,
let textView = self?.textView else { return }
textView.updatedViewport(self?.scrollView.documentVisibleRect ?? .zero)
self?.gutterView.needsDisplay = true
self?.minimapXConstraint?.constant = clipView.bounds.origin.x
}
}

// Layout on frame change
func setUpOnScrollViewFrameChangeObserver() {
NotificationCenter.default.addObserver(
forName: NSView.frameDidChangeNotification,
object: scrollView.contentView,
Expand All @@ -114,20 +125,26 @@ extension TextViewController {
self?.emphasisManager?.removeEmphases(for: EmphasisGroup.brackets)
self?.updateTextInsets()
}
}

func setUpTextViewFrameChangeObserver() {
NotificationCenter.default.addObserver(
forName: NSView.frameDidChangeNotification,
object: textView,
queue: .main
) { [weak self] _ in
guard let textView = self?.textView else { return }
self?.gutterView.frame.size.height = (self?.textView.frame.height ?? 0) + 10
self?.gutterView.frame.origin.y = (self?.textView.frame.origin.y ?? 0.0)
- (self?.scrollView.contentInsets.top ?? 0)

self?.gutterView.needsDisplay = true
self?.guideView?.updatePosition(in: textView)
self?.scrollView.needsLayout = true
}
}

func setUpSelectionChangedObserver() {
NotificationCenter.default.addObserver(
forName: TextSelectionManager.selectionChangedNotification,
object: textView.selectionManager,
Expand All @@ -136,7 +153,9 @@ extension TextViewController {
self?.updateCursorPosition()
self?.emphasizeSelectionPairs()
}
}

func setUpAppearanceChangedObserver() {
NSApp.publisher(for: \.effectiveAppearance)
.receive(on: RunLoop.main)
.sink { [weak self] newValue in
Expand All @@ -153,6 +172,14 @@ extension TextViewController {
.store(in: &cancellables)
}

func setUpOberservers() {
setUpOnScrollChangeObserver()
setUpOnScrollViewFrameChangeObserver()
setUpTextViewFrameChangeObserver()
setUpSelectionChangedObserver()
setUpAppearanceChangedObserver()
}

func setUpKeyBindings(eventMonitor: inout Any?) {
eventMonitor = NSEvent.addLocalMonitorForEvents(matching: .keyDown) { [weak self] event -> NSEvent? in
guard let self = self else { return event }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,5 +19,10 @@ extension TextViewController {
highlighter?.invalidate()
minimapView.updateContentViewHeight()
minimapView.updateDocumentVisibleViewPosition()

// Update reformatting guide position
if let guideView = textView.subviews.first(where: { $0 is ReformattingGuideView }) as? ReformattingGuideView {
guideView.updatePosition(in: textView)
}
}
}
43 changes: 41 additions & 2 deletions Sources/CodeEditSourceEditor/Controller/TextViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ import TextFormation
///
/// A view controller class for managing a source editor. Uses ``CodeEditTextView/TextView`` for input and rendering,
/// tree-sitter for syntax highlighting, and TextFormation for live editing completions.
public class TextViewController: NSViewController {
public class TextViewController: NSViewController { // swiftlint:disable:this type_body_length
// swiftlint:disable:next line_length
public static let cursorPositionUpdatedNotification: Notification.Name = .init("TextViewController.cursorPositionNotification")

Expand Down Expand Up @@ -69,6 +69,7 @@ public class TextViewController: NSViewController {
gutterView.textColor = theme.text.color.withAlphaComponent(0.35)
gutterView.selectedLineTextColor = theme.text.color
minimapView.setTheme(theme)
guideView?.setTheme(theme)
}
}

Expand Down Expand Up @@ -233,6 +234,37 @@ public class TextViewController: NSViewController {
)
}

/// The column at which to show the reformatting guide
public var reformatAtColumn: Int = 80 {
didSet {
if let guideView = self.guideView {
guideView.setColumn(reformatAtColumn)
guideView.updatePosition(in: textView)
guideView.needsDisplay = true
}
}
}

/// Whether to show the reformatting guide
public var showReformattingGuide: Bool = false {
didSet {
if let guideView = self.guideView {
guideView.setVisible(showReformattingGuide)
guideView.updatePosition(in: textView)
guideView.needsDisplay = true
}
}
}

/// The reformatting guide view
var guideView: ReformattingGuideView! {
didSet {
if let oldValue = oldValue {
oldValue.removeFromSuperview()
}
}
}

// MARK: Init

init(
Expand All @@ -257,7 +289,9 @@ public class TextViewController: NSViewController {
bracketPairEmphasis: BracketPairEmphasis?,
undoManager: CEUndoManager? = nil,
coordinators: [TextViewCoordinator] = [],
showMinimap: Bool
showMinimap: Bool,
reformatAtColumn: Int = 80,
showReformattingGuide: Bool = false
) {
self.language = language
self.font = font
Expand All @@ -278,6 +312,8 @@ public class TextViewController: NSViewController {
self.bracketPairEmphasis = bracketPairEmphasis
self._undoManager = undoManager
self.showMinimap = showMinimap
self.reformatAtColumn = reformatAtColumn
self.showReformattingGuide = showReformattingGuide

super.init(nibName: nil, bundle: nil)

Expand Down Expand Up @@ -306,6 +342,9 @@ public class TextViewController: NSViewController {
delegate: self
)

// Initialize guide view
self.guideView = ReformattingGuideView(column: reformatAtColumn, isVisible: showReformattingGuide, theme: theme)

coordinators.forEach {
$0.prepareCoordinator(controller: self)
}
Expand Down
Loading