dbar: animate hiding overlay bars on fullscreen in niri (#4000)

port 2.0
This commit is contained in:
StevenKerman authored and GitHub committed 2026-10-29 11:28:50 -04:00
1 parent 791537bf7b
commit 47f7f3d798
12 files changed
+192 -9

No files matched your search

@@ -1592,6 +1592,7 @@ var LOCAL_SPEC = {
popupGapsManual: 4,
maximizeDetection: true,
useOverlayLayer: false,
hideOnFullscreen: false,
scrollEnabled: true,
scrollXBehavior: "focusWindow",
scrollYBehavior: "workspace",
+1 -1
View File
@@ -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, {
+1
View File
@@ -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
+18 -3
View File
@@ -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();
}
+1
View File
@@ -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
+14
View File
@@ -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"]
@@ -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;
+34
View File
@@ -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;
}
+7 -3
View File
@@ -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);
+7 -2
View File
@@ -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"));
@@ -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);
});
@@ -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",