krz/orgstar

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

Commit bba622a330

bba622a330741075866a63f110953d34a9235a04

parent: 0a1f223b79

Verified · cmc

cmc <hello@cleberg.net> · 2026-10-09 02:41 UTC

Draw the iOS selection over block bands

The selection highlight draws under the text, where a block's band covered it: a block's fragments draw the selected part of each line over the band, and read the band's width and the selection when they draw, so blocks redraw as the selection or the view's width changes.

Layout: unified · split

Sources/OrgstarMobile/MobileEditor.swift +63 −2
@@ -22,9 +22,13 @@ final class LockedRanges: @unchecked Sendable {
22final class FragmentStyle: @unchecked Sendable { 22final class FragmentStyle: @unchecked Sendable {
23 private let lock = NSLock() 23 private let lock = NSLock()
24 private var band: (color: UIColor, width: CGFloat) = (.clear, 0) 24 private var band: (color: UIColor, width: CGFloat) = (.clear, 0)
25 private var selected: (range: Range<Int>, color: UIColor)?
25 26
26 func set(blockBackground color: UIColor, width: CGFloat) { lock.withLock { band = (color, width) } } 27 func set(blockBackground color: UIColor, width: CGFloat) { lock.withLock { band = (color, width) } }
27 var blockBand: (color: UIColor, width: CGFloat) { lock.withLock { band } } 28 var blockBand: (color: UIColor, width: CGFloat) { lock.withLock { band } }
29 /// The selection, which a block's fragment draws over its band, as the Mac's fragments do.
30 func set(selection range: Range<Int>?, color: UIColor) { lock.withLock { selected = range.map { ($0, color) } } }
31 var selection: (range: Range<Int>, color: UIColor)? { lock.withLock { selected } }
28} 32}
29 33
30/// A text view that offers hardware-keyboard presses to the editor's keymap first. 34/// A text view that offers hardware-keyboard presses to the editor's keymap first.
@@ -155,6 +159,7 @@ final class MobileEditor: NSObject, DocumentBuffer {
155 /// Blocks drawn on a band. 159 /// Blocks drawn on a band.
156 let shaded = LockedRanges() 160 let shaded = LockedRanges()
157 let fragmentStyle = FragmentStyle() 161 let fragmentStyle = FragmentStyle()
162 private var selectionColor: UIColor = .clear
158 /// `org-indent-mode` and `org-hide-leading-stars`, from the settings and `#+STARTUP`. 163 /// `org-indent-mode` and `org-hide-leading-stars`, from the settings and `#+STARTUP`.
159 private var indented = true 164 private var indented = true
160 private var hideStars = false 165 private var hideStars = false
@@ -520,6 +525,27 @@ final class MobileEditor: NSObject, DocumentBuffer {
520 let view = interaction.highlightView as? SelectionHighlightView ?? SelectionHighlightView() 525 let view = interaction.highlightView as? SelectionHighlightView ?? SelectionHighlightView()
521 view.color = theme.color("selection") ?? UIColor.tintColor.withAlphaComponent(0.25) 526 view.color = theme.color("selection") ?? UIColor.tintColor.withAlphaComponent(0.25)
522 if interaction.highlightView !== view { interaction.highlightView = view } 527 if interaction.highlightView !== view { interaction.highlightView = view }
528 selectionColor = view.color
529 }
530
531 /// The highlight view draws under the text, so a block's band would cover it: blocks the
532 /// selection enters or leaves are laid out again to draw it over their band.
533 private func redrawSelectedBlocks() {
534 let selected = textView.selectedRange
535 let range = selected.length > 0 ? selected.location..<NSMaxRange(selected) : nil
536 let old = fragmentStyle.selection?.range
537 fragmentStyle.set(selection: range, color: selectionColor)
538 guard let manager = textView.textLayoutManager, let content = manager.textContentManager else { return }
539 let touched = [old, range].compactMap { $0 }
540 for block in shaded.all where touched.contains(where: { $0.overlaps(block) }) {
541 guard let start = content.location(content.documentRange.location, offsetBy: block.lowerBound),
542 let end = content.location(start, offsetBy: block.count),
543 let textRange = NSTextRange(location: start, end: end) else { continue }
544 // Fragments are kept across layout passes, so they are asked to draw again too.
545 manager.invalidateRenderingAttributes(for: textRange)
546 manager.invalidateLayout(for: textRange)
547 }
548 textView.setNeedsDisplay()
523 } 549 }
524 550
525 /// The band behind blocks spans the view's width, or the widest line's when truncated; 551 /// The band behind blocks spans the view's width, or the widest line's when truncated;
@@ -532,7 +558,10 @@ final class MobileEditor: NSObject, DocumentBuffer {
532 fragmentStyle.set(blockBackground: band.color, width: width) 558 fragmentStyle.set(blockBackground: band.color, width: width)
533 imageWidth = textView.bounds.width 559 imageWidth = textView.bounds.width
534 if showsImages, resized { restyle(0..<textView.textStorage.length) } 560 if showsImages, resized { restyle(0..<textView.textStorage.length) }
535 if let manager = textView.textLayoutManager { manager.invalidateLayout(for: manager.documentRange) } 561 if let manager = textView.textLayoutManager {
562 manager.invalidateRenderingAttributes(for: manager.documentRange)
563 manager.invalidateLayout(for: manager.documentRange)
564 }
536 } 565 }
537 566
538 /// Styles the whole file again. 567 /// Styles the whole file again.
@@ -825,6 +854,7 @@ final class MobileEditor: NSObject, DocumentBuffer {
825 854
826 /// The caret left hidden text (a tap past a fold): what hides it opens. 855 /// The caret left hidden text (a tap past a fold): what hides it opens.
827 fileprivate func selectionChanged() { 856 fileprivate func selectionChanged() {
857 redrawSelectedBlocks()
828 let caret = textView.selectedRange.location 858 let caret = textView.selectedRange.location
829 if hidden.containing(caret) != nil { unfold(around: caret) } 859 if hidden.containing(caret) != nil { unfold(around: caret) }
830 revealCaretLine() 860 revealCaretLine()
@@ -897,6 +927,10 @@ extension MobileEditor: NSTextLayoutManagerDelegate {
897 let fragment = OrgLineFragment(textElement: textElement, range: range) 927 let fragment = OrgLineFragment(textElement: textElement, range: range)
898 fragment.folded = folded 928 fragment.folded = folded
899 fragment.band = band 929 fragment.band = band
930 if band != nil {
931 fragment.start = offset
932 fragment.style = fragmentStyle
933 }
900 fragment.image = image 934 fragment.image = image
901 return fragment 935 return fragment
902 } 936 }
@@ -950,6 +984,9 @@ final class OrgLineFragment: NSTextLayoutFragment, @unchecked Sendable {
950 var folded = false 984 var folded = false
951 var band: (color: UIColor, width: CGFloat)? 985 var band: (color: UIColor, width: CGFloat)?
952 var image: InlineImageBox? 986 var image: InlineImageBox?
987 /// For a block's lines: where the fragment starts, and where the selection is read.
988 var start = 0
989 var style: FragmentStyle?
953 990
954 static func image(_ string: NSAttributedString) -> InlineImageBox? { 991 static func image(_ string: NSAttributedString) -> InlineImageBox? {
955 var found: InlineImageBox? 992 var found: InlineImageBox?
@@ -971,7 +1008,9 @@ final class OrgLineFragment: NSTextLayoutFragment, @unchecked Sendable {
971 guard let band else { return nil } 1008 guard let band else { return nil }
972 // Not under the empty line TextKit adds after a final newline. 1009 // Not under the empty line TextKit adds after a final newline.
973 let height = textLineFragments.last { $0.characterRange.length > 0 }?.typographicBounds.maxY ?? layoutFragmentFrame.height 1010 let height = textLineFragments.last { $0.characterRange.length > 0 }?.typographicBounds.maxY ?? layoutFragmentFrame.height
974 return CGRect(x: -layoutFragmentFrame.minX, y: 0, width: max(band.width, layoutFragmentFrame.maxX), height: height) 1011 // The width as it is now: the view may have been laid out since this fragment was made.
1012 let width = style?.blockBand.width ?? band.width
1013 return CGRect(x: -layoutFragmentFrame.minX, y: 0, width: max(width, layoutFragmentFrame.maxX), height: height)
975 } 1014 }
976 1015
977 private var imageRect: CGRect? { 1016 private var imageRect: CGRect? {
@@ -992,6 +1031,7 @@ final class OrgLineFragment: NSTextLayoutFragment, @unchecked Sendable {
992 context.setFillColor(band.color.cgColor) 1031 context.setFillColor(band.color.cgColor)
993 context.fill(rect.offsetBy(dx: point.x, dy: point.y)) 1032 context.fill(rect.offsetBy(dx: point.x, dy: point.y))
994 context.restoreGState() 1033 context.restoreGState()
1034 drawSelection(at: point, in: context, bandRect: rect)
995 } 1035 }
996 super.draw(at: point, in: context) 1036 super.draw(at: point, in: context)
997 UIGraphicsPushContext(context) 1037 UIGraphicsPushContext(context)
@@ -1008,6 +1048,27 @@ final class OrgLineFragment: NSTextLayoutFragment, @unchecked Sendable {
1008 } 1048 }
1009 } 1049 }
1010 1050
1051 /// The selected part of each line, over the band; a selection running past a line's end
1052 /// fills to the band's edge.
1053 private func drawSelection(at point: CGPoint, in context: CGContext, bandRect: CGRect) {
1054 guard let selection = style?.selection else { return }
1055 context.saveGState()
1056 context.setFillColor(selection.color.cgColor)
1057 for line in textLineFragments where line.characterRange.length > 0 {
1058 let lineStart = start + line.characterRange.location
1059 let lineEnd = lineStart + line.characterRange.length
1060 let from = max(selection.range.lowerBound, lineStart), to = min(selection.range.upperBound, lineEnd)
1061 guard from < to || (from == to && selection.range.upperBound > lineEnd && from == lineEnd) else { continue }
1062 let bounds = line.typographicBounds
1063 // From the margin when the selection began on an earlier line, as the system draws it.
1064 let margin = bandRect.minX + (textLayoutManager?.textContainer?.lineFragmentPadding ?? 0)
1065 let x1 = selection.range.lowerBound < lineStart ? margin : bounds.minX + line.locationForCharacter(at: from - start).x
1066 let x2 = selection.range.upperBound >= lineEnd ? bandRect.maxX : bounds.minX + line.locationForCharacter(at: to - start).x
1067 context.fill(CGRect(x: point.x + x1, y: point.y + bounds.minY, width: max(0, x2 - x1), height: bounds.height))
1068 }
1069 context.restoreGState()
1070 }
1071
1011 private func drawEntities(at point: CGPoint) { 1072 private func drawEntities(at point: CGPoint) {
1012 guard let string = (textElement as? NSTextParagraph)?.attributedString else { return } 1073 guard let string = (textElement as? NSTextParagraph)?.attributedString else { return }
1013 string.enumerateAttribute(.orgEntityDisplay, in: NSRange(location: 0, length: string.length)) { value, range, _ in 1074 string.enumerateAttribute(.orgEntityDisplay, in: NSRange(location: 0, length: string.length)) { value, range, _ in