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 {
17 /// The text as shown, for accessibility. 17 /// The text as shown, for accessibility.
18 var accessibleText: (() -> AccessibleText)? 18 var accessibleText: (() -> AccessibleText)?
19 var onMouseDown: (() -> Void)? 19 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 }
20 /// Each editor keeps its own undo history, not the window's. 43 /// Each editor keeps its own undo history, not the window's.
21 private let ownUndoManager = UndoManager() 44 private let ownUndoManager = UndoManager()
22 override var undoManager: UndoManager? { ownUndoManager } 45 override var undoManager: UndoManager? { ownUndoManager }
Sources/OrgEditorAppKit/OrgEditor.swift +55 −1
@@ -69,6 +69,8 @@ public final class OrgEditor: NSObject {
69 } 69 }
70 private var isLoading = false 70 private var isLoading = false
71 private var previousSelection = 0 71 private var previousSelection = 0
72 /// The selection shaded block lines last drew.
73 private var previousSelectionRange = NSRange(location: 0, length: 0)
72 /// Text before this offset is styled; after it, only base attributes until the background 74 /// Text before this offset is styled; after it, only base attributes until the background
73 /// passes reach it. Always at a line start. 75 /// passes reach it. Always at a line start.
74 private(set) var styledUpTo = 0 76 private(set) var styledUpTo = 0
@@ -122,6 +124,16 @@ public final class OrgEditor: NSObject {
122 dispatcher = KeyDispatcher(keymap: KeymapPreset.emacs.keymap) 124 dispatcher = KeyDispatcher(keymap: KeymapPreset.emacs.keymap)
123 super.init() 125 super.init()
124 orgTextView.keyHandler = { [weak self] event in self?.handleKey(event) ?? false } 126 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 }
125 orgTextView.onCommandClick = { [weak self] offset in 137 orgTextView.onCommandClick = { [weak self] offset in
126 guard let self, Links.link(at: offset, text: self.document.text, tree: self.document.tree) != nil 138 guard let self, Links.link(at: offset, text: self.document.text, tree: self.document.tree) != nil
127 || Links.timestampDays(at: offset, text: self.document.text) != nil else { return false } 139 || Links.timestampDays(at: offset, text: self.document.text) != nil else { return false }
@@ -219,7 +231,8 @@ public final class OrgEditor: NSObject {
219 textView.drawsBackground = true 231 textView.drawsBackground = true
220 textView.backgroundColor = theme.color("background") ?? .textBackgroundColor 232 textView.backgroundColor = theme.color("background") ?? .textBackgroundColor
221 textView.insertionPointColor = theme.color("cursor") ?? .textColor 233 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]
223 if let view = orgTextView { 236 if let view = orgTextView {
224 view.lineNumberFont = NSFont.monospacedDigitSystemFont(ofSize: max(8, theme.font.pointSize - 2), weight: .regular) 237 view.lineNumberFont = NSFont.monospacedDigitSystemFont(ofSize: max(8, theme.font.pointSize - 2), weight: .regular)
225 view.lineNumberColor = theme.color("line-number") ?? .tertiaryLabelColor 238 view.lineNumberColor = theme.color("line-number") ?? .tertiaryLabelColor
@@ -1309,11 +1322,33 @@ public final class OrgEditor: NSObject {
1309 return mapped 1322 return mapped
1310 } 1323 }
1311 1324
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
1312 /// Keeps the caret out of folded text: moving forward into a fold jumps past it, moving 1346 /// Keeps the caret out of folded text: moving forward into a fold jumps past it, moving
1313 /// back jumps to the end of the heading line. 1347 /// back jumps to the end of the heading line.
1314 func selectionChanged() { 1348 func selectionChanged() {
1315 let selection = textView.selectedRange() 1349 let selection = textView.selectedRange()
1316 defer { previousSelection = selection.location } 1350 defer { previousSelection = selection.location }
1351 redrawShadedSelection(selection)
1317 onSelectionChange?(selection) 1352 onSelectionChange?(selection)
1318 revealCaretLine() 1353 revealCaretLine()
1319 orgTextView?.redrawLineNumbers() 1354 orgTextView?.redrawLineNumbers()
@@ -1435,6 +1470,25 @@ final class ShadedFragment: NSTextLayoutFragment, @unchecked Sendable {
1435 context.saveGState() 1470 context.saveGState()
1436 context.setFillColor(Theme.blockBackground.cgColor) 1471 context.setFillColor(Theme.blockBackground.cgColor)
1437 context.fill(band.offsetBy(dx: point.x, dy: point.y)) 1472 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 }
1438 context.restoreGState() 1492 context.restoreGState()
1439 super.draw(at: point, in: context) 1493 super.draw(at: point, in: context)
1440 } 1494 }
Sources/OrgEditorAppKit/Theme.swift +4
@@ -53,6 +53,10 @@ struct Theme {
53 53
54 /// The band behind blocks, read by layout fragments off the main actor. 54 /// The band behind blocks, read by layout fragments off the main actor.
55 nonisolated(unsafe) static var blockBackground = NSColor.clear 55 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> = []
56 60
57 /// Markup hidden from view: no width, no color. 61 /// Markup hidden from view: no width, no color.
58 var hidden: [NSAttributedString.Key: Any] { 62 var hidden: [NSAttributedString.Key: Any] {