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

@ -70,7 +70,6 @@ in
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 osd; // cx, cy, hw, hh volume/brightness OSD (hw <= 0: none)
vec4 fillColor; // straight (non-premultiplied) rgba
vec4 borderColor;
vec2 res;
@ -105,8 +104,6 @@ in
d = smin(d, sdRoundedBox(p, session.xy, session.zw, panelR), meltK);
if (launcher.z > 0.5)
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);
// 1 inside the union, 0 outside (antialiased)
@ -180,12 +177,15 @@ in
readonly property int frameWidth: 6
// Layout / metric tokens (referenced, not hardcoded)
// Bar band height. Drives widget heights, dropdown y-origin and
// the shader cutout geometry change here, not in ~10 places.
readonly property int barHeight: 30
readonly property int radius: 8 // cards, panels, dropdowns
readonly property int radiusSmall: 6 // workspace dots, pill buttons
readonly property int radiusTiny: 4 // hover rows, action chips
// Bar band thickness. The bar is a VERTICAL column on the left
// edge, so this is its width: it drives widget cell widths, the
// dropdown x-origin and the shader cutout geometry change
// here, not in ~15 places. Wide enough for the stacked HH/mm
// clock, which is what sets the floor.
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 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 systemctl: "${pkgs.systemd}/bin/systemctl"
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.
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 {
top: true
left: true
right: true
bottom: true
}
implicitHeight: bar.screen.height
exclusiveZone: Theme.barHeight
implicitWidth: bar.screen.width
exclusiveZone: Theme.barWidth
color: "transparent"
mask: Region {
@ -673,8 +679,8 @@ in
id: barBgRect
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
height: Theme.barHeight
anchors.bottom: parent.bottom
width: Theme.barWidth
}
// 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.
ShaderEffect {
anchors.fill: parent
readonly property real panelLeft: chrome.x + 8
readonly property real panelRight: chrome.x + chrome.width + (chrome.flushRight ? 4 : -8)
// The panel/toast centre-y uses (barHeight 4): the
// surface overlaps 4px up into the bar so they melt.
readonly property real surfTopY: Theme.barHeight - 4
// Panels grow rightward out of the bar column, so the
// cross-axis is now vertical: the 8px inset that used to
// be left/right is top/bottom.
readonly property real panelTop: chrome.y + 8
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(
bar.width / 2,
(Theme.barHeight + bar.height - Theme.frameWidth) / 2,
bar.width / 2 - Theme.frameWidth,
(bar.height - Theme.frameWidth - Theme.barHeight) / 2)
(Theme.barWidth + bar.width - Theme.frameWidth) / 2,
bar.height / 2,
(bar.width - Theme.frameWidth - Theme.barWidth) / 2,
bar.height / 2 - Theme.frameWidth)
property vector4d panel: chrome.visible
? Qt.vector4d((panelLeft + panelRight) / 2, surfTopY + chrome.height / 2,
(panelRight - panelLeft) / 2, 4 + chrome.height / 2)
? Qt.vector4d(surfLeftX + chrome.width / 2, (panelTop + panelBottom) / 2,
4 + chrome.width / 2, (panelBottom - panelTop) / 2)
: Qt.vector4d(0, 0, 0, 0)
property vector4d toast: toastItem.visible && _toastRect.height > 0.5
? Qt.vector4d(toastItem.x + 8 + _toastRect.width / 2, surfTopY + _toastRect.height / 2,
_toastRect.width / 2, 4 + _toastRect.height / 2)
property vector4d toast: toastItem.visible && _toastRect.width > 0.5
? Qt.vector4d(surfLeftX + _toastRect.width / 2, toastItem.y + 8 + _toastRect.height / 2,
4 + _toastRect.width / 2, _toastRect.height / 2)
: Qt.vector4d(0, 0, 0, 0)
readonly property real sessRight: bar.width - Theme.frameWidth + 4
property vector4d session: sessionMenu.visible
? Qt.vector4d((sessionMenu.x + sessRight) / 2, sessionMenu.y + sessionMenu.height / 2,
(sessRight - sessionMenu.x) / 2, sessionMenu.height / 2)
? Qt.vector4d(surfLeftX + sessionMenu.width / 2, sessionMenu.y + sessionMenu.height / 2,
4 + sessionMenu.width / 2, sessionMenu.height / 2)
: 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
property vector4d launcher: launcherPanel.visible
? Qt.vector4d(launcherPanel.x + launcherPanel.width / 2, (launcherPanel.y + launchBot) / 2,
launcherPanel.width / 2, (launchBot - launcherPanel.y) / 2)
: 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 borderColor: Qt.vector4d(Theme.base03.r, Theme.base03.g, Theme.base03.b, 1)
property vector2d res: Qt.vector2d(width, height)
@ -1003,8 +1008,9 @@ in
}
// Session menu: icon-only power controls morphing out of
// the right frame column at screen centre (Super+L). Keyboard:
// arrows/Tab move the selection, Enter activates, Esc closes.
// 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
@ -1044,8 +1050,10 @@ in
}
}
x: bar.width - Theme.frameWidth - openW
y: Math.round((bar.height - height) / 2)
// Unfolds rightward out of the bar at the bottom-left,
// level with the power button that opens it.
x: Theme.barWidth
y: Math.round(bar.height - Theme.frameWidth - height - 8)
width: openW
height: openH
visible: openW > 0.5
@ -1071,8 +1079,8 @@ in
Rectangle {
id: sessionCard
anchors.verticalCenter: parent.verticalCenter
anchors.right: parent.right
anchors.rightMargin: Theme.panelGap
anchors.left: parent.left
anchors.leftMargin: Theme.panelGap
width: 48
height: sessionCol.height + 8
radius: Theme.radius
@ -1417,99 +1425,150 @@ in
}
}
// Left workspace dots: accent pill for the focused
// workspace, dim dots otherwise. All colours from Theme
// (stylix); the pill matches hyprland's active border accent.
Row {
anchors.left: parent.left
// 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
// pad their dots by 3px, hence the -3.
anchors.leftMargin: 12 - 3
anchors.verticalCenter: barBgRect.verticalCenter
spacing: 4
Repeater {
model: Hyprland.workspaces
Item {
id: wsItem
required property var modelData
visible: modelData.id > 0
width: visible ? dot.width + 6 : 0
height: Theme.barHeight
Rectangle {
id: dot
anchors.centerIn: parent
width: wsItem.modelData.focused ? 18 : 6
height: 6
radius: 3
color: wsItem.modelData.focused ? Theme.base0D
: wsMa.containsMouse ? Theme.base04 : Theme.base03
Behavior on width {
NumberAnimation { duration: 200; easing.type: Easing.OutExpo }
}
Behavior on color { ColorAnimation { duration: Theme.animFade } }
}
MouseArea {
id: wsMa
anchors.fill: parent
hoverEnabled: true
onClicked: wsItem.modelData.activate()
}
}
}
}
// Center clock. SystemClock ticks on the minute boundary
// instead of a 1 Hz Timer; the calendar reads clockText.now too.
// 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 {
id: clockText
anchors.horizontalCenter: parent.horizontalCenter
anchors.verticalCenter: barBgRect.verticalCenter
property date now: sysClock.date
text: now.toLocaleTimeString(Qt.locale(), "HH:mm")
color: Theme.base05
font.pixelSize: 13
font.weight: Font.Medium
// Top cluster: logo, clock, workspaces. The bar is a column
// now, so the two clusters anchor to opposite ends and the gap
// between them is the OSD slot.
Column {
id: barTop
anchors.top: parent.top
anchors.topMargin: 12
anchors.horizontalCenter: barBgRect.horizontalCenter
spacing: 12
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(); });
// 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
Repeater {
model: Hyprland.workspaces
Item {
id: wsItem
required property var modelData
visible: modelData.id > 0
width: Theme.barWidth
height: visible ? dot.height + 6 : 0
Rectangle {
id: dot
anchors.centerIn: parent
width: 6
height: wsItem.modelData.focused ? 18 : 6
radius: 3
color: wsItem.modelData.focused ? Theme.base0D
: wsMa.containsMouse ? Theme.base04 : Theme.base03
Behavior on height {
NumberAnimation { duration: 200; easing.type: Easing.OutExpo }
}
Behavior on color { ColorAnimation { duration: Theme.animFade } }
}
MouseArea {
id: wsMa
anchors.fill: parent
hoverEnabled: true
onClicked: wsItem.modelData.activate()
}
}
}
}
}
// 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
// Bottom cluster: status icons, tray, power button. Anchored
// to the bottom edge so the gap above it between here and the
// workspace dots stays free as the OSD slot.
Column {
id: barBottom
anchors.bottom: parent.bottom
anchors.bottomMargin: 12
anchors.horizontalCenter: barBgRect.horizontalCenter
spacing: 10
// Volume
Item {
id: volWidget
width: volRow.width
height: Theme.barHeight
width: Theme.barWidth
height: 26
property PwNode sink: Pipewire.defaultAudioSink
@ -1527,29 +1586,18 @@ in
function openVolDropdown() {
bar.toggleDropdown(volDropdown, function() {
let pos = volWidget.mapToItem(bar.contentItem, volWidget.width / 2, 0);
volDropdown.dropdownX = pos.x;
let pos = volWidget.mapToItem(bar.contentItem, 0, volWidget.height / 2);
volDropdown.dropdownY = pos.y;
});
}
Row {
id: volRow
anchors.verticalCenter: parent.verticalCenter
spacing: 3
SIcon {
anchors.verticalCenter: parent.verticalCenter
text: volWidget.volIcon
color: volWidget.muted ? Theme.base03 : Theme.base05
font.pixelSize: 16
}
SText {
anchors.verticalCenter: parent.verticalCenter
text: volWidget.vol + "%"
color: volWidget.muted ? Theme.base03 : Theme.base05
font.pixelSize: 13
}
// Icon only the level lives in the OSD slot above and
// in the dropdown; "100%" does not fit the column.
SIcon {
anchors.centerIn: parent
text: volWidget.volIcon
color: volWidget.muted ? Theme.base03 : Theme.base05
font.pixelSize: 18
}
MouseArea {
@ -1575,8 +1623,8 @@ in
// Network status
Item {
id: netWidget
width: 16
height: Theme.barHeight
width: Theme.barWidth
height: 26
property string netState: "disconnected"
property string netConn: ""
@ -1721,8 +1769,8 @@ in
function openNetDropdown() {
bar.toggleDropdown(netDropdown, function() {
wifiScanProc.running = true;
let pos = netWidget.mapToItem(bar.contentItem, netWidget.width / 2, 0);
netDropdown.dropdownX = pos.x;
let pos = netWidget.mapToItem(bar.contentItem, 0, netWidget.height / 2);
netDropdown.dropdownY = pos.y;
});
}
@ -1742,8 +1790,8 @@ in
// Battery
Item {
id: batteryWidget
width: batteryText.width + 4 + batteryIconText.width
height: Theme.barHeight
width: Theme.barWidth
height: batteryCol.height
// Live DBus-driven properties from the UPower service
// no polling, no /sys parsing, no subprocess spawns.
@ -1771,38 +1819,38 @@ in
: batteryLevel >= 15 ? "battery_2_bar"
: "battery_alert"
Row {
anchors.verticalCenter: parent.verticalCenter
spacing: 4
// Explicit vertical centering: Rows top-align by
// default, and the icon font's taller line metrics
// 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
}
// Stacked: icon over percentage. Side by side no longer
// fits, but the level is worth keeping on a laptop.
Column {
id: batteryCol
anchors.horizontalCenter: parent.horizontalCenter
spacing: 0
SIcon {
id: batteryIconText
anchors.verticalCenter: parent.verticalCenter
anchors.horizontalCenter: parent.horizontalCenter
text: batteryWidget.batteryIcon
color: batteryWidget.batteryLevel <= 15 ? Theme.base08
: batteryWidget.batteryLevel <= 30 ? Theme.base0A
: 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() {
bar.toggleDropdown(batteryDropdown, function() {
let pos = batteryWidget.mapToItem(bar.contentItem, batteryWidget.width / 2, 0);
batteryDropdown.dropdownX = pos.x;
let pos = batteryWidget.mapToItem(bar.contentItem, 0, batteryWidget.height / 2);
batteryDropdown.dropdownY = pos.y;
brightRead.running = true; // refresh sliders on open
});
}
@ -1825,8 +1873,8 @@ in
// desktop, so the card gets its own bar icon.
Item {
id: qsWidget
width: qsIcon.width
height: Theme.barHeight
width: Theme.barWidth
height: 26
SIcon {
id: qsIcon
@ -1837,8 +1885,8 @@ in
function openQsDropdown() {
bar.toggleDropdown(qsDropdown, function() {
let pos = qsWidget.mapToItem(bar.contentItem, qsWidget.width / 2, 0);
qsDropdown.dropdownX = pos.x;
let pos = qsWidget.mapToItem(bar.contentItem, 0, qsWidget.height / 2);
qsDropdown.dropdownY = pos.y;
});
}
@ -1855,11 +1903,11 @@ in
}
''}
// Tray icons
Row {
// Tray icons stacked, like everything else in the column
Column {
id: trayArea
spacing: 8
height: Theme.barHeight
width: Theme.barWidth
anchors.verticalCenter: parent.verticalCenter
Repeater {
@ -1867,8 +1915,8 @@ in
Item {
required property var modelData
width: 24
height: Theme.barHeight
width: Theme.barWidth
height: 24
Image {
id: trayIcon
@ -1898,14 +1946,14 @@ in
if (modelData.hasMenu && !(bar.activeDropdown === contextMenu && contextMenu.trayItem === modelData)) {
if (bar.activeDropdown === contextMenu) {
// Same dropdown, just switch content
let pos = parent.mapToItem(bar.contentItem, parent.width / 2, 0);
contextMenu.dropdownX = pos.x;
let pos = parent.mapToItem(bar.contentItem, 0, parent.height / 2);
contextMenu.dropdownY = pos.y;
contextMenu.trayItem = modelData;
menuOpener.menu = modelData.menu;
} else {
bar.toggleDropdown(contextMenu, function() {
let pos = parent.mapToItem(bar.contentItem, parent.width / 2, 0);
contextMenu.dropdownX = pos.x;
let pos = parent.mapToItem(bar.contentItem, 0, parent.height / 2);
contextMenu.dropdownY = pos.y;
contextMenu.trayItem = modelData;
menuOpener.menu = modelData.menu;
});
@ -1920,8 +1968,8 @@ in
onClicked: (event) => {
if (modelData.hasMenu) {
bar.toggleDropdown(contextMenu, function() {
let pos = parent.mapToItem(bar.contentItem, parent.width / 2, 0);
contextMenu.dropdownX = pos.x;
let pos = parent.mapToItem(bar.contentItem, 0, parent.height / 2);
contextMenu.dropdownY = pos.y;
contextMenu.trayItem = modelData;
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.
@ -1942,13 +2014,14 @@ in
id: dropdown
property bool open: 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 fullHeight: 200
// Flush-right dropdowns merge into the screen frame's
// right column instead of centering on their widget.
property bool alignRight: false
property real dropdownHeight: open ? fullHeight : 0
property real dropdownWidth: open ? fullWidth : 0
default property alias content: dropdownContent.data
function animateClose() {
@ -1976,12 +2049,11 @@ in
open = true;
}
x: alignRight
? bar.width - Theme.frameWidth - width
: Math.round(Math.min(bar.width - Theme.frameWidth - width, Math.max(Theme.frameWidth, dropdownX - width / 2)))
y: Theme.barHeight
width: fullWidth + (alignRight ? 8 : 16)
height: fullHeight + 4
x: Theme.barWidth
y: Math.round(Math.min(bar.height - Theme.frameWidth - height,
Math.max(Theme.frameWidth, dropdownY - height / 2)))
width: fullWidth + 4
height: fullHeight + 16
visible: false
onVisibleChanged: {
@ -2004,13 +2076,14 @@ in
// revealed as the panel slides/grows over it and wiped as
// the panel leaves, instead of popping in place. The inner
// 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 {
id: _dropdownRect
x: (chrome.x + 8) - dropdown.x
y: 0
width: Math.max(0, chrome.width - (chrome.flushRight ? 8 : 16))
height: Math.min(dropdown.fullHeight, chrome.height)
x: 0
y: (chrome.y + 8) - dropdown.y
width: Math.min(dropdown.fullWidth, chrome.width)
height: Math.max(0, chrome.height - 16)
clip: true
opacity: dropdown.open ? 1 : 0
Behavior on opacity {
@ -2019,7 +2092,7 @@ in
Item {
id: dropdownContent
x: 8 - _dropdownRect.x
y: 8 - _dropdownRect.y
width: dropdown.fullWidth
height: dropdown.fullHeight
}
@ -2031,76 +2104,65 @@ in
// instantly when opening from closed.
Item {
id: chrome
property real tX: 0
property real tW: 200
property real tH: 0
property bool flushRight: false
property real openH: bar.activeDropdown ? tH : 0
property real tY: 0
property real tH: 200
property real tW: 0
property real openW: bar.activeDropdown ? tW : 0
property bool snap: false
readonly property real stubW: 32
readonly property real stubH: 32
// Grow-from / shrink-to the widget that owns the dropdown:
// the panel opens as a small stub on the button and
// expands; closing retargets back to the stub while the
// height collapses.
function stubX(dd) {
return Math.round(Math.min(bar.width - Theme.frameWidth - stubW, Math.max(Theme.frameWidth, dd.dropdownX - stubW / 2)));
// the panel opens as a small stub beside the button and
// expands rightward; closing retargets back to the stub
// while the width collapses.
function stubY(dd) {
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) {
snap = true;
tX = stubX(dd);
tW = stubW;
tY = stubY(dd);
tH = stubH;
snap = false;
}
function shrinkToButton(dd) {
tX = stubX(dd);
tW = stubW;
tY = stubY(dd);
tH = stubH;
}
x: tX
y: Theme.barHeight
width: tW
height: openH
visible: height > 0.5
x: Theme.barWidth
y: tY
width: openW
height: tH
visible: width > 0.5
Binding {
target: chrome; property: "tX"
value: bar.activeDropdown ? bar.activeDropdown.x : 0
when: bar.activeDropdown !== null
restoreMode: Binding.RestoreNone
}
Binding {
target: chrome; property: "tW"
value: bar.activeDropdown ? bar.activeDropdown.width : 0
target: chrome; property: "tY"
value: bar.activeDropdown ? bar.activeDropdown.y : 0
when: bar.activeDropdown !== null
restoreMode: Binding.RestoreNone
}
Binding {
target: chrome; property: "tH"
value: bar.activeDropdown ? bar.activeDropdown.fullHeight + 4 : 0
value: bar.activeDropdown ? bar.activeDropdown.height : 0
when: bar.activeDropdown !== null
restoreMode: Binding.RestoreNone
}
Binding {
target: chrome; property: "flushRight"
value: bar.activeDropdown ? bar.activeDropdown.alignRight : false
target: chrome; property: "tW"
value: bar.activeDropdown ? bar.activeDropdown.fullWidth + 4 : 0
when: bar.activeDropdown !== null
restoreMode: Binding.RestoreNone
}
Behavior on tX {
Behavior on tY {
enabled: !chrome.snap
NumberAnimation { duration: Theme.animMorph; easing.type: Easing.OutExpo }
}
Behavior on tW {
Behavior on tH {
enabled: !chrome.snap
NumberAnimation { duration: Theme.animMorph; easing.type: Easing.OutExpo }
}
Behavior on openH {
Behavior on openW {
NumberAnimation { duration: Theme.animMorph; easing.type: Easing.OutExpo }
}
@ -2109,7 +2171,6 @@ in
// Context menu
BarDropdown {
id: contextMenu
alignRight: true
property var trayItem: null
fullWidth: menuItems.width + 2 * Theme.panelGap
fullHeight: menuItems.height + 2 * Theme.panelGap
@ -2194,7 +2255,6 @@ in
// Volume dropdown
BarDropdown {
id: volDropdown
alignRight: true
fullWidth: volDropdownCol.width + 2 * Theme.panelGap
fullHeight: volDropdownCol.height + 2 * Theme.panelGap
@ -2351,7 +2411,6 @@ in
// Network dropdown
BarDropdown {
id: netDropdown
alignRight: true
fullWidth: netDropdownCol.width + 2 * Theme.panelGap
fullHeight: netDropdownCol.height + 2 * Theme.panelGap
@ -2486,7 +2545,6 @@ in
// Battery dropdown
BarDropdown {
id: batteryDropdown
alignRight: true
fullWidth: batteryDropdownCol.width + 2 * Theme.panelGap
fullHeight: batteryDropdownCol.height + 2 * Theme.panelGap
@ -2686,7 +2744,6 @@ in
// Quick settings dropdown (desktop) just the night light.
BarDropdown {
id: qsDropdown
alignRight: true
fullWidth: qsCol.width + 2 * Theme.panelGap
fullHeight: qsCol.height + 2 * Theme.panelGap
@ -2705,7 +2762,7 @@ in
// Right: MPRIS media controls + notification list.
BarDropdown {
id: calPopup
dropdownX: bar.width / 2
dropdownY: bar.height / 2
// ceil: Text metrics give fractional sizes; fractional rect
// edges render as soft 2px lines
fullWidth: Math.ceil(calRow.width) + 2 * Theme.panelGap
@ -3233,36 +3290,34 @@ in
}
}
// OSD: volume / brightness overlay melting out of the
// bottom frame, on the focused monitor only. Volume is
// reactive Pipewire reports changes from any source (media
// keys, pavucontrol, a hardware mixer), so no IPC is needed.
// Brightness arrives via shellRoot's signal instead: sysfs
// backlight notifies with POLLPRI, which inotify never sees.
// Deliberately absent from the window `mask` above, unlike
// every other surface a transient overlay must not eat
// clicks on whatever it covers.
// OSD: volume / brightness level, shown inline in the bar's
// empty middle the gap between the two clusters exists for
// this. Volume is reactive: Pipewire reports changes from any
// source (media keys, pavucontrol, a hardware mixer), so no IPC
// is needed. Brightness arrives via shellRoot's signal instead,
// because sysfs backlight notifies with POLLPRI, which inotify
// never sees. Focused monitor only.
Item {
id: osdItem
readonly property bool isFocused: Hyprland.focusedMonitor
&& Hyprland.focusedMonitor.name === bar.screen.name
// 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 shown: false
property string icon: "volume_up"
property real value: 0
property color fill: Theme.base0D
width: 260
height: shown ? 52 : 0
x: Math.round((bar.width - width) / 2)
y: bar.height - Theme.frameWidth - height
visible: height > 0.5
clip: true
width: Theme.barWidth
anchors.horizontalCenter: barBgRect.horizontalCenter
anchors.top: barTop.bottom
anchors.bottom: barBottom.top
opacity: shown ? 1 : 0
visible: opacity > 0.01
Behavior on height {
NumberAnimation { duration: Theme.animMorph; easing.type: Easing.OutExpo }
Behavior on opacity {
NumberAnimation { duration: Theme.animContent; easing.type: Easing.OutCubic }
}
function show(ic, v, f) {
@ -3300,35 +3355,36 @@ in
}
}
Row {
Column {
anchors.centerIn: parent
width: parent.width - 4 * Theme.panelGap
spacing: 10
spacing: 8
// 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 {
id: osdIcon
anchors.verticalCenter: parent.verticalCenter
width: 22
anchors.horizontalCenter: parent.horizontalCenter
text: osdItem.icon
color: Theme.base05
font.pixelSize: 20
}
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
font.pixelSize: 16
}
}
}
@ -3342,10 +3398,10 @@ in
readonly property var mutedApps: ["discord", "Discord", "Vesktop", "vesktop", "Spotify", "spotify", "vlc", "mpv"]
readonly property bool isPrimary: bar.screen === Quickshell.screens[0]
x: Math.round(bar.width / 2 - width / 2)
y: Theme.barHeight
width: _toastRect.width + 16
height: _toastRect.height + 4
x: Theme.barWidth
y: Theme.frameWidth + 8
width: _toastRect.width + 4
height: _toastRect.height + 16
Process {
id: notifSoundProc
@ -3413,13 +3469,17 @@ in
Item {
id: _toastRect
anchors.horizontalCenter: parent.horizontalCenter
anchors.left: parent.left
anchors.top: parent.top
width: 320
height: toastItem.toastOpen ? toastCard.height + 2 * Theme.panelGap : 0
anchors.topMargin: 8
// 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
Behavior on height {
Behavior on width {
NumberAnimation { duration: Theme.animMorph; easing.type: Easing.OutExpo }
}
@ -3429,8 +3489,8 @@ in
id: toastCard
anchors.top: parent.top
anchors.left: parent.left
anchors.right: parent.right
anchors.margins: Theme.panelGap
width: 320 - 2 * Theme.panelGap
height: Math.max(toastPreview.visible ? 48 : 0, toastCol.height) + 16
radius: Theme.radius
color: Theme.cardBg