Skip to content

Commit 46646f0

Browse files
author
jiangzhibin
committed
feat(gesture): 将关闭窗口手势从长按捏合改为上滑 1 秒
变更内容: - 关闭窗口触发机制从"长按捏合"改为"上滑持续" - 时间阈值从 2.0 秒缩短为 1.0 秒 - 添加与"恢复最小化窗口"的冲突处理逻辑 修复与优化: - 修复 HUD 浮层干扰窗口检测导致的抖动问题 - 添加 Chrome 视觉全屏检测(解决演示模式不设置 AXFullScreen 的问题) - 优化 isInTitleBarArea 方法,减少重复窗口查询 其他: - 更新 README 文档和 HUD 反馈图标/文字 - 添加 xcuserstate 到 .gitignore
1 parent 1c064e8 commit 46646f0

File tree

5 files changed

+127
-42
lines changed

5 files changed

+127
-42
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,6 @@
11
/.npm-cache/
22
/.idea/
33
/.claude/
4+
5+
# Xcode user-specific files
6+
*.xcuserstate

README.md

Lines changed: 8 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
> 建议录制以下场景:
2626
> - 双指张开手势 → 窗口全屏
2727
> - 双指捏合手势 → 窗口还原(全屏时)
28-
> - 长按捏合 2秒 → 关闭窗口(非全屏时,带环形进度条)
28+
> - 上滑 1秒 → 关闭窗口(非全屏时,带环形进度条)
2929
> - 双指下滑手势 → 窗口最小化
3030
> - 双指上滑手势 → 恢复最小化窗口
3131
@@ -48,18 +48,18 @@ SwishMini 为 macOS 带来直观的触控板手势控制,让窗口管理更加
4848
|------|------|------|
4949
| 👐 **双指张开** | 全屏 | 将当前窗口切换至全屏模式 |
5050
| 🤏 **双指捏合** | 还原 | 全屏时退出全屏 |
51-
| 🤏 **长按捏合 2秒** | 关闭窗口 | 非全屏时长按捏合关闭当前窗口 |
51+
| 👆 **上滑 1秒** | 关闭窗口 | 非全屏时上滑 1 秒关闭当前窗口 |
5252
| 👇 **双指下滑** | 最小化 | 最小化当前窗口到 Dock |
5353
| 👆 **双指上滑** | 取消最小化 | 在原位置恢复最小化的窗口 |
5454

5555
### 🎯 HUD 视觉反馈
5656

5757
执行手势时,屏幕会显示实时视觉反馈:
5858

59-
- **环形进度条**长按关闭窗口时显示倒计时进度环
59+
- **环形进度条**上滑关闭窗口时显示倒计时进度环
6060
- **颜色渐变**:从橙色平滑过渡到红色,表示紧迫程度
6161
- **进度百分比**:实时显示当前进度(如 50%、75%)
62-
- **取消提示**松手或张开手指时显示"已取消"
62+
- **取消提示**松手或收回手指时显示"已取消"
6363

6464
### 🌐 特别支持
6565

@@ -324,7 +324,7 @@ Made with ❤️ by 江志彬
324324
> Suggested scenarios to record:
325325
> - Two-finger pinch open → Window goes fullscreen
326326
> - Two-finger pinch close → Window restores (when fullscreen)
327-
> - Long press pinch 2s → Close window (when not fullscreen, with progress ring)
327+
> - Swipe up 1s → Close window (when not fullscreen, with progress ring)
328328
> - Two-finger swipe down → Window minimizes
329329
> - Two-finger swipe up → Restore minimized window
330330
@@ -347,18 +347,18 @@ SwishMini brings intuitive trackpad gesture control to macOS, making window mana
347347
|---------|--------|-------------|
348348
| 👐 **Two-Finger Pinch Open** | Fullscreen | Switch current window to fullscreen mode |
349349
| 🤏 **Two-Finger Pinch Close** | Restore | Exit fullscreen (when in fullscreen) |
350-
| 🤏 **Long Press Pinch 2s** | Close Window | Close current window (when not fullscreen) |
350+
| 👆 **Swipe Up 1s** | Close Window | Close current window (when not fullscreen) |
351351
| 👇 **Two-Finger Swipe Down** | Minimize | Minimize current window to Dock |
352352
| 👆 **Two-Finger Swipe Up** | Unminimize | Restore minimized window at original location |
353353

354354
### 🎯 HUD Visual Feedback
355355

356356
Real-time visual feedback is displayed when performing gestures:
357357

358-
- **Progress Ring**: Shows countdown progress when long-pressing to close window
358+
- **Progress Ring**: Shows countdown progress when swiping up to close window
359359
- **Color Gradient**: Smoothly transitions from orange to red, indicating urgency
360360
- **Progress Percentage**: Displays current progress in real-time (e.g., 50%, 75%)
361-
- **Cancel Indicator**: Shows "Cancelled" when releasing or spreading fingers
361+
- **Cancel Indicator**: Shows "Cancelled" when releasing or retracting fingers
362362

363363
### 🌐 Special Support
364364

SwishMini/GestureFeedback.swift

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -32,9 +32,9 @@ enum GestureCandidate: Equatable {
3232
case pinchOpen
3333
/// 双指捏合 -> 还原(全屏时退出全屏)
3434
case pinchClose
35-
/// 非全屏 + 长按捏合 -> 关闭窗口
35+
/// 非全屏 + 上滑持续 1 秒 -> 关闭窗口
3636
case closeWindow
37-
/// 已取消(未完成长按捏合就松手/张开手指
37+
/// 已取消(上滑关闭未完成就松手
3838
case cancelled
3939
/// 双指下滑 -> 最小化
4040
case swipeDown
@@ -47,7 +47,7 @@ enum GestureCandidate: Equatable {
4747
case .none: return "macwindow"
4848
case .pinchOpen: return "arrow.up.left.and.arrow.down.right"
4949
case .pinchClose: return "arrow.down.right.and.arrow.up.left"
50-
case .closeWindow: return "xmark.circle"
50+
case .closeWindow: return "arrow.up.circle.fill" // 上滑关闭
5151
case .cancelled: return "xmark.circle"
5252
case .swipeDown: return "arrow.down.circle"
5353
case .swipeUp: return "arrow.up.circle"
@@ -60,7 +60,7 @@ enum GestureCandidate: Equatable {
6060
case .none: return "标题栏区域"
6161
case .pinchOpen: return "双指张开"
6262
case .pinchClose: return "双指捏合"
63-
case .closeWindow: return "长按捏合"
63+
case .closeWindow: return "上滑关闭"
6464
case .cancelled: return "已取消"
6565
case .swipeDown: return "双指下滑"
6666
case .swipeUp: return "双指上滑"

SwishMini/PinchGestureDetector.swift

Lines changed: 47 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -40,9 +40,9 @@ class PinchGestureDetector {
4040
private var gestureStartY: Float = 0
4141
private var previousY: Float = 0
4242

43-
// 非全屏捏合关闭窗口的持续时间阈值(秒)
44-
private let nonFullScreenPinchCloseHoldThreshold: TimeInterval = 2.0
45-
43+
// 非全屏上滑关闭窗口的持续时间阈值(秒)
44+
private let nonFullScreenSwipeUpCloseThreshold: TimeInterval = 1.0
45+
4646
// 框架引用
4747
private var frameworkHandle: UnsafeMutableRawPointer?
4848
private var deviceList: [UnsafeMutableRawPointer] = []
@@ -202,6 +202,11 @@ class PinchGestureDetector {
202202
let denom = max(CGFloat(swipeDownThreshold), 0.0001)
203203
return (.swipeDown, min(absY / denom, 1))
204204
} else {
205+
// 上滑:非全屏时显示"关闭窗口"提示(使用时间进度驱动 HUD 动画)
206+
if !isWindowFullScreen {
207+
let holdProgress = min(gestureDuration / nonFullScreenSwipeUpCloseThreshold, 1)
208+
return (.closeWindow, holdProgress)
209+
}
205210
let denom = max(CGFloat(swipeUpThreshold), 0.0001)
206211
return (.swipeUp, min(absY / denom, 1))
207212
}
@@ -212,11 +217,6 @@ class PinchGestureDetector {
212217
let denom = max(CGFloat(pinchOpenThreshold) - 1.0, 0.0001)
213218
return (.pinchOpen, min((scale - 1.0) / denom, 1))
214219
} else {
215-
// 捏合:非全屏时显示"关闭窗口"提示(使用时间进度驱动 HUD 动画)
216-
if !isWindowFullScreen {
217-
let holdProgress = min(gestureDuration / nonFullScreenPinchCloseHoldThreshold, 1)
218-
return (.closeWindow, holdProgress)
219-
}
220220
let denom = max(1.0 - CGFloat(pinchCloseThreshold), 0.0001)
221221
return (.pinchClose, min((1.0 - scale) / denom, 1))
222222
}
@@ -240,7 +240,7 @@ class PinchGestureDetector {
240240
let windowFrame = windowInfo?.frame
241241
let isWindowFullScreen = windowInfo.map { WindowManager.shared.isWindowFullScreen($0.window) } ?? false
242242

243-
let classified: (candidate: GestureCandidate, progress: CGFloat)
243+
var classified: (candidate: GestureCandidate, progress: CGFloat)
244244
if let override = override {
245245
classified = override
246246
} else {
@@ -253,6 +253,19 @@ class PinchGestureDetector {
253253
)
254254
}
255255

256+
// 修正:如果有可恢复的最小化窗口且鼠标在恢复热点附近,
257+
// 上滑应优先显示为"取消最小化",避免 HUD 错误地显示"关闭窗口"进度环
258+
if classified.candidate == .closeWindow, let record = lastMinimizedWindow {
259+
let dx = mouseLocation.x - record.location.x
260+
let dy = mouseLocation.y - record.location.y
261+
let distance = sqrt(dx * dx + dy * dy)
262+
if distance <= restoreProximityThreshold {
263+
let absY = abs(yDelta)
264+
let denom = max(CGFloat(swipeUpThreshold), 0.0001)
265+
classified = (.swipeUp, min(absY / denom, 1))
266+
}
267+
}
268+
256269
// 记录:一旦进入"关闭窗口"提示状态
257270
if (phase == .began || phase == .changed), classified.candidate == .closeWindow {
258271
didEnterCloseWindowHint = true
@@ -442,11 +455,13 @@ class PinchGestureDetector {
442455
let willPinchOpen = hasValidWindow && isPinchGestureDominant && finalScale > pinchOpenThreshold
443456
let willPinchClose = hasValidWindow && isPinchGestureDominant && finalScale < pinchCloseThreshold
444457

445-
// 预判是否会执行关闭窗口操作(非全屏 + 捏合 + 达到阈值 + 持续时间足够 + 有效窗口)
458+
// 预判是否会执行关闭窗口操作(非全屏 + 上滑主导 + 达到阈值 + 持续时间足够 + 有效窗口)
446459
let willCloseWindow = hasValidWindow &&
447460
!isWindowFullScreen &&
448-
willPinchClose &&
449-
gestureDuration >= nonFullScreenPinchCloseHoldThreshold
461+
isSwipeGestureDominant &&
462+
totalYDelta > swipeUpThreshold &&
463+
gestureDuration >= nonFullScreenSwipeUpCloseThreshold &&
464+
!willSwipeUp // 若命中"恢复最小化"则不应关闭窗口
450465

451466
// 预判是否会执行全屏还原操作(全屏 + 捏合)
452467
let willRestoreFromFullScreen = hasValidWindow && isWindowFullScreen && willPinchClose
@@ -529,7 +544,10 @@ class PinchGestureDetector {
529544

530545
switch gesture {
531546
case .swipeUp:
532-
// 双指上滑 -> 取消最小化
547+
// 双指上滑:
548+
// 1. 优先恢复最小化窗口(如果有记录且在原位置附近)
549+
// 2. 否则,非全屏 + 持续 >= 1 秒:关闭窗口
550+
// 3. 全屏时:无动作(或后续扩展为其他功能)
533551
if let record = lastMinimizedWindow {
534552
// 检查是否在原来的位置附近
535553
let dx = mouseLocation.x - record.location.x
@@ -540,11 +558,22 @@ class PinchGestureDetector {
540558
print("✅ [Action] 在原位置附近上滑,恢复窗口 (距离: \(String(format: "%.0f", distance))px)")
541559
WindowManager.shared.unminimizeWindow(record.windowElement)
542560
lastMinimizedWindow = nil // 清除记录
543-
} else {
544-
print("⚠️ [Action] 上滑位置距离历史位置过远 (\(String(format: "%.0f", distance))px > \(restoreProximityThreshold)px)")
561+
return
545562
}
563+
print("⚠️ [Action] 上滑位置距离历史位置过远 (\(String(format: "%.0f", distance))px > \(restoreProximityThreshold)px)")
564+
}
565+
566+
// 未触发恢复(可能没有记录,或不在恢复热点),检查是否应该关闭窗口
567+
guard let (window, _) = WindowManager.shared.getWindowUnderMouse(mouseLocation) else {
568+
print("⚠️ [Action] 无法获取当前窗口")
569+
return
570+
}
571+
572+
if !WindowManager.shared.isWindowFullScreen(window) && gestureDuration >= nonFullScreenSwipeUpCloseThreshold {
573+
print("❌ [Action] 非全屏 + 长上滑(\(String(format: "%.1f", gestureDuration))s >= \(nonFullScreenSwipeUpCloseThreshold)s),关闭窗口")
574+
WindowManager.shared.closeWindow(window)
546575
} else {
547-
print("⚠️ [Action] 没有记录的最小化窗口")
576+
print("ℹ️ [Action] 上滑但不满足关闭条件")
548577
}
549578
return
550579

@@ -566,16 +595,12 @@ class PinchGestureDetector {
566595
case .pinchClose:
567596
// 双指捏合:
568597
// - 全屏状态:退出全屏
569-
// - 非全屏 + 持续>=2秒:关闭窗口
570-
// - 非全屏 + 持续<2秒:无动作
598+
// - 非全屏:无动作
571599
if WindowManager.shared.isWindowFullScreen(window) {
572600
print("🔄 [Action] 全屏状态,退出全屏")
573601
WindowManager.shared.restoreWindow(window)
574-
} else if gestureDuration >= nonFullScreenPinchCloseHoldThreshold {
575-
print("❌ [Action] 非全屏 + 长捏合(\(String(format: "%.1f", gestureDuration))s >= \(nonFullScreenPinchCloseHoldThreshold)s),关闭窗口")
576-
WindowManager.shared.closeWindow(window)
577602
} else {
578-
print("ℹ️ [Action] 非全屏 + 短捏合(\(String(format: "%.1f", gestureDuration))s < \(nonFullScreenPinchCloseHoldThreshold)s),无动作")
603+
print("ℹ️ [Action] 非全屏状态,捏合无动作")
579604
}
580605

581606
case .swipeDown:

SwishMini/WindowManager.swift

Lines changed: 65 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -22,16 +22,20 @@ class WindowManager {
2222
guard let mainScreen = NSScreen.screens.first else { return nil }
2323
let mainScreenHeight = mainScreen.frame.height
2424
let screenPoint = CGPoint(x: mouseLocation.x, y: mainScreenHeight - mouseLocation.y)
25-
25+
26+
// 排除自身进程的窗口(如 HUD 浮层),防止 HUD 干扰窗口检测导致抖动
27+
let selfPID = getpid()
28+
2629
guard let windowList = CGWindowListCopyWindowInfo([.optionOnScreenOnly, .excludeDesktopElements], kCGNullWindowID) as? [[String: Any]] else {
2730
return nil
2831
}
29-
32+
3033
for windowInfo in windowList {
3134
guard let boundsDict = windowInfo[kCGWindowBounds as String] as? [String: CGFloat],
3235
let ownerPID = windowInfo[kCGWindowOwnerPID as String] as? pid_t,
3336
let layer = windowInfo[kCGWindowLayer as String] as? Int,
34-
layer >= 0 && layer < 25 else {
37+
layer >= 0 && layer < 25,
38+
ownerPID != selfPID else { // 忽略 SwishMini 自身的窗口
3539
continue
3640
}
3741

@@ -67,17 +71,20 @@ class WindowManager {
6771
guard let mainScreen = NSScreen.screens.first else { return false }
6872
let screenHeight = mainScreen.frame.height
6973

74+
// 单次查询窗口信息,确保结果一致性
75+
let windowInfo = getWindowUnderMouse(point)
76+
7077
// 全屏检测:鼠标在屏幕顶部边缘(用于触发全屏标题栏)
7178
let screenTopEdge: CGFloat = 6 // 顶部 6 像素触发区域
7279
if point.y >= screenHeight - screenTopEdge {
7380
// 检查当前窗口是否全屏
74-
if let (window, _) = getWindowUnderMouse(point), isWindowFullScreen(window) {
81+
if let window = windowInfo?.window, isWindowFullScreen(window) {
7582
return true
7683
}
7784
}
7885

7986
// 普通窗口标题栏检测
80-
guard let (_, frame) = getWindowUnderMouse(point) else {
87+
guard let frame = windowInfo?.frame else {
8188
return false
8289
}
8390

@@ -90,15 +97,65 @@ class WindowManager {
9097
}
9198

9299
/// 检查窗口是否处于全屏状态
100+
/// - Note: 对 Chrome 进行特殊处理,因为 Chrome 使用键盘快捷键进入的"演示模式"全屏
101+
/// 不会设置 AXFullScreen 属性,需要通过窗口尺寸判断
93102
func isWindowFullScreen(_ window: AXUIElement) -> Bool {
103+
// 方法1: 检查标准的 AXFullScreen 属性
94104
var fullScreenValue: AnyObject?
95105
if AXUIElementCopyAttributeValue(window, "AXFullScreen" as CFString, &fullScreenValue) == .success,
96-
let isFullScreen = fullScreenValue as? Bool {
97-
return isFullScreen
106+
let isFullScreen = fullScreenValue as? Bool, isFullScreen {
107+
return true
108+
}
109+
110+
// 方法2: 对 Chrome 进行视觉全屏检测
111+
// Chrome 使用 ⌘+Ctrl+F 进入的全屏不会设置 AXFullScreen 属性
112+
if isChrome(window) {
113+
return isWindowVisuallyFullScreen(window)
98114
}
115+
99116
return false
100117
}
101-
118+
119+
/// 检查窗口是否"视觉上全屏"(覆盖整个屏幕,包括菜单栏区域)
120+
/// - Note: 用于检测 Chrome 等不设置 AXFullScreen 属性的应用
121+
private func isWindowVisuallyFullScreen(_ window: AXUIElement) -> Bool {
122+
guard let windowFrame = getWindowFrame(window) else {
123+
return false
124+
}
125+
126+
// 找到窗口所在的屏幕(而非主屏幕)
127+
let windowCenter = CGPoint(
128+
x: windowFrame.origin.x + windowFrame.width / 2,
129+
y: windowFrame.origin.y + windowFrame.height / 2
130+
)
131+
guard let screen = NSScreen.screens.first(where: { $0.frame.contains(windowCenter) }) ?? NSScreen.main else {
132+
return false
133+
}
134+
135+
let screenFrame = screen.frame
136+
137+
// 严格的全屏检测:窗口必须覆盖整个屏幕(包括菜单栏)
138+
// Chrome 演示模式全屏特征:
139+
// 1. 窗口 y 坐标接近 0(屏幕顶部)
140+
// 2. 窗口 x 坐标接近屏幕左边界
141+
// 3. 窗口宽度等于屏幕宽度
142+
// 4. 窗口高度等于屏幕高度(真全屏覆盖菜单栏)
143+
let tolerance: CGFloat = 2.0 // 收紧容差,减少误判
144+
145+
let isAtScreenOrigin = abs(windowFrame.origin.x - screenFrame.origin.x) <= tolerance &&
146+
abs(windowFrame.origin.y - screenFrame.origin.y) <= tolerance
147+
let isFullWidth = abs(windowFrame.width - screenFrame.width) <= tolerance
148+
let isFullHeight = abs(windowFrame.height - screenFrame.height) <= tolerance // 必须覆盖菜单栏
149+
150+
let isVisuallyFullScreen = isAtScreenOrigin && isFullWidth && isFullHeight
151+
152+
if isVisuallyFullScreen {
153+
print("🔍 [WindowManager] Chrome 视觉全屏检测: 窗口覆盖整个屏幕 (screen: \(screenFrame), window: \(windowFrame))")
154+
}
155+
156+
return isVisuallyFullScreen
157+
}
158+
102159
// MARK: - 窗口信息
103160

104161
private func getWindowFrame(_ window: AXUIElement) -> CGRect? {

0 commit comments

Comments
 (0)