Back MessageList with NSTextView for cross-row selection#2
Merged
buggerman merged 2 commits intoselectable-chat-textfrom Apr 24, 2026
Merged
Back MessageList with NSTextView for cross-row selection#2buggerman merged 2 commits intoselectable-chat-textfrom
buggerman merged 2 commits intoselectable-chat-textfrom
Conversation
SwiftUI's .textSelection(.enabled) only permits selection within a
single Text view — drag-selecting across chat messages was impossible.
Replace the LazyVStack { MessageRow } rendering in MessageList and
ServerMessageList with a new MessageBufferView: NSViewRepresentable
wrapping NSScrollView + a non-editable NSTextView.
What you now get for free from AppKit:
- True drag-selection across any contiguous range of the buffer, across
rows and message kinds.
- Cmd+A to select the whole buffer.
- Native Cmd+C copy of the selection as plain text.
- Native incremental Find via Cmd+F (in addition to the existing
SwiftUI FindBar, which continues to work as a channel filter).
- Right-click context menu with Look Up / Search / Share plus our
Copy Message / Copy Text / Copy Nickname items.
Rendering in the coordinator mirrors the prior SwiftUI output:
timestamp + sender + IRC-styled content, highlight lines get an accent
background, and a centered "── new ──" paragraph marks the first
unread message. Nick colors round-trip to NSColor via NSColor(Color).
MessageBufferView diff-appends on pure tail extensions and does a full
rebuild when options change (nick-colors toggle, timestamp format,
last-read marker, or message reordering from find-filter). Scroll
pinning follows the classic mIRC rule: if the user was at the bottom
before append, stay at the bottom; otherwise leave their position
alone so they can read history.
The dead SwiftUI views MessageRow, LineMarker, LinkPreviewView, and
the firstPreviewableURL/_linkDetector helpers are removed. Link
previews are not rendered in this build; the existing "Fetch link
previews" preference toggle has no effect until a follow-up restores
inline rendering via NSTextAttachment.
Implementation notes:
- Coordinator is @mainactor; textView and scrollView are weak refs.
- `@objc private func copyAction(_:)` is the one ObjC-adjacent line
required to satisfy NSMenuItem's target-action contract. It is a
private selector callback, not a new public API surface.
- `.bryggaMessageID` attributed-string key tags each message's
paragraph range so the menu handler can recover the clicked Message.
Add a LinkPreviewAttachmentCell subclass of NSTextAttachmentCell that draws the familiar rounded card — thumbnail on the left, site name / title / summary on the right — directly inside the NSTextView buffer. For direct-image URLs (Content-Type image/*) the cell fills the card with the image itself, bounded at 420×240. The card is indented under the message body (firstLineHeadIndent 68) so it lines up beneath the content rather than under the timestamp column. Observation flow: - MessageBufferView takes a LinkPreviewStore? and a linkPreviewsEnabled flag. MessageList and ServerMessageList thread these through from AppState and the existing @AppStorage toggle respectively — the preference toggle works again. - The Coordinator observes `store.cache` via withObservationTracking. When any preview status changes the onChange fires on the main actor, the coordinator reapplies from its cached last inputs, and re-subscribes. - Image bytes are decoded once per Coordinator lifetime into an in-memory NSImage cache keyed by URL. Fetch uses a per-request URLSession.ephemeral with a 10 s timeout and 2 MB cap, matching LinkPreviewStore's own bounds. When an image lands the coordinator reapplies and the cell is redrawn. Clicking anywhere on the preview card follows the URL — the attachment character carries a .link attribute pointing at the preview's source URL. LinkPreviewAttachmentCell is deliberately not @mainactor because NSCell's overridable methods are declared nonisolated in AppKit. The class holds only immutable value-type state (LinkPreview + NSImage?) so nonisolated access is safe. Its layout constants are nonisolated static lets so they can be referenced from the draw path without a main-actor hop.
buggerman
added a commit
that referenced
this pull request
Apr 24, 2026
Back MessageList with NSTextView (re-target of #2 to main)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
LazyVStack { MessageRow }inMessageListandServerMessageListwith a newMessageBufferView: NSViewRepresentablewrappingNSScrollView+ non-editableNSTextView.NSTextAttachmentCellsubclass draws the same thumbnail + title + summary card that the old SwiftUILinkPreviewViewdid, observable-wired toLinkPreviewStore.cacheso it updates when a preview resolves.Changes
Sources/Brygga/Views/MessageBufferView.swift— new file.NSViewRepresentable+Coordinator: NSObject, NSTextViewDelegate. Builds attributed storage per message (timestamp + sender + IRC-styled content), appends or full-rebuilds as appropriate, handles scroll pinning, wires a right-click menu with Copy Message / Copy Text / Copy Nickname. Private.bryggaMessageIDattributed-string key tags each paragraph range so the menu handler can recover the clickedMessage. ContainsLinkPreviewAttachmentCell: NSTextAttachmentCell— draws a compact card with a thumbnail on the left and site/title/summary on the right, or fills the card with a direct-image URL's image. Preview observation useswithObservationTrackingonLinkPreviewStore.cache; image bytes are fetched once per Coordinator lifetime withURLSession.ephemeral(10 s timeout, 2 MB cap, http/https only).Sources/Brygga/Views/ContentView.swift:MessageListbody reduced to aMessageBufferView(messages: visibleMessages, …, linkPreviews: appState.linkPreviews)call.visibleMessagesfilter logic unchanged. Marker index replaced witheffectiveLastReadIDpassed through.ServerMessageListbody reduced similarly.@Environment(AppState.self)to reachappState.linkPreviewsand on@AppStorage(linkPreviewsEnabled)for the toggle.MessageRow,LineMarker,LinkPreviewView,firstPreviewableURL,_linkDetector— all dead after the swap. Net: −305 LOC inContentView.swift.What AppKit gives us for free
⌘Ato select the whole buffer.⌘Ccopy of the selection as plain text.⌘F) inside theNSTextView, in addition to the existing SwiftUIFindBarwhich still works as a channel-wide filter.NSTextView's default right-click menu (Look Up, Search, Share) augmented with our three Copy items.Rendering notes
.backgroundColorrange; the 2pt accent-gutter stripe from the oldMessageRowis gone — acceptable visual simplification, can be restored later via a paragraph-block border if the maintainer wants it back.NSColorviaNSColor(Color)— the sharedNickColor.color(for:)palette still drives both.firstLineHeadIndent: 68) so they line up beneath the content, not beneath the timestamp column. Clicking anywhere on the card follows the URL.Pure-Swift policy
AGENTS.md forbids introducing Obj-C, bridging, or
@objcon new APIs. This change uses AppKit classes (NSTextView,NSScrollView,NSFont,NSColor,NSMenu,NSTextAttachment,NSTextAttachmentCell,NSBezierPath) — already the pattern elsewhere in the repo (seeBryggaApp.swift,PreferencesView.swift). There is one@objc private func copyAction(_:)inside the privateCoordinator; it's a selector callback required byNSMenuItem's target-action contract — not a new public API surface. If that counts as a violation per your reading of the rule, tell me and I'll drop the custom menu items and rely onNSTextView's default context menu alone (the native Copy on a selection still works).Test plan
swift build— passesswift test— 88 tests pass (unchanged; all existing tests still green,MessageBufferViewhas no new unit tests because it's view-level AppKit code)swiftformat --lint .— cleanScripts/build-app.sh— buildsbuild/Brygga.appfineNSTextViewmenu).https://i.imgur.com/abcd.png) — confirm it renders as an inline image capped at 420×240.⌘⇧D).Risk / rollback
Medium. This replaces the hottest UI path with an AppKit-backed view. The scroll pinning, incremental append, full-rebuild paths, and observation/fetch paths are all new code. Revert the commits if any manual check fails; PR #1's per-row selection + Copy menu remains in place independently.