Skip to content

Wayland support - #11

Open
pplupo wants to merge 49 commits into
Euro-Office:mainfrom
pplupo:feature/wayland-migration
Open

Wayland support#11
pplupo wants to merge 49 commits into
Euro-Office:mainfrom
pplupo:feature/wayland-migration

Conversation

@pplupo

@pplupo pplupo commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Reopened from feature/wayland-migration (closed #10, which was opened from release/wayland-db-support and carried unrelated work — the CDP gateway bridge, int64_t churn — that doesn't belong here). Same Wayland migration work as desktop-apps#30, sdkjs#64, core#113, and DesktopEditors#42.

pplupo and others added 30 commits July 3, 2026 17:52
Signed-off-by: Peter P. Lupo <pplupo@gmail.com>
Enable Qt input method handling for dead key sequences (ã, é, ü, ç, etc.):

1. Enable WA_InputMethodEnabled on the QCefView widget for Wayland
2. Suppress raw dead key events (Qt::Key_Dead_Grave..Key_Dead_Greek) in
   keyPressEvent/keyReleaseEvent so Qt's IM can compose them
3. Add inputMethodEvent handler that receives the composed character and
   sends it to CEF via the full KEYDOWN->CHAR->KEYUP sequence
4. Add inputMethodQuery to support Qt's IM state queries

Applied cleanly on top of acc5477b (074a568 in desktop-sdk) with no
changes to OnPaint or any other rendering code.

Signed-off-by: Peter P. Lupo <pplupo@gmail.com>
CEF's off-screen render buffer reached Qt through OnPaint calling
repaint()/update() from inside CEF's callback stack. On Wayland the
wl_surface_commit / frame-callback handshake never completed at a
top-of-loop point, so the app looked frozen until a physical input
event forced a synchronous flush.

Iterations tried before landing on the fix:
- Fixed the 16ms Wayland heartbeat detection (QGuiApplication::platformName()
  instead of an env var check that could miss Qt6's default Wayland backend).
- Swapped repaint()/sendEvent(UpdateRequest) for a plain update() call in
  OnPaint.
- Moved the repaint trigger out of OnPaint entirely: OnPaint now only stages
  the frame and sets a dirty flag; a top-of-loop poller drove update() and
  QWindow::requestUpdate(). This didn't fully clear the stall on this
  compositor -- rendering still only committed on user input, effectively
  back to the original symptom.
- Added a guarded, input-excluded processEvents() fallback in the poller.
  This regressed badly: since content animates continuously, the poller
  spun the loop ~60x/sec while excluding user input, starving mouse and
  keyboard. Removed.

Final fix: present the CEF buffer through a QOpenGLWidget overlay
(QCefGLWidget) instead of the raster QWidget backing store. On Wayland the
GL swap is itself the wl_surface_commit and is throttled by the
compositor's frame callback natively, so presentation no longer depends on
a top-of-loop nudge or a physical input event to complete. OnPaint stages
the frame and pushes it to the overlay; the poller only nudges a repaint,
with no event-loop spin.

Also pulls in the OpenGLWidgets Qt module locally in qt_wrapper's
CMakeLists.txt and .pro file, since the build only resets desktop-sdk and
never touches core/common.cmake.

Signed-off-by: Peter P. Lupo <pplupo@gmail.com>
CEF's own clipboard integration silently no-ops in off-screen-rendering
mode under Wayland: the OS clipboard protocol needs a real wl_surface
and input-event serial to claim ownership, and OSR mode never has one
(Qt owns the only real window). Copy writes never leave the app and
paste never sees anything written outside it.

Route copy/paste through the platform widget's real clipboard instead:
a fire-and-forget CefProcessMessage for writes (clipboard_write) and a
CefMessageRouter query/callback round trip for reads (clipboard_read),
bridged on the Qt side via QClipboard in QCefView::SetClipboardData/
GetClipboardData. sdkjs mirrors every copy write into the bridge and
services paste through it when available, covering both the legacy
and new (navigator.clipboard) copy paths.

Signed-off-by: Peter P. Lupo <pplupo@gmail.com>
…rray size)

- CefProcessMessage sends go through CefFrame::SendProcessMessage in
  this CEF version, not CefBrowser (which has no such member).
- X11/X.h #defines Success to 0, colliding with
  CefMessageRouterBrowserSide::Callback::Success; #undef it locally.
- EXTEND_METHODS_COUNT needed bumping to 198 for the added
  nativeClipboardWrite entry (fixed-size const char* array).

Signed-off-by: Peter P. Lupo <pplupo@gmail.com>
message_router_ (base client::ClientHandler) is only constructed
lazily inside OnAfterCreated(), once a real browser exists. The
previous registration point, right after CAscClientHandler's own
construction in CCefView::load(), ran before that -- message_router_
was still a null CefRefPtr there, so AddHandler() dereferenced null
and crashed on every launch. Move registration into OnAfterCreated(),
right after the base class call that creates message_router_, guarded
so it only runs once even if OnAfterCreated() fires again for popups.

Signed-off-by: Peter P. Lupo <pplupo@gmail.com>
…e reverted)

Signed-off-by: Peter P. Lupo <pplupo@gmail.com>
Diagnostic logging (now removed) confirmed the actual bug: CCefView's
GetScreenPoint mixed physical-pixel and DIP units. widgetScreenX/Y
(from GetWidgetScreenPosition) is already in physical/device pixels;
viewX/viewY are DIPs (the convention used throughout for
GetViewRect/mouse events). Multiplying viewX/viewY by the device
scale factor on top of that -- following CEF's own doc comment for
this callback, "provide screen device (pixel) coordinates" -- doubles
the scaling for the view-relative portion.

Chromium exposes this callback's result to JS as
MouseEvent.screenX/screenY, which per the DOM spec must be in CSS
pixels (DIPs), not physical pixels. With a fractional device scale
factor (e.g. 1.15), the excess grew proportionally with distance from
the origin -- confirmed against logged click coordinates, which showed
an exact match to click * (scale - 1). sdkjs's own context-menu
edge-avoidance logic, positioned using this inflated screenX/screenY,
pushed the menu off-screen for clicks near the edge of large
(especially maximized) windows.

Normalize both terms to DIPs instead of physical pixels.

Signed-off-by: Peter P. Lupo <pplupo@gmail.com>
… (to be reverted)

Signed-off-by: Peter P. Lupo <pplupo@gmail.com>
GetScreenPoint is confirmed correct after the previous fix (screenX/Y
now track viewX/Y properly, no more device-scale inflation), but the
document context menu offset persists regardless -- confirming
GetScreenPoint isn't the code path responsible for that menu's
positioning. Removing the temporary logging; the fix to GetScreenPoint
itself is kept since it's a genuine correctness improvement (matches
the DOM MouseEvent.screenX/screenY spec), even though it didn't turn
out to be the cause of the reported bug.

Signed-off-by: Peter P. Lupo <pplupo@gmail.com>
…pping

Alternative to the JS-level menu-position compensation: instead of
correcting for CEF OSR applying device_scale_factor as an internal CSS
zoom, eliminate the zoom at the source by always reporting a scale
factor of 1.0 to CEF and feeding it a physical-pixel view rect/buffer
size directly. GetViewRect and GetScreenInfo now report physical
pixels instead of DIPs; GetScreenPoint no longer needs a DIP/physical
conversion since both terms are physical; mouse events sent to CEF
are scaled up from Qt's DIP coordinates to match.

Expected side effect: window.innerWidth/innerHeight, devicePixelRatio,
and all CSS-pixel-based UI sizing in sdkjs/web-apps will change (more
CSS pixels reported for the same physical screen area), which may
shrink or misalign toolbars/icons/text across the whole app. This is
exploratory -- keep isolated on this branch until confirmed safe.

Signed-off-by: Peter P. Lupo <pplupo@gmail.com>
… double-click)

Three related native bugs in the Qt/CEF Wayland input path, found via an
iterative build-test-log cycle against the spreadsheet editor:

1. focusOutEvent unconditionally returned before telling CEF it lost
   focus. On Wayland, keyboard events are forwarded to CEF manually
   regardless of Qt's own focus state, so this let CEF's internal focus
   bookkeeping drift out of sync with Qt's. Propagate focus-out to CEF as
   intended.

2. Tab never reached keyPressEvent at all: Qt's own
   QWidget::focusNextPrevChild() intercepts Tab/Shift+Tab for
   widget-to-widget focus traversal before keyPressEvent sees the key.
   On Wayland nothing reliably reclaimed focus afterward, producing a
   freeze where no key (including Alt-menu accelerators) worked until a
   mouse click forced Qt to resolve focus. Override focusNextPrevChild()
   to refuse the traversal on Wayland so Tab falls through to
   keyPressEvent() and is forwarded to CEF like any other key.

3. Double-click silently did nothing: Qt's Wayland backend delivers an
   ordinary mousePressEvent for BOTH clicks of a double-click (not just
   the first), so the naive fix of forwarding a synthetic click from
   mouseDoubleClickEvent sent a THIRD, conflicting click-down event to
   CEF for two physical clicks -- corrupting Blink's click-count
   tracking, which sdkjs's double-click-to-edit relies on via
   MouseEvent.detail === 2. Track click count/timing natively in
   mousePressEvent (mirroring Qt's own multi-click detection), forward
   the same count on the matching release, and stop
   mouseDoubleClickEvent from talking to CEF at all -- it now only keeps
   Qt's own bookkeeping intact.

Signed-off-by: Peter P. Lupo <pplupo@gmail.com>
Signed-off-by: Hendrik Leidinger <hendrik.leidinger@nextcloud.com>
Assisted-by: Claude Code:Opus 4.8
…n Wayland OSR mode

Windowed CEF gets a real native window and sets the OS cursor itself
when the page requests a shape change. OSR mode (used for Wayland)
has no CEF-owned window, so OnCursorChange was never wired up and
nothing ever changed the cursor there.

Override CefDisplayHandler::OnCursorChange in CAscClientHandler and
bridge the requested cef_cursor_type_t to the platform widget via a
new CCefViewWidgetImpl::SetCursorType, implemented in QCefView with
a mapping to the closest Qt::CursorShape.

Signed-off-by: Peter P. Lupo <pplupo@gmail.com>
Signed-off-by: Hendrik Leidinger <hendrik.leidinger@nextcloud.com>
…er under Wayland OSR

OnCursorChange previously only forwarded the named cef_cursor_type_t
value, which covers CSS cursor keywords like text/move/ew-resize but
not `cursor: url(...)` custom-image cursors (used throughout sdkjs,
e.g. the spreadsheet's column/row resize-divider hover cursor and
table-select cursors registered via g_oHtmlCursor.register). CEF
reports those as CT_CUSTOM with the actual bitmap in
custom_cursor_info instead of a named type, so they silently fell
back to the plain arrow.

Add CCefViewWidgetImpl::SetCursorCustom, taking the premultiplied
BGRA buffer/size/hotspot CEF provides, and build a real QCursor from
it in QCefView -- same buffer layout OnPaint already assumes for the
OSR frame.

Signed-off-by: Peter P. Lupo <pplupo@gmail.com>
…dsf-1.0-osr

Add CCefViewWidgetImpl::GetUIScalePercentage(), overridden in QCefView to
bucket Qt's logicalDotsPerInch() into 100/150/200/250% the same way
LibreOffice's CountDPIScaleFactor() does off a 96 DPI baseline. This is
deliberately independent of GetDeviceScaleFactor()/devicePixelRatio(),
which dsf-1.0-osr forces to always report 1:1 for CEF's OSR coordinate
mapping -- reusing that value here would lose the real DPI signal on
HiDPI displays.

CCefView::UpdateUIScalePercentage() pushes the computed factor into the
page: it sets --pixel-ratio-factor, --x-small-btn-size, and
--x-small-btn-icon-size (CSS custom properties already consumed by
web-apps' LESS but never previously set by anything), and corrects
window.devicePixelRatio before re-running sdkjs's own
AscBrowser.checkZoom(), since sdkjs's canvas rendering reads
devicePixelRatio directly rather than any of the LESS-side variables.

Called on main-frame load, and again on widget move (covers the common
case of dragging the window to a different-DPI monitor without adding
new Qt screen-changed signal plumbing).

Signed-off-by: Peter P. Lupo <pplupo@gmail.com>
The rebuilt binary confirmed the fix code is present (via the
devicePixelRatio marker string), but toolbar icon size still shows no
visible change. Add temporary diagnostics to find out why:

- CCefView::UpdateUIScalePercentage() now logs to /tmp/uiscale_debug.log
  (bypassing the app's --log-severity=disable, which would otherwise
  silently drop LOG()-style messages) confirming it's actually called,
  with what percentage/factor, and against which frame URL.
- The injected JS now wraps each step (devicePixelRatio override, CSS
  custom property setProperty calls, checkZoom() re-run) in its own
  try/catch with console.log/console.error, since a single throw (e.g.
  if devicePixelRatio is non-configurable in this Chromium build) could
  have silently aborted the rest of the injected script before the CSS
  variables were ever set.
- CAscApplicationManager now honors EURO_OFFICE_DEBUG=1 to call the
  existing (but previously never-invoked) SetDebugInfoSupport(true), so
  F1 opens real DevTools and the above console output is actually
  visible instead of guessing blind.

All temporary; remove once the root cause is confirmed and fixed.

Signed-off-by: Peter P. Lupo <pplupo@gmail.com>
…on scale

Two real bugs found via live CDP inspection of the built binary:

1. The actual editor UI (ribbon, AscCommon) loads in a nested iframe
   (documenteditor/main/index.html), not the main frame. OnLoadEnd's
   frame->IsMain() check meant UpdateUIScalePercentage() never injected
   into that iframe -- only into the outer shell pages, which have no
   ribbon at all. Fixed by enumerating every frame via
   CefBrowser::GetFrameIdentifiers()/GetFrame() and injecting into each.

2. Setting --x-small-btn-size via document.documentElement.style even
   directly in the real iframe (confirmed via devtools) had zero effect
   on .btn-toolbar's rendered height -- verified empirically: an inline
   !important override on the button DID work and rendered correctly
   (screenshot-confirmed via CDP Page.captureScreenshot), but the CSS
   custom property route does not reach this rule for a reason not
   pinned down (not an @Property registration, not an inline style,
   not a media query -- the matched-styles cascade looked correct but
   the value never took effect). Rather than keep chasing that, inject
   a <style id="ui-scale-override-style"> with !important declarations
   for .btn-toolbar/.btn-toolbar .icon directly, which is guaranteed to
   win regardless of whatever is defeating the variable indirection.
   Screenshot-confirmed working: toolbar icons visibly shrink to match
   LibreOffice's density.

Signed-off-by: Peter P. Lupo <pplupo@gmail.com>
Two corrections based on user feedback after the icon-size fix landed:

1. Wrong scale signal. GetUIScalePercentage() bucketed off
   logicalDotsPerInch() the way LibreOffice's CountDPIScaleFactor() does
   (X11-era, 96 DPI baseline) -- but on this machine's actual Wayland
   compositor (KDE, configured display scale 1.15 via kscreen-doctor),
   that bucketing returned a flat 100%, silently discarding the real
   configured scale. Wayland sets an explicit output scale directly
   rather than relying on physical-DPI heuristics, and Qt's own
   devicePixelRatio() already tracks that scale live -- this is a
   separate value from what dsf-1.0-osr forces CEF's own
   device_scale_factor to (for OSR coordinate-mapping only); Qt keeps
   reporting the real ratio locally regardless. Use it directly.

2. Scope was too narrow. The previous fix only touched .btn-toolbar
   (small Home-tab icons), matched to LibreOffice's compact 16px as a
   reference baseline. But shrinking only those buttons made the whole
   toolbar row look collapsed by comparison (unchanged font/ribbon/input
   sizing around now-tiny buttons), which read as "everything got
   smaller." Broadened the !important override to also cover: ribbon-tab
   (x-huge) buttons and their icons, form controls (dropdowns/inputs),
   and document/UI font-size classes (.font-size-tiny through
   .font-size-very-huge) -- each scaled from Euro-Office's own existing
   default as its 1.0x reference (not LibreOffice's, which has no
   equivalent ribbon UI to reference). Only the small toolbar buttons use
   LibreOffice's 16px as the reference, for lack of a better baseline.

Signed-off-by: Peter P. Lupo <pplupo@gmail.com>
…ice's own 20px

The previous commit already reframed every other element (ribbon
buttons, form controls, fonts) to use Euro-Office's own existing
defaults as the 1.0x reference, since the actual bug is display-scale
tracking, not LibreOffice size-matching -- but left the small toolbar
buttons on LibreOffice's 16px by oversight. That undermined the fix:
16px * 1.15 (this machine's real display scale) ~= 18px, still smaller
than the original 20px baseline the user is comparing against. Revert
to 20px so scaling now correctly starts from Euro-Office's own size
(20px * 1.15 ~= 23px) rather than an unrelated reference point.

Signed-off-by: Peter P. Lupo <pplupo@gmail.com>
…ad-hoc overrides

The previous approach hand-picked selectors to !important-override
(.btn-toolbar, ribbon x-huge buttons, form controls, font-size classes)
after finding the plain CSS-custom-property route didn't visibly affect
.btn-toolbar's height. That patchwork was necessarily incomplete: it
missed color-swatch decoration elements (.btn-color-value-line etc,
absolutely positioned with fixed px offsets), causing visible overlap
once the desktop scale was actually changed and the ribbon container
itself never resized around the now-larger buttons, clipping dropdowns.

Root cause of both the incompleteness and the earlier CSS-variable
mystery: this app already has its own comprehensive HiDPI scaling
system. Common.Utils.checkSize() (web-apps
apps/common/main/lib/util/utils.js) computes the real device pixel
ratio (preferring window.AscCommon.checkDeviceScale(), which reads
window.devicePixelRatio -- the same value this code already overrides)
and adds a pixel-ratio__1_25/1_5/1_75/2/2_5 class to document.body.
Nearly every scale-aware CSS rule in this codebase -- buttons,
dropdowns, color swatches, ribbon spacing -- is keyed off that class,
not off --x-small-btn-size in isolation. checkSize() runs on load and
on window resize, and is also exposed directly as
Common.Utils.checkSize, so it can be re-run here instead of faking a
resize event.

Verified live via CDP: after overriding devicePixelRatio and calling
checkSize(), document.body correctly gains e.g. "pixel-ratio__1_5", and
the Bold toolbar button's rendered height changes accordingly (20px ->
23px) via the app's own CSS, with no per-selector patching needed.

Removed the entire !important stylesheet-injection block in favor of
this single checkSize() call.

Signed-off-by: Peter P. Lupo <pplupo@gmail.com>
Two bugs found via live CDP console monitoring during an actual OS-level
display scale change (1.15 -> 1.5 -> 1.15):

1. checkSize()/checkZoom() never actually ran in production. OnLoadEnd
   fires before the app's own JS bootstrap finishes -- Common.Utils and
   AscCommon were confirmed always undefined at first injection (logged
   "not present at injection time" on every load). The fix only ever
   appeared to work in manual CDP testing because those tests ran well
   after the page had settled. Now polls every 200ms (up to ~10s) until
   both are available, instead of trying once and giving up silently.

2. No re-injection happened at all during the actual scale change --
   confirmed via a 90-second live console monitor spanning the whole
   test, which captured zero additional [UIScale] log lines. Neither
   moveEvent nor resizeEvent fire on a pure OS-level scale change (ruling
   out the moveEvent-based race theorized earlier); no Qt/CEF signal for
   this was found. Added a 1s QTimer in QCefView that polls
   GetUIScalePercentage() and only re-injects when the value actually
   changes, which is the mechanism that will actually pick up a live
   scale change now.

Signed-off-by: Peter P. Lupo <pplupo@gmail.com>
User reports the enlarge still reverts to the small size even without
changing the scale back -- but the last monitoring session only watched
for ~13s and saw no revert in that window. Need to see whether Qt's own
devicePixelRatio() is itself unstable/flip-flopping some time after a
real display-scale change (independent of whether this code reacts to
it correctly), over a longer window. Logs every 1s poll tick's raw
GetUIScalePercentage() value to /tmp/uiscale_debug.log regardless of
whether it triggered a re-injection.

Temporary; remove alongside the rest of the debug logging once resolved.

Signed-off-by: Peter P. Lupo <pplupo@gmail.com>
…ePixelRatio

User confirmed the revert happens almost instantly -- icons/text flash
to the correct larger size for what looks like a single frame, then
snap back. That's far faster than anything a 1s poll or CDP console
logging could observe reliably, and points to a race with Chromium
itself rather than anything in this code's own timing.

The renderer runs with --force-device-scale-factor=1
(client_renderer_wrapper / cef command line). window.devicePixelRatio
is a browser-native, reactive WebIDL property; a real OS-level
display-scale change plausibly lets Chromium reassert that forced
value through its own internal layout/DPI-change handling faster than
the JS-level Object.defineProperty() override set here can be trusted
to stick -- especially once the app's own native $(window).on('resize',
checkSize) handler fires (which is presumably what is actually racing
here, given it's wired to real resize events this app's own code
already relies on elsewhere).

window.AscCommon.checkDeviceScale() -- the actual function both
checkSize() (CSS pixel-ratio__N class) and AscBrowser.checkZoom()
(canvas retinaPixelRatio) call to read the scale -- is a plain
app-defined JS function, not a browser-native property. Nothing in
Chromium can silently reassert it the way it can devicePixelRatio.
Monkeypatch it directly to return this code's computed factor instead
of reading window.devicePixelRatio, closing the race regardless of
what order the native resize handler and this code's own poll fire in.

Signed-off-by: Peter P. Lupo <pplupo@gmail.com>
Per explicit request: apply the display-scale correction as a single
uniform constant to the whole UI via CEF's own page zoom, rather than
continuing to patch individual CSS classes/JS functions -- guarantees
proportional scaling everywhere in one place, and (per this reasoning)
should not need any mouse-coordinate compensation, since Chromium's own
input pipeline already translates physical-pixel events into
zoomed-layout coordinates as core functionality, independent of
whatever this app's own C++ does for view-rect sizing.

This codebase already has a CheckZoom()/SetZoomLevel() mechanism
(further down in this file) that is currently dead on Linux
(GetDpiChecker() is WIN32-only) and explicitly skips zoom on Wayland,
with a comment warning that GetScreenInfo's device_scale_factor and
SetZoomLevel's CSS zoom compound into double-scaling if both are
non-neutral simultaneously. That comment predates dsf-1.0-osr, which
now unconditionally forces device_scale_factor to 1.0 (neutral) --
GetScreenInfo no longer contributes any real scale to compound with,
so SetZoomLevel can safely be the sole scaling mechanism now.

Removes the CSS custom property / pixel-ratio__N body-class / stylesheet
injection entirely. Keeps only the checkDeviceScale() monkeypatch +
AscBrowser.checkZoom() trigger, needed for the separate case of the
document/page <canvas> element, whose backing-buffer resolution is set
explicitly by sdkjs's own JS independent of page zoom -- zoom stretches
whatever resolution the canvas already has, so it still needs the real
display scale fed to it directly to avoid blurriness.

Signed-off-by: Peter P. Lupo <pplupo@gmail.com>
GetUIScalePercentage() unconditionally used devicePixelRatio(), which
still reflects Xft.dpi under xcb despite AA_Use96Dpi and the HiDPI env
overrides. That value was fed into CEF's page zoom on top of the
Xft.dpi-based geometry scale already applied via
QDpiChecker::GetMonitorDpi()/Core_GetMonitorScale, double-scaling
content relative to the window frame and leaving an unpainted strip.
Restrict the devicePixelRatio()-based zoom to Wayland, where it is the
sole source of scale.

Signed-off-by: Peter P. Lupo <pplupo@gmail.com>
Init()'s XCreateSimpleWindow and SetWindowSize() (used by UpdateSize())
size the embedded CEF child window directly from QWidget::width()/
height(), which are device-independent pixels. X11 geometry is
physical pixels, and the outer Qt window ends up sized in physical
pixels regardless, since devicePixelRatio() isn't reliably neutralized
by AA_Use96Dpi under XWayland. That mismatch left the CEF child window
undersized relative to the real window frame, leaving an unpainted
region. Scale both by devicePixelRatio() to match.

Signed-off-by: Peter P. Lupo <pplupo@gmail.com>
Fixing the CEF-zoom double-application (previous commit) and the CEF
child window's undersized X11 geometry (commit before that) surfaced a
third gap: CEF content and native Qt chrome (tab bar, etc.) each got
their scale from a different, platform-exclusive source, and each
source only worked on one platform:

- Native chrome uses QDpiChecker::GetMonitorDpi(), which is Xft.dpi-
  based and correct on xcb but inert on Wayland (getX11SessionDpi()
  returns 0 there), falling back to a flat, unscaled DPI regardless of
  the compositor's real output scale.
- CEF content uses QCefView::GetUIScalePercentage(), gated to Wayland-
  only after the previous fix and returning a flat 100% (no zoom) on
  xcb, since the double-application it used to have there is gone but
  nothing replaced it with the single correct scale.

Result: xcb had correctly-scaled chrome but unscaled (too small) CEF
content; Wayland had correctly-scaled CEF content but unscaled (too
small) chrome.

Fix both sides to read from the same source per platform:
- GetUIScalePercentage() on xcb now derives its percentage from
  QDpiChecker::GetMonitorDpi()/GetScale(), the same Xft.dpi-based value
  native chrome already uses, instead of a flat 100%.
- GetMonitorDpi() on Wayland now derives an equivalent DPI from
  devicePixelRatio() when getX11SessionDpi() is inert, instead of
  falling back to a flat, unscaled physicalDotsPerInchX()/Y().

Also documents the undocumented small-screen (<=1600x900) 96-DPI
clamp's history (Ascensio/ONLYOFFICE tracker bugs 50621/50711, flipped
between 192 and 96 DPI three days apart with no rationale recorded in
either commit) and carves out a Wayland exception, since it would
otherwise silently override the new Wayland devicePixelRatio()-derived
DPI on small outputs.

Signed-off-by: Peter P. Lupo <pplupo@gmail.com>
Wayland shows two distinct GetUIScalePercentage() values across the
two QCefView types (cvwtSimple shell vs cvwtEditor document content),
125% and 200%, and the tab bar appears rendered at its unscaled base
size rather than either. Need type + geometry alongside the existing
percentage/zoom values to identify which view is which and why the
tab bar doesn't match. Remove once the underlying issue is found.

Signed-off-by: Peter P. Lupo <pplupo@gmail.com>
pplupo and others added 19 commits July 22, 2026 16:08
The poll timer and moveEvent() each independently read
devicePixelRatio()-derived scale and applied it immediately on any
change. During startup, window placement fires several real move/
resize events in quick succession, and devicePixelRatio() can
transiently misreport (observed: 200% then 125% then 200% again,
repeating, before settling permanently) on individual reads taken
around those events -- each misread was applied immediately since
nothing checked whether a reading was actually stable.

Extract the shared logic into MaybeUpdateUIScale() and only commit
(call UpdateUIScalePercentage()) once the same value is read on two
consecutive checks, from either trigger. moveEvent() now goes through
this same debounced path instead of committing synchronously, so a
single bad reading just becomes the pending value and gets overwritten
by the next check rather than being acted on.

Signed-off-by: Peter P. Lupo <pplupo@gmail.com>
The previous debounce lived in QCefView, guarding only the poll timer
and moveEvent() -- but OnLoadEnd() (cefview.cpp) calls
CCefView::UpdateUIScalePercentage() directly once per frame, including
the nested iframe the actual editor UI loads in, completely bypassing
that guard. Since multiple frames finish loading in quick succession
during startup, each transiently-misread devicePixelRatio() value was
still being committed via this uncovered path.

Move the debounce into UpdateUIScalePercentage() itself, since it's
the single choke point all three triggers (poll timer, moveEvent,
OnLoadEnd) converge on already reading GetUIScalePercentage()
internally. Unlike the QCefView version, this can't just skip its own
body when a reading isn't yet confirmed -- the per-frame JS injection
below still needs to run on every call regardless of whether the
scale changed, since each frame is a separate JS context -- so instead
of skipping, it falls back to the last confirmed value until the new
one is seen twice in a row.

Signed-off-by: Peter P. Lupo <pplupo@gmail.com>
QDpiChecker::GetWidgetDpi() delegated to GetMonitorDpi(), which reads
QScreen::devicePixelRatio() -- but native widgets (window decoration,
title-bar buttons, dialogs) were still reading a too-high value
(2.0 instead of 1.25) well after startup, even once CEF's UI scale
(which reads QWidget::devicePixelRatio() instead, already proven
correct/self-correcting) had settled. QScreen's value appears to stay
pinned to the initial integer-rounded buffer scale and never receive
the same fractional-scale correction. Read the widget's own
devicePixelRatio() directly instead of going through the screen-level
lookup for the Wayland case.

Signed-off-by: Peter P. Lupo <pplupo@gmail.com>
That change assumed QWidget::devicePixelRatio() was more reliable than
QScreen::devicePixelRatio() on Wayland, but empirical testing showed
both report the identical wrong value for the same widget -- the real
cause was Qt's Wayland platform plugin permanently rounding
devicePixelRatio() to the nearest integer unless the app opts into
Qt::HighDpiScaleFactorRoundingPolicy::PassThrough (now set in
desktop-apps' main.cpp), which fixes both equally. This switch
provided no benefit once that's set, so restore the simpler
screen-based lookup.

Signed-off-by: Peter P. Lupo <pplupo@gmail.com>
Lets the CEF log be correlated by time against desktop-apps' native-
widget DPI debug log, to confirm whether native widgets hit the same
startup devicePixelRatio() race already proven for CEF.

Signed-off-by: Peter P. Lupo <pplupo@gmail.com>
QScreen::devicePixelRatio() can stay stuck at the rounded integer
value after PassThrough is set, while a widget's own
devicePixelRatio() correctly receives the compositor's async
fractional-scale answer. Query the widget directly on Wayland
instead of going through the screen-based lookup.

Signed-off-by: Peter P. Lupo <pplupo@gmail.com>
Investigation is concluded; the underlying fix is confirmed working.

Signed-off-by: Peter P. Lupo <pplupo@gmail.com>
Signed-off-by: Hendrik Leidinger <hendrik.leidinger@nextcloud.com>
A freshly created Wayland surface reports the compositor's rounded
integer scale rather than the real fractional one until the
fractional-scale protocol replies -- measured at 2.0 for roughly 90ms
on a 1.25 output. The first UI-scale read caught that value, so CEF
page zoom was set to 200% and then revised to 125% once it corrected.
That revision is the visible jump in the editor and its ribbon during
startup. The native chrome does not move, because its manual factor is
a residual that divides devicePixelRatio out and so reads the same in
both states.

Report a negative percentage while the surface is younger than the
settle window, and skip applying the scale on a negative reading.
Because the debounce state starts at -1, the first reading after the
window is applied directly, so the correct zoom is the first one ever
set rather than the second.

A one-shot timer fires just after the window closes so the scale is
applied promptly; the existing 1s poll would otherwise leave room for
the page to paint unscaled first. A compositor slower than the settle
window corrects on the next poll tick, exactly as it does today.

Verified with a spreadsheet and a document open: every reading 125%,
no 200% reading at all.

Signed-off-by: Peter P. Lupo <pplupo@gmail.com>
CCefView::UpdateUIScalePercentage() required a new reading to appear on
two consecutive calls before committing to it, guarding against
devicePixelRatio() transiently misreporting during startup window
placement. That race is already fully handled upstream:
QCefView::GetUIScalePercentage() withholds a value entirely (returns
negative, skipped before reaching this debounce) while the surface is
younger than its settle window.

Traced with per-call logging across repeated monitor-to-monitor moves
(drag and instant keyboard-shortcut crossings, screens at 1.25x and
1.5x): the raw reading was correct on the very first call after every
single crossing, with devicePixelRatio() reflecting the new output
before window()->screen() even caught up to it. The debounce was
therefore not guarding against anything on a real scale change --
it was unconditionally delaying every one by two calls, holding the
old, wrong-for-the-new-monitor zoom in between. That delay is what
was visible as the editor and ribbon rescaling twice on every monitor
crossing.

Apply GetUIScalePercentage()'s value directly and drop the now-fully-
orphaned m_dPendingUIScalePercentage/m_dLastKnownUIScalePercentage
debounce state.

Signed-off-by: Peter P. Lupo <pplupo@gmail.com>
OnCursorChange was added to bridge cursor-shape changes to the OS on
Wayland, where OSR mode gives CEF no native window to set the cursor
on itself. It was written unconditionally, though, with no OSR/Wayland
check, and always returned true -- the signal that tells CEF the app
already set the cursor and CEF should not.

In windowed mode (X11/xcb, Windows), CEF owns a real native child
window and sets the OS cursor on it directly when this handler returns
false. Returning true unconditionally told CEF's windowed-mode cursor
handling to stand down on every platform, not just Wayland OSR. The
app's own fallback, SetCursorType(), then calls setCursor() on the Qt
QCefView ancestor widget -- which has no visible effect, since the
pointer sits over CEF's own separate native child window, not that
ancestor. Net result on any windowed platform: the cursor froze at
whatever CEF's child window had by default (a plain arrow) everywhere
in the editor content -- no I-beam over text, no resize handles, no
row/column select arrows, no move cursor.

Gate the interception to GetWidgetImpl()->IsWayland(), matching the
comment already documenting the OSR-only rationale. Windowed platforms
now return false and let CEF handle its own cursor as designed.

Signed-off-by: Peter P. Lupo <pplupo@gmail.com>
UpdateUIScalePercentage() applied SetZoomLevel + WasResized + the
per-frame checkDeviceScale/checkZoom JS injection unconditionally on
every call, including the 1s poll timer tick, regardless of whether
the scale had actually changed since the last apply.

Cache the last-applied percentage and skip SetZoomLevel/WasResized when
it matches -- those are whole-browser, genuinely idempotent operations,
safe to skip. The per-frame JS injection loop is deliberately NOT
gated by the same check: this function is also OnLoadEnd()'s trigger
for a newly loaded frame (including nested iframes), which has never
received the checkDeviceScale monkeypatch even when the global scale
itself hasn't moved since the last apply -- skipping it there would
silently break the injection for any document opened at an already-
seen display scale.

Also drops the two [UIScale] console.log calls (the checkZoom re-run
trace and the give-up-waiting message); the console.error failure
traces stay.

Signed-off-by: Peter P. Lupo <pplupo@gmail.com>
…r image paste

CClipboardQueryHandler (the paste/'clipboard_read' side of the bridge)
was registered unconditionally in RegisterClipboardQueryHandler(), on
every platform. The bridge exists solely to work around CEF's own OS
clipboard integration being unusable in Wayland OSR mode -- windowed
platforms (X11, Windows) already have working native clipboard
integration via CEF's own real window, and registering this handler
there too meant it activated alongside CEF's own clipboard.write()
path, racing for the same OS clipboard. Only register it when
GetWidgetImpl()->IsWayland(). The write side (nativeClipboardWrite) is
addressed on the JS side instead (sdkjs commit gating
_isNativeClipboardAvailable() on window.RendererProcessVariable.
isWayland), since the binding itself sits in a large fixed-size
platform-independent method table not worth restructuring for this.

Separately: QCefView::GetClipboardData() only ever checked
QMimeData::hasImage() (raw pixel data, e.g. a screenshot tool's 'copy
image'), never hasUrls() -- which is what a file manager's 'Copy'
actually populates (Qt normalizes text/uri-list, CF_HDROP, etc into
this one portable API). So copying an image file in the file manager
and pasting it into a document silently did nothing under Wayland: no
recognized clipboard format ever reached the JS side to match against.
Now checks hasUrls() when hasImage() didn't already find something,
and loads+encodes a single local image-file URL as PNG through the
same image/png path the hasImage() branch already used, so it flows
through the existing NativePaste() image handling in
clipboard_base.js unchanged. Pasting a non-image file remains a no-op,
consistent with every other document editor.

Signed-off-by: Peter P. Lupo <pplupo@gmail.com>
Traced with real diagnostics: the document opened unscaled under
Wayland, but manually toggling the display scale afterward corrected
it. window.AscCommon.AscBrowser.retinaPixelRatio (what drives canvas
backing-store resolution and CSS scale throughout sdkjs) *was* already
correcting itself shortly after page load, via this function's own
existing monkeypatch-then-poll mechanism -- confirmed via a console
diagnostic showing applicationPixelRatio jump from 1 to the real value
(e.g. 1.25) about 180ms after the page's own devicePixelRatio-based
first read.

The gap: AscBrowser.checkZoom() (what the poll calls) only updates
that internal state: it does not itself trigger a repaint at the
corrected scale. That only happens through each editor's own
asc_Resize()-equivalent, which every editor's web-apps Viewport.js
already wires to a genuine window 'resize' DOM event -- exactly what
manually toggling the display scale fires naturally, which is why that
already fixed it. The poll's correction landing asynchronously after
the document has already opened does not fire that event, so
retinaPixelRatio became correct internally while nothing ever redrew
to match it.

Dispatch a synthetic 'resize' after checkZoom() succeeds, so the same
already-correct, editor-agnostic resize pipeline picks up the
correction. Deduped per frame against the last f actually dispatched
for: this per-frame injection re-runs on every call including the 1s
poll tick even when the scale is unchanged (by design, see the
existing comment above this string -- a newly loaded frame still needs
the monkeypatch), so without the dedup this would fire a resize/
redraw every second forever instead of once per real change.

Signed-off-by: Peter P. Lupo <pplupo@gmail.com>
…s own window

The prior fix dispatched 'resize' on window -- but window here is the
canvas frame's own window, a nested iframe and separate browsing
context from the outer shell page (see the existing comment on the
injection loop below: 'a separate browsing context from the outer
shell page'). The $(window).on('resize', ...) listener that actually
triggers each editor's asc_Resize()-equivalent redraw lives on the
outer shell's window, not the iframe's -- a resize event dispatched
inside a child iframe does not propagate to window.top, so the
previous fix corrected AscBrowser.retinaPixelRatio's internal state
(confirmed via diagnostic) but the redraw listener it was meant to
reach never received anything and the document stayed visibly
unscaled, exactly as before.

Target window.top explicitly, falling back to window if already at
the top (harmless no-op difference). Logs which target it used and for
which factor, to confirm on the next test.

Signed-off-by: Peter P. Lupo <pplupo@gmail.com>
The window.top dispatch was confirmed firing with no error (logged),
yet the document stayed visibly unscaled at open -- only a much later,
real (user-triggered) scale change actually redrew it. The two only
differ in timing: a real scale change necessarily happens well after
the app has fully loaded, while this poll's dispatch typically fires
under 1s after launch, plausibly before the outer shell page's own UI
framework has finished initializing and attached its
$(window).on('resize', ...) listener (Viewport.js) -- an early
dispatch would then land on nothing.

Retry a few times over the next several seconds instead of dispatching
once, so at least one attempt lands after the outer shell is
definitely ready. This is a timing-robustness fix based on the
available evidence (dispatch confirmed firing; only a late dispatch
observed to work), not a confirmed root cause for why the very first
attempt specifically goes unheard -- if this doesn't resolve it, that
needs its own diagnostic (e.g. checking whether the listener is even
registered yet at dispatch time).

Signed-off-by: Peter P. Lupo <pplupo@gmail.com>
Reverts 94ba7b4, 2a8a0d3 and ddb1ec4. All three were built on a
misdiagnosis: that the document stayed unscaled because sdkjs's
retinaPixelRatio correction landed without anything triggering a
redraw. Diagnostics disproved it -- the final version dispatched
'resize' on window.top four times across three seconds, every dispatch
logged with no error, and the document still rendered unscaled.

The actual cause is elsewhere (fixed separately): CEF page zoom being
dropped by navigation and never re-applied. These commits add a
synthetic-resize burst that fixes nothing, so drop them rather than
leave unrelated churn on the branch.

Signed-off-by: Peter P. Lupo <pplupo@gmail.com>
Regression from the SetZoomLevel dedup added for the PR review
(bfdfdd5): that cached the last-applied percentage and skipped
re-applying SetZoomLevel/WasResized when it matched, but nothing ever
invalidated the cache. CEF's page zoom is per-load -- navigating drops
it -- so the sequence was: first UpdateUIScalePercentage() applies the
zoom and caches 125, the editor then navigates to the actual document,
CEF resets the zoom for that load, and every subsequent poll sees an
unchanged 125, skips, and never restores it. The document rendered
unscaled indefinitely.

That matches the reported symptom exactly, including why it was
self-healing: any real display-scale change makes the percentage
differ from the cached value, so the zoom gets applied again and stays
correct (and toggling back likewise). It also explains why the earlier
resize-dispatch attempts had no effect -- sdkjs's own retinaPixelRatio
correction was working the whole time; it was CEF page zoom that was
missing.

Reset the cache in OnLoadStart, alongside the m_dDeviceScale reset
already there for the same per-load reason, so the next call after any
navigation re-applies the zoom. Keeps the dedup's benefit for the 1s
poll tick, which is what the review asked for.

Signed-off-by: Peter P. Lupo <pplupo@gmail.com>
The review asked for the [UIScale] console.log calls to go; these two
console.error calls are the same debug instrumentation in the same
injected snippet, introduced by the same commit, and equally have no
place running in every frame of a shipped build.

Keep the try/catch in both cases -- this script is injected into every
frame, including ones with no AscCommon at all, so a throw would stop
the poll below from ever being installed. Neither site has any recovery
to attempt, so the catches are intentionally empty, with a comment
saying why rather than leaving a bare block for the next reader to
wonder about.

Signed-off-by: Peter P. Lupo <pplupo@gmail.com>
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