eleven / messenger Public

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

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