| 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 |