eleven / ocho Public

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

Plain git — no account needed to clone.

Download

Download this file

1import SwiftUI
2import UIKit
3import LChatCore
4
5// The composer's text input. We drop to a UITextView (via a representable)
6// instead of SwiftUI's TextField because we need to intercept Return *before* it
7// inserts a newline: `.onKeyPress` sits above the field and the focused text
8// input swallows Return first, so a SwiftUI-level handler can't make Return send.
9// UITextView's `shouldChangeTextIn` delegate gates every edit — the one place the
10// interception reliably wins.
11//
12// `sendOnReturn` follows the CLAUDE.md platform split: on the Mac a bare Return
13// sends and Shift+Return inserts a newline (matching the web composer on
14// desktop); on touch, Return always inserts a newline and the send button sends.
15//
16// Rich marks (#890 stage 2): bold/italic/code/strike style IN the box — ⌘B/⌘I
17// (and the selection Format menu), ⌘E/⌘⇧X, and format-as-you-type (**bold**
18// turns bold on the closing marker; Backspace right after restores the
19// literal). The contract with the rest of the app:
20// - The `text` binding is the DISPLAY string (exactly what's in the box, no
21// markers) — the typeahead/commands space, unchanged from the plain days.
22// - The wire form is read at send via ComposerFieldController.serializedMarkdown()
23// (the LChatCore serializer twin, vector-pinned against the web).
24// - Style truth is the .composerStyle attribute; fonts derive from it.
25// - External binding writes mean "set literal plain text" (edit-load shows
26// markers, exactly as today) — programmatic mid-draft edits (typeahead
27// splices) go through the controller so styling and caret survive.
28struct ComposerField: UIViewRepresentable {
29 @Binding var text: String
30 @Binding var focused: Bool
31 // Caret position as a UTF-16 offset (UIKit's native unit) — drives the
32 // typeahead, which needs to know which token the caret sits in.
33 var caret: Binding<Int>? = nil
34 var placeholder: String = ""
35 var sendOnReturn: Bool
36 // Format-as-you-type (the closing-marker rules). OFF still leaves the
37 // shortcuts working — markers you type stay literal and render in the feed.
38 var formatAsYouType: Bool = true
39 var controller: ComposerFieldController? = nil
40 var onSend: () -> Void
41 // A pasted image (the composer's "paste to upload") — data is JPEG.
42 var onPasteImage: ((Data) -> Void)? = nil
43 // Hardware-keyboard ↑ with an empty draft: edit your last message.
44 var onEditLast: (() -> Void)? = nil
45 // Hardware-keyboard Escape (iPad): the composer's peel ladder — typeahead,
46 // command panel, an armed edit/reply, the docked thread pane. Returns
47 // whether the press was consumed; unconsumed falls through to UIKit.
48 var onEscape: (() -> Bool)? = nil
49
50 func makeCoordinator() -> Coordinator { Coordinator(self) }
51
52 func makeUIView(context: Context) -> GrowingTextView {
53 let tv = GrowingTextView()
54 tv.onPasteImage = onPasteImage
55 tv.onEditLast = onEditLast
56 tv.onEscape = onEscape
57 tv.delegate = context.coordinator
58 tv.font = tv.baseBodyFont
59 // Live-track the system Text Size (Dynamic Type) — preferredFont only
60 // sizes at creation without this (styled runs re-derive in
61 // traitCollectionDidChange).
62 tv.adjustsFontForContentSizeCategory = true
63 tv.backgroundColor = .clear
64 tv.textContainerInset = .zero
65 tv.textContainer.lineFragmentPadding = 0
66 tv.isScrollEnabled = false
67 // UIKit's standard rich-editing hooks: routes ⌘B/⌘I and the selection
68 // Format menu to toggleBoldface/toggleItalics, which we intercept.
69 tv.allowsEditingTextAttributes = true
70 tv.typingAttributes = tv.baseAttributes
71 controller?.view = tv
72 return tv
73 }
74
75 func updateUIView(_ tv: GrowingTextView, context: Context) {
76 context.coordinator.parent = self
77 controller?.view = tv
78 tv.formatAsYouType = formatAsYouType
79 // The key/paste hooks follow the latest values — a bar whose
80 // attachment sources change (a bot joining flips a default-command
81 // thread) must not keep the closures it was made with.
82 tv.onPasteImage = onPasteImage
83 tv.onEditLast = onEditLast
84 tv.onEscape = onEscape
85 // A plain string compare — user edits round-trip exactly, so only
86 // EXTERNAL sets (edit-load, clear after send, pushback) land here,
87 // and those are literal plain text by contract.
88 if tv.text != text { tv.setPlainText(text) }
89 tv.placeholderLabel.text = placeholder
90 tv.placeholderLabel.isHidden = !text.isEmpty
91 // Drive first responder from the focus binding — async so we never mutate
92 // UIKit responder state in the middle of a SwiftUI update. Comparing to the
93 // live isFirstResponder (not the previous binding value) means a re-assert
94 // to `true` after a send-button tap still re-grabs focus.
95 if focused != tv.isFirstResponder {
96 DispatchQueue.main.async {
97 if focused { tv.becomeFirstResponder() } else { tv.resignFirstResponder() }
98 }
99 }
100 }
101
102 // Grow with the text up to six lines, then let it scroll.
103 func sizeThatFits(_ proposal: ProposedViewSize, uiView: GrowingTextView, context: Context) -> CGSize? {
104 let width = proposal.width ?? uiView.bounds.width
105 guard width > 0, width.isFinite else { return nil }
106 let fit = uiView.sizeThatFits(CGSize(width: width, height: .greatestFiniteMagnitude)).height
107 let scroll = fit > uiView.capHeight
108 if uiView.isScrollEnabled != scroll {
109 DispatchQueue.main.async { uiView.isScrollEnabled = scroll }
110 }
111 return CGSize(width: width, height: ceil(min(fit, uiView.capHeight)))
112 }
113
114 final class Coordinator: NSObject, UITextViewDelegate {
115 var parent: ComposerField
116 init(_ parent: ComposerField) { self.parent = parent }
117
118 func textViewDidChange(_ tv: UITextView) {
119 guard let tv = tv as? GrowingTextView else { return }
120 tv.editCounter &+= 1
121 // Format-as-you-type runs before the binding sync so what the
122 // binding sees is the post-rule text. Guarded against IME
123 // composition and reentrancy inside.
124 if parent.formatAsYouType { tv.maybeApplyInlineRule() }
125 // Kept SYNCHRONOUS on purpose. This fires only for user-driven edits
126 // (a programmatic `tv.text` set does NOT call it), so it never runs
127 // inside a SwiftUI view update — no "modifying state during update".
128 // And the text write MUST stay sync: deferring it would open a
129 // runloop window where tv.text and the binding disagree, and an
130 // unrelated re-render landing there (socket/presence churn) would let
131 // updateUIView clobber the just-typed character.
132 parent.text = tv.text
133 parent.caret?.wrappedValue = tv.selectedRange.location
134 tv.placeholderLabel.isHidden = !tv.text.isEmpty
135 }
136
137 func textViewDidChangeSelection(_ tv: UITextView) {
138 // This DOES fire synchronously when updateUIView sets `tv.text` (a
139 // programmatic text change moves the selection), so writing the caret
140 // binding here lands mid-view-update — undefined behaviour
141 // ("Modifying state during view update"). Defer it to the next
142 // runloop, the same async idiom the first-responder wiring uses.
143 let caret = tv.selectedRange.location
144 DispatchQueue.main.async { self.parent.caret?.wrappedValue = caret }
145 }
146
147 func textView(_ tv: UITextView, shouldChangeTextIn range: NSRange,
148 replacementText replacement: String) -> Bool {
149 guard let growing = tv as? GrowingTextView else { return true }
150 // Backspace immediately after a format rule fired restores the
151 // literal text instead of deleting styled content (the web's
152 // undoInputRule-on-Backspace parity).
153 if replacement.isEmpty, range.length > 0,
154 growing.ruleUndoStamp == growing.editCounter, tv.undoManager?.canUndo == true {
155 tv.undoManager?.undo()
156 return false
157 }
158 if replacement == "\n" {
159 // Desktop: Shift+Return falls through as a newline; a bare
160 // Return sends. Either way we swallow the keystroke so no
161 // stray newline lands in the field.
162 if parent.sendOnReturn, !growing.shiftHeld {
163 parent.onSend()
164 return false
165 }
166 // A TYPED newline carries plain style — the marked-break
167 // contract (**a**\n**b** on the wire) — while the typing style
168 // is restored after, so continuing to type stays bold, like
169 // the web after Shift+Enter.
170 growing.insertPlainNewline()
171 return false
172 }
173 // Arm the format-as-you-type check: rules fire on single typed
174 // characters only (never pastes, never IME composition).
175 growing.pendingRuleCheck =
176 range.length == 0 && replacement.count == 1 && tv.markedTextRange == nil
177 return true
178 }
179
180 func textViewDidBeginEditing(_ tv: UITextView) {
181 if !parent.focused { parent.focused = true }
182 }
183 func textViewDidEndEditing(_ tv: UITextView) {
184 if parent.focused { parent.focused = false }
185 }
186 }
187}
188
189// Programmatic access to the live field for edits that must not disturb
190// styling or the caret: typeahead splices go through insertText (undo,
191// delegate flow and caret placement all behave), never a binding rewrite.
192// serializedMarkdown() is the wire form read at send — the getMarkdown to
193// the binding's getText, exactly the web facade's split.
194@MainActor
195final class ComposerFieldController {
196 weak var view: GrowingTextView?
197
198 func insert(_ text: String, replacingUTF16 range: NSRange?) {
199 guard let tv = view else { return }
200 if let range { tv.selectedRange = range } // typing attrs follow the position
201 tv.insertText(text)
202 }
203
204 func serializedMarkdown(escapeLiterals: Bool = false) -> String {
205 guard let tv = view else { return "" }
206 return composerMarkdown(of: tv.attributedText ?? NSAttributedString(), escapeLiterals: escapeLiterals)
207 }
208
209 func focus() {
210 view?.becomeFirstResponder()
211 }
212}
213
214// A UITextView that reports a placeholder, caps its growth at six lines,
215// tracks Shift for the Return split, and owns the rich-mark machinery:
216// .composerStyle is the truth, fonts derive from it.
217final class GrowingTextView: UITextView {
218 let placeholderLabel = UILabel()
219 private(set) var shiftHeld = false
220 var formatAsYouType = true
221 // Pasting an image stages it as an upload instead of inserting nothing —
222 // text pastes are untouched, and a mixed clipboard does both.
223 var onPasteImage: ((Data) -> Void)?
224 // Hardware-keyboard ↑ with an empty draft (the desktop edit convention).
225 var onEditLast: (() -> Void)?
226 // Hardware-keyboard Escape — the container's peel ladder; true = consumed.
227 var onEscape: (() -> Bool)?
228 // Format-as-you-type bookkeeping (see Coordinator + maybeApplyInlineRule).
229 var pendingRuleCheck = false
230 var editCounter: UInt64 = 0
231 var ruleUndoStamp: UInt64 = .max
232 private var applyingRule = false
233
234 // The unstyled body font every run's rendering derives from. Never read
235 // `font` for metrics — after styled content lands it reports the run at
236 // the caret (a bold/mono base would warp the cap and placeholder).
237 private(set) var baseBodyFont: UIFont = .preferredFont(forTextStyle: .body)
238
239 var baseAttributes: [NSAttributedString.Key: Any] {
240 [.font: baseBodyFont, .foregroundColor: UIColor.label]
241 }
242
243 // Rendering attributes derived from the semantic style — the ONLY place
244 // style becomes fonts. Code is monospaced on a quiet fill (the web
245 // composer's chip, at field scale); strike is a strikethrough line.
246 func renderingAttributes(for style: InlineStyle) -> [NSAttributedString.Key: Any] {
247 var attrs = baseAttributes
248 guard !style.isPlain else { return attrs }
249 attrs[.composerStyle] = ComposerStyleAttr(style)
250 if style.code {
251 attrs[.font] = UIFont.monospacedSystemFont(ofSize: baseBodyFont.pointSize * 0.92, weight: .regular)
252 attrs[.backgroundColor] = UIColor.secondarySystemFill
253 return attrs
254 }
255 var traits: UIFontDescriptor.SymbolicTraits = []
256 if style.bold { traits.insert(.traitBold) }
257 if style.italic { traits.insert(.traitItalic) }
258 if !traits.isEmpty, let d = baseBodyFont.fontDescriptor.withSymbolicTraits(traits) {
259 attrs[.font] = UIFont(descriptor: d, size: baseBodyFont.pointSize)
260 }
261 if style.strike { attrs[.strikethroughStyle] = NSUnderlineStyle.single.rawValue }
262 return attrs
263 }
264
265 private func styleAtCaret() -> InlineStyle {
266 (typingAttributes[.composerStyle] as? ComposerStyleAttr)?.style ?? .plain
267 }
268
269 // ---- external sets + newline contract ----
270
271 /// An external set (edit-load, clear, pushback): literal plain text,
272 /// caret at the end, typing style reset, history dropped (a cleared
273 /// draft mustn't ⌘Z a sent message back).
274 func setPlainText(_ s: String) {
275 attributedText = NSAttributedString(string: s, attributes: baseAttributes)
276 selectedRange = NSRange(location: (s as NSString).length, length: 0)
277 typingAttributes = baseAttributes
278 undoManager?.removeAllActions()
279 placeholderLabel.isHidden = !s.isEmpty
280 }
281
282 /// A typed newline: the "\n" itself is plain (marked-break contract), the
283 /// typing style around it survives so the next line continues the mark.
284 func insertPlainNewline() {
285 let saved = typingAttributes
286 typingAttributes = baseAttributes
287 insertText("\n")
288 typingAttributes = saved
289 }
290
291 // ---- mark toggles (⌘B/⌘I via UIKit's standard routing, ⌘E/⌘⇧X ours) ----
292
293 override var keyCommands: [UIKeyCommand]? {
294 var commands = super.keyCommands ?? []
295 commands.append(UIKeyCommand(input: "e", modifierFlags: .command,
296 action: #selector(toggleCodeMark)))
297 let strike = UIKeyCommand(input: "x", modifierFlags: [.command, .shift],
298 action: #selector(toggleStrikeMark))
299 commands.append(strike)
300 return commands
301 }
302
303 override func toggleBoldface(_ sender: Any?) { toggle(\.bold) }
304 override func toggleItalics(_ sender: Any?) { toggle(\.italic) }
305 @objc private func toggleCodeMark() { toggle(\.code) }
306 @objc private func toggleStrikeMark() { toggle(\.strike) }
307
308 override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool {
309 // No underline: the wire grammar has no marker for it, and a style
310 // that silently vanishes on send would be a lie.
311 if action == #selector(toggleUnderline(_:)) { return false }
312 if action == #selector(paste(_:)), onPasteImage != nil,
313 UIPasteboard.general.hasImages { return true }
314 return super.canPerformAction(action, withSender: sender)
315 }
316
317 private func flipped(_ style: InlineStyle, _ key: WritableKeyPath<InlineStyle, Bool>, to on: Bool) -> InlineStyle {
318 var out = style
319 out[keyPath: key] = on
320 // Code is exclusive both ways, like the web schema.
321 if key == \.code, on { out.bold = false; out.italic = false; out.strike = false }
322 if key != \.code, on { out.code = false }
323 return out
324 }
325
326 private func toggle(_ key: WritableKeyPath<InlineStyle, Bool>) {
327 let range = selectedRange
328 if range.length == 0 {
329 // Bare caret: flip the typing style — the next characters wear it.
330 let style = flipped(styleAtCaret(), key, to: !styleAtCaret()[keyPath: key])
331 typingAttributes = renderingAttributes(for: style)
332 return
333 }
334 guard let storage = textStorage as NSTextStorage? else { return }
335 // Standard toggle semantics: all-styled → remove, else add.
336 var allStyled = true
337 storage.enumerateAttribute(.composerStyle, in: range) { value, _, stop in
338 let style = (value as? ComposerStyleAttr)?.style ?? .plain
339 if !style[keyPath: key] { allStyled = false; stop.pointee = true }
340 }
341 let before = storage.attributedSubstring(from: range)
342 undoManager?.registerUndo(withTarget: self) { tv in
343 tv.textStorage.replaceCharacters(in: range, with: before)
344 tv.delegate?.textViewDidChange?(tv)
345 }
346 storage.beginEditing()
347 storage.enumerateAttribute(.composerStyle, in: range) { value, subRange, _ in
348 let style = (value as? ComposerStyleAttr)?.style ?? .plain
349 let flippedStyle = flipped(style, key, to: !allStyled)
350 storage.setAttributes(renderingAttributes(for: flippedStyle), range: subRange)
351 }
352 storage.endEditing()
353 // Attribute-only edits don't fire textViewDidChange — poke the
354 // delegate or a send right after ⌘B serializes the pre-toggle draft
355 // (the attempt-one trap, kept fixed).
356 delegate?.textViewDidChange?(self)
357 }
358
359 // ---- format-as-you-type ----
360
361 // Called from textViewDidChange after a single typed character: if the
362 // text up to the caret completes an inline rule (**b**, *i*, `c`, ~s~ —
363 // ComposerRules, drift-gated against the web), swap the marker span for
364 // the styled content. One Backspace (or ⌘Z) restores the literal.
365 func maybeApplyInlineRule() {
366 guard pendingRuleCheck, !applyingRule, markedTextRange == nil else {
367 pendingRuleCheck = false
368 return
369 }
370 pendingRuleCheck = false
371 let caret = selectedRange.location
372 guard selectedRange.length == 0, caret > 0 else { return }
373 let ns = text as NSString
374 let lineStart = ns.range(of: "\n", options: .backwards,
375 range: NSRange(location: 0, length: caret)).upperBound
376 let lineRange = NSRange(location: lineStart == NSNotFound ? 0 : lineStart,
377 length: caret - (lineStart == NSNotFound ? 0 : lineStart))
378 guard lineRange.length > 0 else { return }
379 guard let hit = inlineCompletion(lineUpToCaret: ns.substring(with: lineRange)) else { return }
380 let absolute = NSRange(location: lineRange.location + hit.replaceRange.lowerBound,
381 length: hit.replaceRange.upperBound - hit.replaceRange.lowerBound)
382 // Never inside a code run (the web's rangeHasMark(code) guard).
383 var touchesCode = false
384 textStorage.enumerateAttribute(.composerStyle, in: absolute) { value, _, stop in
385 if ((value as? ComposerStyleAttr)?.style.code ?? false) { touchesCode = true; stop.pointee = true }
386 }
387 guard !touchesCode else { return }
388 var style = InlineStyle()
389 switch hit.mark {
390 case .bold: style.bold = true
391 case .italic: style.italic = true
392 case .code: style.code = true
393 case .strike: style.strike = true
394 }
395 applyingRule = true
396 let before = textStorage.attributedSubstring(from: absolute)
397 let beforeCaret = caret
398 undoManager?.registerUndo(withTarget: self) { tv in
399 let restored = NSRange(location: absolute.location, length: (hit.content as NSString).length)
400 tv.textStorage.replaceCharacters(in: restored, with: before)
401 tv.selectedRange = NSRange(location: beforeCaret, length: 0)
402 tv.typingAttributes = tv.baseAttributes
403 tv.delegate?.textViewDidChange?(tv)
404 }
405 textStorage.replaceCharacters(
406 in: absolute,
407 with: NSAttributedString(string: hit.content, attributes: renderingAttributes(for: style)))
408 selectedRange = NSRange(location: absolute.location + (hit.content as NSString).length, length: 0)
409 // The web's removeStoredMark: the next keystroke types unstyled.
410 typingAttributes = baseAttributes
411 applyingRule = false
412 delegate?.textViewDidChange?(self)
413 // Stamped AFTER the poke — it increments editCounter, and the
414 // Backspace-undo check compares against the post-poke value.
415 ruleUndoStamp = editCounter
416 }
417
418 // ---- paste ----
419
420 override func paste(_ sender: Any?) {
421 let pb = UIPasteboard.general
422 if pb.hasImages, let onPasteImage {
423 for image in pb.images ?? [] {
424 if let data = image.jpegData(compressionQuality: 0.92) { onPasteImage(data) }
425 }
426 if !pb.hasStrings { return } // image-only paste fully consumed
427 }
428 // Text pastes flatten to the caret's typing style (a web page's fonts
429 // and colors have no wire form), with the web composer's guards: past
430 // 1MB the paste is refused — attach a file instead.
431 if let s = pb.string, !s.isEmpty {
432 if s.utf16.count > 1_000_000 { return }
433 insertText(s)
434 return
435 }
436 super.paste(sender)
437 }
438
439 // ---- metrics / chrome (base font, never the caret run's) ----
440
441 private var lineHeight: CGFloat { baseBodyFont.lineHeight }
442 var capHeight: CGFloat { ceil(lineHeight * 6) }
443
444 override init(frame: CGRect, textContainer: NSTextContainer?) {
445 super.init(frame: frame, textContainer: textContainer)
446 placeholderLabel.font = .preferredFont(forTextStyle: .body)
447 placeholderLabel.adjustsFontForContentSizeCategory = true
448 placeholderLabel.textColor = .placeholderText
449 placeholderLabel.numberOfLines = 1
450 addSubview(placeholderLabel)
451 }
452 required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
453
454 override func layoutSubviews() {
455 super.layoutSubviews()
456 placeholderLabel.frame = CGRect(x: 0, y: 0, width: bounds.width, height: lineHeight)
457 }
458
459 // Dynamic Type: refresh the base font and re-derive every styled run from
460 // its semantic attribute (the reason fonts are never the style truth).
461 override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
462 super.traitCollectionDidChange(previousTraitCollection)
463 guard previousTraitCollection?.preferredContentSizeCategory != traitCollection.preferredContentSizeCategory else { return }
464 baseBodyFont = .preferredFont(forTextStyle: .body)
465 let full = NSRange(location: 0, length: textStorage.length)
466 textStorage.beginEditing()
467 textStorage.enumerateAttribute(.composerStyle, in: full) { value, range, _ in
468 let style = (value as? ComposerStyleAttr)?.style ?? .plain
469 textStorage.setAttributes(renderingAttributes(for: style), range: range)
470 }
471 textStorage.endEditing()
472 typingAttributes = renderingAttributes(for: styleAtCaret())
473 }
474
475 override func pressesBegan(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
476 if let mods = presses.first?.key?.modifierFlags { shiftHeld = mods.contains(.shift) }
477 if presses.first?.key?.keyCode == .keyboardUpArrow, text.isEmpty, let onEditLast {
478 onEditLast()
479 return
480 }
481 // Escape peels the composer's innermost layer (typeahead → command
482 // panel → armed edit/reply → …); an unconsumed press stays UIKit's.
483 if presses.first?.key?.keyCode == .keyboardEscape, let onEscape, onEscape() {
484 return
485 }
486 super.pressesBegan(presses, with: event)
487 }
488 override func pressesEnded(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
489 shiftHeld = false
490 super.pressesEnded(presses, with: event)
491 }
492 override func pressesCancelled(_ presses: Set<UIPress>, with event: UIPressesEvent?) {
493 shiftHeld = false
494 super.pressesCancelled(presses, with: event)
495 }
496}
497