Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions __test__/main/desktop-lyrics-layer.spec.ts
Original file line number Diff line number Diff line change
@@ -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/);
});
});
179 changes: 179 additions & 0 deletions __test__/main/window.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<unknown>[] = [];

Expand All @@ -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,
Expand All @@ -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();
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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", () => {
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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() {
Expand Down
41 changes: 35 additions & 6 deletions docs/WM_RULES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 的窗口规则设置可在 `系统设置 > 应用和窗口 > 窗口管理 > 窗口规则` 中找到。
Expand Down Expand Up @@ -77,6 +81,14 @@ KDE 的窗口规则设置可在 `系统设置 > 应用和窗口 > 窗口管理 >

</details>

上面这几个合成器上不需要以上规则。

layer surface 只能待在合成器分给它的那块输出上,协议里没有换输出的请求,所以拖动本身换不了屏:拖到边缘会停住,窗口**整个**留在屏内——不留一截在边缘外,因为那截透明、不接受鼠标,用户就没法把它抓回来了(而且合成器会把「锚在左上」的 surface 缩到屏幕剩下的宽度,于是窗口会被越拖越窄)。窗口贴住屏幕边之后,光标越过屏幕交界线的那一刻正好就是光标离开窗口的那一刻,此时松手,应用会把歌词窗口销毁重建到光标所在的那块屏幕上,贴着推过去的那条边——所以拖到哪块屏就落在哪块屏。会闪一下,而且这次拖动会中断,需要再拖一次继续微调。光标又回到窗口里再松手就什么都不做。

同一次拖动可能连着报几次推挤(窗口在按着不放的指针底下被换掉了,新的那个又被拖到同一条边上),所以同一时间只允许一次跳屏,其余丢掉;而且只有**当前**这个歌词窗口报的推挤才算数——被换掉的旧窗口还留在屏幕上时,它会继续报它当初那条边,跟着它走会把歌词又搬回去。

窗口的位置是按「某一块屏幕的角」记的,所以换过屏幕、拔过线之后记住的位置可能不在这块屏上。这种情况下窗口会被拉回屏内,位置整个放不下就回到默认位置(主屏底部居中),不会出现「启动了但看不见歌词」;比屏幕还大的窗口会被缩到屏幕大小(否则合成器会自己缩,原生层记的几何就跟实际对不上了)。

## niri

niri 下光标交互无法穿透到浮动窗口下方,关闭菜单只能通过 Esc 按键。
Expand Down Expand Up @@ -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$"
Expand Down Expand Up @@ -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 拖动/缩放的处理 | 排查「拖动没有实时跟随」一类问题时使用 |
Loading
Loading