From d52354406d8352a3cd63a622b0aa978fde184da9 Mon Sep 17 00:00:00 2001 From: David Heinemeier Hansson Date: Thu, 21 May 2026 20:19:40 +0200 Subject: [PATCH] Fun panels --- shell/plugins/panels/Monitor.qml | 209 ++++++++++-------- shell/plugins/panels/Power.qml | 360 +++++++++++++++++++++++-------- 2 files changed, 387 insertions(+), 182 deletions(-) diff --git a/shell/plugins/panels/Monitor.qml b/shell/plugins/panels/Monitor.qml index 4bf48d4f..3dc9bd71 100644 --- a/shell/plugins/panels/Monitor.qml +++ b/shell/plugins/panels/Monitor.qml @@ -225,6 +225,21 @@ Panel { return String(Math.round(n * 100) / 100) } + // Playful mood-name for a given brightness percent. Bands intentionally + // span ~10–20 points so casual tweaks change the label, while small + // nudges within one band don't. + function brightnessName(percent) { + var p = Math.round(percent) + if (p >= 95) return "Sun blast" + if (p >= 80) return "Solar flare" + if (p >= 65) return "Golden hour" + if (p >= 45) return "Even day" + if (p >= 30) return "Soft glow" + if (p >= 20) return "Lamp light" + if (p >= 10) return "Candlelit" + return "Night owl" + } + function updateDisplays(displaysJson) { try { root.displays = displaysJson ? JSON.parse(displaysJson) : [] @@ -355,7 +370,7 @@ Panel { bar: root.bar open: root.opened focusTarget: keyCatcher - contentWidth: panel.fittedContentWidth(Style.space(320)) + contentWidth: panel.fittedContentWidth(Style.space(380)) contentHeight: panel.fittedContentHeight(panelColumn.implicitHeight, Style.space(560)) PanelKeyCatcher { @@ -384,108 +399,121 @@ Panel { width: scrollArea.availableWidth spacing: Style.space(14) - // ---- Brightness ---- - Column { + // ---------- Hero: sun icon · BRIGHTNESS · percentage ---------- + Item { width: parent.width - spacing: Style.space(6) + visible: root.brightnessAvailable + implicitHeight: Math.max(heroIcon.implicitHeight, heroPercent.implicitHeight) - PanelSectionHeader { - text: "Brightness" - foreground: root.bar.foreground - fontFamily: root.bar.fontFamily - fontSize: Style.font.bodySmall - } - - CursorSurface { - id: brightnessRow - visible: root.brightnessAvailable - width: parent.width - height: brightnessInner.implicitHeight + Style.spacing.controlGap - hasCursor: root.cursorActive && root.focusSection === "brightness" && root.selectedIndex === -1 - onHasCursorChanged: if (hasCursor) root.ensureCursorVisible(brightnessRow) - foreground: root.bar.foreground - outline: true - - Row { - id: brightnessInner - anchors.fill: parent - anchors.leftMargin: Style.space(6) - anchors.rightMargin: Style.space(6) - spacing: Style.space(8) - - Text { - text: "󰃠" - color: root.bar.foreground - font.family: root.bar.fontFamily - font.pixelSize: Style.font.heading - width: Style.space(22) - horizontalAlignment: Text.AlignHCenter - anchors.verticalCenter: parent.verticalCenter - } - - PanelSlider { - id: brightnessSlider - bar: root.bar - width: parent.width - Style.space(22) - brightnessLabel.width - Style.space(16) - anchors.verticalCenter: parent.verticalCenter - minimum: 1 - maximum: 100 - step: 1 - value: root.brightnessPercent - integer: true - onMoved: function(v) { root.previewBrightness(v) } - onReleased: function(v) { - brightnessDebounce.stop() - root.setBrightness(v) - } - } - - Text { - id: brightnessLabel - text: Math.round(brightnessSlider.dragging ? brightnessSlider.liveValue : root.brightnessPercent) + "%" - color: root.bar.foreground - font.family: root.bar.fontFamily - font.pixelSize: Style.font.bodySmall - width: Style.space(36) - horizontalAlignment: Text.AlignRight - anchors.verticalCenter: parent.verticalCenter - } - } - - HoverHandler { - onHoveredChanged: if (hovered) { - root.cursorActive = true - root.focusSection = "brightness" - root.selectedIndex = -1 - } - } + Text { + id: heroIcon + text: root.displays.length > 1 ? "󰍺" : "󰍹" + color: root.bar.foreground + font.family: root.bar.fontFamily + font.pixelSize: Style.font.display + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter } Text { - visible: !root.brightnessAvailable - text: "No controllable backlight found" - color: Qt.darker(root.bar.foreground, 1.5) + id: heroLabel + text: root.brightnessName(brightnessSlider.dragging ? brightnessSlider.liveValue : root.brightnessPercent).toUpperCase() + color: Qt.darker(root.bar.foreground, 1.4) font.family: root.bar.fontFamily - font.pixelSize: Style.font.bodySmall + font.pixelSize: Style.font.caption + font.bold: true + font.letterSpacing: 1.2 + anchors.left: heroIcon.right + anchors.leftMargin: Style.space(14) + anchors.right: heroPercent.left + anchors.rightMargin: Style.space(10) + anchors.verticalCenter: parent.verticalCenter + elide: Text.ElideRight + } + + Text { + id: heroPercent + text: Math.round(brightnessSlider.dragging ? brightnessSlider.liveValue : root.brightnessPercent) + "%" + color: root.bar.foreground + font.family: root.bar.fontFamily + font.pixelSize: Style.font.displayLarge + font.bold: true + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter } } - // ---- Scale ---- + // ---------- Brightness slider (full width below hero) ---------- + CursorSurface { + id: brightnessRow + visible: root.brightnessAvailable + width: parent.width + height: brightnessSlider.implicitHeight + Style.spacing.controlGap + hasCursor: root.cursorActive && root.focusSection === "brightness" && root.selectedIndex === -1 + onHasCursorChanged: if (hasCursor) root.ensureCursorVisible(brightnessRow) + foreground: root.bar.foreground + outline: true + + PanelSlider { + id: brightnessSlider + bar: root.bar + anchors.fill: parent + anchors.leftMargin: Style.space(6) + anchors.rightMargin: Style.space(6) + minimum: 1 + maximum: 100 + step: 1 + value: root.brightnessPercent + integer: true + onMoved: function(v) { root.previewBrightness(v) } + onReleased: function(v) { + brightnessDebounce.stop() + root.setBrightness(v) + } + } + + HoverHandler { + onHoveredChanged: if (hovered) { + root.cursorActive = true + root.focusSection = "brightness" + root.selectedIndex = -1 + } + } + } + + Text { + visible: !root.brightnessAvailable + text: "No controllable backlight found" + color: Qt.darker(root.bar.foreground, 1.5) + font.family: root.bar.fontFamily + font.pixelSize: Style.font.bodySmall + } + + PanelSeparator { + visible: root.brightnessAvailable + foreground: root.bar.foreground + } + + // ---------- Scale ---------- Column { width: parent.width - spacing: Style.space(6) + spacing: Style.space(10) PanelSectionHeader { - text: "Scale" + text: "SCALE" foreground: root.bar.foreground fontFamily: root.bar.fontFamily - fontSize: Style.font.bodySmall } Row { + id: scaleRow width: parent.width spacing: Style.space(6) + readonly property real cellWidth: root.scaleValues.length > 0 + ? (width - spacing * (root.scaleValues.length - 1)) / root.scaleValues.length + : 0 + Repeater { model: root.scaleValues @@ -495,22 +523,27 @@ Panel { scaleValue: modelData scaleIndex: index + width: scaleRow.cellWidth } } } } - // ---- Monitors ---- + PanelSeparator { + visible: root.displays.length > 1 + foreground: root.bar.foreground + } + + // ---------- Monitors ---------- Column { width: parent.width - spacing: Style.space(6) + spacing: Style.space(10) visible: root.displays.length > 1 PanelSectionHeader { - text: "Monitors" + text: "DISPLAYS" foreground: root.bar.foreground fontFamily: root.bar.fontFamily - fontSize: Style.font.bodySmall } Repeater { @@ -537,10 +570,12 @@ Panel { required property int scaleIndex text: scaleValue + "x" + fontSize: Style.font.bodySmall foreground: root.bar.foreground fontFamily: root.bar.fontFamily horizontalPadding: Style.spacing.controlPaddingX - verticalPadding: Style.spacing.controlPaddingY + verticalPadding: Style.spacing.controlPaddingY + Style.space(2) + bordered: true active: root.normalizeScale(root.monitorScale) === root.normalizeScale(scaleValue) hasCursor: root.cursorActive && root.focusSection === "scale" && root.selectedIndex === scaleIndex diff --git a/shell/plugins/panels/Power.qml b/shell/plugins/panels/Power.qml index ef2d27a8..c47c660b 100644 --- a/shell/plugins/panels/Power.qml +++ b/shell/plugins/panels/Power.qml @@ -28,14 +28,14 @@ Panel { function batteryIcon() { var device = UPower.displayDevice - if (!device || !device.isPresent) return "" + if (!device || !device.isPresent) return "" var chargingIcons = ["󰢜", "󰂆", "󰂇", "󰂈", "󰢝", "󰂉", "󰢞", "󰂊", "󰂋", "󰂅"] var defaultIcons = ["󰁺", "󰁻", "󰁼", "󰁽", "󰁾", "󰁿", "󰂀", "󰂁", "󰂂", "󰁹"] var index = Math.max(0, Math.min(9, Math.floor(device.percentage * 10))) if (device.state === UPowerDeviceState.FullyCharged) return "󰂅" - if (!UPower.onBattery && device.state !== UPowerDeviceState.Charging) return "" + if (!UPower.onBattery && device.state !== UPowerDeviceState.Charging) return "" if (device.state === UPowerDeviceState.Charging) return chargingIcons[index] return defaultIcons[index] } @@ -47,17 +47,82 @@ Panel { if (!UPower.onBattery && percentage >= 1) { return "Fully charged" } else if (UPower.onBattery) { - return "Battery" + return "On battery" } else { return "Charging" } } + function profileIcon(name) { + if (name === "power-saver") return "󰌪" + if (name === "balanced") return "󰊚" + if (name === "performance") return "󰓅" + return "󰂄" + } + readonly property bool fullyCharged: { var device = UPower.displayDevice return device && device.isPresent && device.state === UPowerDeviceState.FullyCharged } + // 0..1 charge level, used by the visual progress bar. + readonly property real batteryFraction: { + var d = UPower.displayDevice + return d && d.isPresent ? Math.max(0, Math.min(1, d.percentage)) : 0 + } + + readonly property bool batteryLow: UPower.onBattery && batteryFraction > 0 && batteryFraction <= 0.2 + readonly property bool charging: { + var d = UPower.displayDevice + return d && d.isPresent && d.state === UPowerDeviceState.Charging + } + + readonly property color batteryFillColor: { + if (batteryLow) return Color.urgent + return root.bar ? root.bar.foreground : Color.foreground + } + + // Cute agent-flavored phrases shown in the hero status line, rotated on a + // timer so the panel feels alive when current is flowing (either direction). + readonly property var chargingPhrases: [ + "Pumping power", + "Injecting electrons", + "Pouring juice", + "Amassing watts", + "Hoarding joules", + "Sucking volts", + "Topping reserves", + "Soaking amps", + "Inhaling kilowatts" + ] + readonly property var onBatteryPhrases: [ + "Slurping power", + "Spending joules", + "Draining watts", + "Burning electrons", + "Sipping juice", + "Spending coulombs", + "Bleeding amps", + "Guzzling volts", + "Munching reserves" + ] + property int phraseIndex: 0 + + // Whichever list is "active" given the current power state. + readonly property var activePhrases: { + if (fullyCharged) return [] + if (charging) return chargingPhrases + if (UPower.onBattery) return onBatteryPhrases + return [] + } + readonly property bool rotatingPhrases: activePhrases.length > 0 + + readonly property string heroStatusText: { + if (fullyCharged) return "Fully charged" + if (rotatingPhrases) return activePhrases[phraseIndex % activePhrases.length] + return modeLabel() + } + function refresh() { if (!batteryProc.running) batteryProc.running = true if (!profilesProc.running) profilesProc.running = true @@ -72,6 +137,9 @@ Panel { if (idx <= 0) continue next[lines[i].substring(0, idx)] = lines[i].substring(idx + 1).trim() } + // Keep last known good data if a refresh briefly returns nothing — happens + // around AC plug/unplug events. Avoids the section collapsing mid-transition. + if (Object.keys(next).length === 0) return if (targetName === "battery") batteryInfo = next else systemInfo = next } @@ -87,6 +155,9 @@ Panel { list.push(parts[0]) if (parts[1] === "1") active = parts[0] } + // Same guard as battery: preserve the last known profile list across + // transient empty payloads so the buttons don't blink out. + if (list.length === 0) return profiles = list activeProfile = active if (profileIndex >= profiles.length) profileIndex = Math.max(0, profiles.length - 1) @@ -139,6 +210,49 @@ Panel { Timer { interval: 5000; running: root.opened; repeat: true; onTriggered: root.refresh() } + // Rotate the status phrase while the panel is open and we're in a + // rotating state (charging or on battery). The text swap is wrapped in a + // fade so the changeover reads as one organism rather than a hard cut. + Timer { + id: phraseTimer + interval: 2800 + running: root.opened && root.rotatingPhrases + repeat: true + triggeredOnStart: false + onTriggered: phraseSwap.restart() + } + + SequentialAnimation { + id: phraseSwap + PropertyAnimation { + target: heroStatus; property: "opacity" + to: 0.0; duration: 180; easing.type: Easing.OutQuad + } + ScriptAction { + script: { + var n = root.activePhrases.length + if (n > 0) root.phraseIndex = (root.phraseIndex + 1) % n + } + } + PropertyAnimation { + target: heroStatus; property: "opacity" + to: 1.0; duration: 260; easing.type: Easing.InQuad + } + } + + // If we leave a rotating state mid-swap, halt the animation and snap back + // to full opacity so "FULLY CHARGED" is legible immediately rather than + // appearing dimmed. + Connections { + target: root + function onRotatingPhrasesChanged() { + if (!root.rotatingPhrases) { + phraseSwap.stop() + heroStatus.opacity = 1.0 + } + } + } + WidgetButton { id: button anchors.fill: parent @@ -158,7 +272,7 @@ Panel { bar: root.bar open: root.opened focusTarget: keyCatcher - contentWidth: panel.fittedContentWidth(Style.space(340)) + contentWidth: panel.fittedContentWidth(Style.space(380)) contentHeight: panel.fittedContentHeight(column.implicitHeight) PanelKeyCatcher { @@ -177,106 +291,163 @@ Panel { anchors.left: parent.left anchors.right: parent.right anchors.top: parent.top - spacing: Style.space(16) - - Item { - width: parent.width - implicitHeight: Style.space(28) + spacing: Style.space(14) + // ---------- Hero: battery icon · status · percentage ---------- Item { - id: iconWrapper - width: Style.space(28) - height: Style.space(28) - anchors.left: parent.left - anchors.verticalCenter: parent.verticalCenter + width: parent.width + implicitHeight: Math.max(heroIcon.implicitHeight, heroPercent.implicitHeight) Text { - id: acIcon + id: heroIcon text: root.batteryIcon() - color: root.bar.foreground + color: root.batteryLow ? Color.urgent : root.bar.foreground font.family: root.bar.fontFamily - font.pixelSize: Style.font.iconLarge - anchors.centerIn: parent + font.pixelSize: Style.font.display + anchors.left: parent.left + anchors.verticalCenter: parent.verticalCenter + + Behavior on color { ColorAnimation { duration: 200 } } + } + + Text { + id: heroStatus + text: root.heroStatusText.toUpperCase() + color: Qt.darker(root.bar.foreground, 1.4) + font.family: root.bar.fontFamily + font.pixelSize: Style.font.caption + font.bold: true + font.letterSpacing: 1.2 + anchors.left: heroIcon.right + anchors.leftMargin: Style.space(14) + anchors.right: heroPercent.left + anchors.rightMargin: Style.space(10) + anchors.verticalCenter: parent.verticalCenter + elide: Text.ElideRight + } + + Text { + id: heroPercent + text: root.batteryInfo.percentage || "—" + color: root.batteryLow ? Color.urgent : root.bar.foreground + font.family: root.bar.fontFamily + font.pixelSize: Style.font.displayLarge + font.bold: true + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + + Behavior on color { ColorAnimation { duration: 200 } } } } - Text { - text: root.modeLabel() - color: root.bar.foreground - font.family: root.bar.fontFamily - font.pixelSize: Style.font.subtitle - font.bold: true - anchors.left: iconWrapper.right - anchors.leftMargin: Style.spacing.controlPaddingX - anchors.verticalCenter: parent.verticalCenter - } - - Text { - visible: root.batteryInfo.percentage !== undefined - text: root.batteryInfo.percentage || "" - color: root.bar.foreground - font.family: root.bar.fontFamily - font.pixelSize: Style.font.subtitle - font.bold: true - anchors.right: parent.right - anchors.verticalCenter: parent.verticalCenter - } - } - - Row { - visible: root.batteryInfo.percentage !== undefined && !root.fullyCharged - anchors.horizontalCenter: parent.horizontalCenter - spacing: Style.space(24) - - Column { - width: Style.space(140) - spacing: Style.spacing.labelGap - InfoPair { label: "Battery size"; value: root.batteryInfo.size || "" } - InfoPair { label: "Threshold"; value: root.batteryInfo.threshold || "—" } - } - - Column { - width: Style.space(140) - spacing: Style.spacing.labelGap - InfoPair { label: UPower.onBattery ? "Time left" : "Time to full"; value: root.batteryInfo.time || "—" } - InfoPair { label: UPower.onBattery ? "Discharging" : "Charging"; value: root.batteryInfo.rate || "" } - } - } - - PanelSeparator { - visible: !root.fullyCharged - foreground: root.bar.foreground - } - - Column { - width: parent.width - spacing: Style.space(12) - PanelSectionHeader { - visible: !root.fullyCharged - text: "POWER PROFILE" - foreground: root.bar.foreground - fontFamily: root.bar.fontFamily - } - Row { + // ---------- Battery progress bar ---------- + Item { width: parent.width - spacing: Style.space(6) - Repeater { - model: root.profiles - Button { - required property var modelData - required property int index - text: String(modelData).charAt(0).toUpperCase() + String(modelData).slice(1) - foreground: root.bar.foreground - fontFamily: root.bar.fontFamily - horizontalPadding: Style.spacing.controlPaddingX - verticalPadding: Style.spacing.controlPaddingY - active: root.activeProfile === modelData - hasCursor: root.cursorActive && root.profileIndex === index - onClicked: root.setProfile(modelData) - onHovered: function(h) { - if (h) { - root.cursorActive = true - root.profileIndex = index + implicitHeight: Style.space(8) + + Rectangle { + id: barTrack + anchors.fill: parent + radius: height / 2 + color: Qt.rgba(root.bar.foreground.r, root.bar.foreground.g, root.bar.foreground.b, 0.12) + } + + Rectangle { + id: barFill + anchors.left: barTrack.left + anchors.verticalCenter: barTrack.verticalCenter + height: barTrack.height + radius: barTrack.radius + color: root.batteryFillColor + width: Math.max(barTrack.height, barTrack.width * root.batteryFraction) + + Behavior on width { NumberAnimation { duration: 320; easing.type: Easing.OutCubic } } + Behavior on color { ColorAnimation { duration: 220 } } + + // Subtle pulse while charging — visible signal that energy is flowing in. + SequentialAnimation on opacity { + running: root.charging && !root.fullyCharged && root.opened + loops: Animation.Infinite + alwaysRunToEnd: true + NumberAnimation { from: 1.0; to: 0.55; duration: 950; easing.type: Easing.InOutSine } + NumberAnimation { from: 0.55; to: 1.0; duration: 950; easing.type: Easing.InOutSine } + } + } + } + + // ---------- Stats ---------- + // Visibility is intentionally only gated by "we've ever loaded data" so + // the section never collapses mid-transition. fullyCharged is *not* part + // of the condition: UPower briefly reports FullyCharged on plug-in when + // the battery sits above the charge-control start threshold, and we + // refuse to flicker the whole panel for that ~1s window. + Row { + visible: root.batteryInfo.percentage !== undefined + width: parent.width + spacing: Style.space(20) + + Column { + width: (parent.width - parent.spacing) / 2 + spacing: Style.spacing.labelGap + InfoPair { label: "Battery size"; value: root.batteryInfo.size || "" } + InfoPair { label: "Threshold"; value: root.batteryInfo.threshold || "—" } + } + + Column { + width: (parent.width - parent.spacing) / 2 + spacing: Style.spacing.labelGap + InfoPair { label: UPower.onBattery ? "Time left" : "Time to full"; value: root.batteryInfo.time || "—" } + InfoPair { label: UPower.onBattery ? "Discharging" : "Charging"; value: root.batteryInfo.rate || "" } + } + } + + PanelSeparator { + foreground: root.bar.foreground + } + + // ---------- Power profile picker ---------- + Column { + width: parent.width + spacing: Style.space(10) + + PanelSectionHeader { + text: "POWER PROFILE" + foreground: root.bar.foreground + fontFamily: root.bar.fontFamily + } + + Row { + id: profileRow + width: parent.width + spacing: Style.space(6) + + readonly property real cellWidth: root.profiles.length > 0 + ? (width - spacing * (root.profiles.length - 1)) / root.profiles.length + : 0 + + Repeater { + model: root.profiles + Button { + required property var modelData + required property int index + width: profileRow.cellWidth + iconText: root.profileIcon(String(modelData)) + iconSize: Style.font.title + text: String(modelData).charAt(0).toUpperCase() + String(modelData).slice(1) + fontSize: Style.font.bodySmall + foreground: root.bar.foreground + fontFamily: root.bar.fontFamily + horizontalPadding: Style.spacing.controlPaddingX + verticalPadding: Style.spacing.controlPaddingY + Style.space(2) + bordered: true + active: root.activeProfile === modelData + hasCursor: root.cursorActive && root.profileIndex === index + onClicked: root.setProfile(modelData) + onHovered: function(h) { + if (h) { + root.cursorActive = true + root.profileIndex = index + } } } } @@ -285,7 +456,6 @@ Panel { } } } - } component InfoPair: Row { property string label: ""