Skip to content

feat(lyrics): 桌面歌词在 Wayland 上改用 layer shell overlay - #312

Open
clansty wants to merge 1 commit into
YUCLing:mainfrom
clansty:feat/desktop-lyrics-layer-shell
Open

clansty wants to merge 1 commit into
YUCLing:mainfrom
clansty:feat/desktop-lyrics-layer-shell

Conversation

@clansty

@clansty clansty commented Oct 14, 2026 •

Copy link
Copy Markdown

问题

桌面歌词一直是一个 xdg_toplevel,靠「跳过任务栏 + 置顶 + 用户自己写窗口规则」来假装自己是桌面歌词。窗口规则做不到的部分:层级盖不过全屏窗口、切工作区就消失、每个合成器的语法还各不相同;位置和尺寸也完全由 WM 说了算。

改动

zwlr_layer_shell_v1(wlroots 系、KWin、niri 等都已实现)的 overlay 层就是为这类东西准备的:层级由合成器保证(全屏窗口也盖不住),窗口不再是一个窗口,没有 WM 会去平铺或移动它,因此不需要任何窗口规则。

layer shell 唯独没有拖动和缩放——layer surface 的位置和尺寸属于应用,协议里只有 set_margin / set_size,没有「用户把它拖走了」这件事。这部分在原生层自己做:

  • 代理重放手势(modules/window)。转换后的窗口照样会发 xdg_toplevel.move / resize,原生层把它接住,跟着指针位移改 margin / size。要害是位移始终从按下的那一刻算起:按住期间合成器给客户端的坐标一直是按住时那套几何下的,所以指针在窗口里的位置就是它当初抓住窗口的地方加上它此后走过的全部距离。把「上一步」再累加上去,一次拖动会被放大成事件个数的倍数(一批 8 个事件跑出 8 倍距离),所以每个事件都从手势起点重算。
  • 窗口整个留在屏内。layer surface 换不了输出,拖到边缘就只能停住;停住之后光标继续走就会离开窗口,那一刻松手,就把光标推过的那条边报给应用,由应用把窗口销毁重建到光标所在的屏幕上。留整块而不是留一条边,是因为接受鼠标的区域只有文字本身——露在屏外的那截永远抓不到;而且合成器会把「锚在左上」的 surface 缩到屏幕剩下的宽度,拖到右边只会让窗口越来越窄。
  • 几何从客户端自己身上问。屏幕多大这件事,客户端可能自己知道(zxdg_output_v1 的 logical_size,退而求其次 wl_output 的 mode/scale),也可能只能由渲染进程上报;三者依次兜底,并且窗口落在屏外时一定会被拉回来。
  • 记住位置。合成器从不告诉客户端 layer surface 在哪,所以拖动结束、换屏、缩放之后都把几何连同所在的输出名写进设置。位置按「某一块屏幕的角」记,于是还原时会检查这块屏幕放不放得下整块窗口,放不下就回默认位置——不会出现「启动了但看不见歌词」。

应用侧(src/main/windows/desktop-lyrics.ts)只负责三件事:要不要用 layer shell、跳屏、记住位置。

哪些合成器默认启用:拖动能不能跟手,取决于合成器会不会在承载新 margin 的那次 commit 上就把 surface 摆过去——这是重排时机的问题,协议里没有任何回报可用于判断,所以只能按实测列。实测结果是 niri、sway、river 跟手;Hyprland 追不上指针,labwc、Wayfire 拖动时会抖,KWin 根本不重排。因此默认只在这三个上启用,其余(KDE、GNOME、Hyprland、labwc、Wayfire……)照旧走普通窗口 + 窗口规则;想试可以设 LYRICS_LAYER_SHELL=1,想明确关掉设 0。判定是纯函数 src/main/windows/desktop-lyrics-layer.ts,带单测。日志里会写明每个窗口最终是 layer surface 还是普通窗口,以及不在名单里的原因。

需要注意

  • 跨屏时会闪一下,并且这次拖动会中断,因为换屏是把窗口销毁重建(layer surface 的输出在创建时就固定了,协议里没有换输出的请求),不是移动窗口。
  • gui/src/lib/paintPulse.ts 不是可有可无的:layer surface 的几何是双缓冲的,只有客户端 commit 才被采纳,而客户端只在有东西要画时才 commit,所以空闲的歌词窗口跟不上拖动。指针在窗口上期间每帧重绘一个看不见的色块,是这里唯一确定有效的办法(modules/window/README.md 里写了这条对应用侧的契约)。
  • 名单外的合成器或设置了 LYRICS_LAYER_SHELL=0 时一切照旧,走原来的窗口规则;docs/WM_RULES.md 已按两种情况分开写。
  • 原生模块和应用一起改了,跑之前需要 pnpm build:modules。没构建的话应用会打一条日志并让歌词退回普通窗口,而不是变成一个拖不动的哑巴窗口(src/main/window.ts 的 isLayerShellNativeComplete)。

测试

cargo test -p window 241 项、pnpm test 761 项通过;cargo clippy --workspace --all-targets --all-features -- -D warnings、cargo fmt --all --check、pnpm lint、pnpm format:check 干净。已在 niri(双屏,副屏在主屏左侧)上手工验证拖动、缩放、横竖切换、跨屏跳转与重启还原。其余合成器的实测结果见上,名单会随反馈调整。

@greptile-apps

greptile-apps Bot commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

RetriggerView in GreptileConfidence Score: 4/5

[Medium impact] The changes affect desktop lyrics across the app's native, main-process, and renderer paths.

应先修复生命周期切换与跨屏替换之间的竞争,再合并;超大缩放和邻屏选择问题为非阻断问题。

Findings

  1. P1 隐藏后歌词重新出现 ▶
  2. P2 缩放把歌词移出屏幕 ▶
  3. P2 歌词跳到错误邻屏 ▶
Fix with agent prompt
### Issue 1
src/main/windows/desktop-lyrics.ts:622-640
跳屏等待页面加载期间,用户隐藏歌词不会取消替换。此时 `desktop_lyrics` 的名称仍指向旧窗口,所以隐藏操作只隐藏旧窗口。页面随后就绪,`showAndWaitForPage()` 仍会显示新窗口,并移交名称、销毁旧窗口,导致刚隐藏的歌词重新出现。

等待期间修改 `window.lifecycle`,也会被失败分支的 `window = previous` 覆盖。请让隐藏和生命周期切换取消当前跳屏,并在显示、移交或恢复窗口前确认这次替换仍有效。

### Issue 2
modules/window/src/linux/wayland/handlers/grab.rs:285-287
`resolve()` 在窗口超过屏幕大小后只限制位置,没有限制宽高,而此时 `keep_inside()` 会原样返回位置。例如,1280 像素宽的屏幕上,宽度 800、左边距 40 的窗口向左扩展 600 像素,会实际发送宽度 1400、左边距 -560 的请求,让部分歌词越过屏幕边界。跨过尺寸上限时位置还会突变:扩展 480 像素时左边距为 0,再扩展一像素就变成 -441。

页面上报屏幕尺寸后虽会纠正,但下一次移动仍按手势起点重算,再次覆盖纠正结果。这是非阻断的缩放问题。请在生成请求前限制宽高,并据此计算位置,不要依赖页面事后拉回。

### Issue 3
src/main/windows/desktop-lyrics.ts:364-367
光标位置仍停在原屏时,`displayBeyond` 只比较推动方向上的距离,没有检查另一方向是否相交。原屏右上方有一块屏幕、右侧还有真正相邻的屏幕时,两者都可能得到零距离,最终按显示器枚举顺序选中右上方那块。执行完整跳屏后,错误目标还被用于替换窗口和保存输出;只有右上方屏幕时也会发生同样的跳转。

这是非阻断的多屏选屏问题。请先检查屏幕在另一方向上的重叠范围,再选择用户推向的邻屏,避免用户需要再次移动歌词来纠正位置。

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Summary

桌面歌词在默认支持的 Wayland 桌面上改用 overlay 层,并由原生代码驱动拖动和缩放。

  • Desktop lyrics use an overlay and keep a usable position across screens.
  • Dragging and resizing desktop lyrics moves the overlay itself.
  • The lyrics page keeps committing while someone drags its overlay.

Reviews (8) · Last reviewed commit: "feat(lyrics): 桌面歌词在 Wayland 上改用 layer sh..." · Reviewed by Greptile

Comment thread modules/window/src/linux/wayland/handlers/objects.rs
Comment thread src/main/windows/desktop-lyrics.ts Outdated
Comment thread modules/window/src/linux/wayland/inject.rs Outdated
Comment thread src/main/windows/desktop-lyrics.ts Outdated
Comment thread src/main/windows/desktop-lyrics.ts Outdated
Comment thread src/main/windows/desktop-lyrics.ts Outdated
Comment on lines +364 to +367
.filter((display) => display.id !== from.id)
.map((display) => ({ display, distance: startsAt(display) }))
.filter(({ distance }) => distance !== null && distance >= 0)
.sort((a, b) => (a.distance ?? 0) - (b.distance ?? 0))[0]?.display ?? null

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 歌词跳到错误邻屏

光标位置仍停在原屏时,displayBeyond 只比较推动方向上的距离,没有检查另一方向是否相交。原屏右上方有一块屏幕、右侧还有真正相邻的屏幕时,两者都可能得到零距离,最终按显示器枚举顺序选中右上方那块。执行完整跳屏后,错误目标还被用于替换窗口和保存输出;只有右上方屏幕时也会发生同样的跳转。

这是非阻断的多屏选屏问题。请先检查屏幕在另一方向上的重叠范围,再选择用户推向的邻屏,避免用户需要再次移动歌词来纠正位置。

Artifacts

Evidence from the check

  • Loads each revision's complete module from Git and drives its registered push handler across nine mocked display layouts, making the reproduction rerunnable.

Command output from the check

  • Runs the nine layouts against the base revision and captures no registered push handler or window replacement, establishing the pre-change behavior.

Command output from the check

  • Runs the same layouts against PR312 head and captures selected outputs, settings writes, replacement lifecycle, and call stacks, confirming the diagonal-target defect.

Command output from the check

  • Captures both complete source revisions using Git and checks the scoped file for local differences, preserving the executed implementation and confirming no source fix was applied.

View artifacts

T-Rex Ran code and verified through T-Rex

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/main/windows/desktop-lyrics.ts
Line: 364-367

Comment:
**歌词跳到错误邻屏**

光标位置仍停在原屏时,`displayBeyond` 只比较推动方向上的距离,没有检查另一方向是否相交。原屏右上方有一块屏幕、右侧还有真正相邻的屏幕时,两者都可能得到零距离,最终按显示器枚举顺序选中右上方那块。执行完整跳屏后,错误目标还被用于替换窗口和保存输出;只有右上方屏幕时也会发生同样的跳转。

这是非阻断的多屏选屏问题。请先检查屏幕在另一方向上的重叠范围,再选择用户推向的邻屏,避免用户需要再次移动歌词来纠正位置。

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Comment thread src/main/windows/desktop-lyrics.ts Outdated
@clansty
clansty force-pushed the feat/desktop-lyrics-layer-shell branch from c6ce052 to eafa0de Compare October 14, 2026 16:15
Comment thread modules/window/src/linux/wayland/state.rs Outdated
Comment on lines +572 to +585
await Promise.race([
showAndWaitForPage(next),
new Promise((_, reject) => {
setTimeout(
() => reject(new Error("the window built again never came up")),
LYRICS_HOP_TIMEOUT_MS
);
}),
]);
// 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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 隐藏后歌词重新出现

跳屏等待页面加载期间,用户隐藏歌词不会取消替换。此时 desktop_lyrics 的名称仍指向旧窗口,所以隐藏操作只隐藏旧窗口。页面随后就绪,showAndWaitForPage() 仍会显示新窗口,并移交名称、销毁旧窗口,导致刚隐藏的歌词重新出现。

等待期间修改 window.lifecycle,也会被失败分支的 window = previous 覆盖。请让隐藏和生命周期切换取消当前跳屏,并在显示、移交或恢复窗口前确认这次替换仍有效。

Knowledge Base Used: Desktop windows and overlays

Artifacts

Evidence from the check

  • The executed Vitest harness drives real main-process modules with mocked Electron and checks both requested interleavings, making the reproduction inspectable.

Evidence from the check

  • The executed generator adapts the existing window test mocks and authors the focused test, documenting exactly how the harness was constructed.

Evidence from the check

  • The executed runner loads the three main modules from each specified revision, captures commands and exit codes, and restores production files afterward, making the comparison reproducible.

Command output from the check

  • Captured git commands print both revisions of the three exercised modules and numbered candidate lines, tying the runtime comparison to concrete source.

Command output from the check

  • One base Vitest run captures both control flows with hideHonored and lifecyclePreserved true, showing that neither newer action is overwritten.

Command output from the check

  • One head Vitest run captures lyrics becoming visible after hide and the old lifecycle wrapper replacing the new one after readiness failure, confirming the candidate.

Command output from the check

  • Captured git diff and HEAD checks show production files restored unchanged at the supplied head, confirming that validation left no production fix.

View artifacts

T-Rex Ran code and verified through T-Rex

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/main/windows/desktop-lyrics.ts
Line: 572-585

Comment:
**隐藏后歌词重新出现**

跳屏等待页面加载期间,用户隐藏歌词不会取消替换。此时 `desktop_lyrics` 的名称仍指向旧窗口,所以隐藏操作只隐藏旧窗口。页面随后就绪,`showAndWaitForPage()` 仍会显示新窗口,并移交名称、销毁旧窗口,导致刚隐藏的歌词重新出现。

等待期间修改 `window.lifecycle`,也会被失败分支的 `window = previous` 覆盖。请让隐藏和生命周期切换取消当前跳屏,并在显示、移交或恢复窗口前确认这次替换仍有效。

**Knowledge Base Used:** [Desktop windows and overlays](https://app.greptile.com/rainplus/-/custom-context/knowledge-base/yucling/open-orpheus/-/docs/desktop-windows-and-overlays.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

@clansty
clansty force-pushed the feat/desktop-lyrics-layer-shell branch from eafa0de to 619d225 Compare October 14, 2026 16:33
Comment thread src/main/windows/desktop-lyrics.ts
@YUCLing

YUCLing commented Oct 14, 2026

Copy link
Copy Markdown
Owner

似乎没有看到对大小调整做支持?iirc Layer Shell窗口也不支持WM处理的大小调整

@greptileai

@greptile-apps

greptile-apps Bot commented Oct 14, 2026

Copy link
Copy Markdown
Contributor

有支持,大小调整是在 native proxy 里重放的,不是交给 WM 处理,所以在 desktop-lyrics.ts 里不太显眼。

  • modules/window/src/linux/wayland/handlers/grab.rs 会拦截转换后客户端发出的 xdg_toplevel.resize,记录按下时的尺寸和坐标,并根据被抓住的边计算新的 width/height;对左/上边还会同步调整 margin,以保持相反的边不动。
  • native 层随后直接发送 layer-shell 的 set_size(先于 set_margin),并发送 wl_surface.commit 让双缓冲状态生效;最小尺寸是 32×32,同时会把整个窗口限制在当前输出内。
  • modules/window/src/lib.rs 暴露了 set_window_layer_size,应用侧的 changeOrientation 通过 managed.setLayerSize(height, width) 使用它;普通窗口才走 Electron 的 wnd.setSize。

因此 layer-shell 路径下既支持从窗口边缘拖动调整大小,也支持代码主动调整大小。这里的 xdg_toplevel.resize 只是被代理消费并转换成 zwlr_layer_surface_v1.set_size,不会原样交给 compositor/WM;paintPulse 仍然是必要的,因为每次 set_size 后要靠 commit 才能让几何真正被 compositor 采纳。

@clansty

clansty commented Oct 14, 2026 via email

Copy link
Copy Markdown
Author

@clansty
clansty force-pushed the feat/desktop-lyrics-layer-shell branch from 619d225 to f81052e Compare October 14, 2026 17:58
@clansty

clansty commented Oct 14, 2026

Copy link
Copy Markdown
Author

三轮评审的意见都处理了,逐条说一下改法。除了第 3 轮那条关于放弃窗口名字的(那个改动直接来自这次评审),其余每一条都补了对应的测试。

第一轮

意见 处理
wl_output.mode 参数顺序 确实读错了位(把 flags/width 当宽高)。改读偏移 12/16,并且只接受带当前模式标志的那一条——一个输出会广告多条模式。原来那个测试只发了两个参数,正好把 bug 遮住,测试也改成真实事件了
向已销毁对象写请求 解析窗口和整组写出改为同一个临界区,取锁顺序与代理自己 outbound 路径一致(先写锁、再连接状态):拿到写锁后对象还在记录里,就说明它的 destroy 还没被转发出去。set_layer_bounds 同样处理
跳屏失败不回滚 位置必须在建窗之前设好(窗口就是按它创建的),所以改成快照,失败时把 geometry / output / screen 和设置里那条一起放回
Promise.race 空转 ManagedWindow.show() 返回 void,这个分支上 race 立刻就赢了。改为等页面真的起来——渲染进程挂载后会请求首次全量更新,这是唯一可靠的信号;透明窗口不一定有 ready-to-show,这一点在这个 PR 的开发过程中已经踩过。顺带把顺序也改成:窗口已建好时先等页面、再 show(),页面起不来的话新窗口不会先露一下脸。超时 1.5s → 5s,因为现在这个 deadline 是真的会生效的
副屏位置被重置 usablePlacement 原来拿单块屏的尺寸校验坐标,而普通窗口传进去的是 getBounds() 的全局坐标。现在分两种坐标空间:layer surface 的 margin 按单块屏校验,窗口管理器摆放的按桌面坐标校验任意一块屏
监听器累积 on-demand 生命周期每次 show 都重建窗口,useLyricsLayerShell 会在同一个 wrapper 上再注册一遍。改成每个 wrapper 只订阅一次
displayBeyond 选错邻屏 先按「是否共享推动方向那条边」过滤;第 2 轮又补了并列时的处理(见下)

第二轮

竖屏按旋转方向:wl_output.mode 报的是面板原生的横向尺寸,竖着用的屏幕要按 wl_output.geometry 的 transform 交换宽高(奇数值就是 90°/270°)。现在解析这个事件(transform 在两个变长字符串后面),测试里加了「同一个 mode,加 transform=1 之前 (1920,1080)、之后 (1080,1920)」。这条只在客户端没绑定 zxdg_output_v1 时走得到,是条兜底路径,但既然 code 里写着「mode 和 scale 加起来是同一回事」,就不该是错的。

跳屏期间隐藏 / 切生命周期:页面加载完之后、接管之前会再确认一次——开始跳屏时旧窗口在屏上,而现在已经不在了,就当作废(用比较而不是直接看 isVisible():后者万一对透明 + layer surface 的窗口不靠谱,直接判断会让跨屏拖动整个失灵,比较式写法最坏退化成改动前的行为)。生命周期切换则是同步放弃进行中的跳屏,再从用户真正看着的那个窗口重建——否则切换会把名字交给一个已被销毁的 wrapper。

displayBeyond 并列:上一轮加了「必须共享那条边」的过滤,但没解决评审说的那种并列:斜上方的屏如果和原屏在另一条轴上有哪怕一点重叠,也算「挨着」,而它和真正相邻的屏距离可能都是 0,最后按显示器枚举顺序选中斜的那块。现在并列时按共享边的长度排序。

第三轮

giveUp() 销毁 next 却留下它的名字。根因比这条更靠前一点:每个歌词窗口一出生就带着 desktop_lyrics 这个名字,所以跳屏期间两个 wrapper 同时应答这个名字,而 fromName() 取先注册的那个。正常情况下返回的是旧窗口、看不出问题;一旦生命周期切换把旧窗口的名字抹掉,那个已销毁的 next 就成了第一个命中的——隐藏/显示打在它身上,按需窗口还会把它重建出来,于是屏幕上两个歌词。现在放弃时先清掉它的名字,让「这个名字只有一个窗口在应答」成立。

另外:已 rebase 到 #311 之后

#311 把 layer 与 popup 的 policy 合并成 WINDOW_ROLES,和这个 PR 的改动有重叠。变基时只有 README 报了文本冲突,但有几个文件是「没冲突却合坏了」的(lib.rs / linux.rs / wayland/mod.rs 里我加的函数被塞进了上游函数的签名中间、objects.rs 的导入拼成两份、state.rs 里 WINDOW_POLICIES 已被并成 WINDOW_ROLES、index.js / index.d.ts 需要重新生成)。这些都修好了,并逐文件确认过是「上游 + 本 PR」而不是互相覆盖:state.rs、window.ts 相对上游零删除,其余文件的删除行都是本 PR 自己的改写;测试数也对得上(基线 184 + 本 PR 46 − #311 删掉的 1 = 229)。

验证:cargo test -p window 229 项、cargo clippy --workspace --all-targets --all-features -- -D warnings、cargo fmt --all --check;pnpm test 745 项、pnpm lint(含 gui 的 eslint + svelte-check)、pnpm format:check;pnpm build:modules 之后 git status 干净,说明提交里的 napi 生成物与源码一致。

CI 那三个 workflow 目前还是 action_required(首次贡献者需要批准才会跑),麻烦看一下。

Comment thread src/main/windows/desktop-lyrics.ts
Comment thread src/main/windows/desktop-lyrics.ts
Comment thread modules/window/src/linux/wayland/handlers/pointer.rs Outdated
@clansty
clansty force-pushed the feat/desktop-lyrics-layer-shell branch from f81052e to 23c598f Compare October 14, 2026 18:34
Comment thread modules/window/src/linux/wayland/handlers/grab.rs
@codecov

codecov Bot commented Oct 15, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 76.87916% with 486 lines in your changes missing coverage. Please review.
✅ Project coverage is 59.31%. Comparing base (e5b43f4) to head (c50ac85).
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
src/main/windows/desktop-lyrics.ts 0.00% 200 Missing and 53 partials ⚠️
modules/window/src/linux.rs 0.00% 79 Missing ⚠️
modules/window/src/lib.rs 0.00% 38 Missing ⚠️
modules/window/src/linux/wayland/mod.rs 0.00% 22 Missing ⚠️
modules/window/src/linux/wayland/state.rs 92.38% 22 Missing ⚠️
modules/window/src/linux/wayland/handlers/grab.rs 97.48% 19 Missing ⚠️
...es/window/src/linux/wayland/handlers/conversion.rs 61.53% 15 Missing ⚠️
modules/window/src/linux/wayland/inject.rs 89.72% 15 Missing ⚠️
...dules/window/src/linux/wayland/handlers/objects.rs 95.06% 12 Missing ⚠️
modules/window/src/linux/wayland/handlers/mod.rs 80.00% 5 Missing ⚠️
... and 3 more
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #312      +/-   ##
==========================================
+ Coverage   57.39%   59.31%   +1.92%     
==========================================
  Files         173      175       +2     
  Lines       19176    21252    +2076     
  Branches     1793     1882      +89     
==========================================
+ Hits        11006    12606    +1600     
- Misses       7320     7743     +423     
- Partials      850      903      +53     
Flag Coverage Δ
node 35.35% <12.06%> (-0.77%) ⬇️
rust 75.94% <87.25%> (+1.88%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@YUCLing

YUCLing commented Oct 15, 2026

Copy link
Copy Markdown
Owner

很感谢你能尝试这个方案,但目前在KDE上的体验不尽如人意。

_20261015_095543.webm

经测试与屏幕的缩放率似乎没有关系。此外,跨屏的体验上也不怎么好。

目前来看该方案还无法媲美手动通过窗口规则对其进行调整的体验,只是省略了用户初次使用手动配置的步骤并提供了位置记录,我认为暂时还不应该替换原有方案.

@clansty

clansty commented Oct 15, 2026

Copy link
Copy Markdown
Author

我觉得要不仅在 wlroots 窗口管理器上启用好了
因为之前的窗口方案在 niri 上就不太行,现在这么做了之后,在 niri 上是很完美的,拖动和鼠标穿透都工作

@clansty

clansty commented Oct 15, 2026

Copy link
Copy Markdown
Author

跨屏的体验确实是一个限制,不过这似乎挺难搞好
现在我提交的大概是我能想到最好的方法
有别的想法我也可以试试

@clansty
clansty force-pushed the feat/desktop-lyrics-layer-shell branch 2 times, most recently from db79d3d to 3a71818 Compare October 15, 2026 11:12
@clansty

clansty commented Oct 15, 2026

Copy link
Copy Markdown
Author

关于 KDE:谢谢实测。录屏我这边打不开(三次都是 422,可能是我的网络),所以下面是从机制推的,跟实际不符的话请纠正我。

现象:从描述和 niri 上的对照看,是歌词窗口追不上指针,而不是整个桌面被拖卡。这两件事成因完全不同——前者可以排除「每个 motion 事件都触发一次重排,把合成器灌爆」那条路(那条我能限流,但看现象不是它)。

判断:layer surface 的位置就是它的 margin,拖动是应用替合成器重放的,所以要求合成器在收到新 margin 的那一刻就重排这个 surface。KWin 不这么做,窗口就永远差一截,且与缩放率无关。协议里没有任何事件会告诉我们 layer surface 在哪、什么时候被重排过(这正是这套机制必须自己驱动手势的原因),所以这个能力测不出来,只能按合成器判。

改动:默认只在拖得动的合成器上开 overlay —— niri(这套东西长出来的地方)、Hyprland、wlroots 系(sway、river、labwc、wayfire:这一族在 margin 变化时同步重排,是共享实现,所以算「有理由」而不是「猜」)。其余(KDE、GNOME……)照旧用普通窗口,和这个 PR 之前完全一致:位置仍由合成器摆放(Wayland 上不再由应用请求坐标),窗口规则那套照旧生效。

  • 想试:LYRICS_LAYER_SHELL=1;想明确关掉:=0。原来的 LYRICS_NO_LAYER_SHELL 换成了这一个开关,省得记两个变量名
  • 判定是纯函数(src/main/windows/desktop-lyrics-layer.ts)并带单测;桌面名从 XDG_CURRENT_DESKTOP / XDG_SESSION_DESKTOP / DESKTOP_SESSION 取,冒号分隔的多个名字都认
  • docs/WM_RULES.md 里写了默认打开的是哪几个、为什么 KWin 不在里面、以及怎么覆盖

想请你帮的忙:

  1. 方便的话在 KDE 上设 LYRICS_LAYER_SHELL=1 复测一次。如果确实还是那个现象,就能确认是 KWin 的 layer surface 重排时机问题——那这条只能等 KWin 那边改,我们这边绕不过去;反过来如果换了个方式就好了,那是我判断错了,我再查。
  2. 这个列表只能靠实测往里加。Hyprland 是我按常识放进去的,我这边没有能验证的 Hyprland/KWin 环境;其他合成器的实际表现,欢迎你或别人补充。

还没处理的:greptile 报的触屏拖动确实存在——原生层接了 wl_touch.down,但手势仍然要求同一个 surface 上有鼠标指针,所以手指拖不动(有鼠标时手势还会绑到鼠标上),接下来处理。另外 paintPulse(让客户端在拖动期间持续出帧的那个)在普通窗口路径上是白干的,会按开关一起关掉。

@YUCLing

YUCLing commented Oct 16, 2026

Copy link
Copy Markdown
Owner

在 KDE 上问题依旧存在

可以将代码再次 rebase 到 main 吗?以便我能在 Sway 和 Hyprland 上测试

@clansty
clansty force-pushed the feat/desktop-lyrics-layer-shell branch from 3a71818 to 89ce7cf Compare October 16, 2026 21:36
@clansty

clansty commented Oct 16, 2026

Copy link
Copy Markdown
Author

已经 rebase 到 e5b43f4(#313)了,分支仍然只有一个提交,git push --force-with-lease 过了。

关于 KDE,我想先确认一件事,因为两种情况的结论完全相反:你这次复测是带了 LYRICS_LAYER_SHELL=1(我上一条请你这样测的),还是没设这个变量直接跑的?

  • 如果带了 =1:那正好确认了上一条的判断 —— KWin 不在收到新 margin 时重排 layer surface,这条我们这边绕不过去。白名单本身是生效的:默认情况下 KDE 根本不走 layer surface。
  • 如果是默认跑的却还是那个现象:那就是我判断错了,或者白名单没生效,请告诉我,我按下面的办法查。

默认给 KDE 的是这样:窗口不做任何转换(setLayerShell 不会被调用),所以原生层不会替它重放手势 —— 拖动仍然是 KWin 自己的交互式移动;位置也照旧交给合成器(Wayland 上本来就不由应用请求坐标),窗口规则那套完全不受影响。也就是说,KDE 上应该是和这个 PR 之前一模一样的行为。

想确认当时走的是哪条路,可以搜日志:普通窗口那条会打

the desktop lyrics stay an ordinary window   reason=...

reason 会说明为什么。KDE 上应该是 <你的桌面名> leaves a layer surface behind the pointer。日志在应用数据目录的 logs/latest.ndjson(开发模式是仓库下的 data/logs/latest.ndjson),从终端跑的话也会直接打在屏上。如果这行没出现,那就是当时真的开了 layer surface,也就是环境里有 LYRICS_LAYER_SHELL。

另外提前说清一件事,免得又有落差:位置记录只在 layer surface 那条路上有。普通窗口路径在 Wayland 上不记位置,因为客户端根本不知道自己的窗口在哪(getBounds 的位置是假的)。所以 KDE 上「省略初次手动配置」这一半也拿不到,这不是白名单的副作用,是 Wayland 本身的限制 —— 想在 KDE 上也记位置得单独做,与 layer shell 无关。如果你觉得这是必须的,告诉我,我看看有没有别的路子。

Sway / Hyprland 就麻烦你了。这两个在名单里,其中 Hyprland 是我按「wlroots 系会在 margin 变化时重排」推的,没有实测过;Sway 同理。拖动跟手就是对的;不跟手的话请把它们也从名单里去掉 —— 名单里只应该放实测能用的,LYRICS_LAYER_SHELL=1 随时可以手动开。

@YUCLing

YUCLing commented Oct 17, 2026 •

Copy link
Copy Markdown
Owner

以下WM测试后存在问题

KDE

_20261017_095953.webm

Hyprland

_20261017_094519.webm

labwc

_20261017_101656.webm

Wayfire

_20261017_121438.webm

其他白名单WM均正常

@clansty

clansty commented Oct 17, 2026

Copy link
Copy Markdown
Author

kde 测试的时候,是不是带了 LYRICS_LAYER_SHELL=1

kde 是不在白名单内的,默认应该不会启用

@YUCLing

YUCLing commented Oct 17, 2026

Copy link
Copy Markdown
Owner

kde 测试的时候,是不是带了 LYRICS_LAYER_SHELL=1

kde 是不在白名单内的,默认应该不会启用

按要求启用了测试

桌面歌词一直是一个 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)默认一切照旧,走原来的窗口规则。
@clansty
clansty force-pushed the feat/desktop-lyrics-layer-shell branch from 89ce7cf to c50ac85 Compare October 17, 2026 10:10
@clansty

clansty commented Oct 17, 2026

Copy link
Copy Markdown
Author

先说 KDE:它不在名单里,默认也不会走 layer surface。我把代码路径又确认了一遍:whyNotALayerSurface("KDE", …) 返回非空 → setLayerShell 根本不会被调用 → 原生层不做角色转换 → 手势重放碰不到它,拖动仍然是 KWin 自己的交互式移动。默认配置下 KDE 的行为和这个 PR 之前一模一样,没有可抖的余地。

能在这条路上出现的唯一入口是环境变量 LYRICS_LAYER_SHELL=1。上一轮我请你「在 KDE 上设 LYRICS_LAYER_SHELL=1 复测一次」,所以想确认:KDE 那段录像是不是带这个变量跑的? 如果是,它和 labwc 的「抖」就是同一批数据(强开下的对照),不是白名单失效;如果不是,那就是我判断错了,麻烦把日志里这几行发我(这次新加的,之前只有普通窗口那条):

  • the desktop lyrics take a layer surface —— 走的是 layer surface(同一条里还有 desktop)
  • the desktop lyrics stay an ordinary window + reason —— 走普通窗口,reason 写明为什么

日志在应用数据目录的 logs/latest.ndjson,终端里跑会直接打屏。

然后是重要的部分:你的实测把「wlroots 系」这个假设证伪了。原来的名单是「niri + wlroots 系」,理由是「这一族在 margin 变化时会同步重排」——现在看这个推理是错的,同一族里 sway、river 可以,labwc、wayfire 不行。所以名单按实测收窄成 niri、sway、river,Hyprland、labwc、Wayfire 都移出去了(KDE 本来就不在),docs/WM_RULES.md 和判定模块里的理由、PR 描述都按实测重写了。

想请你确认三件事:

  1. 「其他白名单WM均正常」我理解成 niri、sway、river。对吗?如果 sway 或 river 也有问题,我一并从名单里删掉——名单只留实测过的。
  2. Wayfire 的症状具体是什么?和 labwc 一样是抖,还是别的?(录像我这边还是打不开,抱歉)
  3. KDE 那次是不是如前所述带了 LYRICS_LAYER_SHELL=1。

如果第 3 条是「没带」而 KDE 还是抖,除了日志还有个可能:paintPulse(拖动期间每帧重绘一个看不见的色块,用来逼客户端 commit)现在在普通窗口路径上也会跑,而对普通窗口它完全是白干的。我本来就打算按开关把它关在那条路之外,真凶是它的话关掉就好了。

机制上这几种现象是同一件事的三个程度,都不是「支不支持 layer shell」:layer surface 的位置就是 margin,拖动是应用替合成器重放的,所以合成器必须在承载新 margin 的那次 commit 上就把 surface 摆过去。Hyprland 慢半拍(追不上指针),labwc、Wayfire 时快时慢(抖),KWin 干脆不动(永远追不上)。协议里没有任何 event 会告诉我们 surface 被摆到了哪里、什么时候摆的,应用侧没法自适应,只能按名字列。

如果你觉得「默认在三个上开」还是太激进,那就再退一步:默认全关,只留给 LYRICS_LAYER_SHELL=1 手动开。你说了算。

@clansty

clansty commented Oct 17, 2026

Copy link
Copy Markdown
Author

要是新的 commit 还是不行的话,我要不改成默认只开测试通过的,别的都默认关吧(包括 kde)

@clansty

clansty commented Oct 17, 2026

Copy link
Copy Markdown
Author

或者说这个环境变量的开关或许可以移动到设置界面里,这样用户可以更方便的调整

Comment on lines +285 to +287
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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 缩放把歌词移出屏幕

resolve() 在窗口超过屏幕大小后只限制位置,没有限制宽高,而此时 keep_inside() 会原样返回位置。例如,1280 像素宽的屏幕上,宽度 800、左边距 40 的窗口向左扩展 600 像素,会实际发送宽度 1400、左边距 -560 的请求,让部分歌词越过屏幕边界。跨过尺寸上限时位置还会突变:扩展 480 像素时左边距为 0,再扩展一像素就变成 -441。

页面上报屏幕尺寸后虽会纠正,但下一次移动仍按手势起点重算,再次覆盖纠正结果。这是非阻断的缩放问题。请在生成请求前限制宽高,并据此计算位置,不要依赖页面事后拉回。

Artifacts

Runner for base and head native resize validation

  • Extracts the supplied revisions, temporarily includes the native test, and captures actual Cargo execution output; the comparison is reproducible without changing tracked product sources.

Native resize test template

  • Defines the generated pointer gesture, socket capture, bounds reports, and assertions used to exercise production code; the resize algorithm itself is not mocked.

Exact native test source executed against the PR base

  • Contains the exact test included in the base build, with only head-specific APIs omitted; the gesture inputs match the head run.

Native resize execution before the PR

  • Captures the compiled base run and socket observations for the generated gesture; the resize is suppressed and geometry remains unchanged.

Exact native test source executed against the PR head

  • Contains the exact test included in the head build, including correction and release assertions; the candidate path is exercised through production protocol filtering.

Native resize execution with output overflow and correction reversal

  • Captures actual server-bound requests, geometry callbacks, and policy values on head; width 1400 at left −560 returns after a bounds correction.

Current source locations and validation environment

  • Captures numbered production source, repository status, and display/compositor availability checks; the cited lines are current and tracked product sources remain unchanged.

View artifacts

T-Rex Ran code and verified through T-Rex

Prompt To Fix With AI
This is a comment left during a code review.
Path: modules/window/src/linux/wayland/handlers/grab.rs
Line: 285-287

Comment:
**缩放把歌词移出屏幕**

`resolve()` 在窗口超过屏幕大小后只限制位置,没有限制宽高,而此时 `keep_inside()` 会原样返回位置。例如,1280 像素宽的屏幕上,宽度 800、左边距 40 的窗口向左扩展 600 像素,会实际发送宽度 1400、左边距 -560 的请求,让部分歌词越过屏幕边界。跨过尺寸上限时位置还会突变:扩展 480 像素时左边距为 0,再扩展一像素就变成 -441。

页面上报屏幕尺寸后虽会纠正,但下一次移动仍按手势起点重算,再次覆盖纠正结果。这是非阻断的缩放问题。请在生成请求前限制宽高,并据此计算位置,不要依赖页面事后拉回。

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

@YUCLing

YUCLing commented Oct 17, 2026

Copy link
Copy Markdown
Owner

我看了一下,这个PR的体量有些大,我可能需要些时间才能审核完

@clansty

clansty commented Oct 17, 2026

Copy link
Copy Markdown
Author

辛苦了

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants