Skip to content

feat(notch): trackpad gestures, Space-swipe collapse, and motion/appearance controls - #314

Open
Mrjamedd wants to merge 18 commits into
wxtsky:mainfrom
Mrjamedd:agent/notch-gesture-support
Open

feat(notch): trackpad gestures, Space-swipe collapse, and motion/appearance controls#314
Mrjamedd wants to merge 18 commits into
wxtsky:mainfrom
Mrjamedd:agent/notch-gesture-support

Conversation

@Mrjamedd

@Mrjamedd Mrjamedd commented Aug 14, 2026

Copy link
Copy Markdown

Adds trackpad gesture control to the island, collapses it when the user swipes to another desktop, and adds two appearance/motion settings. Rebased on main at v1.0.32; swift test is green (957 tests, 0 failures).

Gestures on the island

Scroll gestures over the island, interpreted by a small pure state machine (NotchGestureInterpreter) with a 24pt activation threshold and a 1.2× axis-dominance rule so a diagonal drift doesn't fire the wrong action:

  • swipe down — open
  • swipe up — close
  • swipe horizontally — move through the session filters (all / status / cli)

Horizontal direction is invertible via a setting. Gesture delivery uses both a local and a global monitor, because a nonactivating panel doesn't receive scroll events when macOS routes them to the app underneath the physical notch.

Collapse when switching desktops

An expanded island used to ride through the entire Spaces animation and snap shut at the end, because NSWorkspace.activeSpaceDidChangeNotification only fires once macOS has finished the transition.

Detecting the swipe as it happens turned out to need a mechanism worth spelling out, since two obvious candidates don't work:

  • NSEvent global monitors deliver scroll events to a background app but strip the NSTouch payload — measured 156 scroll events with zero touches and no gesture events at all.
  • NSEvent.trackSwipeEvent only operates on .scrollWheel events with precise deltas, i.e. the two-finger page-swipe gesture. A three/four-finger Spaces swipe never arrives as a scroll event, so there is no gestureAmount to read.
  • The responder-chain touchesMoved(with:) path only fires while the panel is the key window — never the case when the user is working in another app.

This uses MultitouchSupport, the private framework, which does deliver normalized finger positions to a background app with no permission prompt. Flagging that explicitly for review, since it's a private-API dependency:

  • resolved through dlopen/dlsym rather than linked
  • MTTouch's 96-byte stride is validated before any field is read
  • if either check fails, isAvailable goes false and the existing activeSpaceDidChange observer remains as the fallback — no crash, just the old late-collapse behaviour
  • devices are re-armed on wake, since MultitouchSupport stops delivering frames across sleep

Scoping keeps it from firing on unrelated gestures:

  • armed only while a collapsible surface is actually on screen, so an idle island does no per-frame work
  • 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 this, a global gesture would collapse the island on every desktop switch anywhere on screen.
  • required finger count is read from the system trackpad preference (TrackpadThreeFingerHorizSwipeGesture / TrackpadFourFingerHorizSwipeGesture), so it matches whichever gesture the user actually has bound, and stays silent if they've turned it off

Verified against real hardware: 2006 contact frames, 4 simultaneous fingers, 4/4 swipes detected, one crossing per gesture.

Contrast edge tinted by mascot

Opt-in, off by default, so the white edge stays 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 — toggling changes hue and nothing else. The edge follows whichever mascot the bar is showing and crossfades over 0.35s when that changes.

Signature colors moved out of a private table in MascotsPage into MascotPalette, so the settings swatches and the edge tint can't drift, keyed to match MascotView's routing including aliases (cursor-cli, qoderwork, google-antigravity, omp).

Open / close speed

A 0.5×–2.0× slider scaling the spring response for the island's open and close animations. It scales the response rather than swapping in a fixed duration, so the motion stays interruptible at every setting, and responses are read live so the slider applies without a relaunch. It sits outside the hover-to-open branch deliberately: it governs the close animation too, which runs regardless of what opened the island.

Default change

openOnHover now ships off. Brushing the notch on the way to the menu bar shouldn't expand the island; click and swipe remain the deliberate ways in. showContrastEdge stays on. Flagging this as the one behaviour change for existing users who never touched the setting.

Tests

swift test — 957 tests, 0 failures. New coverage for the gesture interpreter, action policy, hitbox, Spaces-swipe detector (direction, single-fire-per-gesture, jitter rejection, finger-count filtering, rebaselining after a lift), mascot palette (fallbacks, aliases, blend bounds, plus guards that every mascot stays pale enough to read as a highlight and saturated ones keep a distinguishable hue), and the animation-speed math.

One drive-by fix: 810711a flipped the horizontal direction mapping and updated one of its two tests. testGestureEmitsOnlyOnceUntilEnded kept the pre-flip expectation, so the suite had two tests asserting opposite results for identical input. Corrected in d0d0d7a.

Notes for review

  • The private-framework dependency is the main thing to weigh. It's isolated to MultitouchDevice.swift behind an availability check, and the feature degrades to the previous behaviour if it ever stops resolving — but it is private API, and it would block Mac App Store distribution if that's ever a goal.
  • MascotPalette needed a grok entry after rebasing onto the new Grok mascot; it uses white to match GrokView. Worth a glance that the color is right.

nguyenvanduocit pushed a commit to nguyenvanduocit/CodeIsland that referenced this pull request Aug 15, 2026
…s (PR wxtsky#314 watch)

New upstream PR wxtsky#314 (wxtsky/CodeIsland, open Aug 14): configurable trackpad
gesture support for the notch panel — swipe up/down to open/close, left/right
to cycle filter modes, hover-open delay slider, haptic feedback. Not yet merged;
added as T-084 (Backlog, medium priority, M effort, gate on merge).

All other watched PRs (wxtsky#285, wxtsky#295, wxtsky#305, wxtsky#310, wxtsky#311) still open. No new commits
on upstream/main since v1.0.31 (Jul 23). vibe-island quiet since Jul 17.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NxYTPGyRVNKvyjfKbBivfh
@wxtsky

wxtsky commented Aug 15, 2026

Copy link
Copy Markdown
Owner

Ran the full swift test suite against this branch for you, since your notes say your Mac can't resolve XCTest. Rebased onto current main first (see conflict note below), then built and ran everything.

Result: 919 tests, 2 skipped, 2 failures — both in this PR's own test file, and they are a genuine contradiction rather than an environment problem.

NotchGestureInterpreterTests.swift:22: testGestureEmitsOnlyOnceUntilEnded :
  XCTAssertEqual failed: ("Optional(NotchGestureAction.navigatePrevious)") is not equal to ("Optional(NotchGestureAction.navigateNext)")
NotchGestureInterpreterTests.swift:25: (same)

Two of your tests disagree about the same input:

// testPhysicalDirectionsMapToNaturalNotchActions — matches the implementation
XCTAssertEqual(action(x: -30), .navigatePrevious)

// testGestureEmitsOnlyOnceUntilEnded — expects the opposite for the same delta
XCTAssertEqual(interpreter.consume(sample(x: -30)), .navigateNext)

Both helpers feed physicalDeltaX, so the inputs really are identical. The implementation returns .navigatePrevious, which agrees with the first test and with your PR description ("physical swipe left navigates right; physical swipe right navigates left"), so the second test looks like a stale expectation from before the direction was settled. Suggested fix — it also keeps the test's actual subject (emit-once-per-gesture) intact:

-        XCTAssertEqual(interpreter.consume(sample(x: -30)), .navigateNext)
+        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)

Everything else passes, including all of NotchGestureHitboxTests and the settings/localization/hover coverage.

Rebase note: main moved a fair bit today. One conflict, in PanelWindowController.swift: sessionObservationTask is gone — the 500 ms observation poll was replaced by isSessionObservationArmed + re-arming from onChange (#299, it was a permanent wakeup for no benefit). Your activeSpaceTransitionTask sits alongside it unchanged; resolving is just keeping both declarations. Nothing else in your branch conflicted, and it builds clean after that.

No rush — leaving it as a draft since it's yours to finish. The gesture work reads well; the hitbox tests in particular are nice.

Mrjamedd and others added 18 commits August 15, 2026 13:33
…speed

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 <noreply@anthropic.com>
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 <noreply@anthropic.com>
@Mrjamedd
Mrjamedd force-pushed the agent/notch-gesture-support branch from 3088104 to d0d0d7a Compare August 15, 2026 17:37
@Mrjamedd Mrjamedd changed the title feat(notch): add configurable gesture support feat(notch): trackpad gestures, Space-swipe collapse, and motion/appearance controls Aug 15, 2026
@Mrjamedd
Mrjamedd marked this pull request as ready for review August 15, 2026 17:38
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants