diff --git a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift index 564d2c129..8a42a5f1a 100644 --- a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift +++ b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/ContentView.swift @@ -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, fileURL: URL?) { self._document = document @@ -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( @@ -65,7 +69,9 @@ struct ContentView: View { language: $language, theme: $theme, showMinimap: $showMinimap, - indentOption: $indentOption + indentOption: $indentOption, + reformatAtColumn: $reformatAtColumn, + showReformattingGuide: $showReformattingGuide ) } .ignoresSafeArea() diff --git a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/StatusBar.swift b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/StatusBar.swift index e08aa04eb..779f5cd35 100644 --- a/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/StatusBar.swift +++ b/Example/CodeEditSourceEditorExample/CodeEditSourceEditorExample/Views/StatusBar.swift @@ -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 { @@ -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) diff --git a/Sources/CodeEditSourceEditor/CodeEditSourceEditor/CodeEditSourceEditor.swift b/Sources/CodeEditSourceEditor/CodeEditSourceEditor/CodeEditSourceEditor.swift index 22348d10b..12a0f5a10 100644 --- a/Sources/CodeEditSourceEditor/CodeEditSourceEditor/CodeEditSourceEditor.swift +++ b/Sources/CodeEditSourceEditor/CodeEditSourceEditor/CodeEditSourceEditor.swift @@ -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, language: CodeLanguage, @@ -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 @@ -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 @@ -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, @@ -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 @@ -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 @@ -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 @@ -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): @@ -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) { @@ -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) } diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift index f0b828399..1b960ed48 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+LoadView.swift @@ -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) @@ -43,6 +52,7 @@ extension TextViewController { styleScrollView() styleGutterView() styleMinimapView() + setUpHighlighter() setUpTextFormation() @@ -51,7 +61,7 @@ extension TextViewController { } setUpConstraints() - setUpListeners() + setUpOberservers() textView.updateFrameIfNeeded() @@ -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, @@ -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, @@ -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 @@ -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 } diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController+ReloadUI.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController+ReloadUI.swift index e7c584588..d5ec302b8 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController+ReloadUI.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController+ReloadUI.swift @@ -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) + } } } diff --git a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift index b7bc97262..8d3b8b69f 100644 --- a/Sources/CodeEditSourceEditor/Controller/TextViewController.swift +++ b/Sources/CodeEditSourceEditor/Controller/TextViewController.swift @@ -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") @@ -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) } } @@ -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( @@ -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 @@ -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) @@ -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) } diff --git a/Sources/CodeEditSourceEditor/Minimap/MinimapView.swift b/Sources/CodeEditSourceEditor/Minimap/MinimapView.swift index ce6b6f86e..d515d94d1 100644 --- a/Sources/CodeEditSourceEditor/Minimap/MinimapView.swift +++ b/Sources/CodeEditSourceEditor/Minimap/MinimapView.swift @@ -77,6 +77,7 @@ public class MinimapView: FlippedNSView { public init(textView: TextView, theme: EditorTheme) { self.textView = textView self.lineRenderer = MinimapLineRenderer(textView: textView) + let isLightMode = (theme.background.usingColorSpace(.deviceRGB)?.brightnessComponent ?? 0.0) > 0.5 self.scrollView = ForwardingScrollView() scrollView.translatesAutoresizingMaskIntoConstraints = false @@ -92,12 +93,16 @@ public class MinimapView: FlippedNSView { self.documentVisibleView = NSView() documentVisibleView.translatesAutoresizingMaskIntoConstraints = false documentVisibleView.wantsLayer = true - documentVisibleView.layer?.backgroundColor = theme.text.color.withAlphaComponent(0.05).cgColor + documentVisibleView.layer?.backgroundColor = isLightMode + ? NSColor.black.withAlphaComponent(0.065).cgColor + : NSColor.white.withAlphaComponent(0.065).cgColor self.separatorView = NSView() separatorView.translatesAutoresizingMaskIntoConstraints = false separatorView.wantsLayer = true - separatorView.layer?.backgroundColor = NSColor.separatorColor.cgColor + separatorView.layer?.backgroundColor = isLightMode + ? NSColor.black.withAlphaComponent(0.1).cgColor + : NSColor.white.withAlphaComponent(0.1).cgColor super.init(frame: .zero) @@ -171,16 +176,16 @@ public class MinimapView: FlippedNSView { // Constrain to all sides scrollView.topAnchor.constraint(equalTo: topAnchor), scrollView.bottomAnchor.constraint(equalTo: bottomAnchor), - scrollView.leadingAnchor.constraint(equalTo: leadingAnchor), + scrollView.leadingAnchor.constraint(equalTo: separatorView.trailingAnchor), scrollView.trailingAnchor.constraint(equalTo: trailingAnchor), // Scrolling, but match width - contentView.leadingAnchor.constraint(equalTo: leadingAnchor), - contentView.trailingAnchor.constraint(equalTo: trailingAnchor), + contentView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor), + contentView.trailingAnchor.constraint(equalTo: scrollView.trailingAnchor), contentViewHeightConstraint, // Y position set manually - documentVisibleView.leadingAnchor.constraint(equalTo: leadingAnchor), + documentVisibleView.leadingAnchor.constraint(equalTo: scrollView.leadingAnchor), documentVisibleView.trailingAnchor.constraint(equalTo: trailingAnchor), // Separator on leading side @@ -310,7 +315,13 @@ public class MinimapView: FlippedNSView { /// /// - Parameter theme: The selected theme. public func setTheme(_ theme: EditorTheme) { - documentVisibleView.layer?.backgroundColor = theme.text.color.withAlphaComponent(0.05).cgColor + let isLightMode = theme.background.brightnessComponent > 0.5 + documentVisibleView.layer?.backgroundColor = isLightMode + ? NSColor.black.withAlphaComponent(0.065).cgColor + : NSColor.white.withAlphaComponent(0.065).cgColor + separatorView.layer?.backgroundColor = isLightMode + ? NSColor.black.withAlphaComponent(0.1).cgColor + : NSColor.white.withAlphaComponent(0.1).cgColor layer?.backgroundColor = theme.background.cgColor } } diff --git a/Sources/CodeEditSourceEditor/ReformattingGuide/ReformattingGuideView.swift b/Sources/CodeEditSourceEditor/ReformattingGuide/ReformattingGuideView.swift new file mode 100644 index 000000000..bb395ee28 --- /dev/null +++ b/Sources/CodeEditSourceEditor/ReformattingGuide/ReformattingGuideView.swift @@ -0,0 +1,111 @@ +import AppKit +import CodeEditTextView + +class ReformattingGuideView: NSView { + private var column: Int + private var _isVisible: Bool + private var theme: EditorTheme + + var isVisible: Bool { + get { _isVisible } + set { + _isVisible = newValue + isHidden = !newValue + needsDisplay = true + } + } + + init(column: Int = 80, isVisible: Bool = false, theme: EditorTheme) { + self.column = column + self._isVisible = isVisible + self.theme = theme + super.init(frame: .zero) + wantsLayer = true + isHidden = !isVisible + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // Draw the reformatting guide line and shaded area + override func draw(_ dirtyRect: NSRect) { + super.draw(dirtyRect) + guard isVisible else { + return + } + + // Determine if we should use light or dark colors based on the theme's background color + let isLightMode = theme.background.brightnessComponent > 0.5 + + // Set the line color based on the theme + let lineColor = isLightMode ? + NSColor.black.withAlphaComponent(0.075) : + NSColor.white.withAlphaComponent(0.175) + + // Set the shaded area color (slightly more transparent) + let shadedColor = isLightMode ? + NSColor.black.withAlphaComponent(0.025) : + NSColor.white.withAlphaComponent(0.025) + + // Draw the vertical line (accounting for inverted Y coordinate system) + lineColor.setStroke() + let linePath = NSBezierPath() + linePath.move(to: NSPoint(x: frame.minX, y: frame.maxY)) // Start at top + linePath.line(to: NSPoint(x: frame.minX, y: frame.minY)) // Draw down to bottom + linePath.lineWidth = 1.0 + linePath.stroke() + + // Draw the shaded area to the right of the line + shadedColor.setFill() + let shadedRect = NSRect( + x: frame.minX, + y: frame.minY, + width: frame.width, + height: frame.height + ) + shadedRect.fill() + } + + func updatePosition(in textView: TextView) { + guard isVisible else { + return + } + + // Calculate the x position based on the font's character width and column number + let charWidth = textView.font.boundingRectForFont.width + let xPosition = CGFloat(column) * charWidth / 2 // Divide by 2 to account for coordinate system + + // Get the scroll view's content size + guard let scrollView = textView.enclosingScrollView else { return } + let contentSize = scrollView.documentVisibleRect.size + + // Ensure we don't create an invalid frame + let maxWidth = max(0, contentSize.width - xPosition) + + // Update the frame to be a vertical line at the specified column with a shaded area to the right + let newFrame = NSRect( + x: xPosition, + y: 0, // Start above the visible area + width: maxWidth + 1000, + height: contentSize.height // Use extended height + ).pixelAligned + + frame = newFrame + needsDisplay = true + } + + func setVisible(_ visible: Bool) { + isVisible = visible + } + + func setColumn(_ newColumn: Int) { + column = newColumn + needsDisplay = true + } + + func setTheme(_ newTheme: EditorTheme) { + theme = newTheme + needsDisplay = true + } +}