quickshell: 2x rounding again, card padding, session menu as a dropdown

Radii doubled a second time (32/24/16).

Dropdown panels were +16 on the cross axis while the shader insets 8
top and bottom, leaving the panel edge flush with the content (0
padding) against a 4px overhang on the growth axis. Now +24 with the
content offset 12, giving 4px above and below to match the right.

Session menu becomes a real BarDropdown instead of its own surface:
it shares the morphing chrome with the other bar menus, so switching
between it and e.g. volume glides rather than cross-fading. Drops its
dedicated SDF surface, shader uniform and mask region, and the power
button gains the same hover-to-switch the other widgets have. Moved
beside volDropdown so it sits after the component declaration.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
rope 2026-07-28 13:50:27 +01:00
parent 8c549e1422
commit a36a3f35b7

View file

@ -68,7 +68,6 @@ in
vec4 cutout; // cx, cy, hw, hh rounded inner screen cutout vec4 cutout; // cx, cy, hw, hh rounded inner screen cutout
vec4 panel; // cx, cy, hw, hh dropdown panel (hw <= 0: none) vec4 panel; // cx, cy, hw, hh dropdown panel (hw <= 0: none)
vec4 toast; // cx, cy, hw, hh toast (hw <= 0: none) vec4 toast; // cx, cy, hw, hh toast (hw <= 0: none)
vec4 session; // cx, cy, hw, hh session menu (hw <= 0: none)
vec4 launcher; // cx, cy, hw, hh bottom launcher (hw <= 0: none) vec4 launcher; // cx, cy, hw, hh bottom launcher (hw <= 0: none)
vec4 fillColor; // straight (non-premultiplied) rgba vec4 fillColor; // straight (non-premultiplied) rgba
vec4 borderColor; vec4 borderColor;
@ -100,8 +99,6 @@ in
d = smin(d, sdRoundedBox(p, panel.xy, panel.zw, panelR), meltK); d = smin(d, sdRoundedBox(p, panel.xy, panel.zw, panelR), meltK);
if (toast.z > 0.5) if (toast.z > 0.5)
d = smin(d, sdRoundedBox(p, toast.xy, toast.zw, panelR), meltK); d = smin(d, sdRoundedBox(p, toast.xy, toast.zw, panelR), meltK);
if (session.z > 0.5)
d = smin(d, sdRoundedBox(p, session.xy, session.zw, panelR), meltK);
if (launcher.z > 0.5) if (launcher.z > 0.5)
d = smin(d, sdRoundedBox(p, launcher.xy, launcher.zw, panelR), meltK); d = smin(d, sdRoundedBox(p, launcher.xy, launcher.zw, panelR), meltK);
@ -183,9 +180,9 @@ in
// here, not in ~15 places. Wide enough for the stacked HH/mm // here, not in ~15 places. Wide enough for the stacked HH/mm
// clock, which is what sets the floor. // clock, which is what sets the floor.
readonly property int barWidth: 46 readonly property int barWidth: 46
readonly property int radius: 16 // cards, panels, dropdowns readonly property int radius: 32 // cards, panels, dropdowns
readonly property int radiusSmall: 12 // workspace dots, pill buttons readonly property int radiusSmall: 24 // workspace dots, pill buttons
readonly property int radiusTiny: 8 // hover rows, action chips readonly property int radiusTiny: 16 // hover rows, action chips
readonly property int cardPad: 8 // card inner padding (inset = 2×) readonly property int cardPad: 8 // card inner padding (inset = 2×)
readonly property int panelGap: 8 // gap between panel border and cards (all dropdowns/menus/toast) readonly property int panelGap: 8 // gap between panel border and cards (all dropdowns/menus/toast)
@ -632,7 +629,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 || bar.activeDropdown !== null) && !bar.screenshotPinned ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.None WlrLayershell.keyboardFocus: (launcherPanel.open || bar.activeDropdown !== null) && !bar.screenshotPinned ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.None
// Vertical bar on the left edge. The surface still covers the // Vertical bar on the left edge. The surface still covers the
// whole screen (the frame band and every panel are drawn into // whole screen (the frame band and every panel are drawn into
@ -661,12 +658,6 @@ in
width: toastItem.visible ? toastItem.width : 0 width: toastItem.visible ? toastItem.width : 0
height: toastItem.visible ? toastItem.height : 0 height: toastItem.visible ? toastItem.height : 0
} }
Region {
x: sessionMenu.visible ? sessionMenu.x : 0
y: sessionMenu.visible ? sessionMenu.y : 0
width: sessionMenu.visible ? sessionMenu.width : 0
height: sessionMenu.visible ? sessionMenu.height : 0
}
Region { Region {
x: launcherPanel.visible ? launcherPanel.x : 0 x: launcherPanel.visible ? launcherPanel.x : 0
y: launcherPanel.visible ? launcherPanel.y : 0 y: launcherPanel.visible ? launcherPanel.y : 0
@ -691,7 +682,10 @@ in
} }
function toggleSession() { function toggleSession() {
sessionMenu.toggle(); bar.toggleDropdown(sessionMenu, function() {
let pos = powerWidget.mapToItem(bar.contentItem, 0, powerWidget.height / 2);
sessionMenu.dropdownY = pos.y;
});
} }
function toggleLauncher() { function toggleLauncher() {
@ -1000,12 +994,6 @@ in
? Qt.vector4d(surfLeftX + _toastRect.width / 2, (meltTop + toastBot) / 2, ? Qt.vector4d(surfLeftX + _toastRect.width / 2, (meltTop + toastBot) / 2,
4 + _toastRect.width / 2, (toastBot - meltTop) / 2) 4 + _toastRect.width / 2, (toastBot - meltTop) / 2)
: Qt.vector4d(0, 0, 0, 0) : Qt.vector4d(0, 0, 0, 0)
// Session menu sits in the bottom-left corner and melts
// down into the bottom frame band.
property vector4d session: sessionMenu.visible
? Qt.vector4d(surfLeftX + sessionMenu.width / 2, (sessionMenu.y + meltBot) / 2,
4 + sessionMenu.width / 2, (meltBot - sessionMenu.y) / 2)
: Qt.vector4d(0, 0, 0, 0)
// The launcher is the one surface that stays put: it still // The launcher is the one surface that stays put: it still
// grows from the bottom frame band at screen centre. // grows from the bottom frame band at screen centre.
readonly property real launchBot: bar.height - Theme.frameWidth + 4 readonly property real launchBot: bar.height - Theme.frameWidth + 4
@ -1023,142 +1011,6 @@ in
fragmentShader: "file://${chromeShader}" fragmentShader: "file://${chromeShader}"
} }
// Session menu: icon-only power controls morphing out of
// the bar at the bottom-left (Super+L, or the power button).
// Keyboard: arrows/Tab move the selection, Enter activates,
// Esc closes.
Item {
id: sessionMenu
property bool open: false
property int selIdx: 0
// Both axes animate so the panel expands from a small
// point on the column (like the top dropdowns' stub seed)
// instead of rolling out at full height.
property real openW: open ? sessionCard.width + 2 * Theme.panelGap : 0
property real openH: open ? sessionCard.height + 2 * Theme.panelGap : 36
Behavior on openW {
NumberAnimation { duration: Theme.animMorph; easing.type: Easing.OutExpo }
}
Behavior on openH {
NumberAnimation { duration: Theme.animMorph; easing.type: Easing.OutExpo }
}
readonly property var actions: [
{ icon: "lock", danger: false, act: "lock" },
{ icon: "logout", danger: false, act: "logout" },
{ icon: "restart_alt", danger: true, act: "reboot" },
{ icon: "power_settings_new", danger: true, act: "poweroff" }
]
function activate(act) {
open = false;
if (act === "lock") bar.shellRoot.lock();
else if (act === "logout") Hyprland.dispatch("hl.dsp.exit()");
else if (act === "reboot") Quickshell.execDetached([Commands.systemctl, "reboot"]);
else Quickshell.execDetached([Commands.systemctl, "poweroff"]);
}
function toggle() {
open = !open;
if (open) {
selIdx = 0;
forceActiveFocus();
}
}
// Unfolds rightward out of the bar at the bottom-left,
// level with the power button that opens it. Bottom edge
// sits on the frame's inner edge so the shader can melt
// the two together (meltBot) instead of leaving a seam.
x: Theme.barWidth
y: Math.round(bar.height - Theme.frameWidth - height)
width: openW
height: openH
visible: openW > 0.5
focus: open
Keys.onEscapePressed: open = false
Keys.onUpPressed: selIdx = (selIdx + actions.length - 1) % actions.length
Keys.onDownPressed: selIdx = (selIdx + 1) % actions.length
Keys.onTabPressed: selIdx = (selIdx + 1) % actions.length
Keys.onReturnPressed: activate(actions[selIdx].act)
Keys.onEnterPressed: activate(actions[selIdx].act)
// Content pinned to the column edge, revealed by the grow
Item {
anchors.fill: parent
clip: true
opacity: sessionMenu.open ? 1 : 0
Behavior on opacity {
NumberAnimation { duration: Theme.animContent; easing.type: Easing.OutCubic }
}
// Card backing, matching the other dropdowns
Rectangle {
id: sessionCard
anchors.verticalCenter: parent.verticalCenter
anchors.left: parent.left
anchors.leftMargin: Theme.panelGap
width: 48
height: sessionCol.height + 8
radius: Theme.radius
color: Theme.cardBg
// Sliding selection pill same tech as the power
// profile selector; glides between the buttons.
Rectangle {
width: 40
height: 40
radius: Theme.radius
color: Theme.base02
border.width: 1
border.color: Theme.base03
x: sessionCol.x
y: sessionCol.y + sessionMenu.selIdx * 44
Behavior on y {
NumberAnimation { duration: 250; easing.type: Easing.OutExpo }
}
}
Column {
id: sessionCol
anchors.centerIn: parent
spacing: 4
Repeater {
model: sessionMenu.actions
Item {
id: sessBtn
required property var modelData
required property int index
width: 40
height: 40
SIcon {
anchors.centerIn: parent
text: sessBtn.modelData.icon
// All four buttons share the logout
// setup: base05, FILL on selection.
color: Theme.base05
font.pixelSize: 20
font.weight: 600
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onEntered: sessionMenu.selIdx = sessBtn.index
onClicked: sessionMenu.activate(sessBtn.modelData.act)
}
}
}
}
}
}
}
// Launcher: rises out of the bottom frame edge (Super+R). // Launcher: rises out of the bottom frame edge (Super+R).
// Lives in the SDF field, so it melts into the frame and its // Lives in the SDF field, so it melts into the frame and its
// height morphs live as results filter. Results sit above the // height morphs live as results filter. Results sit above the
@ -1375,11 +1227,10 @@ in
// closes whatever is open. Suspended while screenshotPinned so // closes whatever is open. Suspended while screenshotPinned so
// slurp can grab input without dismissing the menu. // slurp can grab input without dismissing the menu.
HyprlandFocusGrab { HyprlandFocusGrab {
active: (sessionMenu.open || launcherPanel.open || bar.activeDropdown !== null) && !bar.screenshotPinned active: (launcherPanel.open || bar.activeDropdown !== null) && !bar.screenshotPinned
windows: [bar] windows: [bar]
onCleared: { onCleared: {
if (bar.screenshotPinned) return; if (bar.screenshotPinned) return;
sessionMenu.open = false;
launcherPanel.open = false; launcherPanel.open = false;
bar.closeAllDropdowns(); bar.closeAllDropdowns();
} }
@ -2044,6 +1895,11 @@ in
hoverEnabled: true hoverEnabled: true
cursorShape: Qt.PointingHandCursor cursorShape: Qt.PointingHandCursor
onClicked: bar.toggleSession() onClicked: bar.toggleSession()
onEntered: {
if (bar.activeDropdown) {
if (bar.activeDropdown !== sessionMenu) bar.toggleSession();
}
}
} }
} }
} }
@ -2099,7 +1955,11 @@ in
x: Theme.barWidth x: Theme.barWidth
y: Math.round(Math.max(yMin, Math.min(yMax, dropdownY - height / 2))) y: Math.round(Math.max(yMin, Math.min(yMax, dropdownY - height / 2)))
width: fullWidth + 4 width: fullWidth + 4
height: fullHeight + 16 // +24, not +16: the shader insets the panel 8px top and
// bottom, so +16 left the panel edge flush with the content
// (0 padding) while the growth axis overhung by 4. The
// extra 8 gives 4px above and below, matching the right.
height: fullHeight + 24
visible: false visible: false
onVisibleChanged: { onVisibleChanged: {
@ -2138,7 +1998,9 @@ in
Item { Item {
id: dropdownContent id: dropdownContent
y: 8 - _dropdownRect.y // 12 = panel inset (8) + the 4px breathing room,
// keeping the content centred in the taller panel.
y: 12 - _dropdownRect.y
width: dropdown.fullWidth width: dropdown.fullWidth
height: dropdown.fullHeight height: dropdown.fullHeight
} }
@ -2298,6 +2160,104 @@ in
} }
} }
// Session menu: icon-only power controls morphing out of
// the bar at the bottom-left (Super+L, or the power button).
// Keyboard: arrows/Tab move the selection, Enter activates,
// Esc closes.
BarDropdown {
id: sessionMenu
fullWidth: 48 + 2 * Theme.panelGap
fullHeight: sessionCard.height + 2 * Theme.panelGap
property int selIdx: 0
readonly property var actions: [
{ icon: "lock", danger: false, act: "lock" },
{ icon: "logout", danger: false, act: "logout" },
{ icon: "restart_alt", danger: true, act: "reboot" },
{ icon: "power_settings_new", danger: true, act: "poweroff" }
]
function activate(act) {
animateClose();
if (act === "lock") bar.shellRoot.lock();
else if (act === "logout") Hyprland.dispatch("hl.dsp.exit()");
else if (act === "reboot") Quickshell.execDetached([Commands.systemctl, "reboot"]);
else Quickshell.execDetached([Commands.systemctl, "poweroff"]);
}
focus: open
onOpenChanged: if (open) { selIdx = 0; forceActiveFocus(); }
Keys.onEscapePressed: bar.closeAllDropdowns()
Keys.onUpPressed: selIdx = (selIdx + actions.length - 1) % actions.length
Keys.onDownPressed: selIdx = (selIdx + 1) % actions.length
Keys.onTabPressed: selIdx = (selIdx + 1) % actions.length
Keys.onReturnPressed: activate(actions[selIdx].act)
Keys.onEnterPressed: activate(actions[selIdx].act)
// Card backing, matching the other dropdowns. The grow /
// clip reveal is BarDropdown's job now.
Rectangle {
id: sessionCard
anchors.centerIn: parent
width: 48
height: sessionCol.height + 8
radius: Theme.radius
color: Theme.cardBg
// Sliding selection pill same tech as the power
// profile selector; glides between the buttons.
Rectangle {
width: 40
height: 40
radius: Theme.radius
color: Theme.base02
border.width: 1
border.color: Theme.base03
x: sessionCol.x
y: sessionCol.y + sessionMenu.selIdx * 44
Behavior on y {
NumberAnimation { duration: 250; easing.type: Easing.OutExpo }
}
}
Column {
id: sessionCol
anchors.centerIn: parent
spacing: 4
Repeater {
model: sessionMenu.actions
Item {
id: sessBtn
required property var modelData
required property int index
width: 40
height: 40
SIcon {
anchors.centerIn: parent
text: sessBtn.modelData.icon
// All four buttons share the logout
// setup: base05, FILL on selection.
color: Theme.base05
font.pixelSize: 20
font.weight: 600
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onEntered: sessionMenu.selIdx = sessBtn.index
onClicked: sessionMenu.activate(sessBtn.modelData.act)
}
}
}
}
}
}
// Volume dropdown // Volume dropdown
BarDropdown { BarDropdown {
id: volDropdown id: volDropdown