Skip to content

Commit 79213d6

Browse files
committed
Stop message hover interactions beneath command suggestions
1 parent ff389ae commit 79213d6

4 files changed

Lines changed: 115 additions & 6 deletions

File tree

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import Cocoa
2+
3+
func chatLayerPointerIsExposed(_ view: NSView, at point: NSPoint) -> Bool {
4+
guard !view.isHiddenOrHasHiddenAncestor, let contentView = view.window?.contentView,
5+
let hit = contentView.hitTest(view.convert(point, to: contentView.superview)) else {
6+
return false
7+
}
8+
return hit === view || hit.isDescendant(of: view)
9+
}

‎packages/ChatUI/Sources/ChatUI/ChatLayerScene.swift‎

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -634,15 +634,22 @@ class ChatLayerSceneView: View {
634634
return node == nil
635635
}
636636

637+
var hasExposedPointer: Bool {
638+
return lastPointer.map { chatLayerPointerIsExposed(self, at: $0) } ?? false
639+
}
640+
637641
func updateHover(at point: NSPoint?) {
638642
guard let current = storedState else { return }
639643
hoverGeneration += 1
640644
let generation = hoverGeneration
641-
let target = point.flatMap(hitNode)
645+
let isExposed = point.map { chatLayerPointerIsExposed(self, at: $0) } ?? true
646+
let target = isExposed ? point.flatMap(hitNode) : nil
642647
guard storedState === current, hoverGeneration == generation else { return }
643648
let enabledTarget = target?.isEffectivelyEnabled == true ? target : nil
644649
if matchesHoverPath(enabledTarget) {
645-
updatePointerCursor(hovered.reversed().first(where: { $0.cursor != nil })?.cursor ?? .arrow, at: point)
650+
if isExposed {
651+
updatePointerCursor(hovered.reversed().first(where: { $0.cursor != nil })?.cursor ?? .arrow, at: point)
652+
}
646653
return
647654
}
648655
let next = path(to: enabledTarget)
@@ -658,7 +665,9 @@ class ChatLayerSceneView: View {
658665
_ = node.onPointer?(event(.entered, node: node, point: point ?? .zero))
659666
}
660667
guard storedState === current, hoverGeneration == generation else { return }
661-
updatePointerCursor(next.reversed().first(where: { $0.cursor != nil })?.cursor ?? .arrow, at: point)
668+
if isExposed {
669+
updatePointerCursor(next.reversed().first(where: { $0.cursor != nil })?.cursor ?? .arrow, at: point)
670+
}
662671
}
663672

664673
func updatePointerCursor(_ cursor: NSCursor, at point: NSPoint?) {
@@ -674,6 +683,7 @@ class ChatLayerSceneView: View {
674683
lastPointer = point
675684
updateHover(at: point)
676685
guard storedState === current, inputGeneration == generation else { return true }
686+
if kind == .moved, !hasExposedPointer { return false }
677687
if kind == .up {
678688
if blockedButtons.remove(button) != nil { return true }
679689
guard let target = captures.removeValue(forKey: button) else { return false }

‎packages/ChatUI/Sources/ChatUI/ChatLayerTextLinkState.swift‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@ final class ChatLayerTextLinkState: NSObject, NSViewToolTipOwner, Disposable {
2323
init(node: ChatLayerTextNode) { self.node = node }
2424

2525
func view(_ view: NSView, stringForToolTip tag: NSView.ToolTipTag, point: NSPoint, userData data: UnsafeMutableRawPointer?) -> String {
26-
guard let node, let scene = node.scene, view === scene, !scene.isHiddenOrHasHiddenAncestor, node.isVisible, node.isEffectivelyEnabled else { return "" }
26+
guard let node, let scene = node.scene, view === scene, !scene.isHiddenOrHasHiddenAncestor, node.isVisible, node.isEffectivelyEnabled,
27+
chatLayerPointerIsExposed(scene, at: point) else { return "" }
2728
if let toolTip { return toolTip }
2829
guard let layout = node.textLayout,
2930
let link = layout.link(at: node.layer.convert(point, from: scene.root.layer)),
@@ -100,7 +101,7 @@ final class ChatLayerTextLinkState: NSObject, NSViewToolTipOwner, Disposable {
100101

101102
func hover(link: Any, range: NSRange, layout: TextViewLayout) {
102103
guard let node, node.textLayout === layout, let scene = node.scene,
103-
node.isVisible, node.isEffectivelyEnabled, !scene.isHiddenOrHasHiddenAncestor else { return }
104+
node.isVisible, node.isEffectivelyEnabled, !scene.isHiddenOrHasHiddenAncestor, scene.hasExposedPointer else { return }
104105
if text === layout.attributedString, self.range == range { return }
105106
if let text, self.range.location != NSNotFound, self.range.location < text.length,
106107
let previous = text.attribute(.link, at: self.range.location, effectiveRange: nil) {
@@ -116,7 +117,7 @@ final class ChatLayerTextLinkState: NSObject, NSViewToolTipOwner, Disposable {
116117
let timer = SwiftSignalKit.Timer(timeout: 1.0, repeat: false, completion: { [weak self, weak node, weak layout, weak scene] timer in
117118
guard let self, let node, let layout, let scene, self.version == version,
118119
self.timer === timer, node.textLayout === layout else { return }
119-
guard node.scene === scene, node.isVisible, node.isEffectivelyEnabled, !scene.isHiddenOrHasHiddenAncestor,
120+
guard node.scene === scene, node.isVisible, node.isEffectivelyEnabled, !scene.isHiddenOrHasHiddenAncestor, scene.hasExposedPointer,
120121
self.windowId == scene.window.map(ObjectIdentifier.init), self.matches(layout), !contextMenuOnScreen() else {
121122
self.cancel()
122123
return
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import Cocoa
2+
import XCTest
3+
@testable import ChatUI
4+
5+
private final class PointerPassthroughView: NSView {
6+
override func hitTest(_ point: NSPoint) -> NSView? { nil }
7+
}
8+
9+
final class ChatLayerPointerVisibilityTests: XCTestCase {
10+
private var window: NSWindow!
11+
private var host: NSView!
12+
private var scene: NSView!
13+
private let point = NSPoint(x: 20, y: 25)
14+
15+
override func setUp() {
16+
super.setUp()
17+
_ = NSApplication.shared
18+
window = NSWindow(contentRect: NSRect(x: 40, y: 30, width: 400, height: 300), styleMask: .borderless, backing: .buffered, defer: false)
19+
window.isReleasedWhenClosed = false
20+
let content = NSView(frame: NSRect(x: 0, y: 0, width: 400, height: 300))
21+
window.contentView = content
22+
host = NSView(frame: NSRect(x: 31, y: 47, width: 330, height: 230))
23+
content.addSubview(host)
24+
scene = NSView(frame: NSRect(x: 13, y: 17, width: 260, height: 180))
25+
host.addSubview(scene)
26+
}
27+
28+
override func tearDown() {
29+
window.contentView = nil
30+
window.close()
31+
window = nil
32+
host = nil
33+
scene = nil
34+
super.tearDown()
35+
}
36+
37+
private func cover() -> NSView {
38+
let overlay = NSView(frame: scene.frame)
39+
host.addSubview(overlay, positioned: .above, relativeTo: scene)
40+
return overlay
41+
}
42+
43+
func testUncoveredMessageCanReceiveHover() {
44+
XCTAssertTrue(chatLayerPointerIsExposed(scene, at: point))
45+
}
46+
47+
func testSuggestionsCoverMessageEvenWhenItsOwnHitTestSucceeds() {
48+
_ = cover()
49+
XCTAssertTrue(scene.hitTest(scene.convert(point, to: host)) === scene)
50+
XCTAssertFalse(chatLayerPointerIsExposed(scene, at: point))
51+
}
52+
53+
func testCoverAppearingAtStationaryPointerInvalidatesHover() {
54+
XCTAssertTrue(chatLayerPointerIsExposed(scene, at: point))
55+
let overlay = cover()
56+
XCTAssertFalse(chatLayerPointerIsExposed(scene, at: point))
57+
overlay.removeFromSuperview()
58+
XCTAssertTrue(chatLayerPointerIsExposed(scene, at: point))
59+
}
60+
61+
func testHiddenSuggestionsDoNotBlockMessage() {
62+
let overlay = cover()
63+
overlay.isHidden = true
64+
XCTAssertTrue(chatLayerPointerIsExposed(scene, at: point))
65+
}
66+
67+
func testPassthroughOverlayDoesNotBlockMessage() {
68+
host.addSubview(PointerPassthroughView(frame: scene.frame))
69+
XCTAssertTrue(chatLayerPointerIsExposed(scene, at: point))
70+
}
71+
72+
func testNativeChildOfSceneRemainsExposed() {
73+
scene.addSubview(NSView(frame: NSRect(x: 10, y: 10, width: 50, height: 50)))
74+
XCTAssertTrue(chatLayerPointerIsExposed(scene, at: point))
75+
}
76+
77+
func testHiddenAndDetachedMessageCannotReceiveHover() {
78+
scene.isHidden = true
79+
XCTAssertFalse(chatLayerPointerIsExposed(scene, at: point))
80+
scene.isHidden = false
81+
scene.removeFromSuperview()
82+
XCTAssertFalse(chatLayerPointerIsExposed(scene, at: point))
83+
}
84+
85+
func testPointerOutsideClippedHostCannotReceiveHover() {
86+
scene.setFrameSize(NSSize(width: 400, height: 180))
87+
XCTAssertFalse(chatLayerPointerIsExposed(scene, at: NSPoint(x: 350, y: 25)))
88+
}
89+
}

0 commit comments

Comments
 (0)