diff --git a/Example/CodeEditTextViewExample/CodeEditTextViewExample.xcodeproj/project.pbxproj b/Example/CodeEditTextViewExample/CodeEditTextViewExample.xcodeproj/project.pbxproj index 97a0dffc..4a583aa2 100644 --- a/Example/CodeEditTextViewExample/CodeEditTextViewExample.xcodeproj/project.pbxproj +++ b/Example/CodeEditTextViewExample/CodeEditTextViewExample.xcodeproj/project.pbxproj @@ -189,7 +189,7 @@ CODE_SIGN_ENTITLEMENTS = CodeEditTextViewExample/CodeEditTextViewExample.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_ASSET_PATHS = "\"CodeEditTextViewExample/Preview Content\""; + DEVELOPMENT_ASSET_PATHS = ""; DEVELOPMENT_TEAM = ""; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -226,7 +226,7 @@ CODE_SIGN_ENTITLEMENTS = CodeEditTextViewExample/CodeEditTextViewExample.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; - DEVELOPMENT_ASSET_PATHS = "\"CodeEditTextViewExample/Preview Content\""; + DEVELOPMENT_ASSET_PATHS = ""; DEVELOPMENT_TEAM = ""; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; diff --git a/Example/CodeEditTextViewExample/CodeEditTextViewExample/Views/ContentView.swift b/Example/CodeEditTextViewExample/CodeEditTextViewExample/Views/ContentView.swift index f914a802..c6b0f4f0 100644 --- a/Example/CodeEditTextViewExample/CodeEditTextViewExample/Views/ContentView.swift +++ b/Example/CodeEditTextViewExample/CodeEditTextViewExample/Views/ContentView.swift @@ -9,9 +9,18 @@ import SwiftUI struct ContentView: View { @Binding var document: CodeEditTextViewExampleDocument + @AppStorage("wraplines") private var wrapLines: Bool = true + @AppStorage("edgeinsets") private var enableEdgeInsets: Bool = false var body: some View { - SwiftUITextView(text: $document.text) + VStack(spacing: 0) { + HStack { + Toggle("Wrap Lines", isOn: $wrapLines) + Toggle("Inset Edges", isOn: $enableEdgeInsets) + } + Divider() + SwiftUITextView(text: $document.text, wrapLines: $wrapLines, enableEdgeInsets: $enableEdgeInsets) + } } } diff --git a/Example/CodeEditTextViewExample/CodeEditTextViewExample/Views/SwiftUITextView.swift b/Example/CodeEditTextViewExample/CodeEditTextViewExample/Views/SwiftUITextView.swift index 8dd341c2..96d5d732 100644 --- a/Example/CodeEditTextViewExample/CodeEditTextViewExample/Views/SwiftUITextView.swift +++ b/Example/CodeEditTextViewExample/CodeEditTextViewExample/Views/SwiftUITextView.swift @@ -11,15 +11,20 @@ import CodeEditTextView struct SwiftUITextView: NSViewControllerRepresentable { @Binding var text: String + @Binding var wrapLines: Bool + @Binding var enableEdgeInsets: Bool func makeNSViewController(context: Context) -> TextViewController { let controller = TextViewController(string: text) context.coordinator.controller = controller + controller.wrapLines = wrapLines + controller.enableEdgeInsets = enableEdgeInsets return controller } func updateNSViewController(_ nsViewController: TextViewController, context: Context) { - // Do nothing, our binding has to be a one-way binding + nsViewController.wrapLines = wrapLines + nsViewController.enableEdgeInsets = enableEdgeInsets } func makeCoordinator() -> Coordinator { diff --git a/Example/CodeEditTextViewExample/CodeEditTextViewExample/Views/TextViewController.swift b/Example/CodeEditTextViewExample/CodeEditTextViewExample/Views/TextViewController.swift index 0e568b55..a880d973 100644 --- a/Example/CodeEditTextViewExample/CodeEditTextViewExample/Views/TextViewController.swift +++ b/Example/CodeEditTextViewExample/CodeEditTextViewExample/Views/TextViewController.swift @@ -11,6 +11,22 @@ import CodeEditTextView class TextViewController: NSViewController { var scrollView: NSScrollView! var textView: TextView! + var enableEdgeInsets: Bool = false { + didSet { + if enableEdgeInsets { + textView.edgeInsets = .init(left: 20, right: 30) + textView.textInsets = .init(left: 10, right: 30) + } else { + textView.edgeInsets = .zero + textView.textInsets = .zero + } + } + } + var wrapLines: Bool = true { + didSet { + textView.wrapLines = wrapLines + } + } init(string: String) { textView = TextView(string: string) @@ -24,6 +40,14 @@ class TextViewController: NSViewController { override func loadView() { scrollView = NSScrollView() textView.translatesAutoresizingMaskIntoConstraints = false + textView.wrapLines = wrapLines + if enableEdgeInsets { + textView.edgeInsets = .init(left: 30, right: 30) + textView.textInsets = .init(left: 0, right: 30) + } else { + textView.edgeInsets = .zero + textView.textInsets = .zero + } scrollView.translatesAutoresizingMaskIntoConstraints = false scrollView.documentView = textView diff --git a/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager+Draw.swift b/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager+Draw.swift new file mode 100644 index 00000000..b52fdc78 --- /dev/null +++ b/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager+Draw.swift @@ -0,0 +1,97 @@ +// +// File.swift +// CodeEditTextView +// +// Created by Khan Winter on 1/12/25. +// + +import AppKit + +extension TextSelectionManager { + /// Draws line backgrounds and selection rects for each selection in the given rect. + /// - Parameter rect: The rect to draw in. + func drawSelections(in rect: NSRect) { + guard let context = NSGraphicsContext.current?.cgContext else { return } + context.saveGState() + var highlightedLines: Set = [] + // For each selection in the rect + for textSelection in textSelections { + if textSelection.range.isEmpty { + drawHighlightedLine( + in: rect, + for: textSelection, + context: context, + highlightedLines: &highlightedLines + ) + } else { + drawSelectedRange(in: rect, for: textSelection, context: context) + } + } + context.restoreGState() + } + + /// Draws a highlighted line in the given rect. + /// - Parameters: + /// - rect: The rect to draw in. + /// - textSelection: The selection to draw. + /// - context: The context to draw in. + /// - highlightedLines: The set of all lines that have already been highlighted, used to avoid highlighting lines + /// twice and updated if this function comes across a new line id. + private func drawHighlightedLine( + in rect: NSRect, + for textSelection: TextSelection, + context: CGContext, + highlightedLines: inout Set + ) { + guard let linePosition = layoutManager?.textLineForOffset(textSelection.range.location), + !highlightedLines.contains(linePosition.data.id) else { + return + } + highlightedLines.insert(linePosition.data.id) + context.saveGState() + + let insetXPos = max(rect.minX, edgeInsets.left) + let maxWidth = (textView?.frame.width ?? 0) - insetXPos - edgeInsets.right + + let selectionRect = CGRect( + x: insetXPos, + y: linePosition.yPos, + width: min(rect.width, maxWidth), + height: linePosition.height + ).pixelAligned + + if selectionRect.intersects(rect) { + context.setFillColor(selectedLineBackgroundColor.cgColor) + context.fill(selectionRect) + } + context.restoreGState() + } + + /// Draws a selected range in the given context. + /// - Parameters: + /// - rect: The rect to draw in. + /// - range: The range to highlight. + /// - context: The context to draw in. + private func drawSelectedRange(in rect: NSRect, for textSelection: TextSelection, context: CGContext) { + context.saveGState() + + let fillColor = (textView?.isFirstResponder ?? false) + ? selectionBackgroundColor.cgColor + : selectionBackgroundColor.grayscale.cgColor + + context.setFillColor(fillColor) + + let fillRects = getFillRects(in: rect, for: textSelection) + + let minX = fillRects.min(by: { $0.origin.x < $1.origin.x })?.origin.x ?? 0 + let minY = fillRects.min(by: { $0.origin.y < $1.origin.y })?.origin.y ?? 0 + let max = fillRects.max(by: { $0.maxY < $1.maxY }) ?? .zero + let origin = CGPoint(x: minX, y: minY) + let size = CGSize(width: max.maxX - minX, height: max.maxY - minY) + textSelection.boundingRect = CGRect(origin: origin, size: size) + + context.fill(fillRects) + context.restoreGState() + } + +} diff --git a/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager+FillRects.swift b/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager+FillRects.swift index 46f56758..640cc409 100644 --- a/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager+FillRects.swift +++ b/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager+FillRects.swift @@ -30,8 +30,8 @@ extension TextSelectionManager { return [] } - let insetXPos = max(layoutManager.edgeInsets.left, rect.minX) - let insetWidth = max(0, rect.maxX - insetXPos - layoutManager.edgeInsets.right) + let insetXPos = max(edgeInsets.left, rect.minX) + let insetWidth = max(0, rect.maxX - insetXPos - edgeInsets.right) let insetRect = NSRect(x: insetXPos, y: rect.origin.y, width: insetWidth, height: rect.height) // Calculate the first line and any rects selected diff --git a/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager.swift b/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager.swift index 546e856f..b6311a9a 100644 --- a/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager.swift +++ b/Sources/CodeEditTextView/TextSelectionManager/TextSelectionManager.swift @@ -38,6 +38,13 @@ public class TextSelectionManager: NSObject { } } + /// Determines how far inset to draw selection content. + public var edgeInsets: HorizontalEdgeInsets = .zero { + didSet { + delegate?.setNeedsDisplay() + } + } + internal(set) public var textSelections: [TextSelection] = [] weak var layoutManager: TextLayoutManager? weak var textStorage: NSTextStorage? @@ -224,87 +231,4 @@ public class TextSelectionManager: NSObject { textSelection.view?.removeFromSuperview() } } - - // MARK: - Draw - - /// Draws line backgrounds and selection rects for each selection in the given rect. - /// - Parameter rect: The rect to draw in. - func drawSelections(in rect: NSRect) { - guard let context = NSGraphicsContext.current?.cgContext else { return } - context.saveGState() - var highlightedLines: Set = [] - // For each selection in the rect - for textSelection in textSelections { - if textSelection.range.isEmpty { - drawHighlightedLine( - in: rect, - for: textSelection, - context: context, - highlightedLines: &highlightedLines - ) - } else { - drawSelectedRange(in: rect, for: textSelection, context: context) - } - } - context.restoreGState() - } - - /// Draws a highlighted line in the given rect. - /// - Parameters: - /// - rect: The rect to draw in. - /// - textSelection: The selection to draw. - /// - context: The context to draw in. - /// - highlightedLines: The set of all lines that have already been highlighted, used to avoid highlighting lines - /// twice and updated if this function comes across a new line id. - private func drawHighlightedLine( - in rect: NSRect, - for textSelection: TextSelection, - context: CGContext, - highlightedLines: inout Set - ) { - guard let linePosition = layoutManager?.textLineForOffset(textSelection.range.location), - !highlightedLines.contains(linePosition.data.id) else { - return - } - highlightedLines.insert(linePosition.data.id) - context.saveGState() - let selectionRect = CGRect( - x: rect.minX, - y: linePosition.yPos, - width: rect.width, - height: linePosition.height - ) - if selectionRect.intersects(rect) { - context.setFillColor(selectedLineBackgroundColor.cgColor) - context.fill(selectionRect) - } - context.restoreGState() - } - - /// Draws a selected range in the given context. - /// - Parameters: - /// - rect: The rect to draw in. - /// - range: The range to highlight. - /// - context: The context to draw in. - private func drawSelectedRange(in rect: NSRect, for textSelection: TextSelection, context: CGContext) { - context.saveGState() - - let fillColor = (textView?.isFirstResponder ?? false) - ? selectionBackgroundColor.cgColor - : selectionBackgroundColor.grayscale.cgColor - - context.setFillColor(fillColor) - - let fillRects = getFillRects(in: rect, for: textSelection) - - let minX = fillRects.min(by: { $0.origin.x < $1.origin.x })?.origin.x ?? 0 - let minY = fillRects.min(by: { $0.origin.y < $1.origin.y })?.origin.y ?? 0 - let max = fillRects.max(by: { $0.maxY < $1.maxY }) ?? .zero - let origin = CGPoint(x: minX, y: minY) - let size = CGSize(width: max.maxX - minX, height: max.maxY - minY) - textSelection.boundingRect = CGRect(origin: origin, size: size) - - context.fill(fillRects) - context.restoreGState() - } } diff --git a/Sources/CodeEditTextView/TextView/TextView.swift b/Sources/CodeEditTextView/TextView/TextView.swift index 48fc75c5..df896117 100644 --- a/Sources/CodeEditTextView/TextView/TextView.swift +++ b/Sources/CodeEditTextView/TextView/TextView.swift @@ -150,13 +150,28 @@ public class TextView: NSView, NSTextContent { } } - /// The edge insets for the text view. + /// The edge insets for the text view. This value insets every piece of drawable content in the view, including + /// selection rects. + /// + /// To further inset the text from the edge, without modifying how selections are inset, use ``textInsets`` public var edgeInsets: HorizontalEdgeInsets { get { - layoutManager?.edgeInsets ?? .zero + selectionManager.edgeInsets + } + set { + layoutManager.edgeInsets = newValue + textInsets + selectionManager.edgeInsets = newValue + } + } + + /// Insets just drawn text from the horizontal edges. This is in addition to the insets in ``edgeInsets``, but does + /// not apply to other drawn content. + public var textInsets: HorizontalEdgeInsets { + get { + layoutManager.edgeInsets - selectionManager.edgeInsets } set { - layoutManager?.edgeInsets = newValue + layoutManager.edgeInsets = edgeInsets + newValue } } diff --git a/Sources/CodeEditTextView/Utils/HorizontalEdgeInsets.swift b/Sources/CodeEditTextView/Utils/HorizontalEdgeInsets.swift index 47427938..65201008 100644 --- a/Sources/CodeEditTextView/Utils/HorizontalEdgeInsets.swift +++ b/Sources/CodeEditTextView/Utils/HorizontalEdgeInsets.swift @@ -7,7 +7,7 @@ import Foundation -public struct HorizontalEdgeInsets: Codable, Sendable, Equatable { +public struct HorizontalEdgeInsets: Codable, Sendable, Equatable, AdditiveArithmetic { public var left: CGFloat public var right: CGFloat @@ -29,4 +29,12 @@ public struct HorizontalEdgeInsets: Codable, Sendable, Equatable { public static let zero: HorizontalEdgeInsets = { HorizontalEdgeInsets(left: 0, right: 0) }() + + public static func + (lhs: HorizontalEdgeInsets, rhs: HorizontalEdgeInsets) -> HorizontalEdgeInsets { + HorizontalEdgeInsets(left: lhs.left + rhs.left, right: lhs.right + rhs.right) + } + + public static func - (lhs: HorizontalEdgeInsets, rhs: HorizontalEdgeInsets) -> HorizontalEdgeInsets { + HorizontalEdgeInsets(left: lhs.left - rhs.left, right: lhs.right - rhs.right) + } }