refactor: unified BarModule base component, click-to-open panels, remove pinning

This commit is contained in:
Damocles 2026-04-25 11:52:20 +02:00
commit 26476dc930
33 changed files with 265 additions and 509 deletions

View file

@ -0,0 +1,226 @@
import QtQuick
import Quickshell
import "../services" as S
Column {
id: root
required property color accentColor
signal runCommand(var cmd)
signal dismiss
readonly property bool _isNiri: Quickshell.env("NIRI_SOCKET") !== ""
property var _confirmItem: null
function _run(cmd) {
runCommand(cmd);
dismiss();
}
function _requestAction(item) {
if (item.confirm) {
_confirmItem = item;
} else {
_run(item.cmd);
}
}
function _cancelConfirm() {
_confirmItem = null;
}
// Normal menu entries
Column {
visible: !root._confirmItem
width: root.width
Repeater {
model: [
{
label: "Lock",
icon: "\uF023",
cmd: ["loginctl", "lock-session"],
color: S.Theme.base0D,
confirm: false
},
{
label: "Suspend",
icon: "\uF186",
cmd: ["systemctl", "suspend"],
color: S.Theme.base0E,
confirm: false
},
{
label: "Logout",
icon: "\uF2F5",
cmd: root._isNiri ? ["niri", "msg", "action", "quit"] : ["loginctl", "terminate-user", ""],
color: S.Theme.base0A,
confirm: false
},
{
label: "Reboot",
icon: "\uF021",
cmd: ["systemctl", "reboot"],
color: S.Theme.base09,
confirm: true
},
{
label: "Shutdown",
icon: "\uF011",
cmd: ["systemctl", "poweroff"],
color: S.Theme.base08,
confirm: true
}
]
delegate: Item {
id: entry
required property var modelData
required property int index
width: root.width
height: 32
Rectangle {
anchors.fill: parent
anchors.leftMargin: 4
anchors.rightMargin: 4
color: entryHover.hovered ? S.Theme.base02 : "transparent"
radius: S.Theme.radius
}
Text {
id: entryIcon
anchors.verticalCenter: parent.verticalCenter
anchors.left: parent.left
anchors.leftMargin: 12
text: entry.modelData.icon
color: entry.modelData.color
font.pixelSize: S.Theme.fontSize + 1
font.family: S.Theme.iconFontFamily
}
Text {
anchors.verticalCenter: parent.verticalCenter
anchors.left: entryIcon.right
anchors.leftMargin: 10
text: entry.modelData.label
color: S.Theme.base05
font.pixelSize: S.Theme.fontSize
font.family: S.Theme.fontFamily
}
HoverHandler {
id: entryHover
cursorShape: Qt.PointingHandCursor
}
TapHandler {
onTapped: root._requestAction(entry.modelData)
}
}
}
}
// Confirmation view
Column {
visible: !!root._confirmItem
width: root.width
spacing: 4
Item {
width: parent.width
height: 28
Text {
anchors.centerIn: parent
text: root._confirmItem ? root._confirmItem.label + "?" : ""
color: root._confirmItem ? root._confirmItem.color : S.Theme.base05
font.pixelSize: S.Theme.fontSize
font.family: S.Theme.fontFamily
font.bold: true
}
}
Row {
anchors.horizontalCenter: parent.horizontalCenter
spacing: 8
Item {
width: 72
height: 28
Rectangle {
anchors.fill: parent
color: cancelHover.hovered ? S.Theme.base02 : S.Theme.base01
radius: S.Theme.radius
border.width: 1
border.color: S.Theme.base03
}
Text {
anchors.centerIn: parent
text: "Cancel"
color: S.Theme.base05
font.pixelSize: S.Theme.fontSize
font.family: S.Theme.fontFamily
}
HoverHandler {
id: cancelHover
cursorShape: Qt.PointingHandCursor
}
TapHandler {
onTapped: root._cancelConfirm()
}
}
Item {
width: 72
height: 28
Rectangle {
anchors.fill: parent
color: {
if (!root._confirmItem)
return S.Theme.base02;
const c = root._confirmItem.color;
return confirmHover.hovered ? Qt.rgba(c.r, c.g, c.b, 0.3) : Qt.rgba(c.r, c.g, c.b, 0.15);
}
radius: S.Theme.radius
border.width: 1
border.color: root._confirmItem ? root._confirmItem.color : S.Theme.base03
}
Text {
anchors.centerIn: parent
text: "Confirm"
color: root._confirmItem ? root._confirmItem.color : S.Theme.base05
font.pixelSize: S.Theme.fontSize
font.family: S.Theme.fontFamily
font.bold: true
}
HoverHandler {
id: confirmHover
cursorShape: Qt.PointingHandCursor
}
TapHandler {
onTapped: {
if (root._confirmItem)
root._run(root._confirmItem.cmd);
}
}
}
}
Item {
width: 1
height: 4
}
}
}

View file

@ -13,6 +13,7 @@ MemoryApplet 1.0 MemoryApplet.qml
MprisApplet 1.0 MprisApplet.qml MprisApplet 1.0 MprisApplet.qml
NetworkApplet 1.0 NetworkApplet.qml NetworkApplet 1.0 NetworkApplet.qml
NotifApplet 1.0 NotifApplet.qml NotifApplet 1.0 NotifApplet.qml
PowerApplet 1.0 PowerApplet.qml
Separator 1.0 Separator.qml Separator 1.0 Separator.qml
SparklineCanvas 1.0 SparklineCanvas.qml SparklineCanvas 1.0 SparklineCanvas.qml
TemperatureApplet 1.0 TemperatureApplet.qml TemperatureApplet 1.0 TemperatureApplet.qml

View file

@ -4,12 +4,12 @@ import "." as M
import "../services" as S import "../services" as S
import "../applets" as C import "../applets" as C
M.OsdSection { M.BarModule {
id: root id: root
spacing: S.Theme.moduleSpacing spacing: S.Theme.moduleSpacing
opacity: S.Modules.backlight.enable && S.BacklightService.available ? 1 : 0 opacity: S.Modules.backlight.enable && S.BacklightService.available ? 1 : 0
visible: opacity > 0 visible: opacity > 0
_panelHovered: hoverPanel.panelHovered tooltip: "Brightness: " + percent + "%"
property int percent: S.BacklightService.percent property int percent: S.BacklightService.percent
property bool _percentInit: false property bool _percentInit: false
@ -27,24 +27,6 @@ M.OsdSection {
onWheel: event => S.BacklightService.adjust(event.angleDelta.y) onWheel: event => S.BacklightService.adjust(event.angleDelta.y)
} }
M.HoverPanel {
id: hoverPanel
showPanel: root._showPanel
screen: QsWindow.window?.screen ?? null
anchorItem: root
accentColor: root.accentColor
panelNamespace: "nova-backlight"
panelTitle: "Brightness"
contentWidth: 200
C.BacklightApplet {
width: parent.width
percent: root.percent
accentColor: root.accentColor
onSetPercent: pct => S.BacklightService.setPercent(pct)
}
}
M.BarIcon { M.BarIcon {
icon: "\uF185" icon: "\uF185"
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
@ -54,4 +36,23 @@ M.OsdSection {
minText: "100%" minText: "100%"
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
} }
M.HoverPanel {
id: hoverPanel
showPanel: root._showPanel
screen: QsWindow.window?.screen ?? null
anchorItem: root
accentColor: root.accentColor
panelNamespace: "nova-backlight"
panelTitle: "Brightness"
contentWidth: 200
onDismissed: root.dismissPanel()
C.BacklightApplet {
width: parent.width
percent: root.percent
accentColor: root.accentColor
onSetPercent: pct => S.BacklightService.setPercent(pct)
}
}
} }

View file

@ -221,7 +221,6 @@ PanelWindow {
rightEdge: true rightEdge: true
M.BatteryModule {} M.BatteryModule {}
M.PowerModule { M.PowerModule {
bar: bar
visible: S.Modules.power.enable visible: S.Modules.power.enable
} }
} }

View file

@ -1,15 +1,13 @@
import QtQuick import QtQuick
import Quickshell
import "." as M
import "../services" as S import "../services" as S
// Icon element with crossfade animation on icon change.
// Pure visual component - tooltip handling lives in the parent BarModule.
Text { Text {
id: root id: root
property string icon: "" property string icon: ""
property string tooltip: ""
property string minIcon: "" property string minIcon: ""
property color accentColor: parent?.accentColor ?? S.Theme.base05 property color accentColor: parent?.accentColor ?? S.Theme.base05
property bool _hovered: false
property string _displayIcon: icon property string _displayIcon: icon
property string _pendingIcon: "" property string _pendingIcon: ""
@ -54,23 +52,4 @@ Text {
font.pixelSize: root.font.pixelSize font.pixelSize: root.font.pixelSize
font.family: root.font.family font.family: root.font.family
} }
HoverHandler {
cursorShape: Qt.PointingHandCursor
onHoveredChanged: {
root._hovered = hovered;
if (hovered && root.tooltip !== "") {
M.TooltipState.text = root.tooltip;
M.TooltipState.itemX = root.mapToGlobal(root.width / 2, 0).x - (QsWindow.window?.screen?.x ?? 0);
M.TooltipState.screen = QsWindow.window?.screen ?? null;
M.TooltipState.accentColor = root.accentColor;
M.TooltipState.visible = true;
} else if (!hovered && root.tooltip !== "") {
M.TooltipState.visible = false;
}
}
}
onTooltipChanged: if (_hovered && tooltip !== "")
M.TooltipState.text = tooltip
} }

View file

@ -1,15 +1,13 @@
import QtQuick import QtQuick
import Quickshell
import "." as M
import "../services" as S import "../services" as S
// Label element with minimum-width support via minText.
// Pure visual component - tooltip handling lives in the parent BarModule.
Text { Text {
id: root id: root
property string label: "" property string label: ""
property string tooltip: ""
property string minText: "" property string minText: ""
property color accentColor: parent?.accentColor ?? S.Theme.base05 property color accentColor: parent?.accentColor ?? S.Theme.base05
property bool _hovered: false
text: label text: label
width: minText ? Math.max(implicitWidth, _minMetrics.width) : implicitWidth width: minText ? Math.max(implicitWidth, _minMetrics.width) : implicitWidth
@ -25,23 +23,4 @@ Text {
font.pixelSize: root.font.pixelSize font.pixelSize: root.font.pixelSize
font.family: root.font.family font.family: root.font.family
} }
HoverHandler {
cursorShape: Qt.PointingHandCursor
onHoveredChanged: {
root._hovered = hovered;
if (hovered && root.tooltip !== "") {
M.TooltipState.text = root.tooltip;
M.TooltipState.itemX = root.mapToGlobal(root.width / 2, 0).x - (QsWindow.window?.screen?.x ?? 0);
M.TooltipState.screen = QsWindow.window?.screen ?? null;
M.TooltipState.accentColor = root.accentColor;
M.TooltipState.visible = true;
} else if (!hovered && root.tooltip !== "") {
M.TooltipState.visible = false;
}
}
}
onTooltipChanged: if (_hovered && tooltip !== "")
M.TooltipState.text = tooltip
} }

View file

@ -0,0 +1,84 @@
import QtQuick
import Quickshell
import "." as M
import "../services" as S
// Unified base component for all bar modules.
// Provides: tooltip on hover, panel state management, OSD flash support.
//
// On tap: toggles _panelOpen and emits tapped(). Modules that want custom tap
// behavior connect onTapped to their action (the toggle still happens).
//
// Modules with a HoverPanel bind:
// M.HoverPanel { showPanel: root._showPanel; onDismissed: root.dismissPanel() }
//
// Modules without a panel need nothing - the toggle is a harmless no-op.
Row {
id: root
property string tooltip: ""
property bool _hovered: false
property color accentColor: parent?.accentColor ?? S.Theme.base05
property int cursorShape: Qt.PointingHandCursor
// Panel state
property bool _panelOpen: false
property bool _osdActive: false
readonly property bool _showPanel: _panelOpen || _osdActive
signal tapped
function flashPanel() {
_osdActive = true;
_osdTimer.restart();
}
function dismissPanel() {
_panelOpen = false;
_osdActive = false;
_osdTimer.stop();
}
Timer {
id: _osdTimer
interval: 1500
onTriggered: if (!root._panelOpen)
root._osdActive = false
}
on_PanelOpenChanged: {
if (_panelOpen)
M.TooltipState.visible = false;
}
HoverHandler {
cursorShape: root.cursorShape
onHoveredChanged: {
root._hovered = hovered;
if (hovered && root.tooltip !== "" && !root._panelOpen) {
M.TooltipState.text = root.tooltip;
M.TooltipState.itemX = root.mapToGlobal(root.width / 2, 0).x - (QsWindow.window?.screen?.x ?? 0);
M.TooltipState.screen = QsWindow.window?.screen ?? null;
M.TooltipState.accentColor = root.accentColor;
M.TooltipState.visible = true;
} else if (!hovered && root.tooltip !== "") {
M.TooltipState.visible = false;
}
}
}
TapHandler {
onTapped: {
root._panelOpen = !root._panelOpen;
root.tapped();
}
}
onTooltipChanged: if (_hovered && tooltip !== "" && !_panelOpen)
M.TooltipState.text = tooltip
Behavior on opacity {
NumberAnimation {
duration: 150
}
}
}

View file

@ -1,36 +0,0 @@
import QtQuick
import Quickshell
import "." as M
import "../services" as S
Row {
id: root
property string tooltip: ""
property bool _hovered: false
property color accentColor: parent?.accentColor ?? S.Theme.base05
Behavior on opacity {
NumberAnimation {
duration: 150
}
}
HoverHandler {
cursorShape: Qt.PointingHandCursor
onHoveredChanged: {
root._hovered = hovered;
if (hovered && root.tooltip !== "") {
M.TooltipState.text = root.tooltip;
M.TooltipState.itemX = root.mapToGlobal(root.width / 2, 0).x - (QsWindow.window?.screen?.x ?? 0);
M.TooltipState.screen = QsWindow.window?.screen ?? null;
M.TooltipState.accentColor = root.accentColor;
M.TooltipState.visible = true;
} else if (!hovered && root.tooltip !== "") {
M.TooltipState.visible = false;
}
}
}
onTooltipChanged: if (_hovered && tooltip !== "")
M.TooltipState.text = tooltip
}

View file

@ -4,12 +4,12 @@ import "." as M
import "../services" as S import "../services" as S
import "../applets" as C import "../applets" as C
M.PinnableSection { M.BarModule {
id: root id: root
spacing: S.Theme.moduleSpacing spacing: S.Theme.moduleSpacing
opacity: S.Modules.battery.enable && S.BatteryService.available ? 1 : 0 opacity: S.Modules.battery.enable && S.BatteryService.available ? 1 : 0
visible: opacity > 0 visible: opacity > 0
_panelHovered: hoverPanel.panelHovered tooltip: "Battery: " + Math.round(S.BatteryService.percent) + "%" + (S.BatteryService.charging ? " (charging)" : "")
property real _blinkOpacity: 1 property real _blinkOpacity: 1
@ -18,7 +18,6 @@ M.PinnableSection {
minOpacity: 0.45 minOpacity: 0.45
} }
// Bar widgets
M.BarIcon { M.BarIcon {
icon: { icon: {
if (S.BatteryService.charging) if (S.BatteryService.charging)
@ -30,9 +29,6 @@ M.PinnableSection {
opacity: root._blinkOpacity opacity: root._blinkOpacity
font.pixelSize: S.Theme.fontSize + 2 font.pixelSize: S.Theme.fontSize + 2
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
TapHandler {
onTapped: root._pinned = !root._pinned
}
} }
M.BarLabel { M.BarLabel {
label: Math.round(S.BatteryService.percent) + "%" label: Math.round(S.BatteryService.percent) + "%"
@ -40,12 +36,8 @@ M.PinnableSection {
color: S.BatteryService.stateColor color: S.BatteryService.stateColor
opacity: root._blinkOpacity opacity: root._blinkOpacity
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
TapHandler {
onTapped: root._pinned = !root._pinned
}
} }
// Hover panel
M.HoverPanel { M.HoverPanel {
id: hoverPanel id: hoverPanel
showPanel: root._showPanel showPanel: root._showPanel
@ -55,6 +47,7 @@ M.PinnableSection {
panelNamespace: "nova-battery" panelNamespace: "nova-battery"
panelTitle: "Battery" panelTitle: "Battery"
contentWidth: 240 contentWidth: 240
onDismissed: root.dismissPanel()
C.BatteryApplet { C.BatteryApplet {
width: hoverPanel.contentWidth width: hoverPanel.contentWidth

View file

@ -4,28 +4,28 @@ import "." as M
import "../services" as S import "../services" as S
import "../applets" as C import "../applets" as C
M.PinnableSection { M.BarModule {
id: root id: root
spacing: S.Theme.moduleSpacing spacing: S.Theme.moduleSpacing
opacity: S.Modules.bluetooth.enable && S.BluetoothService.state !== "unavailable" ? 1 : 0 opacity: S.Modules.bluetooth.enable && S.BluetoothService.state !== "unavailable" ? 1 : 0
visible: opacity > 0 visible: opacity > 0
_panelHovered: hoverPanel.panelHovered tooltip: {
if (S.BluetoothService.state === "connected")
return "Bluetooth: " + S.BluetoothService.device;
if (S.BluetoothService.state === "off")
return "Bluetooth: off";
return "Bluetooth: on";
}
M.BarIcon { M.BarIcon {
icon: "\uF294" icon: "\uF294"
color: S.BluetoothService.state === "off" ? S.Theme.base04 : root.accentColor color: S.BluetoothService.state === "off" ? S.Theme.base04 : root.accentColor
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
TapHandler {
onTapped: root._pinned = !root._pinned
}
} }
M.BarLabel { M.BarLabel {
visible: S.BluetoothService.state === "connected" visible: S.BluetoothService.state === "connected"
label: S.BluetoothService.device + (S.BluetoothService.batteryPct >= 0 ? " " + S.BluetoothService.batteryPct + "%" : "") label: S.BluetoothService.device + (S.BluetoothService.batteryPct >= 0 ? " " + S.BluetoothService.batteryPct + "%" : "")
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
TapHandler {
onTapped: root._pinned = !root._pinned
}
} }
Connections { Connections {
@ -44,6 +44,7 @@ M.PinnableSection {
panelNamespace: "nova-bluetooth" panelNamespace: "nova-bluetooth"
panelTitle: "Bluetooth" panelTitle: "Bluetooth"
contentWidth: 250 contentWidth: 250
onDismissed: root.dismissPanel()
titleActionsComponent: Component { titleActionsComponent: Component {
Item { Item {
width: 20 width: 20

View file

@ -4,10 +4,10 @@ import "." as M
import "../services" as S import "../services" as S
import "../applets" as C import "../applets" as C
M.PinnableSection { M.BarModule {
id: root id: root
spacing: S.Theme.moduleSpacing spacing: S.Theme.moduleSpacing
_panelHovered: hoverPanel.panelHovered tooltip: Qt.formatDateTime(clock.date, "dddd, dd. MMMM yyyy")
SystemClock { SystemClock {
id: clock id: clock
@ -19,9 +19,6 @@ M.PinnableSection {
label: Qt.formatDateTime(clock.date, "ddd, dd. MMM HH:mm") label: Qt.formatDateTime(clock.date, "ddd, dd. MMM HH:mm")
minText: "Wed, 00. Sep 00:00" minText: "Wed, 00. Sep 00:00"
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
TapHandler {
onTapped: root._pinned = !root._pinned
}
} }
M.HoverPanel { M.HoverPanel {
@ -33,6 +30,7 @@ M.PinnableSection {
panelNamespace: "nova-clock" panelNamespace: "nova-clock"
panelTitle: Qt.formatTime(clock.date, "HH:mm:ss") panelTitle: Qt.formatTime(clock.date, "HH:mm:ss")
contentWidth: 220 contentWidth: 220
onDismissed: root.dismissPanel()
C.ClockApplet { C.ClockApplet {
width: hoverPanel.contentWidth width: hoverPanel.contentWidth

View file

@ -4,10 +4,10 @@ import "." as M
import "../services" as S import "../services" as S
import "../applets" as C import "../applets" as C
M.PinnableSection { M.BarModule {
id: root id: root
spacing: Math.max(1, S.Theme.moduleSpacing - 2) spacing: Math.max(1, S.Theme.moduleSpacing - 2)
_panelHovered: hoverPanel.panelHovered tooltip: "CPU: " + S.SystemStats.cpuUsage + "% @ " + S.SystemStats.cpuFreqGhz.toFixed(2) + " GHz"
readonly property var _cores: S.SystemStats.cpuCores readonly property var _cores: S.SystemStats.cpuCores
readonly property var _coreMaxFreq: S.SystemStats.cpuCoreMaxFreq readonly property var _coreMaxFreq: S.SystemStats.cpuCoreMaxFreq
@ -34,17 +34,11 @@ M.PinnableSection {
M.BarIcon { M.BarIcon {
icon: "\uF2DB" icon: "\uF2DB"
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
TapHandler {
onTapped: root._pinned = !root._pinned
}
} }
M.BarLabel { M.BarLabel {
label: S.SystemStats.cpuUsage.toString().padStart(2) + "%@" + S.SystemStats.cpuFreqGhz.toFixed(2) label: S.SystemStats.cpuUsage.toString().padStart(2) + "%@" + S.SystemStats.cpuFreqGhz.toFixed(2)
minText: "99%@9.99" minText: "99%@9.99"
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
TapHandler {
onTapped: root._pinned = !root._pinned
}
} }
M.HoverPanel { M.HoverPanel {
@ -56,6 +50,7 @@ M.PinnableSection {
panelNamespace: "nova-cpu" panelNamespace: "nova-cpu"
panelTitle: "CPU" panelTitle: "CPU"
contentWidth: 260 contentWidth: 260
onDismissed: root.dismissPanel()
C.CpuApplet { C.CpuApplet {
width: hoverPanel.contentWidth width: hoverPanel.contentWidth

View file

@ -4,10 +4,10 @@ import "." as M
import "../services" as S import "../services" as S
import "../applets" as C import "../applets" as C
M.PinnableSection { M.BarModule {
id: root id: root
spacing: Math.max(1, S.Theme.moduleSpacing - 2) spacing: Math.max(1, S.Theme.moduleSpacing - 2)
_panelHovered: hoverPanel.panelHovered tooltip: "Disk: " + _rootPct + "% used"
property var _mounts: S.SystemStats.diskMounts property var _mounts: S.SystemStats.diskMounts
property int _rootPct: S.SystemStats.diskRootPct property int _rootPct: S.SystemStats.diskRootPct
@ -23,18 +23,12 @@ M.PinnableSection {
icon: "\uF0C9" icon: "\uF0C9"
color: root._anyWarn ? S.Theme.base09 : root.accentColor color: root._anyWarn ? S.Theme.base09 : root.accentColor
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
TapHandler {
onTapped: root._pinned = !root._pinned
}
} }
M.BarLabel { M.BarLabel {
label: root._rootPct + "%" label: root._rootPct + "%"
minText: "100%" minText: "100%"
color: root._anyWarn ? S.Theme.base09 : root.accentColor color: root._anyWarn ? S.Theme.base09 : root.accentColor
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
TapHandler {
onTapped: root._pinned = !root._pinned
}
} }
M.HoverPanel { M.HoverPanel {
@ -46,6 +40,7 @@ M.PinnableSection {
panelNamespace: "nova-disk" panelNamespace: "nova-disk"
panelTitle: "Disk" panelTitle: "Disk"
contentWidth: 260 contentWidth: 260
onDismissed: root.dismissPanel()
C.DiskApplet { C.DiskApplet {
width: hoverPanel.contentWidth width: hoverPanel.contentWidth

View file

@ -4,26 +4,20 @@ import "." as M
import "../services" as S import "../services" as S
import "../applets" as C import "../applets" as C
M.PinnableSection { M.BarModule {
id: root id: root
spacing: Math.max(1, S.Theme.moduleSpacing - 2) spacing: Math.max(1, S.Theme.moduleSpacing - 2)
visible: S.Modules.gpu.enable && S.SystemStats.gpuAvailable visible: S.Modules.gpu.enable && S.SystemStats.gpuAvailable
_panelHovered: hoverPanel.panelHovered tooltip: "GPU: " + S.SystemStats.gpuUsage + "%"
M.BarIcon { M.BarIcon {
icon: "\uEB4C" icon: "\uEB4C"
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
TapHandler {
onTapped: root._pinned = !root._pinned
}
} }
M.BarLabel { M.BarLabel {
label: S.SystemStats.gpuUsage + "%" label: S.SystemStats.gpuUsage + "%"
minText: "100%" minText: "100%"
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
TapHandler {
onTapped: root._pinned = !root._pinned
}
} }
M.HoverPanel { M.HoverPanel {
@ -35,6 +29,7 @@ M.PinnableSection {
panelNamespace: "nova-gpu" panelNamespace: "nova-gpu"
panelTitle: "GPU" panelTitle: "GPU"
contentWidth: 240 contentWidth: 240
onDismissed: root.dismissPanel()
C.GpuApplet { C.GpuApplet {
width: hoverPanel.contentWidth width: hoverPanel.contentWidth

View file

@ -4,33 +4,19 @@ import Quickshell.Wayland
import "." as M import "." as M
import "../services" as S import "../services" as S
// Unified bar panel — fullscreen transparent window so content can resize // Bar panel - fullscreen transparent window so content can resize freely
// freely without triggering Wayland surface resizes. // without triggering Wayland surface resizes.
// //
// Hover mode (popupMode: false, default): // Parent drives visibility via showPanel. Click-outside or Esc dismisses
// Parent drives visibility via showPanel. Panel auto-closes when showPanel // and emits dismissed(). Pass anchorItem for lazy position computation.
// drops, with a 50ms debounce. Reports panelHovered back to parent.
// Pass anchorItem for lazy position computation on each show.
//
// Popup mode (popupMode: true):
// Shows immediately on creation. Stays open until click-outside or
// dismiss() call. Emits dismissed() when closed — caller's LazyLoader
// sets active: false. Pass anchorX (screen-relative centre x).
PanelWindow { PanelWindow {
id: root id: root
property bool popupMode: false property bool showPanel: false
// Hover mode
property bool showPanel: true
property Item anchorItem: null property Item anchorItem: null
property bool panelHovered: false
// Popup mode
property real anchorX: -1 property real anchorX: -1
signal dismissed signal dismissed
// Shared
required property color accentColor required property color accentColor
property string panelTitle: "" property string panelTitle: ""
property Component titleActionsComponent: null property Component titleActionsComponent: null
@ -43,22 +29,6 @@ PanelWindow {
color: "transparent" color: "transparent"
property bool _winVisible: false property bool _winVisible: false
property bool _pinned: false
property real _dragStartX: 0
property real _dragStartY: 0
property bool _dragging: false
// When pinned: mask = full panel so content is interactive, desktop accessible outside.
// When dragging: mask = null (full screen) so Niri keeps delivering events when cursor
// leaves the panel bounds mid-drag.
mask: (_pinned && !_dragging) ? _pinMask : null
property Region _pinMask: Region {
x: panelContainer.x
y: panelContainer.y
width: panelContainer.width
height: panelContainer.height
}
WlrLayershell.layer: WlrLayer.Overlay WlrLayershell.layer: WlrLayer.Overlay
WlrLayershell.exclusiveZone: 0 WlrLayershell.exclusiveZone: 0
@ -80,38 +50,29 @@ PanelWindow {
if (root.anchorItem) { if (root.anchorItem) {
const pt = root.anchorItem.mapToGlobal(root.anchorItem.width / 2, 0); const pt = root.anchorItem.mapToGlobal(root.anchorItem.width / 2, 0);
cx = pt.x - (scr?.x ?? 0); cx = pt.x - (scr?.x ?? 0);
} else { } else if (root.anchorX >= 0) {
cx = root.anchorX; cx = root.anchorX;
} else {
cx = sw / 2;
} }
panelContainer.x = Math.max(0, Math.min(Math.round(cx - root.contentWidth / 2), sw - root.contentWidth)); panelContainer.x = Math.max(0, Math.min(Math.round(cx - root.contentWidth / 2), sw - root.contentWidth));
} }
// Grace period: after _show(), suppress auto-close briefly so Niri has time // Grace period: after _show(), suppress click-outside dismiss briefly so
// to route wl_pointer.enter to the new overlay surface (cursor may be stationary). // Niri has time to route wl_pointer.enter to the new overlay surface.
// Popup mode gets a longer window (1500ms) so the user can move the cursor to the
// panel content after clicking the bar without accidentally dismissing it.
property bool _grace: false property bool _grace: false
Timer { Timer {
id: _graceTimer id: _graceTimer
interval: root.popupMode ? 1500 : 400 interval: 400
onTriggered: { onTriggered: root._grace = false
root._grace = false;
if (!root.showPanel && !root._pinned)
root.dismiss();
}
} }
// Content-change grace: call keepOpen(ms) when panel content is about to // Content-change grace: call keepOpen(ms) when panel content is about to
// resize/rebuild (session switch, device list change, etc.) to prevent the // resize/rebuild (session switch, device list change, etc.).
// hover-drop-on-resize from closing the panel.
property bool _contentBusy: false property bool _contentBusy: false
Timer { Timer {
id: _contentBusyTimer id: _contentBusyTimer
onTriggered: { onTriggered: root._contentBusy = false
root._contentBusy = false;
if (!root.showPanel && !root._grace && !root._pinned)
_hideTimer.restart();
}
} }
function keepOpen(ms) { function keepOpen(ms) {
_contentBusy = true; _contentBusy = true;
@ -121,13 +82,7 @@ PanelWindow {
function _show() { function _show() {
_updatePosition(); _updatePosition();
// Only snap to closed position when genuinely opening from scratch.
// If we are interrupting a hide animation, animate back from the current
// y/opacity so there's no visible jump — the showAnim NumberAnimations
// always run from the *current* value to their target.
if (!hideAnim.running) { if (!hideAnim.running) {
// Explicitly set y before animating — avoids the y:-height binding (live, depends on
// _panelColumn.height) surviving a 0→0 no-op animation when layout isn't done yet.
panelContainer.y = -(panelContainer.height > 0 ? panelContainer.height : 400); panelContainer.y = -(panelContainer.height > 0 ? panelContainer.height : 400);
panelContainer.opacity = 0; panelContainer.opacity = 0;
} }
@ -144,12 +99,12 @@ PanelWindow {
} }
function dismiss() { function dismiss() {
_pinned = false; if (!_winVisible)
return;
showAnim.stop(); showAnim.stop();
if (S.Theme.reducedMotion) { if (S.Theme.reducedMotion) {
_winVisible = false; _winVisible = false;
if (popupMode) dismissed();
dismissed();
} else { } else {
hideAnim.start(); hideAnim.start();
} }
@ -157,30 +112,12 @@ PanelWindow {
_graceTimer.stop(); _graceTimer.stop();
} }
Component.onCompleted: if (popupMode)
_show()
Timer {
id: _hideTimer
interval: 150
onTriggered: if (!root.showPanel && !root._grace && !root._pinned && !root._contentBusy)
root.dismiss()
}
onShowPanelChanged: { onShowPanelChanged: {
if (root.popupMode)
return;
if (showPanel) { if (showPanel) {
_hideTimer.stop();
// Only replay the open animation if the panel is actually closed or
// currently animating away. If it is already visible, stopping the
// hide timer is sufficient — calling _show() would reset y/opacity to
// 0 and cause a visible flash when the cursor crosses the gap between
// the bar module and the panel.
if (!_winVisible || hideAnim.running) if (!_winVisible || hideAnim.running)
_show(); _show();
} else { } else {
_hideTimer.restart(); dismiss();
} }
} }
@ -220,17 +157,15 @@ PanelWindow {
} }
onFinished: { onFinished: {
root._winVisible = false; root._winVisible = false;
if (root.popupMode) root.dismissed();
root.dismissed();
} }
} }
// Popup mode: click-outside dismiss. // Click-outside dismiss.
// TapHandler fires for all taps; position check skips taps inside panelContainer. // TapHandler fires for all taps; position check skips taps inside panelContainer.
// Gated on !_grace so spurious events during the 400ms opening window don't dismiss. // Gated on !_grace so spurious events during the opening window don't dismiss.
Item { Item {
anchors.fill: parent anchors.fill: parent
visible: root.popupMode
TapHandler { TapHandler {
enabled: !root._grace enabled: !root._grace
@ -243,6 +178,13 @@ PanelWindow {
} }
} }
// Esc dismiss
Shortcut {
sequence: "Escape"
enabled: root._winVisible
onActivated: root.dismiss()
}
M.PopupBackground { M.PopupBackground {
x: panelContainer.x x: panelContainer.x
y: panelContainer.y y: panelContainer.y
@ -260,52 +202,17 @@ PanelWindow {
height: _panelColumn.height height: _panelColumn.height
opacity: 0 opacity: 0
HoverHandler {
enabled: !root.popupMode && !root._pinned
onHoveredChanged: if (!root.popupMode && !root._pinned)
root.panelHovered = hovered
}
Column { Column {
id: _panelColumn id: _panelColumn
width: root.contentWidth width: root.contentWidth
// Header row: title + action buttons + pin — shown in hover mode always, // Header row: title + action buttons
// and in popup mode when a title or actions are provided.
Item { Item {
id: _headerItem id: _headerItem
visible: !root.popupMode || root.panelTitle !== "" || root.titleActionsComponent !== null visible: root.panelTitle !== "" || root.titleActionsComponent !== null
width: parent.width width: parent.width
height: 24 height: 24
// Drag header to freely reposition panel while pinned (hover mode only).
// _dragging clears the input mask so Niri keeps delivering events when the
// cursor leaves the panel bounds during a fast drag.
DragHandler {
enabled: root._pinned && !root.popupMode
onActiveChanged: {
root._dragging = active;
if (active) {
root._dragStartX = panelContainer.x;
root._dragStartY = panelContainer.y;
}
}
onActiveTranslationChanged: {
if (active) {
const sw = root.screen?.width ?? 1920;
const sh = root.screen?.height ?? 1080;
panelContainer.x = Math.max(0, Math.min(root._dragStartX + activeTranslation.x, sw - root.contentWidth));
panelContainer.y = Math.max(0, Math.min(root._dragStartY + activeTranslation.y, sh - panelContainer.height));
}
}
}
// Show move cursor on header when pinned
HoverHandler {
enabled: root._pinned && !root.popupMode
cursorShape: Qt.SizeAllCursor
}
Text { Text {
visible: root.panelTitle !== "" visible: root.panelTitle !== ""
anchors.left: parent.left anchors.left: parent.left
@ -318,52 +225,14 @@ PanelWindow {
font.family: S.Theme.fontFamily font.family: S.Theme.fontFamily
} }
// Action buttons — anchored left of pin button slot
Loader { Loader {
id: _titleActionsLoader id: _titleActionsLoader
anchors.right: _pinBtn.left anchors.right: parent.right
anchors.rightMargin: 4 anchors.rightMargin: 4
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
sourceComponent: root.titleActionsComponent sourceComponent: root.titleActionsComponent
} }
// Pin button — zero-width in popup mode so actions anchor flush to right
Item {
id: _pinBtn
anchors.right: parent.right
anchors.rightMargin: root.popupMode ? 0 : 4
anchors.verticalCenter: parent.verticalCenter
width: root.popupMode ? 0 : 20
height: 20
visible: !root.popupMode
HoverHandler {
cursorShape: Qt.PointingHandCursor
}
TapHandler {
onTapped: {
root._pinned = !root._pinned;
if (!root._pinned && !root.showPanel)
root.dismiss();
}
}
Text {
anchors.centerIn: parent
text: root._pinned ? "\uDB81\uDC03" : "\uDB82\uDD31"
color: root._pinned ? root.accentColor : S.Theme.base04
font.pixelSize: S.Theme.fontSize - 1
font.family: S.Theme.iconFontFamily
Behavior on color {
ColorAnimation {
duration: 100
}
}
}
}
// Divider at bottom of header
Rectangle { Rectangle {
anchors.left: parent.left anchors.left: parent.left
anchors.right: parent.right anchors.right: parent.right
@ -391,7 +260,7 @@ PanelWindow {
} }
} }
// Border overlay — on top of content so full-bleed items don't cover it // Border overlay - on top of content so full-bleed items don't cover it
Rectangle { Rectangle {
x: panelContainer.x x: panelContainer.x
y: panelContainer.y y: panelContainer.y

View file

@ -3,17 +3,15 @@ import Quickshell
import "." as M import "." as M
import "../services" as S import "../services" as S
M.BarIcon { M.BarModule {
id: root id: root
color: S.IdleInhibitService.active ? S.Theme.base09 : root.accentColor
tooltip: { tooltip: {
const parts = ["Idle inhibition: " + (S.IdleInhibitService.active ? "active" : "inactive")]; const parts = ["Idle inhibition: " + (S.IdleInhibitService.active ? "active" : "inactive")];
if (S.IdleInhibitService.inhibitors) if (S.IdleInhibitService.inhibitors)
parts.push(S.IdleInhibitService.inhibitors); parts.push(S.IdleInhibitService.inhibitors);
return parts.join("\n"); return parts.join("\n");
} }
onTapped: S.IdleInhibitService.toggle()
icon: S.IdleInhibitService.active ? "\uF06E" : "\uF070"
Timer { Timer {
interval: 5000 interval: 5000
@ -23,9 +21,9 @@ M.BarIcon {
onTriggered: S.IdleInhibitService.refreshInhibitors() onTriggered: S.IdleInhibitService.refreshInhibitors()
} }
MouseArea { M.BarIcon {
anchors.fill: parent color: S.IdleInhibitService.active ? S.Theme.base09 : root.accentColor
cursorShape: Qt.PointingHandCursor icon: S.IdleInhibitService.active ? "\uF06E" : "\uF070"
onClicked: S.IdleInhibitService.toggle() anchors.verticalCenter: parent.verticalCenter
} }
} }

View file

@ -4,10 +4,10 @@ import "." as M
import "../services" as S import "../services" as S
import "../applets" as C import "../applets" as C
M.PinnableSection { M.BarModule {
id: root id: root
spacing: Math.max(1, S.Theme.moduleSpacing - 2) spacing: Math.max(1, S.Theme.moduleSpacing - 2)
_panelHovered: hoverPanel.panelHovered tooltip: "Memory: " + usedGb.toFixed(1) + " / " + totalGb.toFixed(1) + " GB"
property int percent: S.SystemStats.memPercent property int percent: S.SystemStats.memPercent
property real usedGb: S.SystemStats.memUsedGb property real usedGb: S.SystemStats.memUsedGb
@ -25,17 +25,11 @@ M.PinnableSection {
M.BarIcon { M.BarIcon {
icon: "\uEFC5" icon: "\uEFC5"
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
TapHandler {
onTapped: root._pinned = !root._pinned
}
} }
M.BarLabel { M.BarLabel {
label: root.percent + "%" label: root.percent + "%"
minText: "100%" minText: "100%"
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
TapHandler {
onTapped: root._pinned = !root._pinned
}
} }
M.HoverPanel { M.HoverPanel {
@ -47,6 +41,7 @@ M.PinnableSection {
panelNamespace: "nova-memory" panelNamespace: "nova-memory"
panelTitle: "Memory" panelTitle: "Memory"
contentWidth: 240 contentWidth: 240
onDismissed: root.dismissPanel()
C.MemoryApplet { C.MemoryApplet {
width: hoverPanel.contentWidth width: hoverPanel.contentWidth

View file

@ -6,12 +6,12 @@ import "." as M
import "../services" as S import "../services" as S
import "../applets" as C import "../applets" as C
M.PinnableSection { M.BarModule {
id: root id: root
spacing: S.Theme.moduleSpacing spacing: S.Theme.moduleSpacing
opacity: S.Modules.mpris.enable && player !== null ? 1 : 0 opacity: S.Modules.mpris.enable && player !== null ? 1 : 0
visible: opacity > 0 visible: opacity > 0
_panelHovered: hoverPanel.panelHovered tooltip: player ? (player.trackTitle || player.identity || "Media") + (playing ? " (playing)" : " (paused)") : "Media"
readonly property var _players: S.MprisService.players readonly property var _players: S.MprisService.players
readonly property MprisPlayer player: S.MprisService.player readonly property MprisPlayer player: S.MprisService.player
@ -19,7 +19,6 @@ M.PinnableSection {
property string _cachedArt: "" property string _cachedArt: ""
property string _artTrack: "" property string _artTrack: ""
// Cache art URL at root level so it's captured even when panel is hidden
readonly property string _artUrl: player?.trackArtUrl ?? "" readonly property string _artUrl: player?.trackArtUrl ?? ""
readonly property string _currentTrack: player?.trackTitle ?? "" readonly property string _currentTrack: player?.trackTitle ?? ""
on_ArtUrlChanged: if (_artUrl) on_ArtUrlChanged: if (_artUrl)
@ -74,22 +73,12 @@ M.PinnableSection {
M.BarIcon { M.BarIcon {
icon: root.playing ? "\uF04B" : (root.player?.playbackState === MprisPlaybackState.Paused ? "\uDB80\uDFE4" : "\uDB81\uDCDB") icon: root.playing ? "\uF04B" : (root.player?.playbackState === MprisPlaybackState.Paused ? "\uDB80\uDFE4" : "\uDB81\uDCDB")
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: root._pinned = !root._pinned
}
} }
M.BarLabel { M.BarLabel {
label: root.player?.trackTitle || root.player?.identity || "" label: root.player?.trackTitle || root.player?.identity || ""
elide: Text.ElideRight elide: Text.ElideRight
width: Math.min(implicitWidth, 200) width: Math.min(implicitWidth, 200)
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
MouseArea {
anchors.fill: parent
cursorShape: Qt.PointingHandCursor
onClicked: root._pinned = !root._pinned
}
} }
M.HoverPanel { M.HoverPanel {
@ -101,6 +90,7 @@ M.PinnableSection {
panelNamespace: "nova-mpris" panelNamespace: "nova-mpris"
panelTitle: "Now Playing" panelTitle: "Now Playing"
contentWidth: 280 contentWidth: 280
onDismissed: root.dismissPanel()
C.MprisApplet { C.MprisApplet {
width: hoverPanel.contentWidth width: hoverPanel.contentWidth

View file

@ -4,10 +4,18 @@ import "." as M
import "../services" as S import "../services" as S
import "../applets" as C import "../applets" as C
M.PinnableSection { M.BarModule {
id: root id: root
spacing: S.Theme.moduleSpacing spacing: S.Theme.moduleSpacing
_panelHovered: hoverPanel.panelHovered tooltip: {
if (state === "wifi")
return "Wi-Fi: " + S.NetworkService.essid;
if (state === "eth")
return "Ethernet: connected";
if (state === "linked")
return "Network: linked";
return "Network: disconnected";
}
readonly property string state: S.NetworkService.state readonly property string state: S.NetworkService.state
@ -23,18 +31,12 @@ M.PinnableSection {
} }
color: root.state === "disconnected" ? S.Theme.base08 : root.accentColor color: root.state === "disconnected" ? S.Theme.base08 : root.accentColor
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
TapHandler {
onTapped: root._pinned = !root._pinned
}
} }
M.BarLabel { M.BarLabel {
visible: root.state === "wifi" visible: root.state === "wifi"
label: S.NetworkService.essid label: S.NetworkService.essid
color: root.state === "disconnected" ? S.Theme.base08 : root.accentColor color: root.state === "disconnected" ? S.Theme.base08 : root.accentColor
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
TapHandler {
onTapped: root._pinned = !root._pinned
}
} }
Connections { Connections {
@ -53,6 +55,7 @@ M.PinnableSection {
panelNamespace: "nova-network" panelNamespace: "nova-network"
panelTitle: "Wi-Fi" panelTitle: "Wi-Fi"
contentWidth: 250 contentWidth: 250
onDismissed: root.dismissPanel()
titleActionsComponent: Component { titleActionsComponent: Component {
Item { Item {
width: 20 width: 20

View file

@ -5,10 +5,10 @@ import "." as M
import "../services" as S import "../services" as S
import "../applets" as C import "../applets" as C
M.PinnableSection { M.BarModule {
id: root id: root
spacing: S.Theme.moduleSpacing spacing: S.Theme.moduleSpacing
_panelHovered: hoverPanel.panelHovered tooltip: S.NotifService.count > 0 ? "Notifications: " + S.NotifService.count + (S.NotifService.dnd ? " (DND)" : "") : (S.NotifService.dnd ? "Do not disturb" : "No notifications")
readonly property bool hasUrgent: S.NotifService.list.some(n => n.urgency === NotificationUrgency.Critical && n.state !== "dismissed") readonly property bool hasUrgent: S.NotifService.list.some(n => n.urgency === NotificationUrgency.Critical && n.state !== "dismissed")
@ -20,18 +20,12 @@ M.PinnableSection {
} }
color: S.NotifService.dnd ? S.Theme.base04 : root.accentColor color: S.NotifService.dnd ? S.Theme.base04 : root.accentColor
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
TapHandler {
onTapped: root._pinned = !root._pinned
}
} }
M.BarLabel { M.BarLabel {
id: countLabel id: countLabel
label: S.NotifService.count > 0 ? String(S.NotifService.count) + (root.hasUrgent ? "!" : "") : "" label: S.NotifService.count > 0 ? String(S.NotifService.count) + (root.hasUrgent ? "!" : "") : ""
color: root.hasUrgent ? S.Theme.base08 : root.accentColor color: root.hasUrgent ? S.Theme.base08 : root.accentColor
anchors.verticalCenter: parent.verticalCenter anchors.verticalCenter: parent.verticalCenter
TapHandler {
onTapped: root._pinned = !root._pinned
}
transform: Scale { transform: Scale {
id: countScale id: countScale
@ -83,6 +77,7 @@ M.PinnableSection {
panelNamespace: "nova-notifications" panelNamespace: "nova-notifications"
panelTitle: "Notifications" panelTitle: "Notifications"
contentWidth: 350 contentWidth: 350
onDismissed: root.dismissPanel()
titleActionsComponent: Component { titleActionsComponent: Component {
Row { Row {
spacing: 8 spacing: 8

Some files were not shown because too many files have changed in this diff Show more