Watch
1
0
Fork
You've already forked SouveraineOS
0
SouveraineOS/docs/tasks/60-the-multitasking-view.md
Fimeg 71f6ce591e TASK-60: shell half done; the release was the seam
The awkward hand-off was not the dwell gate — it was committing at whatever
shift the thumb left. quickstep travels to the end target first; settleTo
does that now. Dwell restored to match prior art.

Records the geometry being measured rather than assumed, and two findings
that are not this task's: furniture_at() is a stub so synthetic touch cannot
reach the pill, and Origin is two-valued so agent touches carry no audit.
2026-08-07 12:26:25 -04:00

444 lines
24 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# TASK 60 — multitasking is a place, not a transition
**Status: 2026-08-07 (latest) — both halves are built and the strand repro
passes on hardware.** The compositor owns the transition geometry; the shell
drives it through four verbs and names the card rect once. Read "One owner" for
why, then "Shell half: DONE" for what landed.
## One owner — the rebuild, 2026-08-07
### The fault, stated once
**Two writers own one geometry.** The rail scales the real windows through
`pose`; `ZoneOverview` scales its cards through `scale`. Both read
`zonePullProgress`, and a session was spent trying to make the two numbers
agree — the card ran `0.6 → 1.0` while the pose ran `1.0 → 0.6`, so at the
handoff the window was at 0.6 of the glass and the card arrived near full
bleed.
That is `DEVICE-STATE-MACHINE.md` §1's blind actors in the render path, and
TASK-52 names the same rule for exactly this reason: *one attention model, one
clock, effects as strategies over it*. Every symptom is the one fault — the
size pop, the doubled frame (viewtop's 3 px `#9EC7FF` under the card's
`radius: 18`), and the strand.
**The strand is the proof.** Casey, 2026-08-07: open btop, swipe to
multitasking, tap Home, reopen multitasking, tap btop — btop comes back as a
small card in the middle of the screen. Every clear-the-pose path built that
day hangs off the rail's drag edge (`endPull``clearPose`). *Those taps are
`ZoneOverview`'s and never touch the rail*, so nothing ever runs. A transform
that one surface applies and a different surface must remember to undo will
always have a path that forgets. Note also that `pose` is TASK-52 **#10,
gravity wells** — an atmosphere primitive that navigation borrowed.
### What the prior art does — quickstep, on this machine
`~/Downloads/lineage-trebuchet/quickstep/src/com/android/quickstep/`.
- **One float.** `SwipeUpAnimationLogic.mCurrentShift` ∈ [0,1] — its own comment:
0 = preview snapshot completely visible, 1 = *"preview snapshot completely
aligned with the recents view"*. `TaskViewSimulator` transforms the **live
window onto the real card rect**. The card never has to be made to match the
window, because the window is placed on the card. There is no second geometry.
- **The drag length is derived from the destination.**
`getSwipeUpDestinationAndLength(dp, ctx, TEMP_RECT, …)` returns the rect and
the length together, and `mDragLengthFactor = dp.heightPx / mTransitionDragLength`.
Our `missionAt` is 18% of the panel, a number with no relationship to where
the card actually is.
- **End targets are an enum**, not a set of booleans: `HOME, RECENTS, NEW_TASK,
LAST_TASK, ALL_APPS` (`GestureState.java:77`). Every gesture resolves to
exactly one, and **`LAST_TASK` is a first-class destination** — "went back to
the app" is a real endpoint that animates, not an abandon branch.
- **The controller owns the transform and hands it back at the endpoint.**
Nothing hopefully-undoes anything, which is structurally why Android cannot
strand a window and we can.
- **Dwell is first-class.** `isFling = mGestureStarted && !mIsMotionPaused &&
|endVelocity| > threshold`, and in gestural mode `mIsMotionPaused → RECENTS`
(`AbsSwipeUpHandler:1121,1322`). A pause is never a fling.
`MIN_PROGRESS_FOR_OVERVIEW = 0.7`.
Sailfish/Glacier (`~/phone-qml-research/glacier-home`) is the QML-side reference
for the same shape and is worth reading before the shell half.
### The plan
1. **One owner of the geometry.** The compositor owns the transition: one verb
carrying `shift` 0→1, where 1 means the window sits exactly on its card rect.
The destination rect is named once at gesture start, not re-derived per
frame. The shell stops calling `poseActiveZone` for navigation and stops
scaling cards; `ZoneOverview` draws chrome — label, close control,
neighbours — and never a competing transform.
2. **End targets as an enum.** One end-target state replaces
`missionControlOpen` / `overviewOpen` / `zonePullProgress`. Entering and
leaving become the same machinery whether the gesture came from the pill or
from a card tap — which is precisely what the strand repro broke. `LastTask`
is a destination, so the transform is released by *arriving*, not by
remembering to clear.
3. **Detents from the destination rect**, not 18%. Keep the dwell; make
"paused is never a fling" explicit rather than emergent.
4. **One frame.** A window inside the transition is furniture in the overview's
composition, so `focus_border` suppresses itself while a window is owned by
the transition and the card carries the chrome.
### Compositor half: DONE 2026-08-07 (`souveraine-viewtop` `3c85397`)
Gates green on archdev — 239 tests, `cargo fmt`, workspace clippy and
`--features kms` clippy, all `-D warnings`. Not yet on the phone: needs a CI
package and a session restart.
- `crates/compositor/src/transition.rs` — the state. `begin(&[TransitionTarget])`
/ `shift_to(0..=1)` / `commit(EndTarget)` / `cancel()` / `forget(id)`, seven
unit tests. Empty `begin` refused; `forget` on the last leg ends the flight;
`forget` is wired to surface destruction beside `navigation.forget`.
- Wire: `OverviewBegin { to } / OverviewProgress { progress } / OverviewCommit
{ target } / OverviewCancel`, plus `TransitionTarget` and `EndTarget`
(`home | overview | last_zone | zone{zone}`), all four in the verb table.
- Geometry: `Viewtop::carried(window)` returns the lerped origin and scale, and
**`window_origin` and `window_extent` both consult it**, so the render and the
hit test inherit the carry with no call-site changes. Anchor is `(0,0)` on
purpose so `pose_origin`'s pivot term vanishes.
- `focus_border` returns empty while a window is carried — the card owns the
frame.
### Shell half: DONE 2026-08-07 (`souveraine`)
`services/ZoneTransition.qml` is the one owner: the card rect, the four verbs,
the end-target table, and the clock. `ZoneOverview` and the rail both read it
and neither derives a geometry.
**Deleted, not adapted** — `ViewtopControl.poseActiveZone` / `clearPose` /
`_posed`, `zoneCard.scale`, and `GlobalStates.zonePullProgress`. The card's
inner `ColumnLayout` went too: its 8 px margin was a second opinion about where
a window sits inside its zone. Panes are now the compositor's own tiling scaled,
so a split zone draws as what it is.
**One clock, two strategies.** The rail reports travel and interprets nothing.
`ZoneTransition` derives `shift` (what the compositor carries on, clamped) and
`presence` (how present the destination is, rising to the detent then receding
past it, so a home-bound pull never previews somewhere the release will not go).
Two curves is correct; two *places* was the bug, and putting the second one on
the rail was the first thing tried here and reverted. TASK-52's rule — one
clock, effects as strategies over it — is the same rule as "one owner", a level
up, and `pose` is a gravity well borrowed from those primitives, so this is her
felt environment, not merely navigation chrome.
#### What actually made the swipe feel wrong
Not the dwell gate. **The release committed where the thumb left it.** Lift at
60% of the climb and the carry was released at 60%, so the window jumped to its
final state with no travel — two motions with a cut between them. Every attempt
to fix this by tuning fades was treating the symptom.
quickstep does not do that (`AbsSwipeUpHandler.handleNormalGestureEnd`):
```java
float endShift = endTarget.isLauncher ? 1 : 0;
long expectedDuration = Math.abs(Math.round((endShift - currentShift)
* MAX_SWIPE_DURATION * SWIPE_DURATION_MULTIPLIER));
duration = Math.min(MAX_SWIPE_DURATION, expectedDuration);
startShift = currentShift;
```
`ZoneTransition.settleTo()` is that, with the same numbers — 350 ms cap,
multiplier `min(1/0.7, 1/0.3)` from `MIN_PROGRESS_FOR_OVERVIEW`. Every release
travels to its destination and commits on arrival, including the abandoned
half-swipe (`last_zone`, end shift 0), which is the commonest gesture of all and
used to snap.
Correcting a claim made earlier in this session: the dwell gate was **restored**
after reading the source. quickstep gates the same way and the "ever" is a latch,
not a level:
```java
recentsAttachedToAppWindow = mHasMotionEverBeenPaused || mIsLikelyToStartNewTask;
```
Removing it went *against* prior art. A fast unpaused flick showing no
destination on its way past is correct.
#### Measured on hardware (`10.10.30.213`, viewtop `r97.g3c853972`)
- The carry lands where it is told: target `(135,280) 270×520` logical →
`(271,561) 539×1035` physical at scale 2.0. `focus_border` correctly absent.
- Shell-driven: card `(73.9, 12.0) 392.2×784.4`, scale 0.7263; carried window
measured `(148,89)(931,1590)` against predicted `(147.8,82.1)(932.2,1592.7)`.
- **The strand repro passes**, three times across three shell builds.
#### The geometry is measured now, not assumed
`ZoneTransition` took the card's box from `screen.height * 0.78`. The overview
`PanelWindow` respects exclusive zones, so its height changes underneath: read
once as **1040** (something reserving 40 px) and once as **1080**. The assumption
was right today and would have drifted silently the moment the dock reserved.
The surface reports its own rect through `measuredAt()`, and `carryState` prints
`surface` beside `panelWindow` so a card in the wrong place is *read*, not
theorised — the same reason `state` exists at all.
#### Two findings that are not this task's
1. **A synthesized touch cannot reach the pill.** `furniture_at()` returns `None`
unconditionally and deliberately (`wayland.rs:4490`: *"until the engine
publishes a region layout, nothing is furniture and every touch is session
content"*), so an agent's `touch_down` on the rail lands on the client window
behind it. This is the structural argument for "it has to be a verb" below —
the touch path *cannot* drive the pill today. A drawn hand (Casey, 2026-08-07,
art in progress) rides on the verbs and does not wait on this.
2. **`Origin` is two-valued**, so on-device Ani and a remote caller over the
socket are indistinguishable, and an agent touch leaves no trail at all —
`is_evidence()` is false by design. Evidence ("does the machine think a human
is here") and audit ("who did what") are different questions and only the
first is answered. TASK-41's attested producers is where the second lives.
Still unseen on glass: a split zone's card, which is right by construction
(each pane is the compositor's reported rect scaled) but has not been exercised.
### Shell half: the plan as written (kept for the argument)
Rewrite the pill and `ZoneOverview` to drive the four verbs instead of posing
windows and scaling cards independently.
1. `ZoneOverview` computes each zone card's rect in panel coordinates. That rect
— not `0.6`, not an inset — is the `TransitionTarget` for the windows on that
zone.
2. `SystemGestureRail.onPressed` → `overview_begin` with those rects.
`onPositionChanged` → `overview_progress`. Release → `overview_commit` with
the end target chosen from projected travel and dwell, or `overview_cancel`.
3. **Delete** `ViewtopControl.poseActiveZone` / `clearPose` / `_posed` and
`GlobalStates.zonePullProgress`'s pose duties, and drop `zoneCard.scale`
entirely — the compositor is now carrying the real window onto that rect, so
a card that also scales is the second writer all over again. The card becomes
chrome: label, close control, and the neighbours either side.
4. Tapping a card is `overview_commit { target: zone }` — which is what makes the
strand repro pass, because that path now releases like every other.
Today's four shell commits (`57208c8`, `f699f57`, `452acea`, `72171ac`) are the
two-writer design and step 3 deletes most of them. Kept on the phone meanwhile
because the sizes at least agree.
### It has to be a verb, because she gives the tour
Casey, 2026-08-07: there is a point where an agent is asked for a tour or a demo
of the phone, *"and that does mean even these little gesture steps will be
possible."* So the transition is a **verb with a caller**, not a side effect of a
touch handler — `shift` and the end target are both reachable from the verb
table, or a tour is impossible and TASK-30's third capability enumeration grows
a fourth.
The principles that already govern her hand govern this too, and they are built
(`input.rs`):
- `Origin::{Physical, Agent}`, carried from the source, never a caller-set field.
- `AGENT_SLOT_BASE = 1 << 16`, so a synthesized contact cannot land on a slot a
thumb owns.
- `Origin::is_evidence()` — **an agent-driven tour must not be evidence.** A demo
that raises `observed_confidence` or feeds the idle budget is the machine
believing a human is present because she moved a window.
- Step-up stays human-only, and already is: `input.rs:432` routes
`Origin::Physical => Route::LockSurface` and `Origin::Agent =>
Route::Withheld("the lock surface takes fingers, not the agent")`. The
navigation verb must not become a way around that.
Connects to TASK-50 (the same origin machinery, and the inverse mapping `pose`
owes the hit test) and doctrine §13 (the 60 is hers; the credential is the 40).
## 2026-08-07 — the cards were zero pixels wide
Reported as *"it goes to a blur screen but nothing else"*, and every signal read
healthy: `zones 2, windows 1, subscribed true, loader active, item present,
opacity 1, progress 1`. The blur was the backdrop; the cards were there and
**540 px shorter than nothing** — `item.w: 0, h: 842`.
`ZoneOverview` took its width from the `Column` it sits in, and a Column is as
wide as its widest child. In the drawer the search widget supplies that width;
**mission control is the cards alone and has no search**, so the only child left
was the loader — whose width came from the column, whose width came from the
loader. The cycle resolves to zero. Bound to `panelWindow.width` instead, which
is what this surface covers anyway (its mask is `missionBackdrop`, not the
drawer's sheet).
Two things that made it findable, and are worth keeping:
- **`overview missionControl` and `overview state` verbs.** The one surface
reported broken was the only one nothing but a pill swipe could raise, so it
could not be inspected or tested. `state` reports what the cards are drawn
from, and reading `item.w: 0` beside `column.w: 0` is what located this in
one step after a long time spent on wrong theories.
- **A false lead worth recording:** the compositor was refusing every capture
with `"no client captures the screen behind a lock screen"`, and `grim` hung
the same way the cards did. That is *correct* behaviour behind a lock — the
session had been left locked by a test. Capture was never the problem. Check
`locked` in `state` before concluding anything from a capture failure.
**Cards are inset** (26 px sides, 12 top) so neighbouring zones show through —
full-bleed reads as "you are looking at that app" rather than "here are the
places you can go", and leaves nothing on screen saying there ARE neighbours.
### Still open: the swipe in
Casey, 2026-08-07: *"the visual style from swiping up into this mode... we lost
the consistency... the swipe to this swap is awkward."*
The machinery exists and is where to look. `ZoneOverview.progress` follows
`GlobalStates.zonePullProgress` while the rail is pulling and the state flag
takes over on commit — this task's own acceptance is *"no frame where a window
is scaled by one and laid out by the other."* Two concrete suspects:
1. Cards start at `scale 0.6 + 0.4 * share`, chosen to match the scale the
rail's `pose` has reached at the multitasking detent. **0.6 is a
written-down constant**; if the rail's actual pose there is not 0.6, the
card pops to a different size at handoff.
2. The inset above makes the card **smaller than the full-bleed window it
replaces** at the handoff moment, which may have made that seam worse.
Either the rail's pose should end at the inset card's rect, or the inset
should animate in after the handoff rather than being there at `share = 0`.
Needs watching in motion. Screenshots cannot see it.
## Original — design, 2026-08-05
Written after building the wrong thing and being told so. Casey, on device:
> *"Still major issues. We might need to take these concepts we've built back to
> the workshop. The scaling doesn't follow the selected app. meaning app#2
> opened even if focused is not scalling down. Second, all we have is scaling;
> it's supposed to be showing me all sorta backgrounded zones; including split
> zones. You've seen macOS. That whole multitasking view is a function of it's
> own."*
**Repo:** `souveraine` (shell surfaces), `souveraine-viewtop` (capture + zone
facts).
## What was built, and why it is not the thing
The rail's short swipe scales the live windows on the active zone from 1.0 to
0.6 as the thumb climbs (`ViewtopControl.poseActiveZone`), then opens
`GlobalStates.missionControlOpen`. That is a **transition** — the animation of
leaving an app — and it was mistaken for the **destination**.
Two failures follow from that mistake, and both were seen on hardware:
1. **It scales the wrong windows.** `poseActiveZone` poses everything whose
`workspace` matches `ViewtopControl.activeZone`, and that list comes from a
**2-second poll** of `{"op":"workspaces"}`. Open a second app and the poll
has not caught up, so the newly focused window is not in the set being
scaled. There is no "focused window" fact in the shell at all — the
compositor knows it (`Surface::focused`) and does not report it in the
`workspaces` reply.
2. **Scaling one zone is not a view of all of them.** Even done perfectly, it
shows the zone you are already on, smaller. It cannot show what is
*backgrounded*, which is the entire question multitasking answers.
## What exists
| Piece | Where | State |
|---|---|---|
| `WindowOverview.qml` | `modules/souveraine/navigation/` | One column of equal cards, one per **window**, live `ScreencopyView` texture, flick-up to dismiss |
| Zone facts | `{"op":"workspaces"}` | `count`, `active`, `offset` (the float the strip is scrolled to), `settled`, and per window `{id, workspace, at, size}` |
| Per-window capture | viewtop, since 2026-08-03 | A card can show a window rather than the screen covering it |
| `pose` / `unpose` | viewtop | Now genuinely scales content (fixed 2026-08-05 — it had only ever moved it) |
**`WindowOverview.qml`'s own header states the gap**: *"viewtop has them
[workspaces], and they are not Hyprland's: a continuous strip that grows when a
window needs a room and shrinks when the last one leaves… That is a thing this
overview could draw and does not yet."* This task is that sentence.
## What macOS actually does, since it is the named reference
Mission Control is **two levels in one surface**: a row of *spaces* across the
top, and the windows of the current space spread below. Neither alone is the
feature — the row answers "where else am I?" and the spread answers "what is
here?". Dragging a window onto a space in the row moves it there, which is the
same gesture doing placement.
Ours differs in one load-bearing way and it must not be papered over: **macOS
spaces are a fixed set the user creates; viewtop's zones are minted and
destroyed by need** (`workspace.rs`: "a workspace exists because something is on
it"). A row of zones is therefore a row that grows and shrinks under you, and
home is the one that is always there and always empty.
## The questions
### Q1. What is a card — a window, or a zone?
Today it is a window. macOS says both, at two levels. A phone has room for one
level at a time. Options: zones-as-cards with their windows composited inside
(split zones then read correctly, which is Casey's explicit ask); windows-as-
cards grouped by zone; or a two-level surface where the zone row is a strip and
tapping one spreads its windows. **Q's real content:** a split zone has two
windows and must look like one thing you can switch to, not two.
### Q2. Live textures, or posed real windows?
Two mechanisms now exist and they are not the same:
- `ScreencopyView` — a live *picture*. Cheap to lay out anywhere, but it is not
the window; input goes to the card.
- `pose` — the *real* window, scaled, still touchable through `unpose`'s inverse
mapping.
The reference compositor composes client windows as external textures directly
into the shell's scene, which is the third answer and the one the in-process
Flutter shell would make natural (`SHELL-BOUNDARY.md`). Choosing here decides
whether the overview is a gallery of pictures or the actual desktop, zoomed
out — and whether the transition can be continuous into it.
### Q3. Where does the focused window come from?
The shell has no such fact. `Surface::focused` exists in the compositor's
registry and is not in the `workspaces` reply. Any answer to Q1 needs it, and it
should be reported rather than inferred — inferring "the last window I saw
open" is the kind of shadow copy §4 spends its whole argument against.
### Q4. Is the poll acceptable?
`ViewtopControl` polls every 2 s. That is already the proximate cause of failure
1. A view built on it will be wrong every time something changes faster than
two seconds — which is every gesture. Either the compositor pushes zone/window
changes, or this surface asks synchronously when it opens and stops guessing in
between. **Design the push channel; it is owed anyway.**
### Q5. What does the transition become?
If the destination is a real view, the scale-on-drag is either (a) the first
frames of it, continuous into the cards, or (b) noise that should be deleted.
macOS and Phosh both do (a) — Phosh's `home.c` is a drag surface travelling
between two states, not a button. (a) is better and harder: it means the
transition and the view share one progress value, which is the reference
overview's whole trick (`WindowOverview.qml` already cites it: "one `progress`
driving everything").
### Q6. What can you do to a card?
Switch to it, certainly. Close it — the `close`/`kill` verbs exist. Move a
window between zones — `{"op":"workspace","to":N,"surface":id}` exists too, so
drag-a-card-onto-a-zone is reachable today. Decide before building, because it
changes whether cards need to be drag targets.
## Acceptance (measured, on the phone)
- Every zone with something on it is represented, and a **split zone reads as
one destination containing two windows** — not as two unrelated cards.
- The view reflects state at the moment it opened, not up to two seconds stale.
Open an app, immediately open multitasking: the new app is there.
- Tapping a card goes to that zone. Home is reachable from it.
- Whatever drives the transition and whatever draws the view agree — no frame
where a window is scaled by one and laid out by the other. Met by there being
one geometry, not by two that were tuned to match.
- The scale-on-drag either continues into the view or is gone. Not both.
- **The strand repro passes.** btop → multitasking → Home → multitasking → tap
btop, and btop is full size. No path leaves a window transformed, including
the ones that never touch the rail.
- **The gesture is reachable as a verb.** An agent can drive `shift` and pick an
end target well enough to demo multitasking, and the run leaves
`Origin::Agent` throughout: no evidence, no idle-budget credit, no route to
the lock surface.
## Connects to
TASK-14 (the overview pane and its app grid; "a grid of workspace thumbnails is
dead weight on a phone" is the constraint that made cards windows in the first
place), TASK-55 (the sheet, and `ViewtopControl` as the one path to scene
verbs), TASK-43 (the compositor's surface model and per-window capture), TASK-52
(atmosphere — `pose` is a gravity well and this is its first real user),
`VIEWTOP-AND-DENIAL.md` (external textures into the shell's scene; Q2's third
answer), `SHELL-BOUNDARY.md` (the in-process shell that would make it natural).