mac: highlight the clicked chat row instantly (#912)
* mac: highlight the clicked chat row instantly, decouple pane build Selection highlight and the right-hand pane rebuild committed in one SwiftUI transaction, so the row didn't light up until the (expensive) feed fold or webview build finished — every chat switch read as lag. The pane's thread id now follows the selection one runloop turn behind, letting the highlight paint in its own cheap frame first. Rows also now light up on mouse-DOWN like NSTableView (the selection fill/ring moved into the button styles, which see isPressed), instead of waiting for the Button action on release. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * mac: commit selection on mouse-down, not a pressed tint The pressed-state tint lit the new row while the old row's selected fill stayed until mouse-up — two highlighted rows for the length of a click. NSTableView's real behaviour is that selection MOVES on mouse-down, so do that: the style fires onPress when isPressed flips, the old row un-fills in the same frame the new one lights, and only one row is ever highlighted. The Button action keeps the same selectThread path for keyboard/AX activation, which never presses. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> ---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 file changed,
+60
−20
ios/LChatMac/NativeChatView.swift+60 −20
diff --git a/ios/LChatMac/NativeChatView.swift b/ios/LChatMac/NativeChatView.swift| index f3431a1..854d2ed 100644 |
| --- a/ios/LChatMac/NativeChatView.swift |
| +++ b/ios/LChatMac/NativeChatView.swift |
| @@ -14,6 +14,11 @@ struct NativeChatView: View { |
| var isActive = true |
| @StateObject private var store: ThreadStore |
| @State private var selectedID: String? |
| + // The right pane's thread id. It follows `selectedID` one runloop turn |
| + // behind: swapping the pane (a feed fold or a webview build) in the same |
| + // transaction as the click held the row highlight back until the pane |
| + // finished building, which made every chat switch read as lag. |
| + @State private var paneID: String? |
| @State private var showDiscover = false |
| // One instance-wide socket keeps the SIDEBAR live: the hub fans every |
| // thread's frames to us anyway, so any stored-message activity just |
| @@ -159,6 +164,11 @@ struct NativeChatView: View { |
| dragZones.isActive = isActive |
| installWindowDragMonitor() |
| } |
| + // The hop that decouples click feedback from pane cost: selection |
| + // commits (and paints) first, the pane swap lands next turn. |
| + .onChange(of: selectedID) { _, id in |
| + DispatchQueue.main.async { paneID = id } |
| + } |
| .onChange(of: isActive) { _, active in |
| dragZones.isActive = active |
| if active { |
| @@ -282,7 +292,7 @@ struct NativeChatView: View { |
| } |
| private var selectedThread: ChatThread? { |
| - store.threads.first { $0.id == selectedID } |
| + store.threads.first { $0.id == paneID } |
| } |
| // ---- the resize handle + collapsed rail --------------------------------- |
| @@ -313,8 +323,7 @@ struct NativeChatView: View { |
| VStack(spacing: 8) { |
| ForEach(visibleThreads) { thread in |
| Button { |
| - selectedID = thread.id |
| - store.markRead(thread) |
| + selectThread(thread) |
| } label: { |
| ThreadAvatar(title: thread.title, source: thread.avatar, photo: thread.isEleven ? ElevenBrand.icon : nil, glyph: thread.glyph, symbol: AppIdentity.symbol(for: thread.appId), mark: AppIdentity.mark(for: thread.appId)) |
| .overlay(alignment: .topTrailing) { |
| @@ -325,12 +334,6 @@ struct NativeChatView: View { |
| theme.sidebar.resolved(scheme), lineWidth: 2)) |
| } |
| } |
| - .overlay { |
| - if thread.id == selectedID { |
| - Circle().strokeBorder(theme.accent.resolved(scheme), lineWidth: 2) |
| - .padding(-3) |
| - } |
| - } |
| // The avatar itself is click-transparent (its tap |
| // gesture is lightbox-only), so the BUTTON needs |
| // its own hit shape — without one, a label made |
| @@ -338,7 +341,9 @@ struct NativeChatView: View { |
| // button unclickable. |
| .contentShape(Circle()) |
| } |
| - .buttonStyle(NoPressDimButtonStyle()) |
| + .buttonStyle(RailRowButtonStyle(selected: thread.id == selectedID, |
| + ring: theme.accent.resolved(scheme), |
| + onPress: { selectThread(thread) })) |
| .help(thread.title) |
| .contextMenu { threadMenu(thread) } |
| } |
| @@ -1276,11 +1281,17 @@ struct NativeChatView: View { |
| filterApps = true |
| } |
| + // Selecting is one act whether it arrives on mouse-down (the style's |
| + // onPress) or on the Button's release-time action (keyboard/AX). |
| + private func selectThread(_ t: ChatThread) { |
| + selectedID = t.id |
| + store.markRead(t) |
| + store.reopenIfHidden(t) // opening a closed chat from search reopens it |
| + } |
| + |
| private func threadRow(_ t: ChatThread) -> some View { |
| Button { |
| - selectedID = t.id |
| - store.markRead(t) |
| - store.reopenIfHidden(t) // opening a closed chat from search reopens it |
| + selectThread(t) |
| } label: { |
| HStack(spacing: 10) { |
| ThreadAvatar(title: t.title, source: t.avatar, photo: t.isEleven ? ElevenBrand.icon : nil, glyph: t.glyph, symbol: AppIdentity.symbol(for: t.appId), mark: AppIdentity.mark(for: t.appId)) |
| @@ -1330,11 +1341,10 @@ struct NativeChatView: View { |
| } |
| .padding(8) |
| .frame(maxWidth: .infinity, alignment: .leading) |
| - .background(RoundedRectangle(cornerRadius: 10, style: .continuous) |
| - .fill(t.id == selectedID ? Color.accentColor.opacity(0.16) : .clear)) |
| .contentShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) |
| } |
| - .buttonStyle(NoPressDimButtonStyle()) |
| + .buttonStyle(ThreadRowButtonStyle(selected: t.id == selectedID, |
| + onPress: { selectThread(t) })) |
| .contextMenu { threadMenu(t) } |
| } |
| @@ -1599,11 +1609,41 @@ private struct GroupThreadSurface: View { |
| } |
| } |
| -// Chat-index buttons (rows, rail avatars): selection is the only highlight. |
| -// The stock .plain style dims the label on mousedown, which reads as a |
| -// translucency flicker on every chat switch — this style just doesn't. |
| -struct NoPressDimButtonStyle: ButtonStyle { |
| +// Chat-index buttons (rows, rail avatars): selection COMMITS on mouse-DOWN, |
| +// like NSTableView — the style is the one place that sees the press, so it |
| +// fires `onPress` the moment isPressed flips. Moving the selection itself |
| +// (not just a pressed tint) keeps exactly one row highlighted: the old row |
| +// un-fills in the same frame the new one lights. The Button's action still |
| +// runs on release for keyboard/AX activation, which never sets isPressed. |
| +// (These also deliberately skip .plain's mousedown dim, which read as a |
| +// translucency flicker on every chat switch.) |
| +struct ThreadRowButtonStyle: ButtonStyle { |
| + let selected: Bool |
| + let onPress: () -> Void |
| + func makeBody(configuration: Configuration) -> some View { |
| + configuration.label |
| + .background(RoundedRectangle(cornerRadius: 10, style: .continuous) |
| + .fill(selected ? Color.accentColor.opacity(0.16) : .clear)) |
| + .onChange(of: configuration.isPressed) { _, pressed in |
| + if pressed { onPress() } |
| + } |
| + } |
| +} |
| + |
| +// The rail posture's accent ring, selection-committed the same way. |
| +struct RailRowButtonStyle: ButtonStyle { |
| + let selected: Bool |
| + let ring: Color |
| + let onPress: () -> Void |
| func makeBody(configuration: Configuration) -> some View { |
| configuration.label |
| + .overlay { |
| + if selected { |
| + Circle().strokeBorder(ring, lineWidth: 2).padding(-3) |
| + } |
| + } |
| + .onChange(of: configuration.isPressed) { _, pressed in |
| + if pressed { onPress() } |
| + } |
| } |
| } |