Skip to content

macOS: pixel-exact live-resize support#49

Draft
vejeta wants to merge 22 commits into
sysprog21:mainfrom
vejeta:fix/hidpi-pixel-exact-resize
Draft

macOS: pixel-exact live-resize support#49
vejeta wants to merge 22 commits into
sysprog21:mainfrom
vejeta:fix/hidpi-pixel-exact-resize

Conversation

@vejeta

@vejeta vejeta commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator

What

Pixel-exact live-resize support for macOS (HiDPI/Retina), the fix for the
artifacts it exposed, and a fix for an XMMS close-time crash the differential
tests were flaking on. Rebased onto main (now including #50). Verified
end-to-end with the xwpe harness.

  • Live-resize core (src/mac-live-resize.c/.h): a CFRunLoopObserver
    drives a reflow callback on each tick of Cocoa's modal resize loop, so the
    client keeps repainting while its own event loop is blocked in the drag.
    Opt-in via libx11CompatRegisterLiveResizeReflow().
  • Present path (src/drawing.c, src/events.c): present during the modal
    loop, coalescing the client's repaint burst so only the final frame reaches
    the screen; re-entrancy guards prevent a nested resize loop. The observer
    skips its per-tick full-window readback when a tick produced no new content
    (no reflow, window not moving), so residual ticks after mouse-up drain
    instantly instead of queueing ~40ms redundant presents.
  • Resize event routing (src/events.c, src/window.*): SDL
    RESIZED/SIZE_CHANGED -> ConfigureNotify for host-driven drags, while dropping
    the SDL echo of a client XResizeWindow (suppressSdlResizeEcho).
  • Resize-increment snap (src/display.c, src/events.c/.h, src/window.*):
    replay the ICCCM size = base + i * inc quantization a real WM performs. A
    client that publishes WM_NORMAL_HINTS PResizeInc (e.g. xwpe, whose grid is
    whole font cells) has its increments cached at XSetWMSizeHints; at drag end
    snapTopLevelToResizeIncrements rounds the window DOWN to a whole increment so
    no sub-cell remainder band survives. Gated on PResizeInc, so clients that
    never set it are unaffected. The pure axis math is factored into
    libx11CompatSnapAxisToIncrement for driver-independent unit testing.
  • HiDPI physical-pixel promotion (src/display.*, src/font.c,
    src/window.*, src/pointer.c): promote top-levels to physical pixels,
    scale font metrics, translate pointer coords. No-op at scale 1. The initial
    promotion prefers SDL_GetWindowSizeInPixels so the first mapped dialog
    adopts the correct cell grid instead of a logical (1x) size.
  • XCopyArea clamp (src/drawing.c): clampCopyAreaSrcRect() keeps a 1:1
    blit inside the source texture bounds. Root cause of the stale function-key
    bar after resize (an Expose carrying the full sub-cell window size vs a
    back buffer snapped to a whole-cell grid).
  • Mixed-DPI monitor move (src/display.*, src/events.c; xwpe WeXterm.c):
    when a window is dragged to a display with a different backing scale, the shim
    re-derives the per-window HiDPI scale and rescales the cached PResizeInc
    increments (which live in physical-pixel space) by the new/old scale ratio, so
    the drag-end snap quantises to the new monitor's cell. Handled on both the SDL2
    (SDL_WINDOWEVENT_DISPLAY_CHANGED) and SDL3 backends; xwpe reloads its Xft font
    at the new size and re-advertises WM_NORMAL_HINTS so the cell grid stays
    correct.
  • Xft glyph compositing on the GPU (src/xft.c): drawUtf8String used to
    alpha-blend each glyph on the CPU (XGetImage -> blend -> XPutImage), which
    re-dirtied the pixmap readback cache and forced a full GPU->CPU readback per
    character (~78% of main-thread time while typing). It now uploads the blended
    glyph to a BLEND texture, applies the colour alpha via SDL_SetTextureAlphaMod,
    and SDL_RenderCopys onto the drawable, mirroring the core-font path -- the
    per-character readback is gone from the profile.
  • XMMS teardown fix (compat/xmms-patches/0003-fix-teardown-volume-race.patch):
    cleanup_plugins() frees ip_data/op_data but the plugin-cleanup loops
    pump the GLib main loop, which dispatches the still-armed volume idle;
    read_volume then reads those freed structs and XMMS dies with its SIGSEGV
    handler (exit 1) during close. Null the globals as they are freed and let the
    volume reads short-circuit on NULL, so a late tick is a clean no-op. Closed a
    flaky xmms-window.replay assert-exit 0 failure: the crash reproduced 6/80
    under real SDL2 without the patch, 0/200 with it.
  • Tests / harness: reflow-hook and resize-increment (snap_axis_to_increment)
    unit tests, a UI replay that resizes xwpe and asserts real content in the
    grown area, and the exported libx11Compat* shim ABI manifest
    (tests/shim-symbols.txt).

Upstream integration

The genuine xwpe fixes now live upstream, so this PR bumps XWPE_REVISION to
the xwpe commit that carries them and drops all compat/xwpe-patches/ (the
directory is gone; XWPE_PATCHES globs an empty set).

As clarified before, now:

  1. Bump XWPE_REVISION and drop the harness patches once the fixes land
    upstream -> done.
  2. Shim-only changes (e.g. the live-resize reflow hook) preserved in xwpe
    upstream rather than kept as harness patches here -> done via the bumped
    revision.
  3. compat/xwpe-patches/ layout -> reworked away entirely; nothing left to
    restructure.

cubic-dev-ai[bot]

This comment was marked as resolved.

vejeta added a commit to vejeta/libx11-compat that referenced this pull request Jul 10, 2026
Resolve the 16 issues raised in the cubic-dev-ai review of the
pixel-exact live-resize support.

font: remove the MAX_FONT_SIZE cap on the HiDPI-scaled physical size so
large fonts render at full size on Retina; scale the 6x13 bitmap fallback
by the nearest-integer HiDPI factor so it matches the scaled core metrics.

window-internal: limit the resize band clear/expose optimization to the
top-left-anchored gravities it models (NorthWest/Static); other bit
gravities fall back to a full-window clear/expose.

events/pointer: share scaleSdlPointToPixels via events.h so XQueryPointer
and the event delivery path agree on rounding and scale selection.

tests: enforce the exported libx11Compat* shim ABI in symbol coverage via
tests/shim-symbols.txt (no-op where the shim is not built).

harness patches:
- 007: only rebind xftdraw when it was actually bound (renderer-neutral).
- 008: resolve the live-resize reflow hook with dlsym(RTLD_DEFAULT, ...)
  instead of a weak undefined symbol, portable across GNU ld and Mach-O.
- 012: extract the duplicated Expose clamp arithmetic into a shared
  e_x_copy_backbuf_clamped() helper.
@vejeta

vejeta commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator Author

Thanks for the review. All 16 points are addressed in 7a73e5a.

  • font (HiDPI): dropped the MAX_FONT_SIZE cap on the scaled physical size; the 6x13 bitmap fallback now scales by the nearest-integer HiDPI factor so it matches the scaled core metrics.
  • window-internal (bit gravity): the resize band clear/expose optimization is limited to the top-left-anchored gravities it models (NorthWest/Static); any other gravity falls back to a full-window clear/expose.
  • events/pointer (dedup): scaleSdlPointToPixels is now shared via events.h, so XQueryPointer and the event path agree on rounding and scale.
  • symbol coverage: make symbol-coverage now enforces the exported libx11Compat* shim ABI via tests/shim-symbols.txt (no-op where the shim isn't built).
  • harness 007: only rebinds xftdraw when it was actually bound (renderer-neutral).
  • harness 008: resolves the reflow hook with dlsym(RTLD_DEFAULT, ...) instead of a weak undefined symbol — portable across GNU ld and Mach-O.
  • harness 012: extracted the duplicated Expose clamp arithmetic into a shared e_x_copy_backbuf_clamped() helper.

make check-unit and make symbol-coverage are green; the patch series still applies at --fuzz=0 with zero rejects.

cubic-dev-ai[bot]

This comment was marked as resolved.

@vejeta vejeta marked this pull request as draft July 10, 2026 13:01
jserv

This comment was marked as resolved.

@jserv

jserv commented Jul 11, 2026

Copy link
Copy Markdown
Contributor
  1. Once the genuine xwpe fixes land upstream, should we bump XWPE_REVISION and drop the corresponding harness patches (007-010, 012)?

Yes, please go ahead.

  1. Should shim-only changes (e.g. the live-resize reflow hook) live behind a configure flag in xwpe upstream, or stay as harness patches here?

Preserving changes in xwpe would be better.

  1. Is the current compat/xwpe-patches/ organization the preferred long-term layout, or would you rather it be restructured?

The current patches are admittedly a bit hacky, as I am still new to XWPE. Please feel free to rework them as needed.

vejeta added 2 commits July 11, 2026 07:46
Rebased onto main and squashed from fix/hidpi-pixel-exact-resize.

- HiDPI: size the initial cell grid from the mapped window's pixel size
  (SDL_GetWindowSizeInPixels) and promote the window texture to pixels, so the
  first xwpe dialog matches the physical Retina backing instead of the logical
  1x size.
- Live resize: a CFRunLoop observer (src/mac-live-resize.c) drives a reflow
  hook each tick of a macOS modal resize drag, so the client repaints real
  content into the grown area instead of a stretched guess.
- XCopyArea stays pixel-exact: clamp a Pixmap source rect to the source texture
  (clampCopyAreaSrcRect) so a copy that runs past the pixmap leaves the trailing
  row/column untouched rather than scaling or painting the zero-padded readback.
- Adopt the upstream xwpe that now carries the shim-integration changes (bump
  XWPE_REVISION) and drop the harness patches (005-012) now integrated in xwpe.
- Headless-present robustness, clang-format-20, and a resize UI replay/assertion.

Conflicts with main (which consolidated the differential tests) were resolved by
keeping main's clipCopyAreaRects structure and re-applying the pixel-exact clamp.
Add compat/xmms-patches/0003-fix-teardown-volume-race.patch. XMMS's
cleanup_plugins() frees ip_data and op_data, but the plugin-cleanup loops pump
the GLib main loop, which dispatches the still-armed volume idle. That tick
runs read_volume -> input_get_volume -> get_input_playing / output_get_volume,
which read ip_data->playing and op_data->current_output_plugin after the structs
are freed, so a late tick dereferences freed memory and XMMS dies with its
"You've probably found a bug in XMMS" SIGSEGV handler (exit 1) during close.

Null the globals as they are freed and let the reads short-circuit on NULL, so
a volume tick that fires mid-teardown becomes a clean no-op instead of a
freed-pointer dereference.

This closed a flaky failure of tests/ui/replays/xmms-window.replay's
assert-exit 0: with the patch out, the close-time crash reproduced 6/80 under
real SDL2; with it in, 0/200.
@vejeta vejeta force-pushed the fix/hidpi-pixel-exact-resize branch from 9272d4f to 93aeaac Compare July 11, 2026 07:01
@vejeta vejeta marked this pull request as ready for review July 11, 2026 07:08
@vejeta

vejeta commented Jul 11, 2026

Copy link
Copy Markdown
Collaborator Author

Rebase latest main branch as #50 consolidates differential tests.

Thanks for the heads up!

This PR should now be ready for review! It took several days of testing to get this right, but I am starting to get confident that xwpe could be released in macos now with libx11-compat. I will perform some dogfooding, but will you consider making a initial release soon?

It will be very nice for the ecosystem where xwpe has been updated recently.

cubic-dev-ai[bot]

This comment was marked as resolved.

… hygiene

Fixes for the Linux-verifiable findings from the latest review pass:

- Runtime-gate SDL_GetWindowSizeInPixels (display.c/.h, window.c, sdl-wrapper.c):
  the >=2.26 call was selected at compile time only, so a build against >=2.26
  headers running on an older SDL resolved a NULL dlsym and aborted at the first
  XOpenDisplay. Add compatSdlHasWindowSizeInPixels(), which checks the loaded
  library via SDL_GetVersion, and keep the renderer-output-size fallback always
  compiled. LIBX11_COMPAT_NO_SIZE_IN_PIXELS forces the fallback so the older-SDL
  path is exercisable on a new host.
- Snap: preserve the ICCCM base size (events.c/.h). A drag ending in
  [base, base+inc) now rounds DOWN to base (i == 0) instead of being bumped up a
  whole increment; only a genuine zero-base zero result is floored to one
  increment. Update the unit test and doc accordingly, and document inc <= 0 as a
  defined no-op.
- Base-size hint cache (display.c): when PBaseSize is absent but PMinSize is set,
  fall back to the minimum as the snap grid origin (ICCCM), matching missing.c,
  instead of defaulting the base to 0.
- Present wake (drawing.c): clear presentWakePending before rescheduling in the
  coalesce path so schedulePresentWake can arm the next timer; otherwise a repaint
  burst that outlives its first wake could be stranded past the safety cap.
- Test hygiene (check.c): drain the deferred close of the throwaway victim display
  on the early-return failure paths so it is not leaked.
cubic-dev-ai[bot]

This comment was marked as resolved.

…t retry

Fixes for the macOS HiDPI / live-resize findings from the latest review pass.
These paths are inert at scale 1.0 (Linux/CI), so they are validated on a macOS
Retina session; the shared/Linux paths are covered by the existing smoke.

- HiDPI client resize (window-internal.c): convert requested/returned SDL sizes
  through the cached per-axis scale in configureWindow, so a 2x-Retina
  XResizeWindow no longer requests a doubled native window and records the
  logical size as physical.
- HiDPI remap growth (window.c): demote a top-level's promoted physical geometry
  back to logical points (and reset the cached scale) in
  unrealizeTopLevelWindow, so an unmap/map cycle no longer grows the window by
  the HiDPI factor each time.
- Display-scale change (events.c): handle SDL_WINDOWEVENT_DISPLAY_CHANGED and
  re-derive the per-window scale from the live pixel/point ratio, so moving a
  window between monitors of different DPI does not keep multiplying by the old
  scale. Event-driven only; the resize fast path (and its test fakes) is
  untouched. SDL2 backend only for now (the SDL3 backend uses a different
  window-event model).
- Drag-end snap for hook-less clients (events.c): run
  snapTopLevelToResizeIncrements for every mapped top-level when forceReflow is
  set, before the reflow branch, so clients without a reflow hook also settle on
  a whole-cell size.
- Per-window coalesce state (window.h/window-internal.c/events.c): move the
  live-resize last-seen size and settle counter onto WindowStruct so two
  windows resizing at once no longer overwrite each other's state and never
  settle.
- Stale origin on top/left resize (events.c): persist the queried logical
  position on an accepted RESIZED, mirroring the MOVED branch.
- Present retry on upload failure (drawing.c): check SDL_UpdateTexture and
  return False on error so the frame stays dirty for retry instead of leaving
  stale pixels marked as presented.
- Stale live-resize disarm (mac-live-resize.c): ignore a late
  NSWindowDidEndLiveResize when the captured window is still in live resize (or
  the observer is already gone), so a delayed end notification cannot kill a new
  drag's reflow.

The new HiDPI-probe fallback and the DISPLAY_CHANGED handler are gated to the
SDL2 backend (SDL3 always provides SDL_GetWindowSizeInPixels and routes window
events differently), keeping both SDL_BACKEND builds compiling.
@vejeta vejeta force-pushed the fix/hidpi-pixel-exact-resize branch from 596d4fd to 3d2f9f0 Compare July 11, 2026 13:21
jserv

This comment was marked as resolved.

Dragging an xwpe window between monitors with different backing scales
(e.g. a Retina 2x display and a 1x display on macOS) left the window
with stale physical-pixel geometry and a stale font scale on the SDL3
backend: the DISPLAY_CHANGED handler was compiled out under SDL3, and
the client had no way to learn the new backing scale to re-derive its
fixed point size. Fonts and layout came out over- or under-sized on the
destination monitor.

Enable the DISPLAY_CHANGED path on SDL3 and add a client-visible channel
for the backing scale so a client that renders at a fixed point size can
track the destination monitor:

- events.c: let SDL_WINDOWEVENT_DISPLAY_CHANGED through the event filter
  on the SDL3 backend (only for a known top-level), and enable the
  convertEvent handler for SDL3. SDL2 follows the move with a
  SIZE_CHANGED that reflows geometry, but SDL3 delivers no geometry event
  for a bare move, so re-derive the per-window scale, refresh the global
  font scale, republish the scale property, and re-promote the window to
  the new monitor's physical size (reflowing the client) here.

- display.c/display.h: publish the probed backing scale on the root
  window as the _LIBX11_COMPAT_HIDPI_SCALE CARDINAL property (scale * 1000
  fixed point) at XOpenDisplay and again on each qualifying monitor move.
  Format-32 properties use the documented long client convention.

- mk/xwpe.mk: bump XWPE_REVISION to the vejeta/xwpe commit that reads the
  property and reloads its Xft font at the new density, so make xwpe
  reproduces the end-to-end fix.

On a real X server the property is simply absent and clients fall back to
1.0, so behavior there is unchanged. Faked test resizes are unaffected;
the DISPLAY_CHANGED work is event-driven and never runs on the resize
fast path.
cubic-dev-ai[bot]

This comment was marked as resolved.

vejeta added 9 commits July 12, 2026 06:36
PR vejeta/xwpe#1 (Reload the font on a mixed-DPI monitor move) is merged to main as b850185. Re-point XWPE_REVISION from the feature-branch commit 9192e0c to the immutable merge commit so make xwpe builds against the merged mainline.
hiDpiScaledMetric returns a short, so a large advance width scaled by a
high HiDPI factor could exceed SHRT_MAX and wrap negative before the
cast. Clamp to [SHRT_MIN, SHRT_MAX] before casting, mirroring the INT_MAX
guard already in hiDpiScaledPixelSize.

Addresses review feedback on PR sysprog21#49 (font.c:564).
The accelerated present read each dirty rect one scanline at a time,
issuing an SDL_RenderReadPixels plus an SDL_UpdateTexture per row - about
1600 round trips for a tall full-window present. Size the CPU scratch to
the largest rect this frame (w*h*4, grown once up front) and read/upload
each rect in a single call pair instead.

Addresses review feedback on PR sysprog21#49 (drawing.c:494).
XSetWMSizeHints caches the resize increments (used by the live-resize
drag-end snap) straight from the XSizeHints struct, but a client that
writes WM_NORMAL_HINTS through a raw XChangeProperty bypassed that path
and left hasResizeInc clear. Add cacheResizeIncrementsFromNormalHints-
Property to decode the stored property bytes, and call it from the
XChangeProperty WM_NORMAL_HINTS hook so both write paths agree.

Addresses review feedback on PR sysprog21#49 (display.c:879).
The observer callback cleared liveResizeObserverRunning before the
idle-disarm branch, so the disarmLiveResizeObserver() call there took the
synchronous CFRunLoopRemoveObserver path from inside the callback frame -
exactly the re-entrant removal the pending-stop latch exists to avoid.
Keep liveResizeObserverRunning set until after every disarm decision so
any disarm reached from within the callback defers through the latch, then
clear it and honour the latch once the callback is about to return.

Addresses review feedback on PR sysprog21#49 (mac-live-resize.c:109).
topLeftGravityString cached the result of +stringWithUTF8String:, which
returns an autoreleased instance. Under manual retain/release (no ARC)
the surrounding autorelease pool drains every run-loop tick, leaving the
process-lifetime static dangling and risking a use-after-free on later
frames. Build it with +alloc/-initWithUTF8String: instead, which returns
a +1 retained instance that is never autoreleased.

Addresses review feedback on PR sysprog21#49 (mac-live-resize.c:433).
snapTopLevelToResizeIncrements runs inside the live-resize present frame,
where SDL_PumpEvents would re-enter the event path the present must not be
nested under. The pump is also unnecessary: the RESIZED echo that
SDL_SetWindowSize queues is neutralized at delivery - suppressSdlResizeEcho
drops it in the callback, and the RESIZED sizeChanged short-circuit skips
the destructive path when it does arrive - so there is nothing to flush
synchronously. Remove the pump.

Addresses review feedback on PR sysprog21#49 (events.c:768).
The drag-end increment snap ran for every mapped top-level regardless of
whether a reflow hook was registered. For a hook-less client the snap
resized the SDL window and suppressed the RESIZED echo, but nothing
updated ws->w/h or reflowed the client to the snapped size, so the client
kept drawing at the pre-snap size and its content was clipped by up to a
cell. Move the snap inside the reflow branch, where the reflow that
follows reads the snapped size and repaints the client to match, keeping
the snapped geometry and the client content consistent.

Addresses review feedback on PR sysprog21#49 (events.c:813).
The DISPLAY_CHANGED handler grows the top-level via
postSyntheticWindowResize on a mixed-DPI monitor move but did not
invalidate the cached (0,0,w,h) visible region, so on a scale-up move the
newly added physical columns/rows stayed clipped and black. Invalidate it
after the resize exactly as the RESIZED ConfigureNotify path does.

Addresses review feedback on PR sysprog21#49 (events.c:2893).
@vejeta

vejeta commented Jul 12, 2026

Copy link
Copy Markdown
Collaborator Author

@jserv thanks for the thorough review — all 7 points addressed, one commit each (pushed on top of the branch):

# Comment Commit
events.c:756 drop the in-present SDL_PumpEvents() 43136f5
events.c:801 snap only when a reflow hook is registered (hook-less clip) 4d16732
mac-live-resize.c:455 retain the cached topLeft gravity NSString (no-ARC UAF) 7726f70
mac-live-resize.c:115 keep observer-running set across the disarm decision 834a28b
drawing.c:507 read/upload each present rect once (was per-scanline) 36a33cd
display.c:879 cache resize increments from raw WM_NORMAL_HINTS writes 55e7edd
font.c:564 clamp hiDpiScaledMetric to the short range 3e54267

make check-unit and clang-format are clean, and I re-verified the live-resize drag scenarios (constant font, drag-end settle, repeated drags) on Retina after the mac-live-resize changes.

cubic-dev-ai[bot]

This comment was marked as resolved.

@vejeta vejeta marked this pull request as draft July 12, 2026 10:54
vejeta added 7 commits July 12, 2026 13:08
The resize-increment cache read base_width/base_height (indices 15-16)
whenever PBaseSize was set, but those fields exist only in the 18-element
ICCCM-v1 payload. A legacy 15-element (pre-ICCCM) write reaches the
decoder with dataLength == OldNumPropSizeElements, so dereferencing
sizeHints->baseWidth/baseHeight read past the stored property.

Only treat PBaseSize as present when dataLength >= NumPropSizeElements,
matching the ICCCM layout; otherwise fall back to min size (or 0) as the
grid origin, as the code already does when PBaseSize is unset.

Addresses cubic-dev-ai review (out-of-bounds read, P1).
The drag-end increment snap calls SDL_SetWindowSize from inside the
live-resize present frame and cannot pump SDL to flush the echo, so the
RESIZED/SIZE_CHANGED it queues was delivered later and converted into a
second, redundant ConfigureNotify (the snap already posts one).

Record the exact logical size the snap wrote (snapEchoW/H) and have the
event filter drop resize echoes carrying that size, clearing the key on
the matching RESIZED so a later genuine resize back to the same size is
kept. configureWindow keeps its synchronous-pump suppression flag for
the client XResizeWindow path; the size key covers only the snap path
that cannot pump.

Addresses cubic-dev-ai review (duplicate ConfigureNotify, P2).
The accelerated present grew a per-window CPU readback buffer to the
largest dirty rect and retained it between frames, so several 4K/8K
windows live-resizing at once could hold multiple full backing-sized
buffers indefinitely.

Bound the retained buffer at 8 MiB (LIBX11_COMPAT_PRESENT_READBACK_CAP,
floored to one scanline of the widest rect) and read any rect larger
than the cap in successive horizontal bands that each fit. The common
case still transfers a whole rect in one read/upload call pair, so the
per-rect fast path jserv asked for is preserved; only the pathological
oversized present is banded.

Addresses cubic-dev-ai review (retained scratch memory pressure, P2).
Two HiDPI live-resize fixes plus the matching xwpe revision bump.

xft: drawUtf8String composited each glyph on the CPU. To alpha-blend it
read the destination pixmap back with XGetImage (SDL_RenderReadPixels),
blended per pixel, then XPutImage - which re-dirtied the pixmap readback
cache, forcing a full-pixmap GPU->CPU readback for every character typed.
That readback dominated typing latency (~78% of main-thread time under a
sampling profile).

Rewrite the draw path to composite on the GPU, mirroring the core-font
path: upload the TTF_RenderUTF8_Blended surface to a BLEND texture,
apply the colour alpha via SDL_SetTextureAlphaMod, intersect the Xft clip
rects with the base renderer clip, and SDL_RenderCopy onto the drawable's
render target. Remove the now-dead blendPixel and xftClipContains, and
rename the file-local clampToInt to xftClampToInt to avoid colliding with
drawing.h. After the change the readback path is gone from the profile
(0 samples even under sustained typing).

events: on SDL_WINDOWEVENT_DISPLAY_CHANGED, rescale the cached PResizeInc
(widthInc/heightInc/baseWidth/baseHeight/minWidth/minHeight) by
newScale/oldScale. Those increments live in physical-pixel space, so a
monitor move that changes the backing scale left them describing the
previous monitor's cell, and the drag-end snap quantised to the wrong
grid - leaving a permanent sub-cell remainder band on the new display.
Rescaling keeps the snap grid correct even for clients that do not
re-publish WM_NORMAL_HINTS after a DPI change.

mk/xwpe.mk: bump XWPE_REVISION to 88290d5 (xwpe re-publishes
WM_NORMAL_HINTS after its DPI font refit, the client-side half of the
remainder-band fix).
The SDL2 leg links libX11-compat against the in-tree dlopen wrapper
instead of libSDL2 directly, so every SDL symbol the tree calls needs a
SDL_WRAP thunk. The new GPU-composite glyph path in xft.c calls
SDL_SetTextureAlphaMod, which had no thunk, leaving an undefined
reference in libX11-compat.so on the SDL2 backend (CI) and cascading a
link failure into every downstream job. The SDL3 leg links libSDL3
directly, so local SDL3 builds were unaffected.

Add the missing thunk. SDL_SetTextureAlphaMod predates SDL 2.0.0, so no
version guard is needed.
The previous xwpe capture predated the GPU-composite Xft path and the
pixel-exact live-resize work in this PR, and looked dated on HiDPI.
Replace assets/xwpe.png with a current capture showing crisp
GPU-composited glyphs, syntax highlighting, and clangd-driven inlay
hints and semantic colours on a Retina display, and update the caption
and alt text to match.
@vejeta vejeta force-pushed the fix/hidpi-pixel-exact-resize branch from 0a96de7 to cda58c9 Compare July 12, 2026 18:20
@vejeta vejeta marked this pull request as ready for review July 12, 2026 18:21

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

4 issues found across 35 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="src/mac-live-resize.c">

<violation number="1" location="src/mac-live-resize.c:106">
P1: Destroying the resized window from a reflow callback can leave `liveResizeWindow` dangling and crash on this `inLiveResize` message after the present returns. Keep an owned NSWindow reference and release it during disarm, or clear/disarm it as part of native-window teardown.</violation>
</file>

<file name="src/window-internal.c">

<violation number="1" location="src/window-internal.c:1593">
P2: Resizing a window using NorthEastGravity, CenterGravity, or another non-top-left bit gravity leaves retained pixels at the top-left, not the requested anchor. Translate the copied source/destination rect for each gravity, or consistently fall back to discarding contents for unsupported gravities.</violation>
</file>

<file name="src/drawing.c">

<violation number="1" location="src/drawing.c:301">
P2: A `clock_gettime` failure can permanently suppress presents: `beginCoalesceClientRepaint` creates a deadline from zero, but `coalesceActive` can never reach it while the clock still returns zero. Treat an unavailable clock as an inactive/expired coalesce gate.</violation>
</file>

<file name="src/font.c">

<violation number="1" location="src/font.c:1369">
P1: Core-font text becomes the wrong physical size after moving a window between differently scaled displays: the font face is opened once at a global scale, but display changes alter per-window backing scale without rebuilding that face. Track scale per drawable or recreate/invalidate affected `CompatFont` faces and text-cache entries when the target window's scale changes; do not use a stale process-global scale for this decision.</violation>
</file>

Tip: cubic can generate docs of your entire codebase and keep them up to date. Try it here.

Re-trigger cubic

Comment thread src/font.c
return None;
}
int fontSize = requestedFontSize(name);
int fontSize = hiDpiScaledPixelSize(requestedFontSize(name));

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Core-font text becomes the wrong physical size after moving a window between differently scaled displays: the font face is opened once at a global scale, but display changes alter per-window backing scale without rebuilding that face. Track scale per drawable or recreate/invalidate affected CompatFont faces and text-cache entries when the target window's scale changes; do not use a stale process-global scale for this decision.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/font.c, line 1369:

<comment>Core-font text becomes the wrong physical size after moving a window between differently scaled displays: the font face is opened once at a global scale, but display changes alter per-window backing scale without rebuilding that face. Track scale per drawable or recreate/invalidate affected `CompatFont` faces and text-cache entries when the target window's scale changes; do not use a stale process-global scale for this decision.</comment>

<file context>
@@ -1261,7 +1366,7 @@ Font XLoadFont(Display *display, _Xconst char *name)
         return None;
     }
-    int fontSize = requestedFontSize(name);
+    int fontSize = hiDpiScaledPixelSize(requestedFontSize(name));
     resource->ttf = TTF_OpenFont(fontEntry->filePath, fontSize);
     if (!resource->ttf) {
</file context>

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deferring this to a follow-up. Per-drawable font-scale tracking is a real
enhancement, but it is a larger architectural change than the rest of this PR:
CompatFont faces and the text cache are keyed globally today, so doing this
correctly means rekeying both by target scale and invalidating/rebuilding faces
on every DISPLAY_CHANGED, plus deciding a policy for shared faces used across
windows on different displays.

In practice the multi-display core-font mis-size is also mitigated by the xwpe
side of this work (font re-derive on DPI change) and by the global-scale reset
this PR adds. I would rather land the per-drawable face tracking as its own
change with dedicated tests than fold a partial version into this PR. Filing as
a follow-up.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Agreed — the per-drawable face tracking is a separate change, and this PR’s global-scale reset plus xwpe font re-derive covers the immediate issue. I’ll keep the full rekey/invalidation work as a follow-up with dedicated tests.

Comment thread src/mac-live-resize.c
* (stays YES) yet flips to NO the moment the loop exits, so we disarm
* exactly then instead of waiting for a possibly-delayed DidEnd
* notification. */
int stillResizing = liveResizeWindowInLiveResize();

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: Destroying the resized window from a reflow callback can leave liveResizeWindow dangling and crash on this inLiveResize message after the present returns. Keep an owned NSWindow reference and release it during disarm, or clear/disarm it as part of native-window teardown.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/mac-live-resize.c, line 106:

<comment>Destroying the resized window from a reflow callback can leave `liveResizeWindow` dangling and crash on this `inLiveResize` message after the present returns. Keep an owned NSWindow reference and release it during disarm, or clear/disarm it as part of native-window teardown.</comment>

<file context>
@@ -0,0 +1,506 @@
+     * (stays YES) yet flips to NO the moment the loop exits, so we disarm
+     * exactly then instead of waiting for a possibly-delayed DidEnd
+     * notification. */
+    int stillResizing = liveResizeWindowInLiveResize();
+    if (!stillResizing) {
+        /* Modal loop has ended (DidEnd may not have posted yet). Disarm and
</file context>

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deferring this to a follow-up. The concrete re-entrant use-after-free that was
reachable here (destroying the display/window from inside the reflow callback)
is already handled in this PR: XCloseDisplay defers the actual close while a
present is in flight and drains it afterward, and the observer disarm path keeps
the running latch set until after the disarm decision so both paths route
through the pending-stop handling.

Holding an owned (retained) NSWindow reference and releasing it during disarm is
a stronger invariant that also covers a client destroying the native window by
some path other than XCloseDisplay. That is worth doing, but it changes the
observer's ownership model and wants its own review; I would rather not widen
this PR's Objective-C lifetime handling further here. Filing as a follow-up.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reachable re-entrant close is already handled in this PR by the deferred XCloseDisplay path and the pending-stop latch, so that part of the parent comment is too broad here. The retained NSWindow ownership change is a separate, stronger invariant and should go in the follow-up you mentioned.

Comment thread src/events.c Outdated
Comment thread src/events.c Outdated
Comment thread src/display.c Outdated
Comment thread src/window-internal.c
int copyH = oldH < (int) windowStruct->h ? oldH : (int) windowStruct->h;
if (copyW > 0 && copyH > 0) {
SDL_Rect overlap = {0, 0, copyW, copyH};
SDL_RenderCopy(windowRenderer, oldTexture, &overlap, &overlap);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Resizing a window using NorthEastGravity, CenterGravity, or another non-top-left bit gravity leaves retained pixels at the top-left, not the requested anchor. Translate the copied source/destination rect for each gravity, or consistently fall back to discarding contents for unsupported gravities.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/window-internal.c, line 1593:

<comment>Resizing a window using NorthEastGravity, CenterGravity, or another non-top-left bit gravity leaves retained pixels at the top-left, not the requested anchor. Translate the copied source/destination rect for each gravity, or consistently fall back to discarding contents for unsupported gravities.</comment>

<file context>
@@ -1484,6 +1575,24 @@ void resizeWindowTexture(Window window)
+        int copyH = oldH < (int) windowStruct->h ? oldH : (int) windowStruct->h;
+        if (copyW > 0 && copyH > 0) {
+            SDL_Rect overlap = {0, 0, copyW, copyH};
+            SDL_RenderCopy(windowRenderer, oldTexture, &overlap, &overlap);
+        }
+    }
</file context>

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deferring this to a follow-up. The retained-pixel copy here is a best-effort
optimization on top of a full Expose that repaints the window anyway, so a
non-top-left bit gravity does not lose content — it just briefly retains the
wrong pixels in the anchor region until the client redraws. The only consumer
in this stack (xwpe) uses top-left gravity, so the general case is untested in
practice.

Doing this properly means translating the source/destination overlap rect per
gravity anchor (NorthEast, Center, South, ...) and testing each, or explicitly
discarding contents for the gravities we do not model. Both are reasonable; I
would rather add the full gravity matrix with tests as a separate change than
ship a partial anchor implementation here. Filing as a follow-up.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The parent comment is too broad for this PR. The retained copy is just a temporary optimization while the full Expose repaints, and this stack only uses top-left gravity, so the non-top-left anchor case is out of scope here. A full gravity matrix can land separately with tests.

Comment thread src/drawing.c
uint64_t deadline = coalesceDeadlineGet();
if (deadline == 0)
return False;
if (monotonicNowNs() >= deadline) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: A clock_gettime failure can permanently suppress presents: beginCoalesceClientRepaint creates a deadline from zero, but coalesceActive can never reach it while the clock still returns zero. Treat an unavailable clock as an inactive/expired coalesce gate.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/drawing.c, line 301:

<comment>A `clock_gettime` failure can permanently suppress presents: `beginCoalesceClientRepaint` creates a deadline from zero, but `coalesceActive` can never reach it while the clock still returns zero. Treat an unavailable clock as an inactive/expired coalesce gate.</comment>

<file context>
@@ -223,12 +277,364 @@ static void schedulePresentWake(void)
+    uint64_t deadline = coalesceDeadlineGet();
+    if (deadline == 0)
+        return False;
+    if (monotonicNowNs() >= deadline) {
+        coalesceDeadlineSet(0);
+        return False;
</file context>
Suggested change
if (monotonicNowNs() >= deadline) {
uint64_t now = monotonicNowNs();
if (now == 0 || now >= deadline) {

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Deferring this to a follow-up, but noting it is very low risk in practice.
monotonicNowNs() is backed by SDL_GetPerformanceCounter (with a clock_gettime
fallback), which does not fail on the platforms this shim targets; a genuine
zero return would require the monotonic clock to be unavailable, at which point
coalescing is the least of the problems.

That said, treating an unavailable/zero clock as an expired (inactive) coalesce
gate is the correct defensive behavior and the suggested one-line guard is
harmless. I am grouping it with the other hardening items rather than adding it
to this already-large review batch, so it lands with a focused test for the
zero-clock path. Filing as a follow-up.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The clock-failure concern is valid, but I’m deferring it to a follow-up with a focused zero-clock test. The parent comment can stay as a hardening item, but it’s out of this batch.

Comment thread src/drawing.c Outdated
Comment thread src/drawing.c
Comment thread src/xft.c Outdated
@vejeta vejeta marked this pull request as draft July 12, 2026 22:15
…ape/XTest robustness

events.c: drop the SDL3-only guard on the SDL_WINDOWEVENT_DISPLAY_CHANGED
filter so mixed-DPI scale refreshes reach the handler on default SDL2 builds
too (the subevent enum exists since SDL 2.0.18).

display.c: reset globalHiDpiScale to 1.0 at the top of probeGlobalHiDpiScale
so a later 1x session cannot inherit a prior Retina scale (the probe only
overwrites it on a pixel!=point mismatch, and the probe-failure path returns
early). Also remove the redundant inline resize-increment cache in
XSetWMSizeHints: the XChangeProperty success path already refreshes the cache
from the stored property, so the inline decode both duplicated that work and
ran even when the property write failed.

drawing.c: check the SDL_RenderCopy return in the accelerated present path and
bail (leaving the frame dirty for retry) on failure, mirroring the adjacent
readback/upload error handling instead of silently marking a dropped frame
presented.

xft.c: clip the glyph copy to the drawable's sibling visible region the same
way the core-font path does, so Xft text into a child obscured by a
higher-stacked sibling can no longer paint over that sibling; and only present
when the post-draw shape composite succeeded, so a failed composite recomposes
from a fresh baseline instead of flashing masked-out pixels.

xtest.c/events.c: tag synthetic XTest motion and button events with
which == SDL_TOUCH_MOUSEID (as the wheel path already does) and skip
scaleSdlPointToPixels for those in convertEvent. XTest coordinates are already
in X11 physical pixels, so the extra scale double-counted them on Retina; real
hardware events still carry SDL points and keep the scale.
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