From 781db32703aa01688604a1161e550f4e796268f0 Mon Sep 17 00:00:00 2001 From: Mrjamedd Date: Fri, 14 Aug 2026 11:48:51 -0400 Subject: [PATCH 01/18] docs: design notch gesture support --- ...2026-08-14-notch-gesture-support-design.md | 153 ++++++++++++++++++ 1 file changed, 153 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-14-notch-gesture-support-design.md diff --git a/docs/superpowers/specs/2026-08-14-notch-gesture-support-design.md b/docs/superpowers/specs/2026-08-14-notch-gesture-support-design.md new file mode 100644 index 00000000..0cc7bef2 --- /dev/null +++ b/docs/superpowers/specs/2026-08-14-notch-gesture-support-design.md @@ -0,0 +1,153 @@ +# Notch Gesture Support Design + +## Summary + +Add a dedicated **Gestures** settings page and native trackpad gesture support to CodeIsland's notch header. Users can choose whether hovering opens the notch, adjust the hover-to-open delay, click or swipe down to open, swipe up to close eligible surfaces, and swipe horizontally to change the existing `ALL` / `STA` / `CLI` session filter. + +The change stays limited to notch interaction behavior, settings, localization, diagnostics, and focused tests. It does not alter session processing, permission decisions, question answers, or terminal activation. + +## Goals + +- Let users disable hover-to-open without losing direct ways to open the notch. +- Make hover-to-open timing configurable from 0.1 to 1.5 seconds. +- Add reliable two-finger trackpad gestures scoped to the visible notch header. +- Preserve current safety rules for approval and question cards. +- Preserve existing defaults for current users. + +## Non-goals + +- Global gestures outside the notch header. +- Keyboard shortcut changes. +- Gesture customization or arbitrary gesture-to-action mapping. +- Changes to mouse-leave timing, permission handling, session data, or panel layout. +- Navigation between individual session cards. + +## User Experience + +### Gestures settings page + +Add `Gestures` to the settings sidebar immediately after `Behavior`. + +The page contains: + +1. **Open on hover**, enabled by default to preserve current behavior. +2. **Hover delay**, visible or enabled only when Open on hover is enabled. + - Range: 0.1 to 1.5 seconds. + - Step: 0.1 seconds. + - Default: 0.5 seconds, matching the current fixed delay. +3. The existing hover haptic toggle and intensity control, moved from Behavior to Gestures so all hover interaction preferences remain together. +4. A concise, localized gesture reference describing click, swipe down, swipe up, and horizontal filter navigation. + +The existing `collapseOnMouseLeave` preference and its 0.5-second close delay remain unchanged. + +### Opening + +- When **Open on hover** is enabled, pointer entry keeps the current prehover animation and opens after the configured delay. +- When **Open on hover** is disabled, pointer entry does not start the prehover or opening timer. +- Clicking the collapsed compact notch opens the session list immediately. Clicking is open-only; it does not close an expanded notch. +- A two-finger swipe down over the notch header opens the session list immediately. +- Click and swipe-down are intentional actions, so they open regardless of Smart Suppress. Smart Suppress continues to affect hover-only opening. + +### Closing + +- Leaving an expanded view keeps the current mouse-leave behavior and 0.5-second grace delay. +- A two-finger swipe up over the notch header closes `.sessionList` and `.completionCard` immediately. +- Swipe up is ignored for `.approvalCard` and `.questionCard`. It never approves, denies, answers, skips, or dismisses a pending request. +- Swiping up while already collapsed is a no-op. + +### Filter navigation + +Horizontal two-finger swipes navigate the existing `ALL` / `STA` / `CLI` session filters when the expanded session-list header is showing those controls. + +- A physical swipe left advances the selection visually to the right: `ALL` to `STA`, then `STA` to `CLI`. +- A physical swipe right moves the selection visually to the left. +- Navigation stops at `ALL` and `CLI`; it never wraps. +- Horizontal navigation is ignored when the filter controls are not visible. + +### Gesture scope + +Gestures are recognized only while the pointer is inside the visible compact/header strip of the notch. Transparent parts of the panel window and the scrollable session-card body do not participate. This prevents interference with ordinary scrolling elsewhere. + +## Architecture + +### Gesture interpreter + +Add a small pure Swift gesture interpreter in the CodeIsland target. It consumes normalized AppKit scroll samples and emits semantic actions: + +- `open` +- `close` +- `navigatePrevious` +- `navigateNext` + +The interpreter: + +- accumulates precise scroll deltas for one gesture sequence; +- locks to the dominant axis before emitting an action; +- normalizes device-direction metadata so physical finger movement follows the agreed natural direction; +- ignores momentum-only events; +- emits at most one action per gesture sequence; +- resets when the gesture ends or is cancelled; +- uses a small activation threshold to reject incidental trackpad noise. + +All threshold, axis-locking, direction, and one-action behavior is unit-testable without creating an AppKit window. + +### AppKit event monitor + +Add a lifecycle-owned local `NSEvent` monitor for `.scrollWheel` events. The monitor forwards precise samples to the interpreter only while SwiftUI reports that the pointer is inside the notch header gesture region. + +The monitor does not install a global listener and does not interpret events outside the region. It consumes a scroll sequence only after the interpreter recognizes a notch action, avoiding broad interference with regular scrolling. + +The monitor exposes semantic actions to `NotchPanelView`; it does not mutate `AppState` or settings directly. + +### SwiftUI action routing + +`NotchPanelView` owns the interaction policy: + +- open actions set the surface to `.sessionList` and preserve the existing active-session fallback; +- close actions check the current surface and preserve protected approval/question cards; +- filter actions update `sessionGroupingMode` using the fixed ordered list `all`, `status`, `cli` with clamped indices; +- header hover state enables or disables event interpretation; +- click-to-open attaches to the collapsed compact header without covering expanded buttons or cards; +- hover opening reads the new settings instead of the current fixed delay. + +Keep the pure filter-step calculation separate from rendering so boundary behavior is unit-tested. + +### Settings and localization + +Add persisted settings with registered defaults: + +- `openOnHover: Bool = true` +- `hoverOpenDelay: Double = 0.5` + +Add a `.gestures` settings page, localized sidebar title, control labels, descriptions, and gesture reference strings for every localization dictionary already maintained in `L10n.swift`. + +Include the new settings in diagnostics export alongside the existing interaction preferences. + +## Error and Edge-case Handling + +- A missing or malformed saved hover delay is clamped to the supported 0.1-to-1.5-second range before scheduling a timer. +- Disabling Open on hover cancels any pending hover-open timer and clears prehover state. +- Re-entering during the mouse-leave grace period cancels collapse exactly as it does today. +- Gesture actions are state-aware and become no-ops when the current surface cannot accept them. +- Momentum scrolling cannot trigger a second action after the user's fingers leave the trackpad. +- Filter navigation remains stable if session count changes during a gesture. + +## Testing + +Add focused unit tests for: + +- gesture axis locking and activation threshold; +- physical swipe directions, including device-direction normalization; +- one action per gesture and momentum suppression; +- swipe-down open and swipe-up close action classification; +- filter movement in both directions and clamping at `ALL` / `CLI`; +- protected approval/question surfaces ignoring close actions; +- hover-delay clamping and default value; +- existing three-stage hover behavior using a configurable delay without changing the 0.5-second close delay; +- settings page registration and localization-key completeness where existing test patterns support it. + +Validation will run the focused gesture/settings tests first, followed by the full Swift test suite and a debug build. + +## Pull Request Shape + +Use branch `agent/notch-gesture-support` and open a draft PR against `wxtsky/CodeIsland:main` from the user's fork. The PR will contain only the gesture implementation, settings/localization updates, diagnostics update, tests, and this design document. From 1605a7b8e3521d2e751a0bce1364e96e97b64727 Mon Sep 17 00:00:00 2001 From: Mrjamedd Date: Fri, 14 Aug 2026 11:52:35 -0400 Subject: [PATCH 02/18] docs: plan notch gesture support --- .../plans/2026-08-14-notch-gesture-support.md | 505 ++++++++++++++++++ 1 file changed, 505 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-14-notch-gesture-support.md diff --git a/docs/superpowers/plans/2026-08-14-notch-gesture-support.md b/docs/superpowers/plans/2026-08-14-notch-gesture-support.md new file mode 100644 index 00000000..b2c44eeb --- /dev/null +++ b/docs/superpowers/plans/2026-08-14-notch-gesture-support.md @@ -0,0 +1,505 @@ +# Notch Gesture Support Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add configurable hover opening, click opening, local two-finger trackpad gestures, and horizontal `ALL` / `STA` / `CLI` filter navigation to CodeIsland's notch header. + +**Architecture:** A pure `NotchGestureInterpreter` converts normalized AppKit scroll samples into semantic actions, while a lifecycle-owned local event monitor observes only the notch header region. `NotchPanelView` owns state-aware action routing, and persisted settings plus a dedicated Gestures page control hover behavior. + +**Tech Stack:** Swift 5.9+, SwiftUI, AppKit `NSEvent`, UserDefaults/AppStorage, XCTest, Swift Package Manager + +**Spec:** `docs/superpowers/specs/2026-08-14-notch-gesture-support-design.md` + +## Global Constraints + +- Keep the deployment floor at macOS 14 and add no dependencies. +- Recognize gestures only while the pointer is inside the visible notch header. +- Default Open on hover to `true` and hover delay to `0.5` seconds. +- Clamp hover delay to `0.1...1.5` seconds in `0.1`-second UI steps. +- Keep the existing `0.5`-second mouse-leave close delay unchanged. +- Never let a gesture approve, deny, answer, skip, or dismiss a pending request. +- Horizontal physical swipe left navigates right; physical swipe right navigates left; filters clamp without wrapping. +- Preserve Smart Suppress for hover opening only; click and swipe-down are explicit opening actions. + +--- + +### Task 1: Persisted Hover Settings + +**Files:** +- Modify: `Sources/CodeIsland/Settings.swift` +- Create: `Tests/CodeIslandTests/NotchGestureSettingsTests.swift` + +**Interfaces:** +- Produces: `HoverOpenDelay.minimum`, `.maximum`, `.step`, and `clamped(_:)`. +- Produces: `SettingsKey.openOnHover`, `SettingsKey.hoverOpenDelay`. +- Produces: `SettingsDefaults.openOnHover == true`, `SettingsDefaults.hoverOpenDelay == 0.5`. +- Produces: `SettingsManager.openOnHover: Bool`, `SettingsManager.hoverOpenDelay: Double`. + +- [ ] **Step 1: Write failing settings tests** + +```swift +import XCTest +@testable import CodeIsland + +final class NotchGestureSettingsTests: XCTestCase { + func testHoverSettingsPreserveExistingDefaults() { + XCTAssertTrue(SettingsDefaults.openOnHover) + XCTAssertEqual(SettingsDefaults.hoverOpenDelay, 0.5, accuracy: 0.001) + } + + func testHoverDelayBoundsAndStepAreStable() { + XCTAssertEqual(HoverOpenDelay.minimum, 0.1, accuracy: 0.001) + XCTAssertEqual(HoverOpenDelay.maximum, 1.5, accuracy: 0.001) + XCTAssertEqual(HoverOpenDelay.step, 0.1, accuracy: 0.001) + } + + func testHoverDelayClampsMalformedSavedValues() { + 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) + } +} +``` + +- [ ] **Step 2: Run the focused tests and verify failure** + +Run: `swift test --filter NotchGestureSettingsTests` + +Expected: compilation fails because the new settings symbols do not exist. + +- [ ] **Step 3: Add the minimal settings implementation** + +Add near the other settings value helpers: + +```swift +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) + } +} +``` + +Add keys, defaults, default registration, and manager accessors: + +```swift +static let openOnHover = "openOnHover" +static let hoverOpenDelay = "hoverOpenDelay" + +static let openOnHover = true +static let hoverOpenDelay = 0.5 + +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) } +} +``` + +- [ ] **Step 4: Run the focused tests and verify pass** + +Run: `swift test --filter NotchGestureSettingsTests` + +Expected: PASS. + +- [ ] **Step 5: Commit the settings contract** + +```bash +git add Sources/CodeIsland/Settings.swift Tests/CodeIslandTests/NotchGestureSettingsTests.swift +git commit -m "feat(settings): add notch hover preferences" +``` + +### Task 2: Pure Trackpad Gesture Interpreter + +**Files:** +- Create: `Sources/CodeIsland/NotchGesture.swift` +- Create: `Tests/CodeIslandTests/NotchGestureInterpreterTests.swift` + +**Interfaces:** +- Produces: `enum NotchGestureAction: Equatable { case open, close, navigatePrevious, navigateNext }`. +- Produces: `struct NotchScrollSample` with physical deltas, phase flags, precision, and momentum state. +- Produces: `struct NotchGestureInterpreter` with `mutating func consume(_:) -> NotchGestureAction?` and `mutating func reset()`. + +- [ ] **Step 1: Write failing interpreter tests** + +Cover these exact cases using a local sample helper: + +```swift +func testPhysicalDirectionsMapToNaturalNotchActions() { + XCTAssertEqual(action(x: -30, y: 0), .navigateNext) + XCTAssertEqual(action(x: 30, y: 0), .navigatePrevious) + XCTAssertEqual(action(x: 0, y: -30), .open) + XCTAssertEqual(action(x: 0, y: 30), .close) +} + +func testGestureRequiresThresholdAndDominantAxis() { + XCTAssertNil(action(x: 8, y: 0)) + XCTAssertNil(action(x: 30, y: 28)) +} + +func testGestureEmitsOnlyOnceUntilEnded() { + var interpreter = NotchGestureInterpreter() + XCTAssertEqual(interpreter.consume(sample(x: -30, phase: .changed)), .navigateNext) + XCTAssertNil(interpreter.consume(sample(x: -30, phase: .changed))) + XCTAssertNil(interpreter.consume(sample(phase: .ended))) + XCTAssertEqual(interpreter.consume(sample(x: -30, phase: .began)), .navigateNext) +} + +func testMomentumAndNonPreciseScrollAreIgnored() { + var interpreter = NotchGestureInterpreter() + XCTAssertNil(interpreter.consume(sample(y: -40, momentum: true))) + XCTAssertNil(interpreter.consume(sample(y: -40, precise: false))) +} +``` + +- [ ] **Step 2: Run the focused tests and verify failure** + +Run: `swift test --filter NotchGestureInterpreterTests` + +Expected: compilation fails because the interpreter types do not exist. + +- [ ] **Step 3: Implement the minimal interpreter** + +Use a 24-point activation threshold and 1.2 axis-dominance ratio. Accumulate deltas between `.began` and `.ended`, ignore momentum and imprecise wheel input, set an `emitted` flag after the first action, and reset on end/cancel. + +Normalize AppKit events at the boundary: + +```swift +extension NotchScrollSample { + init(event: NSEvent) { + let direction: CGFloat = event.isDirectionInvertedFromDevice ? -1 : 1 + self.init( + physicalDeltaX: event.scrollingDeltaX * direction, + physicalDeltaY: event.scrollingDeltaY * direction, + began: event.phase.contains(.began), + ended: event.phase.contains(.ended) || event.phase.contains(.cancelled), + momentum: !event.momentumPhase.isEmpty, + precise: event.hasPreciseScrollingDeltas + ) + } +} +``` + +- [ ] **Step 4: Run focused tests and verify pass** + +Run: `swift test --filter NotchGestureInterpreterTests` + +Expected: PASS. + +- [ ] **Step 5: Commit the interpreter** + +```bash +git add Sources/CodeIsland/NotchGesture.swift Tests/CodeIslandTests/NotchGestureInterpreterTests.swift +git commit -m "feat(gestures): interpret notch trackpad swipes" +``` + +### Task 3: State-aware Gesture Policy + +**Files:** +- Modify: `Sources/CodeIsland/NotchGesture.swift` +- Create: `Tests/CodeIslandTests/NotchGesturePolicyTests.swift` + +**Interfaces:** +- Produces: `NotchGesturePolicy.canOpen(surface:hasSessions:) -> Bool`. +- Produces: `NotchGesturePolicy.canClose(surface:) -> Bool`. +- Produces: `NotchGesturePolicy.filterMode(from:action:controlsVisible:) -> String?`. + +- [ ] **Step 1: Write failing policy tests** + +```swift +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), "status") + XCTAssertEqual(NotchGesturePolicy.filterMode(from: "status", action: .navigateNext, controlsVisible: true), "cli") + XCTAssertEqual(NotchGesturePolicy.filterMode(from: "cli", action: .navigateNext, controlsVisible: true), "cli") + XCTAssertEqual(NotchGesturePolicy.filterMode(from: "status", action: .navigatePrevious, controlsVisible: true), "all") + XCTAssertEqual(NotchGesturePolicy.filterMode(from: "all", action: .navigatePrevious, controlsVisible: true), "all") + XCTAssertNil(NotchGesturePolicy.filterMode(from: "all", action: .navigateNext, controlsVisible: false)) +} +``` + +- [ ] **Step 2: Run focused tests and verify failure** + +Run: `swift test --filter NotchGesturePolicyTests` + +Expected: compilation fails because `NotchGesturePolicy` does not exist. + +- [ ] **Step 3: Implement the policy** + +Use a fixed mode order: + +```swift +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 + } + } +} +``` + +Implement `filterMode` with a clamped index and return `nil` when controls are hidden or the action is not horizontal. + +- [ ] **Step 4: Run focused tests and verify pass** + +Run: `swift test --filter NotchGesturePolicyTests` + +Expected: PASS. + +- [ ] **Step 5: Commit the policy** + +```bash +git add Sources/CodeIsland/NotchGesture.swift Tests/CodeIslandTests/NotchGesturePolicyTests.swift +git commit -m "feat(gestures): add notch action policy" +``` + +### Task 4: Gestures Settings Page, Localization, and Diagnostics + +**Files:** +- Modify: `Sources/CodeIsland/SettingsView.swift` +- Modify: `Sources/CodeIsland/L10n.swift` +- Modify: `Sources/CodeIsland/DiagnosticsExporter.swift` +- Modify: `Tests/CodeIslandTests/L10nTests.swift` + +**Interfaces:** +- Produces: `SettingsPage.gestures` between Behavior and Appearance. +- Consumes: Task 1 settings and `HoverOpenDelay` constants. + +- [ ] **Step 1: Add failing localization assertions** + +Extend each existing translation-value test to assert that `L10n.shared["gestures"]` returns the expected localized page name. Add a test that every supported non-English dictionary contains all English keys, including `zh` and `zh-Hant`. + +- [ ] **Step 2: Run localization tests and verify failure** + +Run: `swift test --filter L10nTests` + +Expected: FAIL because gesture keys and translations are missing. + +- [ ] **Step 3: Add the Gestures page and localized copy** + +Add `.gestures` to `SettingsPage`, its icon/color switch cases, the first sidebar group after `.behavior`, and the detail switch. + +Create `GesturesPage` with: + +```swift +@AppStorage(SettingsKey.openOnHover) private var openOnHover = SettingsDefaults.openOnHover +@AppStorage(SettingsKey.hoverOpenDelay) private var hoverOpenDelay = SettingsDefaults.hoverOpenDelay +@AppStorage(SettingsKey.hapticOnHover) private var hapticOnHover = SettingsDefaults.hapticOnHover +@AppStorage(SettingsKey.hapticIntensity) private var hapticIntensity = SettingsDefaults.hapticIntensity +``` + +The page uses a grouped `Form`, an Open on hover toggle, a slider bound to `HoverOpenDelay.minimum...maximum` with `step: HoverOpenDelay.step`, a formatted seconds label, moved haptic controls, and a four-row gesture reference. Remove only the moved haptic controls from `BehaviorPage`; leave all other behavior settings untouched. + +Add these keys to every language dictionary with real localized strings: + +```text +gestures +gesture_opening +open_on_hover +open_on_hover_desc +hover_open_delay +seconds_short +gesture_reference +gesture_click_open +gesture_swipe_down_open +gesture_swipe_up_close +gesture_swipe_horizontal_filter +``` + +- [ ] **Step 4: Export the settings in diagnostics** + +Add `openOnHover` and the clamped `hoverOpenDelay` to the diagnostics metadata settings dictionary. + +- [ ] **Step 5: Run localization and settings tests** + +Run: `swift test --filter L10nTests` + +Run: `swift test --filter NotchGestureSettingsTests` + +Expected: PASS. + +- [ ] **Step 6: Commit the settings UI** + +```bash +git add Sources/CodeIsland/SettingsView.swift Sources/CodeIsland/L10n.swift Sources/CodeIsland/DiagnosticsExporter.swift Tests/CodeIslandTests/L10nTests.swift +git commit -m "feat(settings): add gesture controls" +``` + +### Task 5: Notch Event Monitor and View Integration + +**Files:** +- Modify: `Sources/CodeIsland/NotchGesture.swift` +- Modify: `Sources/CodeIsland/NotchPanelView.swift` +- Modify: `Tests/CodeIslandTests/NotchPanelViewTests.swift` + +**Interfaces:** +- Produces: lifecycle-owned `NotchGestureMonitor.start(onAction:)`, `.isEnabled`, and `.stop()`. +- Consumes: gesture interpreter, policy, settings, AppState surface, and session grouping preference. + +- [ ] **Step 1: Add failing hover regression tests** + +Extend `NotchHoverInteractionTests` to assert that `HoverOpenDelay.clamped` supplies the configurable open timer while `NotchHoverInteraction.collapseDelay` remains exactly `0.5`. Add a state-machine regression showing disabling hover while in prehover returns the visual phase to collapsed through a new `.hoverDisabled` event. + +- [ ] **Step 2: Run the focused tests and verify failure** + +Run: `swift test --filter NotchHoverInteractionTests` + +Expected: FAIL because `.hoverDisabled` is not defined. + +- [ ] **Step 3: Add the local AppKit monitor** + +Implement a small `@MainActor` reference type in `NotchGesture.swift`: + +```swift +final class NotchGestureMonitor { + var isEnabled = false + private var monitor: Any? + private var interpreter = NotchGestureInterpreter() + + func start(onAction: @escaping (NotchGestureAction) -> Void) { + guard monitor == nil else { return } + monitor = NSEvent.addLocalMonitorForEvents(matching: .scrollWheel) { [weak self] event in + guard let self, self.isEnabled else { return event } + guard let action = self.interpreter.consume(NotchScrollSample(event: event)) else { return event } + onAction(action) + return nil + } + } + + func stop() { + if let monitor { NSEvent.removeMonitor(monitor) } + monitor = nil + interpreter.reset() + } + + deinit { + if let monitor { NSEvent.removeMonitor(monitor) } + } +} +``` + +If Swift concurrency rejects AppKit monitor removal from `deinit`, make `stop()` the lifecycle guarantee and omit only the `deinit` fallback rather than weakening actor isolation. + +- [ ] **Step 4: Integrate the compact/header region** + +In `NotchPanelView`: + +- add AppStorage for `openOnHover`, `hoverOpenDelay`, and parent-level `sessionGroupingMode`; +- preserve a `NotchGestureMonitor` and `gestureRegionHovered` state; +- attach hover tracking and click-open to the compact/header strip, not the expanded card body; +- start/stop the monitor with view appearance; +- route semantic actions through small `openSessionList`, `closeForGesture`, and `handleGestureAction` methods using `NotchGesturePolicy`; +- make click open-only and require a collapsed surface with sessions; +- use the configured, clamped delay only when Open on hover is enabled; +- cancel pending prehover and timers when Open on hover becomes disabled; +- leave the existing mouse-leave collapse timer at `NotchHoverInteraction.collapseDelay`; +- preserve approval/question protection and completion-queue cancellation behavior. + +- [ ] **Step 5: Run focused gesture and hover tests** + +Run: `swift test --filter NotchGesture` + +Run: `swift test --filter NotchHoverInteractionTests` + +Expected: PASS. + +- [ ] **Step 6: Run full verification** + +Run: `swift test` + +Run: `swift build` + +Expected: both commands exit 0. + +- [ ] **Step 7: Commit the integration** + +```bash +git add Sources/CodeIsland/NotchGesture.swift Sources/CodeIsland/NotchPanelView.swift Tests/CodeIslandTests/NotchPanelViewTests.swift +git commit -m "feat(notch): add configurable trackpad gestures" +``` + +### Task 6: Final Diff Review and Draft PR + +**Files:** +- Review all files changed from `upstream/main`. +- Update: `docs/superpowers/plans/2026-08-14-notch-gesture-support.md` checkboxes if tracked in the PR. + +**Interfaces:** +- Consumes: all prior tasks. +- Produces: a pushed fork branch and draft PR targeting `wxtsky/CodeIsland:main`. + +- [ ] **Step 1: Review scope and diff hygiene** + +Run: `git status -sb` + +Run: `git diff --check upstream/main...HEAD` + +Run: `git diff --stat upstream/main...HEAD` + +Run: `git log --oneline upstream/main..HEAD` + +Expected: only the approved design, plan, settings, gesture implementation, localization, diagnostics, and tests are present. + +- [ ] **Step 2: Re-run release-facing verification** + +Run: `swift test` + +Run: `swift build` + +Expected: both commands exit 0 with fresh output. + +- [ ] **Step 3: Push to the authenticated user's fork** + +Ensure remotes are: + +```text +origin git@github.com:Mrjamedd/CodeIsland.git +upstream https://github.com/wxtsky/CodeIsland.git +``` + +Then run: + +```bash +git push -u origin agent/notch-gesture-support +``` + +- [ ] **Step 4: Open the draft PR** + +Create a draft PR against `wxtsky/CodeIsland:main` with title: + +```text +feat(notch): add configurable trackpad gestures +``` + +The body must summarize settings, gestures, safety boundaries, user impact, and the exact verification commands. Use `Mrjamedd:agent/notch-gesture-support` as the cross-fork head. + +- [ ] **Step 5: Report review handoff** + +Provide the draft PR URL, branch, commits, tests, and any manual trackpad verification still recommended. From 7f617b26743903ecf4656de6ab33fdfa66b21ba4 Mon Sep 17 00:00:00 2001 From: Mrjamedd Date: Fri, 14 Aug 2026 11:59:38 -0400 Subject: [PATCH 03/18] feat(settings): add notch hover preferences --- Sources/CodeIsland/Settings.swift | 26 +++++++++++++++++++ .../NotchGestureSettingsTests.swift | 15 +++++++++++ 2 files changed, 41 insertions(+) create mode 100644 Tests/CodeIslandTests/NotchGestureSettingsTests.swift diff --git a/Sources/CodeIsland/Settings.swift b/Sources/CodeIsland/Settings.swift index 4b3e2007..8f410d2a 100644 --- a/Sources/CodeIsland/Settings.swift +++ b/Sources/CodeIsland/Settings.swift @@ -17,6 +17,16 @@ 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) + } +} + enum SettingsKey { // Language static let appLanguage = "appLanguage" // "system", "en", "zh", "zh-Hant", "de", "ja", "ko", "tr" @@ -33,6 +43,8 @@ enum SettingsKey { static let hideWhenNoSession = "hideWhenNoSession" static let smartSuppress = "smartSuppress" static let collapseOnMouseLeave = "collapseOnMouseLeave" + static let openOnHover = "openOnHover" + static let hoverOpenDelay = "hoverOpenDelay" static let autoCollapseAfterSessionJump = "autoCollapseAfterSessionJump" static let autoExpandOnPermission = "autoExpandOnPermission" static let autoExpandOnCompletion = "autoExpandOnCompletion" @@ -145,6 +157,8 @@ struct SettingsDefaults { static let hideWhenNoSession = false static let smartSuppress = true static let collapseOnMouseLeave = true + static let openOnHover = true + static let hoverOpenDelay = 0.5 static let autoCollapseAfterSessionJump = false static let autoExpandOnPermission = true static let autoExpandOnCompletion = true @@ -232,6 +246,8 @@ class SettingsManager { SettingsKey.hideWhenNoSession: SettingsDefaults.hideWhenNoSession, SettingsKey.smartSuppress: SettingsDefaults.smartSuppress, SettingsKey.collapseOnMouseLeave: SettingsDefaults.collapseOnMouseLeave, + SettingsKey.openOnHover: SettingsDefaults.openOnHover, + SettingsKey.hoverOpenDelay: SettingsDefaults.hoverOpenDelay, SettingsKey.autoCollapseAfterSessionJump: SettingsDefaults.autoCollapseAfterSessionJump, SettingsKey.autoExpandOnPermission: SettingsDefaults.autoExpandOnPermission, SettingsKey.autoExpandOnCompletion: SettingsDefaults.autoExpandOnCompletion, @@ -336,6 +352,16 @@ class SettingsManager { set { defaults.set(newValue, forKey: SettingsKey.collapseOnMouseLeave) } } + 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 hapticOnHover: Bool { get { defaults.bool(forKey: SettingsKey.hapticOnHover) } set { defaults.set(newValue, forKey: SettingsKey.hapticOnHover) } diff --git a/Tests/CodeIslandTests/NotchGestureSettingsTests.swift b/Tests/CodeIslandTests/NotchGestureSettingsTests.swift new file mode 100644 index 00000000..eb4f5238 --- /dev/null +++ b/Tests/CodeIslandTests/NotchGestureSettingsTests.swift @@ -0,0 +1,15 @@ +import XCTest +@testable import CodeIsland + +final class NotchGestureSettingsTests: XCTestCase { + func testHoverSettingsPreserveExistingDefaults() { + XCTAssertTrue(SettingsDefaults.openOnHover) + XCTAssertEqual(SettingsDefaults.hoverOpenDelay, 0.5, accuracy: 0.001) + } + + 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) + } +} From d2b1df46f107ad6d2370ffab3699a151c4d6da3a Mon Sep 17 00:00:00 2001 From: Mrjamedd Date: Fri, 14 Aug 2026 12:01:00 -0400 Subject: [PATCH 04/18] feat(gestures): interpret notch trackpad swipes --- Sources/CodeIsland/NotchGesture.swift | 117 ++++++++++++++++++ .../NotchGestureInterpreterTests.swift | 73 +++++++++++ 2 files changed, 190 insertions(+) create mode 100644 Sources/CodeIsland/NotchGesture.swift create mode 100644 Tests/CodeIslandTests/NotchGestureInterpreterTests.swift diff --git a/Sources/CodeIsland/NotchGesture.swift b/Sources/CodeIsland/NotchGesture.swift new file mode 100644 index 00000000..951d7992 --- /dev/null +++ b/Sources/CodeIsland/NotchGesture.swift @@ -0,0 +1,117 @@ +import AppKit + +enum NotchGestureAction: Equatable { + case open + case close + case navigatePrevious + case navigateNext +} + +struct NotchScrollSample { + 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 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 + } +} diff --git a/Tests/CodeIslandTests/NotchGestureInterpreterTests.swift b/Tests/CodeIslandTests/NotchGestureInterpreterTests.swift new file mode 100644 index 00000000..39da49cb --- /dev/null +++ b/Tests/CodeIslandTests/NotchGestureInterpreterTests.swift @@ -0,0 +1,73 @@ +import XCTest +@testable import CodeIsland + +final class NotchGestureInterpreterTests: XCTestCase { + func testPhysicalDirectionsMapToNaturalNotchActions() { + 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() + + XCTAssertEqual(interpreter.consume(sample(x: -30)), .navigateNext) + XCTAssertNil(interpreter.consume(sample(x: -30))) + XCTAssertNil(interpreter.consume(sample(ended: true))) + XCTAssertEqual(interpreter.consume(sample(x: -30, began: true)), .navigateNext) + } + + 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 + ) + } +} From c92257a6069a6a7327502fb6cb7323129b95bfbe Mon Sep 17 00:00:00 2001 From: Mrjamedd Date: Fri, 14 Aug 2026 12:01:57 -0400 Subject: [PATCH 05/18] feat(gestures): add notch action policy --- Sources/CodeIsland/NotchGesture.swift | 42 +++++++++++++++++++ .../NotchGesturePolicyTests.swift | 27 ++++++++++++ 2 files changed, 69 insertions(+) create mode 100644 Tests/CodeIslandTests/NotchGesturePolicyTests.swift diff --git a/Sources/CodeIsland/NotchGesture.swift b/Sources/CodeIsland/NotchGesture.swift index 951d7992..03e99e98 100644 --- a/Sources/CodeIsland/NotchGesture.swift +++ b/Sources/CodeIsland/NotchGesture.swift @@ -115,3 +115,45 @@ struct NotchGestureInterpreter { 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 + ) -> String? { + guard controlsVisible, + let currentIndex = filterModes.firstIndex(of: currentMode) else { return nil } + + let offset: Int + switch action { + case .navigatePrevious: + offset = -1 + case .navigateNext: + offset = 1 + case .open, .close: + return nil + } + + let nextIndex = Swift.min( + Swift.max(currentIndex + offset, filterModes.startIndex), + filterModes.index(before: filterModes.endIndex) + ) + return filterModes[nextIndex] + } +} diff --git a/Tests/CodeIslandTests/NotchGesturePolicyTests.swift b/Tests/CodeIslandTests/NotchGesturePolicyTests.swift new file mode 100644 index 00000000..8194321a --- /dev/null +++ b/Tests/CodeIslandTests/NotchGesturePolicyTests.swift @@ -0,0 +1,27 @@ +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), "status") + XCTAssertEqual(NotchGesturePolicy.filterMode(from: "status", action: .navigateNext, controlsVisible: true), "cli") + XCTAssertEqual(NotchGesturePolicy.filterMode(from: "cli", action: .navigateNext, controlsVisible: true), "cli") + XCTAssertEqual(NotchGesturePolicy.filterMode(from: "status", action: .navigatePrevious, controlsVisible: true), "all") + XCTAssertEqual(NotchGesturePolicy.filterMode(from: "all", action: .navigatePrevious, controlsVisible: true), "all") + XCTAssertNil(NotchGesturePolicy.filterMode(from: "all", action: .navigateNext, controlsVisible: false)) + } +} From 61d555da57d49f509aba563aa2cba08d61bc6bcf Mon Sep 17 00:00:00 2001 From: Mrjamedd Date: Fri, 14 Aug 2026 12:05:21 -0400 Subject: [PATCH 06/18] feat(settings): add gesture controls --- Sources/CodeIsland/DiagnosticsExporter.swift | 6 + Sources/CodeIsland/L10n.swift | 91 +++++++++++++++ Sources/CodeIsland/SettingsView.swift | 113 +++++++++++++++---- Tests/CodeIslandTests/L10nTests.swift | 27 +++++ 4 files changed, 217 insertions(+), 20 deletions(-) diff --git a/Sources/CodeIsland/DiagnosticsExporter.swift b/Sources/CodeIsland/DiagnosticsExporter.swift index c90bc2d3..98cfbc77 100644 --- a/Sources/CodeIsland/DiagnosticsExporter.swift +++ b/Sources/CodeIsland/DiagnosticsExporter.swift @@ -120,6 +120,12 @@ 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 + ), "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/L10n.swift b/Sources/CodeIsland/L10n.swift index 3fd6b99d..ceb7cfb2 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,18 @@ 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", + "seconds_short": "%.1f s", + "gesture_reference": "Trackpad Gestures", + "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", @@ -399,6 +412,7 @@ final class L10n: ObservableObject { // Settings pages "general": "Allgemein", "behavior": "Verhalten", + "gestures": "Gesten", "appearance": "Darstellung", "mascots": "Maskottchen", "sound": "Ton", @@ -421,6 +435,18 @@ 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", + "seconds_short": "%.1f s", + "gesture_reference": "Trackpad-Gesten", + "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", @@ -747,6 +773,7 @@ final class L10n: ObservableObject { // Settings pages "general": "通用", "behavior": "行为", + "gestures": "手势", "appearance": "外观", "mascots": "角色", "sound": "声音", @@ -769,6 +796,18 @@ final class L10n: ObservableObject { "builtin_display": "内建显示器", "notch": "(刘海)", + // Gestures + "gesture_opening": "打开", + "open_on_hover": "悬停时打开", + "open_on_hover_desc": "指针停留在刘海上时自动展开面板", + "hover_open_delay": "悬停打开延迟", + "seconds_short": "%.1f 秒", + "gesture_reference": "触控板手势", + "gesture_click_open": "点击收起的刘海即可打开", + "gesture_swipe_down_open": "双指向下轻扫即可打开", + "gesture_swipe_up_close": "双指向上轻扫即可关闭", + "gesture_swipe_horizontal_filter": "双指向左或向右轻扫以切换会话筛选器", + // Behavior "display_section": "显示", "hide_in_fullscreen": "全屏时隐藏", @@ -1099,6 +1138,7 @@ final class L10n: ObservableObject { // Settings pages "general": "一般", "behavior": "行為", + "gestures": "手勢", "appearance": "外觀", "mascots": "角色", "sound": "聲音", @@ -1121,6 +1161,18 @@ final class L10n: ObservableObject { "builtin_display": "內建顯示器", "notch": "(瀏海)", + // Gestures + "gesture_opening": "開啟", + "open_on_hover": "懸停時開啟", + "open_on_hover_desc": "指標停留在瀏海上時自動展開面板", + "hover_open_delay": "懸停開啟延遲", + "seconds_short": "%.1f 秒", + "gesture_reference": "觸控板手勢", + "gesture_click_open": "點擊收合的瀏海即可開啟", + "gesture_swipe_down_open": "雙指向下滑動即可開啟", + "gesture_swipe_up_close": "雙指向上滑動即可關閉", + "gesture_swipe_horizontal_filter": "雙指向左或向右滑動以切換會話篩選器", + // Behavior "display_section": "顯示", "hide_in_fullscreen": "全螢幕時隱藏", @@ -1451,6 +1503,7 @@ final class L10n: ObservableObject { // Settings pages "general": "一般", "behavior": "動作", + "gestures": "ジェスチャ", "appearance": "外観", "mascots": "マスコット", "sound": "サウンド", @@ -1473,6 +1526,18 @@ final class L10n: ObservableObject { "builtin_display": "内蔵ディスプレイ", "notch": "(ノッチ)", + // Gestures + "gesture_opening": "開く", + "open_on_hover": "ホバーで開く", + "open_on_hover_desc": "ポインタをノッチ上に置くと自動的に開きます", + "hover_open_delay": "ホバーで開くまでの時間", + "seconds_short": "%.1f 秒", + "gesture_reference": "トラックパッドジェスチャ", + "gesture_click_open": "コンパクトなノッチをクリックして開く", + "gesture_swipe_down_open": "2本指で下にスワイプして開く", + "gesture_swipe_up_close": "2本指で上にスワイプして閉じる", + "gesture_swipe_horizontal_filter": "2本指で左右にスワイプしてセッションフィルタを切り替える", + // Behavior "display_section": "表示", "hide_in_fullscreen": "フルスクリーン時に隠す", @@ -1803,6 +1868,7 @@ final class L10n: ObservableObject { // Settings pages "general": "일반", "behavior": "동작", + "gestures": "제스처", "appearance": "외관", "mascots": "마스코트", "sound": "사운드", @@ -1825,6 +1891,18 @@ final class L10n: ObservableObject { "builtin_display": "내장 디스플레이", "notch": "(노치)", + // Gestures + "gesture_opening": "열기", + "open_on_hover": "호버로 열기", + "open_on_hover_desc": "포인터를 노치 위에 두면 자동으로 엽니다", + "hover_open_delay": "호버 열기 지연", + "seconds_short": "%.1f초", + "gesture_reference": "트랙패드 제스처", + "gesture_click_open": "컴팩트 노치를 클릭하여 열기", + "gesture_swipe_down_open": "두 손가락으로 아래로 쓸어 열기", + "gesture_swipe_up_close": "두 손가락으로 위로 쓸어 닫기", + "gesture_swipe_horizontal_filter": "두 손가락으로 좌우로 쓸어 세션 필터 변경", + // Behavior "display_section": "표시", "hide_in_fullscreen": "전체 화면에서 숨기기", @@ -2155,6 +2233,7 @@ final class L10n: ObservableObject { // Settings pages "general": "Genel", "behavior": "Davranış", + "gestures": "Hareketler", "appearance": "Görünüm", "mascots": "Maskotlar", "sound": "Ses", @@ -2177,6 +2256,18 @@ 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", + "seconds_short": "%.1f sn", + "gesture_reference": "Dokunmatik Yüzey Hareketleri", + "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", diff --git a/Sources/CodeIsland/SettingsView.swift b/Sources/CodeIsland/SettingsView.swift index 11c14459..d6f80952 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,94 @@ 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.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) } + ) + } + + 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) + } + } + } + + Section(l10n["gesture_reference"]) { + 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) + } + } +} + +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 { @@ -386,8 +478,6 @@ private struct BehaviorPage: View { // 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 @@ -473,23 +563,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"]) { 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" From bc034a98caa87e3f1e157b6af39aad4f1a3a0df8 Mon Sep 17 00:00:00 2001 From: Mrjamedd Date: Fri, 14 Aug 2026 12:13:05 -0400 Subject: [PATCH 07/18] feat(notch): add configurable trackpad gestures --- Sources/CodeIsland/NotchGesture.swift | 82 +++++++++ Sources/CodeIsland/NotchPanelView.swift | 155 +++++++++++------- .../CodeIslandTests/NotchPanelViewTests.swift | 9 + 3 files changed, 188 insertions(+), 58 deletions(-) diff --git a/Sources/CodeIsland/NotchGesture.swift b/Sources/CodeIsland/NotchGesture.swift index 03e99e98..10140744 100644 --- a/Sources/CodeIsland/NotchGesture.swift +++ b/Sources/CodeIsland/NotchGesture.swift @@ -1,5 +1,45 @@ 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 + case hoverDisabled +} + +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), (.prehover, .hoverDisabled): + return .collapsed + case (.prehover, .expandDelayElapsed): + return .expanded + case (.expanded, .collapseDelayElapsed): + return .collapsed + default: + return phase + } + } +} + enum NotchGestureAction: Equatable { case open case close @@ -157,3 +197,45 @@ enum NotchGesturePolicy { return filterModes[nextIndex] } } + +@MainActor +final class NotchGestureMonitor { + var isEnabled = false { + didSet { + if !isEnabled { + interpreter.reset() + } + } + } + + private var monitor: Any? + private var interpreter = NotchGestureInterpreter() + + func start(onAction: @escaping (NotchGestureAction) -> Void) { + guard monitor == nil else { return } + + monitor = NSEvent.addLocalMonitorForEvents(matching: .scrollWheel) { [weak self] event in + guard let self, self.isEnabled else { return event } + guard let action = self.interpreter.consume(NotchScrollSample(event: event)) else { + return event + } + + onAction(action) + return nil + } + } + + func stop() { + if let monitor { + NSEvent.removeMonitor(monitor) + } + monitor = nil + interpreter.reset() + } + + deinit { + if let monitor { + NSEvent.removeMonitor(monitor) + } + } +} diff --git a/Sources/CodeIsland/NotchPanelView.swift b/Sources/CodeIsland/NotchPanelView.swift index 50c9bc7b..f33863a0 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 @@ -98,13 +56,18 @@ 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.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 gestureRegionHovered = 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 @@ -162,6 +125,49 @@ 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 + appState.cancelCompletionQueue() + 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 + ) else { return } + withAnimation(.easeInOut(duration: 0.15)) { + groupingMode = nextMode + } + } + } + var body: some View { VStack(spacing: 0) { VStack(spacing: 0) { @@ -180,6 +186,14 @@ struct NotchPanelView: View { CompactRightWing(appState: appState, expanded: shouldShowExpanded, hasNotch: hasNotch) } .frame(height: notchHeight) + .contentShape(Rectangle()) + .onHover { hovering in + gestureRegionHovered = hovering + gestureMonitor.isEnabled = hovering + } + .onTapGesture { + openSessionList() + } } else if showIdleIndicator { IdleIndicatorBar( mascotSize: mascotSize, @@ -297,7 +311,15 @@ struct NotchPanelView: View { } } } - .onAppear { displayedToolStatus = showToolStatus } + .onAppear { + displayedToolStatus = showToolStatus + gestureMonitor.start { action in + handleGestureAction(action) + } + } + .onDisappear { + gestureMonitor.stop() + } .scaleEffect(shouldShowPrehover ? NotchHoverInteraction.prehoverScale : 1, anchor: .top) .contentShape(Rectangle()) .onHover { hovering in @@ -339,17 +361,27 @@ 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, + + isHovered = hovering + if hovering { + guard openOnHover else { + hoverTimer?.invalidate() + hoverTimer = nil + withAnimation(NotchAnimation.hoverPrehover) { + hoverPhase = NotchHoverInteraction.nextPhase(from: hoverPhase, event: .hoverDisabled) + } + return + } + + // Smart suppress applies only to passive hover opening. Click and + // trackpad gestures are intentional and bypass this check. + if smartSuppress, + 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. withAnimation(NotchAnimation.hoverPrehover) { @@ -357,10 +389,10 @@ struct NotchPanelView: View { } // 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 +408,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,6 +439,19 @@ struct NotchPanelView: View { hoverPhase = .expanded } } + .onChange(of: openOnHover) { _, enabled in + guard !enabled else { return } + hoverTimer?.invalidate() + hoverTimer = nil + withAnimation(NotchAnimation.hoverPrehover) { + hoverPhase = NotchHoverInteraction.nextPhase(from: hoverPhase, event: .hoverDisabled) + } + } + .onChange(of: showBar) { _, visible in + guard !visible else { return } + gestureRegionHovered = false + gestureMonitor.isEnabled = false + } Spacer() .allowsHitTesting(false) diff --git a/Tests/CodeIslandTests/NotchPanelViewTests.swift b/Tests/CodeIslandTests/NotchPanelViewTests.swift index f9aac277..4197b372 100644 --- a/Tests/CodeIslandTests/NotchPanelViewTests.swift +++ b/Tests/CodeIslandTests/NotchPanelViewTests.swift @@ -219,6 +219,15 @@ final class NotchHoverInteractionTests: XCTestCase { XCTAssertEqual(NotchHoverInteraction.nextPhase(from: .prehover, event: .collapseDelayElapsed), .prehover) } + func testDisablingHoverCancelsPrehoverWithoutCollapsingExpandedContent() { + XCTAssertEqual(NotchHoverInteraction.nextPhase(from: .prehover, event: .hoverDisabled), .collapsed) + XCTAssertEqual(NotchHoverInteraction.nextPhase(from: .expanded, event: .hoverDisabled), .expanded) + } + + func testMouseLeaveCloseDelayRemainsUnchanged() { + XCTAssertEqual(NotchHoverInteraction.collapseDelay, 0.5, accuracy: 0.001) + } + func testWidthScaleSliderUsesOnePercentSteps() { XCTAssertEqual(NotchWidthScale.min, 50) XCTAssertEqual(NotchWidthScale.max, 150) From bc8d1cf92e53df3f473f70c64ee9ae2aa30e79e0 Mon Sep 17 00:00:00 2001 From: Mrjamedd Date: Fri, 14 Aug 2026 12:15:43 -0400 Subject: [PATCH 08/18] chore: keep gesture PR scope focused --- Sources/CodeIsland/NotchPanelView.swift | 4 - .../plans/2026-08-14-notch-gesture-support.md | 505 ------------------ ...2026-08-14-notch-gesture-support-design.md | 153 ------ 3 files changed, 662 deletions(-) delete mode 100644 docs/superpowers/plans/2026-08-14-notch-gesture-support.md delete mode 100644 docs/superpowers/specs/2026-08-14-notch-gesture-support-design.md diff --git a/Sources/CodeIsland/NotchPanelView.swift b/Sources/CodeIsland/NotchPanelView.swift index f33863a0..5b408949 100644 --- a/Sources/CodeIsland/NotchPanelView.swift +++ b/Sources/CodeIsland/NotchPanelView.swift @@ -66,7 +66,6 @@ struct NotchPanelView: View { @State private var hoverTimer: Timer? @State private var isHovered = false @State private var idleHovered = false - @State private var gestureRegionHovered = false @State private var gestureMonitor = NotchGestureMonitor() /// Three-stage hover: collapsed → prehover (immediate ack) → expanded (after delay) @State private var hoverPhase: NotchHoverPhase = .collapsed @@ -143,7 +142,6 @@ struct NotchPanelView: View { hoverTimer?.invalidate() hoverTimer = nil hoverPhase = .collapsed - appState.cancelCompletionQueue() withAnimation(NotchAnimation.close) { appState.surface = .collapsed } @@ -188,7 +186,6 @@ struct NotchPanelView: View { .frame(height: notchHeight) .contentShape(Rectangle()) .onHover { hovering in - gestureRegionHovered = hovering gestureMonitor.isEnabled = hovering } .onTapGesture { @@ -449,7 +446,6 @@ struct NotchPanelView: View { } .onChange(of: showBar) { _, visible in guard !visible else { return } - gestureRegionHovered = false gestureMonitor.isEnabled = false } diff --git a/docs/superpowers/plans/2026-08-14-notch-gesture-support.md b/docs/superpowers/plans/2026-08-14-notch-gesture-support.md deleted file mode 100644 index b2c44eeb..00000000 --- a/docs/superpowers/plans/2026-08-14-notch-gesture-support.md +++ /dev/null @@ -1,505 +0,0 @@ -# Notch Gesture Support Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add configurable hover opening, click opening, local two-finger trackpad gestures, and horizontal `ALL` / `STA` / `CLI` filter navigation to CodeIsland's notch header. - -**Architecture:** A pure `NotchGestureInterpreter` converts normalized AppKit scroll samples into semantic actions, while a lifecycle-owned local event monitor observes only the notch header region. `NotchPanelView` owns state-aware action routing, and persisted settings plus a dedicated Gestures page control hover behavior. - -**Tech Stack:** Swift 5.9+, SwiftUI, AppKit `NSEvent`, UserDefaults/AppStorage, XCTest, Swift Package Manager - -**Spec:** `docs/superpowers/specs/2026-08-14-notch-gesture-support-design.md` - -## Global Constraints - -- Keep the deployment floor at macOS 14 and add no dependencies. -- Recognize gestures only while the pointer is inside the visible notch header. -- Default Open on hover to `true` and hover delay to `0.5` seconds. -- Clamp hover delay to `0.1...1.5` seconds in `0.1`-second UI steps. -- Keep the existing `0.5`-second mouse-leave close delay unchanged. -- Never let a gesture approve, deny, answer, skip, or dismiss a pending request. -- Horizontal physical swipe left navigates right; physical swipe right navigates left; filters clamp without wrapping. -- Preserve Smart Suppress for hover opening only; click and swipe-down are explicit opening actions. - ---- - -### Task 1: Persisted Hover Settings - -**Files:** -- Modify: `Sources/CodeIsland/Settings.swift` -- Create: `Tests/CodeIslandTests/NotchGestureSettingsTests.swift` - -**Interfaces:** -- Produces: `HoverOpenDelay.minimum`, `.maximum`, `.step`, and `clamped(_:)`. -- Produces: `SettingsKey.openOnHover`, `SettingsKey.hoverOpenDelay`. -- Produces: `SettingsDefaults.openOnHover == true`, `SettingsDefaults.hoverOpenDelay == 0.5`. -- Produces: `SettingsManager.openOnHover: Bool`, `SettingsManager.hoverOpenDelay: Double`. - -- [ ] **Step 1: Write failing settings tests** - -```swift -import XCTest -@testable import CodeIsland - -final class NotchGestureSettingsTests: XCTestCase { - func testHoverSettingsPreserveExistingDefaults() { - XCTAssertTrue(SettingsDefaults.openOnHover) - XCTAssertEqual(SettingsDefaults.hoverOpenDelay, 0.5, accuracy: 0.001) - } - - func testHoverDelayBoundsAndStepAreStable() { - XCTAssertEqual(HoverOpenDelay.minimum, 0.1, accuracy: 0.001) - XCTAssertEqual(HoverOpenDelay.maximum, 1.5, accuracy: 0.001) - XCTAssertEqual(HoverOpenDelay.step, 0.1, accuracy: 0.001) - } - - func testHoverDelayClampsMalformedSavedValues() { - 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) - } -} -``` - -- [ ] **Step 2: Run the focused tests and verify failure** - -Run: `swift test --filter NotchGestureSettingsTests` - -Expected: compilation fails because the new settings symbols do not exist. - -- [ ] **Step 3: Add the minimal settings implementation** - -Add near the other settings value helpers: - -```swift -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) - } -} -``` - -Add keys, defaults, default registration, and manager accessors: - -```swift -static let openOnHover = "openOnHover" -static let hoverOpenDelay = "hoverOpenDelay" - -static let openOnHover = true -static let hoverOpenDelay = 0.5 - -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) } -} -``` - -- [ ] **Step 4: Run the focused tests and verify pass** - -Run: `swift test --filter NotchGestureSettingsTests` - -Expected: PASS. - -- [ ] **Step 5: Commit the settings contract** - -```bash -git add Sources/CodeIsland/Settings.swift Tests/CodeIslandTests/NotchGestureSettingsTests.swift -git commit -m "feat(settings): add notch hover preferences" -``` - -### Task 2: Pure Trackpad Gesture Interpreter - -**Files:** -- Create: `Sources/CodeIsland/NotchGesture.swift` -- Create: `Tests/CodeIslandTests/NotchGestureInterpreterTests.swift` - -**Interfaces:** -- Produces: `enum NotchGestureAction: Equatable { case open, close, navigatePrevious, navigateNext }`. -- Produces: `struct NotchScrollSample` with physical deltas, phase flags, precision, and momentum state. -- Produces: `struct NotchGestureInterpreter` with `mutating func consume(_:) -> NotchGestureAction?` and `mutating func reset()`. - -- [ ] **Step 1: Write failing interpreter tests** - -Cover these exact cases using a local sample helper: - -```swift -func testPhysicalDirectionsMapToNaturalNotchActions() { - XCTAssertEqual(action(x: -30, y: 0), .navigateNext) - XCTAssertEqual(action(x: 30, y: 0), .navigatePrevious) - XCTAssertEqual(action(x: 0, y: -30), .open) - XCTAssertEqual(action(x: 0, y: 30), .close) -} - -func testGestureRequiresThresholdAndDominantAxis() { - XCTAssertNil(action(x: 8, y: 0)) - XCTAssertNil(action(x: 30, y: 28)) -} - -func testGestureEmitsOnlyOnceUntilEnded() { - var interpreter = NotchGestureInterpreter() - XCTAssertEqual(interpreter.consume(sample(x: -30, phase: .changed)), .navigateNext) - XCTAssertNil(interpreter.consume(sample(x: -30, phase: .changed))) - XCTAssertNil(interpreter.consume(sample(phase: .ended))) - XCTAssertEqual(interpreter.consume(sample(x: -30, phase: .began)), .navigateNext) -} - -func testMomentumAndNonPreciseScrollAreIgnored() { - var interpreter = NotchGestureInterpreter() - XCTAssertNil(interpreter.consume(sample(y: -40, momentum: true))) - XCTAssertNil(interpreter.consume(sample(y: -40, precise: false))) -} -``` - -- [ ] **Step 2: Run the focused tests and verify failure** - -Run: `swift test --filter NotchGestureInterpreterTests` - -Expected: compilation fails because the interpreter types do not exist. - -- [ ] **Step 3: Implement the minimal interpreter** - -Use a 24-point activation threshold and 1.2 axis-dominance ratio. Accumulate deltas between `.began` and `.ended`, ignore momentum and imprecise wheel input, set an `emitted` flag after the first action, and reset on end/cancel. - -Normalize AppKit events at the boundary: - -```swift -extension NotchScrollSample { - init(event: NSEvent) { - let direction: CGFloat = event.isDirectionInvertedFromDevice ? -1 : 1 - self.init( - physicalDeltaX: event.scrollingDeltaX * direction, - physicalDeltaY: event.scrollingDeltaY * direction, - began: event.phase.contains(.began), - ended: event.phase.contains(.ended) || event.phase.contains(.cancelled), - momentum: !event.momentumPhase.isEmpty, - precise: event.hasPreciseScrollingDeltas - ) - } -} -``` - -- [ ] **Step 4: Run focused tests and verify pass** - -Run: `swift test --filter NotchGestureInterpreterTests` - -Expected: PASS. - -- [ ] **Step 5: Commit the interpreter** - -```bash -git add Sources/CodeIsland/NotchGesture.swift Tests/CodeIslandTests/NotchGestureInterpreterTests.swift -git commit -m "feat(gestures): interpret notch trackpad swipes" -``` - -### Task 3: State-aware Gesture Policy - -**Files:** -- Modify: `Sources/CodeIsland/NotchGesture.swift` -- Create: `Tests/CodeIslandTests/NotchGesturePolicyTests.swift` - -**Interfaces:** -- Produces: `NotchGesturePolicy.canOpen(surface:hasSessions:) -> Bool`. -- Produces: `NotchGesturePolicy.canClose(surface:) -> Bool`. -- Produces: `NotchGesturePolicy.filterMode(from:action:controlsVisible:) -> String?`. - -- [ ] **Step 1: Write failing policy tests** - -```swift -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), "status") - XCTAssertEqual(NotchGesturePolicy.filterMode(from: "status", action: .navigateNext, controlsVisible: true), "cli") - XCTAssertEqual(NotchGesturePolicy.filterMode(from: "cli", action: .navigateNext, controlsVisible: true), "cli") - XCTAssertEqual(NotchGesturePolicy.filterMode(from: "status", action: .navigatePrevious, controlsVisible: true), "all") - XCTAssertEqual(NotchGesturePolicy.filterMode(from: "all", action: .navigatePrevious, controlsVisible: true), "all") - XCTAssertNil(NotchGesturePolicy.filterMode(from: "all", action: .navigateNext, controlsVisible: false)) -} -``` - -- [ ] **Step 2: Run focused tests and verify failure** - -Run: `swift test --filter NotchGesturePolicyTests` - -Expected: compilation fails because `NotchGesturePolicy` does not exist. - -- [ ] **Step 3: Implement the policy** - -Use a fixed mode order: - -```swift -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 - } - } -} -``` - -Implement `filterMode` with a clamped index and return `nil` when controls are hidden or the action is not horizontal. - -- [ ] **Step 4: Run focused tests and verify pass** - -Run: `swift test --filter NotchGesturePolicyTests` - -Expected: PASS. - -- [ ] **Step 5: Commit the policy** - -```bash -git add Sources/CodeIsland/NotchGesture.swift Tests/CodeIslandTests/NotchGesturePolicyTests.swift -git commit -m "feat(gestures): add notch action policy" -``` - -### Task 4: Gestures Settings Page, Localization, and Diagnostics - -**Files:** -- Modify: `Sources/CodeIsland/SettingsView.swift` -- Modify: `Sources/CodeIsland/L10n.swift` -- Modify: `Sources/CodeIsland/DiagnosticsExporter.swift` -- Modify: `Tests/CodeIslandTests/L10nTests.swift` - -**Interfaces:** -- Produces: `SettingsPage.gestures` between Behavior and Appearance. -- Consumes: Task 1 settings and `HoverOpenDelay` constants. - -- [ ] **Step 1: Add failing localization assertions** - -Extend each existing translation-value test to assert that `L10n.shared["gestures"]` returns the expected localized page name. Add a test that every supported non-English dictionary contains all English keys, including `zh` and `zh-Hant`. - -- [ ] **Step 2: Run localization tests and verify failure** - -Run: `swift test --filter L10nTests` - -Expected: FAIL because gesture keys and translations are missing. - -- [ ] **Step 3: Add the Gestures page and localized copy** - -Add `.gestures` to `SettingsPage`, its icon/color switch cases, the first sidebar group after `.behavior`, and the detail switch. - -Create `GesturesPage` with: - -```swift -@AppStorage(SettingsKey.openOnHover) private var openOnHover = SettingsDefaults.openOnHover -@AppStorage(SettingsKey.hoverOpenDelay) private var hoverOpenDelay = SettingsDefaults.hoverOpenDelay -@AppStorage(SettingsKey.hapticOnHover) private var hapticOnHover = SettingsDefaults.hapticOnHover -@AppStorage(SettingsKey.hapticIntensity) private var hapticIntensity = SettingsDefaults.hapticIntensity -``` - -The page uses a grouped `Form`, an Open on hover toggle, a slider bound to `HoverOpenDelay.minimum...maximum` with `step: HoverOpenDelay.step`, a formatted seconds label, moved haptic controls, and a four-row gesture reference. Remove only the moved haptic controls from `BehaviorPage`; leave all other behavior settings untouched. - -Add these keys to every language dictionary with real localized strings: - -```text -gestures -gesture_opening -open_on_hover -open_on_hover_desc -hover_open_delay -seconds_short -gesture_reference -gesture_click_open -gesture_swipe_down_open -gesture_swipe_up_close -gesture_swipe_horizontal_filter -``` - -- [ ] **Step 4: Export the settings in diagnostics** - -Add `openOnHover` and the clamped `hoverOpenDelay` to the diagnostics metadata settings dictionary. - -- [ ] **Step 5: Run localization and settings tests** - -Run: `swift test --filter L10nTests` - -Run: `swift test --filter NotchGestureSettingsTests` - -Expected: PASS. - -- [ ] **Step 6: Commit the settings UI** - -```bash -git add Sources/CodeIsland/SettingsView.swift Sources/CodeIsland/L10n.swift Sources/CodeIsland/DiagnosticsExporter.swift Tests/CodeIslandTests/L10nTests.swift -git commit -m "feat(settings): add gesture controls" -``` - -### Task 5: Notch Event Monitor and View Integration - -**Files:** -- Modify: `Sources/CodeIsland/NotchGesture.swift` -- Modify: `Sources/CodeIsland/NotchPanelView.swift` -- Modify: `Tests/CodeIslandTests/NotchPanelViewTests.swift` - -**Interfaces:** -- Produces: lifecycle-owned `NotchGestureMonitor.start(onAction:)`, `.isEnabled`, and `.stop()`. -- Consumes: gesture interpreter, policy, settings, AppState surface, and session grouping preference. - -- [ ] **Step 1: Add failing hover regression tests** - -Extend `NotchHoverInteractionTests` to assert that `HoverOpenDelay.clamped` supplies the configurable open timer while `NotchHoverInteraction.collapseDelay` remains exactly `0.5`. Add a state-machine regression showing disabling hover while in prehover returns the visual phase to collapsed through a new `.hoverDisabled` event. - -- [ ] **Step 2: Run the focused tests and verify failure** - -Run: `swift test --filter NotchHoverInteractionTests` - -Expected: FAIL because `.hoverDisabled` is not defined. - -- [ ] **Step 3: Add the local AppKit monitor** - -Implement a small `@MainActor` reference type in `NotchGesture.swift`: - -```swift -final class NotchGestureMonitor { - var isEnabled = false - private var monitor: Any? - private var interpreter = NotchGestureInterpreter() - - func start(onAction: @escaping (NotchGestureAction) -> Void) { - guard monitor == nil else { return } - monitor = NSEvent.addLocalMonitorForEvents(matching: .scrollWheel) { [weak self] event in - guard let self, self.isEnabled else { return event } - guard let action = self.interpreter.consume(NotchScrollSample(event: event)) else { return event } - onAction(action) - return nil - } - } - - func stop() { - if let monitor { NSEvent.removeMonitor(monitor) } - monitor = nil - interpreter.reset() - } - - deinit { - if let monitor { NSEvent.removeMonitor(monitor) } - } -} -``` - -If Swift concurrency rejects AppKit monitor removal from `deinit`, make `stop()` the lifecycle guarantee and omit only the `deinit` fallback rather than weakening actor isolation. - -- [ ] **Step 4: Integrate the compact/header region** - -In `NotchPanelView`: - -- add AppStorage for `openOnHover`, `hoverOpenDelay`, and parent-level `sessionGroupingMode`; -- preserve a `NotchGestureMonitor` and `gestureRegionHovered` state; -- attach hover tracking and click-open to the compact/header strip, not the expanded card body; -- start/stop the monitor with view appearance; -- route semantic actions through small `openSessionList`, `closeForGesture`, and `handleGestureAction` methods using `NotchGesturePolicy`; -- make click open-only and require a collapsed surface with sessions; -- use the configured, clamped delay only when Open on hover is enabled; -- cancel pending prehover and timers when Open on hover becomes disabled; -- leave the existing mouse-leave collapse timer at `NotchHoverInteraction.collapseDelay`; -- preserve approval/question protection and completion-queue cancellation behavior. - -- [ ] **Step 5: Run focused gesture and hover tests** - -Run: `swift test --filter NotchGesture` - -Run: `swift test --filter NotchHoverInteractionTests` - -Expected: PASS. - -- [ ] **Step 6: Run full verification** - -Run: `swift test` - -Run: `swift build` - -Expected: both commands exit 0. - -- [ ] **Step 7: Commit the integration** - -```bash -git add Sources/CodeIsland/NotchGesture.swift Sources/CodeIsland/NotchPanelView.swift Tests/CodeIslandTests/NotchPanelViewTests.swift -git commit -m "feat(notch): add configurable trackpad gestures" -``` - -### Task 6: Final Diff Review and Draft PR - -**Files:** -- Review all files changed from `upstream/main`. -- Update: `docs/superpowers/plans/2026-08-14-notch-gesture-support.md` checkboxes if tracked in the PR. - -**Interfaces:** -- Consumes: all prior tasks. -- Produces: a pushed fork branch and draft PR targeting `wxtsky/CodeIsland:main`. - -- [ ] **Step 1: Review scope and diff hygiene** - -Run: `git status -sb` - -Run: `git diff --check upstream/main...HEAD` - -Run: `git diff --stat upstream/main...HEAD` - -Run: `git log --oneline upstream/main..HEAD` - -Expected: only the approved design, plan, settings, gesture implementation, localization, diagnostics, and tests are present. - -- [ ] **Step 2: Re-run release-facing verification** - -Run: `swift test` - -Run: `swift build` - -Expected: both commands exit 0 with fresh output. - -- [ ] **Step 3: Push to the authenticated user's fork** - -Ensure remotes are: - -```text -origin git@github.com:Mrjamedd/CodeIsland.git -upstream https://github.com/wxtsky/CodeIsland.git -``` - -Then run: - -```bash -git push -u origin agent/notch-gesture-support -``` - -- [ ] **Step 4: Open the draft PR** - -Create a draft PR against `wxtsky/CodeIsland:main` with title: - -```text -feat(notch): add configurable trackpad gestures -``` - -The body must summarize settings, gestures, safety boundaries, user impact, and the exact verification commands. Use `Mrjamedd:agent/notch-gesture-support` as the cross-fork head. - -- [ ] **Step 5: Report review handoff** - -Provide the draft PR URL, branch, commits, tests, and any manual trackpad verification still recommended. diff --git a/docs/superpowers/specs/2026-08-14-notch-gesture-support-design.md b/docs/superpowers/specs/2026-08-14-notch-gesture-support-design.md deleted file mode 100644 index 0cc7bef2..00000000 --- a/docs/superpowers/specs/2026-08-14-notch-gesture-support-design.md +++ /dev/null @@ -1,153 +0,0 @@ -# Notch Gesture Support Design - -## Summary - -Add a dedicated **Gestures** settings page and native trackpad gesture support to CodeIsland's notch header. Users can choose whether hovering opens the notch, adjust the hover-to-open delay, click or swipe down to open, swipe up to close eligible surfaces, and swipe horizontally to change the existing `ALL` / `STA` / `CLI` session filter. - -The change stays limited to notch interaction behavior, settings, localization, diagnostics, and focused tests. It does not alter session processing, permission decisions, question answers, or terminal activation. - -## Goals - -- Let users disable hover-to-open without losing direct ways to open the notch. -- Make hover-to-open timing configurable from 0.1 to 1.5 seconds. -- Add reliable two-finger trackpad gestures scoped to the visible notch header. -- Preserve current safety rules for approval and question cards. -- Preserve existing defaults for current users. - -## Non-goals - -- Global gestures outside the notch header. -- Keyboard shortcut changes. -- Gesture customization or arbitrary gesture-to-action mapping. -- Changes to mouse-leave timing, permission handling, session data, or panel layout. -- Navigation between individual session cards. - -## User Experience - -### Gestures settings page - -Add `Gestures` to the settings sidebar immediately after `Behavior`. - -The page contains: - -1. **Open on hover**, enabled by default to preserve current behavior. -2. **Hover delay**, visible or enabled only when Open on hover is enabled. - - Range: 0.1 to 1.5 seconds. - - Step: 0.1 seconds. - - Default: 0.5 seconds, matching the current fixed delay. -3. The existing hover haptic toggle and intensity control, moved from Behavior to Gestures so all hover interaction preferences remain together. -4. A concise, localized gesture reference describing click, swipe down, swipe up, and horizontal filter navigation. - -The existing `collapseOnMouseLeave` preference and its 0.5-second close delay remain unchanged. - -### Opening - -- When **Open on hover** is enabled, pointer entry keeps the current prehover animation and opens after the configured delay. -- When **Open on hover** is disabled, pointer entry does not start the prehover or opening timer. -- Clicking the collapsed compact notch opens the session list immediately. Clicking is open-only; it does not close an expanded notch. -- A two-finger swipe down over the notch header opens the session list immediately. -- Click and swipe-down are intentional actions, so they open regardless of Smart Suppress. Smart Suppress continues to affect hover-only opening. - -### Closing - -- Leaving an expanded view keeps the current mouse-leave behavior and 0.5-second grace delay. -- A two-finger swipe up over the notch header closes `.sessionList` and `.completionCard` immediately. -- Swipe up is ignored for `.approvalCard` and `.questionCard`. It never approves, denies, answers, skips, or dismisses a pending request. -- Swiping up while already collapsed is a no-op. - -### Filter navigation - -Horizontal two-finger swipes navigate the existing `ALL` / `STA` / `CLI` session filters when the expanded session-list header is showing those controls. - -- A physical swipe left advances the selection visually to the right: `ALL` to `STA`, then `STA` to `CLI`. -- A physical swipe right moves the selection visually to the left. -- Navigation stops at `ALL` and `CLI`; it never wraps. -- Horizontal navigation is ignored when the filter controls are not visible. - -### Gesture scope - -Gestures are recognized only while the pointer is inside the visible compact/header strip of the notch. Transparent parts of the panel window and the scrollable session-card body do not participate. This prevents interference with ordinary scrolling elsewhere. - -## Architecture - -### Gesture interpreter - -Add a small pure Swift gesture interpreter in the CodeIsland target. It consumes normalized AppKit scroll samples and emits semantic actions: - -- `open` -- `close` -- `navigatePrevious` -- `navigateNext` - -The interpreter: - -- accumulates precise scroll deltas for one gesture sequence; -- locks to the dominant axis before emitting an action; -- normalizes device-direction metadata so physical finger movement follows the agreed natural direction; -- ignores momentum-only events; -- emits at most one action per gesture sequence; -- resets when the gesture ends or is cancelled; -- uses a small activation threshold to reject incidental trackpad noise. - -All threshold, axis-locking, direction, and one-action behavior is unit-testable without creating an AppKit window. - -### AppKit event monitor - -Add a lifecycle-owned local `NSEvent` monitor for `.scrollWheel` events. The monitor forwards precise samples to the interpreter only while SwiftUI reports that the pointer is inside the notch header gesture region. - -The monitor does not install a global listener and does not interpret events outside the region. It consumes a scroll sequence only after the interpreter recognizes a notch action, avoiding broad interference with regular scrolling. - -The monitor exposes semantic actions to `NotchPanelView`; it does not mutate `AppState` or settings directly. - -### SwiftUI action routing - -`NotchPanelView` owns the interaction policy: - -- open actions set the surface to `.sessionList` and preserve the existing active-session fallback; -- close actions check the current surface and preserve protected approval/question cards; -- filter actions update `sessionGroupingMode` using the fixed ordered list `all`, `status`, `cli` with clamped indices; -- header hover state enables or disables event interpretation; -- click-to-open attaches to the collapsed compact header without covering expanded buttons or cards; -- hover opening reads the new settings instead of the current fixed delay. - -Keep the pure filter-step calculation separate from rendering so boundary behavior is unit-tested. - -### Settings and localization - -Add persisted settings with registered defaults: - -- `openOnHover: Bool = true` -- `hoverOpenDelay: Double = 0.5` - -Add a `.gestures` settings page, localized sidebar title, control labels, descriptions, and gesture reference strings for every localization dictionary already maintained in `L10n.swift`. - -Include the new settings in diagnostics export alongside the existing interaction preferences. - -## Error and Edge-case Handling - -- A missing or malformed saved hover delay is clamped to the supported 0.1-to-1.5-second range before scheduling a timer. -- Disabling Open on hover cancels any pending hover-open timer and clears prehover state. -- Re-entering during the mouse-leave grace period cancels collapse exactly as it does today. -- Gesture actions are state-aware and become no-ops when the current surface cannot accept them. -- Momentum scrolling cannot trigger a second action after the user's fingers leave the trackpad. -- Filter navigation remains stable if session count changes during a gesture. - -## Testing - -Add focused unit tests for: - -- gesture axis locking and activation threshold; -- physical swipe directions, including device-direction normalization; -- one action per gesture and momentum suppression; -- swipe-down open and swipe-up close action classification; -- filter movement in both directions and clamping at `ALL` / `CLI`; -- protected approval/question surfaces ignoring close actions; -- hover-delay clamping and default value; -- existing three-stage hover behavior using a configurable delay without changing the 0.5-second close delay; -- settings page registration and localization-key completeness where existing test patterns support it. - -Validation will run the focused gesture/settings tests first, followed by the full Swift test suite and a debug build. - -## Pull Request Shape - -Use branch `agent/notch-gesture-support` and open a draft PR against `wxtsky/CodeIsland:main` from the user's fork. The PR will contain only the gesture implementation, settings/localization updates, diagnostics update, tests, and this design document. From 810711a608a138eac455cfe5e04338968df44537 Mon Sep 17 00:00:00 2001 From: Mrjamedd Date: Fri, 14 Aug 2026 15:31:22 -0400 Subject: [PATCH 09/18] fix(gestures): correct horizontal swipe direction --- Sources/CodeIsland/DiagnosticsExporter.swift | 3 +++ Sources/CodeIsland/L10n.swift | 14 ++++++++++++++ Sources/CodeIsland/NotchGesture.swift | 9 +++++---- Sources/CodeIsland/NotchPanelView.swift | 4 +++- Sources/CodeIsland/Settings.swift | 8 ++++++++ Sources/CodeIsland/SettingsView.swift | 10 ++++++++++ .../NotchGestureInterpreterTests.swift | 6 ++++-- .../NotchGesturePolicyTests.swift | 17 +++++++++++------ .../NotchGestureSettingsTests.swift | 1 + 9 files changed, 59 insertions(+), 13 deletions(-) diff --git a/Sources/CodeIsland/DiagnosticsExporter.swift b/Sources/CodeIsland/DiagnosticsExporter.swift index 98cfbc77..ecd067b1 100644 --- a/Sources/CodeIsland/DiagnosticsExporter.swift +++ b/Sources/CodeIsland/DiagnosticsExporter.swift @@ -126,6 +126,9 @@ struct DiagnosticsExporter { 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/L10n.swift b/Sources/CodeIsland/L10n.swift index ceb7cfb2..451c2389 100644 --- a/Sources/CodeIsland/L10n.swift +++ b/Sources/CodeIsland/L10n.swift @@ -77,6 +77,8 @@ final class L10n: ObservableObject { "hover_open_delay": "Hover Delay", "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", @@ -442,6 +444,8 @@ final class L10n: ObservableObject { "hover_open_delay": "Hover-Verzögerung", "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", @@ -803,6 +807,8 @@ final class L10n: ObservableObject { "hover_open_delay": "悬停打开延迟", "seconds_short": "%.1f 秒", "gesture_reference": "触控板手势", + "invert_horizontal_swipe": "反转横向滑动", + "invert_horizontal_swipe_desc": "反转会话筛选器的左右导航方向", "gesture_click_open": "点击收起的刘海即可打开", "gesture_swipe_down_open": "双指向下轻扫即可打开", "gesture_swipe_up_close": "双指向上轻扫即可关闭", @@ -1168,6 +1174,8 @@ final class L10n: ObservableObject { "hover_open_delay": "懸停開啟延遲", "seconds_short": "%.1f 秒", "gesture_reference": "觸控板手勢", + "invert_horizontal_swipe": "反轉橫向滑動", + "invert_horizontal_swipe_desc": "反轉工作階段篩選器的左右導覽方向", "gesture_click_open": "點擊收合的瀏海即可開啟", "gesture_swipe_down_open": "雙指向下滑動即可開啟", "gesture_swipe_up_close": "雙指向上滑動即可關閉", @@ -1533,6 +1541,8 @@ final class L10n: ObservableObject { "hover_open_delay": "ホバーで開くまでの時間", "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本指で上にスワイプして閉じる", @@ -1898,6 +1908,8 @@ final class L10n: ObservableObject { "hover_open_delay": "호버 열기 지연", "seconds_short": "%.1f초", "gesture_reference": "트랙패드 제스처", + "invert_horizontal_swipe": "가로 스와이프 반전", + "invert_horizontal_swipe_desc": "세션 필터의 좌우 이동 방향을 반대로 전환합니다", "gesture_click_open": "컴팩트 노치를 클릭하여 열기", "gesture_swipe_down_open": "두 손가락으로 아래로 쓸어 열기", "gesture_swipe_up_close": "두 손가락으로 위로 쓸어 닫기", @@ -2263,6 +2275,8 @@ final class L10n: ObservableObject { "hover_open_delay": "Üzerine Gelme Gecikmesi", "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", diff --git a/Sources/CodeIsland/NotchGesture.swift b/Sources/CodeIsland/NotchGesture.swift index 10140744..70ab347e 100644 --- a/Sources/CodeIsland/NotchGesture.swift +++ b/Sources/CodeIsland/NotchGesture.swift @@ -135,7 +135,7 @@ struct NotchGestureInterpreter { if horizontal >= Self.activationThreshold, horizontal >= vertical * Self.axisDominance { - action = accumulatedX < 0 ? .navigateNext : .navigatePrevious + action = accumulatedX > 0 ? .navigateNext : .navigatePrevious } else if vertical >= Self.activationThreshold, vertical >= horizontal * Self.axisDominance { action = accumulatedY < 0 ? .open : .close @@ -175,7 +175,8 @@ enum NotchGesturePolicy { static func filterMode( from currentMode: String, action: NotchGestureAction, - controlsVisible: Bool + controlsVisible: Bool, + inverted: Bool ) -> String? { guard controlsVisible, let currentIndex = filterModes.firstIndex(of: currentMode) else { return nil } @@ -183,9 +184,9 @@ enum NotchGesturePolicy { let offset: Int switch action { case .navigatePrevious: - offset = -1 + offset = inverted ? 1 : -1 case .navigateNext: - offset = 1 + offset = inverted ? -1 : 1 case .open, .close: return nil } diff --git a/Sources/CodeIsland/NotchPanelView.swift b/Sources/CodeIsland/NotchPanelView.swift index 5b408949..416c81ab 100644 --- a/Sources/CodeIsland/NotchPanelView.swift +++ b/Sources/CodeIsland/NotchPanelView.swift @@ -58,6 +58,7 @@ struct NotchPanelView: View { @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.hapticOnHover) private var hapticOnHover = SettingsDefaults.hapticOnHover @AppStorage(SettingsKey.hapticIntensity) private var hapticIntensity = SettingsDefaults.hapticIntensity @AppStorage(SettingsKey.sessionGroupingMode) private var groupingMode = SettingsDefaults.sessionGroupingMode @@ -158,7 +159,8 @@ struct NotchPanelView: View { guard let nextMode = NotchGesturePolicy.filterMode( from: groupingMode, action: action, - controlsVisible: controlsVisible + controlsVisible: controlsVisible, + inverted: invertHorizontalSwipeDirection ) else { return } withAnimation(.easeInOut(duration: 0.15)) { groupingMode = nextMode diff --git a/Sources/CodeIsland/Settings.swift b/Sources/CodeIsland/Settings.swift index 8f410d2a..17175c08 100644 --- a/Sources/CodeIsland/Settings.swift +++ b/Sources/CodeIsland/Settings.swift @@ -45,6 +45,7 @@ enum SettingsKey { static let collapseOnMouseLeave = "collapseOnMouseLeave" static let openOnHover = "openOnHover" static let hoverOpenDelay = "hoverOpenDelay" + static let invertHorizontalSwipeDirection = "invertHorizontalSwipeDirection" static let autoCollapseAfterSessionJump = "autoCollapseAfterSessionJump" static let autoExpandOnPermission = "autoExpandOnPermission" static let autoExpandOnCompletion = "autoExpandOnCompletion" @@ -159,6 +160,7 @@ struct SettingsDefaults { static let collapseOnMouseLeave = true static let openOnHover = true static let hoverOpenDelay = 0.5 + static let invertHorizontalSwipeDirection = false static let autoCollapseAfterSessionJump = false static let autoExpandOnPermission = true static let autoExpandOnCompletion = true @@ -248,6 +250,7 @@ class SettingsManager { SettingsKey.collapseOnMouseLeave: SettingsDefaults.collapseOnMouseLeave, SettingsKey.openOnHover: SettingsDefaults.openOnHover, SettingsKey.hoverOpenDelay: SettingsDefaults.hoverOpenDelay, + SettingsKey.invertHorizontalSwipeDirection: SettingsDefaults.invertHorizontalSwipeDirection, SettingsKey.autoCollapseAfterSessionJump: SettingsDefaults.autoCollapseAfterSessionJump, SettingsKey.autoExpandOnPermission: SettingsDefaults.autoExpandOnPermission, SettingsKey.autoExpandOnCompletion: SettingsDefaults.autoExpandOnCompletion, @@ -362,6 +365,11 @@ class SettingsManager { set { defaults.set(HoverOpenDelay.clamped(newValue), forKey: SettingsKey.hoverOpenDelay) } } + 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 d6f80952..dbe42bfb 100644 --- a/Sources/CodeIsland/SettingsView.swift +++ b/Sources/CodeIsland/SettingsView.swift @@ -115,6 +115,7 @@ 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.invertHorizontalSwipeDirection) private var invertHorizontalSwipeDirection = SettingsDefaults.invertHorizontalSwipeDirection @AppStorage(SettingsKey.hapticOnHover) private var hapticOnHover = SettingsDefaults.hapticOnHover @AppStorage(SettingsKey.hapticIntensity) private var hapticIntensity = SettingsDefaults.hapticIntensity @@ -174,6 +175,15 @@ private struct GesturesPage: View { } 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"]) diff --git a/Tests/CodeIslandTests/NotchGestureInterpreterTests.swift b/Tests/CodeIslandTests/NotchGestureInterpreterTests.swift index 39da49cb..019ae42e 100644 --- a/Tests/CodeIslandTests/NotchGestureInterpreterTests.swift +++ b/Tests/CodeIslandTests/NotchGestureInterpreterTests.swift @@ -3,8 +3,10 @@ import XCTest final class NotchGestureInterpreterTests: XCTestCase { func testPhysicalDirectionsMapToNaturalNotchActions() { - XCTAssertEqual(action(x: -30), .navigateNext) - XCTAssertEqual(action(x: 30), .navigatePrevious) + // 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) } diff --git a/Tests/CodeIslandTests/NotchGesturePolicyTests.swift b/Tests/CodeIslandTests/NotchGesturePolicyTests.swift index 8194321a..bb1b513d 100644 --- a/Tests/CodeIslandTests/NotchGesturePolicyTests.swift +++ b/Tests/CodeIslandTests/NotchGesturePolicyTests.swift @@ -17,11 +17,16 @@ final class NotchGesturePolicyTests: XCTestCase { } func testFilterNavigationUsesNaturalDirectionAndClamps() { - XCTAssertEqual(NotchGesturePolicy.filterMode(from: "all", action: .navigateNext, controlsVisible: true), "status") - XCTAssertEqual(NotchGesturePolicy.filterMode(from: "status", action: .navigateNext, controlsVisible: true), "cli") - XCTAssertEqual(NotchGesturePolicy.filterMode(from: "cli", action: .navigateNext, controlsVisible: true), "cli") - XCTAssertEqual(NotchGesturePolicy.filterMode(from: "status", action: .navigatePrevious, controlsVisible: true), "all") - XCTAssertEqual(NotchGesturePolicy.filterMode(from: "all", action: .navigatePrevious, controlsVisible: true), "all") - XCTAssertNil(NotchGesturePolicy.filterMode(from: "all", action: .navigateNext, controlsVisible: false)) + 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 index eb4f5238..d8ae664e 100644 --- a/Tests/CodeIslandTests/NotchGestureSettingsTests.swift +++ b/Tests/CodeIslandTests/NotchGestureSettingsTests.swift @@ -5,6 +5,7 @@ final class NotchGestureSettingsTests: XCTestCase { func testHoverSettingsPreserveExistingDefaults() { XCTAssertTrue(SettingsDefaults.openOnHover) XCTAssertEqual(SettingsDefaults.hoverOpenDelay, 0.5, accuracy: 0.001) + XCTAssertFalse(SettingsDefaults.invertHorizontalSwipeDirection) } func testHoverDelayClampsValuesToSupportedRange() { From c3241edd5cb6fff4dfd1f1e1758fc6074c51594d Mon Sep 17 00:00:00 2001 From: Mrjamedd Date: Fri, 14 Aug 2026 15:48:10 -0400 Subject: [PATCH 10/18] fix(notch): refine gesture hitbox and hover feedback --- Sources/CodeIsland/NotchGesture.swift | 114 +++++++++++++++--- Sources/CodeIsland/NotchPanelView.swift | 52 ++++---- .../CodeIsland/PanelWindowController.swift | 3 + .../NotchGestureInterpreterTests.swift | 35 ++++++ .../CodeIslandTests/NotchPanelViewTests.swift | 14 ++- 5 files changed, 179 insertions(+), 39 deletions(-) diff --git a/Sources/CodeIsland/NotchGesture.swift b/Sources/CodeIsland/NotchGesture.swift index 70ab347e..cee8d5c4 100644 --- a/Sources/CodeIsland/NotchGesture.swift +++ b/Sources/CodeIsland/NotchGesture.swift @@ -15,7 +15,6 @@ enum NotchHoverEvent { case mouseExited case expandDelayElapsed case collapseDelayElapsed - case hoverDisabled } enum NotchHoverInteraction { @@ -28,7 +27,7 @@ enum NotchHoverInteraction { switch (phase, event) { case (.collapsed, .mouseEntered): return .prehover - case (.prehover, .mouseExited), (.prehover, .hoverDisabled): + case (.prehover, .mouseExited): return .collapsed case (.prehover, .expandDelayElapsed): return .expanded @@ -38,16 +37,25 @@ enum NotchHoverInteraction { return phase } } + + static func shouldScheduleOpen(openOnHover: Bool) -> Bool { + openOnHover + } +} + +enum NotchVisualStyle { + static let outlineWidth: CGFloat = 1 + static let outlineOpacity = 0.12 } -enum NotchGestureAction: Equatable { +enum NotchGestureAction: Equatable, Sendable { case open case close case navigatePrevious case navigateNext } -struct NotchScrollSample { +struct NotchScrollSample: Sendable { let physicalDeltaX: CGFloat let physicalDeltaY: CGFloat let began: Bool @@ -104,6 +112,29 @@ struct NotchScrollSample { } } +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, headerWidth: CGFloat, headerHeight: CGFloat) { + let width = Swift.min(Swift.max(headerWidth, 0), panelFrame.width) + minX = panelFrame.midX - width / 2 + maxX = panelFrame.midX + width / 2 + minY = panelFrame.maxY - Swift.max(headerHeight, 0) + maxY = panelFrame.maxY + Self.topEdgeExtension + } + + func contains(_ point: NSPoint) -> Bool { + point.x >= minX && point.x <= maxX && point.y >= minY && point.y <= maxY + } +} + struct NotchGestureInterpreter { static let activationThreshold: CGFloat = 24 static let axisDominance: CGFloat = 1.2 @@ -209,34 +240,89 @@ final class NotchGestureMonitor { } } - private var monitor: Any? + private var localMonitor: Any? + private var globalMonitor: Any? private var interpreter = NotchGestureInterpreter() + private var panelFrameProvider: (() -> NSRect?)? + private var onAction: ((NotchGestureAction) -> Void)? + private var headerWidth: CGFloat = 0 + private var headerHeight: CGFloat = 0 + + func updateRegion(headerWidth: CGFloat, headerHeight: CGFloat) { + self.headerWidth = headerWidth + self.headerHeight = headerHeight + } - func start(onAction: @escaping (NotchGestureAction) -> Void) { - guard monitor == nil else { return } + func start( + panelFrameProvider: @escaping () -> NSRect?, + onAction: @escaping (NotchGestureAction) -> Void + ) { + guard localMonitor == nil, globalMonitor == nil else { return } + self.panelFrameProvider = panelFrameProvider + self.onAction = onAction - monitor = NSEvent.addLocalMonitorForEvents(matching: .scrollWheel) { [weak self] event in + localMonitor = NSEvent.addLocalMonitorForEvents(matching: .scrollWheel) { [weak self] event in guard let self, self.isEnabled else { return event } - guard let action = self.interpreter.consume(NotchScrollSample(event: event)) else { + guard self.isWithinRegion(NSEvent.mouseLocation) else { + self.interpreter.reset() return event } + guard let action = self.interpreter.consume(NotchScrollSample(event: event)) else { return event } 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 + let sample = NotchScrollSample(event: event) + let location = NSEvent.mouseLocation + Task { @MainActor [weak self] in + self?.consumeGlobal(sample, at: location) + } + } } func stop() { - if let monitor { - NSEvent.removeMonitor(monitor) + if let localMonitor { + NSEvent.removeMonitor(localMonitor) } - monitor = nil + if let globalMonitor { + NSEvent.removeMonitor(globalMonitor) + } + localMonitor = nil + globalMonitor = nil + panelFrameProvider = nil + onAction = nil interpreter.reset() } + private func consumeGlobal(_ sample: NotchScrollSample, at location: NSPoint) { + guard isEnabled, isWithinRegion(location) else { + interpreter.reset() + return + } + guard let action = interpreter.consume(sample) else { return } + onAction?(action) + } + + private func isWithinRegion(_ location: NSPoint) -> Bool { + guard let panelFrame = panelFrameProvider?() else { return false } + return NotchGestureHitbox( + panelFrame: panelFrame, + headerWidth: headerWidth, + headerHeight: headerHeight + ).contains(location) + } + deinit { - if let monitor { - NSEvent.removeMonitor(monitor) + 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 416c81ab..f26b210a 100644 --- a/Sources/CodeIsland/NotchPanelView.swift +++ b/Sources/CodeIsland/NotchPanelView.swift @@ -187,9 +187,6 @@ struct NotchPanelView: View { } .frame(height: notchHeight) .contentShape(Rectangle()) - .onHover { hovering in - gestureMonitor.isEnabled = hovering - } .onTapGesture { openSessionList() } @@ -290,6 +287,18 @@ struct NotchPanelView: View { ) .fill(.black) ) + .overlay( + NotchPanelShape( + topExtension: shouldShowExpanded ? 14 : 3, + bottomRadius: shouldShowExpanded ? 24 : 12, + minHeight: notchHeight + ) + .stroke( + Color.white.opacity(NotchVisualStyle.outlineOpacity), + lineWidth: NotchVisualStyle.outlineWidth + ) + .allowsHitTesting(false) + ) .offset(y: curtainOffset) .opacity(curtainOpacity) .onChange(of: showToolStatus) { _, newValue in @@ -312,7 +321,11 @@ struct NotchPanelView: View { } .onAppear { displayedToolStatus = showToolStatus - gestureMonitor.start { action in + gestureMonitor.updateRegion(headerWidth: panelWidth, headerHeight: notchHeight) + gestureMonitor.isEnabled = showBar + gestureMonitor.start(panelFrameProvider: { + (NSApp.delegate as? AppDelegate)?.panelController?.gesturePanelFrame + }) { action in handleGestureAction(action) } } @@ -363,15 +376,17 @@ struct NotchPanelView: View { isHovered = hovering if hovering { - guard openOnHover else { - hoverTimer?.invalidate() - hoverTimer = nil - withAnimation(NotchAnimation.hoverPrehover) { - hoverPhase = NotchHoverInteraction.nextPhase(from: hoverPhase, event: .hoverDisabled) - } - return + 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, @@ -381,13 +396,7 @@ struct NotchPanelView: View { return } - // Immediate lightweight acknowledgement; a quick pass-through - // only ever plays this first stage and reverses. - withAnimation(NotchAnimation.hoverPrehover) { - hoverPhase = NotchHoverInteraction.nextPhase(from: hoverPhase, event: .mouseEntered) - } // Delay full expansion to avoid accidental triggers - hoverTimer?.invalidate() hoverTimer = Timer.scheduledTimer(withTimeInterval: HoverOpenDelay.clamped(hoverOpenDelay), repeats: false) { _ in Task { @MainActor in // Guard: mouse may have left during the delay @@ -442,13 +451,12 @@ struct NotchPanelView: View { guard !enabled else { return } hoverTimer?.invalidate() hoverTimer = nil - withAnimation(NotchAnimation.hoverPrehover) { - hoverPhase = NotchHoverInteraction.nextPhase(from: hoverPhase, event: .hoverDisabled) - } } .onChange(of: showBar) { _, visible in - guard !visible else { return } - gestureMonitor.isEnabled = false + gestureMonitor.isEnabled = visible + } + .onChange(of: panelWidth) { _, width in + gestureMonitor.updateRegion(headerWidth: width, headerHeight: notchHeight) } Spacer() diff --git a/Sources/CodeIsland/PanelWindowController.swift b/Sources/CodeIsland/PanelWindowController.swift index 829b9249..2ac8397c 100644 --- a/Sources/CodeIsland/PanelWindowController.swift +++ b/Sources/CodeIsland/PanelWindowController.swift @@ -670,6 +670,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 { diff --git a/Tests/CodeIslandTests/NotchGestureInterpreterTests.swift b/Tests/CodeIslandTests/NotchGestureInterpreterTests.swift index 019ae42e..7c0ecdb6 100644 --- a/Tests/CodeIslandTests/NotchGestureInterpreterTests.swift +++ b/Tests/CodeIslandTests/NotchGestureInterpreterTests.swift @@ -73,3 +73,38 @@ final class NotchGestureInterpreterTests: XCTestCase { ) } } + +final class NotchGestureHitboxTests: XCTestCase { + private let panelFrame = NSRect(x: 100, y: 300, width: 600, height: 500) + + func testIncludesExactScreenTopAndPhysicalNotchBand() { + let hitbox = NotchGestureHitbox( + panelFrame: panelFrame, + headerWidth: 240, + headerHeight: 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, + headerWidth: 240, + headerHeight: 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 testExpandedHeaderWidthExpandsGestureRegion() { + let collapsed = NotchGestureHitbox(panelFrame: panelFrame, headerWidth: 240, headerHeight: 38) + let expanded = NotchGestureHitbox(panelFrame: panelFrame, headerWidth: 520, headerHeight: 38) + let point = NSPoint(x: panelFrame.midX + 220, y: panelFrame.maxY - 20) + + XCTAssertFalse(collapsed.contains(point)) + XCTAssertTrue(expanded.contains(point)) + } +} diff --git a/Tests/CodeIslandTests/NotchPanelViewTests.swift b/Tests/CodeIslandTests/NotchPanelViewTests.swift index 4197b372..ae51037b 100644 --- a/Tests/CodeIslandTests/NotchPanelViewTests.swift +++ b/Tests/CodeIslandTests/NotchPanelViewTests.swift @@ -219,15 +219,23 @@ final class NotchHoverInteractionTests: XCTestCase { XCTAssertEqual(NotchHoverInteraction.nextPhase(from: .prehover, event: .collapseDelayElapsed), .prehover) } - func testDisablingHoverCancelsPrehoverWithoutCollapsingExpandedContent() { - XCTAssertEqual(NotchHoverInteraction.nextPhase(from: .prehover, event: .hoverDisabled), .collapsed) - XCTAssertEqual(NotchHoverInteraction.nextPhase(from: .expanded, event: .hoverDisabled), .expanded) + 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 testNotchOutlineIsFaintAndOnePixelWide() { + XCTAssertEqual(NotchVisualStyle.outlineWidth, 1, accuracy: 0.001) + XCTAssertGreaterThan(NotchVisualStyle.outlineOpacity, 0) + XCTAssertLessThanOrEqual(NotchVisualStyle.outlineOpacity, 0.15) + } + func testWidthScaleSliderUsesOnePercentSteps() { XCTAssertEqual(NotchWidthScale.min, 50) XCTAssertEqual(NotchWidthScale.max, 150) From 10dc4c18a3722901ad380a0f3b6a3895a37211fc Mon Sep 17 00:00:00 2001 From: Mrjamedd Date: Fri, 14 Aug 2026 19:08:48 -0400 Subject: [PATCH 11/18] fix(notch): restore swipe open and soften contrast edge --- Sources/CodeIsland/NotchGesture.swift | 57 +++++++++++-------- Sources/CodeIsland/NotchPanelView.swift | 13 ++++- .../CodeIsland/NotchVisibleOutlineShape.swift | 51 +++++++++++++++++ .../NotchGestureInterpreterTests.swift | 29 ++++++++++ .../CodeIslandTests/NotchPanelViewTests.swift | 31 ++++++++-- 5 files changed, 151 insertions(+), 30 deletions(-) create mode 100644 Sources/CodeIsland/NotchVisibleOutlineShape.swift diff --git a/Sources/CodeIsland/NotchGesture.swift b/Sources/CodeIsland/NotchGesture.swift index cee8d5c4..5d5229ec 100644 --- a/Sources/CodeIsland/NotchGesture.swift +++ b/Sources/CodeIsland/NotchGesture.swift @@ -44,8 +44,13 @@ enum NotchHoverInteraction { } enum NotchVisualStyle { - static let outlineWidth: CGFloat = 1 - static let outlineOpacity = 0.12 + static let contrastEdgeWidth: CGFloat = 0.75 + static let contrastEdgeOpacity = 0.08 + static let contrastEdgeBlurRadius: CGFloat = 0.35 + + static func showsContrastEdge(hasNotch: Bool, phase: NotchHoverPhase) -> Bool { + !hasNotch || phase != .collapsed + } } enum NotchGestureAction: Equatable, Sendable { @@ -263,11 +268,11 @@ final class NotchGestureMonitor { localMonitor = NSEvent.addLocalMonitorForEvents(matching: .scrollWheel) { [weak self] event in guard let self, self.isEnabled else { return event } - guard self.isWithinRegion(NSEvent.mouseLocation) else { - self.interpreter.reset() - return event - } - guard let action = self.interpreter.consume(NotchScrollSample(event: event)) else { return event } + guard let action = self.consumeObservedSample( + NotchScrollSample(event: event), + at: NSEvent.mouseLocation, + panelFrame: self.panelFrameProvider?() + ) else { return event } onAction(action) return nil @@ -277,11 +282,13 @@ final class NotchGestureMonitor { // 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 - let sample = NotchScrollSample(event: event) - let location = NSEvent.mouseLocation - Task { @MainActor [weak self] in - self?.consumeGlobal(sample, at: location) - } + guard let self, + let action = self.consumeObservedSample( + NotchScrollSample(event: event), + at: NSEvent.mouseLocation, + panelFrame: self.panelFrameProvider?() + ) else { return } + self.onAction?(action) } } @@ -299,22 +306,26 @@ final class NotchGestureMonitor { interpreter.reset() } - private func consumeGlobal(_ sample: NotchScrollSample, at location: NSPoint) { - guard isEnabled, isWithinRegion(location) else { + /// 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 + return nil } - guard let action = interpreter.consume(sample) else { return } - onAction?(action) - } - - private func isWithinRegion(_ location: NSPoint) -> Bool { - guard let panelFrame = panelFrameProvider?() else { return false } - return NotchGestureHitbox( + guard NotchGestureHitbox( panelFrame: panelFrame, headerWidth: headerWidth, headerHeight: headerHeight - ).contains(location) + ).contains(location) else { + interpreter.reset() + return nil + } + return interpreter.consume(sample) } deinit { diff --git a/Sources/CodeIsland/NotchPanelView.swift b/Sources/CodeIsland/NotchPanelView.swift index f26b210a..19b2211c 100644 --- a/Sources/CodeIsland/NotchPanelView.swift +++ b/Sources/CodeIsland/NotchPanelView.swift @@ -288,15 +288,21 @@ struct NotchPanelView: View { .fill(.black) ) .overlay( - NotchPanelShape( + NotchVisibleOutlineShape( topExtension: shouldShowExpanded ? 14 : 3, bottomRadius: shouldShowExpanded ? 24 : 12, minHeight: notchHeight ) .stroke( - Color.white.opacity(NotchVisualStyle.outlineOpacity), - lineWidth: NotchVisualStyle.outlineWidth + Color.white.opacity(NotchVisualStyle.contrastEdgeOpacity), + style: StrokeStyle( + lineWidth: NotchVisualStyle.contrastEdgeWidth, + lineCap: .round, + lineJoin: .round + ) ) + .blur(radius: NotchVisualStyle.contrastEdgeBlurRadius) + .opacity(NotchVisualStyle.showsContrastEdge(hasNotch: hasNotch, phase: hoverPhase) ? 1 : 0) .allowsHitTesting(false) ) .offset(y: curtainOffset) @@ -464,6 +470,7 @@ struct NotchPanelView: View { } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) .animation(NotchAnimation.open, 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/Tests/CodeIslandTests/NotchGestureInterpreterTests.swift b/Tests/CodeIslandTests/NotchGestureInterpreterTests.swift index 7c0ecdb6..7e90f96d 100644 --- a/Tests/CodeIslandTests/NotchGestureInterpreterTests.swift +++ b/Tests/CodeIslandTests/NotchGestureInterpreterTests.swift @@ -108,3 +108,32 @@ final class NotchGestureHitboxTests: XCTestCase { XCTAssertTrue(expanded.contains(point)) } } + +@MainActor +final class NotchGestureMonitorTests: XCTestCase { + 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(headerWidth: 240, headerHeight: 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)) + } + + 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/NotchPanelViewTests.swift b/Tests/CodeIslandTests/NotchPanelViewTests.swift index ae51037b..82d32210 100644 --- a/Tests/CodeIslandTests/NotchPanelViewTests.swift +++ b/Tests/CodeIslandTests/NotchPanelViewTests.swift @@ -230,10 +230,33 @@ final class NotchHoverInteractionTests: XCTestCase { XCTAssertEqual(NotchHoverInteraction.collapseDelay, 0.5, accuracy: 0.001) } - func testNotchOutlineIsFaintAndOnePixelWide() { - XCTAssertEqual(NotchVisualStyle.outlineWidth, 1, accuracy: 0.001) - XCTAssertGreaterThan(NotchVisualStyle.outlineOpacity, 0) - XCTAssertLessThanOrEqual(NotchVisualStyle.outlineOpacity, 0.15) + func testContrastEdgeOnlyAppearsWhenSoftwareIslandNeedsSeparation() { + XCTAssertFalse(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 testContrastEdgeIsSofterThanAUniformOutline() { + XCTAssertLessThan(NotchVisualStyle.contrastEdgeWidth, 1) + XCTAssertGreaterThan(NotchVisualStyle.contrastEdgeOpacity, 0) + XCTAssertLessThanOrEqual(NotchVisualStyle.contrastEdgeOpacity, 0.1) + XCTAssertGreaterThan(NotchVisualStyle.contrastEdgeBlurRadius, 0) + } + + 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() { From f68963806acc7aea7a297c0239600eba41a1a5c7 Mon Sep 17 00:00:00 2001 From: Mrjamedd Date: Fri, 14 Aug 2026 19:34:15 -0400 Subject: [PATCH 12/18] fix(notch): restore responsive gesture actions --- Sources/CodeIsland/NotchAnimation.swift | 13 ++++- .../CodeIsland/NotchAnimationMetrics.swift | 15 ++++++ Sources/CodeIsland/NotchGesture.swift | 53 ++++++++++++++++--- Sources/CodeIsland/NotchPanelView.swift | 12 ++--- .../CodeIsland/PanelWindowController.swift | 8 ++- .../NotchGestureInterpreterTests.swift | 28 ++++++++++ .../CodeIslandTests/NotchPanelViewTests.swift | 11 ++-- 7 files changed, 120 insertions(+), 20 deletions(-) create mode 100644 Sources/CodeIsland/NotchAnimationMetrics.swift diff --git a/Sources/CodeIsland/NotchAnimation.swift b/Sources/CodeIsland/NotchAnimation.swift index abde6fff..de369660 100644 --- a/Sources/CodeIsland/NotchAnimation.swift +++ b/Sources/CodeIsland/NotchAnimation.swift @@ -2,15 +2,24 @@ import SwiftUI enum NotchAnimation { /// 展开面板:微弹,有少许回弹感 - static let open = Animation.spring(response: 0.42, dampingFraction: 0.82) + static let open = Animation.spring(response: NotchAnimationMetrics.openResponse, dampingFraction: 0.82) /// 收起面板:临界阻尼,无过冲(防止 NotchPanelShape 底边露出刘海) - static let close = Animation.spring(response: 0.38, dampingFraction: 1.0) + static let 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) /// 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..6e6869ec --- /dev/null +++ b/Sources/CodeIsland/NotchAnimationMetrics.swift @@ -0,0 +1,15 @@ +import Foundation + +enum NotchAnimationMetrics { + enum SurfaceTransition: Equatable { + case open + case close + } + + static let openResponse: TimeInterval = 0.42 + static let closeResponse: TimeInterval = openResponse + + static func surfaceTransition(for surface: IslandSurface) -> SurfaceTransition { + surface == .collapsed ? .close : .open + } +} diff --git a/Sources/CodeIsland/NotchGesture.swift b/Sources/CodeIsland/NotchGesture.swift index 5d5229ec..a43f020e 100644 --- a/Sources/CodeIsland/NotchGesture.swift +++ b/Sources/CodeIsland/NotchGesture.swift @@ -44,15 +44,24 @@ enum NotchHoverInteraction { } enum NotchVisualStyle { - static let contrastEdgeWidth: CGFloat = 0.75 - static let contrastEdgeOpacity = 0.08 - static let contrastEdgeBlurRadius: CGFloat = 0.35 + static let contrastEdgeWidth: CGFloat = 0.9 + static let contrastEdgeOpacity = 0.14 + static let contrastEdgeBlurRadius: CGFloat = 0 static func showsContrastEdge(hasNotch: Bool, phase: NotchHoverPhase) -> Bool { !hasNotch || phase != .collapsed } } +/// 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 +} + enum NotchGestureAction: Equatable, Sendable { case open case close @@ -235,6 +244,32 @@ enum NotchGesturePolicy { } } +/// 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 { @@ -248,6 +283,7 @@ final class NotchGestureMonitor { 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 headerWidth: CGFloat = 0 @@ -274,7 +310,9 @@ final class NotchGestureMonitor { panelFrame: self.panelFrameProvider?() ) else { return event } - onAction(action) + self.actionDelivery.deliver(action) { [weak self] action in + self?.onAction?(action) + } return nil } @@ -282,13 +320,16 @@ final class NotchGestureMonitor { // 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, + guard let self else { return } + guard let action = self.consumeObservedSample( NotchScrollSample(event: event), at: NSEvent.mouseLocation, panelFrame: self.panelFrameProvider?() ) else { return } - self.onAction?(action) + self.actionDelivery.deliver(action) { [weak self] action in + self?.onAction?(action) + } } } diff --git a/Sources/CodeIsland/NotchPanelView.swift b/Sources/CodeIsland/NotchPanelView.swift index 19b2211c..87e5510c 100644 --- a/Sources/CodeIsland/NotchPanelView.swift +++ b/Sources/CodeIsland/NotchPanelView.swift @@ -49,6 +49,7 @@ struct NotchPanelView: View { 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 @@ -329,9 +330,7 @@ struct NotchPanelView: View { displayedToolStatus = showToolStatus gestureMonitor.updateRegion(headerWidth: panelWidth, headerHeight: notchHeight) gestureMonitor.isEnabled = showBar - gestureMonitor.start(panelFrameProvider: { - (NSApp.delegate as? AppDelegate)?.panelController?.gesturePanelFrame - }) { action in + gestureMonitor.start(panelFrameProvider: interactionContext.panelFrame) { action in handleGestureAction(action) } } @@ -395,10 +394,7 @@ struct NotchPanelView: View { // Smart suppress applies only to passive hover opening. Click and // trackpad gestures are intentional and bypass this check. - if smartSuppress, - let delegate = NSApp.delegate as? AppDelegate, - let pc = delegate.panelController, - pc.isActiveTerminalForeground() { + if smartSuppress, interactionContext.isActiveTerminalForeground() { return } @@ -469,7 +465,7 @@ struct NotchPanelView: View { .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/PanelWindowController.swift b/Sources/CodeIsland/PanelWindowController.swift index 2ac8397c..9cd19b1d 100644 --- a/Sources/CodeIsland/PanelWindowController.swift +++ b/Sources/CodeIsland/PanelWindowController.swift @@ -341,7 +341,13 @@ 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 + } + ) ) let contentView = NotchHostingView(rootView: rootView) contentView.sizingOptions = [] diff --git a/Tests/CodeIslandTests/NotchGestureInterpreterTests.swift b/Tests/CodeIslandTests/NotchGestureInterpreterTests.swift index 7e90f96d..68380cba 100644 --- a/Tests/CodeIslandTests/NotchGestureInterpreterTests.swift +++ b/Tests/CodeIslandTests/NotchGestureInterpreterTests.swift @@ -111,6 +111,34 @@ final class NotchGestureHitboxTests: XCTestCase { @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) diff --git a/Tests/CodeIslandTests/NotchPanelViewTests.swift b/Tests/CodeIslandTests/NotchPanelViewTests.swift index 82d32210..4171c108 100644 --- a/Tests/CodeIslandTests/NotchPanelViewTests.swift +++ b/Tests/CodeIslandTests/NotchPanelViewTests.swift @@ -239,9 +239,14 @@ final class NotchHoverInteractionTests: XCTestCase { func testContrastEdgeIsSofterThanAUniformOutline() { XCTAssertLessThan(NotchVisualStyle.contrastEdgeWidth, 1) - XCTAssertGreaterThan(NotchVisualStyle.contrastEdgeOpacity, 0) - XCTAssertLessThanOrEqual(NotchVisualStyle.contrastEdgeOpacity, 0.1) - XCTAssertGreaterThan(NotchVisualStyle.contrastEdgeBlurRadius, 0) + XCTAssertEqual(NotchVisualStyle.contrastEdgeOpacity, 0.14, accuracy: 0.001) + 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() { From 5288210fc1168f953b427f48848dc44622a729a2 Mon Sep 17 00:00:00 2001 From: Mrjamedd Date: Fri, 14 Aug 2026 19:45:28 -0400 Subject: [PATCH 13/18] refine(notch): grade the contrast edge --- Sources/CodeIsland/NotchGesture.swift | 10 +++++--- Sources/CodeIsland/NotchPanelView.swift | 23 ++++++++++++++++++- .../CodeIslandTests/NotchPanelViewTests.swift | 12 ++++++---- 3 files changed, 37 insertions(+), 8 deletions(-) diff --git a/Sources/CodeIsland/NotchGesture.swift b/Sources/CodeIsland/NotchGesture.swift index a43f020e..18f39def 100644 --- a/Sources/CodeIsland/NotchGesture.swift +++ b/Sources/CodeIsland/NotchGesture.swift @@ -45,11 +45,15 @@ enum NotchHoverInteraction { enum NotchVisualStyle { static let contrastEdgeWidth: CGFloat = 0.9 - static let contrastEdgeOpacity = 0.14 + 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 - static func showsContrastEdge(hasNotch: Bool, phase: NotchHoverPhase) -> Bool { - !hasNotch || phase != .collapsed + static func showsContrastEdge(hasNotch _: Bool, phase _: NotchHoverPhase) -> Bool { + true } } diff --git a/Sources/CodeIsland/NotchPanelView.swift b/Sources/CodeIsland/NotchPanelView.swift index 87e5510c..79a4fbd2 100644 --- a/Sources/CodeIsland/NotchPanelView.swift +++ b/Sources/CodeIsland/NotchPanelView.swift @@ -295,7 +295,28 @@ struct NotchPanelView: View { minHeight: notchHeight ) .stroke( - Color.white.opacity(NotchVisualStyle.contrastEdgeOpacity), + LinearGradient( + stops: [ + .init( + color: .white.opacity(NotchVisualStyle.contrastEdgeTopOpacity), + location: 0 + ), + .init( + color: .white.opacity(NotchVisualStyle.contrastEdgeTopOpacity), + location: NotchVisualStyle.contrastEdgeUpperHoldLocation + ), + .init( + color: .white.opacity(NotchVisualStyle.contrastEdgeSideOpacity), + location: NotchVisualStyle.contrastEdgeSideLocation + ), + .init( + color: .white.opacity(NotchVisualStyle.contrastEdgeBottomOpacity), + location: 1 + ), + ], + startPoint: .top, + endPoint: .bottom + ), style: StrokeStyle( lineWidth: NotchVisualStyle.contrastEdgeWidth, lineCap: .round, diff --git a/Tests/CodeIslandTests/NotchPanelViewTests.swift b/Tests/CodeIslandTests/NotchPanelViewTests.swift index 4171c108..c98e93c9 100644 --- a/Tests/CodeIslandTests/NotchPanelViewTests.swift +++ b/Tests/CodeIslandTests/NotchPanelViewTests.swift @@ -230,16 +230,20 @@ final class NotchHoverInteractionTests: XCTestCase { XCTAssertEqual(NotchHoverInteraction.collapseDelay, 0.5, accuracy: 0.001) } - func testContrastEdgeOnlyAppearsWhenSoftwareIslandNeedsSeparation() { - XCTAssertFalse(NotchVisualStyle.showsContrastEdge(hasNotch: true, phase: .collapsed)) + 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 testContrastEdgeIsSofterThanAUniformOutline() { + func testContrastEdgeStrengthBuildsFromInvisibleTopToVisibleBottom() { XCTAssertLessThan(NotchVisualStyle.contrastEdgeWidth, 1) - XCTAssertEqual(NotchVisualStyle.contrastEdgeOpacity, 0.14, accuracy: 0.001) + 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) } From 7b362c963e5082d34572e7ebad98259efe9ab248 Mon Sep 17 00:00:00 2001 From: Mrjamedd Date: Fri, 14 Aug 2026 19:55:37 -0400 Subject: [PATCH 14/18] feat(notch): expand gesture region and add edge setting --- Sources/CodeIsland/L10n.swift | 14 +++++ Sources/CodeIsland/NotchGesture.swift | 33 ++++++++---- Sources/CodeIsland/NotchPanelView.swift | 47 ++++++++++++++-- Sources/CodeIsland/Settings.swift | 3 ++ Sources/CodeIsland/SettingsView.swift | 7 +++ .../NotchGestureInterpreterTests.swift | 54 +++++++++++++++---- .../NotchGestureSettingsTests.swift | 2 + 7 files changed, 137 insertions(+), 23 deletions(-) diff --git a/Sources/CodeIsland/L10n.swift b/Sources/CodeIsland/L10n.swift index 451c2389..1ace48e8 100644 --- a/Sources/CodeIsland/L10n.swift +++ b/Sources/CodeIsland/L10n.swift @@ -195,6 +195,8 @@ 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", "default": "Default", "content": "Content", "content_font_size": "Content Font Size", @@ -558,6 +560,8 @@ 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", "default": "Standard", "content": "Inhalt", "content_font_size": "Schriftgröße des Inhalts", @@ -925,6 +929,8 @@ final class L10n: ObservableObject { "notch_height_match_menubar": "对齐菜单栏高度", "notch_height_custom": "自定义高度", "custom_notch_height": "自定义高度", + "show_contrast_edge": "对比边缘", + "show_contrast_edge_desc": "在灵动岛周围显示细微的对比边缘", "default": "默认", "content": "内容", "content_font_size": "内容字体大小", @@ -1292,6 +1298,8 @@ final class L10n: ObservableObject { "notch_height_match_menubar": "對齊選單列高度", "notch_height_custom": "自訂高度", "custom_notch_height": "自訂高度", + "show_contrast_edge": "對比邊緣", + "show_contrast_edge_desc": "在動態島周圍顯示細微的對比邊緣", "default": "預設", "content": "內容", "content_font_size": "內容字體大小", @@ -1659,6 +1667,8 @@ final class L10n: ObservableObject { "notch_height_match_menubar": "メニューバーの高さに合わせる", "notch_height_custom": "カスタム高さ", "custom_notch_height": "カスタム高さ", + "show_contrast_edge": "コントラストエッジ", + "show_contrast_edge_desc": "アイランドの周囲に控えめなコントラストエッジを表示", "default": "デフォルト", "content": "内容", "content_font_size": "内容の文字サイズ", @@ -2026,6 +2036,8 @@ final class L10n: ObservableObject { "notch_height_match_menubar": "메뉴 막대 높이에 맞춤", "notch_height_custom": "사용자 지정 높이", "custom_notch_height": "사용자 지정 높이", + "show_contrast_edge": "대비 테두리", + "show_contrast_edge_desc": "아일랜드 주위에 은은한 대비 테두리를 표시합니다", "default": "기본값", "content": "콘텐츠", "content_font_size": "콘텐츠 글꼴 크기", @@ -2393,6 +2405,8 @@ 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", "default": "Varsayılan", "content": "İçerik", "content_font_size": "İçerik Yazı Boyutu", diff --git a/Sources/CodeIsland/NotchGesture.swift b/Sources/CodeIsland/NotchGesture.swift index 18f39def..dc1d5f38 100644 --- a/Sources/CodeIsland/NotchGesture.swift +++ b/Sources/CodeIsland/NotchGesture.swift @@ -140,11 +140,11 @@ struct NotchGestureHitbox { private let minY: CGFloat private let maxY: CGFloat - init(panelFrame: NSRect, headerWidth: CGFloat, headerHeight: CGFloat) { - let width = Swift.min(Swift.max(headerWidth, 0), panelFrame.width) + 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.max(headerHeight, 0) + minY = panelFrame.maxY - Swift.min(Swift.max(regionHeight, 0), panelFrame.height) maxY = panelFrame.maxY + Self.topEdgeExtension } @@ -153,6 +153,19 @@ struct NotchGestureHitbox { } } +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 @@ -290,12 +303,12 @@ final class NotchGestureMonitor { private let actionDelivery = NotchGestureActionDelivery() private var panelFrameProvider: (() -> NSRect?)? private var onAction: ((NotchGestureAction) -> Void)? - private var headerWidth: CGFloat = 0 - private var headerHeight: CGFloat = 0 + private var regionWidth: CGFloat = 0 + private var regionHeight: CGFloat = 0 - func updateRegion(headerWidth: CGFloat, headerHeight: CGFloat) { - self.headerWidth = headerWidth - self.headerHeight = headerHeight + func updateRegion(width: CGFloat, height: CGFloat) { + regionWidth = width + regionHeight = height } func start( @@ -364,8 +377,8 @@ final class NotchGestureMonitor { } guard NotchGestureHitbox( panelFrame: panelFrame, - headerWidth: headerWidth, - headerHeight: headerHeight + regionWidth: regionWidth, + regionHeight: regionHeight ).contains(location) else { interpreter.reset() return nil diff --git a/Sources/CodeIsland/NotchPanelView.swift b/Sources/CodeIsland/NotchPanelView.swift index 79a4fbd2..488882c2 100644 --- a/Sources/CodeIsland/NotchPanelView.swift +++ b/Sources/CodeIsland/NotchPanelView.swift @@ -43,6 +43,14 @@ enum ToolNameDisplay { } } +private struct NotchVisibleSizePreferenceKey: PreferenceKey { + static var defaultValue: CGSize = .zero + + static func reduce(value: inout CGSize, nextValue: () -> CGSize) { + value = nextValue() + } +} + struct NotchPanelView: View { var appState: AppState let hasNotch: Bool @@ -60,6 +68,7 @@ struct NotchPanelView: View { @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.hapticOnHover) private var hapticOnHover = SettingsDefaults.hapticOnHover @AppStorage(SettingsKey.hapticIntensity) private var hapticIntensity = SettingsDefaults.hapticIntensity @AppStorage(SettingsKey.sessionGroupingMode) private var groupingMode = SettingsDefaults.sessionGroupingMode @@ -75,6 +84,7 @@ struct NotchPanelView: View { @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 @@ -169,6 +179,16 @@ struct NotchPanelView: View { } } + 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) { @@ -279,6 +299,14 @@ struct NotchPanelView: View { } } .frame(width: panelWidth) + .background { + GeometryReader { geometry in + Color.clear.preference( + key: NotchVisibleSizePreferenceKey.self, + value: geometry.size + ) + } + } .clipped() .background( NotchPanelShape( @@ -324,7 +352,11 @@ struct NotchPanelView: View { ) ) .blur(radius: NotchVisualStyle.contrastEdgeBlurRadius) - .opacity(NotchVisualStyle.showsContrastEdge(hasNotch: hasNotch, phase: hoverPhase) ? 1 : 0) + .opacity( + showContrastEdge && NotchVisualStyle.showsContrastEdge(hasNotch: hasNotch, phase: hoverPhase) + ? 1 + : 0 + ) .allowsHitTesting(false) ) .offset(y: curtainOffset) @@ -349,7 +381,7 @@ struct NotchPanelView: View { } .onAppear { displayedToolStatus = showToolStatus - gestureMonitor.updateRegion(headerWidth: panelWidth, headerHeight: notchHeight) + updateGestureRegion() gestureMonitor.isEnabled = showBar gestureMonitor.start(panelFrameProvider: interactionContext.panelFrame) { action in handleGestureAction(action) @@ -478,8 +510,15 @@ struct NotchPanelView: View { .onChange(of: showBar) { _, visible in gestureMonitor.isEnabled = visible } - .onChange(of: panelWidth) { _, width in - gestureMonitor.updateRegion(headerWidth: width, headerHeight: notchHeight) + .onChange(of: panelWidth) { _, _ in + updateGestureRegion() + } + .onChange(of: shouldShowExpanded) { _, _ in + updateGestureRegion() + } + .onPreferenceChange(NotchVisibleSizePreferenceKey.self) { size in + visiblePanelSize = size + updateGestureRegion(visibleSize: size) } Spacer() diff --git a/Sources/CodeIsland/Settings.swift b/Sources/CodeIsland/Settings.swift index 17175c08..7e189e04 100644 --- a/Sources/CodeIsland/Settings.swift +++ b/Sources/CodeIsland/Settings.swift @@ -62,6 +62,7 @@ enum SettingsKey { static let showAgentDetails = "showAgentDetails" static let notchHeightMode = "notchHeightMode" static let customNotchHeight = "customNotchHeight" + static let showContrastEdge = "showContrastEdge" // Sound static let soundEnabled = "soundEnabled" @@ -176,6 +177,7 @@ struct SettingsDefaults { static let showAgentDetails = false static let notchHeightMode = NotchHeightMode.matchNotch.rawValue static let customNotchHeight = 37.0 + static let showContrastEdge = true static let soundEnabled = false static let soundVolume = 50 @@ -265,6 +267,7 @@ class SettingsManager { SettingsKey.showAgentDetails: SettingsDefaults.showAgentDetails, SettingsKey.notchHeightMode: SettingsDefaults.notchHeightMode, SettingsKey.customNotchHeight: SettingsDefaults.customNotchHeight, + SettingsKey.showContrastEdge: SettingsDefaults.showContrastEdge, SettingsKey.soundEnabled: SettingsDefaults.soundEnabled, SettingsKey.soundVolume: SettingsDefaults.soundVolume, SettingsKey.soundSessionStart: SettingsDefaults.soundSessionStart, diff --git a/Sources/CodeIsland/SettingsView.swift b/Sources/CodeIsland/SettingsView.swift index dbe42bfb..43838aff 100644 --- a/Sources/CodeIsland/SettingsView.swift +++ b/Sources/CodeIsland/SettingsView.swift @@ -952,6 +952,7 @@ 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 private var notchHeightMode: Binding { Binding( @@ -1018,6 +1019,12 @@ 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) + } } Section(l10n["content"]) { diff --git a/Tests/CodeIslandTests/NotchGestureInterpreterTests.swift b/Tests/CodeIslandTests/NotchGestureInterpreterTests.swift index 68380cba..7b6fccf6 100644 --- a/Tests/CodeIslandTests/NotchGestureInterpreterTests.swift +++ b/Tests/CodeIslandTests/NotchGestureInterpreterTests.swift @@ -80,8 +80,8 @@ final class NotchGestureHitboxTests: XCTestCase { func testIncludesExactScreenTopAndPhysicalNotchBand() { let hitbox = NotchGestureHitbox( panelFrame: panelFrame, - headerWidth: 240, - headerHeight: 38 + regionWidth: 240, + regionHeight: 38 ) XCTAssertTrue(hitbox.contains(NSPoint(x: panelFrame.midX, y: panelFrame.maxY))) @@ -91,21 +91,46 @@ final class NotchGestureHitboxTests: XCTestCase { func testRemainsRestrictedToCenteredNotchHeader() { let hitbox = NotchGestureHitbox( panelFrame: panelFrame, - headerWidth: 240, - headerHeight: 38 + 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 testExpandedHeaderWidthExpandsGestureRegion() { - let collapsed = NotchGestureHitbox(panelFrame: panelFrame, headerWidth: 240, headerHeight: 38) - let expanded = NotchGestureHitbox(panelFrame: panelFrame, headerWidth: 520, headerHeight: 38) - let point = NSPoint(x: panelFrame.midX + 220, y: panelFrame.maxY - 20) + 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)) } } @@ -144,7 +169,7 @@ final class NotchGestureMonitorTests: XCTestCase { let location = NSPoint(x: panelFrame.midX, y: panelFrame.maxY) let monitor = NotchGestureMonitor() monitor.isEnabled = true - monitor.updateRegion(headerWidth: 240, headerHeight: 38) + 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)) @@ -152,6 +177,17 @@ final class NotchGestureMonitorTests: XCTestCase { 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) + } + private func sample( y: CGFloat = 0, began: Bool = false, diff --git a/Tests/CodeIslandTests/NotchGestureSettingsTests.swift b/Tests/CodeIslandTests/NotchGestureSettingsTests.swift index d8ae664e..928d45c7 100644 --- a/Tests/CodeIslandTests/NotchGestureSettingsTests.swift +++ b/Tests/CodeIslandTests/NotchGestureSettingsTests.swift @@ -6,6 +6,8 @@ final class NotchGestureSettingsTests: XCTestCase { XCTAssertTrue(SettingsDefaults.openOnHover) XCTAssertEqual(SettingsDefaults.hoverOpenDelay, 0.5, accuracy: 0.001) XCTAssertFalse(SettingsDefaults.invertHorizontalSwipeDirection) + XCTAssertTrue(SettingsDefaults.showContrastEdge) + XCTAssertEqual(SettingsKey.showContrastEdge, "showContrastEdge") } func testHoverDelayClampsValuesToSupportedRange() { From e5967cdbe4d0bcda4fc17b867a9682477125bfcc Mon Sep 17 00:00:00 2001 From: Mrjamedd Date: Fri, 14 Aug 2026 20:06:22 -0400 Subject: [PATCH 15/18] fix(notch): follow desktop transitions --- Sources/CodeIsland/PanelWindowBehavior.swift | 13 +++++++++++++ Sources/CodeIsland/PanelWindowController.swift | 2 +- .../PanelWindowControllerTests.swift | 11 +++++++++++ 3 files changed, 25 insertions(+), 1 deletion(-) create mode 100644 Sources/CodeIsland/PanelWindowBehavior.swift diff --git a/Sources/CodeIsland/PanelWindowBehavior.swift b/Sources/CodeIsland/PanelWindowBehavior.swift new file mode 100644 index 00000000..2a4af971 --- /dev/null +++ b/Sources/CodeIsland/PanelWindowBehavior.swift @@ -0,0 +1,13 @@ +import AppKit + +enum PanelWindowBehavior { + /// Join every Space while participating in the system's desktop transition, + /// so the island travels with the screen instead of dropping out mid-swipe. + static let collectionBehavior: NSWindow.CollectionBehavior = [ + .canJoinAllSpaces, + .canJoinAllApplications, + .managed, + .fullScreenAuxiliary, + .ignoresCycle, + ] +} diff --git a/Sources/CodeIsland/PanelWindowController.swift b/Sources/CodeIsland/PanelWindowController.swift index 9cd19b1d..ed74afde 100644 --- a/Sources/CodeIsland/PanelWindowController.swift +++ b/Sources/CodeIsland/PanelWindowController.swift @@ -235,7 +235,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 diff --git a/Tests/CodeIslandTests/PanelWindowControllerTests.swift b/Tests/CodeIslandTests/PanelWindowControllerTests.swift index b15dae01..4fda094f 100644 --- a/Tests/CodeIslandTests/PanelWindowControllerTests.swift +++ b/Tests/CodeIslandTests/PanelWindowControllerTests.swift @@ -3,6 +3,17 @@ import XCTest @testable import CodeIsland final class PanelWindowControllerTests: XCTestCase { + func testPanelFollowsSpaceTransitionsWhileRemainingAvailableOnEverySpace() { + let behavior = PanelWindowBehavior.collectionBehavior + + XCTAssertTrue(behavior.contains(.canJoinAllSpaces)) + XCTAssertTrue(behavior.contains(.canJoinAllApplications)) + XCTAssertTrue(behavior.contains(.managed)) + XCTAssertTrue(behavior.contains(.fullScreenAuxiliary)) + XCTAssertTrue(behavior.contains(.ignoresCycle)) + XCTAssertFalse(behavior.contains(.stationary)) + } + func testScreenHopMotionUsesMoreVisibleTiming() { let motion = PanelWindowController.screenHopMotion() From 27dc37feea2ad75c4167b40fe4c5040899069c5a Mon Sep 17 00:00:00 2001 From: Mrjamedd Date: Fri, 14 Aug 2026 20:44:45 -0400 Subject: [PATCH 16/18] fix(notch): stabilize expanded gestures and spaces --- Sources/CodeIsland/NotchGesture.swift | 39 +++++++++-- Sources/CodeIsland/NotchPanelView.swift | 64 +++++++++++++++++++ Sources/CodeIsland/PanelWindowBehavior.swift | 11 ++++ .../CodeIsland/PanelWindowController.swift | 30 ++++++--- .../NotchGestureInterpreterTests.swift | 52 +++++++++++++++ .../PanelWindowControllerTests.swift | 10 +++ 6 files changed, 190 insertions(+), 16 deletions(-) diff --git a/Sources/CodeIsland/NotchGesture.swift b/Sources/CodeIsland/NotchGesture.swift index dc1d5f38..1843b0ab 100644 --- a/Sources/CodeIsland/NotchGesture.swift +++ b/Sources/CodeIsland/NotchGesture.swift @@ -296,7 +296,6 @@ final class NotchGestureMonitor { } } } - private var localMonitor: Any? private var globalMonitor: Any? private var interpreter = NotchGestureInterpreter() @@ -305,12 +304,24 @@ final class NotchGestureMonitor { 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 @@ -375,11 +386,27 @@ final class NotchGestureMonitor { interpreter.reset() return nil } - guard NotchGestureHitbox( - panelFrame: panelFrame, - regionWidth: regionWidth, - regionHeight: regionHeight - ).contains(location) else { + + 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 } diff --git a/Sources/CodeIsland/NotchPanelView.swift b/Sources/CodeIsland/NotchPanelView.swift index 488882c2..5c12a314 100644 --- a/Sources/CodeIsland/NotchPanelView.swift +++ b/Sources/CodeIsland/NotchPanelView.swift @@ -51,6 +51,63 @@ private struct NotchVisibleSizePreferenceKey: PreferenceKey { } } +/// 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 @@ -307,6 +364,12 @@ struct NotchPanelView: View { ) } } + .background { + NotchVisibleContentFrameReader { frame, panelFrame in + gestureMonitor.updateVisibleContentFrame(frame, relativeTo: panelFrame) + } + .allowsHitTesting(false) + } .clipped() .background( NotchPanelShape( @@ -388,6 +451,7 @@ struct NotchPanelView: View { } } .onDisappear { + gestureMonitor.updateVisibleContentFrame(nil) gestureMonitor.stop() } .scaleEffect(shouldShowPrehover ? NotchHoverInteraction.prehoverScale : 1, anchor: .top) diff --git a/Sources/CodeIsland/PanelWindowBehavior.swift b/Sources/CodeIsland/PanelWindowBehavior.swift index 2a4af971..bdee9118 100644 --- a/Sources/CodeIsland/PanelWindowBehavior.swift +++ b/Sources/CodeIsland/PanelWindowBehavior.swift @@ -1,5 +1,16 @@ 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 { /// Join every Space while participating in the system's desktop transition, /// so the island travels with the screen instead of dropping out mid-swipe. diff --git a/Sources/CodeIsland/PanelWindowController.swift b/Sources/CodeIsland/PanelWindowController.swift index ed74afde..7de59fcc 100644 --- a/Sources/CodeIsland/PanelWindowController.swift +++ b/Sources/CodeIsland/PanelWindowController.swift @@ -169,6 +169,7 @@ 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? @@ -272,19 +273,27 @@ class PanelWindowController: NSObject, NSWindowDelegate { Task { @MainActor in guard let self = self else { return } self.refreshCurrentScreen() - if self.isActiveSpaceFullscreen() { - self.fullscreenLatch = true + self.activeSpaceTransitionTask?.cancel() + self.fullscreenPoller?.invalidate() + self.fullscreenPoller = nil + + // Keep the island present while macOS animates between desktops. + // CGWindowList/frontmostApplication can still describe the outgoing + // Space here, which previously caused a transient orderOut/orderFront. + self.fullscreenLatch = PanelSpaceTransitionPolicy.immediateFullscreenLatch + self.updateVisibility() + + 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() } } } @@ -703,6 +712,7 @@ class PanelWindowController: NSObject, NSWindowDelegate { } deinit { + activeSpaceTransitionTask?.cancel() autoScreenPoller?.invalidate() fullscreenPoller?.invalidate() for observer in settingsObservers { diff --git a/Tests/CodeIslandTests/NotchGestureInterpreterTests.swift b/Tests/CodeIslandTests/NotchGestureInterpreterTests.swift index 7b6fccf6..6bd4f3e8 100644 --- a/Tests/CodeIslandTests/NotchGestureInterpreterTests.swift +++ b/Tests/CodeIslandTests/NotchGestureInterpreterTests.swift @@ -188,6 +188,58 @@ final class NotchGestureMonitorTests: XCTestCase { 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, diff --git a/Tests/CodeIslandTests/PanelWindowControllerTests.swift b/Tests/CodeIslandTests/PanelWindowControllerTests.swift index 4fda094f..1a0826c9 100644 --- a/Tests/CodeIslandTests/PanelWindowControllerTests.swift +++ b/Tests/CodeIslandTests/PanelWindowControllerTests.swift @@ -3,6 +3,16 @@ 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)) + } + func testPanelFollowsSpaceTransitionsWhileRemainingAvailableOnEverySpace() { let behavior = PanelWindowBehavior.collectionBehavior From 631605298f427e81f118db1b30931cc30f1568aa Mon Sep 17 00:00:00 2001 From: Mrjamedd Date: Sat, 15 Aug 2026 13:33:48 -0400 Subject: [PATCH 17/18] feat(notch): collapse on Space swipe, tint the edge, tune open/close speed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Collapse the island when the user swipes to another desktop -------------------------------------------------------- The existing NSWorkspace.activeSpaceDidChangeNotification observer only fires once macOS has finished the transition, so an expanded island rode through the whole Spaces animation and snapped shut at the end. The responder-chain NSTouch path added earlier only fires while the panel is the key window, which is never the case when the user is working in another app. Neither NSEvent global monitors nor NSEvent.trackSwipeEvent can close that gap. Global monitors deliver scroll events but strip the NSTouch payload (measured: 156 scroll events, zero touches, no gesture events). trackSwipeEvent only operates on .scrollWheel events with precise deltas — the two-finger page-swipe gesture — so a three/four-finger Spaces swipe never reaches it. MultitouchSupport does deliver normalized finger positions to a background app with no permission prompt. It is resolved via dlopen rather than linked, and MTTouch's 96-byte stride is validated before any field is read, so a future macOS degrades to isAvailable == false and the existing activeSpaceDidChange fallback rather than crashing. Detection is scoped tightly: armed only while a collapsible surface is on screen, and the threshold crossing collapses only when the pointer is over the island's rendered content, reusing the same NotchGestureHitbox and visible-content geometry the in-view gesture monitor already computes. Without that gate a global gesture would collapse the island on every desktop switch anywhere on screen. The required finger count is read from the system trackpad preference, so it matches whichever gesture the user actually has bound. Match the contrast edge to the mascot ------------------------------------- Opt-in, off by default, so the white edge remains the shipped look and turning it off restores it exactly. Each mascot's signature color is lifted 62% toward white before it reaches the stroke, keeping a hairline highlight that carries a hue rather than a colored outline; geometry and opacities are untouched. Signature colors moved out of a private table in MascotsPage into MascotPalette so the settings swatches and the edge tint cannot drift, keyed to match MascotView's routing including aliases. Open/close speed control ------------------------ A 0.5x–2.0x slider scaling the spring response for the island's open and close animations. It scales response rather than swapping in a fixed duration, so the motion stays interruptible at every setting, and the responses are read live so the slider takes effect without a relaunch. It sits outside the hover-to-open branch because it governs the close animation too, which runs regardless of what opened the island. Shipped defaults ---------------- openOnHover now ships off: brushing the notch on the way to the menu bar should not expand the island, leaving click and swipe as the deliberate ways in. showContrastEdge stays on. Co-Authored-By: Claude Opus 5 --- Sources/CodeIsland/AllSpacesAnchor.swift | 82 ++++++ .../CodeIsland/FourFingerSwipeGesture.swift | 75 ++++++ Sources/CodeIsland/IslandSurface.swift | 11 + Sources/CodeIsland/L10n.swift | 42 +++ Sources/CodeIsland/MascotPalette.swift | 99 +++++++ Sources/CodeIsland/MultitouchDevice.swift | 244 ++++++++++++++++++ Sources/CodeIsland/NotchAnimation.swift | 11 +- .../CodeIsland/NotchAnimationMetrics.swift | 19 +- Sources/CodeIsland/NotchGesture.swift | 9 + Sources/CodeIsland/NotchPanelView.swift | 33 ++- Sources/CodeIsland/PanelWindowBehavior.swift | 11 +- .../CodeIsland/PanelWindowController.swift | 138 ++++++++-- Sources/CodeIsland/Settings.swift | 49 +++- Sources/CodeIsland/SettingsView.swift | 105 ++++++-- Sources/CodeIsland/SpacesSwipeDetector.swift | 103 ++++++++ Sources/CodeIsland/SpacesSwipeMonitor.swift | 85 ++++++ .../FourFingerSwipeGestureTests.swift | 23 ++ .../CodeIslandTests/IslandSurfaceTests.swift | 18 ++ .../CodeIslandTests/MascotPaletteTests.swift | 86 ++++++ .../NotchAnimationSpeedTests.swift | 73 ++++++ .../NotchGestureSettingsTests.swift | 6 +- .../PanelWindowControllerTests.swift | 13 +- .../SpacesSwipeDetectorTests.swift | 121 +++++++++ 23 files changed, 1393 insertions(+), 63 deletions(-) create mode 100644 Sources/CodeIsland/AllSpacesAnchor.swift create mode 100644 Sources/CodeIsland/FourFingerSwipeGesture.swift create mode 100644 Sources/CodeIsland/MascotPalette.swift create mode 100644 Sources/CodeIsland/MultitouchDevice.swift create mode 100644 Sources/CodeIsland/SpacesSwipeDetector.swift create mode 100644 Sources/CodeIsland/SpacesSwipeMonitor.swift create mode 100644 Tests/CodeIslandTests/FourFingerSwipeGestureTests.swift create mode 100644 Tests/CodeIslandTests/IslandSurfaceTests.swift create mode 100644 Tests/CodeIslandTests/MascotPaletteTests.swift create mode 100644 Tests/CodeIslandTests/NotchAnimationSpeedTests.swift create mode 100644 Tests/CodeIslandTests/SpacesSwipeDetectorTests.swift 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/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 1ace48e8..48ae06bb 100644 --- a/Sources/CodeIsland/L10n.swift +++ b/Sources/CodeIsland/L10n.swift @@ -75,6 +75,8 @@ final class L10n: ObservableObject { "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", @@ -96,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", @@ -197,6 +201,8 @@ final class L10n: ObservableObject { "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", @@ -444,6 +450,8 @@ final class L10n: ObservableObject { "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", @@ -465,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", @@ -562,6 +572,8 @@ final class L10n: ObservableObject { "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", @@ -809,6 +821,8 @@ final class L10n: ObservableObject { "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": "反转横向滑动", @@ -830,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 完成时自动展开面板", @@ -931,6 +947,8 @@ final class L10n: ObservableObject { "custom_notch_height": "自定义高度", "show_contrast_edge": "对比边缘", "show_contrast_edge_desc": "在灵动岛周围显示细微的对比边缘", + "tint_contrast_edge_mascot": "匹配吉祥物颜色", + "tint_contrast_edge_mascot_desc": "用当前吉祥物的颜色为对比边缘着色", "default": "默认", "content": "内容", "content_font_size": "内容字体大小", @@ -1178,6 +1196,8 @@ final class L10n: ObservableObject { "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": "反轉橫向滑動", @@ -1199,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 完成時自動展開面板", @@ -1300,6 +1322,8 @@ final class L10n: ObservableObject { "custom_notch_height": "自訂高度", "show_contrast_edge": "對比邊緣", "show_contrast_edge_desc": "在動態島周圍顯示細微的對比邊緣", + "tint_contrast_edge_mascot": "符合吉祥物顏色", + "tint_contrast_edge_mascot_desc": "用目前吉祥物的顏色為對比邊緣著色", "default": "預設", "content": "內容", "content_font_size": "內容字體大小", @@ -1547,6 +1571,8 @@ final class L10n: ObservableObject { "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": "横スワイプを反転", @@ -1568,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": "エージェント完了時にパネルを自動展開", @@ -1669,6 +1697,8 @@ final class L10n: ObservableObject { "custom_notch_height": "カスタム高さ", "show_contrast_edge": "コントラストエッジ", "show_contrast_edge_desc": "アイランドの周囲に控えめなコントラストエッジを表示", + "tint_contrast_edge_mascot": "マスコットの色に合わせる", + "tint_contrast_edge_mascot_desc": "コントラストエッジを現在のマスコットの色で色付けします", "default": "デフォルト", "content": "内容", "content_font_size": "内容の文字サイズ", @@ -1916,6 +1946,8 @@ final class L10n: ObservableObject { "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": "가로 스와이프 반전", @@ -1937,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": "에이전트 완료 시 패널 자동 확장", @@ -2038,6 +2072,8 @@ final class L10n: ObservableObject { "custom_notch_height": "사용자 지정 높이", "show_contrast_edge": "대비 테두리", "show_contrast_edge_desc": "아일랜드 주위에 은은한 대비 테두리를 표시합니다", + "tint_contrast_edge_mascot": "마스코트 색상 사용", + "tint_contrast_edge_mascot_desc": "현재 마스코트의 색상으로 대비 테두리를 물들입니다", "default": "기본값", "content": "콘텐츠", "content_font_size": "콘텐츠 글꼴 크기", @@ -2285,6 +2321,8 @@ final class L10n: ObservableObject { "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", @@ -2306,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", @@ -2407,6 +2447,8 @@ final class L10n: ObservableObject { "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 de369660..e9fca546 100644 --- a/Sources/CodeIsland/NotchAnimation.swift +++ b/Sources/CodeIsland/NotchAnimation.swift @@ -2,13 +2,20 @@ import SwiftUI enum NotchAnimation { /// 展开面板:微弹,有少许回弹感 - static let open = Animation.spring(response: NotchAnimationMetrics.openResponse, dampingFraction: 0.82) + static var open: Animation { + .spring(response: NotchAnimationMetrics.openResponse, dampingFraction: 0.82) + } /// 收起面板:临界阻尼,无过冲(防止 NotchPanelShape 底边露出刘海) - static let close = Animation.spring(response: NotchAnimationMetrics.closeResponse, 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) diff --git a/Sources/CodeIsland/NotchAnimationMetrics.swift b/Sources/CodeIsland/NotchAnimationMetrics.swift index 6e6869ec..c2f0e36a 100644 --- a/Sources/CodeIsland/NotchAnimationMetrics.swift +++ b/Sources/CodeIsland/NotchAnimationMetrics.swift @@ -6,8 +6,23 @@ enum NotchAnimationMetrics { case close } - static let openResponse: TimeInterval = 0.42 - static let closeResponse: TimeInterval = openResponse + /// 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 index 1843b0ab..b0781ffa 100644 --- a/Sources/CodeIsland/NotchGesture.swift +++ b/Sources/CodeIsland/NotchGesture.swift @@ -52,6 +52,11 @@ enum NotchVisualStyle { 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 } @@ -64,6 +69,10 @@ enum NotchVisualStyle { 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 { diff --git a/Sources/CodeIsland/NotchPanelView.swift b/Sources/CodeIsland/NotchPanelView.swift index 5c12a314..72be4d38 100644 --- a/Sources/CodeIsland/NotchPanelView.swift +++ b/Sources/CodeIsland/NotchPanelView.swift @@ -126,6 +126,8 @@ struct NotchPanelView: View { @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 @@ -165,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 } @@ -367,6 +390,7 @@ struct NotchPanelView: View { .background { NotchVisibleContentFrameReader { frame, panelFrame in gestureMonitor.updateVisibleContentFrame(frame, relativeTo: panelFrame) + interactionContext.reportVisibleContentFrame(frame, panelFrame) } .allowsHitTesting(false) } @@ -389,19 +413,19 @@ struct NotchPanelView: View { LinearGradient( stops: [ .init( - color: .white.opacity(NotchVisualStyle.contrastEdgeTopOpacity), + color: contrastEdgeColor.opacity(NotchVisualStyle.contrastEdgeTopOpacity), location: 0 ), .init( - color: .white.opacity(NotchVisualStyle.contrastEdgeTopOpacity), + color: contrastEdgeColor.opacity(NotchVisualStyle.contrastEdgeTopOpacity), location: NotchVisualStyle.contrastEdgeUpperHoldLocation ), .init( - color: .white.opacity(NotchVisualStyle.contrastEdgeSideOpacity), + color: contrastEdgeColor.opacity(NotchVisualStyle.contrastEdgeSideOpacity), location: NotchVisualStyle.contrastEdgeSideLocation ), .init( - color: .white.opacity(NotchVisualStyle.contrastEdgeBottomOpacity), + color: contrastEdgeColor.opacity(NotchVisualStyle.contrastEdgeBottomOpacity), location: 1 ), ], @@ -420,6 +444,7 @@ struct NotchPanelView: View { ? 1 : 0 ) + .animation(NotchAnimation.contrastEdgeTint, value: contrastEdgeColor) .allowsHitTesting(false) ) .offset(y: curtainOffset) diff --git a/Sources/CodeIsland/PanelWindowBehavior.swift b/Sources/CodeIsland/PanelWindowBehavior.swift index bdee9118..7c0ee28f 100644 --- a/Sources/CodeIsland/PanelWindowBehavior.swift +++ b/Sources/CodeIsland/PanelWindowBehavior.swift @@ -12,12 +12,15 @@ enum PanelSpaceTransitionPolicy { } enum PanelWindowBehavior { - /// Join every Space while participating in the system's desktop transition, - /// so the island travels with the screen instead of dropping out mid-swipe. + /// `.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, - .canJoinAllApplications, - .managed, + .stationary, .fullScreenAuxiliary, .ignoresCycle, ] diff --git a/Sources/CodeIsland/PanelWindowController.swift b/Sources/CodeIsland/PanelWindowController.swift index 7de59fcc..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 @@ -173,6 +195,8 @@ class PanelWindowController: NSObject, NSWindowDelegate { 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? @@ -211,6 +235,7 @@ class PanelWindowController: NSObject, NSWindowDelegate { guard let self else { return } self.isSessionObservationArmed = false self.updateVisibility() + self.updateSpacesSwipeArming() self.armSessionObservation() } } @@ -222,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 @@ -247,6 +285,7 @@ class PanelWindowController: NSObject, NSWindowDelegate { setupHorizontalDragMonitor() updatePosition() panel.orderFrontRegardless() + AllSpacesAnchor.shared.anchor(panel) MascotAnimationGate.shared.setPanelVisible(true) // Screen change observer @@ -273,15 +312,18 @@ class PanelWindowController: NSObject, NSWindowDelegate { Task { @MainActor in guard let self = self else { return } self.refreshCurrentScreen() + self.collapseIfPossible() self.activeSpaceTransitionTask?.cancel() self.fullscreenPoller?.invalidate() self.fullscreenPoller = nil - // Keep the island present while macOS animates between desktops. - // CGWindowList/frontmostApplication can still describe the outgoing - // Space here, which previously caused a transient orderOut/orderFront. + // 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.updateVisibility() self.activeSpaceTransitionTask = Task { @MainActor [weak self] in try? await Task.sleep(for: .seconds(PanelSpaceTransitionPolicy.fullscreenEvaluationDelay)) @@ -321,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 { @@ -355,12 +435,20 @@ class PanelWindowController: NSObject, NSWindowDelegate { 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 } @@ -482,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) @@ -627,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 } @@ -712,6 +815,9 @@ class PanelWindowController: NSObject, NSWindowDelegate { } deinit { + if let panel { + AllSpacesAnchor.shared.release(panel) + } activeSpaceTransitionTask?.cancel() autoScreenPoller?.invalidate() fullscreenPoller?.invalidate() diff --git a/Sources/CodeIsland/Settings.swift b/Sources/CodeIsland/Settings.swift index 7e189e04..705e77e5 100644 --- a/Sources/CodeIsland/Settings.swift +++ b/Sources/CodeIsland/Settings.swift @@ -27,6 +27,29 @@ enum HoverOpenDelay { } } +/// 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" @@ -43,8 +66,10 @@ 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" @@ -63,6 +88,7 @@ enum SettingsKey { static let notchHeightMode = "notchHeightMode" static let customNotchHeight = "customNotchHeight" static let showContrastEdge = "showContrastEdge" + static let tintContrastEdgeWithMascot = "tintContrastEdgeWithMascot" // Sound static let soundEnabled = "soundEnabled" @@ -159,8 +185,13 @@ struct SettingsDefaults { static let hideWhenNoSession = false static let smartSuppress = true static let collapseOnMouseLeave = true - static let openOnHover = 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 @@ -178,6 +209,9 @@ struct SettingsDefaults { 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 @@ -250,8 +284,10 @@ 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, @@ -268,6 +304,7 @@ class SettingsManager { 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, @@ -358,6 +395,11 @@ 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) } @@ -368,6 +410,11 @@ class SettingsManager { 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) } diff --git a/Sources/CodeIsland/SettingsView.swift b/Sources/CodeIsland/SettingsView.swift index 43838aff..154d12c6 100644 --- a/Sources/CodeIsland/SettingsView.swift +++ b/Sources/CodeIsland/SettingsView.swift @@ -115,6 +115,7 @@ 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 @@ -126,6 +127,13 @@ private struct GesturesPage: View { ) } + private var animationSpeedBinding: Binding { + Binding( + get: { NotchAnimationSpeed.clamped(notchAnimationSpeed) }, + set: { notchAnimationSpeed = NotchAnimationSpeed.clamped($0) } + ) + } + var body: some View { Form { Section(l10n["gesture_opening"]) { @@ -172,6 +180,27 @@ private struct GesturesPage: View { .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"]) { @@ -193,6 +222,7 @@ private struct GesturesPage: View { .formStyle(.grouped) .onAppear { hoverOpenDelay = HoverOpenDelay.clamped(hoverOpenDelay) + notchAnimationSpeed = NotchAnimationSpeed.clamped(notchAnimationSpeed) } } } @@ -482,6 +512,7 @@ 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 @@ -554,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"], @@ -953,6 +992,7 @@ private struct AppearancePage: View { @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( @@ -1025,6 +1065,13 @@ private struct AppearancePage: View { .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"]) { @@ -1158,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 { @@ -1230,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/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/NotchGestureSettingsTests.swift b/Tests/CodeIslandTests/NotchGestureSettingsTests.swift index 928d45c7..a2593935 100644 --- a/Tests/CodeIslandTests/NotchGestureSettingsTests.swift +++ b/Tests/CodeIslandTests/NotchGestureSettingsTests.swift @@ -2,8 +2,10 @@ import XCTest @testable import CodeIsland final class NotchGestureSettingsTests: XCTestCase { - func testHoverSettingsPreserveExistingDefaults() { - XCTAssertTrue(SettingsDefaults.openOnHover) + 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) diff --git a/Tests/CodeIslandTests/PanelWindowControllerTests.swift b/Tests/CodeIslandTests/PanelWindowControllerTests.swift index 1a0826c9..ee7d2331 100644 --- a/Tests/CodeIslandTests/PanelWindowControllerTests.swift +++ b/Tests/CodeIslandTests/PanelWindowControllerTests.swift @@ -13,15 +13,20 @@ final class PanelWindowControllerTests: XCTestCase { XCTAssertFalse(PanelSpaceTransitionPolicy.settledFullscreenLatch(isFullscreen: false)) } - func testPanelFollowsSpaceTransitionsWhileRemainingAvailableOnEverySpace() { + /// `.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(.canJoinAllApplications)) - XCTAssertTrue(behavior.contains(.managed)) + XCTAssertTrue(behavior.contains(.stationary)) XCTAssertTrue(behavior.contains(.fullScreenAuxiliary)) XCTAssertTrue(behavior.contains(.ignoresCycle)) - XCTAssertFalse(behavior.contains(.stationary)) + XCTAssertFalse(behavior.contains(.managed)) + XCTAssertFalse(behavior.contains(.canJoinAllApplications)) } func testScreenHopMotionUsesMoreVisibleTiming() { 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))) + } +} From d0d0d7a184bd8e845c47ca4c21675ce7fa017723 Mon Sep 17 00:00:00 2001 From: Mrjamedd Date: Sat, 15 Aug 2026 13:37:43 -0400 Subject: [PATCH 18/18] test(gestures): align emit-once test with the corrected swipe direction 810711a flipped the horizontal mapping to `accumulatedX > 0 ? .navigateNext : .navigatePrevious` and updated testPhysicalDirectionsMapToNaturalNotchActions to match, but testGestureEmitsOnlyOnceUntilEnded kept the pre-flip expectation. The two then asserted opposite results for the same input, since `action(x:)` is just `consume(sample(x:))`. Co-Authored-By: Claude Opus 5 --- Tests/CodeIslandTests/NotchGestureInterpreterTests.swift | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Tests/CodeIslandTests/NotchGestureInterpreterTests.swift b/Tests/CodeIslandTests/NotchGestureInterpreterTests.swift index 6bd4f3e8..206dc8d1 100644 --- a/Tests/CodeIslandTests/NotchGestureInterpreterTests.swift +++ b/Tests/CodeIslandTests/NotchGestureInterpreterTests.swift @@ -19,10 +19,12 @@ final class NotchGestureInterpreterTests: XCTestCase { func testGestureEmitsOnlyOnceUntilEnded() { var interpreter = NotchGestureInterpreter() - XCTAssertEqual(interpreter.consume(sample(x: -30)), .navigateNext) + // 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)), .navigateNext) + XCTAssertEqual(interpreter.consume(sample(x: -30, began: true)), .navigatePrevious) } func testMomentumAndNonPreciseScrollAreIgnored() {