quickshell: click-outside dropdown dismissal, drop auto-close timers

Extend the launcher/session HyprlandFocusGrab to the bar dropdowns and
remove the per-dropdown inactivity timers. Shift+Super+S brackets hyprshot
with a screenshot pin so open menus survive slurp's input grab.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
rope 2026-06-17 14:19:10 +01:00
parent 215239e7aa
commit 83b4c5ef09
2 changed files with 36 additions and 40 deletions

View file

@ -281,7 +281,9 @@ in
end end
-- Screenshots Shift+Super+S matches GNOME binding -- Screenshots Shift+Super+S matches GNOME binding
hl.bind(mod .. " + SHIFT + S", hl.dsp.exec_cmd("hyprshot -m region --clipboard-only")) -- Pin/unpin quickshell's focus grab around the region select so an
-- open menu survives slurp's input grab (no-ops if qs isn't up).
hl.bind(mod .. " + SHIFT + S", hl.dsp.exec_cmd("sh -c 'qs ipc call screenshot pin; hyprshot -m region --clipboard-only; qs ipc call screenshot unpin'"))
hl.bind("Print", hl.dsp.exec_cmd("hyprshot -m output --clipboard-only")) hl.bind("Print", hl.dsp.exec_cmd("hyprshot -m output --clipboard-only"))
-- Settings shortcut Super+I matches GNOME binding -- Settings shortcut Super+I matches GNOME binding

View file

@ -238,6 +238,16 @@ in
function reload(): void { Quickshell.reload(false); } function reload(): void { Quickshell.reload(false); }
} }
// Screenshot pin: the Shift+Super+S keybind brackets hyprshot
// with pin/unpin so the focus grab is suspended while slurp
// grabs input otherwise the open menu would close like any
// click-outside. Self-heals via a watchdog if unpin is missed.
IpcHandler {
target: "screenshot"
function pin(): void { if (root.mainBar) root.mainBar.setScreenshotPin(true); }
function unpin(): void { if (root.mainBar) root.mainBar.setScreenshotPin(false); }
}
NotificationServer { NotificationServer {
id: _notifServer id: _notifServer
bodySupported: true bodySupported: true
@ -291,7 +301,7 @@ in
// (caelestia's): the grab redirects focus to this window and // (caelestia's): the grab redirects focus to this window and
// OnDemand lets the layer surface accept it. Exclusive fights // OnDemand lets the layer surface accept it. Exclusive fights
// the grab it self-clears and instantly closes the panel. // the grab it self-clears and instantly closes the panel.
WlrLayershell.keyboardFocus: sessionMenu.open || launcherPanel.open ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.None WlrLayershell.keyboardFocus: (sessionMenu.open || launcherPanel.open || bar.activeDropdown !== null) && !bar.screenshotPinned ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.None
anchors { anchors {
top: true top: true
@ -910,18 +920,38 @@ in
} }
} }
// Click-outside dismissal for the keyboard-grabbing panels // Click-outside dismissal for every panel the launcher,
// session menu AND the bar dropdowns. Clicking into another
// window (or anywhere outside the bar) clears the grab and
// closes whatever is open. Suspended while screenshotPinned so
// slurp can grab input without dismissing the menu.
HyprlandFocusGrab { HyprlandFocusGrab {
active: sessionMenu.open || launcherPanel.open active: (sessionMenu.open || launcherPanel.open || bar.activeDropdown !== null) && !bar.screenshotPinned
windows: [bar] windows: [bar]
onCleared: { onCleared: {
if (bar.screenshotPinned) return;
sessionMenu.open = false; sessionMenu.open = false;
launcherPanel.open = false; launcherPanel.open = false;
bar.closeAllDropdowns();
} }
} }
property var activeDropdown: null property var activeDropdown: null
// Set by the screenshot keybind (via IPC) to hold menus open
// while a region screenshot runs. Watchdog unpins if the
// bracketing unpin call is ever missed.
property bool screenshotPinned: false
Timer {
id: _pinWatchdog
interval: 30000
onTriggered: bar.screenshotPinned = false
}
function setScreenshotPin(v) {
screenshotPinned = v;
if (v) _pinWatchdog.restart(); else _pinWatchdog.stop();
}
function closeAllDropdowns() { function closeAllDropdowns() {
if (activeDropdown && activeDropdown.visible) { if (activeDropdown && activeDropdown.visible) {
activeDropdown.animateClose(); activeDropdown.animateClose();
@ -1024,7 +1054,6 @@ in
onEntered: { onEntered: {
if (bar.activeDropdown) { if (bar.activeDropdown) {
if (bar.activeDropdown !== calPopup) bar.toggleDropdown(calPopup, function() { calPopup.resetView(); }); if (bar.activeDropdown !== calPopup) bar.toggleDropdown(calPopup, function() { calPopup.resetView(); });
else bar.activeDropdown.resetAutoClose();
} }
} }
} }
@ -1104,7 +1133,6 @@ in
onEntered: { onEntered: {
if (bar.activeDropdown) { if (bar.activeDropdown) {
if (bar.activeDropdown !== volDropdown) volWidget.openVolDropdown(); if (bar.activeDropdown !== volDropdown) volWidget.openVolDropdown();
else bar.activeDropdown.resetAutoClose();
} }
} }
} }
@ -1271,7 +1299,6 @@ in
onEntered: { onEntered: {
if (bar.activeDropdown) { if (bar.activeDropdown) {
if (bar.activeDropdown !== netDropdown) netWidget.openNetDropdown(); if (bar.activeDropdown !== netDropdown) netWidget.openNetDropdown();
else bar.activeDropdown.resetAutoClose();
} }
} }
} }
@ -1352,7 +1379,6 @@ in
onEntered: { onEntered: {
if (bar.activeDropdown) { if (bar.activeDropdown) {
if (bar.activeDropdown !== batteryDropdown) batteryWidget.openBatteryDropdown(); if (bar.activeDropdown !== batteryDropdown) batteryWidget.openBatteryDropdown();
else bar.activeDropdown.resetAutoClose();
} }
} }
} }
@ -1366,12 +1392,6 @@ in
height: Theme.barHeight height: Theme.barHeight
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
HoverHandler {
onHoveredChanged: {
if (hovered && bar.activeDropdown) bar.activeDropdown.resetAutoClose();
}
}
Repeater { Repeater {
model: SystemTray.items model: SystemTray.items
@ -1405,7 +1425,6 @@ in
acceptedButtons: Qt.NoButton acceptedButtons: Qt.NoButton
onEntered: { onEntered: {
if (bar.activeDropdown) { if (bar.activeDropdown) {
bar.activeDropdown.resetAutoClose();
if (modelData.hasMenu && !(bar.activeDropdown === contextMenu && contextMenu.trayItem === modelData)) { if (modelData.hasMenu && !(bar.activeDropdown === contextMenu && contextMenu.trayItem === modelData)) {
if (bar.activeDropdown === contextMenu) { if (bar.activeDropdown === contextMenu) {
// Same dropdown, just switch content // Same dropdown, just switch content
@ -1413,7 +1432,6 @@ in
contextMenu.dropdownX = pos.x; contextMenu.dropdownX = pos.x;
contextMenu.trayItem = modelData; contextMenu.trayItem = modelData;
menuOpener.menu = modelData.menu; menuOpener.menu = modelData.menu;
contextMenu.resetAutoClose();
} else { } else {
bar.toggleDropdown(contextMenu, function() { bar.toggleDropdown(contextMenu, function() {
let pos = parent.mapToItem(bar.contentItem, parent.width / 2, 0); let pos = parent.mapToItem(bar.contentItem, parent.width / 2, 0);
@ -1457,7 +1475,6 @@ in
property real dropdownX: 0 property real dropdownX: 0
property real fullWidth: 200 property real fullWidth: 200
property real fullHeight: 200 property real fullHeight: 200
property int autoCloseMs: 1500
// Flush-right dropdowns merge into the screen frame's // Flush-right dropdowns merge into the screen frame's
// right column instead of centering on their widget. // right column instead of centering on their widget.
property bool alignRight: false property bool alignRight: false
@ -1477,14 +1494,9 @@ in
bar.activeDropdown = null; bar.activeDropdown = null;
chrome.shrinkToButton(dropdown); chrome.shrinkToButton(dropdown);
} }
_autoClose.stop();
_closeDelay.start(); _closeDelay.start();
} }
function resetAutoClose() {
if (visible && !closing) _autoClose.restart();
}
// Reopen a dropdown that's mid-close: the pending hide // Reopen a dropdown that's mid-close: the pending hide
// timer must be cancelled, otherwise it fires later and // timer must be cancelled, otherwise it fires later and
// closes the revived dropdown (and the whole chrome). // closes the revived dropdown (and the whole chrome).
@ -1492,7 +1504,6 @@ in
_closeDelay.stop(); _closeDelay.stop();
closing = false; closing = false;
open = true; open = true;
_autoClose.restart();
} }
x: alignRight x: alignRight
@ -1507,33 +1518,18 @@ in
if (visible) { if (visible) {
closing = false; closing = false;
open = true; open = true;
_autoClose.restart();
} else { } else {
open = false; open = false;
closing = false; closing = false;
_autoClose.stop();
} }
} }
Timer {
id: _autoClose
interval: dropdown.autoCloseMs
onTriggered: bar.closeAllDropdowns()
}
Timer { Timer {
id: _closeDelay id: _closeDelay
interval: 300 interval: 300
onTriggered: { dropdown.visible = false; dropdown.closing = false; if (bar.activeDropdown === dropdown) bar.activeDropdown = null; } onTriggered: { dropdown.visible = false; dropdown.closing = false; if (bar.activeDropdown === dropdown) bar.activeDropdown = null; }
} }
HoverHandler {
onHoveredChanged: {
if (hovered) _autoClose.stop();
else _autoClose.restart();
}
}
// Content is clipped to the chrome's ANIMATED geometry // Content is clipped to the chrome's ANIMATED geometry
// revealed as the panel slides/grows over it and wiped as // revealed as the panel slides/grows over it and wiped as
// the panel leaves, instead of popping in place. The inner // the panel leaves, instead of popping in place. The inner
@ -1731,7 +1727,6 @@ in
alignRight: true alignRight: true
fullWidth: volDropdownCol.width + 28 fullWidth: volDropdownCol.width + 28
fullHeight: volDropdownCol.height + 20 fullHeight: volDropdownCol.height + 20
autoCloseMs: 3000
Column { Column {
id: volDropdownCol id: volDropdownCol
@ -2165,7 +2160,6 @@ in
// edges render as soft 2px lines // edges render as soft 2px lines
fullWidth: Math.ceil(calRow.width) + 24 fullWidth: Math.ceil(calRow.width) + 24
fullHeight: Math.ceil(calRow.height) + 24 fullHeight: Math.ceil(calRow.height) + 24
autoCloseMs: 3000
// Month being viewed; reset to today when the popup opens // Month being viewed; reset to today when the popup opens
// (via the setup function passed to bar.toggleDropdown). // (via the setup function passed to bar.toggleDropdown).