-
Notifications
You must be signed in to change notification settings - Fork 18
Emphasis Manager Enhancements #78
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
Changes from all commits
Commits
Show all changes
21 commits
Select commit
Hold shift + click to select a range
13e876c
Cleaned up the emphasize highlights appearance. Added black text layer.
austincondiff 3edf6dc
Removed unused changes from last commit.
austincondiff 4cc8772
Enabled anti aliasing and font smoothing. Using findHighlightColor in…
austincondiff 30a4699
Update EmphasizeAPI Colors On Draw
thecoolwinter 66fb2ed
Renamed EmphasisAPI to EmphasisManager. Separated concerns by moving …
austincondiff d1f56d6
Fix Scrolling To Emphasized Ranges, Swift 6 Warning
thecoolwinter 7032997
Use flatMap
thecoolwinter e93eaa0
Weakly reference `layer` and `textLayer` in delayed animation
thecoolwinter 1766731
Fix nested `if`
thecoolwinter dff34d8
Weakly reference `self` in delayed animation
thecoolwinter 05ef1cd
Update docs
thecoolwinter 50081c0
Move `Emphasis` and `EmphasisStyle` to Files
thecoolwinter 475002d
Merge branch 'feat/in-doc-search' of https://github.com/CodeEditApp/C…
thecoolwinter 5bdf601
Remove Extra Closing Bracket
thecoolwinter 365addb
Lint Error
thecoolwinter 49b9a5e
Comment on line drawing
thecoolwinter 2dd0fad
Return Early From `updateSelectionViews` When Not First Responder
thecoolwinter 6eb9f08
Docs, Naming
thecoolwinter e16c9d1
Docs
thecoolwinter 1495df7
Docs - Spelling
thecoolwinter 5136f8e
Add EmphasisManager Docs
thecoolwinter File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,47 @@ | ||
// | ||
// Emphasis.swift | ||
// CodeEditTextView | ||
// | ||
// Created by Khan Winter on 3/31/25. | ||
// | ||
|
||
import AppKit | ||
|
||
/// Represents a single emphasis with its properties | ||
public struct Emphasis { | ||
/// The range the emphasis applies it's style to, relative to the entire text document. | ||
public let range: NSRange | ||
|
||
/// The style to apply emphasis with, handled by the ``EmphasisManager``. | ||
public let style: EmphasisStyle | ||
|
||
/// Set to `true` to 'flash' the emphasis before removing it automatically after being added. | ||
/// | ||
/// Useful when an emphasis should be temporary and quick, like when emphasizing paired brackets in a document. | ||
public let flash: Bool | ||
|
||
/// Set to `true` to style the emphasis as 'inactive'. | ||
/// | ||
/// When ``style`` is ``EmphasisStyle/standard``, this reduces shadows and background color. | ||
/// For all styles, if drawing text on top of them, this uses ``EmphasisManager/getInactiveTextColor`` instead of | ||
/// the text view's text color to render the emphasized text. | ||
public let inactive: Bool | ||
|
||
/// Set to `true` if the emphasis manager should update the text view's selected range to match | ||
/// this object's ``Emphasis/range`` value. | ||
public let selectInDocument: Bool | ||
|
||
public init( | ||
range: NSRange, | ||
style: EmphasisStyle = .standard, | ||
flash: Bool = false, | ||
inactive: Bool = false, | ||
selectInDocument: Bool = false | ||
) { | ||
self.range = range | ||
self.style = style | ||
self.flash = flash | ||
self.inactive = inactive | ||
self.selectInDocument = selectInDocument | ||
} | ||
} |
319 changes: 319 additions & 0 deletions
319
Sources/CodeEditTextView/EmphasisManager/EmphasisManager.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,319 @@ | ||
// | ||
// EmphasisManager.swift | ||
// CodeEditTextView | ||
// | ||
// Created by Tom Ludwig on 05.11.24. | ||
// | ||
|
||
import AppKit | ||
|
||
/// Manages text emphases within a text view, supporting multiple styles and groups. | ||
/// | ||
/// Text emphasis draws attention to a range of text, indicating importance. | ||
/// This object may be used in a code editor to emphasize search results, or indicate | ||
/// bracket pairs, for instance. | ||
/// | ||
/// This object is designed to allow for easy grouping of emphasis types. An outside | ||
/// object is responsible for managing what emphases are visible. Because it's very | ||
/// likely that more than one type of emphasis may occur on the document at the same | ||
/// time, grouping allows each emphasis to be managed separately from the others by | ||
/// each outside object without knowledge of the other's state. | ||
public final class EmphasisManager { | ||
/// Internal representation of a emphasis layer with its associated text layer | ||
thecoolwinter marked this conversation as resolved.
Show resolved
Hide resolved
|
||
private struct EmphasisLayer { | ||
let emphasis: Emphasis | ||
let layer: CAShapeLayer | ||
let textLayer: CATextLayer? | ||
} | ||
|
||
private var emphasisGroups: [String: [EmphasisLayer]] = [:] | ||
private let activeColor: NSColor = .findHighlightColor | ||
private let inactiveColor: NSColor = NSColor.lightGray.withAlphaComponent(0.4) | ||
private var originalSelectionColor: NSColor? | ||
|
||
weak var textView: TextView? | ||
|
||
init(textView: TextView) { | ||
self.textView = textView | ||
} | ||
|
||
/// Adds a single emphasis to the specified group. | ||
/// - Parameters: | ||
/// - emphasis: The emphasis to add | ||
/// - id: A group identifier | ||
public func addEmphasis(_ emphasis: Emphasis, for id: String) { | ||
addEmphases([emphasis], for: id) | ||
} | ||
|
||
/// Adds multiple emphases to the specified group. | ||
/// - Parameters: | ||
/// - emphases: The emphases to add | ||
/// - id: The group identifier | ||
public func addEmphases(_ emphases: [Emphasis], for id: String) { | ||
// Store the current selection background color if not already stored | ||
if originalSelectionColor == nil { | ||
originalSelectionColor = textView?.selectionManager.selectionBackgroundColor ?? .selectedTextBackgroundColor | ||
} | ||
|
||
let layers = emphases.map { createEmphasisLayer(for: $0) } | ||
emphasisGroups[id] = layers | ||
|
||
// Handle selections | ||
handleSelections(for: emphases) | ||
|
||
// Handle flash animations | ||
for (index, emphasis) in emphases.enumerated() where emphasis.flash { | ||
let layer = layers[index] | ||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { [weak self] in | ||
guard let self = self else { return } | ||
self.applyFadeOutAnimation(to: layer.layer, textLayer: layer.textLayer) | ||
// Remove the emphasis from the group | ||
if var emphases = self.emphasisGroups[id] { | ||
emphases.remove(at: index) | ||
if emphases.isEmpty { | ||
self.emphasisGroups.removeValue(forKey: id) | ||
} else { | ||
self.emphasisGroups[id] = emphases | ||
} | ||
} | ||
} | ||
} | ||
} | ||
|
||
/// Replaces all emphases in the specified group. | ||
/// - Parameters: | ||
/// - emphases: The new emphases | ||
/// - id: The group identifier | ||
public func replaceEmphases(_ emphases: [Emphasis], for id: String) { | ||
removeEmphases(for: id) | ||
addEmphases(emphases, for: id) | ||
} | ||
|
||
/// Updates the emphases for a group by transforming the existing array. | ||
/// - Parameters: | ||
/// - id: The group identifier | ||
/// - transform: The transformation to apply to the existing emphases | ||
public func updateEmphases(for id: String, _ transform: ([Emphasis]) -> [Emphasis]) { | ||
guard let existingLayers = emphasisGroups[id] else { return } | ||
let existingEmphases = existingLayers.map { $0.emphasis } | ||
let newEmphases = transform(existingEmphases) | ||
replaceEmphases(newEmphases, for: id) | ||
} | ||
|
||
/// Removes all emphases for the given group. | ||
/// - Parameter id: The group identifier | ||
public func removeEmphases(for id: String) { | ||
emphasisGroups[id]?.forEach { layer in | ||
layer.layer.removeAllAnimations() | ||
layer.layer.removeFromSuperlayer() | ||
layer.textLayer?.removeAllAnimations() | ||
layer.textLayer?.removeFromSuperlayer() | ||
} | ||
emphasisGroups[id] = nil | ||
} | ||
|
||
/// Removes all emphases for all groups. | ||
public func removeAllEmphases() { | ||
emphasisGroups.keys.forEach { removeEmphases(for: $0) } | ||
emphasisGroups.removeAll() | ||
|
||
// Restore original selection emphasising | ||
if let originalColor = originalSelectionColor { | ||
textView?.selectionManager.selectionBackgroundColor = originalColor | ||
} | ||
originalSelectionColor = nil | ||
} | ||
|
||
/// Gets all emphases for a given group. | ||
/// - Parameter id: The group identifier | ||
/// - Returns: Array of emphases in the group | ||
public func getEmphases(for id: String) -> [Emphasis] { | ||
emphasisGroups[id]?.map { $0.emphasis } ?? [] | ||
} | ||
|
||
/// Updates the positions and bounds of all emphasis layers to match the current text layout. | ||
public func updateLayerBackgrounds() { | ||
for layer in emphasisGroups.flatMap(\.value) { | ||
if let shapePath = textView?.layoutManager?.roundedPathForRange(layer.emphasis.range) { | ||
if #available(macOS 14.0, *) { | ||
layer.layer.path = shapePath.cgPath | ||
} else { | ||
layer.layer.path = shapePath.cgPathFallback | ||
} | ||
|
||
// Update bounds and position | ||
if let cgPath = layer.layer.path { | ||
let boundingBox = cgPath.boundingBox | ||
layer.layer.bounds = boundingBox | ||
layer.layer.position = CGPoint(x: boundingBox.midX, y: boundingBox.midY) | ||
} | ||
|
||
// Update text layer if it exists | ||
if let textLayer = layer.textLayer { | ||
var bounds = shapePath.bounds | ||
bounds.origin.y += 1 // Move down by 1 pixel | ||
textLayer.frame = bounds | ||
} | ||
} | ||
} | ||
} | ||
|
||
private func createEmphasisLayer(for emphasis: Emphasis) -> EmphasisLayer { | ||
guard let shapePath = textView?.layoutManager?.roundedPathForRange(emphasis.range) else { | ||
return EmphasisLayer(emphasis: emphasis, layer: CAShapeLayer(), textLayer: nil) | ||
} | ||
|
||
let layer = createShapeLayer(shapePath: shapePath, emphasis: emphasis) | ||
textView?.layer?.insertSublayer(layer, at: 1) | ||
|
||
let textLayer = createTextLayer(for: emphasis) | ||
if let textLayer = textLayer { | ||
textView?.layer?.addSublayer(textLayer) | ||
} | ||
|
||
if emphasis.inactive == false && emphasis.style == .standard { | ||
applyPopAnimation(to: layer) | ||
} | ||
|
||
return EmphasisLayer(emphasis: emphasis, layer: layer, textLayer: textLayer) | ||
} | ||
|
||
private func createShapeLayer(shapePath: NSBezierPath, emphasis: Emphasis) -> CAShapeLayer { | ||
let layer = CAShapeLayer() | ||
|
||
switch emphasis.style { | ||
case .standard: | ||
layer.cornerRadius = 4.0 | ||
layer.fillColor = (emphasis.inactive ? inactiveColor : activeColor).cgColor | ||
layer.shadowColor = .black | ||
layer.shadowOpacity = emphasis.inactive ? 0.0 : 0.5 | ||
layer.shadowOffset = CGSize(width: 0, height: 1.5) | ||
layer.shadowRadius = 1.5 | ||
layer.opacity = 1.0 | ||
layer.zPosition = emphasis.inactive ? 0 : 1 | ||
case .underline(let color): | ||
layer.lineWidth = 1.0 | ||
layer.lineCap = .round | ||
layer.strokeColor = color.cgColor | ||
layer.fillColor = nil | ||
layer.opacity = emphasis.flash ? 0.0 : 1.0 | ||
layer.zPosition = 1 | ||
case .outline(let color): | ||
layer.cornerRadius = 2.5 | ||
layer.borderColor = color.cgColor | ||
layer.borderWidth = 0.5 | ||
layer.fillColor = nil | ||
layer.opacity = emphasis.flash ? 0.0 : 1.0 | ||
layer.zPosition = 1 | ||
} | ||
|
||
if #available(macOS 14.0, *) { | ||
layer.path = shapePath.cgPath | ||
} else { | ||
layer.path = shapePath.cgPathFallback | ||
} | ||
|
||
// Set bounds of the layer; needed for the scale animation | ||
if let cgPath = layer.path { | ||
let boundingBox = cgPath.boundingBox | ||
layer.bounds = boundingBox | ||
layer.position = CGPoint(x: boundingBox.midX, y: boundingBox.midY) | ||
} | ||
|
||
return layer | ||
} | ||
|
||
private func createTextLayer(for emphasis: Emphasis) -> CATextLayer? { | ||
guard let textView = textView, | ||
let layoutManager = textView.layoutManager, | ||
let shapePath = layoutManager.roundedPathForRange(emphasis.range), | ||
let originalString = textView.textStorage?.attributedSubstring(from: emphasis.range) else { | ||
return nil | ||
} | ||
|
||
var bounds = shapePath.bounds | ||
bounds.origin.y += 1 // Move down by 1 pixel | ||
|
||
// Create text layer | ||
let textLayer = CATextLayer() | ||
textLayer.frame = bounds | ||
textLayer.backgroundColor = NSColor.clear.cgColor | ||
textLayer.contentsScale = textView.window?.screen?.backingScaleFactor ?? 2.0 | ||
textLayer.allowsFontSubpixelQuantization = true | ||
textLayer.zPosition = 2 | ||
|
||
// Get the font from the attributed string | ||
if let font = originalString.attribute(.font, at: 0, effectiveRange: nil) as? NSFont { | ||
textLayer.font = font | ||
} else { | ||
textLayer.font = NSFont.systemFont(ofSize: NSFont.systemFontSize) | ||
} | ||
|
||
updateTextLayer(textLayer, with: originalString, emphasis: emphasis) | ||
return textLayer | ||
} | ||
|
||
private func updateTextLayer( | ||
_ textLayer: CATextLayer, | ||
with originalString: NSAttributedString, | ||
emphasis: Emphasis | ||
) { | ||
let text = NSMutableAttributedString(attributedString: originalString) | ||
text.addAttribute( | ||
.foregroundColor, | ||
value: emphasis.inactive ? getInactiveTextColor() : NSColor.black, | ||
range: NSRange(location: 0, length: text.length) | ||
) | ||
textLayer.string = text | ||
} | ||
|
||
private func getInactiveTextColor() -> NSColor { | ||
if textView?.effectiveAppearance.name == .darkAqua { | ||
return .white | ||
} | ||
return .black | ||
} | ||
|
||
private func applyPopAnimation(to layer: CALayer) { | ||
let scaleAnimation = CAKeyframeAnimation(keyPath: "transform.scale") | ||
scaleAnimation.values = [1.0, 1.25, 1.0] | ||
scaleAnimation.keyTimes = [0, 0.3, 1] | ||
scaleAnimation.duration = 0.1 | ||
scaleAnimation.timingFunctions = [CAMediaTimingFunction(name: .easeOut)] | ||
|
||
layer.add(scaleAnimation, forKey: "popAnimation") | ||
} | ||
|
||
private func applyFadeOutAnimation(to layer: CALayer, textLayer: CATextLayer?) { | ||
let fadeAnimation = CABasicAnimation(keyPath: "opacity") | ||
fadeAnimation.fromValue = 1.0 | ||
fadeAnimation.toValue = 0.0 | ||
fadeAnimation.duration = 0.1 | ||
fadeAnimation.timingFunction = CAMediaTimingFunction(name: .easeOut) | ||
fadeAnimation.fillMode = .forwards | ||
fadeAnimation.isRemovedOnCompletion = false | ||
|
||
layer.add(fadeAnimation, forKey: "fadeOutAnimation") | ||
|
||
if let textLayer = textLayer, let textFadeAnimation = fadeAnimation.copy() as? CABasicAnimation { | ||
textLayer.add(textFadeAnimation, forKey: "fadeOutAnimation") | ||
textLayer.add(textFadeAnimation, forKey: "fadeOutAnimation") | ||
} | ||
|
||
// Remove both layers after animation completes | ||
DispatchQueue.main.asyncAfter(deadline: .now() + fadeAnimation.duration) { [weak layer, weak textLayer] in | ||
layer?.removeFromSuperlayer() | ||
textLayer?.removeFromSuperlayer() | ||
} | ||
} | ||
|
||
/// Handles selection of text ranges for emphases where select is true | ||
private func handleSelections(for emphases: [Emphasis]) { | ||
let selectableRanges = emphases.filter(\.selectInDocument).map(\.range) | ||
guard let textView, !selectableRanges.isEmpty else { return } | ||
|
||
textView.selectionManager.setSelectedRanges(selectableRanges) | ||
textView.scrollSelectionToVisible() | ||
textView.needsDisplay = true | ||
} | ||
} |
31 changes: 31 additions & 0 deletions
31
Sources/CodeEditTextView/EmphasisManager/EmphasisStyle.swift
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,31 @@ | ||
// | ||
// EmphasisStyle.swift | ||
// CodeEditTextView | ||
// | ||
// Created by Khan Winter on 3/31/25. | ||
// | ||
|
||
import AppKit | ||
|
||
/// Defines the style of emphasis to apply to text ranges | ||
public enum EmphasisStyle: Equatable { | ||
/// Standard emphasis with background color | ||
case standard | ||
/// Underline emphasis with a line color | ||
case underline(color: NSColor) | ||
/// Outline emphasis with a border color | ||
case outline(color: NSColor) | ||
|
||
public static func == (lhs: EmphasisStyle, rhs: EmphasisStyle) -> Bool { | ||
switch (lhs, rhs) { | ||
case (.standard, .standard): | ||
return true | ||
case (.underline(let lhsColor), .underline(let rhsColor)): | ||
return lhsColor == rhsColor | ||
case (.outline(let lhsColor), .outline(let rhsColor)): | ||
return lhsColor == rhsColor | ||
default: | ||
return false | ||
} | ||
} | ||
} |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.