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
nguyenvanduocit pushed a commit to nguyenvanduocit/CodeIsland that referenced this pull request Aug 18, 2026
Upstream quiet since v1.0.32 (Aug 15). Issue wxtsky#316 is Codex-specific.
PR wxtsky#314 (trackpad gestures) still open and watched as T-084.
vibeislandapp/vibe-island quiet for 32 days.
nguyenvanduocit pushed a commit to nguyenvanduocit/CodeIsland that referenced this pull request Aug 21, 2026
…am quiet day 6

- wxtsky/CodeIsland: HEAD still at 6eea9af (v1.0.32, Aug 15); PR wxtsky#319 is README docs only;
  issues wxtsky#317-wxtsky#320 are non-Claude CLIs or Buddy hardware companion — all skipped
- vibeislandapp/vibe-island: issue wxtsky#220 (EDR quarantine) not applicable — our bridge
  uses local Unix socket only, not ngrok tunnels that trigger CrowdStrike heuristics
- PR wxtsky#314 (trackpad gestures, T-084) still watching

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01FEXX1hgdjDSmNdjkAn71bR
nguyenvanduocit pushed a commit to nguyenvanduocit/CodeIsland that referenced this pull request Aug 29, 2026
…1.0.32)

No new commits on wxtsky/CodeIsland or vibeislandapp/vibe-island since
Aug 26. Issues wxtsky#327 (Qoder) and wxtsky#328 (Codex) are non-Claude CLI specific.
PRs wxtsky#314, wxtsky#323, wxtsky#325, wxtsky#326 remain open and watching. No actionable items.

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

@wxtsky wxtsky left a comment

Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this — the engineering standard is high, and the parts of it I want are things I'd have never got to on my own. I merged it locally onto current main (which has moved: #315, #319, #322, #323, #325, #326, #329 all landed today) and it builds clean with 990 tests green, so nothing below is about it not working. It's about what I can't take responsibility for shipping in one piece.

Requesting changes with a concrete split at the bottom.

1. There are two private-API surfaces, and only one of them is flagged

The PR calls out MultitouchSupport, and that one I'm actually fairly comfortable with — dlopen/dlsym, a 96-byte stride check before any field is read, isAvailable going false, and the activeSpaceDidChange observer still there as the fallback. That is the right way to do it.

AllSpacesAnchor.swift is not mentioned anywhere in the description, and it's the riskier of the two:

@_silgen_name("CGSSpaceCreate")
private func CGSSpaceCreate(_ cid: CGSConnectionID, _ options: Int, _ properties: NSDictionary?) -> CGSSpaceID

Seven undocumented CGS symbols, bound by @_silgen_name, called unconditionally from a singleton init at launch. There is no isAvailable, no degradation path, and no way for it to fail softly: a missing or renamed symbol is a dyld failure, and a changed signature is undefined behaviour, not a caught error. CodeIsland currently has zero private-API dependencies (grep -rn '_silgen_name\|dlopen\|PrivateFrameworks' Sources/ is empty on main), ships notarized and auto-updates via Sparkle — so the blast radius of getting this wrong is every installed copy failing to launch after an OS update, with no way for me to reach those users.

If the anchor is genuinely load-bearing for the swipe behaviour, it needs the same treatment MultitouchDevice got: resolve through dlopen/dlsym, verify every symbol, and fall back to plain collectionBehavior when any is missing. deinit on a static let shared also never runs, so the space it creates is there for the process lifetime regardless.

2. The rationale for the window changes doesn't match this codebase

PanelWindowBehavior.collectionBehavior is byte-identical to what PanelWindowController already passed:

// main, before this PR
panel.collectionBehavior = [.canJoinAllSpaces, .fullScreenAuxiliary, .stationary, .ignoresCycle]

but its doc comment explains that .canJoinAllSpaces and .managed are contradictory "which is what caused the island to drop out and reappear mid-swipe". .managed has never been in that set. So the comment diagnoses a bug that isn't in this code, and the "fix" is a reordering of the same four flags — which means whatever was actually causing the mid-swipe behaviour you saw is still unexplained, and I don't know which of the other changes in this area is the one doing the work.

Related, and the reason I can't just wave it through: the style mask gains .utilityWindow and .hudWindow because it "matches a reference notch-overlay panel's style mask". I don't take code or structure from other notch apps — licensing, and I'd rather this project's decisions be ones I can defend on their own terms. If those two flags earn their place, I need the reason in terms of what CodeIsland's panel does.

panel.isMovable = false I'll take on its own merits; that one's well argued.

3. Two behaviour changes that shouldn't ride along

The close spring changes timing. NotchAnimationMetrics.baseCloseResponse = baseOpenResponse makes it 0.42; it is 0.38 on main, and deliberately so — the close is critically damped at 0.38 / 1.0 specifically so NotchPanelShape's bottom edge never overshoots out from under the notch. I wrote that reasoning up on #298 last month. A speed slider is a great answer to #298; silently retuning the curve it scales is not, and at 0.5× the overshoot risk is where it'd actually show.

Hover-open goes from unconditional to off. openOnHover and hoverOpenDelay are new keys — hover-open isn't a setting today, it's just how the island works. Introducing the setting defaulted off doesn't flip a default, it removes a behaviour every existing user has, and they'd have to find a new checkbox to get it back. I'm open to the argument (brushing past on the way to the menu bar is a real annoyance, and #298's reporter is essentially complaining about the same surface), but that's its own change with its own release note, not a line inside a gestures PR.

What I'd like to do

Split it. Taking these three as separate PRs, in any order, and I'll review them the day they land:

  1. Open/close speed sliderNotchAnimationSpeed + the settings row, with baseCloseResponse staying at 0.38. This is the piece I most want; it's a real answer to #298 and it stands entirely alone. The clamp handling UserDefaults.double's 0-for-absent is exactly right.
  2. Mascot contrast edgeMascotPalette plus the tint. Opt-in, off by default, geometry untouched, and pulling the signature colours out of MascotsPage's private table so the swatches and the edge can't drift is a genuine improvement on its own. Note main now also has a grok mascot, which you already caught.
  3. Gestures on the islandNotchGestureInterpreter, the hitbox, the local+global scroll monitors, the filter cycling. No private API, pure state machine, well tested. My only ask is that hover-open stays as it is and this is purely additive.

That leaves the Spaces-swipe collapse (MultitouchSupport + AllSpacesAnchor + the panel/space plumbing) as its own PR, where we can weigh the private-API question on its own instead of it being the reason three good features wait. For that one I'd want the anchor made optional the way MultitouchDevice already is — or dropped, if the swipe detection works without it.

Also worth knowing before you rebase: #325 refactored ApprovalBar's click-to-jump into a shared startNotchCardJump, so NotchPanelView.swift has moved under you.

Genuinely — the MultitouchSupport write-up, the finger-count preference reading, and catching the two tests that asserted opposite results for the same input (810711a/d0d0d7a) are all better than I'd expect. I want this in; I just need it in pieces I can each say yes to.

@Mrjamedd

Mrjamedd commented Sep 2, 2026

Copy link
Copy Markdown
Author

Thanks for the detailed review, and for rebasing and running the full suite against current main. The split makes sense, and the concrete boundaries are very helpful.

I’ll separate the work into focused PRs:

  1. Open/close speed slider, keeping baseCloseResponse at 0.38.
  2. Mascot contrast-edge tint as an opt-in feature.
  3. Island gestures as a purely additive change, preserving the existing hover-open behavior.
  4. Spaces-swipe collapse on its own, with AllSpacesAnchor made optional through dynamic symbol resolution and a safe fallback—or removed if the swipe detection does not need it. I’ll also leave out the window-style flags unless there is a CodeIsland-specific reason for them.

I’ll start with the speed slider, then follow with the other isolated pieces. Thanks again—the feedback is clear and actionable.

nguyenvanduocit pushed a commit to nguyenvanduocit/CodeIsland that referenced this pull request Sep 3, 2026
wxtsky/CodeIsland: no commits after v1.0.33 (Sep 1).
vibeislandapp/vibe-island: one docs-only commit (issue template fix).
PR wxtsky#314 (trackpad gestures, T-084) still open.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QNFCm1nJvL3bUFugfEYrPC
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