Skip to content

windows: put the palette back when a theme browse is abandoned - #745

Open
deblasis wants to merge 2 commits into
windowsfrom
fix/732-picker-cancel-revert
Open

windows: put the palette back when a theme browse is abandoned#745
deblasis wants to merge 2 commits into
windowsfrom
fix/732-picker-cancel-revert

Conversation

@deblasis

@deblasis deblasis commented Aug 25, 2026

Copy link
Copy Markdown
Owner

Closes #732

The defect

The inline picker applies every theme it names through ConfigService, so a preview repaints every surface and all the chrome, not just the pane the picker is drawn in. Nothing told a browse from a choice, so arrowing past a theme and pressing Escape left it applied for good.

Why reading the confirmed flag would not have fixed it

Both directions are wrong, and both were verified in the Zig:

A cancel is silent, once the selection has moved. Escape and Ctrl+C set should_quit and fall through to a notify that fires only when the index changed, which it has not -- so no final callback arrives. (The exception is cancelling on the very first key, before anything has notified: that one does fire a preview for the theme the list opened on. The guard and the revert absorb it between them.)

And accepting on the first key fires two callbacks -- the confirm, then an echo preview for the theme just confirmed -- so the last callback of that run says "not confirmed". After any arrow there is no echo at all.

So the close decides, not the last callback seen.

Ownership

The first version put the snapshot on the window. The review caught that as a regression this change would have introduced: the palette is the app's, ApplyThemeColors writes one array and fans the change to every window, and two browses can be live at once because each +list-themes routes to whichever window is active and a window only ever closes its own picker. Browse in W1, accept in W2, cancel in W1, and the accepted theme was thrown away for colours from before it.

One slot on App now, with rules about the slot rather than about a run: the first preview to find it empty fills it, a confirm empties it, a close spends it.

The latch is gone, and that is the point rather than a simplification. With two browses sharing a palette a latch belongs to neither, and it reintroduces the same defect from the other side -- accept in one window, browse on in the other, and its cancel is silenced by a flag that has nothing to do with the accept, leaving the browsed theme sitting over the accepted one.

The pipe path had its own copy of the same idea, snapshotting on every client connect and reverting on a disconnect without a confirm, and knew nothing about the picker. The two genuinely overlap -- only a lone +list-themes reaches the inline picker, while any other argument form drives PREVIEW:/CONFIRM: on the same pipe -- so it shares the slot now and records before it applies.

Also from the review

The callback carries the run it belongs to, so one still queued when a second picker opens cannot record against it -- a stale confirm would otherwise latch the new run and reproduce the original defect through the fix.

Two guards were weaker than they read. The sweep for unrecorded applies matched call receivers, so assigning the delegate to a local first walked past it; it reads identifiers now. And the reachability check rejected a wrapping if but not a preceding early return, which is what the escape it was written for actually looks like.

Tests

The decision is a small state machine in Ghostty.Core, for the reason the pipe retry policy and the active-window choice are there: the test project does not reference the shell, so logic left in the window is only ever exercised by a human arrowing through a theme list.

Every rule verified by mutation. Worth recording one: the first version of the clobber test stayed green under the latch mutation, because a single End() clears a latch anyway. It only became a real test when rewritten to browse on after the confirm and assert the cancel returns the accepted colours.

Suite 2304 passing, 2305 total.

Not fixed here

A confirmed theme is still not written to the config, so the choice is lost on restart -- # 743. Nothing on this path reaches the config writer, and wiring it up means deciding how theme interacts with explicit colour keys and with the light/dark split form.

The type is still called InlineThemePreviewSession although it now governs the pipe path too, and making the apply itself the record would close the unrecorded-apply class structurally rather than by a sweep. Both are separate changes.

Size-override: 1013 of 1364 countable lines are tests -- 704 of them the wiring guards and their per-rule docstrings, the rest unit tests for the pure session. The production change is 351 lines across four files. The guards are what caught this change's own ownership regression, so they belong with the fix rather than in a follow-up.

Closes #732

The inline picker applies every theme it names through ConfigService, so
a preview repaints every surface and all the chrome, not just the pane
the picker is drawn in. Nothing told a browse from a choice, so arrowing
past a theme and pressing Escape left it applied for good.

Reading the confirmed flag would not have fixed it, in either direction.
A cancel is silent: Escape and ^C set should_quit and fall through to a
notify that only fires when the selection moved, which it has not, so no
final callback arrives at all. And accepting fires two callbacks -- the
confirm, then a preview for the very theme just confirmed, on the same
key -- so the last callback of an accepted run says "not confirmed". A
run whose outcome were read off it would revert the theme the user chose.

So the flag is latched when it arrives, and the close is what decides.
The snapshot is taken lazily before the first preview and no other: on
every later one the live colours are already a preview's, and restoring
those would put back a theme the user never had. It is a capture callback
rather than a value so the call site cannot take a snapshot that is not
the first, and the palette is copied because the shell's is one array
that theme application overwrites in place.

The revert is not narrowed to the endings that look like a user
cancelling. A surface freed under the picker is not the user rejecting a
theme, but it is not acceptance either, and the colours belong to the app
rather than that surface, so leaving them would be the same defect in
every remaining window. It costs one in-memory apply, and ConfigService
fences it once shutdown starts.

The decision is a small state machine in Ghostty.Core rather than inline
in the window, for the reason the pipe retry policy and the active-window
choice are: the test project does not reference the shell, so logic left
here is only ever exercised by a human arrowing through a theme list.

Two things this does not fix, both pre-existing and reported rather than
absorbed. A confirmed theme is not persisted -- nothing on this path
writes the config file, so a chosen theme is gone on restart. And the
inline and pipe paths can genuinely overlap, because only a lone
+list-themes routes to the picker while any other argument form drives
the pipe, so their snapshots are kept separate rather than shared.
The snapshot was a field on the window, but the palette it saves is the
app's: ApplyThemeColors writes one array and fans the change out to every
window. Two browses can be live at once, because each list-themes routes
to whichever window is active and a window only ever closes its own
picker. So browsing in one window, accepting a theme in the other, then
cancelling the first restored colours from before the accept and threw
the accepted theme away. That was new here -- before, the cancel did
nothing at all.

One slot on App now, and the rules are about the slot rather than about a
run: the first preview to find it empty fills it, a confirm empties it,
and a close spends it. The latch is gone. With two browses sharing a
palette a latch belongs to neither, and keeping it reintroduces the same
defect from the other side: accept in one window, browse on in the other,
and its cancel is silenced by a flag that has nothing to do with the
accept, leaving the browsed theme sitting over the accepted one.

The pipe path had its own copy of the same idea -- snapshot every client
connect, revert on a disconnect without a confirm -- and knew nothing
about the picker. Both paths can run at once, since only a lone
list-themes reaches the picker and any other argument form drives the
pipe. It shares the slot now, recording before it applies.

Also from the pass: the callback carries the run it belongs to, so one
still queued when a second picker opens cannot record against it; the
sweep for unrecorded applies reads identifiers rather than call
receivers, so assigning the delegate to a local first no longer walks
past it; and the reachability check now rejects a guard that returns
before the call as well as one that wraps it, which is what the escape it
was written for actually looked like.

Three comments claimed a cancel never calls back. That holds only once
the selection has moved: cancelling on the very first key does fire a
preview, for the theme the list opened on, which the guard and the revert
absorb between them.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

The inline theme picker ignores the confirmed flag, so a cancelled preview stays applied

1 participant