Phase 1 and Phase 2 of punktfunk-planning/design/touch-client-overlay.md (§2–§4, §6): the dial, the pad path, the editor, and the virtual controller. Stacked on the Phase 0 PR (touch/phase-0 is the base), so this diff is Phases 1–2 only. One commit per work package or fix.
What a touch user gets
A two-finger twist on the stream opens a ring of six buttons under the fingers, frame by frame — 10° arms it, 30° commits it, a lift short of commit winds it back in and sends nothing. The centre button opens a sheet with the full catalogue and the resolution presets. Toggles (mic, stats, touch mode, the virtual controller) leave the ring open so the new state is visible; commands close it; a tap outside or 8 s idle closes it; End stream and host power sit behind a two-press arm. Closed, the ring leaves the view hierarchy entirely (tenet 1).
The editor is the ring (§3.3): on iOS and Android the Quick actions setting shows the in-stream ring, full size, over a backdrop that runs the real twist. Tap a slot to pick its action from a grouped catalogue, drag a disc onto another to swap (a spring plays the swap), tap the centre to see depth two, inert. Shortcuts are rows with a stacked-keycap disc; a shortcut is edited on a screen with a tappable keyboard. The console (Skia) is the ring too — on a stage that scales to the window — with the shortcut editor as list rows plus two trays (the keyboard for the name, a keyboard-shaped key tray for the key); it edits the global blob only.
The virtual controller (§4, Android and iPhone/iPad): the ring's Virtual controller slot draws a controller over the stream. The host sees one controller arrive when it appears and one leave when it goes, on the next free pad index beside any real pad. A finger on a control drives the game; a finger anywhere else falls through to the touch mode, so tap-to-click keeps working beside it. Face buttons roll (A to B under one thumb), the D-pad reads eight ways, a stick follows its first finger from wherever it landed, a trigger reads the finger's position down its pill. Three presets (Full / Sticks and shoulders / D-pad and face buttons), Opacity and Scale, under Quick actions.
What a pad user gets (T20)
Select+A, Select first, opens the ring — the first chord the host never sees. While the ring is up the pad belongs to it: the desktop masks the pads and polls the first into menu events; Android and Apple release everything held on the host and adopt the hardware state silently; on close nothing is replayed. D-pad or left stick step the highlight, A fires it, B closes; the sheet is a cursor list on every shell. The virtual controller is masked the same way, and its buttons ride the same chords on Android.
Per shell:
Android (Compose)
iOS (SwiftUI)
Desktop (Skia)
Opener
twist; Back (no longer ends the session — §5.3); Select+A
twist; the corner disc; Select+A; tvOS: a short Back
twist; ⌃⌥⇧O; Select+A
Slots
all of §3.1
all except send_text; pad dimmed on tvOS
all except pad, send_text
Editor
the ring itself; shortcut screen; Layout/Opacity/Scale
the ring itself (a sheet); shortcut sheet; Layout/Opacity/Scale
the ring itself (RingEditorScreen); ShortcutEditorScreen as rows + a key tray
Virtual controller
VirtualPad.kt over GamepadRouter.openExternal
VirtualPadLayer.swift (UIKit) over VirtualPadWire
none (D3)
Sheet
cursor list; resolution/refresh rows over nativeRequestMode, re-read live
cursor list over requestMode
MenuList; Left/Right cycles presets
Reduce motion
—
honoured
honoured
Haptics
tick on arm, tap per press, firm "no", warning on arm, thump at commit; tick per pad press
the same five through .sensoryFeedback; a light impact per pad press
none
Commits
Phase 1, the ring:
5f54a175 T7 — overlay_actions: one JSON blob, a tier-P profile field in Rust, Kotlin and Swift.
4c493de0 T7a — the twist in all three engines. 83cefd3c — a drifting twist no longer locks itself into a scroll (the notch-hold rule; found on the Nothing Phone).
f910f888 — key_vk. 01db9fcd — legends are words, keycap discs, distinct power glyphs, the iOS shortcut editor.
86964078, 7e073472 T9 — the Compose ring and sheet, the haptic vocabulary. 6618d078 — the sheet re-reads the live mode.
67bdadda T10 — the SwiftUI ring and sheet. 5adb5ad9 — the discs spring out one by one. a02c5744 — the ring on tvOS, a short Back opens it.
4f1b71ed, 86c8980b T8 — the Skia ring and sheet. 7ce448d0 — Reduce motion on the Skia ring.
577def85 T20 — Select+A swallowed in all three routers; the pad drives the ring and the sheet.
The editor (T19):
b1e63bff — list-form editors on all three shells.
7526e6f2, 4d613ce0, 4cfc50ee, b9838f00, e4c8e2d8 — iOS: the editor is the ring; a pick no longer closes it; a sheet, not a push; a drag no longer also picks; keycaps keep one height.
1c34358d, 22cba0ca, fa0d138d — Android: the editor is the ring; the drag works (the twist surface is a sibling); the ring opens at the stage's centre.
cf63f6cf — console: a shortcut editor. 4f59fd42, 6621e5ad — the swap plays as a spring and settles without a flash.
4ca58075 — console: the editor is the ring (Ring's editing mode: pick, lift-and-drop swap, pointer drag; a grouped picker; the shortcuts under it) and a real shortcut editor (keycap preview, name, modifier chips, a keyboard-shaped key grid). The six stepped rows and the stepped key form are gone; ring.rs compiles for the Android console too.
a45ba525 — the ring's catalogue (by group, with per-platform notes), its geometry and the shortcut upsert/remove move into pf-client-core, so the three desktop editors below draw one model.
The desktop shells (native, tenet 2; one model, tenet 3):
d9787009 GTK — ui_quick_actions.rs: a Quick actions row → the ring as a subpage (circular buttons on the ring's geometry over a gradient stage, popover picker with notes and a keyboard "Swap with…", drag-and-drop swap, shortcuts, Reset behind an alert) and a shortcut subpage (live keycap, name, modifier toggles, a keyboard-shaped key grid, "Press the chord…" from the real keyboard). Obeys the dialog's scope model.
dda1e1e9 Windows — app/quick_actions.rs: a Quick actions section (the ring on a Canvas with drag-swap and click-to-pick into a picker card, a button row for keyboard and screen-reader reach, shortcuts with an inline editor and accelerator-driven "Press the chord…"); every edit commits through the page's commit, so profiles absorb the whole ring.
0c4d503b console, from the Deck — the stick points at a slot (D12: menu_nav::ring_sector → MenuEvent::Sector, emitted before the buttons and the move; the D-pad steps once the stick lets go); the editor's stage is the flat card face and sizes itself from the ring's geometry so the label under the ring is no longer clipped.
d70e6f64 console, from the Deck's second look — the stream overlay's damage gate froze every self-clocked animation (the sheet opened with no rows until a cursor move): Ring::animating() and MenuList::animating() keep the key changing per tick until everything has landed. On that loop the ring got its look: a sprung arrival with overshoot, shadowed discs with a rim of light, an eased glowing highlight, a vignette scrim, a sheet that rises, a wind-in on close. The editor no longer walks into the shortcut list when the stick points at 6 o'clock (only a D-pad Down or a second push does). The shortcut editor is the console's own form grammar — MenuList rows (Name, Key, Hold with Ctrl/Alt/Shift/Win, Save, Remove) under the keycap preview, the keyboard tray for the name and a keyboard-shaped key tray for the key drawn the tray's way, so the focused key is a solid accent face. The presenter logs SDL's touch devices at connect and the first finger of a session (direct or not).
21be74b4 console — the action picker's scrim covers the whole screen (the shell only translates the canvas; the scrim overshoots the content rect, the hint bar draws after and keeps the picker's controls legible); only the spring's excess overshoots the discs' travel (the stagger's headroom had parked the early slots past their seats, clipping the 12 o'clock disc); the editor's stage scales itself down on short or narrow windows so the ring is never clipped.
807bb349 console, from the Nothing Phone — the stage's focus halo took a pre-scaled corner (it squares with the display scale: a second, rounder card behind the dial at phone scale); and on a short-wide screen (a landscape phone under the console's density-based scale) the shortcut rows move beside the stage so the ring takes the height instead of shrinking.
ac7fa7e2 console — icons: Lucide v0.462.0 (ISC) as embedded 24×24 path data (icons.rs, generated by scripts/gen-lucide-icons.py), parsed by Skia's SVG-path parser and stroked at Lucide's own weight — no icon font, no new dependency. Every built-in dial slot draws its icon (the muted mic its struck twin, the centre an ellipsis, an empty slot in the editor a faint plus); shortcuts keep their keycap chords and unknown host actions their labels. The hint bar's glyphs joined the same language: chevron directions, the set's return arrow in the remote's badge, one scaled hairline on every container, heavier PlayStation shapes. LUCIDE-LICENSE ships in the crate; THIRD-PARTY-NOTICES.txt regenerated (listing Lucide as vendored source, and catching the manifest up with the branch's lockfile).
Phase 2, the virtual controller:
acd61b31 T14 — Android: VirtualPad.kt, the Virtual controller slot as a toggle, the settings group; ExternalPad.axis now honours the ring's mask.
8b45652e T15 — Apple: VirtualPad.swift (geometry, rules, VirtualPadWire), VirtualPadLayer.swift (UIKit), the session model's toggle and mask, the editor's section.
da73d2e9 T16 — docs: input.md and client-settings.md.
Phase 3, Deck Gaming Mode:
3052e560 T17 — SteamTouchMouse: in a gamescope session with no finger seen, a mouse delta of 150 px or more is a leaked touch position — dropped, and a one-time notice names the plugin's Punktfunk controller layout. build-flatpak.sh gains BRANCH=. T18 (evdev) stays deferred.
T11 and T13 are inside the rings.
Things the build found that the design does not say
SDL delivers the two fingers of one frame as separate events. The desktop engine judges the twist only when the other finger's event is from the same frame (DIAL_FRAME_MS 4) or older than a pivot's stillness (DIAL_PIVOT_MS 50).
Any "first X is final" latch must run after the competing gesture's arm window. The Android scroll path latched a notch at 4 px of centroid drift before the dial could arm, so a real twist was a scroll (83cefd3c).
Compose: a pointerInput on a parent of the ring eats the discs' drags (taps survive). The twist surface and the pad layer are siblings under the ring.
UIKit hands a tap on a SwiftUI button to a UIViewRepresentable underneath it as well. The editor's backdrop tap closed the ring on every pick (4d613ce0).
A NavigationLink pushed from the iOS settings detail column (not a NavigationStack) pops the collapsed split view and kills List(selection:); sub-screens there are sheets.
pf-console-ui also builds for the Android GL host — the Skia ring is gated to Linux/Windows; the desktop ring draws only in --browse sessions.
The console settings store is one real file per test process; a store round-trip in a test races the settings tests (reduce_motion_freezes… is the known flake).
ExternalPad.axis on Android sent while the ring owned the pad — a stick still held under the ring wrote over the flush. Fixed in acd61b31; a capture-link stick had the same leak.
Corners cut, with the upgrade path
The console still ships no icon font: its icons are Lucide path data, stroked by Skia (ac7fa7e2). Only an unknown host action still draws as a word.
The stick picks a slot by sector on the desktop only; Android and Apple still step four-way (their routers quantise the stick before the ring sees it).
The desktop set_masked is shared with the gamescope overlay-focus mask (§8 trap 18).
Reduce Motion on Android has no cheap equivalent.
D9's per-row scope picker is owed; the console edits the global blob. A swap on the console has no spring, and its shortcut rows carry no disc (MenuList draws text).
The Deck's touchscreen in Gaming Mode is unverified either way (§5.5): the one session on record delivered neither fingers nor T17's leaked warp; the connect-time touch devices log line is the next thing to read.
The virtual controller has fixed positions per preset, fixed dead zones, no stick clicks, no gyro or touchpad emulation (§4.3). It starts hidden each session.
Verification
rustfmt; clippy -D warnings for pf-presenter, pf-client-core, pf-console-ui, punktfunk-client-session in the container; pf-presenter 64, pf-client-core 242, pf-console-ui 227 tests there.
Android: compile through cargo-ndk, all unit suites, OverlayActionsTest 6/6, VirtualPadTest 5/5; the debug APK on the Nothing Phone.
Swift: kit and app targets on iOS, tvOS and macOS; OverlayActionsTests 6/6 and VirtualPadTests 5/5 on macOS (SwiftPM); the Release build installed and launched on an iPhone 16 Pro Max.
On glass: the twist opens the ring on both phones; the editor's pick, swap and shortcuts on both. The virtual controller drew and drove the host's UI on the Nothing Phone (its feel is unreported; the iPhone try is owed). The console editors, the in-stream ring's look, the icons and the glyphs were looked at on the Deck and the Nothing Phone through five feedback rounds. Every §9 done-when is still open as a recorded result.
Not in this PR: T18 (evdev touch on the Deck), D9.
Phase 1 and Phase 2 of `punktfunk-planning/design/touch-client-overlay.md` (§2–§4, §6): the dial, the pad path, the editor, and the virtual controller. Stacked on the Phase 0 PR (`touch/phase-0` is the base), so this diff is Phases 1–2 only. One commit per work package or fix.
## What a touch user gets
A **two-finger twist** on the stream opens a ring of six buttons under the fingers, frame by frame — 10° arms it, 30° commits it, a lift short of commit winds it back in and sends nothing. The centre button opens a sheet with the full catalogue and the resolution presets. Toggles (mic, stats, touch mode, the virtual controller) leave the ring open so the new state is visible; commands close it; a tap outside or 8 s idle closes it; End stream and host power sit behind a two-press arm. Closed, the ring leaves the view hierarchy entirely (tenet 1).
**The editor is the ring** (§3.3): on iOS and Android the Quick actions setting shows the in-stream ring, full size, over a backdrop that runs the real twist. Tap a slot to pick its action from a grouped catalogue, drag a disc onto another to swap (a spring plays the swap), tap the centre to see depth two, inert. Shortcuts are rows with a stacked-keycap disc; a shortcut is edited on a screen with a tappable keyboard. The console (Skia) is the ring too — on a stage that scales to the window — with the shortcut editor as list rows plus two trays (the keyboard for the name, a keyboard-shaped key tray for the key); it edits the global blob only.
**The virtual controller** (§4, Android and iPhone/iPad): the ring's Virtual controller slot draws a controller over the stream. The host sees one controller arrive when it appears and one leave when it goes, on the next free pad index beside any real pad. A finger on a control drives the game; a finger anywhere else falls through to the touch mode, so tap-to-click keeps working beside it. Face buttons roll (A to B under one thumb), the D-pad reads eight ways, a stick follows its first finger from wherever it landed, a trigger reads the finger's position down its pill. Three presets (Full / Sticks and shoulders / D-pad and face buttons), Opacity and Scale, under Quick actions.
## What a pad user gets (T20)
`Select+A`, Select first, opens the ring — the first chord the host never sees. While the ring is up the pad belongs to it: the desktop masks the pads and polls the first into menu events; Android and Apple release everything held on the host and adopt the hardware state silently; on close nothing is replayed. D-pad or left stick step the highlight, A fires it, B closes; the sheet is a cursor list on every shell. The virtual controller is masked the same way, and its buttons ride the same chords on Android.
Per shell:
| | Android (Compose) | iOS (SwiftUI) | Desktop (Skia) |
|---|---|---|---|
| Opener | twist; **Back** (no longer ends the session — §5.3); `Select+A` | twist; the corner disc; `Select+A`; tvOS: a short Back | twist; `⌃⌥⇧O`; `Select+A` |
| Slots | all of §3.1 | all except `send_text`; `pad` dimmed on tvOS | all except `pad`, `send_text` |
| Editor | the ring itself; shortcut screen; Layout/Opacity/Scale | the ring itself (a sheet); shortcut sheet; Layout/Opacity/Scale | the ring itself (`RingEditorScreen`); `ShortcutEditorScreen` as rows + a key tray |
| Virtual controller | `VirtualPad.kt` over `GamepadRouter.openExternal` | `VirtualPadLayer.swift` (UIKit) over `VirtualPadWire` | none (D3) |
| Sheet | cursor list; resolution/refresh rows over `nativeRequestMode`, re-read live | cursor list over `requestMode` | `MenuList`; Left/Right cycles presets |
| Reduce motion | — | honoured | honoured |
| Haptics | tick on arm, tap per press, firm "no", warning on arm, thump at commit; tick per pad press | the same five through `.sensoryFeedback`; a light impact per pad press | none |
## Commits
Phase 1, the ring:
- `5f54a175` T7 — `overlay_actions`: one JSON blob, a tier-P profile field in Rust, Kotlin and Swift.
- `4c493de0` T7a — the twist in all three engines. `83cefd3c` — a drifting twist no longer locks itself into a scroll (the notch-hold rule; found on the Nothing Phone).
- `f910f888` — `key_vk`. `01db9fcd` — legends are words, keycap discs, distinct power glyphs, the iOS shortcut editor.
- `86964078`, `7e073472` T9 — the Compose ring and sheet, the haptic vocabulary. `6618d078` — the sheet re-reads the live mode.
- `67bdadda` T10 — the SwiftUI ring and sheet. `5adb5ad9` — the discs spring out one by one. `a02c5744` — the ring on tvOS, a short Back opens it.
- `4f1b71ed`, `86c8980b` T8 — the Skia ring and sheet. `7ce448d0` — Reduce motion on the Skia ring.
- `577def85` T20 — `Select+A` swallowed in all three routers; the pad drives the ring and the sheet.
The editor (T19):
- `b1e63bff` — list-form editors on all three shells.
- `7526e6f2`, `4d613ce0`, `4cfc50ee`, `b9838f00`, `e4c8e2d8` — iOS: the editor is the ring; a pick no longer closes it; a sheet, not a push; a drag no longer also picks; keycaps keep one height.
- `1c34358d`, `22cba0ca`, `fa0d138d` — Android: the editor is the ring; the drag works (the twist surface is a sibling); the ring opens at the stage's centre.
- `cf63f6cf` — console: a shortcut editor. `4f59fd42`, `6621e5ad` — the swap plays as a spring and settles without a flash.
- `4ca58075` — console: the editor is the ring (`Ring`'s editing mode: pick, lift-and-drop swap, pointer drag; a grouped picker; the shortcuts under it) and a real shortcut editor (keycap preview, name, modifier chips, a keyboard-shaped key grid). The six stepped rows and the stepped key form are gone; `ring.rs` compiles for the Android console too.
- `a45ba525` — the ring's catalogue (by group, with per-platform notes), its geometry and the shortcut upsert/remove move into `pf-client-core`, so the three desktop editors below draw one model.
The desktop shells (native, tenet 2; one model, tenet 3):
- `d9787009` GTK — `ui_quick_actions.rs`: a Quick actions row → the ring as a subpage (circular buttons on the ring's geometry over a gradient stage, popover picker with notes and a keyboard "Swap with…", drag-and-drop swap, shortcuts, Reset behind an alert) and a shortcut subpage (live keycap, name, modifier toggles, a keyboard-shaped key grid, "Press the chord…" from the real keyboard). Obeys the dialog's scope model.
- `dda1e1e9` Windows — `app/quick_actions.rs`: a Quick actions section (the ring on a `Canvas` with drag-swap and click-to-pick into a picker card, a button row for keyboard and screen-reader reach, shortcuts with an inline editor and accelerator-driven "Press the chord…"); every edit commits through the page's `commit`, so profiles absorb the whole ring.
- `0c4d503b` console, from the Deck — the stick points at a slot (D12: `menu_nav::ring_sector` → `MenuEvent::Sector`, emitted before the buttons and the move; the D-pad steps once the stick lets go); the editor's stage is the flat card face and sizes itself from the ring's geometry so the label under the ring is no longer clipped.
- `d70e6f64` console, from the Deck's second look — the stream overlay's damage gate froze every self-clocked animation (the sheet opened with no rows until a cursor move): `Ring::animating()` and `MenuList::animating()` keep the key changing per tick until everything has landed. On that loop the ring got its look: a sprung arrival with overshoot, shadowed discs with a rim of light, an eased glowing highlight, a vignette scrim, a sheet that rises, a wind-in on close. The editor no longer walks into the shortcut list when the stick points at 6 o'clock (only a D-pad Down or a second push does). The shortcut editor is the console's own form grammar — `MenuList` rows (Name, Key, Hold with Ctrl/Alt/Shift/Win, Save, Remove) under the keycap preview, the keyboard tray for the name and a keyboard-shaped key tray for the key drawn the tray's way, so the focused key is a solid accent face. The presenter logs SDL's touch devices at connect and the first finger of a session (direct or not).
- `21be74b4` console — the action picker's scrim covers the whole screen (the shell only translates the canvas; the scrim overshoots the content rect, the hint bar draws after and keeps the picker's controls legible); only the spring's excess overshoots the discs' travel (the stagger's headroom had parked the early slots past their seats, clipping the 12 o'clock disc); the editor's stage scales itself down on short or narrow windows so the ring is never clipped.
- `807bb349` console, from the Nothing Phone — the stage's focus halo took a pre-scaled corner (it squares with the display scale: a second, rounder card behind the dial at phone scale); and on a short-wide screen (a landscape phone under the console's density-based scale) the shortcut rows move beside the stage so the ring takes the height instead of shrinking.
- `ac7fa7e2` console — icons: Lucide v0.462.0 (ISC) as embedded 24×24 path data (`icons.rs`, generated by `scripts/gen-lucide-icons.py`), parsed by Skia's SVG-path parser and stroked at Lucide's own weight — no icon font, no new dependency. Every built-in dial slot draws its icon (the muted mic its struck twin, the centre an ellipsis, an empty slot in the editor a faint plus); shortcuts keep their keycap chords and unknown host actions their labels. The hint bar's glyphs joined the same language: chevron directions, the set's return arrow in the remote's badge, one scaled hairline on every container, heavier PlayStation shapes. LUCIDE-LICENSE ships in the crate; THIRD-PARTY-NOTICES.txt regenerated (listing Lucide as vendored source, and catching the manifest up with the branch's lockfile).
Phase 2, the virtual controller:
- `acd61b31` T14 — Android: `VirtualPad.kt`, the Virtual controller slot as a toggle, the settings group; `ExternalPad.axis` now honours the ring's mask.
- `8b45652e` T15 — Apple: `VirtualPad.swift` (geometry, rules, `VirtualPadWire`), `VirtualPadLayer.swift` (UIKit), the session model's toggle and mask, the editor's section.
- `da73d2e9` T16 — docs: `input.md` and `client-settings.md`.
Phase 3, Deck Gaming Mode:
- `3052e560` T17 — `SteamTouchMouse`: in a gamescope session with no finger seen, a mouse delta of 150 px or more is a leaked touch position — dropped, and a one-time notice names the plugin's Punktfunk controller layout. `build-flatpak.sh` gains `BRANCH=`. T18 (evdev) stays deferred.
T11 and T13 are inside the rings.
## Things the build found that the design does not say
- **SDL delivers the two fingers of one frame as separate events.** The desktop engine judges the twist only when the other finger's event is from the same frame (`DIAL_FRAME_MS` 4) or older than a pivot's stillness (`DIAL_PIVOT_MS` 50).
- **Any "first X is final" latch must run after the competing gesture's arm window.** The Android scroll path latched a notch at 4 px of centroid drift before the dial could arm, so a real twist was a scroll (`83cefd3c`).
- **Compose: a `pointerInput` on a parent of the ring eats the discs' drags** (taps survive). The twist surface and the pad layer are siblings under the ring.
- **UIKit hands a tap on a SwiftUI button to a `UIViewRepresentable` underneath it as well.** The editor's backdrop tap closed the ring on every pick (`4d613ce0`).
- A `NavigationLink` pushed from the iOS settings detail column (not a `NavigationStack`) pops the collapsed split view and kills `List(selection:)`; sub-screens there are sheets.
- `pf-console-ui` also builds for the Android GL host — the Skia ring is gated to Linux/Windows; the desktop ring draws only in `--browse` sessions.
- The console settings store is one real file per test process; a store round-trip in a test races the settings tests (`reduce_motion_freezes…` is the known flake).
- `ExternalPad.axis` on Android sent while the ring owned the pad — a stick still held under the ring wrote over the flush. Fixed in `acd61b31`; a capture-link stick had the same leak.
## Corners cut, with the upgrade path
- The console still ships no icon font: its icons are Lucide path data, stroked by Skia (`ac7fa7e2`). Only an unknown host action still draws as a word.
- The stick picks a slot by sector on the desktop only; Android and Apple still step four-way (their routers quantise the stick before the ring sees it).
- The desktop `set_masked` is shared with the gamescope overlay-focus mask (§8 trap 18).
- Reduce Motion on Android has no cheap equivalent.
- D9's per-row scope picker is owed; the console edits the global blob. A swap on the console has no spring, and its shortcut rows carry no disc (`MenuList` draws text).
- The Deck's touchscreen in Gaming Mode is unverified either way (§5.5): the one session on record delivered neither fingers nor T17's leaked warp; the connect-time `touch devices` log line is the next thing to read.
- The virtual controller has fixed positions per preset, fixed dead zones, no stick clicks, no gyro or touchpad emulation (§4.3). It starts hidden each session.
## Verification
- rustfmt; clippy `-D warnings` for `pf-presenter`, `pf-client-core`, `pf-console-ui`, `punktfunk-client-session` in the container; `pf-presenter` 64, `pf-client-core` 242, `pf-console-ui` 227 tests there.
- Android: compile through cargo-ndk, all unit suites, `OverlayActionsTest` 6/6, `VirtualPadTest` 5/5; the debug APK on the Nothing Phone.
- Swift: kit and app targets on iOS, tvOS and macOS; `OverlayActionsTests` 6/6 and `VirtualPadTests` 5/5 on macOS (SwiftPM); the Release build installed and launched on an iPhone 16 Pro Max.
- On glass: the twist opens the ring on both phones; the editor's pick, swap and shortcuts on both. The virtual controller drew and drove the host's UI on the Nothing Phone (its feel is unreported; the iPhone try is owed). The console editors, the in-stream ring's look, the icons and the glyphs were looked at on the Deck and the Nothing Phone through five feedback rounds. Every §9 done-when is still open as a recorded result.
Not in this PR: T18 (evdev touch on the Deck), D9.
enricobuehler
changed title from WIP: feat(client): the quick-action ring on Android, iOS and desktop to WIP: feat(client): the quick-action ring, editors and virtual controller2026-08-30 20:15:07 +00:00
enricobuehler
marked the pull request as ready for review 2026-08-30 20:30:51 +00:00
enricobuehler
changed target branch from touch/phase-0 to main2026-08-30 20:39:00 +00:00
The in-stream ring (design/touch-client-overlay.md §3) is configured
by ONE JSON blob, never by a dozen booleans: every cross-client setting
costs about six hand edits per client, and a missed edit ships a
feature on some clients only.
`overlay_actions` is a tier-P field on Settings and SettingsOverlay
(the whole blob overrides, D10), mirrored in Swift and Kotlin with the
existing profile plumbing. The parser is lenient by contract: six
slots, unknown ids and dangling shortcuts become empty slots, a broken
blob is the platform default. The Rust tests are the contract; the
Swift and Kotlin ports carry the same five tests.
No gesture engine tracked the angle between two fingers; two-finger
handling was a centroid, and the ring needed an opener no game, host
or OS claims (design/touch-client-overlay.md §2.1).
Each engine now keeps the finger-to-finger vector from the second
finger's landing and compares it on every move: 10° arms the dial and
suppresses scroll, 30° commits (the ring stays open after the lift),
a lift short of commit cancels and sends nothing, a scroll notch
already sent means the gesture is a scroll for its lifetime, a pinch
is nothing. Progress and direction reach the shell as Dial events; the
ring views consume them next.
SDL reports the two fingers of one frame as separate events, so the
desktop judges the twist only when the other finger's position is from
the same frame or older than a pivot's stillness — halfway through a
plain scroll step the vector reads as a 60° turn.
A ring shortcut stores key NAMES so one profile fires on every client;
the wire speaks Windows VKs. key_vk (Rust, Kotlin) is the shared
table: modifiers, navigation keys, f1–f24, letters and digits; an
unknown name means the chord does not fire and the editor says so.
A touch user had no in-stream menu: no way to end the stream except an
unconfirmed back gesture, no resolution switch, no host actions, no
shortcuts (design/touch-client-overlay.md §0.1).
The ring opens under the fingers from the two-finger twist, frame by
frame, or at the screen centre from Back — which no longer ends the
session; End stream is a slot behind a two-press arm. Six slots come
from the resolved profile's overlay_actions; the centre opens the
sheet with the full catalogue and the resolution presets over
nativeRequestMode. Toggles leave the ring open, commands close it, a
tap outside or 8 s idle closes it, and it leaves the composition
entirely when closed. Host actions are pre-fetched on the session
tick, never when the ring opens. Touch mode becomes live per session.
One thump at commit was the only feedback. The ring now ticks when the
twist arms, taps on every press, thuds firmly on a dimmed button, and
warns when a destructive slot arms — through the console's
ConsoleHaptics, so it follows the system haptics setting.
A touch user had one ✕ disc that ended the session and nothing else:
no resolution switch, no host actions, no shortcuts, no way to change
the touch model mid-stream (design/touch-client-overlay.md §0.1).
The ring opens under the fingers from the two-finger twist, frame by
frame, or from the corner disc, which now opens it instead of ending
the session; End stream is a slot behind a two-press arm. Six glass
slots come from the resolved profile's overlay_actions; the centre
opens the sheet with the full catalogue and the resolution presets
over requestMode. It leaves the hierarchy entirely when closed
(tenet 1). The dial reaches SwiftUI through a public DialEvent and a
StreamView.onDial hop; the Keyboard slot summons the soft keyboard by
notification; Touch mode gets a session-scoped override. Haptics:
a tick when the twist arms, a thump at commit, a tap per press, a firm
"no" on a dimmed button, a warning when a destructive slot arms.
The desktop session had no in-stream menu: the ⌃⌥⇧ chords were the
only way to end a stream, cycle stats or mute, and a Deck touch user
had none of them (design/touch-client-overlay.md §0.1).
The ring opens under the fingers from the two-finger twist, frame by
frame, or at the window centre from ⌃⌥⇧O; End stream is a slot behind
a two-press arm. Six translucent discs carry short text labels (the
console has no icon font); the centre opens the sheet — a MenuList
with the full catalogue, where Left/Right on the Resolution and
Refresh rows cycle the presets over request_mode. While the ring is
up it owns the glass: pointer and key events go to it, touch fingers
stop feeding the gesture engine, and it takes the damage key so a
settled ring redraws nothing.
The presenter side is one portable contract in pf-client-core::ring
(RingInput / RingCommand / RingFacts), three default methods on the
Overlay trait, per-frame facts on FrameCtx, and SessionOpts carrying
the profile's overlay_actions. Host-action slots are pre-fetched at
connect and ride the console's own command bus. Touch mode becomes
switchable mid-stream (Capture::set_touch_mode) and a shortcut chord
goes out as VKs down then up.
pf-console-ui also builds for the Android GL host, where the ring's
host-action cache is configured out; the Android JNI crate then failed
to link the console. The Compose ring serves Android.
A pad user had no way into the ring: every existing in-stream chord
forwards its buttons (a game sees the X in Select+X), and nothing
in-stream consumed pad input on any client
(design/touch-client-overlay.md §2.6).
`Select+A`, Select first, is the first swallowed chord. All three
routers already hold a lone Select back for the guide hold; an A
inside that window opens the ring and neither press nor release
reaches the host. While the ring is up the pad belongs to it: the
desktop masks the pads and polls the first into menu events, Android
and Apple release held state on the host and adopt the hardware state
silently; on close nothing is replayed. The D-pad and left stick step
the highlight (Right clockwise, Up to 12 o'clock, Down to 6, Y to the
centre), A fires it, B closes, and the sheet becomes a cursor list on
every shell so the same pad reaches the resolution row.
On the Nothing Phone the two-finger twist never opened the ring. Real
fingers never pivot about a fixed point: the centroid drifts a few px
per sample while the pair turns, and the Android scroll path fires a
notch per 4 px of centroid travel. The first notch is final
(scrollEmitted), so the gesture was a scroll before it could turn 10°.
The Rust engine shares the 4 px notch; Swift's is 10 pt against a
16 pt slop, so the iPhone mostly got away with it.
While the pair is undecided — under DIAL_SLOP of travel and under
DIAL_ARM_DEG of turn — no notch goes out, and the scroll anchor follows
the centroid so a real scroll starts smoothly once it crosses the slop.
This is what the design's slop rule meant (§2.1: travel past the slop
before arming means scroll); the engines just ran the notch first.
A new Rust test drives a twist whose centroid drifts ~14 px over five
samples: no Scroll act, the dial arms, and 35° commits. The pan test
that mixed a scroll with a rotation now pans 30 px, past the slop.
On iOS the six slot discs and the centre now each ride their own
spring from the centre to their ring position when the ring opens:
the centre first, each slot 25 ms behind the last, on a bouncier
settle (response 0.4, damping 0.62) than the whole-ring spring had.
Scale and opacity ride the same spring.
Before, one scalar placed every disc and the discs were inserted with
`if q > 0`, so a disc that first appeared at commit had nothing to
animate from: the twist path sprang, the corner disc and `Select+A`
popped in place. The discs now stay in the tree at 0 while the overlay
is mounted, and a `phase` (twist / open / closing) picks each disc's
target and animation through `.animation(_:value:)`. During the twist
they still follow the finger frame by frame with no animation; the
spring takes over at commit from wherever they are.
Closing: `RingState.close()` on an open ring raises `closing` for
140 ms, so the overlay stays mounted while the discs ease back in
(ease-in 120 ms) and then leaves the view hierarchy as before
(tenet 1). A cancel short of commit still leaves at once. Reduce Motion
keeps the plain 120 ms fade with no spring and no stagger.
tvOS is untouched (the file is `#if os(iOS)`); Android and the Skia
ring are unchanged. Gates: `pf-touch-apple-check.sh ios|tvos
PunktfunkClient` both exit 0. Not yet on glass.
The Resolution and Refresh rows showed the mode the sheet last asked
for, on the belief that Android had no live read-back of the
negotiated mode. It has one: `nativeVideoSize` reads
`NativeClient::mode()`, which the control task overwrites with the
host's `Reconfigured` ack (`pump/control_task.rs`). So a rejected
request left the row lying for the rest of the session.
The request still writes the asked-for mode at once (the ack lands off
the composition), then re-reads `nativeVideoSize` 500 ms later so a
rejection shows through. The Kotlin doc on `nativeVideoSize` no longer
calls the value fixed for the session. No new JNI: the
`nativeCurrentMode` the plan proposed would have duplicated it.
Gate: `:kit:compileDebugKotlin :app:compileDebugKotlin` through
cargo-ndk, JNI floor check clean.
The `overlay_actions` blob (design/touch-client-overlay.md §3.2) had
parsers and profile plumbing on every shell but no way to change it.
This is the list form of §3.3: pick what each of the six slots holds,
add or remove custom shortcut chords, and reset to the platform ring.
The ring-as-editor with drag-to-swap and the twist tutorial is still
owed; this is the smallest editor that makes the blob reachable.
iOS: a "Quick actions" row under Touch & pointer, bound through
`scoped(SettingsFields.overlayActions)` like every other profileable
row, with the row's override marker and Reset. The editor
(`QuickActionsEditor.swift`) is six Pickers, a shortcuts list with
swipe-to-delete, an add sheet (label, modifier toggles, key picker,
chord preview) and a Reset to default that drops the override in
profile scope or clears the global. A new shortcut takes the first
empty slot (§3.3).
Android: the same shape as a deep sub-screen (`QuickActionsScreen.kt`,
opened from a Quick actions row in Input with its override badge),
writing through the screen's `update` so the profile diff records the
whole blob. `SettingsGroup` goes internal for it to reuse.
Desktop and the Android console (Skia): six `RingSlot` rows and a
`RingReset` action row in the Input tab. ◀ ▶ step a slot through the
catalogue — empty, the built-ins, the three host power actions, then
the blob's own shortcuts — and write the whole blob back. Shortcut
CREATION is not on the console yet (it needs the on-screen keyboard
flow); a slot can point at a chord the blob already carries. The
console edits the global blob only: it never edits profiles, and D9's
per-row scope picker is new machinery left for later. One test drives
the stepping, the clamp at Empty and the shortcut option.
The slot catalogue is the same list in the same order on all three
shells; host actions are the three advertised power ids.
Gates: `pf-touch-apple-check.sh ios PunktfunkClient` exit 0; Android
`:kit`+`:app` compile through cargo-ndk, JNI floor clean; `cargo fmt`;
`pf-console-ui` tests in the container. Not yet on glass.
Design §3.3, "the editor is the ring": the settings editor now shows the
in-stream `RingOverlay` — the same type, not a copy — full size over a
gradient backdrop, and it answers the real two-finger twist with the
real thresholds. Tap a slot to pick its action from the catalogue by
group (Session, Input, View, Audio, Host, Shortcuts, Empty, each with
its availability note); drag a disc onto another slot to swap the two;
tap the centre to see depth two with inert actions. The shortcuts list
and Reset to default stay under the ring. In a profile that owns its
ring, a line under it says so.
What carries it:
- `DialCatcher` (PunktfunkKit): a UIKit surface running the stream's
`TouchMouse` with its wire unplugged, so only the dial's arm, commit
and cancel come out; a one-finger tap reports separately and closes
the ring (or the sheet) in the editor.
- `RingEditing` on `RingOverlay`: a slot tap picks instead of firing,
an empty slot becomes a pick target, the scrim passes touches through
to the backdrop, the 8 s idle close is off, and a `DragGesture` on
each disc resolves the drop to a sector (released near the centre or
on its own slot it springs home).
- `HostAction` gains a public init so the editor previews the three
power slots as offered.
The six-Picker list form from the previous commit is gone; the ring on
appear plays the same spring it does in-stream.
Gates: `pf-touch-apple-check.sh ios|tvos PunktfunkClient` both exit 0.
On the iPhone for feel; drag-to-swap and the twist over the backdrop
are the two things to try.
On the iPhone, after one or two picks the editor went dead: nothing on
the ring answered a tap. UIKit hands a tap on a disc to the backdrop's
`DialCatcherView` as well as to the SwiftUI button above it, so its
tap recognizer ran `ring.close()` on every pick. The ring wound in
behind the picker sheet, and a closed ring's discs are not hit-testable.
The backdrop tap now only dismisses the preview sheet, and whatever
closes the editor's ring — a twist wound back past the commit angle, a
preview row that ends the stream — the ring springs back open once the
wind-in has played (`onChange(of: ring.closing)`), so the editor is
never left with nothing to tap. The caption no longer promises a
twist-to-open state that cannot last.
The backdrop is a plain grey now, as asked, instead of the gradient.
Gate: `pf-touch-apple-check.sh ios PunktfunkClient` exit 0.
Leaving the editor on the iPhone dropped the user on the category list
and no category could be entered again. The settings detail column is
deliberately not a NavigationStack (an inner one doubles the title bar
on iPad), so a NavigationLink pushed from it rode the collapsed
NavigationSplitView's own stack and, on pop, took the `List(selection:)`
binding with it. AboutView's Shortcuts and Acknowledgements rows had
met the same thing and are sheets for it; Quick actions follows them:
a plain row that presents the editor in its own NavigationStack with a
Done button.
The backdrop is the Form's cell colour (`secondarySystemGroupedBackground`,
resolved dark under the editor's scheme), so it reads as one more field
rather than a bright stage — the grey was too bright.
Trap: the new `@State` first landed inside `#if DEBUG && !os(tvOS)`, so
the SwiftPM (debug) gate passed while the Release xcodebuild failed.
It sits with the unconditional properties now.
Gates: `pf-touch-apple-check.sh ios|tvos PunktfunkClient` exit 0; the
Release xcodebuild for the iPhone succeeded and is on the device.
Four things the quick-actions ring and its editor got wrong on glass:
- Legends. `chord_chip` printed "❖" for Win/Super, "⇧" for Shift, "↵"
for Enter — symbols that read as nothing to most people. It now
prints the word a keyboard prints (Ctrl, Alt, Shift, Win, Esc, Enter,
Backspace, Del, Ins, PgUp, PgDn, PrtSc, Caps), arrows stay arrows,
and `key_legend` exposes the per-key word. Rust, Kotlin and Swift in
lockstep, with the contract tests moved to the new text.
- A shortcut on a disc drew that legend as one line, which ran to the
disc's edge and past it. It is a stacked keycap now: modifiers small
on top (on Apple the compact glyphs its keyboards print, ⌃ ⌥ ⇧ ⌘),
the key large under them, shrinking to fit — `ChordKeycap` on iOS,
the same shape in Compose.
- The three host power actions shared one "power" glyph and were
indistinguishable on the ring. Sleep is a moon, restart a circular
arrow, shut down the power symbol, on iOS and Android.
- The iOS shortcut editor was a bare add sheet. Each shortcut is a row
with its disc, name and legend; tapping it (or Add) opens an editor
with the disc and legend previewed live, a name field, the four
modifiers as chips, and the key on a grouped keyboard you tap
(function, letters, numbers, editing, navigation). Remove lives on
the same sheet; a new shortcut still takes the first empty slot.
Also `every_row_has_exactly_one_tab` learns the seven ring rows the
T19 commit added (47 → 54): that test had not run since — the
container script tests only `pf-presenter` unless `TEST_EXTRA` names
more crates.
Gates: Release xcodebuild on the iPhone; container `pf-client-core`
(237) and `pf-console-ui` (215) tests, fmt, clippy; Android compile +
`OverlayActionsTest` 6/6; Swift `OverlayActionsTests` updated in step
but not run (needs the macOS core rebuilt).
The word keys (Backspace, PrtSc, PgDn) wrapped inside the 44 pt grid
cells, so those caps grew taller than their neighbours. Every cap now
keeps one line at a fixed 30 pt and shrinks its text to fit, and the
groups that hold word keys (editing, navigation, other) lay out on
80 pt columns; letters, numbers and function keys keep the tight 44.
Gate: Release xcodebuild on the iPhone.
In the editor the disc's Button fired on release after a drag-to-swap, so the picker sheet opened on top of the swap. One high-priority DragGesture(minimumDistance: 0) owns the disc while editing: a touch that stays within 8 pt is the pick, one carried onto another slot is the swap. In-stream the gesture mask is .subviews, which leaves the Button exactly as it was.
Gate: Release xcodebuild on the iPhone, installed and launched.
Design §2.5: the Apple TV's entry to the quick-action ring is a short
press of the remote's Back; a long hold still ends the session. The
ring is the same `RingOverlay` the iPhone draws, driven by the pad path
that already existed (`RingState.handleNav`, `Select+A`, the capture's
`ringOpen` gate) — there is no twist, tap or drag on a TV, so those
three paths are `#if os(iOS)` inside the file and the rest compiles
for both.
What carries it:
- `SiriRemotePointer.onShortBack`, fired on a Back released under the
1 s disconnect hold; the model routes it into `onRingChord`, the
hook the pad chord already used, so the view wires one closure for
both. On tvOS that closure toggles: open at the screen centre,
close if it is up.
- `ContentView` mounts the overlay on tvOS as iOS does (only while
open), pre-fetches host actions at session start, and shares
`ringActions`; the twist sink stays iOS-only.
- `TouchInputMode` moves out of `TouchMouse.swift`'s iOS-only block:
the enum is plain data every platform can name, the gesture machine
under it stays iOS. On tvOS the Touch mode slot is dimmed with
"Apple TV has no touch screen".
Not on an Apple TV yet — no device here. Gates: SwiftPM tvOS, iOS and
macOS client targets; a Release xcodebuild of Punktfunk-tvOS for a
generic device (no signing), which is the build that has no DEBUG.
Design §3.3 on Android, matching the iPhone: the settings editor shows
the in-stream `RingOverlay` — the same composable — full size over a
backdrop that runs the real two-finger twist (`streamTouchInput` with
a `0` handle: `send_event` drops every wire call, the dial still arms,
commits and cancels). Tap a slot to pick its action from the catalogue
by group with availability notes; drag a disc onto another to swap;
tap the centre to see depth two with inert actions. Whatever closes the
ring it springs back open, so the editor is never left with nothing to
tap. The backdrop is the theme's `surfaceVariant`, one more field.
`RingEditing` on the Compose `RingOverlay`: a slot tap picks instead of
firing, an empty slot is a pick target, the scrim takes nothing, the
8 s idle close is off, and `detectDragGestures` on each disc resolves
the drop to a sector — it consumes past touch slop, which is what keeps
the tap from also firing. `ChordKeycap` and `KeycapDisc` are shared
with the editor's previews.
Shortcuts match the iPhone's editor: a row per shortcut with its disc,
name and legend; tapping it (or Add) opens a screen with the disc and
legend previewed live, a name field, the four modifiers as chips, and
the key on a grouped keyboard of chips (word keys on wider chips).
Remove lives there; a new shortcut takes the first empty slot.
Gate: `:app:compileDebugKotlin`; the debug APK for the Nothing Phone.
Design §2.3: with reduce_motion on, the Skia ring crossfades in place — the commit snaps to open instead of springing, and the discs sit at their slots from the first frame and only fade; the twist still opens it. The Android and iOS rings already did this (iOS via accessibilityReduceMotion).
Gate: container pf-console-ui tests (215), fmt, clippy.
The console could point a ring slot at a chord the blob already
carried but could not make one. Design §3.3's pad form, on the
existing widgets: a "Quick-action shortcuts" action row in the Input
tab opens `RingShortcutsScreen` — the blob's chords as a list (name
and legend), and an editor for one: a Name field on the on-screen
keyboard tray (Steam's on a Deck), Ctrl / Alt / Shift / Win as On/Off
rows, the Key stepped with ◀ ▶ through everything `key_vk` knows
(wrapping on A), Save, and Remove for an existing one. A new shortcut
takes the first empty slot, as on the phones. B peels back: keyboard,
editor, screen.
Writes rebase on the store's file and save, like the settings screen —
this is one more whole-file writer. The blob logic (`apply_draft`,
`remove_shortcut`) is pure, and the test drives it directly: the store
is one real file shared by every test in the process, and a round trip
through it races the settings tests (the same race behind the known
`reduce_motion_freezes…` flake).
`every_row_has_exactly_one_tab` learns the row (54 → 55).
Gates: container `pf-console-ui` tests (216), fmt, clippy; the Android
kit build through cargo-ndk, which compiles this crate too.
On glass, five things about the quick-actions editor:
- Android: dragging a disc onto another slot did nothing. The twist
surface sat on the PARENT of the ring, and the stream's touch
handler consumes every change it sees, so the discs' drag detectors
only ever saw consumed moves and cancelled; a tap survived because
it has no moves. The surface is a sibling under the ring now, the
way the stream's gesture layer is under the in-stream ring.
- The centre disc was live in the editor on both platforms, opening a
preview sheet of something that is not editable there. It is drawn
dimmed and inert while editing.
- The "Tap a button to change it…" line sat on top of the discs. It
is the field's caption under it now on both platforms, like every
other field's.
- Android: the stage had a fill of its own inside the card; it is
transparent like the other fields.
- Android: the key chips in the shortcut editor laid their label out
from the start of a fixed-width chip; the label is centred.
Gates: Android debug APK on the Nothing Phone; Release xcodebuild on
the iPhone.
Dropping a disc on another slot swapped the two contents in place. Now
the two discs travel to each other's slots on a spring (the dragged one
from wherever it was released); once they land the blob is written and
the drawing order snaps back to identity with the contents swapped, so
that write draws nothing. iOS keeps a per-disc `order` animated with
`.animation(value:)` and resets it in a transaction with animations
off; Compose animates each disc's position with a spring only while a
swap plays and a snap otherwise, so the twist and a drag still follow
the finger.
Two looks on the editor's stage, both platforms: the ring's scrim (18 %
black while open) is off in the editor — it read as a fill — and a
stage narrower than two ring margins centres the ring instead of
pinning it to one side (the clamp assumed a screen). Android's stage
stands outside the settings card, with its caption under it: the
card's inset left it narrower than the ring, and a card around a
control that draws its own discs read as a fill too.
Gates: Android debug APK on the Nothing Phone; Release xcodebuild on
the iPhone.
The swap's reset fired at 450 ms, inside the spring's settle, so the
two discs jumped their last few points as the contents swapped — a
flash. The spring is stiffer (response 0.35 / damping 0.82 on iOS,
StiffnessMedium / 0.82 in Compose) and the reset waits 650 ms, past
the settle, on both.
Android: the ring is in a card again like every other field — an
`OutlinedCard` without the group's 16 dp inset, so the stage keeps the
width the ring needs and stays centred; the caption sits under it.
Gates: Android debug APK on the Nothing Phone; Release xcodebuild on
the iPhone.
The first open used a centre computed at composition, one step behind the size state, so the ring opened at (0, 0) and the clamp pinned it to the margin — up and left of the card by a slot radius. Both effects that open the ring read the live size now. Verified by screenshot on the Nothing Phone.
Design §4 on Android: the ring's Virtual controller slot is a toggle
that draws a controller over the stream and hides it again. While it
is up it holds one wire pad on the router — `openExternal(XBOX360)`,
the slot model the capture links already use — so the host sees one
controller arrive and one leave, on the lowest free index beside any
real pad (§9). Its buttons ride `slotButton`, so the chords (`Select+A`
opens the ring, the exit hold, the mic and stats chords) and the ring's
mask work on it exactly as on a real pad.
Every control is its own pointer-input node above the gesture layer
and below the ring: a finger on a control is taken, a finger between
them never reaches the layer and falls through to the touch mode
beneath, so tap-to-click keeps working beside the pad. The face
buttons are one node of four discs (a thumb rolling from A to B
presses B); the D-pad reads eight ways by angle; a stick is owned by
its first finger and follows it from wherever it landed, with a fixed
dead zone; a trigger reads the finger's position down its pill, so a
slow press is a slow press. A tick per press. Three presets with fixed
positions; a narrow layer (a phone upright) stacks the clusters
instead of spreading them. Select, Guide and Start are on every
preset — a pad that cannot pause is a bug.
Router: `ExternalPad.axis` is dropped while the ring owns the pad,
like a button. `setRingOpen` had zeroed the axes on the wire, and a
stick still held under the ring wrote over that; a capture-link stick
had the same leak.
Editor: a Virtual controller group under the ring — Layout (Full,
Sticks and shoulders, D-pad and face buttons), Opacity and Scale
sliders that write on release — through the same `onChange` the ring
uses; `SettingDropdown` goes internal for it. The picker's note no
longer promises a later release.
Left out on purpose: stick clicks (no control for them), a
drag-to-position editor, per-game layouts, gyro and touchpad
emulation (§4.3). The pad is per session and starts hidden.
Gates: `:kit` + `:app` compile through cargo-ndk; `VirtualPadTest`
5/5 (every preset fits and never overlaps at three sizes, the D-pad
sectors, the stick travel, the trigger pull) and `OverlayActionsTest`
6/6; the debug APK is installed on the Nothing Phone, which was locked
— not yet seen on glass.
Neither had a page. `input.md` gains "The quick-action ring" — the
twist, the per-shell openers, `Select+A` — and under it "Virtual
controller": where it is shown from, that the host sees a real
arrival and removal on the next free pad index, the fall-through
rule, how the sticks, D-pad and triggers read a finger, and where the
three settings live. `client-settings.md` gains the Quick actions and
Virtual controller rows with their availability (the controller is
Android and iPhone/iPad only; Apple TV, a Deck and the desktops say
why not), and stops counting the Input section's rows — it said five
and listed ten.
Design §4 on Apple, matching Android: the ring's Virtual controller
slot toggles a controller over the stream. While it is up the session
model holds one `VirtualPadWire` — the lowest free wire index from
`GamepadManager`'s one allocator (the way `Sc2Capture` claims one),
declared an Xbox 360 pad, its Arrival before any input, held state
flushed and its Remove on close — so the host sees one controller
arrive and one leave beside any real pad (§9). The ring's mask reaches
it through `setRingOpen`: everything held is released on the host
when the ring opens, nothing is sent until it closes, nothing is
replayed after. Toggled from the ring, it starts masked.
The layer is UIKit: SwiftUI has no multi-touch, and UIKit keeps a
touch with the view it began in, which is exactly the ownership a
stick needs. Each control is one `PadControlUIView` placed by SwiftUI
at its preset position; the layer draws nothing between them, so a
finger there never reaches it and falls through to `StreamLayerUIView`
— tap-to-click keeps working beside the pad. The geometry and the
three input rules (`padControls`, `dpadBits`, `stickWire`,
`triggerWire`) live in the kit with the same numbers as the Android
`VirtualPad.kt`, so the two pads feel the same: four face discs in one
view (a thumb rolls from A to B), an eight-way D-pad by angle, a stick
that follows its first finger from wherever it landed, a trigger read
off the finger's position down its pill. A light impact per press.
tvOS keeps the slot dimmed — "Apple TV has no touch screen" — the way
the Touch mode slot is; the layer file is `#if os(iOS)`.
Editor: a Virtual controller section under the ring — Layout picker,
Opacity and Scale sliders that write on release — through the same
`blob` binding the ring uses. The picker's note no longer promises a
later release.
Gates: `pf-touch-apple-check.sh ios|tvos PunktfunkClient` both
`swift exit: 0`; `VirtualPadTests` (the Swift twin of the Android
test) written in step but not run — the bundle holds the 5-slice
iOS/tvOS core and the macOS test host needs it rebuilt. The Release
xcodebuild for the iPhone succeeded, installed and launched on the
device — not yet seen on glass.
Design §5.5: on a Deck in Gaming Mode, Steam Input owns the
touchscreen and replays it as a mouse whose "relative" deltas are
absolute positions — a field capture read 0 finger events against 341
mouse motions of 300 to 450 px. Under the stream's relative-mouse lock
every one of those walked the host cursor further into a corner, and
since SDL saw no fingers, all three touch models were dead together.
`SDL_TOUCH_MOUSE_EVENTS=0` never reached them: they are Steam's, not
SDL's.
`SteamTouchMouse` (touch.rs) recognises that shape — a gamescope
session (`overlay_focus::gamescope_session`, now pub), no direct-touch
finger yet this session, a delta of 150 px or more that no finger drag
produces — and the presenter drops those motions instead of summing
them. The first one raises the session notice once: "Steam Input is
sending the touchscreen as a mouse — pick the Punktfunk controller
layout for touch". A real mouse on a docked Deck keeps working (its
deltas are small), and the first real finger to reach SDL switches
the guard off for the session.
The lever the notice names is the one the Deck docs already teach:
the Steam Input layout the Decky plugin installs, whose whole job is
real touch. §5.5 still marks it unverified on glass — the docs gain a
line on what happens without it, and the notice is the reminder. T18
(reading the touchscreen from evdev) stays deferred.
`build-flatpak.sh` gains `BRANCH=` so a Deck tracking the hosted
`canary` can take a test build in place of it; a second branch beside
it would win the plain `flatpak run` the plugin issues.
Gates: `cargo test -p pf-presenter --lib touch` on the Mac (19, the
new `gaming_mode_drops_leaked_touch_positions_until_a_finger_is_seen`
among them); container build + clippy `-D warnings` for `pf-presenter`
and `pf-client-core` and their tests; fmt. Not yet on a Deck.
Design §3.3 on the Skia console, done the way the phones have it. The
six "Ring, N o'clock" rows stepped with ◀ ▶ and the form that stepped
a key name through sixty entries are gone; one Quick actions row opens
`RingEditorScreen`: the in-stream `Ring` — the same type, in a new
editing mode — full size over a gradient stage. With a pad, the stick
or D-pad walks the slots, A opens the catalogue by group (Session,
Input, View, Audio, Host, Shortcuts, Empty, each with its availability
note and the current pick marked), Y lifts a disc and A drops it on
another to swap, B puts it down or goes back. With a pointer, a click
on a disc picks, a carry onto another disc swaps — `PointerKind::
Release` finally has its gesture. Under the ring the shortcuts sit as
rows with New shortcut and Reset to default (two presses); Down past
6 o'clock walks into them, Up from the top walks back.
`Ring` gains `edit_at` and an `Editing` state: no scrim, no idle
close, the centre dimmed and inert, the three power slots previewed
as a host that offers them would show them (a dimmed "does not offer
it" lied about the slot), a lifted disc raised, a carried disc under
the pointer, and `EditEvent::{Pick, Swap}` out through `take_edit`.
Nothing fires. The one desktop-only thing it read, the host-action
cache, is behind a cfg now, so the ring — and with it the editor —
compiles for the Android console too, which is the pad editor there.
Shortcut discs draw as stacked keycaps (the modifiers small on top,
the key large under them) in-stream and in the editors alike.
`ShortcutEditorScreen` replaces `RingShortcutsScreen`: the disc as
the ring will draw it with its legend, the name on the on-screen
keyboard (Steam's on a Deck), the four modifiers as chips, and the
key on a keyboard-shaped grid — every name `key_vk` knows, six rows
laid out as a keyboard lays them, walked with the stick (Up and Down
keep the column) or clicked — then Save, and Remove for an existing
one. A new shortcut still takes the first empty slot.
Left as it was: the console edits the global blob only (D9's scope
picker is still owed), and a swap on the console has no spring — the
discs change in place.
Gates: container fmt, clippy `-D warnings`, `pf-console-ui` 221 tests
(the ring's two editor tests, the catalogue, the draft round trip,
the grid's coverage and column rule; `every_row_has_exactly_one_tab`
55 → 48); the Android kit build through cargo-ndk, which compiles
this crate for the Android console. Not yet on glass.
Three desktop editors of the ring are about to exist — the Skia
console's, the GTK shell's and the Windows client's — and design
tenet 3 says share the model, not the renderer. So `pf-client-core`
now owns what every editor was restating:
- `overlay_actions::catalogue(cfg, platform)`: the groups, entries and
availability notes an editor lists, in one order, with the desktop's
notes (no virtual controller, no typed text) where the phones have
none.
- `ring::{RING_RADIUS, SLOT_DIAMETER, CENTRE_DIAMETER, slot_angle_deg,
slot_offset}`: the ring's geometry at 100 %, so three drawings of it
cannot drift apart (tenet 8).
- `OverlayConfig::{upsert_shortcut, remove_shortcut}`: a new shortcut
takes the next id and the first empty slot; an existing one changes
in place; a removed one empties its slot — the same on every client.
The console's editors read these instead of their own copies; the
tests moved with the code.
Design §3.3 in the GTK shell, on the toolkit's own widgets so its
focus, drag-and-drop and screen reader carry the editor. A Quick
actions row on the Input page opens a subpage: six round buttons on
the ring's own geometry over a gradient stage, the inert centre, and
the name of the button under the pointer or the focus. A click picks
what the button holds from a popover — the catalogue by group, each
entry's availability note, the current pick marked, and "Swap with…"
for a keyboard; a button dragged onto another swaps the two (GTK4
`DragSource`/`DropTarget`, the slot as a `u32`). Under the ring the
shortcuts as rows with their keycap, Add shortcut, and Reset to
default behind an alert.
The shortcut editor is its own subpage: the disc as the ring will
draw it with its legend, the name as an `EntryRow`, the four modifiers
as toggles, the key on a keyboard-shaped grid of toggles — or "Press
the chord…", which takes the next real key press and fills the
modifiers and the key from it (`gdk::Key` → the chord's names). Save,
and Remove for an existing one.
The blob lives in the dialog's state and is written with the other
rows when the dialog closes, so it obeys the dialog's scope model: in
a profile the row carries the override marker on the first edit,
`commit_profile` writes the whole ring (D10), and the reset puts the
inherited blob back. The row's subtitle is the ring in order.
Gates: container fmt, clippy `-D warnings` and build for
`punktfunk-client-linux`; its tests under Xvfb, with two new ones for
the key names and the summary. Not yet on glass.
Design §3.3 in the Windows client, a Quick actions section of the
settings page drawn with the reactor's own elements. Six discs sit on
the ring's geometry over a gradient stage in a `Canvas`; the stage's
pointer handlers own the carry (a pointer that leaves a disc stops
reporting to it, so the discs cannot), a click on a disc opens its
picker, a disc carried onto another swaps the two. A row of six
buttons under the ring gives a keyboard and a screen reader every
slot the pointer reaches. The picker is a card: the catalogue by
group, the current pick accented, an entry this shell cannot serve
disabled with its note, and "Swap with…" as a menu.
The shortcuts follow: a row each with its keycap, Add, and Reset
behind a menu (the second press). The editor: the disc as the ring
will draw it, the name, the four modifiers as toggles, the key on a
keyboard-shaped grid of toggles — or "Press the chord…", which arms an
accelerator per key and Ctrl/Alt/Shift mix and fills the chord from
the one that fires (the Windows key stays a toggle: the shell never
sees a chord Windows owns). Save, Remove, Cancel.
Every edit commits at once through the page's `commit`, so the
profile scope's absorb records the whole ring (D10) and the page's
override marker shows it. WinUI's brushes here are flat, so the stage
gradient is a 24-bit BMP written once beside the art cache and loaded
as a file URI, with a flat tint if it cannot be written.
Gates: clippy `-D warnings` and the section's unit tests on the .133
Windows box. Not yet on glass.
Three things the Deck showed in the gamepad UI's Quick actions editor.
The stick stepped the highlight one slot per flick, like a list. The
design (§2.6, D12) wants the weapon-wheel idiom: the stick's angle is
the slot, neutral is the centre, the D-pad steps. The stick only ever
reached the ring as a four-way `MenuEvent::Move`, quantised inside
`MenuNav::poll`. `menu_nav::ring_sector` now reads the raw sample: past
the deadzone by magnitude (a diagonal counts) the angle falls into one
of six 60° sectors centred on the slots; an engaged sector holds until
the stick drops under `MENU_RELEASE` or the angle leaves it by 5°, so a
stick resting on a boundary never flickers between two slots. The poll
emits `MenuEvent::Sector(Option<u8>)` on change, BEFORE the same
sample's buttons and `Move`, so the ring has engaged the stick by the
time the move that would have stepped it arrives and an A in the same
sample lands on the slot the stick points at; a sector the snapshot
adopted silently releases silently. While engaged the ring ignores `Move`
(the stick's own repeats included) and the D-pad steps again once the
stick lets go. In-stream, neutral highlights the centre; in the editor
the centre is inert, so neutral leaves the slot where it is. Every
list ignores the new variant through its wildcard arm; Android maps
integers INTO `MenuEvent` and is untouched. Android and Apple still
step four-way — their routers quantise the stick before the ring sees
it — and owe the same event.
The stage under the ring was a purple-to-teal gradient. It read as
decoration on the Deck, so it is the flat card face every other card on
the shell uses; `theme::stage_gradient` is gone.
The label under the ring — the highlighted slot's name, or the editor's
"press A" hint — sat at centre + radius + slot diameter, 328 units into
a 330-unit stage clipped to its rounded rect, so only its top edge
showed. The stage now sizes itself from the ring's own geometry (the top
slot, the bottom slot, `ring::LABEL_H` under it, one pad each side) and
the ring sits one pad below the stage's top, so nothing of the ring is
ever clipped. The caption says the stick points.
Tests: `ring_sector` for the six centres, the magnitude deadzone, the
overlap hysteresis and the release threshold; the poll's ordering and
change-only emission; the ring's stick/D-pad handover in-stream and in
the editor.
Five things the Deck showed in one session, and one it could not.
The sheet opened empty. The stream overlay redraws only when
`Ring::damage` changes, and that key held only discrete state — the
sheet's `MenuList` fans its rows in on a mount entrance, its focus
eases and springs, none of which the key knew about, so the rows sat at
their first frame (unrisen, invisible) until a cursor move forced one
more redraw. The same gate quietly stalled the ring's own arrival short
of 1 once its steps fell under the key's quantisation. `MenuList` now
reports `animating()` from a settled flag it computes at the end of
every render (no entrance, every ease landed, every spring at rest, the
scroll where it wants to be — the eases are landed explicitly, an
approach never arrives on its own), and `Ring::animating()` folds it
in with its own spring, its highlight eases and its wind-in; while any
of that is true the key hashes a frame counter that `tick` advances, so
the overlay draws every frame until it has all landed and then goes
back to costing nothing.
With the loop fixed the ring could be made to look like something. The
arrival is a spring (`springs::RING`, a whisker of overshoot the six
discs carry into their travel and their size, staggered as before), the
discs sit on a soft shadow with a rim of light on their top edge, the
pad's highlight is a glow and a ring at an eased amount per disc so it
travels between them instead of snapping, the scrim is a pool of shade
around the ring thinning to a veil, the label rides in with the ring,
the sheet rises into its seat under a shadow while its rows fan in, and
a closed ring winds back in and fades instead of vanishing — closed for
input at once (`open`), drawn a moment longer (`visible`). Reduce
motion keeps the crossfade it had. The theme owns the four new paints.
The editor handed focus to the shortcut list when the stick pointed at
6 o'clock: the walk fired on any `Move(Down)` at slot 3, and the stick's
own move rides behind its sector in the same sample. It now walks on a
D-pad Down there, or on the stick pushed down a second time once its
first push put the highlight there; never on the move behind a sector
(`stick_engaged`), and the move that follows a sector-walk is swallowed
so the list is not stepped twice. Coming back up, the ring adopts a
still-held stick so its repeats step nothing until it lets go.
The shortcut editor was its own thing — chips, a hand-drawn grid, a
name box — with a focus halo nothing else on the shell uses, which on a
Deck is invisible. It is now the console's form grammar: a `MenuList`
of rows (Name, Key, Hold with Ctrl/Alt/Shift/Win as stepped On/Off
rows, Add/Save, Remove) under the disc as the ring will draw it; the
name opens the keyboard tray as the add-host screen does, the key opens
a key tray of the same shape — the 66 keys laid out like a keyboard,
drawn the keyboard tray's way: flat keys, the focused one filled with
the accent, the chosen one washed with it. Row focus is the list's own
lift and tint; key focus is a solid accent face.
Touch on the Deck: the user's twist did nothing in Gaming Mode, and the
log shows why it cannot be diagnosed yet — no T17 notice, no finger,
nothing. The presenter now logs SDL's touch devices (id, kind, name) at
connect, the first direct finger of a session, and once, a finger from
a non-direct device it ignores. §5.5 stays unverified; the handoff
carries the evidence and the question.
Tests: the ring's damage key changes per tick while animating and not
once settled; the shortcut rows follow the draft; the key tray walks
like a keyboard. The grid, draft and column tests stay.
Two more from the Deck.
The picker's scrim stopped at the content rect, so the heading above it
stayed bright — a band instead of a modal. The shell only translates
the canvas (insets, transitions), it never clips a screen to its rect,
so the scrim now overshoots every edge by more than any heading, hint
bar or inset can be. The hint bar draws after the screen and stays
legible; it already carries the picker's own controls while it is up.
The dial was cut off at the top — and the cause was the overshoot
clamp of the previous commit, not the stage. The stagger normalisation
divides the per-slot lag out, so `q_raw` sits ABOVE 1 for the early
slots whenever `shown` is near 1; the old code clamped travel at 1, the
new clamp at 1.15 parked slots 0–2 up to 15 % past their seats FOR
GOOD, and the 12 o'clock disc rode out of the stage's clip. Only the
spring's excess past 1 overshoots now (`q + (shown − 1).max(0)`), so at
rest every disc sits exactly on its seat, in the stream and in the
editor alike; the centre's pop follows the same rule.
And for every window the stage cannot fit at full size: the editor
computes a fit from the rect's height (keeping room for the caption and
two shortcut rows) and the stage's width, scales the stage by it and
draws the ring at the same reduced scale — so the ring is never
clipped, whatever the screen; on the Deck the fit is 1 and nothing
changes.
Two from the Nothing Phone's gamepad UI, both scale bugs the Deck's
scale of 1 could never show.
The "second fill with a different border radius" behind the dial was
the stage's focus halo. `focus_halo` takes its corner in DESIGN units
and scales it itself, like `panel` — every other caller passes it that
way — but the editor handed it an already-scaled corner, so the halo's
rounding grew with the SQUARE of the display scale: identical to the
stage at scale 1, a visibly rounder ghost card at the phone's ~2.
The dial was tiny because the fit reserved 140 design units for the
shortcut rows below the stage. A phone's console scale is
max(min(w,h)/800, density·0.75) — landscape leaves ~380 units of
content height, and the reservation crushed the ring to about half
size. Now, when the fit-below falls under 0.75 and the screen is wide
enough, the shortcut rows move BESIDE the stage and the ring scales to
the height instead — near full size on a landscape phone. The couch
layout is untouched: its fit-below is over 1, so the rows stay under
the stage exactly as the Deck shows them.
"All text looks boring and ugly" — the dial's discs carried short words
because the console has no icon font. It still has none: the icons are
Lucide v0.462.0 (ISC), each one carried as its 24×24 SVG path data in
`icons.rs` — every shape of the source SVG folded into one path string
by scripts/gen-lucide-icons.py (a leading relative `m` is absolutised,
since concatenation would re-anchor it) — parsed by Skia's own SVG-path
parser and stroked at Lucide's native width 2 with round caps, scaled
to whatever box the caller gives. A designed, consistent set, rendered
exactly as drawn, for a page of string constants: no font ships, no
dependency lands, and every mark takes the console's paints.
On the dial: End stream, Leave, Touch mode, Keyboard, Stats, Mic (and
its struck twin while muted), Virtual controller, Send text, the three
host powers (moon, cycle, power) and the centre's ellipsis. A shortcut
keeps its stacked keycap chord — its content IS text — and an unknown
host action falls back to its label, so nothing the set cannot name
goes blank. An empty slot in the editor shows a faint plus; in-stream
it stays plain glass.
The hint bar's gamepad glyphs joined the same language, because they
were three dialects: solid floating triangles beside badged letters,
two ink alphas, three container treatments, and a keycap hairline that
never scaled. Now the directional marks are the set's chevrons (a
direction is not a button, so they stay badge-free), the remote's back
arrow is the set's return mark inside its badge, every container wears
the same scaled hairline, and the PlayStation shapes grew to match the
letters' weight, round-joined.
Licensing: LUCIDE-LICENSE sits in the crate and the notices generator
lists it as vendored third-party source; THIRD-PARTY-NOTICES.txt is
regenerated, which also catches the file up with this branch's real
lockfile (nine crates left, chacha20 moved — the committed baseline
was stale).
Tests: every icon parses and stays inside its 24-unit box; every
built-in slot maps to an icon while shortcuts and unknown host actions
stay text.
The docs-drift gate flags PUNKTFUNK_* names in code that the docs never
mention, and this branch carries two: `PUNKTFUNK_HOST_CAP2_TOUCH`, the
v28 ABI capability bit — a quoted literal only because cbindgen.toml
renames the C constant, not an env var anyone sets — and
`PUNKTFUNK_STANDBY_SINK_KEEP`, the vdisplay standby-sink neutraliser's
operator opt-out, which is not this branch's feature to document. Both
take the gate's own escape for deliberately internal knobs: a line in
scripts/ci/docs-undocumented-env-baseline.txt. The check runs green
locally with them.
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Phase 1 and Phase 2 of
punktfunk-planning/design/touch-client-overlay.md(§2–§4, §6): the dial, the pad path, the editor, and the virtual controller. Stacked on the Phase 0 PR (touch/phase-0is the base), so this diff is Phases 1–2 only. One commit per work package or fix.What a touch user gets
A two-finger twist on the stream opens a ring of six buttons under the fingers, frame by frame — 10° arms it, 30° commits it, a lift short of commit winds it back in and sends nothing. The centre button opens a sheet with the full catalogue and the resolution presets. Toggles (mic, stats, touch mode, the virtual controller) leave the ring open so the new state is visible; commands close it; a tap outside or 8 s idle closes it; End stream and host power sit behind a two-press arm. Closed, the ring leaves the view hierarchy entirely (tenet 1).
The editor is the ring (§3.3): on iOS and Android the Quick actions setting shows the in-stream ring, full size, over a backdrop that runs the real twist. Tap a slot to pick its action from a grouped catalogue, drag a disc onto another to swap (a spring plays the swap), tap the centre to see depth two, inert. Shortcuts are rows with a stacked-keycap disc; a shortcut is edited on a screen with a tappable keyboard. The console (Skia) is the ring too — on a stage that scales to the window — with the shortcut editor as list rows plus two trays (the keyboard for the name, a keyboard-shaped key tray for the key); it edits the global blob only.
The virtual controller (§4, Android and iPhone/iPad): the ring's Virtual controller slot draws a controller over the stream. The host sees one controller arrive when it appears and one leave when it goes, on the next free pad index beside any real pad. A finger on a control drives the game; a finger anywhere else falls through to the touch mode, so tap-to-click keeps working beside it. Face buttons roll (A to B under one thumb), the D-pad reads eight ways, a stick follows its first finger from wherever it landed, a trigger reads the finger's position down its pill. Three presets (Full / Sticks and shoulders / D-pad and face buttons), Opacity and Scale, under Quick actions.
What a pad user gets (T20)
Select+A, Select first, opens the ring — the first chord the host never sees. While the ring is up the pad belongs to it: the desktop masks the pads and polls the first into menu events; Android and Apple release everything held on the host and adopt the hardware state silently; on close nothing is replayed. D-pad or left stick step the highlight, A fires it, B closes; the sheet is a cursor list on every shell. The virtual controller is masked the same way, and its buttons ride the same chords on Android.Per shell:
Select+ASelect+A; tvOS: a short Back⌃⌥⇧O;Select+Asend_text;paddimmed on tvOSpad,send_textRingEditorScreen);ShortcutEditorScreenas rows + a key trayVirtualPad.ktoverGamepadRouter.openExternalVirtualPadLayer.swift(UIKit) overVirtualPadWirenativeRequestMode, re-read liverequestModeMenuList; Left/Right cycles presets.sensoryFeedback; a light impact per pad pressCommits
Phase 1, the ring:
5f54a175T7 —overlay_actions: one JSON blob, a tier-P profile field in Rust, Kotlin and Swift.4c493de0T7a — the twist in all three engines.83cefd3c— a drifting twist no longer locks itself into a scroll (the notch-hold rule; found on the Nothing Phone).f910f888—key_vk.01db9fcd— legends are words, keycap discs, distinct power glyphs, the iOS shortcut editor.86964078,7e073472T9 — the Compose ring and sheet, the haptic vocabulary.6618d078— the sheet re-reads the live mode.67bdaddaT10 — the SwiftUI ring and sheet.5adb5ad9— the discs spring out one by one.a02c5744— the ring on tvOS, a short Back opens it.4f1b71ed,86c8980bT8 — the Skia ring and sheet.7ce448d0— Reduce motion on the Skia ring.577def85T20 —Select+Aswallowed in all three routers; the pad drives the ring and the sheet.The editor (T19):
b1e63bff— list-form editors on all three shells.7526e6f2,4d613ce0,4cfc50ee,b9838f00,e4c8e2d8— iOS: the editor is the ring; a pick no longer closes it; a sheet, not a push; a drag no longer also picks; keycaps keep one height.1c34358d,22cba0ca,fa0d138d— Android: the editor is the ring; the drag works (the twist surface is a sibling); the ring opens at the stage's centre.cf63f6cf— console: a shortcut editor.4f59fd42,6621e5ad— the swap plays as a spring and settles without a flash.4ca58075— console: the editor is the ring (Ring's editing mode: pick, lift-and-drop swap, pointer drag; a grouped picker; the shortcuts under it) and a real shortcut editor (keycap preview, name, modifier chips, a keyboard-shaped key grid). The six stepped rows and the stepped key form are gone;ring.rscompiles for the Android console too.a45ba525— the ring's catalogue (by group, with per-platform notes), its geometry and the shortcut upsert/remove move intopf-client-core, so the three desktop editors below draw one model.The desktop shells (native, tenet 2; one model, tenet 3):
d9787009GTK —ui_quick_actions.rs: a Quick actions row → the ring as a subpage (circular buttons on the ring's geometry over a gradient stage, popover picker with notes and a keyboard "Swap with…", drag-and-drop swap, shortcuts, Reset behind an alert) and a shortcut subpage (live keycap, name, modifier toggles, a keyboard-shaped key grid, "Press the chord…" from the real keyboard). Obeys the dialog's scope model.dda1e1e9Windows —app/quick_actions.rs: a Quick actions section (the ring on aCanvaswith drag-swap and click-to-pick into a picker card, a button row for keyboard and screen-reader reach, shortcuts with an inline editor and accelerator-driven "Press the chord…"); every edit commits through the page'scommit, so profiles absorb the whole ring.0c4d503bconsole, from the Deck — the stick points at a slot (D12:menu_nav::ring_sector→MenuEvent::Sector, emitted before the buttons and the move; the D-pad steps once the stick lets go); the editor's stage is the flat card face and sizes itself from the ring's geometry so the label under the ring is no longer clipped.d70e6f64console, from the Deck's second look — the stream overlay's damage gate froze every self-clocked animation (the sheet opened with no rows until a cursor move):Ring::animating()andMenuList::animating()keep the key changing per tick until everything has landed. On that loop the ring got its look: a sprung arrival with overshoot, shadowed discs with a rim of light, an eased glowing highlight, a vignette scrim, a sheet that rises, a wind-in on close. The editor no longer walks into the shortcut list when the stick points at 6 o'clock (only a D-pad Down or a second push does). The shortcut editor is the console's own form grammar —MenuListrows (Name, Key, Hold with Ctrl/Alt/Shift/Win, Save, Remove) under the keycap preview, the keyboard tray for the name and a keyboard-shaped key tray for the key drawn the tray's way, so the focused key is a solid accent face. The presenter logs SDL's touch devices at connect and the first finger of a session (direct or not).21be74b4console — the action picker's scrim covers the whole screen (the shell only translates the canvas; the scrim overshoots the content rect, the hint bar draws after and keeps the picker's controls legible); only the spring's excess overshoots the discs' travel (the stagger's headroom had parked the early slots past their seats, clipping the 12 o'clock disc); the editor's stage scales itself down on short or narrow windows so the ring is never clipped.807bb349console, from the Nothing Phone — the stage's focus halo took a pre-scaled corner (it squares with the display scale: a second, rounder card behind the dial at phone scale); and on a short-wide screen (a landscape phone under the console's density-based scale) the shortcut rows move beside the stage so the ring takes the height instead of shrinking.ac7fa7e2console — icons: Lucide v0.462.0 (ISC) as embedded 24×24 path data (icons.rs, generated byscripts/gen-lucide-icons.py), parsed by Skia's SVG-path parser and stroked at Lucide's own weight — no icon font, no new dependency. Every built-in dial slot draws its icon (the muted mic its struck twin, the centre an ellipsis, an empty slot in the editor a faint plus); shortcuts keep their keycap chords and unknown host actions their labels. The hint bar's glyphs joined the same language: chevron directions, the set's return arrow in the remote's badge, one scaled hairline on every container, heavier PlayStation shapes. LUCIDE-LICENSE ships in the crate; THIRD-PARTY-NOTICES.txt regenerated (listing Lucide as vendored source, and catching the manifest up with the branch's lockfile).Phase 2, the virtual controller:
acd61b31T14 — Android:VirtualPad.kt, the Virtual controller slot as a toggle, the settings group;ExternalPad.axisnow honours the ring's mask.8b45652eT15 — Apple:VirtualPad.swift(geometry, rules,VirtualPadWire),VirtualPadLayer.swift(UIKit), the session model's toggle and mask, the editor's section.da73d2e9T16 — docs:input.mdandclient-settings.md.Phase 3, Deck Gaming Mode:
3052e560T17 —SteamTouchMouse: in a gamescope session with no finger seen, a mouse delta of 150 px or more is a leaked touch position — dropped, and a one-time notice names the plugin's Punktfunk controller layout.build-flatpak.shgainsBRANCH=. T18 (evdev) stays deferred.T11 and T13 are inside the rings.
Things the build found that the design does not say
DIAL_FRAME_MS4) or older than a pivot's stillness (DIAL_PIVOT_MS50).83cefd3c).pointerInputon a parent of the ring eats the discs' drags (taps survive). The twist surface and the pad layer are siblings under the ring.UIViewRepresentableunderneath it as well. The editor's backdrop tap closed the ring on every pick (4d613ce0).NavigationLinkpushed from the iOS settings detail column (not aNavigationStack) pops the collapsed split view and killsList(selection:); sub-screens there are sheets.pf-console-uialso builds for the Android GL host — the Skia ring is gated to Linux/Windows; the desktop ring draws only in--browsesessions.reduce_motion_freezes…is the known flake).ExternalPad.axison Android sent while the ring owned the pad — a stick still held under the ring wrote over the flush. Fixed inacd61b31; a capture-link stick had the same leak.Corners cut, with the upgrade path
ac7fa7e2). Only an unknown host action still draws as a word.set_maskedis shared with the gamescope overlay-focus mask (§8 trap 18).MenuListdraws text).touch deviceslog line is the next thing to read.Verification
-D warningsforpf-presenter,pf-client-core,pf-console-ui,punktfunk-client-sessionin the container;pf-presenter64,pf-client-core242,pf-console-ui227 tests there.OverlayActionsTest6/6,VirtualPadTest5/5; the debug APK on the Nothing Phone.OverlayActionsTests6/6 andVirtualPadTests5/5 on macOS (SwiftPM); the Release build installed and launched on an iPhone 16 Pro Max.Not in this PR: T18 (evdev touch on the Deck), D9.
WIP: feat(client): the quick-action ring on Android, iOS and desktopto WIP: feat(client): the quick-action ring, editors and virtual controllerDesign §3.3 on the Skia console, done the way the phones have it. The six "Ring, N o'clock" rows stepped with ◀ ▶ and the form that stepped a key name through sixty entries are gone; one Quick actions row opens `RingEditorScreen`: the in-stream `Ring` — the same type, in a new editing mode — full size over a gradient stage. With a pad, the stick or D-pad walks the slots, A opens the catalogue by group (Session, Input, View, Audio, Host, Shortcuts, Empty, each with its availability note and the current pick marked), Y lifts a disc and A drops it on another to swap, B puts it down or goes back. With a pointer, a click on a disc picks, a carry onto another disc swaps — `PointerKind:: Release` finally has its gesture. Under the ring the shortcuts sit as rows with New shortcut and Reset to default (two presses); Down past 6 o'clock walks into them, Up from the top walks back. `Ring` gains `edit_at` and an `Editing` state: no scrim, no idle close, the centre dimmed and inert, the three power slots previewed as a host that offers them would show them (a dimmed "does not offer it" lied about the slot), a lifted disc raised, a carried disc under the pointer, and `EditEvent::{Pick, Swap}` out through `take_edit`. Nothing fires. The one desktop-only thing it read, the host-action cache, is behind a cfg now, so the ring — and with it the editor — compiles for the Android console too, which is the pad editor there. Shortcut discs draw as stacked keycaps (the modifiers small on top, the key large under them) in-stream and in the editors alike. `ShortcutEditorScreen` replaces `RingShortcutsScreen`: the disc as the ring will draw it with its legend, the name on the on-screen keyboard (Steam's on a Deck), the four modifiers as chips, and the key on a keyboard-shaped grid — every name `key_vk` knows, six rows laid out as a keyboard lays them, walked with the stick (Up and Down keep the column) or clicked — then Save, and Remove for an existing one. A new shortcut still takes the first empty slot. Left as it was: the console edits the global blob only (D9's scope picker is still owed), and a swap on the console has no spring — the discs change in place. Gates: container fmt, clippy `-D warnings`, `pf-console-ui` 221 tests (the ring's two editor tests, the catalogue, the draft round trip, the grid's coverage and column rule; `every_row_has_exactly_one_tab` 55 → 48); the Android kit build through cargo-ndk, which compiles this crate for the Android console. Not yet on glass.Three desktop editors of the ring are about to exist — the Skia console's, the GTK shell's and the Windows client's — and design tenet 3 says share the model, not the renderer. So `pf-client-core` now owns what every editor was restating: - `overlay_actions::catalogue(cfg, platform)`: the groups, entries and availability notes an editor lists, in one order, with the desktop's notes (no virtual controller, no typed text) where the phones have none. - `ring::{RING_RADIUS, SLOT_DIAMETER, CENTRE_DIAMETER, slot_angle_deg, slot_offset}`: the ring's geometry at 100 %, so three drawings of it cannot drift apart (tenet 8). - `OverlayConfig::{upsert_shortcut, remove_shortcut}`: a new shortcut takes the next id and the first empty slot; an existing one changes in place; a removed one empties its slot — the same on every client. The console's editors read these instead of their own copies; the tests moved with the code.