Skip to content

Scale the whole interface with the desktop's scale factor - #250

Merged
genixpro merged 4 commits into
masterfrom
carol/ui-scale
Sep 12, 2026
Merged

Scale the whole interface with the desktop's scale factor#250
genixpro merged 4 commits into
masterfrom
carol/ui-scale

Conversation

@Giszmo

@Giszmo Giszmo commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Every glob2 screen is laid out in fixed pixels against a roughly 640x480 design. Fonts load at 20/13/10 px (GlobalContainer.cpp:217), the sidebar is 160 px (GameGUIViewport.h), the top stat bar is a literal 16 px (GameGUIDraw.cpp:236), and nothing reads the desktop's scale. Measured on a 5120x2816 display at 175%:

element before desktop text alongside it
stat-bar digits ~9 px cap height ~18 px cap height
zoom control - 100% + 144x22 px, ~7 px cap
sidebar 160 px = 3.1% of screen width
main menu panel 368 px = 7.2% of screen width

Approach

Multiplying the ~950 layout literals across 33 screens would be a huge, risky diff, and the bitmap art would still have to be scaled at draw time. Instead this reuses the logical-resolution path setRes() already has for fullscreen letterboxing: the window keeps the requested size, the interface is laid out on a surface of window / uiScale, and nextFrame() / the GL viewport scale it back up. windowToLogical() already maps input back, so hit-testing is unchanged.

Everything grows by one factor, so the proportions between screens are preserved rather than re-invented.

The factor is taken from GLOB2_UI_SCALE, then the new Settings -> Display -> Interface scale preference, then the desktop. SDL_GetDisplayDPI reports the panel's physical DPI, not the fraction the user chose, so the desktop value comes from Xft.dpi in the X resource database — what GTK, Qt and Xwayland read. libX11 is dlopened, so no new build dependency and non-X11 builds simply report no scale.

Verification

Captured headlessly under Xvfb at 5120x2816, before and after, all crops at the same physical scale.

Top stat bar, the element in the report:

counters

The zoom control:

zoom

Sidebar (before left, after right):

sidebar

Main menu:

menu

Numbers, measured from the captures:

  • Top stat bar 16 -> 28 px, exactly 1.75x.
  • Main menu panel 368 -> 644 px, exactly 368 x 1.75.
  • Auto-detection with Xft.dpi: 168 set produces a 644 px panel too — identical to asking for 175% explicitly.
  • 800x600 window with a requested 200%: the scale is reduced to 1.25 so the logical surface stays at the 640x480 floor instead of an unusable 400x300.
  • TestsRunner 187 tests OK; WinningConditionsHarness and the four campaign harnesses pass.
  • check_translations.py --strict and test_translations.py pass; the three new keys are translated in all 33 languages.
  • No simulation code reads gfx->getW()/getH() or the screen settings, so replays and lockstep are unaffected.

Limits

  • Text is scaled with the frame, so it is softer than a native render. Crisp text needs higher-resolution assets and font loading at scale — deliberately a separate change.
  • Auto-detection covers X11 and Xwayland via Xft.dpi. A session without an X display reports nothing and falls back to 100%; the Settings control and GLOB2_UI_SCALE cover it.
  • The desktop scale is read once at startup and cached; changing it while the game runs needs a restart.
  • Translations for the three new strings are machine-generated and would benefit from native-speaker review.

🤖 Generated with Claude Code

Every screen is laid out in fixed pixels against a roughly 640x480 design:
fonts are loaded at 20/13/10 px, the sidebar is 160 px wide, the top stat bar
is 16 px tall, and nothing reads the desktop's scale. On a 5120x2816 display
at 175% that leaves the in-game counters at a 9 px cap height, about half the
size of the surrounding desktop text, and the main menu in a 368 px column.

Rather than multiply roughly 950 layout literals across 33 screens, reuse the
logical-resolution path setRes() already has for fullscreen letterboxing: the
window keeps the requested size while the interface is laid out on a surface
of window / uiScale, which nextFrame() and the GL viewport scale back up.
Widgets, fonts and the map keep their pixel sizes, so every screen grows by
the same factor and their proportions are preserved. windowToLogical() already
maps input back, so hit-testing needs no change.

The factor comes from GLOB2_UI_SCALE, then the new uiScale preference, then
the desktop. SDL_GetDisplayDPI reports the panel's physical DPI rather than
the fraction the user picked, so read Xft.dpi from the X resource database,
which is what GTK, Qt and Xwayland use; libX11 is dlopened so builds without
X11 just report no scale. A 640x480 floor keeps the scale from shrinking the
layout below what the widgets are written for, including on the context's
first setRes() before setMinRes() has run.

Text is scaled with the frame and so is softer than a native render. Crisper
output needs higher-resolution assets, which is a separate change.

Opus 5 helped authoring this commit.
The Interface scale row overwrote its collapsed label with the scale in use,
so it never showed the "Match the desktop" default and, after a change that
waits for a restart, kept reading the old value. Let the row show the chosen
entry like every other choice.

restartRequired() compared the preference against the scale in use, which the
640x480 floor may have reduced; a small software window then reported a
pending restart that no restart would clear. Compare against the scale the
last setRes() was asked for instead, and fold the check into restartRequired().

Drop the GDK_SCALE, GDK_DPI_SCALE and QT_SCALE_FACTOR guesses: Xft.dpi is what
GNOME, KDE and Xwayland publish, and GLOB2_UI_SCALE remains as the override.

Fable 5.1 helped authoring this commit.
@Giszmo
Giszmo requested a review from genixpro September 12, 2026 00:15
@genixpro

Copy link
Copy Markdown
Contributor

Reviewed this and built it on Linux/x86_64. The approach is the right one — reusing the logical-surface path instead of multiplying ~950 layout literals is clearly the correct trade, and taking the factor from Xft.dpi rather than SDL_GetDisplayDPI is right for the reason you give. One real bug, empirically reproduced.

The 640x480 design floor does not survive a live window resize

setRes() carefully clamps uiScale so the logical surface never drops below the floor. updateWindowSize() divides by the same uiScale without re-applying that clamp, and both SDL_SetWindowMinimumSize calls (GraphicContext.cpp:172 and :571) pass the unscaled minW/minH. So the window can be dragged down to 640x480 while uiScale is still 1.75, and the interface is then laid out on a 366x274 surface.

Measured, software renderer under Xvfb on this branch:

=== no UI scale (control) ===
uiScale in use              : 1.000
logical surface at 1280x960 : 1280x960
logical surface at 640x480  : 640x480
OK

=== GLOB2_UI_SCALE=1.75 ===
uiScale in use              : 1.750
logical surface at 1280x960 : 731x549
logical surface at 640x480  : 366x274
uiScale after resize        : 1.750
BELOW FLOOR

366 px is narrower than the 368 px main menu panel your own description measures, so the panel cannot fit on screen at all. RESIZABLE is the default (Settings.cpp:31), so this is the ordinary windowed path, not an exotic configuration.

Worth noting that the project already asserts this exact invariant — WindowResizeHarness.cpp:233 resizes to 300x200 and requires getW() >= 640 && getH() >= 480 with the message "Minimum size not enforced". That harness passes on this branch only because it constructs its context at 640x480, where setRes's clamp immediately drops the scale to 1.0, so it never exercises a scale above 1. Raising the harness's starting size to something like 1280x960 would turn this into a caught regression rather than a silent one.

Repro: subclass GraphicContext at 1280x960 with RESIZABLE, setMinRes(640,480), then SDL_SetWindowSize(window, 640, 480) and updateWindowSize(), and print getW()/getH(). Happy to attach the ~40-line probe if useful.

Two ways to fix, and the choice is a design call rather than obvious:

  • Scale the window minimumSDL_SetWindowMinimumSize(window, minW * uiScale, minH * uiScale) in both places. At 175% the window simply cannot go below 1120x840, which is honest about what the scale costs.
  • Re-clamp in updateWindowSize() the way setRes does. Matches your stated rule that "a window genuinely smaller than the floor keeps scale 1", but the interface would visibly re-scale mid-drag.

I lean towards the first, but it's your call.

Everything else checked out

  • Settings compatibility is fine. uiScale is a new plain-text key defaulting to 0; an old preferences.txt without it simply keeps the default. Out-of-range values from a hand-edited file are clamped by effectiveUiScale in both directions — I checked 99999 (→4.0) and -500 (→1.0).
  • effectiveUiScale precedence and clamping match what the description claims: env, then preference, then desktop, bounded to [1,4].
  • The dlopen is well-behaved — refcounted against SDL's own libX11, display closed before dlclose, all three symbols null-checked, result cached in a function-local static.
  • The torus/fullscreen letterbox path composesisScalingActive() keys off windowW != sdlsurface->w, which is now also true for a scaled window, so the existing scaled blit and windowToLogical() inverse both apply unchanged.
  • You added a settings test (SettingsScreenTest.cpp) covering the new control including the restart-required semantics, which is more than most UI changes get.

Minor: strstr(database, "Xft.dpi:") is not anchored to a line start, so a resource like SomeApp.Xft.dpi: 240 elsewhere in the database would be picked up instead. Unlikely to bite, but a newline-anchored search would be free.

For a maintainer, not for me

The real question on this PR is the one in your own Limits section: text is scaled with the frame, so it is softer everywhere. That's a global visual-quality trade for HiDPI usability and it needs someone looking at it on a real HiDPI panel, not reading a diff. Same for the 33 machine-generated translations — those want a native speaker per language, or at minimum a decision that machine translations are acceptable here.

@genixpro

Copy link
Copy Markdown
Contributor

Maintainer call on the softness question: I've looked at the before/after renders and the scaled interface reads fine. The text being softer is an acceptable price for a usable interface on a high-DPI display, so that limit in your description is not a blocker.

For the record on what that was judged from: a 1280x960 main-menu capture at 100% and at GLOB2_UI_SCALE=1.75, same binary both times, grabbed from the X framebuffer rather than printScreen() (which writes the logical surface and would have hidden the upscale). That's the conservative case — on the 5120x2816 panel this PR is really for, the size gain is considerably larger.

That leaves two things before this can go in:

  1. The 640x480 floor not surviving a live window resize — the bug in my earlier review. Independent of how the text looks, and still needs fixing.
  2. The 33 machine-generated translations. That's a project policy question rather than a visual one, and I don't think three settings labels warrant blocking on native-speaker review — but I'd like @Giszmo or another maintainer to say so explicitly rather than have it happen by default.

genixpro and others added 2 commits September 12, 2026 00:20
setRes() reduces uiScale so the logical surface never drops below the 640x480
the widgets are authored against. updateWindowSize() divides the window by that
same uiScale without re-applying the clamp, and both SDL_SetWindowMinimumSize
calls passed the unscaled minW/minH -- so a resizable window (the default, see
Settings.cpp) dragged down to SDL's 640x480 minimum laid the interface out on a
366x274 surface. That is narrower than the 368px main menu panel, which then
could not fit on screen at all.

The minimum is a floor on the logical surface, not on the window it is stretched
into, so the window's own minimum has to be the scaled one. Both call sites now
go through applyWindowMinimumSize(), which multiplies by uiScale; setRes()
re-applies it whenever the scale changes. At 175% the window simply cannot go
below 1120x840, and the logical surface sits exactly on 640x480.

Re-clamping uiScale inside updateWindowSize() was the alternative. It was
rejected because the interface would visibly re-scale while the user is still
dragging the window edge.

WindowResizeHarness already asserted this invariant ("Minimum size not
enforced") but could not catch this: its context is built at 640x480, where
setRes() reduces the scale back to 1 and nothing is ever stretched. It now also
drives a 1280x960 context at scale 1.75 and shrinks that, which fails on the
unfixed tree with "Interface scale lets a resize break the layout floor" and
exits 1, under both the software and GL renderers.

Verification: harness passes software and GL; the standalone repro that found
this reports 640x480 where it reported 366x274 before; TestsRunner 187 OK and
every test/ harness passes.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01A5GEG5t9ithd47SFm6PTDZ
@genixpro

Copy link
Copy Markdown
Contributor

Pushed f5f02cf fixing the floor bug, plus a merge of current master (#252, #253 and #262 have landed since this branch was cut).

The fix. The minimum is a floor on the logical surface, not on the window it gets stretched into, so the window's own minimum has to be the scaled one. Both SDL_SetWindowMinimumSize call sites now go through a shared applyWindowMinimumSize() that multiplies by uiScale, and setRes() re-applies it whenever the scale changes. At 175% the window simply cannot be dragged below 1120x840, and the logical surface lands exactly on 640x480.

I went with this rather than re-clamping uiScale inside updateWindowSize() because that second option makes the whole interface visibly re-scale while the user is still dragging the window edge. Say the word if you'd rather have it the other way.

The regression test. WindowResizeHarness already asserted this invariant — "Minimum size not enforced" — but structurally could not catch it: its context is built at 640x480, where setRes() reduces the scale back to 1 and nothing is ever stretched, so it never exercised a scale above 1. It now also drives a 1280x960 context at scale 1.75 and shrinks that. Confirmed it fails on the unfixed tree:

=== WITHOUT FIX (software) ===
FAIL: Interface scale lets a resize break the layout floor
exit=1

=== WITH FIX (software) ===
PASS software: cache, callback guards, reflow, context lifetime, input, minimum size, recreation
exit=0

Same result under the GL renderer. The standalone probe that found this now reports a 640x480 logical surface where it reported 366x274 before.

TestsRunner 187 OK and every test/ harness passes.

On the translations: not blocking on native-speaker review for three settings labels. Merging on the strength of the softness call above.

@genixpro
genixpro merged commit 6d88091 into master Sep 12, 2026
3 checks passed
@genixpro
genixpro deleted the carol/ui-scale branch September 12, 2026 04:39
genixpro pushed a commit that referenced this pull request Sep 12, 2026
The building panel already told the player how many units a selected building
could not hire and why, but not which ones, so the counts were not actionable.
While the local player has a building selected, it now remembers the units
behind each tally by gid and the map view draws a badge -- a mini "no entry"
sign -- over exactly those units.

Every reason wears the same badge rather than a shape per reason: several
reasons can apply to one unit at once with only the first recorded, so distinct
shapes would have been a promise the engine cannot keep. Which reason applies is
spelled out as text beside the badge in the panel. The badge is computed from
the circle equation rather than loaded as a sprite, so it stays exact at the
scaled-up interface sizes (#250), and carries a white border so it survives on a
red team's own units -- the units it is only ever drawn on.

Display only, and deliberately so. The tally is incremented unconditionally and
only the gid is recorded, gated on the local selection, so a client with the
building selected runs the same simulation as one without. The new members are
absent from Building::checkSum, are not serialized, and Building is never
memset, which is what makes std::vector members safe here. No version bump.

The panel rows and the map badges ask one shared gate, shouldShowFailingUnitMarkers,
so a unit can never wear a badge the panel has no row to explain, and a building
that has stopped asking for units shows neither.

Verification: failing-unit-markers harness (new, in CI under xvfb) covers
recording on and off, the rendered markers, and the deselect path;
BuildingFailureDisplayTest covers the gate. Reviewed on screen at real map size
over grass, sand, water and a red team unit.

Known follow-ups, all display-only: the most common reason ("too far from
building") frequently marks units outside the current view -- in one of six real
obstructions sampled from a 13000-tick four-AI game, selecting the building
changed zero pixels; drawUnit does a linear search of the gid lists per unit per
frame; recycled gids can briefly badge the wrong unit; and because tallies are
saved while gid lists are not, the panel can show a row with no badges until the
first hiring pass after a load.
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.

2 participants