Skip to content

fix(display): follow screen-parameter changes - #188

Open
shsw228 wants to merge 3 commits into
typester:mainfrom
shsw228:fix/screen-parameter-changes
Open

fix(display): follow screen-parameter changes#188
shsw228 wants to merge 3 commits into
typester:mainfrom
shsw228:fix/screen-parameter-changes

Conversation

@shsw228

@shsw228 shsw228 commented Aug 3, 2026

Copy link
Copy Markdown

Fixes #186.

What

NSApplicationDidChangeScreenParameters was received and then discarded, on the assumption that CGDisplayRegisterReconfigurationCallback covered every geometry change. It does not, so changes reported only through the notification were never noticed: the daemon kept serving the geometry it read at startup, and subscribers were told nothing at all.

The notification is reported on the channel the reconfiguration callback already uses, which turns DisplayReconfigEvent into a two-variant DisplayChangeSignal. Both paths then signal the same CFRunLoopSource, so a real reconfiguration — which fires the notification and the callback together — is delivered as one callback for the pair, and the reconciliation runs once from a single place.

The sender and the run loop source are already held by the display module, so the workspace observer needs neither passed in and the handler needs no side-channel flag to tell it what woke it.

Commits

commit what it does
fix(display): follow screen-parameter changes handles the notification, converging both paths on one channel and one CFRunLoopSource so a burst reconciles once; a single hotplug previously produced eight callbacks and eight full retiles
perf(display): ignore pre-change reconfiguration callbacks drops the kCGDisplayBeginConfigurationFlag pass, where CGDisplayBounds still describes the outgoing configuration
perf(display): skip the retile when the configuration is unchanged reports no change when the configuration read from the OS is the one already held

Option<DisplayChangeResult>

The last commit changes handle_display_change to return Option<DisplayChangeResult> rather than an empty result.

The caller could not otherwise tell "nothing changed" from "no particular display needs a retile": it read an empty displays_to_retile as "no specific target, so do them all" and fell through to a full retile — the exact work the commit exists to avoid. It also emitted display_updated for every display on every reconcile, regardless of the result.

With the distinction in the type, the callback returns early on None, and the retile branch loses its fallback and drives the list it is handed. Every branch that returns Some fills that list in exhaustively, so an empty list means no display needs one.

Behaviour change

No retile runs and no events are emitted for a no-op reconcile. Subscribers that relied on display_updated arriving on every screen-parameter notification, including ones that changed nothing, will see fewer events.

Verification

  • cargo test --workspace 312 passing, and each of the three commits builds on its own; cargo clippy --all-targets reports no new warnings.
  • Exercised on hardware, with a subscriber attached and debug logging on:
    • No-op — Dock autohide toggled twice. It is a screen-parameter change that leaves the top edge alone, so the configuration compares equal: Reconciling displaysDisplay configuration unchanged, nothing to do, no retile, 0 events. Before this change the same toggle produced a full retile and 4 display_updated events carrying identical payloads.
    • Real change — menu bar toggled between auto-hide and always-visible. The external display moves 2560x1080 @ (0,0)2560x1050 @ (0,30) and windows follow, without a daemon restart.
  • The no-op behaviour has no automated coverage: the early return lives in the run loop source callback, which needs a window server to drive. The unit test asserts that handle_display_change reports no change; that the caller then does nothing is only shown by the run above.

Environment

macOS 27.0 (26A5378n), Apple Silicon. Independent from the PR for #185.

shsw228 added 3 commits August 3, 2026 20:16
Showing or hiding the menu bar changes how much of a display is usable, but it
is not a display reconfiguration: CGDisplayRegisterReconfigurationCallback does
not fire. macOS reports it through NSApplicationDidChangeScreenParameters,
which was received and then discarded on the assumption that the display
callback covered everything.

Nothing else re-reads the screen list, and AppKit caches it per process, so the
daemon kept serving the geometry it had at startup. Toggling the setting had no
effect on the layout until the daemon was restarted, and subscribers were told
nothing at all -- verified by discarding the notification again on this branch:
zero display events, and the reported main display stayed at 2560x1080 @ (0,0)
while the OS reserved 30px.

Report the notification on the same channel the reconfiguration callback
already uses, which turns DisplayReconfigEvent into a two-variant
DisplayChangeSignal. Both paths then converge before anything is re-read:
CFRunLoopSource coalesces signals raised before the run loop services it, so a
notification and the callbacks it accompanies collapse into one callback, and
the handler drains the queue and reconciles once.

That matters beyond the notification: CoreGraphics delivers one signal per
affected display and the reconciliation covers the whole configuration, so a
single hotplug produced eight callbacks and eight full retiles, seven of them
against a configuration that had not settled yet. Measured after the change: a
display mode change logs one "Reconciling displays" for the notification plus
two callbacks that previously produced two.

Routing it through the existing channel keeps the observer free of display
plumbing: the sender and the run loop source are already held by the display
module, so the notification needs neither passed in, and the handler needs no
side-channel flag to tell it what woke it.
CoreGraphics invokes the reconfiguration callback twice per affected display:
once before the change with kCGDisplayBeginConfigurationFlag set, and once
after with the flags describing what changed. Both were forwarded.

During the "begin" phase CGDisplayBounds still describes the outgoing
configuration, so that pass laid windows out against metrics that were about to
be replaced. The post-change pass corrects it, so the visible effect is a
transient, but it doubles the reconciliation work per hotplug.

Drop the pre-change notification. Over an unplug and replug all eight
callbacks that reached the handler carried post-change flags (0x1220, 0x1008,
0x111a, 0x133e, 0x1000), none with the begin bit.
macOS emits several screen-parameter notifications around a single change, and
some describe a configuration that is already in state. handle_display_change
retiled unconditionally, so those spurious notifications moved every window on
every display for nothing.

Compare the configuration read from the OS against the one held in state and
report no change when they match. The comparison goes through Rect::from_bounds,
the same conversion sync_all uses to store the frame, so the two cannot drift
apart.

Report it as None rather than an empty DisplayChangeResult. The caller cannot
tell the two apart otherwise: it treated an empty displays_to_retile as "no
particular display, so do them all" and fell through to a full retile, which is
exactly what this is meant to avoid. With that distinction in the type, the
retile branch loses its fallback and drives the list it is handed, which every
branch fills in exhaustively.

The early return also covers event emission. display_updated was emitted for
every display on every reconcile, so a Dock autohide toggle -- a screen
parameter change that leaves the top edge alone -- woke every subscriber with
identical payloads.

This changes observable behaviour: no retile runs and no events are emitted for
a no-op reconcile.

Measured around a display mode change: the trailing notification 800ms later
now takes the early exit instead of retiling.
@shsw228

shsw228 commented Aug 3, 2026

Copy link
Copy Markdown
Author

Heads up on the failing Lint job: it is not caused by anything in this PR.

The single error is a pre-existing lint in code this PR does not touch:

error: this `match` expression can be replaced with `?`
   --> yashiki/src/app/effects.rs:140:17
   = note: `-D clippy::question-mark` implied by `-D warnings`
  • yashiki/src/app/effects.rs is byte-identical to main here (git diff main -- yashiki/src/app/effects.rs is empty), and it is not among the files this PR changes (app.rs, app/channels.rs, core/state/display.rs, core/state/mod.rs, macos/display.rs, macos/workspace.rs).
  • Reproduced on main alone: a detached worktree at 9a22f98 with the same command the CI runs (cargo clippy --all -- -D warnings) fails with that one error.
  • Test passes on this PR.

The trigger looks like the toolchain floating rather than a code change: ci.yml uses dtolnay/rust-toolchain@stable and there is no rust-toolchain.toml, and the last CI run on main was 2026-06-19, when stable was older. The current stable (1.97.0) flags this call site.

The fix is one place:

Effect::ExecCommandTracked { command, path } => {
    let pid = manipulator.exec_command_tracked(&command, &path)?;
    state
        .borrow_mut()
        .tracked_processes
        .push(crate::core::TrackedProcess { pid, _command: command.clone() });
    tracing::info!("Tracked process started: {} (pid={})", command, pid);
}

Happy to send that as a separate PR so this one stays scoped to the display change — just say the word. Pinning the toolchain would also stop stable bumps from turning unrelated PRs red.

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.

Screen-parameter changes are discarded, so display geometry goes stale

1 participant