Scale the whole interface with the desktop's scale factor - #250
Conversation
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.
|
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 The 640x480 design floor does not survive a live window resize
Measured, software renderer under Xvfb on this branch: 366 px is narrower than the 368 px main menu panel your own description measures, so the panel cannot fit on screen at all. Worth noting that the project already asserts this exact invariant — Repro: subclass Two ways to fix, and the choice is a design call rather than obvious:
I lean towards the first, but it's your call. Everything else checked out
Minor: For a maintainer, not for meThe 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. |
|
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 That leaves two things before this can go in:
|
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
|
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 I went with this rather than re-clamping The regression test. Same result under the GL renderer. The standalone probe that found this now reports a 640x480 logical surface where it reported 366x274 before.
On the translations: not blocking on native-speaker review for three settings labels. Merging on the strength of the softness call above. |
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.
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%:- 100% +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 ofwindow / uiScale, andnextFrame()/ 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_GetDisplayDPIreports the panel's physical DPI, not the fraction the user chose, so the desktop value comes fromXft.dpiin the X resource database — what GTK, Qt and Xwayland read.libX11isdlopened, 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:
The zoom control:
Sidebar (before left, after right):
Main menu:
Numbers, measured from the captures:
Xft.dpi: 168set produces a 644 px panel too — identical to asking for 175% explicitly.TestsRunner187 tests OK;WinningConditionsHarnessand the four campaign harnesses pass.check_translations.py --strictandtest_translations.pypass; the three new keys are translated in all 33 languages.gfx->getW()/getH()or the screen settings, so replays and lockstep are unaffected.Limits
Xft.dpi. A session without an X display reports nothing and falls back to 100%; the Settings control andGLOB2_UI_SCALEcover it.🤖 Generated with Claude Code