krz/orgstar

A native macOS editor for org-mode files. editor org-mode swift

Commit 433d982dfa

433d982dfac259414e8cbec24352502a4499fccf

parent: 02bd211f34

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-07 23:04 UTC

Show the selection over shaded blocks

A block line's band is drawn over the view's selection highlight, so the
line draws the selection over its band, in the color the view uses
(the theme's when focused, the muted system color when not), across
the line's full height. Selection and focus changes redraw the block
lines involved.

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] {