Pretty entities and sub/superscripts !45

merged merged by cmc on 2026-10-05 19:04 UTC · krz/orgstar:pretty-entities into main

5 files changed, +189 −17

Layout: unified · split

Sources/OrgCore/Commands/VisibleText.swift +8 −16
@@ -54,24 +54,16 @@ struct VisibleLine {
5454 }
5555 }
5656 }
57 if options.prettyEntities, line.contains(where: { $0 == "\\" || $0 == "_" || $0 == "^" }) {
58 let ns = line as NSString
59 let all = NSRange(location: 0, length: ns.length)
57 if options.prettyEntities {
6058 // `org-fontify-entities`: a single-character entity is composed into one glyph.
6159 // Positions inside it stay at its starting column; its width counts at its end.
62 let entity = try! NSRegularExpression(pattern: "\\\\(there4|sup[123]|frac[13][24]|[a-zA-Z]+)($|\\{\\}|[^\\p{L}\\n])")
63 for match in entity.matches(in: line, range: all) {
64 guard let display = OrgEntities.display[ns.substring(with: match.range(at: 1))], display.count == 1 else { continue }
65 let end = ns.substring(with: match.range(at: 2)) == "{}" ? NSMaxRange(match.range(at: 2)) : NSMaxRange(match.range(at: 1))
66 for i in match.range.location..<end where i < widths.count { widths[i] = 0 }
67 widths[end - 1] = displayWidth(display)
68 }
69 let pattern = options.subSuperscriptsNeedBraces
70 ? "\\S([_^])(\\{)[^{}\\n]*(\\})"
71 : "\\S([_^])(?:(\\{)[^{}\\n]*(\\})|[*]|[-+]?[^-+*!@#$%^_ \\t\\r\\n,:\"?<>~;./{}=()]+)"
72 for match in try! NSRegularExpression(pattern: pattern).matches(in: line, range: all) {
73 for group in 1...3 where match.range(at: group).location != NSNotFound {
74 hide(match.range(at: group).location..<NSMaxRange(match.range(at: group)))
60 for run in PrettyEntities.runs(in: line, subSuperscriptsNeedBraces: options.subSuperscriptsNeedBraces) {
61 switch run {
62 case .entity(let range, let display):
63 for i in range where i < widths.count { widths[i] = 0 }
64 widths[range.upperBound - 1] = displayWidth(display)
65 case .script(let markers, _, _):
66 for marker in markers { hide(marker) }
7567 }
7668 }
7769 }
Sources/OrgCore/Semantic/PrettyEntities.swift added +54
@@ -0,0 +1,54 @@
1import Foundation
2
3/// What `org-pretty-entities` changes on a line (`org-fontify-entities`, `org-raise-scripts`):
4/// single-character entities show as that character, and sub/superscripts lose their `_`, `^`
5/// and braces.
6public enum PrettyEntities {
7 public enum Run: Equatable, Sendable {
8 /// `\alpha` or `\alpha{}`, shown as `display`. Offsets are UTF-16, within the line.
9 case entity(range: Range<Int>, display: String)
10 /// A sub/superscript: `markers` (`_` or `^`, and braces) are hidden and `body` raised
11 /// or lowered.
12 case script(markers: [Range<Int>], body: Range<Int>, superscript: Bool)
13 }
14
15 static let entity = try! NSRegularExpression(pattern: "\\\\(there4|sup[123]|frac[13][24]|[a-zA-Z]+)($|\\{\\}|[^\\p{L}\\n])")
16 static let scriptsWithBraces = try! NSRegularExpression(pattern: "\\S([_^])(\\{)[^{}\\n]*(\\})")
17 static let scripts = try! NSRegularExpression(pattern: "\\S([_^])(?:(\\{)[^{}\\n]*(\\})|[*]|[-+]?[^-+*!@#$%^_ \\t\\r\\n,:\"?<>~;./{}=()]+)")
18
19 public static func runs(in line: String, subSuperscriptsNeedBraces: Bool) -> [Run] {
20 guard line.contains(where: { $0 == "\\" || $0 == "_" || $0 == "^" }) else { return [] }
21 let ns = line as NSString
22 let all = NSRange(location: 0, length: ns.length)
23 var runs: [Run] = []
24 for match in entity.matches(in: line, range: all) {
25 guard let display = OrgEntities.display[ns.substring(with: match.range(at: 1))], display.count == 1 else { continue }
26 let end = ns.substring(with: match.range(at: 2)) == "{}" ? NSMaxRange(match.range(at: 2)) : NSMaxRange(match.range(at: 1))
27 runs.append(.entity(range: match.range.location..<end, display: display))
28 }
29 // `org-raise-scripts` searches again from the last character of a match that a `_` or
30 // `^` follows, so `a_b^c` raises both.
31 let regex = subSuperscriptsNeedBraces ? scriptsWithBraces : scripts
32 var matches: [NSTextCheckingResult] = []
33 var from = 0
34 while from < ns.length, let match = regex.firstMatch(in: line, range: NSRange(location: from, length: ns.length - from)) {
35 matches.append(match)
36 let end = NSMaxRange(match.range)
37 from = end < ns.length && (ns.character(at: end) == 95 || ns.character(at: end) == 94) ? end - 1 : end
38 if from <= match.range.location { from = match.range.location + 1 }
39 }
40 for match in matches {
41 let markers = (1...3).compactMap { group -> Range<Int>? in
42 let r = match.range(at: group)
43 return r.location == NSNotFound ? nil : r.location..<NSMaxRange(r)
44 }
45 let marker = match.range(at: 1)
46 let hasBraces = match.range(at: 2).location != NSNotFound
47 let body = hasBraces
48 ? NSMaxRange(match.range(at: 2))..<match.range(at: 3).location
49 : NSMaxRange(marker)..<NSMaxRange(match.range)
50 runs.append(.script(markers: markers, body: body, superscript: ns.substring(with: marker) == "^"))
51 }
52 return runs
53 }
54}
Sources/OrgEditorAppKit/OrgEditor.swift +89 −1
@@ -534,6 +534,7 @@ public final class OrgEditor: NSObject {
534534 }
535535 }
536536 let revealed = caretLine
537 var scriptExclusions: [Range<Int>] = []
537538 for run in Presentation.styles(document.tree, in: range, highlighter: highlighter) {
538539 guard let clipped = clip(run.range, to: range) else { continue }
539540 if !showMarkup, hides(run.role), NSIntersectionRange(clipped, revealed).length == 0 {
@@ -541,6 +542,48 @@ public final class OrgEditor: NSObject {
541542 } else {
542543 theme.apply(run.role, to: storage, range: clipped)
543544 }
545 switch run.role {
546 case .bold, .italic, .underline, .strikeThrough, .verbatim, .code, .link, .linkMarkup, .keyword: scriptExclusions.append(run.range)
547 default: break
548 }
549 }
550 if !showMarkup, options.prettyEntities { prettify(range, in: storage, revealed: revealed, scriptExclusions: scriptExclusions) }
551 }
552
553 /// `org-pretty-entities`: entities drawn as their character over the entity's last
554 /// character (the rest hidden), sub/superscripts raised without their markers. Not on the
555 /// caret's line, in comments or in blocks; sub/superscripts not in emphasis, links or
556 /// keywords, as `org-raise-scripts` leaves them.
557 private func prettify(_ range: Range<Int>, in storage: NSTextStorage, revealed: NSRange, scriptExclusions: [Range<Int>]) {
558 let text = storage.string as NSString
559 var location = range.lowerBound
560 while location < range.upperBound {
561 let line = text.lineRange(for: NSRange(location: location, length: 0))
562 guard line.length > 0 else { break }
563 location = NSMaxRange(line)
564 if line.location == revealed.location || shaded.containing(line.location) != nil { continue }
565 let content = text.substring(with: line)
566 if content.range(of: "^[ \\t]*#([ +]|$)", options: .regularExpression) != nil { continue }
567 for run in PrettyEntities.runs(in: content, subSuperscriptsNeedBraces: options.subSuperscriptsNeedBraces) {
568 switch run {
569 case .entity(let r, let display):
570 let start = line.location + r.lowerBound
571 let last = line.location + r.upperBound - 1
572 if last > start { storage.addAttributes(theme.hidden, range: NSRange(location: start, length: last - start)) }
573 storage.addAttributes([.foregroundColor: NSColor.clear, .orgEntityDisplay: display], range: NSRange(location: last, length: 1))
574 case .script(let markers, let body, let superscript):
575 let bodyStart = line.location + body.lowerBound
576 if scriptExclusions.contains(where: { $0.contains(bodyStart) }) || body.isEmpty { continue }
577 for marker in markers {
578 storage.addAttributes(theme.hidden, range: NSRange(location: line.location + marker.lowerBound, length: marker.count))
579 }
580 let size = theme.font.pointSize
581 storage.addAttributes([
582 .font: NSFont.monospacedSystemFont(ofSize: size * 0.7, weight: .regular),
583 .baselineOffset: (superscript ? 0.3 : -0.3) * size,
584 ], range: NSRange(location: bodyStart, length: body.count))
585 }
586 }
544587 }
545588 }
546589
@@ -819,6 +862,9 @@ extension OrgEditor: NSTextLayoutManagerDelegate {
819862 let offset = manager.offset(from: manager.documentRange.location, to: range.location)
820863 if folded.contains(offset) { return FoldedHeadingFragment(textElement: textElement, range: range) }
821864 if shaded.containing(offset) != nil { return ShadedFragment(textElement: textElement, range: range) }
865 if let paragraph = textElement as? NSTextParagraph, EntityFragment.hasEntities(paragraph.attributedString) {
866 return EntityFragment(textElement: textElement, range: range)
867 }
822868 }
823869 return NSTextLayoutFragment(textElement: textElement, range: range)
824870 }
@@ -829,7 +875,9 @@ extension OrgEditor: NSTextLayoutManagerDelegate {
829875final class ShadedFragment: NSTextLayoutFragment, @unchecked Sendable {
830876 private var band: CGRect {
831877 let width = textLayoutManager?.textContainer?.size.width ?? layoutFragmentFrame.width
832 return CGRect(x: -layoutFragmentFrame.minX, y: 0, width: width, height: layoutFragmentFrame.height)
878 // Not under the empty line TextKit adds after a final newline.
879 let height = textLineFragments.last { $0.characterRange.length > 0 }?.typographicBounds.maxY ?? layoutFragmentFrame.height
880 return CGRect(x: -layoutFragmentFrame.minX, y: 0, width: width, height: height)
833881 }
834882
835883 override var renderingSurfaceBounds: CGRect { super.renderingSurfaceBounds.union(band) }
@@ -843,10 +891,50 @@ final class ShadedFragment: NSTextLayoutFragment, @unchecked Sendable {
843891 }
844892}
845893
894extension NSAttributedString.Key {
895 /// The character an entity shows as, on the entity's last character.
896 static let orgEntityDisplay = NSAttributedString.Key("OrgEntityDisplay")
897}
898
899/// A paragraph with pretty entities: each is drawn as its character where the entity's last
900/// (now invisible) character sits, so it takes one column as in Emacs.
901final class EntityFragment: NSTextLayoutFragment, @unchecked Sendable {
902 static func hasEntities(_ string: NSAttributedString) -> Bool {
903 var found = false
904 string.enumerateAttribute(.orgEntityDisplay, in: NSRange(location: 0, length: string.length)) { value, _, stop in
905 if value != nil { found = true; stop.pointee = true }
906 }
907 return found
908 }
909
910 override func draw(at point: CGPoint, in context: CGContext) {
911 super.draw(at: point, in: context)
912 Self.drawEntities(of: self, at: point, in: context)
913 }
914
915 static func drawEntities(of fragment: NSTextLayoutFragment, at point: CGPoint, in context: CGContext) {
916 guard let string = (fragment.textElement as? NSTextParagraph)?.attributedString else { return }
917 NSGraphicsContext.saveGraphicsState()
918 NSGraphicsContext.current = NSGraphicsContext(cgContext: context, flipped: true)
919 string.enumerateAttribute(.orgEntityDisplay, in: NSRange(location: 0, length: string.length)) { value, range, _ in
920 guard let display = value as? String,
921 let line = fragment.textLineFragments.first(where: { NSLocationInRange(range.location, $0.characterRange) }) else { return }
922 var attributes = string.attributes(at: range.location, effectiveRange: nil)
923 attributes[.foregroundColor] = NSColor.textColor
924 attributes[.orgEntityDisplay] = nil
925 let x = line.locationForCharacter(at: range.location).x
926 let bounds = line.typographicBounds
927 NSAttributedString(string: display, attributes: attributes).draw(at: CGPoint(x: point.x + bounds.minX + x, y: point.y + bounds.minY))
928 }
929 NSGraphicsContext.restoreGraphicsState()
930 }
931}
932
846933/// A heading line drawn with an ellipsis after it, like org's folded headings.
847934final class FoldedHeadingFragment: NSTextLayoutFragment, @unchecked Sendable {
848935 override func draw(at point: CGPoint, in context: CGContext) {
849936 super.draw(at: point, in: context)
937 EntityFragment.drawEntities(of: self, at: point, in: context)
850938 guard let line = textLineFragments.first else { return }
851939 let bounds = line.typographicBounds
852940 let ellipsis = NSAttributedString(string: " …", attributes: [
Tests/OrgCoreTests/PrettyEntitiesTests.swift added +17
@@ -0,0 +1,17 @@
1import Testing
2@testable import OrgCore
3
4struct PrettyEntitiesTests {
5 @Test func entitiesAndChainedScripts() {
6 let runs = PrettyEntities.runs(in: "\\alpha + \\beta{} = x_{1}^{2} \\to", subSuperscriptsNeedBraces: true)
7 #expect(runs == [
8 .entity(range: 0..<6, display: "α"),
9 .entity(range: 9..<16, display: "β"),
10 .entity(range: 29..<32, display: "→"),
11 .script(markers: [20..<21, 21..<22, 23..<24], body: 22..<23, superscript: false),
12 .script(markers: [24..<25, 25..<26, 27..<28], body: 26..<27, superscript: true),
13 ])
14 #expect(PrettyEntities.runs(in: "snake_case", subSuperscriptsNeedBraces: true).isEmpty)
15 #expect(PrettyEntities.runs(in: "a_b", subSuperscriptsNeedBraces: false) == [.script(markers: [1..<2], body: 2..<3, superscript: false)])
16 }
17}
Tests/OrgEditorAppKitTests/MarkupTests.swift +21
@@ -70,3 +70,24 @@ struct LineNumberTests {
7070 #expect(h.textView.textContainerInset.width == 0)
7171 }
7272}
73
74@MainActor
75struct PrettyEntityTests {
76 @Test func entitiesShowAsCharactersOffTheCaretLine() {
77 let text = "first\nThen \\alpha and x_{1}\n"
78 let h = Harness(text)
79 h.caret(at: 0)
80 h.editor.options.prettyEntities = true
81 h.editor.options.subSuperscriptsNeedBraces = true
82 h.editor.showMarkup = false
83 h.drainStyling()
84 let storage = h.textView.textStorage!
85 let last = (text as NSString).range(of: "\\alpha").location + 5
86 #expect(storage.attribute(.orgEntityDisplay, at: last, effectiveRange: nil) as? String == "α")
87 let one = (text as NSString).range(of: "1}").location
88 #expect((storage.attribute(.baselineOffset, at: one, effectiveRange: nil) as? CGFloat ?? 0) < 0)
89
90 h.caret(at: last)
91 #expect(storage.attribute(.orgEntityDisplay, at: last, effectiveRange: nil) == nil)
92 }
93}