Skip to content

Add Text Insets #65

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

Merged
merged 3 commits into from
Jan 16, 2025
Merged
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 @@ -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;
Expand Down Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
//
// TextSelectionManager+Draw.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<UUID> = []
// 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<UUID>
) {
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()
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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?
Expand Down Expand Up @@ -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<UUID> = []
// 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<UUID>
) {
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()
}
}
40 changes: 40 additions & 0 deletions Sources/CodeEditTextView/TextView/TextView+SetText.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
//
// TextView+SetText.swift
// CodeEditTextView
//
// Created by Khan Winter on 1/12/25.
//

import AppKit

extension TextView {
/// Sets the text view's text to a new value.
/// - Parameter text: The new contents of the text view.
public func setText(_ text: String) {
let newStorage = NSTextStorage(string: text)
self.setTextStorage(newStorage)
}

/// Set a new text storage object for the view.
/// - Parameter textStorage: The new text storage to use.
public func setTextStorage(_ textStorage: NSTextStorage) {
self.textStorage = textStorage

subviews.forEach { view in
view.removeFromSuperview()
}

textStorage.addAttributes(typingAttributes, range: documentRange)
layoutManager.textStorage = textStorage
layoutManager.reset()

selectionManager.textStorage = textStorage
selectionManager.setSelectedRanges(selectionManager.textSelections.map { $0.range })

_undoManager?.clearStack()

textStorage.delegate = storageDelegate
needsDisplay = true
needsLayout = true
}
}
Loading