| index 1cd2f51..f650f6d 100644 |
| --- a/ios/LChat/Views/ComposerField.swift |
| +++ b/ios/LChat/Views/ComposerField.swift |
| @@ -1,5 +1,6 @@ |
| import SwiftUI |
| import UIKit |
| +import LChatCore |
| |
| // The composer's text input. We drop to a UITextView (via a representable) |
| // instead of SwiftUI's TextField because we need to intercept Return *before* it |
| @@ -11,6 +12,19 @@ import UIKit |
| // `sendOnReturn` follows the CLAUDE.md platform split: on the Mac a bare Return |
| // sends and Shift+Return inserts a newline (matching the web composer on |
| // desktop); on touch, Return always inserts a newline and the send button sends. |
| +// |
| +// Rich marks (#890 stage 2): bold/italic/code/strike style IN the box — ⌘B/⌘I |
| +// (and the selection Format menu), ⌘E/⌘⇧X, and format-as-you-type (**bold** |
| +// turns bold on the closing marker; Backspace right after restores the |
| +// literal). The contract with the rest of the app: |
| +// - The `text` binding is the DISPLAY string (exactly what's in the box, no |
| +// markers) — the typeahead/commands space, unchanged from the plain days. |
| +// - The wire form is read at send via ComposerFieldController.serializedMarkdown() |
| +// (the LChatCore serializer twin, vector-pinned against the web). |
| +// - Style truth is the .composerStyle attribute; fonts derive from it. |
| +// - External binding writes mean "set literal plain text" (edit-load shows |
| +// markers, exactly as today) — programmatic mid-draft edits (typeahead |
| +// splices) go through the controller so styling and caret survive. |
| struct ComposerField: UIViewRepresentable { |
| @Binding var text: String |
| @Binding var focused: Bool |
| @@ -19,6 +33,10 @@ struct ComposerField: UIViewRepresentable { |
| var caret: Binding<Int>? = nil |
| var placeholder: String = "" |
| var sendOnReturn: Bool |
| + // Format-as-you-type (the closing-marker rules). OFF still leaves the |
| + // shortcuts working — markers you type stay literal and render in the feed. |
| + var formatAsYouType: Bool = true |
| + var controller: ComposerFieldController? = nil |
| var onSend: () -> Void |
| // A pasted image (the composer's "paste to upload") — data is JPEG. |
| var onPasteImage: ((Data) -> Void)? = nil |
| @@ -32,20 +50,31 @@ struct ComposerField: UIViewRepresentable { |
| tv.onPasteImage = onPasteImage |
| tv.onEditLast = onEditLast |
| tv.delegate = context.coordinator |
| - tv.font = .preferredFont(forTextStyle: .body) |
| + tv.font = tv.baseBodyFont |
| // Live-track the system Text Size (Dynamic Type) — preferredFont only |
| - // sizes at creation without this. |
| + // sizes at creation without this (styled runs re-derive in |
| + // traitCollectionDidChange). |
| tv.adjustsFontForContentSizeCategory = true |
| tv.backgroundColor = .clear |
| tv.textContainerInset = .zero |
| tv.textContainer.lineFragmentPadding = 0 |
| tv.isScrollEnabled = false |
| + // UIKit's standard rich-editing hooks: routes ⌘B/⌘I and the selection |
| + // Format menu to toggleBoldface/toggleItalics, which we intercept. |
| + tv.allowsEditingTextAttributes = true |
| + tv.typingAttributes = tv.baseAttributes |
| + controller?.view = tv |
| return tv |
| } |
| |
| func updateUIView(_ tv: GrowingTextView, context: Context) { |
| context.coordinator.parent = self |
| - if tv.text != text { tv.text = text } |
| + controller?.view = tv |
| + tv.formatAsYouType = formatAsYouType |
| + // A plain string compare — user edits round-trip exactly, so only |
| + // EXTERNAL sets (edit-load, clear after send, pushback) land here, |
| + // and those are literal plain text by contract. |
| + if tv.text != text { tv.setPlainText(text) } |
| tv.placeholderLabel.text = placeholder |
| tv.placeholderLabel.isHidden = !text.isEmpty |
| // Drive first responder from the focus binding — async so we never mutate |
| @@ -76,6 +105,12 @@ struct ComposerField: UIViewRepresentable { |
| init(_ parent: ComposerField) { self.parent = parent } |
| |
| func textViewDidChange(_ tv: UITextView) { |
| + guard let tv = tv as? GrowingTextView else { return } |
| + tv.editCounter &+= 1 |
| + // Format-as-you-type runs before the binding sync so what the |
| + // binding sees is the post-rule text. Guarded against IME |
| + // composition and reentrancy inside. |
| + if parent.formatAsYouType { tv.maybeApplyInlineRule() } |
| // Kept SYNCHRONOUS on purpose. This fires only for user-driven edits |
| // (a programmatic `tv.text` set does NOT call it), so it never runs |
| // inside a SwiftUI view update — no "modifying state during update". |
| @@ -85,7 +120,7 @@ struct ComposerField: UIViewRepresentable { |
| // updateUIView clobber the just-typed character. |
| parent.text = tv.text |
| parent.caret?.wrappedValue = tv.selectedRange.location |
| - (tv as? GrowingTextView)?.placeholderLabel.isHidden = !tv.text.isEmpty |
| + tv.placeholderLabel.isHidden = !tv.text.isEmpty |
| } |
| |
| func textViewDidChangeSelection(_ tv: UITextView) { |
| @@ -100,12 +135,35 @@ struct ComposerField: UIViewRepresentable { |
| |
| func textView(_ tv: UITextView, shouldChangeTextIn range: NSRange, |
| replacementText replacement: String) -> Bool { |
| - guard parent.sendOnReturn, replacement == "\n" else { return true } |
| - // Shift+Return falls through as a newline; a bare Return sends. Either |
| - // way we swallow the keystroke so no stray newline lands in the field. |
| - if (tv as? GrowingTextView)?.shiftHeld == true { return true } |
| - parent.onSend() |
| - return false |
| + guard let growing = tv as? GrowingTextView else { return true } |
| + // Backspace immediately after a format rule fired restores the |
| + // literal text instead of deleting styled content (the web's |
| + // undoInputRule-on-Backspace parity). |
| + if replacement.isEmpty, range.length > 0, |
| + growing.ruleUndoStamp == growing.editCounter, tv.undoManager?.canUndo == true { |
| + tv.undoManager?.undo() |
| + return false |
| + } |
| + if replacement == "\n" { |
| + // Desktop: Shift+Return falls through as a newline; a bare |
| + // Return sends. Either way we swallow the keystroke so no |
| + // stray newline lands in the field. |
| + if parent.sendOnReturn, !growing.shiftHeld { |
| + parent.onSend() |
| + return false |
| + } |
| + // A TYPED newline carries plain style — the marked-break |
| + // contract (**a**\n**b** on the wire) — while the typing style |
| + // is restored after, so continuing to type stays bold, like |
| + // the web after Shift+Enter. |
| + growing.insertPlainNewline() |
| + return false |
| + } |
| + // Arm the format-as-you-type check: rules fire on single typed |
| + // characters only (never pastes, never IME composition). |
| + growing.pendingRuleCheck = |
| + range.length == 0 && replacement.count == 1 && tv.markedTextRange == nil |
| + return true |
| } |
| |
| func textViewDidBeginEditing(_ tv: UITextView) { |
| @@ -117,24 +175,233 @@ struct ComposerField: UIViewRepresentable { |
| } |
| } |
| |
| -// A UITextView that reports a placeholder, caps its growth at six lines, and |
| -// tracks whether Shift is held so Shift+Return can mean "newline" while a bare |
| -// Return sends. |
| +// Programmatic access to the live field for edits that must not disturb |
| +// styling or the caret: typeahead splices go through insertText (undo, |
| +// delegate flow and caret placement all behave), never a binding rewrite. |
| +// serializedMarkdown() is the wire form read at send — the getMarkdown to |
| +// the binding's getText, exactly the web facade's split. |
| +@MainActor |
| +final class ComposerFieldController { |
| + weak var view: GrowingTextView? |
| + |
| + func insert(_ text: String, replacingUTF16 range: NSRange?) { |
| + guard let tv = view else { return } |
| + if let range { tv.selectedRange = range } // typing attrs follow the position |
| + tv.insertText(text) |
| + } |
| + |
| + func serializedMarkdown() -> String { |
| + guard let tv = view else { return "" } |
| + return composerMarkdown(of: tv.attributedText ?? NSAttributedString()) |
| + } |
| + |
| + func focus() { |
| + view?.becomeFirstResponder() |
| + } |
| +} |
| + |
| +// A UITextView that reports a placeholder, caps its growth at six lines, |
| +// tracks Shift for the Return split, and owns the rich-mark machinery: |
| +// .composerStyle is the truth, fonts derive from it. |
| final class GrowingTextView: UITextView { |
| let placeholderLabel = UILabel() |
| private(set) var shiftHeld = false |
| + var formatAsYouType = true |
| // Pasting an image stages it as an upload instead of inserting nothing — |
| // text pastes are untouched, and a mixed clipboard does both. |
| var onPasteImage: ((Data) -> Void)? |
| // Hardware-keyboard ↑ with an empty draft (the desktop edit convention). |
| var onEditLast: (() -> Void)? |
| + // Format-as-you-type bookkeeping (see Coordinator + maybeApplyInlineRule). |
| + var pendingRuleCheck = false |
| + var editCounter: UInt64 = 0 |
| + var ruleUndoStamp: UInt64 = .max |
| + private var applyingRule = false |
| + |
| + // The unstyled body font every run's rendering derives from. Never read |
| + // `font` for metrics — after styled content lands it reports the run at |
| + // the caret (a bold/mono base would warp the cap and placeholder). |
| + private(set) var baseBodyFont: UIFont = .preferredFont(forTextStyle: .body) |
| + |
| + var baseAttributes: [NSAttributedString.Key: Any] { |
| + [.font: baseBodyFont, .foregroundColor: UIColor.label] |
| + } |
| + |
| + // Rendering attributes derived from the semantic style — the ONLY place |
| + // style becomes fonts. Code is monospaced on a quiet fill (the web |
| + // composer's chip, at field scale); strike is a strikethrough line. |
| + func renderingAttributes(for style: InlineStyle) -> [NSAttributedString.Key: Any] { |
| + var attrs = baseAttributes |
| + guard !style.isPlain else { return attrs } |
| + attrs[.composerStyle] = ComposerStyleAttr(style) |
| + if style.code { |
| + attrs[.font] = UIFont.monospacedSystemFont(ofSize: baseBodyFont.pointSize * 0.92, weight: .regular) |
| + attrs[.backgroundColor] = UIColor.secondarySystemFill |
| + return attrs |
| + } |
| + var traits: UIFontDescriptor.SymbolicTraits = [] |
| + if style.bold { traits.insert(.traitBold) } |
| + if style.italic { traits.insert(.traitItalic) } |
| + if !traits.isEmpty, let d = baseBodyFont.fontDescriptor.withSymbolicTraits(traits) { |
| + attrs[.font] = UIFont(descriptor: d, size: baseBodyFont.pointSize) |
| + } |
| + if style.strike { attrs[.strikethroughStyle] = NSUnderlineStyle.single.rawValue } |
| + return attrs |
| + } |
| + |
| + private func styleAtCaret() -> InlineStyle { |
| + (typingAttributes[.composerStyle] as? ComposerStyleAttr)?.style ?? .plain |
| + } |
| + |
| + // ---- external sets + newline contract ---- |
| + |
| + /// An external set (edit-load, clear, pushback): literal plain text, |
| + /// caret at the end, typing style reset, history dropped (a cleared |
| + /// draft mustn't ⌘Z a sent message back). |
| + func setPlainText(_ s: String) { |
| + attributedText = NSAttributedString(string: s, attributes: baseAttributes) |
| + selectedRange = NSRange(location: (s as NSString).length, length: 0) |
| + typingAttributes = baseAttributes |
| + undoManager?.removeAllActions() |
| + placeholderLabel.isHidden = !s.isEmpty |
| + } |
| + |
| + /// A typed newline: the "\n" itself is plain (marked-break contract), the |
| + /// typing style around it survives so the next line continues the mark. |
| + func insertPlainNewline() { |
| + let saved = typingAttributes |
| + typingAttributes = baseAttributes |
| + insertText("\n") |
| + typingAttributes = saved |
| + } |
| + |
| + // ---- mark toggles (⌘B/⌘I via UIKit's standard routing, ⌘E/⌘⇧X ours) ---- |
| + |
| + override var keyCommands: [UIKeyCommand]? { |
| + var commands = super.keyCommands ?? [] |
| + commands.append(UIKeyCommand(input: "e", modifierFlags: .command, |
| + action: #selector(toggleCodeMark))) |
| + let strike = UIKeyCommand(input: "x", modifierFlags: [.command, .shift], |
| + action: #selector(toggleStrikeMark)) |
| + commands.append(strike) |
| + return commands |
| + } |
| + |
| + override func toggleBoldface(_ sender: Any?) { toggle(\.bold) } |
| + override func toggleItalics(_ sender: Any?) { toggle(\.italic) } |
| + @objc private func toggleCodeMark() { toggle(\.code) } |
| + @objc private func toggleStrikeMark() { toggle(\.strike) } |
| |
| override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { |
| + // No underline: the wire grammar has no marker for it, and a style |
| + // that silently vanishes on send would be a lie. |
| + if action == #selector(toggleUnderline(_:)) { return false } |
| if action == #selector(paste(_:)), onPasteImage != nil, |
| UIPasteboard.general.hasImages { return true } |
| return super.canPerformAction(action, withSender: sender) |
| } |
| |
| + private func flipped(_ style: InlineStyle, _ key: WritableKeyPath<InlineStyle, Bool>, to on: Bool) -> InlineStyle { |
| + var out = style |
| + out[keyPath: key] = on |
| + // Code is exclusive both ways, like the web schema. |
| + if key == \.code, on { out.bold = false; out.italic = false; out.strike = false } |
| + if key != \.code, on { out.code = false } |
| + return out |
| + } |
| + |
| + private func toggle(_ key: WritableKeyPath<InlineStyle, Bool>) { |
| + let range = selectedRange |
| + if range.length == 0 { |
| + // Bare caret: flip the typing style — the next characters wear it. |
| + let style = flipped(styleAtCaret(), key, to: !styleAtCaret()[keyPath: key]) |
| + typingAttributes = renderingAttributes(for: style) |
| + return |
| + } |
| + guard let storage = textStorage as NSTextStorage? else { return } |
| + // Standard toggle semantics: all-styled → remove, else add. |
| + var allStyled = true |
| + storage.enumerateAttribute(.composerStyle, in: range) { value, _, stop in |
| + let style = (value as? ComposerStyleAttr)?.style ?? .plain |
| + if !style[keyPath: key] { allStyled = false; stop.pointee = true } |
| + } |
| + let before = storage.attributedSubstring(from: range) |
| + undoManager?.registerUndo(withTarget: self) { tv in |
| + tv.textStorage.replaceCharacters(in: range, with: before) |
| + tv.delegate?.textViewDidChange?(tv) |
| + } |
| + storage.beginEditing() |
| + storage.enumerateAttribute(.composerStyle, in: range) { value, subRange, _ in |
| + let style = (value as? ComposerStyleAttr)?.style ?? .plain |
| + let flippedStyle = flipped(style, key, to: !allStyled) |
| + storage.setAttributes(renderingAttributes(for: flippedStyle), range: subRange) |
| + } |
| + storage.endEditing() |
| + // Attribute-only edits don't fire textViewDidChange — poke the |
| + // delegate or a send right after ⌘B serializes the pre-toggle draft |
| + // (the attempt-one trap, kept fixed). |
| + delegate?.textViewDidChange?(self) |
| + } |
| + |
| + // ---- format-as-you-type ---- |
| + |
| + // Called from textViewDidChange after a single typed character: if the |
| + // text up to the caret completes an inline rule (**b**, *i*, `c`, ~s~ — |
| + // ComposerRules, drift-gated against the web), swap the marker span for |
| + // the styled content. One Backspace (or ⌘Z) restores the literal. |
| + func maybeApplyInlineRule() { |
| + guard pendingRuleCheck, !applyingRule, markedTextRange == nil else { |
| + pendingRuleCheck = false |
| + return |
| + } |
| + pendingRuleCheck = false |
| + let caret = selectedRange.location |
| + guard selectedRange.length == 0, caret > 0 else { return } |
| + let ns = text as NSString |
| + let lineStart = ns.range(of: "\n", options: .backwards, |
| + range: NSRange(location: 0, length: caret)).upperBound |
| + let lineRange = NSRange(location: lineStart == NSNotFound ? 0 : lineStart, |
| + length: caret - (lineStart == NSNotFound ? 0 : lineStart)) |
| + guard lineRange.length > 0 else { return } |
| + guard let hit = inlineCompletion(lineUpToCaret: ns.substring(with: lineRange)) else { return } |
| + let absolute = NSRange(location: lineRange.location + hit.replaceRange.lowerBound, |
| + length: hit.replaceRange.upperBound - hit.replaceRange.lowerBound) |
| + // Never inside a code run (the web's rangeHasMark(code) guard). |
| + var touchesCode = false |
| + textStorage.enumerateAttribute(.composerStyle, in: absolute) { value, _, stop in |
| + if ((value as? ComposerStyleAttr)?.style.code ?? false) { touchesCode = true; stop.pointee = true } |
| + } |
| + guard !touchesCode else { return } |
| + var style = InlineStyle() |
| + switch hit.mark { |
| + case .bold: style.bold = true |
| + case .italic: style.italic = true |
| + case .code: style.code = true |
| + case .strike: style.strike = true |
| + } |
| + applyingRule = true |
| + let before = textStorage.attributedSubstring(from: absolute) |
| + let beforeCaret = caret |
| + undoManager?.registerUndo(withTarget: self) { tv in |
| + let restored = NSRange(location: absolute.location, length: (hit.content as NSString).length) |
| + tv.textStorage.replaceCharacters(in: restored, with: before) |
| + tv.selectedRange = NSRange(location: beforeCaret, length: 0) |
| + tv.typingAttributes = tv.baseAttributes |
| + tv.delegate?.textViewDidChange?(tv) |
| + } |
| + textStorage.replaceCharacters( |
| + in: absolute, |
| + with: NSAttributedString(string: hit.content, attributes: renderingAttributes(for: style))) |
| + selectedRange = NSRange(location: absolute.location + (hit.content as NSString).length, length: 0) |
| + // The web's removeStoredMark: the next keystroke types unstyled. |
| + typingAttributes = baseAttributes |
| + ruleUndoStamp = editCounter |
| + applyingRule = false |
| + delegate?.textViewDidChange?(self) |
| + } |
| + |
| + // ---- paste ---- |
| + |
| override func paste(_ sender: Any?) { |
| let pb = UIPasteboard.general |
| if pb.hasImages, let onPasteImage { |
| @@ -143,10 +410,20 @@ final class GrowingTextView: UITextView { |
| } |
| if !pb.hasStrings { return } // image-only paste fully consumed |
| } |
| + // Text pastes flatten to the caret's typing style (a web page's fonts |
| + // and colors have no wire form), with the web composer's guards: past |
| + // 1MB the paste is refused — attach a file instead. |
| + if let s = pb.string, !s.isEmpty { |
| + if s.utf16.count > 1_000_000 { return } |
| + insertText(s) |
| + return |
| + } |
| super.paste(sender) |
| } |
| |
| - private var lineHeight: CGFloat { (font ?? .preferredFont(forTextStyle: .body)).lineHeight } |
| + // ---- metrics / chrome (base font, never the caret run's) ---- |
| + |
| + private var lineHeight: CGFloat { baseBodyFont.lineHeight } |
| var capHeight: CGFloat { ceil(lineHeight * 6) } |
| |
| override init(frame: CGRect, textContainer: NSTextContainer?) { |
| @@ -164,6 +441,22 @@ final class GrowingTextView: UITextView { |
| placeholderLabel.frame = CGRect(x: 0, y: 0, width: bounds.width, height: lineHeight) |
| } |
| |
| + // Dynamic Type: refresh the base font and re-derive every styled run from |
| + // its semantic attribute (the reason fonts are never the style truth). |
| + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) { |
| + super.traitCollectionDidChange(previousTraitCollection) |
| + guard previousTraitCollection?.preferredContentSizeCategory != traitCollection.preferredContentSizeCategory else { return } |
| + baseBodyFont = .preferredFont(forTextStyle: .body) |
| + let full = NSRange(location: 0, length: textStorage.length) |
| + textStorage.beginEditing() |
| + textStorage.enumerateAttribute(.composerStyle, in: full) { value, range, _ in |
| + let style = (value as? ComposerStyleAttr)?.style ?? .plain |
| + textStorage.setAttributes(renderingAttributes(for: style), range: range) |
| + } |
| + textStorage.endEditing() |
| + typingAttributes = renderingAttributes(for: styleAtCaret()) |
| + } |
| + |
| override func pressesBegan(_ presses: Set<UIPress>, with event: UIPressesEvent?) { |
| if let mods = presses.first?.key?.modifierFlags { shiftHeld = mods.contains(.shift) } |
| if presses.first?.key?.keyCode == .keyboardUpArrow, text.isEmpty, let onEditLast { |