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