summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-09-07 14:00:29 -0300
committerGabriel Schneider <[email protected]>2026-09-07 14:10:43 -0300
commit7545cdbf47559d4e996c7d3fc9ae8968a3342292 (patch)
tree1344829ac67c55c2c233e813cd6c32bb8e548281
parent3f2d6f43199d0e230490396deb50f8dc49c7b8b0 (diff)
downloadpardes-7545cdbf47559d4e996c7d3fc9ae8968a3342292.tar.gz
pardes-7545cdbf47559d4e996c7d3fc9ae8968a3342292.zip
macos: use two-finger click for Exec and deep press for Look
-rw-r--r--docs/macos.md41
-rw-r--r--src/CHANGELOG.md18
-rw-r--r--src/macos/Sources/PardesView.swift52
-rw-r--r--test/macos-snapshots/trackpad.golden2
-rw-r--r--test/macos-snapshots/trackpad.snap20
-rw-r--r--test/macos_e2e.swift8
6 files changed, 87 insertions, 54 deletions
diff --git a/docs/macos.md b/docs/macos.md
index 0ce29bf9..ae990944 100644
--- a/docs/macos.md
+++ b/docs/macos.md
@@ -177,14 +177,14 @@ marks the view dirty.
acme wants three mouse buttons — 1 selects, 2 executes, 3 looks — and the
machine this runs on has a glass rectangle. So the rectangle is taught to speak
-the vocabulary, and the mapping is the one macOS itself already suggests:
+the vocabulary, cheapest gesture to commonest verb:
| gesture | button | verb |
| --- | --- | --- |
| one finger | 1 | select |
-| two fingers | 3 | Look |
+| two fingers | 2 | Exec |
| three fingers | 2 | Exec |
-| a deep press | 2 | Exec |
+| a deep press | 3 | Look |
| two fingers twisted | — | `n` / `N` |
**The finger count decides, not the button stream.** This is the part that only
@@ -192,8 +192,9 @@ real hardware could teach, and it is worth spelling out because the obvious
implementation is wrong. macOS's secondary click is "click or tap with **two or
more** fingers", so with that setting on — the default — a *three*-finger click
is delivered as `rightMouseDown` exactly like a two-finger one. A view that
-trusts the stream cannot tell them apart and quietly does Look for both. The
-trace that caught it, from a real trackpad:
+trusts the stream cannot tell them apart and quietly does Look for both, which
+is the one thing a multi-finger click here must NOT do. The trace that caught
+it, from a real trackpad:
```
pardes: rightMouseDown: resting=2
@@ -205,10 +206,18 @@ button from the fingers first and falls back to the stream only when there are
no fingers to count — which is exactly the real-mouse case, where right is Look
and the middle button is Exec.
+Two and three fingers landing on the same verb is therefore not a wasted
+gesture, it is the hardware refusing to distinguish them under the default
+setting. Look is the deep press instead: on a trackpad it is the one gesture
+the system does not overload, and it wants "Force Click and haptic feedback"
+on in System Settings — which nothing in this process can read, so a Mac with
+that off (or a trackpad with no force sensor) reaches Look through a real
+mouse's right button and the `Look` builtin, keyboard Enter included.
+
The count comes from `event.touches(matching: .touching, in: nil)`, with `nil`
rather than the view because that argument filters on touch/view association and
an association that fails does not raise, it returns zero fingers — a
-two-finger Look silently degrading into a select. Belt and braces: the view also
+two-finger Exec silently degrading into a select. Belt and braces: the view also
keeps a running `restingFingers` from the four `touchesXxx` callbacks, because
the touch set hanging off a *mouse* event is an accident of how the click was
produced and can come back empty. The mouse event's own set wins when it has
@@ -220,21 +229,21 @@ core tracks a drag keyed by button, and answering a press of 3 with a release of
A deep press arrives as `pressureChange` reaching stage 2, and only the
transition counts — AppKit repeats stage 2 for as long as the finger stays down.
-By then a press has already gone out, so it is *released* before the middle one
-is sent. That ordering is not tidiness: a middle press arriving while the core
-holds a left select-drag is acme's 1-2 chord, which is **Cut**. The release
+By then a press has already gone out, so it is *released* before the right one
+is sent. That ordering is not tidiness: a right press arriving while the core
+holds a left select-drag is acme's 1-3 chord, which is **Paste**. The release
costs a cursor move at the click point, which is what clicking there would have
-done anyway.
+done anyway; a multi-finger press released this way fires the Exec its fingers
+already asked for.
Which press gets upgraded is deliberately not restricted to the left one, and
that too came from the trace: on a Force Touch trackpad the deep press usually
rides a click that already went out on the *right* stream, so gating on a
latched left button meant the conversion never fired at all — the log showed
`pressure: stage=2 latched=nil` and nothing else. Any in-flight click upgrades;
-already-Exec is the only case with nothing to do. The view also needs
+already-Look is the only case with nothing to do. The view also needs
`NSPressureConfiguration(pressureBehavior: .primaryDeepClick)` or stage 2 is the
-system's business and never arrives — and the user needs "Force Click and
-haptic feedback" on in System Settings, which nothing in this process can read.
+system's business and never arrives.
Twisting two fingers is a dial, and a dial over a list of look-able places is
`n`. A notch moves the SELECTION one place along and opens nothing; Enter opens
@@ -388,11 +397,11 @@ layer here; everything pardes binds lives on the other four.
All three verbs are cheap to mistake for broken, because acme's verbs are about
the *word under the pointer* and most words resolve to nothing:
-- **Look** (two fingers) on a filename opens it; on a word that names no file
+- **Look** (a deep press) on a filename opens it; on a word that names no file
and matches nothing else on screen, it searches, finds where it already is,
and the screen does not move. The pulse still fires — the gesture worked.
-- **Exec** (three fingers) on a builtin name runs it. On ordinary prose it types
- that word at a shell, which needs a terminal pane to type into.
+- **Exec** (two or three fingers) on a builtin name runs it. On ordinary prose
+ it types that word at a shell, which needs a terminal pane to type into.
- **`n`/`N`** (twist) moves the SELECTION to the next look-able place and opens
nothing; Enter opens what it landed on. It walks a ring across panes — the
ones a Look came from first, then the output buffers none has — so a twist
diff --git a/src/CHANGELOG.md b/src/CHANGELOG.md
index 714497b6..39c2057d 100644
--- a/src/CHANGELOG.md
+++ b/src/CHANGELOG.md
@@ -2,6 +2,24 @@
## 0.0.2
+- The macOS trackpad's two verbs trade places: a two-finger click is Exec and
+ a deep press is Look. Exec is what a hand spends a session doing, so it takes
+ the gesture that costs nothing, and the verb that goes somewhere is worth one
+ deliberate push past the click. Three fingers stay Exec, and that is the
+ hardware rather than a gesture left unused: macOS's secondary click is "click
+ or tap with two or more fingers", so a three-finger click arrives as
+ `rightMouseDown` exactly like a two-finger one, and the resting count is the
+ only thing that keeps either of them off Look. `pressureChange` still
+ releases the click in flight before the new button goes out, which now steps
+ around acme's 1-3 chord (Paste) where it used to step around 1-2 (Cut). Look
+ by finger count is gone, so a Mac with "Force Click and haptic feedback" off
+ — or a trackpad with no force sensor — reaches it through a real mouse's
+ right button, the `Look` builtin, or Enter on the word. `trackpad.snap`
+ asserts all of it without a trackpad, because `NSTouch` and the pressure
+ stages have no public constructors and `Trackpad` is therefore pure policy a
+ script can name finger counts to; the swap left every golden screen
+ byte-identical under the other gesture, which is what shows the two paths
+ were exchanged and nothing else moved.
- A filtered terminal costs what an unfiltered one does. `Filter`'s second
stage asked `RGB.contrast` for every cell it painted, and that call ends in
`std.math.pow` six times over — a libm round trip per cell, per frame, to
diff --git a/src/macos/Sources/PardesView.swift b/src/macos/Sources/PardesView.swift
index 3f5f1346..f9afa7b1 100644
--- a/src/macos/Sources/PardesView.swift
+++ b/src/macos/Sources/PardesView.swift
@@ -45,25 +45,27 @@ protocol PardesViewDelegate: AnyObject {
/// constructed, so this is the part of the gesture a test can reach.
///
/// acme's three buttons are the whole vocabulary — 1 selects, 2 executes,
-/// 3 looks — and a trackpad has one surface. Two fingers is the gesture macOS
-/// itself spells "secondary", so it is Look; three is the one left over, so it
-/// is Exec, the heavier verb, which is also what a deep press means.
+/// 3 looks — and a trackpad has one surface. More than one finger is Exec,
+/// because running something is what a hand does over and over and it gets
+/// the gesture that costs nothing; Look is the deep press, one deliberate
+/// push past the click for the verb that goes somewhere.
///
-/// The finger count has to win over the stream, and that is not a preference.
-/// macOS's secondary click is "click or tap with TWO OR MORE fingers": with it
-/// on, a three-finger click is delivered as rightMouseDown exactly like a
-/// two-finger one, and a view that trusts the stream cannot tell them apart —
-/// three fingers silently did Look. Measured on real hardware, which is the
-/// only way this was ever going to be found: `rightMouseDown: resting=3`.
+/// Two and three fingers are the same verb here, and that is the hardware
+/// talking rather than a shrug. macOS's secondary click is "click or tap with
+/// TWO OR MORE fingers": with it on — the default — a three-finger click is
+/// delivered as rightMouseDown exactly like a two-finger one, and a view that
+/// trusts the stream calls both of them Look. Measured on real hardware, which
+/// is the only way this was ever going to be found:
+/// `rightMouseDown: resting=3`. So the count is what keeps a multi-finger
+/// click off Look, and the stream cannot be trusted to do it.
///
-/// So the stream is only the fallback, for when there are no fingers to count:
+/// The stream is only the fallback, for when there are no fingers to count:
/// a real mouse's right button is Look and its middle button is Exec, and both
/// arrive with an empty touch set.
enum Trackpad {
static func button(stream: pardes_mouse_button_e, fingers: Int) -> pardes_mouse_button_e {
switch fingers {
- case 2: return PARDES_MOUSE_RIGHT
- case 3...: return PARDES_MOUSE_MIDDLE
+ case 2...: return PARDES_MOUSE_MIDDLE
// One finger, or none to count: the stream is the answer. A trackpad
// single click comes in on the left stream and stays left; a real
// mouse's right and middle buttons keep their acme meanings.
@@ -72,9 +74,9 @@ enum Trackpad {
}
/// A force click is a deliberate second gesture on top of an ordinary one,
- /// so it gets the verb that does something rather than the one that
- /// navigates.
- static let forceClickButton: pardes_mouse_button_e = PARDES_MOUSE_MIDDLE
+ /// so it gets the verb that goes somewhere rather than the one that runs
+ /// something: press harder and you Look.
+ static let forceClickButton: pardes_mouse_button_e = PARDES_MOUSE_RIGHT
}
// Matches bg_default/fg_default in src/gui/gui.zig and DEFAULT_FG/DEFAULT_BG in
@@ -643,7 +645,7 @@ final class PardesView: NSView {
// hardware it does not always: AppKit routes NSTouch through the four
// touchesXxx callbacks, and the touch set hanging off a *mouse* event can
// come back empty depending on how the click was produced. Empty reads as
- // one finger, which is a two-finger Look silently degrading into a select
+ // one finger, which is a two-finger Exec silently degrading into a select
// — the exact failure this was supposed to avoid. So the count is
// maintained here and the mouse event's own set is preferred only when it
// has something in it.
@@ -674,7 +676,7 @@ final class PardesView: NSView {
registerForDraggedTypes([.fileURL])
// Indirect touches are the trackpad's. Without this the touch set is
// always empty and every click looks like one finger, which is exactly
- // the bug that would make two-finger Look silently never fire.
+ // the bug that would make two-finger Exec silently never fire.
allowedTouchTypes = [.indirect]
// Without a pressure configuration the deep-press stages are the
// system's business and stage 2 may never be delivered here.
@@ -1737,15 +1739,17 @@ final class PardesView: NSView {
/// flight. AppKit keeps sending stage-2 events while the finger stays down,
/// so only the transition counts.
///
- /// Whatever button is in flight is released before the middle one goes out:
- /// a middle press arriving while the core holds a left select-drag is
- /// acme's 1-2 chord, which is Cut. Releasing first costs a cursor move at
- /// the click point — which is what clicking there would have done anyway.
+ /// Whatever button is in flight is released before the look one goes out:
+ /// a right press arriving while the core holds a left select-drag is
+ /// acme's 1-3 chord, which is Paste. Releasing first costs a cursor move
+ /// at the click point — which is what clicking there would have done
+ /// anyway; a multi-finger press released this way fires its own Exec,
+ /// which is what the fingers already asked for.
///
/// Not gated on the press being a LEFT one, which is what stopped this
/// working: on a Force Touch trackpad the deep press is just as likely to
- /// have arrived on the right stream, and an in-flight Look upgraded by
- /// pressing harder is precisely the gesture. Already-Exec is the only case
+ /// have arrived on the right stream, and an in-flight Exec upgraded by
+ /// pressing harder is precisely the gesture. Already-Look is the only case
/// with nothing to do.
override func pressureChange(with event: NSEvent) {
trace("pressure: stage=\(event.stage) latched=\(String(describing: latchedButton?.rawValue))")
@@ -1829,7 +1833,7 @@ final class PardesView: NSView {
/// (secondary click, three-finger drag, force click, "look up"), none of
/// which this process can read, and every one of which turns a gesture into
/// a different NSEvent or into none at all. When someone reports that
- /// two-finger Look does nothing, this is the only thing that can answer
+ /// two-finger Exec does nothing, this is the only thing that can answer
/// whether AppKit saw two fingers, one, or no click at all.
private func trace(_ message: @autoclosure () -> String) {
guard PardesView.tracing else { return }
diff --git a/test/macos-snapshots/trackpad.golden b/test/macos-snapshots/trackpad.golden
index 631e5af9..b5f9f567 100644
--- a/test/macos-snapshots/trackpad.golden
+++ b/test/macos-snapshots/trackpad.golden
@@ -246,7 +246,7 @@
| 1 @p0:2:3-6 x MARK a
| 2 @p0:4:3-6 x MARK b
| 3 @p0:6:3-6 x MARK c
-== snap force grid=100x30 cursor=57,2
+== snap exec3 grid=100x30 cursor=57,2
|New Newcol Joincol Find Grep Help Changelog Tutor Dump NextColor Debug Kill
| /private/tmp/pardes-macos-e2e/trackpad/cwd Save /private/tmp/pardes-macos-e2e/trackpad/cwd/+New
| RK c\n' 1
diff --git a/test/macos-snapshots/trackpad.snap b/test/macos-snapshots/trackpad.snap
index 05f46e1f..df843e5d 100644
--- a/test/macos-snapshots/trackpad.snap
+++ b/test/macos-snapshots/trackpad.snap
@@ -3,8 +3,8 @@
#
# Trackpad.button(fingers:) is pure policy with no NSEvent in it, so a script
# can name how many fingers were resting and get back the button a hand would
-# have produced: two is acme's button 3 (Look), three or more is button 2
-# (Exec), and a deep press is button 2 by another route. pardes_take_haptic is
+# have produced: two or more fingers is acme's button 2 (Exec), one finger is
+# left to select, and a deep press is button 3 (Look). pardes_take_haptic is
# the other half — it is how the pulse the core armed is read back on a machine
# with nothing to feel it with.
#
@@ -36,9 +36,9 @@ haptic look
snap adapter
# Back to a clean slate for the finger-count cases below.
haptic none
-# TWO FINGERS RESTING = button 3 = Look. MARK names no file, so looking at it
+# A DEEP PRESS = button 3 = Look. MARK names no file, so looking at it
# searches, and the hits land in a +Search buffer split under the shell.
-fingers 2 4 3
+force 4 3
wait 10000 @p0:
stable 700 15000
haptic look
@@ -70,18 +70,20 @@ snap scrolled
wheel wheeldown 4 5
stable 700 10000
snap notched
-# THREE FINGERS = button 2 = Exec. `New` in the top tag is a builtin, so this
+# TWO FINGERS = button 2 = Exec. `New` in the top tag is a builtin, so this
# spawns a pane rather than running something the machine would have to own.
-fingers 3 1 0
+fingers 2 1 0
stable 700 20000
haptic exec
snap exec
-# A DEEP PRESS is the same button 2 by another route; `Newcol` proves it went
+# THREE FINGERS are the same button 2, and that is the case the finger count
+# exists for: macOS delivers them on the right stream exactly like two, so a
+# view that trusted the stream would Look here. `Newcol` proves this one went
# somewhere different from the click above.
-force 5 0
+fingers 3 5 0
stable 700 20000
haptic exec
-snap force
+snap exec3
# One finger is still an ordinary left press, and selecting is not a verb the
# hand should feel.
fingers 1 4 3
diff --git a/test/macos_e2e.swift b/test/macos_e2e.swift
index 800e2d43..87733ea9 100644
--- a/test/macos_e2e.swift
+++ b/test/macos_e2e.swift
@@ -658,16 +658,16 @@ private final class Driver: PardesViewDelegate {
case "fingers":
let view = try live()
- // The whole two-finger-Look / three-finger-Exec feature, asserted
- // without a trackpad: the policy is pure and lives in Trackpad, so a
- // script names the finger count and gets the button a hand gets.
+ // The whole finger-count feature, asserted without a trackpad: the
+ // policy is pure and lives in Trackpad, so a script names the
+ // finger count and gets the button a hand gets.
//
// The stream is the one macOS actually uses for a multi-finger
// click when its own secondary click is on, which is the default
// and which is what real hardware was observed doing for BOTH two
// and three fingers. Passing RIGHT here is therefore the harder
// case: it is the one where a view that trusted the stream would
- // call three fingers a Look.
+ // call every multi-finger click a Look instead of an Exec.
let count = try int(args, 0, "fingers count")
let cell = try gridPoint(args, 1, "fingers")
let stream = count >= 2 ? PARDES_MOUSE_RIGHT : PARDES_MOUSE_LEFT