Show the selection over shaded blocks !143

merged merged by cmc on 2026-10-07 23:04 UTC · krz/orgstar:block-selection into main

3 files changed, +82 −1

Layout: unified · split

Sources/OrgEditorAppKit/Keys.swift +23
@@ -17,6 +17,29 @@ final class OrgTextView: NSTextView {
1717 /// The text as shown, for accessibility.
1818 var accessibleText: (() -> AccessibleText)?
1919 var onMouseDown: (() -> Void)?
20 /// The view gained or lost focus, or its window key status: the selection's color changes.
21 var onFocusChange: (() -> Void)?
22 private var keyObservers: [NSObjectProtocol] = []
23
24 override func becomeFirstResponder() -> Bool {
25 defer { onFocusChange?() }
26 return super.becomeFirstResponder()
27 }
28
29 override func resignFirstResponder() -> Bool {
30 defer { onFocusChange?() }
31 return super.resignFirstResponder()
32 }
33
34 override func viewDidMoveToWindow() {
35 super.viewDidMoveToWindow()
36 keyObservers.forEach(NotificationCenter.default.removeObserver)
37 keyObservers = [NSWindow.didBecomeKeyNotification, NSWindow.didResignKeyNotification].map { name in
38 NotificationCenter.default.addObserver(forName: name, object: window, queue: .main) { [weak self] _ in
39 MainActor.assumeIsolated { self?.onFocusChange?() }
40 }
41 }
42 }
2043 /// Each editor keeps its own undo history, not the window's.
2144 private let ownUndoManager = UndoManager()
2245 override var undoManager: UndoManager? { ownUndoManager }
Sources/OrgEditorAppKit/OrgEditor.swift +55 −1
@@ -69,6 +69,8 @@ public final class OrgEditor: NSObject {
6969 }
7070 private var isLoading = false
7171 private var previousSelection = 0
72 /// The selection shaded block lines last drew.
73 private var previousSelectionRange = NSRange(location: 0, length: 0)
7274 /// Text before this offset is styled; after it, only base attributes until the background
7375 /// passes reach it. Always at a line start.
7476 private(set) var styledUpTo = 0
@@ -122,6 +124,16 @@ public final class OrgEditor: NSObject {
122124 dispatcher = KeyDispatcher(keymap: KeymapPreset.emacs.keymap)
123125 super.init()
124126 orgTextView.keyHandler = { [weak self] event in self?.handleKey(event) ?? false }
127 orgTextView.onFocusChange = { [weak self] in
128 guard let self else { return }
129 let id = ObjectIdentifier(self.textLayoutManager)
130 if let window = self.textView.window, window.isKeyWindow, window.firstResponder === self.textView {
131 Theme.focusedLayouts.insert(id)
132 } else {
133 Theme.focusedLayouts.remove(id)
134 }
135 self.redrawShaded([self.textView.selectedRange()])
136 }
125137 orgTextView.onCommandClick = { [weak self] offset in
126138 guard let self, Links.link(at: offset, text: self.document.text, tree: self.document.tree) != nil
127139 || Links.timestampDays(at: offset, text: self.document.text) != nil else { return false }
@@ -219,7 +231,8 @@ public final class OrgEditor: NSObject {
219231 textView.drawsBackground = true
220232 textView.backgroundColor = theme.color("background") ?? .textBackgroundColor
221233 textView.insertionPointColor = theme.color("cursor") ?? .textColor
222 textView.selectedTextAttributes = [.backgroundColor: theme.color("selection") ?? .selectedTextBackgroundColor]
234 Theme.selection = theme.color("selection") ?? .selectedTextBackgroundColor
235 textView.selectedTextAttributes = [.backgroundColor: Theme.selection]
223236 if let view = orgTextView {
224237 view.lineNumberFont = NSFont.monospacedDigitSystemFont(ofSize: max(8, theme.font.pointSize - 2), weight: .regular)
225238 view.lineNumberColor = theme.color("line-number") ?? .tertiaryLabelColor
@@ -1309,11 +1322,33 @@ public final class OrgEditor: NSObject {
13091322 return mapped
13101323 }
13111324
1325 /// Shaded block lines the old or new selection covers are drawn again, as they draw the
1326 /// selection over their band.
1327 private func redrawShadedSelection(_ selection: NSRange) {
1328 defer { previousSelectionRange = selection }
1329 redrawShaded([previousSelectionRange, selection])
1330 }
1331
1332 private func redrawShaded(_ ranges: [NSRange]) {
1333 guard let manager = textLayoutManager.textContentManager else { return }
1334 for old in ranges where old.length > 0 {
1335 for block in shaded.all where block.overlaps(old.location..<NSMaxRange(old)) {
1336 let lower = max(block.lowerBound, old.location)
1337 let upper = min(block.upperBound, NSMaxRange(old))
1338 guard let start = manager.location(manager.documentRange.location, offsetBy: lower),
1339 let end = manager.location(start, offsetBy: upper - lower),
1340 let range = NSTextRange(location: start, end: end) else { continue }
1341 textLayoutManager.invalidateLayout(for: range)
1342 }
1343 }
1344 }
1345
13121346 /// Keeps the caret out of folded text: moving forward into a fold jumps past it, moving
13131347 /// back jumps to the end of the heading line.
13141348 func selectionChanged() {
13151349 let selection = textView.selectedRange()
13161350 defer { previousSelection = selection.location }
1351 redrawShadedSelection(selection)
13171352 onSelectionChange?(selection)
13181353 revealCaretLine()
13191354 orgTextView?.redrawLineNumbers()
@@ -1435,6 +1470,25 @@ final class ShadedFragment: NSTextLayoutFragment, @unchecked Sendable {
14351470 context.saveGState()
14361471 context.setFillColor(Theme.blockBackground.cgColor)
14371472 context.fill(band.offsetBy(dx: point.x, dy: point.y))
1473 // The band covers the view's selection highlight, so the selected text is drawn on it,
1474 // in the color the view uses: the theme's when focused, the system's muted one when not.
1475 if let manager = textLayoutManager {
1476 let active = Theme.focusedLayouts.contains(ObjectIdentifier(manager))
1477 context.setFillColor((active ? Theme.selection : NSColor.unemphasizedSelectedTextBackgroundColor).cgColor)
1478 let single = textLineFragments.count == 1
1479 for selection in manager.textSelections {
1480 for range in selection.textRanges where !range.isEmpty {
1481 guard let part = range.intersection(rangeInElement), !part.isEmpty else { continue }
1482 manager.enumerateTextSegments(in: part, type: .selection, options: [.rangeNotRequired]) { _, frame, _, _ in
1483 var rect = frame.offsetBy(dx: point.x - self.layoutFragmentFrame.minX, dy: point.y - self.layoutFragmentFrame.minY)
1484 // A one-line fragment's selection covers its band, leaving no gap to the next line.
1485 if single { rect = CGRect(x: rect.minX, y: point.y, width: rect.width, height: self.band.height) }
1486 context.fill(rect)
1487 return true
1488 }
1489 }
1490 }
1491 }
14381492 context.restoreGState()
14391493 super.draw(at: point, in: context)
14401494 }
Sources/OrgEditorAppKit/Theme.swift +4
@@ -53,6 +53,10 @@ struct Theme {
5353
5454 /// The band behind blocks, read by layout fragments off the main actor.
5555 nonisolated(unsafe) static var blockBackground = NSColor.clear
56 /// The selection's color, which shaded block lines draw over their band.
57 nonisolated(unsafe) static var selection = NSColor.selectedTextBackgroundColor
58 /// Layout managers whose view has focus in the key window, for the selection's color.
59 nonisolated(unsafe) static var focusedLayouts: Set<ObjectIdentifier> = []
5660
5761 /// Markup hidden from view: no width, no color.
5862 var hidden: [NSAttributedString.Key: Any] {