diff --git a/settings/hyprland.nix b/settings/hyprland.nix index 730773c..bcf1455 100644 --- a/settings/hyprland.nix +++ b/settings/hyprland.nix @@ -104,8 +104,8 @@ in # 6px of the outer gap is occupied by the quickshell screen # frame (Theme.frameWidth) — 18 on framed sides keeps the # visible frame↔window gap equal to the inter-window gap (12). - # Top stays 12: the bar reserves its own space. - gaps_out = { top = 12; right = 18; bottom = 18; left = 18; }; + # Left stays 12: the bar lives there and reserves its own space. + gaps_out = { top = 18; right = 18; bottom = 18; left = 12; }; border_size = 2; layout = "dwindle"; resize_on_border = true; diff --git a/settings/quickshell.nix b/settings/quickshell.nix index 2b0a611..d9203d2 100644 --- a/settings/quickshell.nix +++ b/settings/quickshell.nix @@ -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