From ffa74d641c2ccc8feceee527432bde802a85a5e3 Mon Sep 17 00:00:00 2001 From: cmux Date: Fri, 6 Mar 2026 13:59:04 -0800 Subject: [PATCH 1/2] Hide help menu popover arrow Replace the SwiftUI .popover with an NSPopover presented via NSViewRepresentable that uses the shouldHideAnchor KVC trick to hide the arrow. The positioning rect is shifted toward the preferred edge to compensate for the hidden arrow's space. Co-Authored-By: Claude Opus 4.6 --- Sources/ContentView.swift | 76 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 74 insertions(+), 2 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 588eba67..1fb28ebe 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -7492,9 +7492,12 @@ private struct SidebarHelpMenuButton: View { } .buttonStyle(SidebarFooterIconButtonStyle()) .frame(width: buttonSize, height: buttonSize, alignment: .center) - .popover(isPresented: $isPopoverPresented, arrowEdge: .bottom) { + .background(ArrowlessPopoverAnchor( + isPresented: $isPopoverPresented, + preferredEdge: .maxY + ) { helpPopover - } + }) .accessibilityElement(children: .ignore) .help(helpTitle) .accessibilityLabel(helpTitle) @@ -7655,6 +7658,75 @@ private struct SidebarHelpMenuButton: View { } } +/// Presents an NSPopover without an arrow using the shouldHideAnchor KVC trick. +private struct ArrowlessPopoverAnchor: NSViewRepresentable { + @Binding var isPresented: Bool + let preferredEdge: NSRectEdge + @ViewBuilder let content: () -> PopoverContent + + func makeNSView(context: Context) -> NSView { + NSView() + } + + func updateNSView(_ nsView: NSView, context: Context) { + if isPresented { + guard context.coordinator.popover == nil else { return } + + let popover = NSPopover() + popover.behavior = .semitransient + popover.animates = true + popover.setValue(true, forKeyPath: "shouldHideAnchor") + popover.contentViewController = NSHostingController(rootView: content()) + popover.delegate = context.coordinator + context.coordinator.popover = popover + + // Show relative to a rect shifted toward the preferred edge to close + // the gap left by the hidden arrow (~13pt arrow height). + let arrowCompensation: CGFloat = 13 + var rect = nsView.bounds + switch preferredEdge { + case .maxY: + rect = NSRect(x: rect.minX, y: rect.maxY - arrowCompensation, width: rect.width, height: arrowCompensation) + case .minY: + rect = NSRect(x: rect.minX, y: rect.minY, width: rect.width, height: arrowCompensation) + case .maxX: + rect = NSRect(x: rect.maxX - arrowCompensation, y: rect.minY, width: arrowCompensation, height: rect.height) + case .minX: + rect = NSRect(x: rect.minX, y: rect.minY, width: arrowCompensation, height: rect.height) + default: break + } + popover.show(relativeTo: rect, of: nsView, preferredEdge: preferredEdge) + } else { + context.coordinator.dismiss() + } + } + + func makeCoordinator() -> Coordinator { + Coordinator(isPresented: $isPresented) + } + + final class Coordinator: NSObject, NSPopoverDelegate { + @Binding var isPresented: Bool + var popover: NSPopover? + + init(isPresented: Binding) { + _isPresented = isPresented + } + + func dismiss() { + popover?.performClose(nil) + popover = nil + } + + func popoverDidClose(_ notification: Notification) { + popover = nil + if isPresented { + isPresented = false + } + } + } +} + private struct SidebarFooterIconButtonStyle: ButtonStyle { func makeBody(configuration: Configuration) -> some View { SidebarFooterIconButtonStyleBody(configuration: configuration) From 0080ee791b421af7176b572b32f963893d93a1bc Mon Sep 17 00:00:00 2001 From: cmux Date: Sat, 7 Mar 2026 18:51:46 -0800 Subject: [PATCH 2/2] Tighten help popover sizing --- Sources/ContentView.swift | 139 ++++++++++++++++++++++++++++++-------- 1 file changed, 109 insertions(+), 30 deletions(-) diff --git a/Sources/ContentView.swift b/Sources/ContentView.swift index 1fb28ebe..711d7ae6 100644 --- a/Sources/ContentView.swift +++ b/Sources/ContentView.swift @@ -7494,7 +7494,8 @@ private struct SidebarHelpMenuButton: View { .frame(width: buttonSize, height: buttonSize, alignment: .center) .background(ArrowlessPopoverAnchor( isPresented: $isPopoverPresented, - preferredEdge: .maxY + preferredEdge: .maxY, + detachedGap: 4 ) { helpPopover }) @@ -7658,44 +7659,27 @@ private struct SidebarHelpMenuButton: View { } } -/// Presents an NSPopover without an arrow using the shouldHideAnchor KVC trick. private struct ArrowlessPopoverAnchor: NSViewRepresentable { @Binding var isPresented: Bool let preferredEdge: NSRectEdge + let detachedGap: CGFloat @ViewBuilder let content: () -> PopoverContent func makeNSView(context: Context) -> NSView { - NSView() + let view = NSView() + context.coordinator.anchorView = view + return view } func updateNSView(_ nsView: NSView, context: Context) { + context.coordinator.anchorView = nsView + context.coordinator.updateRootView(AnyView(content())) + if isPresented { - guard context.coordinator.popover == nil else { return } - - let popover = NSPopover() - popover.behavior = .semitransient - popover.animates = true - popover.setValue(true, forKeyPath: "shouldHideAnchor") - popover.contentViewController = NSHostingController(rootView: content()) - popover.delegate = context.coordinator - context.coordinator.popover = popover - - // Show relative to a rect shifted toward the preferred edge to close - // the gap left by the hidden arrow (~13pt arrow height). - let arrowCompensation: CGFloat = 13 - var rect = nsView.bounds - switch preferredEdge { - case .maxY: - rect = NSRect(x: rect.minX, y: rect.maxY - arrowCompensation, width: rect.width, height: arrowCompensation) - case .minY: - rect = NSRect(x: rect.minX, y: rect.minY, width: rect.width, height: arrowCompensation) - case .maxX: - rect = NSRect(x: rect.maxX - arrowCompensation, y: rect.minY, width: arrowCompensation, height: rect.height) - case .minX: - rect = NSRect(x: rect.minX, y: rect.minY, width: arrowCompensation, height: rect.height) - default: break - } - popover.show(relativeTo: rect, of: nsView, preferredEdge: preferredEdge) + context.coordinator.present( + preferredEdge: preferredEdge, + detachedGap: detachedGap + ) } else { context.coordinator.dismiss() } @@ -7707,12 +7691,54 @@ private struct ArrowlessPopoverAnchor: NSViewRepresentable final class Coordinator: NSObject, NSPopoverDelegate { @Binding var isPresented: Bool - var popover: NSPopover? + + weak var anchorView: NSView? + private let hostingController = NSHostingController(rootView: AnyView(EmptyView())) + private var popover: NSPopover? init(isPresented: Binding) { _isPresented = isPresented } + func updateRootView(_ rootView: AnyView) { + hostingController.rootView = AnyView(rootView.fixedSize()) + hostingController.view.invalidateIntrinsicContentSize() + hostingController.view.layoutSubtreeIfNeeded() + } + + func present(preferredEdge: NSRectEdge, detachedGap: CGFloat) { + guard let anchorView else { + isPresented = false + dismiss() + return + } + + let popover = popover ?? makePopover() + if popover.isShown { + return + } + + hostingController.view.invalidateIntrinsicContentSize() + hostingController.view.layoutSubtreeIfNeeded() + let fittingSize = hostingController.view.fittingSize + if fittingSize.width > 0, fittingSize.height > 0 { + popover.contentSize = NSSize( + width: ceil(fittingSize.width), + height: ceil(fittingSize.height) + ) + } + + popover.show( + relativeTo: positioningRect( + for: anchorView.bounds, + preferredEdge: preferredEdge, + detachedGap: detachedGap + ), + of: anchorView, + preferredEdge: preferredEdge + ) + } + func dismiss() { popover?.performClose(nil) popover = nil @@ -7724,6 +7750,59 @@ private struct ArrowlessPopoverAnchor: NSViewRepresentable isPresented = false } } + + private func makePopover() -> NSPopover { + let popover = NSPopover() + popover.behavior = .semitransient + popover.animates = true + popover.setValue(true, forKeyPath: "shouldHideAnchor") + popover.contentViewController = hostingController + popover.delegate = self + self.popover = popover + return popover + } + + private func positioningRect( + for bounds: CGRect, + preferredEdge: NSRectEdge, + detachedGap: CGFloat + ) -> CGRect { + let hiddenArrowInset: CGFloat = 13 + let compensation = max(hiddenArrowInset - detachedGap, 0) + + switch preferredEdge { + case .maxY: + return NSRect( + x: bounds.minX, + y: bounds.maxY - compensation, + width: bounds.width, + height: compensation + ) + case .minY: + return NSRect( + x: bounds.minX, + y: bounds.minY, + width: bounds.width, + height: compensation + ) + case .maxX: + return NSRect( + x: bounds.maxX - compensation, + y: bounds.minY, + width: compensation, + height: bounds.height + ) + case .minX: + return NSRect( + x: bounds.minX, + y: bounds.minY, + width: compensation, + height: bounds.height + ) + @unknown default: + return bounds + } + } } }