| 1 | import 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 | |
| 13 | public 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. |
| 20 | public 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**). |
| 40 | public 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). |
| 64 | public func composerMarkdown(of a: NSAttributedString, escapeLiterals: Bool = false) -> String { |
| 65 | serializeComposer([.paragraph(composerInlineNodes(of: a))], escapeLiterals: escapeLiterals) |
| 66 | } |
| 67 | |