diff --git a/Sources/CodeIsland/AllSpacesAnchor.swift b/Sources/CodeIsland/AllSpacesAnchor.swift new file mode 100644 index 00000000..0ca333ed --- /dev/null +++ b/Sources/CodeIsland/AllSpacesAnchor.swift @@ -0,0 +1,82 @@ +import AppKit + +/// Anchors a window into a dedicated private CoreGraphics "space" that sits far +/// above the ordinary per-desktop Space stack, instead of relying solely on +/// `NSWindow.collectionBehavior`'s `.canJoinAllSpaces`. Public collectionBehavior +/// tells WindowServer the window is a *member* of every Space, but the window is +/// still part of the normal per-Space window pool and can still be nudged during +/// a Space-swipe gesture. Anchoring it into a separate, always-shown space above +/// everything else keeps it outside that pool entirely — the same mechanism +/// several other menu-bar/notch overlay tools rely on for this exact guarantee. +/// +/// This uses undocumented CoreGraphics ("CGS") symbols. They've been stable for +/// years across many shipping tools that use this pattern, but Apple could +/// change or remove them in a future release. +final class AllSpacesAnchor { + static let shared = AllSpacesAnchor() + + /// Absolute level for the anchor space. Deliberately the maximum 32-bit + /// value CGS's level field accepts — the private API's underlying type + /// is narrower than Swift's `Int`, so this uses the exact tested constant + /// rather than `Int.max`. + private static let maxSpaceLevel = 2_147_483_647 + + private let spaceID: CGSSpaceID + private var anchoredWindows: Set = [] + + private init() { + // The creation option MUST be 1 — with 0, Finder treats the new space + // as a real desktop and starts drawing desktop icons into it. + let desktopIconSuppressionFlag = 1 + spaceID = CGSSpaceCreate(CGSMainConnection(), desktopIconSuppressionFlag, nil) + CGSSpaceSetAbsoluteLevel(CGSMainConnection(), spaceID, Self.maxSpaceLevel) + CGSShowSpaces(CGSMainConnection(), [spaceID]) + } + + /// Moves `window` into the anchor space. Idempotent. + func anchor(_ window: NSWindow) { + guard !anchoredWindows.contains(window) else { return } + anchoredWindows.insert(window) + CGSAddWindowsToSpaces(CGSMainConnection(), [window.windowNumber] as NSArray, [spaceID]) + } + + /// Removes `window` from the anchor space. Idempotent. + func release(_ window: NSWindow) { + guard anchoredWindows.remove(window) != nil else { return } + CGSRemoveWindowsFromSpaces(CGSMainConnection(), [window.windowNumber] as NSArray, [spaceID]) + } + + deinit { + CGSHideSpaces(CGSMainConnection(), [spaceID]) + CGSSpaceDestroy(CGSMainConnection(), spaceID) + } +} + +// MARK: - Private CoreGraphics Spaces bindings + +private typealias CGSConnectionID = UInt +private typealias CGSSpaceID = UInt64 + +@_silgen_name("_CGSDefaultConnection") +private func CGSMainConnection() -> CGSConnectionID + +@_silgen_name("CGSSpaceCreate") +private func CGSSpaceCreate(_ cid: CGSConnectionID, _ options: Int, _ properties: NSDictionary?) -> CGSSpaceID + +@_silgen_name("CGSSpaceDestroy") +private func CGSSpaceDestroy(_ cid: CGSConnectionID, _ space: CGSSpaceID) + +@_silgen_name("CGSSpaceSetAbsoluteLevel") +private func CGSSpaceSetAbsoluteLevel(_ cid: CGSConnectionID, _ space: CGSSpaceID, _ level: Int) + +@_silgen_name("CGSAddWindowsToSpaces") +private func CGSAddWindowsToSpaces(_ cid: CGSConnectionID, _ windows: NSArray, _ spaces: NSArray) + +@_silgen_name("CGSRemoveWindowsFromSpaces") +private func CGSRemoveWindowsFromSpaces(_ cid: CGSConnectionID, _ windows: NSArray, _ spaces: NSArray) + +@_silgen_name("CGSHideSpaces") +private func CGSHideSpaces(_ cid: CGSConnectionID, _ spaces: NSArray) + +@_silgen_name("CGSShowSpaces") +private func CGSShowSpaces(_ cid: CGSConnectionID, _ spaces: NSArray) diff --git a/Sources/CodeIsland/DiagnosticsExporter.swift b/Sources/CodeIsland/DiagnosticsExporter.swift index c90bc2d3..ecd067b1 100644 --- a/Sources/CodeIsland/DiagnosticsExporter.swift +++ b/Sources/CodeIsland/DiagnosticsExporter.swift @@ -120,6 +120,15 @@ struct DiagnosticsExporter { "hideWhenNoSession": UserDefaults.standard.bool(forKey: SettingsKey.hideWhenNoSession), "collapseOnMouseLeave": UserDefaults.standard.bool(forKey: SettingsKey.collapseOnMouseLeave), "smartSuppress": UserDefaults.standard.bool(forKey: SettingsKey.smartSuppress), + "openOnHover": UserDefaults.standard.object(forKey: SettingsKey.openOnHover) as? Bool + ?? SettingsDefaults.openOnHover, + "hoverOpenDelay": HoverOpenDelay.clamped( + UserDefaults.standard.object(forKey: SettingsKey.hoverOpenDelay) as? Double + ?? SettingsDefaults.hoverOpenDelay + ), + "invertHorizontalSwipeDirection": UserDefaults.standard.object( + forKey: SettingsKey.invertHorizontalSwipeDirection + ) as? Bool ?? SettingsDefaults.invertHorizontalSwipeDirection, "sessionTimeout": UserDefaults.standard.integer(forKey: SettingsKey.sessionTimeout), "maxVisibleSessions": UserDefaults.standard.integer(forKey: SettingsKey.maxVisibleSessions), "mascotSpeed": UserDefaults.standard.integer(forKey: SettingsKey.mascotSpeed), diff --git a/Sources/CodeIsland/FourFingerSwipeGesture.swift b/Sources/CodeIsland/FourFingerSwipeGesture.swift new file mode 100644 index 00000000..c79b285b --- /dev/null +++ b/Sources/CodeIsland/FourFingerSwipeGesture.swift @@ -0,0 +1,75 @@ +import AppKit + +enum FourFingerSwipeDirection: Equatable { + case left + case right +} + +/// Pure threshold math for a raw four-finger trackpad swipe, independent of the +/// NSTouch plumbing that drives it. Watching for the touches directly (rather +/// than reacting to NSWorkspace.activeSpaceDidChangeNotification after the +/// fact) lets the panel start collapsing as the gesture crosses the threshold, +/// not only once macOS has already finished switching Spaces. +enum FourFingerSwipeGesture { + /// Fraction of the trackpad's normalized width (0...1) the four touches' + /// average position must move before the swipe counts as deliberate. + static let normalizedDisplacementThreshold: CGFloat = 0.10 + + /// Tolerance for floating-point rounding at the threshold boundary — touch + /// coordinates are sensor-derived doubles, so exact equality isn't meaningful. + private static let epsilon: CGFloat = 0.0001 + + static func direction(startX: CGFloat, currentX: CGFloat) -> FourFingerSwipeDirection? { + let movement = currentX - startX + if movement >= normalizedDisplacementThreshold - epsilon { return .right } + if movement <= -normalizedDisplacementThreshold + epsilon { return .left } + return nil + } +} + +/// Tracks raw NSTouch events on whichever view owns it to detect a four-finger +/// horizontal swipe independent of AppKit's own gesture recognizers or of +/// macOS actually completing a Space switch. Delivery is scoped to the key +/// window per AppKit's touch-event model — this fires reliably while the +/// panel itself is key (e.g. right after the user opened it), which is +/// exactly the case this exists for: collapsing an open panel when the user +/// swipes away from it. +protocol FourFingerSwipeObserving: NSView { + var onFourFingerSwipeThresholdCrossed: (() -> Void)? { get set } +} + +extension FourFingerSwipeObserving { + func fourFingerSwipeTouchesBegan(_ event: NSEvent, state: inout FourFingerSwipeTrackingState) { + let touches = event.touches(matching: .touching, in: self) + guard touches.count == 4 else { + state.reset() + return + } + state.startX = Self.averageX(of: touches) + state.triggered = false + } + + func fourFingerSwipeTouchesMoved(_ event: NSEvent, state: inout FourFingerSwipeTrackingState) { + guard !state.triggered, let startX = state.startX else { return } + let touches = event.touches(matching: .touching, in: self) + guard touches.count == 4 else { return } + let currentX = Self.averageX(of: touches) + guard FourFingerSwipeGesture.direction(startX: startX, currentX: currentX) != nil else { return } + state.triggered = true + onFourFingerSwipeThresholdCrossed?() + } + + private static func averageX(of touches: Set) -> CGFloat { + touches.reduce(CGFloat.zero) { $0 + $1.normalizedPosition.x } / CGFloat(touches.count) + } +} + +struct FourFingerSwipeTrackingState { + var startX: CGFloat? + var triggered = false + + mutating func reset() { + startX = nil + triggered = false + } +} diff --git a/Sources/CodeIsland/IslandSurface.swift b/Sources/CodeIsland/IslandSurface.swift index 1984a500..b9d75f6a 100644 --- a/Sources/CodeIsland/IslandSurface.swift +++ b/Sources/CodeIsland/IslandSurface.swift @@ -13,6 +13,17 @@ enum IslandSurface: Equatable { var isExpanded: Bool { self != .collapsed } + /// Whether this surface may be silently collapsed by something other than + /// direct user action (e.g. clicking outside the panel, switching desktops) + /// — approval/question cards represent a pending request the user still + /// needs to act on and must stay put until explicitly resolved. + var canAutoCollapse: Bool { + switch self { + case .collapsed, .approvalCard, .questionCard: return false + case .sessionList, .completionCard: return true + } + } + /// 当前 surface 关联的 session ID(如有) var sessionId: String? { switch self { diff --git a/Sources/CodeIsland/L10n.swift b/Sources/CodeIsland/L10n.swift index 3fd6b99d..48ae06bb 100644 --- a/Sources/CodeIsland/L10n.swift +++ b/Sources/CodeIsland/L10n.swift @@ -47,6 +47,7 @@ final class L10n: ObservableObject { // Settings pages "general": "General", "behavior": "Behavior", + "gestures": "Gestures", "appearance": "Appearance", "mascots": "Mascots", "sound": "Sound", @@ -69,6 +70,22 @@ final class L10n: ObservableObject { "builtin_display": "Built-in Display", "notch": "(Notch)", + // Gestures + "gesture_opening": "Opening", + "open_on_hover": "Open on Hover", + "open_on_hover_desc": "Automatically open the notch when the pointer rests over it", + "hover_open_delay": "Hover Delay", + "notch_animation_speed": "Open / Close Speed", + "notch_animation_speed_desc": "How fast the island expands and collapses", + "seconds_short": "%.1f s", + "gesture_reference": "Trackpad Gestures", + "invert_horizontal_swipe": "Invert Horizontal Swipes", + "invert_horizontal_swipe_desc": "Reverse left and right session-filter navigation", + "gesture_click_open": "Click the compact notch to open", + "gesture_swipe_down_open": "Two-finger swipe down to open", + "gesture_swipe_up_close": "Two-finger swipe up to close", + "gesture_swipe_horizontal_filter": "Two-finger swipe left or right to change session filters", + // Behavior "display_section": "Display", "hide_in_fullscreen": "Hide in Fullscreen", @@ -81,6 +98,8 @@ final class L10n: ObservableObject { "smart_suppress_desc": "Don't auto-expand panel when agent's terminal tab is in foreground", "collapse_on_mouse_leave": "Auto-collapse on Mouse Leave", "collapse_on_mouse_leave_desc": "Collapse expanded panel back to notch when mouse moves away", + "collapse_on_space_swipe": "Auto-collapse on Desktop Switch", + "collapse_on_space_swipe_desc": "Shrink the expanded panel back to the notch as soon as a Spaces swipe begins", "auto_collapse_after_session_jump": "Auto-collapse after session jump", "auto_collapse_after_session_jump_desc": "Collapse panel after clicking a session and successfully switching to its terminal/client", "auto_expand_on_completion": "Auto-Expand Panel on Agent Completion", @@ -180,6 +199,10 @@ final class L10n: ObservableObject { "notch_height_match_menubar": "Match Menu Bar Height", "notch_height_custom": "Custom Height", "custom_notch_height": "Custom Height", + "show_contrast_edge": "Contrast Edge", + "show_contrast_edge_desc": "Show a subtle contrast edge around the island", + "tint_contrast_edge_mascot": "Match Mascot Color", + "tint_contrast_edge_mascot_desc": "Tint the contrast edge with the active mascot's color", "default": "Default", "content": "Content", "content_font_size": "Content Font Size", @@ -399,6 +422,7 @@ final class L10n: ObservableObject { // Settings pages "general": "Allgemein", "behavior": "Verhalten", + "gestures": "Gesten", "appearance": "Darstellung", "mascots": "Maskottchen", "sound": "Ton", @@ -421,6 +445,22 @@ final class L10n: ObservableObject { "builtin_display": "Integriertes Display", "notch": "(Notch)", + // Gestures + "gesture_opening": "Öffnen", + "open_on_hover": "Beim Darüberfahren öffnen", + "open_on_hover_desc": "Die Notch automatisch öffnen, wenn der Zeiger darauf ruht", + "hover_open_delay": "Hover-Verzögerung", + "notch_animation_speed": "Öffnungs-/Schließgeschwindigkeit", + "notch_animation_speed_desc": "Wie schnell die Insel auf- und zuklappt", + "seconds_short": "%.1f s", + "gesture_reference": "Trackpad-Gesten", + "invert_horizontal_swipe": "Horizontales Wischen umkehren", + "invert_horizontal_swipe_desc": "Die Navigation durch Sitzungsfilter nach links und rechts umkehren", + "gesture_click_open": "Zum Öffnen auf die kompakte Notch klicken", + "gesture_swipe_down_open": "Mit zwei Fingern nach unten wischen, um zu öffnen", + "gesture_swipe_up_close": "Mit zwei Fingern nach oben wischen, um zu schließen", + "gesture_swipe_horizontal_filter": "Mit zwei Fingern links oder rechts zwischen Sitzungsfiltern wechseln", + // Behavior "display_section": "Anzeige", "hide_in_fullscreen": "Im Vollbild ausblenden", @@ -433,6 +473,8 @@ final class L10n: ObservableObject { "smart_suppress_desc": "Panel nicht automatisch erweitern, wenn der Terminal-Tab des Agenten im Vordergrund ist", "collapse_on_mouse_leave": "Beim Verlassen mit der Maus einklappen", "collapse_on_mouse_leave_desc": "Erweitertes Panel wieder zur Notch einklappen, wenn die Maus es verlässt", + "collapse_on_space_swipe": "Beim Schreibtischwechsel einklappen", + "collapse_on_space_swipe_desc": "Erweitertes Panel zur Notch einklappen, sobald eine Spaces-Wischgeste beginnt", "auto_collapse_after_session_jump": "Nach Sitzungswechsel automatisch einklappen", "auto_collapse_after_session_jump_desc": "Panel einklappen, nachdem eine Sitzung angeklickt und erfolgreich zu ihrem Terminal/Client gewechselt wurde", "auto_expand_on_completion": "Panel nach Agent-Abschluss automatisch erweitern", @@ -528,6 +570,10 @@ final class L10n: ObservableObject { "notch_height_match_menubar": "An Menüleistenhöhe anpassen", "notch_height_custom": "Eigene Höhe", "custom_notch_height": "Eigene Höhe", + "show_contrast_edge": "Kontrastkante", + "show_contrast_edge_desc": "Eine dezente Kontrastkante um die Island anzeigen", + "tint_contrast_edge_mascot": "Maskottchenfarbe übernehmen", + "tint_contrast_edge_mascot_desc": "Kontrastkante in der Farbe des aktiven Maskottchens einfärben", "default": "Standard", "content": "Inhalt", "content_font_size": "Schriftgröße des Inhalts", @@ -747,6 +793,7 @@ final class L10n: ObservableObject { // Settings pages "general": "通用", "behavior": "行为", + "gestures": "手势", "appearance": "外观", "mascots": "角色", "sound": "声音", @@ -769,6 +816,22 @@ final class L10n: ObservableObject { "builtin_display": "内建显示器", "notch": "(刘海)", + // Gestures + "gesture_opening": "打开", + "open_on_hover": "悬停时打开", + "open_on_hover_desc": "指针停留在刘海上时自动展开面板", + "hover_open_delay": "悬停打开延迟", + "notch_animation_speed": "展开 / 收起速度", + "notch_animation_speed_desc": "灵动岛展开和收起动画的快慢", + "seconds_short": "%.1f 秒", + "gesture_reference": "触控板手势", + "invert_horizontal_swipe": "反转横向滑动", + "invert_horizontal_swipe_desc": "反转会话筛选器的左右导航方向", + "gesture_click_open": "点击收起的刘海即可打开", + "gesture_swipe_down_open": "双指向下轻扫即可打开", + "gesture_swipe_up_close": "双指向上轻扫即可关闭", + "gesture_swipe_horizontal_filter": "双指向左或向右轻扫以切换会话筛选器", + // Behavior "display_section": "显示", "hide_in_fullscreen": "全屏时隐藏", @@ -781,6 +844,8 @@ final class L10n: ObservableObject { "smart_suppress_desc": "Agent 所在终端标签页在前台时不自动展开面板", "collapse_on_mouse_leave": "鼠标离开时自动收起", "collapse_on_mouse_leave_desc": "鼠标移出展开的面板后自动收回到刘海状态", + "collapse_on_space_swipe": "切换桌面时自动收起", + "collapse_on_space_swipe_desc": "检测到切换桌面的滑动手势时,立即将展开的面板收回到刘海状态", "auto_collapse_after_session_jump": "点击跳转会话后自动收起面板", "auto_collapse_after_session_jump_desc": "点击会话并成功切换到对应终端/客户端后自动收起面板", "auto_expand_on_completion": "Agent 完成时自动展开面板", @@ -880,6 +945,10 @@ final class L10n: ObservableObject { "notch_height_match_menubar": "对齐菜单栏高度", "notch_height_custom": "自定义高度", "custom_notch_height": "自定义高度", + "show_contrast_edge": "对比边缘", + "show_contrast_edge_desc": "在灵动岛周围显示细微的对比边缘", + "tint_contrast_edge_mascot": "匹配吉祥物颜色", + "tint_contrast_edge_mascot_desc": "用当前吉祥物的颜色为对比边缘着色", "default": "默认", "content": "内容", "content_font_size": "内容字体大小", @@ -1099,6 +1168,7 @@ final class L10n: ObservableObject { // Settings pages "general": "一般", "behavior": "行為", + "gestures": "手勢", "appearance": "外觀", "mascots": "角色", "sound": "聲音", @@ -1121,6 +1191,22 @@ final class L10n: ObservableObject { "builtin_display": "內建顯示器", "notch": "(瀏海)", + // Gestures + "gesture_opening": "開啟", + "open_on_hover": "懸停時開啟", + "open_on_hover_desc": "指標停留在瀏海上時自動展開面板", + "hover_open_delay": "懸停開啟延遲", + "notch_animation_speed": "展開 / 收合速度", + "notch_animation_speed_desc": "靈動島展開和收合動畫的快慢", + "seconds_short": "%.1f 秒", + "gesture_reference": "觸控板手勢", + "invert_horizontal_swipe": "反轉橫向滑動", + "invert_horizontal_swipe_desc": "反轉工作階段篩選器的左右導覽方向", + "gesture_click_open": "點擊收合的瀏海即可開啟", + "gesture_swipe_down_open": "雙指向下滑動即可開啟", + "gesture_swipe_up_close": "雙指向上滑動即可關閉", + "gesture_swipe_horizontal_filter": "雙指向左或向右滑動以切換會話篩選器", + // Behavior "display_section": "顯示", "hide_in_fullscreen": "全螢幕時隱藏", @@ -1133,6 +1219,8 @@ final class L10n: ObservableObject { "smart_suppress_desc": "Agent 所在終端機分頁在前景時不自動展開面板", "collapse_on_mouse_leave": "滑鼠離開時自動收合", "collapse_on_mouse_leave_desc": "滑鼠移出展開的面板後自動收回到瀏海狀態", + "collapse_on_space_swipe": "切換桌面時自動收合", + "collapse_on_space_swipe_desc": "偵測到切換桌面的滑動手勢時,立即將展開的面板收回到瀏海狀態", "auto_collapse_after_session_jump": "點擊跳轉會話後自動收合面板", "auto_collapse_after_session_jump_desc": "點擊會話並成功切換到對應終端機/客戶端後自動收合面板", "auto_expand_on_completion": "Agent 完成時自動展開面板", @@ -1232,6 +1320,10 @@ final class L10n: ObservableObject { "notch_height_match_menubar": "對齊選單列高度", "notch_height_custom": "自訂高度", "custom_notch_height": "自訂高度", + "show_contrast_edge": "對比邊緣", + "show_contrast_edge_desc": "在動態島周圍顯示細微的對比邊緣", + "tint_contrast_edge_mascot": "符合吉祥物顏色", + "tint_contrast_edge_mascot_desc": "用目前吉祥物的顏色為對比邊緣著色", "default": "預設", "content": "內容", "content_font_size": "內容字體大小", @@ -1451,6 +1543,7 @@ final class L10n: ObservableObject { // Settings pages "general": "一般", "behavior": "動作", + "gestures": "ジェスチャ", "appearance": "外観", "mascots": "マスコット", "sound": "サウンド", @@ -1473,6 +1566,22 @@ final class L10n: ObservableObject { "builtin_display": "内蔵ディスプレイ", "notch": "(ノッチ)", + // Gestures + "gesture_opening": "開く", + "open_on_hover": "ホバーで開く", + "open_on_hover_desc": "ポインタをノッチ上に置くと自動的に開きます", + "hover_open_delay": "ホバーで開くまでの時間", + "notch_animation_speed": "開閉スピード", + "notch_animation_speed_desc": "アイランドが展開・収縮する速さ", + "seconds_short": "%.1f 秒", + "gesture_reference": "トラックパッドジェスチャ", + "invert_horizontal_swipe": "横スワイプを反転", + "invert_horizontal_swipe_desc": "セッションフィルタの左右の移動方向を反転します", + "gesture_click_open": "コンパクトなノッチをクリックして開く", + "gesture_swipe_down_open": "2本指で下にスワイプして開く", + "gesture_swipe_up_close": "2本指で上にスワイプして閉じる", + "gesture_swipe_horizontal_filter": "2本指で左右にスワイプしてセッションフィルタを切り替える", + // Behavior "display_section": "表示", "hide_in_fullscreen": "フルスクリーン時に隠す", @@ -1485,6 +1594,8 @@ final class L10n: ObservableObject { "smart_suppress_desc": "エージェントのターミナルタブが前面にあるときはパネルを自動展開しません", "collapse_on_mouse_leave": "マウスが離れたら自動で折りたたむ", "collapse_on_mouse_leave_desc": "マウスが離れると展開したパネルをノッチ状態に戻します", + "collapse_on_space_swipe": "デスクトップ切り替え時に自動で折りたたむ", + "collapse_on_space_swipe_desc": "スペース切り替えのスワイプが始まった時点で展開したパネルをノッチ状態に戻します", "auto_collapse_after_session_jump": "セッション移動後に自動で折りたたむ", "auto_collapse_after_session_jump_desc": "セッションをクリックして対応するターミナルやクライアントへ正常に切り替えた後、パネルを自動で折りたたみます", "auto_expand_on_completion": "エージェント完了時にパネルを自動展開", @@ -1584,6 +1695,10 @@ final class L10n: ObservableObject { "notch_height_match_menubar": "メニューバーの高さに合わせる", "notch_height_custom": "カスタム高さ", "custom_notch_height": "カスタム高さ", + "show_contrast_edge": "コントラストエッジ", + "show_contrast_edge_desc": "アイランドの周囲に控えめなコントラストエッジを表示", + "tint_contrast_edge_mascot": "マスコットの色に合わせる", + "tint_contrast_edge_mascot_desc": "コントラストエッジを現在のマスコットの色で色付けします", "default": "デフォルト", "content": "内容", "content_font_size": "内容の文字サイズ", @@ -1803,6 +1918,7 @@ final class L10n: ObservableObject { // Settings pages "general": "일반", "behavior": "동작", + "gestures": "제스처", "appearance": "외관", "mascots": "마스코트", "sound": "사운드", @@ -1825,6 +1941,22 @@ final class L10n: ObservableObject { "builtin_display": "내장 디스플레이", "notch": "(노치)", + // Gestures + "gesture_opening": "열기", + "open_on_hover": "호버로 열기", + "open_on_hover_desc": "포인터를 노치 위에 두면 자동으로 엽니다", + "hover_open_delay": "호버 열기 지연", + "notch_animation_speed": "열기 / 닫기 속도", + "notch_animation_speed_desc": "아일랜드가 펼쳐지고 접히는 속도", + "seconds_short": "%.1f초", + "gesture_reference": "트랙패드 제스처", + "invert_horizontal_swipe": "가로 스와이프 반전", + "invert_horizontal_swipe_desc": "세션 필터의 좌우 이동 방향을 반대로 전환합니다", + "gesture_click_open": "컴팩트 노치를 클릭하여 열기", + "gesture_swipe_down_open": "두 손가락으로 아래로 쓸어 열기", + "gesture_swipe_up_close": "두 손가락으로 위로 쓸어 닫기", + "gesture_swipe_horizontal_filter": "두 손가락으로 좌우로 쓸어 세션 필터 변경", + // Behavior "display_section": "표시", "hide_in_fullscreen": "전체 화면에서 숨기기", @@ -1837,6 +1969,8 @@ final class L10n: ObservableObject { "smart_suppress_desc": "에이전트의 터미널 탭이 전경일 때 패널을 자동 확장하지 않습니다", "collapse_on_mouse_leave": "마우스가 벗어나면 자동 접기", "collapse_on_mouse_leave_desc": "마우스가 멀어지면 확장된 패널을 노치 상태로 다시 접습니다", + "collapse_on_space_swipe": "데스크탑 전환 시 자동 접기", + "collapse_on_space_swipe_desc": "스페이스 전환 스와이프가 시작되는 즉시 확장된 패널을 노치 상태로 접습니다", "auto_collapse_after_session_jump": "세션 이동 후 자동 접기", "auto_collapse_after_session_jump_desc": "세션을 클릭해 해당 터미널이나 클라이언트로 성공적으로 전환한 뒤 패널을 자동으로 접습니다", "auto_expand_on_completion": "에이전트 완료 시 패널 자동 확장", @@ -1936,6 +2070,10 @@ final class L10n: ObservableObject { "notch_height_match_menubar": "메뉴 막대 높이에 맞춤", "notch_height_custom": "사용자 지정 높이", "custom_notch_height": "사용자 지정 높이", + "show_contrast_edge": "대비 테두리", + "show_contrast_edge_desc": "아일랜드 주위에 은은한 대비 테두리를 표시합니다", + "tint_contrast_edge_mascot": "마스코트 색상 사용", + "tint_contrast_edge_mascot_desc": "현재 마스코트의 색상으로 대비 테두리를 물들입니다", "default": "기본값", "content": "콘텐츠", "content_font_size": "콘텐츠 글꼴 크기", @@ -2155,6 +2293,7 @@ final class L10n: ObservableObject { // Settings pages "general": "Genel", "behavior": "Davranış", + "gestures": "Hareketler", "appearance": "Görünüm", "mascots": "Maskotlar", "sound": "Ses", @@ -2177,6 +2316,22 @@ final class L10n: ObservableObject { "builtin_display": "Dahili Ekran", "notch": "(Çentik)", + // Gestures + "gesture_opening": "Açma", + "open_on_hover": "Üzerine Gelince Aç", + "open_on_hover_desc": "İşaretçi çentiğin üzerinde beklediğinde otomatik olarak aç", + "hover_open_delay": "Üzerine Gelme Gecikmesi", + "notch_animation_speed": "Açılma / Kapanma Hızı", + "notch_animation_speed_desc": "Adanın ne kadar hızlı açılıp kapandığı", + "seconds_short": "%.1f sn", + "gesture_reference": "Dokunmatik Yüzey Hareketleri", + "invert_horizontal_swipe": "Yatay Kaydırmayı Ters Çevir", + "invert_horizontal_swipe_desc": "Oturum filtrelerindeki sol ve sağ gezinme yönünü tersine çevir", + "gesture_click_open": "Açmak için kompakt çentiğe tıkla", + "gesture_swipe_down_open": "Açmak için iki parmakla aşağı kaydır", + "gesture_swipe_up_close": "Kapatmak için iki parmakla yukarı kaydır", + "gesture_swipe_horizontal_filter": "Oturum filtrelerini değiştirmek için iki parmakla sola veya sağa kaydır", + // Behavior "display_section": "Görüntüleme", "hide_in_fullscreen": "Tam Ekran Gizle", @@ -2189,6 +2344,8 @@ final class L10n: ObservableObject { "smart_suppress_desc": "Ajanın terminal sekmesi ön plandayken paneli otomatik genişletme", "collapse_on_mouse_leave": "Fare Çıkınca Daralt", "collapse_on_mouse_leave_desc": "Fare panelden ayrıldığında genişletilmiş paneli çentik durumuna geri daralt", + "collapse_on_space_swipe": "Masaüstü Değişince Daralt", + "collapse_on_space_swipe_desc": "Spaces kaydırma hareketi başlar başlamaz genişletilmiş paneli çentik durumuna daralt", "auto_collapse_after_session_jump": "Oturuma Geçince Otomatik Daralt", "auto_collapse_after_session_jump_desc": "Bir oturuma tıklayıp terminal/istemciye başarıyla geçince paneli otomatik daralt", "auto_expand_on_completion": "Ajan Tamamlandığında Paneli Otomatik Genişlet", @@ -2288,6 +2445,10 @@ final class L10n: ObservableObject { "notch_height_match_menubar": "Menü Çubuğu Yüksekliğiyle Eşleştir", "notch_height_custom": "Özel Yükseklik", "custom_notch_height": "Özel Yükseklik", + "show_contrast_edge": "Kontrast Kenarı", + "show_contrast_edge_desc": "Ada çevresinde hafif bir kontrast kenarı göster", + "tint_contrast_edge_mascot": "Maskot Rengiyle Eşleştir", + "tint_contrast_edge_mascot_desc": "Kontrast kenarını etkin maskotun rengiyle renklendir", "default": "Varsayılan", "content": "İçerik", "content_font_size": "İçerik Yazı Boyutu", diff --git a/Sources/CodeIsland/MascotPalette.swift b/Sources/CodeIsland/MascotPalette.swift new file mode 100644 index 00000000..a3400f79 --- /dev/null +++ b/Sources/CodeIsland/MascotPalette.swift @@ -0,0 +1,99 @@ +import SwiftUI + +/// One mascot's signature color, kept as components so it can be blended on +/// macOS 14 — `Color.mix(with:by:)` needs macOS 15. +struct MascotSignatureColor: Equatable { + let red: Double + let green: Double + let blue: Double + + var color: Color { + Color(red: red, green: green, blue: blue) + } + + /// Pull the color toward white by `fraction` (0 = untouched, 1 = white). + /// + /// Tinting the contrast edge with a raw brand color turns a hairline + /// highlight into a colored stripe. Lifting it toward white keeps the edge + /// reading as light that happens to carry a hue. + func blendedWithWhite(_ fraction: Double) -> MascotSignatureColor { + let amount = min(max(fraction, 0), 1) + return MascotSignatureColor( + red: red + (1 - red) * amount, + green: green + (1 - green) * amount, + blue: blue + (1 - blue) * amount + ) + } +} + +/// Signature colors per CLI source, mirroring the routing in `MascotView` so +/// every source that resolves to a mascot also resolves to a color — aliases +/// included. +enum MascotPalette { + /// Clawd, the default mascot for unrecognized sources. + static let fallback = MascotSignatureColor(red: 0.871, green: 0.533, blue: 0.427) + + private static let dex = MascotSignatureColor(red: 0.92, green: 0.92, blue: 0.93) + private static let grok = MascotSignatureColor(red: 1.0, green: 1.0, blue: 1.0) + private static let gemini = MascotSignatureColor(red: 0.278, green: 0.588, blue: 0.894) + private static let cursor = MascotSignatureColor(red: 0.96, green: 0.31, blue: 0.0) + private static let copilot = MascotSignatureColor(red: 0.35, green: 0.75, blue: 0.95) + private static let qoder = MascotSignatureColor(red: 0.165, green: 0.859, blue: 0.361) + private static let droid = MascotSignatureColor(red: 0.835, green: 0.416, blue: 0.149) + private static let buddyViolet = MascotSignatureColor(red: 0.424, green: 0.302, blue: 1.0) + private static let workBuddy = MascotSignatureColor(red: 0.475, green: 0.380, blue: 0.870) + private static let openClaw = MascotSignatureColor(red: 0.93, green: 0.36, blue: 0.24) + private static let qwen = MascotSignatureColor(red: 0.486, green: 0.228, blue: 0.929) + private static let kimi = MascotSignatureColor(red: 0.29, green: 0.56, blue: 1.0) + private static let kiro = MascotSignatureColor(red: 0.62, green: 0.45, blue: 1.0) + private static let pi = MascotSignatureColor(red: 0.55, green: 0.43, blue: 0.95) + private static let openCode = MascotSignatureColor(red: 0.55, green: 0.55, blue: 0.57) + private static let cline = MascotSignatureColor(red: 0.00, green: 0.70, blue: 0.49) + + static let signatureColors: [String: MascotSignatureColor] = [ + "codex": dex, + "grok": grok, + "gemini": gemini, + "google-antigravity": gemini, + "cursor": cursor, + "cursor-cli": cursor, + "trae": cursor, + "traecn": cursor, + "traecli": cursor, + "copilot": copilot, + "qoder": qoder, + "qoder-cli": qoder, + "qoderwork": qoder, + "droid": droid, + "codebuddy": buddyViolet, + "codybuddycn": buddyViolet, + "stepfun": buddyViolet, + "antigravity": buddyViolet, + "hermes": buddyViolet, + "workbuddy": workBuddy, + "openclaw": openClaw, + "qwen": qwen, + "kimi": kimi, + "kiro": kiro, + "pi": pi, + "omp": pi, + "opencode": openCode, + "cline": cline, + ] + + static func signature(for source: String) -> MascotSignatureColor { + signatureColors[source] ?? fallback + } + + static func color(for source: String) -> Color { + signature(for: source).color + } + + /// The mascot's color as the contrast edge should wear it: lifted toward + /// white so it stays a highlight rather than becoming a colored line. + static func contrastEdgeTint(for source: String) -> Color { + signature(for: source) + .blendedWithWhite(NotchVisualStyle.contrastEdgeTintWhiteMix) + .color + } +} diff --git a/Sources/CodeIsland/MultitouchDevice.swift b/Sources/CodeIsland/MultitouchDevice.swift new file mode 100644 index 00000000..e83cc6e5 --- /dev/null +++ b/Sources/CodeIsland/MultitouchDevice.swift @@ -0,0 +1,244 @@ +import AppKit +import os.log + +private let log = Logger(subsystem: "com.codeisland", category: "Multitouch") + +/// Raw trackpad contact frames, observed globally regardless of which app is +/// frontmost. +/// +/// AppKit has no public route to this. `NSEvent` global monitors deliver +/// scroll events but strip their `NSTouch` payload entirely (measured: 156 +/// scroll events, zero touches), and the responder-chain `touchesMoved(with:)` +/// path only fires while our panel is the key window — which is never the case +/// when the user is working in another app and swipes to a different Space. +/// A `CGEventTap` would need an Accessibility grant; this needs none. +/// +/// So this reaches for MultitouchSupport, the same private framework the +/// established trackpad utilities use for exactly this reason. Everything is +/// resolved through `dlopen`/`dlsym` rather than linked, and the struct layout +/// is validated before it is trusted, so a future macOS that moves or removes +/// any of it degrades to `isAvailable == false` instead of crashing. Callers +/// are expected to keep a coarser fallback for that case. +@MainActor +final class MultitouchDevice { + static let shared = MultitouchDevice() + + /// One trackpad contact, reduced to what swipe detection actually needs. + struct Contact { + let normalizedX: CGFloat + let isTouching: Bool + } + + /// Invoked on a background thread owned by MultitouchSupport — never the + /// main actor. Kept behind a lock because it is written from the main actor + /// in `start`/`stop` and read from that thread. Work inside it must stay + /// small; it runs at trackpad frame rate. + private let handlerLock = NSLock() + nonisolated(unsafe) private var frameHandler: (([Contact]) -> Void)? + + private var devices: [MTDeviceRef] = [] + private var started = false + + private(set) var isAvailable = false + + private init() {} + + /// Resolve the framework and start every attached multitouch device. + /// Idempotent; safe to call when unavailable. + func start(onFrame: @escaping ([Contact]) -> Void) { + guard !started else { return } + handlerLock.withLock { frameHandler = onFrame } + + guard loadFramework() else { return } + guard MTTouch.layoutMatchesFramework else { + log.error("MultitouchSupport MTTouch layout changed — declining to parse contact frames") + return + } + + Self.activeInstance = self + startDevices() + started = true + isAvailable = !devices.isEmpty + + if isAvailable { + observeWake() + } else { + log.notice("No multitouch devices found") + } + } + + func stop() { + for device in devices { + MultitouchSymbols.shared?.deviceStop(device) + } + devices = [] + started = false + isAvailable = false + Self.activeInstance = nil + handlerLock.withLock { frameHandler = nil } + } + + // MARK: - Framework loading + + private func loadFramework() -> Bool { + guard MultitouchSymbols.shared != nil else { + log.notice("MultitouchSupport unavailable — falling back to coarser Space detection") + return false + } + return true + } + + private func startDevices() { + guard let symbols = MultitouchSymbols.shared else { return } + guard let list = symbols.createDeviceList() else { return } + + let count = CFArrayGetCount(list) + for index in 0.. Int32 + +/// Trampoline out of the C callback and into the live instance. +private let multitouchContactCallback: MTContactCallback = { _, touches, numTouches, _, _ in + guard let instance = MultitouchDevice.activeInstance, + let touches, numTouches > 0 else { return 0 } + + let buffer = touches.assumingMemoryBound(to: MTTouch.self) + var contacts: [MultitouchDevice.Contact] = [] + contacts.reserveCapacity(Int(numTouches)) + for index in 0.. CFMutableArray? + typealias RegisterCallback = @convention(c) (MTDeviceRef, MTContactCallback) -> Void + typealias DeviceRun = @convention(c) (MTDeviceRef, Int32) -> Void + typealias DeviceHalt = @convention(c) (MTDeviceRef) -> Void + + let createDeviceList: CreateDeviceList + let registerContactFrameCallback: RegisterCallback + let deviceStart: DeviceRun + let deviceStop: DeviceHalt + + private init?() { + let path = "/System/Library/PrivateFrameworks/MultitouchSupport.framework/MultitouchSupport" + guard let handle = dlopen(path, RTLD_NOW) else { return nil } + guard + let list = dlsym(handle, "MTDeviceCreateList"), + let register = dlsym(handle, "MTRegisterContactFrameCallback"), + let start = dlsym(handle, "MTDeviceStart"), + let stop = dlsym(handle, "MTDeviceStop") + else { return nil } + + createDeviceList = unsafeBitCast(list, to: CreateDeviceList.self) + registerContactFrameCallback = unsafeBitCast(register, to: RegisterCallback.self) + deviceStart = unsafeBitCast(start, to: DeviceRun.self) + deviceStop = unsafeBitCast(stop, to: DeviceHalt.self) + } +} + +private struct MTPoint { + var x: Float + var y: Float +} + +private struct MTVector { + var position: MTPoint + var velocity: MTPoint +} + +/// Mirrors MultitouchSupport's `MTTouch`. Field order and padding must match +/// the framework's own layout; `layoutMatchesFramework` is the tripwire. +private struct MTTouch { + var frame: Int32 + var timestamp: Double + var identifier: Int32 + var state: Int32 + var fingerID: Int32 + var handID: Int32 + var normalized: MTVector + var size: Float + var pressure: Int32 + var angle: Float + var majorAxis: Float + var minorAxis: Float + var absolute: MTVector + var reserved1: Int32 + var reserved2: Int32 + var density: Float + + /// The framework's struct is 96 bytes. If Swift's computed layout ever + /// disagrees, every field read past `timestamp` would be garbage, so the + /// caller declines to parse rather than acting on nonsense coordinates. + static var layoutMatchesFramework: Bool { + MemoryLayout.stride == 96 + } + + /// States 3 (`MakeTouch`) and 4 (`Touching`) are the ones where a finger is + /// actually down; the rest are hover, lift, and out-of-range transitions. + var isTouching: Bool { + state == 3 || state == 4 + } +} diff --git a/Sources/CodeIsland/NotchAnimation.swift b/Sources/CodeIsland/NotchAnimation.swift index abde6fff..e9fca546 100644 --- a/Sources/CodeIsland/NotchAnimation.swift +++ b/Sources/CodeIsland/NotchAnimation.swift @@ -2,15 +2,31 @@ import SwiftUI enum NotchAnimation { /// 展开面板:微弹,有少许回弹感 - static let open = Animation.spring(response: 0.42, dampingFraction: 0.82) + static var open: Animation { + .spring(response: NotchAnimationMetrics.openResponse, dampingFraction: 0.82) + } /// 收起面板:临界阻尼,无过冲(防止 NotchPanelShape 底边露出刘海) - static let close = Animation.spring(response: 0.38, dampingFraction: 1.0) + static var close: Animation { + .spring(response: NotchAnimationMetrics.closeResponse, dampingFraction: 1.0) + } /// 通知弹出:快速弹跳,用于 completion/approval 自动展开 static let pop = Animation.spring(response: 0.3, dampingFraction: 0.65) /// 微交互:hover 状态变化、按钮高亮等 static let micro = Animation.easeOut(duration: 0.12) + /// Contrast-edge hue crossfade when the featured session — and so the + /// mascot — changes. Slow enough to register as a shift in light, not a blink. + static let contrastEdgeTint = Animation.easeInOut(duration: 0.35) /// Hover 预备段:全量展开的延迟计时期间,先给一个轻量的"我看到你了"反馈 static let hoverPrehover = Animation.easeOut(duration: NotchHoverInteraction.prehoverAnimationDuration) + + static func surface(for surface: IslandSurface) -> Animation { + switch NotchAnimationMetrics.surfaceTransition(for: surface) { + case .open: + return open + case .close: + return close + } + } } // MARK: - Blur + Fade transition diff --git a/Sources/CodeIsland/NotchAnimationMetrics.swift b/Sources/CodeIsland/NotchAnimationMetrics.swift new file mode 100644 index 00000000..c2f0e36a --- /dev/null +++ b/Sources/CodeIsland/NotchAnimationMetrics.swift @@ -0,0 +1,30 @@ +import Foundation + +enum NotchAnimationMetrics { + enum SurfaceTransition: Equatable { + case open + case close + } + + /// Unscaled spring responses — the motion at 1.0× speed. + static let baseOpenResponse: TimeInterval = 0.42 + static let baseCloseResponse: TimeInterval = baseOpenResponse + + /// Read live rather than captured at load, so moving the speed slider + /// takes effect on the next open or close without relaunching. + static var openResponse: TimeInterval { + NotchAnimationSpeed.response(base: baseOpenResponse, speed: storedSpeed) + } + + static var closeResponse: TimeInterval { + NotchAnimationSpeed.response(base: baseCloseResponse, speed: storedSpeed) + } + + private static var storedSpeed: Double { + UserDefaults.standard.double(forKey: SettingsKey.notchAnimationSpeed) + } + + static func surfaceTransition(for surface: IslandSurface) -> SurfaceTransition { + surface == .collapsed ? .close : .open + } +} diff --git a/Sources/CodeIsland/NotchGesture.swift b/Sources/CodeIsland/NotchGesture.swift new file mode 100644 index 00000000..b0781ffa --- /dev/null +++ b/Sources/CodeIsland/NotchGesture.swift @@ -0,0 +1,433 @@ +import AppKit + +// MARK: - Hover interaction state machine + +/// Where the island is in its hover interaction. `prehover` is the immediate +/// lightweight acknowledgement shown while the configurable open delay runs. +enum NotchHoverPhase { + case collapsed + case prehover + case expanded +} + +enum NotchHoverEvent { + case mouseEntered + case mouseExited + case expandDelayElapsed + case collapseDelayElapsed +} + +enum NotchHoverInteraction { + static let prehoverAnimationDuration: TimeInterval = 0.21 + static let collapseDelay: TimeInterval = 0.5 + static let prehoverWidthDelta: CGFloat = 7 + static let prehoverScale: CGFloat = 1.004 + + static func nextPhase(from phase: NotchHoverPhase, event: NotchHoverEvent) -> NotchHoverPhase { + switch (phase, event) { + case (.collapsed, .mouseEntered): + return .prehover + case (.prehover, .mouseExited): + return .collapsed + case (.prehover, .expandDelayElapsed): + return .expanded + case (.expanded, .collapseDelayElapsed): + return .collapsed + default: + return phase + } + } + + static func shouldScheduleOpen(openOnHover: Bool) -> Bool { + openOnHover + } +} + +enum NotchVisualStyle { + static let contrastEdgeWidth: CGFloat = 0.9 + static let contrastEdgeTopOpacity = 0.005 + static let contrastEdgeSideOpacity = 0.07 + static let contrastEdgeBottomOpacity = 0.20 + static let contrastEdgeUpperHoldLocation = 0.18 + static let contrastEdgeSideLocation = 0.72 + static let contrastEdgeBlurRadius: CGFloat = 0 + + /// How far a mascot's signature color is lifted toward white before it is + /// used to tint the edge. High enough that saturated brand colors read as a + /// pale wash at these opacities rather than as a colored outline. + static let contrastEdgeTintWhiteMix = 0.62 + + static func showsContrastEdge(hasNotch _: Bool, phase _: NotchHoverPhase) -> Bool { + true + } +} + +/// Live controller-owned dependencies needed by the panel's input behavior. +/// Passing these directly avoids relying on SwiftUI's forwarding app delegate, +/// which is not guaranteed to be the concrete `AppDelegate` instance. +@MainActor +struct NotchPanelInteractionContext { + let panelFrame: () -> NSRect? + let isActiveTerminalForeground: () -> Bool + /// The island's rendered bounds in screen coordinates, reported as SwiftUI + /// lays them out. The controller needs the same geometry the in-view + /// gesture monitor uses, to tell whether the pointer is over the island. + var reportVisibleContentFrame: (_ contentFrame: NSRect, _ panelFrame: NSRect) -> Void = { _, _ in } +} + +enum NotchGestureAction: Equatable, Sendable { + case open + case close + case navigatePrevious + case navigateNext +} + +struct NotchScrollSample: Sendable { + let physicalDeltaX: CGFloat + let physicalDeltaY: CGFloat + let began: Bool + let ended: Bool + let momentum: Bool + let precise: Bool + + init( + physicalDeltaX: CGFloat, + physicalDeltaY: CGFloat, + began: Bool = false, + ended: Bool = false, + momentum: Bool = false, + precise: Bool = true + ) { + self.physicalDeltaX = physicalDeltaX + self.physicalDeltaY = physicalDeltaY + self.began = began + self.ended = ended + self.momentum = momentum + self.precise = precise + } + + init( + scrollingDeltaX: CGFloat, + scrollingDeltaY: CGFloat, + directionInvertedFromDevice: Bool, + began: Bool = false, + ended: Bool = false, + momentum: Bool = false, + precise: Bool = true + ) { + let direction: CGFloat = directionInvertedFromDevice ? -1 : 1 + self.init( + physicalDeltaX: scrollingDeltaX * direction, + physicalDeltaY: scrollingDeltaY * direction, + began: began, + ended: ended, + momentum: momentum, + precise: precise + ) + } + + init(event: NSEvent) { + self.init( + scrollingDeltaX: event.scrollingDeltaX, + scrollingDeltaY: event.scrollingDeltaY, + directionInvertedFromDevice: event.isDirectionInvertedFromDevice, + began: event.phase.contains(.began), + ended: event.phase.contains(.ended) || event.phase.contains(.cancelled), + momentum: !event.momentumPhase.isEmpty, + precise: event.hasPreciseScrollingDeltas + ) + } +} + +struct NotchGestureHitbox { + /// Include the exact screen edge and tolerate sub-pixel coordinate rounding + /// when macOS reports a pointer hidden behind the physical camera notch. + static let topEdgeExtension: CGFloat = 2 + + private let minX: CGFloat + private let maxX: CGFloat + private let minY: CGFloat + private let maxY: CGFloat + + init(panelFrame: NSRect, regionWidth: CGFloat, regionHeight: CGFloat) { + let width = Swift.min(Swift.max(regionWidth, 0), panelFrame.width) + minX = panelFrame.midX - width / 2 + maxX = panelFrame.midX + width / 2 + minY = panelFrame.maxY - Swift.min(Swift.max(regionHeight, 0), panelFrame.height) + maxY = panelFrame.maxY + Self.topEdgeExtension + } + + func contains(_ point: NSPoint) -> Bool { + point.x >= minX && point.x <= maxX && point.y >= minY && point.y <= maxY + } +} + +enum NotchGestureRegionMetrics { + static func resolvedSize( + renderedSize: CGSize, + fallbackWidth: CGFloat, + headerHeight: CGFloat, + isExpanded: Bool + ) -> CGSize { + let width = renderedSize.width > 0 ? renderedSize.width : fallbackWidth + let height = isExpanded ? max(renderedSize.height, headerHeight) : headerHeight + return CGSize(width: width, height: height) + } +} + +struct NotchGestureInterpreter { + static let activationThreshold: CGFloat = 24 + static let axisDominance: CGFloat = 1.2 + + private var accumulatedX: CGFloat = 0 + private var accumulatedY: CGFloat = 0 + private var emitted = false + + mutating func consume(_ sample: NotchScrollSample) -> NotchGestureAction? { + if sample.began { + reset() + } + + if sample.ended { + reset() + return nil + } + + guard sample.precise, !sample.momentum else { return nil } + + accumulatedX += sample.physicalDeltaX + accumulatedY += sample.physicalDeltaY + + guard !emitted else { return nil } + + let horizontal = abs(accumulatedX) + let vertical = abs(accumulatedY) + let action: NotchGestureAction? + + if horizontal >= Self.activationThreshold, + horizontal >= vertical * Self.axisDominance { + action = accumulatedX > 0 ? .navigateNext : .navigatePrevious + } else if vertical >= Self.activationThreshold, + vertical >= horizontal * Self.axisDominance { + action = accumulatedY < 0 ? .open : .close + } else { + action = nil + } + + if action != nil { + emitted = true + } + return action + } + + mutating func reset() { + accumulatedX = 0 + accumulatedY = 0 + emitted = false + } +} + +enum NotchGesturePolicy { + private static let filterModes = ["all", "status", "cli"] + + static func canOpen(surface: IslandSurface, hasSessions: Bool) -> Bool { + surface == .collapsed && hasSessions + } + + static func canClose(surface: IslandSurface) -> Bool { + switch surface { + case .sessionList, .completionCard: + return true + case .collapsed, .approvalCard, .questionCard: + return false + } + } + + static func filterMode( + from currentMode: String, + action: NotchGestureAction, + controlsVisible: Bool, + inverted: Bool + ) -> String? { + guard controlsVisible, + let currentIndex = filterModes.firstIndex(of: currentMode) else { return nil } + + let offset: Int + switch action { + case .navigatePrevious: + offset = inverted ? 1 : -1 + case .navigateNext: + offset = inverted ? -1 : 1 + case .open, .close: + return nil + } + + let nextIndex = Swift.min( + Swift.max(currentIndex + offset, filterModes.startIndex), + filterModes.index(before: filterModes.endIndex) + ) + return filterModes[nextIndex] + } +} + +/// Keeps AppKit's ordered gesture interpretation separate from SwiftUI state +/// mutation. Global event-monitor callbacks can arrive while AppKit is already +/// dispatching input, so their resulting UI action runs on the next main-loop +/// turn instead of re-entering panel layout from inside the callback. +@MainActor +struct NotchGestureActionDelivery { + typealias Work = @MainActor @Sendable () -> Void + + private let schedule: (@escaping Work) -> Void + + init(schedule: @escaping (@escaping Work) -> Void = { work in + DispatchQueue.main.async(execute: work) + }) { + self.schedule = schedule + } + + func deliver( + _ action: NotchGestureAction, + to handler: @escaping @MainActor @Sendable (NotchGestureAction) -> Void + ) { + schedule { + handler(action) + } + } +} + +@MainActor +final class NotchGestureMonitor { + var isEnabled = false { + didSet { + if !isEnabled { + interpreter.reset() + } + } + } + private var localMonitor: Any? + private var globalMonitor: Any? + private var interpreter = NotchGestureInterpreter() + private let actionDelivery = NotchGestureActionDelivery() + private var panelFrameProvider: (() -> NSRect?)? + private var onAction: ((NotchGestureAction) -> Void)? + private var regionWidth: CGFloat = 0 + private var regionHeight: CGFloat = 0 + private var visibleContentGeometry: (contentFrame: NSRect, panelFrame: NSRect)? + + func updateRegion(width: CGFloat, height: CGFloat) { + regionWidth = width + regionHeight = height + } + + /// AppKit reports the rendered island bounds in screen coordinates. Prefer + /// this live frame to SwiftUI's asynchronous size preference so gestures + /// cover animated expanded content without including transparent panel space. + func updateVisibleContentFrame(_ frame: NSRect?, relativeTo panelFrame: NSRect? = nil) { + guard let frame, let panelFrame else { + visibleContentGeometry = nil + return + } + visibleContentGeometry = (frame, panelFrame) + } + + func start( + panelFrameProvider: @escaping () -> NSRect?, + onAction: @escaping (NotchGestureAction) -> Void + ) { + guard localMonitor == nil, globalMonitor == nil else { return } + self.panelFrameProvider = panelFrameProvider + self.onAction = onAction + + localMonitor = NSEvent.addLocalMonitorForEvents(matching: .scrollWheel) { [weak self] event in + guard let self, self.isEnabled else { return event } + guard let action = self.consumeObservedSample( + NotchScrollSample(event: event), + at: NSEvent.mouseLocation, + panelFrame: self.panelFrameProvider?() + ) else { return event } + + self.actionDelivery.deliver(action) { [weak self] action in + self?.onAction?(action) + } + return nil + } + + // A nonactivating panel does not receive scroll events when macOS routes + // them to the app underneath the physical notch. Global observation fills + // that gap; local events remain consumable and are not duplicated here. + globalMonitor = NSEvent.addGlobalMonitorForEvents(matching: .scrollWheel) { [weak self] event in + guard let self else { return } + guard + let action = self.consumeObservedSample( + NotchScrollSample(event: event), + at: NSEvent.mouseLocation, + panelFrame: self.panelFrameProvider?() + ) else { return } + self.actionDelivery.deliver(action) { [weak self] action in + self?.onAction?(action) + } + } + } + + func stop() { + if let localMonitor { + NSEvent.removeMonitor(localMonitor) + } + if let globalMonitor { + NSEvent.removeMonitor(globalMonitor) + } + localMonitor = nil + globalMonitor = nil + panelFrameProvider = nil + onAction = nil + interpreter.reset() + } + + /// Process samples immediately on AppKit's ordered event-monitor callback. + /// Deferring each sample into a separate task can reorder began/delta/ended. + func consumeObservedSample( + _ sample: NotchScrollSample, + at location: NSPoint, + panelFrame: NSRect? + ) -> NotchGestureAction? { + guard isEnabled, let panelFrame else { + interpreter.reset() + return nil + } + + let gestureHitbox: NotchGestureHitbox + if let visibleContentGeometry { + let visibleContentFrame = visibleContentGeometry.contentFrame.offsetBy( + dx: panelFrame.minX - visibleContentGeometry.panelFrame.minX, + dy: panelFrame.minY - visibleContentGeometry.panelFrame.minY + ) + gestureHitbox = NotchGestureHitbox( + panelFrame: visibleContentFrame, + regionWidth: visibleContentFrame.width, + regionHeight: visibleContentFrame.height + ) + } else { + gestureHitbox = NotchGestureHitbox( + panelFrame: panelFrame, + regionWidth: regionWidth, + regionHeight: regionHeight + ) + } + + guard gestureHitbox.contains(location) else { + interpreter.reset() + return nil + } + return interpreter.consume(sample) + } + + deinit { + if let localMonitor { + NSEvent.removeMonitor(localMonitor) + } + if let globalMonitor { + NSEvent.removeMonitor(globalMonitor) + } + } +} diff --git a/Sources/CodeIsland/NotchPanelView.swift b/Sources/CodeIsland/NotchPanelView.swift index 50c9bc7b..72be4d38 100644 --- a/Sources/CodeIsland/NotchPanelView.swift +++ b/Sources/CodeIsland/NotchPanelView.swift @@ -20,48 +20,6 @@ enum NotchWidthMetrics { } } -// MARK: - Hover interaction state machine - -/// Where the island is in its hover interaction. `prehover` is the immediate -/// lightweight acknowledgement (slight widen + scale) shown while the expand -/// delay is still running — a quick mouse pass-through only ever plays this -/// first stage and reverses, instead of popping the full panel open. -enum NotchHoverPhase { - case collapsed - case prehover - case expanded -} - -enum NotchHoverEvent { - case mouseEntered - case mouseExited - case expandDelayElapsed - case collapseDelayElapsed -} - -enum NotchHoverInteraction { - static let prehoverAnimationDuration: TimeInterval = 0.21 - static let expandDelay: TimeInterval = 0.5 - static let collapseDelay: TimeInterval = 0.5 - static let prehoverWidthDelta: CGFloat = 7 - static let prehoverScale: CGFloat = 1.004 - - static func nextPhase(from phase: NotchHoverPhase, event: NotchHoverEvent) -> NotchHoverPhase { - switch (phase, event) { - case (.collapsed, .mouseEntered): - return .prehover - case (.prehover, .mouseExited): - return .collapsed - case (.prehover, .expandDelayElapsed): - return .expanded - case (.expanded, .collapseDelayElapsed): - return .collapsed - default: - return phase - } - } -} - enum ToolNameDisplay { static let compactMaxCharacters = 24 static let compactMaxWidth: CGFloat = 120 @@ -85,12 +43,78 @@ enum ToolNameDisplay { } } +private struct NotchVisibleSizePreferenceKey: PreferenceKey { + static var defaultValue: CGSize = .zero + + static func reduce(value: inout CGSize, nextValue: () -> CGSize) { + value = nextValue() + } +} + +/// Reports the exact rendered island bounds synchronously from AppKit. The +/// hosting view fills the entire panel, including transparent space, so its +/// window alone is not a safe gesture hit-test boundary. +private struct NotchVisibleContentFrameReader: NSViewRepresentable { + let onFrameChange: @MainActor (_ contentFrame: NSRect, _ panelFrame: NSRect) -> Void + + func makeNSView(context _: Context) -> NotchVisibleContentTrackingView { + let view = NotchVisibleContentTrackingView() + view.onFrameChange = onFrameChange + return view + } + + func updateNSView(_ view: NotchVisibleContentTrackingView, context _: Context) { + view.onFrameChange = onFrameChange + view.reportFrame() + } +} + +@MainActor +private final class NotchVisibleContentTrackingView: NSView { + var onFrameChange: ((_ contentFrame: NSRect, _ panelFrame: NSRect) -> Void)? + private var lastReportedFrame = NSRect.null + + override func hitTest(_ point: NSPoint) -> NSView? { + nil + } + + override func layout() { + super.layout() + reportFrame() + } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + reportFrame() + } + + override func setFrameOrigin(_ newOrigin: NSPoint) { + super.setFrameOrigin(newOrigin) + reportFrame() + } + + override func setFrameSize(_ newSize: NSSize) { + super.setFrameSize(newSize) + reportFrame() + } + + func reportFrame() { + guard let window, !bounds.isEmpty else { return } + let windowFrame = convert(bounds, to: nil) + let screenFrame = window.convertToScreen(windowFrame) + guard screenFrame != lastReportedFrame else { return } + lastReportedFrame = screenFrame + onFrameChange?(screenFrame, window.frame) + } +} + struct NotchPanelView: View { var appState: AppState let hasNotch: Bool let notchHeight: CGFloat let notchW: CGFloat let screenWidth: CGFloat + let interactionContext: NotchPanelInteractionContext @AppStorage(SettingsKey.contentFontSize) private var contentFontSize = SettingsDefaults.contentFontSize @AppStorage(SettingsKey.showAgentDetails) private var showAgentDetails = SettingsDefaults.showAgentDetails @@ -98,19 +122,28 @@ struct NotchPanelView: View { @AppStorage(SettingsKey.hideWhenNoSession) private var hideWhenNoSession = SettingsDefaults.hideWhenNoSession @AppStorage(SettingsKey.showToolStatus) private var showToolStatus = SettingsDefaults.showToolStatus @AppStorage(SettingsKey.collapsedWidthScale) private var collapsedWidthScale = SettingsDefaults.collapsedWidthScale + @AppStorage(SettingsKey.openOnHover) private var openOnHover = SettingsDefaults.openOnHover + @AppStorage(SettingsKey.hoverOpenDelay) private var hoverOpenDelay = SettingsDefaults.hoverOpenDelay + @AppStorage(SettingsKey.invertHorizontalSwipeDirection) private var invertHorizontalSwipeDirection = SettingsDefaults.invertHorizontalSwipeDirection + @AppStorage(SettingsKey.showContrastEdge) private var showContrastEdge = SettingsDefaults.showContrastEdge + @AppStorage(SettingsKey.tintContrastEdgeWithMascot) private var tintContrastEdgeWithMascot = SettingsDefaults.tintContrastEdgeWithMascot + @AppStorage(SettingsKey.defaultSource) private var contrastEdgeDefaultSource = SettingsDefaults.defaultSource @AppStorage(SettingsKey.hapticOnHover) private var hapticOnHover = SettingsDefaults.hapticOnHover @AppStorage(SettingsKey.hapticIntensity) private var hapticIntensity = SettingsDefaults.hapticIntensity + @AppStorage(SettingsKey.sessionGroupingMode) private var groupingMode = SettingsDefaults.sessionGroupingMode /// Delayed hover: prevents accidental expansion when mouse passes through @State private var hoverTimer: Timer? @State private var isHovered = false @State private var idleHovered = false + @State private var gestureMonitor = NotchGestureMonitor() /// Three-stage hover: collapsed → prehover (immediate ack) → expanded (after delay) @State private var hoverPhase: NotchHoverPhase = .collapsed /// Curtain animation for tool status toggle @State private var curtainOffset: CGFloat = 0 @State private var curtainOpacity: Double = 1 @State private var displayedToolStatus: Bool = SettingsDefaults.showToolStatus + @State private var visiblePanelSize: CGSize = .zero private var isActive: Bool { !appState.sessions.isEmpty } /// First launch / no-session state should still render a visible marker so the app @@ -134,6 +167,27 @@ struct NotchPanelView: View { /// Mascot size — fits within the menu bar height private var mascotSize: CGFloat { min(27, notchHeight - 6) } + /// The source whose mascot the bar is showing. Mirrors the bar's own + /// precedence so the edge is tinted by the mascot actually on screen, and + /// falls back to the configured default while everything is idle. + private var contrastEdgeSource: String { + let sid = appState.rotatingSessionId + ?? appState.activeSessionId + ?? appState.sessions.keys.sorted().first + guard let sid, let session = appState.sessions[sid] else { + return contrastEdgeDefaultSource + } + return session.status == .idle ? contrastEdgeDefaultSource : session.source + } + + /// White unless the user has opted into mascot tinting, so turning the + /// setting off restores the original edge exactly. + private var contrastEdgeColor: Color { + tintContrastEdgeWithMascot + ? MascotPalette.contrastEdgeTint(for: contrastEdgeSource) + : .white + } + /// Minimum wing width needed to display compact bar content private var compactWingWidth: CGFloat { mascotSize + 14 } @@ -162,6 +216,59 @@ struct NotchPanelView: View { return nw + wing * 2 + extra + toolExtra + prehoverExtra } + private func openSessionList() { + guard NotchGesturePolicy.canOpen(surface: appState.surface, hasSessions: isActive) else { return } + + withAnimation(NotchAnimation.open) { + appState.surface = .sessionList + appState.cancelCompletionQueue() + if appState.activeSessionId == nil { + appState.activeSessionId = appState.sessions.keys.sorted().first + } + } + } + + private func closeForGesture() { + guard NotchGesturePolicy.canClose(surface: appState.surface) else { return } + + hoverTimer?.invalidate() + hoverTimer = nil + hoverPhase = .collapsed + withAnimation(NotchAnimation.close) { + appState.surface = .collapsed + } + } + + private func handleGestureAction(_ action: NotchGestureAction) { + switch action { + case .open: + openSessionList() + case .close: + closeForGesture() + case .navigatePrevious, .navigateNext: + let controlsVisible = appState.surface == .sessionList && appState.sessions.count > 1 + guard let nextMode = NotchGesturePolicy.filterMode( + from: groupingMode, + action: action, + controlsVisible: controlsVisible, + inverted: invertHorizontalSwipeDirection + ) else { return } + withAnimation(.easeInOut(duration: 0.15)) { + groupingMode = nextMode + } + } + } + + private func updateGestureRegion(visibleSize: CGSize? = nil) { + let regionSize = NotchGestureRegionMetrics.resolvedSize( + renderedSize: visibleSize ?? visiblePanelSize, + fallbackWidth: panelWidth, + headerHeight: notchHeight, + isExpanded: shouldShowExpanded + ) + gestureMonitor.updateRegion(width: regionSize.width, height: regionSize.height) + } + var body: some View { VStack(spacing: 0) { VStack(spacing: 0) { @@ -180,6 +287,10 @@ struct NotchPanelView: View { CompactRightWing(appState: appState, expanded: shouldShowExpanded, hasNotch: hasNotch) } .frame(height: notchHeight) + .contentShape(Rectangle()) + .onTapGesture { + openSessionList() + } } else if showIdleIndicator { IdleIndicatorBar( mascotSize: mascotSize, @@ -268,6 +379,21 @@ struct NotchPanelView: View { } } .frame(width: panelWidth) + .background { + GeometryReader { geometry in + Color.clear.preference( + key: NotchVisibleSizePreferenceKey.self, + value: geometry.size + ) + } + } + .background { + NotchVisibleContentFrameReader { frame, panelFrame in + gestureMonitor.updateVisibleContentFrame(frame, relativeTo: panelFrame) + interactionContext.reportVisibleContentFrame(frame, panelFrame) + } + .allowsHitTesting(false) + } .clipped() .background( NotchPanelShape( @@ -277,6 +403,50 @@ struct NotchPanelView: View { ) .fill(.black) ) + .overlay( + NotchVisibleOutlineShape( + topExtension: shouldShowExpanded ? 14 : 3, + bottomRadius: shouldShowExpanded ? 24 : 12, + minHeight: notchHeight + ) + .stroke( + LinearGradient( + stops: [ + .init( + color: contrastEdgeColor.opacity(NotchVisualStyle.contrastEdgeTopOpacity), + location: 0 + ), + .init( + color: contrastEdgeColor.opacity(NotchVisualStyle.contrastEdgeTopOpacity), + location: NotchVisualStyle.contrastEdgeUpperHoldLocation + ), + .init( + color: contrastEdgeColor.opacity(NotchVisualStyle.contrastEdgeSideOpacity), + location: NotchVisualStyle.contrastEdgeSideLocation + ), + .init( + color: contrastEdgeColor.opacity(NotchVisualStyle.contrastEdgeBottomOpacity), + location: 1 + ), + ], + startPoint: .top, + endPoint: .bottom + ), + style: StrokeStyle( + lineWidth: NotchVisualStyle.contrastEdgeWidth, + lineCap: .round, + lineJoin: .round + ) + ) + .blur(radius: NotchVisualStyle.contrastEdgeBlurRadius) + .opacity( + showContrastEdge && NotchVisualStyle.showsContrastEdge(hasNotch: hasNotch, phase: hoverPhase) + ? 1 + : 0 + ) + .animation(NotchAnimation.contrastEdgeTint, value: contrastEdgeColor) + .allowsHitTesting(false) + ) .offset(y: curtainOffset) .opacity(curtainOpacity) .onChange(of: showToolStatus) { _, newValue in @@ -297,7 +467,18 @@ struct NotchPanelView: View { } } } - .onAppear { displayedToolStatus = showToolStatus } + .onAppear { + displayedToolStatus = showToolStatus + updateGestureRegion() + gestureMonitor.isEnabled = showBar + gestureMonitor.start(panelFrameProvider: interactionContext.panelFrame) { action in + handleGestureAction(action) + } + } + .onDisappear { + gestureMonitor.updateVisibleContentFrame(nil) + gestureMonitor.stop() + } .scaleEffect(shouldShowPrehover ? NotchHoverInteraction.prehoverScale : 1, anchor: .top) .contentShape(Rectangle()) .onHover { hovering in @@ -339,28 +520,31 @@ struct NotchPanelView: View { } // Respect collapseOnMouseLeave setting if !hovering && !SettingsManager.shared.collapseOnMouseLeave { return } - // Smart suppress: don't auto-expand when active session's terminal is foreground - if hovering && smartSuppress { - if let delegate = NSApp.delegate as? AppDelegate, - let pc = delegate.panelController, - pc.isActiveTerminalForeground() { - return - } - } isHovered = hovering if hovering { - // Immediate lightweight acknowledgement; a quick pass-through - // only ever plays this first stage and reverses. + hoverTimer?.invalidate() + hoverTimer = nil + + // Always acknowledge the pointer with the subtle prehover, + // even when passive hover-to-open is disabled. withAnimation(NotchAnimation.hoverPrehover) { hoverPhase = NotchHoverInteraction.nextPhase(from: hoverPhase, event: .mouseEntered) } + + guard NotchHoverInteraction.shouldScheduleOpen(openOnHover: openOnHover) else { return } + + // Smart suppress applies only to passive hover opening. Click and + // trackpad gestures are intentional and bypass this check. + if smartSuppress, interactionContext.isActiveTerminalForeground() { + return + } + // Delay full expansion to avoid accidental triggers - hoverTimer?.invalidate() - hoverTimer = Timer.scheduledTimer(withTimeInterval: NotchHoverInteraction.expandDelay, repeats: false) { _ in + hoverTimer = Timer.scheduledTimer(withTimeInterval: HoverOpenDelay.clamped(hoverOpenDelay), repeats: false) { _ in Task { @MainActor in // Guard: mouse may have left during the delay - guard isHovered else { return } + guard isHovered, openOnHover else { return } if hapticOnHover { let performer = NSHapticFeedbackManager.defaultPerformer switch hapticIntensity { @@ -376,13 +560,7 @@ struct NotchPanelView: View { } } hoverPhase = NotchHoverInteraction.nextPhase(from: hoverPhase, event: .expandDelayElapsed) - withAnimation(NotchAnimation.open) { - appState.surface = .sessionList - appState.cancelCompletionQueue() - if appState.activeSessionId == nil { - appState.activeSessionId = appState.sessions.keys.sorted().first - } - } + openSessionList() } } } else { @@ -413,12 +591,31 @@ struct NotchPanelView: View { hoverPhase = .expanded } } + .onChange(of: openOnHover) { _, enabled in + guard !enabled else { return } + hoverTimer?.invalidate() + hoverTimer = nil + } + .onChange(of: showBar) { _, visible in + gestureMonitor.isEnabled = visible + } + .onChange(of: panelWidth) { _, _ in + updateGestureRegion() + } + .onChange(of: shouldShowExpanded) { _, _ in + updateGestureRegion() + } + .onPreferenceChange(NotchVisibleSizePreferenceKey.self) { size in + visiblePanelSize = size + updateGestureRegion(visibleSize: size) + } Spacer() .allowsHitTesting(false) } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) - .animation(NotchAnimation.open, value: appState.surface) + .animation(NotchAnimation.surface(for: appState.surface), value: appState.surface) + .animation(NotchAnimation.micro, value: hoverPhase) } } diff --git a/Sources/CodeIsland/NotchVisibleOutlineShape.swift b/Sources/CodeIsland/NotchVisibleOutlineShape.swift new file mode 100644 index 00000000..8dbc309e --- /dev/null +++ b/Sources/CodeIsland/NotchVisibleOutlineShape.swift @@ -0,0 +1,51 @@ +import SwiftUI + +/// The visible software perimeter of the island. This path deliberately stays +/// open across the screen edge so it never draws a seam behind the real notch. +struct NotchVisibleOutlineShape: Shape { + var topExtension: CGFloat + var bottomRadius: CGFloat + var minHeight: CGFloat = 0 + + var animatableData: AnimatablePair { + get { AnimatablePair(topExtension, bottomRadius) } + set { + topExtension = newValue.first + bottomRadius = newValue.second + } + } + + func path(in rect: CGRect) -> Path { + let ext = topExtension + let maxY = max(rect.maxY, rect.minY + minHeight) + let br = min(bottomRadius, rect.width / 4, (maxY - rect.minY) / 2) + let k: CGFloat = 0.62 + + var path = Path() + path.move(to: CGPoint(x: rect.minX - ext, y: rect.minY)) + path.addCurve( + to: CGPoint(x: rect.minX, y: rect.minY + ext), + control1: CGPoint(x: rect.minX - ext * 0.35, y: rect.minY), + control2: CGPoint(x: rect.minX, y: rect.minY + ext * 0.35) + ) + path.addLine(to: CGPoint(x: rect.minX, y: maxY - br)) + path.addCurve( + to: CGPoint(x: rect.minX + br, y: maxY), + control1: CGPoint(x: rect.minX, y: maxY - br * (1 - k)), + control2: CGPoint(x: rect.minX + br * (1 - k), y: maxY) + ) + path.addLine(to: CGPoint(x: rect.maxX - br, y: maxY)) + path.addCurve( + to: CGPoint(x: rect.maxX, y: maxY - br), + control1: CGPoint(x: rect.maxX - br * (1 - k), y: maxY), + control2: CGPoint(x: rect.maxX, y: maxY - br * (1 - k)) + ) + path.addLine(to: CGPoint(x: rect.maxX, y: rect.minY + ext)) + path.addCurve( + to: CGPoint(x: rect.maxX + ext, y: rect.minY), + control1: CGPoint(x: rect.maxX, y: rect.minY + ext * 0.35), + control2: CGPoint(x: rect.maxX + ext * 0.35, y: rect.minY) + ) + return path + } +} diff --git a/Sources/CodeIsland/PanelWindowBehavior.swift b/Sources/CodeIsland/PanelWindowBehavior.swift new file mode 100644 index 00000000..7c0ee28f --- /dev/null +++ b/Sources/CodeIsland/PanelWindowBehavior.swift @@ -0,0 +1,27 @@ +import AppKit + +enum PanelSpaceTransitionPolicy { + /// Never hide synchronously from the Space-change callback. The window list + /// and frontmost app can describe the outgoing desktop during the animation. + static let immediateFullscreenLatch = false + static let fullscreenEvaluationDelay: TimeInterval = 0.35 + + static func settledFullscreenLatch(isFullscreen: Bool) -> Bool { + isFullscreen + } +} + +enum PanelWindowBehavior { + /// `.canJoinAllSpaces` and `.managed` are contradictory membership models — + /// `.managed` tells Spaces the window belongs to one Space at a time and should + /// be reassigned as the user switches, which is what caused the island to drop + /// out and reappear mid-swipe. `.stationary` is the correct pairing: the window + /// stays pinned in place, like the menu bar, and never participates in the + /// transition animation at all. + static let collectionBehavior: NSWindow.CollectionBehavior = [ + .canJoinAllSpaces, + .stationary, + .fullScreenAuxiliary, + .ignoresCycle, + ] +} diff --git a/Sources/CodeIsland/PanelWindowController.swift b/Sources/CodeIsland/PanelWindowController.swift index 829b9249..281abd4c 100644 --- a/Sources/CodeIsland/PanelWindowController.swift +++ b/Sources/CodeIsland/PanelWindowController.swift @@ -21,9 +21,11 @@ private class KeyablePanel: NSPanel { /// Also guards against NSHostingView constraint-update re-entrancy crash: /// during updateConstraints(), SwiftUI may invalidate the view graph and /// call setNeedsUpdateConstraints again, which AppKit forbids. -private class NotchHostingView: NSHostingView { +private class NotchHostingView: NSHostingView, FourFingerSwipeObserving { /// When true, the deferred handler is setting super — don't re-defer. private var applyingDeferred = false + private var fourFingerSwipeState = FourFingerSwipeTrackingState() + var onFourFingerSwipeThresholdCrossed: (() -> Void)? override func mouseDown(with event: NSEvent) { window?.makeKey() @@ -40,6 +42,26 @@ private class NotchHostingView: NSHostingView { private var pendingConstraintsValue: Bool? private var pendingLayoutValue: Bool? + override func touchesBegan(with event: NSEvent) { + fourFingerSwipeTouchesBegan(event, state: &fourFingerSwipeState) + super.touchesBegan(with: event) + } + + override func touchesMoved(with event: NSEvent) { + fourFingerSwipeTouchesMoved(event, state: &fourFingerSwipeState) + super.touchesMoved(with: event) + } + + override func touchesEnded(with event: NSEvent) { + fourFingerSwipeState.reset() + super.touchesEnded(with: event) + } + + override func touchesCancelled(with event: NSEvent) { + fourFingerSwipeState.reset() + super.touchesCancelled(with: event) + } + /// Always defer `needsUpdateConstraints = true` to the next run-loop turn. /// During AppKit's display-cycle (constraint-update or layout phases), /// calling setNeedsUpdateConstraints synchronously re-enters @@ -169,9 +191,12 @@ class PanelWindowController: NSObject, NSWindowDelegate { private var autoScreenPoller: Timer? private var fullscreenPoller: Timer? private var isSessionObservationArmed = false + private var activeSpaceTransitionTask: Task? private var fullscreenLatch = false private var settingsObservers: [NSObjectProtocol] = [] private var globalClickMonitor: Any? + private let spacesSwipeMonitor = SpacesSwipeMonitor() + private var visibleContentGeometry: (contentFrame: NSRect, panelFrame: NSRect)? private var lastChosenScreenSignature = "" private var isAnimatingScreenHop = false private var dragStartMouseX: CGFloat? @@ -210,6 +235,7 @@ class PanelWindowController: NSObject, NSWindowDelegate { guard let self else { return } self.isSessionObservationArmed = false self.updateVisibility() + self.updateSpacesSwipeArming() self.armSessionObservation() } } @@ -221,13 +247,26 @@ class PanelWindowController: NSObject, NSWindowDelegate { self.hostingView = contentView let size = panelSize + // `.utilityWindow` + `.hudWindow` matches a reference notch-overlay panel's + // style mask; the chrome they'd normally add is fully suppressed below + // (clear background, no shadow, custom SwiftUI content), but the mask + // itself is part of how such panels stay correctly composited on all + // Spaces without custom Space-change handling. let panel = KeyablePanel( contentRect: NSRect(origin: .zero, size: size), - styleMask: [.borderless, .nonactivatingPanel], + styleMask: [.borderless, .nonactivatingPanel, .utilityWindow, .hudWindow], backing: .buffered, defer: false ) panel.isFloatingPanel = true + // isMovableByWindowBackground alone only blocks click-drag on the + // background — isMovable is the master switch, and left at its + // default (true) any OS-level window-drag mechanism (e.g. the + // accessibility three/four-finger trackpad drag) can reposition the + // panel. That's indistinguishable from "the notch doesn't stay + // pinned during a Space swipe" if the drag gesture and the Space + // gesture are easy to conflate. + panel.isMovable = false panel.acceptsMouseMovedEvents = true panel.level = NSWindow.Level(rawValue: Int(CGWindowLevelForKey(.mainMenuWindow)) + 2) panel.backgroundColor = .clear @@ -235,7 +274,7 @@ class PanelWindowController: NSObject, NSWindowDelegate { panel.hasShadow = false panel.isMovableByWindowBackground = false panel.hidesOnDeactivate = false - panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary, .ignoresCycle] + panel.collectionBehavior = PanelWindowBehavior.collectionBehavior panel.sharingType = .readOnly panel.contentView = contentView panel.delegate = self @@ -246,6 +285,7 @@ class PanelWindowController: NSObject, NSWindowDelegate { setupHorizontalDragMonitor() updatePosition() panel.orderFrontRegardless() + AllSpacesAnchor.shared.anchor(panel) MascotAnimationGate.shared.setPanelVisible(true) // Screen change observer @@ -272,19 +312,30 @@ class PanelWindowController: NSObject, NSWindowDelegate { Task { @MainActor in guard let self = self else { return } self.refreshCurrentScreen() - if self.isActiveSpaceFullscreen() { - self.fullscreenLatch = true + self.collapseIfPossible() + self.activeSpaceTransitionTask?.cancel() + self.fullscreenPoller?.invalidate() + self.fullscreenPoller = nil + + // Bookkeeping only — no window/order/visibility call here. A reference + // notch overlay (boring.notch) does nothing at all on this notification + // and stays pinned purely via .canJoinAllSpaces + .stationary; touching + // orderFrontRegardless/orderOut mid-transition, even when it should be a + // no-op, is itself what can make WindowServer reconsider the window's + // compositing during the live Space-swipe animation. + self.fullscreenLatch = PanelSpaceTransitionPolicy.immediateFullscreenLatch + + self.activeSpaceTransitionTask = Task { @MainActor [weak self] in + try? await Task.sleep(for: .seconds(PanelSpaceTransitionPolicy.fullscreenEvaluationDelay)) + guard !Task.isCancelled, let self else { return } + + self.fullscreenLatch = PanelSpaceTransitionPolicy.settledFullscreenLatch( + isFullscreen: self.isActiveSpaceFullscreen() + ) self.updateVisibility() - self.startFullscreenExitPoller() - } else { - // Non-fullscreen space: clear any stale latch immediately so the panel - // doesn't stay hidden for up to 1.5s while the exit poller catches up (#104). if self.fullscreenLatch { - self.fullscreenLatch = false - self.fullscreenPoller?.invalidate() - self.fullscreenPoller = nil + self.startFullscreenExitPoller() } - self.updateVisibility() } } } @@ -312,23 +363,61 @@ class PanelWindowController: NSObject, NSWindowDelegate { // Global click monitor: close panel + repost click when clicking outside globalClickMonitor = NSEvent.addGlobalMonitorForEvents(matching: [.leftMouseDown, .rightMouseDown]) { [weak self] event in Task { @MainActor in - guard let self = self, self.appState.surface.isExpanded else { return } - // Don't close during approval/question - switch self.appState.surface { - case .approvalCard, .questionCard: return - default: break - } + guard let self = self, self.appState.surface.canAutoCollapse else { return } // Don't collapse if click is within the panel frame (event leaked on external display) if let panelFrame = self.panel?.frame { let clickLocation = NSEvent.mouseLocation if panelFrame.contains(clickLocation) { return } } - withAnimation(NotchAnimation.close) { - self.appState.surface = .collapsed - self.appState.cancelCompletionQueue() - } + self.collapseIfPossible() + } + } + + // Start collapsing the moment a Spaces swipe becomes deliberate. The + // activeSpaceDidChangeNotification observer above only fires once macOS + // has finished the transition, which leaves the island riding through + // the whole animation fully expanded before snapping shut. + spacesSwipeMonitor.start { [weak self] in + guard let self else { return } + guard self.isPointerOverIsland else { + log.debug("Spaces swipe ignored — pointer is not over the island") + return } + log.debug("Spaces swipe crossed threshold over the island — collapsing") + self.collapseIfPossible() } + updateSpacesSwipeArming() + } + + /// Per-frame touch work only runs while there is an expanded surface worth + /// collapsing and the user hasn't turned the behavior off. + private func updateSpacesSwipeArming() { + let enabled = SettingsManager.shared.collapseOnSpaceSwipe + spacesSwipeMonitor.setArmed(enabled && appState.surface.canAutoCollapse) + } + + /// Whether the pointer sits over the island's rendered content. + /// + /// A four-finger swipe is a global gesture, so without this the island + /// would collapse on every desktop switch anywhere on screen. Requiring + /// the pointer to be on the island scopes it to the case that actually + /// matters: the user is looking at the expanded panel and swipes away. + private var isPointerOverIsland: Bool { + guard let panelFrame = panel?.frame, + let geometry = visibleContentGeometry else { return false } + + // The reported frame is relative to the panel position at layout time; + // re-anchor it if the panel has since moved (screen hop, drag). + let contentFrame = geometry.contentFrame.offsetBy( + dx: panelFrame.minX - geometry.panelFrame.minX, + dy: panelFrame.minY - geometry.panelFrame.minY + ) + let hitbox = NotchGestureHitbox( + panelFrame: contentFrame, + regionWidth: contentFrame.width, + regionHeight: contentFrame.height + ) + return hitbox.contains(NSEvent.mouseLocation) } private func makeHostingView(for screen: NSScreen) -> NotchHostingView { @@ -341,11 +430,25 @@ class PanelWindowController: NSObject, NSWindowDelegate { hasNotch: hasNotch, notchHeight: notchHeight, notchW: notchW, - screenWidth: screen.frame.width + screenWidth: screen.frame.width, + interactionContext: NotchPanelInteractionContext( + panelFrame: { [weak self] in self?.gesturePanelFrame }, + isActiveTerminalForeground: { [weak self] in + self?.isActiveTerminalForeground() ?? false + }, + reportVisibleContentFrame: { [weak self] contentFrame, panelFrame in + self?.visibleContentGeometry = (contentFrame, panelFrame) + } + ) ) let contentView = NotchHostingView(rootView: rootView) contentView.sizingOptions = [] contentView.translatesAutoresizingMaskIntoConstraints = true + contentView.allowedTouchTypes = [.indirect] + contentView.wantsRestingTouches = true + contentView.onFourFingerSwipeThresholdCrossed = { [weak self] in + self?.collapseIfPossible() + } return contentView } @@ -467,6 +570,10 @@ class PanelWindowController: NSObject, NSWindowDelegate { self.updateVisibility() self.updatePosition() } + // The system trackpad gesture preference can change while we + // run, and so can the user's own toggle for this behavior. + self.spacesSwipeMonitor.refreshFingerCounts() + self.updateSpacesSwipeArming() } } settingsObservers.append(observer) @@ -612,6 +719,17 @@ class PanelWindowController: NSObject, NSWindowDelegate { } } + /// Collapse the panel if its current surface allows it (see + /// `IslandSurface.canAutoCollapse`) — shared by the click-outside monitor, + /// the Space-change fallback, and the four-finger-swipe detector. + private func collapseIfPossible() { + guard appState.surface.canAutoCollapse else { return } + withAnimation(NotchAnimation.close) { + appState.surface = .collapsed + appState.cancelCompletionQueue() + } + } + /// Update panel visibility based on settings private func updateVisibility() { guard let panel = panel else { return } @@ -670,6 +788,9 @@ class PanelWindowController: NSObject, NSWindowDelegate { return false } + /// Current screen-space bounds used by the notch-only gesture monitor. + var gesturePanelFrame: NSRect? { panel?.frame } + /// Fast check: is the terminal running the active session the foreground app? /// Main-thread safe — no AppleScript or subprocess calls. func isActiveTerminalForeground() -> Bool { @@ -694,6 +815,10 @@ class PanelWindowController: NSObject, NSWindowDelegate { } deinit { + if let panel { + AllSpacesAnchor.shared.release(panel) + } + activeSpaceTransitionTask?.cancel() autoScreenPoller?.invalidate() fullscreenPoller?.invalidate() for observer in settingsObservers { diff --git a/Sources/CodeIsland/Settings.swift b/Sources/CodeIsland/Settings.swift index 4b3e2007..705e77e5 100644 --- a/Sources/CodeIsland/Settings.swift +++ b/Sources/CodeIsland/Settings.swift @@ -17,6 +17,39 @@ enum NotchHeightMode: String, CaseIterable { case custom = "custom" } +enum HoverOpenDelay { + static let minimum = 0.1 + static let maximum = 1.5 + static let step = 0.1 + + static func clamped(_ value: Double) -> Double { + min(max(value, minimum), maximum) + } +} + +/// Speed multiplier for the island's open and close animations. Higher is +/// faster; the stored value scales the spring response rather than a fixed +/// duration, so the motion stays interruptible at every setting. +enum NotchAnimationSpeed { + static let minimum = 0.5 + static let maximum = 2.0 + static let step = 0.1 + static let normal = 1.0 + + /// A missing or zero stored value means "never set" — `UserDefaults.double` + /// returns 0 for an absent key — which must read as normal speed, not as + /// an infinitely slow animation. + static func clamped(_ value: Double) -> Double { + guard value > 0 else { return normal } + return min(max(value, minimum), maximum) + } + + /// Faster speed shortens the spring response. + static func response(base: TimeInterval, speed: Double) -> TimeInterval { + base / clamped(speed) + } +} + enum SettingsKey { // Language static let appLanguage = "appLanguage" // "system", "en", "zh", "zh-Hant", "de", "ja", "ko", "tr" @@ -33,6 +66,11 @@ enum SettingsKey { static let hideWhenNoSession = "hideWhenNoSession" static let smartSuppress = "smartSuppress" static let collapseOnMouseLeave = "collapseOnMouseLeave" + static let collapseOnSpaceSwipe = "collapseOnSpaceSwipe" + static let openOnHover = "openOnHover" + static let hoverOpenDelay = "hoverOpenDelay" + static let notchAnimationSpeed = "notchAnimationSpeed" + static let invertHorizontalSwipeDirection = "invertHorizontalSwipeDirection" static let autoCollapseAfterSessionJump = "autoCollapseAfterSessionJump" static let autoExpandOnPermission = "autoExpandOnPermission" static let autoExpandOnCompletion = "autoExpandOnCompletion" @@ -49,6 +87,8 @@ enum SettingsKey { static let showAgentDetails = "showAgentDetails" static let notchHeightMode = "notchHeightMode" static let customNotchHeight = "customNotchHeight" + static let showContrastEdge = "showContrastEdge" + static let tintContrastEdgeWithMascot = "tintContrastEdgeWithMascot" // Sound static let soundEnabled = "soundEnabled" @@ -145,6 +185,14 @@ struct SettingsDefaults { static let hideWhenNoSession = false static let smartSuppress = true static let collapseOnMouseLeave = true + static let collapseOnSpaceSwipe = true + /// Off by default: hovering the notch on the way to the menu bar is easy to + /// do by accident, so opening is opt-in and click/swipe remain the + /// deliberate ways in. + static let openOnHover = false + static let hoverOpenDelay = 0.5 + static let notchAnimationSpeed = NotchAnimationSpeed.normal + static let invertHorizontalSwipeDirection = false static let autoCollapseAfterSessionJump = false static let autoExpandOnPermission = true static let autoExpandOnCompletion = true @@ -160,6 +208,10 @@ struct SettingsDefaults { static let showAgentDetails = false static let notchHeightMode = NotchHeightMode.matchNotch.rawValue static let customNotchHeight = 37.0 + static let showContrastEdge = true + /// Off by default: the white edge is the established look, and leaving it + /// as the default makes the setting a purely additive opt-in. + static let tintContrastEdgeWithMascot = false static let soundEnabled = false static let soundVolume = 50 @@ -232,6 +284,11 @@ class SettingsManager { SettingsKey.hideWhenNoSession: SettingsDefaults.hideWhenNoSession, SettingsKey.smartSuppress: SettingsDefaults.smartSuppress, SettingsKey.collapseOnMouseLeave: SettingsDefaults.collapseOnMouseLeave, + SettingsKey.collapseOnSpaceSwipe: SettingsDefaults.collapseOnSpaceSwipe, + SettingsKey.openOnHover: SettingsDefaults.openOnHover, + SettingsKey.hoverOpenDelay: SettingsDefaults.hoverOpenDelay, + SettingsKey.notchAnimationSpeed: SettingsDefaults.notchAnimationSpeed, + SettingsKey.invertHorizontalSwipeDirection: SettingsDefaults.invertHorizontalSwipeDirection, SettingsKey.autoCollapseAfterSessionJump: SettingsDefaults.autoCollapseAfterSessionJump, SettingsKey.autoExpandOnPermission: SettingsDefaults.autoExpandOnPermission, SettingsKey.autoExpandOnCompletion: SettingsDefaults.autoExpandOnCompletion, @@ -246,6 +303,8 @@ class SettingsManager { SettingsKey.showAgentDetails: SettingsDefaults.showAgentDetails, SettingsKey.notchHeightMode: SettingsDefaults.notchHeightMode, SettingsKey.customNotchHeight: SettingsDefaults.customNotchHeight, + SettingsKey.showContrastEdge: SettingsDefaults.showContrastEdge, + SettingsKey.tintContrastEdgeWithMascot: SettingsDefaults.tintContrastEdgeWithMascot, SettingsKey.soundEnabled: SettingsDefaults.soundEnabled, SettingsKey.soundVolume: SettingsDefaults.soundVolume, SettingsKey.soundSessionStart: SettingsDefaults.soundSessionStart, @@ -336,6 +395,31 @@ class SettingsManager { set { defaults.set(newValue, forKey: SettingsKey.collapseOnMouseLeave) } } + var collapseOnSpaceSwipe: Bool { + get { defaults.bool(forKey: SettingsKey.collapseOnSpaceSwipe) } + set { defaults.set(newValue, forKey: SettingsKey.collapseOnSpaceSwipe) } + } + + var openOnHover: Bool { + get { defaults.bool(forKey: SettingsKey.openOnHover) } + set { defaults.set(newValue, forKey: SettingsKey.openOnHover) } + } + + var hoverOpenDelay: Double { + get { HoverOpenDelay.clamped(defaults.double(forKey: SettingsKey.hoverOpenDelay)) } + set { defaults.set(HoverOpenDelay.clamped(newValue), forKey: SettingsKey.hoverOpenDelay) } + } + + var notchAnimationSpeed: Double { + get { NotchAnimationSpeed.clamped(defaults.double(forKey: SettingsKey.notchAnimationSpeed)) } + set { defaults.set(NotchAnimationSpeed.clamped(newValue), forKey: SettingsKey.notchAnimationSpeed) } + } + + var invertHorizontalSwipeDirection: Bool { + get { defaults.bool(forKey: SettingsKey.invertHorizontalSwipeDirection) } + set { defaults.set(newValue, forKey: SettingsKey.invertHorizontalSwipeDirection) } + } + var hapticOnHover: Bool { get { defaults.bool(forKey: SettingsKey.hapticOnHover) } set { defaults.set(newValue, forKey: SettingsKey.hapticOnHover) } diff --git a/Sources/CodeIsland/SettingsView.swift b/Sources/CodeIsland/SettingsView.swift index 11c14459..154d12c6 100644 --- a/Sources/CodeIsland/SettingsView.swift +++ b/Sources/CodeIsland/SettingsView.swift @@ -8,6 +8,7 @@ import CodeIslandCore enum SettingsPage: String, Identifiable, Hashable { case general case behavior + case gestures case appearance case mascots case sound @@ -23,6 +24,7 @@ enum SettingsPage: String, Identifiable, Hashable { switch self { case .general: return "gearshape.fill" case .behavior: return "slider.horizontal.3" + case .gestures: return "hand.draw.fill" case .appearance: return "paintbrush.fill" case .mascots: return "person.2.fill" case .sound: return "speaker.wave.2.fill" @@ -38,6 +40,7 @@ enum SettingsPage: String, Identifiable, Hashable { switch self { case .general: return .gray case .behavior: return .orange + case .gestures: return .teal case .appearance: return .blue case .mascots: return .pink case .sound: return .green @@ -56,7 +59,7 @@ private struct SidebarGroup: Hashable { } private let sidebarGroups: [SidebarGroup] = [ - SidebarGroup(title: nil, pages: [.general, .behavior, .appearance, .mascots, .sound, .shortcuts]), + SidebarGroup(title: nil, pages: [.general, .behavior, .gestures, .appearance, .mascots, .sound, .shortcuts]), SidebarGroup(title: "CodeIsland", pages: [.remote, .hooks, .buddy, .about]), ] @@ -90,6 +93,7 @@ struct SettingsView: View { switch selectedPage { case .general: GeneralPage() case .behavior: BehaviorPage(appState: appState) + case .gestures: GesturesPage() case .appearance: AppearancePage() case .mascots: MascotsPage() case .sound: SoundPage() @@ -105,6 +109,134 @@ struct SettingsView: View { } } +// MARK: - Gestures Page + +private struct GesturesPage: View { + @ObservedObject private var l10n = L10n.shared + @AppStorage(SettingsKey.openOnHover) private var openOnHover = SettingsDefaults.openOnHover + @AppStorage(SettingsKey.hoverOpenDelay) private var hoverOpenDelay = SettingsDefaults.hoverOpenDelay + @AppStorage(SettingsKey.notchAnimationSpeed) private var notchAnimationSpeed = SettingsDefaults.notchAnimationSpeed + @AppStorage(SettingsKey.invertHorizontalSwipeDirection) private var invertHorizontalSwipeDirection = SettingsDefaults.invertHorizontalSwipeDirection + @AppStorage(SettingsKey.hapticOnHover) private var hapticOnHover = SettingsDefaults.hapticOnHover + @AppStorage(SettingsKey.hapticIntensity) private var hapticIntensity = SettingsDefaults.hapticIntensity + + private var hoverDelayBinding: Binding { + Binding( + get: { HoverOpenDelay.clamped(hoverOpenDelay) }, + set: { hoverOpenDelay = HoverOpenDelay.clamped($0) } + ) + } + + private var animationSpeedBinding: Binding { + Binding( + get: { NotchAnimationSpeed.clamped(notchAnimationSpeed) }, + set: { notchAnimationSpeed = NotchAnimationSpeed.clamped($0) } + ) + } + + var body: some View { + Form { + Section(l10n["gesture_opening"]) { + Toggle(isOn: $openOnHover) { + VStack(alignment: .leading, spacing: 2) { + Text(l10n["open_on_hover"]) + Text(l10n["open_on_hover_desc"]) + .font(.system(size: 11)) + .foregroundStyle(.tertiary) + } + } + + if openOnHover { + VStack(alignment: .leading, spacing: 6) { + HStack { + Text(l10n["hover_open_delay"]) + Spacer() + Text(String(format: l10n["seconds_short"], hoverDelayBinding.wrappedValue)) + .foregroundStyle(.secondary) + .monospacedDigit() + } + Slider( + value: hoverDelayBinding, + in: HoverOpenDelay.minimum...HoverOpenDelay.maximum, + step: HoverOpenDelay.step + ) + } + + BehaviorToggleRow( + title: l10n["haptic_on_hover"], + desc: l10n["haptic_on_hover_desc"], + isOn: $hapticOnHover, + animation: .hapticHover + ) + if hapticOnHover { + Picker(selection: $hapticIntensity) { + Text(l10n["haptic_light"]).tag(1) + Text(l10n["haptic_medium"]).tag(2) + Text(l10n["haptic_strong"]).tag(3) + } label: { + EmptyView() + } + .pickerStyle(.segmented) + .padding(.leading, 84) + } + } + + // Outside the openOnHover branch on purpose: this governs the + // close animation too, which runs whether or not hover-to-open + // is the thing that opened the island. + VStack(alignment: .leading, spacing: 6) { + HStack { + Text(l10n["notch_animation_speed"]) + Spacer() + Text(String(format: "%.1f×", animationSpeedBinding.wrappedValue)) + .foregroundStyle(.secondary) + .monospacedDigit() + } + Slider( + value: animationSpeedBinding, + in: NotchAnimationSpeed.minimum...NotchAnimationSpeed.maximum, + step: NotchAnimationSpeed.step + ) + Text(l10n["notch_animation_speed_desc"]) + .font(.system(size: 11)) + .foregroundStyle(.tertiary) + } + } + + Section(l10n["gesture_reference"]) { + Toggle(isOn: $invertHorizontalSwipeDirection) { + VStack(alignment: .leading, spacing: 2) { + Text(l10n["invert_horizontal_swipe"]) + Text(l10n["invert_horizontal_swipe_desc"]) + .font(.system(size: 11)) + .foregroundStyle(.tertiary) + } + } + + GestureReferenceRow(icon: "cursorarrow.click", text: l10n["gesture_click_open"]) + GestureReferenceRow(icon: "arrow.down", text: l10n["gesture_swipe_down_open"]) + GestureReferenceRow(icon: "arrow.up", text: l10n["gesture_swipe_up_close"]) + GestureReferenceRow(icon: "arrow.left.and.right", text: l10n["gesture_swipe_horizontal_filter"]) + } + } + .formStyle(.grouped) + .onAppear { + hoverOpenDelay = HoverOpenDelay.clamped(hoverOpenDelay) + notchAnimationSpeed = NotchAnimationSpeed.clamped(notchAnimationSpeed) + } + } +} + +private struct GestureReferenceRow: View { + let icon: String + let text: String + + var body: some View { + Label(text, systemImage: icon) + .foregroundStyle(.secondary) + } +} + // MARK: - Remote Page private struct RemoteHostsPage: View { @@ -380,14 +512,13 @@ private struct BehaviorPage: View { @AppStorage(SettingsKey.hideWhenNoSession) private var hideWhenNoSession = SettingsDefaults.hideWhenNoSession @AppStorage(SettingsKey.smartSuppress) private var smartSuppress = SettingsDefaults.smartSuppress @AppStorage(SettingsKey.collapseOnMouseLeave) private var collapseOnMouseLeave = SettingsDefaults.collapseOnMouseLeave + @AppStorage(SettingsKey.collapseOnSpaceSwipe) private var collapseOnSpaceSwipe = SettingsDefaults.collapseOnSpaceSwipe @AppStorage(SettingsKey.autoCollapseAfterSessionJump) private var autoCollapseAfterSessionJump = SettingsDefaults.autoCollapseAfterSessionJump @AppStorage(SettingsKey.autoExpandOnPermission) private var autoExpandOnPermission = SettingsDefaults.autoExpandOnPermission // Seeded through the migration shim so a legacy autoExpandOnCompletion=false // shows up as "off" here; writes go to the new key via onChange. @State private var completionStyle: String = AppState.completionStyle().rawValue @AppStorage(SettingsKey.pluginSessionMode) private var pluginSessionMode = SettingsDefaults.pluginSessionMode - @AppStorage(SettingsKey.hapticOnHover) private var hapticOnHover = SettingsDefaults.hapticOnHover - @AppStorage(SettingsKey.hapticIntensity) private var hapticIntensity = SettingsDefaults.hapticIntensity @AppStorage(SettingsKey.sessionTimeout) private var sessionTimeout = SettingsDefaults.sessionTimeout @AppStorage(SettingsKey.rotationInterval) private var rotationInterval = SettingsDefaults.rotationInterval @AppStorage(SettingsKey.maxToolHistory) private var maxToolHistory = SettingsDefaults.maxToolHistory @@ -454,6 +585,14 @@ private struct BehaviorPage: View { isOn: $collapseOnMouseLeave, animation: .collapseMouseLeave ) + BehaviorToggleRow( + title: l10n["collapse_on_space_swipe"], + desc: l10n["collapse_on_space_swipe_desc"], + isOn: $collapseOnSpaceSwipe, + // Shares the collapse animation: the outcome it illustrates + // — expanded panel shrinking back to the notch — is the same. + animation: .collapseMouseLeave + ) BehaviorToggleRow( title: l10n["auto_collapse_after_session_jump"], desc: l10n["auto_collapse_after_session_jump_desc"], @@ -473,23 +612,6 @@ private struct BehaviorPage: View { .font(.system(size: 11)) .foregroundStyle(.tertiary) } - BehaviorToggleRow( - title: l10n["haptic_on_hover"], - desc: l10n["haptic_on_hover_desc"], - isOn: $hapticOnHover, - animation: .hapticHover - ) - if hapticOnHover { - Picker(selection: $hapticIntensity) { - Text(l10n["haptic_light"]).tag(1) - Text(l10n["haptic_medium"]).tag(2) - Text(l10n["haptic_strong"]).tag(3) - } label: { - EmptyView() - } - .pickerStyle(.segmented) - .padding(.leading, 84) - } } Section(l10n["auto_approve_tools"]) { @@ -869,6 +991,8 @@ private struct AppearancePage: View { @AppStorage(SettingsKey.collapsedWidthScale) private var collapsedWidthScale = SettingsDefaults.collapsedWidthScale @AppStorage(SettingsKey.notchHeightMode) private var notchHeightModeRaw = SettingsDefaults.notchHeightMode @AppStorage(SettingsKey.customNotchHeight) private var customNotchHeight = SettingsDefaults.customNotchHeight + @AppStorage(SettingsKey.showContrastEdge) private var showContrastEdge = SettingsDefaults.showContrastEdge + @AppStorage(SettingsKey.tintContrastEdgeWithMascot) private var tintContrastEdgeWithMascot = SettingsDefaults.tintContrastEdgeWithMascot private var notchHeightMode: Binding { Binding( @@ -935,6 +1059,19 @@ private struct AppearancePage: View { Slider(value: $customNotchHeight, in: 15...60, step: 1) } } + VStack(alignment: .leading, spacing: 2) { + Toggle(l10n["show_contrast_edge"], isOn: $showContrastEdge) + Text(l10n["show_contrast_edge_desc"]) + .font(.system(size: 11)) + .foregroundStyle(.tertiary) + } + VStack(alignment: .leading, spacing: 2) { + Toggle(l10n["tint_contrast_edge_mascot"], isOn: $tintContrastEdgeWithMascot) + Text(l10n["tint_contrast_edge_mascot_desc"]) + .font(.system(size: 11)) + .foregroundStyle(.tertiary) + } + .disabled(!showContrastEdge) } Section(l10n["content"]) { @@ -1068,33 +1205,35 @@ private struct MascotsPage: View { @AppStorage(SettingsKey.mascotSpeed) private var mascotSpeed = SettingsDefaults.mascotSpeed @AppStorage(SettingsKey.defaultSource) private var defaultSource = SettingsDefaults.defaultSource - private let mascotList: [(name: String, source: String, desc: String, color: Color)] = [ - ("Clawd", "claude", "Claude Code", Color(red: 0.871, green: 0.533, blue: 0.427)), - ("Dex", "codex", "Codex (OpenAI)", Color(red: 0.92, green: 0.92, blue: 0.93)), - ("Grok", "grok", "Grok CLI", Color.white), - ("Gemini", "gemini", "Gemini CLI", Color(red: 0.278, green: 0.588, blue: 0.894)), - ("CursorBot", "cursor", "Cursor", Color(red: 0.96, green: 0.31, blue: 0.0)), - ("TraeBot", "trae", "Trae", Color(red: 0.96, green: 0.31, blue: 0.0)), - ("TraeCNBot", "traecn", "Trae CN", Color(red: 0.96, green: 0.31, blue: 0.0)), - ("CopilotBot", "copilot", "GitHub Copilot", Color(red: 0.35, green: 0.75, blue: 0.95)), - ("QoderBot", "qoder", "Qoder", Color(red: 0.165, green: 0.859, blue: 0.361)), - ("QoderBot", "qoderwork", "QoderWork", Color(red: 0.165, green: 0.859, blue: 0.361)), - ("Droid", "droid", "Factory", Color(red: 0.835, green: 0.416, blue: 0.149)), - ("Buddy", "codebuddy", "CodeBuddy", Color(red: 0.424, green: 0.302, blue: 1.0)), - ("BuddyCN", "codybuddycn", "CodyBuddyCN", Color(red: 0.424, green: 0.302, blue: 1.0)), - ("StepFun", "stepfun", "StepFun", Color(red: 0.424, green: 0.302, blue: 1.0)), - ("AntiGravity", "antigravity", "AntiGravity", Color(red: 0.424, green: 0.302, blue: 1.0)), - ("WorkBuddy", "workbuddy", "WorkBuddy", Color(red: 0.475, green: 0.380, blue: 0.870)), - ("Hermes", "hermes", "Hermes", Color(red: 0.424, green: 0.302, blue: 1.0)), - ("Molty", "openclaw", "OpenClaw", Color(red: 0.93, green: 0.36, blue: 0.24)), - ("QwenBot", "qwen", "Qwen Code", Color(red: 0.486, green: 0.228, blue: 0.929)), - ("KimiBot", "kimi", "Kimi Code CLI", Color(red: 0.29, green: 0.56, blue: 1.0)), - ("Kiro", "kiro", "Kiro", Color(red: 0.62, green: 0.45, blue: 1.0)), - ("Pi", "pi", "Pi", Color(red: 0.55, green: 0.43, blue: 0.95)), - ("Oh My Pi", "omp", "Oh My Pi", Color(red: 0.55, green: 0.43, blue: 0.95)), - ("OpBot", "opencode", "OpenCode", Color(red: 0.55, green: 0.55, blue: 0.57)), - ("ClineBot", "cline", "Cline", Color(red: 0.00, green: 0.70, blue: 0.49)), - ("Gemini", "google-antigravity", "Google Antigravity", Color(red: 0.278, green: 0.588, blue: 0.894)), + /// Colors come from `MascotPalette` so the swatches here and the island's + /// contrast-edge tint can never drift apart. + private let mascotList: [(name: String, source: String, desc: String)] = [ + ("Clawd", "claude", "Claude Code"), + ("Dex", "codex", "Codex (OpenAI)"), + ("Grok", "grok", "Grok CLI"), + ("Gemini", "gemini", "Gemini CLI"), + ("CursorBot", "cursor", "Cursor"), + ("TraeBot", "trae", "Trae"), + ("TraeCNBot", "traecn", "Trae CN"), + ("CopilotBot", "copilot", "GitHub Copilot"), + ("QoderBot", "qoder", "Qoder"), + ("QoderBot", "qoderwork", "QoderWork"), + ("Droid", "droid", "Factory"), + ("Buddy", "codebuddy", "CodeBuddy"), + ("BuddyCN", "codybuddycn", "CodyBuddyCN"), + ("StepFun", "stepfun", "StepFun"), + ("AntiGravity", "antigravity", "AntiGravity"), + ("WorkBuddy", "workbuddy", "WorkBuddy"), + ("Hermes", "hermes", "Hermes"), + ("Molty", "openclaw", "OpenClaw"), + ("QwenBot", "qwen", "Qwen Code"), + ("KimiBot", "kimi", "Kimi Code CLI"), + ("Kiro", "kiro", "Kiro"), + ("Pi", "pi", "Pi"), + ("Oh My Pi", "omp", "Oh My Pi"), + ("OpBot", "opencode", "OpenCode"), + ("ClineBot", "cline", "Cline"), + ("Gemini", "google-antigravity", "Google Antigravity"), ] var body: some View { @@ -1140,7 +1279,7 @@ private struct MascotsPage: View { name: mascot.name, source: mascot.source, desc: mascot.desc, - color: mascot.color, + color: MascotPalette.color(for: mascot.source), status: previewStatus ) } diff --git a/Sources/CodeIsland/SpacesSwipeDetector.swift b/Sources/CodeIsland/SpacesSwipeDetector.swift new file mode 100644 index 00000000..7a83334b --- /dev/null +++ b/Sources/CodeIsland/SpacesSwipeDetector.swift @@ -0,0 +1,103 @@ +import Foundation + +/// Which finger counts macOS currently treats as a horizontal Spaces swipe. +/// +/// The gesture is user-configurable (System Settings › Trackpad › More +/// Gestures › "Swipe between full-screen applications"), so hard-coding four +/// fingers would miss the swipe entirely for anyone who picked three — and +/// would fire on an unrelated three-finger page swipe for anyone who didn't. +enum SpacesSwipeFingerCounts { + /// `2` means the gesture is enabled, `0` disabled. Two domains exist: the + /// built-in trackpad and a paired Magic Trackpad. + private static let enabledValue = 2 + private static let domains = [ + "com.apple.AppleMultitouchTrackpad", + "com.apple.driver.AppleBluetoothMultitouch.trackpad", + ] + private static let threeFingerKey = "TrackpadThreeFingerHorizSwipeGesture" + private static let fourFingerKey = "TrackpadFourFingerHorizSwipeGesture" + + /// Pure resolution so the preference decoding is testable without touching + /// real user defaults. `nil` means "preference absent". + static func resolve(threeFinger: Int?, fourFinger: Int?) -> Set { + guard threeFinger != nil || fourFinger != nil else { + // Nothing readable — accept either rather than silently doing + // nothing, since a false collapse is far cheaper than a miss. + return [3, 4] + } + var counts: Set = [] + if threeFinger == enabledValue { counts.insert(3) } + if fourFinger == enabledValue { counts.insert(4) } + return counts + } + + /// Reads the live preference across both trackpad domains. A gesture + /// enabled in either domain counts as enabled. + static func current() -> Set { + var three: Int? + var four: Int? + for domain in domains { + guard let defaults = UserDefaults(suiteName: domain) else { continue } + if let value = defaults.object(forKey: threeFingerKey) as? Int { + three = max(three ?? 0, value) + } + if let value = defaults.object(forKey: fourFingerKey) as? Int { + four = max(four ?? 0, value) + } + } + return resolve(threeFinger: three, fourFinger: four) + } +} + +/// Turns a stream of raw trackpad contact frames into a single "the user has +/// committed to leaving this Space" signal. +/// +/// This is the globally-observable counterpart to the responder-chain path in +/// `FourFingerSwipeObserving`: same threshold math, but fed by +/// `MultitouchDevice` so it works while another app is frontmost. It fires +/// once per gesture, as the swipe crosses the threshold — well before +/// `NSWorkspace.activeSpaceDidChangeNotification`, which only lands after +/// macOS has finished animating the Space change. +struct SpacesSwipeDetector { + /// Finger counts that count as a Spaces swipe. Empty disables detection, + /// which is correct when the user has turned the system gesture off. + var acceptedFingerCounts: Set + + private var startX: CGFloat? + private var triggered = false + + init(acceptedFingerCounts: Set = [3, 4]) { + self.acceptedFingerCounts = acceptedFingerCounts + } + + /// Feed one contact frame. Returns `true` exactly once per gesture, on the + /// frame where horizontal displacement crosses the threshold. + mutating func consume(touchingX: [CGFloat]) -> Bool { + guard acceptedFingerCounts.contains(touchingX.count) else { + // Finger count left the accepted set — either the gesture ended or + // it was never a Spaces swipe. Either way, start over. + reset() + return false + } + + let currentX = touchingX.reduce(CGFloat.zero, +) / CGFloat(touchingX.count) + + guard let startX else { + self.startX = currentX + return false + } + + guard !triggered else { return false } + guard FourFingerSwipeGesture.direction(startX: startX, currentX: currentX) != nil else { + return false + } + + triggered = true + return true + } + + mutating func reset() { + startX = nil + triggered = false + } +} diff --git a/Sources/CodeIsland/SpacesSwipeMonitor.swift b/Sources/CodeIsland/SpacesSwipeMonitor.swift new file mode 100644 index 00000000..4e0fd058 --- /dev/null +++ b/Sources/CodeIsland/SpacesSwipeMonitor.swift @@ -0,0 +1,85 @@ +import AppKit +import os.log + +private let log = Logger(subsystem: "com.codeisland", category: "SpacesSwipe") + +/// Watches for the user starting a Spaces swipe and reports the moment the +/// gesture becomes deliberate, so an expanded island can shrink while the +/// desktop is still moving rather than snapping shut once it has arrived. +/// +/// Detection only runs while armed — that is, while there is something +/// expanded worth collapsing — so an idle island does no per-frame work. +@MainActor +final class SpacesSwipeMonitor { + private let stateLock = NSLock() + nonisolated(unsafe) private var detector = SpacesSwipeDetector() + nonisolated(unsafe) private var armed = false + + private var onThresholdCrossed: (() -> Void)? + private var started = false + + /// False when MultitouchSupport could not be resolved, which is the signal + /// for callers to lean on their coarser Space-change fallback instead. + var isAvailable: Bool { MultitouchDevice.shared.isAvailable } + + func start(onThresholdCrossed: @escaping () -> Void) { + guard !started else { return } + started = true + self.onThresholdCrossed = onThresholdCrossed + refreshFingerCounts() + MultitouchDevice.shared.start { [weak self] contacts in + self?.handleFrame(contacts) + } + log.notice( + "Spaces swipe detection started — multitouch available: \(self.isAvailable, privacy: .public), finger counts: \(SpacesSwipeFingerCounts.current().sorted().description, privacy: .public)" + ) + } + + func stop() { + guard started else { return } + started = false + onThresholdCrossed = nil + setArmed(false) + MultitouchDevice.shared.stop() + } + + /// Arm only while an expanded surface is on screen. Disarming also clears + /// any partial gesture so the next swipe starts from a clean baseline. + func setArmed(_ isArmed: Bool) { + stateLock.lock() + defer { stateLock.unlock() } + guard armed != isArmed else { return } + armed = isArmed + detector.reset() + } + + /// Re-reads the system trackpad gesture preference. Cheap enough to call + /// whenever settings change; the user can flip this in System Settings + /// while the app is running. + func refreshFingerCounts() { + let counts = SpacesSwipeFingerCounts.current() + stateLock.lock() + detector.acceptedFingerCounts = counts + detector.reset() + stateLock.unlock() + } + + /// Runs on MultitouchSupport's thread at trackpad frame rate. Everything + /// here is bounded arithmetic under a lock; the only main-actor hop is the + /// single threshold crossing, at most once per gesture. + private nonisolated func handleFrame(_ contacts: [MultitouchDevice.Contact]) { + stateLock.lock() + guard armed else { + stateLock.unlock() + return + } + let touchingX = contacts.filter(\.isTouching).map(\.normalizedX) + let crossed = detector.consume(touchingX: touchingX) + stateLock.unlock() + + guard crossed else { return } + Task { @MainActor [weak self] in + self?.onThresholdCrossed?() + } + } +} diff --git a/Tests/CodeIslandTests/FourFingerSwipeGestureTests.swift b/Tests/CodeIslandTests/FourFingerSwipeGestureTests.swift new file mode 100644 index 00000000..5c310e67 --- /dev/null +++ b/Tests/CodeIslandTests/FourFingerSwipeGestureTests.swift @@ -0,0 +1,23 @@ +import XCTest +@testable import CodeIsland + +final class FourFingerSwipeGestureTests: XCTestCase { + func testNoDirectionBelowThreshold() { + XCTAssertNil(FourFingerSwipeGesture.direction(startX: 0.5, currentX: 0.55)) + XCTAssertNil(FourFingerSwipeGesture.direction(startX: 0.5, currentX: 0.45)) + } + + func testRightDirectionAtOrAboveThreshold() { + XCTAssertEqual(FourFingerSwipeGesture.direction(startX: 0.5, currentX: 0.60), .right) + XCTAssertEqual(FourFingerSwipeGesture.direction(startX: 0.5, currentX: 0.70), .right) + } + + func testLeftDirectionAtOrBeyondThreshold() { + XCTAssertEqual(FourFingerSwipeGesture.direction(startX: 0.5, currentX: 0.40), .left) + XCTAssertEqual(FourFingerSwipeGesture.direction(startX: 0.5, currentX: 0.30), .left) + } + + func testThresholdIsTenPercent() { + XCTAssertEqual(FourFingerSwipeGesture.normalizedDisplacementThreshold, 0.10, accuracy: 0.0001) + } +} diff --git a/Tests/CodeIslandTests/IslandSurfaceTests.swift b/Tests/CodeIslandTests/IslandSurfaceTests.swift new file mode 100644 index 00000000..fe06616d --- /dev/null +++ b/Tests/CodeIslandTests/IslandSurfaceTests.swift @@ -0,0 +1,18 @@ +import XCTest +@testable import CodeIsland + +final class IslandSurfaceTests: XCTestCase { + func testCollapsedSurfaceCannotAutoCollapse() { + XCTAssertFalse(IslandSurface.collapsed.canAutoCollapse) + } + + func testApprovalAndQuestionCardsAreProtectedFromAutoCollapse() { + XCTAssertFalse(IslandSurface.approvalCard(sessionId: "s1").canAutoCollapse) + XCTAssertFalse(IslandSurface.questionCard(sessionId: "s1").canAutoCollapse) + } + + func testSessionListAndCompletionCardCanAutoCollapse() { + XCTAssertTrue(IslandSurface.sessionList.canAutoCollapse) + XCTAssertTrue(IslandSurface.completionCard(sessionId: "s1").canAutoCollapse) + } +} diff --git a/Tests/CodeIslandTests/L10nTests.swift b/Tests/CodeIslandTests/L10nTests.swift index 1a9d8cda..73c4f886 100644 --- a/Tests/CodeIslandTests/L10nTests.swift +++ b/Tests/CodeIslandTests/L10nTests.swift @@ -42,6 +42,33 @@ final class L10nTests: XCTestCase { XCTAssertTrue(missingKeys.isEmpty, "German is missing keys: \(missingKeys)") } + func testEveryTranslationContainsAllKeysPresentInEnglish() { + let enKeys = Set(L10n.strings["en"]?.keys ?? Dictionary().keys) + + for language in ["zh", "zh-Hant", "de", "ja", "ko", "tr"] { + let localizedKeys = Set(L10n.strings[language]?.keys ?? Dictionary().keys) + let missingKeys = enKeys.subtracting(localizedKeys) + XCTAssertTrue(missingKeys.isEmpty, "\(language) is missing keys: \(missingKeys)") + } + } + + func testGesturePageNameIsLocalized() { + let expected = [ + "en": "Gestures", + "zh": "手势", + "zh-Hant": "手勢", + "de": "Gesten", + "ja": "ジェスチャ", + "ko": "제스처", + "tr": "Hareketler", + ] + + for (language, translation) in expected { + L10n.shared.language = language + XCTAssertEqual(L10n.shared["gestures"], translation) + } + } + func testTurkishTranslationReturnsCorrectValue() { L10n.shared.language = "tr" diff --git a/Tests/CodeIslandTests/MascotPaletteTests.swift b/Tests/CodeIslandTests/MascotPaletteTests.swift new file mode 100644 index 00000000..8ec2204a --- /dev/null +++ b/Tests/CodeIslandTests/MascotPaletteTests.swift @@ -0,0 +1,86 @@ +import XCTest +@testable import CodeIsland + +final class MascotPaletteTests: XCTestCase { + func testUnknownSourceFallsBackToClawd() { + XCTAssertEqual(MascotPalette.signature(for: "not-a-real-cli"), MascotPalette.fallback) + XCTAssertEqual(MascotPalette.signature(for: ""), MascotPalette.fallback) + } + + /// "claude" has no explicit entry — it is the default branch in MascotView, + /// so it must resolve through the same fallback rather than by accident. + func testClaudeResolvesToFallback() { + XCTAssertEqual(MascotPalette.signature(for: "claude"), MascotPalette.fallback) + } + + func testAliasesShareTheirMascotColor() { + XCTAssertEqual( + MascotPalette.signature(for: "gemini"), + MascotPalette.signature(for: "google-antigravity") + ) + XCTAssertEqual( + MascotPalette.signature(for: "pi"), + MascotPalette.signature(for: "omp") + ) + XCTAssertEqual( + MascotPalette.signature(for: "qoder"), + MascotPalette.signature(for: "qoderwork") + ) + XCTAssertEqual( + MascotPalette.signature(for: "cursor"), + MascotPalette.signature(for: "cursor-cli") + ) + } + + func testBlendingTowardWhiteRaisesEveryChannel() { + let cline = MascotPalette.signature(for: "cline") + let blended = cline.blendedWithWhite(0.62) + XCTAssertGreaterThan(blended.red, cline.red) + XCTAssertGreaterThan(blended.green, cline.green) + XCTAssertGreaterThan(blended.blue, cline.blue) + } + + func testBlendBoundsAreIdentityAndWhite() { + let color = MascotPalette.signature(for: "kimi") + XCTAssertEqual(color.blendedWithWhite(0), color) + + let white = color.blendedWithWhite(1) + XCTAssertEqual(white.red, 1.0, accuracy: 0.0001) + XCTAssertEqual(white.green, 1.0, accuracy: 0.0001) + XCTAssertEqual(white.blue, 1.0, accuracy: 0.0001) + } + + func testBlendClampsOutOfRangeFractions() { + let color = MascotPalette.signature(for: "qwen") + XCTAssertEqual(color.blendedWithWhite(-1), color) + + let clampedHigh = color.blendedWithWhite(5) + XCTAssertEqual(clampedHigh.red, 1.0, accuracy: 0.0001) + } + + /// The point of the blend: even the most saturated mascot must land pale + /// enough to read as tinted light instead of a colored outline. + func testEverySignatureStaysPaleAfterBlending() { + let mix = NotchVisualStyle.contrastEdgeTintWhiteMix + for (source, signature) in MascotPalette.signatureColors { + let blended = signature.blendedWithWhite(mix) + let luminance = 0.2126 * blended.red + 0.7152 * blended.green + 0.0722 * blended.blue + XCTAssertGreaterThan( + luminance, 0.6, + "\(source) is too dark after blending to read as a highlight" + ) + } + } + + /// Channel spread is what carries the hue — the blend must not flatten + /// every mascot into the same near-white. + func testSaturatedMascotsKeepDistinguishableHue() { + let mix = NotchVisualStyle.contrastEdgeTintWhiteMix + for source in ["cline", "kimi", "cursor", "qwen"] { + let blended = MascotPalette.signature(for: source).blendedWithWhite(mix) + let spread = max(blended.red, blended.green, blended.blue) + - min(blended.red, blended.green, blended.blue) + XCTAssertGreaterThan(spread, 0.05, "\(source) lost its hue in the blend") + } + } +} diff --git a/Tests/CodeIslandTests/NotchAnimationSpeedTests.swift b/Tests/CodeIslandTests/NotchAnimationSpeedTests.swift new file mode 100644 index 00000000..9f4e1359 --- /dev/null +++ b/Tests/CodeIslandTests/NotchAnimationSpeedTests.swift @@ -0,0 +1,73 @@ +import XCTest +@testable import CodeIsland + +final class NotchAnimationSpeedTests: XCTestCase { + /// `UserDefaults.double` yields 0 for an absent key. That must read as + /// normal speed — dividing by it would produce an infinite response. + func testUnsetValueReadsAsNormalSpeed() { + XCTAssertEqual(NotchAnimationSpeed.clamped(0), NotchAnimationSpeed.normal) + XCTAssertEqual(NotchAnimationSpeed.clamped(-3), NotchAnimationSpeed.normal) + } + + func testClampsToRange() { + XCTAssertEqual(NotchAnimationSpeed.clamped(0.1), NotchAnimationSpeed.minimum) + XCTAssertEqual(NotchAnimationSpeed.clamped(99), NotchAnimationSpeed.maximum) + XCTAssertEqual(NotchAnimationSpeed.clamped(1.3), 1.3, accuracy: 0.0001) + } + + func testNormalSpeedLeavesBaseResponseUnchanged() { + XCTAssertEqual( + NotchAnimationSpeed.response(base: 0.42, speed: 1.0), + 0.42, + accuracy: 0.0001 + ) + } + + func testHigherSpeedShortensResponse() { + let fast = NotchAnimationSpeed.response(base: 0.42, speed: 2.0) + let normal = NotchAnimationSpeed.response(base: 0.42, speed: 1.0) + XCTAssertLessThan(fast, normal) + XCTAssertEqual(fast, 0.21, accuracy: 0.0001) + } + + func testLowerSpeedLengthensResponse() { + let slow = NotchAnimationSpeed.response(base: 0.42, speed: 0.5) + XCTAssertEqual(slow, 0.84, accuracy: 0.0001) + } + + /// Every reachable slider position must stay a usable spring response — + /// no zero, no runaway. + func testEverySliderPositionProducesSaneResponse() { + var speed = NotchAnimationSpeed.minimum + while speed <= NotchAnimationSpeed.maximum + 0.0001 { + let response = NotchAnimationSpeed.response( + base: NotchAnimationMetrics.baseOpenResponse, + speed: speed + ) + XCTAssertGreaterThan(response, 0.1) + XCTAssertLessThan(response, 1.0) + speed += NotchAnimationSpeed.step + } + } + + func testOpenAndCloseShareTheSameBase() { + XCTAssertEqual( + NotchAnimationMetrics.baseOpenResponse, + NotchAnimationMetrics.baseCloseResponse, + accuracy: 0.0001 + ) + } +} + +/// Shipped defaults for the settings added here. The contrast-edge and +/// hover-open defaults are asserted in `NotchGestureSettingsTests`, which owns +/// that pair. +final class NotchDefaultsTests: XCTestCase { + func testMascotTintShipsOff() { + XCTAssertFalse(SettingsDefaults.tintContrastEdgeWithMascot) + } + + func testAnimationSpeedShipsAtNormal() { + XCTAssertEqual(SettingsDefaults.notchAnimationSpeed, NotchAnimationSpeed.normal) + } +} diff --git a/Tests/CodeIslandTests/NotchGestureInterpreterTests.swift b/Tests/CodeIslandTests/NotchGestureInterpreterTests.swift new file mode 100644 index 00000000..206dc8d1 --- /dev/null +++ b/Tests/CodeIslandTests/NotchGestureInterpreterTests.swift @@ -0,0 +1,257 @@ +import XCTest +@testable import CodeIsland + +final class NotchGestureInterpreterTests: XCTestCase { + func testPhysicalDirectionsMapToNaturalNotchActions() { + // NSEvent's normalized device delta is positive when the fingers move + // left, so the shipped direction advances the filters to the right. + XCTAssertEqual(action(x: 30), .navigateNext) + XCTAssertEqual(action(x: -30), .navigatePrevious) + XCTAssertEqual(action(y: -30), .open) + XCTAssertEqual(action(y: 30), .close) + } + + func testGestureRequiresThresholdAndDominantAxis() { + XCTAssertNil(action(x: 8)) + XCTAssertNil(action(x: 30, y: 28)) + } + + func testGestureEmitsOnlyOnceUntilEnded() { + var interpreter = NotchGestureInterpreter() + + // Negative physical delta is a leftward swipe — see the direction + // mapping asserted in testPhysicalDirectionsMapToNaturalNotchActions. + XCTAssertEqual(interpreter.consume(sample(x: -30)), .navigatePrevious) + XCTAssertNil(interpreter.consume(sample(x: -30))) + XCTAssertNil(interpreter.consume(sample(ended: true))) + XCTAssertEqual(interpreter.consume(sample(x: -30, began: true)), .navigatePrevious) + } + + func testMomentumAndNonPreciseScrollAreIgnored() { + var interpreter = NotchGestureInterpreter() + + XCTAssertNil(interpreter.consume(sample(y: -40, momentum: true))) + XCTAssertNil(interpreter.consume(sample(y: -40, precise: false))) + } + + func testDeviceDirectionMetadataNormalizesPhysicalMovement() { + let natural = NotchScrollSample( + scrollingDeltaX: 30, + scrollingDeltaY: -20, + directionInvertedFromDevice: true + ) + let traditional = NotchScrollSample( + scrollingDeltaX: 30, + scrollingDeltaY: -20, + directionInvertedFromDevice: false + ) + + XCTAssertEqual(natural.physicalDeltaX, -30) + XCTAssertEqual(natural.physicalDeltaY, 20) + XCTAssertEqual(traditional.physicalDeltaX, 30) + XCTAssertEqual(traditional.physicalDeltaY, -20) + } + + private func action(x: CGFloat = 0, y: CGFloat = 0) -> NotchGestureAction? { + var interpreter = NotchGestureInterpreter() + return interpreter.consume(sample(x: x, y: y)) + } + + private func sample( + x: CGFloat = 0, + y: CGFloat = 0, + began: Bool = false, + ended: Bool = false, + momentum: Bool = false, + precise: Bool = true + ) -> NotchScrollSample { + NotchScrollSample( + physicalDeltaX: x, + physicalDeltaY: y, + began: began, + ended: ended, + momentum: momentum, + precise: precise + ) + } +} + +final class NotchGestureHitboxTests: XCTestCase { + private let panelFrame = NSRect(x: 100, y: 300, width: 600, height: 500) + + func testIncludesExactScreenTopAndPhysicalNotchBand() { + let hitbox = NotchGestureHitbox( + panelFrame: panelFrame, + regionWidth: 240, + regionHeight: 38 + ) + + XCTAssertTrue(hitbox.contains(NSPoint(x: panelFrame.midX, y: panelFrame.maxY))) + XCTAssertTrue(hitbox.contains(NSPoint(x: panelFrame.midX, y: panelFrame.maxY - 20))) + } + + func testRemainsRestrictedToCenteredNotchHeader() { + let hitbox = NotchGestureHitbox( + panelFrame: panelFrame, + regionWidth: 240, + regionHeight: 38 + ) + + XCTAssertFalse(hitbox.contains(NSPoint(x: panelFrame.minX + 1, y: panelFrame.maxY - 20))) + XCTAssertFalse(hitbox.contains(NSPoint(x: panelFrame.midX, y: panelFrame.maxY - 60))) + } + + func testExpandedVisiblePanelExpandsGestureRegionInBothAxes() { + let collapsed = NotchGestureHitbox(panelFrame: panelFrame, regionWidth: 240, regionHeight: 38) + let expanded = NotchGestureHitbox(panelFrame: panelFrame, regionWidth: 520, regionHeight: 220) + let point = NSPoint(x: panelFrame.midX + 220, y: panelFrame.maxY - 160) + + XCTAssertFalse(collapsed.contains(point)) + XCTAssertTrue(expanded.contains(point)) + XCTAssertFalse(expanded.contains(NSPoint(x: panelFrame.midX, y: panelFrame.maxY - 240))) + } +} + +final class NotchGestureRegionMetricsTests: XCTestCase { + func testUsesRenderedWidthInsteadOfAnimatedTargetWidth() { + let resolved = NotchGestureRegionMetrics.resolvedSize( + renderedSize: CGSize(width: 360, height: 120), + fallbackWidth: 580, + headerHeight: 38, + isExpanded: true + ) + + XCTAssertEqual(resolved, CGSize(width: 360, height: 120)) + } + + func testCollapsedRegionKeepsRenderedWidthButCapsHeightToHeader() { + let resolved = NotchGestureRegionMetrics.resolvedSize( + renderedSize: CGSize(width: 300, height: 160), + fallbackWidth: 240, + headerHeight: 38, + isExpanded: false + ) + + XCTAssertEqual(resolved, CGSize(width: 300, height: 38)) + } +} + +@MainActor +final class NotchGestureMonitorTests: XCTestCase { + func testPanelContextReadsTheControllersLiveFrame() { + var frame = NSRect(x: 100, y: 300, width: 600, height: 500) + let context = NotchPanelInteractionContext( + panelFrame: { frame }, + isActiveTerminalForeground: { false } + ) + + XCTAssertEqual(context.panelFrame(), frame) + frame.origin.x = 220 + XCTAssertEqual(context.panelFrame(), frame) + } + + func testObservedActionDeliveryDefersUIHandlerUntilScheduledWorkRuns() { + var scheduledWork: (() -> Void)? + var receivedActions: [NotchGestureAction] = [] + let delivery = NotchGestureActionDelivery { work in + scheduledWork = work + } + + delivery.deliver(.open) { action in + receivedActions.append(action) + } + + XCTAssertTrue(receivedActions.isEmpty) + scheduledWork?() + XCTAssertEqual(receivedActions, [.open]) + } + + func testObservedSwipeDownEmitsOpenBeforeGestureEnds() { + let panelFrame = NSRect(x: 100, y: 300, width: 600, height: 500) + let location = NSPoint(x: panelFrame.midX, y: panelFrame.maxY) + let monitor = NotchGestureMonitor() + monitor.isEnabled = true + monitor.updateRegion(width: 240, height: 38) + + XCTAssertNil(monitor.consumeObservedSample(sample(y: 0, began: true), at: location, panelFrame: panelFrame)) + XCTAssertNil(monitor.consumeObservedSample(sample(y: -12), at: location, panelFrame: panelFrame)) + XCTAssertEqual(monitor.consumeObservedSample(sample(y: -13), at: location, panelFrame: panelFrame), .open) + XCTAssertNil(monitor.consumeObservedSample(sample(ended: true), at: location, panelFrame: panelFrame)) + } + + func testObservedSwipeWorksBelowHeaderWhenExpandedRegionIncludesIt() { + let panelFrame = NSRect(x: 100, y: 300, width: 600, height: 500) + let location = NSPoint(x: panelFrame.midX + 180, y: panelFrame.maxY - 160) + let monitor = NotchGestureMonitor() + monitor.isEnabled = true + monitor.updateRegion(width: 520, height: 220) + + XCTAssertNil(monitor.consumeObservedSample(sample(y: 0, began: true), at: location, panelFrame: panelFrame)) + XCTAssertEqual(monitor.consumeObservedSample(sample(y: 25), at: location, panelFrame: panelFrame), .close) + } + + func testLiveVisibleContentFrameOverridesStaleMeasuredRegion() { + let panelFrame = NSRect(x: 100, y: 300, width: 600, height: 500) + let visibleContentFrame = NSRect(x: 140, y: 580, width: 520, height: 220) + let location = NSPoint(x: panelFrame.midX + 220, y: panelFrame.maxY - 180) + let monitor = NotchGestureMonitor() + monitor.isEnabled = true + monitor.updateRegion(width: 240, height: 38) + monitor.updateVisibleContentFrame(visibleContentFrame, relativeTo: panelFrame) + + XCTAssertNil(monitor.consumeObservedSample(sample(y: 0, began: true), at: location, panelFrame: panelFrame)) + XCTAssertEqual(monitor.consumeObservedSample(sample(y: 25), at: location, panelFrame: panelFrame), .close) + } + + func testPanelEventInTransparentRemainderIsRejected() { + let panelFrame = NSRect(x: 100, y: 300, width: 600, height: 500) + let visibleContentFrame = NSRect(x: 140, y: 580, width: 520, height: 220) + let location = NSPoint(x: panelFrame.midX, y: visibleContentFrame.minY - 40) + let monitor = NotchGestureMonitor() + monitor.isEnabled = true + monitor.updateRegion(width: 240, height: 38) + monitor.updateVisibleContentFrame(visibleContentFrame, relativeTo: panelFrame) + + XCTAssertNil(monitor.consumeObservedSample(sample(y: 25, began: true), at: location, panelFrame: panelFrame)) + } + + func testLiveVisibleContentFrameTracksPanelMovement() { + let panelFrame = NSRect(x: 100, y: 300, width: 600, height: 500) + let visibleContentFrame = NSRect(x: 140, y: 580, width: 520, height: 220) + let movedPanelFrame = panelFrame.offsetBy(dx: 700, dy: -120) + let originalLocation = NSPoint(x: visibleContentFrame.midX, y: visibleContentFrame.midY) + let movedLocation = NSPoint(x: originalLocation.x + 700, y: originalLocation.y - 120) + let monitor = NotchGestureMonitor() + monitor.isEnabled = true + monitor.updateVisibleContentFrame(visibleContentFrame, relativeTo: panelFrame) + + XCTAssertNil(monitor.consumeObservedSample( + sample(y: 25, began: true), + at: originalLocation, + panelFrame: movedPanelFrame + )) + XCTAssertNil(monitor.consumeObservedSample( + sample(y: 0, began: true), + at: movedLocation, + panelFrame: movedPanelFrame + )) + XCTAssertEqual(monitor.consumeObservedSample( + sample(y: 25), + at: movedLocation, + panelFrame: movedPanelFrame + ), .close) + } + + private func sample( + y: CGFloat = 0, + began: Bool = false, + ended: Bool = false + ) -> NotchScrollSample { + NotchScrollSample( + physicalDeltaX: 0, + physicalDeltaY: y, + began: began, + ended: ended + ) + } +} diff --git a/Tests/CodeIslandTests/NotchGesturePolicyTests.swift b/Tests/CodeIslandTests/NotchGesturePolicyTests.swift new file mode 100644 index 00000000..bb1b513d --- /dev/null +++ b/Tests/CodeIslandTests/NotchGesturePolicyTests.swift @@ -0,0 +1,32 @@ +import XCTest +@testable import CodeIsland + +final class NotchGesturePolicyTests: XCTestCase { + func testOnlyCollapsedNotchWithSessionsCanOpen() { + XCTAssertTrue(NotchGesturePolicy.canOpen(surface: .collapsed, hasSessions: true)) + XCTAssertFalse(NotchGesturePolicy.canOpen(surface: .sessionList, hasSessions: true)) + XCTAssertFalse(NotchGesturePolicy.canOpen(surface: .collapsed, hasSessions: false)) + } + + func testCloseProtectsApprovalAndQuestionCards() { + XCTAssertTrue(NotchGesturePolicy.canClose(surface: .sessionList)) + XCTAssertTrue(NotchGesturePolicy.canClose(surface: .completionCard(sessionId: "s"))) + XCTAssertFalse(NotchGesturePolicy.canClose(surface: .approvalCard(sessionId: "s"))) + XCTAssertFalse(NotchGesturePolicy.canClose(surface: .questionCard(sessionId: "s"))) + XCTAssertFalse(NotchGesturePolicy.canClose(surface: .collapsed)) + } + + func testFilterNavigationUsesNaturalDirectionAndClamps() { + XCTAssertEqual(NotchGesturePolicy.filterMode(from: "all", action: .navigateNext, controlsVisible: true, inverted: false), "status") + XCTAssertEqual(NotchGesturePolicy.filterMode(from: "status", action: .navigateNext, controlsVisible: true, inverted: false), "cli") + XCTAssertEqual(NotchGesturePolicy.filterMode(from: "cli", action: .navigateNext, controlsVisible: true, inverted: false), "cli") + XCTAssertEqual(NotchGesturePolicy.filterMode(from: "status", action: .navigatePrevious, controlsVisible: true, inverted: false), "all") + XCTAssertEqual(NotchGesturePolicy.filterMode(from: "all", action: .navigatePrevious, controlsVisible: true, inverted: false), "all") + XCTAssertNil(NotchGesturePolicy.filterMode(from: "all", action: .navigateNext, controlsVisible: false, inverted: false)) + } + + func testFilterNavigationCanBeInverted() { + XCTAssertEqual(NotchGesturePolicy.filterMode(from: "status", action: .navigateNext, controlsVisible: true, inverted: true), "all") + XCTAssertEqual(NotchGesturePolicy.filterMode(from: "status", action: .navigatePrevious, controlsVisible: true, inverted: true), "cli") + } +} diff --git a/Tests/CodeIslandTests/NotchGestureSettingsTests.swift b/Tests/CodeIslandTests/NotchGestureSettingsTests.swift new file mode 100644 index 00000000..a2593935 --- /dev/null +++ b/Tests/CodeIslandTests/NotchGestureSettingsTests.swift @@ -0,0 +1,20 @@ +import XCTest +@testable import CodeIsland + +final class NotchGestureSettingsTests: XCTestCase { + func testHoverSettingsShipWithIntendedDefaults() { + // Hover-to-open is opt-in: brushing the notch on the way to the menu + // bar shouldn't expand the island. + XCTAssertFalse(SettingsDefaults.openOnHover) + XCTAssertEqual(SettingsDefaults.hoverOpenDelay, 0.5, accuracy: 0.001) + XCTAssertFalse(SettingsDefaults.invertHorizontalSwipeDirection) + XCTAssertTrue(SettingsDefaults.showContrastEdge) + XCTAssertEqual(SettingsKey.showContrastEdge, "showContrastEdge") + } + + func testHoverDelayClampsValuesToSupportedRange() { + XCTAssertEqual(HoverOpenDelay.clamped(-4), 0.1, accuracy: 0.001) + XCTAssertEqual(HoverOpenDelay.clamped(0.7), 0.7, accuracy: 0.001) + XCTAssertEqual(HoverOpenDelay.clamped(8), 1.5, accuracy: 0.001) + } +} diff --git a/Tests/CodeIslandTests/NotchPanelViewTests.swift b/Tests/CodeIslandTests/NotchPanelViewTests.swift index f9aac277..c98e93c9 100644 --- a/Tests/CodeIslandTests/NotchPanelViewTests.swift +++ b/Tests/CodeIslandTests/NotchPanelViewTests.swift @@ -219,6 +219,55 @@ final class NotchHoverInteractionTests: XCTestCase { XCTAssertEqual(NotchHoverInteraction.nextPhase(from: .prehover, event: .collapseDelayElapsed), .prehover) } + func testDisablingHoverKeepsImmediatePrehoverButDisablesFullOpen() { + XCTAssertEqual(NotchHoverInteraction.nextPhase(from: .collapsed, event: .mouseEntered), .prehover) + XCTAssertFalse(NotchHoverInteraction.shouldScheduleOpen(openOnHover: false)) + XCTAssertTrue(NotchHoverInteraction.shouldScheduleOpen(openOnHover: true)) + XCTAssertEqual(NotchHoverInteraction.nextPhase(from: .expanded, event: .mouseEntered), .expanded) + } + + func testMouseLeaveCloseDelayRemainsUnchanged() { + XCTAssertEqual(NotchHoverInteraction.collapseDelay, 0.5, accuracy: 0.001) + } + + func testContrastEdgeAlwaysAppearsAcrossInteractionStates() { + XCTAssertTrue(NotchVisualStyle.showsContrastEdge(hasNotch: true, phase: .collapsed)) + XCTAssertTrue(NotchVisualStyle.showsContrastEdge(hasNotch: true, phase: .prehover)) + XCTAssertTrue(NotchVisualStyle.showsContrastEdge(hasNotch: true, phase: .expanded)) + XCTAssertTrue(NotchVisualStyle.showsContrastEdge(hasNotch: false, phase: .collapsed)) + } + + func testContrastEdgeStrengthBuildsFromInvisibleTopToVisibleBottom() { + XCTAssertLessThan(NotchVisualStyle.contrastEdgeWidth, 1) + XCTAssertEqual(NotchVisualStyle.contrastEdgeTopOpacity, 0.005, accuracy: 0.0001) + XCTAssertEqual(NotchVisualStyle.contrastEdgeSideOpacity, 0.07, accuracy: 0.0001) + XCTAssertEqual(NotchVisualStyle.contrastEdgeBottomOpacity, 0.20, accuracy: 0.0001) + XCTAssertEqual(NotchVisualStyle.contrastEdgeUpperHoldLocation, 0.18, accuracy: 0.0001) + XCTAssertEqual(NotchVisualStyle.contrastEdgeSideLocation, 0.72, accuracy: 0.0001) + XCTAssertEqual(NotchVisualStyle.contrastEdgeBlurRadius, 0, accuracy: 0.001) + } + + func testCloseUsesTheSameResponseTimeAsOpen() { + XCTAssertEqual(NotchAnimationMetrics.closeResponse, NotchAnimationMetrics.openResponse, accuracy: 0.001) + XCTAssertEqual(NotchAnimationMetrics.surfaceTransition(for: .collapsed), .close) + XCTAssertEqual(NotchAnimationMetrics.surfaceTransition(for: .sessionList), .open) + } + + func testContrastEdgePathStaysOpenAcrossHiddenTop() { + let path = NotchVisibleOutlineShape( + topExtension: 14, + bottomRadius: 24, + minHeight: 38 + ).path(in: CGRect(x: 0, y: 0, width: 300, height: 120)) + var closesAcrossTop = false + path.forEach { element in + if case .closeSubpath = element { + closesAcrossTop = true + } + } + XCTAssertFalse(closesAcrossTop) + } + func testWidthScaleSliderUsesOnePercentSteps() { XCTAssertEqual(NotchWidthScale.min, 50) XCTAssertEqual(NotchWidthScale.max, 150) diff --git a/Tests/CodeIslandTests/PanelWindowControllerTests.swift b/Tests/CodeIslandTests/PanelWindowControllerTests.swift index b15dae01..ee7d2331 100644 --- a/Tests/CodeIslandTests/PanelWindowControllerTests.swift +++ b/Tests/CodeIslandTests/PanelWindowControllerTests.swift @@ -3,6 +3,32 @@ import XCTest @testable import CodeIsland final class PanelWindowControllerTests: XCTestCase { + func testSpaceTransitionKeepsPanelVisibleUntilFullscreenDetectionSettles() { + XCTAssertFalse(PanelSpaceTransitionPolicy.immediateFullscreenLatch) + XCTAssertGreaterThan(PanelSpaceTransitionPolicy.fullscreenEvaluationDelay, 0) + } + + func testSettledSpaceUsesDetectedFullscreenState() { + XCTAssertTrue(PanelSpaceTransitionPolicy.settledFullscreenLatch(isFullscreen: true)) + XCTAssertFalse(PanelSpaceTransitionPolicy.settledFullscreenLatch(isFullscreen: false)) + } + + /// `.managed` tells Spaces the window belongs to exactly one Space and should be + /// reassigned as the user switches — the opposite membership model from + /// `.canJoinAllSpaces`. Combining them is what produced the drop-out/reappear + /// glitch during a Space swipe: `.stationary` (pinned like the menu bar, no + /// transition participation) is the correct pairing with `.canJoinAllSpaces`. + func testPanelStaysPinnedAcrossSpaceTransitionsInsteadOfBeingSpaceManaged() { + let behavior = PanelWindowBehavior.collectionBehavior + + XCTAssertTrue(behavior.contains(.canJoinAllSpaces)) + XCTAssertTrue(behavior.contains(.stationary)) + XCTAssertTrue(behavior.contains(.fullScreenAuxiliary)) + XCTAssertTrue(behavior.contains(.ignoresCycle)) + XCTAssertFalse(behavior.contains(.managed)) + XCTAssertFalse(behavior.contains(.canJoinAllApplications)) + } + func testScreenHopMotionUsesMoreVisibleTiming() { let motion = PanelWindowController.screenHopMotion() diff --git a/Tests/CodeIslandTests/SpacesSwipeDetectorTests.swift b/Tests/CodeIslandTests/SpacesSwipeDetectorTests.swift new file mode 100644 index 00000000..fcd4638c --- /dev/null +++ b/Tests/CodeIslandTests/SpacesSwipeDetectorTests.swift @@ -0,0 +1,121 @@ +import XCTest +@testable import CodeIsland + +final class SpacesSwipeFingerCountsTests: XCTestCase { + func testFourFingerOnlyWhenThreeFingerDisabled() { + XCTAssertEqual( + SpacesSwipeFingerCounts.resolve(threeFinger: 0, fourFinger: 2), + [4] + ) + } + + func testThreeFingerOnlyWhenFourFingerDisabled() { + XCTAssertEqual( + SpacesSwipeFingerCounts.resolve(threeFinger: 2, fourFinger: 0), + [3] + ) + } + + func testBothWhenBothEnabled() { + XCTAssertEqual( + SpacesSwipeFingerCounts.resolve(threeFinger: 2, fourFinger: 2), + [3, 4] + ) + } + + /// The user turned the system gesture off entirely — there is no Spaces + /// swipe to observe, so detection should stay silent rather than guess. + func testEmptyWhenBothDisabled() { + XCTAssertEqual( + SpacesSwipeFingerCounts.resolve(threeFinger: 0, fourFinger: 0), + [] + ) + } + + /// Preferences unreadable: accept either, since a spurious collapse costs + /// far less than never collapsing at all. + func testAcceptsBothWhenPreferencesAbsent() { + XCTAssertEqual( + SpacesSwipeFingerCounts.resolve(threeFinger: nil, fourFinger: nil), + [3, 4] + ) + } +} + +final class SpacesSwipeDetectorTests: XCTestCase { + private func fourFingers(at x: CGFloat) -> [CGFloat] { + [x - 0.05, x, x + 0.05, x + 0.10] + } + + func testFiresOnceThresholdCrossedToTheRight() { + var detector = SpacesSwipeDetector(acceptedFingerCounts: [4]) + XCTAssertFalse(detector.consume(touchingX: fourFingers(at: 0.30))) + XCTAssertFalse(detector.consume(touchingX: fourFingers(at: 0.35))) + XCTAssertTrue(detector.consume(touchingX: fourFingers(at: 0.41))) + } + + func testFiresOnThresholdCrossedToTheLeft() { + var detector = SpacesSwipeDetector(acceptedFingerCounts: [4]) + XCTAssertFalse(detector.consume(touchingX: fourFingers(at: 0.70))) + XCTAssertTrue(detector.consume(touchingX: fourFingers(at: 0.59))) + } + + /// One collapse per gesture — the panel must not be re-collapsed on every + /// remaining frame of the same swipe. + func testDoesNotFireTwiceWithinOneGesture() { + var detector = SpacesSwipeDetector(acceptedFingerCounts: [4]) + _ = detector.consume(touchingX: fourFingers(at: 0.30)) + XCTAssertTrue(detector.consume(touchingX: fourFingers(at: 0.45))) + XCTAssertFalse(detector.consume(touchingX: fourFingers(at: 0.60))) + XCTAssertFalse(detector.consume(touchingX: fourFingers(at: 0.75))) + } + + func testSmallJitterNeverFires() { + var detector = SpacesSwipeDetector(acceptedFingerCounts: [4]) + _ = detector.consume(touchingX: fourFingers(at: 0.50)) + for x in stride(from: CGFloat(0.50), through: 0.58, by: 0.01) { + XCTAssertFalse(detector.consume(touchingX: fourFingers(at: x))) + } + } + + func testWrongFingerCountIsIgnored() { + var detector = SpacesSwipeDetector(acceptedFingerCounts: [4]) + XCTAssertFalse(detector.consume(touchingX: [0.30, 0.35])) + XCTAssertFalse(detector.consume(touchingX: [0.60, 0.65])) + } + + /// Lifting fingers mid-swipe must rebaseline, so a later swipe starting + /// from the new position isn't measured against the abandoned one. + func testLiftingFingersResetsBaseline() { + var detector = SpacesSwipeDetector(acceptedFingerCounts: [4]) + _ = detector.consume(touchingX: fourFingers(at: 0.30)) + XCTAssertFalse(detector.consume(touchingX: [])) + XCTAssertFalse(detector.consume(touchingX: fourFingers(at: 0.45))) + XCTAssertTrue(detector.consume(touchingX: fourFingers(at: 0.56))) + } + + func testDisabledFingerCountsNeverFire() { + var detector = SpacesSwipeDetector(acceptedFingerCounts: []) + _ = detector.consume(touchingX: fourFingers(at: 0.20)) + XCTAssertFalse(detector.consume(touchingX: fourFingers(at: 0.90))) + } + + /// A gesture that ends and restarts should be able to fire again. + func testFiresAgainOnASecondGesture() { + var detector = SpacesSwipeDetector(acceptedFingerCounts: [4]) + _ = detector.consume(touchingX: fourFingers(at: 0.30)) + XCTAssertTrue(detector.consume(touchingX: fourFingers(at: 0.45))) + _ = detector.consume(touchingX: []) + _ = detector.consume(touchingX: fourFingers(at: 0.30)) + XCTAssertTrue(detector.consume(touchingX: fourFingers(at: 0.45))) + } + + /// Only fingers actually down should steer the average. + func testHoveringContactsAreExcludedByCaller() { + var detector = SpacesSwipeDetector(acceptedFingerCounts: [4]) + // Caller filters to touching contacts; a 5-element frame that filters + // down to 4 is what the monitor hands over. + XCTAssertFalse(detector.consume(touchingX: fourFingers(at: 0.30))) + XCTAssertTrue(detector.consume(touchingX: fourFingers(at: 0.42))) + } +}