quickshell: vertical left bar, 2x rounding, left-aligned surfaces

Bar moves from a horizontal top band to a vertical left column
(Theme.barHeight -> barWidth, 46px for the stacked HH/mm clock).
Every panel now grows rightward out of the bar instead of down:
the dropdown chrome's morph axis, the SDF surface geometry and the
session menu all swap x/y. The launcher is the one surface left at
bottom-centre, as asked.

New: NixOS snowflake at the top (recoloured to the accent with the
tray's hidden-Image + ColorOverlay trick), vertical workspace dots
whose focused pill grows on height, an inline OSD slot in the gap
between the two clusters, and a power button in the bottom-left
that opens the session menu.

Dropped the flush-to-far-edge dropdown special case — clamped
tracking plus the SDF melt already covers it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
rope 2026-07-28 13:09:49 +01:00
parent efd1331823
commit 33b729c560
2 changed files with 354 additions and 294 deletions

View file

@ -104,8 +104,8 @@ in
# 6px of the outer gap is occupied by the quickshell screen # 6px of the outer gap is occupied by the quickshell screen
# frame (Theme.frameWidth) — 18 on framed sides keeps the # frame (Theme.frameWidth) — 18 on framed sides keeps the
# visible frame↔window gap equal to the inter-window gap (12). # visible frame↔window gap equal to the inter-window gap (12).
# Top stays 12: the bar reserves its own space. # Left stays 12: the bar lives there and reserves its own space.
gaps_out = { top = 12; right = 18; bottom = 18; left = 18; }; gaps_out = { top = 18; right = 18; bottom = 18; left = 12; };
border_size = 2; border_size = 2;
layout = "dwindle"; layout = "dwindle";
resize_on_border = true; resize_on_border = true;

View file

@ -70,7 +70,6 @@ in
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 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 osd; // cx, cy, hw, hh volume/brightness OSD (hw <= 0: none)
vec4 fillColor; // straight (non-premultiplied) rgba vec4 fillColor; // straight (non-premultiplied) rgba
vec4 borderColor; vec4 borderColor;
vec2 res; vec2 res;
@ -105,8 +104,6 @@ in
d = smin(d, sdRoundedBox(p, session.xy, session.zw, panelR), meltK); 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);
if (osd.z > 0.5)
d = smin(d, sdRoundedBox(p, osd.xy, osd.zw, panelR), meltK);
float fw = fwidth(d); float fw = fwidth(d);
// 1 inside the union, 0 outside (antialiased) // 1 inside the union, 0 outside (antialiased)
@ -180,12 +177,15 @@ in
readonly property int frameWidth: 6 readonly property int frameWidth: 6
// Layout / metric tokens (referenced, not hardcoded) // Layout / metric tokens (referenced, not hardcoded)
// Bar band height. Drives widget heights, dropdown y-origin and // Bar band thickness. The bar is a VERTICAL column on the left
// the shader cutout geometry change here, not in ~10 places. // edge, so this is its width: it drives widget cell widths, the
readonly property int barHeight: 30 // dropdown x-origin and the shader cutout geometry change
readonly property int radius: 8 // cards, panels, dropdowns // here, not in ~15 places. Wide enough for the stacked HH/mm
readonly property int radiusSmall: 6 // workspace dots, pill buttons // clock, which is what sets the floor.
readonly property int radiusTiny: 4 // hover rows, action chips readonly property int barWidth: 46
readonly property int radius: 16 // cards, panels, dropdowns
readonly property int radiusSmall: 12 // workspace dots, pill buttons
readonly property int radiusTiny: 8 // 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)
@ -409,6 +409,9 @@ in
readonly property string notifSound: "${pkgs.libcanberra-gtk3}/bin/canberra-gtk-play" readonly property string notifSound: "${pkgs.libcanberra-gtk3}/bin/canberra-gtk-play"
readonly property string systemctl: "${pkgs.systemd}/bin/systemctl" readonly property string systemctl: "${pkgs.systemd}/bin/systemctl"
readonly property string weatherFetch: "${weatherFetchScript}" readonly property string weatherFetch: "${weatherFetchScript}"
// Recoloured to the theme accent at draw time (ColorOverlay),
// so the stock blue/grey snowflake artwork is fine here.
readonly property string nixLogo: "${pkgs.nixos-icons}/share/icons/hicolor/scalable/apps/nix-snowflake.svg"
} }
''; '';
}; };
@ -631,14 +634,17 @@ in
// 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: (sessionMenu.open || launcherPanel.open || bar.activeDropdown !== null) && !bar.screenshotPinned ? WlrKeyboardFocus.OnDemand : WlrKeyboardFocus.None
// Vertical bar on the left edge. The surface still covers the
// whole screen (the frame band and every panel are drawn into
// it); only the reserved strip is the bar itself.
anchors { anchors {
top: true top: true
left: true left: true
right: true bottom: true
} }
implicitHeight: bar.screen.height implicitWidth: bar.screen.width
exclusiveZone: Theme.barHeight exclusiveZone: Theme.barWidth
color: "transparent" color: "transparent"
mask: Region { mask: Region {
@ -673,8 +679,8 @@ in
id: barBgRect id: barBgRect
anchors.top: parent.top anchors.top: parent.top
anchors.left: parent.left anchors.left: parent.left
anchors.right: parent.right anchors.bottom: parent.bottom
height: Theme.barHeight width: Theme.barWidth
} }
// Register the primary bar so shell.qml's IPC handler can // Register the primary bar so shell.qml's IPC handler can
@ -959,39 +965,38 @@ in
// ears / border-gaps / melt geometry lives in the math now. // ears / border-gaps / melt geometry lives in the math now.
ShaderEffect { ShaderEffect {
anchors.fill: parent anchors.fill: parent
readonly property real panelLeft: chrome.x + 8 // Panels grow rightward out of the bar column, so the
readonly property real panelRight: chrome.x + chrome.width + (chrome.flushRight ? 4 : -8) // cross-axis is now vertical: the 8px inset that used to
// The panel/toast centre-y uses (barHeight 4): the // be left/right is top/bottom.
// surface overlaps 4px up into the bar so they melt. readonly property real panelTop: chrome.y + 8
readonly property real surfTopY: Theme.barHeight - 4 readonly property real panelBottom: chrome.y + chrome.height - 8
// The panel/toast/session centre-x uses (barWidth 4): the
// surface overlaps 4px into the bar so the two melt.
readonly property real surfLeftX: Theme.barWidth - 4
property vector4d cutout: Qt.vector4d( property vector4d cutout: Qt.vector4d(
bar.width / 2, (Theme.barWidth + bar.width - Theme.frameWidth) / 2,
(Theme.barHeight + bar.height - Theme.frameWidth) / 2, bar.height / 2,
bar.width / 2 - Theme.frameWidth, (bar.width - Theme.frameWidth - Theme.barWidth) / 2,
(bar.height - Theme.frameWidth - Theme.barHeight) / 2) bar.height / 2 - Theme.frameWidth)
property vector4d panel: chrome.visible property vector4d panel: chrome.visible
? Qt.vector4d((panelLeft + panelRight) / 2, surfTopY + chrome.height / 2, ? Qt.vector4d(surfLeftX + chrome.width / 2, (panelTop + panelBottom) / 2,
(panelRight - panelLeft) / 2, 4 + chrome.height / 2) 4 + chrome.width / 2, (panelBottom - panelTop) / 2)
: Qt.vector4d(0, 0, 0, 0) : Qt.vector4d(0, 0, 0, 0)
property vector4d toast: toastItem.visible && _toastRect.height > 0.5 property vector4d toast: toastItem.visible && _toastRect.width > 0.5
? Qt.vector4d(toastItem.x + 8 + _toastRect.width / 2, surfTopY + _toastRect.height / 2, ? Qt.vector4d(surfLeftX + _toastRect.width / 2, toastItem.y + 8 + _toastRect.height / 2,
_toastRect.width / 2, 4 + _toastRect.height / 2) 4 + _toastRect.width / 2, _toastRect.height / 2)
: Qt.vector4d(0, 0, 0, 0) : Qt.vector4d(0, 0, 0, 0)
readonly property real sessRight: bar.width - Theme.frameWidth + 4
property vector4d session: sessionMenu.visible property vector4d session: sessionMenu.visible
? Qt.vector4d((sessionMenu.x + sessRight) / 2, sessionMenu.y + sessionMenu.height / 2, ? Qt.vector4d(surfLeftX + sessionMenu.width / 2, sessionMenu.y + sessionMenu.height / 2,
(sessRight - sessionMenu.x) / 2, sessionMenu.height / 2) 4 + sessionMenu.width / 2, sessionMenu.height / 2)
: Qt.vector4d(0, 0, 0, 0) : Qt.vector4d(0, 0, 0, 0)
// The launcher is the one surface that stays put: it still
// 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
property vector4d launcher: launcherPanel.visible property vector4d launcher: launcherPanel.visible
? Qt.vector4d(launcherPanel.x + launcherPanel.width / 2, (launcherPanel.y + launchBot) / 2, ? Qt.vector4d(launcherPanel.x + launcherPanel.width / 2, (launcherPanel.y + launchBot) / 2,
launcherPanel.width / 2, (launchBot - launcherPanel.y) / 2) launcherPanel.width / 2, (launchBot - launcherPanel.y) / 2)
: Qt.vector4d(0, 0, 0, 0) : Qt.vector4d(0, 0, 0, 0)
// OSD shares the launcher's bottom melt line.
property vector4d osd: osdItem.visible
? Qt.vector4d(osdItem.x + osdItem.width / 2, (osdItem.y + launchBot) / 2,
osdItem.width / 2, (launchBot - osdItem.y) / 2)
: Qt.vector4d(0, 0, 0, 0)
property vector4d fillColor: Qt.vector4d(Theme.barBg.r, Theme.barBg.g, Theme.barBg.b, Theme.barBg.a) property vector4d fillColor: Qt.vector4d(Theme.barBg.r, Theme.barBg.g, Theme.barBg.b, Theme.barBg.a)
property vector4d borderColor: Qt.vector4d(Theme.base03.r, Theme.base03.g, Theme.base03.b, 1) property vector4d borderColor: Qt.vector4d(Theme.base03.r, Theme.base03.g, Theme.base03.b, 1)
property vector2d res: Qt.vector2d(width, height) property vector2d res: Qt.vector2d(width, height)
@ -1003,8 +1008,9 @@ in
} }
// Session menu: icon-only power controls morphing out of // Session menu: icon-only power controls morphing out of
// the right frame column at screen centre (Super+L). Keyboard: // the bar at the bottom-left (Super+L, or the power button).
// arrows/Tab move the selection, Enter activates, Esc closes. // Keyboard: arrows/Tab move the selection, Enter activates,
// Esc closes.
Item { Item {
id: sessionMenu id: sessionMenu
property bool open: false property bool open: false
@ -1044,8 +1050,10 @@ in
} }
} }
x: bar.width - Theme.frameWidth - openW // Unfolds rightward out of the bar at the bottom-left,
y: Math.round((bar.height - height) / 2) // level with the power button that opens it.
x: Theme.barWidth
y: Math.round(bar.height - Theme.frameWidth - height - 8)
width: openW width: openW
height: openH height: openH
visible: openW > 0.5 visible: openW > 0.5
@ -1071,8 +1079,8 @@ in
Rectangle { Rectangle {
id: sessionCard id: sessionCard
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
anchors.right: parent.right anchors.left: parent.left
anchors.rightMargin: Theme.panelGap anchors.leftMargin: Theme.panelGap
width: 48 width: 48
height: sessionCol.height + 8 height: sessionCol.height + 8
radius: Theme.radius radius: Theme.radius
@ -1417,17 +1425,98 @@ in
} }
} }
// Left workspace dots: accent pill for the focused // SystemClock ticks on the minute boundary instead of a 1 Hz
// workspace, dim dots otherwise. All colours from Theme // Timer; the calendar reads clockText.now too.
// (stylix); the pill matches hyprland's active border accent. SystemClock {
Row { id: sysClock
anchors.left: parent.left precision: SystemClock.Minutes
// Corner symmetry: the dots sit 12px from the screen's }
// top edge (centered in the 30px bar), so the first dot's
// VISIBLE edge sits 12px from the left edge too. Cells // Top cluster: logo, clock, workspaces. The bar is a column
// pad their dots by 3px, hence the -3. // now, so the two clusters anchor to opposite ends and the gap
anchors.leftMargin: 12 - 3 // between them is the OSD slot.
anchors.verticalCenter: barBgRect.verticalCenter Column {
id: barTop
anchors.top: parent.top
anchors.topMargin: 12
anchors.horizontalCenter: barBgRect.horizontalCenter
spacing: 12
// NixOS snowflake, flattened to the theme accent with the
// same hidden-Image + ColorOverlay trick the tray uses
// (there is no Material Symbols glyph for it).
Item {
width: Theme.barWidth
height: 24
Image {
id: nixLogoImg
anchors.centerIn: parent
width: 22
height: 22
source: "file://" + Commands.nixLogo
sourceSize.width: 22
sourceSize.height: 22
smooth: true
visible: false
}
ColorOverlay {
anchors.fill: nixLogoImg
source: nixLogoImg
color: Theme.base0D
}
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: bar.toggleLauncher()
}
}
// Clock HH over mm; "HH:mm" does not fit a 46px column.
Item {
id: clockText
width: Theme.barWidth
height: clockCol.height
property date now: sysClock.date
Column {
id: clockCol
anchors.horizontalCenter: parent.horizontalCenter
spacing: 0
SText {
anchors.horizontalCenter: parent.horizontalCenter
text: clockText.now.toLocaleTimeString(Qt.locale(), "HH")
font.pixelSize: 15
font.weight: Font.Medium
}
SText {
anchors.horizontalCenter: parent.horizontalCenter
text: clockText.now.toLocaleTimeString(Qt.locale(), "mm")
font.pixelSize: 15
font.weight: Font.Medium
color: Theme.base04
}
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
onClicked: bar.toggleDropdown(calPopup, function() { calPopup.resetView(); })
onEntered: {
if (bar.activeDropdown) {
if (bar.activeDropdown !== calPopup) bar.toggleDropdown(calPopup, function() { calPopup.resetView(); });
}
}
}
}
// Workspace dots: accent pill for the focused workspace,
// dim dots otherwise. Stacked vertically now, so the
// focused pill grows along HEIGHT rather than width.
Column {
spacing: 4 spacing: 4
Repeater { Repeater {
@ -1437,18 +1526,18 @@ in
id: wsItem id: wsItem
required property var modelData required property var modelData
visible: modelData.id > 0 visible: modelData.id > 0
width: visible ? dot.width + 6 : 0 width: Theme.barWidth
height: Theme.barHeight height: visible ? dot.height + 6 : 0
Rectangle { Rectangle {
id: dot id: dot
anchors.centerIn: parent anchors.centerIn: parent
width: wsItem.modelData.focused ? 18 : 6 width: 6
height: 6 height: wsItem.modelData.focused ? 18 : 6
radius: 3 radius: 3
color: wsItem.modelData.focused ? Theme.base0D color: wsItem.modelData.focused ? Theme.base0D
: wsMa.containsMouse ? Theme.base04 : Theme.base03 : wsMa.containsMouse ? Theme.base04 : Theme.base03
Behavior on width { Behavior on height {
NumberAnimation { duration: 200; easing.type: Easing.OutExpo } NumberAnimation { duration: 200; easing.type: Easing.OutExpo }
} }
Behavior on color { ColorAnimation { duration: Theme.animFade } } Behavior on color { ColorAnimation { duration: Theme.animFade } }
@ -1463,53 +1552,23 @@ in
} }
} }
} }
// Center clock. SystemClock ticks on the minute boundary
// instead of a 1 Hz Timer; the calendar reads clockText.now too.
SystemClock {
id: sysClock
precision: SystemClock.Minutes
} }
SText { // Bottom cluster: status icons, tray, power button. Anchored
id: clockText // to the bottom edge so the gap above it between here and the
anchors.horizontalCenter: parent.horizontalCenter // workspace dots stays free as the OSD slot.
anchors.verticalCenter: barBgRect.verticalCenter Column {
property date now: sysClock.date id: barBottom
text: now.toLocaleTimeString(Qt.locale(), "HH:mm") anchors.bottom: parent.bottom
color: Theme.base05 anchors.bottomMargin: 12
font.pixelSize: 13 anchors.horizontalCenter: barBgRect.horizontalCenter
font.weight: Font.Medium
MouseArea {
anchors.fill: parent
hoverEnabled: true
onClicked: bar.toggleDropdown(calPopup, function() { calPopup.resetView(); })
onEntered: {
if (bar.activeDropdown) {
if (bar.activeDropdown !== calPopup) bar.toggleDropdown(calPopup, function() { calPopup.resetView(); });
}
}
}
}
// Right network, battery, tray
Row {
anchors.right: parent.right
// Corner symmetry like the dots: last tray icon's VISIBLE
// edge 12px from the right screen edge; tray cells pad
// their 16px icons by 4px, hence the -4. The extra -2
// optically compensates for transparent padding baked
// into typical tray icon artwork.
anchors.rightMargin: 12 - 4 - 2
anchors.verticalCenter: barBgRect.verticalCenter
spacing: 10 spacing: 10
// Volume // Volume
Item { Item {
id: volWidget id: volWidget
width: volRow.width width: Theme.barWidth
height: Theme.barHeight height: 26
property PwNode sink: Pipewire.defaultAudioSink property PwNode sink: Pipewire.defaultAudioSink
@ -1527,29 +1586,18 @@ in
function openVolDropdown() { function openVolDropdown() {
bar.toggleDropdown(volDropdown, function() { bar.toggleDropdown(volDropdown, function() {
let pos = volWidget.mapToItem(bar.contentItem, volWidget.width / 2, 0); let pos = volWidget.mapToItem(bar.contentItem, 0, volWidget.height / 2);
volDropdown.dropdownX = pos.x; volDropdown.dropdownY = pos.y;
}); });
} }
Row { // Icon only the level lives in the OSD slot above and
id: volRow // in the dropdown; "100%" does not fit the column.
anchors.verticalCenter: parent.verticalCenter
spacing: 3
SIcon { SIcon {
anchors.verticalCenter: parent.verticalCenter anchors.centerIn: parent
text: volWidget.volIcon text: volWidget.volIcon
color: volWidget.muted ? Theme.base03 : Theme.base05 color: volWidget.muted ? Theme.base03 : Theme.base05
font.pixelSize: 16 font.pixelSize: 18
}
SText {
anchors.verticalCenter: parent.verticalCenter
text: volWidget.vol + "%"
color: volWidget.muted ? Theme.base03 : Theme.base05
font.pixelSize: 13
}
} }
MouseArea { MouseArea {
@ -1575,8 +1623,8 @@ in
// Network status // Network status
Item { Item {
id: netWidget id: netWidget
width: 16 width: Theme.barWidth
height: Theme.barHeight height: 26
property string netState: "disconnected" property string netState: "disconnected"
property string netConn: "" property string netConn: ""
@ -1721,8 +1769,8 @@ in
function openNetDropdown() { function openNetDropdown() {
bar.toggleDropdown(netDropdown, function() { bar.toggleDropdown(netDropdown, function() {
wifiScanProc.running = true; wifiScanProc.running = true;
let pos = netWidget.mapToItem(bar.contentItem, netWidget.width / 2, 0); let pos = netWidget.mapToItem(bar.contentItem, 0, netWidget.height / 2);
netDropdown.dropdownX = pos.x; netDropdown.dropdownY = pos.y;
}); });
} }
@ -1742,8 +1790,8 @@ in
// Battery // Battery
Item { Item {
id: batteryWidget id: batteryWidget
width: batteryText.width + 4 + batteryIconText.width width: Theme.barWidth
height: Theme.barHeight height: batteryCol.height
// Live DBus-driven properties from the UPower service // Live DBus-driven properties from the UPower service
// no polling, no /sys parsing, no subprocess spawns. // no polling, no /sys parsing, no subprocess spawns.
@ -1771,38 +1819,38 @@ in
: batteryLevel >= 15 ? "battery_2_bar" : batteryLevel >= 15 ? "battery_2_bar"
: "battery_alert" : "battery_alert"
Row { // Stacked: icon over percentage. Side by side no longer
anchors.verticalCenter: parent.verticalCenter // fits, but the level is worth keeping on a laptop.
spacing: 4 Column {
id: batteryCol
// Explicit vertical centering: Rows top-align by anchors.horizontalCenter: parent.horizontalCenter
// default, and the icon font's taller line metrics spacing: 0
// would push the text off the shared baseline.
SText {
id: batteryText
anchors.verticalCenter: parent.verticalCenter
text: batteryWidget.batteryLevel + "%"
color: batteryWidget.batteryLevel <= 15 ? Theme.base08
: batteryWidget.batteryLevel <= 30 ? Theme.base0A
: Theme.base05
font.pixelSize: 13
}
SIcon { SIcon {
id: batteryIconText id: batteryIconText
anchors.verticalCenter: parent.verticalCenter anchors.horizontalCenter: parent.horizontalCenter
text: batteryWidget.batteryIcon text: batteryWidget.batteryIcon
color: batteryWidget.batteryLevel <= 15 ? Theme.base08 color: batteryWidget.batteryLevel <= 15 ? Theme.base08
: batteryWidget.batteryLevel <= 30 ? Theme.base0A : batteryWidget.batteryLevel <= 30 ? Theme.base0A
: Theme.base05 : Theme.base05
font.pixelSize: 16 font.pixelSize: 18
}
SText {
id: batteryText
anchors.horizontalCenter: parent.horizontalCenter
text: batteryWidget.batteryLevel
color: batteryWidget.batteryLevel <= 15 ? Theme.base08
: batteryWidget.batteryLevel <= 30 ? Theme.base0A
: Theme.base05
font.pixelSize: 10
} }
} }
function openBatteryDropdown() { function openBatteryDropdown() {
bar.toggleDropdown(batteryDropdown, function() { bar.toggleDropdown(batteryDropdown, function() {
let pos = batteryWidget.mapToItem(bar.contentItem, batteryWidget.width / 2, 0); let pos = batteryWidget.mapToItem(bar.contentItem, 0, batteryWidget.height / 2);
batteryDropdown.dropdownX = pos.x; batteryDropdown.dropdownY = pos.y;
brightRead.running = true; // refresh sliders on open brightRead.running = true; // refresh sliders on open
}); });
} }
@ -1825,8 +1873,8 @@ in
// desktop, so the card gets its own bar icon. // desktop, so the card gets its own bar icon.
Item { Item {
id: qsWidget id: qsWidget
width: qsIcon.width width: Theme.barWidth
height: Theme.barHeight height: 26
SIcon { SIcon {
id: qsIcon id: qsIcon
@ -1837,8 +1885,8 @@ in
function openQsDropdown() { function openQsDropdown() {
bar.toggleDropdown(qsDropdown, function() { bar.toggleDropdown(qsDropdown, function() {
let pos = qsWidget.mapToItem(bar.contentItem, qsWidget.width / 2, 0); let pos = qsWidget.mapToItem(bar.contentItem, 0, qsWidget.height / 2);
qsDropdown.dropdownX = pos.x; qsDropdown.dropdownY = pos.y;
}); });
} }
@ -1855,11 +1903,11 @@ in
} }
''} ''}
// Tray icons // Tray icons stacked, like everything else in the column
Row { Column {
id: trayArea id: trayArea
spacing: 8 spacing: 8
height: Theme.barHeight width: Theme.barWidth
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
Repeater { Repeater {
@ -1867,8 +1915,8 @@ in
Item { Item {
required property var modelData required property var modelData
width: 24 width: Theme.barWidth
height: Theme.barHeight height: 24
Image { Image {
id: trayIcon id: trayIcon
@ -1898,14 +1946,14 @@ in
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
let pos = parent.mapToItem(bar.contentItem, parent.width / 2, 0); let pos = parent.mapToItem(bar.contentItem, 0, parent.height / 2);
contextMenu.dropdownX = pos.x; contextMenu.dropdownY = pos.y;
contextMenu.trayItem = modelData; contextMenu.trayItem = modelData;
menuOpener.menu = modelData.menu; menuOpener.menu = modelData.menu;
} 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, 0, parent.height / 2);
contextMenu.dropdownX = pos.x; contextMenu.dropdownY = pos.y;
contextMenu.trayItem = modelData; contextMenu.trayItem = modelData;
menuOpener.menu = modelData.menu; menuOpener.menu = modelData.menu;
}); });
@ -1920,8 +1968,8 @@ in
onClicked: (event) => { onClicked: (event) => {
if (modelData.hasMenu) { if (modelData.hasMenu) {
bar.toggleDropdown(contextMenu, function() { bar.toggleDropdown(contextMenu, function() {
let pos = parent.mapToItem(bar.contentItem, parent.width / 2, 0); let pos = parent.mapToItem(bar.contentItem, 0, parent.height / 2);
contextMenu.dropdownX = pos.x; contextMenu.dropdownY = pos.y;
contextMenu.trayItem = modelData; contextMenu.trayItem = modelData;
menuOpener.menu = modelData.menu; menuOpener.menu = modelData.menu;
}); });
@ -1933,6 +1981,30 @@ in
} }
} }
} }
// Power menu opener bottom-left corner. Opens the same
// session menu as Super+L, which now unfolds from here.
Item {
id: powerWidget
width: Theme.barWidth
height: 26
SIcon {
anchors.centerIn: parent
text: "power_settings_new"
color: powerMa.containsMouse || sessionMenu.open ? Theme.base08 : Theme.base05
font.pixelSize: 18
Behavior on color { ColorAnimation { duration: Theme.animFade } }
}
MouseArea {
id: powerMa
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: bar.toggleSession()
}
}
} }
// Dropdown container content, sizing and autoclose only. // Dropdown container content, sizing and autoclose only.
@ -1942,13 +2014,14 @@ in
id: dropdown id: dropdown
property bool open: false property bool open: false
property bool closing: false property bool closing: false
property real dropdownX: 0 // Vertical centre of the widget that owns this dropdown;
// the panel tracks it, clamped to the screen. The old
// flush-to-far-edge special case is gone the SDF melt
// already makes a clamped panel meet the frame cleanly.
property real dropdownY: 0
property real fullWidth: 200 property real fullWidth: 200
property real fullHeight: 200 property real fullHeight: 200
// Flush-right dropdowns merge into the screen frame's property real dropdownWidth: open ? fullWidth : 0
// right column instead of centering on their widget.
property bool alignRight: false
property real dropdownHeight: open ? fullHeight : 0
default property alias content: dropdownContent.data default property alias content: dropdownContent.data
function animateClose() { function animateClose() {
@ -1976,12 +2049,11 @@ in
open = true; open = true;
} }
x: alignRight x: Theme.barWidth
? bar.width - Theme.frameWidth - width y: Math.round(Math.min(bar.height - Theme.frameWidth - height,
: Math.round(Math.min(bar.width - Theme.frameWidth - width, Math.max(Theme.frameWidth, dropdownX - width / 2))) Math.max(Theme.frameWidth, dropdownY - height / 2)))
y: Theme.barHeight width: fullWidth + 4
width: fullWidth + (alignRight ? 8 : 16) height: fullHeight + 16
height: fullHeight + 4
visible: false visible: false
onVisibleChanged: { onVisibleChanged: {
@ -2004,13 +2076,14 @@ in
// 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
// item counter-offsets so content stays put while the clip // item counter-offsets so content stays put while the clip
// window moves across it. // window moves across it. Growth axis is now x (rightward);
// the clip's height is the chrome's inner cross-axis span.
Item { Item {
id: _dropdownRect id: _dropdownRect
x: (chrome.x + 8) - dropdown.x x: 0
y: 0 y: (chrome.y + 8) - dropdown.y
width: Math.max(0, chrome.width - (chrome.flushRight ? 8 : 16)) width: Math.min(dropdown.fullWidth, chrome.width)
height: Math.min(dropdown.fullHeight, chrome.height) height: Math.max(0, chrome.height - 16)
clip: true clip: true
opacity: dropdown.open ? 1 : 0 opacity: dropdown.open ? 1 : 0
Behavior on opacity { Behavior on opacity {
@ -2019,7 +2092,7 @@ in
Item { Item {
id: dropdownContent id: dropdownContent
x: 8 - _dropdownRect.x y: 8 - _dropdownRect.y
width: dropdown.fullWidth width: dropdown.fullWidth
height: dropdown.fullHeight height: dropdown.fullHeight
} }
@ -2031,76 +2104,65 @@ in
// instantly when opening from closed. // instantly when opening from closed.
Item { Item {
id: chrome id: chrome
property real tX: 0 property real tY: 0
property real tW: 200 property real tH: 200
property real tH: 0 property real tW: 0
property bool flushRight: false property real openW: bar.activeDropdown ? tW : 0
property real openH: bar.activeDropdown ? tH : 0
property bool snap: false property bool snap: false
readonly property real stubW: 32 readonly property real stubH: 32
// Grow-from / shrink-to the widget that owns the dropdown: // Grow-from / shrink-to the widget that owns the dropdown:
// the panel opens as a small stub on the button and // the panel opens as a small stub beside the button and
// expands; closing retargets back to the stub while the // expands rightward; closing retargets back to the stub
// height collapses. // while the width collapses.
function stubX(dd) { function stubY(dd) {
return Math.round(Math.min(bar.width - Theme.frameWidth - stubW, Math.max(Theme.frameWidth, dd.dropdownX - stubW / 2))); return Math.round(Math.min(bar.height - Theme.frameWidth - stubH, Math.max(Theme.frameWidth, dd.dropdownY - stubH / 2)));
} }
// All dropdowns grow from / shrink to their own widget
// flush ones melt onto the frame column as they expand
// (the SDF chrome makes that junction liquid, so the old
// corner-parked seed workaround is unnecessary).
function seedFromButton(dd) { function seedFromButton(dd) {
snap = true; snap = true;
tX = stubX(dd); tY = stubY(dd);
tW = stubW; tH = stubH;
snap = false; snap = false;
} }
function shrinkToButton(dd) { function shrinkToButton(dd) {
tX = stubX(dd); tY = stubY(dd);
tW = stubW; tH = stubH;
} }
x: tX x: Theme.barWidth
y: Theme.barHeight y: tY
width: tW width: openW
height: openH height: tH
visible: height > 0.5 visible: width > 0.5
Binding { Binding {
target: chrome; property: "tX" target: chrome; property: "tY"
value: bar.activeDropdown ? bar.activeDropdown.x : 0 value: bar.activeDropdown ? bar.activeDropdown.y : 0
when: bar.activeDropdown !== null
restoreMode: Binding.RestoreNone
}
Binding {
target: chrome; property: "tW"
value: bar.activeDropdown ? bar.activeDropdown.width : 0
when: bar.activeDropdown !== null when: bar.activeDropdown !== null
restoreMode: Binding.RestoreNone restoreMode: Binding.RestoreNone
} }
Binding { Binding {
target: chrome; property: "tH" target: chrome; property: "tH"
value: bar.activeDropdown ? bar.activeDropdown.fullHeight + 4 : 0 value: bar.activeDropdown ? bar.activeDropdown.height : 0
when: bar.activeDropdown !== null when: bar.activeDropdown !== null
restoreMode: Binding.RestoreNone restoreMode: Binding.RestoreNone
} }
Binding { Binding {
target: chrome; property: "flushRight" target: chrome; property: "tW"
value: bar.activeDropdown ? bar.activeDropdown.alignRight : false value: bar.activeDropdown ? bar.activeDropdown.fullWidth + 4 : 0
when: bar.activeDropdown !== null when: bar.activeDropdown !== null
restoreMode: Binding.RestoreNone restoreMode: Binding.RestoreNone
} }
Behavior on tX { Behavior on tY {
enabled: !chrome.snap enabled: !chrome.snap
NumberAnimation { duration: Theme.animMorph; easing.type: Easing.OutExpo } NumberAnimation { duration: Theme.animMorph; easing.type: Easing.OutExpo }
} }
Behavior on tW { Behavior on tH {
enabled: !chrome.snap enabled: !chrome.snap
NumberAnimation { duration: Theme.animMorph; easing.type: Easing.OutExpo } NumberAnimation { duration: Theme.animMorph; easing.type: Easing.OutExpo }
} }
Behavior on openH { Behavior on openW {
NumberAnimation { duration: Theme.animMorph; easing.type: Easing.OutExpo } NumberAnimation { duration: Theme.animMorph; easing.type: Easing.OutExpo }
} }
@ -2109,7 +2171,6 @@ in
// Context menu // Context menu
BarDropdown { BarDropdown {
id: contextMenu id: contextMenu
alignRight: true
property var trayItem: null property var trayItem: null
fullWidth: menuItems.width + 2 * Theme.panelGap fullWidth: menuItems.width + 2 * Theme.panelGap
fullHeight: menuItems.height + 2 * Theme.panelGap fullHeight: menuItems.height + 2 * Theme.panelGap
@ -2194,7 +2255,6 @@ in
// Volume dropdown // Volume dropdown
BarDropdown { BarDropdown {
id: volDropdown id: volDropdown
alignRight: true
fullWidth: volDropdownCol.width + 2 * Theme.panelGap fullWidth: volDropdownCol.width + 2 * Theme.panelGap
fullHeight: volDropdownCol.height + 2 * Theme.panelGap fullHeight: volDropdownCol.height + 2 * Theme.panelGap
@ -2351,7 +2411,6 @@ in
// Network dropdown // Network dropdown
BarDropdown { BarDropdown {
id: netDropdown id: netDropdown
alignRight: true
fullWidth: netDropdownCol.width + 2 * Theme.panelGap fullWidth: netDropdownCol.width + 2 * Theme.panelGap
fullHeight: netDropdownCol.height + 2 * Theme.panelGap fullHeight: netDropdownCol.height + 2 * Theme.panelGap
@ -2486,7 +2545,6 @@ in
// Battery dropdown // Battery dropdown
BarDropdown { BarDropdown {
id: batteryDropdown id: batteryDropdown
alignRight: true
fullWidth: batteryDropdownCol.width + 2 * Theme.panelGap fullWidth: batteryDropdownCol.width + 2 * Theme.panelGap
fullHeight: batteryDropdownCol.height + 2 * Theme.panelGap fullHeight: batteryDropdownCol.height + 2 * Theme.panelGap
@ -2686,7 +2744,6 @@ in
// Quick settings dropdown (desktop) just the night light. // Quick settings dropdown (desktop) just the night light.
BarDropdown { BarDropdown {
id: qsDropdown id: qsDropdown
alignRight: true
fullWidth: qsCol.width + 2 * Theme.panelGap fullWidth: qsCol.width + 2 * Theme.panelGap
fullHeight: qsCol.height + 2 * Theme.panelGap fullHeight: qsCol.height + 2 * Theme.panelGap
@ -2705,7 +2762,7 @@ in
// Right: MPRIS media controls + notification list. // Right: MPRIS media controls + notification list.
BarDropdown { BarDropdown {
id: calPopup id: calPopup
dropdownX: bar.width / 2 dropdownY: bar.height / 2
// ceil: Text metrics give fractional sizes; fractional rect // ceil: Text metrics give fractional sizes; fractional rect
// edges render as soft 2px lines // edges render as soft 2px lines
fullWidth: Math.ceil(calRow.width) + 2 * Theme.panelGap fullWidth: Math.ceil(calRow.width) + 2 * Theme.panelGap
@ -3233,36 +3290,34 @@ in
} }
} }
// OSD: volume / brightness overlay melting out of the // OSD: volume / brightness level, shown inline in the bar's
// bottom frame, on the focused monitor only. Volume is // empty middle the gap between the two clusters exists for
// reactive Pipewire reports changes from any source (media // this. Volume is reactive: Pipewire reports changes from any
// keys, pavucontrol, a hardware mixer), so no IPC is needed. // source (media keys, pavucontrol, a hardware mixer), so no IPC
// Brightness arrives via shellRoot's signal instead: sysfs // is needed. Brightness arrives via shellRoot's signal instead,
// backlight notifies with POLLPRI, which inotify never sees. // because sysfs backlight notifies with POLLPRI, which inotify
// Deliberately absent from the window `mask` above, unlike // never sees. Focused monitor only.
// every other surface a transient overlay must not eat
// clicks on whatever it covers.
Item { Item {
id: osdItem id: osdItem
readonly property bool isFocused: Hyprland.focusedMonitor readonly property bool isFocused: Hyprland.focusedMonitor
&& Hyprland.focusedMonitor.name === bar.screen.name && Hyprland.focusedMonitor.name === bar.screen.name
// Every binding evaluates once at load; arm on a delay so // Every binding evaluates once at load; arm on a delay so
// a shell restart doesn't flash an OSD on screen. // a shell restart doesn't flash the slider on screen.
property bool armed: false property bool armed: false
property bool shown: false property bool shown: false
property string icon: "volume_up" property string icon: "volume_up"
property real value: 0 property real value: 0
property color fill: Theme.base0D property color fill: Theme.base0D
width: 260 width: Theme.barWidth
height: shown ? 52 : 0 anchors.horizontalCenter: barBgRect.horizontalCenter
x: Math.round((bar.width - width) / 2) anchors.top: barTop.bottom
y: bar.height - Theme.frameWidth - height anchors.bottom: barBottom.top
visible: height > 0.5 opacity: shown ? 1 : 0
clip: true visible: opacity > 0.01
Behavior on height { Behavior on opacity {
NumberAnimation { duration: Theme.animMorph; easing.type: Easing.OutExpo } NumberAnimation { duration: Theme.animContent; easing.type: Easing.OutCubic }
} }
function show(ic, v, f) { function show(ic, v, f) {
@ -3300,35 +3355,36 @@ in
} }
} }
Row { Column {
anchors.centerIn: parent anchors.centerIn: parent
width: parent.width - 4 * Theme.panelGap spacing: 8
spacing: 10
// Vertical track fill grows upward from the bottom.
// PillSlider is horizontal-only, so this is its own
// rather than a rotated instance.
Rectangle {
anchors.horizontalCenter: parent.horizontalCenter
width: 6
height: 96
radius: 3
color: Theme.base02
Rectangle {
anchors.bottom: parent.bottom
width: parent.width
height: Math.max(parent.width,
parent.height * Math.max(0, Math.min(1, osdItem.value)))
radius: parent.radius
color: osdItem.fill
Behavior on height { NumberAnimation { duration: 80 } }
}
}
SIcon { SIcon {
id: osdIcon anchors.horizontalCenter: parent.horizontalCenter
anchors.verticalCenter: parent.verticalCenter
width: 22
text: osdItem.icon text: osdItem.icon
color: Theme.base05 color: Theme.base05
font.pixelSize: 20 font.pixelSize: 16
}
PillSlider {
anchors.verticalCenter: parent.verticalCenter
width: parent.width - osdIcon.width - osdPct.width - 2 * parent.spacing
value: osdItem.value
fillColor: osdItem.fill
}
SText {
id: osdPct
anchors.verticalCenter: parent.verticalCenter
width: 34
horizontalAlignment: Text.AlignRight
text: Math.round(osdItem.value * 100) + "%"
color: Theme.base04
font.pixelSize: 12
} }
} }
} }
@ -3342,10 +3398,10 @@ in
readonly property var mutedApps: ["discord", "Discord", "Vesktop", "vesktop", "Spotify", "spotify", "vlc", "mpv"] readonly property var mutedApps: ["discord", "Discord", "Vesktop", "vesktop", "Spotify", "spotify", "vlc", "mpv"]
readonly property bool isPrimary: bar.screen === Quickshell.screens[0] readonly property bool isPrimary: bar.screen === Quickshell.screens[0]
x: Math.round(bar.width / 2 - width / 2) x: Theme.barWidth
y: Theme.barHeight y: Theme.frameWidth + 8
width: _toastRect.width + 16 width: _toastRect.width + 4
height: _toastRect.height + 4 height: _toastRect.height + 16
Process { Process {
id: notifSoundProc id: notifSoundProc
@ -3413,13 +3469,17 @@ in
Item { Item {
id: _toastRect id: _toastRect
anchors.horizontalCenter: parent.horizontalCenter anchors.left: parent.left
anchors.top: parent.top anchors.top: parent.top
width: 320 anchors.topMargin: 8
height: toastItem.toastOpen ? toastCard.height + 2 * Theme.panelGap : 0 // Reveals rightward now, so width is the animated axis
// and the card inside keeps a fixed width (otherwise it
// would squash instead of being wiped in).
width: toastItem.toastOpen ? 320 : 0
height: toastCard.height + 2 * Theme.panelGap
clip: true clip: true
Behavior on height { Behavior on width {
NumberAnimation { duration: Theme.animMorph; easing.type: Easing.OutExpo } NumberAnimation { duration: Theme.animMorph; easing.type: Easing.OutExpo }
} }
@ -3429,8 +3489,8 @@ in
id: toastCard id: toastCard
anchors.top: parent.top anchors.top: parent.top
anchors.left: parent.left anchors.left: parent.left
anchors.right: parent.right
anchors.margins: Theme.panelGap anchors.margins: Theme.panelGap
width: 320 - 2 * Theme.panelGap
height: Math.max(toastPreview.visible ? 48 : 0, toastCol.height) + 16 height: Math.max(toastPreview.visible ? 48 : 0, toastCol.height) + 16
radius: Theme.radius radius: Theme.radius
color: Theme.cardBg color: Theme.cardBg