Skip to content

Back MessageList with NSTextView for cross-row selection#2

Merged
buggerman merged 2 commits intoselectable-chat-textfrom
nstextview-buffer
Apr 24, 2026
Merged

Back MessageList with NSTextView for cross-row selection#2
buggerman merged 2 commits intoselectable-chat-textfrom
nstextview-buffer

Conversation

@buggerman
Copy link
Copy Markdown
Owner

@buggerman buggerman commented Apr 24, 2026

Summary

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 .bryggaMessageID attributed-string key tags each paragraph range so the menu handler can recover the clicked Message. Contains LinkPreviewAttachmentCell: 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 uses withObservationTracking on LinkPreviewStore.cache; image bytes are fetched once per Coordinator lifetime with URLSession.ephemeral (10 s timeout, 2 MB cap, http/https only).
  • Sources/Brygga/Views/ContentView.swift:
    • MessageList body reduced to a MessageBufferView(messages: visibleMessages, …, linkPreviews: appState.linkPreviews) call. visibleMessages filter logic unchanged. Marker index replaced with effectiveLastReadID passed through.
    • ServerMessageList body reduced similarly.
    • Both lists now depend on @Environment(AppState.self) to reach appState.linkPreviews and on @AppStorage(linkPreviewsEnabled) for the toggle.
    • Deleted: MessageRow, LineMarker, LinkPreviewView, firstPreviewableURL, _linkDetector — all dead after the swap. Net: −305 LOC in ContentView.swift.

What AppKit gives us for free

  • Drag-select across rows — the thing that was missing in Make chat content selectable and add Copy context menu #1.
  • ⌘A to select the whole buffer.
  • Native ⌘C copy of the selection as plain text.
  • Native incremental Find (⌘F) inside the NSTextView, in addition to the existing SwiftUI FindBar which 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

  • Highlight lines render as an accent-colored .backgroundColor range; the 2pt accent-gutter stripe from the old MessageRow is gone — acceptable visual simplification, can be restored later via a paragraph-block border if the maintainer wants it back.
  • The "new" divider is a centered "── new ──" paragraph inserted after the last-read message.
  • Nick colors round-trip to NSColor via NSColor(Color) — the shared NickColor.color(for:) palette still drives both.
  • Scroll pinning follows mIRC: append scrolls to the bottom only if the user was pinned there before the append; otherwise their scroll position is left alone.
  • Link preview cards are indented under the message body (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 @objc on new APIs. This change uses AppKit classes (NSTextView, NSScrollView, NSFont, NSColor, NSMenu, NSTextAttachment, NSTextAttachmentCell, NSBezierPath) — already the pattern elsewhere in the repo (see BryggaApp.swift, PreferencesView.swift). There is one @objc private func copyAction(_:) inside the private Coordinator; it's a selector callback required by NSMenuItem'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 on NSTextView's default context menu alone (the native Copy on a selection still works).

Test plan

  • swift build — passes
  • swift test88 tests pass (unchanged; all existing tests still green, MessageBufferView has no new unit tests because it's view-level AppKit code)
  • swiftformat --lint . — clean
  • Scripts/build-app.sh — builds build/Brygga.app fine
  • Manual (required, I cannot drive the UI from here):
    • Point Brygga at a busy channel (local ergo, Libera, whichever).
    • Drag across several messages spanning multiple rows. ⌘C. Paste into TextEdit. Confirm a multi-line log-style block is on the clipboard — this is the thing that was broken in Make chat content selectable and add Copy context menu #1.
    • ⌘A inside the chat buffer → select all → ⌘C → paste. Confirm the whole visible buffer comes out as plain text.
    • Right-click a message → verify Copy Message, Copy Text, Copy Nickname each place the expected string on the clipboard (unchanged behaviour from Make chat content selectable and add Copy context menu #1, now served by the NSTextView menu).
    • Paste a URL with OG tags (e.g. a news article) into a channel — confirm the preview card appears under the message once the fetch completes, with thumbnail + site / title / summary. Click the card → URL opens.
    • Paste a direct-image URL (e.g. https://i.imgur.com/abcd.png) — confirm it renders as an inline image capped at 420×240.
    • Click a URL in a message — the link opens in the default browser.
    • Type in a busy channel — confirm auto-scroll follows new messages when the user is at the bottom, but does not snap back if the user scrolled up to read history.
    • Set a highlight keyword in Preferences and trigger a highlight — confirm the highlighted line has an accent-tinted background.
    • Leave a channel and come back — confirm the "── new ──" divider appears above the first unread message.
    • Repeat the drag-select test in the server console tab and in a detached channel window (⌘⇧D).
    • Toggle Show joins/parts in Preferences — verify presence traffic hides/shows; no crash on full rebuild.
    • Toggle Colorize nicknames and switch Timestamp format — verify the buffer re-renders with the new attributes.
    • Toggle Fetch link previews off — confirm existing cards stay visible from cache but new URLs produce no card. Toggle back on — new URLs fetch.

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.

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 buggerman merged commit 8cff7f7 into selectable-chat-text Apr 24, 2026
buggerman added a commit that referenced this pull request Apr 24, 2026
Back MessageList with NSTextView (re-target of #2 to main)
@buggerman buggerman deleted the nstextview-buffer branch April 24, 2026 21:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant