eleven / messenger Public

ios: inline marks in the composer — ⌘B/⌘I, format-as-you-type (#890 stage 2a) (#925)

ComposerField edits styled text: .composerStyle is the single style
truth (fonts derive from it — never sniffed, so Dynamic Type re-derives
cleanly), ⌘B/⌘I route through UIKit's standard editing commands with
our toggles, ⌘E/⌘⇧X join via keyCommands, underline stays refused, and
the closing-marker rules (**b**, *i*, `c`, ~s~ — ComposerRules, drift-
gated against the web) fire on single typed characters only, never
during IME composition. Backspace right after a rule restores the
literal, typed newlines stay plain (the marked-break contract), pastes
flatten to the typing style with the web's 1MB guard.

The text binding stays the DISPLAY string (the typeahead's space,
untouched); the wire reads serialized markdown from the new
ComposerFieldController at send — and typeahead splices go through it
too, so styling and caret survive. 'Format as you type' toggle in
Settings (default on). Both screens (native + embed) wired; the embed
bridge still carries one plain string.
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell a01f47f4a656559c65e5d8bf779fffcc87c6517b parent 6b20876
6 files changed, +487 −24
  • ios/LChat/Views/ChatView.swift +14 −4
  • ios/LChat/Views/ComposerField.swift +307 −14
  • ios/LChat/Views/NativeChatScreen.swift +25 −6
  • ios/LChat/Views/ServerSettingsSheet.swift +8 −0
  • ios/LChatCore/Sources/LChatCore/ComposerAttributes.swift +66 −0
  • ios/LChatCore/Tests/LChatCoreTests/ComposerAttributesTests.swift +67 −0
diff --git a/ios/LChat/Views/ChatView.swift b/ios/LChat/Views/ChatView.swift
index 098582d..198b639 100644
--- a/ios/LChat/Views/ChatView.swift
+++ b/ios/LChat/Views/ChatView.swift
@@ -41,6 +41,8 @@ struct ChatView: View {
struct ProfileTarget: Identifiable { let id: String }
// The native typeahead (token under the caret + ranked candidates) —
// computed locally, no webview round-trip.
+ @State private var composerController = ComposerFieldController()
+ @AppStorage("formatAsYouType") private var formatAsYouType = true
@State private var nativeTypeahead: (token: ComposerToken, items: [TypeaheadItem])?
@Environment(\.scenePhase) private var scenePhase
@@ -413,15 +415,18 @@ struct ChatView: View {
// moves to the end of the insert (back in the field's UTF-16 terms).
private func pick(_ item: TypeaheadItem) {
guard let ta = nativeTypeahead else { return }
+ // Through the controller: a binding rewrite would clobber styled runs
+ // and park the caret at the end.
let chars = Array(text)
let utf16 = min(max(caret, 0), text.utf16.count)
let charCaret = text.distance(from: text.startIndex,
to: String.Index(utf16Offset: utf16, in: text))
let start = max(0, min(ta.token.start, chars.count))
let end = max(start, min(charCaret, chars.count))
- let head = String(chars[0..<start]) + item.insert
- text = head + String(chars[end...])
- caret = head.utf16.count
+ let head = String(chars[0..<start])
+ let span = String(chars[start..<end])
+ composerController.insert(item.insert, replacingUTF16: NSRange(
+ location: (head as NSString).length, length: (span as NSString).length))
nativeTypeahead = nil
composerFocused = true
}
@@ -516,6 +521,8 @@ struct ChatView: View {
caret: $caret,
placeholder: defaultCommand?.placeholder ?? "Message",
sendOnReturn: isMac,
+ formatAsYouType: formatAsYouType,
+ controller: composerController,
onSend: { if canSend { send() } }
)
.padding(.leading, 14)
@@ -546,7 +553,10 @@ struct ChatView: View {
// MARK: Actions
private func send() {
- bridge.sendText(text) { sent in
+ // The wire carries serialized markdown (== the display text for
+ // unstyled drafts); the bridge stays one plain string either way.
+ let wire = composerController.serializedMarkdown()
+ bridge.sendText(wire.isEmpty ? text : wire) { sent in
// Only clear on success: if the socket wasn't ready, keep the text so
// nothing is lost and the user can retry.
if sent { text = "" }
diff --git a/ios/LChat/Views/ComposerField.swift b/ios/LChat/Views/ComposerField.swift
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 {
diff --git a/ios/LChat/Views/NativeChatScreen.swift b/ios/LChat/Views/NativeChatScreen.swift
index fd556bc..68e013a 100644
--- a/ios/LChat/Views/NativeChatScreen.swift
+++ b/ios/LChat/Views/NativeChatScreen.swift
@@ -73,6 +73,10 @@ struct NativeChatScreen: View {
@State private var dcRouting = false
@State private var replyTo: ReplyRef?
@State private var editingID: Int64?
+ // Programmatic composer edits (typeahead splices) act on the field via
+ // the controller; the wire markdown is read from it at send (#890).
+ @State private var composerController = ComposerFieldController()
+ @AppStorage("formatAsYouType") private var formatAsYouType = true
// The open reply-thread view's root id (nil = closed) and the header
// stand-in while that root isn't loaded — frozen at open, like the web's
// threadFallback (chat.js openThread's ref argument).
@@ -1348,7 +1352,10 @@ struct NativeChatScreen: View {
// sends (the shouldSend rule).
ComposerField(text: $draft, focused: $composerFocused, caret: $caret,
placeholder: defaultCommand?.placeholder ?? "Message",
- sendOnReturn: false, onSend: sendDraft,
+ sendOnReturn: false,
+ formatAsYouType: formatAsYouType,
+ controller: composerController,
+ onSend: sendDraft,
onPasteImage: { if defaultCommand == nil { stageImageData($0) } },
onEditLast: {
if let last = model.messages.last(where: { $0.mine && !$0.action }) {
@@ -1447,9 +1454,18 @@ struct NativeChatScreen: View {
}
}
+ // The wire form of the draft: serialized markdown when the field is
+ // mounted (styled runs become markers — the vector-pinned twin), the raw
+ // draft otherwise. Commands and routing above always read `draft` (the
+ // display text), mirroring the web's plain/markdown split.
+ private var wireDraft: String {
+ let wire = composerController.serializedMarkdown()
+ return wire.isEmpty ? draft : wire
+ }
+
private func sendDraft() {
if let id = editingID {
- if model.edit(id, text: draft) { cancelEdit() }
+ if model.edit(id, text: wireDraft) { cancelEdit() }
return
}
guard canSend else { return }
@@ -1509,7 +1525,7 @@ struct NativeChatScreen: View {
}
return
}
- if model.send(draft, attachments: staged.compactMap(\.attachment), replyTo: replyTo) {
+ if model.send(wireDraft, attachments: staged.compactMap(\.attachment), replyTo: replyTo) {
draft = ""
staged = []
// Sending from an open thread view keeps it open and re-stages the
@@ -1621,15 +1637,18 @@ struct NativeChatScreen: View {
private func pick(_ item: TypeaheadItem) {
guard let ta = nativeTypeahead else { return }
+ // Splice trigger → caret through the controller: a binding rewrite
+ // would clobber styled runs and park the caret at the end.
let chars = Array(draft)
let utf16 = min(max(caret, 0), draft.utf16.count)
let charCaret = draft.distance(from: draft.startIndex,
to: String.Index(utf16Offset: utf16, in: draft))
let start = max(0, min(ta.token.start, chars.count))
let end = max(start, min(charCaret, chars.count))
- let head = String(chars[0..<start]) + item.insert
- draft = head + String(chars[end...])
- caret = head.utf16.count
+ let head = String(chars[0..<start])
+ let span = String(chars[start..<end])
+ composerController.insert(item.insert, replacingUTF16: NSRange(
+ location: (head as NSString).length, length: (span as NSString).length))
nativeTypeahead = nil
composerFocused = true
}
diff --git a/ios/LChat/Views/ServerSettingsSheet.swift b/ios/LChat/Views/ServerSettingsSheet.swift
index 7b06cc6..03eff3c 100644
--- a/ios/LChat/Views/ServerSettingsSheet.swift
+++ b/ios/LChat/Views/ServerSettingsSheet.swift
@@ -9,6 +9,7 @@ struct ServerSettingsSheet: View {
let store: ThreadStore
@Environment(\.dismiss) private var dismiss
@AppStorage("nativeTheme") private var themeName = "classic"
+ @AppStorage("formatAsYouType") private var formatAsYouType = true
@State private var name = ""
@State private var saving = false
@@ -81,6 +82,13 @@ struct ServerSettingsSheet: View {
.pickerStyle(.inline)
.labelsHidden()
}
+ Section {
+ Toggle("Format as you type", isOn: $formatAsYouType)
+ } header: {
+ Text("Composer")
+ } footer: {
+ Text("**bold** becomes bold as you close the stars — same for *italics*, `code` and ~strikethrough~. Off, the markers stay visible while you type; messages render the same either way.")
+ }
Section {
Text("Notifications are managed in Settings ▸ Notifications ▸ Eleven.")
.font(.footnote)
diff --git a/ios/LChatCore/Sources/LChatCore/ComposerAttributes.swift b/ios/LChatCore/Sources/LChatCore/ComposerAttributes.swift
new file mode 100644
index 0000000..8d05da7
--- /dev/null
+++ b/ios/LChatCore/Sources/LChatCore/ComposerAttributes.swift
@@ -0,0 +1,66 @@
+import Foundation
+
+// The bridge between a TextKit editor's NSAttributedString and the composer
+// model (#890 stage 2): style truth lives in ONE semantic attribute —
+// .composerStyle — and rendering attributes (fonts, strikethrough, colors)
+// are derived FROM it by the editor. Never the reverse: sniffing font traits
+// broke under Dynamic Type and fallback fonts in attempt one, and an
+// attribute the editor forgot to derive is a visual bug, not a wire bug.
+//
+// The adapter below is pure Foundation, so the storage→model derivation is
+// unit-tested headlessly (ComposerAttributesTests) with synthetic strings.
+
+public extension NSAttributedString.Key {
+ static let composerStyle = NSAttributedString.Key("lchat.composerStyle")
+}
+
+// The attribute value. A class with value equality: AppKit/UIKit coalesce
+// attribute runs by isEqual, so two separately-created attrs of the same
+// style must compare equal or every keystroke fragments the storage.
+public final class ComposerStyleAttr: NSObject {
+ public let style: InlineStyle
+ public init(_ style: InlineStyle) { self.style = style }
+
+ public override func isEqual(_ object: Any?) -> Bool {
+ (object as? ComposerStyleAttr)?.style == style
+ }
+ public override var hash: Int {
+ var hasher = Hasher()
+ hasher.combine(style)
+ return hasher.finalize()
+ }
+}
+
+// Attributed contents → the inline nodes of one paragraph (stage-2 scope:
+// blocks arrive in stage 3 with the line-kind attribute). Text runs split at
+// "\n"; each "\n" becomes a .brk carrying the style of the "\n" CHARACTER
+// itself — the marked-break contract: a typed Shift+Return inserts a plain
+// "\n" (**a**\n**b** on the wire), a select-across-lines style toggle styles
+// the "\n" too (**a\nb**).
+public func composerInlineNodes(of a: NSAttributedString) -> [InlineNode] {
+ var nodes: [InlineNode] = []
+ let str = a.string as NSString
+ a.enumerateAttribute(.composerStyle, in: NSRange(location: 0, length: a.length)) { value, range, _ in
+ let style = (value as? ComposerStyleAttr)?.style ?? .plain
+ let segments = str.substring(with: range).split(separator: "\n", omittingEmptySubsequences: false)
+ for (i, segment) in segments.enumerated() {
+ if i > 0 { nodes.append(.brk(style)) }
+ guard !segment.isEmpty else { continue }
+ // Coalesce with a preceding same-style run (rendering-only
+ // attribute changes can fragment runs the grammar doesn't see).
+ if case .run(let last) = nodes.last, last.style == style {
+ nodes[nodes.count - 1] = .run(RichRun(last.text + segment, style: style))
+ } else {
+ nodes.append(.run(RichRun(String(segment), style: style)))
+ }
+ }
+ }
+ return nodes
+}
+
+// The editor's wire form: what sends when the draft was composed rich.
+// For an unstyled draft this equals the display string byte-for-byte (the
+// zero-escape guarantee, fuzz-pinned in ComposerSerializeTests).
+public func composerMarkdown(of a: NSAttributedString) -> String {
+ serializeComposer([.paragraph(composerInlineNodes(of: a))])
+}
diff --git a/ios/LChatCore/Tests/LChatCoreTests/ComposerAttributesTests.swift b/ios/LChatCore/Tests/LChatCoreTests/ComposerAttributesTests.swift
new file mode 100644
index 0000000..ea89d34
--- /dev/null
+++ b/ios/LChatCore/Tests/LChatCoreTests/ComposerAttributesTests.swift
@@ -0,0 +1,67 @@
+import XCTest
+@testable import LChatCore
+
+// The storage→model adapter (#890 stage 2): synthetic attributed strings in,
+// wire bytes out — headless, because .composerStyle is the style truth and
+// no UIKit/AppKit is needed to read it.
+final class ComposerAttributesTests: XCTestCase {
+ private func attr(_ parts: [(String, InlineStyle)]) -> NSAttributedString {
+ let out = NSMutableAttributedString()
+ for (text, style) in parts {
+ var attrs: [NSAttributedString.Key: Any] = [:]
+ if !style.isPlain { attrs[.composerStyle] = ComposerStyleAttr(style) }
+ out.append(NSAttributedString(string: text, attributes: attrs))
+ }
+ return out
+ }
+
+ private let bold = InlineStyle(bold: true)
+
+ func testPlainPassesVerbatim() {
+ for s in ["hello", "a\nb", "2*3 and ~x~", "", "líne ✨"] {
+ XCTAssertEqual(composerMarkdown(of: attr([(s, .plain)])), s)
+ }
+ }
+
+ func testMarksSerialize() {
+ XCTAssertEqual(composerMarkdown(of: attr([("b", bold)])), "**b**")
+ XCTAssertEqual(composerMarkdown(of: attr([("i", InlineStyle(italic: true))])), "*i*")
+ XCTAssertEqual(composerMarkdown(of: attr([("s", InlineStyle(strike: true))])), "~s~")
+ XCTAssertEqual(composerMarkdown(of: attr([("c", InlineStyle(code: true))])), "`c`")
+ XCTAssertEqual(
+ composerMarkdown(of: attr([("say ", .plain), ("it", InlineStyle(bold: true, italic: true)), ("!", .plain)])),
+ "say ***it***!")
+ }
+
+ func testTypedNewlineStaysPlain() {
+ // The marked-break contract: a typed Shift+Return inserts a PLAIN
+ // "\n" — the mark closes and reopens on the wire.
+ let a = attr([("a", bold), ("\n", .plain), ("b", bold)])
+ XCTAssertEqual(composerMarkdown(of: a), "**a**\n**b**")
+ }
+
+ func testStyledNewlineSpansTheMark() {
+ // Select-across-lines ⌘B styles the "\n" too — one marked span.
+ let a = attr([("a\nb", bold)])
+ XCTAssertEqual(composerMarkdown(of: a), "**a\nb**")
+ }
+
+ func testAdjacentSameStyleRunsCoalesce() {
+ // Rendering-only attribute churn fragments runs; the grammar must not
+ // see the seams (an in-mark escape proves the runs merged).
+ let a = attr([("a", bold), ("*", bold), ("b", bold)])
+ XCTAssertEqual(composerMarkdown(of: a), "**a\\*b**")
+ }
+
+ func testDisplayEqualsStorageString() {
+ let a = attr([("plain ", .plain), ("bold", bold), ("\n", .plain), ("tail", .plain)])
+ XCTAssertEqual(displayText([.paragraph(composerInlineNodes(of: a))]), a.string)
+ }
+
+ func testStyleAttrEquality() {
+ // Attribute-run coalescing depends on isEqual across separately
+ // created instances.
+ XCTAssertEqual(ComposerStyleAttr(bold), ComposerStyleAttr(InlineStyle(bold: true)))
+ XCTAssertNotEqual(ComposerStyleAttr(bold), ComposerStyleAttr(.plain))
+ }
+}