Revert the rich-text composers (keep grammar, frost + polish) (#482)
* mac: back to the TextField composer — the NSTextView field wasn't worth it Live use found the NSTextView-backed MacComposerField (PRs #471/#481) too rough to keep: it scrolled instead of auto-growing when text wrapped, and a long paste could wedge the input entirely. Restore the SwiftUI TextField composer (which grows and pastes fine) and drop Mac ⌘B/⌘I with it — the formatting shortcuts weren't worth the trade. Kept from the same work: the FrostedBand footer/header glass, the footer padding + palette plus icon, the botless-chat command typeahead, the locked-pane sign-in, the ***bold italic*** grammar, and the iOS composer (its UITextView predates all this and had no such trouble). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ios: revert the rich composer too — both inputs back to pre-#471 Paul's call before it reached a device: with the Mac field already pulled, the iOS composer goes back to its plain-text UITextView as well (it shares the serialize-on-change design whose failure shapes sank the Mac). The now-orphaned RichTextEdit runs↔markers bridge and its tests go with it — git history and project memory keep the lessons for attempt two. The ***bold italic*** grammar STAYS on every renderer (web + native): it is input-agnostic, already announced in the changelog, and hand-typed markers render on all surfaces as before. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> ---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
5 files changed,
+98
−863
ios/LChat/Views/ComposerField.swift+9 −143ios/LChatCore/Sources/LChatCore/RichTextEdit.swift+0 −106ios/LChatCore/Tests/LChatCoreTests/RichTextEditTests.swift+0 −109ios/LChatMac/MacComposerField.swift+0 −412ios/LChatMac/NativeFeedView.swift+89 −93
diff --git a/ios/LChat/Views/ComposerField.swift b/ios/LChat/Views/ComposerField.swift| index 3027df1..400a079 100644 |
| --- a/ios/LChat/Views/ComposerField.swift |
| +++ b/ios/LChat/Views/ComposerField.swift |
| @@ -1,6 +1,5 @@ |
| 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 |
| @@ -12,21 +11,11 @@ import LChatCore |
| // `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 text: the field edits *styled* text (⌘B/⌘I and the selection callout's |
| -// Format menu — UIKit wires both once allowsEditingTextAttributes is on), but |
| -// the `text` binding stays a plain String holding the wire's marker form |
| -// (**bold** / *italic* / ***both*** — RichTextEdit.swift), so send paths, slash |
| -// commands and drafts are untouched. Display ↔ markers converts at the edges: |
| -// serialize on every change, parse on external sets (an edit-load shows glyphs, |
| -// not asterisks). Underline is refused — the grammar has no marker for it, and |
| -// silently dropping it on send would be worse than not offering it. |
| struct ComposerField: UIViewRepresentable { |
| @Binding var text: String |
| @Binding var focused: Bool |
| // Caret position as a UTF-16 offset (UIKit's native unit) — drives the |
| - // typeahead, which needs to know which token the caret sits in. Offsets are |
| - // into the *serialized* text (the binding), not the styled display text. |
| + // typeahead, which needs to know which token the caret sits in. |
| var caret: Binding<Int>? = nil |
| var placeholder: String = "" |
| var sendOnReturn: Bool |
| @@ -47,9 +36,6 @@ struct ComposerField: UIViewRepresentable { |
| // Live-track the system Text Size (Dynamic Type) — preferredFont only |
| // sizes at creation without this. |
| tv.adjustsFontForContentSizeCategory = true |
| - // ⌘B/⌘I toggle traits on the selection (or the typing attributes at a |
| - // bare caret) — UIKit provides the key commands and the Format menu. |
| - tv.allowsEditingTextAttributes = true |
| tv.backgroundColor = .clear |
| tv.textContainerInset = .zero |
| tv.textContainer.lineFragmentPadding = 0 |
| @@ -59,14 +45,7 @@ struct ComposerField: UIViewRepresentable { |
| func updateUIView(_ tv: GrowingTextView, context: Context) { |
| context.coordinator.parent = self |
| - // Compare serialized forms: user edits round-trip exactly (didChange set |
| - // the binding from this very serialization), so only *external* sets — |
| - // an edit-load, a clear after send — reparse the view. Canonicalize the |
| - // target first so a draft arriving as `_i_` (which serializes back as |
| - // `*i*`) doesn't re-set the view on every update. |
| - if tv.serializedText() != serializeRuns(parseRuns(text)) { |
| - tv.setMarkerText(text) |
| - } |
| + if tv.text != text { tv.text = text } |
| tv.placeholderLabel.text = placeholder |
| tv.placeholderLabel.isHidden = !text.isEmpty |
| // Drive first responder from the focus binding — async so we never mutate |
| @@ -97,15 +76,13 @@ struct ComposerField: UIViewRepresentable { |
| init(_ parent: ComposerField) { self.parent = parent } |
| func textViewDidChange(_ tv: UITextView) { |
| - guard let tv = tv as? GrowingTextView else { return } |
| - parent.text = tv.serializedText() |
| - parent.caret?.wrappedValue = tv.serializedCaret() |
| - tv.placeholderLabel.isHidden = !tv.text.isEmpty |
| + parent.text = tv.text |
| + parent.caret?.wrappedValue = tv.selectedRange.location |
| + (tv as? GrowingTextView)?.placeholderLabel.isHidden = !tv.text.isEmpty |
| } |
| func textViewDidChangeSelection(_ tv: UITextView) { |
| - guard let tv = tv as? GrowingTextView else { return } |
| - parent.caret?.wrappedValue = tv.serializedCaret() |
| + parent.caret?.wrappedValue = tv.selectedRange.location |
| } |
| func textView(_ tv: UITextView, shouldChangeTextIn range: NSRange, |
| @@ -127,9 +104,9 @@ struct ComposerField: UIViewRepresentable { |
| } |
| } |
| -// A UITextView that reports a placeholder, caps its growth at six lines, tracks |
| -// whether Shift is held so Shift+Return can mean "newline" while a bare Return |
| -// sends, and converts its styled contents to/from the wire's marker grammar. |
| +// 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. |
| final class GrowingTextView: UITextView { |
| let placeholderLabel = UILabel() |
| private(set) var shiftHeld = false |
| @@ -139,18 +116,9 @@ final class GrowingTextView: UITextView { |
| // Hardware-keyboard ↑ with an empty draft (the desktop edit convention). |
| var onEditLast: (() -> Void)? |
| - // The unstyled body font — the base every run's traits derive from. Never |
| - // read `font` for this: after styled content lands, `font` reports the run |
| - // at index 0, and deriving from a bold base would bold everything. |
| - private var baseFont: UIFont { .preferredFont(forTextStyle: .body) } |
| - private var baseColor: UIColor { textColor ?? .label } |
| - |
| override func canPerformAction(_ action: Selector, withSender sender: Any?) -> Bool { |
| if action == #selector(paste(_:)), onPasteImage != nil, |
| UIPasteboard.general.hasImages { return true } |
| - // No underline: the marker grammar can't carry it, and applying a style |
| - // that silently vanishes on send would be a lie. (Bold/italic stay.) |
| - if action == #selector(toggleUnderline(_:)) { return false } |
| return super.canPerformAction(action, withSender: sender) |
| } |
| @@ -162,111 +130,9 @@ final class GrowingTextView: UITextView { |
| } |
| if !pb.hasStrings { return } // image-only paste fully consumed |
| } |
| - // Flatten styled pastes: allowsEditingTextAttributes would otherwise keep |
| - // a web page's fonts/colors, which the marker grammar can't represent. |
| - // insertText adopts the caret's typing attributes — pasting into a bold |
| - // stretch stays bold, like Messages. |
| - if let s = pb.string { |
| - insertText(s) |
| - delegate?.textViewDidChange?(self) // programmatic edits don't always notify |
| - return |
| - } |
| super.paste(sender) |
| } |
| - // toggleBoldface/toggleItalics change attributes without changing text, so |
| - // textViewDidChange never fires — but the binding must update *now*: a send |
| - // right after ⌘B would otherwise serialize the pre-toggle draft. |
| - override func toggleBoldface(_ sender: Any?) { |
| - super.toggleBoldface(sender) |
| - delegate?.textViewDidChange?(self) |
| - } |
| - override func toggleItalics(_ sender: Any?) { |
| - super.toggleItalics(sender) |
| - delegate?.textViewDidChange?(self) |
| - } |
| - |
| - // ---- styled contents ↔ marker grammar (RichTextEdit.swift) ---- |
| - |
| - // The style of the character at a UTF-16 index, as an empty-text run. |
| - private func styleAt(_ index: Int) -> RichRun { |
| - let attrs = attributedText.attributes(at: index, effectiveRange: nil) |
| - let font = attrs[.font] as? UIFont |
| - let traits = font?.fontDescriptor.symbolicTraits ?? [] |
| - let strike = ((attrs[.strikethroughStyle] as? Int) ?? 0) != 0 |
| - return RichRun("", bold: traits.contains(.traitBold), italic: traits.contains(.traitItalic), |
| - strike: strike, code: traits.contains(.traitMonoSpace)) |
| - } |
| - |
| - private func runs(of a: NSAttributedString) -> [RichRun] { |
| - var runs: [RichRun] = [] |
| - let str = a.string as NSString |
| - a.enumerateAttributes(in: NSRange(location: 0, length: a.length)) { attrs, range, _ in |
| - let font = attrs[.font] as? UIFont |
| - let traits = font?.fontDescriptor.symbolicTraits ?? [] |
| - let strike = ((attrs[.strikethroughStyle] as? Int) ?? 0) != 0 |
| - let run = RichRun(str.substring(with: range), |
| - bold: traits.contains(.traitBold), italic: traits.contains(.traitItalic), |
| - strike: strike, code: traits.contains(.traitMonoSpace)) |
| - // Coalesce: UIKit splits runs on attributes (color, kerning…) that |
| - // don't matter to the grammar. |
| - if let last = runs.last, last.sameStyle(as: run) { |
| - runs[runs.count - 1].text += run.text |
| - } else { |
| - runs.append(run) |
| - } |
| - } |
| - return runs |
| - } |
| - |
| - /// The field's contents in wire form — what the `text` binding holds. |
| - func serializedText() -> String { serializeRuns(runs(of: attributedText)) } |
| - |
| - /// Replace the contents from wire form (an edit-load / external set): the |
| - /// draft shows glyphs, not asterisks. |
| - func setMarkerText(_ text: String) { |
| - let out = NSMutableAttributedString() |
| - for run in parseRuns(text) { |
| - var traits = baseFont.fontDescriptor.symbolicTraits |
| - if run.bold { traits.insert(.traitBold) } |
| - if run.italic { traits.insert(.traitItalic) } |
| - var font = baseFont |
| - if run.code { |
| - font = .monospacedSystemFont(ofSize: baseFont.pointSize, weight: .regular) |
| - if run.bold || run.italic, let d = font.fontDescriptor.withSymbolicTraits(traits) { |
| - font = UIFont(descriptor: d, size: 0) |
| - } |
| - } else if run.bold || run.italic, let d = baseFont.fontDescriptor.withSymbolicTraits(traits) { |
| - font = UIFont(descriptor: d, size: 0) |
| - } |
| - var attrs: [NSAttributedString.Key: Any] = [.font: font, .foregroundColor: baseColor] |
| - if run.strike { attrs[.strikethroughStyle] = NSUnderlineStyle.single.rawValue } |
| - out.append(NSAttributedString(string: run.text, attributes: attrs)) |
| - } |
| - attributedText = out |
| - // A cleared field must not inherit the last run's style into the next |
| - // message — reset the caret style to plain. |
| - if text.isEmpty { typingAttributes = [.font: baseFont, .foregroundColor: baseColor] } |
| - } |
| - |
| - /// The caret as an offset into the *serialized* text. Serializing the prefix |
| - /// before the caret counts every marker the caret sits after; when the caret |
| - /// is strictly inside a styled run, that prefix closed the run's markers |
| - /// behind it — characters the true position is before, so back them off. |
| - func serializedCaret() -> Int { |
| - let loc = selectedRange.location |
| - guard loc > 0, loc <= attributedText.length else { return 0 } |
| - let prefix = attributedText.attributedSubstring(from: NSRange(location: 0, length: loc)) |
| - var n = (serializeRuns(runs(of: prefix)) as NSString).length |
| - if loc < attributedText.length { |
| - let before = styleAt(loc - 1) |
| - if before.sameStyle(as: styleAt(loc)), let marker = before.marker { |
| - n -= (marker as NSString).length |
| - } |
| - } |
| - return n |
| - } |
| - |
| private var lineHeight: CGFloat { (font ?? .preferredFont(forTextStyle: .body)).lineHeight } |
| var capHeight: CGFloat { ceil(lineHeight * 6) } |
diff --git a/ios/LChatCore/Sources/LChatCore/RichTextEdit.swift b/ios/LChatCore/Sources/LChatCore/RichTextEdit.swift| deleted file mode 100644 |
| index 69f3e83..0000000 |
| --- a/ios/LChatCore/Sources/LChatCore/RichTextEdit.swift |
| +++ /dev/null |
| @@ -1,106 +0,0 @@ |
| -import Foundation |
| - |
| -// The bridge between a rich-text composer and the wire's marker grammar |
| -// (format.js / RichText.swift): a draft is modelled as a flat list of styled |
| -// runs, serialized to markers on send and parsed back for edit-loads. Pure |
| -// Foundation — the UIKit NSAttributedString adapter lives with ComposerField; |
| -// this half is shared so the Mac's native composer can reuse it. |
| -// |
| -// The grammar is single-level (no nesting) with one combined form: |
| -// `code` ***bold italic*** **bold** *italic* ~strike~ |
| -// so a run can't carry every style combination. serializeRuns resolves the |
| -// inexpressible ones by priority: code wins outright, then bold/italic |
| -// (together as ***), and strike only stands alone — a strike combined with |
| -// bold/italic keeps the bold/italic and drops the strike. |
| - |
| -/// A stretch of draft text with one uniform style. |
| -public struct RichRun: Equatable { |
| - public var text: String |
| - public var bold: Bool |
| - public var italic: Bool |
| - public var strike: Bool |
| - public var code: Bool |
| - |
| - public init(_ text: String, bold: Bool = false, italic: Bool = false, |
| - strike: Bool = false, code: Bool = false) { |
| - self.text = text |
| - self.bold = bold |
| - self.italic = italic |
| - self.strike = strike |
| - self.code = code |
| - } |
| - |
| - public var isPlain: Bool { !bold && !italic && !strike && !code } |
| - |
| - public func sameStyle(as other: RichRun) -> Bool { |
| - bold == other.bold && italic == other.italic && strike == other.strike && code == other.code |
| - } |
| - |
| - /// The marker pair for this style, resolved by the priority above; nil for |
| - /// plain runs. Italic serializes as `*`, never `_` — the underscore form |
| - /// needs word boundaries (file_names), the asterisk works mid-word too. |
| - /// Public for the composer's caret math (mapping display offsets to |
| - /// serialized offsets needs marker lengths). |
| - public var marker: String? { |
| - if code { return "`" } |
| - if bold && italic { return "***" } |
| - if bold { return "**" } |
| - if italic { return "*" } |
| - if strike { return "~" } |
| - return nil |
| - } |
| -} |
| - |
| -/// Serialize composer runs to marker text. Markers can't span newlines (the |
| -/// grammar is per-line), so a styled run is split and each line wrapped on its |
| -/// own; whitespace-only stretches stay unwrapped (`** **` is noise, and the |
| -/// style is invisible on spaces anyway). A styled run whose text contains its |
| -/// own marker char will mis-parse on the receiver — the same edge web users |
| -/// typing markers by hand have always had; there is no escaping in the grammar. |
| -public func serializeRuns(_ runs: [RichRun]) -> String { |
| - var out = "" |
| - for run in runs { |
| - guard !run.text.isEmpty else { continue } |
| - guard let marker = run.marker else { |
| - out += run.text |
| - continue |
| - } |
| - // Split keeping empty segments so "a\n\nb" keeps both newlines. |
| - let lines = run.text.split(separator: "\n", omittingEmptySubsequences: false) |
| - out += lines.map { line in |
| - let s = String(line) |
| - if s.isEmpty || s.allSatisfy({ $0 == " " || $0 == "\t" }) { return s } |
| - return marker + s + marker |
| - }.joined(separator: "\n") |
| - } |
| - return out |
| -} |
| - |
| -/// Parse marker text back into runs (the edit-load direction), via the same |
| -/// richTokens the feed renders with — so the composer shows exactly what the |
| -/// bubble showed. Links flatten to plain text (the composer styles nothing |
| -/// about them; their text round-trips verbatim). Adjacent same-style runs |
| -/// coalesce so the output is canonical. |
| -public func parseRuns(_ text: String) -> [RichRun] { |
| - var runs: [RichRun] = [] |
| - func push(_ run: RichRun) { |
| - guard !run.text.isEmpty else { return } |
| - if let last = runs.last, last.sameStyle(as: run) { |
| - runs[runs.count - 1].text += run.text |
| - } else { |
| - runs.append(run) |
| - } |
| - } |
| - for token in richTokens(text) { |
| - switch token { |
| - case .text(let s): push(RichRun(s)) |
| - case .link(let s, _): push(RichRun(s)) |
| - case .bold(let s): push(RichRun(s, bold: true)) |
| - case .italic(let s): push(RichRun(s, italic: true)) |
| - case .boldItalic(let s): push(RichRun(s, bold: true, italic: true)) |
| - case .strike(let s): push(RichRun(s, strike: true)) |
| - case .code(let s): push(RichRun(s, code: true)) |
| - } |
| - } |
| - return runs |
| -} |
diff --git a/ios/LChatCore/Tests/LChatCoreTests/RichTextEditTests.swift b/ios/LChatCore/Tests/LChatCoreTests/RichTextEditTests.swift| deleted file mode 100644 |
| index 6672075..0000000 |
| --- a/ios/LChatCore/Tests/LChatCoreTests/RichTextEditTests.swift |
| +++ /dev/null |
| @@ -1,109 +0,0 @@ |
| -import XCTest |
| -@testable import LChatCore |
| - |
| -// The composer's runs ↔ markers bridge (RichTextEdit.swift). The hard |
| -// invariant is the round trip: parse(serialize(runs)) == runs for anything the |
| -// editor can produce — a draft must read back exactly as it was styled. |
| -final class RichTextEditTests: XCTestCase { |
| - private func roundTrips(_ runs: [RichRun], _ note: String = "") { |
| - XCTAssertEqual(parseRuns(serializeRuns(runs)), runs, note) |
| - } |
| - |
| - func testPlainTextSerializesVerbatim() { |
| - XCTAssertEqual(serializeRuns([RichRun("hello world")]), "hello world") |
| - // Typed markers in a plain run pass through untouched (same as the web |
| - // composer: what you type is what is sent). |
| - XCTAssertEqual(serializeRuns([RichRun("a **manual** marker")]), "a **manual** marker") |
| - } |
| - |
| - func testMarkersPerStyle() { |
| - XCTAssertEqual(serializeRuns([RichRun("b", bold: true)]), "**b**") |
| - XCTAssertEqual(serializeRuns([RichRun("i", italic: true)]), "*i*") |
| - XCTAssertEqual(serializeRuns([RichRun("bi", bold: true, italic: true)]), "***bi***") |
| - XCTAssertEqual(serializeRuns([RichRun("s", strike: true)]), "~s~") |
| - XCTAssertEqual(serializeRuns([RichRun("c", code: true)]), "`c`") |
| - } |
| - |
| - func testItalicSerializesAsAsteriskForMidWordSafety() { |
| - // `_` needs word boundaries in the grammar; `*` renders anywhere. |
| - let s = serializeRuns([RichRun("un"), RichRun("believ", italic: true), RichRun("able")]) |
| - XCTAssertEqual(s, "un*believ*able") |
| - XCTAssertEqual(parseRuns(s), [RichRun("un"), RichRun("believ", italic: true), RichRun("able")]) |
| - } |
| - |
| - func testStyledRunSplitsAtNewlines() { |
| - // Markers can't span lines, so each line wraps on its own. |
| - XCTAssertEqual(serializeRuns([RichRun("one\ntwo", bold: true)]), "**one**\n**two**") |
| - XCTAssertEqual(serializeRuns([RichRun("a\n\nb", italic: true)]), "*a*\n\n*b*") |
| - } |
| - |
| - func testWhitespaceOnlyStretchesStayUnwrapped() { |
| - XCTAssertEqual(serializeRuns([RichRun(" ", bold: true)]), " ") |
| - XCTAssertEqual(serializeRuns([RichRun("a\n \nb", bold: true)]), "**a**\n \n**b**") |
| - } |
| - |
| - func testInexpressibleCombosResolveByPriority() { |
| - // code wins outright; strike drops when combined with bold/italic. |
| - XCTAssertEqual(serializeRuns([RichRun("x", bold: true, code: true)]), "`x`") |
| - XCTAssertEqual(serializeRuns([RichRun("x", bold: true, strike: true)]), "**x**") |
| - XCTAssertEqual(serializeRuns([RichRun("x", italic: true, strike: true)]), "*x*") |
| - } |
| - |
| - func testParseMirrorsTheFeed() { |
| - XCTAssertEqual(parseRuns("a **b** *i* ***bi*** ~s~ `c`"), [ |
| - RichRun("a "), |
| - RichRun("b", bold: true), |
| - RichRun(" "), |
| - RichRun("i", italic: true), |
| - RichRun(" "), |
| - RichRun("bi", bold: true, italic: true), |
| - RichRun(" "), |
| - RichRun("s", strike: true), |
| - RichRun(" "), |
| - RichRun("c", code: true), |
| - ]) |
| - } |
| - |
| - func testLinksFlattenToPlainTextAndCoalesce() { |
| - // The link and its neighbours read back as one plain run — the composer |
| - // doesn't style links, and their text must survive verbatim. |
| - XCTAssertEqual(parseRuns("see https://x.com now"), [RichRun("see https://x.com now")]) |
| - } |
| - |
| - func testUnderscoreItalicCanonicalizesToAsterisk() { |
| - // Not a round trip — parse reads `_i_`, serialize re-emits `*i*`. |
| - XCTAssertEqual(serializeRuns(parseRuns("_i_")), "*i*") |
| - } |
| - |
| - func testRoundTripsEverythingTheEditorProduces() { |
| - roundTrips([RichRun("plain")]) |
| - roundTrips([RichRun("a "), RichRun("b", bold: true), RichRun(" c")]) |
| - roundTrips([RichRun("x", bold: true, italic: true)], "combined bold italic") |
| - roundTrips([ |
| - RichRun("mixed "), |
| - RichRun("bold", bold: true), |
| - RichRun(" and "), |
| - RichRun("both", bold: true, italic: true), |
| - RichRun(" then\nplain line two"), |
| - ]) |
| - roundTrips([RichRun("líne ✨ emoji", italic: true)], "unicode survives") |
| - } |
| - |
| - func testMultiLineStyledRunsCanonicalizeStably() { |
| - // A styled run spanning a newline splits per line (markers can't span |
| - // lines), so its parse is the canonical split form — the newline itself |
| - // is a plain run, which styles identically in the editor (styling on a |
| - // newline is invisible). What must hold is stability from then on: |
| - // serializing the canonical form reproduces the same markers. |
| - let s = serializeRuns([RichRun("two\nlines", bold: true), RichRun(" tail")]) |
| - XCTAssertEqual(s, "**two**\n**lines** tail") |
| - let canonical = parseRuns(s) |
| - XCTAssertEqual(canonical, [ |
| - RichRun("two", bold: true), |
| - RichRun("\n"), |
| - RichRun("lines", bold: true), |
| - RichRun(" tail"), |
| - ]) |
| - XCTAssertEqual(serializeRuns(canonical), s, "canonical form is a fixed point") |
| - } |
| -} |
diff --git a/ios/LChatMac/MacComposerField.swift b/ios/LChatMac/MacComposerField.swift| deleted file mode 100644 |
| index dc52e24..0000000 |
| --- a/ios/LChatMac/MacComposerField.swift |
| +++ /dev/null |
| @@ -1,412 +0,0 @@ |
| -import SwiftUI |
| -import AppKit |
| -import LChatCore |
| - |
| -// The Mac native composer's text input — the NSTextView-backed field the old |
| -// SwiftUI TextField couldn't be (no selection access, no attributed text, no |
| -// caret for the typeahead). The AppKit twin of iOS's ComposerField, sharing its |
| -// contract exactly: |
| -// |
| -// - Rich text in the box: ⌘B/⌘I toggle bold/italic on the selection (or the |
| -// typing style at a bare caret), like Messages. ⌘U is swallowed — the marker |
| -// grammar has no underline, and a style that silently vanishes on send would |
| -// be a lie. |
| -// - The `text` binding stays a plain String holding the wire's marker form |
| -// (**bold** / *italic* / ***both*** — RichTextEdit.swift), so the send path, |
| -// slash commands and drafts are untouched. Serialize on every change; parse |
| -// on external sets (an edit-load shows glyphs, not asterisks). |
| -// - Return sends, Shift/Option+Return inserts a newline (the desktop |
| -// shouldSend split); the typeahead gets first claim on ↑↓/Tab/Return/Esc via |
| -// the handler closures, and ↑ on an empty draft edits your last message. |
| -// - `caret` reports a UTF-16 offset into the *serialized* text — real caret |
| -// tracking for the typeahead (the old field could only assume caret-at-end). |
| -struct MacComposerField: NSViewRepresentable { |
| - @Binding var text: String |
| - @Binding var focused: Bool |
| - var caret: Binding<Int>? = nil |
| - var placeholder: String = "" |
| - var fontSize: CGFloat |
| - var isDisabled: Bool = false |
| - var onSend: () -> Void |
| - // Typeahead/shortcut hooks — each returns whether it consumed the key. |
| - var onMoveUp: () -> Bool = { false } |
| - var onMoveDown: () -> Bool = { false } |
| - var onAccept: () -> Bool = { false } // Tab / Return over an open typeahead |
| - var onEscape: () -> Bool = { false } |
| - // Stage images / file URLs from a paste; returns whether anything staged. |
| - // nil in a default-command thread (text-only surface, like the web). |
| - var onStagePaste: ((NSPasteboard) -> Bool)? = nil |
| - |
| - func makeCoordinator() -> Coordinator { Coordinator(self) } |
| - |
| - func makeNSView(context: Context) -> NSScrollView { |
| - let tv = ComposerTextView() |
| - tv.baseFontSize = fontSize |
| - tv.delegate = context.coordinator |
| - tv.onFocusChange = { [weak coordinator = context.coordinator] has in |
| - guard let parent = coordinator?.parent else { return } |
| - if parent.focused != has { parent.focused = has } |
| - } |
| - tv.isRichText = true // attributed editing; pastes are flattened below |
| - tv.allowsUndo = true |
| - tv.drawsBackground = false |
| - tv.textContainerInset = .zero |
| - tv.textContainer?.lineFragmentPadding = 0 |
| - tv.textContainer?.widthTracksTextView = true |
| - tv.isVerticallyResizable = true |
| - tv.isHorizontallyResizable = false |
| - tv.autoresizingMask = [.width] |
| - // The classic scroll-view pairing: without an unbounded maxSize the |
| - // text view can't grow past its initial frame, and content beyond it |
| - // would neither lay out nor scroll. |
| - tv.minSize = .zero |
| - tv.maxSize = NSSize(width: CGFloat.greatestFiniteMagnitude, |
| - height: CGFloat.greatestFiniteMagnitude) |
| - tv.usesFontPanel = false |
| - tv.importsGraphics = false |
| - tv.isAutomaticLinkDetectionEnabled = false // a link attribute we'd never serialize |
| - tv.applyBaseTypingAttributes() |
| - |
| - let scroll = NSScrollView() |
| - scroll.documentView = tv |
| - scroll.drawsBackground = false |
| - scroll.borderType = .noBorder |
| - scroll.hasVerticalScroller = true |
| - scroll.autohidesScrollers = true |
| - return scroll |
| - } |
| - |
| - func updateNSView(_ scroll: NSScrollView, context: Context) { |
| - context.coordinator.parent = self |
| - guard let tv = scroll.documentView as? ComposerTextView else { return } |
| - tv.onStagePaste = onStagePaste |
| - tv.isEditable = !isDisabled |
| - if tv.baseFontSize != fontSize { |
| - tv.baseFontSize = fontSize |
| - tv.setMarkerText(text) // re-derive every run from the new base size |
| - } |
| - // Compare serialized forms: user edits round-trip exactly, so only |
| - // *external* sets (edit-load, clear after send, typeahead splice) |
| - // reparse the view. Canonicalize the target so a draft arriving as |
| - // `_i_` (serializes back as `*i*`) doesn't re-set on every update. |
| - if tv.serializedText() != serializeRuns(parseRuns(text)) { |
| - tv.setMarkerText(text) |
| - } |
| - tv.placeholderLabel.stringValue = placeholder |
| - tv.placeholderLabel.isHidden = !text.isEmpty |
| - // Drive first responder from the focus binding — async so we never |
| - // mutate responder state mid-update. Compare against the live first |
| - // responder so a re-assert to `true` still re-grabs focus. |
| - let isFirst = tv.window?.firstResponder === tv |
| - if focused != isFirst { |
| - DispatchQueue.main.async { |
| - guard let window = tv.window else { return } |
| - if focused { |
| - window.makeFirstResponder(tv) |
| - } else if window.firstResponder === tv { |
| - window.makeFirstResponder(nil) |
| - } |
| - } |
| - } |
| - } |
| - |
| - // Grow with the text up to nine lines (the old field's lineLimit(1...9)), |
| - // then scroll within. ALWAYS returns a finite, capped height — SwiftUI |
| - // probes with zero/unspecified/infinite proposals, and returning nil for |
| - // any of them marks the view as greedy: the field then swallows the whole |
| - // pane (the giant-composer bug). |
| - func sizeThatFits(_ proposal: ProposedViewSize, nsView: NSScrollView, context: Context) -> CGSize? { |
| - guard let tv = nsView.documentView as? ComposerTextView, |
| - let container = tv.textContainer, let layout = tv.layoutManager else { return nil } |
| - // Resolve a working width: the proposal when it's a real number, else |
| - // whatever the view last had (a probe must still yield a finite height). |
| - var width = proposal.width ?? tv.bounds.width |
| - if !width.isFinite || width <= 0 { width = max(tv.bounds.width, 100) } |
| - if tv.frame.width != width { tv.setFrameSize(NSSize(width: width, height: tv.frame.height)) } |
| - layout.ensureLayout(for: container) |
| - let line = layout.defaultLineHeight(for: tv.baseFont) |
| - let used = layout.usedRect(for: container).height |
| - let height = ceil(min(max(line, used), ceil(line * 9))) |
| - // Width: honor a finite proposal exactly; for zero/infinite probes |
| - // report the working width rather than echoing the probe back. |
| - if let w = proposal.width, w.isFinite, w > 0 { |
| - return CGSize(width: w, height: height) |
| - } |
| - return CGSize(width: width, height: height) |
| - } |
| - |
| - final class Coordinator: NSObject, NSTextViewDelegate { |
| - var parent: MacComposerField |
| - init(_ parent: MacComposerField) { self.parent = parent } |
| - |
| - func textDidChange(_ notification: Notification) { |
| - guard let tv = notification.object as? ComposerTextView else { return } |
| - parent.text = tv.serializedText() |
| - parent.caret?.wrappedValue = tv.serializedCaret() |
| - tv.placeholderLabel.isHidden = !tv.string.isEmpty |
| - } |
| - |
| - func textViewDidChangeSelection(_ notification: Notification) { |
| - guard let tv = notification.object as? ComposerTextView else { return } |
| - parent.caret?.wrappedValue = tv.serializedCaret() |
| - } |
| - |
| - func textView(_ textView: NSTextView, doCommandBy commandSelector: Selector) -> Bool { |
| - switch commandSelector { |
| - case #selector(NSResponder.insertNewline(_:)): |
| - // Desktop shouldSend: Shift/Option+Return newlines (fall through |
| - // to the standard insertion), plain Return accepts the typeahead |
| - // or sends. |
| - let mods = NSApp.currentEvent?.modifierFlags ?? [] |
| - if mods.contains(.shift) || mods.contains(.option) { return false } |
| - if parent.onAccept() { return true } |
| - parent.onSend() |
| - return true |
| - case #selector(NSResponder.moveUp(_:)): |
| - return parent.onMoveUp() |
| - case #selector(NSResponder.moveDown(_:)): |
| - return parent.onMoveDown() |
| - case #selector(NSResponder.insertTab(_:)): |
| - return parent.onAccept() |
| - case #selector(NSResponder.cancelOperation(_:)): |
| - return parent.onEscape() |
| - case #selector(NSResponder.complete(_:)): |
| - return true // ours is the only completion UI — never the system popup |
| - default: |
| - return false |
| - } |
| - } |
| - } |
| -} |
| - |
| -// The NSTextView itself: placeholder, ⌘B/⌘I trait toggles, flattened pastes, |
| -// and the styled-contents ↔ marker-grammar conversion (RichTextEdit.swift). |
| -final class ComposerTextView: NSTextView { |
| - let placeholderLabel = NSTextField(labelWithString: "") |
| - var onStagePaste: ((NSPasteboard) -> Bool)? |
| - var onFocusChange: ((Bool) -> Void)? |
| - |
| - var baseFontSize: CGFloat = NSFont.systemFontSize { |
| - didSet { |
| - placeholderLabel.font = .systemFont(ofSize: baseFontSize) |
| - if string.isEmpty { applyBaseTypingAttributes() } |
| - } |
| - } |
| - // The unstyled body font every run's traits derive from. Never `font` — |
| - // after styled content lands, that reports the run at the caret, and |
| - // deriving from a bold base would bold everything. |
| - var baseFont: NSFont { .systemFont(ofSize: baseFontSize) } |
| - private var baseColor: NSColor { .labelColor } |
| - |
| - override init(frame frameRect: NSRect, textContainer container: NSTextContainer?) { |
| - super.init(frame: frameRect, textContainer: container) |
| - setUpPlaceholder() |
| - } |
| - // AppKit (unlike UIKit) does NOT build the text system for a nil container |
| - // — init(frame:textContainer: nil) yields a view with no textContainer, no |
| - // layoutManager, no textStorage: typing is dead and sizeThatFits' guards |
| - // bail (which read as "greedy", the composer-swallows-the-pane bug). |
| - // Assemble the classic TextKit stack by hand. |
| - convenience init() { |
| - let storage = NSTextStorage() |
| - let layout = NSLayoutManager() |
| - storage.addLayoutManager(layout) |
| - let container = NSTextContainer(size: NSSize(width: 0, height: CGFloat.greatestFiniteMagnitude)) |
| - container.widthTracksTextView = true |
| - layout.addTextContainer(container) |
| - self.init(frame: .zero, textContainer: container) |
| - } |
| - required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") } |
| - |
| - private func setUpPlaceholder() { |
| - placeholderLabel.font = .systemFont(ofSize: baseFontSize) |
| - placeholderLabel.textColor = .placeholderTextColor |
| - placeholderLabel.lineBreakMode = .byTruncatingTail |
| - placeholderLabel.maximumNumberOfLines = 1 |
| - addSubview(placeholderLabel) |
| - } |
| - |
| - override func layout() { |
| - super.layout() |
| - placeholderLabel.frame = NSRect(x: 0, y: 0, width: bounds.width, |
| - height: placeholderLabel.intrinsicContentSize.height) |
| - } |
| - |
| - func applyBaseTypingAttributes() { |
| - typingAttributes = [.font: baseFont, .foregroundColor: baseColor] |
| - } |
| - |
| - override func becomeFirstResponder() -> Bool { |
| - let ok = super.becomeFirstResponder() |
| - if ok { onFocusChange?(true) } |
| - return ok |
| - } |
| - override func resignFirstResponder() -> Bool { |
| - let ok = super.resignFirstResponder() |
| - if ok { onFocusChange?(false) } |
| - return ok |
| - } |
| - |
| - // ⌘B/⌘I toggle traits; ⌘U is swallowed (no underline marker — see the |
| - // representable's doc comment). Everything else keeps its meaning. |
| - override func performKeyEquivalent(with event: NSEvent) -> Bool { |
| - // Key equivalents walk the VIEW hierarchy, not the responder chain — |
| - // and RootView keeps every opened space's pane mounted (hidden at |
| - // opacity 0), each with its own composer. Without the focus guard the |
| - // FIRST composer in the tree swallows ⌘B/⌘I and formats a hidden |
| - // draft, so the shortcut only ever "worked" on the top space. Only the |
| - // focused field may claim the key; everyone else passes it along. |
| - guard window?.firstResponder === self else { |
| - return super.performKeyEquivalent(with: event) |
| - } |
| - if event.modifierFlags.intersection(.deviceIndependentFlagsMask) == .command, |
| - let key = event.charactersIgnoringModifiers?.lowercased() { |
| - switch key { |
| - case "b": toggleTrait(.bold); return true |
| - case "i": toggleTrait(.italic); return true |
| - case "u": return true |
| - default: break |
| - } |
| - } |
| - return super.performKeyEquivalent(with: event) |
| - } |
| - |
| - // Toggle a symbolic trait over the selection (all-styled → remove, else |
| - // add — the standard semantics), or flip the typing style at a bare caret. |
| - // Selection changes go through shouldChangeText/didChangeText so undo works |
| - // and the delegate (→ the binding) hears about the attribute-only edit. |
| - private func toggleTrait(_ trait: NSFontDescriptor.SymbolicTraits) { |
| - let range = selectedRange() |
| - if range.length == 0 { |
| - var attrs = typingAttributes |
| - let font = (attrs[.font] as? NSFont) ?? baseFont |
| - let has = font.fontDescriptor.symbolicTraits.contains(trait) |
| - attrs[.font] = converted(font, trait: trait, on: !has) |
| - typingAttributes = attrs |
| - return |
| - } |
| - guard let storage = textStorage, shouldChangeText(in: range, replacementString: nil) else { return } |
| - var allStyled = true |
| - storage.enumerateAttribute(.font, in: range) { value, _, stop in |
| - let font = (value as? NSFont) ?? baseFont |
| - if !font.fontDescriptor.symbolicTraits.contains(trait) { |
| - allStyled = false |
| - stop.pointee = true |
| - } |
| - } |
| - storage.beginEditing() |
| - storage.enumerateAttribute(.font, in: range) { value, subRange, _ in |
| - let font = (value as? NSFont) ?? baseFont |
| - storage.addAttribute(.font, value: converted(font, trait: trait, on: !allStyled), range: subRange) |
| - } |
| - storage.endEditing() |
| - didChangeText() |
| - } |
| - |
| - private func converted(_ font: NSFont, trait: NSFontDescriptor.SymbolicTraits, on: Bool) -> NSFont { |
| - var traits = font.fontDescriptor.symbolicTraits |
| - if on { traits.insert(trait) } else { traits.remove(trait) } |
| - let descriptor = font.fontDescriptor.withSymbolicTraits(traits) |
| - return NSFont(descriptor: descriptor, size: font.pointSize) ?? font |
| - } |
| - |
| - // Pastes: stage images/file URLs as uploads (the web composer's |
| - // paste-to-upload), and flatten any styled text to the caret's typing |
| - // style — isRichText would otherwise keep a web page's fonts/colors, which |
| - // the marker grammar can't represent. A mixed clipboard does both. |
| - override func paste(_ sender: Any?) { |
| - let pb = NSPasteboard.general |
| - let staged = onStagePaste?(pb) ?? false |
| - if let s = pb.string(forType: .string), !s.isEmpty { |
| - insertText(s, replacementRange: selectedRange()) |
| - return |
| - } |
| - if !staged { super.pasteAsPlainText(sender) } |
| - } |
| - |
| - // ---- styled contents ↔ marker grammar (RichTextEdit.swift) ---- |
| - |
| - // The style of the character at a UTF-16 index, as an empty-text run. |
| - private func styleAt(_ index: Int) -> RichRun { |
| - guard let storage = textStorage else { return RichRun("") } |
| - let attrs = storage.attributes(at: index, effectiveRange: nil) |
| - let traits = ((attrs[.font] as? NSFont) ?? baseFont).fontDescriptor.symbolicTraits |
| - let strike = ((attrs[.strikethroughStyle] as? Int) ?? 0) != 0 |
| - return RichRun("", bold: traits.contains(.bold), italic: traits.contains(.italic), |
| - strike: strike, code: traits.contains(.monoSpace)) |
| - } |
| - |
| - private func runs(of a: NSAttributedString) -> [RichRun] { |
| - var runs: [RichRun] = [] |
| - let str = a.string as NSString |
| - a.enumerateAttributes(in: NSRange(location: 0, length: a.length)) { attrs, range, _ in |
| - let traits = ((attrs[.font] as? NSFont) ?? baseFont).fontDescriptor.symbolicTraits |
| - let strike = ((attrs[.strikethroughStyle] as? Int) ?? 0) != 0 |
| - let run = RichRun(str.substring(with: range), |
| - bold: traits.contains(.bold), italic: traits.contains(.italic), |
| - strike: strike, code: traits.contains(.monoSpace)) |
| - // Coalesce: AppKit splits runs on attributes (color, kerning…) that |
| - // don't matter to the grammar. |
| - if let last = runs.last, last.sameStyle(as: run) { |
| - runs[runs.count - 1].text += run.text |
| - } else { |
| - runs.append(run) |
| - } |
| - } |
| - return runs |
| - } |
| - |
| - /// The field's contents in wire form — what the `text` binding holds. |
| - func serializedText() -> String { |
| - serializeRuns(runs(of: textStorage ?? NSAttributedString())) |
| - } |
| - |
| - /// Replace the contents from wire form (an edit-load / external set): the |
| - /// draft shows glyphs, not asterisks. |
| - func setMarkerText(_ text: String) { |
| - let out = NSMutableAttributedString() |
| - for run in parseRuns(text) { |
| - var traits = baseFont.fontDescriptor.symbolicTraits |
| - if run.bold { traits.insert(.bold) } |
| - if run.italic { traits.insert(.italic) } |
| - var font = baseFont |
| - if run.code { |
| - font = .monospacedSystemFont(ofSize: baseFont.pointSize, weight: .regular) |
| - if run.bold || run.italic { |
| - font = NSFont(descriptor: font.fontDescriptor.withSymbolicTraits(traits), |
| - size: 0) ?? font |
| - } |
| - } else if run.bold || run.italic { |
| - font = NSFont(descriptor: baseFont.fontDescriptor.withSymbolicTraits(traits), |
| - size: 0) ?? font |
| - } |
| - var attrs: [NSAttributedString.Key: Any] = [.font: font, .foregroundColor: baseColor] |
| - if run.strike { attrs[.strikethroughStyle] = NSUnderlineStyle.single.rawValue } |
| - out.append(NSAttributedString(string: run.text, attributes: attrs)) |
| - } |
| - textStorage?.setAttributedString(out) |
| - setSelectedRange(NSRange(location: out.length, length: 0)) |
| - // A cleared field must not inherit the last run's style into the next |
| - // message — reset the caret style to plain. |
| - if text.isEmpty { applyBaseTypingAttributes() } |
| - } |
| - |
| - /// The caret as an offset into the *serialized* text. Serializing the prefix |
| - /// before the caret counts every marker the caret sits after; when the caret |
| - /// is strictly inside a styled run, that prefix closed the run's markers |
| - /// behind it — characters the true position is before, so back them off. |
| - func serializedCaret() -> Int { |
| - guard let storage = textStorage else { return 0 } |
| - let loc = selectedRange().location |
| - guard loc > 0, loc <= storage.length else { return 0 } |
| - let prefix = storage.attributedSubstring(from: NSRange(location: 0, length: loc)) |
| - var n = (serializeRuns(runs(of: prefix)) as NSString).length |
| - if loc < storage.length { |
| - let before = styleAt(loc - 1) |
| - if before.sameStyle(as: styleAt(loc)), let marker = before.marker { |
| - n -= (marker as NSString).length |
| - } |
| - } |
| - return n |
| - } |
| -} |
diff --git a/ios/LChatMac/NativeFeedView.swift b/ios/LChatMac/NativeFeedView.swift| index 14cf47a..5860ba4 100644 |
| --- a/ios/LChatMac/NativeFeedView.swift |
| +++ b/ios/LChatMac/NativeFeedView.swift |
| @@ -93,10 +93,7 @@ struct NativeFeedView: View { |
| // The native composer's draft — cleared only when a send actually reaches |
| // the server (model.send returns false on a down socket, keeping the text). |
| @State private var draft = "" |
| - @State private var composerFocused = false |
| - // The caret as a UTF-16 offset into `draft` (MacComposerField maps its |
| - // display caret to serialized offsets) — drives the typeahead. |
| - @State private var composerCaret = 0 |
| + @FocusState private var composerFocused: Bool |
| // Attachments staged on the draft: picked → uploading (spinner) → ready. |
| // Several can ride one message, like the web's pending list. |
| private struct Staged: Identifiable { |
| @@ -686,36 +683,24 @@ struct NativeFeedView: View { |
| // iMessage-style: a within-window frost (FrostedBand) — matches the |
| // chat stream's surface exactly at rest (blurring the same window |
| // background it sits on) while messages scrolling underneath ghost |
| - // through the glass. (The old `.bar` material looked like a second, |
| - // lighter surface; an opaque fill killed the ghosting.) Only the |
| - // input row wears it — the typeahead/banners above stay |
| - // transparent floaters. |
| + // through the glass. Only the input row wears it — the typeahead/ |
| + // banners above stay transparent floaters. |
| .background(FrostedBand()) |
| } |
| .onChange(of: draft) { _, text in |
| - refreshTypeahead() |
| + // Caret-at-end is the common case while typing; caret tracking for |
| + // mid-text edits needs the NSTextView-backed field (iOS's |
| + // ComposerField pattern) — container work. |
| + taToken = composerToken(text) |
| + taItems = taToken.map { |
| + typeaheadItems($0, roster: roster, myUserId: myUserId, hasBot: hasBot, |
| + appCommands: appCommands) |
| + } ?? [] |
| + taIndex = 0 |
| // The typing beats: alive while there's a draft, an immediate |
| // clear when it empties (edits don't announce themselves). |
| model.typingBeat(active: !text.isEmpty && editingID == nil) |
| } |
| - // Caret moves (clicks / arrows) can enter or leave a token with the |
| - // text unchanged — re-check, like the web composer's keyup handler. |
| - .onChange(of: composerCaret) { _, _ in refreshTypeahead() } |
| - } |
| - |
| - // The token under the REAL caret (MacComposerField tracks it) — the old |
| - // TextField could only assume caret-at-end. The field reports UTF-16, the |
| - // tokenizer speaks Characters, so convert at the boundary (as on iOS). |
| - private func refreshTypeahead() { |
| - let utf16 = min(max(composerCaret, 0), draft.utf16.count) |
| - let idx = String.Index(utf16Offset: utf16, in: draft) |
| - let charCaret = draft.distance(from: draft.startIndex, to: idx) |
| - taToken = composerToken(draft, caret: charCaret) |
| - taItems = taToken.map { |
| - typeaheadItems($0, roster: roster, myUserId: myUserId, hasBot: hasBot, |
| - appCommands: appCommands) |
| - } ?? [] |
| - taIndex = 0 |
| } |
| // Accept a candidate: splice it over the token (trigger → caret), exactly |
| @@ -834,69 +819,77 @@ struct NativeFeedView: View { |
| .buttonStyle(.plain) |
| .padding(.bottom, 4) |
| } |
| - // The NSTextView-backed field (MacComposerField): rich text in the |
| - // box (⌘B/⌘I serialize to markers in `draft`), real caret tracking |
| - // for the typeahead, and paste-to-upload — the SwiftUI TextField it |
| - // replaced could do none of those. Typeahead keys route through the |
| - // handlers: ↑↓ move, Tab/Return accept, Esc dismisses — Return |
| - // falls through to send when no menu is up. Up-arrow ALSO edits the |
| - // last message when the box is empty. |
| - MacComposerField( |
| - text: $draft, |
| - focused: $composerFocused, |
| - caret: $composerCaret, |
| - placeholder: defaultCommand?.placeholder ?? "Message", |
| - fontSize: chatFontSize, // the feed's reading size, like the web composer |
| + TextField(defaultCommand?.placeholder ?? "Message", text: $draft, axis: .vertical) |
| + // Paste an image (or a copied file) straight onto the draft - |
| + // it stages as an upload, like the web composer's paste. In a |
| + // default-command thread the type list is empty, so ⌘V is NOT |
| + // claimed here and the field's normal text paste still works. |
| + .onPasteCommand(of: defaultCommand == nil ? [.png, .jpeg, .tiff, .image, .fileURL] : []) { providers in |
| + handlePaste(providers) |
| + } |
| + .textFieldStyle(.plain) |
| + .font(.system(size: chatFontSize)) // the feed's reading size, like the web composer |
| + .lineLimit(1...9) // grows to ~9 lines, then scrolls within itself |
| + .padding(.horizontal, 14) |
| + .padding(.vertical, 9) |
| + // The web composer: a bordered pill on the panel background, |
| + // not a filled grey field. |
| + .background(RoundedRectangle(cornerRadius: 19, style: .continuous) |
| + .fill(.background)) |
| + .overlay(RoundedRectangle(cornerRadius: 19, style: .continuous) |
| + .strokeBorder(.quaternary, lineWidth: 1)) |
| + .onSubmit(sendDraft) |
| // Queued behind an upload = the message is committed; editing |
| // what's about to auto-send would race it. |
| - isDisabled: sendQueued, |
| - onSend: sendDraft, |
| - onMoveUp: { |
| + .disabled(sendQueued) |
| + // Typeahead keys: ↑↓ move, Tab/Return accept, Esc dismisses — |
| + // Return falls through to send when no menu is up. Up-arrow |
| + // ALSO edits the last message when the box is empty (one |
| + // handler — two .onKeyPress(.upArrow) on a field don't both |
| + // fire, which is why the edit shortcut never worked). |
| + .onKeyPress(.upArrow) { |
| if !taItems.isEmpty { |
| taIndex = (taIndex + taItems.count - 1) % taItems.count |
| - return true |
| + return .handled |
| } |
| if draft.isEmpty, editingID == nil, |
| let last = model.messages.last(where: { $0.mine && !$0.action }) { |
| startEdit(last) |
| - return true |
| + return .handled |
| } |
| - return false |
| - }, |
| - onMoveDown: { |
| - guard !taItems.isEmpty else { return false } |
| + return .ignored |
| + } |
| + .onKeyPress(.downArrow) { |
| + guard !taItems.isEmpty else { return .ignored } |
| taIndex = (taIndex + 1) % taItems.count |
| - return true |
| - }, |
| - onAccept: { |
| - guard !taItems.isEmpty, let token = taToken else { return false } |
| + return .handled |
| + } |
| + .onKeyPress(.tab) { |
| + guard !taItems.isEmpty, let token = taToken else { return .ignored } |
| pick(taItems[taIndex], token: token) |
| - return true |
| - }, |
| - onEscape: { |
| - guard !taItems.isEmpty else { return false } |
| + return .handled |
| + } |
| + .onKeyPress(phases: .down) { press in |
| + guard press.key == .return else { return .ignored } |
| + // Desktop shouldSend: Shift/Option+Return newlines, plain |
| + // Return accepts the typeahead or sends (via onSubmit). |
| + if press.modifiers.contains(.shift) || press.modifiers.contains(.option) { |
| + draft += "\n" |
| + return .handled |
| + } |
| + if !taItems.isEmpty, let token = taToken { |
| + pick(taItems[taIndex], token: token) |
| + return .handled |
| + } |
| + return .ignored |
| + } |
| + .onKeyPress(.escape) { |
| + guard !taItems.isEmpty else { return .ignored } |
| taToken = nil |
| taItems = [] |
| - return true |
| - }, |
| - // Paste an image (or a copied file) straight onto the draft — |
| - // it stages as an upload, like the web composer's paste. Absent |
| - // in a default-command thread (text-only surface), so a text |
| - // paste there stays a plain text paste. |
| - onStagePaste: defaultCommand == nil ? { stagePasteboard($0) } : nil |
| - ) |
| - // Claim the row's full width (the + and send buttons keep their |
| - // fixed sizes) — the representable reports its measured width at |
| - // the max probe, so without this the HStack centres a short pill. |
| - .frame(maxWidth: .infinity) |
| - .padding(.horizontal, 14) |
| - .padding(.vertical, 9) |
| - // The web composer: a bordered pill on the panel background, |
| - // not a filled grey field. |
| - .background(RoundedRectangle(cornerRadius: 19, style: .continuous) |
| - .fill(.background)) |
| - .overlay(RoundedRectangle(cornerRadius: 19, style: .continuous) |
| - .strokeBorder(.quaternary, lineWidth: 1)) |
| + return .handled |
| + } |
| + .focused($composerFocused) |
| // Regaining the app drops the caret into the message box, the |
| // same courtesy the webview shell extends (focusComposer) — |
| // but ONLY from the pane actually on screen (every instance's |
| @@ -1230,24 +1223,27 @@ struct NativeFeedView: View { |
| } |
| } |
| - // Pasted content (from MacComposerField's paste override): file URLs route |
| - // through stage(url); raw image data stages as a PNG (like the web |
| - // composer's paste-to-upload). Returns whether anything staged, so the |
| - // field knows if the paste was (partly) consumed. |
| - private func stagePasteboard(_ pb: NSPasteboard) -> Bool { |
| - if let urls = pb.readObjects(forClasses: [NSURL.self]) as? [URL] { |
| - let files = urls.filter(\.isFileURL) |
| - if !files.isEmpty { |
| - files.forEach { stage($0) } |
| - return true |
| + // Pasted content: file URLs route through stage(url); raw image data |
| + // stages as a PNG (like the web composer's paste-to-upload). |
| + private func handlePaste(_ providers: [NSItemProvider]) { |
| + for provider in providers { |
| + if provider.hasItemConformingToTypeIdentifier(UTType.fileURL.identifier) { |
| + _ = provider.loadObject(ofClass: URL.self) { url, _ in |
| + if let url { DispatchQueue.main.async { stage(url) } } |
| + } |
| + continue |
| + } |
| + let types: [UTType] = [.png, .jpeg, .tiff, .image] |
| + guard let type = types.first(where: { |
| + provider.hasItemConformingToTypeIdentifier($0.identifier) |
| + }) else { continue } |
| + provider.loadDataRepresentation(forTypeIdentifier: type.identifier) { data, _ in |
| + guard let data else { return } |
| + DispatchQueue.main.async { |
| + stage(data: data, name: "pasted.png", type: .png) |
| + } |
| } |
| } |
| - for type in [NSPasteboard.PasteboardType.png, .tiff] { |
| - guard let data = pb.data(forType: type) else { continue } |
| - stage(data: data, name: "pasted.png", type: type == .png ? .png : .tiff) |
| - return true |
| - } |
| - return false |
| } |
| private func stage(_ url: URL) { |