fix(xwm): announce scale 1 to X11 clients while Xwayland runs at logical size
- commit
- 13ff6ac624533c1ed5fb1f224ac1abcb7efa104a
- author
- brice · · 2026-10-22 17:01 UTC
- committer
- brice · · 2026-10-22 17:55 UTC
- parent
- f2f4074
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
8 files · +69 -63
M docs/config.md +2 -2
| @@ -28,7 +28,7 @@ See [examples/config](../examples/config) for every key with its default. | |||
| 28 | 28 | | `terminal.auto-open` | `first` | Open a terminal on the first workspace (`first`), on every empty one (`all`), or never (`off`) | | |
| 29 | 29 | | `startup` | none | Commands run at session start, separated by `;` | | |
| 30 | 30 | | `xwayland` | `satellite` | `off`, `satellite`, `builtin` or a satellite binary path | | |
| 31 | - | | `xwayland.xsettings` | `on` | With `xwayland = builtin`, publish `Gtk/CursorThemeName`, `Gtk/CursorThemeSize` and `Xft/DPI` to X11 apps through XSETTINGS (restart required); `off` leaves them to the apps | | |
| 31 | + | | `xwayland.xsettings` | `on` | With `xwayland = builtin`, publish `Gtk/CursorThemeName`, `Gtk/CursorThemeSize`, `Xft/DPI`, `Gdk/WindowScalingFactor` and `Gdk/UnscaledDPI` to X11 apps through XSETTINGS (restart required); the scale is always announced as 1 (96 dpi) because X11 apps see logical sizes and neferwl upscales their windows; `off` leaves them to the apps | | |
| 32 | 32 | | `background` | `#111111` | Background color | | |
| 33 | 33 | | `border.radius` | `0` | Corner radius in logical pixels, `0` to `64`; floats always, tiles only with gaps | | |
| 34 | 34 | | `border.width` | `2` | Border width in pixels; `0` hides borders | | |
| @@ -104,7 +104,7 @@ destination output, even if that output has no windows. Same-screen workspace | |||
| 104 | 104 | switches and mouse focus do not trigger a flash. | |
| 105 | 105 | It runs when `animations = on`. | |
| 106 | 106 | ||
| 107 | - | `xwayland = satellite` uses `xwayland-satellite` from `PATH`; a custom path also selects satellite. `off` disables X11. `builtin` (experimental): neferwl starts `Xwayland` itself and manages X11 windows as its window manager: windows tile, take focus, close with `close-window` (`WM_DELETE_WINDOW`) and show their title and class. Menus, popups and tooltips (override-redirect windows) are drawn above their window at the right place and never appear as windows in `neferwl state`. X11 apps get the cursor theme and size from `XCURSOR_THEME` and `XCURSOR_SIZE`, the DPI of the first output (96 times its scale) through XSETTINGS (`xwayland.xsettings = off` turns it off), and the themed arrow on the X background. The clipboard and the primary selection are shared with X11 apps in both directions (text only; nothing crosses while the session is locked or otherwise protected). | |
| 107 | + | `xwayland = satellite` uses `xwayland-satellite` from `PATH`; a custom path also selects satellite. `off` disables X11. `builtin` (experimental): neferwl starts `Xwayland` itself and manages X11 windows as its window manager: windows tile, take focus, close with `close-window` (`WM_DELETE_WINDOW`) and show their title and class. Menus, popups and tooltips (override-redirect windows) are drawn above their window at the right place and never appear as windows in `neferwl state`. X11 apps get the cursor theme and size from `XCURSOR_THEME` and `XCURSOR_SIZE`, a scale-1 DPI (96) through XSETTINGS (X11 apps see logical sizes; neferwl upscales their windows) (`xwayland.xsettings = off` turns it off), and the themed arrow on the X background. The clipboard and the primary selection are shared with X11 apps in both directions (text only; nothing crosses while the session is locked or otherwise protected). | |
| 108 | 108 | ||
| 109 | 109 | ## Automatic terminal | |
| 110 | 110 | ||
| @@ -28,7 +28,7 @@ See [examples/config](../examples/config) for every key with its default. | |||||
| 28 | | `terminal.auto-open` | `first` | Open a terminal on the first workspace (`first`), on every empty one (`all`), or never (`off`) | | 28 | | `terminal.auto-open` | `first` | Open a terminal on the first workspace (`first`), on every empty one (`all`), or never (`off`) | | ||
| 29 | | `startup` | none | Commands run at session start, separated by `;` | | 29 | | `startup` | none | Commands run at session start, separated by `;` | | ||
| 30 | | `xwayland` | `satellite` | `off`, `satellite`, `builtin` or a satellite binary path | | 30 | | `xwayland` | `satellite` | `off`, `satellite`, `builtin` or a satellite binary path | | ||
| 31 | - | | `xwayland.xsettings` | `on` | With `xwayland = builtin`, publish `Gtk/CursorThemeName`, `Gtk/CursorThemeSize` and `Xft/DPI` to X11 apps through XSETTINGS (restart required); `off` leaves them to the apps | | 31 | + | | `xwayland.xsettings` | `on` | With `xwayland = builtin`, publish `Gtk/CursorThemeName`, `Gtk/CursorThemeSize`, `Xft/DPI`, `Gdk/WindowScalingFactor` and `Gdk/UnscaledDPI` to X11 apps through XSETTINGS (restart required); the scale is always announced as 1 (96 dpi) because X11 apps see logical sizes and neferwl upscales their windows; `off` leaves them to the apps | |
| 32 | | `background` | `#111111` | Background color | | 32 | | `background` | `#111111` | Background color | | ||
| 33 | | `border.radius` | `0` | Corner radius in logical pixels, `0` to `64`; floats always, tiles only with gaps | | 33 | | `border.radius` | `0` | Corner radius in logical pixels, `0` to `64`; floats always, tiles only with gaps | | ||
| 34 | | `border.width` | `2` | Border width in pixels; `0` hides borders | | 34 | | `border.width` | `2` | Border width in pixels; `0` hides borders | | ||
| @@ -104,7 +104,7 @@ destination output, even if that output has no windows. Same-screen workspace | |||||
| 104 | switches and mouse focus do not trigger a flash. | 104 | switches and mouse focus do not trigger a flash. | ||
| 105 | It runs when `animations = on`. | 105 | It runs when `animations = on`. | ||
| 106 | 106 | ||||
| 107 | - | `xwayland = satellite` uses `xwayland-satellite` from `PATH`; a custom path also selects satellite. `off` disables X11. `builtin` (experimental): neferwl starts `Xwayland` itself and manages X11 windows as its window manager: windows tile, take focus, close with `close-window` (`WM_DELETE_WINDOW`) and show their title and class. Menus, popups and tooltips (override-redirect windows) are drawn above their window at the right place and never appear as windows in `neferwl state`. X11 apps get the cursor theme and size from `XCURSOR_THEME` and `XCURSOR_SIZE`, the DPI of the first output (96 times its scale) through XSETTINGS (`xwayland.xsettings = off` turns it off), and the themed arrow on the X background. The clipboard and the primary selection are shared with X11 apps in both directions (text only; nothing crosses while the session is locked or otherwise protected). | 107 | + | `xwayland = satellite` uses `xwayland-satellite` from `PATH`; a custom path also selects satellite. `off` disables X11. `builtin` (experimental): neferwl starts `Xwayland` itself and manages X11 windows as its window manager: windows tile, take focus, close with `close-window` (`WM_DELETE_WINDOW`) and show their title and class. Menus, popups and tooltips (override-redirect windows) are drawn above their window at the right place and never appear as windows in `neferwl state`. X11 apps get the cursor theme and size from `XCURSOR_THEME` and `XCURSOR_SIZE`, a scale-1 DPI (96) through XSETTINGS (X11 apps see logical sizes; neferwl upscales their windows) (`xwayland.xsettings = off` turns it off), and the themed arrow on the X background. The clipboard and the primary selection are shared with X11 apps in both directions (text only; nothing crosses while the session is locked or otherwise protected). |
| 108 | 108 | ||||
| 109 | ## Automatic terminal | 109 | ## Automatic terminal | ||
| 110 | 110 | ||||
M docs/desktop.md +1 -1
| @@ -58,7 +58,7 @@ By default, X11 apps such as Steam run through [xwayland-satellite](https://gith | |||
| 58 | 58 | xwayland = off # no X11 support | |
| 59 | 59 | ``` | |
| 60 | 60 | ||
| 61 | - | With `xwayland = builtin` (experimental), neferwl starts `Xwayland` itself and manages X11 windows: they tile, take focus and close like Wayland windows. Menus, popups and tooltips of X11 apps are shown above their window and do not take the keyboard focus. X11 apps get the cursor theme (`XCURSOR_THEME`), cursor size (`XCURSOR_SIZE`) and DPI (96 times the scale of the first output) through XSETTINGS (`Gtk/CursorThemeName`, `Gtk/CursorThemeSize`, `Xft/DPI`; `xwayland.xsettings = off` to disable), and the X background shows the themed arrow. The clipboard and the primary selection work between X11 and Wayland apps in both directions, for text; large copies are transferred in chunks (INCR). While the session is locked, no selection data crosses. | |
| 61 | + | With `xwayland = builtin` (experimental), neferwl starts `Xwayland` itself and manages X11 windows: they tile, take focus and close like Wayland windows. Menus, popups and tooltips of X11 apps are shown above their window and do not take the keyboard focus. X11 apps get the cursor theme (`XCURSOR_THEME`), cursor size (`XCURSOR_SIZE`) and DPI through XSETTINGS, always announced as scale 1 (96 dpi) because X11 apps see logical sizes and neferwl upscales their windows (`Gtk/CursorThemeName`, `Gtk/CursorThemeSize`, `Xft/DPI`, `Gdk/WindowScalingFactor`, `Gdk/UnscaledDPI`; `xwayland.xsettings = off` to disable), and the X background shows the themed arrow. The clipboard and the primary selection work between X11 and Wayland apps in both directions, for text; large copies are transferred in chunks (INCR). While the session is locked, no selection data crosses. | |
| 62 | 62 | ||
| 63 | 63 | Games with `builtin`: | |
| 64 | 64 | ||
| @@ -58,7 +58,7 @@ By default, X11 apps such as Steam run through [xwayland-satellite](https://gith | |||||
| 58 | xwayland = off # no X11 support | 58 | xwayland = off # no X11 support | ||
| 59 | ``` | 59 | ``` | ||
| 60 | 60 | ||||
| 61 | - | With `xwayland = builtin` (experimental), neferwl starts `Xwayland` itself and manages X11 windows: they tile, take focus and close like Wayland windows. Menus, popups and tooltips of X11 apps are shown above their window and do not take the keyboard focus. X11 apps get the cursor theme (`XCURSOR_THEME`), cursor size (`XCURSOR_SIZE`) and DPI (96 times the scale of the first output) through XSETTINGS (`Gtk/CursorThemeName`, `Gtk/CursorThemeSize`, `Xft/DPI`; `xwayland.xsettings = off` to disable), and the X background shows the themed arrow. The clipboard and the primary selection work between X11 and Wayland apps in both directions, for text; large copies are transferred in chunks (INCR). While the session is locked, no selection data crosses. | 61 | + | With `xwayland = builtin` (experimental), neferwl starts `Xwayland` itself and manages X11 windows: they tile, take focus and close like Wayland windows. Menus, popups and tooltips of X11 apps are shown above their window and do not take the keyboard focus. X11 apps get the cursor theme (`XCURSOR_THEME`), cursor size (`XCURSOR_SIZE`) and DPI through XSETTINGS, always announced as scale 1 (96 dpi) because X11 apps see logical sizes and neferwl upscales their windows (`Gtk/CursorThemeName`, `Gtk/CursorThemeSize`, `Xft/DPI`, `Gdk/WindowScalingFactor`, `Gdk/UnscaledDPI`; `xwayland.xsettings = off` to disable), and the X background shows the themed arrow. The clipboard and the primary selection work between X11 and Wayland apps in both directions, for text; large copies are transferred in chunks (INCR). While the session is locked, no selection data crosses. |
| 62 | 62 | ||||
| 63 | Games with `builtin`: | 63 | Games with `builtin`: | ||
| 64 | 64 | ||||
M docs/dev/headless.md +2 -2
| @@ -66,7 +66,7 @@ WAYLAND_DISPLAY=<name> go run ./examples/testime -preedit nihon -commit 日本 | |||
| 66 | 66 | ||
| 67 | 67 | `make e2e-xwayland` runs the end-to-end test of the built-in window manager (`xwayland = builtin`). It is behind the `xwayland` build tag, so `go test ./...` and `make check` only compile it (`go vet -tags xwayland`). It needs `Xwayland` on `PATH` and a GPU, and skips without Xwayland. The clipboard test also needs `wl-copy` and `wl-paste` (wl-clipboard) and skips without them; they run with the headless `WAYLAND_DISPLAY` and need no extra focus step, since the focused probe window is enough for `wl-copy --foreground` to set the selection. | |
| 68 | 68 | ||
| 69 | - | The test starts a headless NeferWL with a real `Xwayland` on the first free X display (a live session's `:0` stays untouched) and runs `internal/app/testdata/x11probe`, a small X11 client (`-class`, `-title`, `-size`, `-sleep`, `-at X,Y`) that paints its window green, prints `mapped WxH` and `focus`, and exits 0 on `WM_DELETE_WINDOW`. With `-override` it also maps a magenta override-redirect window (`-override-at`, `-override-size`, `-override-type menu|tooltip`, `-override-for`, `-override-shape`, `-override-dismiss-on-focusout`) and prints `override mapped`, `override unmapped` or `dismissed`. `-fullscreen` (with `-fullscreen-delay`, `-fullscreen-off`) requests `_NET_WM_STATE_FULLSCREEN` by client message and prints `fullscreen WxH` / `fullscreen off WxH` when the window manager publishes the state; `-keyboard-grab` calls `XGrabKeyboard` and prints `grab`. Windowless modes: `-copy TEXT|@file` (with `-copy-sel CLIPBOARD|PRIMARY`, `-copy-for`) owns a selection and serves `TARGETS`, `UTF8_STRING`, `STRING` and `TEXT`, with INCR above 64 KiB, and prints `copy owned`; `-paste CLIPBOARD|PRIMARY` prints `paste "<text>"`, `paste len=N sha256=H` above 4 KiB, or `paste none`; `-xsettings` prints `xsettings serial=N Xft/DPI=… Gtk/CursorThemeName=… Gtk/CursorThemeSize=…` or `xsettings none`; `-root-cursor` prints `cursor WxH` from XFixes `GetCursorImage`. `-at` also sends a ConfigureRequest with the window rect before the map, like games do. It checks: | |
| 69 | + | The test starts a headless NeferWL with a real `Xwayland` on the first free X display (a live session's `:0` stays untouched) and runs `internal/app/testdata/x11probe`, a small X11 client (`-class`, `-title`, `-size`, `-sleep`, `-at X,Y`) that paints its window green, prints `mapped WxH` and `focus`, and exits 0 on `WM_DELETE_WINDOW`. With `-override` it also maps a magenta override-redirect window (`-override-at`, `-override-size`, `-override-type menu|tooltip`, `-override-for`, `-override-shape`, `-override-dismiss-on-focusout`) and prints `override mapped`, `override unmapped` or `dismissed`. `-fullscreen` (with `-fullscreen-delay`, `-fullscreen-off`) requests `_NET_WM_STATE_FULLSCREEN` by client message and prints `fullscreen WxH` / `fullscreen off WxH` when the window manager publishes the state; `-keyboard-grab` calls `XGrabKeyboard` and prints `grab`. Windowless modes: `-copy TEXT|@file` (with `-copy-sel CLIPBOARD|PRIMARY`, `-copy-for`) owns a selection and serves `TARGETS`, `UTF8_STRING`, `STRING` and `TEXT`, with INCR above 64 KiB, and prints `copy owned`; `-paste CLIPBOARD|PRIMARY` prints `paste "<text>"`, `paste len=N sha256=H` above 4 KiB, or `paste none`; `-xsettings` prints `xsettings serial=N Xft/DPI=… Gdk/WindowScalingFactor=… Gdk/UnscaledDPI=… Gtk/CursorThemeName=… Gtk/CursorThemeSize=…` or `xsettings none`; `-root-cursor` prints `cursor WxH` from XFixes `GetCursorImage`. `-at` also sends a ConfigureRequest with the window rect before the map, like games do. It checks: | |
| 70 | 70 | ||
| 71 | 71 | - the state lists one visible `X11Probe` window with the probe's pid, and the screenshot is mostly green inside its tile; | |
| 72 | 72 | - the probe receives focus; | |
| @@ -79,7 +79,7 @@ The test starts a headless NeferWL with a real `Xwayland` on the first free X di | |||
| 79 | 79 | - with the keyboard grab, `Super+q` does not close a fullscreen focused client and works again once it leaves fullscreen; on a windowed client it closes it; | |
| 80 | 80 | - on two outputs, a window created at the second output's origin opens on `HEADLESS-2` and fullscreens there; | |
| 81 | 81 | - clipboard: `wl-copy` text of 4 KiB and 1 MiB (INCR) is read by `x11probe -paste`, and an `x11probe -copy` of the same sizes is read byte-exact by `wl-paste`, for CLIPBOARD and PRIMARY; `wl-copy --clear` gives `paste none`; | |
| 82 | - | - XSETTINGS: serial, `Xft/DPI=98304` (scale 1) or `196608` (`output.HEADLESS-1.scale = 2`), and the cursor theme and size from `XCURSOR_THEME`/`XCURSOR_SIZE`; `xwayland.xsettings = off` leaves no owner; | |
| 82 | + | - XSETTINGS: serial, `Xft/DPI=98304` and `Gdk/WindowScalingFactor=1` at any output scale (also with `output.HEADLESS-1.scale = 2`), and the cursor theme and size from `XCURSOR_THEME`/`XCURSOR_SIZE`; `xwayland.xsettings = off` leaves no owner; | |
| 83 | 83 | - root cursor: `GetCursorImage` over the root is the theme arrow at the theme size (a throwaway theme in `XCURSOR_PATH`); | |
| 84 | 84 | - a window rule `rule.games.app-id = steam_app_\d+` floats an X11 window of class `steam_app_413150`, and not another class. | |
| 85 | 85 | ||
| @@ -66,7 +66,7 @@ WAYLAND_DISPLAY=<name> go run ./examples/testime -preedit nihon -commit 日本 | |||||
| 66 | 66 | ||||
| 67 | `make e2e-xwayland` runs the end-to-end test of the built-in window manager (`xwayland = builtin`). It is behind the `xwayland` build tag, so `go test ./...` and `make check` only compile it (`go vet -tags xwayland`). It needs `Xwayland` on `PATH` and a GPU, and skips without Xwayland. The clipboard test also needs `wl-copy` and `wl-paste` (wl-clipboard) and skips without them; they run with the headless `WAYLAND_DISPLAY` and need no extra focus step, since the focused probe window is enough for `wl-copy --foreground` to set the selection. | 67 | `make e2e-xwayland` runs the end-to-end test of the built-in window manager (`xwayland = builtin`). It is behind the `xwayland` build tag, so `go test ./...` and `make check` only compile it (`go vet -tags xwayland`). It needs `Xwayland` on `PATH` and a GPU, and skips without Xwayland. The clipboard test also needs `wl-copy` and `wl-paste` (wl-clipboard) and skips without them; they run with the headless `WAYLAND_DISPLAY` and need no extra focus step, since the focused probe window is enough for `wl-copy --foreground` to set the selection. | ||
| 68 | 68 | ||||
| 69 | - | The test starts a headless NeferWL with a real `Xwayland` on the first free X display (a live session's `:0` stays untouched) and runs `internal/app/testdata/x11probe`, a small X11 client (`-class`, `-title`, `-size`, `-sleep`, `-at X,Y`) that paints its window green, prints `mapped WxH` and `focus`, and exits 0 on `WM_DELETE_WINDOW`. With `-override` it also maps a magenta override-redirect window (`-override-at`, `-override-size`, `-override-type menu|tooltip`, `-override-for`, `-override-shape`, `-override-dismiss-on-focusout`) and prints `override mapped`, `override unmapped` or `dismissed`. `-fullscreen` (with `-fullscreen-delay`, `-fullscreen-off`) requests `_NET_WM_STATE_FULLSCREEN` by client message and prints `fullscreen WxH` / `fullscreen off WxH` when the window manager publishes the state; `-keyboard-grab` calls `XGrabKeyboard` and prints `grab`. Windowless modes: `-copy TEXT|@file` (with `-copy-sel CLIPBOARD|PRIMARY`, `-copy-for`) owns a selection and serves `TARGETS`, `UTF8_STRING`, `STRING` and `TEXT`, with INCR above 64 KiB, and prints `copy owned`; `-paste CLIPBOARD|PRIMARY` prints `paste "<text>"`, `paste len=N sha256=H` above 4 KiB, or `paste none`; `-xsettings` prints `xsettings serial=N Xft/DPI=… Gtk/CursorThemeName=… Gtk/CursorThemeSize=…` or `xsettings none`; `-root-cursor` prints `cursor WxH` from XFixes `GetCursorImage`. `-at` also sends a ConfigureRequest with the window rect before the map, like games do. It checks: | 69 | + | The test starts a headless NeferWL with a real `Xwayland` on the first free X display (a live session's `:0` stays untouched) and runs `internal/app/testdata/x11probe`, a small X11 client (`-class`, `-title`, `-size`, `-sleep`, `-at X,Y`) that paints its window green, prints `mapped WxH` and `focus`, and exits 0 on `WM_DELETE_WINDOW`. With `-override` it also maps a magenta override-redirect window (`-override-at`, `-override-size`, `-override-type menu|tooltip`, `-override-for`, `-override-shape`, `-override-dismiss-on-focusout`) and prints `override mapped`, `override unmapped` or `dismissed`. `-fullscreen` (with `-fullscreen-delay`, `-fullscreen-off`) requests `_NET_WM_STATE_FULLSCREEN` by client message and prints `fullscreen WxH` / `fullscreen off WxH` when the window manager publishes the state; `-keyboard-grab` calls `XGrabKeyboard` and prints `grab`. Windowless modes: `-copy TEXT|@file` (with `-copy-sel CLIPBOARD|PRIMARY`, `-copy-for`) owns a selection and serves `TARGETS`, `UTF8_STRING`, `STRING` and `TEXT`, with INCR above 64 KiB, and prints `copy owned`; `-paste CLIPBOARD|PRIMARY` prints `paste "<text>"`, `paste len=N sha256=H` above 4 KiB, or `paste none`; `-xsettings` prints `xsettings serial=N Xft/DPI=… Gdk/WindowScalingFactor=… Gdk/UnscaledDPI=… Gtk/CursorThemeName=… Gtk/CursorThemeSize=…` or `xsettings none`; `-root-cursor` prints `cursor WxH` from XFixes `GetCursorImage`. `-at` also sends a ConfigureRequest with the window rect before the map, like games do. It checks: |
| 70 | 70 | ||||
| 71 | - the state lists one visible `X11Probe` window with the probe's pid, and the screenshot is mostly green inside its tile; | 71 | - the state lists one visible `X11Probe` window with the probe's pid, and the screenshot is mostly green inside its tile; | ||
| 72 | - the probe receives focus; | 72 | - the probe receives focus; | ||
| @@ -79,7 +79,7 @@ The test starts a headless NeferWL with a real `Xwayland` on the first free X di | |||||
| 79 | - with the keyboard grab, `Super+q` does not close a fullscreen focused client and works again once it leaves fullscreen; on a windowed client it closes it; | 79 | - with the keyboard grab, `Super+q` does not close a fullscreen focused client and works again once it leaves fullscreen; on a windowed client it closes it; | ||
| 80 | - on two outputs, a window created at the second output's origin opens on `HEADLESS-2` and fullscreens there; | 80 | - on two outputs, a window created at the second output's origin opens on `HEADLESS-2` and fullscreens there; | ||
| 81 | - clipboard: `wl-copy` text of 4 KiB and 1 MiB (INCR) is read by `x11probe -paste`, and an `x11probe -copy` of the same sizes is read byte-exact by `wl-paste`, for CLIPBOARD and PRIMARY; `wl-copy --clear` gives `paste none`; | 81 | - clipboard: `wl-copy` text of 4 KiB and 1 MiB (INCR) is read by `x11probe -paste`, and an `x11probe -copy` of the same sizes is read byte-exact by `wl-paste`, for CLIPBOARD and PRIMARY; `wl-copy --clear` gives `paste none`; | ||
| 82 | - | - XSETTINGS: serial, `Xft/DPI=98304` (scale 1) or `196608` (`output.HEADLESS-1.scale = 2`), and the cursor theme and size from `XCURSOR_THEME`/`XCURSOR_SIZE`; `xwayland.xsettings = off` leaves no owner; | 82 | + | - XSETTINGS: serial, `Xft/DPI=98304` and `Gdk/WindowScalingFactor=1` at any output scale (also with `output.HEADLESS-1.scale = 2`), and the cursor theme and size from `XCURSOR_THEME`/`XCURSOR_SIZE`; `xwayland.xsettings = off` leaves no owner; |
| 83 | - root cursor: `GetCursorImage` over the root is the theme arrow at the theme size (a throwaway theme in `XCURSOR_PATH`); | 83 | - root cursor: `GetCursorImage` over the root is the theme arrow at the theme size (a throwaway theme in `XCURSOR_PATH`); | ||
| 84 | - a window rule `rule.games.app-id = steam_app_\d+` floats an X11 window of class `steam_app_413150`, and not another class. | 84 | - a window rule `rule.games.app-id = steam_app_\d+` floats an X11 window of class `steam_app_413150`, and not another class. | ||
| 85 | 85 | ||||
M internal/adapters/wayland/xwm/configure.go +0 -1
| @@ -123,7 +123,6 @@ func (x *XWM) LayoutChanged(changed bool) { | |||
| 123 | 123 | x.warnedSpace = true | |
| 124 | 124 | x.log.Warn().Int("dropped", n).Int("max", maxRegions).Msg("more outputs than the X11 layout holds, extra outputs ignored") | |
| 125 | 125 | } | |
| 126 | - | x.refreshXSettings() | |
| 127 | 126 | if !changed { | |
| 128 | 127 | return | |
| 129 | 128 | } | |
| @@ -123,7 +123,6 @@ func (x *XWM) LayoutChanged(changed bool) { | |||||
| 123 | x.warnedSpace = true | 123 | x.warnedSpace = true | ||
| 124 | x.log.Warn().Int("dropped", n).Int("max", maxRegions).Msg("more outputs than the X11 layout holds, extra outputs ignored") | 124 | x.log.Warn().Int("dropped", n).Int("max", maxRegions).Msg("more outputs than the X11 layout holds, extra outputs ignored") | ||
| 125 | } | 125 | } | ||
| 126 | - | x.refreshXSettings() | |||
| 127 | if !changed { | 126 | if !changed { | ||
| 128 | return | 127 | return | ||
| 129 | } | 128 | } | ||
M internal/adapters/wayland/xwm/selection_xsettings.go +17 -29
| @@ -2,7 +2,6 @@ package xwm | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "encoding/binary" | |
| 5 | - | "math" | |
| 6 | 5 | "unicode/utf8" | |
| 7 | 6 | ||
| 8 | 7 | "bnema.dev/xturbo" | |
| @@ -29,24 +28,18 @@ type xsettings struct { | |||
| 29 | 28 | ||
| 30 | 29 | serial uint32 | |
| 31 | 30 | // Last published values and the serial of their last change. | |
| 32 | - | dpi int32 | |
| 33 | 31 | theme string | |
| 34 | 32 | size int32 | |
| 35 | - | dpiSerial uint32 | |
| 33 | + | fixedSerial uint32 // serial of the constant settings (scale 1, 96 dpi) | |
| 36 | 34 | themeSerial uint32 | |
| 37 | 35 | sizeSerial uint32 | |
| 38 | 36 | ||
| 39 | 37 | buf [xsBufSize]byte | |
| 40 | 38 | } | |
| 41 | 39 | ||
| 42 | - | // xsettingsDPI is Xft/DPI*1024 for a scale: 96 dpi at scale 1, rounded to an | |
| 43 | - | // integer dpi. | |
| 44 | - | func xsettingsDPI(scale float64) int32 { | |
| 45 | - | if scale <= 0 { | |
| 46 | - | scale = 1 | |
| 47 | - | } | |
| 48 | - | return int32(math.Round(96*scale)) * 1024 | |
| 49 | - | } | |
| 40 | + | // X11 clients see outputs at their logical size and the compositor upscales | |
| 41 | + | // their windows, so the scale announced is always 1 at 96 dpi. | |
| 42 | + | const xsDPI = 96 * 1024 | |
| 50 | 43 | ||
| 51 | 44 | func (x *XWM) cursorThemeName() string { | |
| 52 | 45 | t := x.cfg.CursorTheme | |
| @@ -136,29 +129,18 @@ func (x *XWM) onXSettingsClear(ev *xturbo.Event) { | |||
| 136 | 129 | } | |
| 137 | 130 | } | |
| 138 | 131 | ||
| 139 | - | // refreshXSettings re-evaluates the DPI after a layout change. | |
| 140 | - | func (x *XWM) refreshXSettings() { | |
| 141 | - | if !x.xs.active { | |
| 142 | - | return | |
| 143 | - | } | |
| 144 | - | if x.publishXSettings(false) { | |
| 145 | - | x.wake() | |
| 146 | - | } | |
| 147 | - | } | |
| 148 | - | ||
| 149 | 132 | // publishXSettings encodes and writes the property when a value changed | |
| 150 | 133 | // (force: the first time). It reports whether it wrote. Unchanged values cost | |
| 151 | 134 | // no allocation and no request. | |
| 152 | 135 | func (x *XWM) publishXSettings(force bool) bool { | |
| 153 | 136 | s := &x.xs | |
| 154 | - | dpi := xsettingsDPI(x.space.Scale()) | |
| 155 | 137 | theme, size := x.cursorThemeName(), x.cursorThemeSize() | |
| 156 | - | if !force && s.serial != 0 && dpi == s.dpi && theme == s.theme && size == s.size { | |
| 138 | + | if !force && s.serial != 0 && theme == s.theme && size == s.size { | |
| 157 | 139 | return false | |
| 158 | 140 | } | |
| 159 | 141 | next := s.serial + 1 // wraps | |
| 160 | - | if force || s.serial == 0 || dpi != s.dpi { | |
| 161 | - | s.dpiSerial = next | |
| 142 | + | if force || s.serial == 0 { | |
| 143 | + | s.fixedSerial = next | |
| 162 | 144 | } | |
| 163 | 145 | if force || s.serial == 0 || theme != s.theme { | |
| 164 | 146 | s.themeSerial = next | |
| @@ -166,7 +148,7 @@ func (x *XWM) publishXSettings(force bool) bool { | |||
| 166 | 148 | if force || s.serial == 0 || size != s.size { | |
| 167 | 149 | s.sizeSerial = next | |
| 168 | 150 | } | |
| 169 | - | s.serial, s.dpi, s.theme, s.size = next, dpi, theme, size | |
| 151 | + | s.serial, s.theme, s.size = next, theme, size | |
| 170 | 152 | n := s.encode() | |
| 171 | 153 | xproto.ChangeProperty(x.conn.Buf(), xproto.PropModeReplace, xproto.Window(s.win), | |
| 172 | 154 | xproto.Atom(x.atoms[atomXSettingsSettings]), xproto.Atom(x.atoms[atomXSettingsSettings]), 8, uint32(n), s.buf[:n]) | |
| @@ -182,7 +164,7 @@ func (s *xsettings) encode() int { | |||
| 182 | 164 | clear(b[:16]) | |
| 183 | 165 | b[0] = 0 // LSBFirst | |
| 184 | 166 | le.PutUint32(b[4:], s.serial) | |
| 185 | - | le.PutUint32(b[8:], 3) | |
| 167 | + | le.PutUint32(b[8:], 5) | |
| 186 | 168 | n := 12 | |
| 187 | 169 | putName := func(typ byte, name string, serial uint32) { | |
| 188 | 170 | b[n], b[n+1] = typ, 0 | |
| @@ -194,6 +176,12 @@ func (s *xsettings) encode() int { | |||
| 194 | 176 | le.PutUint32(b[n:], serial) | |
| 195 | 177 | n += 4 | |
| 196 | 178 | } | |
| 179 | + | putName(xsInt, "Gdk/UnscaledDPI", s.fixedSerial) | |
| 180 | + | le.PutUint32(b[n:], xsDPI) | |
| 181 | + | n += 4 | |
| 182 | + | putName(xsInt, "Gdk/WindowScalingFactor", s.fixedSerial) | |
| 183 | + | le.PutUint32(b[n:], 1) | |
| 184 | + | n += 4 | |
| 197 | 185 | putName(xsString, "Gtk/CursorThemeName", s.themeSerial) | |
| 198 | 186 | le.PutUint32(b[n:], uint32(len(s.theme))) | |
| 199 | 187 | n += 4 | |
| @@ -203,8 +191,8 @@ func (s *xsettings) encode() int { | |||
| 203 | 191 | putName(xsInt, "Gtk/CursorThemeSize", s.sizeSerial) | |
| 204 | 192 | le.PutUint32(b[n:], uint32(s.size)) | |
| 205 | 193 | n += 4 | |
| 206 | - | putName(xsInt, "Xft/DPI", s.dpiSerial) | |
| 207 | - | le.PutUint32(b[n:], uint32(s.dpi)) | |
| 194 | + | putName(xsInt, "Xft/DPI", s.fixedSerial) | |
| 195 | + | le.PutUint32(b[n:], xsDPI) | |
| 208 | 196 | n += 4 | |
| 209 | 197 | return n | |
| 210 | 198 | } | |
| @@ -2,7 +2,6 @@ package xwm | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "encoding/binary" | 4 | "encoding/binary" | ||
| 5 | - | "math" | |||
| 6 | "unicode/utf8" | 5 | "unicode/utf8" | ||
| 7 | 6 | ||||
| 8 | "bnema.dev/xturbo" | 7 | "bnema.dev/xturbo" | ||
| @@ -29,24 +28,18 @@ type xsettings struct { | |||||
| 29 | 28 | ||||
| 30 | serial uint32 | 29 | serial uint32 | ||
| 31 | // Last published values and the serial of their last change. | 30 | // Last published values and the serial of their last change. | ||
| 32 | - | dpi int32 | |||
| 33 | theme string | 31 | theme string | ||
| 34 | size int32 | 32 | size int32 | ||
| 35 | - | dpiSerial uint32 | 33 | + | fixedSerial uint32 // serial of the constant settings (scale 1, 96 dpi) |
| 36 | themeSerial uint32 | 34 | themeSerial uint32 | ||
| 37 | sizeSerial uint32 | 35 | sizeSerial uint32 | ||
| 38 | 36 | ||||
| 39 | buf [xsBufSize]byte | 37 | buf [xsBufSize]byte | ||
| 40 | } | 38 | } | ||
| 41 | 39 | ||||
| 42 | - | // xsettingsDPI is Xft/DPI*1024 for a scale: 96 dpi at scale 1, rounded to an | 40 | + | // X11 clients see outputs at their logical size and the compositor upscales |
| 43 | - | // integer dpi. | 41 | + | // their windows, so the scale announced is always 1 at 96 dpi. |
| 44 | - | func xsettingsDPI(scale float64) int32 { | 42 | + | const xsDPI = 96 * 1024 |
| 45 | - | if scale <= 0 { | |||
| 46 | - | scale = 1 | |||
| 47 | - | } | |||
| 48 | - | return int32(math.Round(96*scale)) * 1024 | |||
| 49 | - | } | |||
| 50 | 43 | ||||
| 51 | func (x *XWM) cursorThemeName() string { | 44 | func (x *XWM) cursorThemeName() string { | ||
| 52 | t := x.cfg.CursorTheme | 45 | t := x.cfg.CursorTheme | ||
| @@ -136,29 +129,18 @@ func (x *XWM) onXSettingsClear(ev *xturbo.Event) { | |||||
| 136 | } | 129 | } | ||
| 137 | } | 130 | } | ||
| 138 | 131 | ||||
| 139 | - | // refreshXSettings re-evaluates the DPI after a layout change. | |||
| 140 | - | func (x *XWM) refreshXSettings() { | |||
| 141 | - | if !x.xs.active { | |||
| 142 | - | return | |||
| 143 | - | } | |||
| 144 | - | if x.publishXSettings(false) { | |||
| 145 | - | x.wake() | |||
| 146 | - | } | |||
| 147 | - | } | |||
| 148 | - | ||||
| 149 | // publishXSettings encodes and writes the property when a value changed | 132 | // publishXSettings encodes and writes the property when a value changed | ||
| 150 | // (force: the first time). It reports whether it wrote. Unchanged values cost | 133 | // (force: the first time). It reports whether it wrote. Unchanged values cost | ||
| 151 | // no allocation and no request. | 134 | // no allocation and no request. | ||
| 152 | func (x *XWM) publishXSettings(force bool) bool { | 135 | func (x *XWM) publishXSettings(force bool) bool { | ||
| 153 | s := &x.xs | 136 | s := &x.xs | ||
| 154 | - | dpi := xsettingsDPI(x.space.Scale()) | |||
| 155 | theme, size := x.cursorThemeName(), x.cursorThemeSize() | 137 | theme, size := x.cursorThemeName(), x.cursorThemeSize() | ||
| 156 | - | if !force && s.serial != 0 && dpi == s.dpi && theme == s.theme && size == s.size { | 138 | + | if !force && s.serial != 0 && theme == s.theme && size == s.size { |
| 157 | return false | 139 | return false | ||
| 158 | } | 140 | } | ||
| 159 | next := s.serial + 1 // wraps | 141 | next := s.serial + 1 // wraps | ||
| 160 | - | if force || s.serial == 0 || dpi != s.dpi { | 142 | + | if force || s.serial == 0 { |
| 161 | - | s.dpiSerial = next | 143 | + | s.fixedSerial = next |
| 162 | } | 144 | } | ||
| 163 | if force || s.serial == 0 || theme != s.theme { | 145 | if force || s.serial == 0 || theme != s.theme { | ||
| 164 | s.themeSerial = next | 146 | s.themeSerial = next | ||
| @@ -166,7 +148,7 @@ func (x *XWM) publishXSettings(force bool) bool { | |||||
| 166 | if force || s.serial == 0 || size != s.size { | 148 | if force || s.serial == 0 || size != s.size { | ||
| 167 | s.sizeSerial = next | 149 | s.sizeSerial = next | ||
| 168 | } | 150 | } | ||
| 169 | - | s.serial, s.dpi, s.theme, s.size = next, dpi, theme, size | 151 | + | s.serial, s.theme, s.size = next, theme, size |
| 170 | n := s.encode() | 152 | n := s.encode() | ||
| 171 | xproto.ChangeProperty(x.conn.Buf(), xproto.PropModeReplace, xproto.Window(s.win), | 153 | xproto.ChangeProperty(x.conn.Buf(), xproto.PropModeReplace, xproto.Window(s.win), | ||
| 172 | xproto.Atom(x.atoms[atomXSettingsSettings]), xproto.Atom(x.atoms[atomXSettingsSettings]), 8, uint32(n), s.buf[:n]) | 154 | xproto.Atom(x.atoms[atomXSettingsSettings]), xproto.Atom(x.atoms[atomXSettingsSettings]), 8, uint32(n), s.buf[:n]) | ||
| @@ -182,7 +164,7 @@ func (s *xsettings) encode() int { | |||||
| 182 | clear(b[:16]) | 164 | clear(b[:16]) | ||
| 183 | b[0] = 0 // LSBFirst | 165 | b[0] = 0 // LSBFirst | ||
| 184 | le.PutUint32(b[4:], s.serial) | 166 | le.PutUint32(b[4:], s.serial) | ||
| 185 | - | le.PutUint32(b[8:], 3) | 167 | + | le.PutUint32(b[8:], 5) |
| 186 | n := 12 | 168 | n := 12 | ||
| 187 | putName := func(typ byte, name string, serial uint32) { | 169 | putName := func(typ byte, name string, serial uint32) { | ||
| 188 | b[n], b[n+1] = typ, 0 | 170 | b[n], b[n+1] = typ, 0 | ||
| @@ -194,6 +176,12 @@ func (s *xsettings) encode() int { | |||||
| 194 | le.PutUint32(b[n:], serial) | 176 | le.PutUint32(b[n:], serial) | ||
| 195 | n += 4 | 177 | n += 4 | ||
| 196 | } | 178 | } | ||
| 179 | + | putName(xsInt, "Gdk/UnscaledDPI", s.fixedSerial) | |||
| 180 | + | le.PutUint32(b[n:], xsDPI) | |||
| 181 | + | n += 4 | |||
| 182 | + | putName(xsInt, "Gdk/WindowScalingFactor", s.fixedSerial) | |||
| 183 | + | le.PutUint32(b[n:], 1) | |||
| 184 | + | n += 4 | |||
| 197 | putName(xsString, "Gtk/CursorThemeName", s.themeSerial) | 185 | putName(xsString, "Gtk/CursorThemeName", s.themeSerial) | ||
| 198 | le.PutUint32(b[n:], uint32(len(s.theme))) | 186 | le.PutUint32(b[n:], uint32(len(s.theme))) | ||
| 199 | n += 4 | 187 | n += 4 | ||
| @@ -203,8 +191,8 @@ func (s *xsettings) encode() int { | |||||
| 203 | putName(xsInt, "Gtk/CursorThemeSize", s.sizeSerial) | 191 | putName(xsInt, "Gtk/CursorThemeSize", s.sizeSerial) | ||
| 204 | le.PutUint32(b[n:], uint32(s.size)) | 192 | le.PutUint32(b[n:], uint32(s.size)) | ||
| 205 | n += 4 | 193 | n += 4 | ||
| 206 | - | putName(xsInt, "Xft/DPI", s.dpiSerial) | 194 | + | putName(xsInt, "Xft/DPI", s.fixedSerial) |
| 207 | - | le.PutUint32(b[n:], uint32(s.dpi)) | 195 | + | le.PutUint32(b[n:], xsDPI) |
| 208 | n += 4 | 196 | n += 4 | ||
| 209 | return n | 197 | return n | ||
| 210 | } | 198 | } | ||
M internal/adapters/wayland/xwm/xsettings_test.go +35 -20
| @@ -33,13 +33,25 @@ func (p *xpeer) count(major uint8, match func(xpeerReq) bool) (n int) { | |||
| 33 | 33 | } | |
| 34 | 34 | ||
| 35 | 35 | func TestXSettingsEncode(t *testing.T) { | |
| 36 | - | s := &xsettings{serial: 7, theme: "ab", size: 24, dpi: 96 * 1024, themeSerial: 7, sizeSerial: 5, dpiSerial: 3} | |
| 36 | + | s := &xsettings{serial: 7, theme: "ab", size: 24, themeSerial: 7, sizeSerial: 5, fixedSerial: 3} | |
| 37 | 37 | n := s.encode() | |
| 38 | 38 | u32 := binary.LittleEndian.AppendUint32 | |
| 39 | 39 | var want []byte | |
| 40 | 40 | want = append(want, 0, 0, 0, 0) | |
| 41 | 41 | want = u32(want, 7) | |
| 42 | + | want = u32(want, 5) | |
| 43 | + | // Gdk/UnscaledDPI INT (name length 15 -> pad 16) | |
| 44 | + | want = append(want, 0, 0, 15, 0) | |
| 45 | + | want = append(want, "Gdk/UnscaledDPI"...) | |
| 46 | + | want = append(want, 0) | |
| 42 | 47 | want = u32(want, 3) | |
| 48 | + | want = u32(want, 96*1024) | |
| 49 | + | // Gdk/WindowScalingFactor INT 1 (length 23 -> pad 24) | |
| 50 | + | want = append(want, 0, 0, 23, 0) | |
| 51 | + | want = append(want, "Gdk/WindowScalingFactor"...) | |
| 52 | + | want = append(want, 0) | |
| 53 | + | want = u32(want, 3) | |
| 54 | + | want = u32(want, 1) | |
| 43 | 55 | // Gtk/CursorThemeName STRING "ab" | |
| 44 | 56 | want = append(want, 1, 0, 19, 0) | |
| 45 | 57 | want = append(want, "Gtk/CursorThemeName"...) | |
| @@ -103,14 +115,6 @@ func TestXSettingsOwnership(t *testing.T) { | |||
| 103 | 115 | }) | |
| 104 | 116 | require.NoError(t, x.conn.Sync(&x.h)) | |
| 105 | 117 | require.False(t, x.xs.active) | |
| 106 | - | before := p.count(opChangeProperty, nil) | |
| 107 | - | var sp Space | |
| 108 | - | sp.Set(ports.Layout{{Width: 10, Height: 10, Scale: 2}}) | |
| 109 | - | x.space = &sp | |
| 110 | - | x.refreshXSettings() | |
| 111 | - | require.NoError(t, x.conn.Flush()) | |
| 112 | - | require.NoError(t, x.conn.Sync(&x.h)) | |
| 113 | - | require.Equal(t, before, p.count(opChangeProperty, nil)) | |
| 114 | 118 | } | |
| 115 | 119 | ||
| 116 | 120 | func TestXSettingsExistingOwner(t *testing.T) { | |
| @@ -166,28 +170,38 @@ func TestXSettingsSerial(t *testing.T) { | |||
| 166 | 170 | return p.count(opChangeProperty, func(r xpeerReq) bool { return r.u32(4) == win }) | |
| 167 | 171 | } | |
| 168 | 172 | require.Equal(t, 1, props()) | |
| 169 | - | layout := func(scale float64) { | |
| 173 | + | // Layout changes (any scale) and identical settings write nothing. | |
| 174 | + | for _, scale := range []float64{1, 2} { | |
| 170 | 175 | var sp Space | |
| 171 | 176 | sp.Set(ports.Layout{{Width: 100, Height: 100, Scale: scale}}) | |
| 172 | 177 | x.space = &sp | |
| 173 | 178 | x.LayoutChanged(true) | |
| 174 | 179 | require.NoError(t, x.conn.Flush()) | |
| 175 | 180 | require.NoError(t, x.conn.Sync(&x.h)) | |
| 181 | + | require.Equal(t, 1, props(), "scale %v", scale) | |
| 176 | 182 | } | |
| 177 | - | layout(1) // same settings as boot | |
| 178 | - | require.Equal(t, 1, props()) | |
| 179 | - | layout(2) | |
| 180 | - | require.Equal(t, 2, props()) | |
| 181 | 183 | prop, _ := p.prop(win, "_XSETTINGS_SETTINGS") | |
| 182 | 184 | serial, last := decodeSettings(t, prop.data) | |
| 185 | + | require.EqualValues(t, 1, serial) | |
| 186 | + | require.EqualValues(t, 1, last["Xft/DPI"]) | |
| 187 | + | require.EqualValues(t, 1, last["Gdk/WindowScalingFactor"]) | |
| 188 | + | require.EqualValues(t, 1, last["Gdk/UnscaledDPI"]) | |
| 189 | + | require.EqualValues(t, 96*1024, int32(binary.LittleEndian.Uint32(prop.data[len(prop.data)-4:])), "Xft/DPI is last") | |
| 190 | + | ||
| 191 | + | // A changed cursor size republishes: only that setting's serial moves. | |
| 192 | + | x.cfg.CursorSize = 32 | |
| 193 | + | require.True(t, x.publishXSettings(false)) | |
| 194 | + | require.NoError(t, x.conn.Flush()) | |
| 195 | + | require.NoError(t, x.conn.Sync(&x.h)) | |
| 196 | + | prop, _ = p.prop(win, "_XSETTINGS_SETTINGS") | |
| 197 | + | serial, last = decodeSettings(t, prop.data) | |
| 183 | 198 | require.EqualValues(t, 2, serial) | |
| 184 | - | require.EqualValues(t, 2, last["Xft/DPI"]) | |
| 185 | - | require.EqualValues(t, 1, last["Gtk/CursorThemeSize"]) | |
| 186 | - | require.EqualValues(t, 1, last["Gtk/CursorThemeName"]) | |
| 187 | - | require.EqualValues(t, 192*1024, int32(binary.LittleEndian.Uint32(prop.data[len(prop.data)-4:]))) | |
| 199 | + | require.EqualValues(t, 2, last["Gtk/CursorThemeSize"]) | |
| 200 | + | require.EqualValues(t, 1, last["Xft/DPI"]) | |
| 201 | + | require.False(t, x.publishXSettings(false), "identical settings write nothing") | |
| 188 | 202 | ||
| 189 | 203 | x.xs.serial = 0xffffffff | |
| 190 | - | x.xs.dpi = 0 | |
| 204 | + | x.xs.size = 0 | |
| 191 | 205 | x.publishXSettings(false) | |
| 192 | 206 | require.EqualValues(t, 0, x.xs.serial, "wraps") | |
| 193 | 207 | } | |
| @@ -198,10 +212,11 @@ func TestXSettingsNoChangeAllocations(t *testing.T) { | |||
| 198 | 212 | sp.Set(ports.Layout{{Width: 100, Height: 100, Scale: 1}}) | |
| 199 | 213 | x.space = &sp | |
| 200 | 214 | x.LayoutChanged(true) | |
| 201 | - | l := ports.Layout{{Width: 100, Height: 100, Scale: 1}} | |
| 215 | + | l := ports.Layout{{Width: 100, Height: 100, Scale: 2}} | |
| 202 | 216 | n := testing.AllocsPerRun(100, func() { | |
| 203 | 217 | sp.Set(l) | |
| 204 | 218 | x.LayoutChanged(false) | |
| 219 | + | x.publishXSettings(false) | |
| 205 | 220 | }) | |
| 206 | 221 | require.Zero(t, n) | |
| 207 | 222 | } | |
| @@ -33,13 +33,25 @@ func (p *xpeer) count(major uint8, match func(xpeerReq) bool) (n int) { | |||||
| 33 | } | 33 | } | ||
| 34 | 34 | ||||
| 35 | func TestXSettingsEncode(t *testing.T) { | 35 | func TestXSettingsEncode(t *testing.T) { | ||
| 36 | - | s := &xsettings{serial: 7, theme: "ab", size: 24, dpi: 96 * 1024, themeSerial: 7, sizeSerial: 5, dpiSerial: 3} | 36 | + | s := &xsettings{serial: 7, theme: "ab", size: 24, themeSerial: 7, sizeSerial: 5, fixedSerial: 3} |
| 37 | n := s.encode() | 37 | n := s.encode() | ||
| 38 | u32 := binary.LittleEndian.AppendUint32 | 38 | u32 := binary.LittleEndian.AppendUint32 | ||
| 39 | var want []byte | 39 | var want []byte | ||
| 40 | want = append(want, 0, 0, 0, 0) | 40 | want = append(want, 0, 0, 0, 0) | ||
| 41 | want = u32(want, 7) | 41 | want = u32(want, 7) | ||
| 42 | + | want = u32(want, 5) | |||
| 43 | + | // Gdk/UnscaledDPI INT (name length 15 -> pad 16) | |||
| 44 | + | want = append(want, 0, 0, 15, 0) | |||
| 45 | + | want = append(want, "Gdk/UnscaledDPI"...) | |||
| 46 | + | want = append(want, 0) | |||
| 42 | want = u32(want, 3) | 47 | want = u32(want, 3) | ||
| 48 | + | want = u32(want, 96*1024) | |||
| 49 | + | // Gdk/WindowScalingFactor INT 1 (length 23 -> pad 24) | |||
| 50 | + | want = append(want, 0, 0, 23, 0) | |||
| 51 | + | want = append(want, "Gdk/WindowScalingFactor"...) | |||
| 52 | + | want = append(want, 0) | |||
| 53 | + | want = u32(want, 3) | |||
| 54 | + | want = u32(want, 1) | |||
| 43 | // Gtk/CursorThemeName STRING "ab" | 55 | // Gtk/CursorThemeName STRING "ab" | ||
| 44 | want = append(want, 1, 0, 19, 0) | 56 | want = append(want, 1, 0, 19, 0) | ||
| 45 | want = append(want, "Gtk/CursorThemeName"...) | 57 | want = append(want, "Gtk/CursorThemeName"...) | ||
| @@ -103,14 +115,6 @@ func TestXSettingsOwnership(t *testing.T) { | |||||
| 103 | }) | 115 | }) | ||
| 104 | require.NoError(t, x.conn.Sync(&x.h)) | 116 | require.NoError(t, x.conn.Sync(&x.h)) | ||
| 105 | require.False(t, x.xs.active) | 117 | require.False(t, x.xs.active) | ||
| 106 | - | before := p.count(opChangeProperty, nil) | |||
| 107 | - | var sp Space | |||
| 108 | - | sp.Set(ports.Layout{{Width: 10, Height: 10, Scale: 2}}) | |||
| 109 | - | x.space = &sp | |||
| 110 | - | x.refreshXSettings() | |||
| 111 | - | require.NoError(t, x.conn.Flush()) | |||
| 112 | - | require.NoError(t, x.conn.Sync(&x.h)) | |||
| 113 | - | require.Equal(t, before, p.count(opChangeProperty, nil)) | |||
| 114 | } | 118 | } | ||
| 115 | 119 | ||||
| 116 | func TestXSettingsExistingOwner(t *testing.T) { | 120 | func TestXSettingsExistingOwner(t *testing.T) { | ||
| @@ -166,28 +170,38 @@ func TestXSettingsSerial(t *testing.T) { | |||||
| 166 | return p.count(opChangeProperty, func(r xpeerReq) bool { return r.u32(4) == win }) | 170 | return p.count(opChangeProperty, func(r xpeerReq) bool { return r.u32(4) == win }) | ||
| 167 | } | 171 | } | ||
| 168 | require.Equal(t, 1, props()) | 172 | require.Equal(t, 1, props()) | ||
| 169 | - | layout := func(scale float64) { | 173 | + | // Layout changes (any scale) and identical settings write nothing. |
| 174 | + | for _, scale := range []float64{1, 2} { | |||
| 170 | var sp Space | 175 | var sp Space | ||
| 171 | sp.Set(ports.Layout{{Width: 100, Height: 100, Scale: scale}}) | 176 | sp.Set(ports.Layout{{Width: 100, Height: 100, Scale: scale}}) | ||
| 172 | x.space = &sp | 177 | x.space = &sp | ||
| 173 | x.LayoutChanged(true) | 178 | x.LayoutChanged(true) | ||
| 174 | require.NoError(t, x.conn.Flush()) | 179 | require.NoError(t, x.conn.Flush()) | ||
| 175 | require.NoError(t, x.conn.Sync(&x.h)) | 180 | require.NoError(t, x.conn.Sync(&x.h)) | ||
| 181 | + | require.Equal(t, 1, props(), "scale %v", scale) | |||
| 176 | } | 182 | } | ||
| 177 | - | layout(1) // same settings as boot | |||
| 178 | - | require.Equal(t, 1, props()) | |||
| 179 | - | layout(2) | |||
| 180 | - | require.Equal(t, 2, props()) | |||
| 181 | prop, _ := p.prop(win, "_XSETTINGS_SETTINGS") | 183 | prop, _ := p.prop(win, "_XSETTINGS_SETTINGS") | ||
| 182 | serial, last := decodeSettings(t, prop.data) | 184 | serial, last := decodeSettings(t, prop.data) | ||
| 185 | + | require.EqualValues(t, 1, serial) | |||
| 186 | + | require.EqualValues(t, 1, last["Xft/DPI"]) | |||
| 187 | + | require.EqualValues(t, 1, last["Gdk/WindowScalingFactor"]) | |||
| 188 | + | require.EqualValues(t, 1, last["Gdk/UnscaledDPI"]) | |||
| 189 | + | require.EqualValues(t, 96*1024, int32(binary.LittleEndian.Uint32(prop.data[len(prop.data)-4:])), "Xft/DPI is last") | |||
| 190 | + | ||||
| 191 | + | // A changed cursor size republishes: only that setting's serial moves. | |||
| 192 | + | x.cfg.CursorSize = 32 | |||
| 193 | + | require.True(t, x.publishXSettings(false)) | |||
| 194 | + | require.NoError(t, x.conn.Flush()) | |||
| 195 | + | require.NoError(t, x.conn.Sync(&x.h)) | |||
| 196 | + | prop, _ = p.prop(win, "_XSETTINGS_SETTINGS") | |||
| 197 | + | serial, last = decodeSettings(t, prop.data) | |||
| 183 | require.EqualValues(t, 2, serial) | 198 | require.EqualValues(t, 2, serial) | ||
| 184 | - | require.EqualValues(t, 2, last["Xft/DPI"]) | 199 | + | require.EqualValues(t, 2, last["Gtk/CursorThemeSize"]) |
| 185 | - | require.EqualValues(t, 1, last["Gtk/CursorThemeSize"]) | 200 | + | require.EqualValues(t, 1, last["Xft/DPI"]) |
| 186 | - | require.EqualValues(t, 1, last["Gtk/CursorThemeName"]) | 201 | + | require.False(t, x.publishXSettings(false), "identical settings write nothing") |
| 187 | - | require.EqualValues(t, 192*1024, int32(binary.LittleEndian.Uint32(prop.data[len(prop.data)-4:]))) | |||
| 188 | 202 | ||||
| 189 | x.xs.serial = 0xffffffff | 203 | x.xs.serial = 0xffffffff | ||
| 190 | - | x.xs.dpi = 0 | 204 | + | x.xs.size = 0 |
| 191 | x.publishXSettings(false) | 205 | x.publishXSettings(false) | ||
| 192 | require.EqualValues(t, 0, x.xs.serial, "wraps") | 206 | require.EqualValues(t, 0, x.xs.serial, "wraps") | ||
| 193 | } | 207 | } | ||
| @@ -198,10 +212,11 @@ func TestXSettingsNoChangeAllocations(t *testing.T) { | |||||
| 198 | sp.Set(ports.Layout{{Width: 100, Height: 100, Scale: 1}}) | 212 | sp.Set(ports.Layout{{Width: 100, Height: 100, Scale: 1}}) | ||
| 199 | x.space = &sp | 213 | x.space = &sp | ||
| 200 | x.LayoutChanged(true) | 214 | x.LayoutChanged(true) | ||
| 201 | - | l := ports.Layout{{Width: 100, Height: 100, Scale: 1}} | 215 | + | l := ports.Layout{{Width: 100, Height: 100, Scale: 2}} |
| 202 | n := testing.AllocsPerRun(100, func() { | 216 | n := testing.AllocsPerRun(100, func() { | ||
| 203 | sp.Set(l) | 217 | sp.Set(l) | ||
| 204 | x.LayoutChanged(false) | 218 | x.LayoutChanged(false) | ||
| 219 | + | x.publishXSettings(false) | |||
| 205 | }) | 220 | }) | ||
| 206 | require.Zero(t, n) | 221 | require.Zero(t, n) | ||
| 207 | } | 222 | } | ||
M internal/app/testdata/x11probe/main.go +3 -3
| @@ -21,7 +21,7 @@ | |||
| 21 | 21 | // -copy-for, serving TARGETS and UTF8_STRING/STRING/TEXT, with INCR above | |
| 22 | 22 | // 64 KiB, and prints "copy owned". -paste SEL requests UTF8_STRING (INCR | |
| 23 | 23 | // aware) and prints `paste "<text>"`, `paste len=N sha256=H` above 4 KiB or | |
| 24 | - | // `paste none`. -xsettings prints the XSETTINGS owner's serial, Xft/DPI, | |
| 24 | + | // `paste none`. -xsettings prints the XSETTINGS owner's serial, Xft/DPI, Gdk/WindowScalingFactor, Gdk/UnscaledDPI, | |
| 25 | 25 | // Gtk/CursorThemeName and Gtk/CursorThemeSize, or "xsettings none". | |
| 26 | 26 | // -root-cursor prints "cursor WxH" of the XFixes cursor image. | |
| 27 | 27 | package main | |
| @@ -654,8 +654,8 @@ func (t *tool) xsettingsMode() error { | |||
| 654 | 654 | return fmt.Errorf("xsettings: type %d", typ) | |
| 655 | 655 | } | |
| 656 | 656 | } | |
| 657 | - | fmt.Printf("xsettings serial=%d Xft/DPI=%s Gtk/CursorThemeName=%s Gtk/CursorThemeSize=%s\n", | |
| 658 | - | serial, vals["Xft/DPI"], vals["Gtk/CursorThemeName"], vals["Gtk/CursorThemeSize"]) | |
| 657 | + | fmt.Printf("xsettings serial=%d Xft/DPI=%s Gdk/WindowScalingFactor=%s Gdk/UnscaledDPI=%s Gtk/CursorThemeName=%s Gtk/CursorThemeSize=%s\n", | |
| 658 | + | serial, vals["Xft/DPI"], vals["Gdk/WindowScalingFactor"], vals["Gdk/UnscaledDPI"], vals["Gtk/CursorThemeName"], vals["Gtk/CursorThemeSize"]) | |
| 659 | 659 | return nil | |
| 660 | 660 | } | |
| 661 | 661 | ||
| @@ -21,7 +21,7 @@ | |||||
| 21 | // -copy-for, serving TARGETS and UTF8_STRING/STRING/TEXT, with INCR above | 21 | // -copy-for, serving TARGETS and UTF8_STRING/STRING/TEXT, with INCR above | ||
| 22 | // 64 KiB, and prints "copy owned". -paste SEL requests UTF8_STRING (INCR | 22 | // 64 KiB, and prints "copy owned". -paste SEL requests UTF8_STRING (INCR | ||
| 23 | // aware) and prints `paste "<text>"`, `paste len=N sha256=H` above 4 KiB or | 23 | // aware) and prints `paste "<text>"`, `paste len=N sha256=H` above 4 KiB or | ||
| 24 | - | // `paste none`. -xsettings prints the XSETTINGS owner's serial, Xft/DPI, | 24 | + | // `paste none`. -xsettings prints the XSETTINGS owner's serial, Xft/DPI, Gdk/WindowScalingFactor, Gdk/UnscaledDPI, |
| 25 | // Gtk/CursorThemeName and Gtk/CursorThemeSize, or "xsettings none". | 25 | // Gtk/CursorThemeName and Gtk/CursorThemeSize, or "xsettings none". | ||
| 26 | // -root-cursor prints "cursor WxH" of the XFixes cursor image. | 26 | // -root-cursor prints "cursor WxH" of the XFixes cursor image. | ||
| 27 | package main | 27 | package main | ||
| @@ -654,8 +654,8 @@ func (t *tool) xsettingsMode() error { | |||||
| 654 | return fmt.Errorf("xsettings: type %d", typ) | 654 | return fmt.Errorf("xsettings: type %d", typ) | ||
| 655 | } | 655 | } | ||
| 656 | } | 656 | } | ||
| 657 | - | fmt.Printf("xsettings serial=%d Xft/DPI=%s Gtk/CursorThemeName=%s Gtk/CursorThemeSize=%s\n", | 657 | + | fmt.Printf("xsettings serial=%d Xft/DPI=%s Gdk/WindowScalingFactor=%s Gdk/UnscaledDPI=%s Gtk/CursorThemeName=%s Gtk/CursorThemeSize=%s\n", |
| 658 | - | serial, vals["Xft/DPI"], vals["Gtk/CursorThemeName"], vals["Gtk/CursorThemeSize"]) | 658 | + | serial, vals["Xft/DPI"], vals["Gdk/WindowScalingFactor"], vals["Gdk/UnscaledDPI"], vals["Gtk/CursorThemeName"], vals["Gtk/CursorThemeSize"]) |
| 659 | return nil | 659 | return nil | ||
| 660 | } | 660 | } | ||
| 661 | 661 | ||||
M internal/app/xwayland_test.go +9 -5
| @@ -730,11 +730,11 @@ func cursorTheme(t *testing.T, size int) { | |||
| 730 | 730 | } | |
| 731 | 731 | ||
| 732 | 732 | // TestXwaylandXSettings: the XSETTINGS manager publishes the cursor theme, | |
| 733 | - | // the cursor size and the DPI of the first output. | |
| 733 | + | // the cursor size and a fixed scale 1 / 96 dpi. | |
| 734 | 734 | func TestXwaylandXSettings(t *testing.T) { | |
| 735 | 735 | cursorTheme(t, 32) | |
| 736 | 736 | e := startXwayland(t) | |
| 737 | - | want := "xsettings serial=1 Xft/DPI=98304 Gtk/CursorThemeName=theme Gtk/CursorThemeSize=32" | |
| 737 | + | want := "xsettings serial=1 Xft/DPI=98304 Gdk/WindowScalingFactor=1 Gdk/UnscaledDPI=98304 Gtk/CursorThemeName=theme Gtk/CursorThemeSize=32" | |
| 738 | 738 | var got string | |
| 739 | 739 | until(t, "XSETTINGS owner", func() bool { got = runProbe(t, e, "-xsettings"); return got != "xsettings none" }) | |
| 740 | 740 | if got != want { | |
| @@ -742,14 +742,18 @@ func TestXwaylandXSettings(t *testing.T) { | |||
| 742 | 742 | } | |
| 743 | 743 | } | |
| 744 | 744 | ||
| 745 | - | // TestXwaylandXSettingsScale2: the DPI follows the output scale. | |
| 746 | - | func TestXwaylandXSettingsScale2(t *testing.T) { | |
| 745 | + | // TestXwaylandXSettingsScale2AnnouncesScale1: X11 sees logical sizes, so the | |
| 746 | + | // DPI stays 96 even on a scale-2 output. | |
| 747 | + | func TestXwaylandXSettingsScale2AnnouncesScale1(t *testing.T) { | |
| 747 | 748 | cursorTheme(t, 24) | |
| 748 | 749 | e := startXwaylandWith(t, [][2]int{{xwaylandW, xwaylandH}}, func(c *ports.Config) { | |
| 749 | 750 | c.Outputs = append(c.Outputs, ports.OutputConfig{Name: "HEADLESS-1", Scale: 2, ScaleOnly: true}) | |
| 750 | 751 | }) | |
| 751 | 752 | var got string | |
| 752 | - | until(t, "Xft/DPI=196608", func() bool { got = runProbe(t, e, "-xsettings"); return strings.Contains(got, "Xft/DPI=196608") }) | |
| 753 | + | until(t, "XSETTINGS owner", func() bool { got = runProbe(t, e, "-xsettings"); return got != "xsettings none" }) | |
| 754 | + | if !strings.Contains(got, "Xft/DPI=98304") || !strings.Contains(got, "Gdk/WindowScalingFactor=1") { | |
| 755 | + | t.Errorf("-xsettings = %q, want Xft/DPI=98304 and Gdk/WindowScalingFactor=1", got) | |
| 756 | + | } | |
| 753 | 757 | if !strings.Contains(got, "Gtk/CursorThemeSize=24") { | |
| 754 | 758 | t.Errorf("-xsettings = %q, want Gtk/CursorThemeSize=24", got) | |
| 755 | 759 | } | |
| @@ -730,11 +730,11 @@ func cursorTheme(t *testing.T, size int) { | |||||
| 730 | } | 730 | } | ||
| 731 | 731 | ||||
| 732 | // TestXwaylandXSettings: the XSETTINGS manager publishes the cursor theme, | 732 | // TestXwaylandXSettings: the XSETTINGS manager publishes the cursor theme, | ||
| 733 | - | // the cursor size and the DPI of the first output. | 733 | + | // the cursor size and a fixed scale 1 / 96 dpi. |
| 734 | func TestXwaylandXSettings(t *testing.T) { | 734 | func TestXwaylandXSettings(t *testing.T) { | ||
| 735 | cursorTheme(t, 32) | 735 | cursorTheme(t, 32) | ||
| 736 | e := startXwayland(t) | 736 | e := startXwayland(t) | ||
| 737 | - | want := "xsettings serial=1 Xft/DPI=98304 Gtk/CursorThemeName=theme Gtk/CursorThemeSize=32" | 737 | + | want := "xsettings serial=1 Xft/DPI=98304 Gdk/WindowScalingFactor=1 Gdk/UnscaledDPI=98304 Gtk/CursorThemeName=theme Gtk/CursorThemeSize=32" |
| 738 | var got string | 738 | var got string | ||
| 739 | until(t, "XSETTINGS owner", func() bool { got = runProbe(t, e, "-xsettings"); return got != "xsettings none" }) | 739 | until(t, "XSETTINGS owner", func() bool { got = runProbe(t, e, "-xsettings"); return got != "xsettings none" }) | ||
| 740 | if got != want { | 740 | if got != want { | ||
| @@ -742,14 +742,18 @@ func TestXwaylandXSettings(t *testing.T) { | |||||
| 742 | } | 742 | } | ||
| 743 | } | 743 | } | ||
| 744 | 744 | ||||
| 745 | - | // TestXwaylandXSettingsScale2: the DPI follows the output scale. | 745 | + | // TestXwaylandXSettingsScale2AnnouncesScale1: X11 sees logical sizes, so the |
| 746 | - | func TestXwaylandXSettingsScale2(t *testing.T) { | 746 | + | // DPI stays 96 even on a scale-2 output. |
| 747 | + | func TestXwaylandXSettingsScale2AnnouncesScale1(t *testing.T) { | |||
| 747 | cursorTheme(t, 24) | 748 | cursorTheme(t, 24) | ||
| 748 | e := startXwaylandWith(t, [][2]int{{xwaylandW, xwaylandH}}, func(c *ports.Config) { | 749 | e := startXwaylandWith(t, [][2]int{{xwaylandW, xwaylandH}}, func(c *ports.Config) { | ||
| 749 | c.Outputs = append(c.Outputs, ports.OutputConfig{Name: "HEADLESS-1", Scale: 2, ScaleOnly: true}) | 750 | c.Outputs = append(c.Outputs, ports.OutputConfig{Name: "HEADLESS-1", Scale: 2, ScaleOnly: true}) | ||
| 750 | }) | 751 | }) | ||
| 751 | var got string | 752 | var got string | ||
| 752 | - | until(t, "Xft/DPI=196608", func() bool { got = runProbe(t, e, "-xsettings"); return strings.Contains(got, "Xft/DPI=196608") }) | 753 | + | until(t, "XSETTINGS owner", func() bool { got = runProbe(t, e, "-xsettings"); return got != "xsettings none" }) |
| 754 | + | if !strings.Contains(got, "Xft/DPI=98304") || !strings.Contains(got, "Gdk/WindowScalingFactor=1") { | |||
| 755 | + | t.Errorf("-xsettings = %q, want Xft/DPI=98304 and Gdk/WindowScalingFactor=1", got) | |||
| 756 | + | } | |||
| 753 | if !strings.Contains(got, "Gtk/CursorThemeSize=24") { | 757 | if !strings.Contains(got, "Gtk/CursorThemeSize=24") { | ||
| 754 | t.Errorf("-xsettings = %q, want Gtk/CursorThemeSize=24", got) | 758 | t.Errorf("-xsettings = %q, want Gtk/CursorThemeSize=24", got) | ||
| 755 | } | 759 | } | ||