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 {
2222final class FragmentStyle: @unchecked Sendable {
2323 private let lock = NSLock()
2424 private var band: (color: UIColor, width: CGFloat) = (.clear, 0)
25 private var selected: (range: Range<Int>, color: UIColor)?
2526
2627 func set(blockBackground color: UIColor, width: CGFloat) { lock.withLock { band = (color, width) } }
2728 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 } }
2832}
2933
3034/// A text view that offers hardware-keyboard presses to the editor's keymap first.
@@ -155,6 +159,7 @@ final class MobileEditor: NSObject, DocumentBuffer {
155159 /// Blocks drawn on a band.
156160 let shaded = LockedRanges()
157161 let fragmentStyle = FragmentStyle()
162 private var selectionColor: UIColor = .clear
158163 /// `org-indent-mode` and `org-hide-leading-stars`, from the settings and `#+STARTUP`.
159164 private var indented = true
160165 private var hideStars = false
@@ -520,6 +525,27 @@ final class MobileEditor: NSObject, DocumentBuffer {
520525 let view = interaction.highlightView as? SelectionHighlightView ?? SelectionHighlightView()
521526 view.color = theme.color("selection") ?? UIColor.tintColor.withAlphaComponent(0.25)
522527 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()
523549 }
524550
525551 /// 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 {
532558 fragmentStyle.set(blockBackground: band.color, width: width)
533559 imageWidth = textView.bounds.width
534560 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 }
536565 }
537566
538567 /// Styles the whole file again.
@@ -825,6 +854,7 @@ final class MobileEditor: NSObject, DocumentBuffer {
825854
826855 /// The caret left hidden text (a tap past a fold): what hides it opens.
827856 fileprivate func selectionChanged() {
857 redrawSelectedBlocks()
828858 let caret = textView.selectedRange.location
829859 if hidden.containing(caret) != nil { unfold(around: caret) }
830860 revealCaretLine()
@@ -897,6 +927,10 @@ extension MobileEditor: NSTextLayoutManagerDelegate {
897927 let fragment = OrgLineFragment(textElement: textElement, range: range)
898928 fragment.folded = folded
899929 fragment.band = band
930 if band != nil {
931 fragment.start = offset
932 fragment.style = fragmentStyle
933 }
900934 fragment.image = image
901935 return fragment
902936 }
@@ -950,6 +984,9 @@ final class OrgLineFragment: NSTextLayoutFragment, @unchecked Sendable {
950984 var folded = false
951985 var band: (color: UIColor, width: CGFloat)?
952986 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?
953990
954991 static func image(_ string: NSAttributedString) -> InlineImageBox? {
955992 var found: InlineImageBox?
@@ -971,7 +1008,9 @@ final class OrgLineFragment: NSTextLayoutFragment, @unchecked Sendable {
9711008 guard let band else { return nil }
9721009 // Not under the empty line TextKit adds after a final newline.
9731010 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)
9751014 }
9761015
9771016 private var imageRect: CGRect? {
@@ -992,6 +1031,7 @@ final class OrgLineFragment: NSTextLayoutFragment, @unchecked Sendable {
9921031 context.setFillColor(band.color.cgColor)
9931032 context.fill(rect.offsetBy(dx: point.x, dy: point.y))
9941033 context.restoreGState()
1034 drawSelection(at: point, in: context, bandRect: rect)
9951035 }
9961036 super.draw(at: point, in: context)
9971037 UIGraphicsPushContext(context)
@@ -1008,6 +1048,27 @@ final class OrgLineFragment: NSTextLayoutFragment, @unchecked Sendable {
10081048 }
10091049 }
10101050
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
10111072 private func drawEntities(at point: CGPoint) {
10121073 guard let string = (textElement as? NSTextParagraph)?.attributedString else { return }
10131074 string.enumerateAttribute(.orgEntityDisplay, in: NSRange(location: 0, length: string.length)) { value, range, _ in