eleven / ocho Public

mac: inline marks in the composer — ⌘B/⌘I/⌘E/⌘⇧X, format-as-you-type (#890 stage 2b) (#927)

The Mac twin of #925 on the stage-1 NSTextView field: isRichText with
flattened pastes, toggles over .composerStyle with fonts derived (never
sniffed), performKeyEquivalent with the firstResponder guard (#481 —
every space's hidden pane mounts a composer), ⌘U swallowed, typed
newlines stay plain (the marked-break contract), the closing-marker
rules armed in shouldChangeText and applied in textDidChange with
Backspace-restores-the-literal, and the font ladder re-derives every
run. Send/edit carry serialized markdown from the controller; the
binding stays the display string. 'Format as you type' row in Settings.

Also fixes the iOS Backspace-undo stamp ordering from #925 (the
delegate poke increments the counter the stamp compares against).
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Paul Campbell 408106b7f99f5e3c09e85377c2b33bfb88262ada parent 3afb40a
4 files changed, +237 −12
  • ios/LChat/Views/ComposerField.swift +3 −1
  • ios/LChatMac/MacComposerField.swift +213 −9
  • ios/LChatMac/MacSettingsView.swift +8 −0
  • ios/LChatMac/NativeFeedView.swift +13 −2
diff --git a/ios/LChat/Views/ComposerField.swift b/ios/LChat/Views/ComposerField.swift
index f650f6d..0f6092a 100644
--- a/ios/LChat/Views/ComposerField.swift
+++ b/ios/LChat/Views/ComposerField.swift
@@ -395,9 +395,11 @@ final class GrowingTextView: UITextView {
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)
+ // Stamped AFTER the poke — it increments editCounter, and the
+ // Backspace-undo check compares against the post-poke value.
+ ruleUndoStamp = editCounter
}
// ---- paste ----
diff --git a/ios/LChatMac/MacComposerField.swift b/ios/LChatMac/MacComposerField.swift
index 06246fc..e88c1a1 100644
--- a/ios/LChatMac/MacComposerField.swift
+++ b/ios/LChatMac/MacComposerField.swift
@@ -31,6 +31,9 @@ struct MacComposerField: NSViewRepresentable {
var placeholder: String = ""
var fontSize: CGFloat
var isDisabled: Bool = false
+ // Format-as-you-type (the closing-marker rules). OFF still leaves the
+ // shortcuts working — typed markers stay literal and render in the feed.
+ var formatAsYouType: Bool = true
// Programmatic edits (typeahead splices, panel inserts) act on the view —
// never by rewriting the binding, which would reset the caret to the end.
var controller: MacComposerController? = nil
@@ -54,7 +57,7 @@ struct MacComposerField: NSViewRepresentable {
guard let parent = coordinator?.parent else { return }
if parent.focused != has { parent.focused = has }
}
- tv.isRichText = false // the plain stage; rich lands in a later, gated PR
+ tv.isRichText = true // attributed editing; pastes are flattened in paste(_:)
tv.allowsUndo = true
tv.drawsBackground = false
tv.textContainerInset = .zero
@@ -111,13 +114,11 @@ struct MacComposerField: NSViewRepresentable {
controller?.view = tv
tv.onStagePaste = onStagePaste
tv.isEditable = !isDisabled
+ tv.formatAsYouType = formatAsYouType
if tv.baseFontSize != fontSize {
tv.baseFontSize = fontSize
tv.applyBaseTypingAttributes()
- if let storage = tv.textStorage, storage.length > 0 {
- storage.addAttribute(.font, value: tv.baseFont,
- range: NSRange(location: 0, length: storage.length))
- }
+ tv.rederiveFonts() // every styled run re-derives from .composerStyle
context.coordinator.remeasure(tv)
}
// A plain string compare — user edits round-trip exactly, so only
@@ -202,6 +203,8 @@ struct MacComposerField: NSViewRepresentable {
func textDidChange(_ notification: Notification) {
guard let tv = notification.object as? ComposerTextView else { return }
+ tv.editCounter &+= 1
+ if parent.formatAsYouType { tv.maybeApplyInlineRule() }
parent.text = tv.string
parent.caret?.wrappedValue = tv.selectedRange().location
tv.placeholderLabel.isHidden = !tv.string.isEmpty
@@ -214,6 +217,23 @@ struct MacComposerField: NSViewRepresentable {
parent.caret?.wrappedValue = tv.selectedRange().location
}
+ func textView(_ textView: NSTextView, shouldChangeTextIn affectedCharRange: NSRange,
+ replacementString: String?) -> Bool {
+ guard let tv = textView as? ComposerTextView, let replacement = replacementString else { return true }
+ // Backspace right after a rule fired restores the literal text
+ // (the web's undoInputRule-on-Backspace parity).
+ if replacement.isEmpty, affectedCharRange.length > 0,
+ tv.ruleUndoStamp == tv.editCounter, tv.undoManager?.canUndo == true {
+ tv.undoManager?.undo()
+ return false
+ }
+ // Rules fire on single typed characters only — never pastes,
+ // never IME composition.
+ tv.pendingRuleCheck = affectedCharRange.length == 0 && replacement.count == 1
+ && replacement != "\n" && !tv.hasMarkedText()
+ return true
+ }
+
func textView(_ textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool {
switch commandSelector {
case #selector(NSResponder.insertNewline(_:)):
@@ -221,7 +241,13 @@ struct MacComposerField: NSViewRepresentable {
// through to the standard insertion puts them AT THE CARET
// (the #819 fix); plain Return accepts the typeahead or sends.
let mods = NSApp.currentEvent?.modifierFlags ?? []
- if mods.contains(.shift) || mods.contains(.option) { return false }
+ if mods.contains(.shift) || mods.contains(.option) {
+ // A TYPED newline carries plain style (the marked-break
+ // contract: **a**\n**b** on the wire) while the typing
+ // style survives it, like the web after Shift+Enter.
+ (textView as? ComposerTextView)?.insertPlainNewline()
+ return true
+ }
if parent.onAccept() { return true }
parent.onSend()
return true
@@ -229,7 +255,7 @@ struct MacComposerField: NSViewRepresentable {
// ⌃Return maps here, and the standard behaviour inserts
// U+2028 (a line SEPARATOR, not "\n") — a character the wire
// format must never carry. Normalize to a plain newline.
- textView.insertText("\n", replacementRange: textView.selectedRange())
+ (textView as? ComposerTextView)?.insertPlainNewline()
return true
case #selector(NSResponder.moveUp(_:)):
return parent.onMoveUp()
@@ -267,6 +293,14 @@ final class MacComposerController {
guard let tv = view else { return }
tv.window?.makeFirstResponder(tv)
}
+
+ // The wire form of the draft — serialized markdown from the LChatCore
+ // twin (vector-pinned against the web serializer). The binding stays the
+ // display string; this is the getMarkdown to its getText.
+ func serializedMarkdown() -> String {
+ guard let tv = view, let storage = tv.textStorage else { return "" }
+ return composerMarkdown(of: storage)
+ }
}
// The NSTextView itself: hand-assembled TextKit stack, placeholder, guarded
@@ -275,6 +309,13 @@ final class ComposerTextView: NSTextView {
let placeholderLabel = NSTextField(labelWithString: "")
var onStagePaste: ((NSPasteboard) -> Bool)?
var onFocusChange: ((Bool) -> Void)?
+ // Format-as-you-type bookkeeping (armed in shouldChangeText, consumed in
+ // textDidChange; the stamp drives Backspace-restores-the-literal).
+ var formatAsYouType = true
+ var pendingRuleCheck = false
+ var editCounter: UInt64 = 0
+ var ruleUndoStamp: UInt64 = .max
+ private var applyingRule = false
var baseFontSize: CGFloat = NSFont.systemFontSize {
didSet {
@@ -319,14 +360,177 @@ final class ComposerTextView: NSTextView {
height: placeholderLabel.intrinsicContentSize.height)
}
+ var baseAttributes: [NSAttributedString.Key: Any] {
+ [.font: baseFont, .foregroundColor: baseColor]
+ }
+
func applyBaseTypingAttributes() {
- typingAttributes = [.font: baseFont, .foregroundColor: baseColor]
+ typingAttributes = baseAttributes
+ }
+
+ // Rendering attributes derived from the semantic style — the ONLY place
+ // style becomes fonts (never sniffed back; the Dynamic-Type/ladder trap).
+ 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] = NSFont.monospacedSystemFont(ofSize: baseFont.pointSize * 0.92, weight: .regular)
+ attrs[.backgroundColor] = NSColor.labelColor.withAlphaComponent(0.08) // the web chip's ink-8%
+ return attrs
+ }
+ var traits: NSFontDescriptor.SymbolicTraits = []
+ if style.bold { traits.insert(.bold) }
+ if style.italic { traits.insert(.italic) }
+ if !traits.isEmpty {
+ let d = baseFont.fontDescriptor.withSymbolicTraits(traits)
+ attrs[.font] = NSFont(descriptor: d, size: baseFont.pointSize) ?? baseFont
+ }
+ if style.strike { attrs[.strikethroughStyle] = NSUnderlineStyle.single.rawValue }
+ return attrs
+ }
+
+ private func styleAtCaret() -> InlineStyle {
+ (typingAttributes[.composerStyle] as? ComposerStyleAttr)?.style ?? .plain
+ }
+
+ // The font ladder changed: re-derive every styled run from its semantic
+ // attribute.
+ func rederiveFonts() {
+ guard let storage = textStorage else { return }
+ storage.beginEditing()
+ storage.enumerateAttribute(.composerStyle, in: NSRange(location: 0, length: storage.length)) { value, range, _ in
+ let style = (value as? ComposerStyleAttr)?.style ?? .plain
+ storage.setAttributes(renderingAttributes(for: style), range: range)
+ }
+ storage.endEditing()
+ typingAttributes = renderingAttributes(for: styleAtCaret())
+ }
+
+ // ---- mark toggles: ⌘B/⌘I/⌘E/⌘⇧X, ⌘U swallowed ----
+
+ // Key equivalents walk the VIEW hierarchy, not the responder chain —
+ // every space's pane stays mounted (hidden), so without this guard the
+ // first composer in the tree would format a hidden draft (the #481 bug).
+ override func performKeyEquivalent(with event: NSEvent) -> Bool {
+ guard window?.firstResponder === self else { return super.performKeyEquivalent(with: event) }
+ let mods = event.modifierFlags.intersection(.deviceIndependentFlagsMask)
+ if mods == .command, let key = event.charactersIgnoringModifiers?.lowercased() {
+ switch key {
+ case "b": toggle(\.bold); return true
+ case "i": toggle(\.italic); return true
+ case "e": toggle(\.code); return true
+ case "u": return true // no underline marker — a style that vanished on send would be a lie
+ default: break
+ }
+ }
+ if mods == [.command, .shift], event.charactersIgnoringModifiers?.lowercased() == "x" {
+ toggle(\.strike)
+ return true
+ }
+ return super.performKeyEquivalent(with: event)
+ }
+
+ 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
+ }
+
+ // Toggle over the selection (all-styled → remove, else add) or flip the
+ // typing style at a bare caret. Selection edits ride shouldChangeText/
+ // didChangeText so undo works AND the delegate hears the attribute-only
+ // edit — or a send right after ⌘B serializes the pre-toggle draft.
+ private func toggle(_ key: WritableKeyPath<InlineStyle, Bool>) {
+ let range = selectedRange()
+ if range.length == 0 {
+ let current = styleAtCaret()
+ typingAttributes = renderingAttributes(for: flipped(current, key, to: !current[keyPath: key]))
+ return
+ }
+ guard let storage = textStorage, shouldChangeText(in: range, replacementString: nil) else { return }
+ 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 }
+ }
+ storage.beginEditing()
+ storage.enumerateAttribute(.composerStyle, in: range) { value, subRange, _ in
+ let style = (value as? ComposerStyleAttr)?.style ?? .plain
+ storage.setAttributes(renderingAttributes(for: flipped(style, key, to: !allStyled)), range: subRange)
+ }
+ storage.endEditing()
+ didChangeText()
+ }
+
+ /// 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", replacementRange: selectedRange())
+ typingAttributes = saved
+ }
+
+ // ---- format-as-you-type ----
+
+ // Called from textDidChange after a single typed character: if the line
+ // up to the caret completes an inline rule (**b**, *i*, `c`, ~s~ —
+ // ComposerRules, drift-gated against the web), swap the marker span for
+ // styled content. One Backspace (or ⌘Z) restores the literal.
+ func maybeApplyInlineRule() {
+ guard pendingRuleCheck, !applyingRule, !hasMarkedText() else {
+ pendingRuleCheck = false
+ return
+ }
+ pendingRuleCheck = false
+ let caret = selectedRange().location
+ guard selectedRange().length == 0, caret > 0, let storage = textStorage else { return }
+ let ns = string as NSString
+ let nl = ns.range(of: "\n", options: .backwards, range: NSRange(location: 0, length: caret))
+ let lineStart = nl.location == NSNotFound ? 0 : nl.location + 1
+ let lineRange = NSRange(location: lineStart, length: caret - lineStart)
+ guard lineRange.length > 0,
+ 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
+ storage.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
+ breakUndoCoalescing() // one ⌘Z restores the literal, not the whole burst
+ if shouldChangeText(in: absolute, replacementString: hit.content) {
+ storage.replaceCharacters(
+ in: absolute,
+ with: NSAttributedString(string: hit.content, attributes: renderingAttributes(for: style)))
+ setSelectedRange(NSRange(location: absolute.location + (hit.content as NSString).length, length: 0))
+ // The web's removeStoredMark: the next keystroke types unstyled.
+ applyBaseTypingAttributes()
+ didChangeText()
+ // Stamped AFTER didChangeText — it increments editCounter, and
+ // the Backspace-undo check compares the post-change value.
+ ruleUndoStamp = editCounter
+ }
+ applyingRule = false
}
/// An external set (edit-load, clear after send): literal plain text,
/// caret at the end, typing style reset so the next message starts clean.
func setPlainText(_ text: String) {
- string = text
+ textStorage?.setAttributedString(NSAttributedString(string: text, attributes: baseAttributes))
setSelectedRange(NSRange(location: (text as NSString).length, length: 0))
applyBaseTypingAttributes()
undoManager?.removeAllActions() // a cleared draft mustn't ⌘Z a sent message back
diff --git a/ios/LChatMac/MacSettingsView.swift b/ios/LChatMac/MacSettingsView.swift
index 2a0a694..f59e841 100644
--- a/ios/LChatMac/MacSettingsView.swift
+++ b/ios/LChatMac/MacSettingsView.swift
@@ -7,6 +7,7 @@ import SwiftUI
struct MacSettingsView: View {
@EnvironmentObject var app: MacAppState
@AppStorage("nativeTheme") private var themeName = "classic"
+ @AppStorage("formatAsYouType") private var formatAsYouType = true
@AppStorage(ChatTextSize.key) private var textSize = ChatTextSize.defaultSize
@State private var name = ""
@@ -63,6 +64,13 @@ struct MacSettingsView: View {
} header: {
Text("Text size")
}
+ 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.")
+ }
CLIToolSection()
Section("Profile") {
Picker("Server", selection: $selectedInstanceID) {
diff --git a/ios/LChatMac/NativeFeedView.swift b/ios/LChatMac/NativeFeedView.swift
index f29dba4..10941af 100644
--- a/ios/LChatMac/NativeFeedView.swift
+++ b/ios/LChatMac/NativeFeedView.swift
@@ -216,6 +216,7 @@ struct NativeFeedView: View {
// Programmatic edits (typeahead splice, panel inserts) act on the view via
// the controller, so the caret survives them.
@State private var composerController = MacComposerController()
+ @AppStorage("formatAsYouType") private var formatAsYouType = true
// Attachments staged on the draft: picked → uploading (spinner) → ready.
// Several can ride one message, like the web's pending list.
private struct Staged: Identifiable {
@@ -1256,6 +1257,7 @@ struct NativeFeedView: View {
// Queued behind an upload = the message is committed; editing
// what's about to auto-send would race it.
isDisabled: sendQueued,
+ formatAsYouType: formatAsYouType,
controller: composerController,
onSend: sendDraft,
onMoveUp: {
@@ -1433,12 +1435,21 @@ struct NativeFeedView: View {
return host
}
+ // 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 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() {
// An edit replaces the message in place — no attachments/reply staging.
// cancelEdit (not inline clears) so an edit inside an open thread view
// hands the composer back to the thread's staged reply.
if let id = editingID {
- if model.edit(id, text: draft) { cancelEdit() }
+ if model.edit(id, text: wireDraft) { cancelEdit() }
return
}
guard hasContent else { return }
@@ -1523,7 +1534,7 @@ struct NativeFeedView: View {
}
// Only clear once the send reaches the socket — a down socket keeps the
// draft to resend, never silently drops it (the web's rule).
- 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