Mac: faces + Open-first in the rail's overflow menu (#499)
* mac: faces + Open-first in the rail's overflow menu Each space row in the … menu carries its face — the server photo circle-cropped to 18pt, else the same name-hashed monogram the notification faces use (MonogramImage) — and the submenu leads with Open (the row's own action, spelled out) before Star/Archive. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * mac: menu faces need titleAndIcon; inert rows (Open is the opener); Esc closes the takeover macOS menus render title-only by default — the face was silently dropped without an explicit labelStyle. The row's primaryAction is gone (hover reveals the submenu; Open is the single opener). Esc cancels the create takeover via an invisible cancel-action button, gated on busy. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * Merge main; drop the invisible-button Esc (superseded by #505's monitor) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> ---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 file changed,
+44
−6
ios/LChatMac/Sidebar.swift+44 −6
diff --git a/ios/LChatMac/Sidebar.swift b/ios/LChatMac/Sidebar.swift| index f839425..d00558a 100644 |
| --- a/ios/LChatMac/Sidebar.swift |
| +++ b/ios/LChatMac/Sidebar.swift |
| @@ -178,19 +178,57 @@ struct Sidebar: View { |
| .railTooltip("More") |
| } |
| - // A space as a menu row: click opens it; the submenu holds star/archive |
| - // (identity-gated, like the icon context menu). |
| + // A space as a menu row — its face beside its name. The row itself is |
| + // INERT (hover/click just reveals the submenu); Open is the only opener, |
| + // then star/archive (identity-gated, like the icon context menu). |
| @ViewBuilder private func chatMenuItem(_ chat: SavedChat) -> some View { |
| if app.canStar(chat) { |
| - Menu(app.serverName(chat)) { |
| + Menu { |
| + Button("Open") { app.selection = chat.id } |
| + Divider() |
| Button(app.isStarred(chat) ? "Unstar" : "Star") { app.toggleStar(chat) } |
| Button(app.isArchived(chat) ? "Unarchive" : "Archive") { app.toggleArchive(chat) } |
| - } primaryAction: { |
| - app.selection = chat.id |
| + } label: { |
| + menuRowLabel(chat) |
| } |
| } else { |
| - Button(app.serverName(chat)) { app.selection = chat.id } |
| + Button { app.selection = chat.id } label: { menuRowLabel(chat) } |
| + } |
| + } |
| + |
| + private func menuRowLabel(_ chat: SavedChat) -> some View { |
| + Label { |
| + Text(app.serverName(chat)) |
| + } icon: { |
| + if let icon = menuIcon(chat) { Image(nsImage: icon) } |
| + } |
| + // macOS menus render title-only by default — the face needs an |
| + // explicit titleAndIcon or it's silently dropped. |
| + .labelStyle(.titleAndIcon) |
| + } |
| + |
| + // A small circular face for the menu row: the server photo, else the same |
| + // monogram the notification faces use (MonogramImage — name-hashed colour, |
| + // first initial). Menus want a fixed-size template-ish image, so draw the |
| + // circle crop here rather than fighting SwiftUI clipping inside NSMenu. |
| + private func menuIcon(_ chat: SavedChat) -> NSImage? { |
| + let source: NSImage? |
| + if let photo = app.avatars[chat.id] { |
| + source = photo |
| + } else if let data = MonogramImage.pngData(name: app.serverName(chat), size: 64) { |
| + source = NSImage(data: data) |
| + } else { |
| + source = nil |
| } |
| + guard let source else { return nil } |
| + let side: CGFloat = 18 |
| + let img = NSImage(size: NSSize(width: side, height: side)) |
| + img.lockFocus() |
| + NSBezierPath(ovalIn: NSRect(x: 0, y: 0, width: side, height: side)).addClip() |
| + source.draw(in: NSRect(x: 0, y: 0, width: side, height: side), |
| + from: .zero, operation: .sourceOver, fraction: 1) |
| + img.unlockFocus() |
| + return img |
| } |