Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -508,6 +508,16 @@ Lithe 使用 `BranchPopupRowView` 包装原有 SwiftUI 行,保留共享 `Lithe
直接调用 cell 的检查通过,但用户实测仍未出现展开,不能继续把它当作可靠入口。
子窗口接收该行点击并转交原动作,键盘焦点留在父弹窗;滚轮转交原行滚动路径。
离开、打开动作菜单、禁用、脱离窗口及父窗口关闭都清理浮层及父窗口关闭观察器。
触控板手势绑定开始时的窗口,不能收到首个滚动事件就关闭展开层,否则后续手势会丢失。
展开层在真实滚动时隐藏但继续接收事件,将后续手势转交原生滚动区;只是放上手指时保持名称显示。
手势接收对象不依赖旧行存活,因此懒列表滚走并移除原行后仍可继续滚动。它自己拥有父窗口关闭监听,
父弹窗关闭时立即取消回收任务并移除展开层,不等待结束事件或兜底计时。回收闭包不强持有面板,
避免面板、内容视图和闭包互相持有。
隐藏层上的点击必须按屏幕坐标转换后,重新交给父窗口的原生事件派发(由当前命中控件处理);
左键、右键、拖动和松开属于同一次鼠标操作,隐藏层在松开后回收,父窗口提前关闭则立即回收。
例如滚动后原位置已经是分支 B,点击应交给 B,而不是调用已滚走分支 A 的回调,也不能只关闭隐藏层吞掉点击。
指针超出父窗口内容时,不猜测其他应用或全局窗口的目标,不使用全局事件注入。
代价是手势移交期间短暂保留透明子窗口,并需要转换鼠标事件;丢失结束事件仍由有界计时兜底。
只测量当前悬停行,不给空闲行登记全局悬浮几何;展开使用当前分组里的本地名称,
不额外插入 namespace。
行点击仍通过 `LitheDropdownPopover` 打开动作菜单,无障碍按下和键盘按下保留。
Expand All @@ -533,6 +543,9 @@ Stash/Shelf 复用同一边界,独立展开宿主继续测量完整名称。
`BranchSwitcherPopoverBehaviorTests` 检查深浅选中底色、尺寸、无须展开的行、
动作和禁用行为;悬停必须实际创建可见子窗口,检查完整内容、原行位置、
绘制底色、点击转交、焦点及父窗口关闭清理,不能再直接调用展开或绘制方法证明触发。
手势回归还要覆盖移除旧行后完整滚动转发、隐藏层左/右键的按下/拖动/松开转交当前内容、
事件坐标与修饰键保留,以及父窗口关闭时无需执行定时任务就取消回收和解除父子关系。
通过窗口派发的组件事件不能替代完整产品中的真实触控板与惯性滚动验收。
完整预览已核对分支列表、长名称截断和搜索,原生悬浮层与实际菜单的组合
尚未取得可靠的实机截图,不能用组件截图代替最终视觉验收。

Expand Down
71 changes: 60 additions & 11 deletions macos/Sources/Lithe/Views/Git/BranchPopupRowView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -271,7 +271,9 @@ final class BranchPopupRowControl: NSControl {
if isGesture, let panel = detachExpansion() {
// The panel outlives this row, which a lazy list may drop mid-gesture.
overlay.handOffScrollGesture(to: enclosingScrollView ?? nextResponder,
schedule: scheduleExpansionRetirement) { Self.dismiss(panel) }
schedule: scheduleExpansionRetirement) { [weak panel] in
if let panel { Self.dismiss(panel) }
}
}
isHovered = false
refresh()
Expand Down Expand Up @@ -338,8 +340,8 @@ final class BranchPopupRowControl: NSControl {
private final class BranchPopupExpansionView: NSView {
/// Momentum follows lifted fingers almost immediately, but fingers may rest
/// mid-gesture for as long as they like and retiring then loses the gesture.
/// The long delay only bounds a gesture whose end never arrives; a click on
/// the invisible panel retires it sooner.
/// The long delay only bounds a gesture whose end never arrives. A click
/// transfers its full mouse sequence to the current parent content.
private enum RetirementDelay {
static let afterFingersLift: TimeInterval = 0.15
static let whileGestureActive: TimeInterval = 10
Expand All @@ -357,6 +359,13 @@ private final class BranchPopupExpansionView: NSView {
var onScroll: ((NSEvent) -> Void)?
private var handoff: ScrollGestureHandoff?
private var pendingRetirement: DispatchWorkItem?
private var parentCloseObserver: NSObjectProtocol?
private weak var mouseWindow: NSWindow?

deinit {
pendingRetirement?.cancel()
if let parentCloseObserver { NotificationCenter.default.removeObserver(parentCloseObserver) }
}

/// Hides the panel but keeps it receiving the gesture. An alpha 0 window is
/// click-through by default, which loses the gesture just like closing it,
Expand All @@ -366,27 +375,64 @@ private final class BranchPopupExpansionView: NSView {
onPress = nil
onSecondaryPress = nil
onExit = nil
onScroll = nil
window?.ignoresMouseEvents = false
window?.alphaValue = 0
handoff = ScrollGestureHandoff(target: target, schedule: schedule, retire: retire)
if let parent = window?.parent {
parentCloseObserver = NotificationCenter.default.addObserver(
forName: NSWindow.willCloseNotification, object: parent, queue: .main
) { [weak self] _ in MainActor.assumeIsolated { self?.retireHandoff() } }
}
}

private func forward(_ event: NSEvent, using handoff: ScrollGestureHandoff) {
handoff.target?.scrollWheel(with: event)
guard self.handoff != nil, mouseWindow == nil else { return }
let finished: [NSEvent.Phase] = [.ended, .cancelled]
let isActive = finished.contains(event.momentumPhase) ? false
: !event.momentumPhase.isEmpty || !finished.contains(event.phase)
scheduleRetirement(after: isActive ? RetirementDelay.whileGestureActive : RetirementDelay.afterFingersLift)
}

private func scheduleRetirement(after delay: TimeInterval) {
pendingRetirement?.cancel()
let work = DispatchWorkItem { [weak self] in
MainActor.assumeIsolated { self?.retireHandoff() }
}
pendingRetirement = work
let finished: [NSEvent.Phase] = [.ended, .cancelled]
let isActive = finished.contains(event.momentumPhase) ? false
: !event.momentumPhase.isEmpty || !finished.contains(event.phase)
handoff.schedule(isActive ? RetirementDelay.whileGestureActive : RetirementDelay.afterFingersLift, work)
handoff?.schedule(delay, work)
}

/// Re-enter window dispatch so the current hit view, focus and native mouse
/// tracking own the click, not the row that scrolled away. Keep the hidden
/// panel until mouse-up because AppKit may bind this sequence to it too.
private func forwardMouse(_ event: NSEvent, startsSequence: Bool = false, endsSequence: Bool = false) {
guard handoff != nil, let source = window else { return }
if startsSequence {
mouseWindow = source.parent
scheduleRetirement(after: RetirementDelay.whileGestureActive)
}
defer { if endsSequence { retireHandoff() } }
guard let destination = mouseWindow, destination.isVisible else { retireHandoff(); return }
let point = destination.convertPoint(fromScreen: source.convertPoint(toScreen: event.locationInWindow))
if startsSequence {
guard let content = destination.contentView,
content.bounds.contains(content.convert(point, from: nil)) else { retireHandoff(); return }
}
guard let forwarded = NSEvent.mouseEvent(with: event.type, location: point,
modifierFlags: event.modifierFlags, timestamp: event.timestamp,
windowNumber: destination.windowNumber, context: nil, eventNumber: event.eventNumber,
clickCount: event.clickCount, pressure: event.pressure) else { retireHandoff(); return }
NSApp.sendEvent(forwarded)
}

private func retireHandoff() {
pendingRetirement?.cancel()
pendingRetirement = nil
if let parentCloseObserver { NotificationCenter.default.removeObserver(parentCloseObserver) }
parentCloseObserver = nil
mouseWindow = nil
let retiring = handoff
handoff = nil
retiring?.retire()
Expand All @@ -400,13 +446,16 @@ private final class BranchPopupExpansionView: NSView {
owner: self, userInfo: nil))
}
override func mouseDown(with event: NSEvent) {
retireHandoff()
onPress?()
if handoff != nil { forwardMouse(event, startsSequence: true) } else { onPress?() }
}
override func rightMouseDown(with event: NSEvent) {
retireHandoff()
onSecondaryPress?(event.window?.convertPoint(toScreen: event.locationInWindow) ?? NSEvent.mouseLocation)
if handoff != nil { forwardMouse(event, startsSequence: true) }
else { onSecondaryPress?(event.window?.convertPoint(toScreen: event.locationInWindow) ?? NSEvent.mouseLocation) }
}
override func mouseDragged(with event: NSEvent) { forwardMouse(event) }
override func mouseUp(with event: NSEvent) { forwardMouse(event, endsSequence: true) }
override func rightMouseDragged(with event: NSEvent) { forwardMouse(event) }
override func rightMouseUp(with event: NSEvent) { forwardMouse(event, endsSequence: true) }
override func mouseExited(with event: NSEvent) { onExit?() }
override func scrollWheel(with event: NSEvent) {
if let handoff { forward(event, using: handoff) } else { onScroll?(event) }
Expand Down
78 changes: 71 additions & 7 deletions macos/Tests/LitheTests/BranchSwitcherPopoverBehaviorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -225,25 +225,79 @@ struct BranchSwitcherPopoverBehaviorTests {
#expect(harness.window.childWindows?.contains(panel) != true)
}

@Test
@Test(arguments: [false, true])
@MainActor
func clickingTheHiddenExpansionRetiresItImmediately() throws {
func clickingTheHiddenExpansionReachesTheCurrentContent(rightClick: Bool) throws {
let harness = try BranchExpansionScrollHarness()
defer { harness.close() }
var scheduled: [DispatchWorkItem] = []
harness.row.scheduleExpansionRetirement = { scheduled.append($1) }
harness.row.onPress = { Issue.record("A hidden expansion must not activate its old row") }
harness.row.onSecondaryPress = { _ in Issue.record("A hidden expansion must not open its old row's menu") }
let panel = harness.panel
let overlay = try #require(panel.contentView)
overlay.scrollWheel(with: try harness.event(phase: .began))
#expect(panel.isVisible)

overlay.mouseDown(with: try #require(NSEvent.mouseEvent(with: .leftMouseDown, location: .zero,
modifierFlags: [], timestamp: 0, windowNumber: panel.windowNumber, context: nil,
eventNumber: 0, clickCount: 1, pressure: 1)))
#expect(!panel.isVisible, "A gesture that never ended must not keep an invisible panel over the list")
// Replace the source row, just as scrolling a lazy list can do.
harness.row.removeFromSuperview()
let current = BranchExpansionClickRecorder(frame: harness.scroll.bounds)
harness.scroll.documentView = current
panel.setFrameOrigin(NSPoint(x: panel.frame.minX + 12, y: panel.frame.minY + 3))
let point = NSPoint(x: 20, y: 12)
let expectedPoint = harness.window.convertPoint(fromScreen: panel.convertPoint(toScreen: point))
let types: [NSEvent.EventType] = rightClick
? [.rightMouseDown, .rightMouseDragged, .rightMouseUp]
: [.leftMouseDown, .leftMouseDragged, .leftMouseUp]
for type in types {
panel.sendEvent(try #require(NSEvent.mouseEvent(with: type, location: point,
modifierFlags: [.shift], timestamp: 42, windowNumber: panel.windowNumber, context: nil,
eventNumber: 7, clickCount: 2, pressure: 0.5)))
if type == types.first { #expect(panel.isVisible, "Keep receiving the native mouse sequence until mouse-up") }
}
#expect(current.events.map(\.type) == types)
#expect(current.events.allSatisfy { $0.window === harness.window && $0.locationInWindow == expectedPoint })
#expect(current.events.allSatisfy { $0.modifierFlags.contains(.shift) && $0.clickCount == 2 && $0.timestamp == 42 })
#expect(!panel.isVisible)
#expect(harness.window.childWindows?.contains(panel) != true)
#expect(scheduled.allSatisfy { $0.isCancelled })
}

@Test
@MainActor
func clickingOutsideTheParentContentOnlyRetiresTheHiddenExpansion() throws {
let harness = try BranchExpansionScrollHarness()
defer { harness.close() }
harness.row.scheduleExpansionRetirement = { _, _ in }
let panel = harness.panel
try #require(panel.contentView).scrollWheel(with: try harness.event(phase: .began))
harness.row.removeFromSuperview()
let current = BranchExpansionClickRecorder(frame: harness.scroll.bounds)
harness.scroll.documentView = current
panel.sendEvent(try #require(NSEvent.mouseEvent(with: .leftMouseDown,
location: NSPoint(x: harness.window.frame.width + 20, y: 12), modifierFlags: [], timestamp: 0,
windowNumber: panel.windowNumber, context: nil, eventNumber: 0, clickCount: 1, pressure: 1)))
#expect(current.events.isEmpty)
#expect(!panel.isVisible && panel.parent == nil)
}

@Test
@MainActor
func closingTheParentRetiresTheDetachedExpansionWithoutWaitingForItsTimer() throws {
let harness = try BranchExpansionScrollHarness()
defer { harness.close() }
var scheduled: [DispatchWorkItem] = []
harness.row.scheduleExpansionRetirement = { scheduled.append($1) }
let panel = harness.panel
try #require(panel.contentView).scrollWheel(with: try harness.event(phase: .began))
harness.row.removeFromSuperview()

harness.window.close()

#expect(!panel.isVisible)
#expect(panel.parent == nil)
#expect(scheduled.count == 1 && scheduled.allSatisfy { $0.isCancelled })
}

@Test
@MainActor
func mouseWheelOverAnExpansionClosesItAndScrollsTheList() throws {
Expand Down Expand Up @@ -694,6 +748,16 @@ private final class BranchExpansionScrollHarness {
}
}

private final class BranchExpansionClickRecorder: NSView {
var events: [NSEvent] = []
override func mouseDown(with event: NSEvent) { events.append(event) }
override func mouseDragged(with event: NSEvent) { events.append(event) }
override func mouseUp(with event: NSEvent) { events.append(event) }
override func rightMouseDown(with event: NSEvent) { events.append(event) }
override func rightMouseDragged(with event: NSEvent) { events.append(event) }
override func rightMouseUp(with event: NSEvent) { events.append(event) }
}

private final class BranchHoverTestWindow: NSWindow {
var pointer = NSPoint.zero
override var mouseLocationOutsideOfEventStream: NSPoint { pointer }
Expand Down
Loading
Loading