Window-follows-guest display sizing¶
Prompt¶
Before responding to questions or discussion points in this document, explore the ryll codebase thoroughly. Read relevant source files, understand existing patterns (SPICE protocol handling, channel architecture, async task model, image decompression, egui rendering), and ground your answers in what the code actually does today. Do not speculate about the codebase when you could read it instead. Where a question touches on external concepts (SPICE protocol, QEMU, QXL, TLS/RSA, LZ/GLZ compression), research as needed to give a confident answer. Flag any uncertainty explicitly rather than guessing.
All planning documents should go into docs/plans/.
Consult ARCHITECTURE.md for the system architecture
overview, channel types, and data flow. Consult AGENTS.md
for build commands, project conventions, code organisation,
and a table of protocol reference sources. Key references
include shakenfist/kerbside (Python SPICE proxy with
protocol docs and a reference client),
/srv/src-reference/spice/spice-protocol/ (canonical SPICE
definitions), /srv/src-reference/spice/spice-gtk/
(reference C client), and /srv/src-reference/qemu/qemu/
(server-side SPICE in ui/spice-*).
When we get to detailed planning, I prefer a separate plan
file per detailed phase. These separate files should be named
for the master plan, in the same directory as the master
plan, and simply have -phase-NN-descriptive appended before
the .md file extension. Tracking of these sub-phases should
be done via a table like this in this master plan under the
Execution section.
I prefer one commit per logical change, and at minimum one commit per phase.
Situation¶
Bug report ryll-bugreport-2026-04-30T04-29-47Z.zip (filed
against shakenfist/uncalibrated-sextant) shows the symptom:
the ryll window is sometimes ~20% wider than the guest
surface, sometimes ~20% narrower. The session JSON in the
report shows surface 0 at 1024×768 with report_type:
Display, description "Screen too small?". The bundled
screenshot is the surface buffer (1024×768) — it is
correctly sized, because the screenshot path captures
surface pixels, not the window. So the visible
window-vs-surface mismatch is invisible inside the report
itself; we have to infer it from the description and from
the boot sequence shown in the screenshot.
uncalibrated-sextant boots through several display modes
(640×480x8, 800×600x16, 1024×768x32, then mode locked:
1024×768x32). Each probe is a SPICE SURFACE_CREATE (or an
auto-create from a draw before SURFACE_CREATE, see
ryll/src/app.rs:823-836) at a different size.
The bug is in RyllApp::update at ryll/src/app.rs:1670-1688:
if let Some((w, h)) = self.pending_resize.take() {
if !self.initial_resize_done {
let total_h = h + STATS_BAR_HEIGHT;
ctx.send_viewport_cmd(egui::ViewportCommand::InnerSize(
egui::vec2(w, total_h),
));
// …seed last_sent_resize…
self.initial_resize_done = true;
info!("app: initial window resize to {}x{}", w as u32, h as u32);
} else {
debug!("app: surface resize {}x{} (window already sized)", w, h);
}
}
The window resizes only on the first surface event of the
session. After that, every subsequent SurfaceCreated (and
every auto-create) updates pending_resize but the resize
branch is skipped. The surface still renders at its native
pixel size via egui::Image::new(texture).fit_to_exact_size(size)
at ryll/src/app.rs:2566-2572, so when the guest changes
resolution we end up with a surface drawn at, say, 1024×768
inside a window the user (or the very first probe) sized to
640×480 or 1280×800. That is the mismatch the report
describes.
The fix is to always honour guest surface size by default —
the same way virt-viewer does. The user remains in control
because resizing the window still sends VDAgentMonitorsConfig
to the guest via maybe_send_monitors_resize
(ryll/src/app.rs:1539-1570); if the guest accepts the hint,
its next SURFACE_CREATE confirms the new size and the dedup
in last_sent_resize keeps things stable. If the guest
rejects the hint and stays at its preferred size, the window
should follow the guest — that is the user's question
("perhaps I resized to a resolution the guest doesn't
support so it tries to revert the change?") and is the
reason a one-shot initial resize is the wrong policy.
A hamburger toggle ("Obey guest size hints", default on) is the right escape hatch for users who deliberately want a fixed window size while the guest cycles modes — for example, recording a fixed-size capture, or watching a guest that flaps between resolutions on its own. The toggle is in addition to, not a replacement for, fixing the default behaviour.
Mission and problem statement¶
Make the ryll window track the guest surface size by default, so a guest mode change (during boot, on user-driven resize that the guest re-negotiates, or on guest-initiated mode change) always leaves the window matching the surface. Add a hamburger menu toggle so the operator can opt out when they want a fixed window.
Concretely:
- Remove the
initial_resize_doneone-shot gate inRyllApp::update. Honour everypending_resizewhose target differs from the current viewport interior, not just the first. - Re-seed
last_sent_resizewith the (8-aligned) new surface dimensions on every auto-resize, so the round-trip withmaybe_send_monitors_resizedoes not echo our own resize back to the guest as a freshVDAgentMonitorsConfig. - Skip the auto-resize when the window is maximised or
fullscreen, matching the existing logic in
maybe_send_monitors_resize(which setsbar_height = 0in that case). In maximised/fullscreen we cannot meaningfully change the inner size; the surface should letterbox/scale within the available area instead. Letterboxing is out of scope for this plan — see Future work. - Add an
obey_guest_size: boolfield toRyllApp, defaulttrue. Guard the auto-resize on it. Whenfalse, behave like today after the initial resize: leave the window where it is, and let the surface render at native size inside it (clipped or with empty space). - Surface the toggle in the hamburger menu (
ryll/src/app.rs:1922) as aui.checkbox(&mut self.obey_guest_size, "Obey guest size hints"), placed near the other view toggles. No persistence across sessions in this plan — settings persistence is a separate concern (none exists today; seeryll/src/settings.rs). - Add a
--no-obey-guest-sizeCLI flag inryll/src/config.rsfor users (and CI) who want to start ryll with the toggle already off — useful for fixed-size captures without having to click the menu after every launch. - Document the behaviour in
ARCHITECTURE.md(under the existing Multi-Monitor Support section, or a new sibling "Window sizing" section) and in the README's user-facing options table.
Open questions¶
-
What does "current viewport interior" mean for the skip check? The cleanest test is "do not resize if
(viewport.inner.width, viewport.inner.height - STATS_BAR_HEIGHT)already equals(surface.width, surface.height), modulo 8-pixel alignment". This avoids resizing on every frame when the window already matches. But egui's reported inner size sometimes lags by a frame after we issueViewportCommand::InnerSize; the dedup may need a small tolerance, or to track "last resize we asked for" independently. Recommend: add alast_auto_resize: Option<(u32, u32)>field, dedup against it, and only issue a new resize when the surface differs fromlast_auto_resize(not from the live viewport inner-size). This mirrors howlast_sent_resizealready works on the outgoing side. -
Should the auto-resize fire on every
SurfaceCreated, or only on the primary (channel 0, surface 0)? Todaypending_resizeis set unconditionally for any surface (app.rs:799andapp.rs:835), which means a secondary monitor's surface event could trigger a primary-window resize. The fix should constrain the trigger to the primary surface: only setpending_resizewhen(display_channel_id, surface_id) == (0, 0), or — more simply — when the affected surface key matches the primary key the renderer uses atapp.rs:2553-2559. Recommend: tie the trigger to the primary surface explicitly. -
What about scaling? When
obey_guest_size = false, the surface currently renders at native pixel size and either overflows or leaves empty space. virt-viewer has a "Scale display" mode that keeps aspect ratio while filling the window. That is genuinely a separate feature — call it out as future work and keep it out of this plan. -
HiDPI /
pixels_per_point? egui logical pixels are not necessarily physical pixels.ViewportCommand::InnerSizetakes logical pixels.surface.width × pixels_per_pointis what the user actually sees. We do not need to account for this in the sizing logic itself — we want the window to be exactlysurface.widthlogical pixels wide so that theImage::fit_to_exact_sizemaps 1:1 — but the docs should note that on HiDPI displays the physical window is larger than the surface in pixel count, which is fine because the image is resampled by the GPU.
Execution¶
| Phase | Plan | Status |
|---|---|---|
| 1. Always-fit + dedup | PLAN-display-window-sizing-phase-01-always-fit.md | Complete |
| 2. Hamburger toggle + CLI flag | PLAN-display-window-sizing-phase-02-toggle.md | Complete |
| 3. Tests | PLAN-display-window-sizing-phase-03-tests.md | Complete |
| 4. Docs | PLAN-display-window-sizing-phase-04-docs.md | Complete |
| 5. Resolution-change notifications | PLAN-display-window-sizing-phase-05-notify.md | Complete |
Phase 1 is the bug fix proper. Phase 2 adds the escape
hatch. Phase 3 covers regression tests for the resize
state machine (the round-trip between
maybe_send_monitors_resize and the auto-resize on
SurfaceCreated is fiddly enough to deserve a unit test
that drives both with synthesised events). Phase 4 updates
ARCHITECTURE.md and README.md.
Phase 5 surfaces guest-driven resolution changes through
the existing notifications channel
(ryll/src/notifications.rs). The motivation is operator
visibility: when uncalibrated-sextant cycles through
640×480 → 800×600 → 1024×768 during boot the journey is
informative ("the test actually walked through various
modes"), but it happens too quickly to read off the screen.
A short rate-limit caps the chattiness when the user
drag-resizes the ryll window through many sizes (each drag
that aligns to a fresh 8-pixel bucket can round-trip
through the guest and produce a SURFACE_CREATE). Sketch:
- Trigger: every primary
SurfaceCreated(and primaryImageReadyauto-create) whose(width, height)differs from the previous primary surface. - Source:
NotificationSource::InternalatNotifySeverity::Info. - Message: something like
"Display resolution: WxH". Phase 5 settles the wording. - Rate limiting: at least one of either (a) coalesce
in-flight entries during a short debounce window
(~500ms), so a boot-time storm collapses but distinct
changes separated by user pauses still all surface, or
(b) reuse the existing 30-second dedup window in
notifications.rs:NOTIFICATION_DEDUP_WINDOWif the message string is shaped so identical resolutions fold naturally. The phase plan picks one and explains the tradeoff. Do not notify on user-driven resizes (those originate frommaybe_send_monitors_resize, not from inboundSurfaceCreated) and do not notify on ryll's own auto-fit (it does not produce its ownSurfaceCreated). - The very first
SurfaceCreatedof a session was an open question — is it "initial connection", not a "change"? Resolved by phase 5: we do notify on the first surface, as a cheap "connected at WxH" confirmation that gives operators visibility into the starting mode (especially useful when a guest then walks through several modes during boot). SeePLAN-display-window-sizing-phase-05-notify.md("Open question — resolved") for the rationale.
Agent guidance¶
Execution model¶
All implementation work is done by sub-agents, never in the management session. The management session (this conversation) is reserved for planning, review, and decision-making.
The workflow is:
- Plan at high effort in the management session.
- Spawn a sub-agent for each phase with the brief from the phase plan.
- Review the sub-agent's output — read the actual files, do not trust the summary.
- Fix or retry if the output is wrong.
- Commit once satisfied.
Planning effort¶
The phase plans should be created at medium effort — the surface area is small (one file, one feature) and the mechanics of the fix are well understood. Each phase plan should specify the recommended effort level for the implementing sub-agent.
Step-level guidance¶
Each phase plan should include a table:
| Step | Effort | Model | Isolation | Brief for sub-agent |
|------|--------|-------|-----------|---------------------|
For this plan most steps will be medium effort, sonnet,
no isolation — the change is localised to app.rs,
config.rs, and the README/ARCHITECTURE docs, and the
brief will be detailed enough that opus is overkill.
Management session review checklist¶
After a sub-agent completes:
-
ryll/src/app.rschanges match the plan: theinitial_resize_donegate is gone, the auto-resize is guarded onobey_guest_size,last_sent_resizeis re-seeded on every auto-resize, the maximised/fullscreen short-circuit is in place. -
ryll/src/config.rsexposes--no-obey-guest-sizeand threads it intoRyllApp::new. - The hamburger menu has the new checkbox.
-
cargo test --workspacepasses. -
pre-commit run --all-filespasses. -
ARCHITECTURE.mdandREADME.mddescribe the new behaviour and toggle.
Administration and logistics¶
Success criteria¶
We will know this plan is done when:
- Booting against uncalibrated-sextant ends with the ryll window matching the final guest surface size, no matter which intermediate mode probes the guest cycles through.
- Resizing the ryll window sends
VDAgentMonitorsConfigand the window then re-syncs to whatever resolution the guest actually chose (which may differ from the requested size). - Toggling "Obey guest size hints" off in the hamburger menu freezes the window at its current size; the surface then renders at its native size inside that window, with no further auto-resize.
--no-obey-guest-sizeon the command line starts ryll with the toggle already off.pre-commit run --all-filesandcargo test --workspacepass.ARCHITECTURE.mdandREADME.mdare updated.
Future work¶
- Scaled / letterboxed rendering. When the window does
not match the surface (toggle off, maximised, or
fullscreen), we currently render at native pixel size
with overflow or empty space. A scaled mode that fits
the surface to the window while preserving aspect ratio
would be a natural next step, and is the obvious
expansion of the
display-mode-uibranch beyond this plan. - Settings persistence. Today there is no on-disk
settings layer (
ryll/src/settings.rsonly holds CLI flags). When persistence lands, "Obey guest size hints" should be one of the persisted preferences. - Per-monitor handling. Multi-monitor setups
(
--monitors N) have one surface per monitor. The current plan only auto-resizes the primary window; secondary monitors are out of scope here and deserve their own treatment.
Bugs fixed during this work¶
- uncalibrated-sextant bug report
ryll-bugreport-2026-04-30T04-29-47Z.zip: window smaller/larger than the guest surface after boot mode probes.
Documentation index maintenance¶
When this plan is created:
- Add a row to the Master plans table in
docs/plans/index.md. - Add an entry to
docs/plans/order.yml.
When all phases are complete, update the status column
in index.md to Complete.
Back brief¶
Before executing any step of this plan, please back brief the operator as to your understanding of the plan and how the work you intend to do aligns with that plan.