From c50ac859a3e3e6471c65ccaa98e783f43d279aff Mon Sep 17 00:00:00 2001 From: Clansty Date: Thu, 15 Oct 2026 00:14:43 +0800 Subject: [PATCH] =?UTF-8?q?feat(lyrics):=20=E6=A1=8C=E9=9D=A2=E6=AD=8C?= =?UTF-8?q?=E8=AF=8D=E5=9C=A8=20Wayland=20=E4=B8=8A=E6=94=B9=E7=94=A8=20la?= =?UTF-8?q?yer=20shell=20overlay?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 桌面歌词一直是一个 xdg_toplevel,靠跳过任务栏、置顶和用户自己写窗口规则来假装自己是桌面歌词:层级盖不过全屏窗口,切工作区就消失,每个合成器的语法还各不相同,位置和尺寸也完全由 WM 说了算。zwlr_layer_shell_v1 的 overlay 层正是为这类东西准备的——层级由合成器保证,窗口不再是一个窗口,没有 WM 会去平铺或移动它,因此不需要任何窗口规则。 layer shell 唯独没有拖动和缩放:layer surface 的位置和尺寸属于应用,协议里只有 set_margin / set_size。这部分在原生层自己重放,见 modules/window/README.md 的「Moving and resizing a converted window」。 - modules/window: 新增 handlers/grab.rs,把客户端仍然会发的 xdg_toplevel.move / resize 接住,跟着指针位移改写 margin / size。位移始终从按下的那一刻算起——按住期间合成器给客户端的坐标一直是按住时那套几何下的,所以指针在窗口里的位置就是它当初抓住窗口的地方加上此后走过的全部距离;把「上一步」再累加起来会把一次拖动放大成事件个数倍 - modules/window: 手势认下启动它的那个键:别的键中途按下再松开不是拖动结束,否则窗口会停在原地不再跟手,贴着屏幕边时还会提前报一次推挤 - modules/window: 窗口整个留在屏幕内。layer surface 换不了输出,拖到边缘只能停住,停住之后光标继续走就会离开窗口,那一刻松手就把光标推过的那条边报给应用。留整块而不是留一条边,是因为接受鼠标的区域只有文字本身,露在屏外的那截永远抓不到,而且合成器会把「锚在左上」的 surface 缩到屏幕剩下的宽度 - modules/window: 屏幕尺寸依次从客户端的 zxdg_output_v1、该输出的 wl_output mode/scale、应用自己上报的尺寸里取,并按 wl_output 的 transform 判断屏幕是不是竖着用的;落在屏外的窗口会被拉回来,比屏幕还大的会被缩到屏幕大小,拖动则被夹在屏幕内。wl_output.mode 的参数是 flags 在前,这里原来读错了位,没有 zxdg_output_v1 时会把 1920×1080 记成 1×1920 并把窗口缩成一条 - modules/window: 新增 LayerShellOptions::output 与 onLayerSurfaceOutput,报出(以及按名字指定)convert 后的窗口落在哪块输出上;layer surface 的输出在创建时就定死了,这是唯一能把窗口重建到另一块屏幕上的办法 - modules/window: 直写合成器的 layer 请求改为在同一个临界区里解析窗口并整组写出,取锁顺序与代理自己的 outbound 路径一致(先写锁、再连接状态)。客户端随时可能销毁那个对象,而一个指向已经被销毁对象的请求是会让整条 display 连接断掉的协议错误 - main: overlay 只在拖得动的合成器上默认打开(niri、Hyprland、wlroots 系)。拖动是应用替合成器重放的,合成器得在收到新 margin 的那一刻重排 layer surface;KWin 不这么做,整个拖动过程窗口都追不上指针(与缩放率无关),其他合成器没有验证过,所以它们照旧用普通窗口,位置也照旧交给合成器,`LYRICS_LAYER_SHELL=1/0` 可以强制打开或关闭。判定是纯函数,见 src/main/windows/desktop-lyrics-layer.ts - main: 桌面歌词窗口注册 layer-shell 策略(overlay 层、左上锚点、margin 携带位置、exclusiveZone -1、键盘按需交互),并把拖动、缩放、横竖切换后的位置和所在的输出记进设置,下次启动按同一块屏幕的角还原;整块窗口放不下就回默认位置。横竖切换由渲染层给出目标方向,窗口已经是要的样子就不动它——窗口是按记住的尺寸创建的,每次挂载都取反会把刚创建好的尺寸翻回去 - main: 拖过屏幕交界线时把歌词窗口销毁重建到光标所在的那块屏幕上,贴着推过去的那条边,并把落点写进设置(新窗口已经在屏内时原生层不会报几何,只能这里写)。同一时间只跑一次跳屏,只有当前这个歌词窗口的推挤才算数;新窗口的页面起来之前不动旧窗口,页面加载期间用户把歌词藏起来或切了生命周期,这次跳屏就作废并清掉被放弃窗口的名字,失败或超时则把位置、输出和屏幕记录一起放回去 - gui: 渲染进程上报自己屏幕的尺寸(layer surface 只能由应用告诉原生层屏幕有多大);新增 paintPulse,指针在窗口上期间每帧重绘一个看不见的色块——layer surface 的几何是双缓冲的,客户端只在有东西要画时才 commit,空闲的窗口跟不上拖动 - docs: WM_RULES 按走不走 overlay 分开写,说明默认打开的是哪几个合成器、为什么以及怎么覆盖,并补上跨屏行为;模块 README 补上拖动模型、尺寸来源、整窗在屏内的理由、锁序以及生成绑定的构建说明 - 测试:Rust 230 项、JS 760 项。新增拖动位移不被事件个数放大的回归、推挤上报、整窗在屏内、输出尺寸来源、mode 事件解析(含旋转)、别的键不打断拖动、合成器判定、layer 请求与状态在同一临界区等用例 不支持 layer shell 的合成器(GNOME、KDE)默认一切照旧,走原来的窗口规则。 --- __test__/main/desktop-lyrics-layer.spec.ts | 36 + __test__/main/window.spec.ts | 179 +++ docs/WM_RULES.md | 41 +- gui/src/lib/paintPulse.ts | 78 + .../(transparent)/desktop-lyrics/+page.svelte | 24 +- modules/window/README.md | 162 ++ modules/window/index.d.ts | 92 ++ modules/window/index.js | 5 + modules/window/src/lib.rs | 160 ++ modules/window/src/linux.rs | 101 ++ modules/window/src/linux/wayland/codec.rs | 47 + modules/window/src/linux/wayland/filter.rs | 175 ++- .../src/linux/wayland/handlers/conversion.rs | 56 +- .../window/src/linux/wayland/handlers/grab.rs | 1333 +++++++++++++++++ .../window/src/linux/wayland/handlers/mod.rs | 63 +- .../src/linux/wayland/handlers/objects.rs | 384 ++++- .../src/linux/wayland/handlers/pointer.rs | 28 +- .../src/linux/wayland/handlers/shadow.rs | 19 +- .../src/linux/wayland/handlers/touch.rs | 3 +- modules/window/src/linux/wayland/inject.rs | 245 ++- .../window/src/linux/wayland/layer_shell.rs | 29 +- modules/window/src/linux/wayland/mod.rs | 37 +- modules/window/src/linux/wayland/state.rs | 612 +++++++- .../window/src/linux/wayland/test_support.rs | 55 +- src/bridge/contracts/desktop-lyrics-api.ts | 31 +- src/main/settings.ts | 2 + src/main/window.ts | 120 ++ src/main/windows/desktop-lyrics-layer.ts | 52 + src/main/windows/desktop-lyrics.ts | 762 +++++++++- 29 files changed, 4862 insertions(+), 69 deletions(-) create mode 100644 __test__/main/desktop-lyrics-layer.spec.ts create mode 100644 gui/src/lib/paintPulse.ts create mode 100644 modules/window/src/linux/wayland/handlers/grab.rs create mode 100644 src/main/windows/desktop-lyrics-layer.ts diff --git a/__test__/main/desktop-lyrics-layer.spec.ts b/__test__/main/desktop-lyrics-layer.spec.ts new file mode 100644 index 00000000..6b9d1ce1 --- /dev/null +++ b/__test__/main/desktop-lyrics-layer.spec.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from "vitest"; +import { whyNotALayerSurface } from "../../src/main/windows/desktop-lyrics-layer"; + +describe("which compositors the desktop lyrics take a layer surface on", () => { + it.each(["niri", "NIRI", " Sway ", "sway", "river"])("follows a drag on %s", (desktop) => { + expect(whyNotALayerSurface(desktop, undefined)).toBeNull(); + }); + + it("reads the whole colon-separated name", () => { + expect(whyNotALayerSurface("niri:something", undefined)).toBeNull(); + expect(whyNotALayerSurface("something:Sway", undefined)).toBeNull(); + }); + + it.each(["KDE", "GNOME", "", "KDE:Plasma", "Hyprland", "labwc", "wayfire"])( + "keeps an ordinary window on %s", + (desktop) => { + expect(whyNotALayerSurface(desktop, undefined)).toMatch(/pointer/); + } + ); + + it("lets the variable ask for one anyway", () => { + expect(whyNotALayerSurface("KDE", "1")).toBeNull(); + expect(whyNotALayerSurface("KDE", "true")).toBeNull(); + }); + + it("lets the variable refuse one where they would be taken", () => { + expect(whyNotALayerSurface("niri", "0")).toBe("asked not to"); + expect(whyNotALayerSurface("niri", "false")).toBe("asked not to"); + }); + + it("leaves anything else to the compositor", () => { + expect(whyNotALayerSurface("niri", "")).toBeNull(); + expect(whyNotALayerSurface("niri", "yes")).toBeNull(); + expect(whyNotALayerSurface("KDE", "yes")).toMatch(/pointer/); + }); +}); diff --git a/__test__/main/window.spec.ts b/__test__/main/window.spec.ts index f70a4756..8cb2ec1f 100644 --- a/__test__/main/window.spec.ts +++ b/__test__/main/window.spec.ts @@ -34,6 +34,9 @@ const hoisted = vi.hoisted(() => { * otherwise wipe what `src/main/window.ts` registered when it was imported. */ const layerShellRefusedCallbacks: unknown[] = []; + const layerGeometryCallbacks: unknown[] = []; + const layerPushedCallbacks: unknown[] = []; + const layerOutputCallbacks: unknown[] = []; const appOnCalls: Array<[string, unknown]> = []; const whenReadyPromises: Promise[] = []; @@ -42,6 +45,8 @@ const hoisted = vi.hoisted(() => { desktop: vi.fn(() => 0), setInputRegion: vi.fn(() => true), setWindowLayerShell: vi.fn(() => true), + setWindowLayerBounds: vi.fn(() => true), + setWindowLayerSize: vi.fn(() => true), transferWindowState: vi.fn(() => true), forgetWindowState: vi.fn(() => true), showFailure: null as Error | null, @@ -50,6 +55,18 @@ const hoisted = vi.hoisted(() => { onLayerShellRefused: vi.fn((callback: unknown) => { layerShellRefusedCallbacks.push(callback); }), + layerGeometryCallbacks, + onLayerSurfaceGeometry: vi.fn((callback: unknown) => { + layerGeometryCallbacks.push(callback); + }), + layerPushedCallbacks, + onLayerSurfacePushed: vi.fn((callback: unknown) => { + layerPushedCallbacks.push(callback); + }), + layerOutputCallbacks, + onLayerSurfaceOutput: vi.fn((callback: unknown) => { + layerOutputCallbacks.push(callback); + }), whenReadyPromises, whenReady: vi.fn(() => { const ready = Promise.resolve(); @@ -79,10 +96,15 @@ vi.mock("@open-orpheus/window", () => ({ getDesktopEnvironment: hoisted.desktop, setInputRegion: hoisted.setInputRegion, setWindowLayerShell: hoisted.setWindowLayerShell, + setWindowLayerBounds: hoisted.setWindowLayerBounds, + setWindowLayerSize: hoisted.setWindowLayerSize, transferWindowState: hoisted.transferWindowState, forgetWindowState: hoisted.forgetWindowState, validateLayerShellOptions: hoisted.validateLayerShell, onLayerShellRoleRefused: hoisted.onLayerShellRefused, + onLayerSurfaceGeometry: hoisted.onLayerSurfaceGeometry, + onLayerSurfacePushed: hoisted.onLayerSurfacePushed, + onLayerSurfaceOutput: hoisted.onLayerSurfaceOutput, decorateWindowTitle: hoisted.decorateTitle, drainWindowCallbacks: hoisted.drainWindowCallbacks, isLayerShellAvailable: hoisted.layerShellAvailable, @@ -304,6 +326,10 @@ beforeEach(() => { hoisted.setInputRegion.mockReturnValue(true); hoisted.setWindowLayerShell.mockReset(); hoisted.setWindowLayerShell.mockReturnValue(true); + hoisted.setWindowLayerBounds.mockReset(); + hoisted.setWindowLayerBounds.mockReturnValue(true); + hoisted.setWindowLayerSize.mockReset(); + hoisted.setWindowLayerSize.mockReturnValue(true); hoisted.transferWindowState.mockReset(); hoisted.transferWindowState.mockReturnValue(true); hoisted.forgetWindowState.mockReset(); @@ -647,6 +673,53 @@ describe("ManagedWindow native state", () => { expect(managed.setWindowInputRegion(regions)).toBe(false); }); + + it("reports the screen a layer surface lives on", () => { + const managed = new TestWindow(); + const wnd = asFake(managed.window); + + // A layer surface cannot move to another output, so the native layer needs + // the size of this one to keep a drag inside it. + expect(managed.setLayerBounds(2560, 1440)).toBe(true); + expect(hoisted.setWindowLayerBounds).toHaveBeenCalledExactlyOnceWith( + managedId(wnd), + 2560, + 1440 + ); + }); + + it("reports no screen where no layer surface can exist", () => { + hoisted.desktop.mockReturnValue(X11); + const managed = new TestWindow(); + + expect(managed.setLayerBounds(2560, 1440)).toBe(false); + expect(hoisted.setWindowLayerBounds).not.toHaveBeenCalled(); + }); + + it("sizes a layer surface through the native layer", () => { + const managed = new TestWindow(); + const wnd = asFake(managed.window); + + // A converted window cannot resize itself: the compositor keeps the size + // its layer surface was given. Sub-pixel values are the caller's business. + expect(managed.setLayerSize(640.4, 180.6)).toBe(true); + expect(hoisted.setWindowLayerSize).toHaveBeenCalledExactlyOnceWith(managedId(wnd), 640, 181); + }); + + it("leaves resizing to the window where no layer surface can exist", () => { + hoisted.desktop.mockReturnValue(X11); + const managed = new TestWindow(); + + expect(managed.setLayerSize(640, 180)).toBe(false); + expect(hoisted.setWindowLayerSize).not.toHaveBeenCalled(); + }); + + it("reports a size the native layer could not send", () => { + hoisted.setWindowLayerSize.mockReturnValue(false); + const managed = new TestWindow(); + + expect(managed.setLayerSize(640, 180)).toBe(false); + }); }); describe("OnDemandWindow recreation", () => { @@ -715,6 +788,48 @@ describe("OnDemandWindow hiding", () => { }); }); +describe("ManagedWindow.moveNameTo only", () => { + it("moves the name and nothing else", () => { + // A window is looked up by name, and the lookup takes whichever wrapper + // comes first — so the one being replaced has to stop answering to it, or + // the next hide or show lands on a window that is already gone. + const managed = new TestWindow(); + managed.setData("name", "desktop_lyrics"); + const next = new TestWindow(); + hoisted.transferWindowState.mockClear(); + + managed.moveNameTo(next); + + expect(next.getData("name")).toBe("desktop_lyrics"); + expect(managed.getData("name")).toBe(""); + expect(ManagedWindow.fromName("desktop_lyrics")).toBe(next); + // Its own native state is the replacement's to register: for a window + // being placed on another screen, that placement is the point of + // replacing it. + expect(hoisted.transferWindowState).not.toHaveBeenCalled(); + }); +}); + +describe("replacing a window under the same name", () => { + it("does not hand on a name that has already been handed over", () => { + // Two replacements of one window would otherwise leave the second + // answering to nothing at all — and the first is then a window on screen + // that the name does not lead to. + const name = "handed-over-name"; + const first = new TestWindow(); + first.setData("name", name); + const second = new TestWindow(); + const third = new TestWindow(); + + first.moveNameTo(second); + first.moveNameTo(third); + + expect(second.getData("name")).toBe(name); + expect(third.getData("name")).toBeUndefined(); + expect(ManagedWindow.fromName(name)).toBe(second); + }); +}); + describe("switchWindowPolicy", () => { it("dismisses the old window and moves the recorded state", () => { const managed = new TestWindow(); @@ -904,6 +1019,70 @@ describe("ManagedWindow layer shell", () => { expect(event.data).toBe(managed.window); }); + it("surfaces where the native layer left a moved window", async () => { + // The listener is registered once the app is ready. + await hoisted.whenReadyPromises.at(-1); + const managed = new TestWindow(); + managed.setLayerShell(options); + const reported = managed.once("layerGeometry"); + const reportGeometry = hoisted.layerGeometryCallbacks.at(-1) as (geometry: { + windowId: string; + x: number; + y: number; + width: number; + height: number; + }) => void; + expect(reportGeometry).toBeTypeOf("function"); + + // The native layer replays the gesture, so it is the only thing that knows + // where the surface ended up. + reportGeometry({ windowId: managed.id, x: 120, y: 48, width: 640, height: 180 }); + + const event = await reported; + expect(event.name).toBe("layerGeometry"); + expect(event.data).toEqual({ x: 120, y: 48, width: 640, height: 180 }); + }); + + it("surfaces a gesture that ran out of screen", async () => { + await hoisted.whenReadyPromises.at(-1); + const managed = new TestWindow(); + managed.setLayerShell(options); + const pushed = managed.once("layerPushed"); + const reportPush = hoisted.layerPushedCallbacks.at(-1) as ( + windowId: string, + edges: number + ) => void; + expect(reportPush).toBeTypeOf("function"); + + // The window stopped at the edge because a layer surface cannot move to + // another output, which is where the application takes over. + reportPush(managed.id, 8); + + const event = await pushed; + expect(event.name).toBe("layerPushed"); + expect(event.data).toEqual({ edges: 8 }); + }); + + it("surfaces the screen a layer surface ended up on", async () => { + await hoisted.whenReadyPromises.at(-1); + const managed = new TestWindow(); + managed.setLayerShell(options); + const reported = managed.once("layerOutput"); + const reportOutput = hoisted.layerOutputCallbacks.at(-1) as ( + windowId: string, + output: string + ) => void; + expect(reportOutput).toBeTypeOf("function"); + + // The compositor picked the output when it created the surface and never + // says which: this is the one name the two halves share. + reportOutput(managed.id, "DP-2"); + + const event = await reported; + expect(event.name).toBe("layerOutput"); + expect(event.data).toEqual({ output: "DP-2" }); + }); + it("registers once for an on-demand window's first surface", () => { class OnDemandLayerWindow extends TestOnDemandWindow { constructor() { diff --git a/docs/WM_RULES.md b/docs/WM_RULES.md index 9ce5ad0b..0012531e 100644 --- a/docs/WM_RULES.md +++ b/docs/WM_RULES.md @@ -4,6 +4,10 @@ 本文档在窗口规则的配置中,将仅通过窗口标题对 Open Orpheus 的对应窗口进行匹配。此外,本文档的窗口规则仅作为参考建议,你也可以自行调整优化,使其更符合你的使用习惯。 +在 niri、sway 与 river 上,桌面歌词窗口会以合成器的 overlay 层呈现:位置和尺寸由应用自己管理(拖动、缩放、横竖切换后都会记住,下次启动回到原位),层级由合成器保证(全屏窗口也盖不住它),因此不需要任何窗口规则。 + +为什么只有这几个:layer surface 的位置就是它的 margin,拖动是应用替合成器重放的,所以合成器必须在承载新 margin 的那次 commit 上就把 surface 摆过去。实测:niri、sway、river 跟手;Hyprland 追不上指针;labwc、Wayfire 拖动时会抖;KWin 根本不重排,窗口自始至终追不上。这些都不是「支不支持 layer shell」的问题,而是重排时机的问题,协议里也没有任何回报可用于判断,所以只能按实测列出来。名单外的合成器想试可以设 `LYRICS_LAYER_SHELL=1`(想关掉设 `0`)。本页「桌面歌词」一节对没有走 overlay 的合成器照旧生效。 + ## KDE KDE 的窗口规则设置可在 `系统设置 > 应用和窗口 > 窗口管理 > 窗口规则` 中找到。 @@ -77,6 +81,14 @@ KDE 的窗口规则设置可在 `系统设置 > 应用和窗口 > 窗口管理 > +上面这几个合成器上不需要以上规则。 + +layer surface 只能待在合成器分给它的那块输出上,协议里没有换输出的请求,所以拖动本身换不了屏:拖到边缘会停住,窗口**整个**留在屏内——不留一截在边缘外,因为那截透明、不接受鼠标,用户就没法把它抓回来了(而且合成器会把「锚在左上」的 surface 缩到屏幕剩下的宽度,于是窗口会被越拖越窄)。窗口贴住屏幕边之后,光标越过屏幕交界线的那一刻正好就是光标离开窗口的那一刻,此时松手,应用会把歌词窗口销毁重建到光标所在的那块屏幕上,贴着推过去的那条边——所以拖到哪块屏就落在哪块屏。会闪一下,而且这次拖动会中断,需要再拖一次继续微调。光标又回到窗口里再松手就什么都不做。 + +同一次拖动可能连着报几次推挤(窗口在按着不放的指针底下被换掉了,新的那个又被拖到同一条边上),所以同一时间只允许一次跳屏,其余丢掉;而且只有**当前**这个歌词窗口报的推挤才算数——被换掉的旧窗口还留在屏幕上时,它会继续报它当初那条边,跟着它走会把歌词又搬回去。 + +窗口的位置是按「某一块屏幕的角」记的,所以换过屏幕、拔过线之后记住的位置可能不在这块屏上。这种情况下窗口会被拉回屏内,位置整个放不下就回到默认位置(主屏底部居中),不会出现「启动了但看不见歌词」;比屏幕还大的窗口会被缩到屏幕大小(否则合成器会自己缩,原生层记的几何就跟实际对不上了)。 + ## niri niri 下光标交互无法穿透到浮动窗口下方,关闭菜单只能通过 Esc 按键。 @@ -123,6 +135,21 @@ window-rule { ### 桌面歌词 +这些合成器上不需要以下规则:歌词窗口以 overlay 层呈现,由应用自己摆放。这时该用 niri 的 layer 规则,按命名空间匹配(`niri msg layers` 可以看到): + +```kdl +layer-rule { + match namespace="^open-orpheus-lyrics$" + + // 例如录屏时不把歌词录进去 + block-out-from "screencast" +} +``` + +透明度、阴影、圆角、背景模糊等属性都可用,窗口规则做不到这些。 + +其他合成器(KDE、GNOME,或设置了 `LYRICS_LAYER_SHELL=0`)沿用以下窗口规则: + ```kdl window-rule { match title="^Open Orpheus Lyrics$" @@ -153,9 +180,11 @@ window-rule { ### 环境变量 -| 变量 | 描述 | 备注 | -| ------------------------------- | ---------------------------------------- | ------------------------------------------------- | -| `MENU_OVERLAY_NO_FULLSCREEN` | 禁用菜单全屏 | 对全屏窗口背景不透明的 WM 默认激活,如非 KDE 桌面 | -| `MENU_OVERLAY_FORCE_FULLSCREEN` | 强制启用菜单全屏 | | -| `MENU_OVERLAY_NO_MAXIMIZE` | 禁用菜单自动最大化 | 对部分 Tiling WM 默认激活,如 niri | -| `MENU_OVERLAY_FORCE_MAXIMIZE` | 强制启用菜单最大化(需要菜单全屏被禁用) | | +| 变量 | 描述 | 备注 | +| ------------------------------- | -------------------------------------------------- | ------------------------------------------------- | +| `MENU_OVERLAY_NO_FULLSCREEN` | 禁用菜单全屏 | 对全屏窗口背景不透明的 WM 默认激活,如非 KDE 桌面 | +| `MENU_OVERLAY_FORCE_FULLSCREEN` | 强制启用菜单全屏 | | +| `MENU_OVERLAY_NO_MAXIMIZE` | 禁用菜单自动最大化 | 对部分 Tiling WM 默认激活,如 niri | +| `MENU_OVERLAY_FORCE_MAXIMIZE` | 强制启用菜单最大化(需要菜单全屏被禁用) | | +| `LYRICS_LAYER_SHELL` | 强制打开(`1`)/ 关闭(`0`)桌面歌词的 layer shell | 不设时只在上面列出的合成器上打开 | +| `ORPHEUS_LAYER_DEBUG` | 打印代理对 layer surface 拖动/缩放的处理 | 排查「拖动没有实时跟随」一类问题时使用 | diff --git a/gui/src/lib/paintPulse.ts b/gui/src/lib/paintPulse.ts new file mode 100644 index 00000000..f33ef278 --- /dev/null +++ b/gui/src/lib/paintPulse.ts @@ -0,0 +1,78 @@ +import type { Attachment } from "svelte/attachments"; + +/** + * How long the window keeps drawing after the last pointer event on it. + * + * A drag or a resize is one unbroken pointer interaction, so the pulse outlives + * any of them; the tail only has to span the gap between two of them. + */ +const IDLE_MS = 3000; + +/** The painted mark, in CSS pixels. */ +const MARK = 8; + +/** Two values a rasterizer cannot round together, and the eye cannot see. */ +const FAINT = "rgba(0, 0, 0, 0.004)"; +const FAINTER = "rgba(0, 0, 0, 0.008)"; + +/** + * Keep the window drawing while the pointer is working in it. + * + * On Wayland a layer surface's geometry — a move, a resize — is double-buffered: + * the compositor adopts it when the surface is committed, and a client commits + * only when it has something to draw. An idle window therefore never picks the + * new geometry up, and only moves once some unrelated event makes it draw. + * Repainting an imperceptible mark keeps frames coming for as long as the + * pointer is on the window, which is exactly as long as a drag or a resize can + * last — and it is what the window would be doing on its own if it had anything + * moving on it. + */ +export const paintPulseAttachment: Attachment = (element) => { + // The mark below is drawn in CSS pixels, which only an element can be given. + if (!(element instanceof HTMLElement)) return; + + const mark = document.createElement("div"); + mark.style.cssText = [ + "position: fixed", + "left: 0", + "top: 0", + `width: ${MARK}px`, + `height: ${MARK}px`, + "pointer-events: none", + "z-index: 2147483647", + `background: ${FAINT}`, + ].join(";"); + element.appendChild(mark); + + let frame: number | null = null; + let idle: ReturnType | undefined; + let faint = false; + + const draw = () => { + faint = !faint; + mark.style.background = faint ? FAINT : FAINTER; + frame = requestAnimationFrame(draw); + }; + + const wake = () => { + if (frame === null) frame = requestAnimationFrame(draw); + clearTimeout(idle); + idle = setTimeout(sleep, IDLE_MS); + }; + + const sleep = () => { + if (frame !== null) cancelAnimationFrame(frame); + frame = null; + mark.style.background = FAINT; + }; + + const events = ["pointerenter", "pointermove", "pointerdown"]; + for (const event of events) element.addEventListener(event, wake); + + return () => { + for (const event of events) element.removeEventListener(event, wake); + clearTimeout(idle); + if (frame !== null) cancelAnimationFrame(frame); + mark.remove(); + }; +}; diff --git a/gui/src/routes/(transparent)/desktop-lyrics/+page.svelte b/gui/src/routes/(transparent)/desktop-lyrics/+page.svelte index 9596619e..218e8b81 100644 --- a/gui/src/routes/(transparent)/desktop-lyrics/+page.svelte +++ b/gui/src/routes/(transparent)/desktop-lyrics/+page.svelte @@ -14,6 +14,7 @@ import * as settings from "$lib/settings"; import multihover from "$lib/multihover"; import { onpointerdrag } from "$lib/pointer"; + import { paintPulseAttachment } from "$lib/paintPulse"; const api = getBridge("desktopLyrics"); @@ -117,7 +118,7 @@ if (!lyricStyle) return; if (lyricStyle.vertical !== previousVertical) { previousVertical = lyricStyle.vertical; - api.changeOrientation(); + api.changeOrientation(lyricStyle.vertical); } }); @@ -192,10 +193,30 @@ currentTime = e.data.time * 1000; playing = e.data.playState; }); + + reportScreenSize(); + window.addEventListener("resize", reportScreenSize); + api.events.requestScreenSize(reportScreenSize); + return () => window.removeEventListener("resize", reportScreenSize); }); + /** + * Tell the main process how large the screen is. + * + * A layer surface lives on one output and cannot be moved to another one, so + * the native layer has to know where that output ends: the renderer is the + * only half that can read it, and it is also the half that knows when the + * window has moved to a different screen. + */ + function reportScreenSize() { + api.reportScreenSize(Math.round(window.screen.width), Math.round(window.screen.height)); + } + function onDrag() { if (locked) return; + // The size is what stops the drag at the edge of the screen rather than + // pushing the window off it, so it is worth refreshing before every one. + reportScreenSize(); api.dragWindow(); } @@ -220,6 +241,7 @@ class:cursor-grab={!locked} class:flex-col={!lyricStyle.vertical} {@attach onpointerdrag(onDrag)} + {@attach paintPulseAttachment} onpointerenter={() => { clearTimeout(disableTimer); }} diff --git a/modules/window/README.md b/modules/window/README.md index 56ae649b..b51cac1e 100644 --- a/modules/window/README.md +++ b/modules/window/README.md @@ -195,6 +195,151 @@ keeps its overlay fallback for that. An `xdg_positioner` needs a size before `get_popup`, and only the renderer knows the measured menu size, so the policy is re-registered for each window rather than once per class. +### Moving and resizing a converted window + +Layer shell has no equivalent of either: a layer surface's position and size +belong to the application, and only `set_margin` and `set_size` change them. A +converted client asks for both anyway — `xdg_toplevel.move` from the window's own +drag, `resize` from its edge hit test — so the proxy replays the gesture itself, +from the two things a client is told about it: where the pointer is within the +surface, and the geometry the surface has. + +What a gesture is after is one number: how far the pointer has moved since it +grabbed the window — and a motion event already says exactly that. For as long +as the button is down the compositor keeps measuring the pointer against the +geometry the press was made on, so the pointer's place in those coordinates is +the place it grabbed the window at plus everything it has done since. The +window's own movement is deliberately not in the numbers, and that is the whole +reason the emulation works: a gesture that rewrites the window's margins can +read its own progress back out of the pointer. A resize comes out of the same +movement, which is also what keeps the edge the user did not grab exactly where +it was. + +The one thing a step must never be is the previous step plus a bit. The +pointer's place in the surface is measured from the press, not from the geometry +the last step asked for, so a step added to a step counts everything the drag +has done again on every motion event: eight events of ten pixels would carry the +window eighty pixels past where it belongs rather than eighty in all. Every step +is computed from the gesture's start instead, so a batch of motion events lands +the window exactly where a single one would. What the window's commits are for +is making the geometry take effect at all: they are what the arrangement +follows, which is the next paragraph. + +Two conditions have to hold, and a gesture that fails either is dropped — with +`ORPHEUS_LAYER_DEBUG` set, the proxy prints what it made of each one: + +- **The margin is the position.** Only a surface anchored to the top and left + edges can be driven this way; any other shape is placed by the compositor, and + a client cannot read where that is. `set_margin` and `set_size` from the + application are unaffected by this — they are its own to say. +- **The serial has to be one this surface received.** `move` and `resize` name + the press (or the pointer enter) that started them, and the proxy only replays + a gesture it can match against the press it recorded for that surface. + +A gesture also has to know where the window stops being reachable — and so does +the window itself, because a placement is margins from one output's corner: a +window created with another screen's placement is off the one it lands on, +clipped out of sight and past the reach of any pointer, with nothing left that +could bring it back. The layer role cannot be moved to another output either — +the protocol has no request for it, and the role is fixed when the surface is +created — so a drag towards a second monitor would carry the window off the edge +of the one it lives on, which is also the edge that feeds the gesture its motion. + +Both questions have the same answer, and there are three of them, best first: + +- the client's own `zxdg_output_v1` for the output the surface entered, in the + logical coordinates the margins are measured in; +- that output's `wl_output` mode over its scale, for a client that made no such + object — rounded down, since a size that is too small holds a window further + inside its screen while one that is too large lets a drag take it off an edge + it cannot come back from; +- `setWindowLayerBounds(windowId, width, height)`: the size the application + measured on its own screen, for a client that bound neither. + +A gesture keeps the _whole_ window inside that size, and a window that turns up +outside it — created with a placement from another screen, or restored from one, +or larger than the screen itself — is pulled back in as soon as its surface says +which output it is on. Whole rather than partly, for two reasons that both cost +the user the window: the part of it that accepts the pointer is the part that +has something to show, so a strip over the edge is a strip that cannot be +grabbed; and a compositor arranges a surface anchored to the top left by the +room left on the output, so one hanging over the edge is _shrunk_ to fit, which +would leave the geometry a gesture believes in a lie. What follows from that is +also what makes a drag past the edge legible: with the window flush against it, +the pointer leaves the window at the moment it leaves the screen, so the edge +the user pushed through is the edge they crossed. Without a size at all there is +nothing to hold it by, and a gesture that loses its surface is undone instead — +the fallback, not the rule. + +A window pinned at an edge is as far as a gesture can take it. A button press +holds the pointer to the window it was made on, so the motion events keep coming +even once the pointer has been dragged clear of the window — which a window that +follows the pointer never lets happen — and a gesture that ends that way is a +user pushing past the edge of the screen. The proxy reports it, with the edges it +was pinned against, through `onLayerSurfacePushed`; moving the window further is +a decision only the application can make, because it means creating the window +again — a layer surface keeps its output for life, and there is no request that +changes it. A policy may name the output it wants (`LayerShellOptions::output`, +matched against every name the client's `wl_output` and `zxdg_output_v1` gave +that output), and `onLayerSurfaceOutput` reports the output a converted window +ended up on in those same names: a window created again can be asked for by what +the proxy itself said, with nothing in between to match up. + +These requests cannot travel the injection path everything else uses. The id they +name is a layer surface to the compositor and the client's `xdg_toplevel` to the +filter, and the client's namespace reads the layer opcodes as different +xdg-toplevel ones — `set_size` is opcode `0`, the toplevel's `destroy`. They are +therefore written straight to the compositor under the connection's write lock, +which is what keeps a short direct write from interleaving with a forwarded +chunk. + +The same lock is what keeps such a request from naming an object the compositor +has already been told to forget, which is a protocol error that takes the whole +display connection down with it: the client can destroy the surface at any +moment, from its own thread. Resolving the window and writing the group are +therefore one critical section, taken in the order the proxy's own outbound path +takes its locks — the write lock first, the connection state under it. A window +still recorded once that lock is held is one whose destroy has not been forwarded +yet, because forwarding is what holds the same lock and destroying the surface is +what removes the record. + +Each one is followed by a `wl_surface.commit` on the same surface, which the +proxy also emits. Layer-surface state is double-buffered — the compositor adopts +it at the surface's next commit — so the commit is what makes the change take +effect at all. A commit that attaches no new buffer is legal, and the client's +own commits are unaffected. Whether a compositor reads such a commit as a change +to arrange is its own business, and at least one does not; the mechanism that +always works is the client's own commit, which is why an application that wants +a drag to follow the pointer has to keep the window drawing while the pointer is +on it. `gui/src/lib/paintPulse.ts` is that half: an imperceptible repaint for as +long as the user is working in the window, so the window is never idle in the +middle of a drag. + +A gesture ends when the button is released, and the geometry it leaves behind is +what the window keeps. Where the screen's size is unknown, a gesture that loses +its surface is **undone** first: the window can have been dragged anywhere, and +the place the gesture found it in is the one outcome that cannot strand it +off-screen. With the size known there is nothing to undo — the window is held +inside the screen — so the gesture stays in flight for a pointer that has been +dragged off the window, and the button is what decides what that meant. Dragging +a layer surface between outputs needs the surface itself recreated on the other +output, which is a decision only the application can make. + +The geometry a gesture leaves behind is remembered three times over: in the +cached role decision, so hiding and showing the window (a new role object on the +same surface) puts it back where it was; in the window's policy, so a window the +application destroys and builds again under the same managed id comes back there +too; and in a report to the application, through `onLayerSurfaceGeometry`, since +a compositor never tells a client where a layer surface is and an application +that persists a placement has nowhere else to read it from. + +An application-side resize goes through `setWindowLayerSize(windowId, width, +height)`: a converted client cannot resize itself, because the buffer it would +attach is not the one the compositor keeps the surface at. Electron's own +`setSize` still works on the window it looks like, and the compositor's configure +brings the window to the size the layer surface was given, so a caller that +resizes a layer window resizes the surface instead. + ### What a converted client does that has to be shadowed A real client speaks more xdg-shell than `get_toplevel`, and some of those calls @@ -257,3 +402,20 @@ same feature there. `ManagedWindow`-based windows reach this through background window is not a window _on_ the screen but the screen itself, so that caller also sizes the window to the display — on Wayland the layer surface's four anchors do that on their own, and nothing else would. + +## Building + +`pnpm build:modules` from the repository root builds every module; `pnpm build` +inside this directory builds only this one. + +`index.js` and `index.d.ts` are generated by the napi CLI from the `#[napi]` +wrappers in `src/lib.rs`, so they are never edited by hand: a new function +reaches the application by getting a wrapper there and rebuilding, and a +hand-written line in either file is gone at the next build. + +The application imports these functions by name, which makes an out-of-date +binding fail early and loudly: Electron cannot start at all, and the error is +`Named export 'X' not found ... is a CommonJS module`. The rebuild is the fix. +`isLayerShellNativeComplete` in `src/main/window.ts` covers the narrower case +where the wrapper exists but the binary behind it does not, and falls back to +ordinary windows with a message saying so. diff --git a/modules/window/index.d.ts b/modules/window/index.d.ts index f91dc82c..7db1581c 100644 --- a/modules/window/index.d.ts +++ b/modules/window/index.d.ts @@ -96,6 +96,16 @@ export declare const enum LayerShellLayer { export interface LayerShellOptions { /** Purpose of the surface, e.g. `"open-orpheus-menu"`. Required. */ namespace: string + /** + * The output to create the surface on, by the name the compositor knows + * (for example `"DP-1"`) — the name `onLayerSurfaceOutput` reports. The + * compositor picks when this is absent or names an output it never + * advertised. + * + * A layer surface keeps the output it was created with for its whole life, + * so this is the only way to put a window on a particular one. + */ + output?: string /** Defaults to the top layer. */ layer?: LayerShellLayer anchorTop?: boolean @@ -118,6 +128,21 @@ export interface LayerShellOptions { keyboardInteractivity?: number } +/** + * Where a layer surface the user moved or resized ended up. + * + * The coordinates are the ones the window's policy is written in, which is the + * same logical space Electron reports. + */ +export interface LayerSurfaceGeometry { + /** The managed window id the surface belongs to. */ + windowId: string + x: number + y: number + width: number + height: number +} + /** * Listen for windows whose layer-shell role was refused. * @@ -130,6 +155,45 @@ export interface LayerShellOptions { */ export declare function onLayerShellRoleRefused(callback: (windowId: string) => void): void +/** + * Listen for a layer surface the user finished moving or resizing. + * + * A compositor never tells a client where a layer surface is, and the native + * layer is the one replaying the gesture, so this is the only way an + * application learns where a surface ended up — and the only way it can put a + * window it recreates back there. + * + * Only for Wayland on Linux. + */ +export declare function onLayerSurfaceGeometry(callback: (geometry: LayerSurfaceGeometry) => void): void + +/** + * Listen for the output a converted window's layer surface ended up on. + * + * A layer surface keeps the output the compositor created it on, and nothing + * in the protocol ever says which that was — a client is only told that its + * surface entered an output. That name is what a window created again after a + * drag past the screen edge has to be asked for by, and it is the proxy's own + * name for the output, so the application hands it straight back without + * matching it against anything. + * + * Only for Wayland on Linux. + */ +export declare function onLayerSurfaceOutput(callback: (windowId: string, output: string) => void): void + +/** + * Listen for a gesture the user pushed past the edge of the output it is on. + * + * The window stopped at the edge because a layer surface keeps the output it + * was created with, and there is no request to move it to another one; this is + * the moment at which the application can decide to create the window again on + * the output next to it. `edges` uses the xdg-toplevel resize edge flags: the + * top, bottom, left and right of the window are 1, 2, 4 and 8. + * + * Only for Wayland on Linux. + */ +export declare function onLayerSurfacePushed(callback: (windowId: string, edges: number) => void): void + /** * Set regions that the window is used to receive inputs. * @@ -139,6 +203,21 @@ export declare function setInputRegion(windowHandle: string | Buffer, rects?: { export declare function setWindowAsBackground(hwnd: Buffer): void +/** + * Record how large the output a converted window's layer surface lives on is. + * + * A compositor never tells a client how big an output is, and a layer surface + * cannot move to another one — so a gesture the proxy replays has no way of + * knowing where the window would stop being reachable. Reporting the size of + * the screen the window is on is what keeps a drag inside it, with a strip of + * the window left on screen. Sizes are in the same logical coordinates the + * window's policy is written in, and do nothing for a window that is not a + * layer surface. + * + * Only for Wayland on Linux. + */ +export declare function setWindowLayerBounds(windowId: string, width: number, height: number): boolean + /** * Register (or clear) the layer-shell policy for a managed window. * @@ -154,6 +233,19 @@ export declare function setWindowAsBackground(hwnd: Buffer): void */ export declare function setWindowLayerShell(windowId: string, options?: LayerShellOptions | undefined | null): boolean +/** + * Change the size a converted window's layer surface asks for. + * + * A layer surface's size belongs to the application, and the client cannot ask + * for one itself: a buffer of another size attached to a surface the + * compositor keeps at the size it was told would mismatch it. The request is + * applied at the window's next commit, like every double-buffered surface + * state, and does nothing for a window that is not a layer surface. + * + * Only for Wayland on Linux. + */ +export declare function setWindowLayerSize(windowId: string, width: number, height: number): boolean + /** * Register the intent to present `target_window_id` as an xdg_popup of * `parent_window_id`, with the size the renderer measured. diff --git a/modules/window/index.js b/modules/window/index.js index f0aa89ee..7ad431bb 100644 --- a/modules/window/index.js +++ b/modules/window/index.js @@ -717,9 +717,14 @@ module.exports.isWindowPointerInside = nativeBinding.isWindowPointerInside module.exports.isWindowWaylandPopup = nativeBinding.isWindowWaylandPopup module.exports.LayerShellLayer = nativeBinding.LayerShellLayer module.exports.onLayerShellRoleRefused = nativeBinding.onLayerShellRoleRefused +module.exports.onLayerSurfaceGeometry = nativeBinding.onLayerSurfaceGeometry +module.exports.onLayerSurfaceOutput = nativeBinding.onLayerSurfaceOutput +module.exports.onLayerSurfacePushed = nativeBinding.onLayerSurfacePushed module.exports.setInputRegion = nativeBinding.setInputRegion module.exports.setWindowAsBackground = nativeBinding.setWindowAsBackground +module.exports.setWindowLayerBounds = nativeBinding.setWindowLayerBounds module.exports.setWindowLayerShell = nativeBinding.setWindowLayerShell +module.exports.setWindowLayerSize = nativeBinding.setWindowLayerSize module.exports.setWindowPopupPolicy = nativeBinding.setWindowPopupPolicy module.exports.supportsNativeWaylandPopup = nativeBinding.supportsNativeWaylandPopup module.exports.transferWindowState = nativeBinding.transferWindowState diff --git a/modules/window/src/lib.rs b/modules/window/src/lib.rs index 8eabcc71..000cce4c 100644 --- a/modules/window/src/lib.rs +++ b/modules/window/src/lib.rs @@ -42,6 +42,14 @@ pub enum LayerShellLayer { pub struct LayerShellOptions { /// Purpose of the surface, e.g. `"open-orpheus-menu"`. Required. pub namespace: String, + /// The output to create the surface on, by the name the compositor knows + /// (for example `"DP-1"`) — the name `onLayerSurfaceOutput` reports. The + /// compositor picks when this is absent or names an output it never + /// advertised. + /// + /// A layer surface keeps the output it was created with for its whole life, + /// so this is the only way to put a window on a particular one. + pub output: Option, /// Defaults to the top layer. pub layer: Option, pub anchor_top: Option, @@ -315,6 +323,158 @@ pub fn is_window_pointer_inside(window_id: String) -> bool { } } +/// Where a layer surface the user moved or resized ended up. +/// +/// The coordinates are the ones the window's policy is written in, which is the +/// same logical space Electron reports. +#[napi(object)] +pub struct LayerSurfaceGeometry { + /// The managed window id the surface belongs to. + pub window_id: String, + pub x: i32, + pub y: i32, + pub width: u32, + pub height: u32, +} + +/// Listen for a gesture the user pushed past the edge of the output it is on. +/// +/// The window stopped at the edge because a layer surface keeps the output it +/// was created with, and there is no request to move it to another one; this is +/// the moment at which the application can decide to create the window again on +/// the output next to it. `edges` uses the xdg-toplevel resize edge flags: the +/// top, bottom, left and right of the window are 1, 2, 4 and 8. +/// +/// Only for Wayland on Linux. +#[napi] +pub fn on_layer_surface_pushed( + env: Env, + #[napi(ts_arg_type = "(windowId: string, edges: number) => void")] callback: Function< + FnArgs<(String, u32)>, + (), + >, +) -> Result<()> { + #[cfg(target_os = "linux")] + { + use crate::linux::on_layer_surface_pushed as on_layer_surface_pushed_impl; + on_layer_surface_pushed_impl(env, callback) + } + + #[cfg(not(target_os = "linux"))] + { + let _ = callback; + env.throw("Only supports Linux") + } +} + +/// Listen for a layer surface the user finished moving or resizing. +/// +/// A compositor never tells a client where a layer surface is, and the native +/// layer is the one replaying the gesture, so this is the only way an +/// application learns where a surface ended up — and the only way it can put a +/// window it recreates back there. +/// +/// Only for Wayland on Linux. +#[napi] +pub fn on_layer_surface_geometry( + env: Env, + #[napi(ts_arg_type = "(geometry: LayerSurfaceGeometry) => void")] callback: Function< + LayerSurfaceGeometry, + (), + >, +) -> Result<()> { + #[cfg(target_os = "linux")] + { + use crate::linux::on_layer_surface_geometry as on_layer_surface_geometry_impl; + on_layer_surface_geometry_impl(env, callback) + } + + #[cfg(not(target_os = "linux"))] + { + let _ = callback; + env.throw("Only supports Linux") + } +} + +/// Listen for the output a converted window's layer surface ended up on. +/// +/// A layer surface keeps the output the compositor created it on, and nothing +/// in the protocol ever says which that was — a client is only told that its +/// surface entered an output. That name is what a window created again after a +/// drag past the screen edge has to be asked for by, and it is the proxy's own +/// name for the output, so the application hands it straight back without +/// matching it against anything. +/// +/// Only for Wayland on Linux. +#[napi] +pub fn on_layer_surface_output( + env: Env, + #[napi(ts_arg_type = "(windowId: string, output: string) => void")] callback: Function< + FnArgs<(String, String)>, + (), + >, +) -> Result<()> { + #[cfg(target_os = "linux")] + { + use crate::linux::on_layer_surface_output as on_layer_surface_output_impl; + on_layer_surface_output_impl(env, callback) + } + + #[cfg(not(target_os = "linux"))] + { + let _ = callback; + env.throw("Only supports Linux") + } +} + +/// Record how large the output a converted window's layer surface lives on is. +/// +/// A compositor never tells a client how big an output is, and a layer surface +/// cannot move to another one — so a gesture the proxy replays has no way of +/// knowing where the window would stop being reachable. Reporting the size of +/// the screen the window is on is what keeps a drag inside it, with a strip of +/// the window left on screen. Sizes are in the same logical coordinates the +/// window's policy is written in, and do nothing for a window that is not a +/// layer surface. +/// +/// Only for Wayland on Linux. +#[napi] +pub fn set_window_layer_bounds(window_id: String, width: u32, height: u32) -> bool { + #[cfg(target_os = "linux")] + { + linux::set_layer_bounds(window_id, width, height) + } + + #[cfg(not(target_os = "linux"))] + { + let _ = (window_id, width, height); + false + } +} + +/// Change the size a converted window's layer surface asks for. +/// +/// A layer surface's size belongs to the application, and the client cannot ask +/// for one itself: a buffer of another size attached to a surface the +/// compositor keeps at the size it was told would mismatch it. The request is +/// applied at the window's next commit, like every double-buffered surface +/// state, and does nothing for a window that is not a layer surface. +/// +/// Only for Wayland on Linux. +#[napi] +pub fn set_window_layer_size(window_id: String, width: u32, height: u32) -> bool { + #[cfg(target_os = "linux")] + { + linux::set_layer_size(window_id, width, height) + } + + #[cfg(not(target_os = "linux"))] + { + let _ = (window_id, width, height); + false + } +} + /// Whether the active Wayland hooks allow a native popup attempt. /// /// Independent of the desktop name. This does not guarantee that a particular diff --git a/modules/window/src/linux.rs b/modules/window/src/linux.rs index efe30fcd..df980186 100644 --- a/modules/window/src/linux.rs +++ b/modules/window/src/linux.rs @@ -144,6 +144,7 @@ fn to_wayland_options(options: &crate::LayerShellOptions) -> wayland::LayerShell wayland::LayerShellOptions { namespace: options.namespace.clone(), + output: options.output.clone(), layer: match options.layer { Some(crate::LayerShellLayer::Background) => wayland::LAYER_BACKGROUND, Some(crate::LayerShellLayer::Bottom) => wayland::LAYER_BOTTOM, @@ -371,6 +372,106 @@ pub fn window_pointer_inside(window_id: String) -> bool { wayland::window_pointer_inside(&window_id) } +pub fn on_layer_surface_pushed( + env: Env, + callback: Function, ()>, +) -> Result<()> { + if disable_display_server_hooks() { + return env.throw("onLayerSurfacePushed is unavailable when Wayland hooks are disabled"); + } + + let callback = callback.build_threadsafe_function().build_callback( + |ctx: ThreadsafeCallContext<(String, u32)>| { + Ok(std::convert::Into::>::into(ctx.value)) + }, + )?; + + if !wayland::on_layer_pushed(Box::new(move |window_id: String, edges: u32| { + callback.call((window_id, edges), ThreadsafeFunctionCallMode::NonBlocking); + })) { + return env.throw( + "onLayerSurfacePushed is unavailable because Wayland hooks are not initialized", + ); + } + + Ok(()) +} + +pub fn on_layer_surface_output( + env: Env, + callback: Function, ()>, +) -> Result<()> { + if disable_display_server_hooks() { + return env.throw("onLayerSurfaceOutput is unavailable when Wayland hooks are disabled"); + } + + let callback = callback.build_threadsafe_function().build_callback( + |ctx: ThreadsafeCallContext<(String, String)>| { + Ok(std::convert::Into::>::into( + ctx.value, + )) + }, + )?; + + if !wayland::on_layer_output(Box::new(move |window_id: String, output: String| { + callback.call((window_id, output), ThreadsafeFunctionCallMode::NonBlocking); + })) { + return env.throw( + "onLayerSurfaceOutput is unavailable because Wayland hooks are not initialized", + ); + } + + Ok(()) +} + +pub fn on_layer_surface_geometry( + env: Env, + callback: Function, +) -> Result<()> { + if disable_display_server_hooks() { + return env.throw("onLayerSurfaceGeometry is unavailable when Wayland hooks are disabled"); + } + + let callback = callback + .build_threadsafe_function() + .build_callback(|ctx: ThreadsafeCallContext| Ok(ctx.value))?; + + if !wayland::on_layer_geometry(Box::new( + move |window_id: String, x: i32, y: i32, width: u32, height: u32| { + callback.call( + crate::LayerSurfaceGeometry { + window_id, + x, + y, + width, + height, + }, + ThreadsafeFunctionCallMode::NonBlocking, + ); + }, + )) { + return env.throw( + "onLayerSurfaceGeometry is unavailable because Wayland hooks are not initialized", + ); + } + + Ok(()) +} + +pub fn set_layer_bounds(window_id: String, width: u32, height: u32) -> bool { + if disable_display_server_hooks() || !wayland::is_wayland() { + return false; + } + wayland::set_layer_bounds(&window_id, width, height) +} + +pub fn set_layer_size(window_id: String, width: u32, height: u32) -> bool { + if disable_display_server_hooks() || !wayland::is_wayland() { + return false; + } + wayland::set_layer_size(&window_id, width, height) +} + pub fn capture_window_next_pointer_axis( window_id: String, callback: Function, ()>, diff --git a/modules/window/src/linux/wayland/codec.rs b/modules/window/src/linux/wayland/codec.rs index 6acf1a48..5439f259 100644 --- a/modules/window/src/linux/wayland/codec.rs +++ b/modules/window/src/linux/wayland/codec.rs @@ -12,6 +12,14 @@ pub(crate) enum Iface { WlSeat, WlPointer, WlTouch, + /// A `wl_output` the client bound, tracked for its name and description. + WlOutput, + /// The client's `zxdg_output_manager_v1`. + ZxdgOutputManager, + /// A `zxdg_output_v1` the client created for one `wl_output`: it carries + /// the output's logical position and size, which is the coordinate space a + /// layer surface is placed in. + ZxdgOutput, WlSurface, XdgWmBase, XdgPositioner, @@ -48,6 +56,7 @@ pub(crate) const EVT_BUTTON: u16 = 3; pub(crate) const EVT_AXIS: u16 = 4; pub(crate) const EVT_MOTION: u16 = 2; pub(crate) const BTN_PRESSED: u32 = 1; +pub(crate) const BTN_RELEASED: u32 = 0; pub(crate) const EVT_TOUCH_DOWN: u16 = 0; pub(crate) const WL_TOUCH_RELEASE: u16 = 0; pub(crate) const REQ_GET_XDG_SURFACE: u16 = 2; @@ -58,6 +67,7 @@ pub(crate) const REQ_ACK_CONFIGURE: u16 = 4; pub(crate) const REQ_CREATE_POSITIONER: u16 = 1; pub(crate) const REQ_SET_TITLE: u16 = 2; pub(crate) const REQ_MOVE: u16 = 5; +pub(crate) const REQ_RESIZE: u16 = 6; pub(crate) const REQ_SET_INPUT_REGION: u16 = 5; pub(crate) const REQ_COMMIT: u16 = 6; pub(crate) const WL_POINTER_RELEASE: u16 = 1; @@ -77,6 +87,31 @@ pub(crate) const REQ_POSITIONER_SET_CONSTRAINT_ADJUSTMENT: u16 = 5; pub(crate) const EVT_POPUP_CONFIGURE: u16 = 0; pub(crate) const EVT_POPUP_DONE: u16 = 1; +// wl_output events. The ones that say which output this is, and how large it +// is when the client's own zxdg_output_v1 has not said; the rest pass through +// untouched. +pub(crate) const EVT_OUTPUT_GEOMETRY: u16 = 0; +pub(crate) const EVT_OUTPUT_MODE: u16 = 1; +pub(crate) const EVT_OUTPUT_SCALE: u16 = 3; +pub(crate) const EVT_OUTPUT_NAME: u16 = 4; +pub(crate) const EVT_OUTPUT_DESCRIPTION: u16 = 5; + +/// `wl_output.mode`'s flags: the output is in this mode now. An output +/// advertises every mode it can be in, and only this one is the size it draws. +pub(crate) const MODE_CURRENT: u32 = 1; + +// wl_surface events. Which output a surface is on is the one thing about an +// output a client is ever told, and it is what a layer surface is placed by. +pub(crate) const EVT_SURFACE_ENTER: u16 = 0; +pub(crate) const EVT_SURFACE_LEAVE: u16 = 1; + +// zxdg_output_manager_v1 requests +pub(crate) const REQ_GET_XDG_OUTPUT: u16 = 1; + +// zxdg_output_v1 events +pub(crate) const EVT_XDG_OUTPUT_LOGICAL_SIZE: u16 = 1; +pub(crate) const EVT_XDG_OUTPUT_NAME: u16 = 3; + // wl_registry events pub(crate) const EVT_GLOBAL: u16 = 0; pub(crate) const EVT_GLOBAL_REMOVE: u16 = 1; @@ -225,6 +260,18 @@ impl WlMessage { rfixed_i32(&self.body, offset) } + /// A `fixed` argument with its fractional part kept. + /// + /// Pointer coordinates are 24.8 fixed point; the integer truncation + /// [`WlMessage::fixed_arg`] applies is fine for an anchor, but a drag that + /// accumulated truncated positions would drift. + pub(crate) fn fixed_f64(&self, offset: usize) -> Option { + self.body + .get(offset..offset + 4) + .and_then(|b| b.try_into().ok()) + .map(|b| i32::from_ne_bytes(b) as f64 / 256.0) + } + /// Parses a length-prefixed string argument at `offset`. /// Returns the string and the offset just past its padded data. pub(crate) fn str_arg(&self, offset: usize) -> Option<(&str, usize)> { diff --git a/modules/window/src/linux/wayland/filter.rs b/modules/window/src/linux/wayland/filter.rs index 5f42e03f..6d8e5a4b 100644 --- a/modules/window/src/linux/wayland/filter.rs +++ b/modules/window/src/linux/wayland/filter.rs @@ -1,9 +1,9 @@ use std::os::fd::RawFd; use std::time::Instant; -use super::super::proxy::{Cmsg, Direction, Filtered}; +use super::super::proxy::{Cmsg, Direction, Filtered, sink_for}; use super::codec; -use super::handlers::{self, Action, Effects}; +use super::handlers::{self, Action, Effects, layer_debug_enabled}; use super::state::*; /// Stream pipeline: reassemble bytes → decode messages → run the functionality @@ -116,7 +116,7 @@ pub(crate) fn filter( } // Apply side effects after releasing the connection lock. - if let Some((seat_id, serial, surf_id, x, y)) = fx.button + if let Some((seat_id, serial, surf_id, x, y, button)) = fx.button && let Some(m) = LAST_BUTTON.get() && let Ok(mut buttons) = m.lock() { @@ -129,6 +129,7 @@ pub(crate) fn filter( wl_surface_id: surf_id, x, y, + button, at: Instant::now(), }, ); @@ -146,6 +147,25 @@ pub(crate) fn filter( for (wl_surface_id, axis) in fx.pointer_axes { fire_next_pointer_axis(fd, wl_surface_id, axis); } + // Requests an emulated gesture produced while handling an inbound event. + // They go straight to the compositor: the id they name is a layer surface + // there, but the xdg-toplevel object the client knows, whose opcodes would + // be read as different layer requests — `set_size` is `destroy`. + // + // Only inbound: the outbound path is what holds the write lock while it + // forwards, so taking it here would deadlock against itself. + if is_event + && !fx.layer_requests.is_empty() + && let Some(sink) = sink_for(fd) + && let Some(lock) = sink.write_lock.as_ref() + && let Ok(_guard) = lock.lock() + { + for request in &fx.layer_requests { + if !sink.send_to_server(request) && layer_debug_enabled() { + eprintln!("[proxy:layer] a synthesised request could not be sent"); + } + } + } for wl_surface_id in fx.destroyed_surfaces { clear_last_button_for_surface(fd, wl_surface_id); clear_pointer_axis_watchers_for_surface(fd, wl_surface_id); @@ -307,6 +327,17 @@ mod tests { let guard = conns.lock().unwrap(); check(guard.get(&self.fd()).expect("a connection is registered")) } + + /// Change the connection state kept for this fd. + fn edit(&self, change: impl FnOnce(&mut WaylandConn)) { + let conns = CONNS.get().expect("initialised"); + let mut guard = conns.lock().unwrap(); + change( + guard + .get_mut(&self.fd()) + .expect("a connection is registered"), + ); + } } impl Drop for Conn { @@ -315,6 +346,144 @@ mod tests { } } + /// A 24.8 fixed-point argument, the way the compositor sends coordinates. + fn fixed(value: f64) -> Vec { + ((value * 256.0) as i32).to_ne_bytes().to_vec() + } + + #[test] + fn an_emulated_drag_writes_layer_state_straight_to_the_compositor() { + use std::io::Read; + + use super::super::layer_shell::LayerShellOptions; + use super::super::state::{LastButton, LastButtonState, LayerGeometry, LayerWindow}; + use super::super::test_support::{serial, word}; + use crate::linux::proxy::{SINKS, Sink}; + + const LAYER: u32 = 30; + const SURFACE: u32 = 10; + const POINTER: u32 = 40; + const SERIAL: u32 = 77; + + let _guard = serial(); + init_state(); + let conn = Conn::new(); + conn.register(); + *LAST_BUTTON.get_or_init(Default::default).lock().unwrap() = LastButtonState::default(); + + // A converted window, anchored to the top and left so the proxy can + // move it by margins, with the pointer resting on it. + conn.edit(|conn| { + conn.ifaces.insert(POINTER, codec::Iface::WlPointer); + conn.ifaces.insert(LAYER, codec::Iface::ZwlrLayerSurface); + conn.pointer_focus.insert(POINTER, SURFACE); + conn.pointer_seat.insert(POINTER, 2); + conn.pointer_position.insert(POINTER, (100, 200)); + conn.wl_to_layer.insert(SURFACE, LAYER); + conn.layer_windows.insert( + LAYER, + LayerWindow { + wl_surface: SURFACE, + xdg_surface: 20, + geometry: LayerGeometry { + width: 800, + height: 225, + margin_left: 40, + margin_top: 60, + }, + bounds: None, + output: None, + }, + ); + conn.surface_roles.insert( + SURFACE, + SurfaceRole::Layer(LayerShellOptions { + anchor: super::super::layer_shell::ANCHOR_TOP + | super::super::layer_shell::ANCHOR_LEFT, + ..super::super::test_support::options() + }), + ); + }); + let mut buttons = LAST_BUTTON.get().unwrap().lock().unwrap(); + buttons.by_surface.insert( + (conn.fd(), SURFACE), + LastButton { + fd: conn.fd(), + seat_id: 2, + serial: SERIAL, + wl_surface_id: SURFACE, + x: 100, + y: 200, + button: 0x110, + at: Instant::now(), + }, + ); + drop(buttons); + + // The server side of the injected connection, read back by this test. + let (server, mut observer) = UnixStream::pair().expect("socketpair"); + SINKS.get_or_init(Default::default).lock().unwrap().insert( + conn.fd(), + Sink { + real_fd: server.as_raw_fd(), + app_fd: conn.fd(), + write_lock: Some(std::sync::Arc::new(std::sync::Mutex::new(()))), + }, + ); + + // The application asks to drag the window: the proxy replays it. + let asked = message_bytes(LAYER, codec::REQ_MOVE, &[word(2), word(SERIAL)].concat()); + assert!( + conn.take(Direction::Outbound, &asked).data.is_empty(), + "the request names a layer surface and must not be forwarded" + ); + + // The pointer moves 20 across and 10 down; the window follows it. + let motion = message_bytes( + POINTER, + codec::EVT_MOTION, + &[word(0), fixed(120.0), fixed(210.0)].concat(), + ); + let forwarded = conn.take(Direction::Inbound, &motion); + // Nothing is added to the stream the client reads: the geometry the + // proxy wrote is already flushed at the commit it injected, and the + // window is kept drawing from the application side (`paintPulse`), + // which is what makes the compositor adopt it while the user drags. + assert_eq!(forwarded.data.len(), motion.len()); + + observer.set_nonblocking(true).expect("nonblocking"); + let mut sent = Vec::new(); + let mut buf = [0u8; 64]; + while let Ok(read) = observer.read(&mut buf) { + if read == 0 { + break; + } + sent.extend_from_slice(&buf[..read]); + } + assert_eq!( + sent, + [ + crate::linux::wayland::test_support::request( + LAYER, + codec::REQ_LAYER_SET_MARGIN, + 24, + &[70, 0, 0, 60] + ), + // Followed by the commit that applies it: the window has + // nothing to redraw during a drag, so nothing else would. + crate::linux::wayland::test_support::request(SURFACE, codec::REQ_COMMIT, 8, &[]), + ] + .concat(), + "the margins carry the new position, sent as the layer's own request" + ); + assert_eq!( + conn.with_conn(|c| c.layer_windows.get(&LAYER).unwrap().geometry.margin_left), + 60 + ); + + SINKS.get().unwrap().lock().unwrap().remove(&conn.fd()); + } + #[test] fn complete_messages_round_trip_byte_for_byte() { init_state(); diff --git a/modules/window/src/linux/wayland/handlers/conversion.rs b/modules/window/src/linux/wayland/handlers/conversion.rs index 221e3bd7..f2f49115 100644 --- a/modules/window/src/linux/wayland/handlers/conversion.rs +++ b/modules/window/src/linux/wayland/handlers/conversion.rs @@ -15,7 +15,7 @@ use super::super::codec::{Iface, WlMessage}; use super::super::layer_shell; -use super::super::state::{LayerWindow, SurfaceRole, WaylandConn}; +use super::super::state::{LayerGeometry, LayerWindow, OutputInfo, SurfaceRole, WaylandConn}; use super::{Action, Effects}; /// Track the interfaces the compositor advertises. @@ -90,10 +90,36 @@ pub(crate) fn convert_to_layer_surface( shell_id, )); } + // An output the application named, or the compositor's own choice. A name + // the client never bound is not an error: the window is still created, just + // wherever the compositor puts it. + let output_id = options + .output + .as_deref() + .and_then(|wanted| { + let resolved = super::super::state::output_for_name(conn, wanted); + if resolved.is_none() { + let known: Vec = conn + .outputs + .values() + .flat_map(|output| output.names()) + .map(str::to_string) + .collect(); + super::layer_note(|| { + format!( + "no output named {wanted} (known: {}); the compositor chooses", + known.join(", ") + ) + }); + } + resolved + }) + .unwrap_or(0); messages.push(layer_shell::get_layer_surface( shell_id, toplevel_id, surface_id, + output_id, options.layer, &options.namespace, )); @@ -121,12 +147,40 @@ pub(crate) fn convert_to_layer_surface( options.effective_keyboard(bound_version), )); + // What the window was asked for, against what the screen it is going on + // is: a placement is margins from one output's corner, so this is the pair + // that decides whether it can be seen at all. + super::layer_note(|| { + format!( + "layer surface {toplevel_id} asked for {}x{} at ({}, {}), output {:?} of size {:?}", + options.width, + options.height, + options.margin_left, + options.margin_top, + options.output, + (output_id != 0) + .then(|| conn.outputs.get(&output_id).and_then(OutputInfo::size)) + .flatten() + ) + }); conn.ifaces.insert(toplevel_id, Iface::ZwlrLayerSurface); conn.layer_windows.insert( toplevel_id, LayerWindow { wl_surface: surface_id, xdg_surface: xdg_surface_id, + geometry: LayerGeometry { + width: options.width, + height: options.height, + margin_left: options.margin_left, + margin_top: options.margin_top, + }, + // Unknown until the application reports it, which it does as soon + // as its window is up. + bounds: None, + // Zero is the compositor's own choice, which only the surface's + // `enter` events can name. + output: (output_id != 0).then_some(output_id), }, ); conn.surface_roles diff --git a/modules/window/src/linux/wayland/handlers/grab.rs b/modules/window/src/linux/wayland/handlers/grab.rs new file mode 100644 index 00000000..bcab0930 --- /dev/null +++ b/modules/window/src/linux/wayland/handlers/grab.rs @@ -0,0 +1,1333 @@ +//! Interactive move and resize for a window the compositor knows as a layer +//! surface. +//! +//! Layer shell has neither: a layer surface's position and size belong to the +//! application, and only `set_margin` / `set_size` can change them. A converted +//! window still asks for both — `xdg_toplevel.move` from the application's own +//! drag, `resize` from the window's edge hit test — so the proxy replays the +//! gesture itself, from the two things a client is told: where the pointer is +//! within the surface, and the geometry the surface has. +//! +//! What a gesture is after is how far the pointer has moved since it grabbed +//! the window, and a motion event already says exactly that: for as long as the +//! button is down the compositor keeps measuring the pointer against the +//! geometry the press was made on, so the pointer's place in those coordinates +//! is the place it grabbed the window at plus everything it has done since. The +//! window's own movement is not in the numbers, which is what makes this work +//! at all — a gesture that rewrites the window's margins can read its own +//! progress back out of the pointer. +//! +//! The one thing that follows from that is that a step must never be added to +//! the previous step. The pointer's place in the surface is measured from the +//! press, not from the geometry the last step asked for, so adding a step to a +//! step counts everything the drag has done again on every event: a hundred +//! pixels of pointer would carry the window eight hundred. Every step here is +//! computed from the gesture's start instead, so a batch of events and a single +//! event land the window in the same place. +//! +//! Only a surface anchored to the top and left edges can be driven this way: +//! with those anchors the margins *are* the position. Any other shape keeps the +//! place the compositor gave it — as it does today — and the gesture is dropped. +//! +//! ## Running out of screen +//! +//! A layer surface keeps the output it was created on for its whole life, so a +//! gesture that reaches the edge of that output can go no further. The window +//! is held there with a strip of itself still on screen — losing it would leave +//! the user nothing to grab — and the pointer carries on out of it, which is +//! the one thing a window that is following the pointer never does. That is a +//! push past the edge, and it is reported when the button comes up, so the +//! application can build the window again on the screen next to this one. + +use std::os::fd::RawFd; + +use super::super::codec::{REQ_MOVE, REQ_RESIZE, WlMessage}; +use super::super::inject; +use super::super::layer_shell::{ANCHOR_LEFT, ANCHOR_TOP}; +use super::super::state::{ + GrabMode, LAST_BUTTON, LastButton, LayerGeometry, LayerGrab, SurfaceRole, WaylandConn, + fire_layer_geometry, fire_layer_pushed, keep_inside, layer_bounds, layer_geometry, + margin_range, remember_layer_geometry, set_layer_geometry, +}; +use super::{Action, Effects, layer_debug, layer_note}; + +/// `xdg_toplevel.resize` edge flags, from the protocol. +const EDGE_TOP: u32 = 1; +const EDGE_BOTTOM: u32 = 2; +const EDGE_LEFT: u32 = 4; +const EDGE_RIGHT: u32 = 8; + +/// Floor for an interactive resize, so a fast pointer cannot ask for a size the +/// compositor would refuse to draw. +const MIN_SIZE: i32 = 32; + +/// Replay `msg`, which asks for a move or a resize of a layer surface. +/// +/// Always suppresses: the request names an id the compositor knows as a layer +/// surface, where the xdg-toplevel opcode means something else entirely — +/// `move` is `get_popup` there, and `resize` is `ack_configure`. +pub(crate) fn begin(fd: RawFd, conn: &mut WaylandConn, msg: &WlMessage) -> Action { + match start(fd, conn, msg) { + Ok(grab) => { + // Whether the screen's size is known decides what a drag can do + // when it reaches the edge, so it is worth saying out loud every + // time a gesture starts. + let bounds = layer_bounds(conn, msg.object_id); + layer_note(|| { + format!( + "gesture on {}: {:?} of {}x{} at ({}, {}), screen {bounds:?}", + grab.layer_id, + grab.mode, + grab.size.0, + grab.size.1, + grab.margins.0, + grab.margins.1 + ) + }); + conn.layer_grab = Some(grab); + } + Err(reason) => layer_note(|| format!("gesture on {} refused: {reason}", msg.object_id)), + } + Action::Suppress +} + +fn start(fd: RawFd, conn: &WaylandConn, msg: &WlMessage) -> Result { + let layer = conn + .layer_windows + .get(&msg.object_id) + .ok_or("not a converted window")?; + let wl_surface = layer.wl_surface; + + // Margins only express a position under these two anchors; anything else + // would need the compositor's own placement, which a client cannot read. + match conn.surface_roles.get(&wl_surface) { + Some(SurfaceRole::Layer(options)) if options.anchor == ANCHOR_TOP | ANCHOR_LEFT => {} + _ => return Err("not anchored to the top left, so the margins are not its position"), + } + + let seat_id = msg.u32_arg(8).ok_or("no seat")?; + let serial = msg.u32_arg(12).ok_or("no serial")?; + if !serial_started_a_gesture(fd, conn, wl_surface, serial) { + return Err("the serial names no press or pointer enter on this surface"); + } + + let mode = match msg.opcode { + REQ_MOVE => GrabMode::Move, + REQ_RESIZE => GrabMode::Resize(msg.u32_arg(16).ok_or("no edges")?), + _ => return Err("neither a move nor a resize"), + }; + + // The gesture is driven by the pointer that is on the window; without one + // there is no motion to follow. + let (pointer_id, position) = conn + .pointer_focus + .iter() + .find(|(pointer, surface)| { + **surface == wl_surface && conn.pointer_seat.get(pointer) == Some(&seat_id) + }) + .and_then(|(pointer, _)| conn.pointer_position.get(pointer).map(|p| (*pointer, *p))) + .ok_or("no pointer on the window")?; + + // The button that press was made with, for the gestures that started from + // one: a gesture started from a pointer enter has none, and one whose press + // said nothing about its button is treated the same way. + let button = press_for(fd, wl_surface, serial) + .and_then(|press| (press.button != 0).then_some(press.button)); + + let geometry = layer.geometry; + Ok(LayerGrab { + layer_id: msg.object_id, + wl_surface, + pointer_id, + mode, + button, + origin: (position.0 as f64, position.1 as f64), + left: false, + size: (geometry.width, geometry.height), + margins: (geometry.margin_left, geometry.margin_top), + }) +} + +/// Whether `serial` is the press (or the pointer enter) a gesture may name. +/// +/// A stale serial has to be refused: replaying on one would hand the window to +/// a pointer that never grabbed it. +fn serial_started_a_gesture(fd: RawFd, conn: &WaylandConn, wl_surface: u32, serial: u32) -> bool { + conn.pointer_enter_serial.get(&wl_surface) == Some(&serial) + || press_for(fd, wl_surface, serial).is_some() +} + +/// The press `serial` names on `wl_surface`, if that is what it is. +fn press_for(fd: RawFd, wl_surface: u32, serial: u32) -> Option { + let buttons = LAST_BUTTON.get()?.lock().ok()?; + let press = *buttons.by_surface.get(&(fd, wl_surface))?; + (press.serial == serial).then_some(press) +} + +/// Follow the pointer of an in-flight gesture and return the layer requests +/// that put the window under it. +pub(crate) fn on_motion(conn: &mut WaylandConn, fx: &mut Effects, pointer_id: u32, x: f64, y: f64) { + let Some(grab) = conn.layer_grab.clone() else { + return; + }; + if grab.pointer_id != pointer_id { + return; + } + let Some((current, wl_surface)) = conn + .layer_windows + .get(&grab.layer_id) + .map(|layer| (layer.geometry, layer.wl_surface)) + else { + conn.layer_grab = None; + return; + }; + let bounds = layer_bounds(conn, grab.layer_id); + let target = resolve(&grab, bounds, x, y); + + // The button press holds the pointer to the window for as long as it is + // down, so the motion events keep coming even once the pointer is no longer + // over the window — which is the only way a drag that has been pushed past + // the edge can be noticed at all. + let outside = grab.mode == GrabMode::Move && pointer_outside(target, in_output(&grab, x, y)); + if let Some(grab) = conn.layer_grab.as_mut() { + grab.left = outside; + } + + if current == target { + return; + } + + layer_debug(|| format!("{}: {current:?} -> {target:?}", grab.layer_id)); + let mut requests = geometry_requests(grab.layer_id, current, target); + requests.push(inject::surface_commit_request(wl_surface)); + set_layer_geometry(conn, grab.layer_id, target); + fx.layer_requests.extend(requests); +} + +/// Whether the pointer has been dragged clear of the window. +/// +/// A window that follows the pointer keeps the pointer on itself: the two move +/// together, so the pointer stays where the gesture grabbed it. A pointer +/// dragged off the window is one the window could not keep up with — the edge +/// of the screen it lives on — and where it is now is what counts, not where it +/// has been, so a step the compositor rounded past the edge before the window +/// moved is taken back by the next one. +/// +/// `pointer` is the pointer's own position in the output, which the two halves +/// of the question between them have: the position in the window the compositor +/// reported, and the position of the window itself. +fn pointer_outside(geometry: LayerGeometry, pointer: (f64, f64)) -> bool { + let (x, y) = pointer; + let (left, top) = (geometry.margin_left as f64, geometry.margin_top as f64); + let (right, bottom) = (left + geometry.width as f64, top + geometry.height as f64); + x < left || y < top || x >= right || y >= bottom +} + +/// Where the pointer is in the output, as far as the gesture can tell. +/// +/// The coordinates a grab is given are the ones the press established and never +/// move under it, so the window's position when the gesture started is the +/// origin they are measured from — not the position the gesture has moved it to +/// since. +fn in_output(grab: &LayerGrab, x: f64, y: f64) -> (f64, f64) { + (grab.margins.0 as f64 + x, grab.margins.1 as f64 + y) +} + +/// How far the pointer has moved since it grabbed the window. +fn moved(grab: &LayerGrab, x: f64, y: f64) -> (f64, f64) { + (x - grab.origin.0, y - grab.origin.1) +} + +/// The geometry `grab` asks for while the pointer is at `(x, y)`. +fn resolve(grab: &LayerGrab, bounds: Option<(u32, u32)>, x: f64, y: f64) -> LayerGeometry { + let (dx, dy) = moved(grab, x, y); + let dx = dx.round() as i32; + let dy = dy.round() as i32; + let mut geometry = match grab.mode { + // The window is dragged by as much as the pointer has moved, from where + // the gesture found it — never from the previous step. + GrabMode::Move => LayerGeometry { + width: grab.size.0, + height: grab.size.1, + margin_left: grab.margins.0 + dx, + margin_top: grab.margins.1 + dy, + }, + // A resize works in the same movement, but only the edges the user + // grabbed follow it: the opposite ones stay exactly where they were. + GrabMode::Resize(edges) => { + let width = grab.size.0 as i32; + let height = grab.size.1 as i32; + let mut geometry = LayerGeometry { + width: grab.size.0, + height: grab.size.1, + margin_left: grab.margins.0, + margin_top: grab.margins.1, + }; + if edges & EDGE_RIGHT != 0 { + geometry.width = clamp_size(width + dx); + } + if edges & EDGE_LEFT != 0 { + geometry.width = clamp_size(width - dx); + // The opposite edge stays put, so the margin follows whatever + // the clamp did to the size. + geometry.margin_left += width - geometry.width as i32; + } + if edges & EDGE_BOTTOM != 0 { + geometry.height = clamp_size(height + dy); + } + if edges & EDGE_TOP != 0 { + geometry.height = clamp_size(height - dy); + geometry.margin_top += height - geometry.height as i32; + } + geometry + } + }; + if let Some((width, height)) = bounds { + geometry.margin_left = keep_inside(geometry.margin_left, geometry.width, width); + geometry.margin_top = keep_inside(geometry.margin_top, geometry.height, height); + } + geometry +} + +fn clamp_size(value: i32) -> u32 { + value.max(MIN_SIZE) as u32 +} + +/// The requests that turn `from` into `to`, in the order the compositor wants +/// them. Right and bottom margins stay `0`: they belong to edges the window is +/// not anchored to. +pub(crate) fn geometry_requests( + layer_id: u32, + from: LayerGeometry, + to: LayerGeometry, +) -> Vec> { + let mut requests = Vec::new(); + if (from.width, from.height) != (to.width, to.height) { + requests.push(super::super::layer_shell::set_size( + layer_id, to.width, to.height, + )); + } + if (from.margin_left, from.margin_top) != (to.margin_left, to.margin_top) { + requests.push(super::super::layer_shell::set_margin( + layer_id, + [to.margin_top, 0, 0, to.margin_left], + )); + } + requests +} + +/// End the gesture driven by `pointer_id`, if it is the one in flight. +/// +/// A gesture that ended with the window pinned at the edge of the output it +/// lives on and the pointer outside it is a user pushing past that edge: a +/// layer surface can never be moved to another output, so the only way to put +/// the window where it was pushed is to build it again there, and the +/// application is told which edge to build it against. +pub(crate) fn on_pointer_button_release( + conn: &mut WaylandConn, + pointer_id: u32, + released: Option, +) -> bool { + let Some(grab) = conn.layer_grab.clone() else { + return false; + }; + if grab.pointer_id != pointer_id { + return false; + } + // The gesture belongs to the button it was started with: another one + // pressed and released in the middle of a drag is not the drag ending. + // Ending it there would stop the window following the pointer, and against + // an edge it would report a push the user never made. + if let (Some(started), Some(released)) = (grab.button, released) + && started != released + { + return false; + } + remember(conn, &grab); + conn.layer_grab = None; + + // Only a drag can run out of screen — a resize keeps the window's corner + // where it is — and only a pointer that left the window while it was pinned + // was pushing: a window merely parked at the edge has the pointer inside + // it, exactly like any other. + let edges = match grab.left && grab.mode == GrabMode::Move { + true => pushed_edges(conn, &grab), + false => 0, + }; + if edges == 0 { + return true; + } + if let Some((window_id, _)) = layer_geometry(conn, grab.layer_id) { + layer_note(|| format!("{window_id} was pushed past the edge: {edges}")); + fire_layer_pushed(&window_id, edges); + } + true +} + +/// End the gesture on `wl_surface`, if there is one, and mark it as pushed out. +/// +/// A button press holds the pointer to the window it was made on, so a drag +/// normally never sees this: the pointer's position, not the surface it is over, +/// is what says whether the window still has it. A compositor that does hand +/// the focus on gets the same answer from here. +/// +/// Where the output's size is known the gesture stays in flight either way: the +/// window is held inside the output, so a pointer that is no longer over it is +/// one pushing past the edge, and the button decides what that meant. Where the +/// size is not known the window can have been dragged anywhere, and putting it +/// back where the gesture found it is the one outcome that cannot leave the +/// user with a window they can neither see nor reach. +pub(crate) fn on_surface_left(conn: &mut WaylandConn, fx: &mut Effects, wl_surface: u32) { + let Some(grab) = conn.layer_grab.as_ref() else { + return; + }; + if grab.wl_surface != wl_surface { + return; + } + let layer_id = grab.layer_id; + if let Some(grab) = conn.layer_grab.as_mut() { + grab.left = true; + } + if layer_bounds(conn, layer_id).is_some() { + layer_note(|| format!("the pointer left the gesture on {layer_id}")); + return; + } + undo(conn, fx); +} + +/// The edges a gesture was pushing against when the button came up, or `0`. +/// +/// A gesture that has reached an edge can move the window no further — it is +/// pinned there, and every further step of the pointer is spent on nothing. +/// Since a layer surface cannot leave the output it was created on, that is +/// also where the window has to be created again, on the one next to it. +fn pushed_edges(conn: &WaylandConn, grab: &LayerGrab) -> u32 { + let Some(bounds) = layer_bounds(conn, grab.layer_id) else { + return 0; + }; + let Some(geometry) = conn + .layer_windows + .get(&grab.layer_id) + .map(|layer| layer.geometry) + else { + return 0; + }; + let mut edges = 0; + if let Some((least, most)) = margin_range(geometry.width, bounds.0) { + if geometry.margin_left <= least { + edges |= EDGE_LEFT; + } + if geometry.margin_left >= most { + edges |= EDGE_RIGHT; + } + } + if let Some((least, most)) = margin_range(geometry.height, bounds.1) { + if geometry.margin_top <= least { + edges |= EDGE_TOP; + } + if geometry.margin_top >= most { + edges |= EDGE_BOTTOM; + } + } + edges +} + +/// Put the surface back where the gesture found it. +fn undo(conn: &mut WaylandConn, fx: &mut Effects) { + let Some(grab) = conn.layer_grab.take() else { + return; + }; + let restored = LayerGeometry { + width: grab.size.0, + height: grab.size.1, + margin_left: grab.margins.0, + margin_top: grab.margins.1, + }; + let Some(current) = conn + .layer_windows + .get(&grab.layer_id) + .map(|layer| layer.geometry) + else { + return; + }; + if current == restored { + return; + } + + layer_note(|| { + format!( + "undoing the gesture on {}: {current:?} -> {restored:?}", + grab.layer_id + ) + }); + let mut requests = geometry_requests(grab.layer_id, current, restored); + requests.push(inject::surface_commit_request(grab.wl_surface)); + set_layer_geometry(conn, grab.layer_id, restored); + fx.layer_requests.extend(requests); +} + +/// Write where the gesture left the window into the window's own policy, so a +/// re-created window comes back there. +/// +/// The window on screen keeps the geometry either way: a *hidden and shown* +/// window reuses the same surface, whose role is re-applied from the cached +/// decision. Only a window the application destroys and builds again reads the +/// policy. +fn remember(conn: &WaylandConn, grab: &LayerGrab) { + remember_layer_geometry(conn, grab.layer_id); + // The application asked to be told: a compositor never tells a client where + // its surface is, so this is the only way a window it re-creates can come + // back to the place the user left it. + if let Some((window_id, geometry)) = layer_geometry(conn, grab.layer_id) { + fire_layer_geometry(&window_id, geometry); + } +} + +#[cfg(test)] +mod tests { + use super::super::super::codec::{Iface, REQ_COMMIT, REQ_LAYER_SET_MARGIN, REQ_LAYER_SET_SIZE}; + use super::super::super::layer_shell::LayerShellOptions; + use super::super::super::state::{ + LastButton, LastButtonState, LayerGeometry, LayerWindow, OutputInfo, SurfaceRole, + clear_all_layer_policies, layer_policy, set_layer_policy, + }; + use std::time::Instant; + + /// The button every gesture in these tests is started with. + const BUTTON: u32 = 0x110; + + use super::super::super::test_support::{connected, header, message, request, serial, word}; + use super::*; + + const FD: RawFd = 42; + /// The client's toplevel id, reused by the proxy as the layer surface id. + const LAYER: u32 = 30; + const SURFACE: u32 = 10; + const POINTER: u32 = 40; + const SEAT: u32 = 2; + const SERIAL: u32 = 77; + /// Where the pointer was when the gesture started, in surface coordinates. + const ORIGIN: (f64, f64) = (100.0, 200.0); + /// The same point in the coordinates the window is placed in, which is + /// where a pointer stays for the whole of a drag. + const WORLD: (f64, f64) = (40.0 + ORIGIN.0, 60.0 + ORIGIN.1); + /// The geometry every window in these tests starts with. + const START: LayerGeometry = LayerGeometry { + width: 800, + height: 225, + margin_left: 40, + margin_top: 60, + }; + + thread_local! { + /// The geometry a gesture's coordinates are measured against, which the + /// press fixes for the whole of it. + /// + /// Held here rather than read back from the gesture under test: what a + /// drag makes of that geometry is the whole question — it is what the + /// drag reads its own progress out of — and reading the answer back + /// would make any implementation agree with itself. + static GRABBED_IN: std::cell::Cell = const { std::cell::Cell::new(START) }; + } + + /// The commit that makes a geometry change take effect. + fn commit() -> Vec { + request(SURFACE, REQ_COMMIT, 8, &[]) + } + + /// Run one motion through the handler, as the pointer event would. + fn motion(conn: &mut WaylandConn, x: f64, y: f64) -> Effects { + let mut fx = Effects::default(); + on_motion(conn, &mut fx, POINTER, x, y); + fx + } + + /// The geometry a motion event's coordinates are measured against. + fn frame() -> LayerGeometry { + GRABBED_IN.with(|has| has.get()) + } + + /// Put the pointer at `world` in the coordinates the window is placed in. + /// + /// Which means: report its position within the window, measured against the + /// geometry the compositor has, exactly as a motion event carries it. + fn drag(conn: &mut WaylandConn, world: (f64, f64)) -> Effects { + let frame = frame(); + motion( + conn, + world.0 - frame.margin_left as f64, + world.1 - frame.margin_top as f64, + ) + } + + /// The requests one motion wants sent to the compositor. + fn requests(conn: &mut WaylandConn, x: f64, y: f64) -> Vec> { + motion(conn, x, y).layer_requests + } + + fn move_message() -> WlMessage { + message(LAYER, REQ_MOVE, &[word(SEAT), word(SERIAL)].concat()) + } + + fn resize_message(edges: u32) -> WlMessage { + message( + LAYER, + REQ_RESIZE, + &[word(SEAT), word(SERIAL), word(edges)].concat(), + ) + } + + /// A converted window anchored to the top and left, with the pointer on it + /// and a press the gesture may name. + fn converted(anchor: u32) -> WaylandConn { + *LAST_BUTTON.get_or_init(Default::default).lock().unwrap() = LastButtonState::default(); + GRABBED_IN.with(|has| has.set(START)); + let mut conn = connected(); + conn.layer_windows.insert( + LAYER, + LayerWindow { + wl_surface: SURFACE, + xdg_surface: 20, + geometry: LayerGeometry { + width: 800, + height: 225, + margin_left: 40, + margin_top: 60, + }, + bounds: None, + output: None, + }, + ); + conn.ifaces.insert(LAYER, Iface::ZwlrLayerSurface); + conn.wl_to_layer.insert(SURFACE, LAYER); + conn.surface_roles.insert( + SURFACE, + SurfaceRole::Layer(LayerShellOptions { + anchor, + ..super::super::super::test_support::options() + }), + ); + conn.pointer_focus.insert(POINTER, SURFACE); + conn.pointer_seat.insert(POINTER, SEAT); + conn.pointer_position + .insert(POINTER, (ORIGIN.0 as i32, ORIGIN.1 as i32)); + let mut buttons = LAST_BUTTON.get().unwrap().lock().unwrap(); + buttons.by_surface.insert( + (FD, SURFACE), + LastButton { + fd: FD, + seat_id: SEAT, + serial: SERIAL, + wl_surface_id: SURFACE, + x: ORIGIN.0 as i32, + y: ORIGIN.1 as i32, + button: BUTTON, + at: Instant::now(), + }, + ); + buttons.latest = Some((FD, SURFACE)); + conn + } + + fn top_left() -> WaylandConn { + converted(ANCHOR_TOP | ANCHOR_LEFT) + } + + /// The same, with the screen's size known and the window named. + fn on_a_screen(size: (u32, u32)) -> WaylandConn { + let mut conn = top_left(); + conn.layer_windows.get_mut(&LAYER).unwrap().bounds = Some(size); + conn.surface_ids.insert(SURFACE, "lyrics".to_string()); + conn + } + + /// A listener that collects every push reported. + fn pushes() -> std::sync::mpsc::Receiver<(String, u32)> { + let (sender, receiver) = std::sync::mpsc::channel(); + assert!(super::super::super::state::on_layer_pushed(Box::new( + move |window_id: String, edges: u32| { + let _ = sender.send((window_id, edges)); + } + ))); + receiver + } + + fn geometry(conn: &WaylandConn) -> LayerGeometry { + conn.layer_windows.get(&LAYER).unwrap().geometry + } + + #[test] + fn a_gesture_is_suppressed_and_never_reaches_the_compositor() { + let _serial = serial(); + let mut conn = top_left(); + + assert!(matches!( + begin(FD, &mut conn, &move_message()), + Action::Suppress + )); + assert!(conn.layer_grab.is_some()); + } + + #[test] + fn a_move_follows_the_pointer_from_where_it_started() { + let _serial = serial(); + let mut conn = top_left(); + begin(FD, &mut conn, &move_message()); + + let requests = drag(&mut conn, (WORLD.0 + 16.0, WORLD.1 + 5.0)).layer_requests; + + // Margins are sent top, right, bottom, left, and the window is where + // the pointer put it: the step is the pointer's own movement. + assert_eq!( + requests, + vec![ + request(LAYER, REQ_LAYER_SET_MARGIN, 24, &[65, 0, 0, 56]), + commit(), + ] + ); + assert_eq!( + geometry(&conn), + LayerGeometry { + width: 800, + height: 225, + margin_left: 56, + margin_top: 65, + } + ); + } + + #[test] + fn a_motion_that_changed_nothing_asks_for_nothing() { + let _serial = serial(); + let mut conn = top_left(); + begin(FD, &mut conn, &move_message()); + + assert!( + motion(&mut conn, ORIGIN.0, ORIGIN.1) + .layer_requests + .is_empty() + ); + // A sub-pixel move rounds to the same margin, so nothing is sent — + // which is what keeps a drag from writing on every motion event. + assert!( + motion(&mut conn, ORIGIN.0 + 0.4, ORIGIN.1) + .layer_requests + .is_empty() + ); + assert!( + !motion(&mut conn, ORIGIN.0 + 1.0, ORIGIN.1) + .layer_requests + .is_empty() + ); + } + + /// What a drag is for: the pointer stays where it grabbed the window. + /// + /// The window moves with the pointer, so every step the pointer takes is + /// also a step the window takes away from it. A step computed from where + /// the gesture started carries that movement as if it were the pointer's, + /// and the pointer drifts further out of the window with every event; a + /// step computed from the window as it is now has that drift to undo and + /// nothing else to do. + #[test] + fn a_long_drag_keeps_the_pointer_where_it_grabbed_the_window() { + let _serial = serial(); + let mut conn = top_left(); + begin(FD, &mut conn, &move_message()); + + for step in 1..=20 { + let world = (WORLD.0 + step as f64 * 37.0, WORLD.1 + step as f64 * 11.0); + drag(&mut conn, world); + + let geometry = geometry(&conn); + assert_eq!( + ( + world.0 - geometry.margin_left as f64, + world.1 - geometry.margin_top as f64 + ), + ORIGIN, + "step {step} left the pointer somewhere else in the window" + ); + } + } + + /// A batch of motion events moves the window as far as the pointer moved. + /// + /// Every one of them carries a position measured from the same point — the + /// one the press fixed — so a window dragged by "where the pointer is in it, + /// less where it grabbed it" moves by the whole of that again on each event + /// of the batch: eight events of ten pixels would carry it eighty short of + /// the eighty they add up to, or rather eighty past it. This is the one + /// that is worth counting. + #[test] + fn a_batch_of_motion_events_moves_the_window_once() { + let _serial = serial(); + let mut conn = top_left(); + begin(FD, &mut conn, &move_message()); + + for step in 1..=8 { + drag(&mut conn, (WORLD.0 + step as f64 * 10.0, WORLD.1)); + } + + assert_eq!(geometry(&conn).margin_left, 40 + 80); + } + + /// The window's own movement is not in the coordinates a drag reads. + /// + /// Which is the whole of why the emulation works: the window has been + /// arranged somewhere else between two motion events, and the next event + /// says nothing about that — it says where the pointer is, measured from + /// where the press left it. A drag that took the window's position into + /// account would count its own progress as the pointer's. + #[test] + fn where_a_window_has_been_dragged_to_is_not_in_the_next_position() { + let _serial = serial(); + let mut conn = top_left(); + begin(FD, &mut conn, &move_message()); + + // A step of the window's own, as if the compositor had arranged it. + { + let layer = conn.layer_windows.get_mut(&LAYER).unwrap(); + layer.geometry.margin_left += 300; + } + let requests = drag(&mut conn, (WORLD.0 + 10.0, WORLD.1)).layer_requests; + + // The pointer moved ten pixels, so the window does too — from where the + // gesture found it, not from where it happens to be. + assert_eq!(geometry(&conn).margin_left, 40 + 10); + assert_eq!( + requests.first(), + Some(&request(LAYER, REQ_LAYER_SET_MARGIN, 24, &[60, 0, 0, 50])), + "and the margin it is put at is written out whole" + ); + } + + #[test] + fn a_move_that_comes_back_lands_where_it_started() { + let _serial = serial(); + let mut conn = top_left(); + begin(FD, &mut conn, &move_message()); + + drag(&mut conn, (WORLD.0 + 300.0, WORLD.1 + 120.0)); + let requests = drag(&mut conn, WORLD).layer_requests; + + assert_eq!( + requests, + vec![ + request(LAYER, REQ_LAYER_SET_MARGIN, 24, &[60, 0, 0, 40]), + commit(), + ] + ); + assert_eq!(geometry(&conn).margin_left, 40); + assert_eq!(geometry(&conn).margin_top, 60); + } + + #[test] + fn a_resize_drags_only_the_edges_it_grabbed() { + let _serial = serial(); + let mut conn = top_left(); + begin(FD, &mut conn, &resize_message(EDGE_RIGHT)); + + let requests = requests(&mut conn, ORIGIN.0 + 50.0, ORIGIN.1 + 90.0); + + // Only the width follows a right-edge grab; the margins keep the + // opposite corner where it was. + assert_eq!( + requests, + vec![ + request(LAYER, REQ_LAYER_SET_SIZE, 16, &[850, 225]), + commit(), + ] + ); + } + + #[test] + fn a_top_left_resize_moves_the_margin_with_the_size() { + let _serial = serial(); + let mut conn = top_left(); + begin(FD, &mut conn, &resize_message(EDGE_TOP | EDGE_LEFT)); + + let requests = requests(&mut conn, ORIGIN.0 + 30.0, ORIGIN.1 + 25.0); + + // Both edges move together, and the size is sent before the margin. + assert_eq!( + requests, + vec![ + request(LAYER, REQ_LAYER_SET_SIZE, 16, &[770, 200]), + request(LAYER, REQ_LAYER_SET_MARGIN, 24, &[85, 0, 0, 70]), + commit(), + ] + ); + } + + #[test] + fn a_resize_stops_at_the_floor_and_takes_the_margin_with_it() { + let _serial = serial(); + let mut conn = top_left(); + begin(FD, &mut conn, &resize_message(EDGE_LEFT)); + + // Far past the smallest size the proxy will ask for. + motion(&mut conn, ORIGIN.0 + 4000.0, ORIGIN.1); + + let applied = geometry(&conn); + assert_eq!(applied.width, MIN_SIZE as u32); + // The right edge is still at the margin the session started with. + assert_eq!(applied.margin_left, 40 + 800 - MIN_SIZE); + } + + #[test] + fn a_drag_is_remembered_for_the_surfaces_next_role() { + let _serial = serial(); + let mut conn = top_left(); + begin(FD, &mut conn, &move_message()); + + drag(&mut conn, (WORLD.0 + 10.0, WORLD.1 + 5.0)); + + // Hiding and showing a window builds a new role object, which is + // converted from the cached options: a drag that is not in them is + // undone by the next show. + let Some(SurfaceRole::Layer(options)) = conn.surface_roles.get(&SURFACE) else { + panic!("the window is a layer surface"); + }; + assert_eq!((options.width, options.height), (800, 225)); + assert_eq!((options.margin_left, options.margin_top), (50, 65)); + } + + #[test] + fn a_drag_is_remembered_for_a_window_that_is_built_again() { + let _serial = serial(); + // A window the application destroys and builds again under the same + // managed id is converted from the policy, not from the surface. + let mut conn = top_left(); + conn.surface_ids.insert(SURFACE, "lyrics".to_string()); + assert!(set_layer_policy( + "lyrics", + super::super::super::test_support::options() + )); + + begin(FD, &mut conn, &move_message()); + drag(&mut conn, (WORLD.0 + 10.0, WORLD.1 + 5.0)); + on_pointer_button_release(&mut conn, POINTER, Some(BUTTON)); + + let options = layer_policy("lyrics").expect("still registered"); + assert_eq!((options.margin_left, options.margin_top), (50, 65)); + clear_all_layer_policies(); + } + + #[test] + fn a_finished_gesture_reports_where_the_window_ended_up() { + let _serial = serial(); + let mut conn = top_left(); + conn.surface_ids.insert(SURFACE, "lyrics".to_string()); + let (sender, receiver) = std::sync::mpsc::channel(); + assert!(super::super::super::state::on_layer_geometry(Box::new( + move |window_id: String, x: i32, y: i32, width: u32, height: u32| { + // A listener that outlives its test simply stops receiving. + let _ = sender.send((window_id, x, y, width, height)); + } + ))); + + begin(FD, &mut conn, &move_message()); + drag(&mut conn, (WORLD.0 + 10.0, WORLD.1 + 5.0)); + on_pointer_button_release(&mut conn, POINTER, Some(BUTTON)); + + assert_eq!( + receiver.try_recv().expect("the gesture is reported"), + ("lyrics".to_string(), 50, 65, 800, 225) + ); + } + + #[test] + fn a_gesture_cannot_push_the_window_off_its_output() { + let _serial = serial(); + let mut conn = on_a_screen((1280, 720)); + begin(FD, &mut conn, &move_message()); + + // Far beyond the bottom right corner of the output it lives on. + drag(&mut conn, (WORLD.0 + 4000.0, WORLD.1 + 4000.0)); + + let applied = geometry(&conn); + assert_eq!( + applied.margin_left, + 1280 - 800, + "the window ends where the screen does" + ); + assert_eq!(applied.margin_top, 720 - 225); + } + + #[test] + fn a_gesture_cannot_push_the_window_out_of_the_top_left_either() { + let _serial = serial(); + let mut conn = on_a_screen((1280, 720)); + begin(FD, &mut conn, &move_message()); + + drag(&mut conn, (WORLD.0 - 4000.0, WORLD.1 - 4000.0)); + + let applied = geometry(&conn); + assert_eq!(applied.margin_left, 0); + assert_eq!(applied.margin_top, 0); + } + + /// The compositor's own answer about the output beats the one the + /// application measured, and is the one a drag stops at when the two + /// disagree. + #[test] + fn the_screens_size_comes_from_the_compositor_when_it_knows_it() { + let _serial = serial(); + let mut conn = on_a_screen((2560, 1440)); + conn.outputs.insert( + 7, + OutputInfo { + logical_size: Some((1280, 720)), + ..OutputInfo::default() + }, + ); + conn.surface_outputs.insert(SURFACE, 7); + begin(FD, &mut conn, &move_message()); + + drag(&mut conn, (WORLD.0 + 4000.0, WORLD.1 + 4000.0)); + + assert_eq!(geometry(&conn).margin_left, 1280 - 800); + assert_eq!(geometry(&conn).margin_top, 720 - 225); + } + + /// An output that has only just been announced reports no size, and a + /// window held inside nothing would be pinned to a corner of the screen. + #[test] + fn an_output_that_reports_no_size_falls_back_to_the_reported_one() { + let _serial = serial(); + let mut conn = on_a_screen((1280, 720)); + conn.outputs.insert( + 7, + OutputInfo { + logical_size: Some((0, 0)), + ..OutputInfo::default() + }, + ); + conn.surface_outputs.insert(SURFACE, 7); + begin(FD, &mut conn, &move_message()); + + drag(&mut conn, (WORLD.0 + 4000.0, WORLD.1)); + + assert_eq!(geometry(&conn).margin_left, 1280 - 800); + } + + #[test] + fn a_window_larger_than_its_screen_is_left_alone() { + let _serial = serial(); + let mut conn = top_left(); + { + let layer = conn.layer_windows.get_mut(&LAYER).unwrap(); + // The smallest window a resize can leave, on an output barely + // bigger than the strip that would have to stay inside it. + layer.geometry = LayerGeometry { + width: MIN_SIZE as u32, + height: MIN_SIZE as u32, + margin_left: 40, + margin_top: 60, + }; + layer.bounds = Some((10, 10)); + } + GRABBED_IN.with(|has| has.set(geometry(&conn))); + begin(FD, &mut conn, &move_message()); + + // No margin puts a window that large on the screen, so it follows the + // pointer rather than being pinned somewhere it does not belong. + drag(&mut conn, (WORLD.0 + 40.0, WORLD.1 + 40.0)); + + assert_eq!(geometry(&conn).margin_left, 80); + assert_eq!(geometry(&conn).margin_top, 100); + } + + #[test] + fn pushing_past_an_edge_is_reported_when_the_button_comes_up() { + let _serial = serial(); + let mut conn = on_a_screen((1280, 720)); + let receiver = pushes(); + + // Dragged far past the right edge, until the pointer is out of the + // window: only creating it again can move it any further. The button + // press keeps the motion events coming, so this is where the push is + // noticed — no leave, and nothing else to notice it by. + begin(FD, &mut conn, &move_message()); + drag(&mut conn, (WORLD.0 + 4000.0, WORLD.1)); + assert!( + conn.layer_grab.as_ref().is_some_and(|grab| grab.left), + "the pointer is out of the window" + ); + assert_eq!( + geometry(&conn).margin_left, + 1280 - 800, + "the window is held at the edge it cannot pass" + ); + + on_pointer_button_release(&mut conn, POINTER, Some(BUTTON)); + + assert_eq!( + receiver.try_recv().expect("the push is reported"), + ("lyrics".to_string(), EDGE_RIGHT) + ); + } + + #[test] + fn a_pointer_that_comes_back_into_the_window_is_not_pushing_any_more() { + let _serial = serial(); + let mut conn = on_a_screen((1280, 720)); + let receiver = pushes(); + + begin(FD, &mut conn, &move_message()); + drag(&mut conn, (WORLD.0 + 4000.0, WORLD.1)); + // Dragged back until the window can follow the pointer again. + drag(&mut conn, (WORLD.0 + 100.0, WORLD.1)); + on_pointer_button_release(&mut conn, POINTER, Some(BUTTON)); + + assert!( + receiver.try_recv().is_err(), + "the window followed the pointer in the end" + ); + } + + /// A second button pressed and released in the middle of a drag is not the + /// drag ending: the gesture belongs to the button it was started with. + #[test] + fn a_button_the_gesture_was_not_started_with_does_not_end_it() { + const OTHER_BUTTON: u32 = 0x111; + let _serial = serial(); + let mut conn = on_a_screen((1280, 720)); + let receiver = pushes(); + + begin(FD, &mut conn, &move_message()); + drag(&mut conn, (WORLD.0 + 4000.0, WORLD.1)); + + assert!( + !on_pointer_button_release(&mut conn, POINTER, Some(OTHER_BUTTON)), + "the button the drag was not started with leaves it in flight" + ); + assert!(conn.layer_grab.is_some()); + + // And the drag it was started with goes on to its own ending. + on_pointer_button_release(&mut conn, POINTER, Some(BUTTON)); + assert_eq!( + receiver.try_recv().expect("the gesture is reported"), + ("lyrics".to_string(), EDGE_RIGHT) + ); + } + + #[test] + fn a_drag_that_took_the_pointer_out_of_the_window_and_back_is_not_a_push() { + let _serial = serial(); + let mut conn = on_a_screen((1280, 720)); + let receiver = pushes(); + + begin(FD, &mut conn, &move_message()); + // Out of the window on the left, then to the middle of the screen: the + // window follows the pointer to the end, so nothing was pushed off the + // edge it lives on. + drag(&mut conn, (WORLD.0 - 4000.0, WORLD.1)); + drag(&mut conn, (WORLD.0 + 400.0, WORLD.1)); + on_pointer_button_release(&mut conn, POINTER, Some(BUTTON)); + + assert!(receiver.try_recv().is_err()); + } + + #[test] + fn a_window_parked_at_the_edge_is_not_pushed_anywhere() { + let _serial = serial(); + let mut conn = on_a_screen((1280, 720)); + let receiver = pushes(); + + // Pushed to the edge and let go of there, with the pointer still on it: + // that is where the user wanted the window, not a request to leave the + // screen. How far the window can be pushed before its own edge is what + // decides, and the window is 800 wide. + begin(FD, &mut conn, &move_message()); + drag(&mut conn, (WORLD.0 + 500.0, WORLD.1)); + assert_eq!(geometry(&conn).margin_left, 1280 - 800, "at the edge"); + on_pointer_button_release(&mut conn, POINTER, Some(BUTTON)); + + assert!( + receiver.try_recv().is_err(), + "the pointer never left the window" + ); + } + + #[test] + fn a_pointer_that_left_a_window_away_from_any_edge_reports_nothing() { + let _serial = serial(); + let mut conn = on_a_screen((1280, 720)); + let receiver = pushes(); + + begin(FD, &mut conn, &move_message()); + drag(&mut conn, (WORLD.0 + 10.0, WORLD.1 + 10.0)); + let mut fx = Effects::default(); + on_surface_left(&mut conn, &mut fx, SURFACE); + on_pointer_button_release(&mut conn, POINTER, Some(BUTTON)); + + assert!( + receiver.try_recv().is_err(), + "the window was nowhere near an edge, so nothing is asked for" + ); + } + + #[test] + fn a_resize_is_never_a_push() { + let _serial = serial(); + let mut conn = on_a_screen((1280, 720)); + let receiver = pushes(); + + // A resize that ends at the edge of the screen keeps the window's own + // corner where it is: there is nothing to move anywhere. The pointer is + // out of the window for the whole of a resize, which is what a move + // would be read as. + begin(FD, &mut conn, &resize_message(EDGE_RIGHT)); + motion(&mut conn, ORIGIN.0 + 4000.0, ORIGIN.1); + let mut fx = Effects::default(); + on_surface_left(&mut conn, &mut fx, SURFACE); + on_pointer_button_release(&mut conn, POINTER, Some(BUTTON)); + + assert!(receiver.try_recv().is_err()); + } + + #[test] + fn a_stale_serial_never_grabs() { + let _serial = serial(); + let mut conn = top_left(); + + let stale = message(LAYER, REQ_MOVE, &[word(SEAT), word(SERIAL + 1)].concat()); + assert!(matches!(begin(FD, &mut conn, &stale), Action::Suppress)); + assert!(conn.layer_grab.is_none(), "a gesture it cannot verify"); + } + + #[test] + fn a_pointer_enter_serial_may_also_start_a_gesture() { + let _serial = serial(); + let mut conn = top_left(); + conn.pointer_enter_serial.insert(SURFACE, SERIAL); + + let mut buttons = LAST_BUTTON.get().unwrap().lock().unwrap(); + buttons.by_surface.clear(); + buttons.latest = None; + drop(buttons); + + begin(FD, &mut conn, &move_message()); + assert!(conn.layer_grab.is_some()); + } + + #[test] + fn only_a_top_left_anchored_surface_can_be_driven() { + let _serial = serial(); + // The coverage shape a background window uses: the compositor decides + // where it sits, so there is no position to write. + let mut conn = converted(super::super::super::layer_shell::ANCHOR_ALL); + + begin(FD, &mut conn, &move_message()); + + assert!(conn.layer_grab.is_none()); + } + + #[test] + fn a_gesture_on_a_window_the_pointer_is_not_over_is_refused() { + let _serial = serial(); + let mut conn = top_left(); + conn.pointer_focus.clear(); + + begin(FD, &mut conn, &move_message()); + + assert!(conn.layer_grab.is_none()); + } + + #[test] + fn a_press_and_a_gesture_named_by_another_seat_do_not_grab() { + let _serial = serial(); + let mut conn = top_left(); + + begin( + FD, + &mut conn, + &message(LAYER, REQ_MOVE, &[word(SEAT + 1), word(SERIAL)].concat()), + ); + + assert!(conn.layer_grab.is_none()); + } + + #[test] + fn releasing_the_button_ends_the_gesture() { + let _serial = serial(); + let mut conn = top_left(); + begin(FD, &mut conn, &move_message()); + + assert!(on_pointer_button_release(&mut conn, POINTER, Some(BUTTON))); + assert!( + motion(&mut conn, ORIGIN.0 + 50.0, ORIGIN.1) + .layer_requests + .is_empty() + ); + assert!( + !on_pointer_button_release(&mut conn, POINTER, Some(BUTTON)), + "already over" + ); + } + + #[test] + fn without_the_screens_size_a_lost_window_comes_back() { + let _serial = serial(); + let mut conn = top_left(); + begin(FD, &mut conn, &move_message()); + // Dragged far past the edge, with nothing to say where the edge is. + drag(&mut conn, (WORLD.0 + 3000.0, WORLD.1)); + + let mut fx = Effects::default(); + on_surface_left(&mut conn, &mut fx, SURFACE); + + assert!(conn.layer_grab.is_none()); + assert_eq!( + fx.layer_requests, + vec![ + request(LAYER, REQ_LAYER_SET_MARGIN, 24, &[60, 0, 0, 40]), + commit(), + ], + "the window comes back to where the gesture found it" + ); + assert_eq!(geometry(&conn).margin_left, 40); + } + + #[test] + fn a_pointer_that_merely_left_a_settled_window_changes_nothing() { + let _serial = serial(); + let mut conn = top_left(); + + let mut fx = Effects::default(); + on_surface_left(&mut conn, &mut fx, SURFACE); + + assert!(fx.layer_requests.is_empty(), "no gesture was in flight"); + } + + #[test] + fn destroying_the_window_ends_the_gesture() { + let _serial = serial(); + let mut conn = top_left(); + begin(FD, &mut conn, &move_message()); + + conn.purge(LAYER); + + assert!(conn.layer_grab.is_none()); + } + + #[test] + fn the_requests_use_layer_opcodes() { + let _serial = serial(); + let mut conn = top_left(); + begin(FD, &mut conn, &move_message()); + + // The reason these are injected straight to the compositor: on this id + // the client's namespace reads opcode 0 as `destroy`, and `set_size` is + // what the proxy sends. + let requests = requests(&mut conn, ORIGIN.0 + 1.0, ORIGIN.1); + let (object_id, opcode, size) = header(&requests[0]); + assert_eq!(object_id, LAYER); + assert_eq!((opcode, size), (REQ_LAYER_SET_MARGIN, 24)); + // …and the commit that makes it take effect is on the surface, not on + // the layer object: it is a `wl_surface` request either way. + assert_eq!(requests.last(), Some(&commit())); + } +} diff --git a/modules/window/src/linux/wayland/handlers/mod.rs b/modules/window/src/linux/wayland/handlers/mod.rs index 867ba488..29efcd85 100644 --- a/modules/window/src/linux/wayland/handlers/mod.rs +++ b/modules/window/src/linux/wayland/handlers/mod.rs @@ -5,6 +5,7 @@ //! tables below are the single index of every intercept point. mod conversion; +pub(crate) mod grab; mod objects; mod pointer; pub(crate) mod roles; @@ -13,12 +14,13 @@ mod title; mod touch; use std::os::fd::RawFd; +use std::sync::OnceLock; use super::codec::{ EVT_DELETE_ID, EVT_GLOBAL, EVT_GLOBAL_REMOVE, Iface, REQ_BIND, REQ_COMMIT, REQ_CREATE_SURFACE, REQ_DESTROY, REQ_GET_POINTER, REQ_GET_REGISTRY, REQ_GET_TOPLEVEL, REQ_GET_TOPLEVEL_DECORATION, - REQ_GET_TOUCH, REQ_GET_XDG_SURFACE, REQ_SET_ICON, REQ_SET_TITLE, REQ_SET_WINDOW_GEOMETRY, - WL_POINTER_RELEASE, WL_SEAT_RELEASE, WL_TOUCH_RELEASE, WlMessage, + REQ_GET_TOUCH, REQ_GET_XDG_OUTPUT, REQ_GET_XDG_SURFACE, REQ_SET_ICON, REQ_SET_TITLE, + REQ_SET_WINDOW_GEOMETRY, WL_POINTER_RELEASE, WL_SEAT_RELEASE, WL_TOUCH_RELEASE, WlMessage, }; use super::inject; use super::state::WaylandConn; @@ -37,13 +39,49 @@ pub(crate) enum Action { /// released (global state updates / user callbacks). #[derive(Default)] pub(crate) struct Effects { - pub(crate) button: Option<(u32, u32, u32, i32, i32)>, + /// The press that just landed: seat, serial, surface, where on it, and the + /// button it was made with. + pub(crate) button: Option<(u32, u32, u32, i32, i32, u32)>, /// A window whose surface could not take the layer role, by custom id. pub(crate) layer_shell_refused: Option, pub(crate) entered: Vec<(u32, i32, i32)>, pub(crate) arm_watchers_for: Vec, pub(crate) pointer_axes: Vec<(u32, u32)>, pub(crate) destroyed_surfaces: Vec, + /// Layer-surface requests an emulated gesture wants sent to the compositor. + pub(crate) layer_requests: Vec>, +} + +/// Whether the proxy narrates what it makes of layer-surface traffic. +/// +/// Off unless `ORPHEUS_LAYER_DEBUG` is set: the proxy has no logger, and a drag +/// that does not land where it should is otherwise invisible from outside. +pub(crate) fn layer_debug_enabled() -> bool { + static ENABLED: OnceLock = OnceLock::new(); + *ENABLED.get_or_init(|| { + std::env::var("ORPHEUS_LAYER_DEBUG").is_ok_and(|value| value != "0" && !value.is_empty()) + }) +} + +/// Log one line about layer-surface handling, when the switch is on. +pub(crate) fn layer_debug(message: impl FnOnce() -> String) { + if layer_debug_enabled() { + eprintln!("[proxy:layer] {}", message()); + } +} + +/// Log one decision of a layer-surface gesture, always. +/// +/// The per-motion traffic behind a drag is only interesting with the switch on, +/// but the handful of decisions it makes are not: a gesture that goes nowhere +/// has no other way of saying why, and from outside it looks exactly like one +/// that was never started. +/// +/// Written to the standard error the application inherits, and not to a file: +/// these calls happen inside the hooks the proxy has installed over the +/// process's own syscalls, where opening one is a call back into them. +pub(crate) fn layer_note(message: impl FnOnce() -> String) { + eprintln!("[proxy:layer] {}", message()); } pub(crate) fn dispatch_request( @@ -64,7 +102,7 @@ fn dispatch_request_inner( // A converted window answers to xdg-shell opcodes but must not reach the // compositor as xdg-shell. if conn.layer_windows.contains_key(&msg.object_id) { - return shadow::on_client_toplevel_request(conn, msg, fx); + return shadow::on_client_toplevel_request(fd, conn, msg, fx); } if conn.xdg_to_layer.contains_key(&msg.object_id) { return shadow::on_client_xdg_surface_request(conn, msg); @@ -91,6 +129,7 @@ fn dispatch_request_inner( (Iface::WlCompositor, REQ_CREATE_SURFACE) => objects::on_create_surface(conn, msg), (Iface::WlSeat, REQ_GET_POINTER) => objects::on_get_pointer(conn, msg), (Iface::WlSeat, REQ_GET_TOUCH) => objects::on_get_touch(conn, msg), + (Iface::ZxdgOutputManager, REQ_GET_XDG_OUTPUT) => objects::on_get_xdg_output(conn, msg), (Iface::XdgWmBase, REQ_GET_XDG_SURFACE) => objects::on_get_xdg_surface(conn, msg), (Iface::ZxdgDecorationManager, REQ_GET_TOPLEVEL_DECORATION) => { shadow::on_get_toplevel_decoration(conn, msg) @@ -155,6 +194,18 @@ pub(crate) fn dispatch_event(conn: &mut WaylandConn, msg: &WlMessage, fx: &mut E return shadow::on_layer_event(conn, msg); } + if conn.outputs.contains_key(&msg.object_id) { + return objects::on_output_event(conn, msg); + } + + if conn.xdg_outputs.contains_key(&msg.object_id) { + return objects::on_xdg_output_event(conn, msg); + } + + if conn.ifaces.get(&msg.object_id) == Some(&Iface::WlSurface) { + return objects::on_surface_event(conn, msg, fx); + } + if conn.ifaces.get(&msg.object_id) == Some(&Iface::WlPointer) { return pointer::on_pointer_event(conn, msg, fx); } @@ -372,7 +423,7 @@ mod tests { args.extend_from_slice(&word(0x110)); args.extend_from_slice(&word(BTN_PRESSED)); dispatch_event(&mut conn, &message(6, EVT_BUTTON, &args), &mut fx); - assert_eq!(fx.button, Some((3, 77, 10, 64, -32))); + assert_eq!(fx.button, Some((3, 77, 10, 64, -32, 0x110))); // A release does not start a drag. let mut fx = Effects::default(); @@ -414,7 +465,7 @@ mod tests { args.extend_from_slice(&(32i32 << 8).to_ne_bytes()); let mut fx = Effects::default(); dispatch_event(&mut conn, &message(7, EVT_TOUCH_DOWN, &args), &mut fx); - assert_eq!(fx.button, Some((3, 21, 10, 16, 32))); + assert_eq!(fx.button, Some((3, 21, 10, 16, 32, 0))); // Any other touch event (wl_touch.up is opcode 1) is passed through. let mut fx = Effects::default(); diff --git a/modules/window/src/linux/wayland/handlers/objects.rs b/modules/window/src/linux/wayland/handlers/objects.rs index 6f3e262a..35fdf0e0 100644 --- a/modules/window/src/linux/wayland/handlers/objects.rs +++ b/modules/window/src/linux/wayland/handlers/objects.rs @@ -4,17 +4,19 @@ use std::os::fd::RawFd; use super::super::codec::{ - EVT_POPUP_CONFIGURE, EVT_POPUP_DONE, Iface, REQ_CREATE_POSITIONER, REQ_DESTROY, REQ_GET_POPUP, - REQ_POSITIONER_SET_ANCHOR, REQ_POSITIONER_SET_ANCHOR_RECT, + EVT_OUTPUT_DESCRIPTION, EVT_OUTPUT_GEOMETRY, EVT_OUTPUT_MODE, EVT_OUTPUT_NAME, + EVT_OUTPUT_SCALE, EVT_POPUP_CONFIGURE, EVT_POPUP_DONE, EVT_SURFACE_ENTER, EVT_SURFACE_LEAVE, + EVT_XDG_OUTPUT_LOGICAL_SIZE, EVT_XDG_OUTPUT_NAME, Iface, MODE_CURRENT, REQ_CREATE_POSITIONER, + REQ_DESTROY, REQ_GET_POPUP, REQ_POSITIONER_SET_ANCHOR, REQ_POSITIONER_SET_ANCHOR_RECT, REQ_POSITIONER_SET_CONSTRAINT_ADJUSTMENT, REQ_POSITIONER_SET_GRAVITY, REQ_POSITIONER_SET_SIZE, REQ_SET_WINDOW_GEOMETRY, WlMessage, }; use super::super::layer_shell::xdg_toplevel_configure; use super::super::state::{ - LAST_BUTTON, PopupGeometry, PopupPolicy, SurfaceRole, WaylandConn, popup_policy, - take_popup_policy, + LAST_BUTTON, PopupGeometry, PopupPolicy, SurfaceRole, WaylandConn, fire_layer_output, + layer_bounds, layer_output_label, popup_policy, pull_inside_output, take_popup_policy, }; -use super::{Action, Effects}; +use super::{Action, Effects, layer_note}; // xdg_positioner enums, distinct from layer-shell's anchor bitmask. const POSITIONER_TOP_LEFT: i32 = 5; @@ -42,12 +44,24 @@ pub(crate) fn on_bind(conn: &mut WaylandConn, msg: &WlMessage) -> Action { Some(Iface::WlCompositor) } "wl_seat" => Some(Iface::WlSeat), + // Tracked for its name, which is how the application asks for the + // output a window it re-creates should appear on. + "wl_output" => { + conn.outputs.entry(new_id).or_default(); + Some(Iface::WlOutput) + } "zxdg_decoration_manager_v1" => Some(Iface::ZxdgDecorationManager), "xdg_toplevel_icon_manager_v1" => Some(Iface::XdgToplevelIconManager), "xdg_wm_base" => { conn.xdg_wm_base_id = Some(new_id); Some(Iface::XdgWmBase) } + // The logical geometry of every output, which is the space a layer + // surface's margins are measured in. + "zxdg_output_manager_v1" => { + conn.xdg_output_manager_id = Some(new_id); + Some(Iface::ZxdgOutputManager) + } _ => None, }; if let Some(tag) = tag { @@ -57,6 +71,153 @@ pub(crate) fn on_bind(conn: &mut WaylandConn, msg: &WlMessage) -> Action { Action::Forward } +/// Events naming the output behind a `wl_output` the client bound. +/// +/// Only the name and the description matter: they are what the application +/// knows an output by, and what a policy names when a window has to be created +/// on a particular one. +pub(crate) fn on_output_event(conn: &mut WaylandConn, msg: &WlMessage) -> Action { + let Some(known) = conn.outputs.get_mut(&msg.object_id) else { + return Action::Forward; + }; + match msg.opcode { + EVT_OUTPUT_GEOMETRY => { + // `x, y, physical_width, physical_height, subpixel, make, model, + // transform`: the two strings are the only variable-length part, so + // the transform is wherever they leave off. + if let Some((_, after_make)) = msg.str_arg(28) + && let Some((_, after_model)) = msg.str_arg(after_make) + && let Some(transform) = msg.u32_arg(after_model) + { + known.transform = Some(transform); + } + } + EVT_OUTPUT_NAME => known.name = msg.str_arg(8).map(|(name, _)| name.to_string()), + EVT_OUTPUT_DESCRIPTION => { + known.description = msg.str_arg(8).map(|(text, _)| text.to_string()) + } + EVT_OUTPUT_MODE => { + // `flags, width, height, refresh`: the flags come first, and an + // output advertises every mode it has — only the one it is in now + // is the size it draws in. + let flags = msg.u32_arg(8).unwrap_or(0); + if flags & MODE_CURRENT != 0 + && let (Some(width), Some(height)) = (msg.u32_arg(12), msg.u32_arg(16)) + { + known.mode = Some((width, height)); + } + } + EVT_OUTPUT_SCALE => known.scale = msg.u32_arg(8).map(|scale| scale as i32), + _ => {} + } + Action::Forward +} + +/// `zxdg_output_manager_v1.get_xdg_output(new_id, wl_output)`. +/// +/// The object is the client's own view of one output's logical geometry; the +/// pair is kept so the size it reports can be filed against the output a +/// surface is on, which is the only place that size is worth having. +pub(crate) fn on_get_xdg_output(conn: &mut WaylandConn, msg: &WlMessage) -> Action { + if let (Some(new_id), Some(output_id)) = (msg.u32_arg(8), msg.u32_arg(12)) { + conn.ifaces.insert(new_id, Iface::ZxdgOutput); + conn.xdg_outputs.insert(new_id, output_id); + } + Action::Forward +} + +/// Events giving an output's logical geometry, or the name it is known by. +pub(crate) fn on_xdg_output_event(conn: &mut WaylandConn, msg: &WlMessage) -> Action { + let Some(output_id) = conn.xdg_outputs.get(&msg.object_id).copied() else { + return Action::Forward; + }; + let Some(known) = conn.outputs.get_mut(&output_id) else { + return Action::Forward; + }; + match msg.opcode { + EVT_XDG_OUTPUT_LOGICAL_SIZE => { + if let (Some(width), Some(height)) = (msg.u32_arg(8), msg.u32_arg(12)) { + known.logical_size = Some((width, height)); + } + } + EVT_XDG_OUTPUT_NAME => known.xdg_name = msg.str_arg(8).map(|(name, _)| name.to_string()), + _ => {} + } + Action::Forward +} + +/// `wl_surface.enter` and `wl_surface.leave`: which output a surface is on. +/// +/// This is the only thing that ever says where a layer surface ended up: the +/// compositor chooses the output when it creates the surface, and nothing in +/// the protocol reports that choice afterwards. Without it a drag past the +/// screen edge could name no screen to put the window on. +pub(crate) fn on_surface_event( + conn: &mut WaylandConn, + msg: &WlMessage, + fx: &mut Effects, +) -> Action { + match msg.opcode { + EVT_SURFACE_ENTER => { + if let Some(output_id) = msg.u32_arg(8) { + conn.surface_outputs.insert(msg.object_id, output_id); + if let Some(layer_id) = conn.wl_to_layer.get(&msg.object_id).copied() { + let window_id = conn + .layer_windows + .get(&layer_id) + .and_then(|layer| conn.surface_ids.get(&layer.wl_surface)) + .cloned(); + let label = layer_output_label(conn, layer_id); + // Which screen a layer surface was given, and how large the + // proxy believes it is: the two things a drag against the + // edge of it is decided by. + layer_note(|| { + format!( + "{} is on output {:?} of size {:?}, window {}x{} at ({}, {})", + window_id.as_deref().unwrap_or(""), + label.as_deref().unwrap_or(""), + layer_bounds(conn, layer_id), + conn.layer_windows + .get(&layer_id) + .map_or(0, |layer| layer.geometry.width), + conn.layer_windows + .get(&layer_id) + .map_or(0, |layer| layer.geometry.height), + conn.layer_windows + .get(&layer_id) + .map_or(0, |layer| layer.geometry.margin_left), + conn.layer_windows + .get(&layer_id) + .map_or(0, |layer| layer.geometry.margin_top), + ) + }); + if let (Some(window_id), Some(label)) = (window_id, label) { + fire_layer_output(&window_id, &label); + } + // A window that turned up outside this screen has no way + // back on its own, and this is the first moment the proxy + // can know both that and how large the screen is. + if let Some(requests) = pull_inside_output(conn, layer_id) { + layer_note(|| { + format!("{layer_id} was placed off its screen; pulling it back in") + }); + fx.layer_requests.extend(requests); + } + } + } + } + EVT_SURFACE_LEAVE => { + if let Some(output_id) = msg.u32_arg(8) + && conn.surface_outputs.get(&msg.object_id) == Some(&output_id) + { + conn.surface_outputs.remove(&msg.object_id); + } + } + _ => {} + } + Action::Forward +} + pub(crate) fn on_create_surface(conn: &mut WaylandConn, msg: &WlMessage) -> Action { if let Some(new_id) = msg.u32_arg(8) { conn.ifaces.insert(new_id, Iface::WlSurface); @@ -380,6 +541,219 @@ pub(crate) fn on_delete_id(conn: &mut WaylandConn, msg: &WlMessage) -> Action { Action::Forward } +#[cfg(test)] +mod output_tests { + use super::super::super::codec::{ + EVT_OUTPUT_MODE, EVT_OUTPUT_SCALE, EVT_SURFACE_ENTER, EVT_SURFACE_LEAVE, + EVT_XDG_OUTPUT_LOGICAL_SIZE, EVT_XDG_OUTPUT_NAME, Iface, REQ_BIND, REQ_COMMIT, + REQ_GET_XDG_OUTPUT, REQ_LAYER_SET_MARGIN, + }; + use super::super::super::state::{LayerGeometry, LayerWindow, OutputInfo, output_for_name}; + use super::super::super::test_support::{message, request, wl_string, word}; + use super::*; + + /// A connection with one named output, and a converted window on it. + fn window_on(output: u32) -> WaylandConn { + let mut conn = WaylandConn::new(); + conn.outputs.insert( + output, + OutputInfo { + name: Some("DP-2".into()), + ..OutputInfo::default() + }, + ); + conn.ifaces.insert(10, Iface::WlSurface); + conn.ifaces.insert(30, Iface::ZwlrLayerSurface); + conn.wl_to_layer.insert(10, 30); + conn.surface_ids.insert(10, "lyrics".into()); + conn.layer_windows.insert( + 30, + LayerWindow { + wl_surface: 10, + xdg_surface: 20, + geometry: LayerGeometry { + width: 800, + height: 225, + margin_left: 0, + margin_top: 0, + }, + bounds: None, + output: None, + }, + ); + conn + } + + #[test] + fn a_surface_says_which_output_the_window_landed_on() { + let (sender, receiver) = std::sync::mpsc::channel(); + assert!(super::super::super::state::on_layer_output(Box::new( + move |window_id: String, output: String| { + let _ = sender.send((window_id, output)); + } + ))); + + let mut conn = window_on(7); + let entered = message(10, EVT_SURFACE_ENTER, &word(7)); + assert!(matches!( + on_surface_event(&mut conn, &entered, &mut Effects::default()), + Action::Forward + )); + + // The application is told in the proxy's own name for the output, so + // it can hand that name straight back when it wants a window there. + assert_eq!( + receiver.try_recv().expect("the output is reported"), + ("lyrics".to_string(), "DP-2".to_string()) + ); + let left = message(10, EVT_SURFACE_LEAVE, &word(7)); + on_surface_event(&mut conn, &left, &mut Effects::default()); + assert_eq!(conn.surface_outputs.get(&10), None); + } + + /// A client that made no `zxdg_output_v1` for an output still tells the + /// proxy how large it draws: the mode it is running, and how many of those + /// pixels go into one logical one. + #[test] + fn an_output_without_a_logical_size_is_measured_by_its_mode() { + let mut conn = WaylandConn::new(); + conn.outputs.insert(7, OutputInfo::default()); + // A mode the output has, but is not in: the flags come first, and this + // one is not the size it is drawing in. + on_output_event( + &mut conn, + &message( + 7, + EVT_OUTPUT_MODE, + &[word(0), word(3840), word(2160), word(60_000)].concat(), + ), + ); + assert_eq!(conn.outputs.get(&7).unwrap().size(), None); + on_output_event( + &mut conn, + &message( + 7, + EVT_OUTPUT_MODE, + &[word(MODE_CURRENT), word(3840), word(2160), word(60_000)].concat(), + ), + ); + on_output_event(&mut conn, &message(7, EVT_OUTPUT_SCALE, &word(2))); + + // Rounded down, so the window is held inside the screen rather than + // somewhere the far edge of a screen that is not there. + assert_eq!(conn.outputs.get(&7).unwrap().size(), Some((1920, 1080))); + + // Standing on its side: the mode is still the landscape one the panel + // was made with, and the screen a client draws into is the other way + // round — its geometry is `x, y, physical size, subpixel, make, model, + // transform`, so the transform rides after two strings. + let placement = [word(0), word(0), word(600), word(340), word(0)].concat(); + on_output_event( + &mut conn, + &message( + 7, + EVT_OUTPUT_GEOMETRY, + &[ + placement, + wl_string("Acme"), + wl_string("Portrait 9000"), + word(1), + ] + .concat(), + ), + ); + assert_eq!(conn.outputs.get(&7).unwrap().size(), Some((1080, 1920))); + + // And what the client says itself wins where there is one. + conn.xdg_outputs.insert(70, 7); + on_xdg_output_event( + &mut conn, + &message( + 70, + EVT_XDG_OUTPUT_LOGICAL_SIZE, + &[word(1707), word(960)].concat(), + ), + ); + assert_eq!(conn.outputs.get(&7).unwrap().size(), Some((1707, 960))); + } + + /// A placement is margins from one screen's corner, so a window created + /// with another screen's is nowhere near the one it landed on. Nothing else + /// can bring it back: it is off the screen and out of the pointer's reach. + #[test] + fn a_window_placed_on_another_screens_corner_is_pulled_back_into_view() { + let mut conn = window_on(7); + conn.outputs.insert( + 7, + OutputInfo { + logical_size: Some((1280, 720)), + ..OutputInfo::default() + }, + ); + { + // Placed against the right edge of a screen twice this size. + let layer = conn.layer_windows.get_mut(&30).unwrap(); + layer.geometry.margin_left = 2560 - 24; + layer.geometry.margin_top = 40; + } + + let entered = message(10, EVT_SURFACE_ENTER, &word(7)); + let mut fx = Effects::default(); + on_surface_event(&mut conn, &entered, &mut fx); + + assert_eq!( + fx.layer_requests, + vec![ + request(30, REQ_LAYER_SET_MARGIN, 24, &[40, 0, 0, 1280 - 800]), + request(10, REQ_COMMIT, 8, &[]), + ], + "the window is held wholly on the screen it is on" + ); + let layer = conn.layer_windows.get(&30).unwrap(); + assert_eq!(layer.geometry.margin_left, 1280 - 800); + } + + #[test] + fn the_clients_own_view_of_an_output_names_it_and_sizes_it() { + let mut conn = WaylandConn::new(); + conn.outputs.insert(7, OutputInfo::default()); + let bound = message(5, REQ_BIND, &{ + let mut args = word(1); + args.extend_from_slice(&wl_string("zxdg_output_manager_v1")); + args.extend_from_slice(&word(3)); + args.extend_from_slice(&word(2)); + args + }); + assert!(matches!(on_bind(&mut conn, &bound), Action::Forward)); + assert_eq!(conn.xdg_output_manager_id, Some(2)); + assert_eq!(conn.ifaces.get(&2), Some(&Iface::ZxdgOutputManager)); + + let made = message(2, REQ_GET_XDG_OUTPUT, &[word(70), word(7)].concat()); + on_get_xdg_output(&mut conn, &made); + assert_eq!(conn.xdg_outputs.get(&70), Some(&7)); + + on_xdg_output_event( + &mut conn, + &message(70, EVT_XDG_OUTPUT_NAME, &wl_string("DP-2")), + ); + on_xdg_output_event( + &mut conn, + &message( + 70, + EVT_XDG_OUTPUT_LOGICAL_SIZE, + &[word(2560), word(1440)].concat(), + ), + ); + + // The name a toolkit builds its display label from, and the size the + // window's margins are measured in. + assert_eq!(output_for_name(&conn, "DP-2"), Some(7)); + let known = conn.outputs.get(&7).unwrap(); + assert_eq!(known.logical_size, Some((2560, 1440))); + assert_eq!(known.label().as_deref(), Some("DP-2")); + } +} + #[cfg(test)] mod deleted_id_tests { use super::super::super::test_support::message; diff --git a/modules/window/src/linux/wayland/handlers/pointer.rs b/modules/window/src/linux/wayland/handlers/pointer.rs index fa74f119..f5ab10a9 100644 --- a/modules/window/src/linux/wayland/handlers/pointer.rs +++ b/modules/window/src/linux/wayland/handlers/pointer.rs @@ -1,10 +1,10 @@ //! Pointer events: enter/leave focus tracking and button press capture. use super::super::codec::{ - BTN_PRESSED, EVT_AXIS, EVT_BUTTON, EVT_ENTER, EVT_LEAVE, EVT_MOTION, WlMessage, + BTN_PRESSED, BTN_RELEASED, EVT_AXIS, EVT_BUTTON, EVT_ENTER, EVT_LEAVE, EVT_MOTION, WlMessage, }; use super::super::state::WaylandConn; -use super::{Action, Effects}; +use super::{Action, Effects, grab}; pub(crate) fn on_pointer_event( conn: &mut WaylandConn, @@ -16,18 +16,26 @@ pub(crate) fn on_pointer_event( if let (Some(surf_id), Some(x), Some(y)) = (msg.u32_arg(12), msg.fixed_arg(16), msg.fixed_arg(20)) { + // An interactive move or resize may name this serial. + if let Some(serial) = msg.u32_arg(8) { + conn.pointer_enter_serial.insert(surf_id, serial); + } conn.pointer_focus.insert(msg.object_id, surf_id); conn.pointer_position.insert(msg.object_id, (x, y)); fx.entered.push((surf_id, x, y)); } } EVT_LEAVE => { - conn.pointer_focus.remove(&msg.object_id); + if let Some(surf_id) = conn.pointer_focus.remove(&msg.object_id) { + grab::on_surface_left(conn, fx, surf_id); + } conn.pointer_position.remove(&msg.object_id); } EVT_MOTION => { - if let (Some(x), Some(y)) = (msg.fixed_arg(12), msg.fixed_arg(16)) { - conn.pointer_position.insert(msg.object_id, (x, y)); + if let (Some(x), Some(y)) = (msg.fixed_f64(12), msg.fixed_f64(16)) { + conn.pointer_position + .insert(msg.object_id, (x as i32, y as i32)); + grab::on_motion(conn, fx, msg.object_id, x, y); } } EVT_AXIS => { @@ -40,14 +48,20 @@ pub(crate) fn on_pointer_event( } EVT_BUTTON => { let serial = msg.u32_arg(8); + // `serial, time, button, state`; a code of zero is not one. + let button = msg.u32_arg(16).filter(|code| *code != 0); let state = msg.u32_arg(20); if let (Some(serial), Some(BTN_PRESSED)) = (serial, state) { let surf_id = conn.pointer_focus.get(&msg.object_id).copied(); let seat_id = conn.pointer_seat.get(&msg.object_id).copied(); let position = conn.pointer_position.get(&msg.object_id).copied(); if let (Some(surf_id), Some(seat_id), Some((x, y))) = (surf_id, seat_id, position) { - fx.button = Some((seat_id, serial, surf_id, x, y)); + fx.button = Some((seat_id, serial, surf_id, x, y, button.unwrap_or(0))); } + } else if let Some(BTN_RELEASED) = state { + // Which button came up decides whether a gesture is over: see + // `grab::on_pointer_button_release`. + grab::on_pointer_button_release(conn, msg.object_id, button); } } _ => {} @@ -79,7 +93,7 @@ mod tests { on_pointer_event(&mut conn, &pressed, &mut fx), Action::Forward )); - assert_eq!(fx.button, Some((2, 99, 10, 173, 241))); + assert_eq!(fx.button, Some((2, 99, 10, 173, 241, button))); } } } diff --git a/modules/window/src/linux/wayland/handlers/shadow.rs b/modules/window/src/linux/wayland/handlers/shadow.rs index 1e27ae04..ef522ab4 100644 --- a/modules/window/src/linux/wayland/handlers/shadow.rs +++ b/modules/window/src/linux/wayland/handlers/shadow.rs @@ -9,14 +9,16 @@ //! or dropped instead of being forwarded. Nothing here binds a global or //! decides a role; that is [`super::conversion`]'s job. +use std::os::fd::RawFd; + use super::super::codec::{ DECORATION_CLIENT_SIDE, EVT_LAYER_CLOSED, EVT_LAYER_CONFIGURE, Iface, REQ_ACK_CONFIGURE, - REQ_DECORATION_DESTROY, REQ_DESTROY, REQ_GET_POPUP, REQ_GET_TOPLEVEL, REQ_SET_TITLE, - REQ_SET_WINDOW_GEOMETRY, WlMessage, + REQ_DECORATION_DESTROY, REQ_DESTROY, REQ_GET_POPUP, REQ_GET_TOPLEVEL, REQ_MOVE, REQ_RESIZE, + REQ_SET_TITLE, REQ_SET_WINDOW_GEOMETRY, WlMessage, }; use super::super::layer_shell; use super::super::state::WaylandConn; -use super::{Action, Effects}; +use super::{Action, Effects, grab}; /// Requests from a client that still believes its window is an `xdg_toplevel`. /// @@ -24,6 +26,7 @@ use super::{Action, Effects}; /// layer surface, so forwarding any xdg-toplevel opcode would be misread as a /// different layer request (or rejected outright). pub(crate) fn on_client_toplevel_request( + fd: RawFd, conn: &mut WaylandConn, msg: &WlMessage, fx: &mut Effects, @@ -33,6 +36,9 @@ pub(crate) fn on_client_toplevel_request( conn.purge(msg.object_id); Action::Replace(vec![layer_shell::destroy(msg.object_id)]) } + // Layer shell has no move and no interactive resize, so the proxy + // replays the gesture itself against the surface it converted. + REQ_MOVE | REQ_RESIZE => grab::begin(fd, conn, msg), REQ_SET_TITLE => { // The smuggled window id still has to be captured, but a title has // no meaning for a layer surface. @@ -224,6 +230,10 @@ mod tests { use super::super::conversion; use super::*; + /// A connection of this test module's own: none of these cases reads the + /// press registry the gesture validation consults. + const FD: RawFd = 42; + /// Convert window 30 the way `flush_role` would, for the tests that only /// need a converted window to exist. fn convert(conn: &mut WaylandConn) -> Vec> { @@ -241,6 +251,7 @@ mod tests { let title = decorate_title("layer-window-99", "Real"); let mut fx = Effects::default(); let action = on_client_toplevel_request( + FD, &mut conn, &message(30, REQ_SET_TITLE, &wl_string(&title)), &mut fx, @@ -255,6 +266,7 @@ mod tests { // Anything else is dropped too: the compositor only knows a layer // surface under this id. let action = on_client_toplevel_request( + FD, &mut conn, &message(30, REQ_MOVE, &word(1)), &mut Effects::default(), @@ -263,6 +275,7 @@ mod tests { // Destroying the toplevel destroys the layer surface. let messages = replaced(on_client_toplevel_request( + FD, &mut conn, &message(30, REQ_DESTROY, &[]), &mut Effects::default(), diff --git a/modules/window/src/linux/wayland/handlers/touch.rs b/modules/window/src/linux/wayland/handlers/touch.rs index bda91ea4..3374f469 100644 --- a/modules/window/src/linux/wayland/handlers/touch.rs +++ b/modules/window/src/linux/wayland/handlers/touch.rs @@ -18,7 +18,8 @@ pub(crate) fn on_touch_event(conn: &mut WaylandConn, msg: &WlMessage, fx: &mut E if let (Some(seat_id), Some(x), Some(y)) = (seat_id, msg.fixed_arg(24), msg.fixed_arg(28)) { - fx.button = Some((seat_id, serial, surf_id, x, y)); + // A touch is not a button: there is none to end a gesture by. + fx.button = Some((seat_id, serial, surf_id, x, y, 0)); } } } diff --git a/modules/window/src/linux/wayland/inject.rs b/modules/window/src/linux/wayland/inject.rs index e1b85f37..7ffc5a8a 100644 --- a/modules/window/src/linux/wayland/inject.rs +++ b/modules/window/src/linux/wayland/inject.rs @@ -7,6 +7,7 @@ use crate::linux::Rect; use super::super::proxy::sink_for; use super::codec::*; +use super::layer_shell; use super::state::*; /// `wl_compositor.create_region(new_id)`. @@ -220,6 +221,133 @@ pub(super) fn transfer_input_region(from_id: &str, to_id: &str) -> bool { true } +/// `wl_surface.commit()`, for a surface whose layer state the proxy set. +/// +/// Every layer-surface setter is double-buffered: nothing a gesture writes +/// takes effect until the surface is committed, and the window's own commits +/// only happen when it has something to redraw. A window that is idle — lyrics +/// with nothing moving on them — therefore never commits during a drag, and the +/// position would sit unapplied until some unrelated event produced a frame. +/// The proxy wrote the state, so the proxy commits it; the client's own commits +/// are unaffected, and committing a surface with no new buffer is what asks a +/// compositor for its first configure. +pub(super) fn surface_commit_request(wl_surface_id: u32) -> Vec { + let mut buf = Vec::with_capacity(8); + buf.extend_from_slice(&wl_surface_id.to_ne_bytes()); + buf.extend_from_slice(&((8u32 << 16) | REQ_COMMIT as u32).to_ne_bytes()); + buf +} + +/// Send a group of synthesised layer-shell requests for one converted window. +/// +/// These cannot go through the app-facing socketpair the way every other +/// injection does: the id they name is a layer surface to the compositor but +/// the client's `xdg_toplevel`, and the outbound filter reads it in the +/// client's namespace — where `set_size` is opcode `0`, the toplevel's +/// `destroy`. They are written to the compositor directly instead, under the +/// connection's write lock, exactly as the X11 path does. +/// +/// Resolving the window and writing the requests are one critical section, and +/// the lock is taken first and the state read after it — the order the proxy's +/// own outbound path takes them in. That order is what makes the pair safe: the +/// client can destroy the object these name at any moment, and a request for an +/// object the compositor has already been told to forget is a protocol error +/// that closes the application's whole display connection. A window still +/// recorded once the lock is held is one whose destroy has not been forwarded +/// yet, because forwarding is what holds the same lock, and destroying the +/// surface is what removes the record. +/// +/// `build` gets the connection and the window's `wl_surface`, and returns the +/// requests to write — the whole group at once, so nothing the client sends can +/// land in the middle of it — or `None` for a window that is not there. +pub(super) fn send_layer_requests( + window_id: &str, + build: impl FnOnce(&mut WaylandConn, u32) -> Option>>, +) -> bool { + let Some(conns) = CONNS.get() else { + return false; + }; + // The fd is what the sink is found by. Everything the requests are built + // from is read again below, once nothing can change it. + let fd = { + let Ok(known) = conns.lock() else { + return false; + }; + match window_surface_in(&known, window_id) { + Some((fd, _)) => fd, + None => return false, + } + }; + let Some(sink) = sink_for(fd) else { + return false; + }; + let Some(lock) = sink.write_lock.as_ref() else { + return false; + }; + let Ok(_guard) = lock.lock() else { + return false; + }; + let requests = { + let Ok(mut known) = conns.lock() else { + return false; + }; + let Some((_, wl_surface)) = window_surface_in(&known, window_id) else { + return false; + }; + let Some(conn) = known.get_mut(&fd) else { + return false; + }; + build(conn, wl_surface) + }; + let Some(requests) = requests else { + return false; + }; + let mut sent = true; + for request in &requests { + if !sink.send_to_server(request) { + sent = false; + } + } + if !sent { + super::handlers::layer_note(|| { + format!("a layer request for {window_id} could not be sent") + }); + } + sent +} + +/// Change the size a converted window's layer surface asks for. +/// +/// A layer surface's size is the application's to say, but a converted client +/// cannot ask for one: a buffer of a different size attached to a surface the +/// compositor keeps at the size it was told would mismatch it. `0` is refused +/// rather than sent — the compositor reads a zero axis as "you decide", which +/// only works with both opposite anchors. The request takes effect at the +/// window's next commit, like every other double-buffered surface state. +pub(super) fn set_layer_size(window_id: &str, width: u32, height: u32) -> bool { + if width == 0 || height == 0 { + return false; + } + send_layer_requests(window_id, |conn, wl_surface| { + let layer_id = conn.wl_to_layer.get(&wl_surface).copied()?; + let geometry = LayerGeometry { + width, + height, + ..conn.layer_windows.get(&layer_id)?.geometry + }; + if !set_layer_geometry(conn, layer_id, geometry) { + return None; + } + // The size takes effect at a commit, and the proxy is the one that + // asked for it, so it commits it too — in the same group, so that the + // client cannot land anything between the two. + Some(vec![ + layer_shell::set_size(layer_id, width, height), + surface_commit_request(wl_surface), + ]) + }) +} + pub(super) fn send_xdg_toplevel_move() -> bool { let Some(button) = LAST_BUTTON .get() @@ -250,10 +378,18 @@ pub(super) fn send_xdg_toplevel_move() -> bool { return false; }; - conn.wl_to_top + // A converted window has no toplevel on the compositor's side: its + // layer surface reuses the id the client gave the toplevel, and the + // proxy replays the move against that. + conn.wl_to_layer .get(&wl_surf_id) .copied() - .filter(|id| conn.ifaces.get(id) == Some(&Iface::XdgToplevel)) + .or_else(|| { + conn.wl_to_top + .get(&wl_surf_id) + .copied() + .filter(|id| conn.ifaces.get(id) == Some(&Iface::XdgToplevel)) + }) .or_else(|| { let xdg_id = conn .xdg_to_wl @@ -357,7 +493,7 @@ mod tests { Sink { real_fd: self.peer.as_raw_fd(), app_fd, - write_lock: None, + write_lock: Some(std::sync::Arc::new(Mutex::new(()))), }, ); } @@ -393,6 +529,23 @@ mod tests { read(guard.get_mut(&self.fd).expect("a connection")) } + /// Everything written straight to the server, in order. + /// + /// [`Sink::send_to_server`] writes to the real connection, so these + /// bytes surface at the *other* end from `injected`'s. + fn server_bound(&mut self) -> Vec { + self._app.set_nonblocking(true).expect("nonblocking"); + let mut out = Vec::new(); + let mut buf = [0u8; 4096]; + while let Ok(n) = self._app.read(&mut buf) { + if n == 0 { + break; + } + out.extend_from_slice(&buf[..n]); + } + out + } + /// Everything the proxy injected, in order. fn injected(&mut self) -> Vec { self.peer.set_nonblocking(true).expect("nonblocking"); @@ -694,6 +847,89 @@ mod tests { ); } + /// Turn the fixture's window into a converted one, the way the proxy does. + fn convert_to_layer(fixture: &Fixture) { + fixture.with_conn(|conn| { + conn.ifaces.insert(9, Iface::ZwlrLayerSurface); + conn.wl_to_layer.insert(7, 9); + conn.surface_roles + .insert(7, SurfaceRole::Layer(super::super::test_support::options())); + conn.layer_windows.insert( + 9, + LayerWindow { + wl_surface: 7, + xdg_surface: 20, + geometry: LayerGeometry { + width: 800, + height: 225, + margin_left: 0, + margin_top: 0, + }, + bounds: None, + output: None, + }, + ); + }); + } + + #[test] + fn a_layer_size_change_bypasses_the_client_namespace() { + let _serial = lock_press_state(); + let mut fixture = Fixture::new(); + fixture.with_connection(); + convert_to_layer(&fixture); + + assert!(set_layer_size(WINDOW, 640, 180)); + + // Straight to the compositor, in the layer's own namespace: through + // the client's socketpair this opcode would read as `destroy`. + assert_eq!( + fixture.server_bound(), + [ + request(9, REQ_LAYER_SET_SIZE, 16, &[640, 180]), + // The size is double-buffered, so the proxy commits what it + // asked for rather than waiting for the window to redraw. + request(7, REQ_COMMIT, 8, &[]), + ] + .concat() + ); + // The recorded geometry follows, so a later drag starts from the size + // the window actually has, and so does the role a later show converts + // the surface with. + fixture.with_conn(|conn| { + assert_eq!(conn.layer_windows.get(&9).unwrap().geometry.width, 640); + match conn.surface_roles.get(&7) { + Some(SurfaceRole::Layer(options)) => { + assert_eq!((options.width, options.height), (640, 180)); + } + other => panic!("expected a layer role, got {other:?}"), + } + }); + } + + #[test] + fn a_layer_size_change_needs_a_converted_window() { + let _serial = lock_press_state(); + let fixture = Fixture::new(); + fixture.with_connection(); + + assert!(!set_layer_size(WINDOW, 640, 180)); + assert!(!set_layer_size("nothing-recorded", 640, 180)); + } + + #[test] + fn a_zero_axis_is_refused_rather_than_sent() { + let _serial = lock_press_state(); + let mut fixture = Fixture::new(); + fixture.with_connection(); + convert_to_layer(&fixture); + + // Zero means "compositor decides" to the protocol, which is only valid + // with both anchors on that axis. + assert!(!set_layer_size(WINDOW, 0, 180)); + assert!(fixture.server_bound().is_empty()); + } + #[test] fn a_toplevel_move_replays_the_remembered_press() { let _serial = lock_press_state(); @@ -714,6 +950,7 @@ mod tests { wl_surface_id: 7, x: 0, y: 0, + button: 0x110, at: std::time::Instant::now(), }, ); @@ -748,6 +985,7 @@ mod tests { wl_surface_id: 7, x: 0, y: 0, + button: 0x110, at: std::time::Instant::now(), }, ); @@ -784,6 +1022,7 @@ mod tests { wl_surface_id: 7, x: 0, y: 0, + button: 0x110, at: std::time::Instant::now(), }, ); diff --git a/modules/window/src/linux/wayland/layer_shell.rs b/modules/window/src/linux/wayland/layer_shell.rs index 8f90d1e1..f5200e27 100644 --- a/modules/window/src/linux/wayland/layer_shell.rs +++ b/modules/window/src/linux/wayland/layer_shell.rs @@ -50,6 +50,12 @@ const MAX_NAMESPACE_LEN: usize = 32; #[derive(Clone, Debug, PartialEq, Eq)] pub(crate) struct LayerShellOptions { pub(crate) namespace: String, + /// The output the surface has to be created on, by the name the compositor + /// knows it by; `None` lets the compositor pick. + /// + /// [`super::state::layer_output_label`] hands the application that name, so + /// the same string travels back. + pub(crate) output: Option, pub(crate) layer: u32, pub(crate) anchor: u32, pub(crate) width: u32, @@ -66,6 +72,7 @@ impl Default for LayerShellOptions { fn default() -> Self { Self { namespace: String::new(), + output: None, layer: LAYER_TOP, anchor: 0, width: 0, @@ -179,18 +186,23 @@ pub(crate) fn bind(registry_id: u32, global_name: u32, version: u32, new_id: u32 } /// `zwlr_layer_shell_v1.get_layer_surface(id, surface, output, layer, namespace)`. +/// +/// `output` is the `wl_output` the surface is to live on, or `0` to let the +/// compositor pick the most recently used one. A layer surface keeps the output +/// it was created with for the rest of its life, so this is the only moment at +/// which a window can be placed on a particular one. pub(crate) fn get_layer_surface( shell_id: u32, new_id: u32, surface_id: u32, + output_id: u32, layer: u32, namespace: &str, ) -> Vec { let mut body = Vec::new(); push_u32(&mut body, new_id); push_u32(&mut body, surface_id); - // A null output lets the compositor pick the most recently used one. - push_u32(&mut body, 0); + push_u32(&mut body, output_id); push_u32(&mut body, layer); push_string(&mut body, namespace); message(shell_id, REQ_GET_LAYER_SURFACE, &body) @@ -379,9 +391,20 @@ mod tests { ); } + #[test] + fn get_layer_surface_carries_the_output_it_was_given() { + // The output argument is the only difference from the compositor's own + // choice, which a null id asks for. + let named = get_layer_surface(9, 30, 10, 42, LAYER_OVERLAY, "wl"); + let chosen = get_layer_surface(9, 30, 10, 0, LAYER_OVERLAY, "wl"); + + assert_eq!(u32::from_ne_bytes(named[16..20].try_into().unwrap()), 42); + assert_eq!(u32::from_ne_bytes(chosen[16..20].try_into().unwrap()), 0); + } + #[test] fn get_layer_surface_pads_the_namespace() { - let bytes = get_layer_surface(9, 30, 10, LAYER_OVERLAY, "wl"); + let bytes = get_layer_surface(9, 30, 10, 0, LAYER_OVERLAY, "wl"); let (oid, op, size) = header(&bytes); assert_eq!((oid, op), (9, REQ_GET_LAYER_SURFACE)); assert_eq!(size % 4, 0); diff --git a/modules/window/src/linux/wayland/mod.rs b/modules/window/src/linux/wayland/mod.rs index f8eded91..5106abbb 100644 --- a/modules/window/src/linux/wayland/mod.rs +++ b/modules/window/src/linux/wayland/mod.rs @@ -9,6 +9,7 @@ mod state; mod test_support; use std::os::fd::RawFd; +use std::sync::{Arc, Mutex}; use crate::linux::Rect; @@ -106,6 +107,26 @@ pub(super) fn set_popup_policy( ) } +pub(super) fn set_layer_size(window_id: &str, width: u32, height: u32) -> bool { + inject::set_layer_size(window_id, width, height) +} + +pub(super) fn set_layer_bounds(window_id: &str, width: u32, height: u32) -> bool { + state::set_layer_bounds(window_id, width, height) +} + +pub(super) fn on_layer_output(cb: state::LayerOutputCb) -> bool { + state::on_layer_output(cb) +} + +pub(super) fn on_layer_pushed(cb: state::LayerPushedCb) -> bool { + state::on_layer_pushed(cb) +} + +pub(super) fn on_layer_geometry(cb: state::LayerGeometryCb) -> bool { + state::on_layer_geometry(cb) +} + pub(super) fn clear_popup_policy(target_window_id: &str) -> bool { state::clear_popup_policy(target_window_id) } @@ -173,12 +194,16 @@ impl Protocol for WaylandProtocol { fn spawn(&self, app_fd: RawFd, _real_fd: RawFd) -> Box { on_new_connection(app_fd); - Box::new(WaylandHandler { fd: app_fd }) + Box::new(WaylandHandler { + fd: app_fd, + write_lock: Arc::new(Mutex::new(())), + }) } } struct WaylandHandler { fd: RawFd, + write_lock: Arc>, } impl ConnectionHandler for WaylandHandler { @@ -189,4 +214,14 @@ impl ConnectionHandler for WaylandHandler { fn on_close(&mut self) { state::on_close(self.fd); } + + /// Serializes direct server-bound writes with the proxy's own forwarding. + /// + /// Synthesised layer-shell state (an emulated drag, a size the application + /// asked for) bypasses the filter — the id means something else to the + /// client — so it is written straight to the compositor and has to be kept + /// from interleaving with a forwarded chunk. + fn write_lock(&self) -> Option>> { + Some(Arc::clone(&self.write_lock)) + } } diff --git a/modules/window/src/linux/wayland/state.rs b/modules/window/src/linux/wayland/state.rs index b3cce609..d3088a36 100644 --- a/modules/window/src/linux/wayland/state.rs +++ b/modules/window/src/linux/wayland/state.rs @@ -22,11 +22,160 @@ pub(crate) struct PopupGeometry { // ── Per-connection tracking state ────────────────────────────────────────── +/// Where a converted window's layer surface currently sits. +/// +/// Only meaningful for a surface anchored to the top and left edges, which is +/// the shape the proxy can move by hand: the margins then are the position. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) struct LayerGeometry { + pub(crate) width: u32, + pub(crate) height: u32, + pub(crate) margin_left: i32, + pub(crate) margin_top: i32, +} + +/// What the proxy knows about a `wl_output` the client bound. +/// +/// A layer surface is created on one output and keeps it for life, so a window +/// that has to appear on another one has to be created on it: this is what the +/// proxy can name an output by when the application asks for one. +#[derive(Clone, Debug, Default)] +pub(crate) struct OutputInfo { + /// The compositor's own name for it, e.g. `DP-1` (wl_output v4). + pub(crate) name: Option, + /// Its human-readable description, e.g. the monitor model. + pub(crate) description: Option, + /// The name its `zxdg_output_v1` gives it, when the client made one. This + /// is the name a toolkit builds its display label from, so it is the one an + /// application is most likely to hand back. + pub(crate) xdg_name: Option, + /// Its size in the logical coordinates a layer surface is placed in, as + /// reported by `zxdg_output_v1`. + pub(crate) logical_size: Option<(u32, u32)>, + /// The size of the mode it is running, in the pixels it draws (wl_output). + pub(crate) mode: Option<(u32, u32)>, + /// Which way round it draws that mode (wl_output's transform): one of the + /// quarter turns is a screen standing on its side, and its mode is still the + /// landscape one the panel was made with. + pub(crate) transform: Option, + /// How many of those pixels go into one logical one (wl_output). + pub(crate) scale: Option, +} + +impl OutputInfo { + /// Whether the application's name for an output is this one. + /// + /// Applications know outputs by what their platform reports, which is the + /// compositor's name or its description depending on both, so either one + /// matches. + pub(crate) fn matches(&self, wanted: &str) -> bool { + self.names().any(|known| known == wanted) + } + + /// Every name this output is known by, best first. + pub(crate) fn names(&self) -> impl Iterator { + [&self.xdg_name, &self.name, &self.description] + .into_iter() + .flatten() + .map(String::as_str) + } + + /// The name to hand the application, if the output has one yet. + pub(crate) fn label(&self) -> Option { + self.names().next().map(str::to_string) + } + + /// How large the output is, in the logical coordinates a layer surface's + /// margins are measured in. + /// + /// The client's own `zxdg_output_v1` says so directly and is what a client + /// that has one is placed by. Without it the mode and the scale still add + /// up to the same thing, rounded to the smaller answer: a size that is too + /// small holds a window further inside the screen, and a size that is too + /// large lets a drag take it off the edge it cannot come back from. + pub(crate) fn size(&self) -> Option<(u32, u32)> { + if let Some(size) = self.logical_size.filter(|(w, h)| *w > 0 && *h > 0) { + return Some(size); + } + let (mut width, mut height) = self.mode?; + // A quarter turn — the odd transforms are the 90° and 270° ones — draws + // that mode the other way round. + if self.transform.is_some_and(|turn| turn % 2 == 1) { + std::mem::swap(&mut width, &mut height); + } + let scale = self.scale.unwrap_or(1).max(1) as u32; + Some((width.div_ceil(scale), height.div_ceil(scale))) + } +} + /// A window the proxy converted into a layer surface on the compositor side. #[derive(Clone, Debug)] pub(crate) struct LayerWindow { pub(crate) wl_surface: u32, pub(crate) xdg_surface: u32, + /// The size and position the layer surface was last given. + pub(crate) geometry: LayerGeometry, + /// The size of the output the surface lives on, as the application measured + /// it. A gesture keeps the window inside it, so a window cannot be dragged + /// where the user cannot reach it. + pub(crate) bounds: Option<(u32, u32)>, + /// The output the surface was created on, when the application asked for + /// one by name. The compositor picked it otherwise, and only the surface's + /// own `enter` event can say which that was. + pub(crate) output: Option, +} + +/// Which interactive gesture a [`LayerGrab`] is replaying. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum GrabMode { + Move, + /// The edges the pointer grabbed, as the protocol's edge flags. + Resize(u32), +} + +/// An interactive move or resize the proxy is driving for a layer surface. +/// +/// A layer surface has no compositor-side move or interactive resize, so the +/// proxy drives both itself, from the pointer's position within the surface — +/// the only position a client is ever told — and the geometry the surface has +/// now. A move is a feedback loop rather than a sum of steps: the pointer stays +/// where it was grabbed, so each step only has to undo the drift the window's +/// own movement introduced, and a step that lands wrong is corrected by the +/// next one instead of being carried for the rest of the drag. A resize needs +/// no such loop: the size is not part of the surface's position, so pointer +/// coordinates do not move with it, and the gesture's start is a fixed point +/// for the whole of it. +#[derive(Clone, Debug)] +pub(crate) struct LayerGrab { + /// The client's id for the toplevel, reused as the layer surface id. + pub(crate) layer_id: u32, + pub(crate) wl_surface: u32, + /// The pointer whose motion drives the gesture. + pub(crate) pointer_id: u32, + pub(crate) mode: GrabMode, + /// Pointer position in surface-local coordinates when the gesture started. + /// + /// The coordinates a grab is given do not move under it: the press fixes + /// what they are measured against for the whole gesture, so this is the + /// point the window was grabbed at and every later position is that plus + /// the pointer's own movement. + pub(crate) origin: (f64, f64), + /// The button the press this gesture was started on was made with. + /// + /// A gesture belongs to one button: without this, a second button pressed + /// and released in the middle of a drag would end it. + pub(crate) button: Option, + /// Whether the pointer was last seen out of the window. + /// + /// A window that follows the pointer keeps it inside; a window that cannot + /// move any further — one pinned at the edge of the output it lives on — + /// lets the pointer out of it, and that is the gesture a user is pushing + /// past the edge with. The last position is what counts, so a step that + /// went past the window on its own is taken back by the next one. + pub(crate) left: bool, + /// Geometry when the gesture started. + pub(crate) size: (u32, u32), + pub(crate) margins: (i32, i32), } /// The role a `wl_surface` has taken for the rest of its life. @@ -127,6 +276,21 @@ pub(crate) struct WaylandConn { /// happen first. The id is released when both are done, and the callback's /// events are hidden until then. pub(crate) reservations: HashMap, + /// The serial of the last `wl_pointer.enter` per surface. + /// + /// An interactive move or resize may name either the press or the enter + /// that started it, so both are accepted when a gesture is validated. + pub(crate) pointer_enter_serial: HashMap, + /// The interactive move or resize the proxy is replaying, if any. + pub(crate) layer_grab: Option, + /// The `wl_output` objects the client bound, by their interface id. + pub(crate) outputs: HashMap, + /// The client's `zxdg_output_manager_v1`, if it bound one. + pub(crate) xdg_output_manager_id: Option, + /// `zxdg_output_v1` objects by the `wl_output` each was made for. + pub(crate) xdg_outputs: HashMap, + /// The output each surface is on, from its own `enter` events. + pub(crate) surface_outputs: HashMap, } impl WaylandConn { @@ -160,6 +324,12 @@ impl WaylandConn { pending_role: None, pending_xdg_surface: None, reservations: HashMap::new(), + pointer_enter_serial: HashMap::new(), + layer_grab: None, + outputs: HashMap::new(), + xdg_output_manager_id: None, + xdg_outputs: HashMap::new(), + surface_outputs: HashMap::new(), } } @@ -193,6 +363,12 @@ impl WaylandConn { self.pending_role = None; self.pending_xdg_surface = None; self.reservations.clear(); + self.pointer_enter_serial.clear(); + self.layer_grab = None; + self.outputs.clear(); + self.xdg_output_manager_id = None; + self.xdg_outputs.clear(); + self.surface_outputs.clear(); } pub(crate) fn alloc_injected_id(&mut self) -> Option { @@ -215,6 +391,13 @@ impl WaylandConn { { self.pending_xdg_surface = None; } + // A gesture cannot outlive any of the objects it names: the surface it + // moves, the layer object behind it, or the pointer driving it. + if self.layer_grab.as_ref().is_some_and(|grab| { + grab.layer_id == id || grab.wl_surface == id || grab.pointer_id == id + }) { + self.layer_grab = None; + } match self.ifaces.get(&id).copied() { Some(Iface::WlPointer) => { self.pointer_focus.remove(&id); @@ -225,6 +408,7 @@ impl WaylandConn { self.touch_seat.remove(&id); } Some(Iface::WlSurface) => { + self.surface_outputs.remove(&id); if let Some(layer_id) = self.wl_to_layer.remove(&id) { self.purge(layer_id); } @@ -278,6 +462,17 @@ impl WaylandConn { self.xdg_to_layer.remove(&window.xdg_surface); } } + Some(Iface::WlOutput) => { + self.outputs.remove(&id); + self.xdg_outputs.retain(|_, output| *output != id); + self.surface_outputs.retain(|_, output| *output != id); + } + Some(Iface::ZxdgOutputManager) if self.xdg_output_manager_id == Some(id) => { + self.xdg_output_manager_id = None; + } + Some(Iface::ZxdgOutput) => { + self.xdg_outputs.remove(&id); + } Some(Iface::WlSeat) => { self.pointer_seat.retain(|_, v| *v != id); self.touch_seat.retain(|_, v| *v != id); @@ -322,11 +517,99 @@ impl WaylandConn { #[cfg(test)] mod tests { + use super::super::codec::{Iface, REQ_COMMIT, REQ_LAYER_SET_MARGIN}; use super::super::layer_shell::{ANCHOR_ALL, LAYER_BACKGROUND}; - use super::super::test_support::{options, serial}; + use super::super::test_support::{AttachedSink, options, request, serial}; use super::*; + /// A converted window that has been placed against the corner of a screen + /// it is not on, which is where a window created with another screen's + /// placement ends up. + fn misplaced_window(conn: &mut WaylandConn, window_id: &str) { + conn.ifaces.insert(10, Iface::WlSurface); + conn.ifaces.insert(30, Iface::ZwlrLayerSurface); + conn.wl_to_layer.insert(10, 30); + conn.surface_ids.insert(10, window_id.into()); + conn.layer_windows.insert( + 30, + LayerWindow { + wl_surface: 10, + xdg_surface: 20, + geometry: LayerGeometry { + width: 800, + height: 225, + margin_left: 2560 - 800, + margin_top: 1400, + }, + bounds: None, + output: None, + }, + ); + } + + #[test] + fn the_size_the_application_reports_pulls_a_window_onto_its_screen() { + let _guard = serial(); + init_state(); + let fd = 93_701; + let window_id = "the-placements-window"; + let mut conn = WaylandConn::new(); + misplaced_window(&mut conn, window_id); + CONNS.get().unwrap().lock().unwrap().insert(fd, conn); + let mut sink = AttachedSink::new(fd); + + // The renderer is the half that can read the screen it is on, so this + // is a size that belongs to the output the window really is on — and a + // window outside it is one the user cannot reach. + assert!(set_layer_bounds(window_id, 1280, 720)); + + let conns = CONNS.get().unwrap().lock().unwrap(); + let layer = conns.get(&fd).unwrap().layer_windows.get(&30).unwrap(); + assert_eq!(layer.bounds, Some((1280, 720))); + assert_eq!(layer.geometry.margin_left, 1280 - 800); + assert_eq!(layer.geometry.margin_top, 720 - 225); + drop(conns); + // The window is put back on the screen it is on, in the same group as + // the commit that applies it. + assert_eq!( + sink.written(), + [ + request(30, REQ_LAYER_SET_MARGIN, 24, &[720 - 225, 0, 0, 1280 - 800]), + request(10, REQ_COMMIT, 8, &[]), + ] + .concat() + ); + clear_runtime_state_for_fd(fd); + } + + #[test] + fn a_size_the_window_is_already_inside_leaves_it_alone() { + let _guard = serial(); + init_state(); + let fd = 93_702; + let window_id = "a-window-already-inside"; + let mut conn = WaylandConn::new(); + misplaced_window(&mut conn, window_id); + conn.layer_windows + .get_mut(&30) + .unwrap() + .geometry + .margin_left = 40; + conn.layer_windows.get_mut(&30).unwrap().geometry.margin_top = 60; + CONNS.get().unwrap().lock().unwrap().insert(fd, conn); + let _sink = AttachedSink::new(fd); + + assert!(set_layer_bounds(window_id, 1280, 720)); + + let conns = CONNS.get().unwrap().lock().unwrap(); + let layer = conns.get(&fd).unwrap().layer_windows.get(&30).unwrap(); + assert_eq!(layer.geometry.margin_left, 40); + assert_eq!(layer.geometry.margin_top, 60); + drop(conns); + clear_runtime_state_for_fd(fd); + } + /// Serialize on the shared lock and start from empty policy state; the /// policy map is process-global, so every module that touches it does this. fn policy_fixture() -> std::sync::MutexGuard<'static, ()> { @@ -404,6 +687,26 @@ mod tests { clear_all_layer_policies(); } + #[test] + fn an_output_is_found_by_the_name_or_the_description() { + let mut conn = WaylandConn::new(); + conn.outputs.insert( + 7, + OutputInfo { + name: Some("DP-1".into()), + description: Some("Dell Inc. DELL U2720Q".into()), + ..OutputInfo::default() + }, + ); + + // Either of the two names a platform might report the output by. + assert_eq!(output_for_name(&conn, "DP-1"), Some(7)); + assert_eq!(output_for_name(&conn, "Dell Inc. DELL U2720Q"), Some(7)); + // An output the client never bound is not an error: the compositor + // picks, which is what a null output id means. + assert_eq!(output_for_name(&conn, "DP-9"), None); + } + #[test] fn transferring_an_unknown_window_is_a_no_op() { let _serial = policy_fixture(); @@ -642,6 +945,8 @@ pub(crate) struct LastButton { pub(crate) wl_surface_id: u32, pub(crate) x: i32, pub(crate) y: i32, + /// The button it was made with, or `0` when the event did not say. + pub(crate) button: u32, /// When the press arrived, so a caller can tell a deliberate click from a /// press that merely happened at some point in the past. pub(crate) at: Instant, @@ -764,6 +1069,207 @@ fn window_role(window_id: &str) -> Option { window_roles()?.lock().ok()?.get(window_id).cloned() } +/// Record the geometry a converted window's layer surface now has. +/// +/// The cached role options are what a later role object on the same surface is +/// converted with — hiding and showing a window builds a new one — so they have +/// to carry the geometry the window has now, not the one it was first created +/// with, or a drag would be undone by a hide. +pub(crate) fn set_layer_geometry( + conn: &mut WaylandConn, + layer_id: u32, + geometry: LayerGeometry, +) -> bool { + let Some(window) = conn.layer_windows.get_mut(&layer_id) else { + return false; + }; + window.geometry = geometry; + let wl_surface = window.wl_surface; + if let Some(SurfaceRole::Layer(options)) = conn.surface_roles.get_mut(&wl_surface) { + options.width = geometry.width; + options.height = geometry.height; + options.margin_left = geometry.margin_left; + options.margin_top = geometry.margin_top; + } + true +} + +/// The `wl_output` object the application names, if it is one the client bound. +pub(crate) fn output_for_name(conn: &WaylandConn, wanted: &str) -> Option { + conn.outputs + .iter() + .find(|(_, info)| info.matches(wanted)) + .map(|(id, _)| *id) +} + +/// The managed window a converted layer surface belongs to, and its geometry. +pub(crate) fn layer_geometry(conn: &WaylandConn, layer_id: u32) -> Option<(String, LayerGeometry)> { + let window = conn.layer_windows.get(&layer_id)?; + let window_id = conn.surface_ids.get(&window.wl_surface)?.clone(); + Some((window_id, window.geometry)) +} + +/// `margin` moved as little as it must be to put a `surface`-sized window +/// wholly inside an `output`-sized one. +/// +/// Both values are in the output's own coordinates, and this is the whole of +/// the window's reach: a layer surface cannot move to another output, so a +/// window past the edge of its own is clipped out of sight and out of reach — +/// the pointer is over something else, and the motion that drives a gesture +/// stops arriving. +/// +/// Whole, not partly: the part of a window that accepts the pointer is the part +/// of it that has something to show, so a window hanging over the edge leaves +/// the user a strip that cannot be grabbed — and a compositor that arranges a +/// surface anchored to the top left *shrinks* it to the room left on the +/// output, which would leave the geometry the gesture believes in a lie. +pub(crate) fn keep_inside(margin: i32, surface: u32, output: u32) -> i32 { + match margin_range(surface, output) { + Some((least, most)) => margin.clamp(least, most), + None => margin, + } +} + +/// The margins a window of `surface` size can have inside an `output`-sized +/// one: all of it on screen, or nothing held at all. +/// +/// `None` for a window larger than the output, which has no such margin — it is +/// left where it is rather than pinned to a wall it cannot fit against. +pub(crate) fn margin_range(surface: u32, output: u32) -> Option<(i32, i32)> { + let most = output as i32 - surface as i32; + (most >= 0).then_some((0, most)) +} + +/// The requests that put a converted window back inside the output it is on. +/// +/// A window can turn up outside its screen: created on one screen with a +/// placement measured from another's corner, or restored from a placement that +/// no longer fits. A layer surface has no way back from there — it is clipped +/// out of sight and past the reach of any pointer, so no gesture can even be +/// started on it. Every moment at which the proxy learns how large that screen +/// is is a moment at which it can be pulled back in: when the surface says +/// which output it is on, and when the application reports the size it +/// measured. `None` when it is already inside, or when there is nothing to hold +/// it by. +pub(crate) fn pull_inside_output(conn: &mut WaylandConn, layer_id: u32) -> Option>> { + let (width, height) = layer_bounds(conn, layer_id)?; + let current = conn.layer_windows.get(&layer_id)?.geometry; + // A compositor arranges a surface anchored to the top left by the room it + // has left on the output, and shrinks the surface to fit: a window larger + // than its screen would be drawn smaller than the geometry a gesture + // believes in, which is worse than being told it cannot be that large. + let size = (current.width.min(width), current.height.min(height)); + let inside = LayerGeometry { + width: size.0, + height: size.1, + margin_left: keep_inside(current.margin_left, size.0, width), + margin_top: keep_inside(current.margin_top, size.1, height), + }; + if inside == current { + return None; + } + let wl_surface = conn.layer_windows.get(&layer_id)?.wl_surface; + set_layer_geometry(conn, layer_id, inside); + // The application keeps the placement it is told about, so a placement it + // cannot use is worth correcting rather than left to be asked for again. + remember_layer_geometry(conn, layer_id); + if let Some((window_id, geometry)) = layer_geometry(conn, layer_id) { + fire_layer_geometry(&window_id, geometry); + } + let mut requests = super::handlers::grab::geometry_requests(layer_id, current, inside); + requests.push(super::inject::surface_commit_request(wl_surface)); + Some(requests) +} + +/// The output a converted window's surface is on, if the proxy knows. +/// +/// The surface's own `enter` events are the authority: the compositor picked +/// the output when it created the surface, and the only other thing that names +/// one is the policy the application created the window with. +pub(crate) fn layer_output(conn: &WaylandConn, layer_id: u32) -> Option { + let window = conn.layer_windows.get(&layer_id)?; + conn.surface_outputs + .get(&window.wl_surface) + .copied() + .or(window.output) +} + +/// What the application knows the output a converted window lives on by. +/// +/// This is what a window created again after a drag past the screen edge has to +/// be asked for by name, so it is the proxy's own name for the output rather +/// than the application's — the application hands it straight back, and there +/// is nothing left to match up. +pub(crate) fn layer_output_label(conn: &WaylandConn, layer_id: u32) -> Option { + conn.outputs.get(&layer_output(conn, layer_id)?)?.label() +} + +/// How large the output a converted window lives on is, in the logical +/// coordinates its margins are measured in. +/// +/// The compositor's own answer comes first, and the size the application +/// measured is the fallback: without one of them a gesture cannot tell where +/// the screen ends, and a window dragged past it would be out of reach. A size +/// of nothing is not an answer — an output that has only just been announced +/// reports one, and a window held inside nothing would be pinned to a corner. +pub(crate) fn layer_bounds(conn: &WaylandConn, layer_id: u32) -> Option<(u32, u32)> { + let from_compositor = layer_output(conn, layer_id) + .and_then(|id| conn.outputs.get(&id).and_then(OutputInfo::size)); + from_compositor.or_else(|| conn.layer_windows.get(&layer_id)?.bounds) +} + +/// Record the size of the output a converted window's layer surface lives on. +/// +/// The compositor never tells a client how big the output is, and a layer +/// surface cannot move to another one: without this a gesture could push the +/// window off the edge it lives on, where it is clipped out of sight and out of +/// reach. The application measures its own screen and reports it. +pub(crate) fn set_layer_bounds(window_id: &str, width: u32, height: u32) -> bool { + if width == 0 || height == 0 { + return false; + } + // A window cannot be created off its screen and stay reachable, and a size + // reported for the screen it is not on would be worse than none: this is the + // size of the screen the window is actually on, which is what makes it worth + // holding the window by. + super::inject::send_layer_requests(window_id, |conn, wl_surface| { + let layer_id = conn.wl_to_layer.get(&wl_surface).copied()?; + let window = conn.layer_windows.get_mut(&layer_id)?; + window.bounds = Some((width, height)); + Some(pull_inside_output(conn, layer_id).unwrap_or_default()) + }) +} + +/// Remember a converted window's current geometry as its policy. +/// +/// A window the application re-creates under the same managed id — an on-demand +/// window being shown again — is converted from the policy, so without this it +/// would come back at the place it was first given rather than where the user +/// left it. Called when a gesture ends, not per motion event. +pub(crate) fn remember_layer_geometry(conn: &WaylandConn, layer_id: u32) -> bool { + let Some(window) = conn.layer_windows.get(&layer_id) else { + return false; + }; + let Some(window_id) = conn.surface_ids.get(&window.wl_surface) else { + return false; + }; + let geometry = window.geometry; + let Some(roles) = window_roles() else { + return false; + }; + let Ok(mut roles) = roles.lock() else { + return false; + }; + let Some(WindowRole::Layer(options)) = roles.get_mut(window_id) else { + return false; + }; + options.width = geometry.width; + options.height = geometry.height; + options.margin_left = geometry.margin_left; + options.margin_top = geometry.margin_top; + true +} + /// Whether a connection knows a compositor that takes layer surfaces. /// /// Derived from the live connections rather than remembered in a flag, so a @@ -1063,6 +1569,108 @@ pub(crate) fn clear_last_button_for_surface(fd: RawFd, wl_surface_id: u32) { } } +// ── Layer-surface geometry ──────────────────────────────────────────────── + +/// Reports where the user left a layer surface, by managed window id. +/// +/// The compositor never tells a client where a surface is, so a window the +/// application wants to put back has to be told by the layer that moved it. +pub(crate) type LayerGeometryCb = Box; + +pub(crate) static LAYER_GEOMETRY: OnceLock>> = OnceLock::new(); + +/// Register the one listener for a finished move or resize. +pub(crate) fn on_layer_geometry(cb: LayerGeometryCb) -> bool { + let slot = LAYER_GEOMETRY.get_or_init(|| Mutex::new(None)); + let Ok(mut slot) = slot.lock() else { + return false; + }; + *slot = Some(cb); + true +} + +pub(crate) fn fire_layer_geometry(window_id: &str, geometry: LayerGeometry) { + let Some(slot) = LAYER_GEOMETRY.get() else { + return; + }; + let Ok(slot) = slot.lock() else { + return; + }; + if let Some(callback) = slot.as_ref() { + callback( + window_id.to_string(), + geometry.margin_left, + geometry.margin_top, + geometry.width, + geometry.height, + ); + } +} + +/// Reports a gesture the user pushed past the edge of the output it lives on. +/// +/// The window stopped there because it cannot go any further — a layer surface +/// keeps the output it was created with — which is the only moment at which the +/// application can decide to put it on the one next to it, by creating it +/// again. `edges` uses the xdg-toplevel resize edge flags. +pub(crate) type LayerPushedCb = Box; + +/// Reports which output a converted window ended up on. +/// +/// A layer surface cannot be asked where it is, and an application only knows +/// its screens by what the platform reports, so this is the one word the two +/// halves have in common: the proxy says it, and the application hands it back +/// when it wants the window created on that screen again. +pub(crate) type LayerOutputCb = Box; + +pub(crate) static LAYER_OUTPUT: OnceLock>> = OnceLock::new(); + +/// Register the one listener for the output a window ended up on. +pub(crate) fn on_layer_output(cb: LayerOutputCb) -> bool { + let slot = LAYER_OUTPUT.get_or_init(|| Mutex::new(None)); + let Ok(mut slot) = slot.lock() else { + return false; + }; + *slot = Some(cb); + true +} + +pub(crate) fn fire_layer_output(window_id: &str, output: &str) { + let Some(slot) = LAYER_OUTPUT.get() else { + return; + }; + let Ok(slot) = slot.lock() else { + return; + }; + if let Some(callback) = slot.as_ref() { + callback(window_id.to_string(), output.to_string()); + } +} + +pub(crate) static LAYER_PUSHED: OnceLock>> = OnceLock::new(); + +/// Register the one listener for a gesture that ran out of output. +pub(crate) fn on_layer_pushed(cb: LayerPushedCb) -> bool { + let slot = LAYER_PUSHED.get_or_init(|| Mutex::new(None)); + let Ok(mut slot) = slot.lock() else { + return false; + }; + *slot = Some(cb); + true +} + +pub(crate) fn fire_layer_pushed(window_id: &str, edges: u32) { + let Some(slot) = LAYER_PUSHED.get() else { + return; + }; + let Ok(slot) = slot.lock() else { + return; + }; + if let Some(callback) = slot.as_ref() { + callback(window_id.to_string(), edges); + } +} + // ── Refused layer-shell roles ───────────────────────────────────────────── /// Reports a window whose surface could not take the layer role, by the custom @@ -1589,6 +2197,7 @@ mod popup_tests { wl_surface_id: surface, x: 0, y: 0, + button: 0x110, at: Instant::now(), }, ); @@ -1831,6 +2440,7 @@ mod popup_tests { wl_surface_id: 10, x: 3, y: 4, + button: 0x110, at: Instant::now(), }, ); diff --git a/modules/window/src/linux/wayland/test_support.rs b/modules/window/src/linux/wayland/test_support.rs index 8f93b0a3..bd1c35e2 100644 --- a/modules/window/src/linux/wayland/test_support.rs +++ b/modules/window/src/linux/wayland/test_support.rs @@ -7,8 +7,14 @@ //! be fixed twice. Both live here, named by what they are for, so the wire //! format and the fixtures are written down once. -use std::os::fd::RawFd; -use std::sync::{Mutex, MutexGuard}; +use std::{ + io::Read, + os::fd::{AsRawFd, RawFd}, + os::unix::net::UnixStream, + sync::{Arc, Mutex, MutexGuard}, +}; + +use crate::linux::proxy::{SINKS, Sink}; use super::codec::{Iface, WlMessage}; use super::handlers::Action; @@ -143,6 +149,51 @@ pub(crate) fn connected() -> WaylandConn { conn } +/// A connection with a compositor on the other end of it. +/// +/// Synthesised requests are written straight to the server fd of a sink, so a +/// fixture that only registers a connection has nothing to write to and every +/// sending call is refused rather than merely unobservable. The observer end is +/// where those bytes can be read back. +pub(crate) struct AttachedSink { + /// Holds the fd the writes go to open for as long as the fixture lives. + _server: UnixStream, + /// The far end, which is where a synthesised request shows up. + observer: UnixStream, +} + +impl AttachedSink { + pub(crate) fn new(fd: RawFd) -> Self { + let (server, observer) = UnixStream::pair().expect("socketpair"); + SINKS.get_or_init(Default::default).lock().unwrap().insert( + fd, + Sink { + real_fd: server.as_raw_fd(), + app_fd: fd, + write_lock: Some(Arc::new(Mutex::new(()))), + }, + ); + Self { + _server: server, + observer, + } + } + + /// Everything written since the last read, or nothing yet. + pub(crate) fn written(&mut self) -> Vec { + self.observer.set_nonblocking(true).expect("nonblocking"); + let mut sent = Vec::new(); + let mut buf = [0u8; 256]; + while let Ok(read) = self.observer.read(&mut buf) { + if read == 0 { + break; + } + sent.extend_from_slice(&buf[..read]); + } + sent + } +} + /// One test mutex for every module that touches the process-global state. /// /// It is deliberately poison-tolerant: a failure in one case must not turn diff --git a/src/bridge/contracts/desktop-lyrics-api.ts b/src/bridge/contracts/desktop-lyrics-api.ts index 545e522a..f3285ba9 100644 --- a/src/bridge/contracts/desktop-lyrics-api.ts +++ b/src/bridge/contracts/desktop-lyrics-api.ts @@ -8,10 +8,39 @@ export interface DesktopLyricsContract { offsetUpdate(callback: (offset: number) => void): void; playInfoUpdate(callback: (info: DesktopLyricsPlayInfo | null) => void): void; blur(callback: () => void): void; + /** + * Ask for the screen size to be reported again. + * + * The renderer reports it as soon as it is up, which can be before the + * window's surface has a native identity for the report to be recorded + * against — and a window the native layer does not know the size of is one + * a drag cannot hold on the screen. Once the window is up, the main process + * asks again. + */ + requestScreenSize(callback: () => void): void; }; requestFullUpdate(): Promise; + /** + * The size of the screen this window is on, in the logical coordinates the + * window is placed in. + * + * A layer surface lives on one output and cannot be moved to another, so the + * native layer needs to know where that output ends to keep a drag inside it; + * the renderer is the only half that can read it. + */ + reportScreenSize(width: number, height: number): Promise; dragWindow(): Promise; - changeOrientation(): Promise; + /** + * Lay the window out the way the lyrics are laid out. + * + * The orientation is the renderer's to state, not for the main process to + * toggle: the window is created at the size it was last left at, so a page + * that has just mounted is already the right way round, and swapping there + * would undo the very size it was created with. `vertical` is + * `LyricsStyle.vertical`, which the renderer has in hand when it notices the + * setting change. + */ + changeOrientation(vertical: boolean): Promise; performAction(action: string): Promise; onMouseWheel(pageX: number, pageY: number, delta: number, modifier?: number): Promise; } diff --git a/src/main/settings.ts b/src/main/settings.ts index 82e9d28d..19aa541c 100644 --- a/src/main/settings.ts +++ b/src/main/settings.ts @@ -1,6 +1,8 @@ // Known entries, undefined refers to no default value const KV_ENTRIES: Record = { "audio.currentDevice": undefined, + "desktopLyrics.geometry": undefined, + "desktopLyrics.output": undefined, "desktopLyrics.interpolatedLyricLine": true, "desktopLyrics.opacity": 1, "tray.clickBehavior": "always-show-menu", diff --git a/src/main/window.ts b/src/main/window.ts index 2dcafdb7..ab199d6b 100644 --- a/src/main/window.ts +++ b/src/main/window.ts @@ -9,7 +9,12 @@ import { getDesktopEnvironment, isLayerShellAvailable, onLayerShellRoleRefused, + onLayerSurfaceGeometry, + onLayerSurfaceOutput, + onLayerSurfacePushed, setInputRegion, + setWindowLayerBounds, + setWindowLayerSize, setWindowLayerShell, transferWindowState, validateLayerShellOptions, @@ -68,6 +73,22 @@ registerShutdownFinalizer({ }, }); +/** + * Whether the loaded native module has everything the layer-shell path needs. + * + * The application and the native module are built separately, and a stale + * native module does not fail loudly on its own: the policy it registers is one + * whose geometry nothing can move, so a drag is simply ignored. Callers check + * this and keep the window an ordinary one, where the compositor does the + * dragging itself. + */ +export const isLayerShellNativeComplete = + typeof setWindowLayerSize === "function" && + typeof setWindowLayerBounds === "function" && + typeof onLayerSurfaceGeometry === "function" && + typeof onLayerSurfaceOutput === "function" && + typeof onLayerSurfacePushed === "function"; + export let mainWindow: BrowserWindow | null = null; /** @@ -114,6 +135,32 @@ export interface WindowEvents { * role. */ layerShellRefused: BrowserWindow; + /** + * The user finished moving or resizing this window's layer surface. + * + * A compositor never tells a client where a layer surface is, and the native + * layer is the one replaying the gesture, so this is the only report of where + * the window ended up. Coordinates are the ones the policy was given. + */ + layerGeometry: { x: number; y: number; width: number; height: number }; + /** + * A gesture the user pushed past the edge of the output this window's layer + * surface lives on. + * + * A layer surface keeps the output it was created on, so this is the point at + * which the application can decide to create the window again on the one next + * to it. `edges` uses the xdg-toplevel resize edge flags. + */ + layerPushed: { edges: number }; + /** + * The output this window's layer surface is on. + * + * The compositor chooses the output when it creates the surface and never + * reports the choice, so this is the only thing that says which screen the + * window is on — and the name it carries is the one to give back to + * [`LayerShellOptions.output`] to put a window there again. + */ + layerOutput: { output: string }; } export type WindowData = { @@ -163,6 +210,25 @@ void app.whenReady().then(() => { const wnd = managed?.window; if (managed && wnd) void managed.emit("layerShellRefused", wnd); }); + if (!isLayerShellNativeComplete) { + LOGGER.error( + "the native window module is older than the application: layer-shell windows would ignore " + + "drags, so they stay ordinary windows. Rebuild it with `pnpm build:modules`." + ); + return; + } + onLayerSurfaceGeometry(({ windowId, x, y, width, height }) => { + const managed = ManagedWindow.fromId(windowId); + if (managed) void managed.emit("layerGeometry", { x, y, width, height }); + }); + onLayerSurfacePushed((windowId, edges) => { + const managed = ManagedWindow.fromId(windowId); + if (managed) void managed.emit("layerPushed", { edges }); + }); + onLayerSurfaceOutput((windowId, output) => { + const managed = ManagedWindow.fromId(windowId); + if (managed) void managed.emit("layerOutput", { output }); + }); }); export abstract class ManagedWindow< @@ -428,6 +494,37 @@ export abstract class ManagedWindow< return ManagedWindow.isLayerShellAvailable() && validateLayerShellOptions(options); } + /** + * Tell the native layer how large the screen this window is on is. + * + * A layer surface cannot move to another output, so a drag the native layer + * replays has to know where this one ends; the renderer reads its own screen + * and reports it. Without a report a gesture that loses the window is undone + * instead, which is the fallback rather than the rule. + */ + setLayerBounds(width: number, height: number): boolean { + if (!this.isWayland() || !isLayerShellNativeComplete) return false; + return setWindowLayerBounds(this.id, Math.round(width), Math.round(height)); + } + + /** + * Change the size this window's layer surface asks for. + * + * A layer surface's size belongs to the application, and the compositor keeps + * asking for the one it was given until it is told otherwise; a converted + * client cannot resize itself, because the buffer it attaches would not match + * the surface. Callers therefore have to route their own resizes through this + * for a window they registered with [`setLayerShell`]. + * + * The value means "sent": like every double-buffered surface state, it is + * applied at the window's next commit. Returns `false` for a window that is + * not a layer surface, which is also the answer everywhere but Wayland. + */ + setLayerSize(width: number, height: number): boolean { + if (!this.isWayland() || !isLayerShellNativeComplete) return false; + return setWindowLayerSize(this.id, Math.round(width), Math.round(height)); + } + /** * Release the native state this wrapper recorded (its layer-shell policy and * input region). @@ -673,6 +770,29 @@ export abstract class ManagedWindow< if (managed?.id === id) return managed; } } + /** + * Hand the name this window is looked up by to its replacement. + * + * A window is found by name — that is how the rest of the application closes, + * hides or shows one it never held a reference to — and a name is not unique: + * the lookup takes whichever wrapper comes first. Two wrappers answering to + * one name would therefore send those calls to the one that has already been + * replaced, whose window is gone and whose `hide` does nothing. + * + * Only the name moves. The replacement registers its own native state, which + * for a window being placed somewhere else is the whole point of replacing + * it: see [`switchWindowPolicy`] for the other case, where the state is what + * has to move. + */ + moveNameTo(target: ManagedWindow): void { + const name = this.getData("name"); + // A name that has already been handed over is not one to hand on: an empty + // one would leave the replacement answering to nothing. + if (typeof name !== "string" || name === "") return; + target.setData("name", name); + this.setData("name", ""); + } + static fromName(name: string) { for (const ref of managedWindows) { const managed = ref.deref(); diff --git a/src/main/windows/desktop-lyrics-layer.ts b/src/main/windows/desktop-lyrics-layer.ts new file mode 100644 index 00000000..894e8903 --- /dev/null +++ b/src/main/windows/desktop-lyrics-layer.ts @@ -0,0 +1,52 @@ +/** + * Whether the desktop lyrics take the compositor's layer shell. + * + * A layer surface is placed by margins, and a drag is the proxy replaying the + * gesture against them. That is only smooth where the compositor puts the + * surface where the new margins say on the commit that carries them. Nothing in + * the protocol reports where a layer surface is or when it was arranged — which + * is the very reason the proxy drives the gesture itself — so a compositor that + * defers the arrange looks exactly like one that is lagging, and there is + * nothing to ask at runtime: the compositor has to be known by name. + * + * The list is what the drag was measured to work on, not what it ought to work + * on: every compositor here implements `zwlr_layer_shell_v1`, and the ones left + * off are not missing it — Hyprland trails the pointer, labwc and Wayfire + * jitter, and KWin does not move the surface on a margin change at all. The + * wlroots family is exactly what a list reasoned about from the outside would + * have included, and it splits down the middle. + * + * Everywhere off the list the lyrics stay an ordinary window — what they always + * were, and what the window rules in `docs/WM_RULES.md` are for. + */ +const COMPOSITORS_THAT_FOLLOW_A_DRAG = ["niri", "sway", "river"]; + +/** + * The variable that settles it either way: `1` for a layer surface, `0` for an + * ordinary window. It answers for the compositors on the list as much as for the + * others — the list is a default, not a fact about the compositor that only this + * variable could override. + * + * Anything else (including nothing at all, and a value that is neither) leaves + * the answer to the compositor. + */ +export const LAYER_SHELL_CHOICE = "LYRICS_LAYER_SHELL"; + +/** + * Why the lyrics window is not to take a layer surface, or `null` when it is. + * + * `desktopName` is what the session calls its desktop, which is a + * colon-separated list of names (`KDE:Plasma`) whenever more than one applies. + */ +export function whyNotALayerSurface( + desktopName: string, + choice: string | undefined +): string | null { + if (choice === "1" || choice === "true") return null; + if (choice === "0" || choice === "false") return "asked not to"; + const followed = desktopName + .split(":") + .some((name) => COMPOSITORS_THAT_FOLLOW_A_DRAG.includes(name.trim().toLowerCase())); + if (followed) return null; + return `${desktopName.trim() || "this desktop"} leaves a layer surface behind the pointer`; +} diff --git a/src/main/windows/desktop-lyrics.ts b/src/main/windows/desktop-lyrics.ts index 0b86cfaa..e65cbb7c 100644 --- a/src/main/windows/desktop-lyrics.ts +++ b/src/main/windows/desktop-lyrics.ts @@ -3,7 +3,12 @@ import { join } from "node:path"; import { BrowserWindow, screen } from "electron"; import type { BrowserWindowConstructorOptions } from "electron"; import photon from "@silvia-odwyer/photon-node"; -import { DesktopEnvironment, dragWindow, getDesktopEnvironment } from "@open-orpheus/window"; +import { + DesktopEnvironment, + LayerShellLayer, + dragWindow, + getDesktopEnvironment, +} from "@open-orpheus/window"; import { DesktopLyricsPlayInfo, @@ -13,7 +18,16 @@ import { TextAlignType, } from "$sharedTypes/desktop-lyrics"; -import { guiUrl, mainWindow, ManagedWindow, OnDemandWindow, switchWindowPolicy } from "../window"; +import { + guiUrl, + isLayerShellNativeComplete, + mainWindow, + ManagedWindow, + OnDemandWindow, + switchWindowPolicy, +} from "../window"; +import type { LayerShellOptions } from "../window"; +import { LAYER_SHELL_CHOICE, whyNotALayerSurface } from "./desktop-lyrics-layer"; import { registerIpcHandlers } from "../../bridge/register"; import type { DesktopLyricsContract, @@ -78,35 +92,698 @@ function performAction(action: string) { } } -const desktopLyricsWindowOptions = { - width: 800, // TODO: Proper sizes - height: 225, - skipTaskbar: true, - transparent: true, - hasShadow: false, - frame: false, - resizable: true, - show: false, - title: "Open Orpheus Lyrics", - webPreferences: { - partition: "open-orpheus", - preload: join(import.meta.dirname, "desktop-lyrics.cjs"), - }, -} satisfies BrowserWindowConstructorOptions; +/** Distance the default placement keeps from the bottom of the work area. */ +const LYRICS_BOTTOM_MARGIN = 96; + +/** The size a lyrics window starts at, before the user changes it. */ +const LYRICS_DEFAULT_SIZE = { width: 800, height: 225 }; + +/** How long a toplevel has to hold still before its placement is written. */ +const LYRICS_GEOMETRY_SETTLE_MS = 400; + +/** Where the lyrics are, in the logical coordinates Electron reports. */ +type LyricsGeometry = { x: number; y: number; width: number; height: number }; + +/** The xdg-toplevel resize edge flags, which is how a push is reported. */ +const EDGE_TOP = 1; +const EDGE_BOTTOM = 2; +const EDGE_LEFT = 4; +const EDGE_RIGHT = 8; + +/** The settings key the last placement is kept under. */ +const LYRICS_GEOMETRY_KEY = "desktopLyrics.geometry"; + +/** + * How long a window built again has to come up before the move is given up on. + * + * It is a page load, not a window creation: the deadline has to cover the + * renderer fetching and mounting, which in a development build can be seconds + * on a cold cache. Giving up costs nothing visible — the window the user was + * dragging is still on screen and still theirs. + */ +const LYRICS_HOP_TIMEOUT_MS = 5000; + +/** The settings key the screen the lyrics were left on is kept under. */ +const LYRICS_OUTPUT_KEY = "desktopLyrics.output"; + +/** The placement a first run starts from: bottom centre of the primary display. */ +function defaultLyricsGeometry(): LyricsGeometry { + const { width, height } = LYRICS_DEFAULT_SIZE; + const workArea = screen.getPrimaryDisplay().workAreaSize; + return { + x: Math.max(0, Math.round((workArea.width - width) / 2)), + y: Math.max(0, Math.round(workArea.height - height - LYRICS_BOTTOM_MARGIN)), + width, + height, + }; +} + +let lyricsGeometry = defaultLyricsGeometry(); + +/** + * How large the screen the lyrics are on is, as the renderer last reported it. + * + * A layer surface cannot be asked where it is, so this is what the application + * has to identify its screen by — a display of the same size is the one it is + * on. + */ +let lyricsScreen: { width: number; height: number } | null = null; + +/** + * The output the lyrics are on, by the name the native layer reports. + * + * A layer surface keeps the output the compositor created it on, and the + * compositor never says which that was: the native layer watches the surface + * enter it and names it. That name identifies the screen here, and it is what a + * window created again on it is asked for by — the same name the native layer + * resolves, with nothing in between to match up. + */ +let lyricsOutput: string | null = null; + +/** + * Where a placement's coordinates are measured from. + * + * A layer surface is asked for at margins against the one screen it lives on, + * and a window the window manager places is placed at a position on the desk. + * The two are different spaces, and only one of them is in use at a time. + */ +type PlacementSpace = "screen" | "desk"; + +/** Whether a rectangle wholly contains `geometry`. */ +function insideWhole(geometry: LyricsGeometry, rect: Electron.Rectangle): boolean { + return ( + geometry.x >= rect.x && + geometry.y >= rect.y && + geometry.x + geometry.width <= rect.x + rect.width && + geometry.y + geometry.height <= rect.y + rect.height + ); +} + +/** + * Whether a layer surface can be given `geometry` as margins. + * + * The screen is the one the lyrics were left on, and failing that the smallest + * one there is — every other screen is at least as large, so a placement that + * fits it is one the window can be reached at wherever it ends up. With no + * screen to measure against at all the placement is kept as it is: better where + * the user left it than at the default position of a screen it may not be on. + */ +function fitsOnItsScreen(geometry: LyricsGeometry): boolean { + const display = identifiedDisplay() ?? smallestDisplay(); + if (!display) return true; + return insideWhole(geometry, { + x: 0, + y: 0, + width: Math.round(display.size.width), + height: Math.round(display.size.height), + }); +} + +/** Whether a window the window manager places can be reached at `geometry`. */ +function fitsOnTheDesk(geometry: LyricsGeometry): boolean { + return screen.getAllDisplays().some((display) => insideWhole(geometry, display.bounds)); +} + +/** + * Keep a placement that can be shown, or put the lyrics back where they start. + * + * A placement only means anything in its own space, so it is only meaningful + * against the screens of that space — the layout of the others is not part of + * it, and a position can be well inside the layout while being nowhere near the + * screen the window is created on. That is what a remembered placement has to + * survive: screens unplugged, rearranged or turned off since. + * + * A placement is also not equally useful at any point on a screen: the part of + * the window that accepts the pointer is the part of it that has something to + * show, so a strip hanging over the edge is a strip the user cannot take hold + * of to drag it back. The native layer holds a drag to the same rule — whole on + * the screen — and a placement that cannot be shown whole is not kept at all: + * the lyrics return to where a first run puts them. + * + * The size is left alone: a window larger than its screen is one the native + * layer shrinks to fit, and it reports that back to be kept like any other + * placement. + */ +function usablePlacement(geometry: LyricsGeometry, space: PlacementSpace): LyricsGeometry { + if (space === "desk" ? fitsOnTheDesk(geometry) : fitsOnItsScreen(geometry)) return geometry; + const { x, y } = defaultLyricsGeometry(); + return { ...geometry, x, y }; +} + +/** Remember a placement, now and for the next run. */ +function saveLyricsGeometry(geometry: LyricsGeometry, space: PlacementSpace = placementSpace()) { + lyricsGeometry = usablePlacement(geometry, space); + void settings.set(LYRICS_GEOMETRY_KEY, lyricsGeometry); +} + +/** + * Remember which screen the lyrics are on. + * + * A layer surface keeps the output it was created on, and the compositor picks + * one when the application does not say: without this, a restart would put the + * window back on whichever screen happens to be active, at margins measured + * from a corner it is no longer near. + */ +function saveLyricsOutput(output: string | null) { + lyricsOutput = output; + void settings.set(LYRICS_OUTPUT_KEY, output); +} + +/** Read back the placement the user left the lyrics at, and the screen. */ +async function loadLyricsGeometry() { + // The screen first: it is what says how large the corner the placement is + // measured from is. + const output = await settings.get(LYRICS_OUTPUT_KEY); + if (typeof output === "string") lyricsOutput = output; + + const saved = (await settings.get(LYRICS_GEOMETRY_KEY)) as Partial | undefined; + if (!saved) return; + const merged = { ...lyricsGeometry, ...saved }; + if (!Object.values(merged).every((value) => Number.isFinite(value))) return; + lyricsGeometry = usablePlacement(merged as LyricsGeometry, placementSpace()); +} + +/** + * The window options for the lyrics, at the placement they are remembered at. + * + * A layer surface is placed by its margins, so the coordinates are left to the + * native layer there. Anywhere but Wayland the window manager tells the client + * where the window is, which is what the remembered placement is written from + * and what asking for one means; on Wayland a client is never told, so an + * ordinary window there is left where the compositor puts it — the same thing + * it had before any of this existed. + */ +function desktopLyricsWindowOptions(layerShell: boolean): BrowserWindowConstructorOptions { + const { x, y, width, height } = lyricsGeometry; + const positioned = !layerShell && getDesktopEnvironment() !== DesktopEnvironment.Wayland; + return { + ...(positioned ? { x, y } : {}), + width, + height, + skipTaskbar: true, + transparent: true, + hasShadow: false, + frame: false, + resizable: true, + show: false, + title: "Open Orpheus Lyrics", + webPreferences: { + partition: "open-orpheus", + preload: join(import.meta.dirname, "desktop-lyrics.cjs"), + }, + } satisfies BrowserWindowConstructorOptions; +} + +/** + * The layer-shell policy the lyrics window takes where the compositor can give + * it one. + * + * On the overlay layer the lyrics are above everything the compositor draws — + * including a window that is fullscreen, which is what a floating toplevel is + * not — and the window stops being a window at all: nothing tiles it, and no + * window rule is needed to keep it there. The surface is anchored to the top + * left with the margins carrying its position, which is the one shape the + * native layer can move by hand; a drag and an interactive resize are replayed + * against those margins, since the protocol gives a layer surface neither. + * + * Sizes and positions are in the same logical coordinates Electron reports, + * which is also what the remembered placement is kept in. + */ +function lyricsLayerShellOptions(): LayerShellOptions { + const { x, y, width, height } = lyricsGeometry; + return { + namespace: "open-orpheus-lyrics", + ...(lyricsOutput === null ? {} : { output: lyricsOutput }), + layer: LayerShellLayer.Overlay, + anchorLeft: true, + anchorTop: true, + width, + height, + marginLeft: x, + marginTop: y, + // Do not make room for anything, and let nothing move us: the user places + // the lyrics, not the compositor's bars. + exclusiveZone: -1, + // On demand: a click gives the window keyboard focus, the way it had it as + // a toplevel, without taking the keyboard away from the rest of the session. + keyboardInteractivity: 2, + }; +} + +/** + * Ask the window's renderer for the size of the screen it is on. + * + * Only the renderer can read it, and the window it reads it from is the one the + * native layer has to hold inside that screen; the report at mount can arrive + * before the surface the native layer records it against exists, so it is asked + * for again once the surface is known to be up. + */ +/** Windows whose renderer has already been asked for the size of its screen. */ +const askedForScreenSize = new WeakSet(); + +/** Windows whose layer events have already been subscribed to. */ +const subscribedToLayerEvents = new WeakSet(); + +function requestScreenSize(managed: ManagedWindow) { + // Once per window. The report at mount can be too early to be recorded, and + // the answer does not change afterwards — a window that ends up on another + // screen is a window built again, with an identity of its own. + if (askedForScreenSize.has(managed)) return; + askedForScreenSize.add(managed); + managed.send("desktopLyrics.requestScreenSize"); +} + +/** + * The screen the lyrics are on, as far as the application can tell. + * + * The native layer names it, which is the only account of a layer surface's + * output there is; failing that, the size the renderer reported is enough to + * tell screens of different sizes apart. + */ +function identifiedDisplay(): Electron.Display | null { + const displays = screen.getAllDisplays(); + if (lyricsOutput !== null) { + const named = displays.find((display) => display.label === lyricsOutput); + if (named) return named; + } + if (lyricsScreen !== null) { + const { width, height } = lyricsScreen; + const sized = displays.find( + (display) => + Math.round(display.size.width) === width && Math.round(display.size.height) === height + ); + if (sized) return sized; + } + return null; +} + +/** The screen the lyrics are on, or the primary one when that cannot be told. */ +function currentDisplay(): Electron.Display | null { + return identifiedDisplay() ?? screen.getPrimaryDisplay() ?? null; +} + +/** The smallest screen there is, which every other one is at least as large as. */ +function smallestDisplay(): Electron.Display | null { + const area = (display: Electron.Display) => display.size.width * display.size.height; + return [...screen.getAllDisplays()].sort((a, b) => area(a) - area(b))[0] ?? null; +} + +/** The screen that starts where `from` ends, in the direction the user pushed. */ +function displayBeyond(from: Electron.Display, edges: number): Electron.Display | null { + const origin = from.bounds; + const horizontal = (edges & (EDGE_LEFT | EDGE_RIGHT)) !== 0; + const startsAt = (display: Electron.Display): number | null => { + const bounds = display.bounds; + if (edges & EDGE_RIGHT) return bounds.x - (origin.x + origin.width); + if (edges & EDGE_LEFT) return origin.x - (bounds.x + bounds.width); + if (edges & EDGE_BOTTOM) return bounds.y - (origin.y + origin.height); + if (edges & EDGE_TOP) return origin.y - (bounds.y + bounds.height); + return null; + }; + // How much of the edge the user pushed through a screen covers. A screen + // further along that direction is only next to this one if it covers any of + // it at all: one that merely starts past the corner, diagonally away, can be + // nearer and still not be the screen the drag was heading for. + const overlap = (display: Electron.Display): number => { + const bounds = display.bounds; + return horizontal + ? Math.min(bounds.y + bounds.height, origin.y + origin.height) - Math.max(bounds.y, origin.y) + : Math.min(bounds.x + bounds.width, origin.x + origin.width) - Math.max(bounds.x, origin.x); + }; + // Screens are laid out in the same logical space the lyrics are placed in, so + // this is a comparison of rectangles — and the nearest one in that direction + // is the next one over, however the layout is arranged. Two of them at the + // same distance are told apart by how much of that edge they share, since one + // of those can be a screen that only touches the corner. + const candidates = screen + .getAllDisplays() + .filter((display) => display.id !== from.id) + .map((display) => ({ display, distance: startsAt(display) })) + .filter(({ distance }) => distance !== null && distance >= 0); + const beside = candidates.filter(({ display }) => overlap(display) > 0); + return ( + (beside.length > 0 ? beside : candidates).sort( + (a, b) => (a.distance ?? 0) - (b.distance ?? 0) || overlap(b.display) - overlap(a.display) + )[0]?.display ?? null + ); +} + +/** Where the lyrics land on a screen they have just been pushed onto. */ +function placementOn(display: Electron.Display, edges: number): LyricsGeometry { + const { width, height } = lyricsGeometry; + const area = display.workAreaSize; + const within = (value: number, most: number) => Math.min(Math.max(value, 0), Math.max(most, 0)); + let { x, y } = lyricsGeometry; + if (edges & EDGE_RIGHT) x = 0; + else if (edges & EDGE_LEFT) x = Math.max(area.width - width, 0); + if (edges & EDGE_BOTTOM) y = 0; + else if (edges & EDGE_TOP) y = Math.max(area.height - height, 0); + return { width, height, x: within(x, area.width - width), y: within(y, area.height - height) }; +} -function setupDesktopLyricsWindow(wnd: BrowserWindow): BrowserWindow { +/** Whether a move to another screen is already under way. */ +let movingToAnotherScreen = false; + +/** + * The move to another screen that is under way, and how to give up on it. + * + * Never more than one at a time. It is held here rather than inside the move + * because giving up has to be possible from outside it, and synchronously: a + * caller that is about to change something the move would otherwise undo cannot + * wait for the page load the move is blocked on. + */ +let abandonPendingMove: (() => void) | null = null; + +/** Windows whose page has reported itself up. */ +const pagesUp = new WeakSet(); + +/** Windows waiting for that report. */ +const waitingForPage = new WeakMap void>(); + +/** Record that `managed`'s page has mounted and asked for its first update. */ +function reportPageIsUp(managed: ManagedWindow) { + pagesUp.add(managed); + waitingForPage.get(managed)?.(); + waitingForPage.delete(managed); +} + +/** + * Resolve once `managed` has something to show, or reject if it never does. + * + * The renderer asks for a full update as soon as it has mounted, and that is the + * one report of a window being up: `ready-to-show` is not emitted for a + * transparent window, and a window whose page never loads would otherwise look + * exactly like one that did. + */ +function whenPageIsUp(managed: ManagedWindow): Promise { + if (pagesUp.has(managed)) return Promise.resolve(); + const wnd = managed.window; + if (!wnd || wnd.isDestroyed()) { + return Promise.reject(new Error("the window built again is already gone")); + } + return new Promise((resolve, reject) => { + const closed = () => { + waitingForPage.delete(managed); + reject(new Error("the window built again was closed before its page was up")); + }; + wnd.once("closed", closed); + waitingForPage.set(managed, () => { + wnd.off("closed", closed); + resolve(); + }); + }); +} + +/** Ask a window to show, and resolve once it has a page to show. */ +async function showAndWaitForPage(managed: ManagedWindow) { + // A window that is already built loads its page while it is still hidden, so + // there the page is waited for first and the window is shown only once it has + // one — a page that never loads then never takes the place of the window the + // user was dragging, not even for the length of the wait. The on-demand + // lifecycle has no window at all until it is shown, so the order there is the + // other way round. + if (managed.window) { + await whenPageIsUp(managed); + await managed.show(); + return; + } + await managed.show(); + await whenPageIsUp(managed); +} + +/** + * Move the lyrics to the screen the user dragged them onto. + * + * This is what a drag that ran out of screen asks for, and it is a window + * creation rather than a move: a layer surface keeps the output it was created + * on for its whole life, and the protocol has no request that changes it. The + * window the user was dragging is therefore replaced by one created on the + * screen the pointer was left on, placed against the edge they pushed towards — + * so the lyrics end up where the drag was going, however far it went. + * + * A drag only ends up somewhere else by having pushed the window against the + * edge of its screen and then out from under the pointer, which the native + * layer reports; a window merely parked at the edge keeps the pointer on it, + * and nothing happens. + */ +async function hopToNextScreen(edges: number) { + // One at a time. A drag across an edge can report the push more than once — + // the window it was reported for is replaced under the pointer that is still + // holding it, and the replacement is dragged into the same edge — and two of + // these at once build two windows for one drag: the name is handed to one of + // them, and the other stays on screen with nothing left that can close it. + if (movingToAnotherScreen) return; + movingToAnotherScreen = true; + try { + await moveToTheScreenPushedTowards(edges); + } finally { + movingToAnotherScreen = false; + } +} + +async function moveToTheScreenPushedTowards(edges: number) { + const from = currentDisplay(); + // The screen the pointer was left on is where the user dragged the lyrics to. + // It is only readable while the pointer is over one of this application's + // windows, though, so the last position it knows can be no further than the + // edge the drag was pushed through — and the screen that starts there is what + // that edge means. + const under = screen.getDisplayNearestPoint(screen.getCursorScreenPoint()); + const target = under && under.id !== from?.id ? under : from ? displayBeyond(from, edges) : null; + if (!target) { + LOGGER.info( + { edges, screen: under?.label ?? under?.id }, + "no screen to move the desktop lyrics to in that direction" + ); + return; + } + LOGGER.info( + { edges, from: lyricsOutput ?? from?.id, to: target.label ?? target.id }, + "moving the desktop lyrics to the screen the pointer was left on" + ); + + // Where the lyrics are now, to put back if the window built again never comes + // up: the one on screen is still there, and still on that screen. It has to + // be set before the window is created, because that is what the window is + // created at. + const before = { geometry: lyricsGeometry, output: lyricsOutput, screen: lyricsScreen }; + lyricsGeometry = placementOn(target, edges); + saveLyricsOutput(target.label ?? null); + lyricsScreen = target.size; + // The placement the window is created at is also the one the next launch has + // to come back to, and nothing else writes it: a window that is already + // inside its screen is never corrected, so no report of its own would. The + // failure below puts the old one back. + void settings.set(LYRICS_GEOMETRY_KEY, lyricsGeometry); + + // The window the user was dragging is only let go of once its replacement has + // something to show: a lyrics window that cannot be built again is one the + // user has no way of getting back, and the placement just remembered is the + // only thing that would have put it back. + const previous = window; + // Whether the lyrics were on screen when the move started. What the answer + // means is only ever a comparison with this: a window that reports itself + // hidden for a reason of its own is not the user having hidden the lyrics. + const wasOnScreen = previous?.wantsToBeVisible() ?? false; + let next: ManagedWindow | undefined; + // Giving up puts the window, the placement and the screen back where they + // were and throws the replacement away. Idempotent, because it can be reached + // both from here and from whoever stopped the move. + let abandoned = false; + const giveUp = () => { + if (abandoned) return; + abandoned = true; + // A lyrics window is born answering to the name the window on screen does, + // and a name is looked up by whichever wrapper comes first: this one left + // behind would be the window a later hide or show reached — one whose own + // window is gone, which the on-demand lifecycle would then build again + // alongside the lyrics that are already there. + next?.setData("name", ""); + next?.destroy(); + // Unless something else has taken over in the meantime: a lifecycle switch + // builds a window of its own, and that one is the application's now. + if (window === next) window = previous; + lyricsGeometry = before.geometry; + lyricsScreen = before.screen; + saveLyricsOutput(before.output); + // Exactly what was there before: a geometry report from the window being + // thrown away may already have written the other screen's placement. + void settings.set(LYRICS_GEOMETRY_KEY, before.geometry); + }; + try { + next = createWindowForLifecycle(await settings.get("window.lifecycle")); + window = next; + abandonPendingMove = giveUp; + // A replacement that never gets there would leave the move half-done: the + // window the user was dragging is still on screen, and the one that took + // its name has nothing to show — so every later show or close goes to the + // wrong one. The wait is therefore bounded, and giving up puts everything + // back. + await Promise.race([ + showAndWaitForPage(next), + new Promise((_, reject) => { + setTimeout( + () => reject(new Error("the window built again never came up")), + LYRICS_HOP_TIMEOUT_MS + ); + }), + ]); + // The lyrics can have been hidden while the page was loading: a hide goes + // to the window that is on screen, which is the one this one is here to + // replace, and it is still that window the user is looking at. Hidden, they + // are to stay hidden — the replacement is not what they hid. + if (abandoned || (wasOnScreen && !previous?.wantsToBeVisible())) return giveUp(); + // Once it is up: the name is how everything else reaches this window, and + // the wrapper it is being taken from has to stop answering to it — the + // lookup takes whichever comes first, and the window behind that one is on + // its way out. + previous?.moveNameTo(next); + LOGGER.info( + { from: previous?.id, to: next.id, name: next.getData("name") }, + "the desktop lyrics were built again" + ); + } catch (err) { + // A move that was stopped is not a move that went wrong. + if (!abandoned) { + LOGGER.error( + { err, edges, to: target.label ?? target.id }, + "the desktop lyrics could not be moved to another screen" + ); + } + return giveUp(); + } finally { + abandonPendingMove = null; + } + previous?.destroy(); +} + +/** What the session calls its desktop, which is a colon-separated list. */ +const desktopName = + process.env.XDG_CURRENT_DESKTOP || + process.env.XDG_SESSION_DESKTOP || + process.env.DESKTOP_SESSION || + ""; + +/** + * Why the lyrics cannot take a layer surface here, or `null` when they can. + * + * One answer for two callers: the window that has to decide whether to ask for + * the layer role, and the placement that has to know which space it is measured + * in — the two follow from each other, and a placement read in the wrong one is + * a window somewhere the user never put it. + */ +function whyLayerShellIsUnavailable(): string | null { + // The compositor first: whether a layer surface can be dragged on it at all + // decides the shape of the window, and `desktop-lyrics-layer.ts` is where + // that is argued. + const byCompositor = whyNotALayerSurface(desktopName, process.env[LAYER_SHELL_CHOICE]); + if (byCompositor !== null) return byCompositor; + if (!ManagedWindow.isLayerShellAvailable()) return "the compositor has no layer shell"; + // Without the native half of the layer-shell path the window could only be + // moved by the compositor, which a layer surface never is. `window.ts` logs + // why; here the window simply stays an ordinary one. + if (!isLayerShellNativeComplete) return "the native module is older"; + return null; +} + +/** Which space the placement in use is measured in. */ +function placementSpace(): PlacementSpace { + return whyLayerShellIsUnavailable() === null ? "screen" : "desk"; +} + +/** + * Give the lyrics window the overlay layer, where the compositor takes one. + * + * Returns whether it did; without a layer surface the window stays an ordinary + * toplevel and keeps the window-manager rules the documentation describes. + */ +function useLyricsLayerShell(managed: ManagedWindow): boolean { + const unavailable = whyLayerShellIsUnavailable(); + if (unavailable !== null) { + LOGGER.info( + { wrap: managed.id, reason: unavailable }, + "the desktop lyrics stay an ordinary window" + ); + return false; + } + // The native layer replays the move and resize, so it is also the only thing + // that knows where the surface ended up: the compositor does not tell clients + // where their layer surfaces are. Subscribing once per wrapper, because the + // on-demand lifecycle builds the window again on every show and a listener + // added each time would report every event once per show so far. + if (!subscribedToLayerEvents.has(managed)) { + subscribedToLayerEvents.add(managed); + managed.on("layerPushed", (event) => { + // A window that is no longer the lyrics is not the one being dragged: a + // window an earlier move left behind keeps reporting the edge it was + // pushed against, and following that would move the lyrics back. + if (managed !== window) return; + void hopToNextScreen(event.data.edges); + }); + managed.on("layerGeometry", (event) => { + if (managed !== window) return; + saveLyricsGeometry(event.data); + requestScreenSize(managed); + }); + managed.on("layerOutput", (event) => { + // The native layer is the only thing that can say which screen a layer + // surface was given, and it names it the way it wants it asked for. + saveLyricsOutput(event.data.output); + // Both of these say the surface is up, which is what the first report may + // have been too early for. + requestScreenSize(managed); + }); + } + const taken = managed.setLayerShell(lyricsLayerShellOptions()); + // Which of the two the window came out as is the first thing a report about + // its drag turns on, and the other answer is logged for the same reason: with + // a list of compositors in the way, "was it a layer surface at all" cannot be + // read off the session. + LOGGER.info( + { wrap: managed.id, desktop: desktopName, taken }, + taken + ? "the desktop lyrics take a layer surface" + : "the layer surface was refused, so the desktop lyrics stay an ordinary window" + ); + return taken; +} + +function setupDesktopLyricsWindow(wnd: BrowserWindow, layerShell: boolean): BrowserWindow { + const managed = ManagedWindow.fromBrowserWindow(wnd); + const de = getDesktopEnvironment(); void wnd.loadURL(guiUrl("/desktop-lyrics")); + if (!layerShell && de !== DesktopEnvironment.Wayland) { + // Without a layer surface the window manager places the window and tells + // the client where it put it, so this is where the placement comes from — + // and it is only worth reading where that happens at all: on Wayland a + // client is never told where its own toplevel is, and `getBounds` answers + // with a position nothing ever put it at. + let pending: ReturnType | undefined; + const remember = () => { + clearTimeout(pending); + pending = setTimeout(() => { + // A window the window manager placed is placed on the desk, so that is + // the space its position is in — not one screen's own corner. + if (!wnd.isDestroyed()) saveLyricsGeometry(wnd.getBounds(), "desk"); + }, LYRICS_GEOMETRY_SETTLE_MS); + }; + wnd.on("moved", remember); + wnd.on("resized", remember); + } + wnd.on("blur", () => { if (wnd.isDestroyed()) return; wnd.webContents.send("desktopLyrics.blur"); }); - const de = getDesktopEnvironment(); - registerIpcHandlers(wnd.webContents, "desktopLyrics", { requestFullUpdate: async () => { if (wnd.isDestroyed()) return; + // The page has mounted, which is the only report of a window being up: + // a window built again is waited for on exactly this, before the one the + // user was dragging is let go of. + if (managed) reportPageIsUp(managed); // Can trigger updates refreshLyricsStyle(); setLyricsOffset(lyricsOffset); @@ -116,6 +793,10 @@ function setupDesktopLyricsWindow(wnd: BrowserWindow): BrowserWindow { performAction: async (_event, action: string) => { performAction(action); }, + reportScreenSize: async (_event, width: number, height: number) => { + lyricsScreen = { width, height }; + managed?.setLayerBounds(width, height); + }, onMouseWheel: async (_event, pageX: number, pageY: number, delta: number, modifier = 0) => { if (!mainWindow || mainWindow.isDestroyed()) return; let x = pageX; @@ -133,10 +814,24 @@ function setupDesktopLyricsWindow(wnd: BrowserWindow): BrowserWindow { y ); }, - changeOrientation: async () => { + changeOrientation: async (_event, vertical: boolean) => { if (wnd.isDestroyed()) return; - const sz = wnd.getSize(); - wnd.setSize(sz[1], sz[0]); + const [width, height] = wnd.getSize(); + // The window is created at the size it was last left at, so a page that + // has just mounted — a restart, a window shown again, one built again on + // another screen — is already the way round the lyrics are laid out. + // Swapping there would undo the size it was created with, so only the + // other way round is a reason to swap. + const isVertical = width < height; + if (isVertical === vertical) return; + // A layer surface is sized by the native layer, and the compositor's + // configure resizes the window from there; anything else keeps resizing + // itself. + if (managed?.setLayerSize(height, width)) { + saveLyricsGeometry({ ...lyricsGeometry, width: height, height: width }); + } else { + wnd.setSize(height, width); + } }, dragWindow: async () => { if (wnd.isDestroyed()) return; @@ -161,7 +856,13 @@ class DesktopLyricsWindow extends ManagedWindow { super(); this.setData("name", "desktop_lyrics"); this.requestCloseApproval(notifyDesktopLyricsClose); - setupDesktopLyricsWindow(this.createBrowserWindow(desktopLyricsWindowOptions)); + // Before the window: the role is decided when the surface's first role + // object is created, which `createBrowserWindow` can already do. + const layerShell = useLyricsLayerShell(this); + setupDesktopLyricsWindow( + this.createBrowserWindow(desktopLyricsWindowOptions(layerShell)), + layerShell + ); } } @@ -173,7 +874,11 @@ class DesktopLyricsOnDemandWindow extends OnDemandWindow { } createWindow(): BrowserWindow { - return setupDesktopLyricsWindow(this.createBrowserWindow(desktopLyricsWindowOptions)); + const layerShell = useLyricsLayerShell(this); + return setupDesktopLyricsWindow( + this.createBrowserWindow(desktopLyricsWindowOptions(layerShell)), + layerShell + ); } } @@ -196,12 +901,19 @@ function registerLifecycleSwitch() { settingsEvents.on("change", (e) => { if (e.data.key !== "window.lifecycle" || !window) return; + // A move to another screen that is still building its window is stopped + // first: it is the wrapper this switch is about to replace, and a + // replacement that outlives the move would leave two windows and one name + // between them. Stopped rather than waited for, so the switch starts from + // the window the user is actually looking at. + abandonPendingMove?.(); window = switchWindowPolicy(window, () => createWindowForLifecycle(e.data.value)); }); } export let window: ManagedWindow; export default async function createDesktopLyricsWindow() { + await loadLyricsGeometry(); window = createWindowForLifecycle(await settings.get("window.lifecycle")); registerLifecycleSwitch(); }