eleven / ocho Public

Clone
git clone https://amadan.net/eleven/ocho

Plain git — no account needed to clone.

Download

Download this file

1import Foundation
2
3// The bridge between a TextKit editor's NSAttributedString and the composer
4// model (#890 stage 2): style truth lives in ONE semantic attribute —
5// .composerStyle — and rendering attributes (fonts, strikethrough, colors)
6// are derived FROM it by the editor. Never the reverse: sniffing font traits
7// broke under Dynamic Type and fallback fonts in attempt one, and an
8// attribute the editor forgot to derive is a visual bug, not a wire bug.
9//
10// The adapter below is pure Foundation, so the storage→model derivation is
11// unit-tested headlessly (ComposerAttributesTests) with synthetic strings.
12
13public extension NSAttributedString.Key {
14 static let composerStyle = NSAttributedString.Key("lchat.composerStyle")
15}
16
17// The attribute value. A class with value equality: AppKit/UIKit coalesce
18// attribute runs by isEqual, so two separately-created attrs of the same
19// style must compare equal or every keystroke fragments the storage.
20public final class ComposerStyleAttr: NSObject {
21 public let style: InlineStyle
22 public init(_ style: InlineStyle) { self.style = style }
23
24 public override func isEqual(_ object: Any?) -> Bool {
25 (object as? ComposerStyleAttr)?.style == style
26 }
27 public override var hash: Int {
28 var hasher = Hasher()
29 hasher.combine(style)
30 return hasher.finalize()
31 }
32}
33
34// Attributed contents → the inline nodes of one paragraph (stage-2 scope:
35// blocks arrive in stage 3 with the line-kind attribute). Text runs split at
36// "\n"; each "\n" becomes a .brk carrying the style of the "\n" CHARACTER
37// itself — the marked-break contract: a typed Shift+Return inserts a plain
38// "\n" (**a**\n**b** on the wire), a select-across-lines style toggle styles
39// the "\n" too (**a\nb**).
40public func composerInlineNodes(of a: NSAttributedString) -> [InlineNode] {
41 var nodes: [InlineNode] = []
42 let str = a.string as NSString
43 a.enumerateAttribute(.composerStyle, in: NSRange(location: 0, length: a.length)) { value, range, _ in
44 let style = (value as? ComposerStyleAttr)?.style ?? .plain
45 let segments = str.substring(with: range).split(separator: "\n", omittingEmptySubsequences: false)
46 for (i, segment) in segments.enumerated() {
47 if i > 0 { nodes.append(.brk(style)) }
48 guard !segment.isEmpty else { continue }
49 // Coalesce with a preceding same-style run (rendering-only
50 // attribute changes can fragment runs the grammar doesn't see).
51 if case .run(let last) = nodes.last, last.style == style {
52 nodes[nodes.count - 1] = .run(RichRun(last.text + segment, style: style))
53 } else {
54 nodes.append(.run(RichRun(String(segment), style: style)))
55 }
56 }
57 }
58 return nodes
59}
60
61// The editor's wire form: what sends when the draft was composed rich.
62// For an unstyled draft this equals the display string byte-for-byte (the
63// zero-escape guarantee, fuzz-pinned in ComposerSerializeTests).
64public func composerMarkdown(of a: NSAttributedString, escapeLiterals: Bool = false) -> String {
65 serializeComposer([.paragraph(composerInlineNodes(of: a))], escapeLiterals: escapeLiterals)
66}
67