Commit bba622a330
Verified · cmc
Layout: unified · split
Sources/OrgstarMobile/MobileEditor.swift +63 −2
| @@ -22,9 +22,13 @@ final class LockedRanges: @unchecked Sendable { | ||
| 22 | 22 | final class FragmentStyle: @unchecked Sendable { |
| 23 | 23 | private let lock = NSLock() |
| 24 | 24 | private var band: (color: UIColor, width: CGFloat) = (.clear, 0) |
| 25 | private var selected: (range: Range<Int>, color: UIColor)? | |
| 25 | 26 | |
| 26 | 27 | func set(blockBackground color: UIColor, width: CGFloat) { lock.withLock { band = (color, width) } } |
| 27 | 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 | 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 | 159 | /// Blocks drawn on a band. |
| 156 | 160 | let shaded = LockedRanges() |
| 157 | 161 | let fragmentStyle = FragmentStyle() |
| 162 | private var selectionColor: UIColor = .clear | |
| 158 | 163 | /// `org-indent-mode` and `org-hide-leading-stars`, from the settings and `#+STARTUP`. |
| 159 | 164 | private var indented = true |
| 160 | 165 | private var hideStars = false |
| @@ -520,6 +525,27 @@ final class MobileEditor: NSObject, DocumentBuffer { | ||
| 520 | 525 | let view = interaction.highlightView as? SelectionHighlightView ?? SelectionHighlightView() |
| 521 | 526 | view.color = theme.color("selection") ?? UIColor.tintColor.withAlphaComponent(0.25) |
| 522 | 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 | 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 | 558 | fragmentStyle.set(blockBackground: band.color, width: width) |
| 533 | 559 | imageWidth = textView.bounds.width |
| 534 | 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 | 567 | /// Styles the whole file again. |
| @@ -825,6 +854,7 @@ final class MobileEditor: NSObject, DocumentBuffer { | ||
| 825 | 854 | |
| 826 | 855 | /// The caret left hidden text (a tap past a fold): what hides it opens. |
| 827 | 856 | fileprivate func selectionChanged() { |
| 857 | redrawSelectedBlocks() | |
| 828 | 858 | let caret = textView.selectedRange.location |
| 829 | 859 | if hidden.containing(caret) != nil { unfold(around: caret) } |
| 830 | 860 | revealCaretLine() |
| @@ -897,6 +927,10 @@ extension MobileEditor: NSTextLayoutManagerDelegate { | ||
| 897 | 927 | let fragment = OrgLineFragment(textElement: textElement, range: range) |
| 898 | 928 | fragment.folded = folded |
| 899 | 929 | fragment.band = band |
| 930 | if band != nil { | |
| 931 | fragment.start = offset | |
| 932 | fragment.style = fragmentStyle | |
| 933 | } | |
| 900 | 934 | fragment.image = image |
| 901 | 935 | return fragment |
| 902 | 936 | } |
| @@ -950,6 +984,9 @@ final class OrgLineFragment: NSTextLayoutFragment, @unchecked Sendable { | ||
| 950 | 984 | var folded = false |
| 951 | 985 | var band: (color: UIColor, width: CGFloat)? |
| 952 | 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 | 991 | static func image(_ string: NSAttributedString) -> InlineImageBox? { |
| 955 | 992 | var found: InlineImageBox? |
| @@ -971,7 +1008,9 @@ final class OrgLineFragment: NSTextLayoutFragment, @unchecked Sendable { | ||
| 971 | 1008 | guard let band else { return nil } |
| 972 | 1009 | // Not under the empty line TextKit adds after a final newline. |
| 973 | 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 | 1016 | private var imageRect: CGRect? { |
| @@ -992,6 +1031,7 @@ final class OrgLineFragment: NSTextLayoutFragment, @unchecked Sendable { | ||
| 992 | 1031 | context.setFillColor(band.color.cgColor) |
| 993 | 1032 | context.fill(rect.offsetBy(dx: point.x, dy: point.y)) |
| 994 | 1033 | context.restoreGState() |
| 1034 | drawSelection(at: point, in: context, bandRect: rect) | |
| 995 | 1035 | } |
| 996 | 1036 | super.draw(at: point, in: context) |
| 997 | 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 | 1072 | private func drawEntities(at point: CGPoint) { |
| 1012 | 1073 | guard let string = (textElement as? NSTextParagraph)?.attributedString else { return } |
| 1013 | 1074 | string.enumerateAttribute(.orgEntityDisplay, in: NSRange(location: 0, length: string.length)) { value, range, _ in |