From e04787789effbb74a1a755df89ef6de8bbf83e85 Mon Sep 17 00:00:00 2001 From: Lawrence Chen <54008264+lawrencecchen@users.noreply.github.com> Date: Thu, 5 Mar 2026 22:38:10 -0800 Subject: [PATCH] Fix markdown panel text click focus (#991) * Add markdown click regression test * Fix markdown panel click focus * Preserve markdown text selection * Make markdown observer tests deterministic --- Sources/Panels/MarkdownPanelView.swift | 74 ++++++++++++- cmuxTests/CmuxWebViewKeyEquivalentTests.swift | 104 ++++++++++++++++++ 2 files changed, 175 insertions(+), 3 deletions(-) diff --git a/Sources/Panels/MarkdownPanelView.swift b/Sources/Panels/MarkdownPanelView.swift index b3b7a971..b96325db 100644 --- a/Sources/Panels/MarkdownPanelView.swift +++ b/Sources/Panels/MarkdownPanelView.swift @@ -1,3 +1,4 @@ +import AppKit import SwiftUI import MarkdownUI @@ -30,9 +31,12 @@ struct MarkdownPanelView: View { .padding(FocusFlashPattern.ringInset) .allowsHitTesting(false) } - .contentShape(Rectangle()) - .onTapGesture { - onRequestPanelFocus() + .overlay { + if isVisibleInUI { + // Observe left-clicks without intercepting them so markdown text + // selection and link activation continue to use the native path. + MarkdownPointerObserver(onPointerDown: onRequestPanelFocus) + } } .onChange(of: panel.focusFlashToken) { _ in triggerFocusFlashAnimation() @@ -283,3 +287,67 @@ struct MarkdownPanelView: View { } } } + +private struct MarkdownPointerObserver: NSViewRepresentable { + let onPointerDown: () -> Void + + func makeNSView(context: Context) -> MarkdownPanelPointerObserverView { + let view = MarkdownPanelPointerObserverView() + view.onPointerDown = onPointerDown + return view + } + + func updateNSView(_ nsView: MarkdownPanelPointerObserverView, context: Context) { + nsView.onPointerDown = onPointerDown + } +} + +final class MarkdownPanelPointerObserverView: NSView { + var onPointerDown: (() -> Void)? + private var eventMonitor: Any? + + override var mouseDownCanMoveWindow: Bool { false } + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + installEventMonitorIfNeeded() + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + if let eventMonitor { + NSEvent.removeMonitor(eventMonitor) + } + } + + override func hitTest(_ point: NSPoint) -> NSView? { + nil + } + + func shouldHandle(_ event: NSEvent) -> Bool { + guard event.type == .leftMouseDown, + let window, + event.window === window, + !isHiddenOrHasHiddenAncestor else { return false } + let point = convert(event.locationInWindow, from: nil) + return bounds.contains(point) + } + + func handleEventIfNeeded(_ event: NSEvent) -> NSEvent { + guard shouldHandle(event) else { return event } + DispatchQueue.main.async { [weak self] in + self?.onPointerDown?() + } + return event + } + + private func installEventMonitorIfNeeded() { + guard eventMonitor == nil else { return } + eventMonitor = NSEvent.addLocalMonitorForEvents(matching: [.leftMouseDown]) { [weak self] event in + self?.handleEventIfNeeded(event) ?? event + } + } +} diff --git a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift index 71d7ed96..3226755f 100644 --- a/cmuxTests/CmuxWebViewKeyEquivalentTests.swift +++ b/cmuxTests/CmuxWebViewKeyEquivalentTests.swift @@ -10830,6 +10830,110 @@ final class FileDropOverlayViewTests: XCTestCase { } } +@MainActor +final class MarkdownPanelPointerObserverViewTests: XCTestCase { + private func makeWindow() -> NSWindow { + let window = NSWindow( + contentRect: NSRect(x: 0, y: 0, width: 320, height: 180), + styleMask: [.titled, .closable], + backing: .buffered, + defer: false + ) + window.makeKeyAndOrderFront(nil) + window.displayIfNeeded() + window.contentView?.layoutSubtreeIfNeeded() + return window + } + + private func makeMouseEvent( + type: NSEvent.EventType, + location: NSPoint, + window: NSWindow, + eventNumber: Int = 1 + ) -> NSEvent { + guard let event = NSEvent.mouseEvent( + with: type, + location: location, + modifierFlags: [], + timestamp: ProcessInfo.processInfo.systemUptime, + windowNumber: window.windowNumber, + context: nil, + eventNumber: eventNumber, + clickCount: 1, + pressure: 1.0 + ) else { + fatalError("Expected to create mouse event") + } + return event + } + + func testObserverTriggersFocusForVisibleLeftClickInsideBounds() { + let window = makeWindow() + defer { window.orderOut(nil) } + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let overlay = MarkdownPanelPointerObserverView(frame: contentView.bounds) + overlay.autoresizingMask = [.width, .height] + let focusExpectation = expectation(description: "observer forwards focus callback") + var pointerDownCount = 0 + overlay.onPointerDown = { + pointerDownCount += 1 + focusExpectation.fulfill() + } + contentView.addSubview(overlay) + + _ = overlay.handleEventIfNeeded( + makeMouseEvent(type: .leftMouseDown, location: NSPoint(x: 60, y: 60), window: window) + ) + wait(for: [focusExpectation], timeout: 1.0) + + XCTAssertEqual(pointerDownCount, 1) + } + + func testObserverIgnoresOutsideOrForeignWindowClicks() { + let window = makeWindow() + defer { window.orderOut(nil) } + let otherWindow = makeWindow() + defer { otherWindow.orderOut(nil) } + guard let contentView = window.contentView else { + XCTFail("Expected content view") + return + } + + let overlay = MarkdownPanelPointerObserverView(frame: contentView.bounds) + overlay.autoresizingMask = [.width, .height] + let noFocusExpectation = expectation(description: "observer ignores invalid clicks") + noFocusExpectation.isInverted = true + var pointerDownCount = 0 + overlay.onPointerDown = { + pointerDownCount += 1 + noFocusExpectation.fulfill() + } + contentView.addSubview(overlay) + + _ = overlay.handleEventIfNeeded( + makeMouseEvent(type: .leftMouseDown, location: NSPoint(x: 400, y: 400), window: window) + ) + _ = overlay.handleEventIfNeeded( + makeMouseEvent(type: .leftMouseDown, location: NSPoint(x: 60, y: 60), window: otherWindow, eventNumber: 2) + ) + _ = overlay.handleEventIfNeeded( + makeMouseEvent(type: .leftMouseDragged, location: NSPoint(x: 60, y: 60), window: window, eventNumber: 3) + ) + wait(for: [noFocusExpectation], timeout: 0.1) + + XCTAssertEqual(pointerDownCount, 0) + } + + func testObserverDoesNotParticipateInHitTesting() { + let overlay = MarkdownPanelPointerObserverView(frame: NSRect(x: 0, y: 0, width: 200, height: 100)) + XCTAssertNil(overlay.hitTest(NSPoint(x: 40, y: 30))) + } +} + final class BrowserLinkOpenSettingsTests: XCTestCase { private var suiteName: String! private var defaults: UserDefaults!