From 9b762b8ec05a38f2287efdf7c4652384cfac1124 Mon Sep 17 00:00:00 2001 From: StevenKerman Date: Thu, 29 Oct 2026 10:28:50 -0500 Subject: [PATCH] dbar: animate hiding overlay bars on fullscreen in niri (#4000) port 2.0 (cherry picked from commit 47f7f3d798be46c2fd69bf14a3fff8f22630c573) --- quickshell/Common/settings/SettingsSpec.js | 1 + quickshell/DMSShellIPC.qml | 2 +- quickshell/Modules/DBar/DBar.qml | 1 + quickshell/Modules/DBar/DBarBody.qml | 21 +++++- quickshell/Modules/DBar/DBarWindow.qml | 1 + quickshell/Modules/Settings/DBarTab.qml | 14 ++++ quickshell/Services/CompositorService.qml | 7 ++ quickshell/Services/NiriFullscreen.js | 34 ++++++++++ quickshell/Services/NiriService.qml | 10 ++- quickshell/tests/bar-ipc.test.mjs | 9 ++- .../tests/niri-workspace-state.test.mjs | 68 +++++++++++++++++++ .../translations/settings_search_index.json | 33 +++++++++ 12 files changed, 192 insertions(+), 9 deletions(-) create mode 100644 quickshell/Services/NiriFullscreen.js create mode 100644 quickshell/tests/niri-workspace-state.test.mjs diff --git a/quickshell/Common/settings/SettingsSpec.js b/quickshell/Common/settings/SettingsSpec.js index 1e7c6e43f..6413a37f3 100644 --- a/quickshell/Common/settings/SettingsSpec.js +++ b/quickshell/Common/settings/SettingsSpec.js @@ -1592,6 +1592,7 @@ var LOCAL_SPEC = { popupGapsManual: 4, maximizeDetection: true, useOverlayLayer: false, + hideOnFullscreen: false, scrollEnabled: true, scrollXBehavior: "focusWindow", scrollYBehavior: "workspace", diff --git a/quickshell/DMSShellIPC.qml b/quickshell/DMSShellIPC.qml index e3729b3a8..eba156633 100644 --- a/quickshell/DMSShellIPC.qml +++ b/quickshell/DMSShellIPC.qml @@ -914,7 +914,7 @@ Item { function toggleReveal(selector: string, value: string): string { return withBarConfig(selector, value, false, bar => { - if (!bar.autoHide) + if (!bar.autoHide && !BarWidgetService.dankBarItems[bar.id]?.fullscreenAutoHide) return "BAR_AUTO_HIDE_DISABLED"; if (!(bar.visible ?? true)) { SettingsData.updateBarConfig(bar.id, { diff --git a/quickshell/Modules/DBar/DBar.qml b/quickshell/Modules/DBar/DBar.qml index a0d5ca40e..94726873c 100644 --- a/quickshell/Modules/DBar/DBar.qml +++ b/quickshell/Modules/DBar/DBar.qml @@ -25,6 +25,7 @@ Item { Component.onDestruction: BarWidgetService.unregisterDankBarItem(_registeredBarId, root) property alias barVariants: barVariants + readonly property bool fullscreenAutoHide: barVariants.instances.some(bar => bar.fullscreenAutoHide) property var hyprlandOverviewLoader: null property bool systemTrayMenuOpen: false diff --git a/quickshell/Modules/DBar/DBarBody.qml b/quickshell/Modules/DBar/DBarBody.qml index 65cdecff8..572df4ef5 100644 --- a/quickshell/Modules/DBar/DBarBody.qml +++ b/quickshell/Modules/DBar/DBarBody.qml @@ -1,5 +1,6 @@ import QtQuick import Quickshell +import Quickshell.Wayland import qs.Common import qs.Modules.DIsland import qs.Services @@ -22,6 +23,13 @@ Item { readonly property bool barRevealed: inputMask.showing readonly property bool autoHideActive: topBarCore.autoHide + readonly property bool fullscreenAutoHide: !barWindow.isIsland + && !usesFrameBarChrome + && (barConfig?.hideOnFullscreen ?? false) + && hostWindow?.dBarLayer === WlrLayer.Overlay + && CompositorService.isNiri + && !CompositorService.overviewActiveOnScreen(screenName) + && CompositorService.visibleNiriFullscreenToplevelOnScreen(screen) readonly property bool isIsland: barConfig?.island === true // Resolver-owned so the body, the frame and the dismiss window agree during the frame latch. @@ -822,7 +830,7 @@ Item { readonly property bool reserveExclusiveWhenAutoHidden: FrameTransitionState.effectiveFrameEnabled && usesFrameBarChrome && !!barWindow.screen && SettingsData.isScreenInPreferences(barWindow.screen, SettingsData.frameScreenPreferences) - readonly property real surfaceExclusiveZone: isIsland ? ((islandFree || (islandHost?.floating ?? false)) ? 0 : SettingsData.islandReserveThickness(barConfig)) : (!(barConfig?.visible ?? true) || (topBarCore.autoHide && !barWindow.reserveExclusiveWhenAutoHidden)) ? -1 : (barWindow.effectiveBarThickness + effectiveSpacing + (usesFrameBarChrome ? 0 : (barConfig?.bottomGap ?? 0))) + readonly property real surfaceExclusiveZone: isIsland ? ((islandFree || (islandHost?.floating ?? false)) ? 0 : SettingsData.islandReserveThickness(barConfig)) : (!(barConfig?.visible ?? true) || ((barConfig?.autoHide ?? false) && !barWindow.reserveExclusiveWhenAutoHidden)) ? -1 : (barWindow.effectiveBarThickness + effectiveSpacing + (usesFrameBarChrome ? 0 : (barConfig?.bottomGap ?? 0))) readonly property alias inputMaskItem: inputMask @@ -934,7 +942,12 @@ Item { anchors.fill: parent layer.enabled: false - property bool autoHide: (!barWindow.isIsland || barWindow.islandOverlay) && (barConfig?.autoHide ?? false) + readonly property bool autoHide: ((!barWindow.isIsland || barWindow.islandOverlay) && (barConfig?.autoHide ?? false)) || barWindow.fullscreenAutoHide + onAutoHideChanged: { + if (!autoHide) + SettingsData.setBarIpcReveal(barConfig?.id ?? "", false); + evaluateReveal(); + } property bool revealSticky: false // In click-through mode the hidden bar's input mask covers the full // band while the revealed bar's mask covers only the widget sections, @@ -1032,6 +1045,9 @@ Item { if (inOverviewWithShow) return true; + if (barWindow.fullscreenAutoHide) + return hoverReveal || popoutPinsReveal || revealSticky || ipcReveal; + const showOnWindowsSetting = barConfig?.showOnWindowsOpen ?? false; if (showOnWindowsSetting && autoHide && CompositorService.windowOverlapSupported) { if (barWindow.shouldHideForWindows) @@ -1048,7 +1064,6 @@ Item { readonly property var rootWindowBarConfig: rootWindow.barConfig onRootWindowBarConfigChanged: { - autoHide = (!barWindow.isIsland || barWindow.islandOverlay) && (barConfig?.autoHide ?? false); evaluateReveal(); } diff --git a/quickshell/Modules/DBar/DBarWindow.qml b/quickshell/Modules/DBar/DBarWindow.qml index ed0b8a477..a73719dfa 100644 --- a/quickshell/Modules/DBar/DBarWindow.qml +++ b/quickshell/Modules/DBar/DBarWindow.qml @@ -21,6 +21,7 @@ PanelWindow { readonly property bool isVertical: body.isVertical readonly property int barPos: body.barPos readonly property bool barRevealed: body.barRevealed + readonly property bool fullscreenAutoHide: body.fullscreenAutoHide readonly property bool isIsland: body.isIsland readonly property var islandHost: body.islandHost readonly property var leadingSectionRect: body.leadingSectionRect diff --git a/quickshell/Modules/Settings/DBarTab.qml b/quickshell/Modules/Settings/DBarTab.qml index 07ef772bb..9ef76936f 100644 --- a/quickshell/Modules/Settings/DBarTab.qml +++ b/quickshell/Modules/Settings/DBarTab.qml @@ -634,6 +634,20 @@ Item { } } + SettingsToggleRow { + settingKey: "barHideOnFullscreen" + resetStore: bar + resetKeys: ["hideOnFullscreen"] + tags: ["bar", "fullscreen", "hide", "visibility"] + visible: CompositorService.isNiri && !bar.islandOwnsSelectedBarTop && (bar.selectedBarConfig?.useOverlayLayer ?? false) + text: I18n.tr("Hide bar on fullscreen", "bar visibility toggle") + description: I18n.tr("Slide the bar away over fullscreen applications. Move the pointer to the screen edge to reveal it.", "bar fullscreen auto-hide description") + checked: bar.selectedBarConfig?.hideOnFullscreen ?? false + onToggled: toggled => SettingsData.updateBarConfig(bar.selectedBarId, { + hideOnFullscreen: toggled + }) + } + SettingsToggleRow { settingKey: "islandUseOverlayLayer" tags: ["island", "fullscreen", "overlay", "layer"] diff --git a/quickshell/Services/CompositorService.qml b/quickshell/Services/CompositorService.qml index e819f967d..a13e16004 100644 --- a/quickshell/Services/CompositorService.qml +++ b/quickshell/Services/CompositorService.qml @@ -11,6 +11,7 @@ import qs.DCommon.Common as DCommon import qs.Services import "../Common/WorkspaceModel.js" as WorkspaceModel import "../Common/WindowModel.js" as WindowModel +import "NiriFullscreen.js" as NiriFullscreen Singleton { id: root @@ -873,6 +874,12 @@ Singleton { return false; } + function visibleNiriFullscreenToplevelOnScreen(screenOrName) { + if (!isNiri || !NiriService.hasInitialConnection) + return false; + return NiriFullscreen.isFullscreenOnScreen(_screenName(screenOrName), NiriService.allWorkspaces, NiriService.windows, ToplevelManager.toplevels?.values); + } + function filterCurrentDisplay(toplevels, screenName) { if (!toplevels || toplevels.length === 0 || !screenName) return toplevels; diff --git a/quickshell/Services/NiriFullscreen.js b/quickshell/Services/NiriFullscreen.js new file mode 100644 index 000000000..2b05326fd --- /dev/null +++ b/quickshell/Services/NiriFullscreen.js @@ -0,0 +1,34 @@ +.pragma library + +function isFullscreenOnScreen(screenName, workspaces, windows, toplevels) { + if (!screenName || !workspaces || !windows || !toplevels) + return false; + + const workspace = workspaces.find(ws => ws.output === screenName && ws.is_active); + if (!workspace || workspace.active_window_id === null || workspace.active_window_id === undefined) + return false; + + const window = windows.find(w => w.id === workspace.active_window_id && w.workspace_id === workspace.id); + if (!window || !window.app_id) + return false; + + const matches = toplevels.filter(t => t && t.appId === window.app_id + && (t.title || "") === (window.title || "") + && t.screens?.some(screen => screen.name === screenName)); + + if (window.is_focused) { + const focused = matches.filter(t => t.activated); + if (focused.length === 1) + return !!focused[0].fullscreen; + } + + // Niri IPC and wlr-foreign-toplevel have no shared window identifier. + // Do not guess when equal titles on the same output make the match ambiguous. + const workspaceIds = new Set(workspaces.filter(ws => ws.output === screenName).map(ws => ws.id)); + const sameWindows = windows.filter(w => workspaceIds.has(w.workspace_id) + && w.app_id === window.app_id && (w.title || "") === (window.title || "")); + if (sameWindows.length !== 1 || matches.length !== 1) + return false; + + return !!matches[0].fullscreen; +} diff --git a/quickshell/Services/NiriService.qml b/quickshell/Services/NiriService.qml index f6ed40744..d727761d0 100644 --- a/quickshell/Services/NiriService.qml +++ b/quickshell/Services/NiriService.qml @@ -477,7 +477,7 @@ Singleton { for (const ws of data.workspaces) { const oldWs = root.workspaces[ws.id]; newWorkspaces[ws.id] = ws; - if (oldWs && oldWs.active_window_id !== undefined) { + if (ws.active_window_id === undefined && oldWs && oldWs.active_window_id !== undefined) { newWorkspaces[ws.id].active_window_id = oldWs.active_window_id; } } @@ -582,14 +582,18 @@ Singleton { } function handleWorkspaceActiveWindowChanged(data) { - if (root.workspaces[data.workspace_id]) { - if (data.active_window_id !== null && data.active_window_id !== undefined) + const ws = root.workspaces[data.workspace_id]; + if (ws) { + if (ws.is_focused && data.active_window_id !== null && data.active_window_id !== undefined) lastFocusedWindowId = data.active_window_id; updateWorkspace(data.workspace_id, { "active_window_id": data.active_window_id }); } + if (!ws?.is_focused) + return; + let changed = false; const updatedWindows = new Array(windows.length); diff --git a/quickshell/tests/bar-ipc.test.mjs b/quickshell/tests/bar-ipc.test.mjs index 1fa644d0b..31d68bdd6 100644 --- a/quickshell/tests/bar-ipc.test.mjs +++ b/quickshell/tests/bar-ipc.test.mjs @@ -8,7 +8,7 @@ const barBlock = source.slice(source.indexOf(" function getBarConfig("), sour const functions = [...barBlock.matchAll(/^ {4,8}function (\w+)\(([^)]*)\)(?:: \w+)? \{\n[\s\S]*?^\s{4,8}\}$/gm)]; const untyped = functions.map(match => match[0].replace(/\((.*?)\)(?:: \w+)? \{/, (_, params) => "(" + params.replace(/: \w+/g, "") + ") {")); -function shell(barConfigs, toggleResults = []) { +function shell(barConfigs, toggleResults = [], barItems = {}) { const updates = []; const reveals = []; const SettingsData = { @@ -22,7 +22,8 @@ function shell(barConfigs, toggleResults = []) { return toggleResults.shift(); } }; - const context = vm.createContext({ SettingsData }); + const BarWidgetService = { dankBarItems: barItems }; + const context = vm.createContext({ SettingsData, BarWidgetService }); for (const body of untyped) vm.runInContext(body.replace(/^ {4}/gm, ""), context); return { context, updates, reveals }; @@ -75,6 +76,10 @@ test("toggleReveal only works on auto hide bars and unhides first", () => { assert.equal(context.toggleReveal("id", "hidden"), "BAR_TUCK_SUCCESS"); assert.deepEqual(reveals.slice(1), [["toggle", "hidden"], ["toggle", "hidden"]]); bars[2].visible = false; + + const fullscreen = shell(bars, [true], { main: { fullscreenAutoHide: true } }); + assert.equal(fullscreen.context.toggleReveal("id", "main"), "BAR_REVEAL_SUCCESS"); + assert.deepEqual(fullscreen.reveals, [["toggle", "main"]]); }); const ipcBarBody = source.slice(source.indexOf(" function getPreferredBar("), source.indexOf(" readonly property var defaultAppMimeTypes")); diff --git a/quickshell/tests/niri-workspace-state.test.mjs b/quickshell/tests/niri-workspace-state.test.mjs new file mode 100644 index 000000000..b7415f200 --- /dev/null +++ b/quickshell/tests/niri-workspace-state.test.mjs @@ -0,0 +1,68 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import vm from 'node:vm'; +import test from 'node:test'; + +const source = readFileSync(new URL('../Services/NiriService.qml', import.meta.url), 'utf8'); + +function service() { + const context = vm.createContext({ + workspaces: { + 1: { id: 1, idx: 1, output: 'A', is_active: true, is_focused: true, active_window_id: 10 }, + 2: { id: 2, idx: 1, output: 'B', is_active: true, is_focused: false, active_window_id: 20 } + }, + windows: [ + { id: 10, workspace_id: 1, is_focused: true }, + { id: 11, workspace_id: 1, is_focused: false }, + { id: 20, workspace_id: 2, is_focused: false }, + { id: 21, workspace_id: 2, is_focused: false } + ], + allWorkspaces: [], + lastFocusedWindowId: 10, + updateCurrentOutputWorkspaces() {} + }); + context.root = context; + // Execute the actual JS event handlers with their QML property storage mocked. + for (const name of ['setWorkspaces', 'updateWorkspace', 'handleWorkspacesChanged', 'handleWindowFocusChanged', 'handleWorkspaceActiveWindowChanged']) { + const match = source.match(new RegExp(` function ${name}\\([^]*?(?=\\n function )`)); + assert.ok(match, `event handler ${name} exists`); + vm.runInContext(match[0], context); + } + return context; +} + +test('numeric focus IDs update string-keyed workspace storage', () => { + const s = service(); + s.handleWindowFocusChanged({ id: 11 }); + assert.equal(s.workspaces[1].active_window_id, 11); + assert.equal(s.allWorkspaces.find(ws => ws.id === 1).active_window_id, 11); + assert.equal(s.windows.find(w => w.id === 11).is_focused, true); + assert.equal(s.workspaces[2].active_window_id, 20); +}); + +test('active-window events update the correct workspace without stealing focus', () => { + const s = service(); + s.handleWorkspaceActiveWindowChanged({ workspace_id: 2, active_window_id: 21 }); + assert.equal(s.workspaces[2].active_window_id, 21); + assert.equal(s.windows.find(w => w.id === 10).is_focused, true); + assert.equal(s.windows.find(w => w.id === 21).is_focused, false); + assert.equal(s.lastFocusedWindowId, 10); +}); + +test('focused workspace active-window events preserve normal focus updates', () => { + const s = service(); + s.handleWorkspaceActiveWindowChanged({ workspace_id: 1, active_window_id: 11 }); + assert.equal(s.workspaces[1].active_window_id, 11); + assert.equal(s.windows.find(w => w.id === 11).is_focused, true); + assert.equal(s.lastFocusedWindowId, 11); +}); + +test('fresh workspace snapshots replace stale active-window IDs, including null', () => { + const s = service(); + s.handleWorkspacesChanged({ workspaces: [ + { ...s.workspaces[1], active_window_id: 11 }, + { ...s.workspaces[2], active_window_id: null } + ] }); + assert.equal(s.workspaces[1].active_window_id, 11); + assert.equal(s.workspaces[2].active_window_id, null); +}); diff --git a/quickshell/translations/settings_search_index.json b/quickshell/translations/settings_search_index.json index 7bede4694..a7a807f6e 100644 --- a/quickshell/translations/settings_search_index.json +++ b/quickshell/translations/settings_search_index.json @@ -3794,6 +3794,39 @@ "icon": "border_outer", "conditionKey": "frameEnabled" }, + { + "section": "barHideOnFullscreen", + "label": "Hide bar on fullscreen", + "tabIndex": 3, + "category": "General", + "keywords": [ + "app", + "applications", + "apps", + "away", + "bar", + "edge", + "fullscreen", + "general", + "hide", + "move", + "over", + "panel", + "pointer", + "program", + "programs", + "reveal", + "screen", + "slide", + "statusbar", + "taskbar", + "topbar", + "visibility" + ], + "parentLabel": "Bar", + "description": "Slide the bar away over fullscreen applications. Move the pointer to the screen edge to reveal it.", + "conditionKey": "isNiri" + }, { "section": "islandHoverCloseDelay", "label": "Hide delay",