Rich-text composer on iOS + Mac: ⌘B/⌘I style in the box; ***bold italic*** joins the grammar (#471)
* web+ios: rich-text composer — ⌘B/⌘I style in the box; ***bold italic*** joins the grammar
The iOS composer now edits styled text like Messages.app: ⌘B/⌘I (and the
selection callout's Format menu) bold/italic the text in the box, and the
draft serializes to the wire's marker grammar on send — the binding stays a
plain String, so send paths, slash commands and drafts are untouched.
Edit-loads parse markers back to glyphs. Underline is refused (no marker for
it); styled pastes flatten; caret offsets map to serialized positions for the
typeahead.
The grammar gains its one combined form, ***bold italic***, in both twins —
format.js and RichText.swift — with parity cases pinned on both sides, plus
the new RichTextEdit.swift runs↔markers converter in LChatCore (pure
Foundation, ready for the Mac's future native composer).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* mac: NSTextView-backed rich composer (⌘B/⌘I) + footer polish
MacComposerField replaces the SwiftUI TextField: rich text in the box
serialized to the marker grammar (the shared RichTextEdit bridge), Return/
Shift+Return split preserved, typeahead keys routed through handlers, paste
staging kept, and the typeahead gains real caret tracking (the old field
could only assume caret-at-end — the flagged "container work").
Footer polish from review: the composer band matches the chat stream's
background (was a lighter .bar material), more breathing room below the
input pill (Messages.app reference), and the + glyph renders white in dark
mode / dark grey in light via palette rendering instead of a knockout.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* mac: assemble the TextKit stack — NSTextView(textContainer: nil) has no text system
AppKit, unlike UIKit, does not build the text machinery when the designated
initializer gets a nil container: textContainer/layoutManager/textStorage are
all nil, typing is dead, and sizeThatFits' guards bail — which SwiftUI reads
as "no preferred size" and hands the field the whole pane (the giant-composer
bug). Build storage → layout manager → container by hand in the convenience
init, and make sizeThatFits answer every probe (zero/unspecified/infinite)
with a finite, nine-line-capped height plus the min/max sizes vertical growth
needs.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* mac+ios: composer polish + botless chats keep their client-answered commands
- The composer pill spans the full row (the representable reported its
measured width at the max probe, so the HStack centred a short pill).
- The footer band is translucent again — ultraThinMaterial under a heavy
windowBackgroundColor tint, so it reads as the feed's surface at rest while
messages ghost through as they scroll beneath.
- The "/" typeahead in a chat with no AI agent now offers what the native
client answers itself (/theme, /themes, /me) instead of nothing — mirroring
the web's bot-flag rule (bot-only commands like /help stay gated). The old
all-or-nothing hasBot gate was pre-existing, surfaced by testing the new
field in a botless DM.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* mac: within-window frost for header + footer; locked spaces offer sign-in
Messages.app's actual translucency recipe: the transcript sits on the plain
window background and the header/footer are NSVisualEffectView within-window
blurs — a blur of a solid IS that solid, so the bands match the feed exactly
at rest and frost only when messages scroll beneath. FrostedBand wraps that
(plus a windowBackground tint to pull the material's luminance back to the
feed's hue); the composer footer swaps its tint hack for it, and plain chats'
headers move into a top safe-area inset so messages visibly pass under the
glass. Webview surfaces (takeover apps, companion splits) keep the stacked
header — an NSView fills its frame and would underlap an inset.
The locked-space pane ("can't be unlocked on this Mac") gains the primary
action its own copy asks for: Sign In with Eleven, right there — with busy/
failure states — instead of only offering removal while the sign-in hid in
Settings.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* mac: frost is pure glass at full strength — tint and intensity were the enemies
Live-tuning found the two traps, both now documented in FrostedBand:
- Any tint mismatches the feed (windowBackgroundColor isn't what the pane
sits on) — pure glass matches at rest by definition, so the band draws no
colour of its own.
- alphaValue < 1 on an NSVisualEffectView silently disables its backdrop
blur — every "lighter" frost was really blur-off with sharp text bleeding
through a faded plate. Header and footer both run the full-strength
.headerView glass.
Also tried and rejected on the way: heavier materials (drag in their own
grey), stacked layers (plate compounds to opaque), and a true CSS-style
backdrop-filter via public CALayer.backgroundFilters (compositor-ignored;
only the private CABackdropLayer samples the backdrop).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
18 files changed,
+1052
−126
changelog/2026-07-09-bold-italic-together.md+3 −0ios/LChat/Views/ComposerField.swift+143 −9ios/LChatCore/Sources/LChatCore/ComposerTypeahead.swift+18 −1ios/LChatCore/Sources/LChatCore/MessageBubble.swift+4 −0ios/LChatCore/Sources/LChatCore/RichText.swift+6 −4ios/LChatCore/Sources/LChatCore/RichTextEdit.swift+106 −0ios/LChatCore/Tests/LChatCoreTests/ComposerTypeaheadTests.swift+36 −5ios/LChatCore/Tests/LChatCoreTests/RichTextEditTests.swift+109 −0ios/LChatCore/Tests/LChatCoreTests/RichTextTests.swift+9 −0ios/LChatMac/FrostedBand.swift+32 −0ios/LChatMac/MacComposerField.swift+403 −0ios/LChatMac/NativeChatView.swift+23 −6ios/LChatMac/NativeFeedView.swift+106 −94ios/LChatMac/RootView.swift+15 −0test/format.test.mjs+16 −0web/static/apps.js+7 −0web/static/apps/chat.js+7 −0web/static/format.js+9 −7
diff --git a/changelog/2026-07-09-bold-italic-together.md b/changelog/2026-07-09-bold-italic-together.md| new file mode 100644 |
| index 0000000..a457858 |
| --- /dev/null |
| +++ b/changelog/2026-07-09-bold-italic-together.md |
| @@ -0,0 +1,3 @@ |
| +🤖 Bold and italic now combine: wrap text in ***three asterisks*** to make it |
| +both at once, anywhere formatting works — alongside the **bold**, *italic*, |
| +~strikethrough~ and `code` you already had. |
diff --git a/ios/LChat/Views/ComposerField.swift b/ios/LChat/Views/ComposerField.swift| index 400a079..3027df1 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,11 +12,21 @@ 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 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. |
| + // typeahead, which needs to know which token the caret sits in. Offsets are |
| + // into the *serialized* text (the binding), not the styled display text. |
| var caret: Binding<Int>? = nil |
| var placeholder: String = "" |
| var sendOnReturn: Bool |
| @@ -36,6 +47,9 @@ 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 |
| @@ -45,7 +59,14 @@ struct ComposerField: UIViewRepresentable { |
| func updateUIView(_ tv: GrowingTextView, context: Context) { |
| context.coordinator.parent = self |
| - if tv.text != text { tv.text = text } |
| + // 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) |
| + } |
| tv.placeholderLabel.text = placeholder |
| tv.placeholderLabel.isHidden = !text.isEmpty |
| // Drive first responder from the focus binding — async so we never mutate |
| @@ -76,13 +97,15 @@ struct ComposerField: UIViewRepresentable { |
| init(_ parent: ComposerField) { self.parent = parent } |
| func textViewDidChange(_ tv: UITextView) { |
| - parent.text = tv.text |
| - parent.caret?.wrappedValue = tv.selectedRange.location |
| - (tv as? GrowingTextView)?.placeholderLabel.isHidden = !tv.text.isEmpty |
| + guard let tv = tv as? GrowingTextView else { return } |
| + parent.text = tv.serializedText() |
| + parent.caret?.wrappedValue = tv.serializedCaret() |
| + tv.placeholderLabel.isHidden = !tv.text.isEmpty |
| } |
| func textViewDidChangeSelection(_ tv: UITextView) { |
| - parent.caret?.wrappedValue = tv.selectedRange.location |
| + guard let tv = tv as? GrowingTextView else { return } |
| + parent.caret?.wrappedValue = tv.serializedCaret() |
| } |
| func textView(_ tv: UITextView, shouldChangeTextIn range: NSRange, |
| @@ -104,9 +127,9 @@ 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. |
| +// 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. |
| final class GrowingTextView: UITextView { |
| let placeholderLabel = UILabel() |
| private(set) var shiftHeld = false |
| @@ -116,9 +139,18 @@ 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) |
| } |
| @@ -130,9 +162,111 @@ 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/ComposerTypeahead.swift b/ios/LChatCore/Sources/LChatCore/ComposerTypeahead.swift| index 1d6e462..cff839d 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/ComposerTypeahead.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/ComposerTypeahead.swift |
| @@ -89,7 +89,15 @@ public func typeaheadItems(_ token: ComposerToken, roster: [RosterEntry], |
| switch token.kind { |
| case .command: |
| let q = token.query.lowercased() |
| - return (appCommands + (hasBot ? botCommands : [])) |
| + // The web's rule (shell.js typeaheadItems): bot-answered commands show |
| + // only where a bot can answer them; client-answered ones show in every |
| + // thread — a botless DM still offers what the client itself runs. The |
| + // native split differs from the web's own: here the CLIENT answers |
| + // theming and /me (SlashCommands.swift) while /help falls through to a |
| + // bot — the web's mirror image — so each side ungates exactly what it |
| + // can answer itself. |
| + let floor = botCommands.filter { hasBot || clientAnswered.contains($0.name) } + [meCommand] |
| + return (appCommands + floor) |
| .filter { $0.name.dropFirst().lowercased().hasPrefix(q) } |
| .prefix(8) |
| .map { .command(name: $0.name, desc: $0.desc) } |
| @@ -118,6 +126,15 @@ public let botCommands: [(name: String, desc: String)] = [ |
| ("/profile", "edit your screen name & photo"), |
| ] |
| +// The subset of the floor the NATIVE client answers itself (SlashCommands.swift |
| +// routes them locally) — offered in every thread, bot or no bot. /help, /dark, |
| +// /light and /profile still fall through to a bot, so they stay gated. |
| +let clientAnswered: Set<String> = ["/theme", "/themes"] |
| + |
| +// /me is handled by the composer itself (SlashCommand.parseMe) — listed so the |
| +// "/" typeahead surfaces it everywhere, like the web's commandCatalog does. |
| +let meCommand = (name: "/me", desc: "post an action line, e.g. /me waves") |
| + |
| // ---- emoji (emoji.js) ----------------------------------------------------- |
| /// emoji.js emojiMatches: prefix hits first, then substring; shorter names win |
diff --git a/ios/LChatCore/Sources/LChatCore/MessageBubble.swift b/ios/LChatCore/Sources/LChatCore/MessageBubble.swift| index 9ee2d3a..57f804b 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/MessageBubble.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/MessageBubble.swift |
| @@ -518,6 +518,10 @@ public struct MessageBubble: View { |
| var a = AttributedString(s) |
| a.font = .system(size: fontSize).italic() |
| out += a |
| + case .boldItalic(let s): |
| + var a = AttributedString(s) |
| + a.font = .system(size: fontSize, weight: .bold).italic() |
| + out += a |
| case .strike(let s): |
| var a = AttributedString(s) |
| a.strikethroughStyle = .single |
diff --git a/ios/LChatCore/Sources/LChatCore/RichText.swift b/ios/LChatCore/Sources/LChatCore/RichText.swift| index 50567fb..9718ac8 100644 |
| --- a/ios/LChatCore/Sources/LChatCore/RichText.swift |
| +++ b/ios/LChatCore/Sources/LChatCore/RichText.swift |
| @@ -11,6 +11,7 @@ public enum RichToken: Equatable { |
| case link(String, URL) // display text + validated http(s) href |
| case bold(String) |
| case italic(String) |
| + case boldItalic(String) // ***both*** — the one combined mark |
| case strike(String) |
| case code(String) |
| } |
| @@ -22,7 +23,7 @@ private let urlRE = try! NSRegularExpression( |
| options: [.caseInsensitive]) |
| private let trailingRE = try! NSRegularExpression(pattern: "[.,!?;:'\")\\]}]+$") |
| private let formatRE = try! NSRegularExpression( |
| - pattern: "`([^`\\n]+)`|\\*\\*([^*\\n]+)\\*\\*|\\*([^*\\n]+)\\*|(?<![\\w])_([^_\\n]+)_(?![\\w])|~([^~\\n]+)~") |
| + pattern: "`([^`\\n]+)`|\\*\\*\\*([^*\\n]+)\\*\\*\\*|\\*\\*([^*\\n]+)\\*\\*|\\*([^*\\n]+)\\*|(?<![\\w])_([^_\\n]+)_(?![\\w])|~([^~\\n]+)~") |
| // format.js BARE_TLDS: scheme-less "host.tld" links only for real TLDs, so |
| // node.js / index.html / main.go stay plain text. |
| @@ -83,7 +84,8 @@ func tokenizeText(_ text: String) -> [(text: String, href: URL?)] { |
| return tokens |
| } |
| -// format.js formatSegment: inline `code` / **bold** / *italic* / _italic_ / ~strike~. |
| +// format.js formatSegment: inline `code` / ***bold italic*** / **bold** / |
| +// *italic* / _italic_ / ~strike~. |
| private func formatSegment(_ text: String, into out: inout [RichToken]) { |
| let str = ns(text) |
| var last = 0 |
| @@ -92,8 +94,8 @@ private func formatSegment(_ text: String, into out: inout [RichToken]) { |
| out.append(.text(str.substring(with: NSRange(location: last, length: m.range.location - last)))) |
| } |
| let makers: [(Int, (String) -> RichToken)] = [ |
| - (1, RichToken.code), (2, RichToken.bold), (3, RichToken.italic), |
| - (4, RichToken.italic), (5, RichToken.strike), |
| + (1, RichToken.code), (2, RichToken.boldItalic), (3, RichToken.bold), |
| + (4, RichToken.italic), (5, RichToken.italic), (6, RichToken.strike), |
| ] |
| for (group, make) in makers { |
| let r = m.range(at: group) |
diff --git a/ios/LChatCore/Sources/LChatCore/RichTextEdit.swift b/ios/LChatCore/Sources/LChatCore/RichTextEdit.swift| new file mode 100644 |
| index 0000000..69f3e83 |
| --- /dev/null |
| +++ b/ios/LChatCore/Sources/LChatCore/RichTextEdit.swift |
| @@ -0,0 +1,106 @@ |
| +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/ComposerTypeaheadTests.swift b/ios/LChatCore/Tests/LChatCoreTests/ComposerTypeaheadTests.swift| index 3a80a3f..1ba233f 100644 |
| --- a/ios/LChatCore/Tests/LChatCoreTests/ComposerTypeaheadTests.swift |
| +++ b/ios/LChatCore/Tests/LChatCoreTests/ComposerTypeaheadTests.swift |
| @@ -49,6 +49,29 @@ final class ComposerTypeaheadTests: XCTestCase { |
| "Swift botCommands has drifted from web/static/bot-commands.js") |
| } |
| + // ---- command availability without a bot -------------------------------- |
| + |
| + private func commandNames(hasBot: Bool) -> [String] { |
| + typeaheadItems(ComposerToken(kind: .command, query: "", start: 0), |
| + roster: [], myUserId: "me", hasBot: hasBot) |
| + .compactMap { if case .command(let name, _) = $0 { return name } else { return nil } } |
| + } |
| + |
| + func testBotlessChatStillOffersClientAnsweredCommands() { |
| + // A DM with no AI agent must still offer what the native client answers |
| + // itself (SlashCommands.swift): theming and /me — and nothing that only |
| + // a bot could answer (/help would post into the void). |
| + XCTAssertEqual(commandNames(hasBot: false), ["/theme", "/themes", "/me"]) |
| + } |
| + |
| + func testBotfulChatOffersTheFullFloor() { |
| + let names = commandNames(hasBot: true) |
| + for expected in botCommands.map(\.name) { |
| + XCTAssertTrue(names.contains(expected), "\(expected) missing with a bot present") |
| + } |
| + XCTAssertTrue(names.contains("/me")) |
| + } |
| + |
| // ---- composerToken (ui.js) parity ------------------------------------- |
| func testCommandToken() { |
| @@ -112,10 +135,17 @@ final class ComposerTypeaheadTests: XCTestCase { |
| } |
| func testCommandCandidatesAreBotGated() { |
| - let tok = ComposerToken(kind: .command, query: "the", start: 0) |
| - XCTAssertEqual(typeaheadItems(tok, roster: roster, myUserId: "u2", hasBot: true).map(\.insert), |
| + // Theming is client-answered (SlashCommands.swift), so it shows with or |
| + // without a bot; a bot-only command like /help gates on hasBot. |
| + let the = ComposerToken(kind: .command, query: "the", start: 0) |
| + XCTAssertEqual(typeaheadItems(the, roster: roster, myUserId: "u2", hasBot: true).map(\.insert), |
| + ["/theme ", "/themes "]) |
| + XCTAssertEqual(typeaheadItems(the, roster: roster, myUserId: "u2", hasBot: false).map(\.insert), |
| ["/theme ", "/themes "]) |
| - XCTAssertTrue(typeaheadItems(tok, roster: roster, myUserId: "u2", hasBot: false).isEmpty) |
| + let he = ComposerToken(kind: .command, query: "he", start: 0) |
| + XCTAssertEqual(typeaheadItems(he, roster: roster, myUserId: "u2", hasBot: true).map(\.insert), |
| + ["/help "]) |
| + XCTAssertTrue(typeaheadItems(he, roster: roster, myUserId: "u2", hasBot: false).isEmpty) |
| } |
| // App-local verbs (web commandCatalog(threadId)): they show in the owning |
| @@ -125,11 +155,12 @@ final class ComposerTypeaheadTests: XCTestCase { |
| let bare = ComposerToken(kind: .command, query: "", start: 0) |
| XCTAssertEqual(typeaheadItems(bare, roster: roster, myUserId: "u2", hasBot: false, |
| appCommands: add).map(\.insert), |
| - ["/add "], "no bot → the local verb still shows, alone") |
| + ["/add ", "/theme ", "/themes ", "/me "], |
| + "no bot → the local verb leads; client-answered floor follows") |
| XCTAssertEqual(typeaheadItems(bare, roster: roster, myUserId: "u2", hasBot: true, |
| appCommands: add).map(\.insert).first, |
| "/add ", "with a bot the local verb leads the catalog") |
| - let miss = ComposerToken(kind: .command, query: "the", start: 0) |
| + let miss = ComposerToken(kind: .command, query: "he", start: 0) |
| XCTAssertTrue(typeaheadItems(miss, roster: roster, myUserId: "u2", hasBot: false, |
| appCommands: add).isEmpty, |
| "prefix filtering applies to app commands too") |
diff --git a/ios/LChatCore/Tests/LChatCoreTests/RichTextEditTests.swift b/ios/LChatCore/Tests/LChatCoreTests/RichTextEditTests.swift| new file mode 100644 |
| index 0000000..6672075 |
| --- /dev/null |
| +++ b/ios/LChatCore/Tests/LChatCoreTests/RichTextEditTests.swift |
| @@ -0,0 +1,109 @@ |
| +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/LChatCore/Tests/LChatCoreTests/RichTextTests.swift b/ios/LChatCore/Tests/LChatCoreTests/RichTextTests.swift| index 66aee79..65511b1 100644 |
| --- a/ios/LChatCore/Tests/LChatCoreTests/RichTextTests.swift |
| +++ b/ios/LChatCore/Tests/LChatCoreTests/RichTextTests.swift |
| @@ -58,6 +58,15 @@ final class RichTextTests: XCTestCase { |
| XCTAssertEqual(richTokens("well ~nope~ then"), [.text("well "), .strike("nope"), .text(" then")]) |
| } |
| + // test/format.test.mjs "triple asterisk is combined bold italic" |
| + func testTripleAsteriskIsCombinedBoldItalic() { |
| + XCTAssertEqual(richTokens("***both***"), [.boldItalic("both")]) |
| + XCTAssertEqual(richTokens("a ***b c*** d"), [.text("a "), .boldItalic("b c"), .text(" d")]) |
| + // Neighbours don't bleed: bi next to bold, and markers still can't span lines. |
| + XCTAssertEqual(richTokens("***x*** **y**"), [.boldItalic("x"), .text(" "), .bold("y")]) |
| + XCTAssertEqual(richTokens("***a\nb***"), [.text("***a\nb***")]) |
| + } |
| + |
| func testUnderscoreItalicsNeedWordBoundaries() { |
| // file_names must not italicize (format.js's lookbehind/lookahead). |
| XCTAssertEqual(richTokens("see file_name_here"), [.text("see file_name_here")]) |
diff --git a/ios/LChatMac/FrostedBand.swift b/ios/LChatMac/FrostedBand.swift| new file mode 100644 |
| index 0000000..c14efd6 |
| --- /dev/null |
| +++ b/ios/LChatMac/FrostedBand.swift |
| @@ -0,0 +1,32 @@ |
| +import SwiftUI |
| +import AppKit |
| + |
| +// The within-window material glass Messages.app uses for its chat header and |
| +// composer footer: an NSVisualEffectView blending within the window, frosting |
| +// whatever scrolls behind it. Deliberately NO colour of its own — a tint can |
| +// only ever mismatch the feed (it did: windowBackgroundColor isn't what the |
| +// pane actually sits on). A blur of a solid is that solid, so the glass |
| +// matches the transcript at rest and frosts what passes beneath. |
| +// |
| +// ⚠️ Two dead ends, so nobody re-walks them: |
| +// - alphaValue < 1 on an NSVisualEffectView silently DISABLES its backdrop |
| +// blur — you get a faded plate with sharp content bleeding through, not a |
| +// lighter frost. The band must run at exactly 1. |
| +// - A true CSS-style backdrop-filter (pure gaussian via public |
| +// CALayer.backgroundFilters, no material plate) is compositor-ignored in |
| +// this window tree; only the private CABackdropLayer gets backdrop |
| +// sampling, and we don't ship private API. |
| +struct FrostedBand: View { |
| + var body: some View { WithinWindowBlur() } |
| +} |
| + |
| +private struct WithinWindowBlur: NSViewRepresentable { |
| + func makeNSView(context: Context) -> NSVisualEffectView { |
| + let v = NSVisualEffectView() |
| + v.blendingMode = .withinWindow |
| + v.material = .headerView |
| + v.state = .active |
| + return v |
| + } |
| + func updateNSView(_ nsView: NSVisualEffectView, context: Context) {} |
| +} |
diff --git a/ios/LChatMac/MacComposerField.swift b/ios/LChatMac/MacComposerField.swift| new file mode 100644 |
| index 0000000..e309ff3 |
| --- /dev/null |
| +++ b/ios/LChatMac/MacComposerField.swift |
| @@ -0,0 +1,403 @@ |
| +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 { |
| + 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/NativeChatView.swift b/ios/LChatMac/NativeChatView.swift| index d05c97d..3e8a17e 100644 |
| --- a/ios/LChatMac/NativeChatView.swift |
| +++ b/ios/LChatMac/NativeChatView.swift |
| @@ -305,13 +305,30 @@ struct NativeChatView: View { |
| // The feed column: the web's chat header (avatar, title, ⋮ with pin/close) |
| // above the messages. |
| - private var feedColumn: some View { |
| - VStack(spacing: 0) { |
| - if let thread = selectedThread { |
| - feedHeader(thread) |
| - Divider() |
| - } |
| + @ViewBuilder private var feedColumn: some View { |
| + if let thread = selectedThread, !thread.isApp, !isCompanion(thread) { |
| + // Plain chats: the header rides OVER the feed as a safe-area inset, |
| + // frosted with a within-window blur, so messages visibly scroll |
| + // beneath it — Messages.app's header treatment (see FrostedBand). |
| feed |
| + .safeAreaInset(edge: .top, spacing: 0) { |
| + VStack(spacing: 0) { |
| + feedHeader(thread) |
| + Divider() |
| + } |
| + .background(FrostedBand()) |
| + } |
| + } else { |
| + // App/companion surfaces host webviews that fill their frame (an |
| + // NSView doesn't honor safe areas), so their header stays a |
| + // stacked band above the pane. |
| + VStack(spacing: 0) { |
| + if let thread = selectedThread { |
| + feedHeader(thread) |
| + Divider() |
| + } |
| + feed |
| + } |
| } |
| } |
diff --git a/ios/LChatMac/NativeFeedView.swift b/ios/LChatMac/NativeFeedView.swift| index 4f8f445..14cf47a 100644 |
| --- a/ios/LChatMac/NativeFeedView.swift |
| +++ b/ios/LChatMac/NativeFeedView.swift |
| @@ -93,7 +93,10 @@ 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 = "" |
| - @FocusState private var composerFocused: Bool |
| + @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 |
| // Attachments staged on the draft: picked → uploading (spinner) → ready. |
| // Several can ride one message, like the web's pending list. |
| private struct Staged: Identifiable { |
| @@ -680,26 +683,39 @@ struct NativeFeedView: View { |
| } |
| } |
| .frame(maxWidth: .infinity) |
| - // iMessage-style: the input row rides ONE translucent band, so the |
| - // feed shows through it as material blur instead of fully-legible |
| - // text (worst in dark mode). Only the row — the typeahead/banners |
| - // above stay transparent floaters. |
| - .background(.bar) |
| + // 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. |
| + .background(FrostedBand()) |
| } |
| .onChange(of: draft) { _, text in |
| - // 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 |
| + refreshTypeahead() |
| // 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 |
| @@ -808,82 +824,79 @@ struct NativeFeedView: View { |
| Button(action: pickFiles) { |
| Image(systemName: "plus.circle.fill") |
| .font(.system(size: 28)) // same weight as the send button |
| - .foregroundStyle(.tertiary) |
| + // Messages.app: a real glyph on the disc — white in |
| + // dark mode, dark grey in light — not the knocked-out |
| + // transparent plus the monochrome fill gave. |
| + .symbolRenderingMode(.palette) |
| + .foregroundStyle(scheme == .dark ? Color.white : Color(white: 0.3), |
| + .tertiary) |
| } |
| .buttonStyle(.plain) |
| .padding(.bottom, 4) |
| } |
| - 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) |
| + // 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 |
| // Queued behind an upload = the message is committed; editing |
| // what's about to auto-send would race it. |
| - .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) { |
| + isDisabled: sendQueued, |
| + onSend: sendDraft, |
| + onMoveUp: { |
| if !taItems.isEmpty { |
| taIndex = (taIndex + taItems.count - 1) % taItems.count |
| - return .handled |
| + return true |
| } |
| if draft.isEmpty, editingID == nil, |
| let last = model.messages.last(where: { $0.mine && !$0.action }) { |
| startEdit(last) |
| - return .handled |
| + return true |
| } |
| - return .ignored |
| - } |
| - .onKeyPress(.downArrow) { |
| - guard !taItems.isEmpty else { return .ignored } |
| + return false |
| + }, |
| + onMoveDown: { |
| + guard !taItems.isEmpty else { return false } |
| taIndex = (taIndex + 1) % taItems.count |
| - return .handled |
| - } |
| - .onKeyPress(.tab) { |
| - guard !taItems.isEmpty, let token = taToken else { return .ignored } |
| + return true |
| + }, |
| + onAccept: { |
| + guard !taItems.isEmpty, let token = taToken else { return false } |
| pick(taItems[taIndex], token: token) |
| - 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 } |
| + return true |
| + }, |
| + onEscape: { |
| + guard !taItems.isEmpty else { return false } |
| taToken = nil |
| taItems = [] |
| - return .handled |
| - } |
| - .focused($composerFocused) |
| + 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)) |
| // 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 |
| @@ -926,7 +939,9 @@ struct NativeFeedView: View { |
| .disabled(!hasContent) |
| .padding(.bottom, 4) |
| } |
| - .padding(EdgeInsets(top: 8, leading: 10, bottom: 6, trailing: 10)) |
| + // Messages.app gives the input pill real breathing room below — the |
| + // old 6pt read as cramped against the window edge. |
| + .padding(EdgeInsets(top: 8, leading: 10, bottom: 12, trailing: 10)) |
| } |
| private var hasContent: Bool { |
| @@ -1215,27 +1230,24 @@ struct NativeFeedView: View { |
| } |
| } |
| - // 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) |
| - } |
| + // 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 |
| } |
| } |
| + 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) { |
diff --git a/ios/LChatMac/RootView.swift b/ios/LChatMac/RootView.swift| index 95d3657..618af76 100644 |
| --- a/ios/LChatMac/RootView.swift |
| +++ b/ios/LChatMac/RootView.swift |
| @@ -65,13 +65,28 @@ struct RootView: View { |
| // A rail entry with no native identity: signing in again pulls the key |
| // from the member's Eleven account; there is deliberately no web fallback. |
| + // Sign-in is the pane's PRIMARY action — the copy tells you to sign in, so |
| + // the button must be right here, not hidden in Settings (removal stays as |
| + // the destructive escape hatch). |
| private func lockedPane(_ chat: SavedChat) -> some View { |
| ContentUnavailableView { |
| Label(app.serverName(chat), systemImage: "lock") |
| } description: { |
| Text("This space can't be unlocked on this Mac. Sign in with Eleven to bring its key back, or remove it from the rail.") |
| } actions: { |
| + if firstRunSignIn.phase.busy { |
| + ProgressView().controlSize(.small) |
| + } else { |
| + Button("Sign In with Eleven…") { |
| + Task { await firstRunSignIn.signIn(into: app) } |
| + } |
| + .buttonStyle(.borderedProminent) |
| + } |
| Button("Remove from This Mac", role: .destructive) { app.remove(chat) } |
| + if case .failed(let msg) = firstRunSignIn.phase { |
| + Text(msg).font(.footnote).foregroundStyle(.red) |
| + .frame(maxWidth: 420) |
| + } |
| } |
| } |
diff --git a/test/format.test.mjs b/test/format.test.mjs| index a9aa3c1..83301c6 100644 |
| --- a/test/format.test.mjs |
| +++ b/test/format.test.mjs |
| @@ -177,6 +177,22 @@ await test("richTokens: bold beats italic at a double asterisk", () => { |
| assert.deepEqual(richTokens("**b**"), [{ text: "b", mark: "b" }]); |
| }); |
| +await test("richTokens: triple asterisk is combined bold italic", () => { |
| + assert.deepEqual(richTokens("***both***"), [{ text: "both", mark: "bi" }]); |
| + assert.deepEqual(richTokens("a ***b c*** d"), [ |
| + { text: "a " }, |
| + { text: "b c", mark: "bi" }, |
| + { text: " d" }, |
| + ]); |
| + // Neighbours don't bleed: bi next to bold, and markers still can't span lines. |
| + assert.deepEqual(richTokens("***x*** **y**"), [ |
| + { text: "x", mark: "bi" }, |
| + { text: " " }, |
| + { text: "y", mark: "b" }, |
| + ]); |
| + assert.deepEqual(richTokens("***a\nb***"), [{ text: "***a\nb***" }]); |
| +}); |
| + |
| await test("richTokens: mixes plain text, a mark, and a link", () => { |
| const t = richTokens("hi *there* see https://x.com"); |
| assert.deepEqual(t[0], { text: "hi " }); |
diff --git a/web/static/apps.js b/web/static/apps.js| index ca3b965..3b27653 100644 |
| --- a/web/static/apps.js |
| +++ b/web/static/apps.js |
| @@ -1303,6 +1303,13 @@ function renderNoticeBody(text, el) { |
| a.rel = "noopener noreferrer"; |
| a.textContent = tok.text; |
| el.appendChild(a); |
| + } else if (tok.mark === "bi") { |
| + // ***bold italic*** — the one combined mark: em nested inside strong. |
| + const b = document.createElement("strong"); |
| + const i = document.createElement("em"); |
| + i.textContent = tok.text; |
| + b.appendChild(i); |
| + el.appendChild(b); |
| } else if (tok.mark) { |
| const w = document.createElement(NOTICE_MARK_TAG[tok.mark]); |
| w.textContent = tok.text; |
diff --git a/web/static/apps/chat.js b/web/static/apps/chat.js| index 2ebd37b..406587b 100644 |
| --- a/web/static/apps/chat.js |
| +++ b/web/static/apps/chat.js |
| @@ -1603,6 +1603,13 @@ function renderRichText(el, text) { |
| a.rel = "noopener noreferrer"; |
| a.textContent = tok.text; |
| target.appendChild(a); |
| + } else if (tok.mark === "bi") { |
| + // ***bold italic*** — the one combined mark: em nested inside strong. |
| + const b = document.createElement("strong"); |
| + const i = document.createElement("em"); |
| + i.textContent = tok.text; |
| + b.appendChild(i); |
| + target.appendChild(b); |
| } else if (tok.mark) { |
| const w = document.createElement(MARK_TAG[tok.mark]); |
| w.textContent = tok.text; |
diff --git a/web/static/format.js b/web/static/format.js| index fe4ffe5..df2a8d2 100644 |
| --- a/web/static/format.js |
| +++ b/web/static/format.js |
| @@ -67,12 +67,13 @@ export function tokenizeText(text) { |
| return tokens; |
| } |
| -// Inline formatting markers (single level, no nesting): |
| -// `code` **bold** *italic* _italic_ ~strike~ |
| +// Inline formatting markers (single level, no nesting — except the one |
| +// combined form, ***bold italic***, which is its own marker, not nesting): |
| +// `code` ***bold italic*** **bold** *italic* _italic_ ~strike~ |
| // Underscore italics require word boundaries so file_names don't italicize. |
| // (Hyphens are deliberately NOT a marker — they appear too often in normal text, |
| // e.g. dates and "well-known", to be a safe delimiter.) |
| -const FORMAT_RE = /`([^`\n]+)`|\*\*([^*\n]+)\*\*|\*([^*\n]+)\*|(?<![\w])_([^_\n]+)_(?![\w])|~([^~\n]+)~/g; |
| +const FORMAT_RE = /`([^`\n]+)`|\*\*\*([^*\n]+)\*\*\*|\*\*([^*\n]+)\*\*|\*([^*\n]+)\*|(?<![\w])_([^_\n]+)_(?![\w])|~([^~\n]+)~/g; |
| function formatSegment(text, out) { |
| let last = 0; |
| @@ -80,10 +81,11 @@ function formatSegment(text, out) { |
| if (m.index > last) out.push({ text: text.slice(last, m.index) }); |
| let mark, content; |
| if (m[1] != null) [mark, content] = ["code", m[1]]; |
| - else if (m[2] != null) [mark, content] = ["b", m[2]]; |
| - else if (m[3] != null) [mark, content] = ["i", m[3]]; |
| + else if (m[2] != null) [mark, content] = ["bi", m[2]]; |
| + else if (m[3] != null) [mark, content] = ["b", m[3]]; |
| else if (m[4] != null) [mark, content] = ["i", m[4]]; |
| - else [mark, content] = ["s", m[5]]; |
| + else if (m[5] != null) [mark, content] = ["i", m[5]]; |
| + else [mark, content] = ["s", m[6]]; |
| out.push({ text: content, mark }); |
| last = m.index + m[0].length; |
| } |
| @@ -94,7 +96,7 @@ function formatSegment(text, out) { |
| // inline formatting: |
| // { text } → plain text |
| // { text, href } → a link (http(s):// only) — never formatted inside |
| -// { text, mark } → formatted run; mark ∈ b | i | s | code |
| +// { text, mark } → formatted run; mark ∈ b | i | bi | s | code |
| // Links take precedence (we don't format inside a URL). Pure + flat (no nesting) |
| // so shell.js builds DOM with text nodes + a single wrapper element each — no |
| // innerHTML, so it stays XSS-safe. |