From 1858dae2c8857b37bd9862d47cfc7a68fbf3254d Mon Sep 17 00:00:00 2001 From: sudacode Date: Thu, 28 May 2026 23:54:21 -0700 Subject: [PATCH] fix(overlay): use Lua dispatch syntax for Hyprland 0.55+ Lua configs - Detect `configProvider: "lua"` via `hyprctl -j status` at placement time - Emit `hl.dsp.window.*` Lua dispatcher calls instead of legacy hyprlang syntax - Fall back to hyprlang if status call fails or returns non-lua provider --- changes/hyprland-lua-dispatch.md | 4 + .../hyprland-window-placement.test.ts | 125 ++++++++++++++++++ .../services/hyprland-window-placement.ts | 115 +++++++++++++--- 3 files changed, 226 insertions(+), 18 deletions(-) create mode 100644 changes/hyprland-lua-dispatch.md diff --git a/changes/hyprland-lua-dispatch.md b/changes/hyprland-lua-dispatch.md new file mode 100644 index 00000000..9d44cb75 --- /dev/null +++ b/changes/hyprland-lua-dispatch.md @@ -0,0 +1,4 @@ +type: fixed +area: overlay + +- Fixed Hyprland overlay placement on Hyprland 0.55+ Lua configs by using Lua dispatcher syntax when Hyprland reports `configProvider: "lua"`. diff --git a/src/core/services/hyprland-window-placement.test.ts b/src/core/services/hyprland-window-placement.test.ts index 45a2b00d..95809099 100644 --- a/src/core/services/hyprland-window-placement.test.ts +++ b/src/core/services/hyprland-window-placement.test.ts @@ -95,6 +95,54 @@ test('buildHyprlandPlacementDispatches force-aligns floating overlay windows to ); }); +test('buildHyprlandPlacementDispatches emits Lua dispatchers for Lua-config Hyprland sessions', () => { + assert.deepEqual( + buildHyprlandPlacementDispatches( + { + address: '0xabc', + floating: false, + pinned: true, + }, + { + x: 0, + y: 0, + width: 1920, + height: 1080, + }, + { + configProvider: 'lua', + }, + ), + [ + ['dispatch', 'hl.dsp.window.float({ action = "on", window = "address:0xabc" })'], + ['dispatch', 'hl.dsp.window.pin({ action = "off", window = "address:0xabc" })'], + ['dispatch', 'hl.dsp.window.move({ x = 0, y = 0, window = "address:0xabc" })'], + ['dispatch', 'hl.dsp.window.resize({ x = 1920, y = 1080, window = "address:0xabc" })'], + [ + 'dispatch', + 'hl.dsp.window.set_prop({ prop = "rounding", value = "0", window = "address:0xabc" })', + ], + [ + 'dispatch', + 'hl.dsp.window.set_prop({ prop = "border_size", value = "0", window = "address:0xabc" })', + ], + [ + 'dispatch', + 'hl.dsp.window.set_prop({ prop = "no_shadow", value = "1", window = "address:0xabc" })', + ], + [ + 'dispatch', + 'hl.dsp.window.set_prop({ prop = "no_blur", value = "1", window = "address:0xabc" })', + ], + [ + 'dispatch', + 'hl.dsp.window.set_prop({ prop = "decorate", value = "0", window = "address:0xabc" })', + ], + ['dispatch', 'hl.dsp.window.alter_zorder({ mode = "top", window = "address:0xabc" })'], + ], + ); +}); + test('buildHyprlandPlacementDispatches does not pin already floating overlay windows', () => { assert.deepEqual( buildHyprlandPlacementDispatches({ @@ -177,6 +225,9 @@ test('ensureHyprlandWindowFloatingByTitle dispatches float-only placement for ma }, ]); } + if (args.join(' ') === '-j status') { + return JSON.stringify({ configProvider: 'hyprlang' }); + } return ''; }) as never, }); @@ -186,6 +237,7 @@ test('ensureHyprlandWindowFloatingByTitle dispatches float-only placement for ma calls.map(([, args]) => args), [ ['-j', 'clients'], + ['-j', 'status'], ['dispatch', 'setfloating', 'address:0xmatch'], ['dispatch', 'alterzorder', 'top,address:0xmatch'], ], @@ -221,6 +273,9 @@ test('ensureHyprlandWindowFloatingByTitle dispatches exact Hyprland geometry whe }, ]); } + if (args.join(' ') === '-j status') { + return JSON.stringify({ configProvider: 'hyprlang' }); + } return ''; }) as never, }); @@ -230,6 +285,7 @@ test('ensureHyprlandWindowFloatingByTitle dispatches exact Hyprland geometry whe calls.map(([, args]) => args), [ ['-j', 'clients'], + ['-j', 'status'], ['dispatch', 'movewindowpixel', 'exact 0 0,address:0xmatch'], ['dispatch', 'resizewindowpixel', 'exact 1920 1080,address:0xmatch'], ['dispatch', 'setprop', 'address:0xmatch rounding 0'], @@ -241,3 +297,72 @@ test('ensureHyprlandWindowFloatingByTitle dispatches exact Hyprland geometry whe ], ); }); + +test('ensureHyprlandWindowFloatingByTitle dispatches Lua syntax for Lua-config Hyprland sessions', () => { + const calls: unknown[][] = []; + const placed = ensureHyprlandWindowFloatingByTitle({ + title: 'SubMiner Stats', + platform: 'linux', + env: { + HYPRLAND_INSTANCE_SIGNATURE: 'abc', + }, + pid: 456, + bounds: { + x: 0, + y: 0, + width: 1920, + height: 1080, + }, + execFileSync: ((command: string, args: string[], options: unknown) => { + calls.push([command, args, options]); + if (args.join(' ') === '-j clients') { + return JSON.stringify([ + { + address: '0xmatch', + pid: 456, + title: 'SubMiner Stats', + mapped: true, + floating: true, + pinned: false, + }, + ]); + } + if (args.join(' ') === '-j status') { + return JSON.stringify({ configProvider: 'lua' }); + } + return ''; + }) as never, + }); + + assert.equal(placed, true); + assert.deepEqual( + calls.map(([, args]) => args), + [ + ['-j', 'clients'], + ['-j', 'status'], + ['dispatch', 'hl.dsp.window.move({ x = 0, y = 0, window = "address:0xmatch" })'], + ['dispatch', 'hl.dsp.window.resize({ x = 1920, y = 1080, window = "address:0xmatch" })'], + [ + 'dispatch', + 'hl.dsp.window.set_prop({ prop = "rounding", value = "0", window = "address:0xmatch" })', + ], + [ + 'dispatch', + 'hl.dsp.window.set_prop({ prop = "border_size", value = "0", window = "address:0xmatch" })', + ], + [ + 'dispatch', + 'hl.dsp.window.set_prop({ prop = "no_shadow", value = "1", window = "address:0xmatch" })', + ], + [ + 'dispatch', + 'hl.dsp.window.set_prop({ prop = "no_blur", value = "1", window = "address:0xmatch" })', + ], + [ + 'dispatch', + 'hl.dsp.window.set_prop({ prop = "decorate", value = "0", window = "address:0xmatch" })', + ], + ['dispatch', 'hl.dsp.window.alter_zorder({ mode = "top", window = "address:0xmatch" })'], + ], + ); +}); diff --git a/src/core/services/hyprland-window-placement.ts b/src/core/services/hyprland-window-placement.ts index 46775824..ad871185 100644 --- a/src/core/services/hyprland-window-placement.ts +++ b/src/core/services/hyprland-window-placement.ts @@ -19,10 +19,12 @@ export interface HyprlandPlacementBounds { } export interface HyprlandPlacementDispatchOptions { + configProvider?: HyprlandConfigProvider; promote?: boolean; } type ExecFileSync = typeof execFileSync; +export type HyprlandConfigProvider = 'hyprlang' | 'lua'; export function shouldAttemptHyprlandWindowPlacement( platform: NodeJS.Platform = process.platform, @@ -75,37 +77,88 @@ export function buildHyprlandPlacementDispatches( } const windowAddress = `address:${client.address}`; + const configProvider = options.configProvider ?? 'hyprlang'; const dispatches: string[][] = []; if (client.floating !== true) { - dispatches.push(['dispatch', 'setfloating', windowAddress]); + dispatches.push( + configProvider === 'lua' + ? luaWindowDispatch('float', windowAddress, ['action = "on"']) + : ['dispatch', 'setfloating', windowAddress], + ); } if (client.pinned === true) { - dispatches.push(['dispatch', 'pin', windowAddress]); + dispatches.push( + configProvider === 'lua' + ? luaWindowDispatch('pin', windowAddress, ['action = "off"']) + : ['dispatch', 'pin', windowAddress], + ); } const roundedBounds = roundPlacementBounds(bounds); if (roundedBounds) { - dispatches.push([ - 'dispatch', - 'movewindowpixel', - `exact ${roundedBounds.x} ${roundedBounds.y},${windowAddress}`, - ]); - dispatches.push([ - 'dispatch', - 'resizewindowpixel', - `exact ${roundedBounds.width} ${roundedBounds.height},${windowAddress}`, - ]); - dispatches.push(['dispatch', 'setprop', `${windowAddress} rounding 0`]); - dispatches.push(['dispatch', 'setprop', `${windowAddress} border_size 0`]); - dispatches.push(['dispatch', 'setprop', `${windowAddress} no_shadow 1`]); - dispatches.push(['dispatch', 'setprop', `${windowAddress} no_blur 1`]); - dispatches.push(['dispatch', 'setprop', `${windowAddress} decorate 0`]); + if (configProvider === 'lua') { + dispatches.push( + luaWindowDispatch('move', windowAddress, [ + `x = ${roundedBounds.x}`, + `y = ${roundedBounds.y}`, + ]), + ); + dispatches.push( + luaWindowDispatch('resize', windowAddress, [ + `x = ${roundedBounds.width}`, + `y = ${roundedBounds.height}`, + ]), + ); + dispatches.push(luaWindowSetProp(windowAddress, 'rounding', '0')); + dispatches.push(luaWindowSetProp(windowAddress, 'border_size', '0')); + dispatches.push(luaWindowSetProp(windowAddress, 'no_shadow', '1')); + dispatches.push(luaWindowSetProp(windowAddress, 'no_blur', '1')); + dispatches.push(luaWindowSetProp(windowAddress, 'decorate', '0')); + } else { + dispatches.push([ + 'dispatch', + 'movewindowpixel', + `exact ${roundedBounds.x} ${roundedBounds.y},${windowAddress}`, + ]); + dispatches.push([ + 'dispatch', + 'resizewindowpixel', + `exact ${roundedBounds.width} ${roundedBounds.height},${windowAddress}`, + ]); + dispatches.push(['dispatch', 'setprop', `${windowAddress} rounding 0`]); + dispatches.push(['dispatch', 'setprop', `${windowAddress} border_size 0`]); + dispatches.push(['dispatch', 'setprop', `${windowAddress} no_shadow 1`]); + dispatches.push(['dispatch', 'setprop', `${windowAddress} no_blur 1`]); + dispatches.push(['dispatch', 'setprop', `${windowAddress} decorate 0`]); + } } if (options.promote !== false) { - dispatches.push(['dispatch', 'alterzorder', `top,${windowAddress}`]); + dispatches.push( + configProvider === 'lua' + ? luaWindowDispatch('alter_zorder', windowAddress, ['mode = "top"']) + : ['dispatch', 'alterzorder', `top,${windowAddress}`], + ); } return dispatches; } +function luaWindowDispatch(name: string, windowAddress: string, fields: string[]): string[] { + return [ + 'dispatch', + `hl.dsp.window.${name}({ ${[...fields, `window = ${luaString(windowAddress)}`].join(', ')} })`, + ]; +} + +function luaWindowSetProp(windowAddress: string, prop: string, value: string): string[] { + return luaWindowDispatch('set_prop', windowAddress, [ + `prop = ${luaString(prop)}`, + `value = ${luaString(value)}`, + ]); +} + +function luaString(value: string): string { + return `"${value.replace(/\\/g, '\\\\').replace(/"/g, '\\"')}"`; +} + function roundPlacementBounds( bounds?: HyprlandPlacementBounds | null, ): HyprlandPlacementBounds | null { @@ -154,7 +207,9 @@ export function ensureHyprlandWindowFloatingByTitle(options: { return false; } + const configProvider = detectHyprlandConfigProvider(run); const dispatches = buildHyprlandPlacementDispatches(client, options.bounds, { + configProvider, promote: options.promote, }); for (const args of dispatches) { @@ -165,3 +220,27 @@ export function ensureHyprlandWindowFloatingByTitle(options: { return false; } } + +function detectHyprlandConfigProvider(run: ExecFileSync): HyprlandConfigProvider { + try { + return parseHyprlandConfigProvider( + String(run('hyprctl', ['-j', 'status'], { encoding: 'utf-8' })), + ); + } catch { + return 'hyprlang'; + } +} + +function parseHyprlandConfigProvider(output: string): HyprlandConfigProvider { + const payloadStart = output.indexOf('{'); + if (payloadStart < 0) { + return 'hyprlang'; + } + + const parsed = JSON.parse(output.slice(payloadStart)) as unknown; + return isHyprlandStatusPayload(parsed) && parsed.configProvider === 'lua' ? 'lua' : 'hyprlang'; +} + +function isHyprlandStatusPayload(value: unknown): value is { configProvider?: unknown } { + return Boolean(value) && typeof value === 'object'; +}